From 5d598db0df2ce9d9074e9415ed9a79450855caa7 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Wed, 7 Oct 2026 05:27:40 +0000 Subject: [PATCH] Publish curated Unraid-AppOps-Gateway source from 84f2a9958b2bdf1c1c13553925026d75f32703c3 --- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 55 +- backend/app/mcp_server.py | 30 +- backend/app/services/deployment_broker.py | 23 +- .../test_controller_hub_release_volume.py | 87 +- ...isting_registered_target_reconciliation.py | 5 + .../tests/test_registered_build_context.py | 14 +- ...egistered_environment_capture_transport.py | 397 ++++++++ ...t_registered_legacy_environment_capture.py | 466 ++++++++++ ...t_registered_legacy_followup_controller.py | 167 ++++ .../test_registered_legacy_followup_core.py | 398 +++++++++ .../test_registered_legacy_followup_review.py | 845 ++++++++++++++++++ .../tests/test_registered_legacy_runtime.py | 271 +++++- ...est_repopulse_controller_adoption_store.py | 51 +- 14 files changed, 2765 insertions(+), 46 deletions(-) create mode 100644 backend/tests/test_registered_environment_capture_transport.py create mode 100644 backend/tests/test_registered_legacy_environment_capture.py create mode 100644 backend/tests/test_registered_legacy_followup_controller.py create mode 100644 backend/tests/test_registered_legacy_followup_core.py create mode 100644 backend/tests/test_registered_legacy_followup_review.py diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 4d4a399..35ba438 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 `ba66029223865607deddc234e283c11127324eb8`. 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 `84f2a9958b2bdf1c1c13553925026d75f32703c3`. 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 1c9faf9..aff986d 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "ba66029223865607deddc234e283c11127324eb8", + "sourceRevision": "84f2a9958b2bdf1c1c13553925026d75f32703c3", "files": [ { "path": ".dockerignore", @@ -404,8 +404,8 @@ }, { "path": "backend/app/mcp_server.py", - "sha256": "9d9e6b881cc293799bde6dc244bfb04442ad3c30f3757bad99d824f12fb30d0a", - "bytes": 44777 + "sha256": "650edf520d95c61751422e1c775ef0e77a3c1d7340ca83c00aeb667ed64e106a", + "bytes": 46224 }, { "path": "backend/app/runner_statistics_mcp.py", @@ -539,8 +539,8 @@ }, { "path": "backend/app/services/deployment_broker.py", - "sha256": "693e0a46920a7a35d129f89b31d31a2fba026a704b899eb89e34023b0a0c52c1", - "bytes": 39197 + "sha256": "80ee3b6ed5b724633934e00b37ddfa9bac9db7d4e12e56909eff5a31364c164a", + "bytes": 40896 }, { "path": "backend/app/services/deployment_control_plane.py", @@ -939,8 +939,8 @@ }, { "path": "backend/tests/test_controller_hub_release_volume.py", - "sha256": "c0ea7e949d1e1c258eccfed815750f885009337dd56db7be5a9cb598e558fa24", - "bytes": 115271 + "sha256": "4fe32bd783e2535724d75ff892e9b842ecba0a3b3ec6b141de45ab31d96bd945", + "bytes": 118768 }, { "path": "backend/tests/test_controller_root_procedure_contract.py", @@ -1004,8 +1004,8 @@ }, { "path": "backend/tests/test_existing_registered_target_reconciliation.py", - "sha256": "0c493282fecf31c5d2606cce0dac869658dbb0e032cce00b7fa96e82c065f3cc", - "bytes": 14764 + "sha256": "cf3197de9cae178fdfa8ace704199c2749fa3ecc967c0e853a740dbc3dfb580f", + "bytes": 14922 }, { "path": "backend/tests/test_exposure_preferences.py", @@ -1364,19 +1364,44 @@ }, { "path": "backend/tests/test_registered_build_context.py", - "sha256": "d51be0e2fec9fafae9616e624dbd87090d15d03dc6d8b8c55e910942ef352133", - "bytes": 13474 + "sha256": "82be26e40510252a9f8fbeea57b170d5e8cada2f28a770fc3ad229fbb7fd6eea", + "bytes": 13801 }, { "path": "backend/tests/test_registered_build_metadata.py", "sha256": "696fe58dd7df47924d96b870f1467eb2f1ebdb4b640465902c01844784e6b96a", "bytes": 10303 }, + { + "path": "backend/tests/test_registered_environment_capture_transport.py", + "sha256": "f2846c9046ffc09332249695f19df519afe12fef06efed18af5c4ab3d653fbe6", + "bytes": 13607 + }, { "path": "backend/tests/test_registered_legacy_adoption.py", "sha256": "d67ca2425008cc84ac9c84122a43ef774aff311719f32040283bb1692fe12502", "bytes": 17614 }, + { + "path": "backend/tests/test_registered_legacy_environment_capture.py", + "sha256": "e14c09e7c28512b6378e1f6a67f6b2fa8eace20bb36dc6420ea247a26f10192d", + "bytes": 17467 + }, + { + "path": "backend/tests/test_registered_legacy_followup_controller.py", + "sha256": "40cc28c34d6fdb57620809b6f19a6853f0ae41479f4d3052a601fcd561726535", + "bytes": 7267 + }, + { + "path": "backend/tests/test_registered_legacy_followup_core.py", + "sha256": "b30d0514ad542f3af965b270bdc680b87f8d08dd3a63ed34635587fc0b8e3158", + "bytes": 17144 + }, + { + "path": "backend/tests/test_registered_legacy_followup_review.py", + "sha256": "9ebf9f77e33386ce66ee5c0d34bc09a4262a20983b209505648d634cd14c1b64", + "bytes": 35368 + }, { "path": "backend/tests/test_registered_legacy_http.py", "sha256": "db2ac891871416d02b40634193aeb4491c006b655bd2dd0f01315ac1d69c5ab6", @@ -1384,8 +1409,8 @@ }, { "path": "backend/tests/test_registered_legacy_runtime.py", - "sha256": "dd8eb3e3293b94f7fb2ce716cf2a55ba561f9c6e91f624f900be6fe141c84b1a", - "bytes": 38314 + "sha256": "094c5bcaa5108bfc973cf56af9d666c55374a54717f9ae449af05afb48b90999", + "bytes": 49098 }, { "path": "backend/tests/test_registered_legacy_transport.py", @@ -1434,8 +1459,8 @@ }, { "path": "backend/tests/test_repopulse_controller_adoption_store.py", - "sha256": "e63025aea8d587d072026e7e5643c5c5d764b875275a84e23004f4450b24d34b", - "bytes": 41807 + "sha256": "3fc8e936f3bf4bfb0550debf716c63a522b5401d46eee78db54b626659fd9289", + "bytes": 42782 }, { "path": "backend/tests/test_repopulse_legacy_adoption_protocol.py", diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 3063b37..4fdfb0a 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -14,6 +14,7 @@ from typing import Any from mcp.server import MCPServer from mcp.server.transport_security import TransportSecuritySettings from mcp.types import ToolAnnotations +from pydantic import StrictBool from sqlmodel import Session, select from .api.routes_actions import action_registry as build_action_registry @@ -679,13 +680,32 @@ def appops_hub_component_status(plan_id: str) -> dict[str, Any]: return _json(DeploymentBrokerClient().hub_component_operation("status", {"planId": plan_id})) +@appops_mcp.tool(name="appops.registered_legacy.observe", annotations=_READ_ONLY_TOOL) +@_tracked("appops.registered_legacy.observe") +def appops_registered_legacy_observe(repository: str) -> dict[str, Any]: + """Read registered runtime fingerprints, container/image IDs and mismatched key names only.""" + return _json(DeploymentBrokerClient().registered_legacy_operation("observe", {"repository": repository})) + + @appops_mcp.tool(name="appops.registered_legacy.baseline", annotations=_READ_ONLY_TOOL) @_tracked("appops.registered_legacy.baseline") -def appops_registered_legacy_baseline(repository: str, request_id: str) -> dict[str, Any]: - """Observe an existing registered runtime whose previous source revision is unknown.""" - return _json(DeploymentBrokerClient().registered_legacy_operation("baseline", { - "repository": repository, "requestId": request_id, - })) +def appops_registered_legacy_baseline( + repository: str, request_id: str, capture_runtime_environment: StrictBool = False, + expected_source_fingerprint: str | None = None, expected_runtime_fingerprint: str | None = None, + expected_state_sha256: str | None = None, expected_container_ids: dict[str, str] | None = None, +) -> dict[str, Any]: + """Seal a registered baseline; opt-in runtime environment capture requires all observed CAS bindings. Values stay private.""" + bindings = {"expectedSourceFingerprint": expected_source_fingerprint, + "expectedRuntimeFingerprint": expected_runtime_fingerprint, + "expectedStateSha256": expected_state_sha256, "expectedContainerIds": expected_container_ids} + if type(capture_runtime_environment) is not bool or ( + not capture_runtime_environment and any(value is not None for value in bindings.values()) + ) or (capture_runtime_environment and any(value is None for value in bindings.values())): + raise ValueError("registered capture contract is invalid") + payload: dict[str, Any] = {"repository": repository, "requestId": request_id} + if capture_runtime_environment: + payload.update(captureRuntimeEnvironment=True, **bindings) + return _json(DeploymentBrokerClient().registered_legacy_operation("baseline", payload)) @appops_mcp.tool(name="appops.registered_legacy.preflight", annotations=_READ_ONLY_TOOL) diff --git a/backend/app/services/deployment_broker.py b/backend/app/services/deployment_broker.py index 36ec092..87dfff3 100644 --- a/backend/app/services/deployment_broker.py +++ b/backend/app/services/deployment_broker.py @@ -296,8 +296,29 @@ class DeploymentBrokerClient: def registered_legacy_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]: """Signed fixed-stage metadata adoption/recovery for existing registered targets.""" - if stage not in {"baseline", "preflight", "adopt", "status", "rollback", "delivery"}: + if stage not in {"observe", "baseline", "preflight", "adopt", "status", "rollback", "delivery"}: raise ValueError("registered legacy stage is invalid") + if stage == "observe" and (not isinstance(payload, dict) or set(payload) != {"repository"}): + raise ValueError("registered observation contract is invalid") + if stage == "baseline": + if not isinstance(payload, dict): + raise ValueError("registered capture contract is invalid") + base = {"repository", "requestId"} + bindings = {"expectedSourceFingerprint", "expectedRuntimeFingerprint", "expectedStateSha256", "expectedContainerIds"} + capture = payload.get("captureRuntimeEnvironment", False) + if (type(capture) is not bool or (capture is False and + set(payload) not in (base, base | {"captureRuntimeEnvironment"}))): + raise ValueError("registered capture contract is invalid") + if capture is True: + if (set(payload) != base | bindings | {"captureRuntimeEnvironment"} + or any(not isinstance(payload.get(field), str) or not re.fullmatch(r"[0-9a-f]{64}", payload[field]) + for field in bindings - {"expectedContainerIds"}) + or not isinstance(payload.get("expectedContainerIds"), dict) + or not 1 <= len(payload["expectedContainerIds"]) <= 64 + or any(not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", name) + or not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value) + for name, value in payload["expectedContainerIds"].items())): + raise ValueError("registered capture contract is invalid") return self._request( "POST", f"/v1/appops/registered-legacy/{stage}", secret=settings.mcp_token or "", payload=payload, diff --git a/backend/tests/test_controller_hub_release_volume.py b/backend/tests/test_controller_hub_release_volume.py index 0763515..6cbee1a 100644 --- a/backend/tests/test_controller_hub_release_volume.py +++ b/backend/tests/test_controller_hub_release_volume.py @@ -24,6 +24,81 @@ spec.loader.exec_module(module) REVISION = "a" * 40 IMAGE_ID = "sha256:" + "b" * 64 ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n" + + +@pytest.fixture(autouse=True) +def compose_config_root(monkeypatch, tmp_path): + # Production uses the fixed /tmp tmpfs; tests never write outside their tree. + monkeypatch.setattr(module, "_COMPOSE_CONFIG_ROOT", tmp_path) + + +@pytest.mark.parametrize("operation", ["config", "stop", "up"]) +def test_compose_uses_private_ephemeral_cli_config_with_readonly_root( + monkeypatch, tmp_path, operation, +): + directories = [] + + def run(arguments, *, data=None, diagnostic_action=None, **kwargs): + # Compose up attempts to mkdir /root/.docker even for a dry run. The + # controller promoter must keep its root filesystem read-only. + assert arguments[:2] == ["docker", "--config"] + directory = Path(arguments[2]) + directories.append(directory) + assert directory.parent == tmp_path + assert directory.name.startswith("itworx-controller-compose-") + assert directory.is_dir() and not directory.is_symlink() + if os.name == "posix": + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + assert directory.stat().st_uid == os.getuid() + assert arguments[3] == "compose" + assert diagnostic_action == f"compose_{operation}" + assert data == b"captured-private-compose" + assert arguments[-1] == operation + assert arguments[arguments.index("--env-file") + 1] == "/dev/null" + assert arguments[arguments.index("-f") + 1] == "-" + (directory / "generated-client-state").write_bytes(b"private-client-state") + return b"ok" + + monkeypatch.setattr(module, "_run", run) + for _ in range(2): + assert module._compose([operation], b"captured-private-compose") == b"ok" + assert directories[0] != directories[1] + assert all(not directory.exists() for directory in directories) + + +def test_compose_private_cli_config_is_cleaned_after_command_failure(monkeypatch): + directories = [] + failure = module.VolumeTransitionError( + "controller_volume_command_failed", + command_failure=("compose_up", "exit_1", "mount_read_only"), + ) + + def run(arguments, **kwargs): + directory = Path(arguments[2]) + directories.append(directory) + (directory / "client-state").write_bytes(b"private-state") + raise failure + + monkeypatch.setattr(module, "_run", run) + with pytest.raises(module.VolumeTransitionError) as caught: + module._compose(["up"], b"private-compose") + assert caught.value is failure + assert directories and all(not directory.exists() for directory in directories) + + +@pytest.mark.parametrize("action", ["compose_config", "compose_stop", "compose_up", "image_inspect"]) +def test_compose_boundary_diagnostic_never_discloses_stderr(monkeypatch, action): + stderr = b"mkdir /root/.docker: read-only file system fixture-secret-token" + monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: types.SimpleNamespace( + returncode=1, stdout=b"", stderr=stderr, + )) + with pytest.raises(module.VolumeTransitionError) as caught: + module._run(["docker", "fixed-operation"], diagnostic_action=action) + diagnostic = module._safe_command_diagnostic(caught.value.command_failure) + assert diagnostic == f"{action}_exit_1_mount_read_only" + assert "fixture-secret-token" not in diagnostic + assert "/root/.docker" not in diagnostic + # Frozen historical layouts; these fixtures must not follow the acceptance registry. HISTORICAL_PRE_CANDIDATE_RELEASE_FILES = ( "controller_paths.py", "mcp_hub_release_store.py", "deploy.py", @@ -716,7 +791,8 @@ def test_compose_uses_protected_code_and_two_config_views(compose): assert rendered["services"]["runner"] == baseline["services"]["runner"] assert service["environment"] == baseline["services"]["deploy-control"]["environment"] compose_calls = [ - arguments for arguments, _ in calls if arguments[:2] == ["docker", "compose"] + arguments for arguments, _ in calls + if arguments[:2] == ["docker", "--config"] and arguments[3] == "compose" ] assert compose_calls assert all("--project-directory" in arguments for arguments in compose_calls) @@ -1696,14 +1772,17 @@ def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose): assert baseline["name"] == "gitea-deploy-runners" -def test_rollback_uses_no_dependencies_when_recreating_prior_controller(compose, tmp_path, monkeypatch): +@pytest.mark.parametrize("phase", ["stopped", "activated"]) +def test_rollback_uses_no_dependencies_when_recreating_prior_controller( + compose, tmp_path, monkeypatch, phase, +): _baseline, calls = compose previous = REVISION target = "c" * 40 previous_compose = module.rendered_model(ORIGINAL_COMPOSE, previous, IMAGE_ID) target_compose = module.rendered_model(ORIGINAL_COMPOSE, target, IMAGE_ID) receipt = { - "phase": "activated", + "phase": phase, "imageId": IMAGE_ID, "previousRevision": previous, "sourceRevision": target, @@ -1739,7 +1818,7 @@ def test_rollback_uses_no_dependencies_when_recreating_prior_controller(compose, up_calls = [ arguments for arguments, _data in calls - if arguments[:2] == ["docker", "compose"] and "up" in arguments + if arguments[:2] == ["docker", "--config"] and arguments[3] == "compose" and "up" in arguments ] assert len(up_calls) == 1 up_index = up_calls[0].index("up") diff --git a/backend/tests/test_existing_registered_target_reconciliation.py b/backend/tests/test_existing_registered_target_reconciliation.py index 3161dd6..855266f 100644 --- a/backend/tests/test_existing_registered_target_reconciliation.py +++ b/backend/tests/test_existing_registered_target_reconciliation.py @@ -262,9 +262,14 @@ def original_git_reader(tmp_path, monkeypatch, request): def test_original_descriptor_cannot_be_substituted_by_git_replacement(original_git_reader): bare, original, namespace = original_git_reader # A real replacement keeps the branch SHA but swaps the source descriptor. + replacement_env = { + key: value for key, value in os.environ.items() + if key != "GIT_NO_REPLACE_OBJECTS" + } replaced = subprocess.check_output( ["git", f"--git-dir={bare}", "show", f"{original}:.itworx/deployment-target.json"], text=True, + env=replacement_env, ) assert json.loads(replaced)["verifyOciRevision"] is True result = namespace["git_run"]("show", f"{original}:.itworx/deployment-target.json") diff --git a/backend/tests/test_registered_build_context.py b/backend/tests/test_registered_build_context.py index 819ca2e..4a8f873 100644 --- a/backend/tests/test_registered_build_context.py +++ b/backend/tests/test_registered_build_context.py @@ -338,9 +338,17 @@ def test_git_replacement_refs_cannot_substitute_other_commit_contents(inputs): other = git(checkout, "rev-parse", "HEAD") git(bare, "fetch", "--quiet", str(checkout), "HEAD") git(bare, "replace", protected, other) - assert ( - git(bare, "show", f"{protected}:server/main.py") == "replacement commit bytes" - ) + # The positive attack demonstration must not inherit the production guard. + replacement_env = { + key: value for key, value in os.environ.items() + if key != "GIT_NO_REPLACE_OBJECTS" + } + replaced = subprocess.check_output( + ["git", "-C", str(bare), "show", f"{protected}:server/main.py"], + text=True, + env=replacement_env, + ).strip() + assert replaced == "replacement commit bytes" tree = Path(prepare(load(), inputs)["quiz-server"]) assert tree.joinpath("server/main.py").read_text() == "print('protected')\n" diff --git a/backend/tests/test_registered_environment_capture_transport.py b/backend/tests/test_registered_environment_capture_transport.py new file mode 100644 index 0000000..75088f8 --- /dev/null +++ b/backend/tests/test_registered_environment_capture_transport.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from types import SimpleNamespace + +import pytest +from mcp import Client +from test_hub_component_transport import call +from test_hub_component_transport import controller as _controller_fixture + +from backend.app import mcp_server +from backend.app.services.deployment_broker import DeploymentBrokerClient + +REPOSITORY = "jens/registered-app" +REQUEST_ID = "11111111-1111-4111-8111-111111111111" +PREFIX = "/v1/appops/registered-legacy/" +CAS = { + "expectedSourceFingerprint": "a" * 64, + "expectedRuntimeFingerprint": "b" * 64, + "expectedStateSha256": "c" * 64, + "expectedContainerIds": {"registered-app": "d" * 64}, +} + + +@pytest.fixture +def controller(monkeypatch): + return _controller_fixture.__wrapped__(monkeypatch) + + +@pytest.fixture +def broker_calls(monkeypatch): + calls = [] + monkeypatch.setattr( + DeploymentBrokerClient, + "_request", + lambda _self, method, path, **kwargs: calls.append((method, path, kwargs)) + or {"status": "observed"}, + ) + return calls + + +def test_observe_client_accepts_only_repository_and_exact_route(broker_calls): + result = DeploymentBrokerClient().registered_legacy_operation( + "observe", {"repository": REPOSITORY} + ) + assert result == {"status": "observed"} + assert broker_calls[0][0:2] == ("POST", PREFIX + "observe") + assert broker_calls[0][2]["payload"] == {"repository": REPOSITORY} + with pytest.raises(ValueError): + DeploymentBrokerClient().registered_legacy_operation( + "observe", {"repository": REPOSITORY, "environment": {"secret": "value"}} + ) + assert len(broker_calls) == 1 + + +def test_baseline_default_mcp_preserves_original_payload(broker_calls): + mcp_server.appops_registered_legacy_baseline(REPOSITORY, REQUEST_ID) + assert broker_calls[0][2]["payload"] == { + "repository": REPOSITORY, + "requestId": REQUEST_ID, + } + + +def test_baseline_capture_mcp_forwards_exact_optin_bindings(broker_calls): + mcp_server.appops_registered_legacy_baseline( + REPOSITORY, + REQUEST_ID, + capture_runtime_environment=True, + expected_source_fingerprint=CAS["expectedSourceFingerprint"], + expected_runtime_fingerprint=CAS["expectedRuntimeFingerprint"], + expected_state_sha256=CAS["expectedStateSha256"], + expected_container_ids=CAS["expectedContainerIds"], + ) + assert broker_calls[0][2]["payload"] == { + "repository": REPOSITORY, + "requestId": REQUEST_ID, + "captureRuntimeEnvironment": True, + **CAS, + } + + +@pytest.mark.parametrize( + "capture,bindings", [(False, CAS), (True, {}), (1, CAS), ("true", CAS)] +) +def test_capture_contract_rejects_before_broker(capture, bindings, broker_calls): + with pytest.raises(ValueError): + mcp_server.appops_registered_legacy_baseline( + REPOSITORY, + REQUEST_ID, + capture_runtime_environment=capture, + expected_source_fingerprint=bindings.get("expectedSourceFingerprint"), + expected_runtime_fingerprint=bindings.get("expectedRuntimeFingerprint"), + expected_state_sha256=bindings.get("expectedStateSha256"), + expected_container_ids=bindings.get("expectedContainerIds"), + ) + assert not broker_calls + + +def test_observe_http_has_no_request_id_or_mutating_helpers(controller, monkeypatch): + module, _projects = controller + calls = [] + + def observe(payload, **kwargs): + calls.append((payload, kwargs)) + return { + "status": "observed", + "repository": REPOSITORY, + "sourceFingerprint": CAS["expectedSourceFingerprint"], + "runtimeFingerprint": CAS["expectedRuntimeFingerprint"], + "expectedStateSha256": CAS["expectedStateSha256"], + "containerIDs": CAS["expectedContainerIds"], + "imageIDs": {"registered-app": "sha256:" + "e" * 64}, + "environmentMismatchKeys": ["registered-app/SERVER_URL"], + } + + fake = SimpleNamespace( + observe=observe, + read_registered_state=lambda *_args: ({}, None), + RegisteredLegacyAdoptionError=ValueError, + ) + monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake) + monkeypatch.setattr( + module, + "_registered_legacy_api_reader", + lambda *_args: pytest.fail("observe must not read Git API"), + ) + code, result = call( + module, + PREFIX + "observe", + {"repository": REPOSITORY}, + "_handle_registered_legacy", + ) + assert code == 200 and result["status"] == "observed" + assert calls[0][0] == {"repository": REPOSITORY} + assert "environment" not in result + assert result["environmentMismatchKeys"] == ["registered-app/SERVER_URL"] + assert ( + call( + module, + PREFIX + "observe", + {"repository": REPOSITORY, "requestId": REQUEST_ID}, + "_handle_registered_legacy", + )[0] + == 400 + ) + + +def test_observe_mcp_schema_is_readonly_and_calls_no_other_operation(broker_calls): + async def exercise(): + async with Client(mcp_server.appops_mcp) as client: + listing = await client.list_tools() + tool = next( + item + for item in listing.tools + if item.name == "appops.registered_legacy.observe" + ) + assert tool.annotations.read_only_hint is True + assert tool.annotations.destructive_hint is False + assert set(tool.input_schema["properties"]) == {"repository"} + result = await client.call_tool(tool.name, {"repository": REPOSITORY}) + assert not result.is_error + + asyncio.run(exercise()) + assert len(broker_calls) == 1 + assert broker_calls[0][2]["payload"] == {"repository": REPOSITORY} + + +@pytest.mark.parametrize( + "changes", + [ + {"captureRuntimeEnvironment": "true", **CAS}, + {"captureRuntimeEnvironment": 1, **CAS}, + {"captureRuntimeEnvironment": False, **CAS}, + {"captureRuntimeEnvironment": True}, + {"captureRuntimeEnvironment": True, **CAS, "expectedSourceFingerprint": "bad"}, + {"captureRuntimeEnvironment": True, **CAS, "expectedContainerIds": {}}, + {"environment": {"PRIVATE_KEY": "caller-value-must-not-escape"}}, + ], +) +def test_broker_baseline_rejects_incomplete_or_untyped_capture_before_transport( + changes, broker_calls +): + with pytest.raises(ValueError) as error: + DeploymentBrokerClient().registered_legacy_operation( + "baseline", {"repository": REPOSITORY, "requestId": REQUEST_ID, **changes} + ) + assert "caller-value-must-not-escape" not in str(error.value) + assert not broker_calls + + +@pytest.mark.parametrize( + "changes", + [ + {"captureRuntimeEnvironment": "true", **CAS}, + {"captureRuntimeEnvironment": 1, **CAS}, + {"captureRuntimeEnvironment": False, **CAS}, + {"captureRuntimeEnvironment": True}, + { + "captureRuntimeEnvironment": True, + **CAS, + "environment": {"PRIVATE_KEY": "caller-value-must-not-escape"}, + }, + ], +) +def test_http_capture_shape_rejects_before_registry_or_core( + changes, controller, monkeypatch +): + module, _ = controller + monkeypatch.setattr( + module, + "_load_projects", + lambda: pytest.fail("invalid capture must not reach core"), + ) + code, result = call( + module, + PREFIX + "baseline", + {"repository": REPOSITORY, "requestId": REQUEST_ID, **changes}, + "_handle_registered_legacy", + ) + assert code == 400 and result == {"status": "invalid_contract"} + assert "caller-value-must-not-escape" not in json.dumps(result) + + +def test_http_observation_extra_environment_is_rejected_without_value_echo( + controller, monkeypatch +): + module, _ = controller + fake = SimpleNamespace( + observe=lambda *_args, **_kwargs: { + "status": "observed", + "environment": {"PRIVATE_KEY": "private-observer-value"}, + }, + read_registered_state=lambda *_args: ({}, None), + RegisteredLegacyAdoptionError=type("AdoptionError", (RuntimeError,), {}), + ) + monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake) + code, result = call( + module, + PREFIX + "observe", + {"repository": REPOSITORY}, + "_handle_registered_legacy", + ) + assert code == 503 and result == {"status": "registered_controller_unavailable"} + assert "private-observer-value" not in json.dumps(result) + + +def test_observe_requires_existing_signed_controller_authentication( + controller, monkeypatch +): + module, _ = controller + monkeypatch.setattr( + module, "_self_upgrade_signature_is_valid", lambda *_args, **_kwargs: False + ) + monkeypatch.setattr( + module, + "_load_projects", + lambda: pytest.fail("unauthenticated observe must not read registry"), + ) + assert ( + call( + module, + PREFIX + "observe", + {"repository": REPOSITORY}, + "_handle_registered_legacy", + )[0] + == 403 + ) + + +def test_mcp_wire_capture_boolean_does_not_coerce_strings_or_numbers(broker_calls): + async def exercise(): + async with Client(mcp_server.appops_mcp) as client: + listing = await client.list_tools() + tool = next( + item + for item in listing.tools + if item.name == "appops.registered_legacy.baseline" + ) + assert tool.annotations.read_only_hint is True + assert ( + tool.input_schema["properties"]["capture_runtime_environment"][ + "default" + ] + is False + ) + for value in ("true", 1): + result = await client.call_tool( + tool.name, + { + "repository": REPOSITORY, + "request_id": REQUEST_ID, + "capture_runtime_environment": value, + }, + ) + assert result.is_error + + asyncio.run(exercise()) + assert not broker_calls + + +def test_http_observe_real_registered_runtime_returns_only_safe_keynames( + controller, monkeypatch, tmp_path +): + from contextlib import nullcontext + from functools import partial + from test_registered_legacy_runtime import ( + _load_module, + production_runtime as runtime_fixture, + ) + + core = _load_module() + runtime, boundary, project, model = runtime_fixture.__wrapped__( + core, monkeypatch, tmp_path + ) + repository = "jens/quiz-monitor" + model["services"]["quiz-server"]["environment"]["KNOWLEDGE_OPTIMIZER_MODEL"] = ( + "intended-test-model" + ) + boundary.live["Config"]["Env"].append( + "KNOWLEDGE_OPTIMIZER_MODEL=observed-test-model" + ) + monkeypatch.setattr(core, "_global_deploy_lock", lambda: nullcontext()) + # Reuse the adapter fixture's validated registration against its temporary source tree. + monkeypatch.setattr( + core, + "validate_registered_legacy_target", + lambda repo, target: target["slug"] + if repo == repository + else pytest.fail("wrong registered target"), + ) + monkeypatch.setattr( + core, "read_registered_state", lambda *_args, **_kwargs: ({}, None) + ) + monkeypatch.setattr( + core, + "observe", + partial(core.observe, runtime=runtime, state_root=tmp_path / "state"), + ) + monkeypatch.setitem(sys.modules, "registered_legacy_adoption", core) + module, _projects = controller + monkeypatch.setattr(module, "_load_projects", lambda: {repository: project}) + code, result = call( + module, + PREFIX + "observe", + {"repository": repository}, + "_handle_registered_legacy", + ) + assert code == 200 and result["status"] == "observed" + assert result["environmentMismatchKeys"] == [ + "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL" + ] + assert result["expectedStateSha256"] == core._state_digest(None) + assert set(result) == { + "status", + "repository", + "expectedStateSha256", + "sourceFingerprint", + "runtimeFingerprint", + "containerIDs", + "imageIDs", + "environmentMismatchKeys", + } + serialized = json.dumps(result) + assert "intended-test-model" not in serialized + assert "observed-test-model" not in serialized + assert "198.19.1.2" not in serialized + + +def test_http_complete_capture_forwards_no_environment_values(controller, monkeypatch): + module, _projects = controller + called = [] + fake = SimpleNamespace( + baseline=lambda payload, **_kwargs: called.append(payload) + or { + "status": "baseline_captured", + "capturedEnvironmentKeys": ["service/SETTING"], + }, + read_registered_state=lambda *_args: ({}, None), + RegisteredLegacyAdoptionError=ValueError, + ) + monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake) + payload = { + "repository": REPOSITORY, + "requestId": REQUEST_ID, + "captureRuntimeEnvironment": True, + **CAS, + } + code, result = call( + module, PREFIX + "baseline", payload, "_handle_registered_legacy" + ) + assert code == 200 and called == [payload] + assert result == { + "status": "baseline_captured", + "capturedEnvironmentKeys": ["service/SETTING"], + } diff --git a/backend/tests/test_registered_legacy_environment_capture.py b/backend/tests/test_registered_legacy_environment_capture.py new file mode 100644 index 0000000..57af177 --- /dev/null +++ b/backend/tests/test_registered_legacy_environment_capture.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "registered_legacy_adoption.py" +REPOSITORY = "jens/quiz-monitor" +REQUEST_ID = "6f9619ff-8b86-4011-b42d-00c04fc964ff" +KEY = b"k" * 32 +IMAGE_ID = "sha256:" + "b" * 64 +CONTAINER_ID = "c" * 64 +MISMATCH_KEY = "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL" +STATE = b'{"schema_version":3,"live_status":"unknown"}\n' +SOURCE_FP = "1" * 64 +RUNTIME_FP = "2" * 64 +SECRET_VALUE = "gpt-6-luna" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("registered_legacy_capture_test", MODULE_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def module(): + return _load_module() + + +@pytest.fixture +def project(tmp_path): + return { + "slug": "quiz-monitor", + "branch": "main", + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": ["quiz-monitor-quiz-server-1"], + "source_dir": "/srv/example/appdata/quiz-monitor/source", + "verify_oci_revision": True, + } + + +def _observation(): + container = { + "Id": CONTAINER_ID, + "Image": IMAGE_ID, + "Config": { + "Labels": { + "com.docker.compose.project": "quiz-monitor", + "com.docker.compose.service": "quiz-server", + }, + "Env": ["KNOWLEDGE_OPTIMIZER_MODEL=" + SECRET_VALUE], + }, + "State": {"Running": True}, + } + return { + "status": "observed", + "sourceFingerprint": SOURCE_FP, + "runtimeFingerprint": RUNTIME_FP, + "imageIDs": {"quiz-monitor-quiz-server-1": IMAGE_ID}, + "environmentMismatchKeys": [MISMATCH_KEY], + "capturedEnvironmentKeys": [MISMATCH_KEY], + "artifacts": { + "containers": { + "quiz-monitor-quiz-server-1": { + "container_id": CONTAINER_ID, + "image_id": IMAGE_ID, + "image_ref": "quiz-monitor:latest", + "service": "quiz-server", + "running": True, + }, + }, + }, + "runtimeConfiguration": { + "composeModel": { + "services": { + "quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": "gpt-5.5"}, + }, + }, + }, + "containers": {"quiz-monitor-quiz-server-1": container}, + "imageEnvironments": {IMAGE_ID: {}}, + }, + } + + +class _CaptureRuntime: + def __init__(self, observation=None): + self.observation = observation or _observation() + self.strict_calls = 0 + self.capture_calls = 0 + + def observe(self, repository, _project, *, receipt_key): + self.strict_calls += 1 + assert repository == REPOSITORY + assert receipt_key == KEY + raise RuntimeError("strict observer rejects environment mismatch") + + def observe_for_baseline_capture(self, repository, _project, *, receipt_key): + self.capture_calls += 1 + assert repository == REPOSITORY + assert receipt_key == KEY + return copy.deepcopy(self.observation) + + +def _payload(): + observation = _observation() + return { + "repository": REPOSITORY, + "requestId": REQUEST_ID, + "captureRuntimeEnvironment": True, + "expectedSourceFingerprint": observation["sourceFingerprint"], + "expectedRuntimeFingerprint": observation["runtimeFingerprint"], + "expectedStateSha256": hashlib.sha256(STATE).hexdigest(), + "expectedContainerIds": { + "quiz-monitor-quiz-server-1": CONTAINER_ID, + }, + } + + +def _read_state(_repository, _project): + return {"live_status": "unknown"}, STATE + + +def test_observe_returns_only_cas_metadata_and_environment_key_names(module, project): + runtime = _CaptureRuntime() + result = module.observe( + {"repository": REPOSITORY}, + registry={REPOSITORY: project}, + read_state=_read_state, + runtime=runtime, + receipt_key=KEY, + ) + + assert result == { + "status": "observed", + "repository": REPOSITORY, + "expectedStateSha256": hashlib.sha256(STATE).hexdigest(), + "sourceFingerprint": SOURCE_FP, + "runtimeFingerprint": RUNTIME_FP, + "containerIDs": {"quiz-monitor-quiz-server-1": CONTAINER_ID}, + "imageIDs": {"quiz-monitor-quiz-server-1": IMAGE_ID}, + "environmentMismatchKeys": [MISMATCH_KEY], + } + encoded = json.dumps(result) + assert SECRET_VALUE not in encoded + assert "gpt-5.5" not in encoded + assert runtime.strict_calls == 0 + assert runtime.capture_calls == 1 + + +def test_explicit_capture_is_hmac_bound_private_and_idempotent(module, project, tmp_path): + runtime = _CaptureRuntime() + payload = _payload() + first = module.baseline( + payload, + registry={REPOSITORY: project}, + read_state=_read_state, + runtime=runtime, + receipt_key=KEY, + state_root=tmp_path, + ) + second = module.baseline( + payload, + registry={REPOSITORY: project}, + read_state=_read_state, + runtime=runtime, + receipt_key=KEY, + state_root=tmp_path, + ) + assert first == second + assert first["capturedEnvironmentKeys"] == [MISMATCH_KEY] + assert SECRET_VALUE not in json.dumps(first) + + paths = module._paths(tmp_path, "quiz-monitor", REQUEST_ID) + signed, _ = module._load_signed(paths["baseline"], KEY, module.BASELINE_SCHEMA) + private, _ = module._load_private(paths["private"], KEY) + assert signed["environmentCapture"]["containerIDs"] == payload["expectedContainerIds"] + assert signed["environmentCapture"]["capturedEnvironmentKeys"] == [MISMATCH_KEY] + assert private["environmentCapture"] == signed["environmentCapture"] + assert SECRET_VALUE in json.dumps(private) + assert module._capture_runtime_environment( + private, + captured_environment_keys=signed["environmentCapture"]["capturedEnvironmentKeys"], + ) == {"quiz-server": {"KNOWLEDGE_OPTIMIZER_MODEL": SECRET_VALUE}} + assert runtime.strict_calls == 0 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX private-file mode enforcement") +def test_candidate_compose_proof_rejects_group_readable_environment_values(module, tmp_path): + paths = module._paths(tmp_path, "quiz-monitor", REQUEST_ID) + module._write_signed( + paths["candidate"], + {"schema": module.CANDIDATE_SCHEMA, "composeModel": {"secret": SECRET_VALUE}}, + KEY, + ) + paths["candidate"].chmod(0o640) + + with pytest.raises(module.RegisteredLegacyAdoptionError): + module._load_signed(paths["candidate"], KEY, module.CANDIDATE_SCHEMA) + + +@pytest.mark.parametrize( + "captured_keys", + [[], ["quiz-server/UNDECLARED_RUNTIME_VALUE"]], +) +def test_candidate_environment_preservation_requires_exact_signed_capture_keys( + module, captured_keys +): + private = _observation()["runtimeConfiguration"] + with pytest.raises(module.RegisteredLegacyAdoptionError): + module._capture_runtime_environment( + {"runtimeConfiguration": private}, + captured_environment_keys=captured_keys, + ) + + +@pytest.mark.parametrize( + "mutate", + [ + lambda payload: payload.pop("expectedContainerIds"), + lambda payload: payload.update(expectedSourceFingerprint="f" * 64), + lambda payload: payload.update(expectedRuntimeFingerprint="f" * 64), + lambda payload: payload.update(expectedStateSha256="f" * 64), + lambda payload: payload.update(expectedContainerIds={"quiz-monitor-quiz-server-1": "f" * 64}), + lambda payload: payload.update(unexpected="value"), + ], +) +def test_capture_rejects_incomplete_or_changed_cas_without_writing(module, project, tmp_path, mutate): + payload = _payload() + mutate(payload) + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.baseline( + payload, + registry={REPOSITORY: project}, + read_state=_read_state, + runtime=_CaptureRuntime(), + receipt_key=KEY, + state_root=tmp_path, + ) + assert not list(tmp_path.rglob("*.baseline.json")) + assert not list(tmp_path.rglob("*.private.json")) + + +@pytest.mark.parametrize( + "payload", + [ + {"repository": REPOSITORY, "requestId": REQUEST_ID, "expectedRuntimeFingerprint": "2" * 64}, + {"repository": REPOSITORY, "requestId": REQUEST_ID, + "captureRuntimeEnvironment": False, "expectedStateSha256": hashlib.sha256(STATE).hexdigest()}, + ], +) +def test_default_baseline_does_not_accept_capture_compare_fields(module, project, tmp_path, payload): + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.baseline( + payload, + registry={REPOSITORY: project}, + read_state=_read_state, + runtime=_CaptureRuntime(), + receipt_key=KEY, + state_root=tmp_path, + ) + assert not list(tmp_path.rglob("*.baseline.json")) + + +def test_capture_reobserves_exact_runtime_and_container_preimage(module, project, tmp_path): + runtime = _CaptureRuntime() + module.baseline( + _payload(), registry={REPOSITORY: project}, read_state=_read_state, + runtime=runtime, receipt_key=KEY, state_root=tmp_path, + ) + paths = module._paths(tmp_path, "quiz-monitor", REQUEST_ID) + with pytest.raises(module.RegisteredLegacyAdoptionError, match="source or runtime changed"): + changed = _observation() + changed["runtimeFingerprint"] = "f" * 64 + module._load_current_baseline( + REPOSITORY, "quiz-monitor", REQUEST_ID, paths, KEY, _read_state, + project, tmp_path, _CaptureRuntime(changed), + ) + + changed_container = _observation() + changed_container["artifacts"]["containers"]["quiz-monitor-quiz-server-1"]["container_id"] = "d" * 64 + with pytest.raises(module.RegisteredLegacyAdoptionError, match="container identity changed"): + module._load_current_baseline( + REPOSITORY, "quiz-monitor", REQUEST_ID, paths, KEY, _read_state, + project, tmp_path, _CaptureRuntime(changed_container), + ) + + +def test_private_capture_record_requires_owner_only_read_permissions(module, tmp_path): + if module.os.name == "nt": + pytest.skip("POSIX file-mode enforcement is not available on Windows") + path = tmp_path / "private.json" + module._write_signed( + path, + {"schema": "itworx.registered-legacy-private/v1", "repository": REPOSITORY}, + KEY, + ) + path.chmod(0o640) + + with pytest.raises(module.RegisteredLegacyAdoptionError, match="permissions"): + module._load_private(path, KEY) + + path.chmod(0o600) + private, _ = module._load_private(path, KEY) + assert private["repository"] == REPOSITORY + + +def test_signed_capture_value_is_required_in_the_rendered_candidate_model(module, project): + captured_key = "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL" + current_model = {"services": {"quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": SECRET_VALUE}, + "image": "quiz-server:current", + }}} + candidate_model = {"services": {"quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": SECRET_VALUE}, + "image": "quiz-server:candidate", + }}} + binding = { + "schema": "itworx.registered-legacy-env-capture/v1", + "sourceFingerprint": SOURCE_FP, + "runtimeFingerprint": RUNTIME_FP, + "expectedStateSha256": hashlib.sha256(STATE).hexdigest(), + "containerIDs": {"quiz-monitor-quiz-server-1": CONTAINER_ID}, + "capturedEnvironmentKeys": [captured_key], + } + runtime_container = { + "Image": IMAGE_ID, + "Config": { + "Labels": {"com.docker.compose.service": "quiz-server"}, + "Env": [f"KNOWLEDGE_OPTIMIZER_MODEL={SECRET_VALUE}"], + }, + } + private = { + "environmentCapture": binding, + "runtimeConfiguration": { + "composeModel": current_model, + "containers": {"quiz-monitor-quiz-server-1": runtime_container}, + "imageEnvironments": {IMAGE_ID: {}}, + }, + } + baseline = {"environmentCapture": binding, "sourceFingerprint": SOURCE_FP, + "runtimeFingerprint": RUNTIME_FP, + "expectedStateSha256": hashlib.sha256(STATE).hexdigest()} + + normalized = module._candidate_runtime_comparison_model( + candidate_model, current_model, baseline, private, project, + ) + + assert normalized["services"]["quiz-server"]["environment"] == { + "KNOWLEDGE_OPTIMIZER_MODEL": SECRET_VALUE, + } + + +def test_raw_source_comparison_allows_only_the_signed_default_or_capture_value(module, project): + captured_key = "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL" + baseline_model = {"services": {"quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": SECRET_VALUE}, + "image": "quiz-server:current", + }}} + binding = { + "schema": "itworx.registered-legacy-env-capture/v1", + "sourceFingerprint": SOURCE_FP, + "runtimeFingerprint": RUNTIME_FP, + "expectedStateSha256": hashlib.sha256(STATE).hexdigest(), + "containerIDs": {"quiz-monitor-quiz-server-1": CONTAINER_ID}, + "capturedEnvironmentKeys": [captured_key], + } + runtime_container = { + "Image": IMAGE_ID, + "Config": { + "Labels": {"com.docker.compose.service": "quiz-server"}, + "Env": [f"KNOWLEDGE_OPTIMIZER_MODEL={SECRET_VALUE}"], + }, + } + private = { + "environmentCapture": binding, + "capturedEnvironmentSource": {"quiz-server": {"KNOWLEDGE_OPTIMIZER_MODEL": "gpt-5.5"}}, + "runtimeConfiguration": { + "composeModel": baseline_model, + "containers": {"quiz-monitor-quiz-server-1": runtime_container}, + "imageEnvironments": {IMAGE_ID: {}}, + }, + } + baseline = {"environmentCapture": binding, "sourceFingerprint": SOURCE_FP, + "runtimeFingerprint": RUNTIME_FP, + "expectedStateSha256": hashlib.sha256(STATE).hexdigest()} + + for source_value in ("gpt-5.5", SECRET_VALUE): + candidate_model = {"services": {"quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": source_value}, + "image": "quiz-server:candidate", + }}} + normalized = module._candidate_source_comparison_model( + candidate_model, baseline_model, baseline, private, project, + ) + assert normalized["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] == SECRET_VALUE + + third_value_model = {"services": {"quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": "gpt-6.2"}, + "image": "quiz-server:candidate", + }}} + with pytest.raises(module.RegisteredLegacyAdoptionError): + module._candidate_source_comparison_model( + third_value_model, baseline_model, baseline, private, project, + ) + + +def test_baseline_rechecks_controller_state_before_public_signed_commit(module, project, tmp_path): + calls = 0 + + def changing_state(_repository, _project): + nonlocal calls + calls += 1 + if calls == 1: + return {"live_status": "unknown"}, STATE + return {"live_status": "changed"}, b'{"live_status":"changed"}\n' + + with pytest.raises(module.RegisteredLegacyAdoptionError, match="state changed before"): + module.baseline( + _payload(), registry={REPOSITORY: project}, read_state=changing_state, + runtime=_CaptureRuntime(), receipt_key=KEY, state_root=tmp_path, + ) + assert calls == 2 + assert not list(tmp_path.rglob("*.baseline.json")) + assert not list(tmp_path.rglob("*.private.json")) + + +@pytest.mark.parametrize("drift", ["source", "runtime", "container", "mismatch_keys"]) +def test_baseline_reobserves_exact_runtime_before_public_commit(module, project, tmp_path, drift): + class _DriftingRuntime(_CaptureRuntime): + def observe_for_baseline_capture(self, repository, target, *, receipt_key): + value = super().observe_for_baseline_capture(repository, target, receipt_key=receipt_key) + if self.capture_calls == 2: + if drift == "source": + value["sourceFingerprint"] = "f" * 64 + elif drift == "runtime": + value["runtimeFingerprint"] = "f" * 64 + elif drift == "container": + value["artifacts"]["containers"]["quiz-monitor-quiz-server-1"][ + "container_id" + ] = "d" * 64 + else: + value["environmentMismatchKeys"] = [] + value["capturedEnvironmentKeys"] = [] + return value + + with pytest.raises(module.RegisteredLegacyAdoptionError, match="runtime changed before baseline"): + module.baseline( + _payload(), registry={REPOSITORY: project}, read_state=_read_state, + runtime=_DriftingRuntime(), receipt_key=KEY, state_root=tmp_path, + ) + assert not list(tmp_path.rglob("*.baseline.json")) + assert not list(tmp_path.rglob("*.private.json")) diff --git a/backend/tests/test_registered_legacy_followup_controller.py b/backend/tests/test_registered_legacy_followup_controller.py new file mode 100644 index 0000000..9697baf --- /dev/null +++ b/backend/tests/test_registered_legacy_followup_controller.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from test_deploy_broker import deploy_module as _deploy_fixture +from test_hub_component_transport import controller as _controller_fixture + + +@pytest.fixture +def deploy_module(): + return _deploy_fixture.__wrapped__() + + +@pytest.fixture +def controller(monkeypatch): + return _controller_fixture.__wrapped__(monkeypatch) + + +@pytest.mark.parametrize("mode", ["deploy", "rollback"]) +def test_original_git_objects_are_bound_before_identity_reads(deploy_module, monkeypatch, mode): + monkeypatch.delenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", raising=False) + monkeypatch.delenv("GIT_NO_REPLACE_OBJECTS", raising=False) + assert deploy_module.bind_registered_legacy_request({}, mode) == "" + assert deploy_module.os.environ["GIT_NO_REPLACE_OBJECTS"] == "1" + + +def test_known_registered_restore_keeps_verified_revision_and_fresh_runtime_ids(deploy_module): + prior_revision = "a" * 40 + restored_artifacts = { + "containers": { + "quiz-monitor-quiz-server-1": { + "container_id": "b" * 64, + "image_id": "sha256:" + "c" * 64, + } + }, + "registered_legacy": {"requestId": "restored-request", "revision": prior_revision}, + } + restored_receipt = {"revision": prior_revision, "containers": restored_artifacts["containers"]} + project = { + "_registered_legacy_restored": True, + "_registered_legacy_restored_revision": prior_revision, + "_registered_legacy_restored_artifacts": restored_artifacts, + "_registered_legacy_restored_receipt": restored_receipt, + } + result = deploy_module.resolve_failed_live_state( + {"live_revision": prior_revision, "live_status": "healthy"}, + {"revision": prior_revision, "legacy_provenance": False, "registered_legacy": {}}, + project, + production_started=True, + failure_stage="verification", + restore_error=None, + ) + + assert result["live_revision"] == prior_revision + assert result["live_status"] == "healthy" + assert result["live_legacy_provenance"] is False + assert result["live_artifacts"] == restored_artifacts + assert result["live_receipt"] == restored_receipt + + +def test_failed_known_registered_restore_keeps_provenance_unknown(deploy_module): + prior_revision = "a" * 40 + result = deploy_module.resolve_failed_live_state( + {"live_revision": prior_revision, "live_status": "healthy"}, + {"revision": prior_revision, "legacy_provenance": False, "registered_legacy": {}}, + {}, + production_started=True, + failure_stage="verification", + restore_error=RuntimeError("restore failed"), + ) + + assert result["live_status"] == "unknown" + assert result["live_artifacts"] == {} + assert result["live_receipt"] == {} + + +def test_known_restore_source_callback_precedes_verification_and_state_binding( + deploy_module, monkeypatch +): + previous_revision = "a" * 40 + failed_revision = "d" * 40 + image_ids = {"quiz-monitor-quiz-server-1": "sha256:" + "c" * 64} + restored_receipt = {"revision": previous_revision, "build": {"revision": previous_revision}} + artifacts = {"containers": {}, "registered_legacy": {"revision": previous_revision}} + events = [] + project = {} + + def core_restore(_repository, _project, _snapshot, _failed, **kwargs): + events.append("signed-preimage-verified") + restore_source = kwargs.get("restore_source") + assert callable(restore_source), "known restore requires a source callback after signed validation" + restore_source(previous_revision) + events.append("restored-runtime-signed") + return {"revision": previous_revision, "artifacts": artifacts, + "imageIDs": image_ids, "liveReceipt": restored_receipt} + + def source_restore(_project, _repository, revision): + assert revision == previous_revision + events.append("prior-source-restored") + + def verify(_project, metadata, *, expected_image_ids): + assert metadata["revision"] == previous_revision + assert expected_image_ids == image_ids + events.append("health-and-provenance-verified") + return restored_receipt + + monkeypatch.setattr(deploy_module, "read_state", lambda _project: {}) + monkeypatch.setattr(deploy_module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + monkeypatch.setattr(deploy_module, "_load_registered_legacy_adoption", lambda: SimpleNamespace( + restore_deployment_snapshot=core_restore + )) + monkeypatch.setattr(deploy_module, "restore_live_source", source_restore) + monkeypatch.setattr(deploy_module, "validate_recorded_receipt", lambda *_args: restored_receipt["build"]) + monkeypatch.setattr(deploy_module, "verify_deployment", verify) + + deploy_module.restore_compose_snapshot( + project, + {"revision": previous_revision, "legacy_provenance": False, "registered_legacy": {}}, + "jens/quiz-monitor", failed_revision, + ) + + assert events == ["signed-preimage-verified", "prior-source-restored", + "restored-runtime-signed", "health-and-provenance-verified"] + assert project["_registered_legacy_restored"] is True + assert project["_registered_legacy_restored_revision"] == previous_revision + assert project["_registered_legacy_restored_receipt"] == restored_receipt + + +@pytest.mark.parametrize("receipt_matches", [True, False]) +def test_typed_rollback_worker_supplies_fixed_source_and_provenance_verifiers( + controller, monkeypatch, receipt_matches +): + import sys + + module, projects = controller + repository = "jens/quiz-monitor" + previous_revision = "a" * 40 + receipt = {"revision": previous_revision, "build": {"revision": previous_revision}} + project = {"slug": "quiz-monitor", "source_dir": "/fixed/source"} + projects[repository] = project + calls = [] + + def rollback(_payload, **kwargs): + kwargs["restore_source"](previous_revision) + kwargs["verify_restored"]({"revision": previous_revision, "liveReceipt": receipt, + "imageIDs": {"quiz": "immutable-image"}}) + calls.append("state-cas") + + monkeypatch.setitem(sys.modules, "registered_legacy_adoption", SimpleNamespace( + rollback=rollback, read_registered_state=lambda *_a: ({}, None), + RegisteredLegacyAdoptionError=ValueError, + record_worker_failure=lambda *_a, **_k: calls.append("failed"), + )) + monkeypatch.setitem(sys.modules, "deploy", SimpleNamespace( + restore_live_source=lambda selected, repo, revision: calls.append( + ("source", selected, repo, revision)), + validate_recorded_receipt=lambda selected, raw, repo, revision: receipt["build"], + verify_deployment=lambda selected, build, **kwargs: calls.append("verified") or ( + receipt if receipt_matches else {**receipt, "revision": "f" * 40} + ), + )) + module._run_registered_legacy_rollback({ + "repository": repository, "requestId": "11111111-1111-4111-8111-111111111111", + }, b"k" * 32) + assert calls == [("source", project, repository, previous_revision), "verified", + "state-cas" if receipt_matches else "failed"] diff --git a/backend/tests/test_registered_legacy_followup_core.py b/backend/tests/test_registered_legacy_followup_core.py new file mode 100644 index 0000000..e66d08a --- /dev/null +++ b/backend/tests/test_registered_legacy_followup_core.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +RUNTIME_TEST_PATH = ROOT / "backend" / "tests" / "test_registered_legacy_runtime.py" +_runtime_spec = importlib.util.spec_from_file_location( + "registered_legacy_followup_runtime_support", RUNTIME_TEST_PATH, +) +assert _runtime_spec and _runtime_spec.loader +runtime_support = importlib.util.module_from_spec(_runtime_spec) +_runtime_spec.loader.exec_module(runtime_support) + +REPOSITORY = runtime_support.REPOSITORY +REQUEST_ID = runtime_support.REQUEST_ID +TARGET_SHA = runtime_support.TARGET_SHA +KEY = runtime_support.KEY +CONTAINER_NAME = runtime_support.CONTAINER_NAME +CAPTURED_VALUE = "gpt-6-luna" +FOLLOWUP_SHA = "f" * 40 +RETRY_SHA = "9" * 40 +FOLLOWUP_IMAGE = "sha256:" + "d" * 64 + + +def _validation_api(revision: str, run_id: int): + def api_json(path, *, query): + if path == f"/repos/{REPOSITORY}/branches/main": + return {"protected": True, "commit": {"id": revision}} + if path == f"/repos/{REPOSITORY}/actions/runs": + return { + "total_count": 1, + "workflow_runs": [{ + "id": run_id, + "run_number": run_id + 10, + "run_attempt": 1, + "event": "push", + "head_sha": revision, + "head_branch": "main", + "path": "managed-validation.yml@refs/heads/main", + "created_at": "2026-10-07T12:00:00Z", + "status": "completed", + "conclusion": "success", + "repository": {"full_name": REPOSITORY}, + "head_repository": {"full_name": REPOSITORY}, + "pull_requests": [], + }], + } + if path == f"/repos/{REPOSITORY}/actions/runs/{run_id}/jobs": + return { + "total_count": 1, + "jobs": [{ + "id": run_id + 100, + "name": "full", + "run_id": run_id, + "run_attempt": 1, + "head_sha": revision, + "head_branch": "main", + "status": "completed", + "conclusion": "success", + }], + } + raise AssertionError(f"unexpected fixed validation request: {path} {query}") + return api_json + + +def _persist_state(state_root: Path, state: dict) -> None: + raw = (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode() + (state_root / "quiz-monitor.json").write_bytes(raw) + + +def _initial_active_case(tmp_path: Path, monkeypatch, *, capture: bool): + module = runtime_support._load_module() + source = tmp_path / "quiz-source" + source.mkdir() + (source / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") + project = runtime_support._project(source) + model = runtime_support._compose_model() + if capture: + model["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] = "gpt-5.5" + boundary = runtime_support._DockerComposeBoundary(module, project, model) + if capture: + boundary.live["Config"]["Env"].append( + f"KNOWLEDGE_OPTIMIZER_MODEL={CAPTURED_VALUE}" + ) + boundary.install(monkeypatch) + runtime, registry, _read_state, state_bytes, unknown_snapshot = ( + runtime_support._adopt_with_production_runtime( + module, boundary, project, tmp_path, monkeypatch, + capture_runtime_environment=capture, + ) + ) + live, deployed = runtime_support._candidate_deployment( + module, runtime, boundary, project, registry, state_bytes, tmp_path, + use_signed_overlay=capture, + ) + live_containers = copy.deepcopy(deployed["live_artifacts"]["containers"]) + standard_receipt = { + "schema": "gitea-deploy-receipt.v1", + "repository": REPOSITORY, + "revision": TARGET_SHA, + "containers": live_containers, + } + state = { + "schema_version": 3, + "repository": REPOSITORY, + "slug": "quiz-monitor", + "compose_project": "quiz-monitor", + "revision": TARGET_SHA, + "live_revision": TARGET_SHA, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": copy.deepcopy(deployed["live_artifacts"]), + "live_receipt": standard_receipt, + "rollback_snapshot": copy.deepcopy(unknown_snapshot), + "status": "success", + } + _persist_state(tmp_path, state) + assert live["revision"] == TARGET_SHA + return module, runtime, boundary, project, model, registry, state, unknown_snapshot + + +def _prepare_followup_candidate(module, runtime, boundary, project, state_root, + revision: str, run_id: int): + state = json.loads((state_root / "quiz-monitor.json").read_text(encoding="utf-8")) + prepared = module.prepare_followup_deployment( + REPOSITORY, project, revision, state, + api_json=_validation_api(revision, run_id), + receipt_key=KEY, state_root=state_root, runtime=runtime, + ) + request_id = prepared["requestId"] + project_bound = dict(project) + project_bound.update({ + "_registered_legacy_request_id": request_id, + "_registered_legacy_repository": REPOSITORY, + }) + source_model = copy.deepcopy(boundary.model) + # The current model is the live overlay; the next protected source carries + # the original declared model value, while the signed overlay restores the + # captured runtime value. + origin_request_id = prepared["originUnknownSnapshot"]["registered_legacy"]["requestId"] + origin_paths = module._paths(state_root, "quiz-monitor", origin_request_id) + origin_baseline, origin_private, _ = module._baseline_payload( + REPOSITORY, "quiz-monitor", origin_request_id, origin_paths, KEY, + ) + origin_capture = module._capture_binding(origin_baseline, origin_private, project) + if origin_capture is not None: + source_model["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] = "gpt-5.5" + module.prepare_build_configuration( + REPOSITORY, project_bound, request_id, revision, source_model, {}, + { + "APPOPS_SOURCE_REVISION": revision, + "APPOPS_SOURCE_REPOSITORY": REPOSITORY, + "APPOPS_SOURCE_TREE": "d" * 40, + "APPOPS_BUILD_ID": f"test-build-{run_id}", + "APPOPS_BUILD_DATE": "2026-10-07T12:00:00Z", + }, + receipt_key=KEY, state_root=state_root, + ) + overlay_path = module.validated_compose_overlay( + project_bound, receipt_key=KEY, state_root=state_root, repository=REPOSITORY, + ) + assert overlay_path is not None + overlay = json.loads(overlay_path.read_text(encoding="utf-8")) + if origin_capture is not None: + assert overlay["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] == CAPTURED_VALUE + boundary.model = runtime_support._compose_config_resolve_escaped(overlay) + boundary.images["quiz-monitor:latest"] = { + "Id": FOLLOWUP_IMAGE, + "Config": { + "Labels": { + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source": REPOSITORY, + }, + "Cmd": list(runtime_support.QUIZ_IMAGE_CMD), + "Entrypoint": None, + "WorkingDir": "/app", + }, + } + boundary.images[FOLLOWUP_IMAGE] = copy.deepcopy(boundary.images["quiz-monitor:latest"]) + module.prepare_candidate_deployment( + REPOSITORY, project_bound, request_id, revision, + receipt_key=KEY, state_root=state_root, runtime=runtime, + ) + module.verify_candidate_pre_mutation( + REPOSITORY, project_bound, request_id, revision, + receipt_key=KEY, state_root=state_root, runtime=runtime, + api_json=_validation_api(revision, run_id), + ) + boundary.live = runtime_support._container( + image_id=FOLLOWUP_IMAGE, container_id="9" * 64, + hostname="followup-generated-hostname", config_hash="followup-config", + ) + boundary.live["Config"]["Image"] = "quiz-monitor:latest" + if origin_capture is not None: + boundary.live["Config"]["Env"].append( + f"KNOWLEDGE_OPTIMIZER_MODEL={CAPTURED_VALUE}" + ) + module.record_candidate_runtime( + REPOSITORY, project_bound, request_id, revision, + receipt_key=KEY, state_root=state_root, runtime=runtime, + ) + containers = { + CONTAINER_NAME: {"container_id": "9" * 64, "image_id": FOLLOWUP_IMAGE} + } + receipt = { + "schema": "gitea-deploy-receipt.v1", "repository": REPOSITORY, + "revision": revision, "containers": copy.deepcopy(containers), + } + live_binding = module.record_live_runtime( + REPOSITORY, project_bound, request_id, revision, receipt, + receipt_key=KEY, state_root=state_root, runtime=runtime, + ) + state = { + "schema_version": 3, "repository": REPOSITORY, + "slug": "quiz-monitor", "compose_project": "quiz-monitor", + "revision": revision, "live_revision": revision, + "live_status": "healthy", "live_legacy_provenance": False, + "live_artifacts": { + "registered_legacy": {"requestId": request_id, "revision": revision, **live_binding}, + "containers": containers, + }, + "live_receipt": receipt, + "rollback_snapshot": prepared["rollbackSnapshot"], + "status": "success", + } + _persist_state(state_root, state) + return prepared, project_bound, state, origin_capture + + +def test_captured_runtime_survives_followup_known_restore_retry_and_unknown_fallback( + tmp_path, monkeypatch +): + module, runtime, boundary, project, _source_model, _registry, initial_state, _unknown = ( + _initial_active_case(tmp_path, monkeypatch, capture=True) + ) + original_lineage = module.active_lineage( + REPOSITORY, project, initial_state, + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert original_lineage["capturedEnvironmentKeys"] == [ + "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL", + ] + first, _first_project, first_state, _capture = _prepare_followup_candidate( + module, runtime, boundary, project, tmp_path, FOLLOWUP_SHA, 31, + ) + assert first["originUnknownSnapshot"]["revision"] == "unknown" + active = module.active_lineage( + REPOSITORY, project, first_state, + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert active["requestId"] == first["requestId"] + assert active["capturedEnvironmentKeys"] == original_lineage["capturedEnvironmentKeys"] + + restored_source_model = copy.deepcopy(boundary.model) + + def restore_source(revision): + assert revision == TARGET_SHA + original_overlay_project = dict(project) + original_overlay_project.update({ + "_registered_legacy_request_id": REQUEST_ID, + "_registered_legacy_repository": REPOSITORY, + }) + original_overlay = module.validated_compose_overlay( + original_overlay_project, receipt_key=KEY, state_root=tmp_path, + repository=REPOSITORY, + ) + assert original_overlay is not None + boundary.model = runtime_support._compose_config_resolve_escaped( + json.loads(original_overlay.read_text(encoding="utf-8")) + ) + + original_docker_run = module._ProductionRuntime._run + + def restore_exact_image(command, *, cwd=None, timeout=45, env=None): + result = original_docker_run(command, cwd=cwd, timeout=timeout, env=env) + if (command[:3] == ["docker", "compose", "--project-name"] + and command[-1:] == ["--force-recreate"]): + rollback_file = Path(command[command.index("--file") + 1]) + restore_model = json.loads(rollback_file.read_text(encoding="utf-8")) + image_ref = restore_model["services"]["quiz-server"]["image"] + boundary.live["Image"] = boundary.images[image_ref]["Id"] + return result + + monkeypatch.setattr(module._ProductionRuntime, "_run", staticmethod(restore_exact_image)) + first_state_bytes = (tmp_path / "quiz-monitor.json").read_bytes() + followup_rollback_payload = { + "repository": REPOSITORY, + "requestId": first["requestId"], + "revision": FOLLOWUP_SHA, + "expectedStateSha256": module._state_digest(first_state_bytes), + } + accepted_followup_rollback = module.accept_rollback( + followup_rollback_payload, registry={REPOSITORY: project}, + receipt_key=KEY, state_root=tmp_path, + ) + assert accepted_followup_rollback["status"] == "rollback_pending" + verified_known_restores = [] + + def verify_known_restore(restored): + assert restored["revision"] == TARGET_SHA + assert restored["legacy_provenance"] is False + assert restored["runtimeReceipt"]["runtimeReceiptKind"] == "restored" + assert (tmp_path / "quiz-monitor.json").read_bytes() == first_state_bytes + verified_known_restores.append(restored["runtimeReceipt"]["runtimeReceiptSha256"]) + + completed_followup_rollback = module.rollback( + followup_rollback_payload, registry={REPOSITORY: project}, runtime=runtime, + receipt_key=KEY, state_root=tmp_path, + restore_source=restore_source, verify_restored=verify_known_restore, + ) + assert completed_followup_rollback["status"] == "rolled-back" + assert len(verified_known_restores) == 1 + restored_state = json.loads((tmp_path / "quiz-monitor.json").read_text(encoding="utf-8")) + followup_rollback_path = module._paths( + tmp_path, project["slug"], first["requestId"], + )["rollback"] + terminal_followup_rollback = followup_rollback_path.read_bytes() + assert json.loads(terminal_followup_rollback)["status"] == "rolled-back" + assert "KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna" in boundary.live["Config"]["Env"] + + restored_lineage = module.active_lineage( + REPOSITORY, project, restored_state, + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert restored_lineage["revision"] == TARGET_SHA + assert restored_lineage["runtimeReceiptKind"] == "restored" + assert restored_lineage["rollbackSnapshot"]["revision"] == "unknown" + + retry = module.prepare_followup_deployment( + REPOSITORY, project, RETRY_SHA, restored_state, + api_json=_validation_api(RETRY_SHA, 41), + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert retry["requestId"] != first["requestId"] + assert retry["originUnknownSnapshot"] == restored_lineage["originUnknownSnapshot"] + assert retry["rollbackSnapshot"]["revision"] == TARGET_SHA + assert restored_source_model["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] == CAPTURED_VALUE + + _rollback_state, current_raw = module.read_registered_state( + REPOSITORY, project, state_root=tmp_path, + ) + assert current_raw is not None + rollback_payload = { + "repository": REPOSITORY, + "requestId": REQUEST_ID, + "revision": TARGET_SHA, + "expectedStateSha256": module._state_digest(current_raw), + } + accepted = module.accept_rollback( + rollback_payload, registry={REPOSITORY: project}, + receipt_key=KEY, state_root=tmp_path, + ) + assert accepted["status"] == "rollback_pending" + completed = module.rollback( + rollback_payload, registry={REPOSITORY: project}, runtime=runtime, + receipt_key=KEY, state_root=tmp_path, + restore_source=restore_source, verify_restored=verify_known_restore, + ) + assert completed["status"] == "rolled-back" + final_state = json.loads((tmp_path / "quiz-monitor.json").read_text(encoding="utf-8")) + assert final_state["live_revision"] == "unknown" + assert final_state["live_status"] == "unknown" + assert final_state["live_legacy_provenance"] is True + assert final_state["live_receipt"] == {} + assert boundary.live["Image"] == runtime_support.BASELINE_IMAGE + assert f"KNOWLEDGE_OPTIMIZER_MODEL={CAPTURED_VALUE}" in boundary.live["Config"]["Env"] + assert followup_rollback_path.read_bytes() == terminal_followup_rollback + + +def test_non_capture_registered_lineage_remains_strict_and_can_follow_up( + tmp_path, monkeypatch +): + module, runtime, _boundary, project, _source_model, _registry, state, _unknown = ( + _initial_active_case(tmp_path, monkeypatch, capture=False) + ) + active = module.active_lineage( + REPOSITORY, project, state, + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert active["capturedEnvironmentKeys"] == [] + prepared = module.prepare_followup_deployment( + REPOSITORY, project, FOLLOWUP_SHA, state, + api_json=_validation_api(FOLLOWUP_SHA, 31), + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + assert prepared["status"] == "followup-ready" + assert prepared["rollbackSnapshot"]["legacy_provenance"] is False diff --git a/backend/tests/test_registered_legacy_followup_review.py b/backend/tests/test_registered_legacy_followup_review.py new file mode 100644 index 0000000..4bff4b0 --- /dev/null +++ b/backend/tests/test_registered_legacy_followup_review.py @@ -0,0 +1,845 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from contextlib import contextmanager +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "registered_legacy_adoption.py" +REPOSITORY = "jens/quiz-monitor" +CONTAINER = "quiz-monitor-quiz-server-1" +IMAGE_ID = "sha256:" + "a" * 64 +SOURCE_FINGERPRINT = "b" * 64 +RUNTIME_FINGERPRINT = "c" * 64 +STATE_SHA256 = "d" * 64 +CONTAINER_ID = "e" * 64 +CAPTURED_VALUE = "gpt-6-luna" +CAPTURED_KEY = "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL" + + +def _load_module(): + spec = importlib.util.spec_from_file_location( + "registered_legacy_followup_review", MODULE_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_deploy_module(): + if "fcntl" not in sys.modules: + fcntl = types.ModuleType("fcntl") + fcntl.LOCK_EX = 2 + fcntl.LOCK_UN = 8 + fcntl.flock = lambda *_args: None + sys.modules["fcntl"] = fcntl + name = "registered_legacy_followup_deploy_review" + spec = importlib.util.spec_from_file_location( + name, ROOT / "ops" / "gitea-deploy-broker" / "deploy.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _captured_lineage(module): + binding = { + "schema": "itworx.registered-legacy-env-capture/v1", + "sourceFingerprint": SOURCE_FINGERPRINT, + "runtimeFingerprint": RUNTIME_FINGERPRINT, + "expectedStateSha256": STATE_SHA256, + "containerIDs": {CONTAINER: CONTAINER_ID}, + "capturedEnvironmentKeys": [CAPTURED_KEY], + } + runtime_container = { + "Image": IMAGE_ID, + "Config": { + "Labels": {"com.docker.compose.service": "quiz-server"}, + "Env": [f"KNOWLEDGE_OPTIMIZER_MODEL={CAPTURED_VALUE}"], + }, + } + baseline_model = { + "services": { + "quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": CAPTURED_VALUE}, + "image": "quiz-monitor:baseline", + } + } + } + private = { + "environmentCapture": binding, + "runtimeConfiguration": { + "composeModel": baseline_model, + "containers": {CONTAINER: runtime_container}, + "imageEnvironments": {IMAGE_ID: {}}, + }, + } + baseline = { + "environmentCapture": binding, + "sourceFingerprint": SOURCE_FINGERPRINT, + "runtimeFingerprint": RUNTIME_FINGERPRINT, + "expectedStateSha256": STATE_SHA256, + } + project = {"containers": [CONTAINER]} + return baseline_model, baseline, private, project + + +def test_followup_candidate_cannot_mask_a_wrong_captured_environment_overlay(): + module = _load_module() + baseline_model, baseline, private, project = _captured_lineage(module) + candidate_model = { + "services": { + "quiz-server": { + "environment": {"KNOWLEDGE_OPTIMIZER_MODEL": "gpt-5.5"}, + "image": "quiz-monitor:candidate", + } + } + } + + with pytest.raises(module.RegisteredLegacyAdoptionError): + module._candidate_runtime_comparison_model( + candidate_model, baseline_model, baseline, private, project + ) + + +def test_main_revalidates_active_lineage_before_same_sha_fast_return( + monkeypatch, tmp_path, capsys +): + """A matching receipt cannot hide drift in a previously adopted runtime.""" + module = _load_deploy_module() + revision = "f" * 40 + receipt = {"repository": REPOSITORY, "revision": revision, "tree": "a" * 40} + state = { + "schema_version": 3, + "repository": REPOSITORY, + "slug": "quiz-monitor", + "compose_project": "quiz-monitor", + "revision": revision, + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": { + "registered_legacy": { + "requestId": "6f9619ff-8b86-4011-b42d-00c04fc964ff", + "revision": revision, + "runtimeReceiptSha256": "b" * 64, + "runtimeFingerprint": "c" * 64, + } + }, + "live_receipt": receipt, + "status": "success", + } + project = { + "slug": "quiz-monitor", + "branch": "main", + "source_dir": str(tmp_path / "source"), + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": [CONTAINER], + "exact_source": True, + "verify_oci_revision": True, + "health_timeout": 180, + } + projects = {REPOSITORY: project} + metadata = {"revision": revision, "tree": "a" * 40} + events = [] + + @contextmanager + def project_lock(_path, _description): + events.append("project-lock") + yield + + class Adoption: + def active_lineage(self, repository, observed_project, observed_state, **_kwargs): + assert repository == REPOSITORY + assert observed_state["live_legacy_provenance"] is False + events.append("active-lineage") + raise module.DeployError("registered live runtime drift") + + monkeypatch.setattr(sys, "argv", ["deploy.py", "deploy", REPOSITORY, revision]) + monkeypatch.delenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", raising=False) + monkeypatch.setattr(module, "load_projects", lambda: projects) + monkeypatch.setattr(module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr(module, "deployment_metadata", lambda *_args: metadata) + monkeypatch.setattr(module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(module, "exclusive_lock", project_lock) + monkeypatch.setattr(module, "read_state", lambda _project: state) + monkeypatch.setattr(module, "verified_current_receipt", lambda *_args: ( + events.append("same-sha-receipt") or receipt + )) + monkeypatch.setattr(module, "_load_registered_legacy_adoption", Adoption) + monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + monkeypatch.setattr(module, "sync_source", lambda *_args, **_kwargs: ( + events.append("sync-source"), pytest.fail("drift must be rejected before source sync") + )) + + with pytest.raises(module.DeployError, match="registered live runtime drift"): + module.main() + + assert "active-lineage" in events + assert "same-sha-receipt" not in events + assert "sync-source" not in events + assert json.loads(capsys.readouterr().out or "{}") == {} + + +def test_main_defers_active_followup_source_sync_until_global_lock( + monkeypatch, tmp_path +): + """Ordinary main deployment must attest the active target before source sync.""" + module = _load_deploy_module() + previous_revision = "1" * 40 + revision = "2" * 40 + state_sha = "d" * 64 + request_id = "6f9619ff-8b86-4011-b42d-00c04fc964ff" + state = { + "schema_version": 3, + "repository": REPOSITORY, + "slug": "quiz-monitor", + "compose_project": "quiz-monitor", + "revision": previous_revision, + "live_revision": previous_revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": {"registered_legacy": {"requestId": request_id}}, + "live_receipt": {"repository": REPOSITORY, "revision": previous_revision}, + "status": "success", + } + project = { + "slug": "quiz-monitor", + "branch": "main", + "source_dir": str(tmp_path / "source"), + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": [CONTAINER], + "exact_source": True, + "verify_oci_revision": True, + "health_timeout": 180, + } + projects = {REPOSITORY: project} + metadata = {"revision": revision, "tree": "a" * 40} + events = [] + lock_state = {"global": False} + + @contextmanager + def project_lock(_path, _description): + events.append("project-lock-enter") + yield + + @contextmanager + def global_lock(): + events.append("global-lock-enter") + lock_state["global"] = True + try: + yield + finally: + lock_state["global"] = False + events.append("global-lock-exit") + + class Adoption: + def active_lineage(self, repository, observed_project, observed_state, **_kwargs): + assert repository == REPOSITORY + assert observed_state == state + events.append("active-lineage") + return { + "requestId": request_id, + "revision": previous_revision, + "sourceFingerprint": SOURCE_FINGERPRINT, + "runtimeFingerprint": RUNTIME_FINGERPRINT, + "runtimeReceiptSha256": "e" * 64, + "originUnknownSnapshot": {"revision": "unknown"}, + } + + def prepare_followup_deployment(self, repository, observed_project, target, observed_state, **_kwargs): + assert lock_state["global"] + assert repository == REPOSITORY + assert target == revision + assert observed_state == state + assert "sync-source" not in events + events.append("prepare-followup") + return { + "requestId": request_id, + "authorization": { + "requestId": request_id, + "revision": target, + "expectedStateSha256": state_sha, + }, + "rollbackSnapshot": {"revision": previous_revision}, + "originUnknownSnapshot": {"revision": "unknown"}, + "expectedStateSha256": state_sha, + "validationEvidenceSha256": "f" * 64, + } + + monkeypatch.setattr(sys, "argv", ["deploy.py", "deploy", REPOSITORY, revision]) + monkeypatch.delenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", raising=False) + monkeypatch.setattr(module, "load_projects", lambda: projects) + monkeypatch.setattr(module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr(module, "deployment_metadata", lambda *_args: metadata) + monkeypatch.setattr(module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(module, "exclusive_lock", project_lock) + monkeypatch.setattr(module, "exclusive_global_deploy_lock", global_lock) + monkeypatch.setattr(module, "read_state", lambda _project: state) + monkeypatch.setattr(module, "verified_current_receipt", lambda *_args: None) + monkeypatch.setattr(module, "validate_forward_revision", lambda *_args, **_kwargs: None) + monkeypatch.setattr(module, "_load_registered_legacy_adoption", Adoption) + monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + + def stop_after_sync(*_args, **_kwargs): + events.append("sync-source") + if not lock_state["global"]: + events.append("sync-before-global") + raise module.DeployError("test stop after source sync") + + monkeypatch.setattr(module, "sync_source", stop_after_sync) + monkeypatch.setattr(module, "copy_environment", lambda *_args: None) + monkeypatch.setattr(module, "restore_live_source", lambda *_args: None) + monkeypatch.setattr(module, "write_state", lambda *_args, **_kwargs: None) + + with pytest.raises(module.DeployError, match="test stop after source sync"): + module.main() + + assert events.index("global-lock-enter") < events.index("active-lineage") + assert events.index("active-lineage") < events.index("prepare-followup") + assert events.index("prepare-followup") < events.index("sync-source") + assert "sync-before-global" not in events + + +def test_main_commits_followup_as_verified_not_legacy_and_keeps_unknown_origin( + monkeypatch, tmp_path, capsys +): + module = _load_deploy_module() + previous_revision = "1" * 40 + revision = "2" * 40 + state_sha = "d" * 64 + request_id = "6f9619ff-8b86-4011-b42d-00c04fc964ff" + origin = {"revision": "unknown", "legacy_provenance": True, "opaque": "signed-origin"} + known_snapshot = { + "revision": previous_revision, + "legacy_provenance": False, + "registered_legacy": {"requestId": request_id}, + "originUnknownSnapshot": origin, + } + state = { + "schema_version": 3, + "repository": REPOSITORY, + "slug": "quiz-monitor", + "compose_project": "quiz-monitor", + "revision": previous_revision, + "live_revision": previous_revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": {"registered_legacy": {"requestId": request_id}}, + "live_receipt": {"repository": REPOSITORY, "revision": previous_revision}, + "status": "success", + } + project = { + "slug": "quiz-monitor", + "branch": "main", + "source_dir": str(tmp_path / "source"), + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": [CONTAINER], + "exact_source": True, + "verify_oci_revision": True, + "health_timeout": 180, + } + projects = {REPOSITORY: project} + metadata = {"revision": revision, "tree": "a" * 40} + events = [] + writes = [] + + @contextmanager + def project_lock(_path, _description): + events.append("project-lock-enter") + yield + + @contextmanager + def global_lock(): + events.append("global-lock-enter") + yield + events.append("global-lock-exit") + + class Adoption: + def active_lineage(self, repository, _observed_project, observed_state, **_kwargs): + assert repository == REPOSITORY + assert observed_state == state + events.append("active-lineage") + return { + "requestId": request_id, + "revision": previous_revision, + "sourceFingerprint": SOURCE_FINGERPRINT, + "runtimeFingerprint": RUNTIME_FINGERPRINT, + "runtimeReceiptSha256": "e" * 64, + "originUnknownSnapshot": origin, + } + + def prepare_followup_deployment(self, repository, _observed_project, target, observed_state, **_kwargs): + assert repository == REPOSITORY and target == revision + assert observed_state == state + events.append("prepare-followup") + return { + "requestId": request_id, + "authorization": { + "requestId": request_id, + "revision": target, + "expectedStateSha256": state_sha, + }, + "rollbackSnapshot": known_snapshot, + "originUnknownSnapshot": origin, + "expectedStateSha256": state_sha, + "validationEvidenceSha256": "f" * 64, + } + + def record_live_runtime(self, repository, observed_project, observed_request, target, _receipt, **_kwargs): + assert repository == REPOSITORY and observed_request == request_id + assert target == revision + assert observed_project["_registered_legacy_request_id"] == request_id + events.append("record-live") + return { + "requestId": request_id, + "revision": target, + "runtimeReceiptSha256": "9" * 64, + "runtimeFingerprint": "8" * 64, + "originUnknownSnapshot": origin, + } + + receipt = {"repository": REPOSITORY, "revision": revision, "tree": metadata["tree"]} + monkeypatch.setattr(sys, "argv", ["deploy.py", "deploy", REPOSITORY, revision]) + monkeypatch.delenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", raising=False) + monkeypatch.setattr(module, "load_projects", lambda: projects) + monkeypatch.setattr(module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr(module, "deployment_metadata", lambda *_args: metadata) + monkeypatch.setattr(module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(module, "exclusive_lock", project_lock) + monkeypatch.setattr(module, "exclusive_global_deploy_lock", global_lock) + monkeypatch.setattr(module, "read_state", lambda _project: state) + monkeypatch.setattr(module, "verified_current_receipt", lambda *_args: None) + monkeypatch.setattr(module, "validate_forward_revision", lambda *_args, **_kwargs: None) + monkeypatch.setattr(module, "revalidate_deployment_candidate", lambda *_args: None) + monkeypatch.setattr(module, "_load_registered_legacy_adoption", Adoption) + monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + monkeypatch.setattr(module, "_registered_legacy_validation_reader", lambda _repository: lambda *_args: {}) + monkeypatch.setattr(module, "sync_source", lambda *_args, **_kwargs: events.append("sync-source")) + monkeypatch.setattr(module, "copy_environment", lambda *_args: None) + monkeypatch.setattr(module, "_mcp_hub_assert_no_unresolved_migration_intent", lambda *_args: None) + monkeypatch.setattr(module, "ensure_docker_capacity", lambda: None) + monkeypatch.setattr(module, "deploy_project", lambda observed_project, _revision, _repository: ( + events.append("deploy"), + pytest.fail("follow-up deploy lost its derived internal request ID") + if observed_project.get("_registered_legacy_request_id") != request_id else None, + )) + monkeypatch.setattr(module, "verify_deployment", lambda *_args: receipt) + monkeypatch.setattr(module, "finish_compose_migration", lambda *_args: None) + monkeypatch.setattr(module, "write_state", lambda *_args, **kwargs: writes.append(kwargs)) + monkeypatch.setattr(module, "cleanup_rollback_image_tags", lambda *_args, **_kwargs: None) + + assert module.main() == 0 + + assert events.index("global-lock-enter") < events.index("active-lineage") + assert events.index("prepare-followup") < events.index("sync-source") < events.index("deploy") + assert "record-live" in events + assert len(writes) == 1 + assert writes[0]["live_legacy_provenance"] is False + assert writes[0]["rollback_snapshot"] == known_snapshot + assert writes[0]["expected_state_sha256"] == state_sha + assert json.loads(capsys.readouterr().out)["status"] == "deployed" + + +def test_main_known_current_rollback_restores_source_then_allows_retry( + monkeypatch, tmp_path, capsys +): + """A failed candidate returns to the verified prior revision and can retry.""" + module = _load_deploy_module() + previous_revision = "1" * 40 + revision = "2" * 40 + state_sha = "d" * 64 + request_ids = [ + "6f9619ff-8b86-4011-b42d-00c04fc964ff", + "8c7b4a2d-3d70-4a49-93e4-41d42aeecc03", + ] + origin = { + "revision": "unknown", + "legacy_provenance": True, + "registered_legacy": {"schema": "origin-unknown-snapshot"}, + } + state = { + "schema_version": 3, + "repository": REPOSITORY, + "slug": "quiz-monitor", + "compose_project": "quiz-monitor", + "revision": previous_revision, + "live_revision": previous_revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": {"registered_legacy": {"requestId": request_ids[0]}}, + "live_receipt": {"schema": "gitea-deploy-receipt.v1", "repository": REPOSITORY, + "revision": previous_revision}, + "rollback_snapshot": {"revision": "unknown", "legacy_provenance": True, + "registered_legacy": {"schema": "origin-unknown-snapshot"}}, + "status": "success", + } + project_template = { + "slug": "quiz-monitor", + "branch": "main", + "source_dir": str(tmp_path / "source"), + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": [CONTAINER], + "verify_oci_revision": True, + "health_timeout": 180, + } + metadata = {"revision": revision, "tree": "a" * 40} + known_snapshot = { + "revision": previous_revision, + "legacy_provenance": False, + "receipt": {"schema": "gitea-deploy-receipt.v1", "repository": REPOSITORY, + "revision": previous_revision}, + "containers": [{"name": CONTAINER, "image_id": IMAGE_ID}], + "registered_legacy": {"schema": "known-current-snapshot"}, + "originUnknownSnapshot": origin, + } + restored_artifacts = { + "registered_legacy": {"requestId": request_ids[0], "revision": previous_revision}, + "containers": {CONTAINER: {"container_id": CONTAINER_ID, "image_id": IMAGE_ID}}, + } + restored_receipt = known_snapshot["receipt"] + events = [] + candidate_attempts = 0 + writes = [] + + @contextmanager + def project_lock(_path, _description): + yield + + @contextmanager + def global_lock(): + events.append("global-lock-enter") + try: + yield + finally: + events.append("global-lock-exit") + + class Adoption: + def active_lineage(self, repository, _project, observed_state, **_kwargs): + assert repository == REPOSITORY + assert observed_state["live_revision"] == previous_revision + assert observed_state["live_status"] == "healthy" + events.append(f"active-lineage:{observed_state['status']}") + return { + "requestId": request_ids[0], + "revision": previous_revision, + "originUnknownSnapshot": origin, + } + + def prepare_followup_deployment(self, repository, _project, target, _state, **_kwargs): + assert repository == REPOSITORY and target == revision + request_id = request_ids[len([e for e in events if e == "prepare-followup"]) ] + events.append("prepare-followup") + return { + "requestId": request_id, + "authorization": { + "requestId": request_id, + "revision": target, + "expectedStateSha256": state_sha, + }, + "rollbackSnapshot": known_snapshot, + "originUnknownSnapshot": origin, + "expectedStateSha256": state_sha, + "validationEvidenceSha256": "f" * 64, + } + + def restore_deployment_snapshot(self, repository, _project, snapshot, failed, **kwargs): + assert repository == REPOSITORY and failed == revision + assert snapshot == known_snapshot + callback = kwargs.get("restore_source") + assert callable(callback) + events.append("rollback-core-accepted-snapshot") + callback(previous_revision) + events.append("prior-source-restored") + return { + "revision": previous_revision, + "artifacts": restored_artifacts, + "imageIDs": {CONTAINER: IMAGE_ID}, + "liveReceipt": restored_receipt, + } + + def verify_current_live_runtime(self, *_args, **_kwargs): + pytest.fail("candidate failure must not claim the failed revision is healthy") + + def record_live_runtime(self, repository, project, request_id, target, _receipt, **_kwargs): + assert repository == REPOSITORY and target == revision + assert project["_registered_legacy_request_id"] == request_id + events.append("record-live") + return { + "requestId": request_id, + "revision": target, + "runtimeReceiptSha256": "9" * 64, + "runtimeFingerprint": "8" * 64, + "originRequestId": request_ids[0], + "originUnknownSnapshot": origin, + } + + adoption = Adoption() + candidate_receipt = { + "schema": "gitea-deploy-receipt.v1", "repository": REPOSITORY, + "revision": revision, "tree": metadata["tree"], + } + + monkeypatch.setattr(sys, "argv", ["deploy.py", "deploy", REPOSITORY, revision]) + monkeypatch.delenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", raising=False) + monkeypatch.setattr(module, "load_projects", lambda: {REPOSITORY: dict(project_template)}) + monkeypatch.setattr(module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr(module, "deployment_metadata", lambda *_args: metadata) + monkeypatch.setattr(module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(module, "exclusive_lock", project_lock) + monkeypatch.setattr(module, "exclusive_global_deploy_lock", global_lock) + monkeypatch.setattr(module, "read_state", lambda _project: state) + monkeypatch.setattr(module, "verified_current_receipt", lambda *_args: None) + monkeypatch.setattr(module, "validate_forward_revision", lambda *_args, **_kwargs: None) + monkeypatch.setattr(module, "_load_registered_legacy_adoption", lambda: adoption) + monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32) + monkeypatch.setattr(module, "_registered_legacy_validation_reader", lambda _repo: lambda *_args: {}) + monkeypatch.setattr(module, "sync_source", lambda *_args, **_kwargs: events.append("sync-source")) + monkeypatch.setattr(module, "copy_environment", lambda *_args: None) + monkeypatch.setattr(module, "restore_live_source", lambda _project, _repo, rev: events.append(f"restore-source:{rev}")) + monkeypatch.setattr(module, "mcp_hub_prepare_current_receipt_model", lambda *_args: None) + monkeypatch.setattr(module, "_mcp_hub_assert_no_unresolved_migration_intent", lambda *_args: None) + monkeypatch.setattr(module, "revalidate_deployment_candidate", lambda *_args: None) + monkeypatch.setattr(module, "validate_managed_v1_runtime", lambda *_args: None) + monkeypatch.setattr(module, "capture_compose_rollback", lambda *_args: pytest.fail("signed rollback snapshot must be reused")) + monkeypatch.setattr(module, "ensure_docker_capacity", lambda: None) + monkeypatch.setattr(module, "finish_compose_migration", lambda *_args: None) + monkeypatch.setattr(module, "restore_compose_migration", lambda *_args: None) + monkeypatch.setattr(module, "validate_recorded_receipt", lambda *_args: {"revision": previous_revision}) + monkeypatch.setattr(module, "verify_deployment", lambda _project, meta, **_kwargs: ( + restored_receipt if meta.get("revision") == previous_revision else candidate_receipt + )) + + def deploy(_project, _revision, _repository): + nonlocal candidate_attempts + candidate_attempts += 1 + events.append(f"deploy:{candidate_attempts}") + if candidate_attempts == 1: + raise module.DeployError("simulated candidate health failure") + + monkeypatch.setattr(module, "deploy_project", deploy) + + def write_state(_repository, project, target, status, message, **kwargs): + writes.append((target, status, dict(kwargs))) + state.update({ + "repository": REPOSITORY, + "status": status, + "revision": kwargs.get("live_revision", state["live_revision"]), + "live_revision": kwargs.get("live_revision", state["live_revision"]), + "live_status": kwargs.get("live_status", state["live_status"]), + "live_legacy_provenance": kwargs.get( + "live_legacy_provenance", state["live_legacy_provenance"] + ), + "live_artifacts": kwargs.get("live_artifacts", state["live_artifacts"]), + "live_receipt": kwargs.get("live_receipt", state["live_receipt"]), + "rollback_snapshot": kwargs.get("rollback_snapshot", state["rollback_snapshot"]), + "last_attempt": {"revision": target, "status": status, "message": message}, + }) + if status == "success": + state["live_revision"] = revision + state["revision"] = revision + state["live_status"] = "healthy" + state["live_legacy_provenance"] = False + state["live_receipt"] = candidate_receipt + state["live_artifacts"] = { + "registered_legacy": dict(project["_registered_legacy_runtime_binding"]), + "containers": restored_artifacts["containers"], + } + state["rollback_snapshot"] = known_snapshot + + monkeypatch.setattr(module, "write_state", write_state) + monkeypatch.setattr(module, "cleanup_rollback_image_tags", lambda *_args, **_kwargs: None) + + with pytest.raises(module.DeployError, match="simulated candidate health failure"): + module.main() + + assert "rollback-core-accepted-snapshot" in events + assert events.index("rollback-core-accepted-snapshot") < events.index( + f"restore-source:{previous_revision}" + ) + assert state["status"] == "failed" + assert state["live_revision"] == previous_revision + assert state["live_status"] == "healthy" + assert state["live_legacy_provenance"] is False + assert state["rollback_snapshot"]["revision"] == "unknown" + + assert module.main() == 0 + + assert candidate_attempts == 2 + assert len([event for event in events if event == "prepare-followup"]) == 2 + assert state["status"] == "success" + assert state["live_revision"] == revision + assert state["live_legacy_provenance"] is False + assert state["live_artifacts"]["registered_legacy"]["originUnknownSnapshot"] == origin + assert state["rollback_snapshot"] == known_snapshot + assert any(event == "active-lineage:failed" for event in events) + assert json.loads(capsys.readouterr().out)["status"] == "deployed" + + +def test_unsigned_known_snapshot_cannot_mutate_source(monkeypatch, tmp_path): + """A forged known-current rollback reference is rejected before source restore.""" + deploy = _load_deploy_module() + adoption = _load_module() + project = { + "slug": "quiz-monitor", + "branch": "main", + "source_dir": "/srv/example/appdata/quiz-monitor/source", + "compose_project": "quiz-monitor", + "compose_files": ["docker-compose.yml"], + "containers": [CONTAINER], + } + previous_revision = "1" * 40 + failed_revision = "2" * 40 + request_id = "6f9619ff-8b86-4011-b42d-00c04fc964ff" + source_changes = [] + forged_snapshot = { + "revision": previous_revision, + "legacy_provenance": False, + "registered_legacy": { + "schema": adoption.KNOWN_SNAPSHOT_SCHEMA, + "requestId": request_id, + "snapshotSha256": "0" * 64, + "targetRevision": failed_revision, + "previousRevision": previous_revision, + "originRequestId": request_id, + "originUnknownSnapshot": {}, + }, + } + monkeypatch.setattr(deploy, "STATE_ROOT", tmp_path) + monkeypatch.setattr(deploy, "read_state", lambda _project: {}) + monkeypatch.setattr(deploy, "_load_registered_legacy_adoption", lambda: adoption) + monkeypatch.setattr(deploy, "_registered_legacy_receipt_key", lambda: b"k" * 32) + monkeypatch.setattr( + deploy, "restore_live_source", + lambda *_args: source_changes.append("source-restored"), + ) + + with pytest.raises(adoption.RegisteredLegacyAdoptionError): + deploy.restore_compose_snapshot( + project, forged_snapshot, REPOSITORY, failed_revision, + ) + + assert source_changes == [] + + +def test_candidate_pre_mutation_rechecks_latest_canonical_main_run( + monkeypatch, tmp_path +): + """A newly failed full run blocks a previously sealed candidate before Compose.""" + support_path = ROOT / "backend" / "tests" / "test_registered_legacy_runtime.py" + support_spec = importlib.util.spec_from_file_location( + "registered_legacy_runtime_followup_review", support_path + ) + assert support_spec and support_spec.loader + support = importlib.util.module_from_spec(support_spec) + support_spec.loader.exec_module(support) + + module = support._load_module() + source = tmp_path / "source" + source.mkdir() + (source / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") + project = support._project(source) + model = support._compose_model() + boundary = support._DockerComposeBoundary(module, project, model) + boundary.install(monkeypatch) + runtime, _registry, _read_state, _state_bytes, _snapshot = ( + support._adopt_with_production_runtime( + module, boundary, project, tmp_path, monkeypatch + ) + ) + bound_project = support._prepare_candidate_overlay( + module, boundary, project, tmp_path + ) + module.prepare_candidate_deployment( + REPOSITORY, + bound_project, + support.REQUEST_ID, + support.TARGET_SHA, + receipt_key=support.KEY, + state_root=tmp_path, + runtime=runtime, + ) + + older_success = { + "id": 7, + "run_number": 19, + "run_attempt": 1, + "event": "push", + "head_sha": support.TARGET_SHA, + "head_branch": "main", + "path": "managed-validation.yml@refs/heads/main", + "created_at": "2026-10-07T12:00:00Z", + "status": "completed", + "conclusion": "success", + "repository": {"full_name": REPOSITORY}, + "head_repository": {"full_name": REPOSITORY}, + "pull_requests": [], + } + newer_failure = { + **older_success, + "id": 8, + "run_number": 20, + "created_at": "2026-10-07T12:01:00Z", + "status": "completed", + "conclusion": "failure", + } + queried = [] + + def failed_latest_api(path, *, query): + queried.append(path) + if path == f"/repos/{REPOSITORY}/actions/runs": + return { + "total_count": 2, + "workflow_runs": [older_success, newer_failure], + } + return support._validation_api(path, query=query) + + with pytest.raises(module.RegisteredLegacyAdoptionError): + module.verify_candidate_pre_mutation( + REPOSITORY, + bound_project, + support.REQUEST_ID, + support.TARGET_SHA, + receipt_key=support.KEY, + state_root=tmp_path, + runtime=runtime, + api_json=failed_latest_api, + ) + + assert queried == [f"/repos/{REPOSITORY}/branches/main", f"/repos/{REPOSITORY}/actions/runs"] + assert boundary.compose_ups == 0 + assert not any( + command[:3] == ["docker", "compose", "--project-name"] + and command[-1:] == ["--force-recreate"] + for command in boundary.commands + ) + + verified = module.verify_candidate_pre_mutation( + REPOSITORY, + bound_project, + support.REQUEST_ID, + support.TARGET_SHA, + receipt_key=support.KEY, + state_root=tmp_path, + runtime=runtime, + api_json=support._validation_api, + ) + assert verified["status"] == "pre-mutation-ok" + assert verified["validationEvidenceSha256"] + assert boundary.compose_ups == 0 diff --git a/backend/tests/test_registered_legacy_runtime.py b/backend/tests/test_registered_legacy_runtime.py index f5a3dcd..5017408 100644 --- a/backend/tests/test_registered_legacy_runtime.py +++ b/backend/tests/test_registered_legacy_runtime.py @@ -264,6 +264,19 @@ class _DockerComposeBoundary: config_hash="new-config-hash", ) self.live["Config"]["Image"] = service["image"] + environment = service.get("environment") or {} + if isinstance(environment, dict): + current_env = {} + for entry in self.live["Config"]["Env"]: + name, value = entry.split("=", 1) + current_env[name] = value + current_env.update({ + str(name): str(value).replace("$$", "$") + for name, value in environment.items() + }) + self.live["Config"]["Env"] = [ + f"{name}={value}" for name, value in current_env.items() + ] return "" raise AssertionError(f"unexpected Docker command: {command}") @@ -374,6 +387,107 @@ def test_production_observe_rejects_runtime_drift_before_baseline_capture( with pytest.raises(module.RegisteredLegacyAdoptionError): runtime.observe(REPOSITORY, project, receipt_key=KEY) + +def test_capture_observer_allows_only_declared_environment_drift_and_reports_names( + production_runtime, module +): + runtime, boundary, project, model = production_runtime + model["services"]["quiz-server"]["environment"]["KNOWLEDGE_OPTIMIZER_MODEL"] = "gpt-5.5" + boundary.live["Config"]["Env"].append("KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna") + + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe(REPOSITORY, project, receipt_key=KEY) + + observed = runtime.observe_for_baseline_capture( + REPOSITORY, project, receipt_key=KEY, + ) + assert observed["status"] == "observed" + assert observed["environmentMismatchKeys"] == [ + "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL", + ] + assert observed["capturedEnvironmentKeys"] == [ + "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL", + ] + assert observed["sourceFingerprint"] + assert observed["runtimeFingerprint"] + assert observed["artifacts"]["containers"][CONTAINER_NAME]["container_id"] == "d" * 64 + registry_project = copy.deepcopy(project) + registry_project["source_dir"] = "/srv/example/appdata/quiz-monitor/source" + + class _CaptureOnlyRuntime: + def observe_for_baseline_capture(self, *_args, **_kwargs): + return copy.deepcopy(observed) + + public = module.observe( + {"repository": REPOSITORY}, + registry={REPOSITORY: registry_project}, + read_state=lambda _repository, _project: ({}, b"{}\n"), + runtime=_CaptureOnlyRuntime(), + receipt_key=KEY, + state_root=runtime.state_root, + ) + assert public["containerIDs"] == {CONTAINER_NAME: "d" * 64} + assert public["imageIDs"] == {CONTAINER_NAME: BASELINE_IMAGE} + assert public["environmentMismatchKeys"] == observed["environmentMismatchKeys"] + serialized = json.dumps(public) + assert "gpt-6-luna" not in serialized + assert "gpt-5.5" not in serialized + + +@pytest.mark.parametrize( + "entries", + [ + ["OPENRGB_HOST=198.19.1.2", "OPENRGB_HOST=attacker-value"], + ["OPENRGB_HOST=198.19.1.2", "malformed-entry"], + ], +) +def test_capture_observer_rejects_duplicate_or_malformed_inspected_environment( + production_runtime, module, entries +): + runtime, boundary, project, model = production_runtime + model["services"]["quiz-server"]["environment"]["KNOWLEDGE_OPTIMIZER_MODEL"] = "gpt-5.5" + boundary.live["Config"]["Env"].append("KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna") + boundary.live["Config"]["Env"] = entries + [ + "QUIZ_TEMPLATE=" + TEMPLATE_VALUE, + "KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna", + ] + + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe_for_baseline_capture(REPOSITORY, project, receipt_key=KEY) + + +def test_capture_observer_still_rejects_unrendered_unsafe_runtime_options( + production_runtime, module +): + runtime, boundary, project, model = production_runtime + model["services"]["quiz-server"]["environment"]["KNOWLEDGE_OPTIMIZER_MODEL"] = "gpt-5.5" + boundary.live["Config"]["Env"].append("KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna") + boundary.live["HostConfig"]["Privileged"] = True + + with pytest.raises(module.RegisteredLegacyAdoptionError, match="privileged"): + runtime.observe_for_baseline_capture(REPOSITORY, project, receipt_key=KEY) + + +@pytest.mark.parametrize( + "model_key,model_value,actual_entry", + [ + (None, None, "UNDECLARED_RUNTIME_VALUE=unexpected"), + ("APPOPS_SOURCE_REVISION", "a" * 40, "APPOPS_SOURCE_REVISION=" + "b" * 40), + ], +) +def test_capture_observer_rejects_undeclared_and_provenance_environment_drift( + production_runtime, module, model_key, model_value, actual_entry +): + runtime, boundary, project, model = production_runtime + model["services"]["quiz-server"]["environment"]["KNOWLEDGE_OPTIMIZER_MODEL"] = "gpt-5.5" + boundary.live["Config"]["Env"].append("KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna") + boundary.live["Config"]["Env"].append(actual_entry) + if model_key: + model["services"]["quiz-server"]["environment"][model_key] = model_value + + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe_for_baseline_capture(REPOSITORY, project, receipt_key=KEY) + assert boundary.compose_ups == 0 assert not any( command[:3] == ["docker", "image", "tag"] for command in boundary.commands @@ -574,7 +688,8 @@ def _validation_api(path, *, query): raise AssertionError(f"unexpected validation request: {path} {query}") -def _adopt_with_production_runtime(module, boundary, project, state_root, monkeypatch): +def _adopt_with_production_runtime(module, boundary, project, state_root, monkeypatch, + *, capture_runtime_environment=False): original_validate = module.validate_registered_legacy_target def validate_temp_target(repository, registered_project): @@ -600,8 +715,23 @@ def _adopt_with_production_runtime(module, boundary, project, state_root, monkey runtime = module._ProductionRuntime(state_root) registry = {REPOSITORY: project} + baseline_payload = {"repository": REPOSITORY, "requestId": REQUEST_ID} + if capture_runtime_environment: + observed = runtime.observe_for_baseline_capture( + REPOSITORY, project, receipt_key=KEY, + ) + baseline_payload.update({ + "captureRuntimeEnvironment": True, + "expectedSourceFingerprint": observed["sourceFingerprint"], + "expectedRuntimeFingerprint": observed["runtimeFingerprint"], + "expectedStateSha256": module._state_digest(state_bytes), + "expectedContainerIds": { + name: artifact["container_id"] + for name, artifact in observed["artifacts"]["containers"].items() + }, + }) baseline = module.baseline( - {"repository": REPOSITORY, "requestId": REQUEST_ID}, + baseline_payload, registry=registry, read_state=read_state, runtime=runtime, @@ -675,6 +805,75 @@ def adopted_production_runtime(production_runtime, tmp_path, monkeypatch): ) +def test_captured_environment_survives_signed_baseline_into_candidate_overlay( + production_runtime, tmp_path, monkeypatch, module +): + _runtime, boundary, project, model = production_runtime + model["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] = "gpt-5.5" + boundary.live["Config"]["Env"].append( + "KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna" + ) + runtime, _registry, _read_state, _state_bytes, _snapshot = ( + _adopt_with_production_runtime( + module, boundary, project, tmp_path, monkeypatch, + capture_runtime_environment=True, + ) + ) + + bound_project = _prepare_candidate_overlay(module, boundary, project, tmp_path) + overlay_path = module.validated_compose_overlay( + bound_project, receipt_key=KEY, state_root=tmp_path, repository=REPOSITORY, + ) + assert overlay_path is not None + overlay = json.loads(overlay_path.read_text(encoding="utf-8")) + assert overlay["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] == "gpt-6-luna" + assert runtime.observe_for_baseline_capture( + REPOSITORY, project, receipt_key=KEY, + )["environmentMismatchKeys"] == [ + "quiz-server/KNOWLEDGE_OPTIMIZER_MODEL", + ] + with pytest.raises(module.RegisteredLegacyAdoptionError): + runtime.observe(REPOSITORY, project, receipt_key=KEY) + + +def test_captured_environment_is_restored_from_signed_old_baseline( + production_runtime, tmp_path, monkeypatch, module +): + _runtime, boundary, project, model = production_runtime + model["services"]["quiz-server"]["environment"][ + "KNOWLEDGE_OPTIMIZER_MODEL" + ] = "gpt-5.5" + boundary.live["Config"]["Env"].append( + "KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna" + ) + runtime, registry, _read_state, state_bytes, snapshot = ( + _adopt_with_production_runtime( + module, boundary, project, tmp_path, monkeypatch, + capture_runtime_environment=True, + ) + ) + _candidate_deployment( + module, runtime, boundary, project, registry, state_bytes, tmp_path, + use_signed_overlay=True, + ) + assert boundary.live["Image"] == CANDIDATE_IMAGE + + restored = module.restore_deployment_snapshot( + REPOSITORY, project, snapshot, TARGET_SHA, + receipt_key=KEY, state_root=tmp_path, runtime=runtime, + ) + + assert restored["imageIDs"] == {CONTAINER_NAME: BASELINE_IMAGE} + assert boundary.compose_ups == 1 + assert boundary.live["Image"] == BASELINE_IMAGE + assert "KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna" in boundary.live["Config"]["Env"] + runtime.observe(REPOSITORY, project, receipt_key=KEY) + + def _prepare_candidate_overlay(module, boundary, project, tmp_path): bound_project = dict(project) bound_project.update( @@ -714,8 +913,17 @@ def _candidate_deployment( *, receipt_container_id=None, receipt_image_id=None, + use_signed_overlay=False, ): bound_project = _prepare_candidate_overlay(module, boundary, project, tmp_path) + if use_signed_overlay: + overlay_path = module.validated_compose_overlay( + bound_project, receipt_key=KEY, state_root=tmp_path, repository=REPOSITORY, + ) + assert overlay_path is not None + boundary.model = _compose_config_resolve_escaped( + json.loads(overlay_path.read_text(encoding="utf-8")) + ) sealed = module.prepare_candidate_deployment( REPOSITORY, bound_project, @@ -733,6 +941,7 @@ def _candidate_deployment( receipt_key=KEY, state_root=tmp_path, runtime=runtime, + api_json=_validation_api, ) boundary.live = _container( image_id=CANDIDATE_IMAGE, @@ -741,6 +950,8 @@ def _candidate_deployment( config_hash="candidate-config-hash", ) boundary.live["Config"]["Image"] = "quiz-monitor:latest" + if use_signed_overlay: + boundary.live["Config"]["Env"].append("KNOWLEDGE_OPTIMIZER_MODEL=gpt-6-luna") module.record_candidate_runtime( REPOSITORY, bound_project, @@ -803,6 +1014,17 @@ def _candidate_deployment( return live, controller_state +def _compose_config_resolve_escaped(value): + """Model Compose config resolving dollars escaped in its saved input file.""" + if isinstance(value, str): + return value.replace("$$", "$") + if isinstance(value, list): + return [_compose_config_resolve_escaped(item) for item in value] + if isinstance(value, dict): + return {key: _compose_config_resolve_escaped(item) for key, item in value.items()} + return value + + def test_candidate_and_live_runtime_are_bound_to_exact_production_observation( adopted_production_runtime, tmp_path ): @@ -956,6 +1178,50 @@ def test_candidate_sealing_rejects_runtime_drift_after_build_before_mutation( assert boundary.compose_ups == 0 +def test_candidate_runtime_rejection_does_not_leak_environment_values( + adopted_production_runtime, monkeypatch, tmp_path, module, capsys +): + ( + runtime, + _boundary, + project, + model, + _registry, + _read_state, + _state_bytes, + _snapshot, + ) = adopted_production_runtime + sentinel = "fixture-secret-runtime-value-7c8f2f" + candidate_model = copy.deepcopy(model) + candidate_model["services"]["quiz-server"]["environment"]["QUIZ_TEMPLATE"] = sentinel + + def prepare_candidate(*_args, **_kwargs): + return { + "composeModel": candidate_model, + "imageIDsByService": {"quiz-server": CANDIDATE_IMAGE}, + } + + monkeypatch.setattr(runtime, "prepare_candidate", prepare_candidate) + with pytest.raises(module.RegisteredLegacyAdoptionError) as caught: + # Use the public operation so logging/exception behavior is tested at + # the boundary where candidate runtime drift is rejected. + module.prepare_candidate_deployment( + REPOSITORY, + project, + REQUEST_ID, + TARGET_SHA, + receipt_key=KEY, + state_root=tmp_path, + runtime=runtime, + ) + + assert "captured runtime configuration" in str(caught.value) + assert sentinel not in str(caught.value) + output = capsys.readouterr() + assert sentinel not in output.out + assert sentinel not in output.err + + def test_pre_mutation_cas_rejects_runtime_drift_after_candidate_is_sealed( adopted_production_runtime, tmp_path ): @@ -992,6 +1258,7 @@ def test_pre_mutation_cas_rejects_runtime_drift_after_candidate_is_sealed( receipt_key=KEY, state_root=tmp_path, runtime=runtime, + api_json=_validation_api, ) assert boundary.compose_ups == 0 diff --git a/backend/tests/test_repopulse_controller_adoption_store.py b/backend/tests/test_repopulse_controller_adoption_store.py index 3777935..7032537 100644 --- a/backend/tests/test_repopulse_controller_adoption_store.py +++ b/backend/tests/test_repopulse_controller_adoption_store.py @@ -42,13 +42,19 @@ def _sha(value: str) -> str: return hashlib.sha256(value.encode("ascii")).hexdigest() -def _second_process_lock(path_text, started, locked): +def _second_process_lock(path_text, ready, attempt, attempting, acquired): import fcntl - started.set() + # Separate process startup from the lock attempt. In particular, the + # parent must not hold the project lock while waiting for multiprocessing + # to import this test module (which can be slow under forkserver on /mnt/c). + ready.set() + if not attempt.wait(15): + return + attempting.set() with open(path_text, "a+") as handle: fcntl.flock(handle, fcntl.LOCK_EX) - locked.set() + acquired.set() fcntl.flock(handle, fcntl.LOCK_UN) @@ -807,20 +813,35 @@ def test_transaction_rejects_other_thread_store_calls_and_transactions(store_env def test_project_flock_blocks_a_second_process(store_env): module, store, _controller, _state = store_env ready = multiprocessing.Event() + attempt = multiprocessing.Event() + attempting = multiprocessing.Event() acquired = multiprocessing.Event() - with store.transaction(): - assert store.load_intent() is None - child = multiprocessing.Process( - target=_second_process_lock, - args=(str(module.PROJECT_LOCK), ready, acquired), - ) - child.start() - assert ready.wait(2) - assert not acquired.wait(0.1) - assert acquired.wait(2) - child.join(timeout=2) - assert child.exitcode == 0 + child = multiprocessing.Process( + target=_second_process_lock, + args=(str(module.PROJECT_LOCK), ready, attempt, attempting, acquired), + ) + child.start() + try: + # Give process creation/import a bounded interval while no lock is held. + assert ready.wait(15), f"lock contender did not start (exit={child.exitcode})" + with store.transaction(): + assert store.load_intent() is None + attempt.set() + assert attempting.wait(5), ( + f"lock contender did not attempt acquisition (exit={child.exitcode})" + ) + assert not acquired.wait(0.1) + assert acquired.wait(5) + child.join(timeout=5) + assert child.exitcode == 0 + finally: + # Let a child blocked on the handshake exit on assertion failures too. + attempt.set() + child.join(timeout=5) + if child.is_alive(): + child.terminate() + child.join(timeout=5) def _registered_recovery(store, restore, *, preserve=True, reason="target_health_failed"):