Publish curated Unraid-AppOps-Gateway source from 84f2a9958b2bdf1c1c13553925026d75f32703c3
This commit is contained in:
1 parent
8dd4b40252
commit
5d598db0df
14 files changed
+2759
-40
No files matched your search
@@ -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.
|
||||
+40
-15
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
args=(str(module.PROJECT_LOCK), ready, attempt, attempting, acquired),
|
||||
)
|
||||
child.start()
|
||||
assert ready.wait(2)
|
||||
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(2)
|
||||
child.join(timeout=2)
|
||||
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"):
|
||||
|
||||
Reference in new issue
Block a user