Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor!: rename Container.exec_mocks to Container.execs and extend mocking #124

Merged
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
73eba4f
Rename and extend container execs.
tonyandrewmeyer Apr 18, 2024
63b62c8
wait() doesn't have the stdout/stderr in the exception (it's not avai…
tonyandrewmeyer Apr 22, 2024
830c732
Add basic StoredState consistency checks.
tonyandrewmeyer Apr 1, 2024
e84a1c3
Update scenario/consistency_checker.py
tonyandrewmeyer May 24, 2024
e827b92
Update scenario/mocking.py
tonyandrewmeyer May 28, 2024
01ae7ac
Make container.execs a frozenset.
tonyandrewmeyer May 31, 2024
9077a53
Mention the exec command prefix matching.
tonyandrewmeyer May 31, 2024
d39cfd8
Undo auto-reformat.
tonyandrewmeyer May 31, 2024
2d0734a
Ensure that there aren't two identical command prefixes.
tonyandrewmeyer May 31, 2024
514c37f
Enable coverage report.
tonyandrewmeyer May 31, 2024
dffea4d
Add a test for duplicate exec detection.
tonyandrewmeyer May 31, 2024
53f2296
Add addition tests for container.get_exec.
tonyandrewmeyer Jun 4, 2024
f1fe0d1
Small adjustments per review.
tonyandrewmeyer Jun 5, 2024
e86d703
Small clean-up.
tonyandrewmeyer Jun 6, 2024
5e9ab73
Rename State.service_status to State.service_statuses, since there ar…
tonyandrewmeyer Jun 7, 2024
fb75c86
Fix broken files.
tonyandrewmeyer May 30, 2024
52f517c
Update scenario/mocking.py
tonyandrewmeyer May 30, 2024
36327d6
Update tests and docs to match final (hopefully\!) API decision.
tonyandrewmeyer Jun 4, 2024
d3f9916
Fix tests.
tonyandrewmeyer Jun 4, 2024
38c7a71
Move the checks that were on binding to the consistency checker.
tonyandrewmeyer Jun 5, 2024
dbfbb91
Update tests now that emitting custom events is not possible.
tonyandrewmeyer Jun 5, 2024
3492ab1
Support 'ctx.on.event_name' for specifying events.
tonyandrewmeyer Apr 24, 2024
0cad14c
Update tests and docs to match final (hopefully\!) API decision.
tonyandrewmeyer Jun 4, 2024
3e4c574
Fix tests.
tonyandrewmeyer Jun 4, 2024
03ef892
Style fixes.
tonyandrewmeyer Jun 6, 2024
432f50a
Post-merge fixes.
tonyandrewmeyer Jun 18, 2024
02d59be
Post-merge fixes.
tonyandrewmeyer Aug 7, 2024
4221b02
Simplify code.
tonyandrewmeyer Aug 7, 2024
e3a15bb
Grammar fix.
tonyandrewmeyer Aug 7, 2024
cc8b3ca
Fix docstring example.
tonyandrewmeyer Aug 7, 2024
df5462f
Fix README.
tonyandrewmeyer Aug 7, 2024
7fe631d
Remove duplicated code.
tonyandrewmeyer Aug 7, 2024
ee72367
Remove unintended change.
tonyandrewmeyer Aug 7, 2024
a631927
Use lists for Exec command prefix examples.
tonyandrewmeyer Aug 7, 2024
23241cb
Small tweaks based on review.
tonyandrewmeyer Aug 8, 2024
42f21b4
Fix frozen bypass.
tonyandrewmeyer Aug 8, 2024
15ea896
Fix stdin usage.
tonyandrewmeyer Aug 8, 2024
8928a0f
Move the exec args to a context attribute.
tonyandrewmeyer Aug 12, 2024
acdb14f
Merge branch '7.0' into container-exec-enhancements
tonyandrewmeyer Aug 12, 2024
71525f0
Fix merge.
tonyandrewmeyer Aug 12, 2024
5000836
Minor fixes based on review.
tonyandrewmeyer Aug 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ If you want to, you can override any of these relation or extra-binding associat

```python
state = scenario.State(networks={
scenario.Network("foo", [BindAddress([Address('192.0.2.1')])])
scenario.Network("foo", [scenario.BindAddress([scenario.Address('192.0.2.1')])])
})
```

Expand Down Expand Up @@ -639,17 +639,19 @@ class MyCharm(ops.CharmBase):
def _on_start(self, _):
foo = self.unit.get_container('foo')
proc = foo.exec(['ls', '-ll'])
stdout, _ = proc.wait_output()
stdout, _ = proc.wait_output("...")
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
assert stdout == LS_LL


def test_pebble_exec():
container = scenario.Container(
name='foo',
exec_mock={
('ls', '-ll'): # this is the command we're mocking
scenario.ExecOutput(return_code=0, # this data structure contains all we need to mock the call.
stdout=LS_LL)
execs={
scenario.Exec(
command_prefix=['ls', '-ll'],
return_code=0,
stdout=LS_LL,
),
}
)
state_in = scenario.State(containers={container})
Expand All @@ -661,8 +663,27 @@ def test_pebble_exec():
ctx.on.pebble_ready(container),
state_in,
)
assert state_out.containers["foo"].get_exec(['ls', '-ll']).stdin == "..."
```

Scenario will attempt to find the right `Exec` object by matching the provided
command prefix against the command used in the ops `container.exec()` call. For
example if the command is `['ls', '-ll']` then the searching will be:

1. an `Exec` with exactly the same as command prefix, `('ls', '-ll')`
2. an `Exec` with the command prefix `('ls', )`
3. an `Exec` with the command prefix `()`

If none of these are found Scenario will raise a `RuntimeError`.
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved

Note that the `return_code`, `stdout`, and `stderr` attributes of an `Exec`
object are the *output* that the charm will receive when a matching `exec` call
is made. You'll want to provide these when constructing the input state, and
will rarely want to assert on them in the output state (they cannot change).
The `stdin` attribute is ignored in the input state, and in the output
state will contain any content that the charm wrote to stdin as part of the
`exec` call, so you'll want to assert that it has the appropriate value.

### Pebble Notices

Pebble can generate notices, which Juju will detect, and wake up the charm to
Expand Down Expand Up @@ -705,9 +726,9 @@ check doesn't have to match the event being generated: by the time that Juju
sends a pebble-check-failed event the check might have started passing again.

```python
ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my-container": {}}})
ctx = scenario.Context(MyCharm, meta={"name": "foo", "containers": {"my_container": {}}})
check_info = scenario.CheckInfo("http-check", failures=7, status=ops.pebble.CheckStatus.DOWN)
container = scenario.Container("my-container", check_infos={check_info})
container = scenario.Container("my_container", check_infos={check_info})
state = scenario.State(containers={container})
ctx.run(ctx.on.pebble_check_failed(info=check_info, container=container), state=state)
```
Expand Down
4 changes: 2 additions & 2 deletions scenario/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Container,
DeferredEvent,
ErrorStatus,
ExecOutput,
Exec,
ICMPPort,
MaintenanceStatus,
Model,
Expand Down Expand Up @@ -49,7 +49,7 @@
"SubordinateRelation",
"PeerRelation",
"Model",
"ExecOutput",
"Exec",
"Mount",
"Container",
"Notice",
Expand Down
36 changes: 21 additions & 15 deletions scenario/consistency_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import marshal
import os
import re
from collections import defaultdict
from collections import Counter, defaultdict
from collections.abc import Sequence
from numbers import Number
from typing import TYPE_CHECKING, Iterable, List, NamedTuple, Tuple, Union
Expand Down Expand Up @@ -186,27 +186,33 @@ def _check_workload_event(
event: "_Event",
state: "State",
errors: List[str],
warnings: List[str], # noqa: U100
warnings: List[str],
):
if not event.container:
errors.append(
"cannot construct a workload event without the container instance. "
"Please pass one.",
)
elif not event.name.startswith(normalize_name(event.container.name)):
errors.append(
f"workload event should start with container name. {event.name} does "
f"not start with {event.container.name}.",
)
if event.container not in state.containers:
else:
if not event.name.startswith(normalize_name(event.container.name)):
errors.append(
f"cannot emit {event.name} because container {event.container.name} "
f"is not in the state.",
f"workload event should start with container name. {event.name} does "
f"not start with {event.container.name}.",
)
if not event.container.can_connect:
warnings.append(
"you **can** fire fire pebble-ready while the container cannot connect, "
"but that's most likely not what you want.",
if event.container not in state.containers:
errors.append(
f"cannot emit {event.name} because container {event.container.name} "
f"is not in the state.",
)
if not event.container.can_connect:
warnings.append(
"you **can** fire fire pebble-ready while the container cannot connect, "
"but that's most likely not what you want.",
)
names = Counter(exec.command_prefix for exec in event.container.execs)
if dupes := [n for n in names if names[n] > 1]:
errors.append(
f"container {event.container.name} has duplicate command prefixes: {dupes}",
)


Expand Down Expand Up @@ -593,7 +599,7 @@ def check_containers_consistency(
):
errors.append(
f"the event being processed concerns check {event.check_info.name}, but that "
"check is not the {evt_container_name} container.",
f"check is not the {evt_container_name} container.",
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
)

# - a container in state.containers is not in meta.containers
Expand Down
70 changes: 51 additions & 19 deletions scenario/mocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
from scenario.context import Context
from scenario.state import Container as ContainerSpec
from scenario.state import (
ExecOutput,
Exec,
Relation,
Secret,
State,
Expand All @@ -73,26 +73,40 @@ class ActionMissingFromContextError(Exception):


class _MockExecProcess:
def __init__(self, command: Tuple[str, ...], change_id: int, out: "ExecOutput"):
def __init__(
self,
command: Tuple[str, ...],
change_id: int,
exec: "Exec",
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
):
self._command = command
self._change_id = change_id
self._out = out
self._exec = exec
self._waited = False
self.stdout = StringIO(self._out.stdout)
self.stderr = StringIO(self._out.stderr)
self.stdout = StringIO(self._exec.stdout)
self.stderr = StringIO(self._exec.stderr)
# You can't pass *in* the stdin, the charm is responsible for that.
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
self.stdin = StringIO()

def wait(self):
self._waited = True
exit_code = self._out.return_code
self._exec._update_stdin(self.stdin.getvalue())
exit_code = self._exec.return_code
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
if exit_code != 0:
raise ExecError(list(self._command), exit_code, None, None)

def wait_output(self):
out = self._out
exit_code = out.return_code
exec = self._exec
self._exec._update_stdin(self.stdin.getvalue())
exit_code = exec.return_code
if exit_code != 0:
raise ExecError(list(self._command), exit_code, None, None)
return out.stdout, out.stderr
raise ExecError(
list(self._command),
exit_code,
self.stdout.read(),
self.stderr.read(),
)
return exec.stdout, exec.stderr

def send_signal(self, sig: Union[int, str]): # noqa: U100
raise NotImplementedError()
Expand Down Expand Up @@ -727,21 +741,39 @@ def _layers(self) -> Dict[str, pebble.Layer]:

@property
def _service_status(self) -> Dict[str, pebble.ServiceStatus]:
return self._container.service_status
return self._container.service_statuses

# Based on a method of the same name from ops.testing.
def _find_exec_handler(self, command) -> Optional["Exec"]:
handlers = {exec.command_prefix: exec for exec in self._container.execs}
# Start with the full command and, each loop iteration, drop the last
# element, until it matches one of the command prefixes in the execs.
# This includes matching against the empty list, which will match any
# command, if there is not a more specific match.
for prefix_len in reversed(range(len(command) + 1)):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understand what's going on.
Suppose you have two execs, one for ls -ll and one for ls -la, and the charm does ls --fubar. Which exec mock will this match?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that neither will match.

However, if the real command is ls -la -ht, then ls -la will match.

Did I get that right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Harness behaviour (which I intended to match here, although I think I'm too low on tests) is that the longest match wins, but also that you have to match what's there. So in your example, neither would match.

exec 1 exec 2 command winner
['ls', '-ll'] ['ls', '-la'] ['ls', '--fubar'] none
[] ['ls'] ['ls', '--fubar'] exec 2
[] ['ls', '-ll'] ['ls', '--fubar'] exec 1
['ls', '-ll', 'foo.txt'] ['ls', '-ll'] ['ls', '-ll', 'foo.txt'] exec 1

I'm not sure what Harness does if there are two that have the exact same command pattern, but it seems to me that should be an error (consistency check?).

What I see charms doing (with Harness) is:

  • using [], presumably because they know that only one command will run and they either want to keep the test code clean or can't be bothered copying the exact command over to the test code
  • using [first] when the command is things like [first, arg1, arg2, arg2] - probably partly the same reasoning as [] but maybe also because there are multiple ways that the charm could run it and the result should always be the same (I haven't looked deeply enough to check)
  • using the full command

It does seem like having this is a nice convenience. @benhoyt probably has the full story on how Harness came to have that behaviour (it slightly predates me joining Canonical if I have the timing right).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Talked over this with @benhoyt on a call also, and he agrees that it's worth keeping the prefix matching in. The spec for adding exec testing in Harness has some more of the background/details.

command_prefix = tuple(command[:prefix_len])
if command_prefix in handlers:
return handlers[command_prefix]
# None of the command prefixes in the execs matched the command, no
# matter how much of it was used, so we have failed to find a handler.
return None

def exec(self, *args, **kwargs): # noqa: U100 type: ignore
cmd = tuple(args[0])
out = self._container.exec_mock.get(cmd)
if not out:
def exec(self, command, **kwargs): # noqa: U100 type: ignore
handler = self._find_exec_handler(command)
if not handler:
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"mock for cmd {command} not found. Please pass to the Container "
f"{self._container.name} a scenario.Exec mock for the "
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
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)
change_id = handler._run()
return _MockExecProcess(
change_id=change_id,
command=command,
exec=handler,
)

def _check_connection(self):
if not self._container.can_connect:
Expand Down
49 changes: 38 additions & 11 deletions scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
List,
Literal,
Optional,
Sequence,
Set,
Tuple,
Type,
Expand Down Expand Up @@ -643,24 +644,35 @@ def _generate_new_change_id():


@dataclasses.dataclass(frozen=True)
class ExecOutput(_max_posargs(0)):
class Exec(_max_posargs(1)):
"""Mock data for simulated :meth:`ops.Container.exec` calls."""

command_prefix: Sequence[str]
return_code: int = 0
"""The return code of the process (0 is success)."""
stdout: str = ""
"""Any content written to stdout by the process."""
stderr: str = ""
"""Any content written to stderr by the process."""
stdin: Optional[str] = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in order to avoid confusions, how about we make this private and add a stdin property?

Also, conceptually, I feel that stdin does not quite belong to the State but to Context and is more on the level of juju_log.

Just spitballing here, but how do we feel about:

ctx = Context(MyCharm)
exe = MyExec(['ls', '-ll'])
ctr = Container("grafana", execs=[exe])
ctx.run('update-status', State(containers={ctr}))

# give me the stdin the charm sent to the grafana container while executing this command
stdin: str = ctx.get_stdin(ctr, exe)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in order to avoid confusions, how about we make this private and add a stdin property?

Yes, that's much better, thanks! Done. (Although it may end up being irrelevant anyway!)

Also, conceptually, I feel that stdin does not quite belong to the State but to Context and is more on the level of juju_log.

I feel like it's much tidier to have everything bundled up together in the one mock process object rather than split off the input to it into the context.

In addition, it does seem reasonably likely that we'd want to extend this in the future to handle the other 'input' elements of exec() - to verify that the right envars are being set, or that it's being run in the right working directory, or as the right user, and so on. It would be fairly clean to add those as additional private attributes of Exec and properties to expose them. I don't think we would want all of them as additional context methods, or to have ctx.get_exec_input(container, command) return some new object (essentially ops.testing.ExecArgs) that collects them all.

I also don't love how the Context object is becoming more and more context+side_effects.

I do get that it's not in the output state in the way that changes to secrets or relation data are - once the process has finished, it's gone. It somewhat feels like stdout/stderr/return_code are the same, in that the executable presumably still lives in the container but what happens when you (mock) run it isn't really "state".

I spent a few minutes convincing the rest of charm-tech that it should stay as it is in our daily call today, and then as I've tried to write it out I've found myself disagreeing with my own arguments and trying to come up with alternatives (I will spare you the terrible ones!).

An executable is basically a method that takes some input and returns some output. What if we did something similar to the handle_exec system from Harness instead?

class MyCharm(ops.CharmBase):
    ...
    def _on_update_status(self, event):
        container = self.unit.get_container("foo")
        grep = container.exec(["grep", "scenario"])
        grep.stdin.write("foo\nsome text the charm wrote in the scenario test\nfoo")
        out, err = grep.wait_output()
        # do something with out, err

def mock_grep(args: ops.testing.ExecArgs, ) -> ops.testing.ExecResult:
    assert args.command == ["grep", "scenario"]
    assert args.stdin == "foo\nsome text the charm wrote in the scenario test\nfoo"
    return ops.testing.ExecResult(stdout="some text the charm wrote in the scenario test")

ctx = Context(MyCharm)
grep = Exec(["grep"], mock_grep)
container = Container("foo", execs={grep})
state_in = State(containers={container})

state_out = ctx.run(ctx.on.update_status(), state_in)

assert ...

So the mock executable lives on in the state, but the input and output is something that's is re-run each time. The input, output, and exit code all live together in one (temporary) place.

I'm not a huge fan of having some of the asserts end up in the mock function, breaking the very clean typical pattern of arrange/act/assert. However, you could still do that with nonlocal or like:

command = []
input = []

def mock_grep(args: ops.testing.ExecArgs) -> ops.testing.ExecResult:
    command.append(args.command)
    input.append(args.stdin)
    return ops.testing.ExecResult(stdout="some text the charm wrote in the scenario test")

ctx = Context(MyCharm)
grep = Exec(["grep"], mock_grep)
container = Container("foo", execs={grep})
state_in = State(containers={container})

state_out = ctx.run(ctx.on.update_status(), state_in)

assert command == ["grep", "scenario"]
assert input == ["..."]

I would personally leave out the "results" shortcut that Harness has. It could always be added later, and it's simple to do - result=127 is just lambda _: ExecResult(exit_code=127) and result="foo" is just lambda _: ExecResult(stdout="foo").

WDYT? Also @benhoyt since we were in agreement on a different result earlier today, sorry! I probably should have written up my draft response before talking to the team and then would have come to a different conclusion earlier.

Copy link
Collaborator

@PietroPasotti PietroPasotti Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm I don't really like the proposal of doing the asserts in a temporary spot. It's very un-scenario-y.

perhaps:

with container.mock('ps', '-aux') as exec:
    ctx.run(event, state)
    assert exec.input == foo
    assert exec.output == foo

with the risk that if you need to mock lots of commands you end up with a confusing bunch of individual contexts?

Copy link
Collaborator Author

@tonyandrewmeyer tonyandrewmeyer Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do also like the flexibility that a method has, for things like returning different output depending on the arguments or the stdin. On the other hand, it can't be included in a JSON dump like the rest of the State can, which is not great - although arguably it's not that different from Container.mounts serialising to a map of locations of content, not the content itself.

I played around with contexts as well - they do seem to be a reasonably natural fit in terms of having something that only exists within a specific scope. It can indeed end up with a lot, e.g.:

with (
    container.mock(['ps', 'aux'], stdout=PS),
    container.mock(['sleep', '10']),
    container.mock(['mysql']) as mysql_exec,
    container.mock(['/bin/false'], exit_code=1),
    container.mock(['grep', 'scenario']) as grep_exec,
):
    ctx.run(event, state)
    assert grep_exec.stdin == bar
    assert mysql_exec.stdin == baz

It's worse in Python <3.10 where you don't get to wrap the contexts in ().

What about bundling it into the existing context manager?

exec = Exec(["ls", "-lh"], stdout=LS_LH)
container = Container(execs={exec})
state = State(containers={container})
ctx = Context(MyCharm)
with ctx(event, state) as mgr:
    state_out = mgr.run()
    assert mgr.exec_history == [ExecArgs(...), ExecArgs(...)]

If you don't care about how the process is executed and just need to mock out the output/return code then you don't need to use the context manager. I do like it more when all the pieces of the process are together, but I also get that it's potentially confusing with some being input and some output (and it's kinda opposite to what you expect, since the input the charm writes is the output of the test, etc).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm, quite like the idea of enclosing it in the Manager object, but I don't think that's going to be as easy to remember as having it directly on the exec.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in terms of API discoverability that's a net loss I'm afraid

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so I've taken this back to something much closer to @PietroPasotti's earlier suggestion, as also discussed with the Charm-Tech team last Friday.

The Exec object only has the results of the process (exit code, stdout, stderr), just like in Scenario 6, and the input to the process (stdin, user, group, environment, etc) are available from the Context in a new exec_history attribute.

I feel like we're doing a great job of keeping the State only state but not a great job at keeping Context only context - it's more and more "context plus side effects". But this way of handling exec is consistent at the moment, so let's go with this way, and I'll open a different ticket for tidying up Context.

"""Any content written to stdin by the charm."""

# change ID: used internally to keep track of mocked processes
_change_id: int = dataclasses.field(default_factory=_generate_new_change_id)

def __post_init__(self):
# The command prefix can be any sequence type, and a list is tidier to
PietroPasotti marked this conversation as resolved.
Show resolved Hide resolved
# write when there's only one string. However, this object needs to be
# hashable, so can't contain a list. We 'freeze' the sequence to a tuple
# to support that.
object.__setattr__(self, "command_prefix", tuple(self.command_prefix))

def _run(self) -> int:
return self._change_id


_ExecMock = Dict[Tuple[str, ...], ExecOutput]
def _update_stdin(self, stdin: str):
# bypass frozen dataclass
object.__setattr__(self, "stdin", stdin)


@dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -803,7 +815,7 @@ class Container(_max_posargs(1)):
layers: Dict[str, pebble.Layer] = dataclasses.field(default_factory=dict)
"""All :class:`ops.pebble.Layer` definitions that have already been added to the container."""

service_status: Dict[str, pebble.ServiceStatus] = dataclasses.field(
service_statuses: Dict[str, pebble.ServiceStatus] = dataclasses.field(
default_factory=dict,
)
"""The current status of each Pebble service running in the container."""
Expand All @@ -828,20 +840,23 @@ class Container(_max_posargs(1)):
}
"""

exec_mock: _ExecMock = dataclasses.field(default_factory=dict)
execs: Iterable[Exec] = frozenset()
"""Simulate executing commands in the container.

Specify each command the charm might run in the container and a :class:`ExecOutput`
Specify each command the charm might run in the container and an :class:`Exec`
containing its return code and any stdout/stderr.

For example::

container = scenario.Container(
name='foo',
exec_mock={
('whoami', ): scenario.ExecOutput(return_code=0, stdout='ubuntu')
('dig', '+short', 'canonical.com'):
scenario.ExecOutput(return_code=0, stdout='185.125.190.20\\n185.125.190.21')
execs={
scenario.Exec(['whoami'], return_code=0, stdout='ubuntu'),
scenario.Exec(
['dig', '+short', 'canonical.com'],
return_code=0,
stdout='185.125.190.20\\n185.125.190.21',
),
}
)
"""
Expand All @@ -853,6 +868,11 @@ class Container(_max_posargs(1)):
def __hash__(self) -> int:
return hash(self.name)

def __post_init__(self):
if not isinstance(self.execs, frozenset):
# Allow passing a regular set (or other iterable) of Execs.
object.__setattr__(self, "execs", frozenset(self.execs))

def _render_services(self):
# copied over from ops.testing._TestingPebbleClient._render_services()
services = {} # type: Dict[str, pebble.Service]
Expand Down Expand Up @@ -893,7 +913,7 @@ def services(self) -> Dict[str, pebble.ServiceInfo]:
# in pebble, it just returns "nothing matched" if there are 0 matches,
# but it ignores services it doesn't recognize
continue
status = self.service_status.get(name, pebble.ServiceStatus.INACTIVE)
status = self.service_statuses.get(name, pebble.ServiceStatus.INACTIVE)
if service.startup == "":
startup = pebble.ServiceStartup.DISABLED
else:
Expand All @@ -915,6 +935,13 @@ def get_filesystem(self, ctx: "Context") -> Path:
"""
return ctx._get_container_root(self.name)

def get_exec(self, command_prefix: Sequence[str]):
"""Get the Exec object from the container with the given command prefix."""
for exec in self.execs:
if exec.command_prefix == command_prefix:
return exec
raise KeyError(f"no exec found with command prefix {command_prefix}")


_RawStatusLiteral = Literal[
"waiting",
Expand Down
Loading
Loading