846 lines
35 KiB
Python
846 lines
35 KiB
Python
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
|