Files
Unraid-AppOps-Gateway-Public/backend/tests/test_registered_legacy_adoption.py
T

390 lines
17 KiB
Python

from __future__ import annotations
import copy
import hashlib
import importlib.util
import json
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"
TARGET_SHA = "a" * 40
KEY = b"k" * 32
def _load_module():
spec = importlib.util.spec_from_file_location("registered_legacy_adoption_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():
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,
}
@pytest.fixture
def baseline_inputs():
state_bytes = b'{"schema_version":3,"live_revision":"0c9c1747","live_status":"unknown"}\n'
image_id = "sha256:" + "a" * 64
observation = {
"sourceFingerprint": "1" * 64,
"runtimeFingerprint": "2" * 64,
"imageIDs": {"quiz-monitor-quiz-server-1": image_id},
"runtimeConfiguration": {
"environment": {"OPENRGB_HOST": "198.19.1.2", "OPENRGB_TOKEN": "private-test-value"},
"composeModel": {"services": {"quiz-server": {"ports": ["1205:8000"]}}},
},
"artifacts": {
"containers": {
"quiz-monitor-quiz-server-1": {
"image_id": image_id,
"container_id": "b" * 64,
"image_ref": "quiz-monitor:latest",
"service": "quiz-server",
"running": True,
}
}
},
}
return state_bytes, observation
class _Runtime:
def __init__(self, observation):
self.observation = observation
self.restored = []
self.snapshots = []
def observe(self, repository, target, *, receipt_key):
assert repository == REPOSITORY
assert target["slug"] == "quiz-monitor"
assert receipt_key == KEY
return copy.deepcopy(self.observation)
def snapshot(self, repository, target, observation, request_id, *, receipt_key):
value = {
"revision": "unknown",
"legacy_provenance": True,
"containers": [
{
"name": "quiz-monitor-quiz-server-1",
"image_id": observation["imageIDs"]["quiz-monitor-quiz-server-1"],
"image_ref": "quiz-monitor:latest",
"rollback_ref": "gitea-deploy-rollback/quiz-monitor-0:legacy-6f9619ff",
"was_running": True,
}
],
}
self.snapshots.append(value)
return copy.deepcopy(value)
def restore(self, repository, target, snapshot, current_revision, *, receipt_key):
self.restored.append((repository, target["slug"], snapshot, current_revision))
return copy.deepcopy(self.observation)
@pytest.fixture
def api():
def api_json(path, *, query):
if path == f"/repos/{REPOSITORY}/branches/main":
return {"protected": True, "commit": {"id": TARGET_SHA}}
if path == f"/repos/{REPOSITORY}/actions/runs":
assert query == {
"event": "push", "branch": "main", "head_sha": TARGET_SHA,
"page": "1", "limit": "100",
}
return {"total_count": 1, "workflow_runs": [{
"id": 7, "run_number": 19, "run_attempt": 1,
"event": "push", "head_sha": TARGET_SHA, "head_branch": "main",
"path": "managed-validation.yml@refs/heads/main",
"display_title": "Managed validation / full (push)",
"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/7/jobs":
assert query == {"page": "1", "limit": "100"}
return {"total_count": 1, "jobs": [{
"id": 9, "name": "full", "run_id": 7, "run_attempt": 1,
"head_sha": TARGET_SHA, "head_branch": "main",
"status": "completed", "conclusion": "success",
}]}
raise AssertionError(f"unexpected Gitea read: {path}")
return api_json
def _read_state(state_bytes):
state = json.loads(state_bytes.decode("utf-8")) if state_bytes else {}
return lambda _repository, _project: (state, state_bytes)
def _baseline(module, project, state_bytes, observation, tmp_path):
(tmp_path / "quiz-monitor.json").write_bytes(state_bytes)
return module.baseline(
{"repository": REPOSITORY, "requestId": REQUEST_ID},
registry={REPOSITORY: project},
read_state=_read_state(state_bytes),
runtime=_Runtime(observation), receipt_key=KEY, state_root=tmp_path,
)
def test_registered_target_policy_accepts_bounded_main_compose_and_rejects_special_targets(
module, project
):
assert module.validate_registered_legacy_target(REPOSITORY, project) == "quiz-monitor"
for repository, mutate in [
("jens/unraid-appops-gateway", lambda candidate: None),
("jens/workmesh", lambda candidate: None),
(REPOSITORY, lambda candidate: candidate.update(managed_plan_version="itworx.deployment/v1")),
(REPOSITORY, lambda candidate: candidate.update(deploy_script="scripts/deploy.sh")),
(REPOSITORY, lambda candidate: candidate.update(containers=[f"quiz-monitor-svc-{n}" for n in range(9)])),
(REPOSITORY, lambda candidate: candidate.update(compose_files=["../other/docker-compose.yml"])),
]:
candidate = copy.deepcopy(project)
mutate(candidate)
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.validate_registered_legacy_target(repository, candidate)
def test_rendered_compose_must_match_observed_environment_mounts_and_published_ports(module):
model = {"services": {"quiz-server": {
"environment": {"OPENRGB_HOST": "198.19.1.2"},
"volumes": [{"type": "bind", "source": "/srv/example/appdata/quiz-monitor/config",
"target": "/config", "read_only": False}],
"ports": [{"target": 8000, "published": "1205", "protocol": "tcp",
"host_ip": "0.0.0.0"}],
}}, "networks": {"default": {"name": "quiz-monitor_default"}}}
container = {"quiz-monitor-quiz-server-1": {
"Config": {"Env": ["OPENRGB_HOST=198.19.1.2"]},
"Mounts": [{"Type": "bind", "Source": "/srv/example/appdata/quiz-monitor/config",
"Destination": "/config", "RW": True}],
"HostConfig": {"NetworkMode": "quiz-monitor_default"},
"NetworkSettings": {"Networks": {"quiz-monitor_default": {}},
"Ports": {"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1205"}]}},
}}
module.validate_compose_runtime(model, container)
wrong = copy.deepcopy(container)
wrong["quiz-monitor-quiz-server-1"]["NetworkSettings"]["Ports"]["8000/tcp"][0]["HostPort"] = "1206"
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.validate_compose_runtime(model, wrong)
def test_rendered_compose_rejects_changed_command_security_and_named_volume_identity(module):
model = {"services": {"quiz-server": {
"image": "quiz-monitor:latest", "command": ["python", "server.py"],
"security_opt": ["no-new-privileges:true"],
"volumes": [{"type": "volume", "source": "quiz-config", "target": "/config"}],
}}, "volumes": {"quiz-config": {"name": "quiz-monitor-config"}},
"networks": {"default": {"name": "quiz-monitor_default"}}}
original = {"quiz-monitor-quiz-server-1": {
"Config": {"Cmd": ["python", "server.py"], "Labels": {"com.docker.compose.service": "quiz-server"}},
"HostConfig": {"SecurityOpt": ["no-new-privileges:true"], "NetworkMode": "quiz-monitor_default"},
"Mounts": [{"Type": "volume", "Name": "quiz-monitor-config", "Destination": "/config", "RW": True}],
"NetworkSettings": {"Networks": {"quiz-monitor_default": {}}, "Ports": {}},
}}
module.validate_compose_runtime(model, original, compose_project="quiz-monitor")
changed = copy.deepcopy(original)
changed["quiz-monitor-quiz-server-1"]["Config"]["Cmd"] = ["sh", "-c", "unexpected"]
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.validate_compose_runtime(model, changed, compose_project="quiz-monitor")
changed = copy.deepcopy(original)
changed["quiz-monitor-quiz-server-1"]["HostConfig"]["SecurityOpt"] = []
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.validate_compose_runtime(model, changed, compose_project="quiz-monitor")
changed = copy.deepcopy(original)
changed["quiz-monitor-quiz-server-1"]["Mounts"][0]["Name"] = "unexpected-volume"
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.validate_compose_runtime(model, changed, compose_project="quiz-monitor")
def test_baseline_binds_exact_runtime_state_without_revealing_stale_sha_or_secrets(
module, project, baseline_inputs, tmp_path
):
state_bytes, observation = baseline_inputs
result = _baseline(module, project, state_bytes, observation, tmp_path)
assert result["previousRevision"] == "unknown"
assert result["expectedStateSha256"] == hashlib.sha256(state_bytes).hexdigest()
assert result["runtimeFingerprint"] == "2" * 64
assert result["imageIDs"] == observation["imageIDs"]
assert "0c9c1747" not in json.dumps(result)
assert "private-test-value" not in json.dumps(result)
root = tmp_path / "registered-legacy-adoption" / "quiz-monitor"
assert (root / f"{REQUEST_ID}.baseline.json").is_file()
private = root / f"{REQUEST_ID}.private.json"
assert private.is_file()
assert "private-test-value" not in (root / f"{REQUEST_ID}.baseline.json").read_text()
def test_preflight_requires_latest_exact_main_push_and_registered_full_job(
module, project, baseline_inputs, api, tmp_path
):
state_bytes, observation = baseline_inputs
baseline = _baseline(module, project, state_bytes, observation, tmp_path)
result = module.preflight(
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
"expectedStateSha256": baseline["expectedStateSha256"]},
registry={REPOSITORY: project}, read_state=_read_state(state_bytes),
runtime=_Runtime(observation), api_json=api, receipt_key=KEY, state_root=tmp_path,
)
assert result["status"] == "preflight-ok"
assert result["revision"] == TARGET_SHA
assert result["validationEvidenceSha256"]
assert result["preflightSha256"]
@pytest.mark.parametrize("invalid", ["pr_event", "wrong_sha", "failed_latest", "missing_full_job", "unprotected"])
def test_preflight_rejects_noncanonical_or_failed_latest_validation(
module, project, baseline_inputs, api, tmp_path, invalid
):
state_bytes, observation = baseline_inputs
baseline = _baseline(module, project, state_bytes, observation, tmp_path)
def bad_api(path, *, query):
value = api(path, query=query)
if path.endswith("/actions/runs"):
run = value["workflow_runs"][0]
if invalid == "pr_event":
run["event"] = "pull_request"
elif invalid == "wrong_sha":
run["head_sha"] = "b" * 40
elif invalid == "failed_latest":
run["run_attempt"] = 2
run["conclusion"] = "failure"
elif path.endswith("/branches/main") and invalid == "unprotected":
value["protected"] = False
elif path.endswith("/jobs") and invalid == "missing_full_job":
value["jobs"][0]["name"] = "backend"
return value
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.preflight(
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
"expectedStateSha256": baseline["expectedStateSha256"]},
registry={REPOSITORY: project}, read_state=_read_state(state_bytes),
runtime=_Runtime(observation), api_json=bad_api, receipt_key=KEY, state_root=tmp_path,
)
def test_adoption_rechecks_gate_and_cas_then_saves_unknown_rollback_proof(
module, project, baseline_inputs, api, tmp_path
):
state_bytes, observation = baseline_inputs
(tmp_path / "quiz-monitor.json").write_bytes(state_bytes)
runtime = _Runtime(observation)
registry = {REPOSITORY: project}
read_state = _read_state(state_bytes)
baseline = module.baseline(
{"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry,
read_state=read_state, runtime=runtime, receipt_key=KEY, state_root=tmp_path,
)
preflight = module.preflight(
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
"expectedStateSha256": baseline["expectedStateSha256"]},
registry=registry, read_state=read_state, runtime=runtime, api_json=api,
receipt_key=KEY, state_root=tmp_path,
)
# Adoption independently rereads main and validation rather than trusting the saved preflight.
calls = []
def rechecked_api(path, *, query):
calls.append(path)
return api(path, query=query)
result = module.adopt(
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
"expectedStateSha256": baseline["expectedStateSha256"],
"preflightSha256": preflight["preflightSha256"]},
registry=registry, read_state=read_state, runtime=runtime, api_json=rechecked_api,
receipt_key=KEY, state_root=tmp_path,
)
assert result["status"] == "adopted"
assert result["previousRevision"] == "unknown"
assert any(path.endswith("/actions/runs") for path in calls)
receipt = tmp_path / "registered-legacy-adoption" / "quiz-monitor" / f"{REQUEST_ID}.receipt.json"
assert receipt.is_file()
assert "private-test-value" not in receipt.read_text()
snapshot = module.capture_deployment_snapshot(
REPOSITORY, project, json.loads(state_bytes), request_id=REQUEST_ID,
target_revision=TARGET_SHA, receipt_key=KEY, state_root=tmp_path, runtime=runtime,
)
assert snapshot["revision"] == "unknown"
assert snapshot["legacy_provenance"] is True
assert snapshot["containers"][0]["image_id"] == observation["imageIDs"]["quiz-monitor-quiz-server-1"]
assert snapshot["registered_legacy"]["snapshotSha256"]
assert module.status(
{"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry,
read_state=read_state, receipt_key=KEY, state_root=tmp_path,
)["status"] == "adopted"
def test_adoption_rejects_a_changed_controller_state_before_snapshot(
module, project, baseline_inputs, api, tmp_path
):
state_bytes, observation = baseline_inputs
current_bytes = [state_bytes]
def read_state(_repository, _project):
return json.loads(current_bytes[0].decode("utf-8")), current_bytes[0]
runtime = _Runtime(observation)
registry = {REPOSITORY: project}
baseline = module.baseline(
{"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry,
read_state=read_state, runtime=runtime, receipt_key=KEY, state_root=tmp_path,
)
preflight = module.preflight(
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
"expectedStateSha256": baseline["expectedStateSha256"]},
registry=registry, read_state=read_state, runtime=runtime, api_json=api,
receipt_key=KEY, state_root=tmp_path,
)
current_bytes[0] += b" "
with pytest.raises(module.RegisteredLegacyAdoptionError):
module.adopt(
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
"expectedStateSha256": baseline["expectedStateSha256"],
"preflightSha256": preflight["preflightSha256"]},
registry=registry, read_state=read_state, runtime=runtime, api_json=api,
receipt_key=KEY, state_root=tmp_path,
)
assert not (tmp_path / "registered-legacy-adoption" / "quiz-monitor" / f"{REQUEST_ID}.receipt.json").exists()
def test_status_is_nonblocking_and_worker_failure_is_durable_and_terminal_safe(
module, project, baseline_inputs, tmp_path
):
state_bytes, observation = baseline_inputs
_baseline(module, project, state_bytes, observation, tmp_path)
pending = module.record_worker_failure(
REPOSITORY, REQUEST_ID, "worker_start_failed", receipt_key=KEY, state_root=tmp_path,
)
assert pending["status"] == "recovery-required"
assert module.record_worker_failure(
REPOSITORY, REQUEST_ID, "second_failure", receipt_key=KEY, state_root=tmp_path,
)["status"] == "recovery-required"
assert module.status(
{"repository": REPOSITORY, "requestId": REQUEST_ID},
registry={REPOSITORY: project}, read_state=_read_state(state_bytes),
receipt_key=KEY, state_root=tmp_path,
)["status"] == "recovery-required"