Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into version-cli-tool
Browse files Browse the repository at this point in the history
  • Loading branch information
PietroPasotti committed Sep 19, 2023
2 parents 11aff05 + 0cd0c15 commit 39352ac
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 41 deletions.
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ state = State(containers=[

In this case, `self.unit.get_container('foo').can_connect()` would return `True`, while for 'bar' it would give `False`.

### Container filesystem setup
You can configure a container to have some files in it:

```python
Expand Down Expand Up @@ -589,6 +590,45 @@ need to associate the container with the event is that the Framework uses an env
pebble-ready event is about (it does not use the event name). Scenario needs that information, similarly, for injecting
that envvar into the charm's runtime.

### Container filesystem post-mortem
If the charm writes files to a container (to a location you didn't Mount as a temporary folder you have access to), you will be able to inspect them using the `get_filesystem` api.

```python
from ops.charm import CharmBase
from scenario import State, Container, Mount, Context


class MyCharm(CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.foo_pebble_ready, self._on_pebble_ready)

def _on_pebble_ready(self, _):
foo = self.unit.get_container('foo')
foo.push('/local/share/config.yaml', "TEST", make_dirs=True)


def test_pebble_push():
container = Container(name='foo',
can_connect=True)
state_in = State(
containers=[container]
)
Context(
MyCharm,
meta={"name": "foo", "containers": {"foo": {}}}).run(
"start",
state_in,
)

# this is the root of the simulated container filesystem. Any mounts will be symlinks in it.
container_root_fs = container.get_filesystem(ctx)
cfg_file = container_root_fs / 'local' / 'share' / 'config.yaml'
assert cfg_file.read_text() == "TEST"
```

### `Container.exec` mocks

`container.exec` is a tad more complicated, but if you get to this low a level of simulation, you probably will have far
worse issues to deal with. You need to specify, for each possible command the charm might run on the container, what the
result of that would be: its return code, what will be written to stdout/stderr.
Expand Down Expand Up @@ -1032,10 +1072,10 @@ can't emit multiple events in a single charm execution.

# The virtual charm root

Before executing the charm, Scenario writes the metadata, config, and actions `yaml`s to a temporary directory. The
Before executing the charm, Scenario copies the charm's `/src`, any libs, the metadata, config, and actions `yaml`s to a temporary directory. The
charm will see that tempdir as its 'root'. This allows us to keep things simple when dealing with metadata that can be
either inferred from the charm type being passed to `Context` or be passed to it as an argument, thereby overriding
the inferred one. This also allows you to test with charms defined on the fly, as in:
the inferred one. This also allows you to test charms defined on the fly, as in:

```python
from ops.charm import CharmBase
Expand All @@ -1052,7 +1092,7 @@ ctx.run('start', State())
```

A consequence of this fact is that you have no direct control over the tempdir that we are creating to put the metadata
you are passing to trigger (because `ops` expects it to be a file...). That is, unless you pass your own:
you are passing to `.run()` (because `ops` expects it to be a file...). That is, unless you pass your own:

```python
from ops.charm import CharmBase
Expand All @@ -1073,8 +1113,11 @@ state = Context(
```

Do this, and you will be able to set up said directory as you like before the charm is run, as well as verify its
contents after the charm has run. Do keep in mind that the metadata files will be overwritten by Scenario, and therefore
ignored.
contents after the charm has run. Do keep in mind that any metadata files you create in it will be overwritten by Scenario, and therefore
ignored, if you pass any metadata keys to `Context`. Omit `meta` in the call
above, and Scenario will instead attempt to read `metadata.yaml` from the
temporary directory.


# Immutability

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ops-scenario"

version = "5.2"
version = "5.2.1"

authors = [
{ name = "Pietro Pasotti", email = "[email protected]" }
Expand Down
7 changes: 6 additions & 1 deletion scenario/mocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,12 @@ def exec(self, *args, **kwargs): # noqa: U100
cmd = tuple(args[0])
out = self._container.exec_mock.get(cmd)
if not out:
raise RuntimeError(f"mock for cmd {cmd} not found.")
raise RuntimeError(
f"mock for cmd {cmd} not found. Please pass to the Container "
f"{self._container.name} a scenario.ExecOutput mock for the "
f"command your charm is attempting to run, or patch "
f"out whatever leads to the call.",
)

change_id = out._run()
return _MockExecProcess(change_id=change_id, command=cmd, out=out)
Expand Down
45 changes: 25 additions & 20 deletions scenario/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,44 +283,49 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]:
# is what the user passed via the CharmSpec
spec = self._charm_spec

if vroot := self._charm_root:
vroot_is_custom = True
virtual_charm_root = Path(vroot)
if charm_virtual_root := self._charm_root:
charm_virtual_root_is_custom = True
virtual_charm_root = Path(charm_virtual_root)
else:
vroot = tempfile.TemporaryDirectory()
virtual_charm_root = Path(vroot.name)
vroot_is_custom = False
charm_virtual_root = tempfile.TemporaryDirectory()
virtual_charm_root = Path(charm_virtual_root.name)
charm_virtual_root_is_custom = False

metadata_yaml = virtual_charm_root / "metadata.yaml"
config_yaml = virtual_charm_root / "config.yaml"
actions_yaml = virtual_charm_root / "actions.yaml"

metadata_files_present: Dict[Path, Union[str, False]] = {
file: file.read_text() if file.exists() else False
metadata_files_present: Dict[Path, Optional[str]] = {
file: file.read_text() if file.exists() else None
for file in (metadata_yaml, config_yaml, actions_yaml)
}

any_metadata_files_present_in_vroot = any(metadata_files_present.values())
any_metadata_files_present_in_charm_virtual_root = any(
v is not None for v in metadata_files_present.values()
)

if spec.is_autoloaded and vroot_is_custom:
if spec.is_autoloaded and charm_virtual_root_is_custom:
# since the spec is autoloaded, in theory the metadata contents won't differ, so we can
# overwrite away even if the custom vroot is the real charm root (the local repo).
# Still, log it for clarity.
if any_metadata_files_present_in_vroot:
if any_metadata_files_present_in_charm_virtual_root:
logger.debug(
f"metadata files found in custom vroot {vroot}. "
f"metadata files found in custom charm_root {charm_virtual_root}. "
f"The spec was autoloaded so the contents should be identical. "
f"Proceeding...",
)

elif not spec.is_autoloaded and any_metadata_files_present_in_vroot:
elif (
not spec.is_autoloaded and any_metadata_files_present_in_charm_virtual_root
):
logger.warn(
f"Some metadata files found in custom user-provided vroot {vroot} "
"while you have passed meta, config or actions to Context.run(). "
f"Some metadata files found in custom user-provided charm_root "
f"{charm_virtual_root} while you have passed meta, config or actions to "
f"Context.run(). "
"Single source of truth are the arguments passed to Context.run(). "
"Vroot metadata files will be overwritten for the "
"charm_root metadata files will be overwritten for the "
"duration of this test, and restored afterwards. "
"To avoid this, clean any metadata files from the vroot before calling run.",
"To avoid this, clean any metadata files from the charm_root before calling run.",
)

metadata_yaml.write_text(yaml.safe_dump(spec.meta))
Expand All @@ -329,15 +334,15 @@ def _virtual_charm_root(self) -> typing.ContextManager[Path]:

yield virtual_charm_root

if vroot_is_custom:
if charm_virtual_root_is_custom:
for file, previous_content in metadata_files_present.items():
if not previous_content: # False: file did not exist before
if previous_content is None: # None == file did not exist before
file.unlink()
else:
file.write_text(previous_content)

else:
vroot.cleanup()
charm_virtual_root.cleanup()

@staticmethod
def _get_state_db(temporary_charm_root: Path):
Expand Down
26 changes: 23 additions & 3 deletions scenario/scripts/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
from scenario.state import (
Address,
BindAddress,
BindFailedError,
Container,
Event,
Model,
Mount,
Network,
Expand Down Expand Up @@ -98,7 +100,15 @@ def format_test_case(
):
"""Format this State as a pytest test case."""
ct = charm_type_name or "CHARM_TYPE, # TODO: replace with charm type name"
en = event_name or "EVENT_NAME, # TODO: replace with event name"
en = "EVENT_NAME, # TODO: replace with event name"
if event_name:
try:
en = Event(event_name).bind(state)
except BindFailedError:
logger.error(
f"Failed to bind {event_name} to {state}; leaving placeholder instead",
)

jv = juju_version or "3.0, # TODO: check juju version is correct"
state_fmt = repr(state)
return _try_format(
Expand Down Expand Up @@ -723,11 +733,12 @@ def _snapshot(
target: str,
model: Optional[str] = None,
pprint: bool = True,
include: str = None,
include: Optional[str] = None,
include_juju_relation_data=False,
include_dead_relation_networks=False,
format: FormatOption = "state",
fetch_files: Dict[str, List[Path]] = None,
event_name: Optional[str] = None,
fetch_files: Optional[Dict[str, List[Path]]] = None,
temp_dir_base_path: Path = SNAPSHOT_OUTPUT_DIR,
):
"""see snapshot's docstring"""
Expand Down Expand Up @@ -852,6 +863,7 @@ def if_include(key, fn, default):
charm_type_name = try_guess_charm_type_name()
txt = format_test_case(
state,
event_name=event_name,
charm_type_name=charm_type_name,
juju_version=juju_version,
)
Expand Down Expand Up @@ -900,6 +912,13 @@ def snapshot(
"``pytest``: Outputs a full-blown pytest scenario test based on this State. "
"Pipe it to a file and fill in the blanks.",
),
event_name: str = typer.Option(
None,
"--event_name",
"-e",
help="Event to include in the generate test file; only applicable "
"if the output format is 'pytest'.",
),
include: str = typer.Option(
"rckndtp",
"--include",
Expand Down Expand Up @@ -950,6 +969,7 @@ def snapshot(
target=target,
model=model,
format=format,
event_name=event_name,
include=include,
include_juju_relation_data=include_juju_relation_data,
include_dead_relation_networks=include_dead_relation_networks,
Expand Down
50 changes: 50 additions & 0 deletions scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ class MetadataNotFoundError(RuntimeError):
"""Raised when Scenario can't find a metadata.yaml file in the provided charm root."""


class BindFailedError(RuntimeError):
"""Raised when Event.bind fails."""


@dataclasses.dataclass(frozen=True)
class _DCBase:
def replace(self, *args, **kwargs):
Expand Down Expand Up @@ -1092,6 +1096,52 @@ def _is_builtin_event(self, charm_spec: "_CharmSpec"):

return event_name in builtins

def bind(self, state: State):
"""Attach to this event the state component it needs.
For example, a relation event initialized without a Relation instance will search for
a suitable relation in the provided state and return a copy of itself with that
relation attached.
"""
entity_name = self.name.split("_")[0]

if self._is_workload_event and not self.container:
try:
container = state.get_container(entity_name)
except ValueError:
raise BindFailedError(f"no container found with name {entity_name}")
return self.replace(container=container)

if self._is_secret_event and not self.secret:
if len(state.secrets) < 1:
raise BindFailedError(f"no secrets found in state: cannot bind {self}")
if len(state.secrets) > 1:
raise BindFailedError(
f"too many secrets found in state: cannot automatically bind {self}",
)
return self.replace(secret=state.secrets[0])

if self._is_relation_event and not self.relation:
ep_name = entity_name
relations = state.get_relations(ep_name)
if len(relations) < 1:
raise BindFailedError(f"no relations on {ep_name} found in state")
if len(relations) > 1:
logger.warning(f"too many relations on {ep_name}: binding to first one")
return self.replace(relation=relations[0])

if self._is_action_event and not self.action:
raise BindFailedError(
"cannot automatically bind action events: if the action has mandatory parameters "
"this would probably result in horrible, undebuggable failures downstream.",
)

else:
raise BindFailedError(
f"cannot bind {self}: only relation, secret, "
f"or workload events can be bound.",
)

def deferred(self, handler: Callable, event_id: int = 1) -> DeferredEvent:
"""Construct a DeferredEvent from this Event."""
handler_repr = repr(handler)
Expand Down
55 changes: 55 additions & 0 deletions tests/test_e2e/test_event_bind.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import pytest

from scenario import Container, Event, Relation, Secret, State
from scenario.state import BindFailedError


def test_bind_relation():
event = Event("foo-relation-changed")
foo_relation = Relation("foo")
state = State(relations=[foo_relation])
assert event.bind(state).relation is foo_relation


def test_bind_relation_notfound():
event = Event("foo-relation-changed")
state = State(relations=[])
with pytest.raises(BindFailedError):
event.bind(state)


def test_bind_relation_toomany(caplog):
event = Event("foo-relation-changed")
foo_relation = Relation("foo")
foo_relation1 = Relation("foo")
state = State(relations=[foo_relation, foo_relation1])
event.bind(state)
assert "too many relations" in caplog.text


def test_bind_secret():
event = Event("secret-changed")
secret = Secret("foo", {"a": "b"})
state = State(secrets=[secret])
assert event.bind(state).secret is secret


def test_bind_secret_notfound():
event = Event("secret-changed")
state = State(secrets=[])
with pytest.raises(BindFailedError):
event.bind(state)


def test_bind_container():
event = Event("foo-pebble-ready")
container = Container("foo")
state = State(containers=[container])
assert event.bind(state).container is container


def test_bind_container_notfound():
event = Event("foo-pebble-ready")
state = State(containers=[])
with pytest.raises(BindFailedError):
event.bind(state)
Loading

0 comments on commit 39352ac

Please sign in to comment.