Files
computer-playbook/utils/docker/image/architectures.py
T
kevinveenbirkenbachandClaude Opus 5 0bb437d9ce feat(images): check declared architectures against the registry, and make a pin say why
Nothing held a role's architectures to reality: test_image_reachable proves a tag exists, test_arm64_images proves four named roles publish arm64, and the closure lint only proves the declarations do not intersect to nothing. An amd64-only bump became a scheduling failure on an arm64 row rather than a red test.

A new external test sweeps every pinned image and judges a role by the intersection over its services, because a role runs only where all of its images run; judging per image would demand arm64 from an amd64-only role's nginx and call a narrowing stale because nginx publishes arm64 while the application does not. A role declaring nothing must cover every architecture the matrix rotates; a role that narrows must narrow to exactly its capability, which is what retires its TODO by itself. An unread image downgrades the stale verdict to a warning, since the image a probe could not read may be the one the narrowing exists for. The probe reads the mirror first and caches on disk, because the sweep is ~160 manifests and Docker Hub counts its allowance per IP, shared.

A new lint requires every architectures declaration to carry a nocheck marker and a Reason. bigbluebutton and opentalk keep their pins with measured reasons; decidim, pretix and taiga lose theirs, because each builds its own image and their upstream Dockerfiles are arch-neutral. Their source builds are not converted yet, so until that lands the matrix may hand them an arm64 row that fails at FROM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 22:11:48 +02:00

71 lines
2.8 KiB
Python

"""Reduce the platforms a role's images publish to where the role can run.
A role is schedulable only where *every* image it pins can run, so its
capability is the intersection over its services rather than a property of
any one of them. Kept apart from the registry probe so the reduction can be
tested without a network.
"""
from __future__ import annotations
from utils.github.variant.pools import ARCHITECTURES
LINUX = "linux"
def runnable_architectures(platforms) -> set[str]:
"""Return the matrix architectures *platforms* can be scheduled on.
Args:
platforms: ``os/architecture`` strings as a registry reports them.
Returns:
The subset of :data:`ARCHITECTURES` offered for ``linux``. Anything
else a manifest lists - another operating system, an architecture the
matrix does not rotate - is dropped, because it cannot carry a row.
"""
offered = set()
for entry in platforms or ():
os_name, _, architecture = str(entry).partition("/")
if os_name == LINUX and architecture in ARCHITECTURES:
offered.add(architecture)
return offered
def role_capability(
offered,
) -> tuple[set[str] | None, dict[str, list[str]], list[str]]:
"""Intersect what a role's images publish, and name what each one lacks.
Args:
offered: mapping of image label to its ``os/architecture`` set, or to
``None`` for an image whose registry answer was indeterminate.
Returns:
``(capability, blame, unread)``. ``capability`` is the intersection
over the images that answered, or ``None`` when none did - which a
caller must not read as "runs nowhere", since a throttled sweep
produces it. ``blame`` maps an architecture to the labels that cannot
run it, so a failure names the pin to change rather than only the
role. ``unread`` lists the labels that did not answer.
An incomplete read can only ever overstate the capability, because the
intersection skips what it could not see. A caller may therefore trust
``capability`` to decide an architecture is missing, but must not conclude
from it that a narrowing is unnecessary while ``unread`` is non-empty: the
image it could not read may be the one the narrowing exists for.
"""
capability: set[str] | None = None
blame: dict[str, list[str]] = {}
unread: list[str] = []
for label in sorted(offered):
platforms = offered[label]
if platforms is None:
unread.append(label)
continue
runnable = runnable_architectures(platforms)
for architecture in sorted(set(ARCHITECTURES) - runnable):
blame.setdefault(architecture, []).append(label)
capability = runnable if capability is None else capability & runnable
return capability, blame, unread