Repository: canonical/operator
Pull request #1282 (2024-07-06 01:48:46)

chore: bump certifi from 2024.2.2 to 2024.7.4 in /docs
Bumps [certifi](https://github.com/certifi/python-certifi) from 2024.2.2 to 2024.7.4.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/certifi/python-certifi/commit/bd8153872e9c6fc98f4023df9c2deaffea2fa463"><code>bd81538</code></a> 2024.07.04 (<a href="https://redirect.github.com/certifi/python-certifi/issues/295">#295</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/06a2cbf21f345563dde6c28b60e29d57e9b210b3"><code>06a2cbf</code></a> Bump peter-evans/create-pull-request from 6.0.5 to 6.1.0 (<a href="https://redirect.github.com/certifi/python-certifi/issues/294">#294</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/13bba02b72bac97c432c277158bc04b4d2a6bc23"><code>13bba02</code></a> Bump actions/checkout from 4.1.6 to 4.1.7 (<a href="https://redirect.github.com/certifi/python-certifi/issues/293">#293</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/e8abcd0e62b334c164b95d49fcabdc9ecbca0554"><code>e8abcd0</code></a> Bump pypa/gh-action-pypi-publish from 1.8.14 to 1.9.0 (<a href="https://redirect.github.com/certifi/python-certifi/issues/292">#292</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/124f4adf171e15cd9a91a8b6e0325ecc97be8fe1"><code>124f4ad</code></a> 2024.06.02 (<a href="https://redirect.github.com/certifi/python-certifi/issues/291">#291</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/c2196ce5d6ee675b27755a19948480a7823e2c6a"><code>c2196ce</code></a> --- (<a href="https://redirect.github.com/certifi/python-certifi/issues/290">#290</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/fefdeec7588ff1c05214b85a552afcad5fdb51b2"><code>fefdeec</code></a> Bump actions/checkout from 4.1.4 to 4.1.5 (<a href="https://redirect.github.com/certifi/python-certifi/issues/289">#289</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/3c5fb1560b826a7f83f1f9750173ff766492c9cf"><code>3c5fb15</code></a> Bump actions/download-artifact from 4.1.6 to 4.1.7 (<a href="https://redirect.github.com/certifi/python-certifi/issues/286">#286</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/4a9569a3eb58db8548536fc16c5c5c7af946a5b1"><code>4a9569a</code></a> Bump actions/checkout from 4.1.2 to 4.1.4 (<a href="https://redirect.github.com/certifi/python-certifi/issues/287">#287</a>)</li>
<li><a href="https://github.com/certifi/python-certifi/commit/1fc808626a895a916b1e4c2b63abae6c5eafdbe3"><code>1fc8086</code></a> Bump peter-evans/create-pull-request from 6.0.4 to 6.0.5 (<a href="https://redirect.github.com/certifi/python-certifi/issues/288">#288</a>)</li>
<li>Additional commits viewable in <a href="https://github.com/certifi/python-certifi/compare/2024.02.02...2024.07.04">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=certifi&package-manager=pip&previous-version=2024.2.2&new-version=2024.7.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/canonical/operator/network/alerts).

</details>


Patch:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 44cc6ed..e4ea683 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -17,7 +17,7 @@ bracex==2.4
     # via wcmatch
 canonical-sphinx-extensions==0.0.20
     # via ops (pyproject.toml)
-certifi==2024.2.2
+certifi==2024.7.4
     # via requests
 charset-normalizer==3.3.2
     # via requests
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..de9b93e 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -1953,9 +1953,12 @@ class ErrorStatus(StatusBase):
 
 @StatusBase.register
 class ActiveStatus(StatusBase):
-    """The unit is ready.
+    """The unit or application is ready and active.
 
-    The unit believes it is correctly offering all the services it has been asked to offer.
+    Set this status when the charm is correctly offering all the services it
+    has been asked to offer. If the unit or application is operational but
+    some feature (like high availability) is in a degraded state, set "active"
+    with an appropriate message.
     """
 
     name = 'active'
@@ -1966,9 +1969,10 @@ class ActiveStatus(StatusBase):
 
 @StatusBase.register
 class BlockedStatus(StatusBase):
-    """The unit requires manual intervention.
+    """The unit or application requires manual intervention.
 
-    An admin has to manually intervene to unblock the unit and let it proceed.
+    Set this status when an administrator has to manually intervene to unblock
+    the charm to let it proceed.
     """
 
     name = 'blocked'
@@ -1976,12 +1980,13 @@ class BlockedStatus(StatusBase):
 
 @StatusBase.register
 class MaintenanceStatus(StatusBase):
-    """The unit is performing maintenance tasks.
-
-    The unit is not yet providing services, but is actively doing work in preparation
-    for providing those services.  This is a "spinning" state, not an error state. It
-    reflects activity on the unit itself, not on peers or related units.
+    """The unit or application is performing maintenance tasks.
 
+    Set this status when the charm is performing an operation such as
+    ``apt install``, or is waiting for something under its control, such as
+    ``pebble-ready`` or an exec operation in the workload container. In
+    contrast to :class:`WaitingStatus`, "maintenance" reflects activity on
+    this unit or charm, not on peers or related units.
     """
 
     name = 'maintenance'
@@ -1989,11 +1994,13 @@ class MaintenanceStatus(StatusBase):
 
 @StatusBase.register
 class WaitingStatus(StatusBase):
-    """A unit is unable to progress.
-
-    The unit is unable to progress to an active state because an application with which
-    it is integrated is not running.
+    """The unit or application is waiting on a charm it's integrated with.
 
+    Set this status when waiting on a charm this is integrated with. For
+    example, a web app charm would set "waiting" status when it is integrated
+    with a database charm that is not ready yet (it might be creating a
+    database). In contrast to :class:`MaintenanceStatus`, "waiting" reflects
+    activity on related units, not on this unit or charm.
     """
 
     name = 'waiting'
@@ -3792,6 +3799,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/.github/workflows/framework-tests.yaml b/.github/workflows/framework-tests.yaml
index 820c04c..fe668dc 100644
--- a/.github/workflows/framework-tests.yaml
+++ b/.github/workflows/framework-tests.yaml
@@ -75,10 +75,10 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
 
-      - name: Set up Go 1.20
+      - name: Set up Go
         uses: actions/setup-go@v5
         with:
-          go-version: "1.20"
+          go-version: "1.22"
 
       - name: Install tox
         run: pip install tox~=4.2
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):