From ac4bf5f01f024475199c9dc2040f3dc3ba216cb5 Mon Sep 17 00:00:00 2001 From: fwazb Date: Mon, 20 Jul 2026 21:30:41 -0700 Subject: [PATCH] Bind staging image push to a verified receipt --- docs/gcp-leoclean-nosend-service-package.md | 141 ++- docs/workstreams/gcp-leoclean/goals.md | 69 +- ops/gcp_leoclean_nosend_package.py | 945 +++++++++++++++-- tests/test_gcp_leoclean_nosend_package.py | 1010 ++++++++++++++++--- 4 files changed, 1907 insertions(+), 258 deletions(-) diff --git a/docs/gcp-leoclean-nosend-service-package.md b/docs/gcp-leoclean-nosend-service-package.md index 5e101bc..422a500 100644 --- a/docs/gcp-leoclean-nosend-service-package.md +++ b/docs/gcp-leoclean-nosend-service-package.md @@ -132,44 +132,88 @@ Cloud SQL CA, identity validators, and manifest-derived synthetic identity sources must be tracked regular files whose bytes, modes, staged state, and SHA-256 values match the exact `HEAD` commit. Unrelated user worktree changes remain permitted because they cannot enter this image context. +Prepared directory paths are derived from those bound files, their modes are +normalized and revalidated, and empty or otherwise unmanifested directories +are rejected before and after the build. Artifact file modes must equal the +artifact manifest; identity, copied source/validator, and top-level metadata +files use fixed package modes and are revalidated across the same boundary. -Use the exact values in `build-args.json` as Docker build arguments. The image -build itself revalidates that those values equal `image-input.json`. +## Build, push, and receipt one candidate + +This is a live Artifact Registry mutation and requires explicit staging-push +authorization. Before running it, verify the staging repository's candidate-tag +immutability or retention policy. The command deliberately has no caller-supplied +repository, tag, build argument, config digest, or manifest digest: + +```bash +install -d -m 0700 /tmp/livingip-leoclean-receipts + +python3 ops/gcp_leoclean_nosend_package.py build-push \ + --repo-root . \ + --image-context /tmp/leoclean-nosend-image-context \ + --docker-binary /usr/bin/docker \ + --docker-host unix:///var/run/docker.sock \ + --docker-config /home/operator/.docker \ + --output-receipt /tmp/livingip-leoclean-receipts/build-push-receipt.json \ + --execute-staging-push +``` + +Before contacting Docker, the command revalidates the exact context and binds +its source closure to the current clean Git revision. It derives the four build +arguments from `image-input.json`, builds one `linux/amd64` image, validates its +config/platform/labels/runtime, derives a content-bound candidate tag from the +local config digest, and requires zero staging repository digests before push. +It does not treat push stdout as authority. A successful push must change the +daemon view to exactly one valid staging `RepoDigest` without changing the +image configuration. + +The atomically published, no-clobber +`livingip.leocleanNoSendBuildPushReceipt.v1` receipt binds the source revision, +image-input hash, local config/platform/labels/runtime, fixed staging +repository, and daemon-observed manifest digest. Its canonical self-hash is an +integrity checksum, not a signature, identity attestation, or independent +provenance service. Local build, candidate-tag, and digest references are +retained because a Docker-config lock does not establish exclusive ownership of +daemon-global cache state; the command never issues `docker image rm` and never +uses `--force`. The remote candidate is also never deleted automatically. If +receipt publication fails after a push, those objects remain inert and +untrusted until a separately authorized operator action resolves them. + +The receipt parent must be an absolute, operator-owned directory without +group/other write access. Publication keeps the staged descriptor open, validates +the in-memory bytes, hard-links that exact inode without replacement, verifies +both names, and fsyncs the directory. The private staged hard link is retained +intentionally: portable Unix interfaces cannot conditionally unlink a pathname +only if it still names a previously verified inode. Publication therefore never +deletes a receipt pathname automatically, and ambiguous failure residue is left +for explicit operator inspection. The receipt is mode `0600`, has exactly two +links, and is consumed later through the same private directory and stable-inode +contract. ## Finalize one inspected digest-bound release -After an authorized build and Artifact Registry push returns the immutable -image digest: +After the authorized build/push command publishes its receipt: ```bash 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> \ - --expected-revision \ - --expected-input-sha256 \ - --expected-config-digest sha256: \ + --build-push-receipt /tmp/livingip-leoclean-receipts/build-push-receipt.json \ --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 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. +This command validates the receipt before Docker, derives the sole +`repository@sha256` reference and expected build config from it, independently +pulls and inspects that immutable image for `linux/amd64`, and refuses output +unless the receipt, registry inspection, image input, complete labels, and +runtime-bearing configuration agree. 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 +`livingip.leocleanNoSendRelease.v3` together with the complete build/push +receipt; unit generation revalidates every cross-binding. Release v1 and v2 +have no deployed consumer and are intentionally rejected; regenerate any local +artifact. For this Linux-only target, inspection normalizes the deprecated Windows-only Docker `ArgsEscaped` field while still requiring the exact entrypoint and command arrays. @@ -183,7 +227,8 @@ 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 +explicit inputs; inherited `DOCKER_*`, `BUILDKIT_*`, and `BUILDX_*` overrides +are removed and Docker stdin is closed. 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. While holding an advisory lock on the explicit Docker config directory, the @@ -203,10 +248,11 @@ values are rejected. ## Offline evidence and live gate -Offline tests prove descriptor and inspection strictness, explicit local Docker -authority, non-destructive cache reuse, atomic bundle publication, artifact and -synthetic identity binding, -digest-only images, pinned runtime versions, a secret-free context, the +Offline tests prove strict receipt/descriptor/inspection schemas, exact +prepared-context and reviewed-Git binding, mocked pre/post-push digest +transitions, explicit local Docker authority, identity-safe cache-reference +handling with retained immutable digest references, atomic receipt and 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 one `linux/amd64` image, starts it with the reviewed restrictions, requires the @@ -222,9 +268,11 @@ 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. 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, +bridge-network smoke does not prove general network-egress denial. The +build/push lifecycle tests use mocked Docker state and the offline inspection +tests use exact Docker JSON fixtures; they do not prove canonical identity, +Cloud SQL permissions, a real Artifact Registry push, the registry's tag +policy, 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 @@ -232,21 +280,28 @@ engine is absent, installation is separate infrastructure work; the deploy must 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 step must execute one authorized remote registry read plus -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 -approvals. +Publishing the branch and stacked PR is only a review handoff. After both the +digest-inspection package and this receipt producer receive exact-head human +review and merge, separate live gates remain: staging repository/tag-policy +preflight; authorized build/push receipt production; authorized independent +digest finalization; installer/restart/rollback; and Cloud SQL/secret/service +deployment proof. CI carries no Artifact Registry credentials and performs no +push. IAM/secret changes, Cloud SQL access, proposal staging, production +promotion, Telegram traffic, and VPS actions remain separate authority. ## Rollback and sunset -This slice's rollback boundary is removal of the unpublished atomic output -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 +This slice's automatic rollback removes an unpublished bundle only. Receipt +pathnames are never removed automatically; the private retained link and any +ambiguous publication residue require explicit operator inspection. Tool-owned +build and candidate tags, the verified +repository-digest reference, and content-addressable layers may remain because +a Docker-config lock cannot establish exclusive ownership of daemon-global +cache state. A remotely pushed candidate is intentionally not deleted during +automatic rollback and cannot be consumed without a valid receipt plus +independent finalization. Any later cache cleanup requires separately proven +daemon isolation. This slice 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/docs/workstreams/gcp-leoclean/goals.md b/docs/workstreams/gcp-leoclean/goals.md index cd56e79..1140584 100644 --- a/docs/workstreams/gcp-leoclean/goals.md +++ b/docs/workstreams/gcp-leoclean/goals.md @@ -40,42 +40,61 @@ cut over safely and retire the VPS after soak. 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. +- PR #206 at exact head `e42d635523fcb90ecdd504b0f0290df7015991e7` + makes release finalization pull and inspect the exact immutable staging + digest. Its five checks pass, but it must not merge unchanged: PR #209's + final repair at `d2ad4f7c964ff70f01be65ad57290b1346153f95` is the required + corrective layer that prevents finalization from deleting daemon-global + digest references a Docker-config lock cannot exclusively own. +- Published as draft PR #210: one structured, self-hashed build/push receipt + producer. It derives the content-bound candidate tag, manifest digest, and + config binding from validated pre/post-push daemon state; finalization + consumes that receipt instead of caller-supplied digest/config values. The + branch has been rebuilt directly on PR #209's final repair and carries the + same retained-digest rule through both build/push and finalization. No + daemon-global build tag, candidate tag, immutable digest reference, or + receipt pathname is removed automatically. The receipt is published and + consumed only through a private operator-owned directory with a retained + two-link, stable-inode contract; remote candidates are retained. The exact + #210 head belongs in the mutable PR evidence, not recursively inside its own + commit. +- Current exact-head validation: 206 focused package/OCI tests and the full + repository suite at 2,480 passed with 3 expected skips; Ruff, Python compile, + and diff checks pass. Two independent advisory exact-head reviews approved + the receipt deletion/identity boundary. Tests mock Docker and perform no + registry push. +- Current gate: publish and review the final #209-based #210 head, allow PR + #209 to correct PR #206's feature branch, rerun/review corrected #206, and + only then merge/retarget the narrow stack. Registry access remains a later, + explicitly authorized live 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. 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 +1. Merge PR #209 into PR #206's feature branch, rerun exact-head checks, obtain + human review, and merge the corrected PR #206. +2. Update, review, retarget, and merge draft PR #210's structured build/push + receipt producer and receipt-bound release-v3 finalizer. +3. Under explicit live authorization, verify the staging repository/tag policy, + build and push one candidate, publish its receipt, and independently finalize + the exact digest. +4. 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 +5. Run the read-only `teleo-staging-1` + host/engine/service-account preflight. +6. Provision the scoped Cloud SQL role/secret and deploy the separate staging service. -6. Prove effective identity, reads, function-only proposal staging, denied +7. Prove effective identity, reads, function-only proposal staging, denied writes/escalation, receipts, restart stability, and rollback. -7. Run behavioral and database parity backtesting. -8. Connect the protected Observatory to canonical claims, proposals, pgvector +8. Run behavioral and database parity backtesting. +9. Connect the protected Observatory to canonical claims, proposals, pgvector projection, and runtime receipts. -9. Cut over, soak, restore-test, revoke VPS credentials, and retire the VPS. +10. Cut over, soak, restore-test, revoke VPS credentials, and retire the VPS. ## Current live-action boundary -No merge, Artifact Registry push, GCP/Cloud SQL/Secret Manager mutation, +No merge without explicit human approval, Artifact Registry push, +GCP/Cloud SQL/Secret Manager mutation, service deployment/restart, Telegram change, production promotion, canonical proposal apply, or VPS access is part of the current offline corrective slice. diff --git a/ops/gcp_leoclean_nosend_package.py b/ops/gcp_leoclean_nosend_package.py index f8d63ef..75ebac9 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -33,7 +33,8 @@ from scripts import leo_identity_manifest as strict_identity_manifest # noqa: E IMAGE_INPUT_SCHEMA = "livingip.leocleanNoSendImageInput.v2" IMAGE_INSPECTION_SCHEMA = "livingip.leocleanNoSendImageInspection.v1" -RELEASE_SCHEMA = "livingip.leocleanNoSendRelease.v2" +BUILD_PUSH_RECEIPT_SCHEMA = "livingip.leocleanNoSendBuildPushReceipt.v1" +RELEASE_SCHEMA = "livingip.leocleanNoSendRelease.v3" ARTIFACT_MANIFEST_SCHEMA = "livingip.leocleanNoSendArtifactManifest.v1" ARTIFACT_RECEIPT_SCHEMA = "livingip.leocleanNoSendArtifactVerification.v1" IDENTITY_MANIFEST_SCHEMA = "livingip.leoIdentityManifest.v1" @@ -50,6 +51,7 @@ SERVICE_ACCOUNT = "sa-teleo-staging-vm@teleo-501523.iam.gserviceaccount.com" PLATFORM = "linux/amd64" IMAGE_REPOSITORY = "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging" CONTAINER_NAME = "livingip-leoclean-nosend" +LOCAL_BUILD_REPOSITORY = "livingip-leoclean-nosend-build" HERMES_COMMIT = "b2f477a30b3c05d0f383c543af98496ae8a96070" PYTHON_VERSION = "3.11.9" @@ -73,6 +75,8 @@ PROFILE_ROOT = "/run/leoclean-profile" RUNTIME_UID = 65532 RUNTIME_GID = 65532 MAX_DOCKER_INSPECTION_BYTES = 1024 * 1024 +MAX_DOCKER_COMMAND_OUTPUT_BYTES = 1024 * 1024 +MAX_BUILD_PUSH_RECEIPT_BYTES = 256 * 1024 IMAGE_RUNTIME_CONFIG = { "user": None, "environment": [ @@ -258,6 +262,27 @@ def _safe_tree(root: Path, label: str) -> None: _require(path.is_dir() or path.is_file(), f"{label} contains an unsupported entry") +def _parent_directory_paths(file_paths: set[str]) -> set[str]: + directories: set[str] = set() + for relative in file_paths: + path = Path(relative) + _require(not path.is_absolute() and path.parts and ".." not in path.parts, "tree file path is unsafe") + parent = path.parent + while parent != Path("."): + directories.add(parent.as_posix()) + parent = parent.parent + return directories + + +def _normalize_prepared_directory_modes(root: Path) -> None: + root.chmod(0o700) + for path in root.rglob("*"): + if not path.is_dir(): + continue + relative = path.relative_to(root).as_posix() + path.chmod(0o700 if relative == "identity" else 0o755) + + def tree_manifest(root: Path, *, excluded: frozenset[str] = frozenset()) -> dict[str, Any]: _safe_tree(root, "tree") files: list[dict[str, Any]] = [] @@ -391,11 +416,7 @@ def _locked_python_runtime(artifact: Path, *, expected_uv_lock_sha256: str) -> d raise PackageError(f"cannot read artifact uv.lock: {type(exc).__name__}") from exc packages = lock.get("package") _require(isinstance(packages, list), "artifact uv.lock package set is missing") - matches = [ - item - for item in packages - if isinstance(item, dict) and str(item.get("name", "")).casefold() == "pyyaml" - ] + matches = [item for item in packages if isinstance(item, dict) and str(item.get("name", "")).casefold() == "pyyaml"] _require(len(matches) == 1 and matches[0].get("version") == PYYAML_VERSION, "artifact PyYAML lock drifted") distributions = {"PyYAML": PYYAML_VERSION} return { @@ -543,7 +564,13 @@ def validate_expected_identity_contract(value: dict[str, Any], *, expected_revis }, "expected identity behavior inputs are not exact", ) - for field in ("artifact_sha256", "artifact_receipt_sha256", "config_sha256", "runtime_contract_sha256", "uv_lock_sha256"): + for field in ( + "artifact_sha256", + "artifact_receipt_sha256", + "config_sha256", + "runtime_contract_sha256", + "uv_lock_sha256", + ): _require(_is_sha256(behavior_inputs.get(field)), f"expected identity behavior input {field} is invalid") source_bindings = behavior_inputs.get("identity_contract_sources") _require( @@ -588,7 +615,9 @@ def build_expected_identity_contract( config_path = profile / "config.yaml" config = strict_identity_manifest.behavior.safe_model_config(config_path) _require(config.get("status") == "observed", "packaged identity model config was not observed") - _require(config.get("config_sha256") == receipt.get("config_sha256"), "packaged config differs from runtime receipt") + _require( + config.get("config_sha256") == receipt.get("config_sha256"), "packaged config differs from runtime receipt" + ) _require( config.get("provider") == MODEL_PROVIDER and config.get("default_model") == DEFAULT_MODEL, "packaged provider/default model drifted", @@ -847,7 +876,9 @@ def validate_identity_bundle( views = manifest.get("compiled_views") _require(isinstance(views, dict) and set(views) == set(rendered), "compiled identity views are incomplete") for name, content in rendered.items(): - _require((root / name).read_text(encoding="utf-8") == content, f"compiled identity view derivation drifted: {name}") + _require( + (root / name).read_text(encoding="utf-8") == content, f"compiled identity view derivation drifted: {name}" + ) _require(views[name].get("sha256") == sha256_file(root / name), f"compiled identity view hash drifted: {name}") _require( set(lock) == {"schema", "manifest_sha256", "identity_inputs_sha256", "compiled_views", "session_boundary"}, @@ -855,7 +886,9 @@ def validate_identity_bundle( ) _require(lock.get("schema") == IDENTITY_LOCK_SCHEMA, "identity lock schema is invalid") _require(lock.get("manifest_sha256") == manifest["manifest_sha256"], "identity lock manifest binding drifted") - _require(lock.get("identity_inputs_sha256") == manifest["identity_inputs_sha256"], "identity lock input binding drifted") + _require( + lock.get("identity_inputs_sha256") == manifest["identity_inputs_sha256"], "identity lock input binding drifted" + ) expected_views = {name: {"sha256": sha256_file(root / name)} for name in sorted(rendered)} _require(lock.get("compiled_views") == expected_views, "identity lock compiled-view binding drifted") _require(lock.get("session_boundary") == inputs.get("session_boundary"), "identity lock session binding drifted") @@ -867,7 +900,9 @@ def validate_identity_bundle( "this package accepts only synthetic noncanonical identity", ) sources = inputs.get("identity_sources") - provenance = (sources or {}).get("database_derived_identity", {}).get("provenance") if isinstance(sources, dict) else None + provenance = ( + (sources or {}).get("database_derived_identity", {}).get("provenance") if isinstance(sources, dict) else None + ) _require( provenance == { @@ -882,7 +917,9 @@ def validate_identity_bundle( for field in ("fingerprint_sha256", "structure_sha256", "table_rows_sha256"): _require(_is_sha256(database.get(field)), f"identity database {field} is invalid") for field in ("database", "database_user", "system_identifier"): - _require(isinstance(database.get(field), str) and bool(database[field]), f"identity database {field} is missing") + _require( + isinstance(database.get(field), str) and bool(database[field]), f"identity database {field} is missing" + ) _require( isinstance(database.get("table_count"), int) and not isinstance(database["table_count"], bool) @@ -1354,6 +1391,7 @@ def prepare_context( "package_contract.py", ): (output / name).chmod(0o644) + (output / "artifact" / "artifact-manifest.json").chmod(0o644) write_json(output / "image-input.json", image_input) write_json( output / "build-args.json", @@ -1364,6 +1402,7 @@ def prepare_context( "TELEO_REVISION": source_revision, }, ) + _normalize_prepared_directory_modes(output) _safe_tree(output, "prepared image context") return image_input except BaseException: @@ -1371,6 +1410,177 @@ def prepare_context( raise +def validate_prepared_image_context(context: Path) -> tuple[dict[str, Any], dict[str, str]]: + """Revalidate every file that can enter the reviewed image build context.""" + + _require(context.is_absolute(), "prepared image context must be absolute") + _safe_tree(context, "prepared image context") + _require( + {path.name for path in context.iterdir()} + == { + "Dockerfile", + "artifact", + "artifact-receipt.json", + "build-args.json", + "cloudsql-server-ca.pem", + "entrypoint.py", + "identity", + "identity-sources", + "image-input.json", + "package_contract.py", + "scripts", + }, + "prepared image context entry set is not exact", + ) + image_input = load_json(context / "image-input.json", "image input") + validate_image_input(image_input) + expected_build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + _require( + load_json(context / "build-args.json", "build arguments") == expected_build_args, + "prepared image build arguments drifted", + ) + bindings = image_input["source_bindings"] + for relative, expected in ( + ("Dockerfile", bindings["Dockerfile"]), + ("entrypoint.py", bindings["entrypoint.py"]), + ("package_contract.py", bindings["package_contract.py"]), + ("scripts/leo_behavior_manifest.py", bindings["scripts/leo_behavior_manifest.py"]), + ("scripts/leo_identity_manifest.py", bindings["scripts/leo_identity_manifest.py"]), + ("cloudsql-server-ca.pem", image_input["runtime"]["cloudsql_ca_sha256"]), + ("artifact-receipt.json", image_input["runtime"]["artifact_receipt_sha256"]), + ): + _require( + sha256_file(_safe_bound_file(context, relative, label=f"prepared image source: {relative}")) == expected, + f"prepared image source drifted: {relative}", + ) + runtime, identity = validate_installed_runtime_identity( + image_input, + artifact=context / "artifact", + receipt=context / "artifact-receipt.json", + identity=context / "identity", + contract_source_root=context, + identity_source_root=context / "identity-sources", + ) + _require(runtime["artifact_sha256"] == image_input["runtime"]["artifact_sha256"], "prepared artifact drifted") + _require(identity == image_input["identity"], "prepared identity drifted") + identity_manifest = load_json(context / "identity" / "identity-manifest.json", "identity manifest") + expected_identity_paths = set(_identity_source_paths(identity_manifest)) + actual_identity_paths = { + path.relative_to(context / "identity-sources").as_posix() + for path in (context / "identity-sources").rglob("*") + if path.is_file() + } + _require(actual_identity_paths == expected_identity_paths, "prepared identity source entry set drifted") + actual_script_paths = { + path.relative_to(context).as_posix() + for path in (context / "scripts").rglob("*") + if path.is_file() + } + _require( + actual_script_paths == set(IDENTITY_CONTRACT_SOURCES), + "prepared identity validator entry set drifted", + ) + _require( + identity_source_manifest(identity_manifest, context / "identity-sources")["sha256"] + == image_input["identity"]["identity_sources_sha256"], + "prepared identity sources drifted", + ) + artifact_manifest = load_json(context / "artifact" / "artifact-manifest.json", "artifact manifest") + artifact_content = artifact_manifest.get("content") + artifact_files = artifact_content.get("files") if isinstance(artifact_content, dict) else None + _require( + isinstance(artifact_files, list) + and all( + isinstance(entry, dict) + and isinstance(entry.get("path"), str) + and isinstance(entry.get("mode"), str) + for entry in artifact_files + ), + "prepared artifact file manifest is invalid", + ) + expected_file_modes: dict[str, int] = { + "Dockerfile": 0o644, + "artifact/artifact-manifest.json": 0o644, + "artifact-receipt.json": 0o644, + "build-args.json": 0o644, + "cloudsql-server-ca.pem": 0o644, + "entrypoint.py": 0o644, + "image-input.json": 0o644, + "package_contract.py": 0o644, + } + for entry in artifact_files: + raw_mode = entry["mode"] + _require(bool(re.fullmatch(r"0o[0-7]{3,4}", raw_mode)), "prepared artifact file mode is invalid") + expected_file_modes[f"artifact/{entry['path']}"] = int(raw_mode.removeprefix("0o"), 8) + expected_file_modes.update({f"identity/{name}": 0o600 for name in IDENTITY_FILES}) + expected_file_modes.update( + {f"identity-sources/{relative}": 0o644 for relative in expected_identity_paths} + ) + expected_file_modes.update({relative: 0o644 for relative in IDENTITY_CONTRACT_SOURCES}) + actual_files = {path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_file()} + _require(actual_files == set(expected_file_modes), "prepared image file entry set drifted") + for relative, expected_mode in expected_file_modes.items(): + _require( + stat.S_IMODE((context / relative).stat().st_mode) == expected_mode, + f"prepared image file mode drifted: {relative}", + ) + expected_directories = {"artifact", "identity", "identity-sources", "scripts"} + expected_directories.update( + f"artifact/{relative}" + for relative in _parent_directory_paths({entry["path"] for entry in artifact_files}) + ) + expected_directories.update( + _parent_directory_paths({f"identity-sources/{relative}" for relative in expected_identity_paths}) + ) + expected_directories.update(_parent_directory_paths(set(IDENTITY_CONTRACT_SOURCES))) + actual_directories = { + path.relative_to(context).as_posix() for path in context.rglob("*") if path.is_dir() + } + _require(actual_directories == expected_directories, "prepared image directory entry set drifted") + _require(stat.S_IMODE(context.stat().st_mode) == 0o700, "prepared image context mode drifted") + for relative in actual_directories: + expected_mode = 0o700 if relative == "identity" else 0o755 + _require( + stat.S_IMODE((context / relative).stat().st_mode) == expected_mode, + f"prepared image directory mode drifted: {relative}", + ) + return image_input, expected_build_args + + +def validate_reviewed_context_source( + repo_root: Path, + context: Path, + image_input: dict[str, Any], +) -> None: + """Bind the prepared context to tracked bytes at the exact reviewed checkout.""" + + validate_image_input(image_input) + revision = image_input["runtime"]["teleo_git_head"] + bindings = image_input["source_bindings"] + expected_source_sha256 = { + "docker/leoclean-nosend/Dockerfile": bindings["Dockerfile"], + "docker/leoclean-nosend/entrypoint.py": bindings["entrypoint.py"], + "ops/gcp-teleo-pgvector-standby-server-ca.pem": image_input["runtime"]["cloudsql_ca_sha256"], + "ops/gcp_leoclean_nosend_package.py": bindings["package_contract.py"], + "scripts/leo_behavior_manifest.py": bindings["scripts/leo_behavior_manifest.py"], + "scripts/leo_identity_manifest.py": bindings["scripts/leo_identity_manifest.py"], + } + _verify_git_source_closure(repo_root, revision, expected_source_sha256) + identity_manifest = load_json(context / "identity" / "identity-manifest.json", "identity manifest") + reviewed_identity_sources = identity_source_manifest(identity_manifest, repo_root) + prepared_identity_sources = identity_source_manifest(identity_manifest, context / "identity-sources") + _require( + reviewed_identity_sources == prepared_identity_sources + and reviewed_identity_sources["sha256"] == image_input["identity"]["identity_sources_sha256"], + "prepared identity sources differ from the reviewed checkout", + ) + + def validate_image_runtime_configuration(inspected: dict[str, Any]) -> dict[str, Any]: """Validate the runtime-bearing Docker config shared by local and registry inspection.""" @@ -1433,10 +1643,7 @@ def validate_image_runtime_configuration(inspected: dict[str, Any]) -> dict[str, 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 all(type(healthcheck.get(name)) is int for name in ("Interval", "Timeout", "StartPeriod", "Retries")) and ( raw_start_interval is None or ( @@ -1521,6 +1728,101 @@ def validate_image_configuration(image_input: dict[str, Any], inspected: dict[st } +def validate_build_push_receipt(value: dict[str, Any], *, image_input: dict[str, Any]) -> None: + """Validate the local-build to registry-manifest handoff consumed by finalization.""" + + validate_image_input(image_input) + _require( + set(value) + == { + "schema", + "source_revision", + "input_sha256", + "local_image", + "registry", + "receipt_sha256", + }, + "build/push receipt fields are not exact", + ) + _require(value.get("schema") == BUILD_PUSH_RECEIPT_SCHEMA, "build/push receipt schema is invalid") + _require( + value.get("source_revision") == image_input["runtime"]["teleo_git_head"], + "build/push receipt source revision drifted", + ) + _require( + value.get("input_sha256") == image_input["input_sha256"], + "build/push receipt image-input binding drifted", + ) + local_image = value.get("local_image") + _require( + isinstance(local_image, dict) and set(local_image) == {"config_digest", "platform", "labels", "runtime_config"}, + "build/push receipt local image fields are not exact", + ) + config_digest = local_image.get("config_digest") + _require( + isinstance(config_digest, str) + and config_digest.startswith("sha256:") + and _is_sha256(config_digest.removeprefix("sha256:")), + "build/push receipt config digest is invalid", + ) + _require(local_image.get("platform") == PLATFORM, "build/push receipt platform is not exact") + _require( + local_image.get("labels") == expected_image_labels(image_input), + "build/push receipt labels are not exact", + ) + _require( + local_image.get("runtime_config") == IMAGE_RUNTIME_CONFIG, + "build/push receipt runtime config is not exact", + ) + registry = value.get("registry") + _require( + isinstance(registry, dict) and set(registry) == {"repository", "manifest_digest"}, + "build/push receipt registry fields are not exact", + ) + _require(registry.get("repository") == IMAGE_REPOSITORY, "build/push receipt repository is not exact") + manifest_digest = registry.get("manifest_digest") + _require( + isinstance(manifest_digest, str) + and manifest_digest.startswith("sha256:") + and _is_sha256(manifest_digest.removeprefix("sha256:")), + "build/push receipt manifest digest is invalid", + ) + stable = {key: item for key, item in value.items() if key != "receipt_sha256"} + _require(value.get("receipt_sha256") == canonical_sha256(stable), "build/push receipt self-hash drifted") + _assert_no_forbidden_descriptor_markers(value) + + +def build_build_push_receipt( + image_input: dict[str, Any], + *, + post_push_image: dict[str, Any], +) -> dict[str, Any]: + validate_image_input(image_input) + observed = validate_image_configuration(image_input, post_push_image) + repository_digests = _target_repository_digests(post_push_image) + _require(len(repository_digests) == 1, "staging push did not produce one exact repository digest") + match = IMAGE_REFERENCE.fullmatch(repository_digests[0]) + _require(match is not None, "staging push repository digest is invalid") + stable = { + "schema": BUILD_PUSH_RECEIPT_SCHEMA, + "source_revision": image_input["runtime"]["teleo_git_head"], + "input_sha256": image_input["input_sha256"], + "local_image": { + "config_digest": observed["config_digest"], + "platform": observed["platform"], + "labels": observed["labels"], + "runtime_config": observed["runtime_config"], + }, + "registry": { + "repository": IMAGE_REPOSITORY, + "manifest_digest": f"sha256:{match.group(1)}", + }, + } + receipt = {**stable, "receipt_sha256": canonical_sha256(stable)} + validate_build_push_receipt(receipt, image_input=image_input) + return receipt + + def build_image_inspection( image_input: dict[str, Any], image_reference: str, @@ -1611,7 +1913,9 @@ def _load_docker_inspection(raw: bytes) -> Any: def _docker_environment() -> dict[str, str]: - return {name: value for name, value in os.environ.items() if not name.startswith("DOCKER_")} + return { + name: value for name, value in os.environ.items() if not name.startswith(("DOCKER_", "BUILDKIT_", "BUILDX_")) + } def _validate_docker_authority(docker_binary: Path, docker_host: str, docker_config: Path) -> None: @@ -1620,7 +1924,10 @@ def _validate_docker_authority(docker_binary: Path, docker_host: str, docker_con _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, + 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( @@ -1673,11 +1980,275 @@ def _run_docker( [str(docker_binary), "--host", docker_host, "--config", str(docker_config), *arguments], check=check, capture_output=True, + stdin=subprocess.DEVNULL, timeout=timeout, env=_docker_environment(), ) +def _bounded_docker_output(result: subprocess.CompletedProcess[bytes], label: str) -> None: + _require( + len(result.stdout) <= MAX_DOCKER_COMMAND_OUTPUT_BYTES and len(result.stderr) <= MAX_DOCKER_COMMAND_OUTPUT_BYTES, + f"{label} output is too large", + ) + + +def _inspect_local_image( + docker_binary: Path, + docker_host: str, + docker_config: Path, + image_reference: str, +) -> dict[str, Any]: + result = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "inspect", image_reference], + check=True, + timeout=60, + ) + _bounded_docker_output(result, "local image inspection") + inspected = _load_docker_inspection(result.stdout) + _require(isinstance(inspected, list) and len(inspected) == 1, "local image inspection is not exact") + _require(isinstance(inspected[0], dict), "local image inspection payload is invalid") + return inspected[0] + + +def _local_tag_config_id( + docker_binary: Path, + docker_host: str, + docker_config: Path, + *, + repository: str, + tag: str, +) -> str | None: + _require( + repository in {LOCAL_BUILD_REPOSITORY, IMAGE_REPOSITORY} + and isinstance(tag, str) + and bool(re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,127}", tag)), + "local image tag is invalid", + ) + reference = f"{repository}:{tag}" + inventory = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "ls", "--no-trunc", "--format", "{{json .}}", repository], + check=True, + timeout=60, + ) + _bounded_docker_output(inventory, "local image inventory") + 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") != repository or row.get("Tag") != tag: + 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)) + inspected = _inspect_local_image(docker_binary, docker_host, docker_config, reference) + config_id = inspected.get("Id") + _require(config_id == inventory_config_id, "local image inventory and inspection disagree") + return inventory_config_id + + +def _target_repository_digests(inspected: dict[str, Any]) -> list[str]: + repository_digests = inspected.get("RepoDigests") + _require( + repository_digests is None + or ( + isinstance(repository_digests, list) + and all(isinstance(item, str) for item in repository_digests) + and len(repository_digests) == len(set(repository_digests)) + ), + "local image repository digests are invalid", + ) + matching = sorted(item for item in (repository_digests or []) if item.startswith(f"{IMAGE_REPOSITORY}@")) + _require( + all(IMAGE_REFERENCE.fullmatch(item) is not None for item in matching), + "local image staging repository digest is invalid", + ) + return matching + + +def build_and_push_staging_image( + repo_root: Path, + context: Path, + *, + docker_binary: Path, + docker_host: str, + docker_config: Path, + output_receipt: Path, + execute_staging_push: bool, +) -> dict[str, Any]: + """Build one reviewed context, push one content-bound candidate, and receipt the result.""" + + _require(execute_staging_push is True, "staging image push requires explicit execution authorization") + image_input, build_args = validate_prepared_image_context(context) + validate_reviewed_context_source(repo_root, context, image_input) + _validate_docker_authority(docker_binary, docker_host, docker_config) + _validate_receipt_output_path(output_receipt) + _require( + context != output_receipt and context not in output_receipt.parents, + "build/push receipt must be outside the image context", + ) + + build_tag = f"input-{image_input['input_sha256']}" + build_reference = f"{LOCAL_BUILD_REPOSITORY}:{build_tag}" + try: + with _DockerCacheLock(docker_config): + _require( + _local_tag_config_id( + docker_binary, + docker_host, + docker_config, + repository=LOCAL_BUILD_REPOSITORY, + tag=build_tag, + ) + is None, + "local build tag must be absent before build", + ) + command = [ + "build", + "--quiet", + "--platform", + PLATFORM, + "--pull=false", + "--file", + str(context / "Dockerfile"), + "--tag", + build_reference, + ] + for name in sorted(build_args): + command.extend(["--build-arg", f"{name}={build_args[name]}"]) + command.append(str(context)) + built = _run_docker( + docker_binary, + docker_host, + docker_config, + command, + check=True, + timeout=1800, + ) + _bounded_docker_output(built, "staging image build") + built_image = _inspect_local_image(docker_binary, docker_host, docker_config, build_reference) + observed = validate_image_configuration(image_input, built_image) + build_config_id = observed["config_digest"] + _require( + _local_tag_config_id( + docker_binary, + docker_host, + docker_config, + repository=LOCAL_BUILD_REPOSITORY, + tag=build_tag, + ) + == build_config_id, + "local build tag differs from the inspected image", + ) + _require( + _target_repository_digests(built_image) == [], + "local build image already has a staging repository digest", + ) + post_build_input, post_build_args = validate_prepared_image_context(context) + _require( + post_build_input == image_input and post_build_args == build_args, + "prepared image context changed during build", + ) + validate_reviewed_context_source(repo_root, context, post_build_input) + + candidate_tag = f"candidate-{build_config_id.removeprefix('sha256:')}" + candidate_reference = f"{IMAGE_REPOSITORY}:{candidate_tag}" + _require( + _local_tag_config_id( + docker_binary, + docker_host, + docker_config, + repository=IMAGE_REPOSITORY, + tag=candidate_tag, + ) + is None, + "content-bound staging candidate tag already exists locally", + ) + tagged = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "tag", build_reference, candidate_reference], + check=True, + timeout=60, + ) + _bounded_docker_output(tagged, "staging candidate tag") + _require( + _local_tag_config_id( + docker_binary, + docker_host, + docker_config, + repository=IMAGE_REPOSITORY, + tag=candidate_tag, + ) + == build_config_id, + "staging candidate tag differs from the built image", + ) + pre_push_image = _inspect_local_image( + docker_binary, + docker_host, + docker_config, + candidate_reference, + ) + _require( + validate_image_configuration(image_input, pre_push_image) == observed, + "staging candidate configuration drifted before push", + ) + _require( + _target_repository_digests(pre_push_image) == [], + "staging candidate already has a repository digest before push", + ) + pushed = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "push", "--quiet", candidate_reference], + check=True, + timeout=1800, + ) + _bounded_docker_output(pushed, "staging image push") + post_push_image = _inspect_local_image( + docker_binary, + docker_host, + docker_config, + candidate_reference, + ) + _require( + validate_image_configuration(image_input, post_push_image) == observed, + "staging candidate configuration drifted across push", + ) + repository_digests = _target_repository_digests(post_push_image) + _require(len(repository_digests) == 1, "staging push did not produce one exact repository digest") + registry_reference = repository_digests[0] + match = IMAGE_REFERENCE.fullmatch(registry_reference) + _require(match is not None, "staging push repository digest is invalid") + receipt = build_build_push_receipt(image_input, post_push_image=post_push_image) + except (OSError, subprocess.SubprocessError, PackageError) as exc: + raise PackageError("staging image build/push failed") from exc + publish_build_push_receipt(output_receipt, receipt, image_input=image_input) + return receipt + + def _local_image_reference_config_id( docker_binary: Path, docker_host: str, @@ -1743,11 +2314,8 @@ def _local_image_reference_config_id( def inspect_registry_image( image_input: dict[str, Any], - image_reference: str, + build_push_receipt: dict[str, Any], *, - expected_revision: str, - expected_input_sha256: str, - expected_config_digest: str, docker_binary: Path, docker_host: str, docker_config: Path, @@ -1755,29 +2323,15 @@ def inspect_registry_image( """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_build_push_receipt(build_push_receipt, image_input=image_input) + image_reference = ( + f"{build_push_receipt['registry']['repository']}@{build_push_receipt['registry']['manifest_digest']}" ) + expected_config_digest = build_push_receipt["local_image"]["config_digest"] _validate_docker_authority(docker_binary, docker_host, docker_config) - with _DockerCacheLock(docker_config): - try: + inspection: dict[str, Any] | None = None + try: + with _DockerCacheLock(docker_config): _run_docker( docker_binary, docker_host, @@ -1807,19 +2361,22 @@ def inspect_registry_image( 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", + "inspected image differs from the receipted build config digest", ) - except (OSError, subprocess.SubprocessError, PackageError) as exc: - raise PackageError("immutable registry image inspection failed") from exc + except (OSError, subprocess.SubprocessError, PackageError) as exc: + raise PackageError("immutable registry image inspection failed") from exc + _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], + build_push_receipt: dict[str, Any], ) -> dict[str, Any]: validate_image_input(image_input) validate_image_inspection(inspection, image_input=image_input) + validate_build_push_receipt(build_push_receipt, 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") @@ -1836,6 +2393,7 @@ def build_release_descriptor( "labels": labels, "input_sha256": image_input["input_sha256"], }, + "build_push_receipt": build_push_receipt, "inspection": inspection, "image_input": image_input, } @@ -1859,7 +2417,16 @@ 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", "inspection", "image_input", "release_sha256"}, + set(value) + == { + "schema", + "target", + "image", + "build_push_receipt", + "inspection", + "image_input", + "release_sha256", + }, "release descriptor fields are not exact", ) _require(value.get("schema") == RELEASE_SCHEMA, "release descriptor schema is invalid") @@ -1877,11 +2444,37 @@ def validate_release_descriptor(value: dict[str, Any]) -> None: 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") + build_push_receipt = value.get("build_push_receipt") + _require(isinstance(build_push_receipt, dict), "release build/push receipt is missing") + validate_build_push_receipt(build_push_receipt, image_input=image_input) + receipt_reference = ( + f"{build_push_receipt['registry']['repository']}@{build_push_receipt['registry']['manifest_digest']}" + ) + _require( + receipt_reference == reference, + "release image differs from its build/push receipt", + ) + _require( + build_push_receipt["local_image"]["config_digest"] == image.get("config_digest"), + "release config digest differs from its build/push receipt", + ) 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( + build_push_receipt["local_image"]["platform"] == inspection.get("platform"), + "release platform differs from its build/push receipt", + ) + _require( + build_push_receipt["local_image"]["labels"] == inspection.get("labels"), + "release labels differ from its build/push receipt", + ) + _require( + build_push_receipt["local_image"]["runtime_config"] == inspection.get("runtime_config"), + "release runtime config differs from its build/push receipt", + ) _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") @@ -2017,6 +2610,226 @@ def _rename_noreplace(source: Path, destination: Path) -> None: raise OSError(error, os.strerror(error), str(destination)) +def _open_private_operator_directory(directory: Path, label: str) -> int: + _require(directory.is_absolute(), f"{label} must be absolute") + 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(directory, flags) + except OSError as exc: + raise PackageError(f"{label} is missing or unsafe") from exc + try: + observed = os.fstat(descriptor) + named = os.stat(directory, follow_symlinks=False) + _require( + stat.S_ISDIR(observed.st_mode) + and (observed.st_dev, observed.st_ino) == (named.st_dev, named.st_ino) + and observed.st_uid == os.geteuid() + and observed.st_mode & 0o022 == 0, + f"{label} must be an operator-owned directory without group/other write access", + ) + except (OSError, PackageError): + os.close(descriptor) + raise + return descriptor + + +def _directory_entry_stat(directory_descriptor: int, name: str) -> os.stat_result | None: + try: + return os.stat(name, dir_fd=directory_descriptor, follow_symlinks=False) + except FileNotFoundError: + return None + + +def _fsync_directory_descriptor(directory_descriptor: int) -> None: + observed = os.fstat(directory_descriptor) + if not stat.S_ISDIR(observed.st_mode): + raise OSError(errno.ENOTDIR, "fsync target is not a directory") + os.fsync(directory_descriptor) + + +def _validate_receipt_output_path(output_receipt: Path) -> None: + _require( + output_receipt.is_absolute() and output_receipt.name not in {"", ".", ".."}, + "build/push receipt output path is invalid", + ) + parent_descriptor = _open_private_operator_directory( + output_receipt.parent, + "build/push receipt parent", + ) + try: + _require( + _directory_entry_stat(parent_descriptor, output_receipt.name) is None, + "build/push receipt output already exists", + ) + finally: + os.close(parent_descriptor) + + +def _verify_receipt_entry( + directory_descriptor: int, + name: str, + expected_identity: tuple[int, int], + *, + expected_size: int, + expected_links: int, + label: str, +) -> os.stat_result: + try: + observed = os.stat(name, dir_fd=directory_descriptor, follow_symlinks=False) + except OSError as exc: + raise PackageError(f"{label} cannot identify output") from exc + _require( + stat.S_ISREG(observed.st_mode) + and (observed.st_dev, observed.st_ino) == expected_identity + and observed.st_uid == os.geteuid() + and stat.S_IMODE(observed.st_mode) == 0o600 + and observed.st_size == expected_size + and observed.st_nlink == expected_links, + f"{label} identity or posture drifted", + ) + return observed + + +def load_private_json(path: Path, label: str) -> dict[str, Any]: + """Read one authority-bearing JSON file through a private dirfd and stable inode.""" + + _require(path.is_absolute() and path.name not in {"", ".", ".."}, f"{label} path is invalid") + parent_descriptor = _open_private_operator_directory(path.parent, f"{label} parent") + descriptor: int | None = None + try: + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path.name, flags, dir_fd=parent_descriptor) + before = os.fstat(descriptor) + _require( + stat.S_ISREG(before.st_mode) + and before.st_uid == os.geteuid() + and before.st_mode & 0o022 == 0 + and before.st_nlink == 2 + and before.st_size <= MAX_BUILD_PUSH_RECEIPT_BYTES, + f"{label} file posture is unsafe", + ) + chunks: list[bytes] = [] + remaining = MAX_BUILD_PUSH_RECEIPT_BYTES + 1 + while remaining: + chunk = os.read(descriptor, min(65536, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + _require(len(payload) <= MAX_BUILD_PUSH_RECEIPT_BYTES, f"{label} is too large") + after = os.fstat(descriptor) + named = os.stat(path.name, dir_fd=parent_descriptor, follow_symlinks=False) + _require( + (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) + == (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns) + and (after.st_dev, after.st_ino) == (named.st_dev, named.st_ino) + and len(payload) == after.st_size, + f"{label} changed while it was read", + ) + except (OSError, UnicodeError) as exc: + raise PackageError(f"cannot read {label}: {type(exc).__name__}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_descriptor) + try: + encoded = payload.decode("utf-8") + except 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 publish_build_push_receipt( + output_receipt: Path, + receipt: dict[str, Any], + *, + image_input: dict[str, Any], +) -> None: + """Atomically publish one validated, self-hashed build/push receipt.""" + + validate_build_push_receipt(receipt, image_input=image_input) + _validate_receipt_output_path(output_receipt) + try: + payload = (json.dumps(receipt, allow_nan=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + except (TypeError, ValueError) as exc: + raise PackageError("cannot encode build/push receipt JSON") from exc + staged_name = f".{output_receipt.name}.{uuid.uuid4().hex}.tmp" + parent_descriptor = _open_private_operator_directory( + output_receipt.parent, + "build/push receipt parent", + ) + descriptor: int | None = None + identity: tuple[int, int] | None = None + try: + _require( + _directory_entry_stat(parent_descriptor, output_receipt.name) is None, + "build/push receipt output already exists", + ) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(staged_name, flags, 0o600, dir_fd=parent_descriptor) + observed = os.fstat(descriptor) + identity = (observed.st_dev, observed.st_ino) + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=False) as handle: + handle.write(payload) + handle.flush() + os.fsync(descriptor) + _verify_receipt_entry( + parent_descriptor, + staged_name, + identity, + expected_size=len(payload), + expected_links=1, + label="staged build/push receipt", + ) + staged_value = _strict_json_loads(payload.decode("utf-8"), "staged build/push receipt") + _require(isinstance(staged_value, dict), "staged build/push receipt must be a JSON object") + validate_build_push_receipt(staged_value, image_input=image_input) + os.link( + staged_name, + output_receipt.name, + src_dir_fd=parent_descriptor, + dst_dir_fd=parent_descriptor, + follow_symlinks=False, + ) + _verify_receipt_entry( + parent_descriptor, + output_receipt.name, + identity, + expected_size=len(payload), + expected_links=2, + label="published build/push receipt", + ) + _verify_receipt_entry( + parent_descriptor, + staged_name, + identity, + expected_size=len(payload), + expected_links=2, + label="retained build/push receipt link", + ) + _fsync_directory_descriptor(parent_descriptor) + except (OSError, PackageError) as exc: + if isinstance(exc, PackageError): + raise + raise PackageError("cannot publish build/push receipt") from exc + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_descriptor) + + def _remove_published_bundle(output_bundle: Path, expected_identity: tuple[int, int]) -> None: try: observed = output_bundle.stat(follow_symlinks=False) @@ -2073,9 +2886,7 @@ def publish_finalize_bundle(output_bundle: Path, release: dict[str, Any]) -> Non 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(), + 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) @@ -2237,12 +3048,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: generate_identity.add_argument("--database-identity", type=Path, required=True) generate_identity.add_argument("--identity-source-root", type=Path, required=True) generate_identity.add_argument("--output", type=Path, required=True) + build_push = subparsers.add_parser("build-push") + build_push.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + build_push.add_argument("--image-context", type=Path, required=True) + build_push.add_argument("--docker-binary", type=Path, required=True) + build_push.add_argument("--docker-host", required=True) + build_push.add_argument("--docker-config", type=Path, required=True) + build_push.add_argument("--output-receipt", type=Path, required=True) + build_push.add_argument("--execute-staging-push", action="store_true") finalize = subparsers.add_parser("finalize") finalize.add_argument("--image-input", type=Path, required=True) - finalize.add_argument("--image-reference", 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("--build-push-receipt", type=Path, 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) @@ -2317,19 +3133,30 @@ def main(argv: list[str] | None = None) -> int: _require(not args.output.exists() and not args.output.is_symlink(), "identity manifest output exists") args.output.parent.mkdir(parents=True, exist_ok=True) write_json(args.output, result, mode=0o600) + elif args.command == "build-push": + result = build_and_push_staging_image( + args.repo_root.resolve(), + args.image_context.absolute(), + docker_binary=args.docker_binary, + docker_host=args.docker_host, + docker_config=args.docker_config, + output_receipt=args.output_receipt.absolute(), + execute_staging_push=args.execute_staging_push, + ) elif args.command == "finalize": image_input = load_json(args.image_input, "image input") + build_push_receipt = load_private_json( + args.build_push_receipt.absolute(), + "build/push receipt", + ) 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, + build_push_receipt, docker_binary=args.docker_binary, docker_host=args.docker_host, docker_config=args.docker_config, ) - result = build_release_descriptor(image_input, inspection) + result = build_release_descriptor(image_input, inspection, build_push_receipt) publish_finalize_bundle(args.output_bundle, result) else: result = load_json(args.release, "release descriptor") diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index 2996a4b..60883a8 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -342,9 +342,7 @@ def fully_rehash_identity_bundle(identity: Path) -> None: lock = json.loads(lock_path.read_text(encoding="utf-8")) lock["manifest_sha256"] = manifest["manifest_sha256"] lock["identity_inputs_sha256"] = inputs_hash - lock["compiled_views"] = { - name: {"sha256": package.sha256_file(identity / name)} for name in sorted(rendered) - } + lock["compiled_views"] = {name: {"sha256": package.sha256_file(identity / name)} for name in sorted(rendered)} write_json(lock_path, lock) @@ -468,6 +466,141 @@ def docker_inventory_fixture( ) +def build_push_receipt_fixture( + image_input: dict[str, object], + reference: str | None = None, + *, + config_digest: str = "sha256:" + "3" * 64, +) -> dict[str, object]: + exact_reference = reference or f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + return package.build_build_push_receipt( + image_input, + post_push_image=docker_inspection_fixture( + image_input, + exact_reference, + config_digest=config_digest, + ), + ) + + +def prepared_context_fixture(tmp_path: Path) -> tuple[Path, dict[str, object]]: + artifact, receipt = artifact_fixture(tmp_path) + identity, _contract = identity_fixture(tmp_path, artifact, receipt) + context = tmp_path / "prepared-context" + image_input = package.prepare_context( + repo_root=ROOT, + artifact=artifact, + receipt=receipt, + identity=identity, + identity_source_root=ROOT, + output=context, + source_revision=REVISION, + ) + return context, image_input + + +def mocked_build_push_docker( + image_input: dict[str, object], + *, + post_push_references: list[str] | None = None, + fail_after_side_effect: str | None = None, +) -> tuple[object, dict[str, object], list[tuple[list[str], dict[str, object]]]]: + config_digest = "sha256:" + "3" * 64 + exact_reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + references = list(post_push_references if post_push_references is not None else [exact_reference]) + build_tag = f"input-{image_input['input_sha256']}" + build_reference = f"{package.LOCAL_BUILD_REPOSITORY}:{build_tag}" + candidate_tag = f"candidate-{config_digest.removeprefix('sha256:')}" + candidate_reference = f"{package.IMAGE_REPOSITORY}:{candidate_tag}" + state: dict[str, object] = {"build": False, "candidate": False, "digests": False, "pushes": 0} + calls: list[tuple[list[str], dict[str, object]]] = [] + + def inspection(reference: str) -> bytes: + value = docker_inspection_fixture(image_input, reference, config_digest=config_digest) + value["RepoDigests"] = references if state["digests"] else [] + return json.dumps([value]).encode() + + def fake_run(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]: + operation = arguments[5:] + calls.append((operation, kwargs)) + if operation[:2] == ["image", "ls"]: + repository = operation[-1] + if "--digests" in operation: + rows = [] + if state["digests"] and repository == package.IMAGE_REPOSITORY: + for reference in references: + if reference.startswith(f"{package.IMAGE_REPOSITORY}@"): + rows.append( + json.dumps( + { + "Repository": package.IMAGE_REPOSITORY, + "Digest": reference.rsplit("@", 1)[1], + "ID": config_digest, + } + ) + ) + payload = (("\n".join(rows) + "\n") if rows else "").encode() + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + rows = [] + if repository == package.LOCAL_BUILD_REPOSITORY and state["build"]: + rows.append( + json.dumps({"Repository": package.LOCAL_BUILD_REPOSITORY, "Tag": build_tag, "ID": config_digest}) + ) + if repository == package.IMAGE_REPOSITORY and state["candidate"]: + rows.append( + json.dumps({"Repository": package.IMAGE_REPOSITORY, "Tag": candidate_tag, "ID": config_digest}) + ) + payload = (("\n".join(rows) + "\n") if rows else "").encode() + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "build": + state["build"] = True + if fail_after_side_effect == "build": + raise subprocess.TimeoutExpired(arguments, 1800, stderr=b"credential=secret") + return subprocess.CompletedProcess(arguments, 0, stdout=b"sha256:ignored\n", stderr=b"") + if operation[:2] == ["image", "tag"]: + assert operation[2:] == [build_reference, candidate_reference] + state["candidate"] = True + if fail_after_side_effect == "tag": + raise subprocess.TimeoutExpired(arguments, 60, stderr=b"credential=secret") + return subprocess.CompletedProcess(arguments, 0, stdout=b"", stderr=b"") + if operation[:2] == ["image", "push"]: + assert operation[2:] == ["--quiet", candidate_reference] + state["digests"] = True + state["pushes"] = int(state["pushes"]) + 1 + if fail_after_side_effect == "push": + raise subprocess.TimeoutExpired(arguments, 1800, stderr=b"credential=secret") + return subprocess.CompletedProcess( + arguments, + 0, + stdout=("sha256:" + "9" * 64 + "\n").encode(), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + reference = operation[2] + present = ( + (reference == build_reference and state["build"]) + or (reference == candidate_reference and state["candidate"]) + or (reference in references and state["digests"]) + ) + assert present, reference + return subprocess.CompletedProcess(arguments, 0, stdout=inspection(reference), stderr=b"") + if operation[:2] == ["image", "rm"]: + reference = operation[2] + assert "--force" not in operation + if "@" in reference: + state["digests"] = False + elif reference == candidate_reference: + state["candidate"] = False + elif reference == build_reference: + state["build"] = False + else: + raise AssertionError(reference) + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + return fake_run, state, calls + + 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) @@ -609,7 +742,9 @@ def test_custom_no_send_policy_requires_explicit_validator_authority(tmp_path: P manifest, expected_runtime_policy_value=contract["permissions"]["runtime_policy"], ) - with pytest.raises(package.strict_identity_manifest.IdentityManifestError, match="expected identity runtime policy"): + with pytest.raises( + package.strict_identity_manifest.IdentityManifestError, match="expected identity runtime policy" + ): package.strict_identity_manifest.validate_identity_manifest(manifest, expected_runtime_policy_value={}) @@ -698,9 +833,7 @@ def test_installed_validation_rederives_fully_rehashed_identity_contract(tmp_pat image_input["runtime"]["artifact_sha256"] = "8" * 64 image_input["runtime"]["uv_lock_sha256"] = "9" * 64 fully_rehash_identity_contract(embedded) - manifest["identity_inputs"]["runtime"]["identity_runtime_sha256"] = embedded["runtime"][ - "identity_runtime_sha256" - ] + manifest["identity_inputs"]["runtime"]["identity_runtime_sha256"] = embedded["runtime"]["identity_runtime_sha256"] write_json(manifest_path, manifest) fully_rehash_identity_bundle(identity) refresh_image_identity_binding(image_input, identity) @@ -720,8 +853,12 @@ 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}" - inspection = package.build_image_inspection(image_input, reference, docker_inspection_fixture(image_input, reference)) - release = package.build_release_descriptor(image_input, inspection) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) package.validate_release_descriptor(release) assert release["image"]["reference"] == reference @@ -731,7 +868,11 @@ def test_release_requires_exact_digest_repository_and_strict_schema(tmp_path: Pa assert release["inspection"] == inspection with pytest.raises(package.PackageError, match="exact staging digest"): - package.build_release_descriptor(image_input, {**inspection, "reference": f"{package.IMAGE_REPOSITORY}:latest"}) + package.build_release_descriptor( + image_input, + {**inspection, "reference": f"{package.IMAGE_REPOSITORY}:latest"}, + build_push_receipt_fixture(image_input, reference), + ) with pytest.raises(package.PackageError, match="exact staging digest"): package.build_release_descriptor( image_input, @@ -739,6 +880,7 @@ def test_release_requires_exact_digest_repository_and_strict_schema(tmp_path: Pa **inspection, "reference": f"europe-west6-docker.pkg.dev/teleo-501523/teleo/other@sha256:{IMAGE_DIGEST}", }, + build_push_receipt_fixture(image_input, reference), ) extra = copy.deepcopy(release) @@ -761,6 +903,560 @@ def test_release_requires_exact_digest_repository_and_strict_schema(tmp_path: Pa package.validate_release_descriptor(wrong_inspection) +def test_build_push_receipt_is_strict_self_hashed_and_repeatable(tmp_path: Path) -> None: + image_input = image_input_fixture(tmp_path) + first = build_push_receipt_fixture(image_input) + second = build_push_receipt_fixture(image_input) + + assert first == second + assert first["schema"] == package.BUILD_PUSH_RECEIPT_SCHEMA + assert first["registry"] == { + "repository": package.IMAGE_REPOSITORY, + "manifest_digest": f"sha256:{IMAGE_DIGEST}", + } + stable = {key: value for key, value in first.items() if key != "receipt_sha256"} + assert first["receipt_sha256"] == package.canonical_sha256(stable) + package.validate_build_push_receipt(first, image_input=image_input) + + +@pytest.mark.parametrize( + ("mutation", "error"), + [ + ("extra", "fields are not exact"), + ("schema", "schema"), + ("revision", "source revision"), + ("input", "input binding"), + ("config", "config digest"), + ("platform", "platform"), + ("labels", "labels"), + ("runtime", "runtime config"), + ("repository", "repository"), + ("manifest", "manifest digest"), + ("self_hash", "self-hash"), + ], +) +def test_build_push_receipt_rejects_every_binding_drift( + tmp_path: Path, + mutation: str, + error: str, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + if mutation == "extra": + receipt["unexpected"] = True + elif mutation == "schema": + receipt["schema"] = "livingip.leocleanNoSendBuildPushReceipt.v0" + elif mutation == "revision": + receipt["source_revision"] = "9" * 40 + elif mutation == "input": + receipt["input_sha256"] = "9" * 64 + elif mutation == "config": + receipt["local_image"]["config_digest"] = "invalid" + elif mutation == "platform": + receipt["local_image"]["platform"] = "linux/arm64" + elif mutation == "labels": + receipt["local_image"]["labels"]["livingip.runtime"] = "other" + elif mutation == "runtime": + receipt["local_image"]["runtime_config"]["cmd"] = ["other"] + elif mutation == "repository": + receipt["registry"]["repository"] = "example.invalid/other" + elif mutation == "manifest": + receipt["registry"]["manifest_digest"] = "invalid" + else: + receipt["receipt_sha256"] = "9" * 64 + if mutation != "self_hash": + stable = {key: value for key, value in receipt.items() if key != "receipt_sha256"} + receipt["receipt_sha256"] = package.canonical_sha256(stable) + + with pytest.raises(package.PackageError, match=error): + package.validate_build_push_receipt(receipt, image_input=image_input) + + +def test_prepared_image_context_revalidates_exact_reviewed_closure(tmp_path: Path) -> None: + context, expected = prepared_context_fixture(tmp_path) + + observed, build_args = package.validate_prepared_image_context(context) + package.validate_reviewed_context_source(ROOT, context, observed) + + assert observed == expected + assert build_args == json.loads((context / "build-args.json").read_text(encoding="utf-8")) + + +@pytest.mark.parametrize( + "mutation", + ["extra", "build_args", "dockerfile", "identity_source", "script", "empty_directory", "directory_mode"], +) +def test_prepared_image_context_rejects_mutated_build_inputs(tmp_path: Path, mutation: str) -> None: + context, _image_input = prepared_context_fixture(tmp_path) + if mutation == "extra": + (context / "unreviewed.txt").write_text("unreviewed\n", encoding="utf-8") + error = "entry set" + elif mutation == "build_args": + build_args = json.loads((context / "build-args.json").read_text(encoding="utf-8")) + build_args["INPUT_SHA256"] = "9" * 64 + write_json(context / "build-args.json", build_args, mode=0o644) + error = "build arguments" + elif mutation == "dockerfile": + (context / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + error = "source drifted" + elif mutation == "identity_source": + source = next(path for path in (context / "identity-sources").rglob("*") if path.is_file()) + source.write_text("{}\n", encoding="utf-8") + error = "identity" + elif mutation == "script": + (context / "scripts" / "unreviewed.py").write_text("raise RuntimeError\n", encoding="utf-8") + error = "validator entry set" + elif mutation == "empty_directory": + (context / "artifact" / "unreviewed-runtime-directory").mkdir() + error = "directory entry set" + else: + (context / "artifact").chmod(0o700) + error = "directory mode" + + with pytest.raises(package.PackageError, match=error): + package.validate_prepared_image_context(context) + + +def test_prepared_image_context_rejects_every_file_mode_class(tmp_path: Path) -> None: + context, _image_input = prepared_context_fixture(tmp_path) + cases = [ + ("Dockerfile", 0o755, 0o644, "file mode drifted"), + ("scripts/leo_identity_manifest.py", 0o755, 0o644, "file mode drifted"), + ( + next( + path.relative_to(context).as_posix() + for path in (context / "identity-sources").rglob("*") + if path.is_file() + ), + 0o755, + 0o644, + "file mode drifted", + ), + ("identity/SOUL.md", 0o644, 0o600, "file mode drifted"), + ("artifact/artifact-manifest.json", 0o600, 0o644, "file mode drifted"), + ("artifact/bootstrap.py", 0o644, 0o755, "artifact content manifest drifted"), + ] + + for relative, changed_mode, expected_mode, error in cases: + target = context / relative + target.chmod(changed_mode) + with pytest.raises(package.PackageError, match=error): + package.validate_prepared_image_context(context) + target.chmod(expected_mode) + + package.validate_prepared_image_context(context) + + +def test_build_push_derives_receipt_and_retains_all_daemon_references( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + context = tmp_path / "context" + context.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + output = tmp_path / "build-push-receipt.json" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + fake_run, state, calls = mocked_build_push_docker(image_input) + reviewed: list[tuple[Path, Path, dict[str, object]]] = [] + monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) + monkeypatch.setattr( + package, + "validate_reviewed_context_source", + lambda root, observed_context, value: reviewed.append((root, observed_context, value)), + ) + monkeypatch.setattr(package.subprocess, "run", fake_run) + monkeypatch.setenv("DOCKER_TLS_VERIFY", "1") + monkeypatch.setenv("BUILDKIT_PROGRESS", "plain") + monkeypatch.setenv("BUILDX_BUILDER", "unreviewed") + + receipt = package.build_and_push_staging_image( + repo_root, + context, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + output_receipt=output, + execute_staging_push=True, + ) + + assert json.loads(output.read_text(encoding="utf-8")) == receipt + assert receipt["registry"]["manifest_digest"] == f"sha256:{IMAGE_DIGEST}" + assert receipt["registry"]["manifest_digest"] != "sha256:" + "9" * 64 + assert reviewed == [(repo_root, context, image_input), (repo_root, context, image_input)] + assert state == {"build": True, "candidate": True, "digests": True, "pushes": 1} + build = next(operation for operation, _kwargs in calls if operation and operation[0] == "build") + expected_prefix = [ + "build", + "--quiet", + "--platform", + package.PLATFORM, + "--pull=false", + "--file", + str(context / "Dockerfile"), + "--tag", + f"{package.LOCAL_BUILD_REPOSITORY}:input-{image_input['input_sha256']}", + ] + assert build[: len(expected_prefix)] == expected_prefix + assert build[-1] == str(context) + encoded_build_args = [build[index + 1] for index, item in enumerate(build) if item == "--build-arg"] + assert encoded_build_args == [f"{name}={build_args[name]}" for name in sorted(build_args)] + mutating = [ + operation + for operation, _kwargs in calls + if operation + and (operation[0] == "build" or operation[:2] in (["image", "tag"], ["image", "push"], ["image", "rm"])) + ] + assert [operation[:2] for operation in mutating] == [ + ["build", "--quiet"], + ["image", "tag"], + ["image", "push"], + ] + assert not any(operation[:2] == ["image", "rm"] for operation, _kwargs in calls) + for _operation, kwargs in calls: + assert kwargs["stdin"] == subprocess.DEVNULL + assert not any(name.startswith(("DOCKER_", "BUILDKIT_", "BUILDX_")) for name in kwargs["env"]) + + +def test_build_push_requires_explicit_execution_flag_before_validation(tmp_path: Path) -> None: + with pytest.raises(package.PackageError, match="explicit execution authorization"): + package.build_and_push_staging_image( + tmp_path, + tmp_path, + docker_binary=tmp_path / "docker", + docker_host="unix:///tmp/docker.sock", + docker_config=tmp_path, + output_receipt=tmp_path / "receipt.json", + execute_staging_push=False, + ) + + +@pytest.mark.parametrize( + "references", + [ + [], + [ + f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}", + f"{package.IMAGE_REPOSITORY}@sha256:{'4' * 64}", + ], + ], +) +def test_build_push_fails_closed_without_one_exact_post_push_digest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + references: list[str], +) -> None: + image_input = image_input_fixture(tmp_path) + build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + context = tmp_path / "context" + context.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + fake_run, state, _calls = mocked_build_push_docker( + image_input, + post_push_references=references, + ) + monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) + monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) + monkeypatch.setattr(package.subprocess, "run", fake_run) + + output = tmp_path / "failed-build-push-receipt.json" + with pytest.raises(package.PackageError, match="build/push failed"): + package.build_and_push_staging_image( + repo_root, + context, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + output_receipt=output, + execute_staging_push=True, + ) + + assert not output.exists() + assert state["build"] is True + assert state["candidate"] is True + + +@pytest.mark.parametrize("failure", ["build", "tag", "push"]) +def test_build_push_retains_daemon_references_when_cli_fails_after_side_effect( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + image_input = image_input_fixture(tmp_path) + build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + context = tmp_path / "context" + context.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + fake_run, state, calls = mocked_build_push_docker( + image_input, + fail_after_side_effect=failure, + ) + monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) + monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) + monkeypatch.setattr(package.subprocess, "run", fake_run) + output = tmp_path / "failed-side-effect-receipt.json" + + with pytest.raises(package.PackageError, match="build/push failed") as raised: + package.build_and_push_staging_image( + repo_root, + context, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + output_receipt=output, + execute_staging_push=True, + ) + + assert "credential=secret" not in str(raised.value) + assert not output.exists() + assert state == { + "build": True, + "candidate": failure in {"tag", "push"}, + "digests": failure == "push", + "pushes": 1 if failure == "push" else 0, + } + removals = [operation for operation, _kwargs in calls if operation[:2] == ["image", "rm"]] + assert removals == [] + + +def test_build_push_receipt_publication_is_atomic_repeatable_and_no_clobber(tmp_path: Path) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + first = tmp_path / "first.json" + second = tmp_path / "second.json" + + package.publish_build_push_receipt(first, receipt, image_input=image_input) + package.publish_build_push_receipt(second, receipt, image_input=image_input) + + assert first.read_bytes() == second.read_bytes() + assert first.stat().st_mode & 0o777 == second.stat().st_mode & 0o777 == 0o600 + before = first.stat(follow_symlinks=False) + with pytest.raises(package.PackageError, match="already exists"): + package.publish_build_push_receipt(first, receipt, image_input=image_input) + after = first.stat(follow_symlinks=False) + assert (after.st_dev, after.st_ino) == (before.st_dev, before.st_ino) + retained = list(tmp_path.glob(".*.tmp")) + assert len(retained) == 2 + assert {entry.stat().st_ino for entry in retained} == {first.stat().st_ino, second.stat().st_ino} + assert first.stat().st_nlink == second.stat().st_nlink == 2 + + +@pytest.mark.parametrize("mode", [0o770, 0o777]) +def test_build_push_receipt_rejects_group_or_world_writable_parent( + tmp_path: Path, + mode: int, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + shared = tmp_path / "shared" + shared.mkdir(mode=mode) + shared.chmod(mode) + output = shared / "receipt.json" + + with pytest.raises(package.PackageError, match="operator-owned directory"): + package.publish_build_push_receipt(output, receipt, image_input=image_input) + + assert not output.exists() + + +def test_build_push_receipt_refuses_replaced_staged_inode( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + output = tmp_path / "published-build-push-receipt.json" + original_link = package.os.link + + def replace_then_link(source: str, destination: str, **kwargs: object) -> None: + directory_descriptor = int(kwargs["src_dir_fd"]) + os.unlink(source, dir_fd=directory_descriptor) + replacement = os.open( + source, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=directory_descriptor, + ) + try: + os.write(replacement, b"attacker replacement") + os.fsync(replacement) + finally: + os.close(replacement) + original_link(source, destination, **kwargs) + + monkeypatch.setattr(package.os, "link", replace_then_link) + + with pytest.raises(package.PackageError, match="identity or posture drifted"): + package.publish_build_push_receipt(output, receipt, image_input=image_input) + + assert output.read_bytes() == b"attacker replacement" + staged = list(tmp_path.glob(".*.tmp")) + assert len(staged) == 1 + assert staged[0].read_bytes() == b"attacker replacement" + assert staged[0].stat().st_ino == output.stat().st_ino + + +def test_build_push_receipt_rollback_preserves_replaced_destination( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + output = tmp_path / "published-build-push-receipt.json" + replacement_payload = b"same-user replacement" + original_link = package.os.link + + def link_then_replace(source: str, destination: str, **kwargs: object) -> None: + original_link(source, destination, **kwargs) + directory_descriptor = int(kwargs["dst_dir_fd"]) + os.unlink(destination, dir_fd=directory_descriptor) + replacement = os.open( + destination, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=directory_descriptor, + ) + try: + os.write(replacement, replacement_payload) + os.fsync(replacement) + finally: + os.close(replacement) + + monkeypatch.setattr(package.os, "link", link_then_replace) + + with pytest.raises(package.PackageError, match="identity or posture drifted"): + package.publish_build_push_receipt(output, receipt, image_input=image_input) + + assert output.read_bytes() == replacement_payload + staged = list(tmp_path.glob(".*.tmp")) + assert len(staged) == 1 + assert staged[0].stat().st_ino != output.stat().st_ino + + +def test_build_push_receipt_never_automatically_unlinks_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + output = tmp_path / "published-build-push-receipt.json" + + def forbidden_unlink(*_args: object, **_kwargs: object) -> None: + raise AssertionError("receipt publication must not unlink pathnames") + + monkeypatch.setattr(package.os, "unlink", forbidden_unlink) + + package.publish_build_push_receipt(output, receipt, image_input=image_input) + + assert output.stat().st_nlink == 2 + + +def test_private_receipt_reader_detects_path_replacement_after_open( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + receipt_path = tmp_path / "published-build-push-receipt.json" + package.publish_build_push_receipt(receipt_path, receipt, image_input=image_input) + original_read = package.os.read + replaced = False + + def replace_then_read(descriptor: int, size: int) -> bytes: + nonlocal replaced + if not replaced: + replaced = True + receipt_path.unlink() + write_json(receipt_path, receipt, mode=0o600) + return original_read(descriptor, size) + + monkeypatch.setattr(package.os, "read", replace_then_read) + + with pytest.raises(package.PackageError, match="changed while it was read"): + package.load_private_json(receipt_path, "build/push receipt") + + +def test_private_receipt_reader_rejects_writable_file( + tmp_path: Path, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt_path = tmp_path / "receipt.json" + write_json(receipt_path, build_push_receipt_fixture(image_input), mode=0o660) + + with pytest.raises(package.PackageError, match="file posture is unsafe"): + package.load_private_json(receipt_path, "build/push receipt") + + +def test_build_push_receipt_publication_rolls_back_failed_parent_fsync( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + receipt = build_push_receipt_fixture(image_input) + output = tmp_path / "published-build-push-receipt.json" + original = package._fsync_directory_descriptor + parent_calls = 0 + + def fail_first_parent(descriptor: int) -> None: + nonlocal parent_calls + parent_calls += 1 + if parent_calls == 1: + raise OSError("injected") + original(descriptor) + + monkeypatch.setattr(package, "_fsync_directory_descriptor", fail_first_parent) + + with pytest.raises(package.PackageError, match="cannot publish"): + package.publish_build_push_receipt(output, receipt, image_input=image_input) + + assert parent_calls == 1 + assert output.exists() + retained = list(tmp_path.glob(".*.tmp")) + assert len(retained) == 1 + assert retained[0].stat().st_ino == output.stat().st_ino + + +def test_release_cross_binds_receipt_registry_and_independent_inspection(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), + ) + receipt = build_push_receipt_fixture(image_input, reference) + release = package.build_release_descriptor(image_input, inspection, receipt) + release["build_push_receipt"]["registry"]["manifest_digest"] = "sha256:" + "4" * 64 + stable_receipt = {key: value for key, value in release["build_push_receipt"].items() if key != "receipt_sha256"} + release["build_push_receipt"]["receipt_sha256"] = package.canonical_sha256(stable_receipt) + stable_release = {key: value for key, value in release.items() if key != "release_sha256"} + release["release_sha256"] = package.canonical_sha256(stable_release) + + with pytest.raises(package.PackageError, match="differs from its build/push receipt"): + package.validate_release_descriptor(release) + + @pytest.mark.parametrize( ("field", "value", "error"), [ @@ -971,14 +1667,8 @@ def test_cli_rejects_ambiguous_trust_bound_json_before_docker( "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, + "--build-push-receipt", + str(path), "--docker-binary", str(docker_binary), "--docker-host", @@ -992,6 +1682,62 @@ def test_cli_rejects_ambiguous_trust_bound_json_before_docker( assert package.main(arguments) == 65 +@pytest.mark.parametrize( + ("command", "forbidden_flag"), + [ + ("build-push", "--manifest-digest"), + ("build-push", "--image-reference"), + ("finalize", "--expected-config-digest"), + ("finalize", "--image-reference"), + ], +) +def test_build_push_and_finalize_expose_no_caller_digest_authority( + tmp_path: Path, + command: str, + forbidden_flag: str, +) -> None: + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + if command == "build-push": + arguments = [ + command, + "--image-context", + str(tmp_path), + "--docker-binary", + str(docker_binary), + "--docker-host", + docker_host, + "--docker-config", + str(docker_config), + "--output-receipt", + str(tmp_path / "receipt.json"), + forbidden_flag, + "sha256:" + "9" * 64, + ] + else: + arguments = [ + command, + "--image-input", + str(tmp_path / "image-input.json"), + "--build-push-receipt", + str(tmp_path / "receipt.json"), + "--docker-binary", + str(docker_binary), + "--docker-host", + docker_host, + "--docker-config", + str(docker_config), + "--output-bundle", + str(tmp_path / "bundle"), + forbidden_flag, + "sha256:" + "9" * 64, + ] + + with pytest.raises(SystemExit) as raised: + package._parse_args(arguments) + + assert raised.value.code == 2 + + @pytest.mark.parametrize( "inventory", [ @@ -1119,10 +1865,7 @@ def test_registry_inspection_absent_pull_dependent_retains_new_reference( 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, + build_push_receipt_fixture(image_input, reference, config_digest=config_digest), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1169,10 +1912,7 @@ def test_registry_inspection_retains_preexisting_reference( 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1216,16 +1956,13 @@ def test_registry_inspection_rejects_config_digest_mismatch_and_retains_referenc 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, + build_push_receipt_fixture(image_input, reference, 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 "receipted build config digest" in str(raised.value.__cause__) assert state == {"present": True, "removed": False} @@ -1263,10 +2000,7 @@ def test_registry_inspection_pull_failure_is_redacted_and_does_not_remove_refere 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1317,10 +2051,7 @@ def test_registry_inspection_validation_failure_retains_reference( 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1366,10 +2097,7 @@ def test_registry_inspection_never_calls_image_rm_after_success( 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1423,10 +2151,7 @@ def test_registry_inspection_has_no_post_inspection_cleanup_window( 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1470,10 +2195,7 @@ def test_registry_inspection_accepts_preexisting_reference_without_removal( 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1484,46 +2206,30 @@ def test_registry_inspection_accepts_preexisting_reference_without_removal( assert not any(operation[:2] == ["image", "rm"] for operation in operations) -def test_registry_inspection_rejects_unreviewed_binding_before_docker( +@pytest.mark.parametrize("mutation", ["revision", "input", "config"]) +def test_registry_inspection_rejects_receipt_binding_drift_before_docker( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + mutation: 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) monkeypatch.setattr(package.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Docker was contacted")) + receipt = build_push_receipt_fixture(image_input, reference) + if mutation == "revision": + receipt["source_revision"] = "9" * 40 + elif mutation == "input": + receipt["input_sha256"] = "9" * 64 + else: + receipt["local_image"]["config_digest"] = "invalid" + stable = {key: value for key, value in receipt.items() if key != "receipt_sha256"} + receipt["receipt_sha256"] = package.canonical_sha256(stable) - with pytest.raises(package.PackageError, match="expected reviewed revision"): + with pytest.raises(package.PackageError): 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", + receipt, docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, @@ -1564,17 +2270,15 @@ def test_registry_inspection_rejects_cache_lock_contention_before_docker( monkeypatch.setattr(package.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("Docker was contacted")) try: - with pytest.raises(package.PackageError, match="cache authority"): + 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, + build_push_receipt_fixture(image_input, reference), docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, ) + assert "cache authority" in str(raised.value.__cause__) finally: package.fcntl.flock(descriptor, package.fcntl.LOCK_UN) os.close(descriptor) @@ -1588,6 +2292,12 @@ def test_finalize_failure_creates_no_output( image_input = image_input_fixture(tmp_path) image_input_path = tmp_path / "image-input.json" write_json(image_input_path, image_input, mode=0o644) + receipt_path = tmp_path / "build-push-receipt.json" + package.publish_build_push_receipt( + receipt_path, + build_push_receipt_fixture(image_input), + image_input=image_input, + ) docker_binary, docker_host, docker_config = docker_authority(tmp_path) output_bundle = tmp_path / "nested" / "release-bundle" @@ -1606,14 +2316,8 @@ def test_finalize_failure_creates_no_output( "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, + "--build-push-receipt", + str(receipt_path), "--docker-binary", str(docker_binary), "--docker-host", @@ -1641,7 +2345,16 @@ def test_finalize_embeds_inspection_before_rendering_unit( 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)) + build_push_receipt = build_push_receipt_fixture(image_input, reference) + receipt_path = tmp_path / "build-push-receipt.json" + package.publish_build_push_receipt( + receipt_path, + build_push_receipt, + image_input=image_input, + ) + 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() @@ -1649,13 +2362,13 @@ def test_finalize_embeds_inspection_before_rendering_unit( def capture_inspection( observed_input: dict[str, object], - observed_reference: str, + observed_receipt: dict[str, object], **kwargs: object, ) -> dict[str, object]: observed.update( { "image_input": observed_input, - "image_reference": observed_reference, + "build_push_receipt": observed_receipt, **kwargs, } ) @@ -1668,14 +2381,8 @@ def test_finalize_embeds_inspection_before_rendering_unit( "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, + "--build-push-receipt", + str(receipt_path), "--docker-binary", str(docker_binary), "--docker-host", @@ -1694,10 +2401,7 @@ def test_finalize_embeds_inspection_before_rendering_unit( 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, + "build_push_receipt": build_push_receipt, "docker_binary": docker_binary, "docker_host": docker_host, "docker_config": docker_config, @@ -1713,8 +2417,12 @@ def test_finalize_bundle_failure_leaves_no_partial_output_or_temporary_residue( ) -> 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) output_bundle = tmp_path / "final" / "bundle" output_bundle.parent.mkdir() if failure == "unit_write": @@ -1743,8 +2451,12 @@ def test_finalize_bundle_failure_leaves_no_partial_output_or_temporary_residue( 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) first = tmp_path / "first" / "bundle" second = tmp_path / "second" / "bundle" first.parent.mkdir() @@ -1763,8 +2475,12 @@ def test_finalize_bundle_is_byte_repeatable(tmp_path: Path) -> None: 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) destination = tmp_path / "bundle" marker = tmp_path / "marker" marker.write_text("preserve\n", encoding="utf-8") @@ -1793,8 +2509,12 @@ def test_finalize_bundle_preserves_preexisting_destination(tmp_path: Path, desti 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) destination = tmp_path / "missing" / "bundle" with pytest.raises(package.PackageError, match="existing safe absolute directory"): @@ -1809,8 +2529,12 @@ def test_finalize_bundle_race_cannot_replace_destination( ) -> 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) destination = tmp_path / "bundle" original = package._rename_noreplace raced_identity: tuple[int, int] | None = None @@ -1841,8 +2565,12 @@ def test_finalize_bundle_rolls_back_and_fsyncs_parent_after_each_durability_fail ) -> 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) destination = tmp_path / "final" / "bundle" destination.parent.mkdir() original = package._fsync_path @@ -1872,8 +2600,12 @@ def test_finalize_bundle_reports_rollback_fsync_failure( ) -> 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) destination = tmp_path / "final" / "bundle" destination.parent.mkdir() original = package._fsync_path @@ -1901,8 +2633,12 @@ def test_finalize_bundle_surfaces_temporary_cleanup_failure( ) -> 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) output_bundle = tmp_path / "final" / "bundle" output_bundle.parent.mkdir() original_write_text = Path.write_text @@ -1922,12 +2658,20 @@ def test_finalize_bundle_surfaces_temporary_cleanup_failure( assert list(output_bundle.parent.glob(".*.tmp")) -def test_release_v1_is_rejected_without_compatibility_shim(tmp_path: Path) -> None: +@pytest.mark.parametrize( + "schema", + ["livingip.leocleanNoSendRelease.v1", "livingip.leocleanNoSendRelease.v2"], +) +def test_older_release_schemas_are_rejected_without_compatibility_shim(tmp_path: Path, schema: 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) - release["schema"] = "livingip.leocleanNoSendRelease.v1" + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) + release["schema"] = schema with pytest.raises(package.PackageError, match="schema"): package.validate_release_descriptor(release) @@ -1936,8 +2680,12 @@ def test_release_v1_is_rejected_without_compatibility_shim(tmp_path: Path) -> No 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) 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) + inspection = package.build_image_inspection( + image_input, reference, docker_inspection_fixture(image_input, reference) + ) + release = package.build_release_descriptor( + image_input, inspection, build_push_receipt_fixture(image_input, inspection["reference"]) + ) unit = package.render_systemd_unit(release) assert "leoclean-gcp-nosend" in unit