From 1050a51afc13e06244840b83c6a62e11d5588ef8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 10 Jul 2026 01:27:35 +1200 Subject: [PATCH 1/2] feat!: inject Juju default databag keys at exec time Relation databags (`local_unit_data`, `remote_units_data`, `remote_unit_data`) now default to empty dicts. Just before the charm runs, Scenario injects the keys Juju itself auto-populates (`egress-subnets`, `ingress-address`, and on Juju 3 `private-address`) into every unit databag, and they flow through to the output state. This is the backwards-incompatible alternative to #2618: instead of stripping `private-address` from the construction default when the mocked Juju version is 4+, Scenario now matches Juju's own behaviour and only inserts the keys the real Juju would insert. Tests that read a `Relation`'s databag before running the charm will see empty dicts. Fixes #2185. Co-Authored-By: Claude Opus 4.7 --- testing/src/scenario/_runtime.py | 4 + testing/src/scenario/state.py | 83 ++++++++++-- .../tests/test_e2e/test_play_assertions.py | 6 +- testing/tests/test_e2e/test_relations.py | 120 ++++++++++++++++-- testing/tests/test_e2e/test_state.py | 3 +- 5 files changed, 192 insertions(+), 24 deletions(-) diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index 410e6dd57..25b1ca29a 100644 --- a/testing/src/scenario/_runtime.py +++ b/testing/src/scenario/_runtime.py @@ -28,6 +28,7 @@ PeerRelation, Relation, SubordinateRelation, + _inject_juju_default_databag_keys, ) if TYPE_CHECKING: # pragma: no cover @@ -313,6 +314,9 @@ def exec( # we make a copy to avoid mutating the input state output_state = copy.deepcopy(state) + # Mirror Juju: populate the auto-managed default keys in every + # relation unit databag before the charm observes them. + _inject_juju_default_databag_keys(output_state, self._juju_version) logger.info(' - generating virtual charm root') with self._virtual_charm_root() as temporary_charm_root: diff --git a/testing/src/scenario/state.py b/testing/src/scenario/state.py index 4b0c8c2a3..9334e9b9b 100644 --- a/testing/src/scenario/state.py +++ b/testing/src/scenario/state.py @@ -655,9 +655,15 @@ class RelationBase: """This application's databag for this relation.""" local_unit_data: RawDataBagContents = dataclasses.field( - default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), + default_factory=dict[str, str], ) - """This unit's databag for this relation.""" + """This unit's databag for this relation. + + Defaults to an empty dict. The Juju-managed keys (``egress-subnets``, + ``ingress-address``, and on Juju 3 also ``private-address``) are injected + at event exec time and appear in the output state, mirroring how Juju + populates the databag before the charm runs. + """ @property def relation_id(self) -> NoReturn: @@ -673,6 +679,11 @@ def _databags(self): yield self.local_app_data yield self.local_unit_data + @property + def _unit_databags(self): + """All unit-scope databags in this relation (excludes app databags).""" + yield self.local_unit_data + @property def _remote_unit_ids(self) -> tuple[UnitID, ...]: """Ids of the units on the other end of this relation.""" @@ -718,13 +729,42 @@ def _validate_databag(self, databag: Mapping[str, str]): _DEFAULT_IP = '192.0.2.0' +# Keys Juju auto-populates in every relation unit databag before the charm +# runs. `private-address` was dropped in Juju 4.0. +_JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU3 = ('egress-subnets', 'ingress-address', 'private-address') +_JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU4 = ('egress-subnets', 'ingress-address') + +# The Juju-3 default unit databag as it appears in the *output* state after +# Scenario has injected the auto-managed keys. Kept as a convenience for test +# assertions; construction defaults are now empty dicts. _DEFAULT_JUJU_DATABAG: dict[str, str] = { - 'egress-subnets': _DEFAULT_IP, - 'ingress-address': _DEFAULT_IP, - 'private-address': _DEFAULT_IP, + key: _DEFAULT_IP for key in _JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU3 } +def _juju_default_unit_databag_keys(juju_version: str) -> tuple[str, ...]: + """Return the keys Juju auto-populates in unit databags for this version.""" + if ops.JujuVersion(juju_version).major < 4: + return _JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU3 + return _JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU4 + + +def _inject_juju_default_databag_keys(state: State, juju_version: str) -> None: # pyright: ignore[reportUnusedFunction] + """Populate Juju-managed default keys in every relation unit databag. + + Mirrors Juju's own behaviour: before the charm runs, Juju sets + ``egress-subnets``, ``ingress-address``, and (on Juju 3) ``private-address`` + in each unit's databag if the key is not already present. Existing values + set by the test author are left untouched. App databags are not affected. + """ + keys = _juju_default_unit_databag_keys(juju_version) + for relation in state.relations: + for databag in relation._unit_databags: + for key in keys: + if key not in databag: + cast('dict[str, str]', databag)[key] = _DEFAULT_IP + + @dataclasses.dataclass(frozen=True, kw_only=True) class Relation(RelationBase): """A relation between the charm and another application.""" @@ -739,9 +779,13 @@ class Relation(RelationBase): remote_app_data: RawDataBagContents = dataclasses.field(default_factory=dict[str, str]) """The current content of the application databag.""" remote_units_data: Mapping[UnitID, RawDataBagContents] = dataclasses.field( - default_factory=lambda: {0: _DEFAULT_JUJU_DATABAG.copy()}, # dedup + default_factory=lambda: {0: {}}, ) - """The current content of the databag for each unit in the relation.""" + """The current content of the databag for each unit in the relation. + + Each unit's databag defaults to an empty dict; Juju-managed keys are + injected at event exec time. + """ remote_model_uuid: str | None = None """The remote model's UUID; uses the main model's UUID if not specified.""" @@ -771,6 +815,12 @@ def _databags(self): # type: ignore yield self.remote_app_data yield from self.remote_units_data.values() + @property + def _unit_databags(self): # type: ignore + """All unit-scope databags in this relation (excludes app databags).""" + yield self.local_unit_data + yield from self.remote_units_data.values() + @dataclasses.dataclass(frozen=True, kw_only=True) class SubordinateRelation(RelationBase): @@ -779,9 +829,12 @@ class SubordinateRelation(RelationBase): remote_app_data: RawDataBagContents = dataclasses.field(default_factory=dict[str, str]) """The current content of the remote application databag.""" remote_unit_data: RawDataBagContents = dataclasses.field( - default_factory=lambda: _DEFAULT_JUJU_DATABAG.copy(), + default_factory=dict[str, str], ) - """The current content of the remote unit databag.""" + """The current content of the remote unit databag. + + Defaults to an empty dict; Juju-managed keys are injected at event exec time. + """ remote_app_name: str = 'remote' """The name of the remote application that *this unit* is attached to.""" @@ -813,6 +866,12 @@ def _databags(self): yield self.remote_app_data yield self.remote_unit_data + @property + def _unit_databags(self): + """All unit-scope databags in this relation (excludes app databags).""" + yield self.local_unit_data + yield self.remote_unit_data + @property def remote_unit_name(self) -> str: """The full name of the remote unit, in the form ``remote/0``.""" @@ -844,6 +903,12 @@ def _databags(self): # type: ignore yield self.local_unit_data yield from self.peers_data.values() + @property + def _unit_databags(self): # type: ignore + """All unit-scope databags in this relation (excludes app databags).""" + yield self.local_unit_data + yield from self.peers_data.values() + @property def _remote_unit_ids(self) -> tuple[UnitID, ...]: """Ids of the units on the other end of this relation.""" diff --git a/testing/tests/test_e2e/test_play_assertions.py b/testing/tests/test_e2e/test_play_assertions.py index 9d4463533..be1ecbc4c 100644 --- a/testing/tests/test_e2e/test_play_assertions.py +++ b/testing/tests/test_e2e/test_play_assertions.py @@ -6,7 +6,7 @@ import dataclasses import pytest -from scenario.state import BlockedStatus, Relation, State +from scenario.state import _DEFAULT_JUJU_DATABAG, BlockedStatus, Relation, State import ops @@ -93,8 +93,8 @@ def check_relation_data(charm: ops.CharmBase): remote_app_data = foo_rel.data[foo_rel.app] assert remote_units_data == { - 'karlos/0': {'foo': 'bar'}, - 'karlos/1': {'baz': 'qux'}, + 'karlos/0': {**_DEFAULT_JUJU_DATABAG, 'foo': 'bar'}, + 'karlos/1': {**_DEFAULT_JUJU_DATABAG, 'baz': 'qux'}, } assert remote_app_data == {'yaba': 'doodle'} diff --git a/testing/tests/test_e2e/test_relations.py b/testing/tests/test_e2e/test_relations.py index a50ed31a4..e70670acf 100644 --- a/testing/tests/test_e2e/test_relations.py +++ b/testing/tests/test_e2e/test_relations.py @@ -186,6 +186,7 @@ def _update_status(self, event: ops.EventBase): state = ctx.run(ctx.on.update_status(), State(relations={rel_in})) rel_out = state.get_relation(rel_in.id) assert rel_out.local_unit_data == { + **_DEFAULT_JUJU_DATABAG, 'to-ignore-key': 'to-ignore-val', 'to-change-key': 'to-change-val-new', 'new-key': 'new-val', @@ -319,7 +320,7 @@ def _update_status(self, event: ops.EventBase): rel_in = PeerRelation(endpoint=relation_name, local_unit_data=original_data) state = ctx.run(ctx.on.update_status(), State(relations={rel_in})) rel_out = state.get_relation(rel_in.id) - assert rel_out.local_unit_data == result_data + assert rel_out.local_unit_data == {**_DEFAULT_JUJU_DATABAG, **result_data} @pytest.mark.parametrize( @@ -472,19 +473,116 @@ def callback(event: ops.EventBase): def test_relation_default_unit_data_regular(): relation = Relation('baz') - assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG - assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG} + assert relation.local_unit_data == {} + assert relation.remote_units_data == {0: {}} def test_relation_default_unit_data_sub(): relation = SubordinateRelation('baz') - assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG - assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == {} + assert relation.remote_unit_data == {} def test_relation_default_unit_data_peer(): relation = PeerRelation('baz') - assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == {} + + +@pytest.mark.parametrize( + 'juju_version,expect_private_address', + [('3.6.14', True), ('4.0.0', False), ('4.1.0', False)], +) +def test_juju_default_databag_injected_at_exec(juju_version: str, expect_private_address: bool): + """Scenario mirrors Juju: default databag keys are injected at exec time. + + On Juju 3, `private-address`, `egress-subnets`, and `ingress-address` are + all populated; on Juju 4, `private-address` is not. + """ + ctx = Context( + Charm, + meta={ + 'name': 'foo', + 'requires': {'foo': {'interface': 'foo'}}, + 'peers': {'p': {'interface': 'p'}}, + 'provides': {'sub': {'interface': 'sub', 'scope': 'container'}}, + }, + juju_version=juju_version, + ) + relation = Relation('foo') + peer = PeerRelation('p') + sub = SubordinateRelation('sub') + state_in = State(leader=True, relations={relation, peer, sub}) + + state_out = ctx.run(ctx.on.start(), state_in) + + rel = state_out.get_relation(relation.id) + peer_rel = state_out.get_relation(peer.id) + sub_rel = state_out.get_relation(sub.id) + assert isinstance(rel, Relation) + assert isinstance(peer_rel, PeerRelation) + assert isinstance(sub_rel, SubordinateRelation) + for databag in ( + rel.local_unit_data, + rel.remote_units_data[0], + peer_rel.local_unit_data, + sub_rel.local_unit_data, + sub_rel.remote_unit_data, + ): + assert databag['egress-subnets'] == '192.0.2.0' + assert databag['ingress-address'] == '192.0.2.0' + assert ('private-address' in databag) is expect_private_address + + +def test_juju_default_databag_preserves_explicit_values(): + """Explicit user-set values must not be overwritten by injection.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + juju_version='3.6.14', + ) + relation = Relation( + 'foo', + local_unit_data={'private-address': '10.0.0.5', 'extra': 'kept'}, + ) + state_out = ctx.run(ctx.on.start(), State(leader=True, relations={relation})) + + rel = state_out.get_relation(relation.id) + assert isinstance(rel, Relation) + assert rel.local_unit_data['private-address'] == '10.0.0.5' + assert rel.local_unit_data['extra'] == 'kept' + # Missing defaults are still filled in. + assert rel.local_unit_data['egress-subnets'] == '192.0.2.0' + assert rel.local_unit_data['ingress-address'] == '192.0.2.0' + + +def test_juju_default_databag_does_not_touch_input_state(): + """Injection happens on the output state; the input State stays untouched.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + ) + relation = Relation('foo') + state_in = State(leader=True, relations={relation}) + + ctx.run(ctx.on.start(), state_in) + + # The Relation the caller still holds is unchanged. + assert relation.local_unit_data == {} + assert relation.remote_units_data == {0: {}} + + +def test_juju_default_databag_not_added_to_app_databags(): + """App databags are not touched — Juju only sets these on unit databags.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + ) + relation = Relation('foo') + state_out = ctx.run(ctx.on.start(), State(leader=True, relations={relation})) + rel = state_out.get_relation(relation.id) + assert isinstance(rel, Relation) + assert rel.local_app_data == {} + assert rel.remote_app_data == {} @pytest.mark.parametrize('evt_name', ('broken', 'created')) @@ -692,11 +790,11 @@ def test_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == {} assert relation.remote_app_name == 'remote' assert relation.limit == 1 assert relation.remote_app_data == {} - assert relation.remote_units_data == {0: _DEFAULT_JUJU_DATABAG} + assert relation.remote_units_data == {0: {}} def test_subordinate_relation_default_values(): @@ -708,11 +806,11 @@ def test_subordinate_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == {} assert relation.remote_app_name == 'remote' assert relation.remote_unit_id == 0 assert relation.remote_app_data == {} - assert relation.remote_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.remote_unit_data == {} def test_peer_relation_default_values(): @@ -724,7 +822,7 @@ def test_peer_relation_default_values(): assert relation.endpoint == endpoint assert relation.interface == interface assert relation.local_app_data == {} - assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG + assert relation.local_unit_data == {} assert relation.peers_data == {} diff --git a/testing/tests/test_e2e/test_state.py b/testing/tests/test_e2e/test_state.py index ce47a1dfb..553081c3d 100644 --- a/testing/tests/test_e2e/test_state.py +++ b/testing/tests/test_e2e/test_state.py @@ -184,7 +184,7 @@ def pre_event(charm: CharmBase): if unit.name == 'remote/1': assert rel.data[unit]['e'] == 'f' else: - assert not rel.data[unit] + assert dict(rel.data[unit]) == _DEFAULT_JUJU_DATABAG state = State( relations={ @@ -269,6 +269,7 @@ def pre_event(charm: CharmBase): relation, local_app_data={'a': 'b'}, local_unit_data={'c': 'd', **_DEFAULT_JUJU_DATABAG}, + remote_units_data={1: _DEFAULT_JUJU_DATABAG, 4: _DEFAULT_JUJU_DATABAG}, ) ) assert out.get_relation(relation.id).local_app_data == {'a': 'b'} From 0a26b86d2fa345d6875ea84157ba4a0ecf2e270d Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 3 Sep 2026 17:56:37 +1200 Subject: [PATCH 2/2] feat!: only provide the Juju default databag keys while the charm runs The keys that Juju manages itself (`egress-subnets`, `ingress-address`, and on Juju 3 `private-address`) are injected into every relation unit databag just before the charm runs, and removed again before the output state is returned, so the output state has the same shape as the input one. A key that the charm wrote to while it was running is left in place. The values now come from the `Network` for the relation's endpoint, as they do in Juju: `ingress-address` (and `private-address`) is the first ingress address, and `egress-subnets` is the comma-separated list of egress subnets. With the default network that means `egress-subnets` is now `192.0.2.0/24` rather than `192.0.2.0`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DdyoibByxhQVcn65aavYfs --- testing/src/scenario/_runtime.py | 9 +- testing/src/scenario/state.py | 104 +++++++--- .../tests/test_e2e/test_play_assertions.py | 6 +- testing/tests/test_e2e/test_relations.py | 196 ++++++++++++++---- testing/tests/test_e2e/test_state.py | 10 +- 5 files changed, 248 insertions(+), 77 deletions(-) diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index 673c1c2f0..e3696c5c8 100644 --- a/testing/src/scenario/_runtime.py +++ b/testing/src/scenario/_runtime.py @@ -29,6 +29,7 @@ Relation, SubordinateRelation, _inject_juju_default_databag_keys, + _remove_juju_default_databag_keys, ) if TYPE_CHECKING: # pragma: no cover @@ -314,9 +315,10 @@ def exec( # we make a copy to avoid mutating the input state output_state = copy.deepcopy(state) - # Mirror Juju: populate the auto-managed default keys in every - # relation unit databag before the charm observes them. - _inject_juju_default_databag_keys(output_state, self._juju_version) + # Mirror Juju: populate the keys that Juju manages itself in every + # relation unit databag before the charm observes them. They're removed + # again below, so that they only exist while the charm is running. + injected_databag_keys = _inject_juju_default_databag_keys(output_state, self._juju_version) logger.info(' - generating virtual charm root') with self._virtual_charm_root() as temporary_charm_root: @@ -381,4 +383,5 @@ def exec( logger.info('event dispatched. done.') assert ops is not None + _remove_juju_default_databag_keys(injected_databag_keys) context._set_output_state(ops.state) diff --git a/testing/src/scenario/state.py b/testing/src/scenario/state.py index a9001efed..ee99f7547 100644 --- a/testing/src/scenario/state.py +++ b/testing/src/scenario/state.py @@ -663,10 +663,11 @@ class RelationBase: ) """This unit's databag for this relation. - Defaults to an empty dict. The Juju-managed keys (``egress-subnets``, - ``ingress-address``, and on Juju 3 also ``private-address``) are injected - at event exec time and appear in the output state, mirroring how Juju - populates the databag before the charm runs. + Defaults to an empty dict. While the charm is running, the keys that Juju + manages itself (``egress-subnets``, ``ingress-address``, and on Juju 3 also + ``private-address``) are present, filled in from the :class:`Network` for + this endpoint. They are removed again before the output state is returned, + so that the output state has the same shape as the input one. """ @property @@ -733,40 +734,82 @@ def _validate_databag(self, databag: Mapping[str, str]): _DEFAULT_IP = '192.0.2.0' -# Keys Juju auto-populates in every relation unit databag before the charm -# runs. `private-address` was dropped in Juju 4.0. -_JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU3 = ('egress-subnets', 'ingress-address', 'private-address') -_JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU4 = ('egress-subnets', 'ingress-address') - -# The Juju-3 default unit databag as it appears in the *output* state after -# Scenario has injected the auto-managed keys. Kept as a convenience for test -# assertions; construction defaults are now empty dicts. +_DEFAULT_EGRESS_SUBNET = '192.0.2.0/24' + +# The unit databag contents that Juju 3 provides for a relation that uses the +# default network. Provided as a convenience for tests that assert on the +# databag contents while the charm is running. _DEFAULT_JUJU_DATABAG: dict[str, str] = { - key: _DEFAULT_IP for key in _JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU3 + 'egress-subnets': _DEFAULT_EGRESS_SUBNET, + 'ingress-address': _DEFAULT_IP, + 'private-address': _DEFAULT_IP, } -def _juju_default_unit_databag_keys(juju_version: str) -> tuple[str, ...]: - """Return the keys Juju auto-populates in unit databags for this version.""" - if ops.JujuVersion(juju_version).major < 4: - return _JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU3 - return _JUJU_DEFAULT_UNIT_DATABAG_KEYS_JUJU4 +def _juju_default_databag(state: State, endpoint: str, juju_version: str) -> dict[str, str]: + """The keys and values that Juju itself puts in a unit databag. + Juju fills these in from the unit's network for the relation's endpoint, so + we do the same, using the :class:`Network` from the state for that endpoint, + or the default network if the state doesn't have one. -def _inject_juju_default_databag_keys(state: State, juju_version: str) -> None: # pyright: ignore[reportUnusedFunction] - """Populate Juju-managed default keys in every relation unit databag. + ``private-address`` is not included for Juju 4 and later, where Juju no + longer provides it. + """ + try: + network = state.get_network(endpoint) + except KeyError: + network = Network(endpoint) + databag: dict[str, str] = {} + if network.egress_subnets: + databag['egress-subnets'] = ','.join(network.egress_subnets) + if network.ingress_addresses: + databag['ingress-address'] = network.ingress_addresses[0] + if ops.JujuVersion(juju_version).major < 4: + # Juju 4 no longer provides private-address. + databag['private-address'] = network.ingress_addresses[0] + return databag + + +def _inject_juju_default_databag_keys( # pyright: ignore[reportUnusedFunction] + state: State, + juju_version: str, +) -> list[tuple[dict[str, str], str, str]]: + """Populate the Juju-managed keys in every relation unit databag. Mirrors Juju's own behaviour: before the charm runs, Juju sets ``egress-subnets``, ``ingress-address``, and (on Juju 3) ``private-address`` - in each unit's databag if the key is not already present. Existing values - set by the test author are left untouched. App databags are not affected. + in each unit's databag if the key is not already present. Values that the + test author has set are left untouched, and app databags are not affected. + + Returns the (databag, key, value) triples that were injected, so that they + can be removed again with :func:`_remove_juju_default_databag_keys`. """ - keys = _juju_default_unit_databag_keys(juju_version) + injected: list[tuple[dict[str, str], str, str]] = [] for relation in state.relations: - for databag in relation._unit_databags: - for key in keys: + defaults = _juju_default_databag(state, relation.endpoint, juju_version) + for raw_databag in relation._unit_databags: + databag = cast('dict[str, str]', raw_databag) + for key, value in defaults.items(): if key not in databag: - cast('dict[str, str]', databag)[key] = _DEFAULT_IP + databag[key] = value + injected.append((databag, key, value)) + return injected + + +def _remove_juju_default_databag_keys( # pyright: ignore[reportUnusedFunction] + injected: Iterable[tuple[dict[str, str], str, str]], +) -> None: + """Remove the keys that :func:`_inject_juju_default_databag_keys` added. + + The keys are only meant to exist while the charm is running, so that the + output state has the same shape as the input one. A key that the charm + changed while it was running is left alone, so that the charm's own writes + are visible in the output state. + """ + for databag, key, value in injected: + if databag.get(key) == value: + del databag[key] @dataclasses.dataclass(frozen=True, kw_only=True) @@ -787,8 +830,9 @@ class Relation(RelationBase): ) """The current content of the databag for each unit in the relation. - Each unit's databag defaults to an empty dict; Juju-managed keys are - injected at event exec time. + Each unit's databag defaults to an empty dict. The keys that Juju manages + itself are only present while the charm is running - see + :attr:`RelationBase.local_unit_data`. """ remote_model_uuid: str | None = None @@ -837,7 +881,9 @@ class SubordinateRelation(RelationBase): ) """The current content of the remote unit databag. - Defaults to an empty dict; Juju-managed keys are injected at event exec time. + Defaults to an empty dict. The keys that Juju manages itself are only + present while the charm is running - see + :attr:`RelationBase.local_unit_data`. """ remote_app_name: str = 'remote' diff --git a/testing/tests/test_e2e/test_play_assertions.py b/testing/tests/test_e2e/test_play_assertions.py index be1ecbc4c..9d4463533 100644 --- a/testing/tests/test_e2e/test_play_assertions.py +++ b/testing/tests/test_e2e/test_play_assertions.py @@ -6,7 +6,7 @@ import dataclasses import pytest -from scenario.state import _DEFAULT_JUJU_DATABAG, BlockedStatus, Relation, State +from scenario.state import BlockedStatus, Relation, State import ops @@ -93,8 +93,8 @@ def check_relation_data(charm: ops.CharmBase): remote_app_data = foo_rel.data[foo_rel.app] assert remote_units_data == { - 'karlos/0': {**_DEFAULT_JUJU_DATABAG, 'foo': 'bar'}, - 'karlos/1': {**_DEFAULT_JUJU_DATABAG, 'baz': 'qux'}, + 'karlos/0': {'foo': 'bar'}, + 'karlos/1': {'baz': 'qux'}, } assert remote_app_data == {'yaba': 'doodle'} diff --git a/testing/tests/test_e2e/test_relations.py b/testing/tests/test_e2e/test_relations.py index e70670acf..d1730288b 100644 --- a/testing/tests/test_e2e/test_relations.py +++ b/testing/tests/test_e2e/test_relations.py @@ -7,7 +7,16 @@ from typing import Any, ClassVar import pytest -from scenario import Context, PeerRelation, Relation, State, SubordinateRelation +from scenario import ( + Address, + BindAddress, + Context, + Network, + PeerRelation, + Relation, + State, + SubordinateRelation, +) from scenario.errors import StateValidationError, UncaughtCharmError from scenario.state import ( _DEFAULT_JUJU_DATABAG, @@ -186,7 +195,6 @@ def _update_status(self, event: ops.EventBase): state = ctx.run(ctx.on.update_status(), State(relations={rel_in})) rel_out = state.get_relation(rel_in.id) assert rel_out.local_unit_data == { - **_DEFAULT_JUJU_DATABAG, 'to-ignore-key': 'to-ignore-val', 'to-change-key': 'to-change-val-new', 'new-key': 'new-val', @@ -320,7 +328,7 @@ def _update_status(self, event: ops.EventBase): rel_in = PeerRelation(endpoint=relation_name, local_unit_data=original_data) state = ctx.run(ctx.on.update_status(), State(relations={rel_in})) rel_out = state.get_relation(rel_in.id) - assert rel_out.local_unit_data == {**_DEFAULT_JUJU_DATABAG, **result_data} + assert rel_out.local_unit_data == result_data @pytest.mark.parametrize( @@ -492,8 +500,10 @@ def test_relation_default_unit_data_peer(): 'juju_version,expect_private_address', [('3.6.14', True), ('4.0.0', False), ('4.1.0', False)], ) -def test_juju_default_databag_injected_at_exec(juju_version: str, expect_private_address: bool): - """Scenario mirrors Juju: default databag keys are injected at exec time. +def test_juju_default_databag_present_while_charm_runs( + juju_version: str, expect_private_address: bool +): + """Scenario mirrors Juju: the keys Juju manages are set while the charm runs. On Juju 3, `private-address`, `egress-subnets`, and `ingress-address` are all populated; on Juju 4, `private-address` is not. @@ -512,29 +522,101 @@ def test_juju_default_databag_injected_at_exec(juju_version: str, expect_private peer = PeerRelation('p') sub = SubordinateRelation('sub') state_in = State(leader=True, relations={relation, peer, sub}) - + expected = dict(_DEFAULT_JUJU_DATABAG) + if not expect_private_address: + del expected['private-address'] + + def check_databags(event: ops.EventBase): + model = event.framework.model + seen = 0 + for endpoint in ('foo', 'p', 'sub'): + for rel in model.relations[endpoint]: + for entity in {model.unit, *rel.units}: + assert dict(rel.data[entity]) == expected + seen += 1 + assert seen == 5 + + Charm._call = check_databags state_out = ctx.run(ctx.on.start(), state_in) + assert Charm.called + + # The keys only exist while the charm is running: the output state has the + # same (empty) databags as the input one. + rel_out = state_out.get_relation(relation.id) + peer_out = state_out.get_relation(peer.id) + sub_out = state_out.get_relation(sub.id) + assert isinstance(rel_out, Relation) + assert isinstance(peer_out, PeerRelation) + assert isinstance(sub_out, SubordinateRelation) + assert rel_out.local_unit_data == {} + assert rel_out.remote_units_data == {0: {}} + assert peer_out.local_unit_data == {} + assert sub_out.local_unit_data == {} + assert sub_out.remote_unit_data == {} + + +def test_juju_default_databag_uses_network_from_state(): + """The values match what Juju would provide, based on the state's network.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + juju_version='3.6.14', + ) + network = Network( + 'foo', + [BindAddress([Address('10.0.0.10', hostname='foo.example.com')])], + ingress_addresses=['10.0.0.10', '10.0.0.11'], + egress_subnets=['10.0.0.0/24', '10.1.0.0/24'], + ) + relation = Relation('foo') + seen: dict[str, str] = {} - rel = state_out.get_relation(relation.id) - peer_rel = state_out.get_relation(peer.id) - sub_rel = state_out.get_relation(sub.id) - assert isinstance(rel, Relation) - assert isinstance(peer_rel, PeerRelation) - assert isinstance(sub_rel, SubordinateRelation) - for databag in ( - rel.local_unit_data, - rel.remote_units_data[0], - peer_rel.local_unit_data, - sub_rel.local_unit_data, - sub_rel.remote_unit_data, - ): - assert databag['egress-subnets'] == '192.0.2.0' - assert databag['ingress-address'] == '192.0.2.0' - assert ('private-address' in databag) is expect_private_address + def check_databag(event: ops.EventBase): + model = event.framework.model + rel = model.get_relation('foo') + assert rel is not None + seen.update(rel.data[model.unit]) + + Charm._call = check_databag + ctx.run( + ctx.on.start(), + State(leader=True, relations={relation}, networks={network}), + ) + assert seen == { + 'egress-subnets': '10.0.0.0/24,10.1.0.0/24', + 'ingress-address': '10.0.0.10', + 'private-address': '10.0.0.10', + } + + +def test_juju_default_databag_with_empty_network(): + """A network with no addresses means Juju has no values to provide.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + juju_version='3.6.14', + ) + network = Network('foo', [], ingress_addresses=[], egress_subnets=[]) + relation = Relation('foo') + seen: dict[str, str] = {'not': 'empty'} + + def check_databag(event: ops.EventBase): + model = event.framework.model + rel = model.get_relation('foo') + assert rel is not None + seen.clear() + seen.update(rel.data[model.unit]) + + Charm._call = check_databag + ctx.run( + ctx.on.start(), + State(leader=True, relations={relation}, networks={network}), + ) + assert seen == {} def test_juju_default_databag_preserves_explicit_values(): - """Explicit user-set values must not be overwritten by injection.""" + """Explicit user-set values must not be overwritten, or removed afterwards.""" ctx = Context( Charm, meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, @@ -544,15 +626,49 @@ def test_juju_default_databag_preserves_explicit_values(): 'foo', local_unit_data={'private-address': '10.0.0.5', 'extra': 'kept'}, ) + + def check_databag(event: ops.EventBase): + model = event.framework.model + rel = model.get_relation('foo') + assert rel is not None + # The missing keys are filled in, and the provided ones are left alone. + assert dict(rel.data[model.unit]) == { + 'private-address': '10.0.0.5', + 'extra': 'kept', + 'egress-subnets': '192.0.2.0/24', + 'ingress-address': '192.0.2.0', + } + + Charm._call = check_databag + state_out = ctx.run(ctx.on.start(), State(leader=True, relations={relation})) + assert Charm.called + + rel_out = state_out.get_relation(relation.id) + assert isinstance(rel_out, Relation) + assert rel_out.local_unit_data == {'private-address': '10.0.0.5', 'extra': 'kept'} + + +def test_juju_default_databag_keeps_values_the_charm_changed(): + """A key the charm wrote to is kept in the output state.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + juju_version='3.6.14', + ) + relation = Relation('foo') + + def set_databag(event: ops.EventBase): + model = event.framework.model + rel = model.get_relation('foo') + assert rel is not None + rel.data[model.unit]['ingress-address'] = '10.0.0.5' + + Charm._call = set_databag state_out = ctx.run(ctx.on.start(), State(leader=True, relations={relation})) - rel = state_out.get_relation(relation.id) - assert isinstance(rel, Relation) - assert rel.local_unit_data['private-address'] == '10.0.0.5' - assert rel.local_unit_data['extra'] == 'kept' - # Missing defaults are still filled in. - assert rel.local_unit_data['egress-subnets'] == '192.0.2.0' - assert rel.local_unit_data['ingress-address'] == '192.0.2.0' + rel_out = state_out.get_relation(relation.id) + assert isinstance(rel_out, Relation) + assert rel_out.local_unit_data == {'ingress-address': '10.0.0.5'} def test_juju_default_databag_does_not_touch_input_state(): @@ -572,17 +688,27 @@ def test_juju_default_databag_does_not_touch_input_state(): def test_juju_default_databag_not_added_to_app_databags(): - """App databags are not touched — Juju only sets these on unit databags.""" + """App databags are not touched: Juju only sets these on unit databags.""" ctx = Context( Charm, meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, ) relation = Relation('foo') + + def check_app_databags(event: ops.EventBase): + model = event.framework.model + rel = model.get_relation('foo') + assert rel is not None + assert dict(rel.data[model.app]) == {} + assert dict(rel.data[rel.app]) == {} + + Charm._call = check_app_databags state_out = ctx.run(ctx.on.start(), State(leader=True, relations={relation})) - rel = state_out.get_relation(relation.id) - assert isinstance(rel, Relation) - assert rel.local_app_data == {} - assert rel.remote_app_data == {} + assert Charm.called + rel_out = state_out.get_relation(relation.id) + assert isinstance(rel_out, Relation) + assert rel_out.local_app_data == {} + assert rel_out.remote_app_data == {} @pytest.mark.parametrize('evt_name', ('broken', 'created')) diff --git a/testing/tests/test_e2e/test_state.py b/testing/tests/test_e2e/test_state.py index 553081c3d..4960102a9 100644 --- a/testing/tests/test_e2e/test_state.py +++ b/testing/tests/test_e2e/test_state.py @@ -268,15 +268,11 @@ def pre_event(charm: CharmBase): replace( relation, local_app_data={'a': 'b'}, - local_unit_data={'c': 'd', **_DEFAULT_JUJU_DATABAG}, - remote_units_data={1: _DEFAULT_JUJU_DATABAG, 4: _DEFAULT_JUJU_DATABAG}, + local_unit_data={'c': 'd'}, ) ) assert out.get_relation(relation.id).local_app_data == {'a': 'b'} - assert out.get_relation(relation.id).local_unit_data == { - 'c': 'd', - **_DEFAULT_JUJU_DATABAG, - } + assert out.get_relation(relation.id).local_unit_data == {'c': 'd'} def test_checkinfo_changeid_none(): @@ -706,7 +702,7 @@ def event_handler(charm: CharmBase, _: EventBase): relation_out = state_out.get_relation(relation_in.id) assert not relation_in.local_app_data assert relation_out.local_app_data == {'a': 'b'} - assert relation_out.local_unit_data == {'c': 'd', **_DEFAULT_JUJU_DATABAG} + assert relation_out.local_unit_data == {'c': 'd'} def test_state_immutable_with_changed_data_container(mycharm: type[CharmBase]):