From 95e325ef137659c7a7cf1152a05dcc06383f3b2d Mon Sep 17 00:00:00 2001 From: Ben Hoyt Date: Fri, 15 Dec 2023 16:11:00 +1300 Subject: [PATCH] feat: support for Pebble Notices (#1086) 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). --- CHANGES.md | 6 +- docs/conf.py | 18 ++-- ops/__init__.py | 6 ++ ops/_private/timeconv.py | 70 ++++++++++++ ops/charm.py | 42 +++++++- ops/main.py | 8 +- ops/model.py | 87 ++++++++++++++- ops/pebble.py | 150 ++++++++++++++++++++++++++ ops/testing.py | 14 +++ test/charms/test_main/src/charm.py | 10 ++ test/test_charm.py | 19 +++- test/test_main.py | 25 +++++ test/test_model.py | 123 +++++++++++++++++++++ test/test_pebble.py | 166 +++++++++++++++++++++++++++++ test/test_private.py | 82 ++++++++++++++ 15 files changed, 809 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index dd9b2c86f..5be00362b 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 b7b4c7cf0..1e9bedf02 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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'), diff --git a/ops/__init__.py b/ops/__init__.py index 17b035244..99c8c3903 100644 --- a/ops/__init__.py +++ b/ops/__init__.py @@ -62,6 +62,8 @@ 'LeaderElectedEvent', 'LeaderSettingsChangedEvent', 'PayloadMeta', + 'PebbleCustomNoticeEvent', + 'PebbleNoticeEvent', 'PebbleReadyEvent', 'PostSeriesUpgradeEvent', 'PreSeriesUpgradeEvent', @@ -129,6 +131,7 @@ 'ErrorStatus', 'InvalidStatusError', 'LazyMapping', + 'LazyNotice', 'MaintenanceStatus', 'Model', 'ModelError', @@ -195,6 +198,8 @@ LeaderElectedEvent, LeaderSettingsChangedEvent, PayloadMeta, + PebbleCustomNoticeEvent, + PebbleNoticeEvent, PebbleReadyEvent, PostSeriesUpgradeEvent, PreSeriesUpgradeEvent, @@ -263,6 +268,7 @@ ErrorStatus, InvalidStatusError, LazyMapping, + LazyNotice, MaintenanceStatus, Model, ModelError, diff --git a/ops/_private/timeconv.py b/ops/_private/timeconv.py index d7f0b24b9..ceb67f32a 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 @@ # Matches [-+]HH:MM _TIMEOFFSET_RE = re.compile(r'([-+])(\d{2}):(\d{2})') +# Matches n.n (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 040758110..e0721eb92 100644 --- a/ops/charm.py +++ b/ops/charm.py @@ -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. @@ -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 @@ 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: diff --git a/ops/main.py b/ops/main.py index 40948d44f..9cfb7201e 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 0f0d281de..1c13c431d 100644 --- a/ops/model.py +++ b/ops/model.py @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/ops/pebble.py b/ops/pebble.py index ac4278c72..10446a6aa 100644 --- a/ops/pebble.py +++ b/ops/pebble.py @@ -19,6 +19,7 @@ import binascii import copy +import dataclasses import datetime import email.message import email.parser @@ -258,6 +259,20 @@ def __enter__(self) -> typing.IO[typing.AnyStr]: ... # noqa '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 @@ def __repr__(self): ).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 `_ + 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 @@ def get_checks( 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.""" diff --git a/ops/testing.py b/ops/testing.py index ab288b7e1..b41a646e0 100644 --- a/ops/testing.py +++ b/ops/testing.py @@ -3259,3 +3259,17 @@ def send_signal(self, sig: Union[int, str], service_names: Iterable[str]): 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 db03907aa..bd4ea799c 100755 --- a/test/charms/test_main/src/charm.py +++ b/test/charms/test_main/src/charm.py @@ -57,6 +57,7 @@ def __init__(self, *args: typing.Any): _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 @@ def __init__(self, *args: typing.Any): 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 @@ def _on_test_pebble_ready(self, event: ops.PebbleReadyEvent): 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 890b99f3b..88a06896f 100644 --- a/test/test_charm.py +++ b/test/test_charm.py @@ -329,16 +329,21 @@ class MyCharm(ops.CharmBase): 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 @@ def on_any_pebble_ready(self, event: ops.PebbleReadyEvent): 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 715c20d5e..5eae41889 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -63,6 +63,9 @@ def __init__(self, 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 @@ def __init__(self, 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 @@ def _simulate_event(self, event_spec: EventSpec): 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 @@ def test_multiple_events_handled(self): 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 6d8aeca68..abccb7f6a 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -1933,6 +1933,68 @@ def test_send_signal(self): ]) 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 @@ def exec(self, command: typing.List[str], **kwargs: typing.Any): 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 @@ def test_reboot(self): 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 c86d266ea..63f1339ad 100644 --- a/test/test_pebble.py +++ b/test/test_pebble.py @@ -442,6 +442,54 @@ def test_file_info_from_dict(self): 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 @@ def test_checklevel_conversion(self): ('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 40f6ac437..e415d19da 100644 --- a/test/test_private.py +++ b/test/test_private.py @@ -100,3 +100,85 @@ def test_parse_rfc3339(self): 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)