Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Relation.active, exclude inactive relations from Model.relations #1091

Merged
merged 21 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7dc4f06
Add Relation.active, and exclude relations that are not active from M…
tonyandrewmeyer Dec 14, 2023
9c4c793
Merge branch 'main' into relation-broken-940
tonyandrewmeyer Dec 14, 2023
cd8416a
Add changelog entry.
tonyandrewmeyer Dec 14, 2023
56b6773
Ensure that when Harness.remove_relation() is called, the relation is…
tonyandrewmeyer Dec 14, 2023
f6d856c
Test that harness works correctly for relation-broken.
tonyandrewmeyer Dec 14, 2023
bdfc95e
Add main tests to verify that active and relations are set properly.
tonyandrewmeyer Dec 14, 2023
1633f15
Add tests for the broken relation specifying.
tonyandrewmeyer Dec 14, 2023
965f591
Undo overly aggressive wrapping.
tonyandrewmeyer Dec 14, 2023
8d98923
Apply suggestions from code review
tonyandrewmeyer Dec 15, 2023
ce1e108
Make active=True the default.
tonyandrewmeyer Dec 15, 2023
aeda7a3
Add comment.
tonyandrewmeyer Dec 15, 2023
277eb4e
Merge branch 'main' into relation-broken-940
tonyandrewmeyer Dec 15, 2023
c3e7c9b
Fix bad undo.
tonyandrewmeyer Dec 15, 2023
8dc1836
Update ops/model.py
tonyandrewmeyer Jan 2, 2024
c83e5a6
Merge branch 'main' into relation-broken-940
tonyandrewmeyer Jan 2, 2024
699a324
Make quotation marks consistent.
tonyandrewmeyer Jan 2, 2024
3476f94
Make quotation marks consistent.
tonyandrewmeyer Jan 2, 2024
580e16d
Merge branch 'main' into relation-broken-940
tonyandrewmeyer Jan 3, 2024
8b38f54
Merge branch 'main' into relation-broken-940
tonyandrewmeyer Jan 10, 2024
42fbf72
Merge branch 'main' into relation-broken-940
benhoyt Jan 12, 2024
7d8a000
Comment out postgresql-operator tests for now
benhoyt Jan 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 2.10.0

* Added support for Pebble Notices (`PebbleCustomNoticeEvent`, `get_notices`, and so on)
* Added `Relation.active`, and excluded inactive relations from `Model.relations`

# 2.9.0

Expand Down
14 changes: 12 additions & 2 deletions ops/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ def _emit_charm_event(charm: 'ops.charm.CharmBase', event_name: str):
event_to_emit.emit(*args, **kwargs)


def _get_juju_relation_id():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a little strange to pull this out as a helper when it is next to all the other functions that are looking directly at ENV vars.
I suppose there is a bit of parsing and casting vs just reading the string which makes it relevant to split out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting it out was actually a code review suggestion from @benhoyt 😄. I'm weakly in favour of splitting it out for the reasons you mention.

Note that we have tentative plans to refactor the env parsing (#1075) and if that goes ahead then this would presumably end up in the initialisation of that context object and no longer need this little helper.

return int(os.environ['JUJU_RELATION_ID'].split(':')[-1])


def _get_event_args(charm: 'ops.charm.CharmBase',
bound_event: 'ops.framework.BoundEvent') -> Tuple[List[Any], Dict[str, Any]]:
event_type = bound_event.event_type
Expand Down Expand Up @@ -189,7 +193,7 @@ def _get_event_args(charm: 'ops.charm.CharmBase',
return [storage], {}
elif issubclass(event_type, ops.charm.RelationEvent):
relation_name = os.environ['JUJU_RELATION']
relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1])
relation_id = _get_juju_relation_id()
relation: Optional[ops.model.Relation] = model.get_relation(relation_name, relation_id)

remote_app_name = os.environ.get('JUJU_REMOTE_APP', '')
Expand Down Expand Up @@ -396,8 +400,14 @@ def main(charm_class: Type[ops.charm.CharmBase],
else:
actions_metadata = None

# If we are in a RelationBroken event, we want to know which relation is
# broken within the model, not only in the event's `.relation` attribute.
if os.environ.get("JUJU_DISPATCH_PATH", "").endswith("-relation-broken"):
broken_relation_id = _get_juju_relation_id()
else:
broken_relation_id = None
meta = CharmMeta.from_yaml(metadata, actions_metadata)
model = ops.model.Model(meta, model_backend)
model = ops.model.Model(meta, model_backend, broken_relation_id=broken_relation_id)

charm_state_path = charm_dir / CHARM_STATE_FILE

Expand Down
34 changes: 27 additions & 7 deletions ops/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ class Model:
as ``self.model`` from any class that derives from :class:`Object`.
"""

def __init__(self, meta: 'ops.charm.CharmMeta', backend: '_ModelBackend'):
def __init__(self, meta: 'ops.charm.CharmMeta', backend: '_ModelBackend',
broken_relation_id: Optional[int] = None):
self._cache = _ModelCache(meta, backend)
self._backend = backend
self._unit = self.get_unit(self._backend.unit_name)
relations: Dict[str, 'ops.RelationMeta'] = meta.relations
self._relations = RelationMapping(relations, self.unit, self._backend, self._cache)
self._relations = RelationMapping(relations, self.unit, self._backend, self._cache,
broken_relation_id=broken_relation_id)
self._config = ConfigData(self._backend)
resources: Iterable[str] = meta.resources
self._resources = Resources(list(resources), self._backend)
Expand Down Expand Up @@ -147,6 +149,9 @@ def relations(self) -> 'RelationMapping':

Answers the question "what am I currently related to".
See also :meth:`.get_relation`.

In a ``relation-broken`` event, the broken relation is excluded from
this list.
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
"""
return self._relations

Expand Down Expand Up @@ -796,15 +801,20 @@ def __repr__(self):
class RelationMapping(Mapping[str, List['Relation']]):
"""Map of relation names to lists of :class:`Relation` instances."""

def __init__(self, relations_meta: Dict[str, 'ops.RelationMeta'], our_unit: 'Unit',
backend: '_ModelBackend', cache: '_ModelCache'):
def __init__(self,
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
relations_meta: Dict[str, 'ops.RelationMeta'],
our_unit: 'Unit',
backend: '_ModelBackend',
cache: '_ModelCache',
broken_relation_id: Optional[int]):
self._peers: Set[str] = set()
for name, relation_meta in relations_meta.items():
if relation_meta.role.is_peer():
self._peers.add(name)
self._our_unit = our_unit
self._backend = backend
self._cache = cache
self._broken_relation_id = broken_relation_id
self._data: _RelationMapping_Raw = {r: None for r in relations_meta}

def __contains__(self, key: str):
Expand All @@ -822,6 +832,8 @@ def __getitem__(self, relation_name: str) -> List['Relation']:
if not isinstance(relation_list, list):
relation_list = self._data[relation_name] = [] # type: ignore
for rid in self._backend.relation_ids(relation_name):
if rid == self._broken_relation_id:
continue
relation = Relation(relation_name, rid, is_peer,
self._our_unit, self._backend, self._cache)
relation_list.append(relation)
Expand Down Expand Up @@ -849,7 +861,7 @@ def _get_unique(self, relation_name: str, relation_id: Optional[int] = None):
# The relation may be dead, but it is not forgotten.
is_peer = relation_name in self._peers
return Relation(relation_name, relation_id, is_peer,
self._our_unit, self._backend, self._cache)
self._our_unit, self._backend, self._cache, active=False)
relations = self[relation_name]
num_related = len(relations)
self._backend._validate_relation_access(
Expand Down Expand Up @@ -1453,13 +1465,21 @@ class Relation:
This is accessed using, for example, ``Relation.data[unit]['foo']``.
"""

active: bool
"""Indicates whether this relation is active.

This will be ``False`` if the current event is a ``relation-broken`` event
associated with this relation.
tonyandrewmeyer marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(
self, relation_name: str, relation_id: int, is_peer: bool, our_unit: Unit,
backend: '_ModelBackend', cache: '_ModelCache'):
backend: '_ModelBackend', cache: '_ModelCache', active: bool = True):
self.name = relation_name
self.id = relation_id
self.app: Optional[Application] = None
self.units: Set[Unit] = set()
self.active = active

if is_peer:
# For peer relations, both the remote and the local app are the same.
Expand All @@ -1474,7 +1494,7 @@ def __init__(
self.app = unit.app
except RelationNotFoundError:
# If the relation is dead, just treat it as if it has no remote units.
pass
self.active = False

# If we didn't get the remote app via our_unit.app or the units list,
# look it up via JUJU_REMOTE_APP or "relation-list --app".
Expand Down
10 changes: 10 additions & 0 deletions ops/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -878,8 +878,18 @@ def remove_relation(self, relation_id: int) -> None:
for unit_name in rel_list_map[relation_id].copy():
self.remove_relation_unit(relation_id, unit_name)

prev_broken_id = None # Silence linter warning.
if self._model is not None:
# Let the model's RelationMapping know that this relation is broken.
# Normally, this is handled in `main`, but while testing we create
# the `Model` object and keep it around for multiple events.
prev_broken_id = self._model._relations._broken_relation_id
self._model.relations._broken_relation_id = relation_id
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
# Ensure that we don't offer a cached relation.
self._model.relations._invalidate(relation_name)
self._emit_relation_broken(relation_name, relation_id, remote_app)
if self._model is not None:
self._model.relations._broken_relation_id = prev_broken_id
self._model.relations._invalidate(relation_name)

self._backend._relation_app_and_units.pop(relation_id)
Expand Down
8 changes: 8 additions & 0 deletions test/charms/test_main/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ def _on_leader_settings_changed(self, event: ops.LeaderSettingsChangedEvent):

def _on_db_relation_joined(self, event: ops.RelationJoinedEvent):
assert event.app is not None, 'application name cannot be None for a relation-joined event'
assert event.relation.active, 'a joined relation is always active'
assert self.model.relations['db']
self._stored.on_db_relation_joined.append(type(event).__name__)
self._stored.observed_event_types.append(type(event).__name__)
self._stored.db_relation_joined_data = event.snapshot()
Expand All @@ -154,13 +156,17 @@ def _on_mon_relation_changed(self, event: ops.RelationChangedEvent):
assert event.unit is not None, (
'a unit name cannot be None for a relation-changed event'
' associated with a remote unit')
assert event.relation.active, 'a changed relation is always active'
assert self.model.relations['mon']
self._stored.on_mon_relation_changed.append(type(event).__name__)
self._stored.observed_event_types.append(type(event).__name__)
self._stored.mon_relation_changed_data = event.snapshot()

def _on_mon_relation_departed(self, event: ops.RelationDepartedEvent):
assert event.app is not None, (
'application name cannot be None for a relation-departed event')
assert event.relation.active, 'a departed relation is still active'
assert self.model.relations['mon']
self._stored.on_mon_relation_departed.append(type(event).__name__)
self._stored.observed_event_types.append(type(event).__name__)
self._stored.mon_relation_departed_data = event.snapshot()
Expand All @@ -170,6 +176,8 @@ def _on_ha_relation_broken(self, event: ops.RelationBrokenEvent):
'relation-broken events cannot have a reference to a remote application')
assert event.unit is None, (
'relation broken events cannot have a reference to a remote unit')
assert not event.relation.active, "relation broken events always have a broken relation"
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
assert not self.model.relations['ha']
self._stored.on_ha_relation_broken.append(type(event).__name__)
self._stored.observed_event_types.append(type(event).__name__)
self._stored.ha_relation_broken_data = event.snapshot()
Expand Down
28 changes: 28 additions & 0 deletions test/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2245,6 +2245,34 @@ def test_dead_relations(self):
['network-get', 'db0', '--format=json'],
])

def test_broken_relations(self):
meta = ops.CharmMeta()
meta.relations = {
'db0': ops.RelationMeta(
ops.RelationRole.provides, 'db0', {'interface': 'db0', 'scope': 'global'}),
'db1': ops.RelationMeta(
ops.RelationRole.requires, 'db1', {'interface': 'db1', 'scope': 'global'}),
'db2': ops.RelationMeta(
ops.RelationRole.peer, 'db2', {'interface': 'db2', 'scope': 'global'}),
}
backend = _ModelBackend('myapp/0')
model = ops.Model(meta, backend, broken_relation_id=8)
fake_script(self, 'relation-ids',
"""if [ "$1" = "db0" ]; then
echo '["db0:4"]'
elif [ "$1" = "db1" ]; then
echo '["db1:8"]'
elif [ "$1" = "db2" ]; then
echo '["db2:16"]'
else
echo '[]'
fi
""")
fake_script(self, 'relation-list', """echo '""'""")
self.assertTrue(model.relations['db0'])
self.assertFalse(model.relations['db1'])
self.assertTrue(model.relations['db2'])

def test_binding_by_relation_name(self):
fake_script(self, 'network-get',
f'''[ "$1" = db0 ] && echo '{self.network_get_out}' || exit 1''')
Expand Down
27 changes: 27 additions & 0 deletions test/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,33 @@ def test_removing_relation_refreshes_charm_model(self):
harness.remove_relation(rel_id)
self.assertIsNone(self._find_relation_in_model_by_id(harness, rel_id))

def test_remove_relation_marks_relation_as_inactive(self):
relations: typing.List[str] = []
is_broken = False

class MyCharm(ops.CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
framework.observe(self.on.db_relation_broken, self._db_relation_broken)

def _db_relation_broken(self, event: ops.RelationBrokenEvent):
nonlocal is_broken, relations
is_broken = not event.relation.active
relations = [rel.name for rel in self.model.relations["db"]]

harness = ops.testing.Harness(MyCharm, meta='''
name: test-app
requires:
db:
interface: pgsql
''')
self.addCleanup(harness.cleanup)
harness.begin()
rel_id = harness.add_relation('db', 'postgresql')
harness.remove_relation(rel_id)
self.assertTrue(is_broken, "event.relation.active not False in relation-broken event")
self.assertFalse(relations, "Model.relations contained broken relation")

def _find_relation_in_model_by_id(
self,
harness: ops.testing.Harness['RelationEventCharm'],
Expand Down
Loading