18
18
import dataclasses
19
19
import datetime
20
20
import fnmatch
21
+ import http
21
22
import inspect
22
23
import io
23
24
import ipaddress
@@ -1098,6 +1099,37 @@ def container_pebble_ready(self, container_name: str):
1098
1099
self .set_can_connect (container , True )
1099
1100
self .charm .on [container_name ].pebble_ready .emit (container )
1100
1101
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
+
1101
1133
def get_workload_version (self ) -> str :
1102
1134
"""Read the workload version that was set by the unit."""
1103
1135
return self ._backend ._workload_version
@@ -2733,6 +2765,8 @@ def __init__(self, backend: _TestingModelBackend, container_root: pathlib.Path):
2733
2765
self ._root = container_root
2734
2766
self ._backend = backend
2735
2767
self ._exec_handlers : Dict [Tuple [str , ...], ExecHandler ] = {}
2768
+ self ._notices : Dict [Tuple [str , str ], pebble .Notice ] = {}
2769
+ self ._last_notice_id = 0
2736
2770
2737
2771
def _handle_exec (self , command_prefix : Sequence [str ], handler : ExecHandler ):
2738
2772
prefix = tuple (command_prefix )
@@ -3012,9 +3046,7 @@ def list_files(self, path: str, *, pattern: Optional[str] = None,
3012
3046
self ._check_absolute_path (path )
3013
3047
file_path = self ._root / path [1 :]
3014
3048
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" )
3018
3050
files = [file_path ]
3019
3051
if not itself :
3020
3052
try :
@@ -3143,19 +3175,13 @@ def exec(
3143
3175
handler = self ._find_exec_handler (command )
3144
3176
if handler is None :
3145
3177
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 )
3149
3179
environment = {} if environment is None else environment
3150
3180
if service_context is not None :
3151
3181
plan = self .get_plan ()
3152
3182
if service_context not in plan .services :
3153
3183
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 )
3159
3185
service = plan .services [service_context ]
3160
3186
environment = {** service .environment , ** environment }
3161
3187
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]):
3246
3272
if service not in plan .services or not self .get_services ([service ])[0 ].is_running ():
3247
3273
# conform with the real pebble api
3248
3274
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 )
3254
3276
3255
3277
# Check if signal name is valid
3256
3278
try :
@@ -3259,19 +3281,86 @@ def send_signal(self, sig: Union[int, str], service_names: Iterable[str]):
3259
3281
# conform with the real pebble api
3260
3282
first_service = next (iter (service_names ))
3261
3283
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 )
3269
3285
3270
3286
def get_checks (self , level = None , names = None ): # type:ignore
3271
3287
raise NotImplementedError (self .get_checks ) # type:ignore
3272
3288
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
+
3273
3359
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 } "' )
3275
3364
3276
3365
def get_notices (
3277
3366
self ,
@@ -3280,6 +3369,38 @@ def get_notices(
3280
3369
user_id : Optional [int ] = None ,
3281
3370
types : Optional [Iterable [Union [pebble .NoticeType , str ]]] = None ,
3282
3371
keys : Optional [Iterable [str ]] = None ,
3283
- after : Optional [datetime .datetime ] = None ,
3284
3372
) -> 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
0 commit comments