Skip to content

Commit 6e199d8

Browse files
authored
Merge branch 'main' into chore/invalid_status
2 parents f974911 + 4360c20 commit 6e199d8

File tree

8 files changed

+488
-94
lines changed

8 files changed

+488
-94
lines changed

ops/model.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,7 +2761,6 @@ def get_notices(
27612761
user_id: Optional[int] = None,
27622762
types: Optional[Iterable[Union[pebble.NoticeType, str]]] = None,
27632763
keys: Optional[Iterable[str]] = None,
2764-
after: Optional[datetime.datetime] = None,
27652764
) -> List[pebble.Notice]:
27662765
"""Query for notices that match all of the provided filters.
27672766
@@ -2773,7 +2772,6 @@ def get_notices(
27732772
user_id=user_id,
27742773
types=types,
27752774
keys=keys,
2776-
after=after,
27772775
)
27782776

27792777
# Define this last to avoid clashes with the imported "pebble" module

ops/pebble.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,6 +2744,33 @@ def get_checks(
27442744
resp = self._request('GET', '/v1/checks', query)
27452745
return [CheckInfo.from_dict(info) for info in resp['result']]
27462746

2747+
def notify(self, type: NoticeType, key: str, *,
2748+
data: Optional[Dict[str, str]] = None,
2749+
repeat_after: Optional[datetime.timedelta] = None) -> str:
2750+
"""Record an occurrence of a notice with the specified options.
2751+
2752+
Args:
2753+
type: Notice type (currently only "custom" notices are supported).
2754+
key: Notice key; must be in "example.com/path" format.
2755+
data: Data fields for this notice.
2756+
repeat_after: Only allow this notice to repeat after this duration
2757+
has elapsed (the default is to always repeat).
2758+
2759+
Returns:
2760+
The notice's ID.
2761+
"""
2762+
body: Dict[str, Any] = {
2763+
'action': 'add',
2764+
'type': type.value,
2765+
'key': key,
2766+
}
2767+
if data is not None:
2768+
body['data'] = data
2769+
if repeat_after is not None:
2770+
body['repeat-after'] = _format_timeout(repeat_after.total_seconds())
2771+
resp = self._request('POST', '/v1/notices', body=body)
2772+
return resp['result']['id']
2773+
27472774
def get_notice(self, id: str) -> Notice:
27482775
"""Get details about a single notice by ID.
27492776
@@ -2760,7 +2787,6 @@ def get_notices(
27602787
user_id: Optional[int] = None,
27612788
types: Optional[Iterable[Union[NoticeType, str]]] = None,
27622789
keys: Optional[Iterable[str]] = None,
2763-
after: Optional[datetime.datetime] = None,
27642790
) -> List[Notice]:
27652791
"""Query for notices that match all of the provided filters.
27662792
@@ -2772,14 +2798,18 @@ def get_notices(
27722798
user (notices whose ``user_id`` matches the requester UID as well as
27732799
public notices).
27742800
2801+
Note that the "after" filter is not yet implemented, as it's not
2802+
needed right now and it's hard to implement correctly with Python's
2803+
datetime type, which only has microsecond precision (and Go's Time
2804+
type has nanosecond precision).
2805+
27752806
Args:
2776-
select: select which notices to return (instead of returning
2777-
notices for the current user)
2778-
user_id: filter for notices for the specified user, including
2779-
public notices (only works for Pebble admins)
2780-
types: filter for notices with any of the specified types
2781-
keys: filter for notices with any of the specified keys
2782-
after: filter for notices that were last repeated after this time
2807+
select: Select which notices to return (instead of returning
2808+
notices for the current user).
2809+
user_id: Filter for notices for the specified user, including
2810+
public notices (only works for Pebble admins).
2811+
types: Filter for notices with any of the specified types.
2812+
keys: Filter for notices with any of the specified keys.
27832813
"""
27842814
query: Dict[str, Union[str, List[str]]] = {}
27852815
if select is not None:
@@ -2790,8 +2820,6 @@ def get_notices(
27902820
query['types'] = [(t.value if isinstance(t, NoticeType) else t) for t in types]
27912821
if keys is not None:
27922822
query['keys'] = list(keys)
2793-
if after is not None:
2794-
query['after'] = after.isoformat()
27952823
resp = self._request('GET', '/v1/notices', query)
27962824
return [Notice.from_dict(info) for info in resp['result']]
27972825

ops/testing.py

Lines changed: 147 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import dataclasses
1919
import datetime
2020
import fnmatch
21+
import http
2122
import inspect
2223
import io
2324
import ipaddress
@@ -1098,6 +1099,37 @@ def container_pebble_ready(self, container_name: str):
10981099
self.set_can_connect(container, True)
10991100
self.charm.on[container_name].pebble_ready.emit(container)
11001101

1102+
def pebble_notify(self, container_name: str, key: str, *,
1103+
data: Optional[Dict[str, str]] = None,
1104+
repeat_after: Optional[datetime.timedelta] = None,
1105+
type: pebble.NoticeType = pebble.NoticeType.CUSTOM) -> str:
1106+
"""Record a Pebble notice with the specified key and data.
1107+
1108+
If :meth:`begin` has been called and the notice is new or was repeated,
1109+
this will trigger a notice event of the appropriate type, for example
1110+
:class:`ops.PebbleCustomNoticeEvent`.
1111+
1112+
Args:
1113+
container_name: Name of workload container.
1114+
key: Notice key; must be in "example.com/path" format.
1115+
data: Data fields for this notice.
1116+
repeat_after: Only allow this notice to repeat after this duration
1117+
has elapsed (the default is to always repeat).
1118+
type: Notice type (currently only "custom" notices are supported).
1119+
1120+
Returns:
1121+
The notice's ID.
1122+
"""
1123+
container = self.model.unit.get_container(container_name)
1124+
client = self._backend._pebble_clients[container.name]
1125+
1126+
id, new_or_repeated = client._notify(type, key, data=data, repeat_after=repeat_after)
1127+
1128+
if self._charm is not None and type == pebble.NoticeType.CUSTOM and new_or_repeated:
1129+
self.charm.on[container_name].pebble_custom_notice.emit(container, id, type.value, key)
1130+
1131+
return id
1132+
11011133
def get_workload_version(self) -> str:
11021134
"""Read the workload version that was set by the unit."""
11031135
return self._backend._workload_version
@@ -2733,6 +2765,8 @@ def __init__(self, backend: _TestingModelBackend, container_root: pathlib.Path):
27332765
self._root = container_root
27342766
self._backend = backend
27352767
self._exec_handlers: Dict[Tuple[str, ...], ExecHandler] = {}
2768+
self._notices: Dict[Tuple[str, str], pebble.Notice] = {}
2769+
self._last_notice_id = 0
27362770

27372771
def _handle_exec(self, command_prefix: Sequence[str], handler: ExecHandler):
27382772
prefix = tuple(command_prefix)
@@ -3012,9 +3046,7 @@ def list_files(self, path: str, *, pattern: Optional[str] = None,
30123046
self._check_absolute_path(path)
30133047
file_path = self._root / path[1:]
30143048
if not file_path.exists():
3015-
raise pebble.APIError(
3016-
body={}, code=404, status='Not Found',
3017-
message=f"stat {path}: no such file or directory")
3049+
raise self._api_error(404, f"stat {path}: no such file or directory")
30183050
files = [file_path]
30193051
if not itself:
30203052
try:
@@ -3143,19 +3175,13 @@ def exec(
31433175
handler = self._find_exec_handler(command)
31443176
if handler is None:
31453177
message = "execution handler not found, please register one using Harness.handle_exec"
3146-
raise pebble.APIError(
3147-
body={}, code=500, status='Internal Server Error', message=message
3148-
)
3178+
raise self._api_error(500, message)
31493179
environment = {} if environment is None else environment
31503180
if service_context is not None:
31513181
plan = self.get_plan()
31523182
if service_context not in plan.services:
31533183
message = f'context service "{service_context}" not found'
3154-
body = {'type': 'error', 'status-code': 500, 'status': 'Internal Server Error',
3155-
'result': {'message': message}}
3156-
raise pebble.APIError(
3157-
body=body, code=500, status='Internal Server Error', message=message
3158-
)
3184+
raise self._api_error(500, message)
31593185
service = plan.services[service_context]
31603186
environment = {**service.environment, **environment}
31613187
working_dir = service.working_dir if working_dir is None else working_dir
@@ -3246,11 +3272,7 @@ def send_signal(self, sig: Union[int, str], service_names: Iterable[str]):
32463272
if service not in plan.services or not self.get_services([service])[0].is_running():
32473273
# conform with the real pebble api
32483274
message = f'cannot send signal to "{service}": service is not running'
3249-
body = {'type': 'error', 'status-code': 500, 'status': 'Internal Server Error',
3250-
'result': {'message': message}}
3251-
raise pebble.APIError(
3252-
body=body, code=500, status='Internal Server Error', message=message
3253-
)
3275+
raise self._api_error(500, message)
32543276

32553277
# Check if signal name is valid
32563278
try:
@@ -3259,19 +3281,86 @@ def send_signal(self, sig: Union[int, str], service_names: Iterable[str]):
32593281
# conform with the real pebble api
32603282
first_service = next(iter(service_names))
32613283
message = f'cannot send signal to "{first_service}": invalid signal name "{sig}"'
3262-
body = {'type': 'error', 'status-code': 500, 'status': 'Internal Server Error',
3263-
'result': {'message': message}}
3264-
raise pebble.APIError(
3265-
body=body,
3266-
code=500,
3267-
status='Internal Server Error',
3268-
message=message) from None
3284+
raise self._api_error(500, message)
32693285

32703286
def get_checks(self, level=None, names=None): # type:ignore
32713287
raise NotImplementedError(self.get_checks) # type:ignore
32723288

3289+
def notify(self, type: pebble.NoticeType, key: str, *,
3290+
data: Optional[Dict[str, str]] = None,
3291+
repeat_after: Optional[datetime.timedelta] = None) -> str:
3292+
notice_id, _ = self._notify(type, key, data=data, repeat_after=repeat_after)
3293+
return notice_id
3294+
3295+
def _notify(self, type: pebble.NoticeType, key: str, *,
3296+
data: Optional[Dict[str, str]] = None,
3297+
repeat_after: Optional[datetime.timedelta] = None) -> Tuple[str, bool]:
3298+
"""Record an occurrence of a notice with the specified details.
3299+
3300+
Return a tuple of (notice_id, new_or_repeated).
3301+
"""
3302+
if type != pebble.NoticeType.CUSTOM:
3303+
message = f'invalid type "{type.value}" (can only add "custom" notices)'
3304+
raise self._api_error(400, message)
3305+
3306+
# The shape of the code below is taken from State.AddNotice in Pebble.
3307+
now = datetime.datetime.now(tz=datetime.timezone.utc)
3308+
3309+
new_or_repeated = False
3310+
unique_key = (type.value, key)
3311+
notice = self._notices.get(unique_key)
3312+
if notice is None:
3313+
# First occurrence of this notice uid+type+key
3314+
self._last_notice_id += 1
3315+
notice = pebble.Notice(
3316+
id=str(self._last_notice_id),
3317+
user_id=0, # Charm should always be able to read pebble_notify notices.
3318+
type=type,
3319+
key=key,
3320+
first_occurred=now,
3321+
last_occurred=now,
3322+
last_repeated=now,
3323+
expire_after=datetime.timedelta(days=7),
3324+
occurrences=1,
3325+
last_data=data or {},
3326+
repeat_after=repeat_after,
3327+
)
3328+
self._notices[unique_key] = notice
3329+
new_or_repeated = True
3330+
else:
3331+
# Additional occurrence, update existing notice
3332+
last_repeated = notice.last_repeated
3333+
if repeat_after is None or now > notice.last_repeated + repeat_after:
3334+
# Update last repeated time if repeat-after time has elapsed (or is None)
3335+
last_repeated = now
3336+
new_or_repeated = True
3337+
notice = dataclasses.replace(
3338+
notice,
3339+
last_occurred=now,
3340+
last_repeated=last_repeated,
3341+
occurrences=notice.occurrences + 1,
3342+
last_data=data or {},
3343+
repeat_after=repeat_after,
3344+
)
3345+
self._notices[unique_key] = notice
3346+
3347+
return notice.id, new_or_repeated
3348+
3349+
def _api_error(self, code: int, message: str) -> pebble.APIError:
3350+
status = http.HTTPStatus(code).phrase
3351+
body = {
3352+
'type': 'error',
3353+
'status-code': code,
3354+
'status': status,
3355+
'result': {'message': message},
3356+
}
3357+
return pebble.APIError(body, code, status, message)
3358+
32733359
def get_notice(self, id: str) -> pebble.Notice:
3274-
raise NotImplementedError(self.get_notice)
3360+
for notice in self._notices.values():
3361+
if notice.id == id:
3362+
return notice
3363+
raise self._api_error(404, f'cannot find notice with ID "{id}"')
32753364

32763365
def get_notices(
32773366
self,
@@ -3280,6 +3369,38 @@ def get_notices(
32803369
user_id: Optional[int] = None,
32813370
types: Optional[Iterable[Union[pebble.NoticeType, str]]] = None,
32823371
keys: Optional[Iterable[str]] = None,
3283-
after: Optional[datetime.datetime] = None,
32843372
) -> List[pebble.Notice]:
3285-
raise NotImplementedError(self.get_notices)
3373+
# Similar logic as api_notices.go:v1GetNotices in Pebble.
3374+
3375+
filter_user_id = 0 # default is to filter by request UID (root)
3376+
if user_id is not None:
3377+
filter_user_id = user_id
3378+
if select is not None:
3379+
if user_id is not None:
3380+
raise self._api_error(400, 'cannot use both "select" and "user_id"')
3381+
filter_user_id = None
3382+
3383+
if types is not None:
3384+
types = {(t.value if isinstance(t, pebble.NoticeType) else t) for t in types}
3385+
if keys is not None:
3386+
keys = set(keys)
3387+
3388+
notices = [notice for notice in self._notices.values() if
3389+
self._notice_matches(notice, filter_user_id, types, keys)]
3390+
notices.sort(key=lambda notice: notice.last_repeated)
3391+
return notices
3392+
3393+
@staticmethod
3394+
def _notice_matches(notice: pebble.Notice,
3395+
user_id: Optional[int] = None,
3396+
types: Optional[Set[str]] = None,
3397+
keys: Optional[Set[str]] = None) -> bool:
3398+
# Same logic as NoticeFilter.matches in Pebble.
3399+
# For example: if user_id filter is set and it doesn't match, return False.
3400+
if user_id is not None and not (notice.user_id is None or user_id == notice.user_id):
3401+
return False
3402+
if types is not None and notice.type not in types:
3403+
return False
3404+
if keys is not None and notice.key not in keys:
3405+
return False
3406+
return True

requirements-dev.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,4 @@ pytest-operator~=0.23
1111
coverage[toml]~=7.0
1212
typing_extensions~=4.2
1313

14-
macaroonbakery != 1.3.3 # TODO: remove once this is fixed: https://github.com/go-macaroon-bakery/py-macaroon-bakery/issues/94
15-
1614
-r requirements.txt

test/test_model.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1980,7 +1980,6 @@ def test_get_notices(self):
19801980
select=pebble.NoticesSelect.ALL,
19811981
types=[pebble.NoticeType.CUSTOM],
19821982
keys=['example.com/a', 'example.com/b'],
1983-
after=datetime.datetime(2023, 12, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc),
19841983
)
19851984
self.assertEqual(len(notices), 1)
19861985
self.assertEqual(notices[0].id, '124')
@@ -1992,7 +1991,6 @@ def test_get_notices(self):
19921991
select=pebble.NoticesSelect.ALL,
19931992
types=[pebble.NoticeType.CUSTOM],
19941993
keys=['example.com/a', 'example.com/b'],
1995-
after=datetime.datetime(2023, 12, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc),
19961994
))])
19971995

19981996

0 commit comments

Comments
 (0)