Skip to content

Commit a3b110e

Browse files
authored
Add collect-status events for multi-status handling (canonical#954)
This implements the `collect_app_status` and `collect_unit_status` events that can be used by charms to allow the framework to automatically collect status (from multiple components of the charm) and set status at the end of every hook. API reference under [`CollectStatusEvent`] (https://ops--954.org.readthedocs.build/en/954/#ops.CollectStatusEvent), though I'll also add usage examples to the new "charm status" SDK doc that Dora is creating. Spec and examples described in [OP037] (https://docs.google.com/document/d/1uQNgif0GG03TdnqT4UM9BxEshXuSvCmT9TdxOHoIUkI/edit). The implementation is very straight-forward: `model.Application` and `model.Unit` each have a list of `_collected_statuses`, and `CollectStatusEvent.add_status` adds to the correct one of those. Then after every hook in `ops/main.py` we call `_evaluate_status`, which triggers each event and sets the app or unit status to the highest-priority status collected (if any statuses were collected). Note that `collect_app_status` is only done on the leader unit. To test when using `ops.testing.Harness`, use `Harness.evaluate_status`. This has full unit tests, including in `test_main.py`, but I've also confirms this works on a real production deploying on Juju.
1 parent dd4865f commit a3b110e

File tree

9 files changed

+400
-23
lines changed

9 files changed

+400
-23
lines changed

ops/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
'CharmEvents',
5656
'CharmMeta',
5757
'CollectMetricsEvent',
58+
'CollectStatusEvent',
5859
'ConfigChangedEvent',
5960
'ContainerMeta',
6061
'ContainerStorageMeta',
@@ -186,6 +187,7 @@
186187
CharmEvents,
187188
CharmMeta,
188189
CollectMetricsEvent,
190+
CollectStatusEvent,
189191
ConfigChangedEvent,
190192
ContainerMeta,
191193
ContainerStorageMeta,

ops/charm.py

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Base objects for the Charm, events and metadata."""
1616

1717
import enum
18+
import logging
1819
import os
1920
import pathlib
2021
from typing import (
@@ -74,6 +75,9 @@
7475
total=False)
7576

7677

78+
logger = logging.getLogger(__name__)
79+
80+
7781
class HookEvent(EventBase):
7882
"""Events raised by Juju to progress a charm's lifecycle.
7983
@@ -826,6 +830,83 @@ def defer(self) -> None:
826830
'this event until you create a new revision.')
827831

828832

833+
class CollectStatusEvent(EventBase):
834+
"""Event triggered at the end of every hook to collect statuses for evaluation.
835+
836+
If the charm wants to provide application or unit status in a consistent
837+
way after the end of every hook, it should observe the
838+
:attr:`collect_app_status <CharmEvents.collect_app_status>` or
839+
:attr:`collect_unit_status <CharmEvents.collect_unit_status>` event,
840+
respectively.
841+
842+
The framework will trigger these events after the hook code runs
843+
successfully (``collect_app_status`` will only be triggered on the leader
844+
unit). If any statuses were added by the event handlers using
845+
:meth:`add_status`, the framework will choose the highest-priority status
846+
and set that as the status (application status for ``collect_app_status``,
847+
or unit status for ``collect_unit_status``).
848+
849+
The order of priorities is as follows, from highest to lowest:
850+
851+
* error
852+
* blocked
853+
* maintenance
854+
* waiting
855+
* active
856+
* unknown
857+
858+
If there are multiple statuses with the same priority, the first one added
859+
wins (and if an event is observed multiple times, the handlers are called
860+
in the order they were observed).
861+
862+
A collect-status event can be observed multiple times, and
863+
:meth:`add_status` can be called multiple times to add multiple statuses
864+
for evaluation. This is useful when a charm has multiple components that
865+
each have a status. Each code path in a collect-status handler should
866+
call ``add_status`` at least once.
867+
868+
Below is an example "web app" charm component that observes
869+
``collect_unit_status`` to provide the status of the component, which
870+
requires a "port" config option set before it can proceed::
871+
872+
class MyCharm(ops.CharmBase):
873+
def __init__(self, *args):
874+
super().__init__(*args)
875+
self.webapp = Webapp(self)
876+
# initialize other components
877+
878+
class WebApp(ops.Object):
879+
def __init__(self, charm: ops.CharmBase):
880+
super().__init__(charm, 'webapp')
881+
self.framework.observe(charm.on.collect_unit_status, self._on_collect_status)
882+
883+
def _on_collect_status(self, event: ops.CollectStatusEvent):
884+
if 'port' not in self.model.config:
885+
event.add_status(ops.BlockedStatus('please set "port" config'))
886+
return
887+
event.add_status(ops.ActiveStatus())
888+
889+
.. # noqa (pydocstyle barfs on the above for unknown reasons I've spent hours on)
890+
"""
891+
892+
def add_status(self, status: model.StatusBase):
893+
"""Add a status for evaluation.
894+
895+
See :class:`CollectStatusEvent` for a description of how to use this.
896+
"""
897+
if not isinstance(status, model.StatusBase):
898+
raise TypeError(f'status should be a StatusBase, not {type(status).__name__}')
899+
model_ = self.framework.model
900+
if self.handle.kind == 'collect_app_status':
901+
if not isinstance(status, model.ActiveStatus):
902+
logger.debug('Adding app status %s', status, stacklevel=2)
903+
model_.app._collected_statuses.append(status)
904+
else:
905+
if not isinstance(status, model.ActiveStatus):
906+
logger.debug('Adding unit status %s', status, stacklevel=2)
907+
model_.unit._collected_statuses.append(status)
908+
909+
829910
class CharmEvents(ObjectEvents):
830911
"""Events generated by Juju pertaining to application lifecycle.
831912
@@ -882,26 +963,41 @@ class CharmEvents(ObjectEvents):
882963

883964
leader_settings_changed = EventSource(LeaderSettingsChangedEvent)
884965
"""DEPRECATED. Triggered when leader changes any settings (see
885-
:class:`LeaderSettingsChangedEvent`)."""
966+
:class:`LeaderSettingsChangedEvent`).
967+
"""
886968

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

890972
secret_changed = EventSource(SecretChangedEvent)
891973
"""Triggered by Juju on the observer when the secret owner changes its contents (see
892-
:class:`SecretChangedEvent`)."""
974+
:class:`SecretChangedEvent`).
975+
"""
893976

894977
secret_expired = EventSource(SecretExpiredEvent)
895978
"""Triggered by Juju on the owner when a secret's expiration time elapses (see
896-
:class:`SecretExpiredEvent`)."""
979+
:class:`SecretExpiredEvent`).
980+
"""
897981

898982
secret_rotate = EventSource(SecretRotateEvent)
899983
"""Triggered by Juju on the owner when the secret's rotation policy elapses (see
900-
:class:`SecretRotateEvent`)."""
984+
:class:`SecretRotateEvent`).
985+
"""
901986

902987
secret_remove = EventSource(SecretRemoveEvent)
903988
"""Triggered by Juju on the owner when a secret revision can be removed (see
904-
:class:`SecretRemoveEvent`)."""
989+
:class:`SecretRemoveEvent`).
990+
"""
991+
992+
collect_app_status = EventSource(CollectStatusEvent)
993+
"""Triggered on the leader at the end of every hook to collect app statuses for evaluation
994+
(see :class:`CollectStatusEvent`).
995+
"""
996+
997+
collect_unit_status = EventSource(CollectStatusEvent)
998+
"""Triggered at the end of every hook to collect unit statuses for evaluation
999+
(see :class:`CollectStatusEvent`).
1000+
"""
9051001

9061002

9071003
class CharmBase(Object):
@@ -995,6 +1091,23 @@ def config(self) -> model.ConfigData:
9951091
return self.model.config
9961092

9971093

1094+
def _evaluate_status(charm: CharmBase): # pyright: ignore[reportUnusedFunction]
1095+
"""Trigger collect-status events and evaluate and set the highest-priority status.
1096+
1097+
See :class:`CollectStatusEvent` for details.
1098+
"""
1099+
if charm.framework.model._backend.is_leader():
1100+
charm.on.collect_app_status.emit()
1101+
app = charm.app
1102+
if app._collected_statuses:
1103+
app.status = model.StatusBase._get_highest_priority(app._collected_statuses)
1104+
1105+
charm.on.collect_unit_status.emit()
1106+
unit = charm.unit
1107+
if unit._collected_statuses:
1108+
unit.status = model.StatusBase._get_highest_priority(unit._collected_statuses)
1109+
1110+
9981111
class CharmMeta:
9991112
"""Object containing the metadata for the charm.
10001113

ops/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,8 @@ def main(charm_class: Type[ops.charm.CharmBase],
440440

441441
_emit_charm_event(charm, dispatcher.event_name)
442442

443+
ops.charm._evaluate_status(charm)
444+
443445
framework.commit()
444446
finally:
445447
framework.close()

ops/model.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ def __init__(self, name: str, meta: 'ops.charm.CharmMeta',
319319
self._cache = cache
320320
self._is_our_app = self.name == self._backend.app_name
321321
self._status = None
322+
self._collected_statuses: 'List[StatusBase]' = []
322323

323324
def _invalidate(self):
324325
self._status = None
@@ -331,6 +332,10 @@ def status(self) -> 'StatusBase':
331332
332333
The status of remote units is always Unknown.
333334
335+
You can also use the :attr:`collect_app_status <CharmEvents.collect_app_status>`
336+
event if you want to evaluate and set application status consistently
337+
at the end of every hook.
338+
334339
Raises:
335340
RuntimeError: if you try to set the status of another application, or if you try to
336341
set the status of this application as a unit that is not the leader.
@@ -465,6 +470,7 @@ def __init__(self, name: str, meta: 'ops.charm.CharmMeta',
465470
self._cache = cache
466471
self._is_our_unit = self.name == self._backend.unit_name
467472
self._status = None
473+
self._collected_statuses: 'List[StatusBase]' = []
468474

469475
if self._is_our_unit and hasattr(meta, "containers"):
470476
containers: _ContainerMeta_Raw = meta.containers
@@ -479,6 +485,10 @@ def status(self) -> 'StatusBase':
479485
480486
The status of any unit other than yourself is always Unknown.
481487
488+
You can also use the :attr:`collect_unit_status <CharmEvents.collect_unit_status>`
489+
event if you want to evaluate and set unit status consistently at the
490+
end of every hook.
491+
482492
Raises:
483493
RuntimeError: if you try to set the status of a unit other than yourself.
484494
InvalidStatusError: if you try to set the status to something other than
@@ -1583,6 +1593,23 @@ def register(cls, child: Type['StatusBase']):
15831593
cls._statuses[child.name] = child
15841594
return child
15851595

1596+
_priorities = {
1597+
'error': 5,
1598+
'blocked': 4,
1599+
'maintenance': 3,
1600+
'waiting': 2,
1601+
'active': 1,
1602+
# 'unknown' or any other status is handled below
1603+
}
1604+
1605+
@classmethod
1606+
def _get_highest_priority(cls, statuses: 'List[StatusBase]') -> 'StatusBase':
1607+
"""Return the highest-priority status from a list of statuses.
1608+
1609+
If there are multiple highest-priority statuses, return the first one.
1610+
"""
1611+
return max(statuses, key=lambda status: cls._priorities.get(status.name, 0))
1612+
15861613

15871614
@StatusBase.register
15881615
class UnknownStatus(StatusBase):

ops/testing.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,21 @@ def __init__(self, *args):
14841484
container_name = container.name
14851485
return self._backend._pebble_clients[container_name]._root
14861486

1487+
def evaluate_status(self) -> None:
1488+
"""Trigger the collect-status events and set application and/or unit status.
1489+
1490+
This will always trigger ``collect_unit_status``, and set the unit status if any
1491+
statuses were added.
1492+
1493+
If running on the leader unit (:meth:`set_leader` has been called with ``True``),
1494+
this will trigger ``collect_app_status``, and set the application status if any
1495+
statuses were added.
1496+
1497+
Tests should normally call this and then assert that ``self.model.app.status``
1498+
or ``self.model.unit.status`` is the value expected.
1499+
"""
1500+
charm._evaluate_status(self.charm)
1501+
14871502

14881503
def _get_app_or_unit_name(app_or_unit: AppUnitOrName) -> str:
14891504
"""Return name of given application or unit (return strings directly)."""

requirements-dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ flake8-builtins==2.1.0
77
pyproject-flake8==4.0.1
88
pep8-naming==0.13.2
99
pytest==7.2.1
10-
pyright==1.1.316
10+
pyright==1.1.317
1111
pytest-operator==0.23.0
1212
coverage[toml]==7.0.5
1313
typing_extensions==4.2.0

0 commit comments

Comments
 (0)