diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 716748e..552db82 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `68878c3167dacf2b424c16780e60e0d60815e477`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `828f7274cafede223d37b2697367bfd89357b079`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index e2cd8bc..efab657 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "68878c3167dacf2b424c16780e60e0d60815e477", + "sourceRevision": "828f7274cafede223d37b2697367bfd89357b079", "files": [ { "path": ".dockerignore", @@ -884,8 +884,8 @@ }, { "path": "backend/tests/test_appops_effective_transaction_reconciliation.py", - "sha256": "486e3cf3b6c2a39763402cd00c4a28b903de128498f2d489d76a0f6d08daaa00", - "bytes": 38457 + "sha256": "a8e19ab869bbefd23fc2ea495009e7ccc176a1791ee2fb4fd11dbb5f3b6fa02a", + "bytes": 38806 }, { "path": "backend/tests/test_appops_live_state_recovery.py", @@ -914,8 +914,13 @@ }, { "path": "backend/tests/test_appops_self_upgrade_runtime_model.py", - "sha256": "cbc9febd7855b987395c7f352589a0e5cbddc8ea0c2f44cc25a24655c5fd3fad", - "bytes": 34600 + "sha256": "bdc6aa7c6bf4acdf9fc3ee1869b20aaf1b33df65960301c18e106f05e057262d", + "bytes": 38998 + }, + { + "path": "backend/tests/test_appops_signed_rollback_recovery.py", + "sha256": "447788187bb37bcaac7d99df8fdbe8caa76c0e71acea3c5c8f86faf3972d572d", + "bytes": 16611 }, { "path": "backend/tests/test_authentik_connector.py", @@ -1414,8 +1419,8 @@ }, { "path": "backend/tests/test_registered_build_context.py", - "sha256": "82be26e40510252a9f8fbeea57b170d5e8cada2f28a770fc3ad229fbb7fd6eea", - "bytes": 13801 + "sha256": "1aef4bb4634a89d8e1c47da3c2ba209958653f2b7108368413d5d889d69052eb", + "bytes": 15112 }, { "path": "backend/tests/test_registered_build_metadata.py", @@ -1459,8 +1464,8 @@ }, { "path": "backend/tests/test_registered_legacy_runtime.py", - "sha256": "094c5bcaa5108bfc973cf56af9d666c55374a54717f9ae449af05afb48b90999", - "bytes": 49098 + "sha256": "6038b7aab1e9501e666ed5e8a5c7819ea5f56cce756f38562d4b32d4d78ab70d", + "bytes": 49968 }, { "path": "backend/tests/test_registered_legacy_transport.py", diff --git a/backend/tests/test_appops_effective_transaction_reconciliation.py b/backend/tests/test_appops_effective_transaction_reconciliation.py index 274efd0..3d1510d 100644 --- a/backend/tests/test_appops_effective_transaction_reconciliation.py +++ b/backend/tests/test_appops_effective_transaction_reconciliation.py @@ -898,7 +898,7 @@ def test_model_rollback_rebinds_the_restored_live_proof( "appops_runtime_model": pointer, } reference = {**pointer, "liveRecordSha256": "d" * 64} - calls = {"plain_snapshot": None, "binding": None, "recorded_receipt": None} + calls = {"plain_snapshot": None, "binding": None, "recorded_receipt": None, "health_verified": False} class StatusReader: @staticmethod @@ -924,7 +924,13 @@ def test_model_rollback_rebinds_the_restored_live_proof( lambda _project, observed_pointer: {"pointer": observed_pointer, "model": model}, ) monkeypatch.setattr(deploy, "_appops_select_effective_model", lambda *_args: None) - monkeypatch.setattr(deploy, "verify_appops_legacy_runtime", lambda *_args, **_kwargs: None) + def health(_project, *, expected_image_ids): + assert expected_image_ids == {"Unraid-AppOps-Gateway": deploy.APPOPS_LEGACY_OLD_IMAGE_ID} + calls["health_verified"] = True + def verify_runtime(*_args, **_kwargs): + assert calls["health_verified"] is True + monkeypatch.setattr(deploy, "verify_containers", health) + monkeypatch.setattr(deploy, "verify_appops_legacy_runtime", verify_runtime) monkeypatch.setattr( deploy, "_appops_captured_environment", lambda _container: {"KNOWLEDGE_OPTIMIZER_MODEL": "synthetic-model"}, diff --git a/backend/tests/test_appops_self_upgrade_runtime_model.py b/backend/tests/test_appops_self_upgrade_runtime_model.py index 660133a..516b962 100644 --- a/backend/tests/test_appops_self_upgrade_runtime_model.py +++ b/backend/tests/test_appops_self_upgrade_runtime_model.py @@ -95,20 +95,24 @@ def _legacy_model(module, image_ref: str | None = None) -> dict[str, object]: } -def _mock_legacy_docker(monkeypatch, module): - environment = _legacy_environment() +def _mock_legacy_docker(monkeypatch, module, *, candidate=False): + environment = _environment() if candidate else _legacy_environment() raw_env = [f"{key}={value}" for key, value in environment.items()] - raw_env.append(f"APPOPS_FRONTEND_ORIGIN={environment['APPOPS_FRONTEND_ORIGIN']}") + if not candidate: + raw_env.append(f"APPOPS_FRONTEND_ORIGIN={environment['APPOPS_FRONTEND_ORIGIN']}") + revision = REVISION if candidate else module.SELF_UPGRADE_OLD_REVISION + tree = TREE if candidate else module.SELF_UPGRADE_OLD_TREE + image_id = IMAGE_ID if candidate else module.SELF_UPGRADE_IMAGE_ID image_labels = { "org.opencontainers.image.source": module.SELF_UPGRADE_REPOSITORY, - "org.opencontainers.image.revision": module.SELF_UPGRADE_OLD_REVISION, - "org.opencontainers.image.source-tree": module.SELF_UPGRADE_OLD_TREE, - "org.opencontainers.image.version": module.SELF_UPGRADE_OLD_REVISION[:12], - "org.opencontainers.image.created": "2026-09-01T00:00:00.000Z", + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source-tree": tree, + "org.opencontainers.image.version": revision[:12], + "org.opencontainers.image.created": BUILT_AT if candidate else "2026-09-01T00:00:00.000Z", } container = { "Id": CONTAINER_ID, - "Image": module.SELF_UPGRADE_IMAGE_ID, + "Image": image_id, "State": {"Running": True, "Health": {"Status": "healthy"}}, "Mounts": [ {"Type": "bind", "Source": module.SELF_UPGRADE_DATA_PATH, @@ -117,7 +121,7 @@ def _mock_legacy_docker(monkeypatch, module): "Destination": module.SELF_UPGRADE_MODELS_TARGET, "RW": True}, ], "Config": { - "Image": module.SELF_UPGRADE_OLD_IMAGE_REF, + "Image": module.SELF_UPGRADE_PRODUCTION_IMAGE_REF if candidate else module.SELF_UPGRADE_OLD_IMAGE_REF, "Hostname": "unraid-appops-gateway", "Env": raw_env, "Labels": { @@ -137,7 +141,7 @@ def _mock_legacy_docker(monkeypatch, module): }, }, } - image = {"Id": module.SELF_UPGRADE_IMAGE_ID, "Config": {"Labels": image_labels}} + image = {"Id": image_id, "Config": {"Labels": image_labels}} def fake_run(args, **_kwargs): if args[:3] == ["docker", "ps", "-a"]: @@ -308,6 +312,43 @@ def test_live_model_records_are_content_addressed_and_activate_only_from_state( assert module._self_upgrade_active_compose_model(state, project=project) is None +@pytest.mark.parametrize("drift", [None, "host", "port", "extra-port", "environment", "image", "unsigned", "stopped", "not-restarting"]) +def test_signed_crashloop_candidate_can_be_identified_only_for_rollback(controller, monkeypatch, drift): + module, _root, _secret = controller + if os.name == "nt" or os.geteuid() != 0: + pytest.skip("The signed model store requires root-owned POSIX files") + pointer = module._self_upgrade_store_compose_model(RECOVERY_ID, REVISION, _model()) + assert isinstance(pointer, dict) + loaded = module._self_upgrade_load_compose_model(RECOVERY_ID, REVISION) + container = _mock_legacy_docker(monkeypatch, module, candidate=True) + container["Config"]["Labels"]["com.docker.compose.project.config_files"] = loaded["path"] + container["State"].update({"Restarting": True, "Status": "restarting", "Health": {"Status": "unhealthy"}}) + container["NetworkSettings"]["Ports"] = {} + container["HostConfig"]["PortBindings"] = { + "1216/tcp": [{"HostIp": module.SELF_UPGRADE_PUBLISH_HOST, "HostPort": "1216"}], + } + if drift == "host": + container["HostConfig"]["PortBindings"]["1216/tcp"][0]["HostIp"] = "0.0.0.0" + elif drift == "port": + container["HostConfig"]["PortBindings"]["1216/tcp"][0]["HostPort"] = "9999" + elif drift == "extra-port": + container["HostConfig"]["PortBindings"]["22/tcp"] = [{"HostIp": "0.0.0.0", "HostPort": "22"}] + elif drift == "environment": + container["Config"]["Env"].append("UNREVIEWED_SETTING=drift") + elif drift == "image": + container["Image"] = "sha256:" + "0" * 64 + elif drift == "unsigned": + pointer = {**pointer, "modelRecordSha256": "0" * 64} + elif drift == "stopped": + container["State"]["Running"] = False + elif drift == "not-restarting": + container["State"]["Restarting"] = False + assert module._self_upgrade_runtime() is None + assert module._self_upgrade_model_runtime_identity(pointer, IMAGE_ID) is None + identity = module._self_upgrade_model_runtime_identity(pointer, IMAGE_ID, require_healthy=False) + assert (identity is not None) == (drift is None) + + def test_candidate_binding_rechecks_exact_model_receipt_and_runtime( controller, monkeypatch, ): @@ -554,8 +595,9 @@ def test_fixed_555_production_reference_is_valid_only_under_signed_legacy_model( assert restored_runtime["imageRef"] == module.SELF_UPGRADE_PRODUCTION_IMAGE_REF +@pytest.mark.parametrize("cache", ["registered-builds", "registered-builds-v2"]) def test_recovery_transaction_binds_authorized_555_preimage_and_holds_pending_state( - controller, monkeypatch, + controller, monkeypatch, cache, ): module, recovery_root, secret = controller if os.name == "nt" or os.geteuid() != 0: @@ -747,6 +789,24 @@ def test_recovery_transaction_binds_authorized_555_preimage_and_holds_pending_st assert module._self_upgrade_update_model_transaction( transaction_id, "0" * 64, "recovery_required", ) is None + candidate_pointer = module._self_upgrade_store_compose_model(RECOVERY_ID, REVISION, _model()) + assert isinstance(candidate_pointer, dict) + context = f"{module.EXACT_SOURCE_ROOT.as_posix()}/{cache}/unraid-appops-gateway/{REVISION}/tree" + for bad_context in ( + context + "/../outside", context.replace(REVISION, "f" * 40), + context.replace(cache, "registered-builds-v3"), context + "-outside", + ): + assert module._self_upgrade_update_model_transaction( + transaction_id, created["sha256"], "source_staged", + {"candidateModelPointer": candidate_pointer, "buildContexts": {"appops-gateway": bad_context}}, + ) is None + staged = module._self_upgrade_update_model_transaction( + transaction_id, created["sha256"], "source_staged", + {"candidateModelPointer": candidate_pointer, "buildContexts": {"appops-gateway": context}}, + ) + assert isinstance(staged, dict) + assert module._self_upgrade_load_model_transaction(transaction_id) == staged + created = staged held = module._self_upgrade_update_model_transaction( transaction_id, created["sha256"], "recovery_required", ) @@ -802,6 +862,21 @@ def test_recovery_transaction_binds_authorized_555_preimage_and_holds_pending_st ) rollback_before = module._self_upgrade_load_model_transaction(rollback_transaction_id) assert isinstance(rollback_before, dict) + intent = { + "schema": "appops-fixed-555-rollback-resume.v1", "stateSha256": "a" * 64, + "candidateRuntimeSha256": "b" * 64, "candidateStableRuntimeSha256": "d" * 64, + "snapshotSha256": hashlib.sha256(module._self_upgrade_canonical_json(rollback_payload["priorSnapshot"])).hexdigest(), + } + resumed = module._self_upgrade_update_model_transaction( + rollback_transaction_id, rollback_before["sha256"], "rolling_back", {"rollbackResumeIntent": intent}, + ) + assert isinstance(resumed, dict) + assert module._self_upgrade_load_model_transaction(rollback_transaction_id) == resumed + assert module._self_upgrade_update_model_transaction( + rollback_transaction_id, resumed["sha256"], "rolling_back", + {"rollbackResumeIntent": {**intent, "stateSha256": "c" * 64}}, + ) is None + rollback_before = resumed rolled_back = module._self_upgrade_update_model_transaction( rollback_transaction_id, rollback_before["sha256"], diff --git a/backend/tests/test_appops_signed_rollback_recovery.py b/backend/tests/test_appops_signed_rollback_recovery.py new file mode 100644 index 0000000..d34bcfa --- /dev/null +++ b/backend/tests/test_appops_signed_rollback_recovery.py @@ -0,0 +1,297 @@ +from __future__ import annotations + +import copy +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +REPOSITORY = "jens/unraid-appops-gateway" +REVISION = "c" * 40 + + +@pytest.fixture +def recovery(monkeypatch): + if sys.platform == "win32": + pytest.skip("The root controller uses Linux fcntl") + monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker")) + spec = importlib.util.spec_from_file_location("signed_rollback_test", ROOT / "ops/gitea-deploy-broker/deploy.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "require_host_root", lambda: None) + project = {"slug": "unraid-appops-gateway"} + metadata = {"id": REVISION[:12], "revision": REVISION, "builtAt": "2026-10-08T00:00:00.000Z"} + backup = {"schema": "fixture", "sha256": "b" * 64} + prior = {"revision": module.APPOPS_LEGACY_OLD_REVISION} + snapshot = { + "revision": module.APPOPS_LEGACY_OLD_REVISION, "legacy_provenance": True, + "receipt": {}, "appops_runtime_model": prior, "appops_data_backup": backup, + } + payload = { + "repository": REPOSITORY, "revision": REVISION, "kind": "recovery", "phase": "rolling_back", + "priorSnapshot": snapshot, "priorModelPointer": prior, + "candidateModelPointer": {"revision": REVISION}, "candidateImageId": "sha256:" + "e" * 64, + "registryFingerprint": "registry-proof", "metadata": metadata, + "transactionId": "transaction-fixture", "preMutationRuntimeSha256": "old-runtime", + } + pending = {"payload": payload, "sha256": "a" * 64} + message = "Expected containers are not healthy/running: fixture; rollback error: AppOps rollback withheld after external runtime drift" + state = { + "repository": REPOSITORY, "status": "failed", "message": message, + "live_revision": module.APPOPS_LEGACY_OLD_REVISION, "live_status": "unknown", "live_receipt": {}, + "last_attempt": {"revision": REVISION, "status": "failed", "message": message, + "metadata": {**metadata, "appopsDataBackup": backup}}, + } + identity = {"revision": REVISION, "runtimeCasSha256": "4" * 64, "containerId": "5" * 64, + "imageId": payload["candidateImageId"]} + reader = SimpleNamespace( + PROJECTS_PATH=Path("/fixed/projects.json"), + _self_upgrade_pending_model_transaction=lambda: pending, + _self_upgrade_verify_rollback_model=lambda *_args: True, + _self_upgrade_read_fixed_file=lambda *_args, **_kwargs: b"registry", + _self_upgrade_secret=lambda: b"fixture-authority", + _self_upgrade_mac=lambda *_args: "registry-proof", + _self_upgrade_model_runtime_identity=lambda *_args, **_kwargs: identity, + _self_upgrade_source_is_exact_legacy=lambda *_args: "verified-source", + ) + events = [] + monkeypatch.setattr(module, "_appops_status_reader", lambda: reader) + monkeypatch.setattr(module, "read_appops_state_preimage", lambda _project: (state, b"state", "state-sha")) + monkeypatch.setattr(module, "validate_commit", lambda *_args: REVISION) + monkeypatch.setattr(module, "deployment_metadata", lambda *_args, **_kwargs: copy.deepcopy(metadata)) + monkeypatch.setattr(module, "verify_appops_data_backup", lambda *_args: events.append("backup-verified")) + return module, project, pending, state, identity, reader, events + + +def test_preflight_binds_signed_transaction_state_and_runtime_without_mutating(recovery): + module, project, pending, _state, identity, _reader, events = recovery + result = module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION) + assert result[:2] == (pending, {**identity, "rollbackRuntimeKind": "candidate"}) + assert len(result[2]) == 64 + assert result[3] == "state-sha" + assert events == ["backup-verified"] + assert project == {"slug": "unraid-appops-gateway"} + + +@pytest.mark.parametrize("drift", ["repository", "phase", "revision", "snapshot", "registry", "state", "metadata", "identity", "signature", "root"]) +def test_recovery_rejects_unproven_authority_before_restore(recovery, monkeypatch, drift): + module, project, pending, state, identity, reader, _events = recovery + repository = REPOSITORY + if drift == "repository": + repository = "jens/another-project" + elif drift == "phase": + pending["payload"]["phase"] = "recovery_required" + elif drift == "revision": + pending["payload"]["revision"] = "a" * 40 + elif drift == "snapshot": + pending["payload"]["priorSnapshot"]["legacy_provenance"] = False + elif drift == "registry": + pending["payload"]["registryFingerprint"] = "changed" + elif drift == "state": + state["live_status"] = "healthy" + elif drift == "metadata": + state["last_attempt"]["metadata"]["revision"] = "a" * 40 + elif drift == "identity": + identity["revision"] = "a" * 40 + elif drift == "signature": + reader._self_upgrade_pending_model_transaction = lambda: None + elif drift == "root": + monkeypatch.setattr(module, "require_host_root", lambda: (_ for _ in ()).throw(module.DeployError("root required"))) + monkeypatch.setattr(module, "restore_compose_snapshot", lambda *_args: pytest.fail("must not restore")) + with pytest.raises(module.DeployError): + module.resume_appops_model_rollback(project, repository, ROOT, REVISION, "0" * 64) + + +def test_apply_rechecks_preflight_before_restore_and_commits_verified_model(recovery, monkeypatch): + module, project, pending, _state, _identity, _reader, events = recovery + fingerprint = module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION)[2] + monkeypatch.setattr(module, "_appops_require_effective_rollback_current", lambda _p: events.append("candidate-reverified")) + sealed_container = {"Id": "5" * 64, "Image": pending["payload"]["candidateImageId"], "Mounts": []} + monkeypatch.setattr(module, "inspect_container", lambda _name: sealed_container) + monkeypatch.setattr(module, "_appops_runtime_cas_digest", lambda _container: "4" * 64) + + def restore(p, *_args): + events.append("restored") + p["_appops_restored_model_binding"] = {"liveRecordSha256": "verified-live"} + p["_appops_legacy_restored"] = True + + def write(*_args, **kwargs): + assert kwargs["expected_state_sha256"] == "state-sha" + assert kwargs["live_legacy_provenance"] is True + assert project["_appops_runtime_model_binding"]["liveRecordSha256"] == "verified-live" + events.append("state-committed") + + monkeypatch.setattr(module, "restore_compose_snapshot", restore) + monkeypatch.setattr(module, "write_state", write) + def update(p, phase, bindings): + if phase == "rolling_back": + assert bindings["rollbackResumeIntent"]["stateSha256"] == "state-sha" + assert bindings["rollbackResumeIntent"]["candidateRuntimeSha256"] == "4" * 64 + events.append("intent-fsynced") + else: + events.append("transaction-completed") + monkeypatch.setattr(module, "_appops_update_model_transaction", update) + monkeypatch.setattr(module, "write_appops_progress", lambda *_args: events.append("progress-completed")) + with pytest.raises(module.DeployError, match="preflight changed"): + module.resume_appops_model_rollback(project, REPOSITORY, ROOT, REVISION, "0" * 64) + assert "restored" not in events + result = module.resume_appops_model_rollback(project, REPOSITORY, ROOT, REVISION, fingerprint) + assert result["status"] == "rolled-back" + assert project["_appops_model_transaction"] == pending + assert events[-6:] == ["candidate-reverified", "intent-fsynced", "restored", "state-committed", "transaction-completed", "progress-completed"] + + +@pytest.mark.parametrize("drift", ["state", "runtime", "registry", "transaction"]) +def test_apply_does_not_adopt_drift_after_the_approved_preflight(recovery, monkeypatch, drift): + module, project, pending, state, identity, reader, _events = recovery + fingerprint = module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION)[2] + original = module.inspect_appops_model_rollback_recovery + calls = 0 + + def inspect(*args): + nonlocal calls + result = original(*args) + calls += 1 + if calls == 1: + if drift == "state": + monkeypatch.setattr(module, "read_appops_state_preimage", lambda _p: (state, b"different", "different-sha")) + elif drift == "runtime": + identity["containerId"] = "6" * 64 + elif drift == "registry": + reader._self_upgrade_mac = lambda *_args: "changed-registry" + else: + pending["sha256"] = "7" * 64 + return result + + monkeypatch.setattr(module, "inspect_appops_model_rollback_recovery", inspect) + monkeypatch.setattr(module, "restore_compose_snapshot", lambda *_args: pytest.fail("unapproved restore")) + monkeypatch.setattr(module, "write_state", lambda *_args, **_kwargs: pytest.fail("unapproved state overwrite")) + with pytest.raises(module.DeployError): + module.resume_appops_model_rollback(project, REPOSITORY, ROOT, REVISION, fingerprint) + + +@pytest.mark.parametrize("kind", ["candidate", "restored", "absent"]) +def test_signed_intent_allows_only_its_exact_candidate_or_prior_model_resume(recovery, monkeypatch, kind): + module, project, pending, _state, identity, reader, events = recovery + payload = pending["payload"] + payload["rollbackResumeIntent"] = { + "schema": "appops-fixed-555-rollback-resume.v1", "stateSha256": "state-sha", + "candidateRuntimeSha256": identity["runtimeCasSha256"], + "snapshotSha256": module._canonical_json_sha256(payload["priorSnapshot"]), + } + def identify(pointer, *_args, **_kwargs): + if pointer == payload["candidateModelPointer"]: + return identity if kind == "candidate" else None + return {**identity, "revision": module.APPOPS_LEGACY_OLD_REVISION} if kind == "restored" else None + reader._self_upgrade_model_runtime_identity = identify + monkeypatch.setattr(module, "inspect_container", lambda _name: None) + monkeypatch.setattr(module, "compose_project_container_names", lambda _name: set()) + fingerprint = module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION)[2] + + def finish(p, *_args): + events.append("attest" if kind == "restored" else "restore") + p["_appops_restored_model_binding"] = {"revision": module.APPOPS_LEGACY_OLD_REVISION} + p["_appops_legacy_restored"] = True + monkeypatch.setattr(module, "_appops_attest_restored_legacy_model", finish) + monkeypatch.setattr(module, "restore_compose_snapshot", finish) + monkeypatch.setattr(module, "write_state", lambda *_args, **_kwargs: events.append("commit")) + monkeypatch.setattr(module, "_appops_update_model_transaction", lambda _p, phase, _b: ( + events.append("complete") if phase == "rolled_back" else pytest.fail("must not replace immutable intent") + )) + monkeypatch.setattr(module, "write_appops_progress", lambda *_args: None) + module.resume_appops_model_rollback(project, REPOSITORY, ROOT, REVISION, fingerprint) + assert events[-3:] == ["attest" if kind == "restored" else "restore", "commit", "complete"] + + +@pytest.mark.parametrize("drift", ["state", "snapshot", "candidate", "foreign-container", "unknown-runtime", "no-intent"]) +def test_resume_intent_never_grants_unrelated_runtime_or_state(recovery, monkeypatch, drift): + module, project, pending, _state, identity, reader, _events = recovery + payload = pending["payload"] + intent = {"stateSha256": "state-sha", "candidateRuntimeSha256": identity["runtimeCasSha256"], + "snapshotSha256": module._canonical_json_sha256(payload["priorSnapshot"])} + payload["rollbackResumeIntent"] = intent + if drift == "state": + intent["stateSha256"] = "changed" + elif drift == "snapshot": + intent["snapshotSha256"] = "changed" + elif drift == "candidate": + identity["runtimeCasSha256"] = "8" * 64 + else: + reader._self_upgrade_model_runtime_identity = lambda *_args, **_kwargs: None + monkeypatch.setattr(module, "inspect_container", lambda _name: {"Id": "foreign"} if drift == "unknown-runtime" else None) + monkeypatch.setattr(module, "compose_project_container_names", lambda _name: {"foreign"} if drift == "foreign-container" else set()) + if drift == "no-intent": + payload.pop("rollbackResumeIntent") + with pytest.raises(module.DeployError): + module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION) + + +def test_appops_source_restore_removes_only_obsolete_empty_tracked_parents(recovery, tmp_path, monkeypatch): + module = recovery[0] + target = tmp_path / "source" + metadata = target / ".gitea-deploy" + metadata.mkdir(parents=True) + old = ["scripts/vendor/yaml/new.py", "docs/reviews/new.md", "docs/retained/new.md"] + (metadata / "tracked-files.txt").write_text("\n".join(old) + "\n") + for name in old: + path = target / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("candidate bytes") + (target / "docs/retained/private.txt").write_text("keep untracked bytes") + (target / "foreign-empty").mkdir() + monkeypatch.setattr(module, "tracked_files", lambda *_args: ["scripts/old.py", "docs/old.md"]) + monkeypatch.setattr(module, "extract_revision", lambda *_args: None) + module.sync_source(ROOT, module.APPOPS_LEGACY_OLD_REVISION, target, "unraid-appops-gateway") + assert not (target / "scripts/vendor").exists() + assert not (target / "docs/reviews").exists() + assert (target / "docs/retained/private.txt").read_text() == "keep untracked bytes" + assert (target / "foreign-empty").is_dir() + + +@pytest.mark.parametrize("drift", [None, "id", "image", "environment", "mounts", "host-config", "network", "inventory", "unsigned", "running", "state", "ports"]) +def test_signed_resume_owns_exact_stopped_candidate_only(recovery, monkeypatch, drift): + module, project, pending, _state, _identity, reader, _events = recovery + current = {"Id": "5" * 64, "Image": pending["payload"]["candidateImageId"], + "Config": {"Env": ["SYNTHETIC=unchanged"]}, "HostConfig": {"NetworkMode": "fixed"}, + "Mounts": [{"Destination": "/data", "Source": "fixed"}], + "State": {"Running": False, "Restarting": False, "Status": "exited"}, + "NetworkSettings": {"Ports": {}, "Networks": {}}} + payload = pending["payload"] + payload["rollbackResumeIntent"] = { + "stateSha256": "state-sha", "candidateRuntimeSha256": "4" * 64, + "candidateStableRuntimeSha256": module._appops_rollback_stable_runtime_sha256(current), + "snapshotSha256": module._canonical_json_sha256(payload["priorSnapshot"]), + } + reader._self_upgrade_model_runtime_identity = lambda *_args, **_kwargs: None + monkeypatch.setattr(module, "inspect_container", lambda _name: current) + monkeypatch.setattr(module, "compose_project_container_names", lambda _name: {"Unraid-AppOps-Gateway", "foreign"} if drift == "inventory" else {"Unraid-AppOps-Gateway"}) + if drift == "id": + current["Id"] = "6" * 64 + elif drift == "image": + current["Image"] = "sha256:" + "6" * 64 + elif drift == "environment": + current["Config"]["Env"].append("SYNTHETIC=changed") + elif drift == "mounts": + current["Mounts"][0]["Source"] = "changed" + elif drift == "host-config": + current["HostConfig"]["NetworkMode"] = "host" + elif drift == "network": + current["NetworkSettings"]["Networks"]["foreign"] = {} + elif drift == "unsigned": + payload.pop("rollbackResumeIntent") + elif drift == "running": + current["State"]["Running"] = True + elif drift == "state": + current["State"]["Status"] = "dead" + elif drift == "ports": + current["NetworkSettings"]["Ports"] = {"1216/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1216"}]} + if drift is not None: + with pytest.raises(module.DeployError): + module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION) + else: + identity = module.inspect_appops_model_rollback_recovery(project, REPOSITORY, ROOT, REVISION)[1] + assert identity["rollbackRuntimeKind"] == "stopped-candidate" + assert identity["containerId"] == current["Id"] diff --git a/backend/tests/test_registered_build_context.py b/backend/tests/test_registered_build_context.py index 4a8f873..99f1628 100644 --- a/backend/tests/test_registered_build_context.py +++ b/backend/tests/test_registered_build_context.py @@ -94,6 +94,35 @@ def test_exact_archive_omits_live_untracked_files_and_preserves_live_tree(inputs assert (tree / "run.sh").stat().st_mode & 0o777 == 0o755 +@pytest.mark.skipif(os.name == "nt", reason="POSIX Docker COPY directory modes") +def test_committed_directories_remain_traversable_after_docker_copy(inputs): + module = load() + tree = Path(prepare(module, inputs)["quiz-server"]) + # Docker COPY preserves these modes in the image, whose application runs + # as another UID. The enclosing private cache protects the source on host. + assert tree.parent.stat().st_mode & 0o777 == 0o700 + for directory in (tree, tree / "server"): + assert directory.stat().st_mode & 0o777 == 0o755 + (tree / "server").chmod(0o700) + with pytest.raises(module.RegisteredBuildContextError): + prepare(module, inputs) + + +def test_new_cache_does_not_rewrite_existing_private_directory_context(inputs): + legacy = inputs[4] / "registered-builds" / "quiz-monitor" / inputs[1] + legacy.mkdir(parents=True, mode=0o700) + marker = legacy / "signed-consumer-fixture" + marker.write_bytes(b"keep previous build bytes") + marker.chmod(0o600) + before = marker.stat() + tree = Path(prepare(load(), inputs)["quiz-server"]) + assert "registered-builds-v2" in tree.parts + assert marker.read_bytes() == b"keep previous build bytes" + assert marker.stat() == before + if os.name != "nt": + assert legacy.stat().st_mode & 0o777 == 0o700 + + @pytest.mark.parametrize("change", ["content", "extra", "missing", "mode", "manifest"]) def test_cached_context_drift_fails_without_replacing_source(inputs, change): module = load() @@ -212,7 +241,7 @@ def test_git_export_attributes_cannot_change_exact_archive_bytes(inputs): git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main") with pytest.raises(module.RegisteredBuildContextError): module.prepare_build_contexts(inputs[0], revision, *inputs[2:5]) - assert not inputs[4].joinpath("registered-builds/quiz-monitor", revision).exists() + assert not inputs[4].joinpath("registered-builds-v2/quiz-monitor", revision).exists() @pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary") diff --git a/backend/tests/test_registered_legacy_runtime.py b/backend/tests/test_registered_legacy_runtime.py index 5017408..c5f0387 100644 --- a/backend/tests/test_registered_legacy_runtime.py +++ b/backend/tests/test_registered_legacy_runtime.py @@ -39,6 +39,19 @@ def _load_module(): return module +@pytest.mark.parametrize("cache", ["registered-builds", "registered-builds-v2"]) +def test_registered_adoption_accepts_only_versioned_owned_archive_roots(tmp_path, monkeypatch, cache): + module = _load_module() + monkeypatch.setattr(module, "REGISTERED_BUILD_ROOT", tmp_path / "registered-builds") + tree = tmp_path / cache / "quiz-monitor" / TARGET_SHA / "tree" + tree.mkdir(parents=True) + services = {"quiz-server": {"build": {"context": "."}}} + assert module._validated_build_contexts("quiz-monitor", TARGET_SHA, services, {"quiz-server": str(tree)}) == {"quiz-server": str(tree)} + for foreign in (tree.parent, tree.with_name("tree-outside"), tree / ".." / "outside"): + with pytest.raises(module.RegisteredLegacyAdoptionError): + module._validated_build_contexts("quiz-monitor", TARGET_SHA, services, {"quiz-server": str(foreign)}) + + @pytest.fixture def module(): return _load_module()