Skip to content

Commit

Permalink
docs
Browse files Browse the repository at this point in the history
  • Loading branch information
PietroPasotti committed Apr 5, 2023
1 parent 14f3baf commit cb9f432
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 7 deletions.
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ def _on_event(self, _event):
You can verify that the charm has followed the expected path by checking the **unit status history** like so:

```python
from charm import MyCharm
from ops.model import MaintenanceStatus, ActiveStatus, WaitingStatus, UnknownStatus
from scenario import State

Expand All @@ -148,14 +149,15 @@ def test_statuses():
UnknownStatus(),
MaintenanceStatus('determining who the ruler is...'),
WaitingStatus('checking this is right...'),
ActiveStatus("I am ruled"),
]
```

Note that the current status is not in the **unit status history**.

Also note that, unless you initialize the State with a preexisting status, the first status in the history will always be `unknown`. That is because, so far as scenario is concerned, each event is "the first event this charm has ever seen".

If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' in State.
If you want to simulate a situation in which the charm already has seen some event, and is in a status other than Unknown (the default status every charm is born with), you will have to pass the 'initial status' to State.

```python
from ops.model import ActiveStatus
Expand Down Expand Up @@ -211,13 +213,63 @@ def test_relation_data():

# which is very idiomatic and superbly explicit. Noice.
```
## Relation types

The only mandatory argument to `Relation` (and other relation types, see below) is `endpoint`. The `interface` will be derived from the charm's `metadata.yaml`. When fully defaulted, a relation is 'empty'. There are no remote units, the remote application is called `'remote'` and only has a single unit `remote/0`, and nobody has written any data to the databags yet.

That is typically the state of a relation when the first unit joins it.

When you use `Relation`, you are specifying a regular (conventional) relation. But that is not the only type of relation. There are also
peer relations and subordinate relations. While in the background the data model is the same, the data access rules and the consistency constraints on them are very different. For example, it does not make sense for a peer relation to have a different 'remote app' than its 'local app', because it's the same application.

### PeerRelation
To declare a peer relation, you should use `scenario.state.PeerRelation`.
The core difference with regular relations is that peer relations do not have a "remote app" (it's this app, in fact).
So unlike `Relation`, a `PeerRelation` does not have `remote_app_name` or `remote_app_data` arguments. Also, it talks in terms of `peers`:
- `Relation.remote_unit_ids` maps to `PeerRelation.peers_ids`
- `Relation.remote_units_data` maps to `PeerRelation.peers_data`

```python
from scenario.state import PeerRelation

relation = PeerRelation(
endpoint="peers",
peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
)
```

be mindful when using `PeerRelation` not to include **"this unit"**'s ID in `peers_data` or `peers_ids`, as that would be flagged by the Consistency Checker:
```python
from scenario import State, PeerRelation

State(relations=[
PeerRelation(
endpoint="peers",
peers_data={1: {}, 2: {}, 42: {'foo': 'bar'}},
)]).trigger("start", ..., unit_id=1) # invalid: this unit's id cannot be the ID of a peer.


```

### SubordinateRelation
To declare a subordinate relation, you should use `scenario.state.SubordinateRelation`.
The core difference with regular relations is that subordinate relations always have exactly one remote unit (there is always exactly one primary unit that this unit can see).
So unlike `Relation`, a `SubordinateRelation` does not have a `remote_units_data` argument. Instead, it has a `remote_unit_data` taking a single `Dict[str:str]`, and takes the primary unit ID as a separate argument.
Also, it talks in terms of `primary`:
- `Relation.remote_unit_ids` becomes `SubordinateRelation.primary_id` (a single ID instead of a list of IDs)
- `Relation.remote_units_data` becomes `SubordinateRelation.remote_unit_data` (a single databag instead of a mapping from unit IDs to databags)
- `Relation.remote_app_name` maps to `SubordinateRelation.primary_app_name`

TODO: describe peer/sub API.
```python
from scenario.state import SubordinateRelation

relation = SubordinateRelation(
endpoint="peers",
remote_unit_data={"foo": "bar"},
primary_app_name="zookeeper",
primary_id=42
)
relation.primary_name # "zookeeper/42"
```


## Triggering Relation Events
Expand Down
8 changes: 6 additions & 2 deletions scenario/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def __init__(
charm_spec: "_CharmSpec",
charm_root: Optional["PathLike"] = None,
juju_version: str = "3.0.0",
unit_id: int = 0,
):
self._charm_spec = charm_spec
self._juju_version = juju_version
Expand All @@ -155,8 +156,8 @@ def __init__(
raise ValueError('invalid metadata: mandatory "name" field is missing.')

self._app_name = app_name
# todo: consider parametrizing unit-id? cfr https://github.com/canonical/ops-scenario/issues/11
self._unit_name = f"{app_name}/0"
self._unit_id = unit_id
self._unit_name = f"{app_name}/{unit_id}"

@staticmethod
def _cleanup_env(env):
Expand Down Expand Up @@ -412,6 +413,7 @@ def trigger(
config: Optional[Dict[str, Any]] = None,
charm_root: Optional[Dict["PathLike", "PathLike"]] = None,
juju_version: str = "3.0",
unit_id: int = 0,
) -> "State":
"""Trigger a charm execution with an Event and a State.
Expand All @@ -433,6 +435,7 @@ def trigger(
:arg config: charm config to use. Needs to be a valid config.yaml format (as a python dict).
If none is provided, we will search for a ``config.yaml`` file in the charm root.
:arg juju_version: Juju agent version to simulate.
:arg unit_id: The ID of the Juju unit that is charm execution is running on.
:arg charm_root: virtual charm root the charm will be executed with.
If the charm, say, expects a `./src/foo/bar.yaml` file present relative to the
execution cwd, you need to use this. E.g.:
Expand Down Expand Up @@ -464,6 +467,7 @@ def trigger(
charm_spec=spec,
juju_version=juju_version,
charm_root=charm_root,
unit_id=unit_id,
)

return runtime.exec(
Expand Down
2 changes: 2 additions & 0 deletions scenario/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def check_builtin_sequences(
template_state: State = None,
pre_event: Optional[Callable[["CharmType"], None]] = None,
post_event: Optional[Callable[["CharmType"], None]] = None,
unit_id: int = 0,
):
"""Test that all the builtin startup and teardown events can fire without errors.
Expand Down Expand Up @@ -124,4 +125,5 @@ def check_builtin_sequences(
config=config,
pre_event=pre_event,
post_event=post_event,
unit_id=unit_id,
)
2 changes: 2 additions & 0 deletions scenario/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,7 @@ def trigger(
config: Optional[Dict[str, Any]] = None,
charm_root: Optional["PathLike"] = None,
juju_version: str = "3.0",
unit_id: int = 0,
) -> "State":
"""Fluent API for trigger. See runtime.trigger's docstring."""
from scenario.runtime import trigger as _runtime_trigger
Expand All @@ -861,6 +862,7 @@ def trigger(
config=config,
charm_root=charm_root,
juju_version=juju_version,
unit_id=unit_id,
)


Expand Down
6 changes: 4 additions & 2 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ class MyEvt(EventBase):


@pytest.mark.parametrize("app_name", ("foo", "bar-baz", "QuX2"))
def test_unit_name(app_name):
@pytest.mark.parametrize("unit_id", (1, 2, 42))
def test_unit_name(app_name, unit_id):
meta = {
"name": app_name,
}
Expand All @@ -100,9 +101,10 @@ def test_unit_name(app_name):
my_charm_type,
meta=meta,
),
unit_id=unit_id,
)

def post_event(charm: CharmBase):
assert charm.unit.name == f"{app_name}/0"
assert charm.unit.name == f"{app_name}/{unit_id}"

runtime.exec(state=State(), event=Event("start"), post_event=post_event)

0 comments on commit cb9f432

Please sign in to comment.