From 64bf9f6b57efff9643806ff17d8894e725b957b1 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 16 Jun 2026 15:17:54 +0000 Subject: [PATCH 1/2] Fix #2185: don't include private-address in default Scenario databags on Juju 4 --- testing/src/scenario/_runtime.py | 2 + testing/src/scenario/state.py | 28 +++++++++++ testing/tests/test_e2e/test_relations.py | 64 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/testing/src/scenario/_runtime.py b/testing/src/scenario/_runtime.py index d36d9a86d..aa1e203e6 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 @@ -304,6 +305,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 48da70abb..2af17ad59 100644 --- a/testing/src/scenario/state.py +++ b/testing/src/scenario/state.py @@ -583,12 +583,40 @@ 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. + """ + parts = juju_version.split('.', 1) + try: + major = int(parts[0]) + except ValueError: + return + if 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 a50ed31a4..d0c1a3b5c 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( From 9e6a02b1bc3afe8af752db80b51d6abc316b11a1 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 6 Jul 2026 16:03:24 +1200 Subject: [PATCH 2/2] refactor: use JujuVersion to parse juju_version in _strip_juju4_defaults --- testing/src/scenario/state.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/testing/src/scenario/state.py b/testing/src/scenario/state.py index 54dcf53ec..47b7158ba 100644 --- a/testing/src/scenario/state.py +++ b/testing/src/scenario/state.py @@ -737,12 +737,7 @@ def _strip_juju4_defaults(state: State, juju_version: str) -> None: Mutates the databag dicts in place; only keys still set to the default sentinel IP are removed, so explicit user-set values are preserved. """ - parts = juju_version.split('.', 1) - try: - major = int(parts[0]) - except ValueError: - return - if major < 4: + if ops.JujuVersion(juju_version).major < 4: return for relation in state.relations: for databag in relation._databags: