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..f8d63ef 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -1741,36 +1741,6 @@ def _local_image_reference_config_id( return config_id -def _remove_new_image_reference( - docker_binary: Path, - docker_host: str, - docker_config: Path, - image_reference: str, - expected_config_id: str, -) -> None: - observed_config_id = _local_image_reference_config_id( - docker_binary, - docker_host, - docker_config, - image_reference, - ) - if observed_config_id is None: - return - _require(observed_config_id == expected_config_id, "new registry image reference changed before cleanup") - _run_docker( - docker_binary, - docker_host, - docker_config, - ["image", "rm", image_reference], - check=True, - timeout=120, - ) - _require( - _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, - "new registry image reference cleanup did not pass", - ) - - def inspect_registry_image( image_input: dict[str, Any], image_reference: str, @@ -1806,17 +1776,8 @@ def inspect_registry_image( "image input differs from the expected reviewed input SHA-256", ) _validate_docker_authority(docker_binary, docker_host, docker_config) - prestate_observed = False - pulled_config_id: str | None = None - inspection: dict[str, Any] | None = None - primary_error: BaseException | None = None with _DockerCacheLock(docker_config): try: - _require( - _local_image_reference_config_id(docker_binary, docker_host, docker_config, image_reference) is None, - "exact registry image reference must be absent before inspection", - ) - prestate_observed = True _run_docker( docker_binary, docker_host, @@ -1849,30 +1810,7 @@ def inspect_registry_image( "inspected image differs from the expected reviewed config digest", ) except (OSError, subprocess.SubprocessError, PackageError) as exc: - primary_error = exc - finally: - try: - if prestate_observed: - if pulled_config_id is None: - pulled_config_id = _local_image_reference_config_id( - docker_binary, - docker_host, - docker_config, - image_reference, - ) - if pulled_config_id is not None: - _remove_new_image_reference( - docker_binary, - docker_host, - docker_config, - image_reference, - pulled_config_id, - ) - except (OSError, subprocess.SubprocessError, PackageError) as cleanup_error: - raise PackageError("immutable registry image cleanup failed") from cleanup_error - if primary_error is not None: - raise PackageError("immutable registry image inspection failed") from primary_error - _require(inspection is not None, "immutable registry image inspection failed") + raise PackageError("immutable registry image inspection failed") from exc return inspection diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index 97e4ddf..2996a4b 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -1064,7 +1064,7 @@ def test_local_image_inventory_rejects_inventory_and_inspection_disagreement( @pytest.mark.parametrize("config_digest", ["sha256:" + "3" * 64, "sha256:" + "a" * 64]) -def test_registry_inspection_uses_explicit_local_authority_and_removes_new_reference( +def test_registry_inspection_absent_pull_dependent_retains_new_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config_digest: str, @@ -1075,7 +1075,7 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer prefix = [str(docker_binary), "--host", docker_host, "--config", str(docker_config)] calls: list[list[str]] = [] environments: list[dict[str, str]] = [] - state = {"present": False} + state = {"present": False, "dependent": False, "inspection_calls": 0} payload = json.dumps( [docker_inspection_fixture(image_input, reference, config_digest=config_digest)] ).encode() @@ -1099,6 +1099,9 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer stderr=b"", ) if operation[:2] == ["image", "inspect"]: + state["inspection_calls"] += 1 + if state["inspection_calls"] == 2: + state["dependent"] = True return subprocess.CompletedProcess( arguments, 0, @@ -1109,9 +1112,7 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer state["present"] = True return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") if operation[:2] == ["image", "rm"]: - assert "--force" not in operation - state["present"] = False - return subprocess.CompletedProcess(arguments, 0, stdout=b"removed\n", stderr=b"") + raise AssertionError("digest reference must never be removed") raise AssertionError(operation) monkeypatch.setattr(package.subprocess, "run", fake_run) @@ -1128,15 +1129,60 @@ def test_registry_inspection_uses_explicit_local_authority_and_removes_new_refer ) assert inspection["status"] == "pass" - assert state["present"] is False + assert state == {"present": True, "dependent": True, "inspection_calls": 2} assert [call[5:] for call in calls if call[5] == "pull"] == [ ["pull", "--quiet", "--platform", package.PLATFORM, reference] ] assert all(not any(name.startswith("DOCKER_") for name in environment) for environment in environments) - assert [call[5:] for call in calls if call[5:7] == ["image", "rm"]] == [["image", "rm", reference]] + assert not any(call[5:7] == ["image", "rm"] for call in calls) -def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_reference( +def test_registry_inspection_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} + + 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"]: + raise AssertionError("digest reference must never be removed") + 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} + + +def test_registry_inspection_rejects_config_digest_mismatch_and_retains_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1180,10 +1226,10 @@ def test_registry_inspection_rejects_reviewed_config_digest_mismatch_and_cleans_ ) assert "expected reviewed config digest" in str(raised.value.__cause__) - assert state == {"present": False, "removed": True} + assert state == {"present": True, "removed": False} -def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( +def test_registry_inspection_pull_failure_is_redacted_and_does_not_remove_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1226,11 +1272,11 @@ def test_registry_inspection_failure_is_redacted_and_cleans_new_reference( docker_config=docker_config, ) - assert state["present"] is False + assert state["present"] is True assert "credential=value" not in str(raised.value) -def test_registry_inspection_validation_failure_removes_new_reference( +def test_registry_inspection_validation_failure_retains_reference( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1280,10 +1326,10 @@ def test_registry_inspection_validation_failure_removes_new_reference( docker_config=docker_config, ) - assert state == {"present": False, "removed": True} + assert state == {"present": True, "removed": False} -def test_registry_inspection_cleanup_failure_prevents_success( +def test_registry_inspection_never_calls_image_rm_after_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1318,20 +1364,22 @@ def test_registry_inspection_cleanup_failure_prevents_success( monkeypatch.setattr(package.subprocess, "run", fake_run) - with pytest.raises(package.PackageError, match="cleanup failed"): - package.inspect_registry_image( - image_input, - reference, - expected_revision=image_input["runtime"]["teleo_git_head"], - expected_input_sha256=image_input["input_sha256"], - expected_config_digest="sha256:" + "3" * 64, - docker_binary=docker_binary, - docker_host=docker_host, - docker_config=docker_config, - ) + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + + assert inspection["status"] == "pass" + assert state["present"] is True -def test_registry_inspection_refuses_to_remove_changed_reference( +def test_registry_inspection_has_no_post_inspection_cleanup_window( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1373,23 +1421,24 @@ def test_registry_inspection_refuses_to_remove_changed_reference( monkeypatch.setattr(package.subprocess, "run", fake_run) - with pytest.raises(package.PackageError, match="cleanup failed"): - package.inspect_registry_image( - image_input, - reference, - expected_revision=image_input["runtime"]["teleo_git_head"], - expected_input_sha256=image_input["input_sha256"], - expected_config_digest="sha256:" + "3" * 64, - docker_binary=docker_binary, - docker_host=docker_host, - docker_config=docker_config, - ) + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) + assert inspection["status"] == "pass" assert state["present"] is True assert state["removed"] is False + assert state["inventory_calls"] == 1 -def test_registry_inspection_rejects_preexisting_reference_without_mutation( +def test_registry_inspection_accepts_preexisting_reference_without_removal( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1411,24 +1460,27 @@ def test_registry_inspection_rejects_preexisting_reference_without_mutation( ) if operation[:2] == ["image", "inspect"]: return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") + if operation[0] == "pull": + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[:2] == ["image", "rm"]: + raise AssertionError("digest reference must never be removed") raise AssertionError(operation) monkeypatch.setattr(package.subprocess, "run", fake_run) - with pytest.raises(package.PackageError, match="inspection failed") as raised: - package.inspect_registry_image( - image_input, - reference, - expected_revision=image_input["runtime"]["teleo_git_head"], - expected_input_sha256=image_input["input_sha256"], - expected_config_digest="sha256:" + "3" * 64, - docker_binary=docker_binary, - docker_host=docker_host, - docker_config=docker_config, - ) + inspection = package.inspect_registry_image( + image_input, + reference, + expected_revision=image_input["runtime"]["teleo_git_head"], + expected_input_sha256=image_input["input_sha256"], + expected_config_digest="sha256:" + "3" * 64, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + ) - assert "must be absent" in str(raised.value.__cause__) - assert not any(operation and operation[0] == "pull" for operation in operations) + assert inspection["status"] == "pass" + assert any(operation and operation[0] == "pull" for operation in operations) assert not any(operation[:2] == ["image", "rm"] for operation in operations)