diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index 80bbf7d9a..e3696c5c8 100644 --- a/testing/src/scenario/_runtime.py +++ b/testing/src/scenario/_runtime.py @@ -28,6 +28,8 @@ PeerRelation, Relation, SubordinateRelation, + _inject_juju_default_databag_keys, + _remove_juju_default_databag_keys, ) if TYPE_CHECKING: # pragma: no cover @@ -313,6 +315,10 @@ def exec( # we make a copy to avoid mutating the input state output_state = copy.deepcopy(state) + # 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: @@ -377,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 0e94c0058..ee99f7547 100644 --- a/testing/src/scenario/state.py +++ b/testing/src/scenario/state.py @@ -659,9 +659,16 @@ 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. 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 def relation_id(self) -> NoReturn: @@ -677,6 +684,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.""" @@ -722,13 +734,84 @@ def _validate_databag(self, databag: Mapping[str, str]): _DEFAULT_IP = '192.0.2.0' +_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] = { - 'egress-subnets': _DEFAULT_IP, + 'egress-subnets': _DEFAULT_EGRESS_SUBNET, 'ingress-address': _DEFAULT_IP, 'private-address': _DEFAULT_IP, } +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. + + ``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. 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`. + """ + injected: list[tuple[dict[str, str], str, str]] = [] + for relation in state.relations: + 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: + 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) class Relation(RelationBase): """A relation between the charm and another application.""" @@ -743,9 +826,14 @@ 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. 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 """The remote model's UUID; uses the main model's UUID if not specified.""" @@ -775,6 +863,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): @@ -783,9 +877,14 @@ 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. 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' """The name of the remote application that *this unit* is attached to.""" @@ -817,6 +916,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``.""" @@ -848,6 +953,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_relations.py b/testing/tests/test_e2e/test_relations.py index a50ed31a4..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, @@ -472,19 +481,234 @@ 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_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. + """ + 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}) + 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] = {} + + 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, or removed afterwards.""" + 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'}, + ) + + 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_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(): + """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') + + 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})) + 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')) @@ -692,11 +916,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 +932,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 +948,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..4960102a9 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={ @@ -268,14 +268,11 @@ def pre_event(charm: CharmBase): replace( relation, local_app_data={'a': 'b'}, - local_unit_data={'c': 'd', **_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(): @@ -705,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]):