Skip to content

Commit d54a26a

Browse files
feat: support starting and stopping Pebble checks, and the checks enabled field (canonical#1560)
Add support for: * The `startup` field in Pebble checks. * The `start_checks` and `stop_checks` Pebble API calls. Harness (and Scenario, mostly via re-using the Harness implementation) is adjusted to more closely simulate the Changes implementation of Pebble Checks, so that the 'if the change ID is the empty string, the check is inactive' behaviour can be simulated. A subtle bug with notices and check-infos is also fixed: previously the mocked Pebble would gather all notices and check-infos from all containers in the state, instead of only those that are in the correct container. [Pebble PR](canonical/pebble#560)
1 parent a83ffef commit d54a26a

File tree

11 files changed

+523
-8
lines changed

11 files changed

+523
-8
lines changed

ops/_private/harness.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3175,7 +3175,42 @@ def autostart_services(self, timeout: float = 30.0, delay: float = 0.1):
31753175
if startup == pebble.ServiceStartup.ENABLED:
31763176
self._service_status[name] = pebble.ServiceStatus.ACTIVE
31773177

3178+
def _new_perform_check(self, info: pebble.CheckInfo) -> pebble.Change:
3179+
now = datetime.datetime.now()
3180+
change = pebble.Change(
3181+
pebble.ChangeID(str(uuid.uuid4())),
3182+
pebble.ChangeKind.PERFORM_CHECK.value,
3183+
summary=info.name,
3184+
status=pebble.ChangeStatus.DOING.value,
3185+
tasks=[],
3186+
ready=False,
3187+
err=None,
3188+
spawn_time=now,
3189+
ready_time=None,
3190+
)
3191+
info.change_id = change.id
3192+
info.status = pebble.CheckStatus.UP
3193+
info.failures = 0
3194+
self._changes[change.id] = change
3195+
return change
3196+
31783197
def replan_services(self, timeout: float = 30.0, delay: float = 0.1):
3198+
for name, check in self._render_checks().items():
3199+
if check.startup == pebble.CheckStartup.DISABLED:
3200+
continue
3201+
info = self._check_infos.get(name)
3202+
if info is None:
3203+
info = pebble.CheckInfo(
3204+
name=name,
3205+
level=check.level,
3206+
status=pebble.CheckStatus.UP,
3207+
failures=0,
3208+
threshold=3 if check.threshold is None else check.threshold,
3209+
startup=check.startup,
3210+
)
3211+
self._check_infos[name] = info
3212+
if not info.change_id:
3213+
self._new_perform_check(info)
31793214
return self.autostart_services(timeout, delay)
31803215

31813216
def start_services(
@@ -3346,12 +3381,35 @@ def add_layer(
33463381
else:
33473382
self._layers[label] = layer_obj
33483383

3384+
# Checks are started when the layer is added, not (only) on replan.
3385+
for name, check in layer_obj.checks.items():
3386+
try:
3387+
info = self._check_infos[name]
3388+
except KeyError:
3389+
status = (
3390+
pebble.CheckStatus.INACTIVE
3391+
if check.startup == pebble.CheckStartup.DISABLED
3392+
else pebble.CheckStatus.UP
3393+
)
3394+
info = pebble.CheckInfo(
3395+
name,
3396+
level=check.level,
3397+
status=status,
3398+
failures=0,
3399+
change_id=pebble.ChangeID(''),
3400+
)
3401+
self._check_infos[name] = info
3402+
info.level = check.level
3403+
info.threshold = 3 if check.threshold is None else check.threshold
3404+
info.startup = check.startup
3405+
if info.startup != pebble.CheckStartup.DISABLED and not info.change_id:
3406+
self._new_perform_check(info)
3407+
33493408
def _render_services(self) -> Dict[str, pebble.Service]:
33503409
services: Dict[str, pebble.Service] = {}
33513410
for key in sorted(self._layers.keys()):
33523411
layer = self._layers[key]
33533412
for name, service in layer.services.items():
3354-
# TODO: merge existing services https://github.com/canonical/operator/issues/1112
33553413
services[name] = service
33563414
return services
33573415

@@ -3743,6 +3801,33 @@ def get_checks(
37433801
if (level is None or level == info.level) and (names is None or info.name in names)
37443802
]
37453803

3804+
def start_checks(self, names: List[str]) -> List[str]:
3805+
self._check_connection()
3806+
started: List[str] = []
3807+
for name in names:
3808+
if name not in self._check_infos:
3809+
raise self._api_error(404, f'cannot find check with name "{name}"')
3810+
info = self._check_infos[name]
3811+
if not info.change_id:
3812+
self._new_perform_check(info)
3813+
started.append(name)
3814+
return started
3815+
3816+
def stop_checks(self, names: List[str]) -> List[str]:
3817+
self._check_connection()
3818+
stopped: List[str] = []
3819+
for name in names:
3820+
if name not in self._check_infos:
3821+
raise self._api_error(404, f'cannot find check with name "{name}"')
3822+
info = self._check_infos[name]
3823+
if info.change_id:
3824+
change = self._changes[info.change_id]
3825+
change.status = pebble.ChangeStatus.ABORT.value
3826+
info.status = pebble.CheckStatus.INACTIVE
3827+
info.change_id = pebble.ChangeID('')
3828+
stopped.append(name)
3829+
return stopped
3830+
37463831
def notify(
37473832
self,
37483833
type: pebble.NoticeType,

ops/model.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2503,6 +2503,30 @@ def get_check(self, check_name: str) -> pebble.CheckInfo:
25032503
raise RuntimeError(f'expected 1 check, got {len(checks)}')
25042504
return checks[check_name]
25052505

2506+
def start_checks(self, *check_names: str) -> List[str]:
2507+
"""Start given check(s) by name.
2508+
2509+
Returns:
2510+
A list of check names that were started. Checks that were already
2511+
running will not be included.
2512+
"""
2513+
if not check_names:
2514+
raise TypeError('start-checks expected at least 1 argument, got 0')
2515+
2516+
return self._pebble.start_checks(check_names)
2517+
2518+
def stop_checks(self, *check_names: str) -> List[str]:
2519+
"""Stop given check(s) by name.
2520+
2521+
Returns:
2522+
A list of check names that were stopped. Checks that were already
2523+
inactive will not be included.
2524+
"""
2525+
if not check_names:
2526+
raise TypeError('stop-checks expected at least 1 argument, got 0')
2527+
2528+
return self._pebble.stop_checks(check_names)
2529+
25062530
@typing.overload
25072531
def pull(self, path: Union[str, PurePath], *, encoding: None) -> BinaryIO: ...
25082532

ops/pebble.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
{
136136
'override': str,
137137
'level': Union['CheckLevel', str],
138+
'startup': Literal['', 'enabled', 'disabled'],
138139
'period': Optional[str],
139140
'timeout': Optional[str],
140141
'http': Optional[HttpDict],
@@ -243,6 +244,7 @@ def __enter__(self) -> typing.IO[typing.AnyStr]: ...
243244
{
244245
'name': str,
245246
'level': NotRequired[str],
247+
'startup': NotRequired[Literal['enabled', 'disabled']],
246248
'status': str,
247249
'failures': NotRequired[int],
248250
'threshold': int,
@@ -1103,6 +1105,7 @@ def __init__(self, name: str, raw: Optional[CheckDict] = None):
11031105
except ValueError:
11041106
level = dct.get('level', '')
11051107
self.level = level
1108+
self.startup = CheckStartup(dct.get('startup', ''))
11061109
self.period: Optional[str] = dct.get('period', '')
11071110
self.timeout: Optional[str] = dct.get('timeout', '')
11081111
self.threshold: Optional[int] = dct.get('threshold')
@@ -1128,6 +1131,7 @@ def to_dict(self) -> CheckDict:
11281131
fields = [
11291132
('override', self.override),
11301133
('level', level),
1134+
('startup', self.startup.value),
11311135
('period', self.period),
11321136
('timeout', self.timeout),
11331137
('threshold', self.threshold),
@@ -1231,6 +1235,15 @@ class CheckStatus(enum.Enum):
12311235

12321236
UP = 'up'
12331237
DOWN = 'down'
1238+
INACTIVE = 'inactive'
1239+
1240+
1241+
class CheckStartup(enum.Enum):
1242+
"""Enum of check startup options."""
1243+
1244+
UNSET = '' # Note that this is treated as ENABLED.
1245+
ENABLED = 'enabled'
1246+
DISABLED = 'disabled'
12341247

12351248

12361249
class LogTarget:
@@ -1407,12 +1420,21 @@ class CheckInfo:
14071420
This can be :attr:`CheckLevel.ALIVE`, :attr:`CheckLevel.READY`, or None (level not set).
14081421
"""
14091422

1423+
startup: CheckStartup
1424+
"""Startup mode.
1425+
1426+
:attr:`CheckStartup.ENABLED` means the check will be started when added, and
1427+
in a replan. :attr:`CheckStartup.DISABLED` means the check must be manually
1428+
started.
1429+
"""
1430+
14101431
status: Union[CheckStatus, str]
14111432
"""Status of the check.
14121433
14131434
:attr:`CheckStatus.UP` means the check is healthy (the number of failures
14141435
is less than the threshold), :attr:`CheckStatus.DOWN` means the check is
1415-
unhealthy (the number of failures has reached the threshold).
1436+
unhealthy (the number of failures has reached the threshold), and
1437+
:attr:`CheckStatus.INACTIVE` means the check is not running.
14161438
"""
14171439

14181440
failures: int
@@ -1442,9 +1464,11 @@ def __init__(
14421464
failures: int = 0,
14431465
threshold: int = 0,
14441466
change_id: Optional[ChangeID] = None,
1467+
startup: CheckStartup = CheckStartup.ENABLED,
14451468
):
14461469
self.name = name
14471470
self.level = level
1471+
self.startup = startup
14481472
self.status = status
14491473
self.failures = failures
14501474
self.threshold = threshold
@@ -1461,20 +1485,27 @@ def from_dict(cls, d: _CheckInfoDict) -> CheckInfo:
14611485
status = CheckStatus(d['status'])
14621486
except ValueError:
14631487
status = d['status']
1488+
change_id = ChangeID(d['change-id']) if 'change-id' in d else None
1489+
if not change_id and 'startup' in d:
1490+
# This is a version of Pebble that supports stopping checks, which
1491+
# means that the check is inactive if it has no change ID.
1492+
status = CheckStatus.INACTIVE
14641493
return cls(
14651494
name=d['name'],
14661495
level=level,
1496+
startup=CheckStartup(d.get('startup', 'enabled')),
14671497
status=status,
14681498
failures=d.get('failures', 0),
14691499
threshold=d['threshold'],
1470-
change_id=ChangeID(d['change-id']) if 'change-id' in d else None,
1500+
change_id=change_id,
14711501
)
14721502

14731503
def __repr__(self):
14741504
return (
14751505
'CheckInfo('
14761506
f'name={self.name!r}, '
14771507
f'level={self.level}, '
1508+
f'startup={self.startup}, '
14781509
f'status={self.status}, '
14791510
f'failures={self.failures}, '
14801511
f'threshold={self.threshold!r}, '
@@ -2126,7 +2157,9 @@ def autostart_services(self, timeout: float = 30.0, delay: float = 0.1) -> Chang
21262157
return self._services_action('autostart', [], timeout, delay)
21272158

21282159
def replan_services(self, timeout: float = 30.0, delay: float = 0.1) -> ChangeID:
2129-
"""Replan by (re)starting changed and startup-enabled services and wait for them to start.
2160+
"""Replan by (re)starting changed and startup-enabled services and checks.
2161+
2162+
After requesting the replan, also wait for any impacted services to start.
21302163
21312164
Args:
21322165
timeout: Seconds before replan change is considered timed out (float). If
@@ -2335,6 +2368,19 @@ def _wait_change_using_polling(
23352368

23362369
raise TimeoutError(f'timed out waiting for change {change_id} ({timeout} seconds)')
23372370

2371+
def _checks_action(self, action: str, checks: Iterable[str]) -> List[str]:
2372+
if isinstance(checks, str) or not hasattr(checks, '__iter__'):
2373+
raise TypeError(f'checks must be of type Iterable[str], not {type(checks).__name__}')
2374+
2375+
checks = tuple(checks)
2376+
for chk in checks:
2377+
if not isinstance(chk, str):
2378+
raise TypeError(f'check names must be str, not {type(chk).__name__}')
2379+
2380+
body = {'action': action, 'checks': checks}
2381+
resp = self._request('POST', '/v1/checks', body=body)
2382+
return resp['result']['changed']
2383+
23382384
def add_layer(self, label: str, layer: Union[str, LayerDict, Layer], *, combine: bool = False):
23392385
"""Dynamically add a new layer onto the Pebble configuration layers.
23402386
@@ -3052,6 +3098,30 @@ def get_checks(
30523098
resp = self._request('GET', '/v1/checks', query)
30533099
return [CheckInfo.from_dict(info) for info in resp['result']]
30543100

3101+
def start_checks(self, checks: Iterable[str]) -> List[str]:
3102+
"""Start checks by name.
3103+
3104+
Args:
3105+
checks: Non-empty list of checks to start.
3106+
3107+
Returns:
3108+
Set of check names that were started. Checks that were already
3109+
running will not be included.
3110+
"""
3111+
return self._checks_action('start', checks)
3112+
3113+
def stop_checks(self, checks: Iterable[str]) -> List[str]:
3114+
"""Stop checks by name.
3115+
3116+
Args:
3117+
checks: Non-empty list of checks to stop.
3118+
3119+
Returns:
3120+
Set of check names that were stopped. Checks that were already
3121+
inactive will not be included.
3122+
"""
3123+
return self._checks_action('stop', checks)
3124+
30553125
def notify(
30563126
self,
30573127
type: NoticeType,

test/test_charm.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ def mock_check_info(
349349
'status': 'down',
350350
'failures': 3,
351351
'threshold': 3,
352+
'change-id': '1',
352353
})
353354
]
354355

test/test_model.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,13 +1885,15 @@ def test_get_checks(self, container: ops.Container):
18851885
'status': 'up',
18861886
'failures': 0,
18871887
'threshold': 3,
1888+
'change-id': '1',
18881889
}),
18891890
pebble.CheckInfo.from_dict({
18901891
'name': 'c2',
18911892
'level': 'alive',
18921893
'status': 'down',
18931894
'failures': 2,
18941895
'threshold': 2,
1896+
'change-id': '2',
18951897
}),
18961898
]
18971899

@@ -1931,6 +1933,7 @@ def test_get_check(self, container: ops.Container):
19311933
'status': 'up',
19321934
'failures': 0,
19331935
'threshold': 3,
1936+
'change-id': '1',
19341937
})
19351938
])
19361939
c = container.get_check('c1')
@@ -1954,13 +1957,15 @@ def test_get_check(self, container: ops.Container):
19541957
'status': 'up',
19551958
'failures': 0,
19561959
'threshold': 3,
1960+
'change-id': '1',
19571961
}),
19581962
pebble.CheckInfo.from_dict({
19591963
'name': 'c2',
19601964
'level': 'alive',
19611965
'status': 'down',
19621966
'failures': 2,
19631967
'threshold': 2,
1968+
'change-id': '2',
19641969
}),
19651970
])
19661971
with pytest.raises(RuntimeError):

0 commit comments

Comments
 (0)