From e42d635523fcb90ecdd504b0f0290df7015991e7 Mon Sep 17 00:00:00 2001 From: fwazb Date: Mon, 20 Jul 2026 19:39:26 -0700 Subject: [PATCH 1/3] Bind no-send releases to inspected images --- docs/gcp-leoclean-nosend-service-package.md | 90 +- docs/workstreams/gcp-leoclean/goals.md | 64 +- ops/gcp_leoclean_nosend_package.py | 795 +++++++++++- tests/test_gcp_leoclean_nosend_package.py | 1227 ++++++++++++++++++- 4 files changed, 2105 insertions(+), 71 deletions(-) diff --git a/docs/gcp-leoclean-nosend-service-package.md b/docs/gcp-leoclean-nosend-service-package.md index c91fc40..85c19fe 100644 --- a/docs/gcp-leoclean-nosend-service-package.md +++ b/docs/gcp-leoclean-nosend-service-package.md @@ -136,7 +136,7 @@ remain permitted because they cannot enter this image context. Use the exact values in `build-args.json` as Docker build arguments. The image build itself revalidates that those values equal `image-input.json`. -## Prepare one digest-bound release candidate +## Finalize one inspected digest-bound release After an authorized build and Artifact Registry push returns the immutable image digest: @@ -146,24 +146,63 @@ python3 ops/gcp_leoclean_nosend_package.py finalize \ --image-input /tmp/leoclean-nosend-image-context/image-input.json \ --image-reference \ europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging@sha256:<64-hex-digest> \ - --output-release /tmp/leoclean-nosend-release.json \ - --output-unit /tmp/leoclean-gcp-nosend.service + --expected-revision \ + --expected-input-sha256 \ + --expected-config-digest sha256: \ + --docker-binary /usr/bin/docker \ + --docker-host unix:///var/run/docker.sock \ + --docker-config /home/operator/.docker \ + --output-bundle /tmp/leoclean-nosend-release ``` -This command validates the supplied digest syntax, target repository, expected -descriptor labels, and generated unit. It does **not** inspect Artifact -Registry and therefore does not attest that the remote digest actually has the -declared platform, labels, input hash, or source revision. The candidate unit -must not be installed until a separate read-only registry-inspection receipt -binds that exact digest to `linux/amd64`, its immutable config digest, the -required labels, the image-input hash, and the reviewed source revision. +This command uses Docker to pull the exact Artifact Registry digest for +`linux/amd64`, inspects the immutable local image, and refuses to create either +bundle file unless the requested repository digest is present and the platform, +config digest, complete required label set, runtime-bearing image configuration, +image-input hash, and separately supplied reviewed source revision all match. +The config digest is a separate, manually reviewed trust input from the +authorized build output; copying it from this command's own registry inspection +would defeat the independent binding. This repository does not yet produce a +structured build-receipt artifact, so this slice must not be described as +proving build-receipt provenance. That producer belongs to the separately +reviewed build/push slice before live finalization. +The normalized inspection is embedded in strict release schema +`livingip.leocleanNoSendRelease.v2`; unit generation revalidates it. Release v1 +has no deployed consumer and is intentionally rejected; regenerate any local +v1 artifact. For this Linux-only target, inspection normalizes the deprecated +Windows-only Docker `ArgsEscaped` field while still requiring the exact +entrypoint and command arrays. + +The output parent must already exist as an absolute, non-symlink directory. +The output is one atomically published directory containing `release.json` and +`leoclean-gcp-nosend.service`. Both files are rendered, strictly JSON-decoded, +revalidated, and fsynced in a sibling temporary directory before a +platform-native no-replace directory rename. An existing or concurrently +created destination is never overwritten. The staged directory and parent are +fsynced; a post-publication durability failure removes only the same published +inode and re-fsyncs the parent. Any failure leaves no partial final bundle. + +The Docker binary, config directory, and local `unix://` daemon endpoint are +explicit inputs; inherited `DOCKER_*` overrides are removed. The pull does not +start a container, install a unit, restart a service, or contact Cloud SQL. +Registry authentication and this pull require a separate live-read approval. +The exact repository-digest reference must be absent before inspection; use a +fresh isolated Docker context if it is already cached. While holding an +advisory lock on the explicit Docker config directory, the command pulls, +records the dynamically observed config ID, then removes the reference only if +that ID is still unchanged and proves it absent before publishing the bundle. +Content-addressable layers may remain. A failed pull, inspection, or cleanup +returns a bounded error without forwarding Docker stderr. Tags, alternate repositories, alternate projects/instances/service accounts, -extra descriptor fields, and mismatched descriptor labels are rejected. +extra descriptor fields, wrong-architecture images, missing repository-digest +bindings, mismatched image labels, duplicate JSON keys, and non-finite JSON +values are rejected. ## Offline evidence and live gate -Offline tests prove descriptor strictness, artifact and synthetic identity binding, +Offline tests prove descriptor and inspection strictness, explicit local Docker +authority, cache-reference rollback, atomic bundle publication, artifact and synthetic identity binding, digest-only images, pinned runtime versions, a secret-free context, the capability-drop contract, no published ports, no Docker-socket mount, no environment file, and no production/VPS/Telegram target. CI also builds exactly @@ -180,9 +219,9 @@ records whether `linux/amd64` ran natively or through cross-platform emulation. Pinned base images and build cache may remain. The entrypoint command rejection is not a real Hermes tool invocation, and the -bridge-network smoke does not prove general network-egress denial. These tests -also do not prove canonical identity, Cloud SQL permissions, Artifact Registry -digest contents, +bridge-network smoke does not prove general network-egress denial. The offline +inspection tests use exact Docker JSON fixtures; they do not prove canonical +identity, Cloud SQL permissions, or the live Artifact Registry digest contents, or that `teleo-staging-1` currently has a compatible container engine, enough disk, the expected architecture, the expected service account, or an unused unit name. Those are the next read-only live preflight. If the @@ -191,17 +230,22 @@ stop instead of falling back to the production sync script or a mutable host virtual environment. Publishing the draft branch and stacked PR is only a review handoff. The next -deployment-evidence slice must add immutable Artifact Registry inspection and -make installation fail closed without its receipt. Live installation, Artifact -Registry publication, service restart, IAM/secret -changes, Cloud SQL access, and proposal staging remain separate approvals. +deployment-evidence step must execute one authorized remote registry read plus +the reversible local exact-reference cache mutation against the staging digest. +A later installer slice must prove installation and rollback consume the same +receipt. Live installation, Artifact Registry publication, service restart, +IAM/secret changes, Cloud SQL access, and proposal staging remain separate +approvals. ## Rollback and sunset -The rollback boundary is one release descriptor, one image digest, and the -dedicated no-send unit. Rollback restores the prior descriptor/unit and prior -digest; it must never restart or edit the production Leo, Telegram, or VPS -services. +This slice's rollback boundary is removal of the unpublished atomic output +bundle plus removal of the exact repository-digest reference that this command +introduced into an initially clean, advisory-locked cache; content-addressable +layers may remain. It installs no unit +and changes no running service. Restoring a prior descriptor, unit, and digest +belongs to the later installer slice, which must never restart or edit the +production Leo, Telegram, or VPS services. The fresh tmpfs profile intentionally has no session continuity. That is appropriate for isolated gateway/database and fresh-turn parity proof, but it diff --git a/docs/workstreams/gcp-leoclean/goals.md b/docs/workstreams/gcp-leoclean/goals.md index 662d787..cd56e79 100644 --- a/docs/workstreams/gcp-leoclean/goals.md +++ b/docs/workstreams/gcp-leoclean/goals.md @@ -20,39 +20,59 @@ cut over safely and retire the VPS after soak. - Complete: PR #179 least-privilege PostgreSQL authority is merged on main. - Complete: the revision-bound, structurally no-send runtime from PR #183 is merged into `main` at `2d88c9765bd9b43641d1efb04a1118c5ce0bce39`. -- Rebuilt from current `main`: PR #182 contains only the four functional - no-send service-package commits. The two stale proof-only commits were not - replayed; this file is the single replacement evidence update. +- Complete: PR #182 head + `3e59845454f5315e955734db7fe598ec4ba4cf70` was merged with merge commit + `94a8160ceeb6821fd113f4c843f024d5de99939a` after all five checks passed. +- Complete: PR #202 remained the exact reviewed three-file QEMU/PID-1 + correction at `ffe170007d0e20d77a5208263e5b33fc999139fc` after retargeting + to `main`, then merged with merge commit + `383996a43beebef57cf84a2ced53213e28a41dca` after all five checks passed. - PR #193 is source material only. It must not be merged wholesale; only the required identity-binding and OCI-lifecycle corrections belong in the narrow #182/#183 stack. -- Complete in the rebuilt code candidate: the narrow package correction binds - every packaged and synthetic-identity source byte to its reviewed Git blob; - container health binds the packaged/live config plus the exact sealed - no-send tool and plugin surface; and the disposable image smoke binds the - bootstrap user, entrypoint, command, healthcheck, and stop signal. -- Separate follow-up: candidate release finalization does not inspect Artifact - Registry. A narrow deployment-evidence slice must bind the immutable remote - digest, `linux/amd64` manifest/config, labels, input hash, and source revision - before any unit can be installed. -- Current gate: obtain a clean exact-head CI run and exact-revision human - review. Rebuilding or publishing the branch does not authorize merge or - deployment. +- Complete through PR #182 head `3e59845`: the narrow package + correction binds every packaged and synthetic-identity source byte to its + reviewed Git blob; container health binds the packaged/live config plus the + exact sealed no-send tool and plugin surface; and the disposable image smoke + also binds the implicit root bootstrap user, entrypoint, command, healthcheck, + and stop signal. The focused package/runtime/identity/OCI suite passed 204 + tests, the repository suite passed 2,338 with 3 skips, and one exact-revision + synthetic `linux/amd64` OCI lifecycle proved UID/GID `65532`, zero runtime + capabilities, no-send health, and complete candidate cleanup on the arm64 Mac + Docker daemon. No GCP or live service was contacted. +- In progress from exact current-main base + `383996a43beebef57cf84a2ced53213e28a41dca`: make release finalization + pull and inspect the exact immutable staging digest and refuse release/unit + output unless the repository binding, `linux/amd64` platform, config digest, + complete labels, runtime configuration, input hash, and source revision all + match. Trust-bound JSON is strict, release publication is atomic and + no-clobber, and failure rollback re-fsyncs its output parent. This slice does + not build, push, install, or deploy an image and has not accessed Artifact + Registry or GCP. +- Current gate: finish exact-revision offline tests, then stop for separate + branch-publication and human-review approval. Remote registry inspection is + a later, separately authorized live-read gate. - Not yet live-proven: staging VM/container engine, IAM/Secret Manager, Cloud SQL effective identity, restart behavior, or model/database parity. ## Remaining order -1. Obtain exact-revision human review for the no-send service-package stack. -2. Run the read-only `teleo-staging-1` host/engine/service-account preflight. -3. Provision the scoped Cloud SQL role/secret and deploy the separate staging +1. Finish, publish, and obtain exact-revision human review for immutable image + inspection and release finalization. +2. Add the structured build/push receipt producer that supplies the independently + reviewed manifest and config digests consumed by finalization. +3. Add the separate fail-closed installer/restart/verification/rollback slice + that consumes the exact inspection-bound release bundle. +4. Run the read-only Artifact Registry and + `teleo-staging-1` host/engine/service-account preflight. +5. Provision the scoped Cloud SQL role/secret and deploy the separate staging service. -4. Prove effective identity, reads, function-only proposal staging, denied +6. Prove effective identity, reads, function-only proposal staging, denied writes/escalation, receipts, restart stability, and rollback. -5. Run behavioral and database parity backtesting. -6. Connect the protected Observatory to canonical claims, proposals, pgvector +7. Run behavioral and database parity backtesting. +8. Connect the protected Observatory to canonical claims, proposals, pgvector projection, and runtime receipts. -7. Cut over, soak, restore-test, revoke VPS credentials, and retire the VPS. +9. Cut over, soak, restore-test, revoke VPS credentials, and retire the VPS. ## Current live-action boundary diff --git a/ops/gcp_leoclean_nosend_package.py b/ops/gcp_leoclean_nosend_package.py index 1ad8f68..fd30b2b 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -4,14 +4,19 @@ from __future__ import annotations import argparse +import ctypes +import errno +import fcntl import hashlib import json +import os import re import shutil import stat import subprocess import sys import tomllib +import uuid from collections.abc import Mapping from pathlib import Path from typing import Any @@ -27,7 +32,8 @@ if str(CONTRACT_ROOT) not in sys.path: from scripts import leo_identity_manifest as strict_identity_manifest # noqa: E402 IMAGE_INPUT_SCHEMA = "livingip.leocleanNoSendImageInput.v2" -RELEASE_SCHEMA = "livingip.leocleanNoSendRelease.v1" +IMAGE_INSPECTION_SCHEMA = "livingip.leocleanNoSendImageInspection.v1" +RELEASE_SCHEMA = "livingip.leocleanNoSendRelease.v2" ARTIFACT_MANIFEST_SCHEMA = "livingip.leocleanNoSendArtifactManifest.v1" ARTIFACT_RECEIPT_SCHEMA = "livingip.leocleanNoSendArtifactVerification.v1" IDENTITY_MANIFEST_SCHEMA = "livingip.leoIdentityManifest.v1" @@ -66,6 +72,57 @@ RUNTIME_ROOT = "/opt/livingip/leoclean-nosend" PROFILE_ROOT = "/run/leoclean-profile" RUNTIME_UID = 65532 RUNTIME_GID = 65532 +MAX_DOCKER_INSPECTION_BYTES = 1024 * 1024 +IMAGE_RUNTIME_CONFIG = { + "user": None, + "environment": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/16/bin", + "GOSU_VERSION=1.19", + "LANG=en_US.utf8", + "PG_MAJOR=16", + "PG_VERSION=16.14-1.pgdg12+1", + "PGDATA=/var/lib/postgresql/data", + "PYTHONDONTWRITEBYTECODE=1", + "PYTHONNOUSERSITE=1", + "PYTHONSAFEPATH=1", + ], + "working_dir": "", + "exposed_ports": {"5432/tcp": {}}, + "volumes": {"/var/lib/postgresql/data": {}}, + "shell": None, + "on_build": None, + "args_escaped": False, + "stop_timeout": None, + "tty": False, + "open_stdin": False, + "stdin_once": False, + "attach_stdin": False, + "attach_stdout": False, + "attach_stderr": False, + "network_disabled": False, + "hostname": "", + "domainname": "", + "mac_address": "", + "entrypoint": [ + "/opt/livingip/leoclean-nosend/venv/bin/python", + "/opt/livingip/leoclean-nosend/entrypoint.py", + ], + "cmd": ["service"], + "healthcheck": { + "test": [ + "CMD", + "/opt/livingip/leoclean-nosend/venv/bin/python", + "/opt/livingip/leoclean-nosend/entrypoint.py", + "health", + ], + "interval_ns": 15_000_000_000, + "timeout_ns": 10_000_000_000, + "start_period_ns": 30_000_000_000, + "start_interval_ns": 0, + "retries": 4, + }, + "stop_signal": "SIGTERM", +} HEX_40 = re.compile(r"^[0-9a-f]{40}$") HEX_64 = re.compile(r"^[0-9a-f]{64}$") @@ -132,21 +189,57 @@ def sha256_file(path: Path) -> str: def canonical_sha256(value: Any) -> str: - encoded = json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8") + try: + encoded = json.dumps( + value, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise PackageError("cannot canonically encode non-finite or unsupported JSON") from exc return hashlib.sha256(encoded).hexdigest() +def _strict_json_loads(encoded: str, label: str) -> Any: + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError("duplicate JSON key") + value[key] = item + return value + + def reject_constant(_value: str) -> Any: + raise ValueError("invalid JSON constant") + + try: + return json.loads( + encoded, + object_pairs_hook=reject_duplicate_keys, + parse_constant=reject_constant, + ) + except (json.JSONDecodeError, ValueError) as exc: + raise PackageError(f"{label} is invalid JSON") from exc + + def load_json(path: Path, label: str) -> dict[str, Any]: try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + encoded = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: raise PackageError(f"cannot read {label}: {type(exc).__name__}") from exc + value = _strict_json_loads(encoded, label) _require(isinstance(value, dict), f"{label} must be a JSON object") return value def write_json(path: Path, value: Mapping[str, Any], mode: int = 0o644) -> None: - path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + encoded = json.dumps(value, allow_nan=False, indent=2, sort_keys=True) + "\n" + except (TypeError, ValueError) as exc: + raise PackageError("cannot encode non-finite or unsupported JSON") from exc + path.write_text(encoded, encoding="utf-8") path.chmod(mode) @@ -1278,8 +1371,518 @@ def prepare_context( raise -def build_release_descriptor(image_input: dict[str, Any], image_reference: str) -> dict[str, Any]: +def validate_image_runtime_configuration(inspected: dict[str, Any]) -> dict[str, Any]: + """Validate the runtime-bearing Docker config shared by local and registry inspection.""" + + config = inspected.get("Config") + healthcheck = config.get("Healthcheck") if isinstance(config, dict) else None + raw_start_interval = healthcheck.get("StartInterval") if isinstance(healthcheck, dict) else None + raw_args_escaped = config.get("ArgsEscaped") if isinstance(config, dict) else None + expected_healthcheck_keys = {"Test", "Interval", "Timeout", "StartPeriod", "Retries"} + allowed_healthcheck_keys = (expected_healthcheck_keys, expected_healthcheck_keys | {"StartInterval"}) + boolean_defaults = { + "Tty": False, + "OpenStdin": False, + "StdinOnce": False, + "AttachStdin": False, + "AttachStdout": False, + "AttachStderr": False, + "NetworkDisabled": False, + } + string_defaults = {"WorkingDir": "", "Hostname": "", "Domainname": "", "MacAddress": ""} + allowed_config_keys = { + "ArgsEscaped", + "AttachStderr", + "AttachStdin", + "AttachStdout", + "Cmd", + "Domainname", + "Entrypoint", + "Env", + "ExposedPorts", + "Healthcheck", + "Hostname", + "Image", + "Labels", + "MacAddress", + "NetworkDisabled", + "OnBuild", + "OpenStdin", + "Shell", + "StdinOnce", + "StopSignal", + "StopTimeout", + "Tty", + "User", + "Volumes", + "WorkingDir", + } + _require( + isinstance(config, dict) + and isinstance(healthcheck, dict) + and set(config) <= allowed_config_keys + and config.get("User") in (None, "") + and config.get("Image") in (None, "") + # ArgsEscaped is a deprecated, Windows-only compatibility field. Linux + # builders may omit it or serialize either boolean; exact argv arrays + # below remain authoritative for this linux/amd64 image. + and (raw_args_escaped is None or type(raw_args_escaped) is bool) + and all(type(config.get(name, default)) is bool for name, default in boolean_defaults.items()) + and all(config.get(name, default) is default for name, default in boolean_defaults.items()) + and all(isinstance(config.get(name, default), str) for name, default in string_defaults.items()) + and all(config.get(name, default) == default for name, default in string_defaults.items()) + and config.get("StopTimeout") is None + and set(healthcheck) in allowed_healthcheck_keys + and all( + type(healthcheck.get(name)) is int + for name in ("Interval", "Timeout", "StartPeriod", "Retries") + ) + and ( + raw_start_interval is None + or ( + isinstance(raw_start_interval, int) + and not isinstance(raw_start_interval, bool) + and raw_start_interval == 0 + ) + ), + "inspected image runtime config is invalid", + ) + start_interval = 0 + runtime_config = { + "user": None, + "environment": config.get("Env"), + "working_dir": config.get("WorkingDir", ""), + "exposed_ports": config.get("ExposedPorts"), + "volumes": config.get("Volumes"), + "shell": config.get("Shell"), + "on_build": config.get("OnBuild"), + "args_escaped": False, + "stop_timeout": config.get("StopTimeout"), + "tty": config.get("Tty", False), + "open_stdin": config.get("OpenStdin", False), + "stdin_once": config.get("StdinOnce", False), + "attach_stdin": config.get("AttachStdin", False), + "attach_stdout": config.get("AttachStdout", False), + "attach_stderr": config.get("AttachStderr", False), + "network_disabled": config.get("NetworkDisabled", False), + "hostname": config.get("Hostname", ""), + "domainname": config.get("Domainname", ""), + "mac_address": config.get("MacAddress", ""), + "entrypoint": config.get("Entrypoint"), + "cmd": config.get("Cmd"), + "healthcheck": { + "test": healthcheck.get("Test"), + "interval_ns": healthcheck.get("Interval"), + "timeout_ns": healthcheck.get("Timeout"), + "start_period_ns": healthcheck.get("StartPeriod"), + "start_interval_ns": start_interval, + "retries": healthcheck.get("Retries"), + }, + "stop_signal": config.get("StopSignal"), + } + _require(runtime_config == IMAGE_RUNTIME_CONFIG, "inspected image runtime config is invalid") + return runtime_config + + +def validate_image_configuration(image_input: dict[str, Any], inspected: dict[str, Any]) -> dict[str, Any]: + """Validate and normalize the immutable runtime-bearing image configuration.""" + validate_image_input(image_input) + config_digest = inspected.get("Id") + _require( + isinstance(config_digest, str) + and config_digest.startswith("sha256:") + and _is_sha256(config_digest.removeprefix("sha256:")), + "inspected image config digest is invalid", + ) + variant = inspected.get("Variant") + _require( + inspected.get("Os") == "linux" + and inspected.get("Architecture") == "amd64" + and (variant is None or variant == ""), + "inspected image platform is not linux/amd64", + ) + config = inspected.get("Config") + labels = config.get("Labels") if isinstance(config, dict) else None + expected_labels = expected_image_labels(image_input) + _require( + isinstance(labels, dict) + and all(isinstance(key, str) and isinstance(item, str) for key, item in labels.items()) + and all(labels.get(key) == item for key, item in expected_labels.items()) + and not any(key.startswith("livingip.") and key not in expected_labels for key in labels), + "inspected image labels are not exact", + ) + runtime_config = validate_image_runtime_configuration(inspected) + return { + "config_digest": config_digest, + "platform": PLATFORM, + "labels": expected_labels, + "runtime_config": runtime_config, + } + + +def build_image_inspection( + image_input: dict[str, Any], + image_reference: str, + inspected: dict[str, Any], +) -> dict[str, Any]: + """Normalize one Docker inspection after an exact digest pull.""" + + validate_image_input(image_input) + match = IMAGE_REFERENCE.fullmatch(image_reference) + _require(match is not None, "inspected image must be the exact staging repository at a sha256 digest") + digest = f"sha256:{match.group(1)}" + observed = validate_image_configuration(image_input, inspected) + repository_digests = inspected.get("RepoDigests") + _require( + isinstance(repository_digests, list) + and all(isinstance(item, str) for item in repository_digests) + and len(repository_digests) == len(set(repository_digests)) + and image_reference in repository_digests, + "inspected image is not bound to the requested repository digest", + ) + value = { + "schema": IMAGE_INSPECTION_SCHEMA, + "status": "pass", + "reference": image_reference, + "digest": digest, + "config_digest": observed["config_digest"], + "platform": PLATFORM, + "labels": observed["labels"], + "runtime_config": observed["runtime_config"], + "input_sha256": image_input["input_sha256"], + "source_revision": image_input["runtime"]["teleo_git_head"], + } + validate_image_inspection(value, image_input=image_input) + return value + + +def validate_image_inspection(value: dict[str, Any], *, image_input: dict[str, Any]) -> None: + validate_image_input(image_input) + _require( + set(value) + == { + "schema", + "status", + "reference", + "digest", + "config_digest", + "platform", + "labels", + "runtime_config", + "input_sha256", + "source_revision", + }, + "image inspection fields are not exact", + ) + _require( + value.get("schema") == IMAGE_INSPECTION_SCHEMA and value.get("status") == "pass", + "image inspection did not pass", + ) + reference = value.get("reference") + match = IMAGE_REFERENCE.fullmatch(reference) if isinstance(reference, str) else None + _require(match is not None, "image inspection reference is not the exact staging digest") + _require(value.get("digest") == f"sha256:{match.group(1)}", "image inspection digest differs from its reference") + config_digest = value.get("config_digest") + _require( + isinstance(config_digest, str) + and config_digest.startswith("sha256:") + and _is_sha256(config_digest.removeprefix("sha256:")), + "image inspection config digest is invalid", + ) + _require(value.get("platform") == PLATFORM, "image inspection platform is not exact") + _require(value.get("labels") == expected_image_labels(image_input), "image inspection labels are not exact") + _require(value.get("runtime_config") == IMAGE_RUNTIME_CONFIG, "image inspection runtime config is not exact") + _require(value.get("input_sha256") == image_input["input_sha256"], "image inspection input binding drifted") + _require( + value.get("source_revision") == image_input["runtime"]["teleo_git_head"], + "image inspection source revision drifted", + ) + _assert_no_forbidden_descriptor_markers(value) + + +def _load_docker_inspection(raw: bytes) -> Any: + _require(len(raw) <= MAX_DOCKER_INSPECTION_BYTES, "registry image inspection output is too large") + try: + encoded = raw.decode("utf-8") + return _strict_json_loads(encoded, "registry image inspection output") + except (UnicodeDecodeError, PackageError) as exc: + raise PackageError("registry image inspection output is invalid") from exc + + +def _docker_environment() -> dict[str, str]: + return {name: value for name, value in os.environ.items() if not name.startswith("DOCKER_")} + + +def _validate_docker_authority(docker_binary: Path, docker_host: str, docker_config: Path) -> None: + _require(docker_binary.is_absolute(), "Docker binary must be absolute") + _require("\x00" not in str(docker_binary) and "\n" not in str(docker_binary), "Docker binary is invalid") + _require(docker_host.startswith("unix://"), "Docker host must be a local Unix endpoint") + socket_path = Path(docker_host.removeprefix("unix://")) + _require( + socket_path.is_absolute() and ".." not in socket_path.parts and "\x00" not in docker_host and "\n" not in docker_host, + "Docker host must be a local Unix endpoint", + ) + _require( + docker_config.is_absolute() and docker_config.is_dir() and not docker_config.is_symlink(), + "Docker config directory is missing or unsafe", + ) + + +class _DockerCacheLock: + def __init__(self, docker_config: Path) -> None: + self._docker_config = docker_config + self._descriptor: int | None = None + + def __enter__(self) -> None: + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(self._docker_config, flags) + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + if "descriptor" in locals(): + os.close(descriptor) + raise PackageError("Docker cache authority is already in use or unsafe") from exc + self._descriptor = descriptor + + def __exit__(self, _kind: object, _value: object, _traceback: object) -> None: + if self._descriptor is None: + return + try: + fcntl.flock(self._descriptor, fcntl.LOCK_UN) + finally: + os.close(self._descriptor) + self._descriptor = None + + +def _run_docker( + docker_binary: Path, + docker_host: str, + docker_config: Path, + arguments: list[str], + *, + check: bool, + timeout: int, +) -> subprocess.CompletedProcess[bytes]: + _validate_docker_authority(docker_binary, docker_host, docker_config) + return subprocess.run( + [str(docker_binary), "--host", docker_host, "--config", str(docker_config), *arguments], + check=check, + capture_output=True, + timeout=timeout, + env=_docker_environment(), + ) + + +def _local_image_reference_config_id( + docker_binary: Path, + docker_host: str, + docker_config: Path, + image_reference: str, +) -> str | None: + inventory = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "ls", "--digests", "--no-trunc", "--format", "{{json .}}", IMAGE_REPOSITORY], + check=True, + timeout=60, + ) + _require(len(inventory.stdout) <= MAX_DOCKER_INSPECTION_BYTES, "local image inventory output is too large") + match = IMAGE_REFERENCE.fullmatch(image_reference) + _require(match is not None, "local image reference is invalid") + expected_digest = f"sha256:{match.group(1)}" + matching_ids: set[str] = set() + for raw_line in inventory.stdout.splitlines(): + if not raw_line.strip(): + continue + try: + row = _load_docker_inspection(raw_line) + except PackageError as exc: + raise PackageError("local image inventory output is invalid") from exc + _require(isinstance(row, dict), "local image inventory row is invalid") + if row.get("Repository") != IMAGE_REPOSITORY or row.get("Digest") != expected_digest: + continue + config_id = row.get("ID") + _require( + isinstance(config_id, str) + and config_id.startswith("sha256:") + and _is_sha256(config_id.removeprefix("sha256:")), + "local image inventory config digest is invalid", + ) + matching_ids.add(config_id) + _require(len(matching_ids) <= 1, "local image inventory is ambiguous") + if not matching_ids: + return None + inventory_config_id = next(iter(matching_ids)) + result = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "inspect", image_reference], + check=True, + timeout=60, + ) + inspected = _load_docker_inspection(result.stdout) + _require(isinstance(inspected, list) and len(inspected) == 1, "local image reference inspection is not exact") + _require(isinstance(inspected[0], dict), "local image reference inspection payload is invalid") + config_id = inspected[0].get("Id") + _require( + isinstance(config_id, str) + and config_id.startswith("sha256:") + and _is_sha256(config_id.removeprefix("sha256:")), + "local image reference config digest is invalid", + ) + _require(config_id == inventory_config_id, "local image inventory and inspection disagree") + return config_id + + +def _remove_new_image_reference( + docker_binary: Path, + docker_host: str, + docker_config: Path, + image_reference: str, + expected_config_id: str, +) -> None: + observed_config_id = _local_image_reference_config_id( + docker_binary, + docker_host, + docker_config, + image_reference, + ) + if observed_config_id is None: + return + _require(observed_config_id == expected_config_id, "new registry image reference changed before cleanup") + _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "rm", image_reference], + check=True, + timeout=120, + ) + _require( + _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, + "new registry image reference cleanup did not pass", + ) + + +def inspect_registry_image( + image_input: dict[str, Any], + image_reference: str, + *, + expected_revision: str, + expected_input_sha256: str, + expected_config_digest: str, + docker_binary: Path, + docker_host: str, + docker_config: Path, +) -> dict[str, Any]: + """Pull and inspect one immutable Artifact Registry image without starting it.""" + + validate_image_input(image_input) + _require( + IMAGE_REFERENCE.fullmatch(image_reference) is not None, + "inspection image must be the exact staging repository at a sha256 digest", + ) + _require(_is_revision(expected_revision), "expected reviewed revision is invalid") + _require(_is_sha256(expected_input_sha256), "expected reviewed input SHA-256 is invalid") + _require( + isinstance(expected_config_digest, str) + and expected_config_digest.startswith("sha256:") + and _is_sha256(expected_config_digest.removeprefix("sha256:")), + "expected reviewed config digest is invalid", + ) + _require( + image_input["runtime"]["teleo_git_head"] == expected_revision, + "image input differs from the expected reviewed revision", + ) + _require( + image_input["input_sha256"] == expected_input_sha256, + "image input differs from the expected reviewed input SHA-256", + ) + _validate_docker_authority(docker_binary, docker_host, docker_config) + prestate_observed = False + pulled_config_id: str | None = None + inspection: dict[str, Any] | None = None + primary_error: BaseException | None = None + with _DockerCacheLock(docker_config): + try: + _require( + _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, + "exact registry image reference must be absent before inspection", + ) + prestate_observed = True + _run_docker( + docker_binary, + docker_host, + docker_config, + ["pull", "--quiet", "--platform", PLATFORM, image_reference], + check=True, + timeout=600, + ) + pulled_config_id = _local_image_reference_config_id( + docker_binary, + docker_host, + docker_config, + image_reference, + ) + _require(pulled_config_id is not None, "pulled registry image reference is missing") + result = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "inspect", image_reference], + check=True, + timeout=60, + ) + inspected = _load_docker_inspection(result.stdout) + _require(isinstance(inspected, list) and len(inspected) == 1, "registry image inspection is not exact") + _require(isinstance(inspected[0], dict), "registry image inspection payload is invalid") + inspection = build_image_inspection(image_input, image_reference, inspected[0]) + _require( + inspection["config_digest"] == expected_config_digest == pulled_config_id, + "inspected image differs from the expected reviewed config digest", + ) + except (OSError, subprocess.SubprocessError, PackageError) as exc: + primary_error = exc + finally: + try: + if prestate_observed: + if pulled_config_id is None: + pulled_config_id = _local_image_reference_config_id( + docker_binary, + docker_host, + docker_config, + image_reference, + ) + if pulled_config_id is not None: + _remove_new_image_reference( + docker_binary, + docker_host, + docker_config, + image_reference, + pulled_config_id, + ) + except (OSError, subprocess.SubprocessError, PackageError) as cleanup_error: + raise PackageError("immutable registry image cleanup failed") from cleanup_error + if primary_error is not None: + raise PackageError("immutable registry image inspection failed") from primary_error + _require(inspection is not None, "immutable registry image inspection failed") + return inspection + + +def build_release_descriptor( + image_input: dict[str, Any], + inspection: dict[str, Any], +) -> dict[str, Any]: + validate_image_input(image_input) + validate_image_inspection(inspection, image_input=image_input) + image_reference = inspection["reference"] match = IMAGE_REFERENCE.fullmatch(image_reference) _require(match is not None, "release image must be the exact staging repository at a sha256 digest") digest = f"sha256:{match.group(1)}" @@ -1290,10 +1893,12 @@ def build_release_descriptor(image_input: dict[str, Any], image_reference: str) "image": { "reference": image_reference, "digest": digest, + "config_digest": inspection["config_digest"], "platform": PLATFORM, "labels": labels, "input_sha256": image_input["input_sha256"], }, + "inspection": inspection, "image_input": image_input, } value = {**stable, "release_sha256": canonical_sha256(stable)} @@ -1316,7 +1921,7 @@ def expected_image_labels(image_input: dict[str, Any]) -> dict[str, str]: def validate_release_descriptor(value: dict[str, Any]) -> None: _require( - set(value) == {"schema", "target", "image", "image_input", "release_sha256"}, + set(value) == {"schema", "target", "image", "inspection", "image_input", "release_sha256"}, "release descriptor fields are not exact", ) _require(value.get("schema") == RELEASE_SCHEMA, "release descriptor schema is invalid") @@ -1326,13 +1931,19 @@ def validate_release_descriptor(value: dict[str, Any]) -> None: validate_image_input(image_input) image = value.get("image") _require( - isinstance(image, dict) and set(image) == {"reference", "digest", "platform", "labels", "input_sha256"}, + isinstance(image, dict) + and set(image) == {"reference", "digest", "config_digest", "platform", "labels", "input_sha256"}, "release image fields are not exact", ) reference = image.get("reference") match = IMAGE_REFERENCE.fullmatch(reference) if isinstance(reference, str) else None _require(match is not None, "release image is not digest-pinned in the staging repository") _require(image.get("digest") == f"sha256:{match.group(1)}", "release image digest differs from its reference") + inspection = value.get("inspection") + _require(isinstance(inspection, dict), "release image inspection is missing") + validate_image_inspection(inspection, image_input=image_input) + _require(inspection.get("reference") == reference, "release image differs from its inspection") + _require(image.get("config_digest") == inspection.get("config_digest"), "release config digest drifted") _require(image.get("platform") == PLATFORM, "release platform is not exact") _require(image.get("input_sha256") == image_input["input_sha256"], "release image input binding drifted") _require(image.get("labels") == expected_image_labels(image_input), "release image labels are not exact") @@ -1419,6 +2030,146 @@ WantedBy=multi-user.target return unit +def _fsync_path(path: Path, *, directory: bool = False) -> None: + flags = os.O_RDONLY + if directory and hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags) + try: + mode = os.fstat(descriptor).st_mode + if directory and not stat.S_ISDIR(mode): + raise OSError(errno.ENOTDIR, "fsync target is not a directory", str(path)) + if not directory and not stat.S_ISREG(mode): + raise OSError(errno.EINVAL, "fsync target is not a regular file", str(path)) + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _rename_noreplace(source: Path, destination: Path) -> None: + """Atomically publish one directory without replacing an existing path.""" + + library = ctypes.CDLL(None, use_errno=True) + source_bytes = os.fsencode(source) + destination_bytes = os.fsencode(destination) + if sys.platform.startswith("linux"): + try: + rename = library.renameat2 + except AttributeError as exc: + raise OSError(errno.ENOSYS, "renameat2 is unavailable") from exc + rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + result = rename(-100, source_bytes, -100, destination_bytes, 1) + elif sys.platform == "darwin": + try: + rename = library.renamex_np + except AttributeError as exc: + raise OSError(errno.ENOSYS, "renamex_np is unavailable") from exc + rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + result = rename(source_bytes, destination_bytes, 0x00000004) + else: + raise OSError(errno.ENOSYS, "atomic no-replace rename is unsupported") + if result != 0: + error = ctypes.get_errno() + if error == errno.EEXIST: + raise FileExistsError(error, "finalize output bundle already exists", str(destination)) + raise OSError(error, os.strerror(error), str(destination)) + + +def _remove_published_bundle(output_bundle: Path, expected_identity: tuple[int, int]) -> None: + try: + observed = output_bundle.stat(follow_symlinks=False) + except OSError as exc: + raise PackageError("finalize bundle rollback cannot identify published output") from exc + _require( + stat.S_ISDIR(observed.st_mode) and (observed.st_dev, observed.st_ino) == expected_identity, + "finalize bundle rollback refused to remove a replaced output", + ) + try: + shutil.rmtree(output_bundle) + _fsync_path(output_bundle.parent, directory=True) + except OSError as exc: + raise PackageError("finalize bundle rollback failed") from exc + _require( + not output_bundle.exists() and not output_bundle.is_symlink(), + "finalize bundle rollback did not pass", + ) + + +def _remove_staged_bundle(staged_bundle: Path, expected_identity: tuple[int, int]) -> None: + try: + observed = staged_bundle.stat(follow_symlinks=False) + except OSError as exc: + raise PackageError("finalize temporary cleanup cannot identify staged output") from exc + _require( + stat.S_ISDIR(observed.st_mode) and (observed.st_dev, observed.st_ino) == expected_identity, + "finalize temporary cleanup refused to remove a replaced output", + ) + try: + shutil.rmtree(staged_bundle) + _fsync_path(staged_bundle.parent, directory=True) + except OSError as exc: + raise PackageError("finalize temporary cleanup failed") from exc + _require( + not staged_bundle.exists() and not staged_bundle.is_symlink(), + "finalize temporary cleanup did not pass", + ) + + +def publish_finalize_bundle(output_bundle: Path, release: dict[str, Any]) -> None: + """Publish release.json and the unit through one directory rename.""" + + validate_release_descriptor(release) + _require(not output_bundle.exists() and not output_bundle.is_symlink(), "finalize output bundle already exists") + try: + release_payload = json.dumps(release, allow_nan=False, indent=2, sort_keys=True) + "\n" + except (TypeError, ValueError) as exc: + raise PackageError("cannot encode finalized release JSON") from exc + unit_payload = render_systemd_unit(release) + staged_bundle = output_bundle.with_name(f".{output_bundle.name}.{uuid.uuid4().hex}.tmp") + staged_created = False + published = False + staged_identity: tuple[int, int] | None = None + try: + _require( + output_bundle.is_absolute() + and output_bundle.parent.is_dir() + and not output_bundle.parent.is_symlink(), + "finalize output parent must be an existing safe absolute directory", + ) + staged_bundle.mkdir(mode=0o700) + staged_created = True + staged_stat = staged_bundle.stat(follow_symlinks=False) + staged_identity = (staged_stat.st_dev, staged_stat.st_ino) + staged_release = staged_bundle / "release.json" + staged_unit = staged_bundle / SERVICE + staged_release.write_text(release_payload, encoding="utf-8") + staged_release.chmod(0o644) + staged_unit.write_text(unit_payload, encoding="utf-8") + staged_unit.chmod(0o644) + validate_release_descriptor(load_json(staged_release, "staged release descriptor")) + _require(staged_unit.read_text(encoding="utf-8") == render_systemd_unit(release), "staged unit drifted") + _fsync_path(staged_release) + _fsync_path(staged_unit) + staged_bundle.chmod(0o755) + _fsync_path(staged_bundle, directory=True) + _rename_noreplace(staged_bundle, output_bundle) + published = True + _fsync_path(output_bundle.parent, directory=True) + except OSError as exc: + if published: + _require(staged_identity is not None, "finalize bundle rollback identity is missing") + _remove_published_bundle(output_bundle, staged_identity) + raise PackageError("cannot publish finalized bundle") from exc + finally: + if staged_created and (staged_bundle.exists() or staged_bundle.is_symlink()): + _require(staged_identity is not None, "finalize temporary cleanup identity is missing") + _remove_staged_bundle(staged_bundle, staged_identity) + + def _git_commit(repo_root: Path) -> str: try: revision = subprocess.run( @@ -1551,8 +2302,13 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: finalize = subparsers.add_parser("finalize") finalize.add_argument("--image-input", type=Path, required=True) finalize.add_argument("--image-reference", required=True) - finalize.add_argument("--output-release", type=Path, required=True) - finalize.add_argument("--output-unit", type=Path, required=True) + finalize.add_argument("--expected-revision", required=True) + finalize.add_argument("--expected-input-sha256", required=True) + finalize.add_argument("--expected-config-digest", required=True) + finalize.add_argument("--docker-binary", type=Path, required=True) + finalize.add_argument("--docker-host", required=True) + finalize.add_argument("--docker-config", type=Path, required=True) + finalize.add_argument("--output-bundle", type=Path, required=True) validate = subparsers.add_parser("validate") validate.add_argument("--release", type=Path, required=True) return parser.parse_args(argv) @@ -1625,13 +2381,18 @@ def main(argv: list[str] | None = None) -> int: write_json(args.output, result, mode=0o600) elif args.command == "finalize": image_input = load_json(args.image_input, "image input") - result = build_release_descriptor(image_input, args.image_reference) - for path in (args.output_release, args.output_unit): - _require(not path.exists() and not path.is_symlink(), "finalize output already exists") - path.parent.mkdir(parents=True, exist_ok=True) - write_json(args.output_release, result) - args.output_unit.write_text(render_systemd_unit(result), encoding="utf-8") - args.output_unit.chmod(0o644) + inspection = inspect_registry_image( + image_input, + args.image_reference, + expected_revision=args.expected_revision, + expected_input_sha256=args.expected_input_sha256, + expected_config_digest=args.expected_config_digest, + docker_binary=args.docker_binary, + docker_host=args.docker_host, + docker_config=args.docker_config, + ) + result = build_release_descriptor(image_input, inspection) + publish_finalize_bundle(args.output_bundle, result) else: result = load_json(args.release, "release descriptor") validate_release_descriptor(result) diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index 1fbb103..97e4ddf 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -391,6 +391,83 @@ def image_input_fixture(tmp_path: Path) -> dict[str, object]: ) +def docker_inspection_fixture( + image_input: dict[str, object], + reference: str, + *, + config_digest: str = "sha256:" + "3" * 64, +) -> dict[str, object]: + runtime = copy.deepcopy(package.IMAGE_RUNTIME_CONFIG) + healthcheck = runtime["healthcheck"] + return { + "Id": config_digest, + "RepoDigests": [reference], + "Architecture": "amd64", + "Os": "linux", + "Variant": None, + "Config": { + "Labels": package.expected_image_labels(image_input), + "User": runtime["user"], + "Env": runtime["environment"], + "WorkingDir": runtime["working_dir"], + "ExposedPorts": runtime["exposed_ports"], + "Volumes": runtime["volumes"], + "Shell": runtime["shell"], + "OnBuild": runtime["on_build"], + "ArgsEscaped": runtime["args_escaped"], + "StopTimeout": runtime["stop_timeout"], + "Tty": runtime["tty"], + "OpenStdin": runtime["open_stdin"], + "StdinOnce": runtime["stdin_once"], + "AttachStdin": runtime["attach_stdin"], + "AttachStdout": runtime["attach_stdout"], + "AttachStderr": runtime["attach_stderr"], + "NetworkDisabled": runtime["network_disabled"], + "Hostname": runtime["hostname"], + "Domainname": runtime["domainname"], + "MacAddress": runtime["mac_address"], + "Entrypoint": runtime["entrypoint"], + "Cmd": runtime["cmd"], + "Healthcheck": { + "Test": healthcheck["test"], + "Interval": healthcheck["interval_ns"], + "Timeout": healthcheck["timeout_ns"], + "StartPeriod": healthcheck["start_period_ns"], + "StartInterval": healthcheck["start_interval_ns"], + "Retries": healthcheck["retries"], + }, + "StopSignal": runtime["stop_signal"], + }, + } + + +def docker_authority(tmp_path: Path) -> tuple[Path, str, Path]: + docker_binary = tmp_path / "bin" / "docker" + docker_config = tmp_path / "docker-config" + docker_config.mkdir() + return docker_binary, "unix:///tmp/livingip-docker.sock", docker_config + + +def docker_inventory_fixture( + reference: str, + *, + present: bool, + config_digest: str = "sha256:" + "3" * 64, +) -> bytes: + if not present: + return b"" + return ( + json.dumps( + { + "Repository": package.IMAGE_REPOSITORY, + "Digest": reference.rsplit("@", 1)[1], + "ID": config_digest, + } + ).encode() + + b"\n" + ) + + def test_image_input_binds_exact_runtime_identity_and_staging_target(tmp_path: Path) -> None: value = image_input_fixture(tmp_path) package.validate_image_input(value) @@ -643,19 +720,25 @@ def test_installed_validation_rederives_fully_rehashed_identity_contract(tmp_pat def test_release_requires_exact_digest_repository_and_strict_schema(tmp_path: Path) -> None: image_input = image_input_fixture(tmp_path) reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" - release = package.build_release_descriptor(image_input, reference) + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) package.validate_release_descriptor(release) assert release["image"]["reference"] == reference assert release["image"]["digest"] == f"sha256:{IMAGE_DIGEST}" + assert release["image"]["config_digest"] == "sha256:" + "3" * 64 assert release["image"]["platform"] == "linux/amd64" + assert release["inspection"] == inspection - with pytest.raises(package.PackageError, match="sha256 digest"): - package.build_release_descriptor(image_input, f"{package.IMAGE_REPOSITORY}:latest") - with pytest.raises(package.PackageError, match="staging repository"): + with pytest.raises(package.PackageError, match="exact staging digest"): + package.build_release_descriptor(image_input, {**inspection, "reference": f"{package.IMAGE_REPOSITORY}:latest"}) + with pytest.raises(package.PackageError, match="exact staging digest"): package.build_release_descriptor( image_input, - f"europe-west6-docker.pkg.dev/teleo-501523/teleo/other@sha256:{IMAGE_DIGEST}", + { + **inspection, + "reference": f"europe-west6-docker.pkg.dev/teleo-501523/teleo/other@sha256:{IMAGE_DIGEST}", + }, ) extra = copy.deepcopy(release) @@ -670,13 +753,1139 @@ def test_release_requires_exact_digest_repository_and_strict_schema(tmp_path: Pa with pytest.raises(package.PackageError, match="labels are not exact"): package.validate_release_descriptor(wrong_label) + wrong_inspection = copy.deepcopy(release) + wrong_inspection["inspection"]["source_revision"] = "9" * 40 + stable = {key: value for key, value in wrong_inspection.items() if key != "release_sha256"} + wrong_inspection["release_sha256"] = package.canonical_sha256(stable) + with pytest.raises(package.PackageError, match="source revision"): + package.validate_release_descriptor(wrong_inspection) + + +@pytest.mark.parametrize( + ("field", "value", "error"), + [ + ("Architecture", "arm64", "platform"), + ("Os", "windows", "platform"), + ("Variant", "v8", "platform"), + ("RepoDigests", [], "repository digest"), + ("RepoDigests", [f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}"] * 2, "repository digest"), + ("Id", "sha256:invalid", "config digest"), + ("Config", {"Labels": {}}, "labels"), + ], +) +def test_image_inspection_rejects_platform_digest_and_label_drift( + tmp_path: Path, + field: str, + value: object, + error: str, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspected = docker_inspection_fixture(image_input, reference) + inspected[field] = value + + with pytest.raises(package.PackageError, match=error): + package.build_image_inspection(image_input, reference, inspected) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("User", "65532"), + ("Env", ["PATH=/unreviewed"]), + ("WorkingDir", "/tmp"), + ("ExposedPorts", {"8080/tcp": {}}), + ("Volumes", {"/host": {}}), + ("Shell", ["/bin/sh", "-c"]), + ("OnBuild", ["RUN id"]), + ("ArgsEscaped", 0), + ("StopTimeout", 30), + ("Tty", True), + ("OpenStdin", True), + ("StdinOnce", True), + ("AttachStdin", True), + ("AttachStdout", True), + ("AttachStderr", True), + ("NetworkDisabled", True), + ("Hostname", "other"), + ("Domainname", "other"), + ("MacAddress", "00:11:22:33:44:55"), + ("WorkingDir", False), + ("WorkingDir", 0), + ("WorkingDir", []), + ("WorkingDir", {}), + ("Entrypoint", ["/bin/sh"]), + ("Cmd", ["probe"]), + ("StopSignal", "SIGKILL"), + ("Unexpected", "unreviewed authority"), + ], +) +def test_image_inspection_rejects_runtime_config_drift(tmp_path: Path, field: str, value: object) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspected = docker_inspection_fixture(image_input, reference) + inspected["Config"][field] = value + + with pytest.raises(package.PackageError, match="runtime config"): + package.build_image_inspection(image_input, reference, inspected) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("Interval", 15_000_000_000.0), + ("Timeout", 10_000_000_000.0), + ("StartPeriod", 30_000_000_000.0), + ("Retries", 4.0), + ("StartInterval", False), + ("Unexpected", 0), + ], +) +def test_image_inspection_rejects_healthcheck_type_and_field_drift( + tmp_path: Path, + field: str, + value: object, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspected = docker_inspection_fixture(image_input, reference) + inspected["Config"]["Healthcheck"][field] = value + + with pytest.raises(package.PackageError, match="runtime config"): + package.build_image_inspection(image_input, reference, inspected) + + +@pytest.mark.parametrize("user", [None, ""]) +@pytest.mark.parametrize("start_interval", [None, 0]) +def test_image_inspection_accepts_portable_root_and_start_interval( + tmp_path: Path, + user: str | None, + start_interval: int | None, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspected = docker_inspection_fixture(image_input, reference) + inspected["Config"]["User"] = user + if start_interval is None: + inspected["Config"]["Healthcheck"].pop("StartInterval") + else: + inspected["Config"]["Healthcheck"]["StartInterval"] = start_interval + + receipt = package.build_image_inspection(image_input, reference, inspected) + + assert receipt["runtime_config"] == package.IMAGE_RUNTIME_CONFIG + + +@pytest.mark.parametrize("args_escaped", [None, False, True]) +def test_image_inspection_normalizes_linux_args_escaped( + tmp_path: Path, + args_escaped: bool | None, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspected = docker_inspection_fixture(image_input, reference) + if args_escaped is None: + inspected["Config"].pop("ArgsEscaped") + else: + inspected["Config"]["ArgsEscaped"] = args_escaped + + receipt = package.build_image_inspection(image_input, reference, inspected) + + assert receipt["runtime_config"]["args_escaped"] is False + + +def test_image_inspection_allows_unrelated_labels_but_rejects_extra_authority(tmp_path: Path) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspected = docker_inspection_fixture(image_input, reference) + inspected["Config"]["Labels"]["org.example.inherited"] = "permitted" + + receipt = package.build_image_inspection(image_input, reference, inspected) + + assert receipt["labels"] == package.expected_image_labels(image_input) + inspected["Config"]["Labels"]["livingip.unreviewed"] = "authority" + with pytest.raises(package.PackageError, match="labels"): + package.build_image_inspection(image_input, reference, inspected) + + +@pytest.mark.parametrize( + "raw", + [ + b'[{"Id":"first","Id":"second"}]', + b'[{"value":NaN}]', + b"\xff", + ], +) +def test_docker_inspection_json_parser_rejects_ambiguous_payloads(raw: bytes) -> None: + with pytest.raises(package.PackageError, match="output is invalid"): + package._load_docker_inspection(raw) + + +def test_docker_inspection_json_parser_rejects_oversized_payload() -> None: + raw = b" " * (package.MAX_DOCKER_INSPECTION_BYTES + 1) + with pytest.raises(package.PackageError, match="too large"): + package._load_docker_inspection(raw) + + +@pytest.mark.parametrize( + "raw", + [ + '{"outer":{"value":"first","value":"second"}}', + '{"value":NaN}', + '{"value":Infinity}', + '{"value":-Infinity}', + ], +) +def test_trust_bound_json_loader_rejects_duplicate_keys_and_nonfinite_values( + tmp_path: Path, + raw: str, +) -> None: + path = tmp_path / "untrusted.json" + path.write_text(raw, encoding="utf-8") + + with pytest.raises(package.PackageError, match="invalid JSON"): + package.load_json(path, "untrusted input") + + +def test_canonical_json_rejects_nonfinite_values() -> None: + with pytest.raises(package.PackageError, match="non-finite"): + package.canonical_sha256({"value": float("nan")}) + + +@pytest.mark.parametrize("command", ["finalize", "validate"]) +@pytest.mark.parametrize("raw", ['{"schema":"first","schema":"second"}', '{"value":NaN}']) +def test_cli_rejects_ambiguous_trust_bound_json_before_docker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + command: str, + raw: str, +) -> None: + path = tmp_path / "ambiguous.json" + path.write_text(raw, encoding="utf-8") + monkeypatch.setattr(package, "inspect_registry_image", lambda *_args, **_kwargs: pytest.fail("Docker used")) + if command == "validate": + arguments = ["validate", "--release", str(path)] + else: + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + arguments = [ + "finalize", + "--image-input", + str(path), + "--image-reference", + f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}", + "--expected-revision", + REVISION, + "--expected-input-sha256", + "1" * 64, + "--expected-config-digest", + "sha256:" + "3" * 64, + "--docker-binary", + str(docker_binary), + "--docker-host", + docker_host, + "--docker-config", + str(docker_config), + "--output-bundle", + str(tmp_path / "bundle"), + ] + + assert package.main(arguments) == 65 + + +@pytest.mark.parametrize( + "inventory", + [ + b'{"Repository":"first","Repository":"second"}\n', + b'{"Repository":NaN}\n', + b"\xff\n", + ], +) +def test_local_image_inventory_rejects_ambiguous_json( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + inventory: bytes, +) -> None: + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + + def fake_run(*_args, **_kwargs): + return subprocess.CompletedProcess([], 0, stdout=inventory, stderr=b"") + + monkeypatch.setattr(package, "_run_docker", fake_run) + + with pytest.raises(package.PackageError, match="local image inventory output is invalid"): + package._local_image_reference_config_id( + docker_binary, + docker_host, + docker_config, + reference, + ) + + +def test_local_image_inventory_rejects_inventory_and_inspection_disagreement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + operations: list[list[str]] = [] + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + operations.append(operation) + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=True), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=json.dumps([{"Id": "sha256:" + "4" * 64}]).encode(), + stderr=b"", + ) + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="inventory and inspection disagree"): + package._local_image_reference_config_id( + docker_binary, + docker_host, + docker_config, + reference, + ) + + assert not any(operation and operation[0] == "pull" for operation in operations) + assert not any(operation[:2] == ["image", "rm"] for operation in operations) + + +@pytest.mark.parametrize("config_digest", ["sha256:" + "3" * 64, "sha256:" + "a" * 64]) +def test_registry_inspection_uses_explicit_local_authority_and_removes_new_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + config_digest: str, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + prefix = [str(docker_binary), "--host", docker_host, "--config", str(docker_config)] + calls: list[list[str]] = [] + environments: list[dict[str, str]] = [] + state = {"present": False} + payload = json.dumps( + [docker_inspection_fixture(image_input, reference, config_digest=config_digest)] + ).encode() + monkeypatch.setenv("DOCKER_HOST", "tcp://unreviewed.example:2375") + monkeypatch.setenv("DOCKER_CONTEXT", "unreviewed") + + def fake_run(arguments: list[str], **kwargs): + calls.append(arguments) + environments.append(kwargs["env"]) + assert arguments[:5] == prefix + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture( + reference, + present=state["present"], + config_digest=config_digest, + ), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=payload, + stderr=b"", + ) + if operation[0] == "pull": + state["present"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + assert "--force" not in operation + state["present"] = False + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest=config_digest, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert inspection["status"] == "pass" + assert state["present"] is False + assert [call[5:] for call in calls if call[5] == "pull"] == [ + ["pull", "--quiet", "--platform", package.PLATFORM, reference] + ] + assert all(not any(name.startswith("DOCKER_") for name in environment) for environment in environments) + assert [call[5:] for call in calls if call[5:7] == ["image", "rm"]] == [["image", "rm", reference]] + + +def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + state = {"present": False, "removed": False} + payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=state["present"]), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + state["present"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + state.update(present=False, removed=True) + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="inspection failed") as raised: + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "4" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert "expected reviewed config digest" in str(raised.value.__cause__) + assert state == {"present": False, "removed": True} + + +def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + state = {"present": False} + payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=state["present"]), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + state["present"] = True + raise subprocess.CalledProcessError(1, arguments, stderr=b"credential=value") + if operation[:2] == ["image", "rm"]: + state["present"] = False + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="inspection failed") as raised: + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert state["present"] is False + assert "credential=value" not in str(raised.value) + + +def test_registry_inspection_validation_failure_removes_new_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + inspected = docker_inspection_fixture(image_input, reference) + inspected["Config"]["Env"] = ["PATH=/unreviewed"] + payload = json.dumps([inspected]).encode() + state = {"present": False, "removed": False} + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=state["present"]), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=payload, + stderr=b"", + ) + if operation[0] == "pull": + state["present"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + state.update(present=False, removed=True) + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="inspection failed"): + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert state == {"present": False, "removed": True} + + +def test_registry_inspection_cleanup_failure_prevents_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() + state = {"present": False} + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=state["present"]), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=payload, + stderr=b"", + ) + if operation[0] == "pull": + state["present"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + raise subprocess.CalledProcessError(1, arguments, stderr=b"busy") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="cleanup failed"): + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + +def test_registry_inspection_refuses_to_remove_changed_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + state = {"present": False, "inventory_calls": 0, "removed": False} + current_config = "sha256:" + "3" * 64 + + def fake_run(arguments: list[str], **_kwargs): + nonlocal current_config + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + state["inventory_calls"] += 1 + if state["inventory_calls"] == 3: + current_config = "sha256:" + "4" * 64 + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture( + reference, + present=state["present"], + config_digest=current_config, + ), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + payload = json.dumps( + [docker_inspection_fixture(image_input, reference, config_digest=current_config)] + ).encode() + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + state["present"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + state["removed"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="cleanup failed"): + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert state["present"] is True + assert state["removed"] is False + + +def test_registry_inspection_rejects_preexisting_reference_without_mutation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + operations: list[list[str]] = [] + payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + operations.append(operation) + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=True), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + with pytest.raises(package.PackageError, match="inspection failed") as raised: + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert "must be absent" in str(raised.value.__cause__) + assert not any(operation and operation[0] == "pull" for operation in operations) + assert not any(operation[:2] == ["image", "rm"] for operation in operations) + + +def test_registry_inspection_rejects_unreviewed_binding_before_docker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + monkeypatch.setattr(package.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Docker was contacted")) + + with pytest.raises(package.PackageError, match="expected reviewed revision"): + package.inspect_registry_image( + image_input, + reference, + expected_revision="9" * 40, + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + with pytest.raises(package.PackageError, match="expected reviewed input SHA-256"): + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256="9" * 64, + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + with pytest.raises(package.PackageError, match="expected reviewed config digest"): + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="invalid", + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + +@pytest.mark.parametrize( + ("binary", "host", "config_kind"), + [ + (Path("docker"), "unix:///tmp/docker.sock", "directory"), + (Path("/usr/bin/docker"), "tcp://127.0.0.1:2375", "directory"), + (Path("/usr/bin/docker"), "unix:///tmp/docker.sock", "missing"), + ], +) +def test_registry_inspection_rejects_implicit_or_remote_docker_authority( + tmp_path: Path, + binary: Path, + host: str, + config_kind: str, +) -> None: + config = tmp_path / "docker-config" + if config_kind == "directory": + config.mkdir() + + with pytest.raises(package.PackageError, match="Docker"): + package._validate_docker_authority(binary, host, config) + + +def test_registry_inspection_rejects_cache_lock_contention_before_docker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + descriptor = os.open(docker_config, os.O_RDONLY) + package.fcntl.flock(descriptor, package.fcntl.LOCK_EX | package.fcntl.LOCK_NB) + monkeypatch.setattr(package.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Docker was contacted")) + + try: + with pytest.raises(package.PackageError, match="cache authority"): + package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + finally: + package.fcntl.flock(descriptor, package.fcntl.LOCK_UN) + os.close(descriptor) + + +def test_finalize_failure_creates_no_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + image_input = image_input_fixture(tmp_path) + image_input_path = tmp_path / "image-input.json" + write_json(image_input_path, image_input, mode=0o644) + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + output_bundle = tmp_path / "nested" / "release-bundle" + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess(arguments, 0, stdout=b"", stderr=b"") + if operation[0] == "pull": + raise subprocess.CalledProcessError(1, arguments, stderr=b"credential=value") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + result = package.main( + [ + "finalize", + "--image-input", + str(image_input_path), + "--image-reference", + f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}", + "--expected-revision", + image_input["runtime"]["teleo_git_head"], + "--expected-input-sha256", + image_input["input_sha256"], + "--expected-config-digest", + "sha256:" + "3" * 64, + "--docker-binary", + str(docker_binary), + "--docker-host", + docker_host, + "--docker-config", + str(docker_config), + "--output-bundle", + str(output_bundle), + ] + ) + + assert result == 65 + assert not output_bundle.exists() + error = capsys.readouterr().err + assert "immutable registry image inspection failed" in error + assert "credential=value" not in error + + +def test_finalize_embeds_inspection_before_rendering_unit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + image_input = image_input_fixture(tmp_path) + image_input_path = tmp_path / "image-input.json" + write_json(image_input_path, image_input, mode=0o644) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + output_bundle = tmp_path / "release" / "release-bundle" + output_bundle.parent.mkdir() + observed: dict[str, object] = {} + + def capture_inspection( + observed_input: dict[str, object], + observed_reference: str, + **kwargs: object, + ) -> dict[str, object]: + observed.update( + { + "image_input": observed_input, + "image_reference": observed_reference, + **kwargs, + } + ) + return inspection + + monkeypatch.setattr(package, "inspect_registry_image", capture_inspection) + + result = package.main( + [ + "finalize", + "--image-input", + str(image_input_path), + "--image-reference", + reference, + "--expected-revision", + image_input["runtime"]["teleo_git_head"], + "--expected-input-sha256", + image_input["input_sha256"], + "--expected-config-digest", + "sha256:" + "3" * 64, + "--docker-binary", + str(docker_binary), + "--docker-host", + docker_host, + "--docker-config", + str(docker_config), + "--output-bundle", + str(output_bundle), + ] + ) + + assert result == 0 + release = json.loads((output_bundle / "release.json").read_text(encoding="utf-8")) + assert release["schema"] == package.RELEASE_SCHEMA + assert release["inspection"] == inspection + assert reference in (output_bundle / package.SERVICE).read_text(encoding="utf-8") + assert observed == { + "image_input": image_input, + "image_reference": reference, + "expected_revision": image_input["runtime"]["teleo_git_head"], + "expected_input_sha256": image_input["input_sha256"], + "expected_config_digest": "sha256:" + "3" * 64, + "docker_binary": docker_binary, + "docker_host": docker_host, + "docker_config": docker_config, + } + assert json.loads(capsys.readouterr().out) == {"schema": package.RELEASE_SCHEMA, "status": "pass"} + + +@pytest.mark.parametrize("failure", ["unit_write", "rename"]) +def test_finalize_bundle_failure_leaves_no_partial_output_or_temporary_residue( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + output_bundle = tmp_path / "final" / "bundle" + output_bundle.parent.mkdir() + if failure == "unit_write": + original_write_text = Path.write_text + + def fail_unit_write(path: Path, *args, **kwargs): + if path.name == package.SERVICE: + raise OSError("injected unit write failure") + return original_write_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", fail_unit_write) + else: + monkeypatch.setattr( + package, + "_rename_noreplace", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected")), + ) + + with pytest.raises(package.PackageError, match="finalized bundle"): + package.publish_finalize_bundle(output_bundle, release) + + assert not output_bundle.exists() + assert not list(output_bundle.parent.glob(".*.tmp")) + + +def test_finalize_bundle_is_byte_repeatable(tmp_path: Path) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + first = tmp_path / "first" / "bundle" + second = tmp_path / "second" / "bundle" + first.parent.mkdir() + second.parent.mkdir() + + package.publish_finalize_bundle(first, release) + package.publish_finalize_bundle(second, release) + + for name in ("release.json", package.SERVICE): + assert (first / name).read_bytes() == (second / name).read_bytes() + assert (first / name).stat().st_mode & 0o777 == 0o644 + assert first.stat().st_mode & 0o777 == second.stat().st_mode & 0o777 == 0o755 + + +@pytest.mark.parametrize("destination_kind", ["file", "directory", "symlink"]) +def test_finalize_bundle_preserves_preexisting_destination(tmp_path: Path, destination_kind: str) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + destination = tmp_path / "bundle" + marker = tmp_path / "marker" + marker.write_text("preserve\n", encoding="utf-8") + if destination_kind == "file": + destination.write_text("preserve\n", encoding="utf-8") + elif destination_kind == "directory": + destination.mkdir() + (destination / "marker").write_text("preserve\n", encoding="utf-8") + else: + destination.symlink_to(marker) + before = destination.lstat() + + with pytest.raises(package.PackageError, match="already exists"): + package.publish_finalize_bundle(destination, release) + + after = destination.lstat() + assert (after.st_dev, after.st_ino) == (before.st_dev, before.st_ino) + if destination_kind == "file": + assert destination.read_text(encoding="utf-8") == "preserve\n" + elif destination_kind == "directory": + assert (destination / "marker").read_text(encoding="utf-8") == "preserve\n" + else: + assert destination.is_symlink() and destination.readlink() == marker + + +def test_finalize_bundle_requires_preexisting_output_parent(tmp_path: Path) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + destination = tmp_path / "missing" / "bundle" + + with pytest.raises(package.PackageError, match="existing safe absolute directory"): + package.publish_finalize_bundle(destination, release) + + assert not destination.parent.exists() + + +def test_finalize_bundle_race_cannot_replace_destination( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + destination = tmp_path / "bundle" + original = package._rename_noreplace + raced_identity: tuple[int, int] | None = None + + def race(source: Path, output: Path) -> None: + nonlocal raced_identity + output.mkdir() + observed = output.stat(follow_symlinks=False) + raced_identity = (observed.st_dev, observed.st_ino) + original(source, output) + + monkeypatch.setattr(package, "_rename_noreplace", race) + + with pytest.raises(package.PackageError, match="cannot publish"): + package.publish_finalize_bundle(destination, release) + + observed = destination.stat(follow_symlinks=False) + assert (observed.st_dev, observed.st_ino) == raced_identity + assert not list(destination.iterdir()) + assert not list(tmp_path.glob(".*.tmp")) + + +@pytest.mark.parametrize("failure_call", [1, 2, 3, 4]) +def test_finalize_bundle_rolls_back_and_fsyncs_parent_after_each_durability_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_call: int, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + destination = tmp_path / "final" / "bundle" + destination.parent.mkdir() + original = package._fsync_path + calls: list[tuple[Path, bool]] = [] + + def fail_once(path: Path, *, directory: bool = False) -> None: + calls.append((path, directory)) + if len(calls) == failure_call: + raise OSError("injected durability failure") + original(path, directory=directory) + + monkeypatch.setattr(package, "_fsync_path", fail_once) + + with pytest.raises(package.PackageError, match="cannot publish"): + package.publish_finalize_bundle(destination, release) + + assert not destination.exists() + assert not list(destination.parent.glob(".*.tmp")) + if failure_call == 4: + assert len(calls) == 5 + assert calls[-1] == (destination.parent, True) + + +def test_finalize_bundle_reports_rollback_fsync_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + destination = tmp_path / "final" / "bundle" + destination.parent.mkdir() + original = package._fsync_path + calls = 0 + + def fail_parent(path: Path, *, directory: bool = False) -> None: + nonlocal calls + calls += 1 + if path == destination.parent and directory: + raise OSError("injected parent fsync failure") + original(path, directory=directory) + + monkeypatch.setattr(package, "_fsync_path", fail_parent) + + with pytest.raises(package.PackageError, match="rollback failed"): + package.publish_finalize_bundle(destination, release) + + assert calls == 5 + assert not destination.exists() + + +def test_finalize_bundle_surfaces_temporary_cleanup_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + output_bundle = tmp_path / "final" / "bundle" + output_bundle.parent.mkdir() + original_write_text = Path.write_text + + def fail_unit_write(path: Path, *args, **kwargs): + if path.name == package.SERVICE: + raise OSError("injected unit write failure") + return original_write_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", fail_unit_write) + monkeypatch.setattr(package.shutil, "rmtree", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("injected"))) + + with pytest.raises(package.PackageError, match="temporary cleanup failed"): + package.publish_finalize_bundle(output_bundle, release) + + assert not output_bundle.exists() + assert list(output_bundle.parent.glob(".*.tmp")) + + +def test_release_v1_is_rejected_without_compatibility_shim(tmp_path: Path) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) + release["schema"] = "livingip.leocleanNoSendRelease.v1" + + with pytest.raises(package.PackageError, match="schema"): + package.validate_release_descriptor(release) + def test_rendered_unit_is_isolated_digest_pinned_and_has_no_credential_or_transport_surface(tmp_path: Path) -> None: image_input = image_input_fixture(tmp_path) - release = package.build_release_descriptor( - image_input, - f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}", - ) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) + release = package.build_release_descriptor(image_input, inspection) unit = package.render_systemd_unit(release) assert "leoclean-gcp-nosend" in unit From c7b4abc34735b6fdc7b4f586d3427f07b4c25596 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Tue, 21 Jul 2026 05:47:01 +0200 Subject: [PATCH 2/3] Retain inspected digest references in shared Docker caches --- docs/gcp-leoclean-nosend-service-package.md | 29 ++++++----- ops/gcp_leoclean_nosend_package.py | 15 +++--- tests/test_gcp_leoclean_nosend_package.py | 54 +++++++++++++++++++++ 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/docs/gcp-leoclean-nosend-service-package.md b/docs/gcp-leoclean-nosend-service-package.md index 85c19fe..5e101bc 100644 --- a/docs/gcp-leoclean-nosend-service-package.md +++ b/docs/gcp-leoclean-nosend-service-package.md @@ -186,13 +186,15 @@ The Docker binary, config directory, and local `unix://` daemon endpoint are explicit inputs; inherited `DOCKER_*` overrides are removed. The pull does not start a container, install a unit, restart a service, or contact Cloud SQL. Registry authentication and this pull require a separate live-read approval. -The exact repository-digest reference must be absent before inspection; use a -fresh isolated Docker context if it is already cached. While holding an -advisory lock on the explicit Docker config directory, the command pulls, -records the dynamically observed config ID, then removes the reference only if -that ID is still unchanged and proves it absent before publishing the bundle. -Content-addressable layers may remain. A failed pull, inspection, or cleanup -returns a bounded error without forwarding Docker stderr. +While holding an advisory lock on the explicit Docker config directory, the +command pulls and records the dynamically observed config ID. It intentionally +retains the exact repository-digest reference: Docker's image cache belongs to +the daemon, so a config-directory lock cannot prove that another client did not +start relying on the same immutable reference after the precheck. Repeated runs +re-pull and re-inspect the same digest without deleting shared cache state. +Content-addressable layers and the verified digest reference may remain. A +failed pull or inspection returns a bounded error without forwarding Docker +stderr. Tags, alternate repositories, alternate projects/instances/service accounts, extra descriptor fields, wrong-architecture images, missing repository-digest @@ -202,7 +204,8 @@ values are rejected. ## Offline evidence and live gate Offline tests prove descriptor and inspection strictness, explicit local Docker -authority, cache-reference rollback, atomic bundle publication, artifact and synthetic identity binding, +authority, non-destructive cache reuse, atomic bundle publication, artifact and +synthetic identity binding, digest-only images, pinned runtime versions, a secret-free context, the capability-drop contract, no published ports, no Docker-socket mount, no environment file, and no production/VPS/Telegram target. CI also builds exactly @@ -231,7 +234,7 @@ virtual environment. Publishing the draft branch and stacked PR is only a review handoff. The next deployment-evidence step must execute one authorized remote registry read plus -the reversible local exact-reference cache mutation against the staging digest. +the retained local exact-reference cache mutation against the staging digest. A later installer slice must prove installation and rollback consume the same receipt. Live installation, Artifact Registry publication, service restart, IAM/secret changes, Cloud SQL access, and proposal staging remain separate @@ -240,10 +243,10 @@ approvals. ## Rollback and sunset This slice's rollback boundary is removal of the unpublished atomic output -bundle plus removal of the exact repository-digest reference that this command -introduced into an initially clean, advisory-locked cache; content-addressable -layers may remain. It installs no unit -and changes no running service. Restoring a prior descriptor, unit, and digest +bundle. The verified repository-digest reference and content-addressable layers +may remain in the local daemon cache so the finalizer never deletes shared cache +state it cannot exclusively own. It installs no unit and changes no running +service. Restoring a prior descriptor, unit, and digest belongs to the later installer slice, which must never restart or edit the production Leo, Telegram, or VPS services. diff --git a/ops/gcp_leoclean_nosend_package.py b/ops/gcp_leoclean_nosend_package.py index fd30b2b..6371df2 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -1781,6 +1781,7 @@ def inspect_registry_image( docker_binary: Path, docker_host: str, docker_config: Path, + cleanup_new_reference: bool = False, ) -> dict[str, Any]: """Pull and inspect one immutable Artifact Registry image without starting it.""" @@ -1812,11 +1813,12 @@ def inspect_registry_image( primary_error: BaseException | None = None with _DockerCacheLock(docker_config): try: - _require( - _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, - "exact registry image reference must be absent before inspection", - ) - prestate_observed = True + if cleanup_new_reference: + _require( + _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, + "exact registry image reference must be absent before inspection cleanup", + ) + prestate_observed = True _run_docker( docker_binary, docker_host, @@ -1852,7 +1854,7 @@ def inspect_registry_image( primary_error = exc finally: try: - if prestate_observed: + if cleanup_new_reference and prestate_observed: if pulled_config_id is None: pulled_config_id = _local_image_reference_config_id( docker_binary, @@ -2390,6 +2392,7 @@ def main(argv: list[str] | None = None) -> int: docker_binary=args.docker_binary, docker_host=args.docker_host, docker_config=args.docker_config, + cleanup_new_reference=False, ) result = build_release_descriptor(image_input, inspection) publish_finalize_bundle(args.output_bundle, result) diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index 97e4ddf..ab52c4b 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -1125,6 +1125,7 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert inspection["status"] == "pass" @@ -1136,6 +1137,52 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer assert [call[5:] for call in calls if call[5:7] == ["image", "rm"]] == [["image", "rm", reference]] +def test_registry_inspection_default_retains_preexisting_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() + state = {"present": True, "pulls": 0, "removed": False} + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=state["present"]), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + state["pulls"] += 1 + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + state["removed"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert inspection["status"] == "pass" + assert state == {"present": True, "pulls": 1, "removed": False} + + def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1177,6 +1224,7 @@ def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_ docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert "expected reviewed config digest" in str(raised.value.__cause__) @@ -1224,6 +1272,7 @@ def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert state["present"] is False @@ -1278,6 +1327,7 @@ def test_registry_inspection_validation_failure_removes_new_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert state == {"present": False, "removed": True} @@ -1328,6 +1378,7 @@ def test_registry_inspection_cleanup_failure_prevents_success( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) @@ -1383,6 +1434,7 @@ def test_registry_inspection_refuses_to_remove_changed_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert state["present"] is True @@ -1425,6 +1477,7 @@ def test_registry_inspection_rejects_preexisting_reference_without_mutation( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert "must be absent" in str(raised.value.__cause__) @@ -1649,6 +1702,7 @@ def test_finalize_embeds_inspection_before_rendering_unit( "docker_binary": docker_binary, "docker_host": docker_host, "docker_config": docker_config, + "cleanup_new_reference": False, } assert json.loads(capsys.readouterr().out) == {"schema": package.RELEASE_SCHEMA, "status": "pass"} From d2ad4f7c964ff70f01be65ad57290b1346153f95 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Tue, 21 Jul 2026 07:00:13 +0200 Subject: [PATCH 3/3] Remove destructive Docker cache cleanup --- ops/gcp_leoclean_nosend_package.py | 67 +----------- tests/test_gcp_leoclean_nosend_package.py | 126 +++++++++++----------- 2 files changed, 63 insertions(+), 130 deletions(-) diff --git a/ops/gcp_leoclean_nosend_package.py b/ops/gcp_leoclean_nosend_package.py index 6371df2..f8d63ef 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -1741,36 +1741,6 @@ def _local_image_reference_config_id( return config_id -def _remove_new_image_reference( - docker_binary: Path, - docker_host: str, - docker_config: Path, - image_reference: str, - expected_config_id: str, -) -> None: - observed_config_id = _local_image_reference_config_id( - docker_binary, - docker_host, - docker_config, - image_reference, - ) - if observed_config_id is None: - return - _require(observed_config_id == expected_config_id, "new registry image reference changed before cleanup") - _run_docker( - docker_binary, - docker_host, - docker_config, - ["image", "rm", image_reference], - check=True, - timeout=120, - ) - _require( - _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, - "new registry image reference cleanup did not pass", - ) - - def inspect_registry_image( image_input: dict[str, Any], image_reference: str, @@ -1781,7 +1751,6 @@ def inspect_registry_image( docker_binary: Path, docker_host: str, docker_config: Path, - cleanup_new_reference: bool = False, ) -> dict[str, Any]: """Pull and inspect one immutable Artifact Registry image without starting it.""" @@ -1807,18 +1776,8 @@ def inspect_registry_image( "image input differs from the expected reviewed input SHA-256", ) _validate_docker_authority(docker_binary, docker_host, docker_config) - prestate_observed = False - pulled_config_id: str | None = None - inspection: dict[str, Any] | None = None - primary_error: BaseException | None = None with _DockerCacheLock(docker_config): try: - if cleanup_new_reference: - _require( - _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, - "exact registry image reference must be absent before inspection cleanup", - ) - prestate_observed = True _run_docker( docker_binary, docker_host, @@ -1851,30 +1810,7 @@ def inspect_registry_image( "inspected image differs from the expected reviewed config digest", ) except (OSError, subprocess.SubprocessError, PackageError) as exc: - primary_error = exc - finally: - try: - if cleanup_new_reference and prestate_observed: - if pulled_config_id is None: - pulled_config_id = _local_image_reference_config_id( - docker_binary, - docker_host, - docker_config, - image_reference, - ) - if pulled_config_id is not None: - _remove_new_image_reference( - docker_binary, - docker_host, - docker_config, - image_reference, - pulled_config_id, - ) - except (OSError, subprocess.SubprocessError, PackageError) as cleanup_error: - raise PackageError("immutable registry image cleanup failed") from cleanup_error - if primary_error is not None: - raise PackageError("immutable registry image inspection failed") from primary_error - _require(inspection is not None, "immutable registry image inspection failed") + raise PackageError("immutable registry image inspection failed") from exc return inspection @@ -2392,7 +2328,6 @@ def main(argv: list[str] | None = None) -> int: docker_binary=args.docker_binary, docker_host=args.docker_host, docker_config=args.docker_config, - cleanup_new_reference=False, ) result = build_release_descriptor(image_input, inspection) publish_finalize_bundle(args.output_bundle, result) diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index ab52c4b..2996a4b 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -1064,7 +1064,7 @@ def test_local_image_inventory_rejects_inventory_and_inspection_disagreement( @pytest.mark.parametrize("config_digest", ["sha256:" + "3" * 64, "sha256:" + "a" * 64]) -def test_registry_inspection_uses_explicit_local_authority_and_removes_new_reference( +def test_registry_inspection_absent_pull_dependent_retains_new_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config_digest: str, @@ -1075,7 +1075,7 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer prefix = [str(docker_binary), "--host", docker_host, "--config", str(docker_config)] calls: list[list[str]] = [] environments: list[dict[str, str]] = [] - state = {"present": False} + state = {"present": False, "dependent": False, "inspection_calls": 0} payload = json.dumps( [docker_inspection_fixture(image_input, reference, config_digest=config_digest)] ).encode() @@ -1099,6 +1099,9 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer stderr=b"", ) if operation[:2] == ["image", "inspect"]: + state["inspection_calls"] += 1 + if state["inspection_calls"] == 2: + state["dependent"] = True return subprocess.CompletedProcess( arguments, 0, @@ -1109,9 +1112,7 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer state["present"] = True return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") if operation[:2] == ["image", "rm"]: - assert "--force" not in operation - state["present"] = False - return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError("digest reference must never be removed") raise AssertionError(operation) monkeypatch.setattr(package.subprocess, "run", fake_run) @@ -1125,19 +1126,18 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, - cleanup_new_reference=True, ) assert inspection["status"] == "pass" - assert state["present"] is False + assert state == {"present": True, "dependent": True, "inspection_calls": 2} assert [call[5:] for call in calls if call[5] == "pull"] == [ ["pull", "--quiet", "--platform", package.PLATFORM, reference] ] assert all(not any(name.startswith("DOCKER_") for name in environment) for environment in environments) - assert [call[5:] for call in calls if call[5:7] == ["image", "rm"]] == [["image", "rm", reference]] + assert not any(call[5:7] == ["image", "rm"] for call in calls) -def test_registry_inspection_default_retains_preexisting_reference( +def test_registry_inspection_retains_preexisting_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1145,7 +1145,7 @@ def test_registry_inspection_default_retains_preexisting_reference( reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" docker_binary, docker_host, docker_config = docker_authority(tmp_path) payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() - state = {"present": True, "pulls": 0, "removed": False} + state = {"present": True, "pulls": 0} def fake_run(arguments: list[str], **_kwargs): operation = arguments[5:] @@ -1162,8 +1162,7 @@ def test_registry_inspection_default_retains_preexisting_reference( state["pulls"] += 1 return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") if operation[:2] == ["image", "rm"]: - state["removed"] = True - return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError("digest reference must never be removed") raise AssertionError(operation) monkeypatch.setattr(package.subprocess, "run", fake_run) @@ -1180,10 +1179,10 @@ def test_registry_inspection_default_retains_preexisting_reference( ) assert inspection["status"] == "pass" - assert state == {"present": True, "pulls": 1, "removed": False} + assert state == {"present": True, "pulls": 1} -def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_reference( +def test_registry_inspection_rejects_config_digest_mismatch_and_retains_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1224,14 +1223,13 @@ def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_ docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, - cleanup_new_reference=True, ) assert "expected reviewed config digest" in str(raised.value.__cause__) - assert state == {"present": False, "removed": True} + assert state == {"present": True, "removed": False} -def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( +def test_registry_inspection_pull_failure_is_redacted_and_does_not_remove_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1272,14 +1270,13 @@ def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, - cleanup_new_reference=True, ) - assert state["present"] is False + assert state["present"] is True assert "credential=value" not in str(raised.value) -def test_registry_inspection_validation_failure_removes_new_reference( +def test_registry_inspection_validation_failure_retains_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1327,13 +1324,12 @@ def test_registry_inspection_validation_failure_removes_new_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, - cleanup_new_reference=True, ) - assert state == {"present": False, "removed": True} + assert state == {"present": True, "removed": False} -def test_registry_inspection_cleanup_failure_prevents_success( +def test_registry_inspection_never_calls_image_rm_after_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1368,21 +1364,22 @@ def test_registry_inspection_cleanup_failure_prevents_success( monkeypatch.setattr(package.subprocess, "run", fake_run) - with pytest.raises(package.PackageError, match="cleanup failed"): - package.inspect_registry_image( - image_input, - reference, - expected_revision=image_input["runtime"]["teleo_git_head"], - expected_input_sha256=image_input["input_sha256"], - expected_config_digest="sha256:" + "3" * 64, - docker_binary=docker_binary, - docker_host=docker_host, - docker_config=docker_config, - cleanup_new_reference=True, - ) + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert inspection["status"] == "pass" + assert state["present"] is True -def test_registry_inspection_refuses_to_remove_changed_reference( +def test_registry_inspection_has_no_post_inspection_cleanup_window( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1424,24 +1421,24 @@ def test_registry_inspection_refuses_to_remove_changed_reference( monkeypatch.setattr(package.subprocess, "run", fake_run) - with pytest.raises(package.PackageError, match="cleanup failed"): - package.inspect_registry_image( - image_input, - reference, - expected_revision=image_input["runtime"]["teleo_git_head"], - expected_input_sha256=image_input["input_sha256"], - expected_config_digest="sha256:" + "3" * 64, - docker_binary=docker_binary, - docker_host=docker_host, - docker_config=docker_config, - cleanup_new_reference=True, - ) + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + assert inspection["status"] == "pass" assert state["present"] is True assert state["removed"] is False + assert state["inventory_calls"] == 1 -def test_registry_inspection_rejects_preexisting_reference_without_mutation( +def test_registry_inspection_accepts_preexisting_reference_without_removal( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1463,25 +1460,27 @@ def test_registry_inspection_rejects_preexisting_reference_without_mutation( ) if operation[:2] == ["image", "inspect"]: return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + raise AssertionError("digest reference must never be removed") raise AssertionError(operation) monkeypatch.setattr(package.subprocess, "run", fake_run) - with pytest.raises(package.PackageError, match="inspection failed") as raised: - package.inspect_registry_image( - image_input, - reference, - expected_revision=image_input["runtime"]["teleo_git_head"], - expected_input_sha256=image_input["input_sha256"], - expected_config_digest="sha256:" + "3" * 64, - docker_binary=docker_binary, - docker_host=docker_host, - docker_config=docker_config, - cleanup_new_reference=True, - ) + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) - assert "must be absent" in str(raised.value.__cause__) - assert not any(operation and operation[0] == "pull" for operation in operations) + assert inspection["status"] == "pass" + assert any(operation and operation[0] == "pull" for operation in operations) assert not any(operation[:2] == ["image", "rm"] for operation in operations) @@ -1702,7 +1701,6 @@ def test_finalize_embeds_inspection_before_rendering_unit( "docker_binary": docker_binary, "docker_host": docker_host, "docker_config": docker_config, - "cleanup_new_reference": False, } assert json.loads(capsys.readouterr().out) == {"schema": package.RELEASE_SCHEMA, "status": "pass"}