From cb9f43217f1aa69a045d6383abe97cd31b49b3b9 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Wed, 5 Apr 2023 11:01:36 +0200 Subject: [PATCH] docs --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++--- scenario/runtime.py | 8 ++++-- scenario/sequences.py | 2 ++ scenario/state.py | 2 ++ tests/test_runtime.py | 6 +++-- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index dbe44e9b..c618c9d6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -148,6 +149,7 @@ def test_statuses(): UnknownStatus(), MaintenanceStatus('determining who the ruler is...'), WaitingStatus('checking this is right...'), + ActiveStatus("I am ruled"), ] ``` @@ -155,7 +157,7 @@ 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 @@ -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 diff --git a/scenario/runtime.py b/scenario/runtime.py index 647035df..680f945d 100644 --- a/scenario/runtime.py +++ b/scenario/runtime.py @@ -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 @@ -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): @@ -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. @@ -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.: @@ -464,6 +467,7 @@ def trigger( charm_spec=spec, juju_version=juju_version, charm_root=charm_root, + unit_id=unit_id, ) return runtime.exec( diff --git a/scenario/sequences.py b/scenario/sequences.py index 04044126..fa30b4dc 100644 --- a/scenario/sequences.py +++ b/scenario/sequences.py @@ -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. @@ -124,4 +125,5 @@ def check_builtin_sequences( config=config, pre_event=pre_event, post_event=post_event, + unit_id=unit_id, ) diff --git a/scenario/state.py b/scenario/state.py index 4ec81857..9b453d8d 100644 --- a/scenario/state.py +++ b/scenario/state.py @@ -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 @@ -861,6 +862,7 @@ def trigger( config=config, charm_root=charm_root, juju_version=juju_version, + unit_id=unit_id, ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d6ef9be1..954ec934 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -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, } @@ -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)