Skip to content

Commit

Permalink
feat: support for Pebble Notices (#1086)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
benhoyt authored Dec 15, 2023
1 parent 09b2d8f commit 95e325e
Show file tree
Hide file tree
Showing 15 changed files with 809 additions and 17 deletions.
6 changes: 5 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 10 additions & 8 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,28 +73,30 @@ def _compute_navigation_tree(context):
# 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'),
Expand Down
6 changes: 6 additions & 0 deletions ops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
'LeaderElectedEvent',
'LeaderSettingsChangedEvent',
'PayloadMeta',
'PebbleCustomNoticeEvent',
'PebbleNoticeEvent',
'PebbleReadyEvent',
'PostSeriesUpgradeEvent',
'PreSeriesUpgradeEvent',
Expand Down Expand Up @@ -129,6 +131,7 @@
'ErrorStatus',
'InvalidStatusError',
'LazyMapping',
'LazyNotice',
'MaintenanceStatus',
'Model',
'ModelError',
Expand Down Expand Up @@ -195,6 +198,8 @@
LeaderElectedEvent,
LeaderSettingsChangedEvent,
PayloadMeta,
PebbleCustomNoticeEvent,
PebbleNoticeEvent,
PebbleReadyEvent,
PostSeriesUpgradeEvent,
PreSeriesUpgradeEvent,
Expand Down Expand Up @@ -263,6 +268,7 @@
ErrorStatus,
InvalidStatusError,
LazyMapping,
LazyNotice,
MaintenanceStatus,
Model,
ModelError,
Expand Down
70 changes: 70 additions & 0 deletions ops/_private/timeconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import datetime
import re
from typing import Union

# Matches yyyy-mm-ddTHH:MM:SS(.sss)ZZZ
_TIMESTAMP_RE = re.compile(
Expand All @@ -24,6 +25,9 @@
# 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.
Expand Down Expand Up @@ -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
42 changes: 41 additions & 1 deletion ops/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ def restore(self, snapshot: Dict[str, Any]):


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.
Expand All @@ -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."""

Expand Down Expand Up @@ -1103,6 +1142,7 @@ def __init__(self, framework: Framework):
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:
Expand Down
8 changes: 7 additions & 1 deletion ops/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
87 changes: 85 additions & 2 deletions ops/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2174,7 +2174,7 @@ def get_service(self, service_name: str) -> pebble.ServiceInfo:
"""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:
Expand Down Expand Up @@ -2202,7 +2202,7 @@ def get_check(self, check_name: str) -> pebble.CheckInfo:
"""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:
Expand Down Expand Up @@ -2719,6 +2719,41 @@ def send_signal(self, sig: Union[int, str], *service_names: str):

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:
Expand Down Expand Up @@ -3489,3 +3524,51 @@ def validate_label_value(cls, label: str, value: str):
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
Loading

0 comments on commit 95e325e

Please sign in to comment.