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

Add collect-status events for multi-status handling #954

Merged
merged 21 commits into from
Jul 21, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8f12f1b
Try approach to stateless multistatus that uses Framework events
benhoyt Jun 15, 2023
8773533
Got charm.collect_[app|unit]_status working
benhoyt Jun 28, 2023
88de08c
Merge branch 'main' into multistatus-poc
benhoyt Jul 10, 2023
c12c0d6
Docstrings and minor updates (just tests left)
benhoyt Jul 10, 2023
0e57f60
Oops, messed up merge conflicts
benhoyt Jul 10, 2023
e63d507
"Fix" linting
benhoyt Jul 11, 2023
5fa1522
Move _evaluate_status to charm.py; add tests
benhoyt Jul 11, 2023
47300c6
WIP fix main tests
benhoyt Jul 11, 2023
9fbafe3
Merge branch 'main' into multistatus-poc
benhoyt Jul 12, 2023
f1f38e0
Finish fixing main.py tests
benhoyt Jul 12, 2023
548eb51
Remove test_multistatus.py testing code
benhoyt Jul 12, 2023
6d58026
Fix main tests on Python 3.8 (no multi-expr with statement)
benhoyt Jul 12, 2023
c65f8a9
Merge branch 'main' into multistatus-poc
benhoyt Jul 17, 2023
3d5fcd4
Type check status arg in add_status for catching errors early
benhoyt Jul 17, 2023
d85d4cb
Bump up to latest Pyright version while we're at it
benhoyt Jul 17, 2023
3c3a4b6
Add testing Harness support for evaluating statuses
benhoyt Jul 17, 2023
694206d
Merge branch 'main' into multistatus-poc
benhoyt Jul 17, 2023
b920b3a
Make Maintenance status higher-pri than Waiting
benhoyt Jul 19, 2023
0967901
Log add_status calls (that aren't ActiveStatus)
benhoyt Jul 19, 2023
1c119ef
Flip maintenance and waiting again, as per Juju's status.DeriveStatus
benhoyt Jul 19, 2023
e389c2a
Make maintenance > waiting again, now that we've done it in Juju too
benhoyt Jul 21, 2023
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
2 changes: 2 additions & 0 deletions ops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
'CharmEvents',
'CharmMeta',
'CollectMetricsEvent',
'CollectStatusEvent',
'ConfigChangedEvent',
'ContainerMeta',
'ContainerStorageMeta',
Expand Down Expand Up @@ -186,6 +187,7 @@
CharmEvents,
CharmMeta,
CollectMetricsEvent,
CollectStatusEvent,
ConfigChangedEvent,
ContainerMeta,
ContainerStorageMeta,
Expand Down
113 changes: 108 additions & 5 deletions ops/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,77 @@ def defer(self) -> None:
'this event until you create a new revision.')


class CollectStatusEvent(EventBase):
"""Event triggered at the end of every hook to collect statuses for evaluation.

If the charm wants to provide application or unit status in a consistent
way after the end of every hook, it should observe the
:attr:`collect_app_status <CharmEvents.collect_app_status>` or
:attr:`collect_unit_status <CharmEvents.collect_unit_status>` event,
respectively.

The framework will trigger these events after the hook code runs
successfully (``collect_app_status`` will only be triggered on the leader
unit). If any statuses were added by the event handlers using
:meth:`add_status`, the framework will choose the highest-priority status
and set that as the status (application status for ``collect_app_status``,
or unit status for ``collect_unit_status``).

The order of priorities is as follows, from highest to lowest:

* error
* blocked
* waiting
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
* maintenance
* active
* unknown

If there are multiple statuses with the same priority, the first one added
wins (and if an event is observed multiple times, the handlers are called
in the order they were observed).

A collect-status event can be observed multiple times, and
:meth:`add_status` can be called multiple times to add multiple statuses
for evaluation. This is useful when a charm has multiple components that
each have a status. Each code path in a collect-status handler should
call ``add_status`` at least once.

Below is an example "web app" charm component that observes
``collect_unit_status`` to provide the status of the component, which
requires a "port" config option set before it can proceed::

class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.webapp = Webapp(self)
# initialize other components

class WebApp(ops.Object):
def __init__(self, charm: ops.CharmBase):
super().__init__(charm, 'webapp')
self.framework.observe(charm.on.collect_unit_status, self._on_collect_status)

def _on_collect_status(self, event: ops.CollectStatusEvent):
if 'port' not in self.model.config:
event.add_status(ops.BlockedStatus('please set "port" config'))
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
return
event.add_status(ops.ActiveStatus())

.. # noqa (pydocstyle barfs on the above for unknown reasons I've spent hours on)
"""

def add_status(self, status: model.StatusBase):
"""Add a status for evaluation.

See :class:`CollectStatusEvent` for a description of how to use this.
"""
model = self.framework.model
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
if self.handle.kind == 'collect_app_status':
model.app._collected_statuses.append(status)
else:
model.unit._collected_statuses.append(status)


class CharmEvents(ObjectEvents):
"""Events generated by Juju pertaining to application lifecycle.

Expand Down Expand Up @@ -882,26 +953,41 @@ class CharmEvents(ObjectEvents):

leader_settings_changed = EventSource(LeaderSettingsChangedEvent)
"""DEPRECATED. Triggered when leader changes any settings (see
:class:`LeaderSettingsChangedEvent`)."""
:class:`LeaderSettingsChangedEvent`).
"""

collect_metrics = EventSource(CollectMetricsEvent)
"""Triggered by Juju to collect metrics (see :class:`CollectMetricsEvent`)."""

secret_changed = EventSource(SecretChangedEvent)
"""Triggered by Juju on the observer when the secret owner changes its contents (see
:class:`SecretChangedEvent`)."""
:class:`SecretChangedEvent`).
"""

secret_expired = EventSource(SecretExpiredEvent)
"""Triggered by Juju on the owner when a secret's expiration time elapses (see
:class:`SecretExpiredEvent`)."""
:class:`SecretExpiredEvent`).
"""

secret_rotate = EventSource(SecretRotateEvent)
"""Triggered by Juju on the owner when the secret's rotation policy elapses (see
:class:`SecretRotateEvent`)."""
:class:`SecretRotateEvent`).
"""

secret_remove = EventSource(SecretRemoveEvent)
"""Triggered by Juju on the owner when a secret revision can be removed (see
:class:`SecretRemoveEvent`)."""
:class:`SecretRemoveEvent`).
"""

collect_app_status = EventSource(CollectStatusEvent)
"""Triggered at the end of every hook to collect app statuses for evaluation
(see :class:`CollectStatusEvent`).
"""
benhoyt marked this conversation as resolved.
Show resolved Hide resolved

collect_unit_status = EventSource(CollectStatusEvent)
"""Triggered at the end of every hook to collect unit statuses for evaluation
(see :class:`CollectStatusEvent`).
"""


class CharmBase(Object):
Expand Down Expand Up @@ -995,6 +1081,23 @@ def config(self) -> model.ConfigData:
return self.model.config


def _evaluate_status(charm: CharmBase): # pyright: ignore[reportUnusedFunction]
"""Trigger collect-status events and evaluate and set the highest-priority status.

See :class:`CollectStatusEvent` for details.
"""
if charm.framework.model._backend.is_leader():
charm.on.collect_app_status.emit()
app = charm.app
if app._collected_statuses:
app.status = model.StatusBase._get_highest_priority(app._collected_statuses)
benhoyt marked this conversation as resolved.
Show resolved Hide resolved

charm.on.collect_unit_status.emit()
unit = charm.unit
if unit._collected_statuses:
unit.status = model.StatusBase._get_highest_priority(unit._collected_statuses)


class CharmMeta:
"""Object containing the metadata for the charm.

Expand Down
2 changes: 2 additions & 0 deletions ops/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ def main(charm_class: Type[ops.charm.CharmBase],

_emit_charm_event(charm, dispatcher.event_name)

ops.charm._evaluate_status(charm)

framework.commit()
finally:
framework.close()
Expand Down
27 changes: 27 additions & 0 deletions ops/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ def __init__(self, name: str, meta: 'ops.charm.CharmMeta',
self._cache = cache
self._is_our_app = self.name == self._backend.app_name
self._status = None
self._collected_statuses: 'List[StatusBase]' = []

def _invalidate(self):
self._status = None
Expand All @@ -331,6 +332,10 @@ def status(self) -> 'StatusBase':

The status of remote units is always Unknown.

You can also use the :attr:`collect_app_status <CharmEvents.collect_app_status>`
event if you want to evaluate and set application status consistently
at the end of every hook.

Raises:
RuntimeError: if you try to set the status of another application, or if you try to
set the status of this application as a unit that is not the leader.
Expand Down Expand Up @@ -465,6 +470,7 @@ def __init__(self, name: str, meta: 'ops.charm.CharmMeta',
self._cache = cache
self._is_our_unit = self.name == self._backend.unit_name
self._status = None
self._collected_statuses: 'List[StatusBase]' = []

if self._is_our_unit and hasattr(meta, "containers"):
containers: _ContainerMeta_Raw = meta.containers
Expand All @@ -479,6 +485,10 @@ def status(self) -> 'StatusBase':

The status of any unit other than yourself is always Unknown.

You can also use the :attr:`collect_unit_status <CharmEvents.collect_unit_status>`
event if you want to evaluate and set unit status consistently at the
end of every hook.

Raises:
RuntimeError: if you try to set the status of a unit other than yourself.
InvalidStatusError: if you try to set the status to something other than
Expand Down Expand Up @@ -1583,6 +1593,23 @@ def register(cls, child: Type['StatusBase']):
cls._statuses[child.name] = child
return child

_priorities = {
'error': 5,
'blocked': 4,
'waiting': 3,
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
'maintenance': 2,
'active': 1,
# 'unknown' or any other status is handled below
}

@classmethod
def _get_highest_priority(cls, statuses: 'List[StatusBase]') -> 'StatusBase':
"""Return the highest-priority status from a list of statuses.

If there are multiple highest-priority statuses, return the first one.
"""
return max(statuses, key=lambda status: cls._priorities.get(status.name, 0))


@StatusBase.register
class UnknownStatus(StatusBase):
Expand Down
126 changes: 126 additions & 0 deletions test/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pathlib import Path

import ops
import ops.charm
from ops.model import _ModelBackend
from ops.storage import SQLiteStorage

Expand Down Expand Up @@ -631,3 +632,128 @@ def on_secret_expired(self, event):
'SecretRemoveEvent',
'SecretExpiredEvent',
])

def test_collect_app_status_leader(self):
class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_app_status, self._on_collect_status)

def _on_collect_status(self, event):
event.add_status(ops.ActiveStatus())
event.add_status(ops.BlockedStatus('first'))
event.add_status(ops.WaitingStatus('waiting'))
event.add_status(ops.BlockedStatus('second'))

fake_script(self, 'is-leader', 'echo true')
fake_script(self, 'status-set', 'exit 0')

charm = MyCharm(self.create_framework())
ops.charm._evaluate_status(charm)

self.assertEqual(fake_script_calls(self, True), [
['is-leader', '--format=json'],
['status-set', '--application=True', 'blocked', 'first'],
])

def test_collect_app_status_no_statuses(self):
class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_app_status, self._on_collect_status)

def _on_collect_status(self, event):
pass

fake_script(self, 'is-leader', 'echo true')

charm = MyCharm(self.create_framework())
ops.charm._evaluate_status(charm)

self.assertEqual(fake_script_calls(self, True), [
['is-leader', '--format=json'],
])

def test_collect_app_status_non_leader(self):
class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_app_status, self._on_collect_status)

def _on_collect_status(self, event):
raise Exception # shouldn't be called

fake_script(self, 'is-leader', 'echo false')

charm = MyCharm(self.create_framework())
ops.charm._evaluate_status(charm)

self.assertEqual(fake_script_calls(self, True), [
['is-leader', '--format=json'],
])

def test_collect_unit_status(self):
class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_unit_status, self._on_collect_status)

def _on_collect_status(self, event):
event.add_status(ops.ActiveStatus())
event.add_status(ops.BlockedStatus('first'))
event.add_status(ops.WaitingStatus('waiting'))
event.add_status(ops.BlockedStatus('second'))

fake_script(self, 'is-leader', 'echo false') # called only for collecting app statuses
fake_script(self, 'status-set', 'exit 0')

charm = MyCharm(self.create_framework())
ops.charm._evaluate_status(charm)

self.assertEqual(fake_script_calls(self, True), [
['is-leader', '--format=json'],
['status-set', '--application=False', 'blocked', 'first'],
])

def test_collect_unit_status_no_statuses(self):
class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_unit_status, self._on_collect_status)

def _on_collect_status(self, event):
pass

fake_script(self, 'is-leader', 'echo false') # called only for collecting app statuses

charm = MyCharm(self.create_framework())
ops.charm._evaluate_status(charm)

self.assertEqual(fake_script_calls(self, True), [
['is-leader', '--format=json'],
])

def test_collect_app_and_unit_status(self):
class MyCharm(ops.CharmBase):
def __init__(self, *args):
super().__init__(*args)
self.framework.observe(self.on.collect_app_status, self._on_collect_app_status)
self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status)

def _on_collect_app_status(self, event):
event.add_status(ops.ActiveStatus())

def _on_collect_unit_status(self, event):
event.add_status(ops.WaitingStatus('blah'))

fake_script(self, 'is-leader', 'echo true')
fake_script(self, 'status-set', 'exit 0')

charm = MyCharm(self.create_framework())
ops.charm._evaluate_status(charm)

self.assertEqual(fake_script_calls(self, True), [
['is-leader', '--format=json'],
['status-set', '--application=True', 'active', ''],
['status-set', '--application=False', 'waiting', 'blah'],
])
Loading