mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Moodle renders the same msmtprc as WordPress and mounts it into two services,
but nothing ever proved a message left the container: its healthcheck opens the
FPM port and nothing else. Moodle 0 of run 31658241728 passed green while mail
could not have worked -- the defect 2da285067 fixed for WordPress was invisible
here, and would have stayed invisible until a user reset a password.
WordPress proves delivery with the msmtp_curl flavor: the probe sends one mail
and writes a marker, and a task after convergence waits for that marker. Moodle
cannot use it, because curl cannot judge a port that answers FastCGI. Rather
than add a second msmtp_* class per liveness shape, the declaration now composes:
healthcheck:
flavor:
- msmtp
- connect
port_key: fpm
msmtp stops being a flavor and becomes a prefix -- shell that runs on the way
past and never decides liveness. connect joins the probes as the one that only
asks whether the port accepts a TCP connection. A declaration names exactly one
probe and any number of prefixes, as a string or a list; msmtp_curl stays as an
alias for [msmtp, curl], and the lint that validates declarations now shares
resolve_flavors with the lookup instead of keeping its own idea of a flavor.
The module became five responsibilities in one file, so it is a package:
probes.py answers which probes exist, prefixes.py what runs before them,
compose.py how a declaration becomes a rendered block. The curl helpers sit with
the probes and mail_branch with the prefix -- each next to its only caller,
rather than together in a module header.
Two defects in this work were found by measuring, not reading:
- the timing merge took the maximum across prefixes only, which cut Moodle's
start period from 15m to 120s and would have marked a heavy PHP app dead
while it was still installing. A prefix may now raise a timing and never
lower one, pinned by test.
- composing moved msmtp_curl's interval from 30s to 1m, because the composed
class derives from Curl where MsmtpCurl derived from Probe. That is the
interval every other curl probe uses, but it was not intended; it now has
its own test rather than riding along unseen.
Regression measured across the whole repo rather than argued: every declared
healthcheck was rendered with the pre-refactor code and with this one. 58 render
byte-identically, 1 is Moodle's intentionally changed declaration, and 2 differ
-- friendica and wordpress, in that interval alone, with the probe command
itself unchanged.
Coverage was checked in both directions. No test was removed: the four deleted
lines are an import, the registry literal, one class name in a url test, and the
helper, which now goes through compose() instead of reaching into PROBES -- a
tightening. New code had two gaps, both closed: connect was the only probe whose
emitted command no test pinned, and compose() raised KeyError for an unknown
name unobserved.
Not verified: no deploy has run any of these probes. The composed class is built
dynamically, so what it emits is proven for all 59 services while docker
accepting it is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""Turns a service's ``healthcheck`` declaration into a rendered compose block.
|
|
|
|
A declaration names exactly one probe and any number of prefixes, either as one
|
|
string or as a list. This module owns that grammar: the aliases, the validation
|
|
and the timing arithmetic that decides what a composed probe inherits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from utils.cache.yaml import dump_yaml_str
|
|
from utils.docker.healthcheck.prefixes import PREFIXES, MsmtpPrefix
|
|
from utils.docker.healthcheck.probes import PROBES, Custom, Probe
|
|
|
|
ALIASES: dict[str, list[str]] = {"msmtp_curl": ["msmtp", "curl"]}
|
|
|
|
_UNITS = {"s": 1.0, "m": 60.0, "h": 3600.0}
|
|
|
|
|
|
def known_flavors() -> str:
|
|
return ", ".join(sorted({*PROBES, *PREFIXES, *ALIASES}))
|
|
|
|
|
|
def resolve_flavors(flavors: str | list[str]) -> list[str]:
|
|
"""Expand what a service declared into a flat list of names.
|
|
|
|
Args:
|
|
flavors: one name, or the list form ``[msmtp, connect]``.
|
|
|
|
Returns:
|
|
The names in declaration order, aliases substituted.
|
|
"""
|
|
names = [flavors] if isinstance(flavors, str) else list(flavors)
|
|
expanded: list[str] = []
|
|
for name in names:
|
|
expanded.extend(ALIASES.get(name, [name]))
|
|
return [name for name in expanded if name]
|
|
|
|
|
|
def timing_rank(value: Any) -> float:
|
|
"""Order timings so the longest wins, whatever unit it is spelled in.
|
|
|
|
Args:
|
|
value: a docker duration such as ``20s`` or ``15m``, or a bare count.
|
|
"""
|
|
text = str(value)
|
|
if text.isdigit():
|
|
return float(text)
|
|
return float(text[:-1]) * _UNITS.get(text[-1:], 1.0) if text[:-1].isdigit() else 0.0
|
|
|
|
|
|
def _split(names: list[str]) -> tuple[str, list[type[MsmtpPrefix]]]:
|
|
"""Sort declared names into the one probe and its prefixes.
|
|
|
|
Raises:
|
|
KeyError: a name is neither a probe nor a prefix.
|
|
ValueError: the declaration carries no probe, or more than one.
|
|
"""
|
|
unknown = [name for name in names if name not in PROBES and name not in PREFIXES]
|
|
if unknown:
|
|
raise KeyError(unknown[0])
|
|
probe_names = [name for name in names if name in PROBES]
|
|
if len(probe_names) != 1:
|
|
raise ValueError(
|
|
f"healthcheck flavors {names} must name exactly one probe, "
|
|
f"got {probe_names or 'none'}"
|
|
)
|
|
return probe_names[0], [PREFIXES[name] for name in names if name in PREFIXES]
|
|
|
|
|
|
def compose(flavors: str | list[str], **context: Any) -> Probe:
|
|
"""Build the probe a declaration asks for, prefixes applied.
|
|
|
|
Args:
|
|
flavors: one name, or a list of prefixes plus exactly one probe.
|
|
context: port, path, hostname, samples and any extras a part needs.
|
|
|
|
Returns:
|
|
The probe; with prefixes it is wrapped so ``test`` renders CMD-SHELL.
|
|
|
|
A prefix cannot judge the container, so the probe keeps the verdict and the
|
|
prefix only ever RAISES a timing -- the composed start period is the longest
|
|
of the two, never the prefix's alone. Lowering it once would have cut a
|
|
fifteen minute start window down to two.
|
|
"""
|
|
names = resolve_flavors(flavors)
|
|
probe_name, prefixes = _split(names)
|
|
probe = PROBES[probe_name](**context)
|
|
if not prefixes:
|
|
return probe
|
|
|
|
rendered = "".join(prefix.render(context) for prefix in prefixes)
|
|
inner = probe.shell()
|
|
|
|
class Composed(type(probe)): # type: ignore[misc, valid-type]
|
|
def test(self) -> list[str]:
|
|
return ["CMD-SHELL", f"{rendered}{inner} || exit 1"]
|
|
|
|
for attribute in ("timeout", "retries", "start_period"):
|
|
candidates = [getattr(probe, attribute)] + [
|
|
getattr(prefix, attribute) for prefix in prefixes
|
|
]
|
|
setattr(Composed, attribute, max(candidates, key=timing_rank))
|
|
return Composed(**context)
|
|
|
|
|
|
def build(flavor: str | list[str], overrides: dict[str, Any], **context: Any) -> str:
|
|
"""Render a service's healthcheck block as YAML, starting at column zero.
|
|
|
|
Args:
|
|
flavor: a name, a list of prefixes plus one probe, or empty for an
|
|
explicit ``test`` argv.
|
|
overrides: the service's healthcheck entry from services.yml.
|
|
context: port, path, hostname, samples and any flavor specific extras.
|
|
|
|
Raises:
|
|
KeyError: a name is unknown.
|
|
"""
|
|
probe = compose(flavor, **context) if flavor else Custom(**context)
|
|
return dump_yaml_str({"healthcheck": probe.block(overrides)}, width=10**6).rstrip(
|
|
"\n"
|
|
)
|