Files
computer-playbook/utils/docker/image/config.py
T
kevinveenbirkenbachandClaude Opus 5 685c6b50ec test(images): require a mount for every VOLUME an image declares
An unmounted VOLUME does not fail: docker creates an anonymous volume for
it, the container works, and the gap shows up later as a volume nobody can
attribute. sys-ctl-hlth-volumes fails the deploy over exactly that, and its
whitelist keys on volume ids, which are new on every deploy, so the gap
cannot be suppressed there either. e0b973f9bb fixed one such case in
web-svc-xmpp by hand; this is the check that would have found it.

Attribution is the whole difficulty. meta/services.yml declares an image
under a key, and the compose file often runs it under another name:
web-app-jitsi declares the image under 'jitsi' and renders the service as
'web', web-app-erpnext runs one image as seven services, and the two RDBMS
consumer keys name no service of their own because the container comes from
sys-svc-rdbms. The sweep therefore unions the entry key with its
proxy.service_key and delegates the RDBMS keys to their provider role. A
first version keyed on the entry alone and reported six roles that mount
every path correctly, web-svc-xmpp among them.

Reading a config blob section is now its own module beside the registry
client, because the two readers were the same walk twice and registry.py
sat three lines under the file-size cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-17 21:17:25 +02:00

149 lines
5.0 KiB
Python

"""What an image's config blob declares: its healthcheck and its volumes.
The manifest names a config blob and the blob carries the sections a
``Dockerfile`` baked in. Reading one section is the same walk as reading
another, so both callers share it: resolve the index to a platform, read the
blob, take the section.
Every answer is three-valued. ``{}`` means the image definitively declares
nothing, ``None`` means the walk could not finish, and a caller that reads the
two as the same thing turns an auth wall or a rate limit into evidence.
"""
from __future__ import annotations
from utils.docker.mirror import mirror_image
from utils.docker.registry import (
cache_read,
cache_write,
fetch_blob,
fetch_manifest,
platform_digest,
probe_order,
)
def _section_at(
image: str, reference: str, section: str, os_name: str, architecture: str
) -> dict | None:
doc = fetch_manifest(image, reference)
if doc is None:
return None
manifests = doc.get("manifests")
if isinstance(manifests, list) and manifests:
digest = platform_digest(manifests, os_name, architecture)
if digest is None:
return None
doc = fetch_manifest(image, digest)
if doc is None or doc.get("manifests"):
return None
config_digest = (doc.get("config") or {}).get("digest")
if not isinstance(config_digest, str) or not config_digest:
return None
blob = fetch_blob(image, config_digest)
if blob is None:
return None
return (blob.get("config") or {}).get(section) or {}
def _section_probed(
section: str, kind: str, image: str, reference: str, os_name: str, architecture: str
) -> tuple[dict | None, str]:
"""The named config section, and which address answered for it.
Args:
section: key under the blob's ``config``, e.g. ``Healthcheck``.
kind: cache namespace, so two sections never share an entry.
image: image name, with or without registry host.
reference: tag or digest.
os_name: platform to resolve a multi-arch index to.
architecture: platform to resolve a multi-arch index to.
Returns:
``(section, source)`` where source is ``cache``, ``mirror``,
``upstream`` or ``none``.
"""
cached = cache_read(kind, image, reference, os_name, architecture)
if isinstance(cached, dict):
return cached, "cache"
mirrored = mirror_image(image)
for candidate in probe_order(image):
found = _section_at(candidate, reference, section, os_name, architecture)
if found is not None:
cache_write(kind, found, image, reference, os_name, architecture)
return found, "mirror" if candidate == mirrored else "upstream"
return None, "none"
def image_healthcheck(
image: str,
reference: str,
*,
os_name: str = "linux",
architecture: str = "amd64",
) -> dict | None:
"""Return the ``HEALTHCHECK`` baked into ``image:reference``.
Args:
image: image name, with or without registry host.
reference: tag or digest.
os_name: platform to resolve a multi-arch index to.
architecture: platform to resolve a multi-arch index to.
Returns:
The image's healthcheck config, ``{}`` when the image definitively
declares none, and ``None`` when the answer is indeterminate.
Reads from the mirror first and falls back to the upstream registry only
for images the mirror does not carry, so a full sweep does not spend the
upstream's rate limit. Determinate answers are cached on disk.
"""
return image_healthcheck_probed(
image, reference, os_name=os_name, architecture=architecture
)[0]
def image_healthcheck_probed(
image: str,
reference: str,
*,
os_name: str = "linux",
architecture: str = "amd64",
) -> tuple[dict | None, str]:
""":func:`image_healthcheck` plus which address answered.
Returns:
``(healthcheck, source)``. Reading upstream is the documented fallback
for images the mirror does not carry; a caller that sweeps many images
can use the counts to tell a fallback from a mirror that stopped
working.
"""
return _section_probed(
"Healthcheck", "healthcheck", image, reference, os_name, architecture
)
def image_volumes(
image: str,
reference: str,
*,
os_name: str = "linux",
architecture: str = "amd64",
) -> dict | None:
"""Return the ``VOLUME`` paths baked into ``image:reference``.
Args:
image: image name, with or without registry host.
reference: tag or digest.
os_name: platform to resolve a multi-arch index to.
architecture: platform to resolve a multi-arch index to.
Returns:
A mapping whose keys are the declared container paths, ``{}`` when the
image definitively declares none, and ``None`` when the answer is
indeterminate.
"""
return _section_probed(
"Volumes", "volumes", image, reference, os_name, architecture
)[0]