diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index 44e3b50be..c24ccc952 100644 --- a/testing/src/scenario/_runtime.py +++ b/testing/src/scenario/_runtime.py @@ -28,6 +28,7 @@ PeerRelation, Relation, SubordinateRelation, + _strip_juju4_defaults, ) if TYPE_CHECKING: # pragma: no cover @@ -313,6 +314,7 @@ def exec( # we make a copy to avoid mutating the input state output_state = copy.deepcopy(state) + _strip_juju4_defaults(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 99ae6ffe4..47b7158ba 100644 --- a/testing/src/scenario/state.py +++ b/testing/src/scenario/state.py @@ -716,12 +716,35 @@ def _validate_databag(self, databag: dict[str, str]): _DEFAULT_IP = '192.0.2.0' +# Juju 3.x populates `private-address` in every relation databag, but Juju 4 +# does not. The dataclass `default_factory` runs at `Relation()` construction +# time, before the test's `juju_version` is known, so we always include +# `private-address` here and strip it later in `_strip_juju4_defaults` if the +# `Context.juju_version` is 4.0 or later. _DEFAULT_JUJU_DATABAG = { 'egress-subnets': _DEFAULT_IP, 'ingress-address': _DEFAULT_IP, 'private-address': _DEFAULT_IP, } +# Keys in `_DEFAULT_JUJU_DATABAG` that Juju 4 no longer auto-populates. +_JUJU_4_REMOVED_DATABAG_KEYS = ('private-address',) + + +def _strip_juju4_defaults(state: State, juju_version: str) -> None: + """Strip Juju-3-only default databag keys from `state` for Juju 4+. + + Mutates the databag dicts in place; only keys still set to the default + sentinel IP are removed, so explicit user-set values are preserved. + """ + if ops.JujuVersion(juju_version).major < 4: + return + for relation in state.relations: + for databag in relation._databags: + for key in _JUJU_4_REMOVED_DATABAG_KEYS: + if databag.get(key) == _DEFAULT_IP: + del databag[key] + @dataclasses.dataclass(frozen=True, kw_only=True) class Relation(RelationBase): diff --git a/testing/tests/test_e2e/test_relations.py b/testing/tests/test_e2e/test_relations.py index cf923e18b..81900c667 100644 --- a/testing/tests/test_e2e/test_relations.py +++ b/testing/tests/test_e2e/test_relations.py @@ -487,6 +487,70 @@ def test_relation_default_unit_data_peer(): assert relation.local_unit_data == _DEFAULT_JUJU_DATABAG +@pytest.mark.parametrize( + 'juju_version,expect_private_address', + [('3.6.14', True), ('4.0.0', False), ('4.1.0', False)], +) +def test_private_address_default_dropped_on_juju_4( + juju_version: str, expect_private_address: bool +): + """Juju 4 no longer auto-populates `private-address` in relation databags. + + Verify the output state reflects that — the default key is preserved on + Juju 3, and stripped on Juju 4+. + """ + 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) + assert ('private-address' in rel.local_unit_data) is expect_private_address + assert ('private-address' in rel.remote_units_data[0]) is expect_private_address + assert ('private-address' in peer_rel.local_unit_data) is expect_private_address + assert ('private-address' in sub_rel.local_unit_data) is expect_private_address + assert ('private-address' in sub_rel.remote_unit_data) is expect_private_address + # `egress-subnets` and `ingress-address` are still set on Juju 4. + assert rel.local_unit_data['ingress-address'] == '192.0.2.0' + assert rel.local_unit_data['egress-subnets'] == '192.0.2.0' + + +def test_private_address_explicit_value_preserved_on_juju_4(): + """Only the default sentinel IP is stripped — explicit user values stay.""" + ctx = Context( + Charm, + meta={'name': 'foo', 'requires': {'foo': {'interface': 'foo'}}}, + juju_version='4.0.0', + ) + 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' + + @pytest.mark.parametrize('evt_name', ('broken', 'created')) def test_relation_events_no_remote_units(evt_name: str, caplog: pytest.LogCaptureFixture): relation = Relation(