diff --git a/ops/__init__.py b/ops/__init__.py index c74ba25b8..c4b0d950a 100644 --- a/ops/__init__.py +++ b/ops/__init__.py @@ -65,6 +65,9 @@ 'LeaderSettingsChangedEvent', 'MetadataLinks', 'PayloadMeta', + 'PebbleCheckEvent', + 'PebbleCheckFailedEvent', + 'PebbleCheckRecoveredEvent', 'PebbleCustomNoticeEvent', 'PebbleNoticeEvent', 'PebbleReadyEvent', @@ -132,6 +135,7 @@ 'ContainerMapping', 'ErrorStatus', 'InvalidStatusError', + 'LazyCheckInfo', 'LazyMapping', 'LazyNotice', 'MaintenanceStatus', @@ -204,6 +208,9 @@ LeaderSettingsChangedEvent, MetadataLinks, PayloadMeta, + PebbleCheckEvent, + PebbleCheckFailedEvent, + PebbleCheckRecoveredEvent, PebbleCustomNoticeEvent, PebbleNoticeEvent, PebbleReadyEvent, @@ -275,6 +282,7 @@ ContainerMapping, ErrorStatus, InvalidStatusError, + LazyCheckInfo, LazyMapping, LazyNotice, MaintenanceStatus, diff --git a/ops/charm.py b/ops/charm.py index 9a1d79a12..f1ab03206 100644 --- a/ops/charm.py +++ b/ops/charm.py @@ -831,6 +831,60 @@ class PebbleCustomNoticeEvent(PebbleNoticeEvent): """Event triggered when a Pebble notice of type "custom" is created or repeats.""" +class PebbleCheckEvent(WorkloadEvent): + """Base class for Pebble check events.""" + + info: model.LazyCheckInfo + """Provide access to the check's current state.""" + + def __init__( + self, + handle: Handle, + workload: model.Container, + check_name: str, + ): + super().__init__(handle, workload) + self.info = model.LazyCheckInfo(workload, check_name) + + def snapshot(self) -> Dict[str, Any]: + """Used by the framework to serialize the event to disk. + + Not meant to be called by charm code. + """ + d = super().snapshot() + d['check_name'] = self.info.name + return d + + def restore(self, snapshot: Dict[str, Any]): + """Used by the framework to deserialize the event from disk. + + Not meant to be called by charm code. + """ + check_name = snapshot.pop('check_name') + super().restore(snapshot) + self.info = model.LazyCheckInfo(self.workload, check_name) + + +class PebbleCheckFailedEvent(PebbleCheckEvent): + """Event triggered when a Pebble check exceeds the configured failure threshold. + + Note that the check may have started passing by the time this event is + emitted (which will mean that a :class:`PebbleCheckRecoveredEvent` will be + emitted next). If the handler is executing code that should only be done + if the check is currently failing, check the current status with + ``event.info.status == ops.pebble.CheckStatus.DOWN``. + """ + + +class PebbleCheckRecoveredEvent(PebbleCheckEvent): + """Event triggered when a Pebble check recovers. + + This event is only triggered when the check has previously reached a failure + state (not simply failed, but failed at least as many times as the + configured threshold). + """ + + class SecretEvent(HookEvent): """Base class for all secret events.""" @@ -1219,6 +1273,10 @@ def __init__(self, framework: Framework): container_name = container_name.replace('-', '_') self.on.define_event(f'{container_name}_pebble_ready', PebbleReadyEvent) self.on.define_event(f'{container_name}_pebble_custom_notice', PebbleCustomNoticeEvent) + self.on.define_event(f'{container_name}_pebble_check_failed', PebbleCheckFailedEvent) + self.on.define_event( + f'{container_name}_pebble_check_recovered', PebbleCheckRecoveredEvent + ) @property def app(self) -> model.Application: diff --git a/ops/main.py b/ops/main.py index d94a19f6e..792972c0f 100644 --- a/ops/main.py +++ b/ops/main.py @@ -167,6 +167,9 @@ def _get_event_args( notice_type = os.environ['JUJU_NOTICE_TYPE'] notice_key = os.environ['JUJU_NOTICE_KEY'] args.extend([notice_id, notice_type, notice_key]) + elif issubclass(event_type, ops.charm.PebbleCheckEvent): + check_name = os.environ['JUJU_PEBBLE_CHECK_NAME'] + args.append(check_name) return args, {} elif issubclass(event_type, ops.charm.SecretEvent): args: List[Any] = [ diff --git a/ops/model.py b/ops/model.py index 040ba09c6..282996b6d 100644 --- a/ops/model.py +++ b/ops/model.py @@ -3792,6 +3792,40 @@ def _ensure_loaded(self): assert self._notice.key == self.key +class LazyCheckInfo: + """Provide lazily-loaded access to a Pebble check's info. + + The attributes provided by this class are the same as those of + :class:`ops.pebble.CheckInfo`, however, the notice details are only fetched + from Pebble if necessary (and cached on the instance). + """ + + name: str + level: Optional[Union[pebble.CheckLevel, str]] + status: Union[pebble.CheckStatus, str] + failures: int + threshold: int + change_id: Optional[pebble.ChangeID] + + def __init__(self, container: Container, name: str): + self._container = container + self.name = name + self._info: Optional[ops.pebble.CheckInfo] = None + + def __repr__(self): + return f'LazyCheckInfo(name={self.name!r})' + + def __getattr__(self, item: str): + # Note: not called for defined attribute `name`. + self._ensure_loaded() + return getattr(self._info, item) + + def _ensure_loaded(self): + if self._info is not None: + return + self._info = self._container.get_check(self.name) + + @dataclasses.dataclass(frozen=True) class CloudCredential: """Credentials for cloud. diff --git a/ops/pebble.py b/ops/pebble.py index 749711418..3d8b85c45 100644 --- a/ops/pebble.py +++ b/ops/pebble.py @@ -1471,6 +1471,15 @@ class NoticeType(enum.Enum): """Enum of notice types.""" CUSTOM = 'custom' + """A custom notice reported via the Pebble client API or ``pebble notify``. + The key and data fields are provided by the user. The key must be in + the format ``example.com/path`` to ensure well-namespaced notice keys. + """ + + CHANGE_UPDATE = 'change-update' + """Recorded whenever a change's status is updated. The key for change-update + notices is the change ID. + """ class NoticesUsers(enum.Enum): @@ -1483,6 +1492,51 @@ class NoticesUsers(enum.Enum): """ +class ChangeKind(enum.Enum): + """Enum of Pebble Change kinds.""" + + START = 'start' + STOP = 'stop' + RESTART = 'restart' + REPLAN = 'replan' + EXEC = 'exec' + RECOVER_CHECK = 'recover-check' + PERFORM_CHECK = 'perform-check' + + +class ChangeStatus(enum.Enum): + """Enum of Pebble Change statuses.""" + + HOLD = 'Hold' + """Hold status means the task should not run for the moment, perhaps as a + consequence of an error on another task.""" + + DO = 'Do' + """Do status means the change or task is ready to start.""" + + DOING = 'Doing' + """Doing status means the change or task is running or an attempt was made to run it.""" + + DONE = 'Done' + """Done status means the change or task was accomplished successfully.""" + + ABORT = 'Abort' + """Abort status means the task should stop doing its activities and then undo.""" + + UNDO = 'Undo' + """Undo status means the change or task should be undone, probably due to an error.""" + + UNDOING = 'Undoing' + """UndoingStatus means the change or task is being undone or an attempt was made to undo it.""" + + ERROR = 'Error' + """Error status means the change or task has errored out while running or being undone.""" + + WAIT = 'Wait' + """Wait status means the task was accomplished successfully but some + external event needs to happen before work can progress further.""" + + @dataclasses.dataclass(frozen=True) class Notice: """Information about a single notice.""" diff --git a/ops/testing.py b/ops/testing.py index 8e00cbef9..bf34016f2 100644 --- a/ops/testing.py +++ b/ops/testing.py @@ -1218,8 +1218,24 @@ def pebble_notify( id, new_or_repeated = client._notify(type, key, data=data, repeat_after=repeat_after) - if self._charm is not None and type == pebble.NoticeType.CUSTOM and new_or_repeated: - self.charm.on[container_name].pebble_custom_notice.emit(container, id, type.value, key) + if self._charm is not None and new_or_repeated: + if type == pebble.NoticeType.CUSTOM: + self.charm.on[container_name].pebble_custom_notice.emit( + container, id, type.value, key + ) + elif type == pebble.NoticeType.CHANGE_UPDATE and data: + kind = pebble.ChangeKind(data.get('kind')) + status = pebble.ChangeStatus(client.get_change(key).status) + if kind == pebble.ChangeKind.PERFORM_CHECK and status == pebble.ChangeStatus.ERROR: + self.charm.on[container_name].pebble_check_failed.emit( + container, data['check-name'] + ) + elif ( + kind == pebble.ChangeKind.RECOVER_CHECK and status == pebble.ChangeStatus.DONE + ): + self.charm.on[container_name].pebble_check_recovered.emit( + container, data['check-name'] + ) return id @@ -3023,6 +3039,8 @@ def __init__(self, backend: _TestingModelBackend, container_root: pathlib.Path): self._exec_handlers: Dict[Tuple[str, ...], ExecHandler] = {} self._notices: Dict[Tuple[str, str], pebble.Notice] = {} self._last_notice_id = 0 + self._changes: Dict[str, pebble.Change] = {} + self._check_infos: Dict[str, pebble.CheckInfo] = {} def _handle_exec(self, command_prefix: Sequence[str], handler: ExecHandler): prefix = tuple(command_prefix) @@ -3056,8 +3074,13 @@ def get_changes( ) -> List[pebble.Change]: raise NotImplementedError(self.get_changes) - def get_change(self, change_id: pebble.ChangeID) -> pebble.Change: - raise NotImplementedError(self.get_change) + def get_change(self, change_id: str) -> pebble.Change: + self._check_connection() + try: + return self._changes[change_id] + except KeyError: + message = f'cannot find change with id "{change_id}"' + raise self._api_error(404, message) from None def abort_change(self, change_id: pebble.ChangeID) -> pebble.Change: raise NotImplementedError(self.abort_change) @@ -3632,8 +3655,16 @@ def send_signal(self, sig: Union[int, str], service_names: Iterable[str]): message = f'cannot send signal to "{first_service}": invalid signal name "{sig}"' raise self._api_error(500, message) from None - def get_checks(self, level=None, names=None): # type:ignore - raise NotImplementedError(self.get_checks) # type:ignore + def get_checks( + self, level: Optional[pebble.CheckLevel] = None, names: Optional[Iterable[str]] = None + ) -> List[pebble.CheckInfo]: + if names is not None: + names = frozenset(names) + return [ + info + for info in self._check_infos.values() + if (level is None or level == info.level) and (names is None or info.name in names) + ] def notify( self, @@ -3658,10 +3689,6 @@ def _notify( Return a tuple of (notice_id, new_or_repeated). """ - if type != pebble.NoticeType.CUSTOM: - message = f'invalid type "{type.value}" (can only add "custom" notices)' - raise self._api_error(400, message) - # The shape of the code below is taken from State.AddNotice in Pebble. now = datetime.datetime.now(tz=datetime.timezone.utc) diff --git a/test/charms/test_main/src/charm.py b/test/charms/test_main/src/charm.py index e987d344f..24c558d19 100755 --- a/test/charms/test_main/src/charm.py +++ b/test/charms/test_main/src/charm.py @@ -58,6 +58,8 @@ def __init__(self, *args: typing.Any): on_collect_metrics=[], on_test_pebble_ready=[], on_test_pebble_custom_notice=[], + on_test_pebble_check_failed=[], + on_test_pebble_check_recovered=[], on_log_critical_action=[], on_log_error_action=[], on_log_warning_action=[], @@ -88,6 +90,10 @@ def __init__(self, *args: typing.Any): self.framework.observe( self.on.test_pebble_custom_notice, self._on_test_pebble_custom_notice ) + self.framework.observe(self.on.test_pebble_check_failed, self._on_test_pebble_check_failed) + self.framework.observe( + self.on.test_pebble_check_recovered, self._on_test_pebble_check_recovered + ) self.framework.observe(self.on.secret_remove, self._on_secret_remove) self.framework.observe(self.on.secret_rotate, self._on_secret_rotate) @@ -197,6 +203,20 @@ def _on_test_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent): self._stored.observed_event_types.append(type(event).__name__) self._stored.test_pebble_custom_notice_data = event.snapshot() + def _on_test_pebble_check_failed(self, event: ops.PebbleCheckFailedEvent): + assert event.workload is not None, 'workload events must have a reference to a container' + assert isinstance(event.info, ops.LazyCheckInfo) + self._stored.on_test_pebble_check_failed.append(type(event).__name__) + self._stored.observed_event_types.append(type(event).__name__) + self._stored.test_pebble_check_failed_data = event.snapshot() + + def _on_test_pebble_check_recovered(self, event: ops.PebbleCheckRecoveredEvent): + assert event.workload is not None, 'workload events must have a reference to a container' + assert isinstance(event.info, ops.LazyCheckInfo) + self._stored.on_test_pebble_check_recovered.append(type(event).__name__) + self._stored.observed_event_types.append(type(event).__name__) + self._stored.test_pebble_check_recovered_data = event.snapshot() + def _on_start_action(self, event: ops.ActionEvent): assert ( event.handle.kind == 'start_action' diff --git a/test/test_charm.py b/test/test_charm.py index 1e81ea219..0b825a6ac 100644 --- a/test/test_charm.py +++ b/test/test_charm.py @@ -323,7 +323,37 @@ def _on_stor4_attach(self, event: ops.StorageAttachedEvent): ] -def test_workload_events(request: pytest.FixtureRequest): +def test_workload_events(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch): + def mock_change(self: ops.pebble.Client, change_id: str): + return ops.pebble.Change.from_dict({ + 'id': ops.pebble.ChangeID(change_id), + 'kind': 'recover-check', + 'ready': False, + 'spawn-time': '2021-01-28T14:37:02.247202105+13:00', + 'status': 'Error', + 'summary': 'Recovering check "test"', + }) + + monkeypatch.setattr(ops.pebble.Client, 'get_change', mock_change) + + def mock_check_info( + self: ops.pebble.Client, + level: typing.Optional[ops.pebble.CheckLevel] = None, + names: typing.Optional[typing.Iterable[str]] = None, + ): + assert names is not None + names = list(names) + return [ + ops.pebble.CheckInfo.from_dict({ + 'name': names[0], + 'status': ops.pebble.CheckStatus.DOWN, + 'failures': 3, + 'threshold': 3, + }) + ] + + monkeypatch.setattr(ops.pebble.Client, 'get_checks', mock_check_info) + class MyCharm(ops.CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) @@ -335,6 +365,12 @@ def __init__(self, framework: ops.Framework): self.on[workload].pebble_custom_notice, self.on_any_pebble_custom_notice, ) + self.framework.observe( + self.on[workload].pebble_check_failed, self.on_any_pebble_check_event + ) + self.framework.observe( + self.on[workload].pebble_check_recovered, self.on_any_pebble_check_event + ) def on_any_pebble_ready(self, event: ops.PebbleReadyEvent): self.seen.append(type(event).__name__) @@ -342,6 +378,14 @@ def on_any_pebble_ready(self, event: ops.PebbleReadyEvent): def on_any_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent): self.seen.append(type(event).__name__) + def on_any_pebble_check_event( + self, event: typing.Union[ops.PebbleCheckFailedEvent, ops.PebbleCheckRecoveredEvent] + ): + self.seen.append(type(event).__name__) + info = event.info + assert info.name == 'test' + assert info.status == ops.pebble.CheckStatus.DOWN + # language=YAML meta = ops.CharmMeta.from_yaml( metadata=""" @@ -371,11 +415,20 @@ def on_any_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent): charm.framework.model.unit.get_container('containerb'), '2', 'custom', 'y' ) + charm.on['container-a'].pebble_check_failed.emit( + charm.framework.model.unit.get_container('container-a'), 'test' + ) + charm.on['containerb'].pebble_check_recovered.emit( + charm.framework.model.unit.get_container('containerb'), 'test' + ) + assert charm.seen == [ 'PebbleReadyEvent', 'PebbleReadyEvent', 'PebbleCustomNoticeEvent', 'PebbleCustomNoticeEvent', + 'PebbleCheckFailedEvent', + 'PebbleCheckRecoveredEvent', ] diff --git a/test/test_main.py b/test/test_main.py index 48cfbbc46..a4765b288 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -74,6 +74,7 @@ def __init__( secret_id: typing.Optional[str] = None, secret_label: typing.Optional[str] = None, secret_revision: typing.Optional[str] = None, + check_name: typing.Optional[str] = None, ): self.event_type = event_type self.event_name = event_name @@ -91,6 +92,7 @@ def __init__( self.secret_id = secret_id self.secret_label = secret_label self.secret_revision = secret_revision + self.check_name = check_name @patch('ops.main.setup_root_logging', new=lambda *a, **kw: None) # type: ignore @@ -452,6 +454,9 @@ def _simulate_event(self, fake_script: FakeScript, event_spec: EventSpec): 'JUJU_NOTICE_TYPE': event_spec.notice_type, 'JUJU_NOTICE_KEY': event_spec.notice_key, }) + if issubclass(event_spec.event_type, ops.charm.PebbleCheckEvent): + assert event_spec.check_name is not None + env['JUJU_PEBBLE_CHECK_NAME'] = event_spec.check_name if issubclass(event_spec.event_type, ops.ActionEvent): event_filename = event_spec.event_name[: -len('_action')].replace('_', '-') assert event_spec.env_var is not None @@ -683,6 +688,30 @@ def test_multiple_events_handled(self, fake_script: FakeScript): 'notice_key': 'example.com/a', }, ), + ( + EventSpec( + ops.PebbleCheckFailedEvent, + 'test_pebble_check_failed', + workload_name='test', + check_name='http-check', + ), + { + 'container_name': 'test', + 'check_name': 'http-check', + }, + ), + ( + EventSpec( + ops.PebbleCheckRecoveredEvent, + 'test_pebble_check_recovered', + workload_name='test', + check_name='http-check', + ), + { + 'container_name': 'test', + 'check_name': 'http-check', + }, + ), ( EventSpec( ops.SecretChangedEvent, @@ -995,6 +1024,7 @@ def _call_event( fi """, ) + fake_script.write( 'storage-list', """ diff --git a/test/test_testing.py b/test/test_testing.py index edcd32fc0..0ae1b46df 100644 --- a/test/test_testing.py +++ b/test/test_testing.py @@ -3612,6 +3612,12 @@ def observe_container_events(self, container_name: str): self.framework.observe( self.on[container_name].pebble_custom_notice, self._on_pebble_custom_notice ) + self.framework.observe( + self.on[container_name].pebble_check_failed, self._on_pebble_check_failed + ) + self.framework.observe( + self.on[container_name].pebble_check_recovered, self._on_pebble_check_recovered + ) def _on_pebble_ready(self, event: ops.PebbleReadyEvent): self.changes.append({ @@ -3633,6 +3639,20 @@ def _on_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent): 'notice_key': event.notice.key, }) + def _on_pebble_check_failed(self, event: ops.PebbleCheckFailedEvent): + self.changes.append({ + 'name': 'pebble-check-failed', + 'container': event.workload.name, + 'check_name': event.info.name, + }) + + def _on_pebble_check_recovered(self, event: ops.PebbleCheckRecoveredEvent): + self.changes.append({ + 'name': 'pebble-check-recovered', + 'container': event.workload.name, + 'check_name': event.info.name, + }) + def get_public_methods(obj: object): """Get the public attributes of obj to compare to another object.""" @@ -6813,6 +6833,48 @@ def _on_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent): assert id != '' assert num_notices == 0 + def test_check_failed(self, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch): + harness = ops.testing.Harness( + ContainerEventCharm, + meta=""" + name: notifier + containers: + foo: + resource: foo-image + """, + ) + request.addfinalizer(harness.cleanup) + harness.set_can_connect('foo', True) + harness.begin() + harness.charm.observe_container_events('foo') + + def get_change(_: ops.pebble.Client, change_id: str): + return ops.pebble.Change.from_dict({ + 'id': change_id, + 'kind': pebble.ChangeKind.PERFORM_CHECK.value, + 'summary': '', + 'status': pebble.ChangeStatus.ERROR.value, + 'ready': False, + 'spawn-time': '2021-02-10T04:36:22.118970777Z', + }) + + monkeypatch.setattr(ops.testing._TestingPebbleClient, 'get_change', get_change) + harness.pebble_notify( + 'foo', + '123', + type=pebble.NoticeType.CHANGE_UPDATE, + data={'kind': 'perform-check', 'check-name': 'http-check'}, + ) + + expected_changes = [ + { + 'name': 'pebble-check-failed', + 'container': 'foo', + 'check_name': 'http-check', + } + ] + assert harness.charm.changes == expected_changes + class PebbleNoticesMixin: def test_get_notice_by_id(self, client: PebbleClientType):