Repository: canonical/operator
Pull request #1086 (2023-12-07 03:15:09)

feat: support for Pebble Notices
This PR implements Ops support for Pebble Notices (described in [JU048](https://docs.google.com/document/d/16PJ85fefalQd7JbWSxkRWn0Ye-Hs8S1yE99eW7pk8fA/edit), with the user ID details in [OP042](https://docs.google.com/document/d/1tQwUxz-rV-NjH-UodDbSDhGcMJGfD3OSoTnBLI9aoGU/edit)).

This adds a new `PebbleNoticeEvent` base event type, with `PebbleCustomNoticeEvent` being the first concrete type -- we'll have more later, such as for `warning` and `change-update` notices. These events have a `notice` attribute which is a `LazyNotice` instance: if you only access `id` or `type` or `key` it won't require a call to Pebble (I think only accessing `key` will be common in charms).

In addition, this adds `get_notice` and `get_notices` methods to both `pebble.Client` and `model.Container`. These return objects of the new `Notice` type.

The `timeconv.parse_duration` helper function is needed by `Notice.from_dict`, to parse Go's [`time.Duration.String`](https://pkg.go.dev/time#Duration.String) format that Pebble uses for the `repeat-after` and `expire-after` fields. Most of the test cases for this were taken from [Go's test cases](https://cs.opensource.google/go/go/+/refs/tags/go1.21.5:src/time/time_test.go;l=891).

This PR does *not* include `Harness` support for Pebble Notices. I plan to add that in a follow-up PR soon (using a `Harness.pebble_notify` function and implementing the `get_notice` and `get_notices` test backend functions).


benhoyt: @tonyandrewmeyer:

> Normally with a significant change like this I like to install it locally and play around with it a bit to get a more holistic feel than from a series of diffs. I haven't done that since (as you mentioned in ~ops-library-development) it's complicated to do that at the moment since this hasn't landed in a Juju release. I generally feel that given how much work there has been on the spec, it's probably unnecessary, but if there is some way to (maybe partially just with pebble but not juju support?) do that, it'd be nice.

Yeah, good call. I'm testing with the following charm (you can replace `src/charm.py` in the test charm [in this Juju PR](https://github.com/juju/juju/pull/16428) with the following code):

```python
#!/usr/bin/env python3
"""Charm to test Pebble Notices."""

import logging

import ops

logger = logging.getLogger(__name__)


class PebbleNoticesCharm(ops.CharmBase):
    """Charm to test Pebble Notices."""

    def __init__(self, *args):
        super().__init__(*args)
        self.framework.observe(self.on["redis"].pebble_ready, self._on_pebble_ready)
        self.framework.observe(self.on["redis"].pebble_custom_notice, self._on_custom_notice)

    def _on_pebble_ready(self, event):
        self.unit.status = ops.ActiveStatus()

    def _on_custom_notice(self, event):
        logger.info(
            f"_on_custom_notice: id={event.notice.id} type={event.notice.type} key={event.notice.key} occurrences={event.notice.occurrences}"
        )

        # Don't include the (arbitrary) ID in the status message
        self.unit.status = ops.MaintenanceStatus(
            f"notice type={event.notice.type} key={event.notice.key}"
        )

        container = event.workload
        notices = container.get_notices(types=[ops.pebble.NoticeType.CUSTOM])
        for i, notice in enumerate(notices):
            logger.info(f"get_notices {i}: {notice}")


if __name__ == "__main__":
    ops.main(PebbleNoticesCharm)
```

And then I `charmcraft pack` and deploy as per the "QA steps" [on that PR](https://github.com/juju/juju/pull/16428), but between packing and deploying, use this little script to inject my local copy of ops (with the changes in *this* PR):

```bash
#!/usr/bin/env bash

if [ "$#" -lt 2 ]
then
	echo "Inject local copy of Python Operator Framework source into charm"
	echo
    echo "usage: inject-ops.sh file.charm /path/to/ops/dir" >&2
    exit 1
fi

if [ ! -f "$2/framework.py" ]; then
    echo "$2/framework.py not found; arg 2 should be path to 'ops' directory"
    exit 1
fi

set -ex

mkdir inject-ops-tmp
unzip -q $1 -d inject-ops-tmp
rm -rf inject-ops-tmp/venv/ops
cp -r $2 inject-ops-tmp/venv/ops
cd inject-ops-tmp
zip -q -r ../inject-ops-new.charm .
cd ..
rm -rf inject-ops-tmp
rm $1
mv inject-ops-new.charm $1
```

I've just tested using this and it works end to end. Hope that helps.
tonyandrewmeyer: > Yeah, good call. I'm testing with the following charm (you can replace `src/charm.py` in the test charm [in this Juju PR](https://github.com/juju/juju/pull/16428) with the following code):

Thanks! I played around with it a bit and all still seems good, as expected :)

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index dd9b2c8..5be0036 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,10 @@
+# 2.10.0
+
+* Added support for Pebble Notices (`PebbleCustomNoticeEvent`, `get_notices`, and so on)
+
 # 2.9.0
 
-* Added log target support to `ops.pebble` layers and plans.
+* Added log target support to `ops.pebble` layers and plans
 * Added `Harness.run_action()`, `testing.ActionOutput`, and `testing.ActionFailed`
 
 # 2.8.0
diff --git a/docs/conf.py b/docs/conf.py
index b7b4c7c..1e9bedf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -73,28 +73,30 @@ nitpicky = True
 # domain name if present. Example entries would be ('py:func', 'int') or
 # ('envvar', 'LD_LIBRARY_PATH').
 nitpick_ignore = [
-    ('py:class', 'ops.model._AddressDict'),
+    # Please keep this list sorted alphabetically.
     ('py:class', '_ChangeDict'),
     ('py:class', '_CheckInfoDict'),
-    ('py:class', 'ops.model._ConfigOption'),
-    ('py:class', 'ops.pebble._FileLikeIO'),
     ('py:class', '_FileInfoDict'),
-    ('py:class', 'ops.pebble._IOSource'),
-    ('py:class', 'ops.model._NetworkDict'),
+    ('py:class', '_NoticeDict'),
     ('py:class', '_ProgressDict'),
     ('py:class', '_Readable'),
     ('py:class', '_RelationMetaDict'),
     ('py:class', '_ResourceMetaDict'),
-    ('py:class', 'ops.pebble._ServiceInfoDict'),
     ('py:class', '_StorageMetaDict'),
-    ('py:class', 'ops.pebble._SystemInfoDict'),
     ('py:class', '_TaskDict'),
     ('py:class', '_TextOrBinaryIO'),
     ('py:class', '_WarningDict'),
-    ('py:class', 'ops.pebble._WebSocket'),
     ('py:class', '_Writeable'),
+    ('py:class', 'ops.model._AddressDict'),
+    ('py:class', 'ops.model._ConfigOption'),
     ('py:class', 'ops.model._ModelBackend'),
     ('py:class', 'ops.model._ModelCache'),
+    ('py:class', 'ops.model._NetworkDict'),
+    ('py:class', 'ops.pebble._FileLikeIO'),
+    ('py:class', 'ops.pebble._IOSource'),
+    ('py:class', 'ops.pebble._ServiceInfoDict'),
+    ('py:class', 'ops.pebble._SystemInfoDict'),
+    ('py:class', 'ops.pebble._WebSocket'),
     ('py:class', 'ops.storage.JujuStorage'),
     ('py:class', 'ops.storage.SQLiteStorage'),
     ('py:class', 'ops.testing.CharmType'),
diff --git a/ops/__init__.py b/ops/__init__.py
index 17b0352..99c8c39 100644
--- a/ops/__init__.py
+++ b/ops/__init__.py
@@ -62,6 +62,8 @@ __all__ = [
     'LeaderElectedEvent',
     'LeaderSettingsChangedEvent',
     'PayloadMeta',
+    'PebbleCustomNoticeEvent',
+    'PebbleNoticeEvent',
     'PebbleReadyEvent',
     'PostSeriesUpgradeEvent',
     'PreSeriesUpgradeEvent',
@@ -129,6 +131,7 @@ __all__ = [
     'ErrorStatus',
     'InvalidStatusError',
     'LazyMapping',
+    'LazyNotice',
     'MaintenanceStatus',
     'Model',
     'ModelError',
@@ -195,6 +198,8 @@ from .charm import (  # noqa: F401
     LeaderElectedEvent,
     LeaderSettingsChangedEvent,
     PayloadMeta,
+    PebbleCustomNoticeEvent,
+    PebbleNoticeEvent,
     PebbleReadyEvent,
     PostSeriesUpgradeEvent,
     PreSeriesUpgradeEvent,
@@ -263,6 +268,7 @@ from .model import (  # noqa: F401 E402
     ErrorStatus,
     InvalidStatusError,
     LazyMapping,
+    LazyNotice,
     MaintenanceStatus,
     Model,
     ModelError,
diff --git a/ops/_private/timeconv.py b/ops/_private/timeconv.py
index d7f0b24..ceb67f3 100644
--- a/ops/_private/timeconv.py
+++ b/ops/_private/timeconv.py
@@ -16,6 +16,7 @@
 
 import datetime
 import re
+from typing import Union
 
 # Matches yyyy-mm-ddTHH:MM:SS(.sss)ZZZ
 _TIMESTAMP_RE = re.compile(
@@ -24,6 +25,9 @@ _TIMESTAMP_RE = re.compile(
 # Matches [-+]HH:MM
 _TIMEOFFSET_RE = re.compile(r'([-+])(\d{2}):(\d{2})')
 
+# Matches n.n<unit> (allow U+00B5 micro symbol as well as U+03BC Greek letter mu)
+_DURATION_RE = re.compile(r'([0-9.]+)([a-zµμ]+)')
+
 
 def parse_rfc3339(s: str) -> datetime.datetime:
     """Parse an RFC3339 timestamp.
@@ -57,3 +61,69 @@ def parse_rfc3339(s: str) -> datetime.datetime:
 
     return datetime.datetime(int(y), int(m), int(d), int(hh), int(mm), int(ss),
                              microsecond=microsecond, tzinfo=tz)
+
+
+def parse_duration(s: str) -> datetime.timedelta:
+    """Parse a formatted Go duration.
+
+    This is similar to Go's time.ParseDuration function: it parses the output
+    of Go's time.Duration.String method, for example "72h3m0.5s". Units are
+    required after each number part, and valid units are "ns", "us", "µs",
+    "ms", "s", "m", and "h".
+    """
+    negative = False
+    if s and s[0] in '+-':
+        negative = s[0] == '-'
+        s = s[1:]
+
+    if s == '0':  # no unit is only okay for "0", "+0", and "-0"
+        return datetime.timedelta(seconds=0)
+
+    matches = list(_DURATION_RE.finditer(s))
+    if not matches:
+        raise ValueError('invalid duration: no number-unit groups')
+    if matches[0].start() != 0 or matches[-1].end() != len(s):
+        raise ValueError('invalid duration: extra input at start or end')
+
+    hours, minutes, seconds, milliseconds, microseconds = 0, 0, 0, 0, 0
+    for match in matches:
+        number, unit = match.groups()
+        if unit == 'ns':
+            microseconds += _duration_number(number) / 1000
+        elif unit in ('us', 'µs', 'μs'):  # U+00B5 (micro symbol), U+03BC (Greek letter mu)
+            microseconds += _duration_number(number)
+        elif unit == 'ms':
+            milliseconds += _duration_number(number)
+        elif unit == 's':
+            seconds += _duration_number(number)
+        elif unit == 'm':
+            minutes += _duration_number(number)
+        elif unit == 'h':
+            hours += _duration_number(number)
+        else:
+            raise ValueError(f'invalid duration: invalid unit {unit!r}')
+
+    duration = datetime.timedelta(
+        hours=hours,
+        minutes=minutes,
+        seconds=seconds,
+        milliseconds=milliseconds,
+        microseconds=microseconds,
+    )
+
+    return -duration if negative else duration
+
+
+def _duration_number(s: str) -> Union[int, float]:
+    """Try converting s to int; if that fails, try float; otherwise raise ValueError.
+
+    This is to preserve precision where possible.
+    """
+    try:
+        try:
+            return int(s)
+        except ValueError:
+            return float(s)
+    except ValueError:
+        # Same exception type, but a slightly more specific error message
+        raise ValueError(f'invalid duration: {s!r} is not a valid float') from None
diff --git a/ops/charm.py b/ops/charm.py
index 0407581..e0721eb 100644
--- a/ops/charm.py
+++ b/ops/charm.py
@@ -711,7 +711,7 @@ class WorkloadEvent(HookEvent):
 
 
 class PebbleReadyEvent(WorkloadEvent):
-    """Event triggered when pebble is ready for a workload.
+    """Event triggered when Pebble is ready for a workload.
 
     This event is triggered when the Pebble process for a workload/container
     starts up, allowing the charm to configure how services should be launched.
@@ -723,6 +723,45 @@ class PebbleReadyEvent(WorkloadEvent):
     """
 
 
+class PebbleNoticeEvent(WorkloadEvent):
+    """Base class for Pebble notice events (each notice type is a subclass)."""
+
+    notice: model.LazyNotice
+    """Provide access to the event notice's details."""
+
+    def __init__(self, handle: 'Handle', workload: 'model.Container',
+                 notice_id: str, notice_type: str, notice_key: str):
+        super().__init__(handle, workload)
+        self.notice = model.LazyNotice(workload, notice_id, notice_type, notice_key)
+
+    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['notice_id'] = self.notice.id
+        d['notice_type'] = (self.notice.type if isinstance(self.notice.type, str)
+                            else self.notice.type.value)
+        d['notice_key'] = self.notice.key
+        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.
+        """
+        super().restore(snapshot)
+        notice_id = snapshot.pop('notice_id')
+        notice_type = snapshot.pop('notice_type')
+        notice_key = snapshot.pop('notice_key')
+        self.notice = model.LazyNotice(self.workload, notice_id, notice_type, notice_key)
+
+
+class PebbleCustomNoticeEvent(PebbleNoticeEvent):
+    """Event triggered when a Pebble notice of type "custom" is created or repeats."""
+
+
 class SecretEvent(HookEvent):
     """Base class for all secret events."""
 
@@ -1103,6 +1142,7 @@ class CharmBase(Object):
         for container_name in self.framework.meta.containers:
             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)
 
     @property
     def app(self) -> model.Application:
diff --git a/ops/main.py b/ops/main.py
index 40948d4..9cfb720 100644
--- a/ops/main.py
+++ b/ops/main.py
@@ -153,7 +153,13 @@ def _get_event_args(charm: 'ops.charm.CharmBase',
     if issubclass(event_type, ops.charm.WorkloadEvent):
         workload_name = os.environ['JUJU_WORKLOAD_NAME']
         container = model.unit.get_container(workload_name)
-        return [container], {}
+        args: List[Any] = [container]
+        if issubclass(event_type, ops.charm.PebbleNoticeEvent):
+            notice_id = os.environ['JUJU_NOTICE_ID']
+            notice_type = os.environ['JUJU_NOTICE_TYPE']
+            notice_key = os.environ['JUJU_NOTICE_KEY']
+            args.extend([notice_id, notice_type, notice_key])
+        return args, {}
     elif issubclass(event_type, ops.charm.SecretEvent):
         args: List[Any] = [
             os.environ['JUJU_SECRET_ID'],
diff --git a/ops/model.py b/ops/model.py
index 0f0d281..1c13c43 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -2174,7 +2174,7 @@ class Container:
         """Get status information for a single named service.
 
         Raises:
-            ModelError: if service_name is not found.
+            ModelError: if a service with the given name is not found
         """
         services = self.get_services(service_name)
         if not services:
@@ -2202,7 +2202,7 @@ class Container:
         """Get check information for a single named check.
 
         Raises:
-            ModelError: if ``check_name`` is not found.
+            ModelError: if a check with the given name is not found
         """
         checks = self.get_checks(check_name)
         if not checks:
@@ -2719,6 +2719,41 @@ class Container:
 
         self._pebble.send_signal(sig, service_names)
 
+    def get_notice(self, id: str) -> pebble.Notice:
+        """Get details about a single notice by ID.
+
+        Raises:
+            ModelError: if a notice with the given ID is not found
+        """
+        try:
+            return self._pebble.get_notice(id)
+        except pebble.APIError as e:
+            if e.code == 404:
+                raise ModelError(f'notice {id!r} not found') from e
+            raise
+
+    def get_notices(
+        self,
+        *,
+        select: Optional[pebble.NoticesSelect] = None,
+        user_id: Optional[int] = None,
+        types: Optional[Iterable[Union[pebble.NoticeType, str]]] = None,
+        keys: Optional[Iterable[str]] = None,
+        after: Optional[datetime.datetime] = None,
+    ) -> List[pebble.Notice]:
+        """Query for notices that match all of the provided filters.
+
+        See :meth:`ops.pebble.Client.get_notices` for documentation of the
+        parameters.
+        """
+        return self._pebble.get_notices(
+            select=select,
+            user_id=user_id,
+            types=types,
+            keys=keys,
+            after=after,
+        )
+
     # Define this last to avoid clashes with the imported "pebble" module
     @property
     def pebble(self) -> pebble.Client:
@@ -3489,3 +3524,51 @@ class _ModelBackendValidator:
         if re.search('[,=]', v) is not None:
             raise ModelError(
                 f'metric label values must not contain "," or "=": {label}={value!r}')
+
+
+class LazyNotice:
+    """Provide lazily-loaded access to a Pebble notice's details.
+
+    The attributes provided by this class are the same as those of
+    :class:`ops.pebble.Notice`, however, the notice details are only fetched
+    from Pebble if necessary (and cached on the instance).
+    """
+
+    id: str
+    user_id: Optional[int]
+    type: Union[pebble.NoticeType, str]
+    key: str
+    first_occurred: datetime.datetime
+    last_occurred: datetime.datetime
+    last_repeated: datetime.datetime
+    occurrences: int
+    last_data: Dict[str, str]
+    repeat_after: Optional[datetime.timedelta]
+    expire_after: Optional[datetime.timedelta]
+
+    def __init__(self, container: Container, id: str, type: str, key: str):
+        self._container = container
+        self.id = id
+        try:
+            self.type = pebble.NoticeType(type)
+        except ValueError:
+            self.type = type
+        self.key = key
+
+        self._notice: Optional[ops.pebble.Notice] = None
+
+    def __repr__(self):
+        type_repr = self.type if isinstance(self.type, pebble.NoticeType) else repr(self.type)
+        return f'LazyNotice(id={self.id!r}, type={type_repr}, key={self.key!r})'
+
+    def __getattr__(self, item: str):
+        # Note: not called for defined attributes (id, type, key)
+        self._ensure_loaded()
+        return getattr(self._notice, item)
+
+    def _ensure_loaded(self):
+        if self._notice is not None:
+            return
+        self._notice = self._container.get_notice(self.id)
+        assert self._notice.type == self.type
+        assert self._notice.key == self.key
diff --git a/ops/pebble.py b/ops/pebble.py
index ac4278c..10446a6 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -19,6 +19,7 @@ For a command-line interface for local testing, see test/pebble_cli.py.
 
 import binascii
 import copy
+import dataclasses
 import datetime
 import email.message
 import email.parser
@@ -258,6 +259,20 @@ if TYPE_CHECKING:
                               'expire-after': str,
                               'repeat-after': str})
 
+    _NoticeDict = TypedDict('_NoticeDict', {
+        'id': str,
+        'user-id': NotRequired[Optional[int]],
+        'type': str,
+        'key': str,
+        'first-occurred': str,
+        'last-occurred': str,
+        'last-repeated': str,
+        'occurrences': int,
+        'last-data': NotRequired[Optional[Dict[str, str]]],
+        'repeat-after': NotRequired[str],
+        'expire-after': NotRequired[str],
+    })
+
 
 class _WebSocket(Protocol):
     def connect(self, url: str, socket: socket.socket): ...  # noqa
@@ -1277,6 +1292,90 @@ class CheckInfo:
                 ).format(self=self)
 
 
+class NoticeType(enum.Enum):
+    """Enum of notice types."""
+
+    CUSTOM = 'custom'
+
+
+class NoticesSelect(enum.Enum):
+    """Enum of :meth:`Client.get_notices` ``select`` values."""
+
+    ALL = 'all'
+    """Select notices from all users (any user ID, including public notices).
+
+    This only works for Pebble admins (for example, root).
+    """
+
+
+@dataclasses.dataclass(frozen=True)
+class Notice:
+    """Information about a single notice."""
+
+    id: str
+    """Server-generated unique ID for this notice."""
+
+    user_id: Optional[int]
+    """UID of the user who may view this notice (None means notice is public)."""
+
+    type: Union[NoticeType, str]
+    """Type of the notice."""
+
+    key: str
+    """The notice key, a string that differentiates notices of this type.
+
+    This is in the format ``example.com/path``.
+    """
+
+    first_occurred: datetime.datetime
+    """The first time one of these notices (type and key combination) occurs."""
+
+    last_occurred: datetime.datetime
+    """The last time one of these notices occurred."""
+
+    last_repeated: datetime.datetime
+    """The time this notice was last repeated.
+
+    See Pebble's `Notices documentation <https://github.com/canonical/pebble/#notices>`_
+    for an explanation of what "repeated" means.
+    """
+
+    occurrences: int
+    """The number of times one of these notices has occurred."""
+
+    last_data: Dict[str, str] = dataclasses.field(default_factory=dict)
+    """Additional data captured from the last occurrence of one of these notices."""
+
+    repeat_after: Optional[datetime.timedelta] = None
+    """Minimum time after one of these was last repeated before Pebble will repeat it again."""
+
+    expire_after: Optional[datetime.timedelta] = None
+    """How long since one of these last occurred until Pebble will drop the notice."""
+
+    @classmethod
+    def from_dict(cls, d: '_NoticeDict') -> 'Notice':
+        """Create new Notice object from dict parsed from JSON."""
+        try:
+            notice_type = NoticeType(d['type'])
+        except ValueError:
+            notice_type = d['type']
+        return cls(
+            id=d['id'],
+            user_id=d.get('user-id'),
+            type=notice_type,
+            key=d['key'],
+            first_occurred=timeconv.parse_rfc3339(d['first-occurred']),
+            last_occurred=timeconv.parse_rfc3339(d['last-occurred']),
+            last_repeated=timeconv.parse_rfc3339(d['last-repeated']),
+            occurrences=d['occurrences'],
+            last_data=d.get('last-data') or {},
+            repeat_after=timeconv.parse_duration(d['repeat-after'])
+            if 'repeat-after' in d else None,
+            expire_after=timeconv.parse_duration(d['expire-after'])
+            if 'expire-after' in d else None,
+        )
+
+
 class ExecProcess(Generic[AnyStr]):
     """Represents a process started by :meth:`Client.exec`.
 
@@ -2645,6 +2744,57 @@ class Client:
         resp = self._request('GET', '/v1/checks', query)
         return [CheckInfo.from_dict(info) for info in resp['result']]
 
+    def get_notice(self, id: str) -> Notice:
+        """Get details about a single notice by ID.
+
+        Raises:
+            APIError: if a notice with the given ID is not found (``code`` 404)
+        """
+        resp = self._request('GET', f'/v1/notices/{id}')
+        return Notice.from_dict(resp['result'])
+
+    def get_notices(
+        self,
+        *,
+        select: Optional[NoticesSelect] = None,
+        user_id: Optional[int] = None,
+        types: Optional[Iterable[Union[NoticeType, str]]] = None,
+        keys: Optional[Iterable[str]] = None,
+        after: Optional[datetime.datetime] = None,
+    ) -> List[Notice]:
+        """Query for notices that match all of the provided filters.
+
+        Pebble returns notices that match all of the filters, for example, if
+        called with ``types=[NoticeType.CUSTOM], keys=["example.com/a"]``,
+        Pebble will only return custom notices that also have key "example.com/a".
+
+        If no filters are specified, return notices viewable by the requesting
+        user (notices whose ``user_id`` matches the requester UID as well as
+        public notices).
+
+        Args:
+            select: select which notices to return (instead of returning
+                notices for the current user)
+            user_id: filter for notices for the specified user, including
+                public notices (only works for Pebble admins)
+            types: filter for notices with any of the specified types
+            keys: filter for notices with any of the specified keys
+            after: filter for notices that were last repeated after this time
+        """
+        query: Dict[str, Union[str, List[str]]] = {}
+        if select is not None:
+            query['select'] = select.value
+        if user_id is not None:
+            query['user-id'] = str(user_id)
+        if types is not None:
+            query['types'] = [(t.value if isinstance(t, NoticeType) else t) for t in types]
+        if keys is not None:
+            query['keys'] = list(keys)
+        if after is not None:
+            query['after'] = after.isoformat()
+        resp = self._request('GET', '/v1/notices', query)
+        return [Notice.from_dict(info) for info in resp['result']]
+
 
 class _FilesParser:
     """A limited purpose multi-part parser backed by files for memory efficiency."""


Test patch:
diff --git a/ops/testing.py b/ops/testing.py
index ab288b7..b41a646 100644
--- a/ops/testing.py
+++ b/ops/testing.py
@@ -3259,3 +3259,17 @@ class _TestingPebbleClient:
 
     def get_checks(self, level=None, names=None):  # type:ignore
         raise NotImplementedError(self.get_checks)  # type:ignore
+
+    def get_notice(self, id: str) -> pebble.Notice:
+        raise NotImplementedError(self.get_notice)
+
+    def get_notices(
+        self,
+        *,
+        select: Optional[pebble.NoticesSelect] = None,
+        user_id: Optional[int] = None,
+        types: Optional[Iterable[Union[pebble.NoticeType, str]]] = None,
+        keys: Optional[Iterable[str]] = None,
+        after: Optional[datetime.datetime] = None,
+    ) -> List[pebble.Notice]:
+        raise NotImplementedError(self.get_notices)
diff --git a/test/charms/test_main/src/charm.py b/test/charms/test_main/src/charm.py
index db03907..bd4ea79 100755
--- a/test/charms/test_main/src/charm.py
+++ b/test/charms/test_main/src/charm.py
@@ -57,6 +57,7 @@ class Charm(ops.CharmBase):
             _on_get_model_name_action=[],
             on_collect_metrics=[],
             on_test_pebble_ready=[],
+            on_test_pebble_custom_notice=[],
 
             on_log_critical_action=[],
             on_log_error_action=[],
@@ -88,6 +89,8 @@ class Charm(ops.CharmBase):
         self.framework.observe(self.on.mon_relation_departed, self._on_mon_relation_departed)
         self.framework.observe(self.on.ha_relation_broken, self._on_ha_relation_broken)
         self.framework.observe(self.on.test_pebble_ready, self._on_test_pebble_ready)
+        self.framework.observe(self.on.test_pebble_custom_notice,
+                               self._on_test_pebble_custom_notice)
 
         self.framework.observe(self.on.secret_remove, self._on_secret_remove)
         self.framework.observe(self.on.secret_rotate, self._on_secret_rotate)
@@ -177,6 +180,13 @@ class Charm(ops.CharmBase):
         self._stored.observed_event_types.append(type(event).__name__)
         self._stored.test_pebble_ready_data = event.snapshot()
 
+    def _on_test_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent):
+        assert event.workload is not None
+        assert isinstance(event.notice, ops.LazyNotice)
+        self._stored.on_test_pebble_custom_notice.append(type(event).__name__)
+        self._stored.observed_event_types.append(type(event).__name__)
+        self._stored.test_pebble_custom_notice_data = event.snapshot()
+
     def _on_start_action(self, event: ops.ActionEvent):
         assert event.handle.kind == 'start_action', (
             'event action name cannot be different from the one being handled')
diff --git a/test/test_charm.py b/test/test_charm.py
index 890b99f..88a0689 100644
--- a/test/test_charm.py
+++ b/test/test_charm.py
@@ -329,16 +329,21 @@ storage:
             def __init__(self, *args: typing.Any):
                 super().__init__(*args)
                 self.seen: typing.List[str] = []
-                self.count = 0
                 for workload in ('container-a', 'containerb'):
                     # Hook up relation events to generic handler.
                     self.framework.observe(
                         self.on[workload].pebble_ready,
                         self.on_any_pebble_ready)
+                    self.framework.observe(
+                        self.on[workload].pebble_custom_notice,
+                        self.on_any_pebble_custom_notice,
+                    )
 
             def on_any_pebble_ready(self, event: ops.PebbleReadyEvent):
                 self.seen.append(type(event).__name__)
-                self.count += 1
+
+            def on_any_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent):
+                self.seen.append(type(event).__name__)
 
         # language=YAML
         self.meta = ops.CharmMeta.from_yaml(metadata='''
@@ -358,11 +363,17 @@ containers:
         charm.on['containerb'].pebble_ready.emit(
             charm.framework.model.unit.get_container('containerb'))
 
+        charm.on['container-a'].pebble_custom_notice.emit(
+            charm.framework.model.unit.get_container('container-a'), '1', 'custom', 'x')
+        charm.on['containerb'].pebble_custom_notice.emit(
+            charm.framework.model.unit.get_container('containerb'), '2', 'custom', 'y')
+
         self.assertEqual(charm.seen, [
             'PebbleReadyEvent',
-            'PebbleReadyEvent'
+            'PebbleReadyEvent',
+            'PebbleCustomNoticeEvent',
+            'PebbleCustomNoticeEvent',
         ])
-        self.assertEqual(charm.count, 2)
 
     def test_relations_meta(self):
         # language=YAML
diff --git a/test/test_main.py b/test/test_main.py
index 715c20d..5eae418 100644
--- a/test/test_main.py
+++ b/test/test_main.py
@@ -63,6 +63,9 @@ class EventSpec:
                  set_in_env: typing.Optional[typing.Dict[str,
                                                          str]] = None,
                  workload_name: typing.Optional[str] = None,
+                 notice_id: typing.Optional[str] = None,
+                 notice_type: typing.Optional[str] = None,
+                 notice_key: typing.Optional[str] = None,
                  departing_unit_name: typing.Optional[str] = None,
                  secret_id: typing.Optional[str] = None,
                  secret_label: typing.Optional[str] = None,
@@ -77,6 +80,9 @@ class EventSpec:
         self.model_name = model_name
         self.set_in_env = set_in_env
         self.workload_name = workload_name
+        self.notice_id = notice_id
+        self.notice_type = notice_type
+        self.notice_key = notice_key
         self.secret_id = secret_id
         self.secret_label = secret_label
         self.secret_revision = secret_revision
@@ -427,6 +433,15 @@ class _TestMain(abc.ABC):
             env.update({
                 'JUJU_WORKLOAD_NAME': event_spec.workload_name,
             })
+        if issubclass(event_spec.event_type, ops.PebbleNoticeEvent):
+            assert event_spec.notice_id is not None
+            assert event_spec.notice_type is not None
+            assert event_spec.notice_key is not None
+            env.update({
+                'JUJU_NOTICE_ID': event_spec.notice_id,
+                'JUJU_NOTICE_TYPE': event_spec.notice_type,
+                'JUJU_NOTICE_KEY': event_spec.notice_key,
+            })
         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
@@ -581,6 +596,16 @@ class _TestMain(abc.ABC):
             EventSpec(ops.PebbleReadyEvent, 'test_pebble_ready',
                       workload_name='test'),
             {'container_name': 'test'},
+        ), (
+            EventSpec(ops.PebbleCustomNoticeEvent, 'test_pebble_custom_notice',
+                      workload_name='test',
+                      notice_id='123',
+                      notice_type='custom',
+                      notice_key='example.com/a'),
+            {'container_name': 'test',
+             'notice_id': '123',
+             'notice_type': 'custom',
+             'notice_key': 'example.com/a'},
         ), (
             EventSpec(ops.SecretChangedEvent, 'secret_changed',
                       secret_id='secret:12345',
diff --git a/test/test_model.py b/test/test_model.py
index 6d8aeca..abccb7f 100644
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -1933,6 +1933,68 @@ containers:
         ])
         self.pebble.requests = []
 
+    def test_get_notice(self):
+        self.pebble.responses.append(pebble.Notice.from_dict({
+            'id': '123',
+            'user-id': 1000,
+            'type': 'custom',
+            'key': 'example.com/a',
+            'first-occurred': '2023-12-07T17:01:02.123456789Z',
+            'last-occurred': '2023-12-07T17:01:03.123456789Z',
+            'last-repeated': '2023-12-07T17:01:04.123456789Z',
+            'occurrences': 8,
+        }))
+
+        notice = self.container.get_notice('123')
+        self.assertEqual(notice.id, '123')
+        self.assertEqual(notice.type, pebble.NoticeType.CUSTOM)
+        self.assertEqual(notice.key, 'example.com/a')
+
+        self.assertEqual(self.pebble.requests, [
+            ('get_notice', '123'),
+        ])
+
+    def test_get_notice_not_found(self):
+        def raise_error(id: str):
+            raise pebble.APIError({'body': ''}, 404, 'status', 'api error!')
+        self.pebble.get_notice = raise_error
+        with self.assertRaises(ops.ModelError):
+            self.container.get_notice('123')
+
+    def test_get_notices(self):
+        self.pebble.responses.append([
+            pebble.Notice.from_dict({
+                'id': '124',
+                'user-id': 1000,
+                'type': 'custom',
+                'key': 'example.com/b',
+                'first-occurred': '2023-12-07T17:01:02.123456789Z',
+                'last-occurred': '2023-12-07T17:01:03.123456789Z',
+                'last-repeated': '2023-12-07T17:01:04.123456789Z',
+                'occurrences': 8,
+            }),
+        ])
+
+        notices = self.container.get_notices(
+            user_id=1000,
+            select=pebble.NoticesSelect.ALL,
+            types=[pebble.NoticeType.CUSTOM],
+            keys=['example.com/a', 'example.com/b'],
+            after=datetime.datetime(2023, 12, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc),
+        )
+        self.assertEqual(len(notices), 1)
+        self.assertEqual(notices[0].id, '124')
+        self.assertEqual(notices[0].type, pebble.NoticeType.CUSTOM)
+        self.assertEqual(notices[0].key, 'example.com/b')
+
+        self.assertEqual(self.pebble.requests, [('get_notices', dict(
+            user_id=1000,
+            select=pebble.NoticesSelect.ALL,
+            types=[pebble.NoticeType.CUSTOM],
+            keys=['example.com/a', 'example.com/b'],
+            after=datetime.datetime(2023, 12, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc),
+        ))])
+
 
 class MockPebbleBackend(_ModelBackend):
     def get_pebble(self, socket_path: str):
@@ -2033,6 +2095,14 @@ class MockPebbleClient:
     def send_signal(self, signal: typing.Union[str, int], service_names: str):
         self.requests.append(('send_signal', signal, service_names))
 
+    def get_notice(self, id: str) -> pebble.Notice:
+        self.requests.append(('get_notice', id))
+        return self.responses.pop(0)
+
+    def get_notices(self, **kwargs: typing.Any):
+        self.requests.append(('get_notices', kwargs))
+        return self.responses.pop(0)
+
 
 class TestModelBindings(unittest.TestCase):
 
@@ -3639,5 +3709,58 @@ class TestUnit(unittest.TestCase):
             self.model.get_unit('other').reboot(now=True)
 
 
+class TestLazyNotice(unittest.TestCase):
+    def test_lazy_notice(self):
+        calls = 0
+        timestamp = datetime.datetime.now()
+
+        class FakeWorkload:
+            def get_notice(self, id: str):
+                nonlocal calls
+                calls += 1
+                return ops.pebble.Notice(
+                    id=id,
+                    user_id=1000,
+                    type=ops.pebble.NoticeType.CUSTOM,
+                    key='example.com/a',
+                    first_occurred=timestamp,
+                    last_occurred=timestamp,
+                    last_repeated=timestamp,
+                    occurrences=7,
+                    last_data={'key': 'val'},
+                )
+
+        workload = typing.cast(ops.Container, FakeWorkload())
+        n = ops.model.LazyNotice(workload, '123', 'custom', 'example.com/a')
+        self.assertEqual(n.id, '123')
+        self.assertEqual(n.type, ops.pebble.NoticeType.CUSTOM)
+        self.assertEqual(n.key, 'example.com/a')
+        self.assertEqual(calls, 0)
+
+        self.assertEqual(n.occurrences, 7)
+        self.assertEqual(calls, 1)
+
+        self.assertEqual(n.user_id, 1000)
+        self.assertEqual(n.last_data, {'key': 'val'})
+        self.assertEqual(calls, 1)
+
+        with self.assertRaises(AttributeError):
+            assert n.not_exist
+
+    def test_repr(self):
+        workload = typing.cast(ops.Container, None)
+        n = ops.model.LazyNotice(workload, '123', 'custom', 'example.com/a')
+        self.assertEqual(
+            repr(n),
+            "LazyNotice(id='123', type=NoticeType.CUSTOM, key='example.com/a')",
+        )
+
+        n = ops.model.LazyNotice(workload, '123', 'foobar', 'example.com/a')
+        self.assertEqual(
+            repr(n),
+            "LazyNotice(id='123', type='foobar', key='example.com/a')",
+        )
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/test/test_pebble.py b/test/test_pebble.py
index c86d266..63f1339 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -442,6 +442,54 @@ single log
         self.assertEqual(info.group_id, 34)
         self.assertEqual(info.group, 'staff')
 
+    def test_notice_from_dict(self):
+        notice = pebble.Notice.from_dict({
+            'id': '123',
+            'user-id': 1000,
+            'type': 'custom',
+            'key': 'example.com/a',
+            'first-occurred': '2023-12-07T17:01:02.123456789Z',
+            'last-occurred': '2023-12-07T17:01:03.123456789Z',
+            'last-repeated': '2023-12-07T17:01:04.123456789Z',
+            'occurrences': 7,
+            'last-data': {'k1': 'v1', 'k2': 'v2'},
+            'repeat-after': '30m',
+            'expire-after': '24h',
+        })
+        self.assertEqual(notice, pebble.Notice(
+            id='123',
+            user_id=1000,
+            type=pebble.NoticeType.CUSTOM,
+            key='example.com/a',
+            first_occurred=datetime_utc(2023, 12, 7, 17, 1, 2, 123457),
+            last_occurred=datetime_utc(2023, 12, 7, 17, 1, 3, 123457),
+            last_repeated=datetime_utc(2023, 12, 7, 17, 1, 4, 123457),
+            occurrences=7,
+            last_data={'k1': 'v1', 'k2': 'v2'},
+            repeat_after=datetime.timedelta(minutes=30),
+            expire_after=datetime.timedelta(hours=24),
+        ))
+
+        notice = pebble.Notice.from_dict({
+            'id': '124',
+            'type': 'other',
+            'key': 'example.com/b',
+            'first-occurred': '2023-12-07T17:01:02.123456789Z',
+            'last-occurred': '2023-12-07T17:01:03.123456789Z',
+            'last-repeated': '2023-12-07T17:01:04.123456789Z',
+            'occurrences': 8,
+        })
+        self.assertEqual(notice, pebble.Notice(
+            id='124',
+            user_id=None,
+            type='other',
+            key='example.com/b',
+            first_occurred=datetime_utc(2023, 12, 7, 17, 1, 2, 123457),
+            last_occurred=datetime_utc(2023, 12, 7, 17, 1, 3, 123457),
+            last_repeated=datetime_utc(2023, 12, 7, 17, 1, 4, 123457),
+            occurrences=8,
+        ))
+
 
 class TestPlan(unittest.TestCase):
     def test_no_args(self):
@@ -2702,6 +2750,124 @@ bad path\r
             ('GET', '/v1/checks', {'level': 'ready', 'names': ['chk2']}, None),
         ])
 
+    def test_get_notice(self):
+        self.client.responses.append({
+            'result': {
+                'id': '123',
+                'user-id': 1000,
+                'type': 'custom',
+                'key': 'example.com/a',
+                'first-occurred': '2023-12-07T17:01:02.123456789Z',
+                'last-occurred': '2023-12-07T17:01:03.123456789Z',
+                'last-repeated': '2023-12-07T17:01:04.123456789Z',
+                'occurrences': 7,
+            },
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        notice = self.client.get_notice('123')
+
+        # No need to re-test full Notice.from_dict behaviour.
+        self.assertEqual(notice.id, '123')
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/notices/123', None, None),
+        ])
+
+    def test_get_notice_not_found(self):
+        self.client.responses.append(pebble.APIError({}, 404, 'Not Found', 'not found'))
+
+        with self.assertRaises(pebble.APIError) as cm:
+            self.client.get_notice('1')
+        self.assertEqual(cm.exception.code, 404)
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/notices/1', None, None),
+        ])
+
+    def test_get_notices_all(self):
+        self.client.responses.append({
+            'result': [{
+                'id': '123',
+                'user-id': 1000,
+                'type': 'custom',
+                'key': 'example.com/a',
+                'first-occurred': '2023-12-07T17:01:02.123456789Z',
+                'last-occurred': '2023-12-07T17:01:03.123456789Z',
+                'last-repeated': '2023-12-07T17:01:04.123456789Z',
+                'occurrences': 7,
+            }, {
+                'id': '124',
+                'type': 'other',
+                'key': 'example.com/b',
+                'first-occurred': '2023-12-07T17:01:02.123456789Z',
+                'last-occurred': '2023-12-07T17:01:03.123456789Z',
+                'last-repeated': '2023-12-07T17:01:04.123456789Z',
+                'occurrences': 8,
+            }],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        checks = self.client.get_notices()
+        self.assertEqual(len(checks), 2)
+        self.assertEqual(checks[0].id, '123')
+        self.assertEqual(checks[1].id, '124')
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/notices', {}, None),
+        ])
+
+    def test_get_notices_filters(self):
+        self.client.responses.append({
+            'result': [{
+                'id': '123',
+                'user-id': 1000,
+                'type': 'custom',
+                'key': 'example.com/a',
+                'first-occurred': '2023-12-07T17:01:02.123456789Z',
+                'last-occurred': '2023-12-07T17:01:03.123456789Z',
+                'last-repeated': '2023-12-07T17:01:04.123456789Z',
+                'occurrences': 7,
+            }, {
+                'id': '124',
+                'type': 'other',
+                'key': 'example.com/b',
+                'first-occurred': '2023-12-07T17:01:02.123456789Z',
+                'last-occurred': '2023-12-07T17:01:03.123456789Z',
+                'last-repeated': '2023-12-07T17:01:04.123456789Z',
+                'occurrences': 8,
+            }],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        notices = self.client.get_notices(
+            user_id=1000,
+            select=pebble.NoticesSelect.ALL,
+            types=[pebble.NoticeType.CUSTOM],
+            keys=['example.com/a', 'example.com/b'],
+            after=datetime_utc(2023, 12, 1, 2, 3, 4, 5),
+        )
+        self.assertEqual(len(notices), 2)
+        self.assertEqual(notices[0].id, '123')
+        self.assertEqual(notices[1].id, '124')
+
+        query = {
+            'user-id': '1000',
+            'select': 'all',
+            'types': ['custom'],
+            'keys': ['example.com/a', 'example.com/b'],
+            'after': '2023-12-01T02:03:04.000005+00:00',
+        }
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/notices', query, None),
+        ])
+
 
 class TestSocketClient(unittest.TestCase):
     def test_socket_not_found(self):
diff --git a/test/test_private.py b/test/test_private.py
index 40f6ac4..e415d19 100644
--- a/test/test_private.py
+++ b/test/test_private.py
@@ -100,3 +100,85 @@ class TestStrconv(unittest.TestCase):
 
         with self.assertRaises(ValueError):
             timeconv.parse_rfc3339('2021-02-10T04:36:22.118970777-99:99')
+
+    def test_parse_duration(self):
+        # Test cases taken from Go's time.ParseDuration tests
+        cases = [
+            # simple
+            ('0', datetime.timedelta(seconds=0)),
+            ('5s', datetime.timedelta(seconds=5)),
+            ('30s', datetime.timedelta(seconds=30)),
+            ('1478s', datetime.timedelta(seconds=1478)),
+            # sign
+            ('-5s', datetime.timedelta(seconds=-5)),
+            ('+5s', datetime.timedelta(seconds=5)),
+            ('-0', datetime.timedelta(seconds=0)),
+            ('+0', datetime.timedelta(seconds=0)),
+            # decimal
+            ('5.0s', datetime.timedelta(seconds=5)),
+            ('5.6s', datetime.timedelta(seconds=5.6)),
+            ('5.s', datetime.timedelta(seconds=5)),
+            ('.5s', datetime.timedelta(seconds=0.5)),
+            ('1.0s', datetime.timedelta(seconds=1)),
+            ('1.00s', datetime.timedelta(seconds=1)),
+            ('1.004s', datetime.timedelta(seconds=1.004)),
+            ('1.0040s', datetime.timedelta(seconds=1.004)),
+            ('100.00100s', datetime.timedelta(seconds=100.001)),
+            # different units
+            ('10ns', datetime.timedelta(seconds=0.000_000_010)),
+            ('11us', datetime.timedelta(seconds=0.000_011)),
+            ('12µs', datetime.timedelta(seconds=0.000_012)),  # U+00B5
+            ('12μs', datetime.timedelta(seconds=0.000_012)),  # U+03BC
+            ('13ms', datetime.timedelta(seconds=0.013)),
+            ('14s', datetime.timedelta(seconds=14)),
+            ('15m', datetime.timedelta(seconds=15 * 60)),
+            ('16h', datetime.timedelta(seconds=16 * 60 * 60)),
+            # composite durations
+            ('3h30m', datetime.timedelta(seconds=3 * 60 * 60 + 30 * 60)),
+            ('10.5s4m', datetime.timedelta(seconds=4 * 60 + 10.5)),
+            ('-2m3.4s', datetime.timedelta(seconds=-(2 * 60 + 3.4))),
+            ('1h2m3s4ms5us6ns', datetime.timedelta(seconds=1 * 60 * 60 + 2 * 60 + 3.004_005_006)),
+            ('39h9m14.425s', datetime.timedelta(seconds=39 * 60 * 60 + 9 * 60 + 14.425)),
+            # large value
+            ('52763797000ns', datetime.timedelta(seconds=52.763_797_000)),
+            # more than 9 digits after decimal point, see https://golang.org/issue/6617
+            ('0.3333333333333333333h', datetime.timedelta(seconds=20 * 60)),
+            # huge string; issue 15011.
+            ('0.100000000000000000000h', datetime.timedelta(seconds=6 * 60)),
+            # This value tests the first overflow check in leadingFraction.
+            ('0.830103483285477580700h', datetime.timedelta(seconds=49 * 60 + 48.372_539_827)),
+
+            # Test precision handling
+            ('7200000h1us', datetime.timedelta(hours=7_200_000, microseconds=1))
+        ]
+
+        for input, expected in cases:
+            output = timeconv.parse_duration(input)
+            self.assertEqual(output, expected,
+                             f'parse_duration({input!r}): expected {expected!r}, got {output!r}')
+
+    def test_parse_duration_errors(self):
+        cases = [
+            # Test cases taken from Go's time.ParseDuration tests
+            '',
+            '3',
+            '-',
+            's',
+            '.',
+            '-.',
+            '.s',
+            '+.s',
+            '1d',
+            '\x85\x85',
+            '\xffff',
+            'hello \xffff world',
+
+            # Additional cases
+            'X3h',
+            '3hY',
+            'X3hY',
+            '3.4.5s',
+        ]
+        for input in cases:
+            with self.assertRaises(ValueError):
+                timeconv.parse_duration(input)