mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
test(lint): judge a healthcheck against what its base was measured to hold
c31c62efa checked four of the repository's 86 healthcheck declarations. It read
the probe's CMD form only, scoped itself to slim, and counted bash as a shell
builtin, which is where most of the gap sat: tcp, connect and http_status all
render CMD bash -c over /dev/tcp, so 31 declarations were skipped as needing
nothing. It now examines 18, and what it still skips it skips for a reason.
bash is a program like any other here. Its absence is not hypothetical: alpine
has no /bin/bash at all, and its busybox /bin/sh does not implement /dev/tcp,
so the redirection those three flavors are built on needs the real shell rather
than any shell. No role pairs them today, which is why this is prevention and
not a fix.
The CMD-SHELL form is parsed instead of ignored: || reads as alternatives, &&
and ; as a sequence needing all its parts, and shell builtins are dropped so a
trailing || exit 1 cannot make every requirement look satisfiable. That brings
in nc, http and the msmtp compositions, six declarations the old shape could
not see. http is the one that needed the alternatives: it runs wget or curl and
is satisfied by either.
alpine moves from a docstring exemption into the scope with a measured table
behind it. Read out of the images with docker create and docker cp, which
reports an absent path rather than a shell's opinion of it: debian slim carries
/bin/bash at 1298416 bytes and none of curl, wget or nc; nginx alpine carries
curl at 272432 bytes as a real binary, wget and nc as busybox applets, and no
bash. Adding a family means measuring it the same way.
Vendor images stay out. Their contents are not knowable from the tree and most
carry no Dockerfile of ours, so the rule's remedy has nowhere to land; skipping
them honestly beats a table that rots.
Sixteen cases were run against the predicate, one per defect class and its
satisfied counterpart. Two corrected the work: nc is a busybox applet on alpine,
which the first table omitted and which made nc-on-alpine flag wrongly, and this
file's own prose named a Debian codename, which test_no_debian_codename_tags
caught in the gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c31c62efaf
commit
cc6da24174
1 file changed
+112
-64
@@ -1,12 +1,12 @@
|
||||
"""A healthcheck that shells out to a binary must find it in the image it runs in.
|
||||
"""A healthcheck must find every program it runs in the image it runs in.
|
||||
|
||||
A ``-slim`` tag is the upstream's stripped variant: it carries the
|
||||
runtime and nothing around it. ``node:26-bookworm`` ships ``/usr/bin/curl``,
|
||||
``node:26-slim`` does not, and a ``healthcheck: flavor: curl`` renders
|
||||
``CMD curl -f <url>`` either way. The probe then fails from the first beat, and
|
||||
the container never reports healthy.
|
||||
A stripped base carries the runtime and little else, and which "little else"
|
||||
differs per family. The declaration does not know that: ``flavor: curl`` renders
|
||||
``CMD curl -f <url>`` and ``flavor: tcp`` renders ``CMD bash -c`` over
|
||||
``/dev/tcp`` whatever the image underneath is. Where the program is absent the
|
||||
probe fails from its first beat and the container never reports healthy.
|
||||
|
||||
The two deploy modes answer differently, and neither answer names the binary.
|
||||
The two deploy modes answer differently, and neither answer names the program.
|
||||
``sys-svc-compose`` waits on the database container alone in compose and on
|
||||
every stack task in swarm, so a permanently unhealthy service passes unnoticed
|
||||
under compose and stops the deploy under swarm: the task dies with ``non-zero
|
||||
@@ -14,23 +14,26 @@ exit (137): dockerexec: unhealthy container``, is rescheduled, dies again, and
|
||||
the wait fails. Every deployment holding the service in its closure fails with
|
||||
it, reported against the service that was waited for.
|
||||
|
||||
Scope
|
||||
=====
|
||||
Roles whose ``meta/services.yml`` declares a healthcheck flavor on an image
|
||||
whose ``version`` names a stripped variant. The binary is read from the probe
|
||||
the declaration renders (:func:`utils.docker.healthcheck.compose.compose`), not
|
||||
restated here, so a flavor that changes its command moves this check with it. A
|
||||
flavor whose command is a shell builtin or the image's own interpreter needs no
|
||||
package and is not checked.
|
||||
What is checked
|
||||
===============
|
||||
Every ``meta/services.yml`` healthcheck whose service sits on a base this file
|
||||
has measured. The programs come from the probe the declaration renders
|
||||
(:func:`utils.docker.healthcheck.compose.compose`), both its ``CMD`` and its
|
||||
``CMD-SHELL`` form, so a flavor that changes its command moves this check with
|
||||
it rather than drifting from a table restated here. ``||`` in a shell probe is
|
||||
read as alternatives, ``&&`` and ``;`` as a sequence that needs all of its
|
||||
parts, and shell builtins are dropped so a trailing ``|| exit 1`` cannot make
|
||||
every requirement look satisfiable.
|
||||
|
||||
The role's ``files/Dockerfile`` must then install that binary. An image that
|
||||
already ships it is not a defence: the tag is a moving target, and the role that
|
||||
depends on the binary is the one that should say so.
|
||||
A requirement is met when the measured base provides the program or the role's
|
||||
``files/Dockerfile`` installs it. A base that is neither family is skipped: a
|
||||
vendor image's contents are not knowable from the tree, and for most of them
|
||||
there is no Dockerfile of ours to add anything to.
|
||||
|
||||
Per-role opt-out
|
||||
================
|
||||
Per-service opt-out
|
||||
===================
|
||||
``# nocheck: healthcheck-binary`` on the ``flavor`` line or the one above it,
|
||||
with a reason naming where the binary comes from instead.
|
||||
with a reason naming where the program comes from instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -48,49 +51,77 @@ from . import PROJECT_ROOT
|
||||
|
||||
RULE = "healthcheck-binary"
|
||||
|
||||
STRIPPED = ("slim",)
|
||||
"""Tag fragments that name an upstream's stripped variant.
|
||||
PROVIDED: dict[str, frozenset[str]] = {
|
||||
"slim": frozenset({"bash", "sh"}),
|
||||
"alpine": frozenset({"sh", "curl", "wget", "nc"}),
|
||||
}
|
||||
"""What each stripped family ships, read out of the images themselves.
|
||||
|
||||
``alpine`` is deliberately out: measured against ``nginx:1.31.6-alpine``, the
|
||||
image carries ``/usr/bin/curl`` as a real 272 KB binary and ``wget`` as a
|
||||
busybox applet, so the four alpine roles that declare those flavors are served
|
||||
by their base today. Widening this tuple to cover them needs that measurement
|
||||
repeated per image, not an assumption from the word "alpine".
|
||||
Measured with ``docker create`` plus ``docker cp``, which reports a path that is
|
||||
absent instead of reporting a shell's opinion of it:
|
||||
|
||||
* ``node:26-slim`` and ``python:3.13.15-slim`` carry ``/bin/bash`` at 1298416
|
||||
bytes and none of ``curl``, ``wget`` or ``nc``. The full ``node:26`` variant
|
||||
does carry ``/usr/bin/curl`` at 280800 bytes, which is why moving a role onto
|
||||
the stripped tag takes the program away without touching its healthcheck.
|
||||
* ``nginx:1.31.6-alpine`` carries ``/usr/bin/curl`` at 272432 bytes as a real
|
||||
binary, ``/usr/bin/wget`` and ``/usr/bin/nc`` as busybox applets, and has no
|
||||
``/bin/bash`` at all. Its ``/bin/sh`` is busybox, which does not implement
|
||||
``/dev/tcp``, so the flavors built on that redirection need the real shell
|
||||
rather than any shell.
|
||||
|
||||
Adding a family means measuring it the same way, not inferring it from the tag.
|
||||
"""
|
||||
|
||||
SHELL_BUILTINS = frozenset({"sh", "bash", "test", "true", "exit", "CMD-SHELL"})
|
||||
"""Probe commands that need no package: the image's own shell runs them."""
|
||||
SHELL_BUILTINS = frozenset({"exit", "true", "false", "test", "[", ":", "echo"})
|
||||
"""Words a probe may invoke that no package provides."""
|
||||
|
||||
|
||||
def _probe_binary(flavor: object) -> str | None:
|
||||
"""The executable a declared flavor asks docker to run, if it is a program.
|
||||
def _families(version: object) -> list[str]:
|
||||
"""The measured families a version tag names, in declaration order."""
|
||||
text = str(version)
|
||||
return [family for family in PROVIDED if family in text]
|
||||
|
||||
|
||||
def _requirements(flavor: object) -> list[list[str]]:
|
||||
"""The programs a declared flavor runs, as alternatives of sequences.
|
||||
|
||||
``[["wget"], ["curl"]]`` means either one satisfies the probe;
|
||||
``[["msmtp", "curl"]]`` would mean it needs both.
|
||||
|
||||
Args:
|
||||
flavor: the ``healthcheck.flavor`` value, one name or a list.
|
||||
"""
|
||||
try:
|
||||
probe = compose(flavor, port=80, path="/", hostname="localhost")
|
||||
test = probe.test()
|
||||
test = [
|
||||
str(part)
|
||||
for part in compose(flavor, port=80, path="/", hostname="h").test()
|
||||
]
|
||||
except Exception:
|
||||
return None
|
||||
argv = [str(part) for part in test]
|
||||
if not argv or argv[0] != "CMD" or len(argv) < 2:
|
||||
return None
|
||||
binary = argv[1]
|
||||
return None if binary in SHELL_BUILTINS else binary
|
||||
return []
|
||||
if not test:
|
||||
return []
|
||||
if test[0] == "CMD":
|
||||
return [[test[1]]] if len(test) > 1 else []
|
||||
if len(test) < 2:
|
||||
return []
|
||||
groups: list[list[str]] = []
|
||||
for alternative in re.split(r"\|\|", test[1]):
|
||||
words = [
|
||||
segment.strip().split()[0]
|
||||
for segment in re.split(r"&&|;", alternative)
|
||||
if segment.strip()
|
||||
]
|
||||
needed = [word for word in words if word not in SHELL_BUILTINS]
|
||||
if needed:
|
||||
groups.append(needed)
|
||||
return groups
|
||||
|
||||
|
||||
def _is_stripped(version: object) -> bool:
|
||||
text = str(version)
|
||||
return any(fragment in text for fragment in STRIPPED)
|
||||
|
||||
|
||||
def _installs(dockerfile_body: str, binary: str) -> bool:
|
||||
"""Whether the Dockerfile names the binary as something it installs."""
|
||||
return (
|
||||
re.search(rf"(?<![\w-]){re.escape(binary)}(?![\w-])", dockerfile_body)
|
||||
is not None
|
||||
)
|
||||
def _installs(dockerfile_body: str, program: str) -> bool:
|
||||
"""Whether the Dockerfile names the program as something it installs."""
|
||||
pattern = rf"(?<![\w-]){re.escape(program)}(?![\w-])"
|
||||
return re.search(pattern, dockerfile_body) is not None
|
||||
|
||||
|
||||
def _flavor_lines(lines: list[str]) -> dict[str, int]:
|
||||
@@ -116,7 +147,7 @@ def _flavor_lines(lines: list[str]) -> dict[str, int]:
|
||||
|
||||
|
||||
class TestHealthcheckBinaryOnSlimBase(unittest.TestCase):
|
||||
def test_a_stripped_base_installs_the_binary_its_healthcheck_runs(self):
|
||||
def test_a_stripped_base_provides_every_program_its_healthcheck_runs(self):
|
||||
offenders = []
|
||||
for services in sorted(
|
||||
(PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_META_SERVICES}")
|
||||
@@ -131,32 +162,49 @@ class TestHealthcheckBinaryOnSlimBase(unittest.TestCase):
|
||||
health = entry.get("healthcheck")
|
||||
if not isinstance(health, dict) or "flavor" not in health:
|
||||
continue
|
||||
if not _is_stripped(entry.get("version", "")):
|
||||
families = _families(entry.get("version", ""))
|
||||
if not families:
|
||||
continue
|
||||
binary = _probe_binary(health["flavor"])
|
||||
if binary is None:
|
||||
groups = _requirements(health["flavor"])
|
||||
if not groups:
|
||||
continue
|
||||
if is_suppressed_at(lines, flavor_lines.get(key, 0), RULE):
|
||||
continue
|
||||
dockerfile = role_dir / "files" / "Dockerfile"
|
||||
body = read_text(str(dockerfile)) if dockerfile.is_file() else ""
|
||||
if _installs(body, binary):
|
||||
available = set().union(*(PROVIDED[family] for family in families))
|
||||
satisfied = any(
|
||||
all(
|
||||
program in available or _installs(body, program)
|
||||
for program in group
|
||||
)
|
||||
for group in groups
|
||||
)
|
||||
if satisfied:
|
||||
continue
|
||||
wanted = " or ".join(" and ".join(group) for group in groups)
|
||||
offenders.append(
|
||||
f"{role_dir.name}: service '{key}' declares healthcheck "
|
||||
f"flavor {health['flavor']!r}, which runs {binary!r}, on the "
|
||||
f"stripped image {entry.get('image')}:{entry.get('version')}; "
|
||||
f"{'files/Dockerfile does not install it' if body else 'the role has no files/Dockerfile'}"
|
||||
f"flavor {health['flavor']!r}, which runs {wanted}, on the "
|
||||
f"{'/'.join(families)} image {entry.get('image')}:"
|
||||
f"{entry.get('version')} that provides "
|
||||
f"{', '.join(sorted(available))}; "
|
||||
+ (
|
||||
"files/Dockerfile installs none of them"
|
||||
if body
|
||||
else "the role has no files/Dockerfile"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[],
|
||||
offenders,
|
||||
"A stripped base carries the runtime and nothing around it, so a "
|
||||
"healthcheck binary has to be installed by the role that depends on "
|
||||
"it. Add it to the role's files/Dockerfile, pick a flavor the image "
|
||||
f"can already run, or mark the line `# nocheck: {RULE}` with a "
|
||||
"reason naming where the binary comes from:\n " + "\n ".join(offenders),
|
||||
"A stripped base carries the runtime and little else, so a program a "
|
||||
"healthcheck runs has to come from the base or from the role that "
|
||||
"depends on it. Install it in the role's files/Dockerfile, pick a "
|
||||
"flavor the image can already run, or mark the line "
|
||||
f"`# nocheck: {RULE}` with a reason naming where the program comes "
|
||||
"from:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user