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