diff --git a/docs/gcp-leoclean-nosend-service-package.md b/docs/gcp-leoclean-nosend-service-package.md index 85c19fe..5e101bc 100644 --- a/docs/gcp-leoclean-nosend-service-package.md +++ b/docs/gcp-leoclean-nosend-service-package.md @@ -186,13 +186,15 @@ The Docker binary, config directory, and local `unix://` daemon endpoint are explicit inputs; inherited `DOCKER_*` overrides are removed. The pull does not start a container, install a unit, restart a service, or contact Cloud SQL. Registry authentication and this pull require a separate live-read approval. -The exact repository-digest reference must be absent before inspection; use a -fresh isolated Docker context if it is already cached. While holding an -advisory lock on the explicit Docker config directory, the command pulls, -records the dynamically observed config ID, then removes the reference only if -that ID is still unchanged and proves it absent before publishing the bundle. -Content-addressable layers may remain. A failed pull, inspection, or cleanup -returns a bounded error without forwarding Docker stderr. +While holding an advisory lock on the explicit Docker config directory, the +command pulls and records the dynamically observed config ID. It intentionally +retains the exact repository-digest reference: Docker's image cache belongs to +the daemon, so a config-directory lock cannot prove that another client did not +start relying on the same immutable reference after the precheck. Repeated runs +re-pull and re-inspect the same digest without deleting shared cache state. +Content-addressable layers and the verified digest reference may remain. A +failed pull or inspection returns a bounded error without forwarding Docker +stderr. Tags, alternate repositories, alternate projects/instances/service accounts, extra descriptor fields, wrong-architecture images, missing repository-digest @@ -202,7 +204,8 @@ values are rejected. ## Offline evidence and live gate Offline tests prove descriptor and inspection strictness, explicit local Docker -authority, cache-reference rollback, atomic bundle publication, artifact and synthetic identity binding, +authority, non-destructive cache reuse, atomic bundle publication, artifact and +synthetic identity binding, digest-only images, pinned runtime versions, a secret-free context, the capability-drop contract, no published ports, no Docker-socket mount, no environment file, and no production/VPS/Telegram target. CI also builds exactly @@ -231,7 +234,7 @@ virtual environment. Publishing the draft branch and stacked PR is only a review handoff. The next deployment-evidence step must execute one authorized remote registry read plus -the reversible local exact-reference cache mutation against the staging digest. +the retained local exact-reference cache mutation against the staging digest. A later installer slice must prove installation and rollback consume the same receipt. Live installation, Artifact Registry publication, service restart, IAM/secret changes, Cloud SQL access, and proposal staging remain separate @@ -240,10 +243,10 @@ approvals. ## Rollback and sunset This slice's rollback boundary is removal of the unpublished atomic output -bundle plus removal of the exact repository-digest reference that this command -introduced into an initially clean, advisory-locked cache; content-addressable -layers may remain. It installs no unit -and changes no running service. Restoring a prior descriptor, unit, and digest +bundle. The verified repository-digest reference and content-addressable layers +may remain in the local daemon cache so the finalizer never deletes shared cache +state it cannot exclusively own. It installs no unit and changes no running +service. Restoring a prior descriptor, unit, and digest belongs to the later installer slice, which must never restart or edit the production Leo, Telegram, or VPS services. diff --git a/ops/gcp_leoclean_nosend_package.py b/ops/gcp_leoclean_nosend_package.py index fd30b2b..6371df2 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -1781,6 +1781,7 @@ def inspect_registry_image( docker_binary: Path, docker_host: str, docker_config: Path, + cleanup_new_reference: bool = False, ) -> dict[str, Any]: """Pull and inspect one immutable Artifact Registry image without starting it.""" @@ -1812,11 +1813,12 @@ def inspect_registry_image( primary_error: BaseException | None = None with _DockerCacheLock(docker_config): try: - _require( - _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, - "exact registry image reference must be absent before inspection", - ) - prestate_observed = True + if cleanup_new_reference: + _require( + _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, + "exact registry image reference must be absent before inspection cleanup", + ) + prestate_observed = True _run_docker( docker_binary, docker_host, @@ -1852,7 +1854,7 @@ def inspect_registry_image( primary_error = exc finally: try: - if prestate_observed: + if cleanup_new_reference and prestate_observed: if pulled_config_id is None: pulled_config_id = _local_image_reference_config_id( docker_binary, @@ -2390,6 +2392,7 @@ def main(argv: list[str] | None = None) -> int: docker_binary=args.docker_binary, docker_host=args.docker_host, docker_config=args.docker_config, + cleanup_new_reference=False, ) result = build_release_descriptor(image_input, inspection) publish_finalize_bundle(args.output_bundle, result) diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index 97e4ddf..ab52c4b 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -1125,6 +1125,7 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert inspection["status"] == "pass" @@ -1136,6 +1137,52 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer assert [call[5:] for call in calls if call[5:7] == ["image", "rm"]] == [["image", "rm", reference]] +def test_registry_inspection_default_retains_preexisting_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + payload = json.dumps([docker_inspection_fixture(image_input, reference)]).encode() + state = {"present": True, "pulls": 0, "removed": False} + + def fake_run(arguments: list[str], **_kwargs): + operation = arguments[5:] + if operation[:2] == ["image", "ls"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=docker_inventory_fixture(reference, present=state["present"]), + stderr=b"", + ) + if operation[:2] == ["image", "inspect"]: + return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + state["pulls"] += 1 + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + state["removed"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError(operation) + + monkeypatch.setattr(package.subprocess, "run", fake_run) + + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert inspection["status"] == "pass" + assert state == {"present": True, "pulls": 1, "removed": False} + + def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1177,6 +1224,7 @@ def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_ docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert "expected reviewed config digest" in str(raised.value.__cause__) @@ -1224,6 +1272,7 @@ def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert state["present"] is False @@ -1278,6 +1327,7 @@ def test_registry_inspection_validation_failure_removes_new_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert state == {"present": False, "removed": True} @@ -1328,6 +1378,7 @@ def test_registry_inspection_cleanup_failure_prevents_success( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) @@ -1383,6 +1434,7 @@ def test_registry_inspection_refuses_to_remove_changed_reference( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert state["present"] is True @@ -1425,6 +1477,7 @@ def test_registry_inspection_rejects_preexisting_reference_without_mutation( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + cleanup_new_reference=True, ) assert "must be absent" in str(raised.value.__cause__) @@ -1649,6 +1702,7 @@ def test_finalize_embeds_inspection_before_rendering_unit( "docker_binary": docker_binary, "docker_host": docker_host, "docker_config": docker_config, + "cleanup_new_reference": False, } assert json.loads(capsys.readouterr().out) == {"schema": package.RELEASE_SCHEMA, "status": "pass"}