Repository: canonical/operator
Pull request #1281 (2024-07-03 06:15:10)

feat: add support for Pebble check-failed and check-recovered events
Two new event types are supported (actually getting these events will also require a Juju change):

* `PebbleChangeFailedEvent` - emitted when a Pebble check in a container hits the failure threshold
* `PebbleChangeRecoveredEvent` - emitted when a Pebble check in a container passes after previously hitting the failure threshold

Each event type has a single new attribute (in addition to those inherited from `WorkloadEvent`):

* `info` - this is a shortcut to Pebble's `get_check` method (not otherwise accessible from the charm), but will only query Pebble if more than the `name` is requested, and will cache the result on the event instance.

Two new enums are added to Pebble, to make it easier to work with these events, particularly with Harness:

* `ChangeStatus` - the possible values for `ops.pebble.Change.status` (note that this is the full list, but we only expect charms to see Doing, Done, and Error for now).
* `ChangeKind` - the possible values for `ops.pebble.Change.kind` (again, this is the current full list, but we only expect charms to see perform-check and recover-check at the moment).

`Harness.pebble_notify` is extended to support emitting the two new events. This requires implementation knowledge (that the check events are triggered by a Pebble notice of type change-updated), but provides minimal support for testing with Harness, and will be supplemented by user-friendly testing in Scenario.

The testing Pebble backend gains an implementation for `get_change` and `get_checks`. These are not used directly here, but will be used in Scenario, and it makes more sense to have the implementation in ops.

This is the ops portion of [OP046](https://docs.google.com/document/d/13ItH8l5ZSmmv9WqZXpqjV8EifZU5e3zxINiNLubnC68/edit). It requires a [corresponding change to Pebble](https://github.com/canonical/pebble/pull/444) and [change to Juju](https://github.com/juju/juju/pull/17655).

tonyandrewmeyer: This is not particularly usable until the Juju and Pebble changes land, but it should be ok to review now (but not merge: we need to wait in case any changes in the Juju/Pebble side have impacts here).

Patch:
diff --git a/ops/__init__.py b/ops/__init__.py
index c74ba25..c4b0d95 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -65,6 +65,9 @@ __all__ = [  # noqa: RUF022 `__all__` is not sorted
     'LeaderSettingsChangedEvent',
     'MetadataLinks',
     'PayloadMeta',
+    'PebbleCheckEvent',
+    'PebbleCheckFailedEvent',
+    'PebbleCheckRecoveredEvent',
     'PebbleCustomNoticeEvent',
     'PebbleNoticeEvent',
     'PebbleReadyEvent',
@@ -132,6 +135,7 @@ __all__ = [  # noqa: RUF022 `__all__` is not sorted
     'ContainerMapping',
     'ErrorStatus',
     'InvalidStatusError',
+    'LazyCheckInfo',
     'LazyMapping',
     'LazyNotice',
     'MaintenanceStatus',
@@ -204,6 +208,9 @@ from .charm import (
     LeaderSettingsChangedEvent,
     MetadataLinks,
     PayloadMeta,
+    PebbleCheckEvent,
+    PebbleCheckFailedEvent,
+    PebbleCheckRecoveredEvent,
     PebbleCustomNoticeEvent,
     PebbleNoticeEvent,
     PebbleReadyEvent,
@@ -275,6 +282,7 @@ from .model import (
     ContainerMapping,
     ErrorStatus,
     InvalidStatusError,
+    LazyCheckInfo,
     LazyMapping,
     LazyNotice,
     MaintenanceStatus,
diff --git a/ops/charm.py b/ops/charm.py
index 9a1d79a..f1ab032 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 @@ class CharmBase(Object):
             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 d94a19f..792972c 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 040ba09..282996b 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -3792,6 +3792,40 @@ class LazyNotice:
         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 7497114..3d8b85c 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."""


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index 8e00cbe..bf34016 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -1218,8 +1218,24 @@ class Harness(Generic[CharmType]):
 
         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 @@ class _TestingPebbleClient:
         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 @@ class _TestingPebbleClient:
     ) -> 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 @@ class _TestingPebbleClient:
             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 @@ class _TestingPebbleClient:
 
         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 e987d34..24c558d 100755
--- a/test/charms/test_main/src/charm.py
+++ b/test/charms/test_main/src/charm.py
@@ -58,6 +58,8 @@ class Charm(ops.CharmBase):
             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 @@ class Charm(ops.CharmBase):
         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 @@ class Charm(ops.CharmBase):
         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 1e81ea2..0b825a6 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -323,7 +323,37 @@ storage:
     ]
 
 
-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 test_workload_events(request: pytest.FixtureRequest):
                     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 test_workload_events(request: pytest.FixtureRequest):
         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 @@ containers:
         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 48cfbbc..a4765b2 100644
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -74,6 +74,7 @@ class EventSpec:
         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 @@ class EventSpec:
         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 @@ class _TestMain(abc.ABC):
                 '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 @@ class _TestMain(abc.ABC):
                     '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 @@ class TestMainWithNoDispatch(_TestMain):
             fi
             """,
         )
+
         fake_script.write(
             'storage-list',
             """
diff --git a/test/test_testing.py b/test/test_testing.py
index edcd32f..0ae1b46 100644
--- a/test/test_testing.py
+++ b/test/test_testing.py
@@ -3612,6 +3612,12 @@ class ContainerEventCharm(RecordingCharm):
         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 @@ class ContainerEventCharm(RecordingCharm):
             '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 @@ class TestNotify:
         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):