Publish curated Unraid-AppOps-Gateway source from a480f280f26f4aa48c61394fa841d2233067c208
This commit is contained in:
1 parent
e73772cdf2
commit
820244dbb3
13 files changed
+2141
-26
No files matched your search
@@ -469,7 +469,7 @@ def test_appops_legacy_intent_is_root_private_and_compare_and_swap_bound(
|
||||
"legacyTree": deploy_module.APPOPS_LEGACY_OLD_TREE,
|
||||
"databaseTree": "1" * 40, "entrypointBlob": "2" * 40,
|
||||
"candidateRevision": revision, "composeBlob": "3" * 40,
|
||||
"dockerfileBlob": "4" * 40,
|
||||
"dockerfileBlob": "73e083dcfef25402665ace5a0bf07a083cf37432",
|
||||
}
|
||||
protected_main = {
|
||||
"semanticSha256": "5" * 64, "revision": revision,
|
||||
@@ -677,7 +677,7 @@ def _appops_rollback_intent_fixture(deploy_module, revision: str, capture_sha256
|
||||
"legacyRevision": old_revision, "legacyTree": deploy_module.APPOPS_LEGACY_OLD_TREE,
|
||||
"databaseTree": "1" * 40, "entrypointBlob": "2" * 40,
|
||||
"candidateRevision": revision, "composeBlob": "3" * 40,
|
||||
"dockerfileBlob": "4" * 40,
|
||||
"dockerfileBlob": "73e083dcfef25402665ace5a0bf07a083cf37432",
|
||||
}
|
||||
protected_main = {
|
||||
"semanticSha256": "5" * 64, "revision": revision,
|
||||
@@ -730,6 +730,40 @@ def _appops_rollback_intent_fixture(deploy_module, revision: str, capture_sha256
|
||||
return repository, metadata, source_proof, protected_main, backup, intent
|
||||
|
||||
|
||||
def test_appops_legacy_intent_accepts_only_the_reviewed_dockerfile_blob_pair(
|
||||
deploy_module
|
||||
):
|
||||
_repository, _metadata, _source, _main, _backup, intent = (
|
||||
_appops_rollback_intent_fixture(deploy_module, "c" * 40, "a" * 64)
|
||||
)
|
||||
deploy_module._appops_validate_intent_payload(intent)
|
||||
|
||||
reviewed_update = copy.deepcopy(intent)
|
||||
reviewed_source = reviewed_update["migrationProof"]["source"]
|
||||
reviewed_source["dockerfileBlob"] = "aae97717280b8495a93bc277e290eeb515b76862"
|
||||
reviewed_evidence = reviewed_update["rollbackSnapshot"]["appops_legacy"]
|
||||
reviewed_evidence["sourceProofSha256"] = deploy_module._canonical_json_sha256(
|
||||
reviewed_source
|
||||
)
|
||||
reviewed_evidence["migrationProofSha256"] = deploy_module._canonical_json_sha256(
|
||||
reviewed_update["migrationProof"]
|
||||
)
|
||||
deploy_module._appops_validate_intent_payload(reviewed_update)
|
||||
|
||||
unknown_update = copy.deepcopy(reviewed_update)
|
||||
unknown_source = unknown_update["migrationProof"]["source"]
|
||||
unknown_source["dockerfileBlob"] = "b" * 40
|
||||
unknown_evidence = unknown_update["rollbackSnapshot"]["appops_legacy"]
|
||||
unknown_evidence["sourceProofSha256"] = deploy_module._canonical_json_sha256(
|
||||
unknown_source
|
||||
)
|
||||
unknown_evidence["migrationProofSha256"] = deploy_module._canonical_json_sha256(
|
||||
unknown_update["migrationProof"]
|
||||
)
|
||||
with pytest.raises(deploy_module.DeployError, match="source proof is invalid"):
|
||||
deploy_module._appops_validate_intent_payload(unknown_update)
|
||||
|
||||
|
||||
def test_appops_cli_rollback_retries_after_state_write_crash_without_synthetic_receipt(
|
||||
deploy_module, monkeypatch, tmp_path, capsys
|
||||
):
|
||||
@@ -1333,7 +1367,7 @@ def test_appops_durable_rollback_requires_completed_intent_and_verified_backup(
|
||||
"legacyTree": deploy_module.APPOPS_LEGACY_OLD_TREE,
|
||||
"databaseTree": "1" * 40, "entrypointBlob": "2" * 40,
|
||||
"candidateRevision": revision, "composeBlob": "3" * 40,
|
||||
"dockerfileBlob": "4" * 40,
|
||||
"dockerfileBlob": "73e083dcfef25402665ace5a0bf07a083cf37432",
|
||||
}
|
||||
protected_main = {
|
||||
"semanticSha256": "5" * 64, "revision": revision,
|
||||
@@ -1503,6 +1537,297 @@ def test_appops_state_write_rejects_compare_and_swap_drift(deploy_module, monkey
|
||||
assert path.read_bytes() == original
|
||||
|
||||
|
||||
def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
|
||||
"""Build the reviewed DB and Dockerfile transitions without old Git history."""
|
||||
repository = tmp_path / "reviewed-source.git"
|
||||
working = tmp_path / "reviewed-source-worktree"
|
||||
working.mkdir()
|
||||
subprocess.run(["git", "init", "--bare", str(repository)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(working)], check=True, capture_output=True)
|
||||
for key, value in (
|
||||
("user.name", "AppOps fixture"),
|
||||
("user.email", "appops-fixture@example.invalid"),
|
||||
("core.autocrlf", "false"),
|
||||
("core.filemode", "true"),
|
||||
):
|
||||
subprocess.run(["git", "-C", str(working), "config", key, value], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(working), "remote", "add", "origin", str(repository)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
db = working / "backend" / "app" / "db"
|
||||
db.mkdir(parents=True)
|
||||
current_db = ROOT / "backend" / "app" / "db"
|
||||
|
||||
def normalize(path):
|
||||
return path.read_bytes().replace(b"\r\n", b"\n")
|
||||
|
||||
candidate_dockerfile = normalize(ROOT / "Dockerfile")
|
||||
lock_setup = (
|
||||
b"RUN set -eu; \\\n"
|
||||
b" lock_parent=/run/itworx-appops; \\\n"
|
||||
b" lock_root=\"$lock_parent/forge-oidc-provision\"; \\\n"
|
||||
)
|
||||
requirements_copy = b"COPY backend/requirements.txt /tmp/requirements.txt\n"
|
||||
assert candidate_dockerfile.count(lock_setup) == 1
|
||||
lock_start = candidate_dockerfile.index(lock_setup)
|
||||
copy_start = candidate_dockerfile.index(requirements_copy, lock_start)
|
||||
legacy_dockerfile = candidate_dockerfile[:lock_start] + candidate_dockerfile[copy_start:]
|
||||
assert hashlib.sha1(
|
||||
f"blob {len(legacy_dockerfile)}\0".encode() + legacy_dockerfile
|
||||
).hexdigest() == "73e083dcfef25402665ace5a0bf07a083cf37432"
|
||||
assert hashlib.sha1(
|
||||
f"blob {len(candidate_dockerfile)}\0".encode() + candidate_dockerfile
|
||||
).hexdigest() == "aae97717280b8495a93bc277e290eeb515b76862"
|
||||
|
||||
current_init = normalize(current_db / "init_db.py")
|
||||
settings_marker = b"settings = Settings()\n"
|
||||
assert current_init.count(settings_marker) == 1
|
||||
body = current_init.split(settings_marker, 1)[1]
|
||||
old_header = (
|
||||
b"from datetime import UTC, datetime\n\n"
|
||||
b"from sqlalchemy import inspect, text\n"
|
||||
b"from sqlmodel import SQLModel, select\n"
|
||||
b"from . import session as db_session\n"
|
||||
b"from .session import get_session\n"
|
||||
b"from ..core.config import Settings\n"
|
||||
b"from ..services.app_classifier import classify_container\n"
|
||||
b"from ..services.app_registry import category_label\n"
|
||||
b"from .models import App, AppContainerMapping, Connector, DiscoveredContainer, "
|
||||
b"NotificationChannel, NotificationEvent, NotificationRule, Policy, WorkMeshPromotion\n\n\n"
|
||||
)
|
||||
old_init = old_header + settings_marker + body
|
||||
forge_seed_rows = (
|
||||
b' Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."),\n',
|
||||
b' Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."),\n',
|
||||
)
|
||||
for row in forge_seed_rows:
|
||||
assert current_init.count(row) == 1
|
||||
old_init = old_init.replace(row, b"", 1)
|
||||
|
||||
(db / "init_db.py").write_bytes(old_init)
|
||||
for filename in ("models.py", "session.py"):
|
||||
(db / filename).write_bytes(normalize(current_db / filename))
|
||||
entrypoint = working / "docker" / "entrypoint.sh"
|
||||
entrypoint.parent.mkdir(parents=True)
|
||||
entrypoint.write_bytes(normalize(ROOT / "docker" / "entrypoint.sh"))
|
||||
entrypoint.chmod(0o644)
|
||||
(working / "docker-compose.yml").write_bytes(normalize(ROOT / "docker-compose.yml"))
|
||||
(working / "Dockerfile").write_bytes(legacy_dockerfile)
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(working), *args],
|
||||
check=True, capture_output=True, text=True,
|
||||
).stdout.strip()
|
||||
|
||||
git("add", "--all")
|
||||
git("commit", "-m", "fixed legacy AppOps source")
|
||||
old_revision = git("rev-parse", "HEAD")
|
||||
git("push", "origin", "HEAD:refs/heads/main")
|
||||
old_tree = git("rev-parse", f"{old_revision}^{{tree}}")
|
||||
old_database_tree = git("rev-parse", f"{old_revision}:backend/app/db")
|
||||
|
||||
(db / "init_db.py").write_bytes(current_init)
|
||||
if candidate_mutation == "extra_database_file":
|
||||
(db / "unreviewed.py").write_text("UNREVIEWED = True\n", encoding="utf-8")
|
||||
elif candidate_mutation == "models_content":
|
||||
(db / "models.py").write_bytes(normalize(current_db / "models.py") + b"\n# unreviewed\n")
|
||||
elif candidate_mutation == "session_content":
|
||||
(db / "session.py").write_bytes(normalize(current_db / "session.py") + b"\n# unreviewed\n")
|
||||
elif candidate_mutation == "init_db_content":
|
||||
(db / "init_db.py").write_bytes(current_init + b"\n# unreviewed database behavior\n")
|
||||
elif candidate_mutation == "forge_policy_changed":
|
||||
changed_init = current_init.replace(
|
||||
b'action="forge.oidc.provision"',
|
||||
b'action="forge.oidc.provision-unreviewed"',
|
||||
1,
|
||||
)
|
||||
assert changed_init != current_init
|
||||
(db / "init_db.py").write_bytes(changed_init)
|
||||
elif candidate_mutation == "database_file_mode":
|
||||
(db / "session.py").chmod(0o755)
|
||||
elif candidate_mutation == "entrypoint_content":
|
||||
entrypoint.write_bytes(entrypoint.read_bytes() + b"# unreviewed entrypoint\n")
|
||||
elif candidate_mutation not in {
|
||||
None, "dockerfile_one_byte", "dockerfile_legacy_unchanged",
|
||||
}:
|
||||
raise AssertionError("unknown fixture mutation")
|
||||
|
||||
if candidate_mutation == "dockerfile_legacy_unchanged":
|
||||
(working / "Dockerfile").write_bytes(legacy_dockerfile)
|
||||
else:
|
||||
candidate_dockerfile = normalize(ROOT / "Dockerfile")
|
||||
if candidate_mutation == "dockerfile_one_byte":
|
||||
original_mode = b'mkdir -m 0700 "$lock_root"'
|
||||
changed_mode = b'mkdir -m 0701 "$lock_root"'
|
||||
assert candidate_dockerfile.count(original_mode) == 1
|
||||
candidate_dockerfile = candidate_dockerfile.replace(
|
||||
original_mode, changed_mode, 1
|
||||
)
|
||||
assert len(candidate_dockerfile) == len(normalize(ROOT / "Dockerfile"))
|
||||
assert sum(left != right for left, right in zip(
|
||||
candidate_dockerfile, normalize(ROOT / "Dockerfile")
|
||||
)) == 1
|
||||
(working / "Dockerfile").write_bytes(candidate_dockerfile)
|
||||
|
||||
git("add", "--all")
|
||||
git("commit", "-m", "candidate source")
|
||||
candidate_revision = git("rev-parse", "HEAD")
|
||||
git("push", "origin", "HEAD:refs/heads/main")
|
||||
candidate_tree = git("rev-parse", f"{candidate_revision}^{{tree}}")
|
||||
candidate_database_tree = git("rev-parse", f"{candidate_revision}:backend/app/db")
|
||||
candidate_entrypoint = git("rev-parse", f"{candidate_revision}:docker/entrypoint.sh")
|
||||
old_dockerfile_blob = git("rev-parse", f"{old_revision}:Dockerfile")
|
||||
candidate_dockerfile_blob = git("rev-parse", f"{candidate_revision}:Dockerfile")
|
||||
return {
|
||||
"repository": repository,
|
||||
"oldRevision": old_revision,
|
||||
"oldTree": old_tree,
|
||||
"oldDatabaseTree": old_database_tree,
|
||||
"candidateRevision": candidate_revision,
|
||||
"candidateTree": candidate_tree,
|
||||
"candidateDatabaseTree": candidate_database_tree,
|
||||
"candidateEntrypoint": candidate_entrypoint,
|
||||
"oldDockerfileBlob": old_dockerfile_blob,
|
||||
"candidateDockerfileBlob": candidate_dockerfile_blob,
|
||||
}
|
||||
|
||||
|
||||
def _reviewed_appops_source_project(deploy_module):
|
||||
return {
|
||||
"_repository_name": "jens/unraid-appops-gateway",
|
||||
"branch": "main",
|
||||
"gitea_repository_id": 9,
|
||||
"source_dir": "/srv/example/appdata/unraid-appops-gateway/source",
|
||||
"compose_project": "unraid-appops-gateway",
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"containers": ["Unraid-AppOps-Gateway"],
|
||||
"verify_oci_revision": True,
|
||||
"environment": {
|
||||
"APPOPS_DATA_PATH": deploy_module.APPOPS_LEGACY_DATA_PATH,
|
||||
"APPOPS_PUBLISH_HOST": deploy_module.APPOPS_LEGACY_PUBLISH_HOST,
|
||||
"APPOPS_PROXY_NETWORK": "itworx-mcp-hub-npm",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_appops_legacy_source_proof_accepts_the_exact_main_184_dockerfile_update(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(tmp_path)
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
proof = deploy_module.verify_appops_legacy_source(
|
||||
history["repository"],
|
||||
_reviewed_appops_source_project(deploy_module),
|
||||
history["candidateRevision"],
|
||||
)
|
||||
|
||||
assert proof == {
|
||||
"legacyRevision": history["oldRevision"],
|
||||
"legacyTree": history["oldTree"],
|
||||
"databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a",
|
||||
"entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8",
|
||||
"candidateRevision": history["candidateRevision"],
|
||||
"composeBlob": "243aee2c6924775bed7706373a5777f4e619e053",
|
||||
"dockerfileBlob": "aae97717280b8495a93bc277e290eeb515b76862",
|
||||
}
|
||||
|
||||
|
||||
def test_appops_legacy_source_proof_keeps_the_old_dockerfile_candidate_valid(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(
|
||||
tmp_path, candidate_mutation="dockerfile_legacy_unchanged"
|
||||
)
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
proof = deploy_module.verify_appops_legacy_source(
|
||||
history["repository"],
|
||||
_reviewed_appops_source_project(deploy_module),
|
||||
history["candidateRevision"],
|
||||
)
|
||||
|
||||
assert proof["dockerfileBlob"] == "73e083dcfef25402665ace5a0bf07a083cf37432"
|
||||
|
||||
|
||||
def test_appops_legacy_source_proof_rejects_a_one_byte_dockerfile_change(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(
|
||||
tmp_path, candidate_mutation="dockerfile_one_byte"
|
||||
)
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
with pytest.raises(deploy_module.DeployError, match="fixed legacy source"):
|
||||
deploy_module.verify_appops_legacy_source(
|
||||
history["repository"],
|
||||
_reviewed_appops_source_project(deploy_module),
|
||||
history["candidateRevision"],
|
||||
)
|
||||
|
||||
|
||||
def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(tmp_path)
|
||||
assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
|
||||
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
assert history["candidateEntrypoint"] == "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8"
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
proof = deploy_module.verify_appops_schema_compatibility(
|
||||
history["repository"], history["candidateRevision"]
|
||||
)
|
||||
|
||||
assert proof == {
|
||||
"legacyRevision": history["oldRevision"],
|
||||
"legacyTree": history["oldTree"],
|
||||
"databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a",
|
||||
"entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8",
|
||||
"candidateRevision": history["candidateRevision"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
"extra_database_file",
|
||||
"models_content",
|
||||
"session_content",
|
||||
"init_db_content",
|
||||
"forge_policy_changed",
|
||||
"database_file_mode",
|
||||
"entrypoint_content",
|
||||
],
|
||||
)
|
||||
def test_appops_seed_tree_exception_rejects_every_unreviewed_tree_or_entrypoint_change(
|
||||
deploy_module, monkeypatch, tmp_path, mutation
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(
|
||||
tmp_path, candidate_mutation=mutation
|
||||
)
|
||||
assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
|
||||
if mutation == "entrypoint_content":
|
||||
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
assert history["candidateEntrypoint"] != "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8"
|
||||
else:
|
||||
assert history["candidateDatabaseTree"] != "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
with pytest.raises(deploy_module.DeployError, match="database schema or entrypoint"):
|
||||
deploy_module.verify_appops_schema_compatibility(
|
||||
history["repository"], history["candidateRevision"]
|
||||
)
|
||||
|
||||
|
||||
def test_appops_schema_compatibility_compares_full_database_tree_and_entrypoint(
|
||||
deploy_module, tmp_path, monkeypatch
|
||||
):
|
||||
@@ -1553,6 +1878,253 @@ def test_appops_schema_compatibility_compares_full_database_tree_and_entrypoint(
|
||||
deploy_module.verify_appops_schema_compatibility(repository, changed)
|
||||
|
||||
|
||||
def _write_appops_failed_revalidation_pair(
|
||||
deploy_module, monkeypatch, tmp_path, *, parent_source="typed_self_upgrade",
|
||||
parent_status="validation_timeout", sibling_source=None, include_sibling=True,
|
||||
):
|
||||
revision = "c" * 40
|
||||
identity = {
|
||||
"gitea_repository_id": 9,
|
||||
"ref": "refs/heads/main",
|
||||
"revision": revision,
|
||||
}
|
||||
semantic = hashlib.sha256(
|
||||
json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
state_root = tmp_path / "state"
|
||||
inbox = state_root / "webhook-inbox"
|
||||
inbox.mkdir(parents=True, mode=0o700)
|
||||
inbox.chmod(0o700)
|
||||
monkeypatch.setattr(deploy_module, "STATE_ROOT", state_root)
|
||||
|
||||
delivery = "12345678-1234-4234-8234-123456789012"
|
||||
body_sha256 = "a" * 64
|
||||
required_contexts = ["Managed validation / full (push)"]
|
||||
parent = {
|
||||
"schema": "gitea-webhook-inbox.v2",
|
||||
"semantic_sha256": semantic,
|
||||
"body_sha256": body_sha256,
|
||||
"delivery": delivery,
|
||||
**identity,
|
||||
"repository": "jens/unraid-appops-gateway",
|
||||
"source": parent_source,
|
||||
"status": parent_status,
|
||||
"reason": parent_status,
|
||||
"validation": {
|
||||
"validation_gate_version": 1,
|
||||
"required_contexts": required_contexts,
|
||||
"timeout_seconds": 1800,
|
||||
"poll_interval_seconds": 5,
|
||||
"max_poll_interval_seconds": 30,
|
||||
"deadline_at": 1900,
|
||||
"next_check_at": 1900,
|
||||
"attempts": 360,
|
||||
"observed": [],
|
||||
"last_result": (
|
||||
"timeout" if parent_status == "validation_timeout" else "reject"
|
||||
),
|
||||
},
|
||||
"audit": [
|
||||
{"stage": "awaiting_validation"},
|
||||
{"stage": parent_status, "reason": parent_status},
|
||||
],
|
||||
}
|
||||
parent_marker = inbox / f"{semantic}.json"
|
||||
parent_marker.write_text(json.dumps(parent), encoding="utf-8")
|
||||
parent_marker.chmod(0o600)
|
||||
|
||||
sibling = {
|
||||
"schema": "gitea-webhook-revalidation.v1",
|
||||
"semantic_sha256": semantic,
|
||||
"revalidation_of": semantic,
|
||||
"body_sha256": body_sha256,
|
||||
"delivery": delivery,
|
||||
**identity,
|
||||
"repository": "jens/unraid-appops-gateway",
|
||||
"status": "failed",
|
||||
"reason": "deployment_failed",
|
||||
"validation": {
|
||||
"validation_gate_version": 1,
|
||||
"required_contexts": required_contexts,
|
||||
"timeout_seconds": 1800,
|
||||
"poll_interval_seconds": 5,
|
||||
"max_poll_interval_seconds": 30,
|
||||
"deadline_at": 2800,
|
||||
"next_check_at": 2500,
|
||||
"attempts": 1,
|
||||
"observed": [{
|
||||
"context": "Managed validation / full (push)",
|
||||
"status": "success",
|
||||
}],
|
||||
"last_result": "pass",
|
||||
},
|
||||
"audit": [
|
||||
{"stage": "revalidation_requested"},
|
||||
{"stage": "validation_passed"},
|
||||
{"stage": "deployment_started"},
|
||||
{"stage": "deployment_failed", "reason": "deployment_failed"},
|
||||
],
|
||||
}
|
||||
if sibling_source is not None:
|
||||
sibling["source"] = sibling_source
|
||||
sibling_marker = inbox / f"{semantic}.revalidation.json"
|
||||
if include_sibling:
|
||||
sibling_marker.write_text(json.dumps(sibling), encoding="utf-8")
|
||||
sibling_marker.chmod(0o600)
|
||||
return {
|
||||
"semantic": semantic,
|
||||
"revision": revision,
|
||||
"parent": parent,
|
||||
"sibling": sibling,
|
||||
"parent_marker": parent_marker,
|
||||
"sibling_marker": sibling_marker,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parent_source", ["gitea_webhook", "typed_self_upgrade"])
|
||||
@pytest.mark.parametrize("parent_status", ["validation_timeout", "validation_failed"])
|
||||
def test_appops_failed_delivery_accepts_only_a_chained_failed_revalidation(
|
||||
deploy_module, monkeypatch, tmp_path, parent_source, parent_status
|
||||
):
|
||||
pair = _write_appops_failed_revalidation_pair(
|
||||
deploy_module,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
parent_source=parent_source,
|
||||
parent_status=parent_status,
|
||||
)
|
||||
original_bytes = pair["parent_marker"].read_bytes()
|
||||
sibling_bytes = pair["sibling_marker"].read_bytes()
|
||||
|
||||
proof = deploy_module.verify_appops_failed_green_delivery(pair["revision"])
|
||||
|
||||
assert proof["revision"] == pair["revision"]
|
||||
assert proof["source"] == parent_source
|
||||
assert proof["delivery"] == "12345678-1234-4234-8234-123456789012"
|
||||
assert proof["bodySha256"] == "a" * 64
|
||||
assert proof["deliveryStatus"] == "failed"
|
||||
assert proof["deliveryReason"] == "deployment_failed"
|
||||
with pytest.raises(deploy_module.DeployError, match="protected full-green"):
|
||||
deploy_module.verify_appops_green_delivery(pair["revision"])
|
||||
assert pair["parent_marker"].read_bytes() == original_bytes
|
||||
assert pair["sibling_marker"].read_bytes() == sibling_bytes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("which", "field", "value"),
|
||||
[
|
||||
("parent", "repository", "attacker/repo"),
|
||||
("parent", "gitea_repository_id", 8),
|
||||
("parent", "ref", "refs/heads/other"),
|
||||
("parent", "revision", "d" * 40),
|
||||
("parent", "semantic_sha256", "b" * 64),
|
||||
("parent", "body_sha256", "b" * 64),
|
||||
("parent", "delivery", "87654321-4321-4321-8321-210987654321"),
|
||||
("parent", "schema", "gitea-webhook-inbox.v1"),
|
||||
("parent", "status", "failed"),
|
||||
("parent", "source", "unknown_source"),
|
||||
("parent", "reason", "validation_failed"),
|
||||
("parent", "validation.timeout_seconds", 900),
|
||||
("parent", "validation.required_contexts", ["Managed validation / pull_request"]),
|
||||
("parent", "audit", [{"stage": "validation_timeout", "reason": "deployment_failed"}]),
|
||||
("sibling", "repository", "attacker/repo"),
|
||||
("sibling", "gitea_repository_id", 8),
|
||||
("sibling", "ref", "refs/heads/other"),
|
||||
("sibling", "revision", "d" * 40),
|
||||
("sibling", "semantic_sha256", "b" * 64),
|
||||
("sibling", "body_sha256", "b" * 64),
|
||||
("sibling", "delivery", "87654321-4321-4321-8321-210987654321"),
|
||||
("sibling", "schema", "gitea-webhook-inbox.v2"),
|
||||
("sibling", "revalidation_of", "b" * 64),
|
||||
("sibling", "source", "typed_self_upgrade"),
|
||||
("sibling", "source", "gitea_webhook"),
|
||||
("sibling", "status", "deployed"),
|
||||
("sibling", "reason", "deployment_interrupted"),
|
||||
("sibling", "validation.timeout_seconds", 900),
|
||||
("sibling", "validation.validation_gate_version", True),
|
||||
("sibling", "validation.required_contexts", ["Managed validation / pull_request"]),
|
||||
("sibling", "validation.observed", [{
|
||||
"context": "Managed validation / full (push)", "status": "pending",
|
||||
}]),
|
||||
("sibling", "audit", [{"stage": "validation_passed"}]),
|
||||
("sibling", "audit", [
|
||||
{"stage": "revalidation_requested"},
|
||||
{"stage": "deployment_started"},
|
||||
{"stage": "validation_passed"},
|
||||
{"stage": "deployment_failed", "reason": "deployment_failed"},
|
||||
]),
|
||||
],
|
||||
)
|
||||
def test_appops_failed_delivery_rejects_an_unbound_or_incomplete_revalidation(
|
||||
deploy_module, monkeypatch, tmp_path, which, field, value
|
||||
):
|
||||
pair = _write_appops_failed_revalidation_pair(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
)
|
||||
target = pair[which]
|
||||
keys = field.split(".")
|
||||
for key in keys[:-1]:
|
||||
target = target[key]
|
||||
target[keys[-1]] = value
|
||||
marker = pair[f"{which}_marker"]
|
||||
marker.write_text(json.dumps(pair[which]), encoding="utf-8")
|
||||
marker.chmod(0o600)
|
||||
|
||||
with pytest.raises(deploy_module.DeployError, match="protected full-green"):
|
||||
deploy_module.verify_appops_failed_green_delivery(pair["revision"])
|
||||
|
||||
|
||||
def test_appops_failed_delivery_does_not_use_a_revalidation_without_its_original(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
pair = _write_appops_failed_revalidation_pair(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
)
|
||||
pair["parent_marker"].unlink()
|
||||
|
||||
with pytest.raises(deploy_module.DeployError, match="protected full-green"):
|
||||
deploy_module.verify_appops_failed_green_delivery(pair["revision"])
|
||||
|
||||
|
||||
def test_appops_failed_delivery_does_not_reopen_an_original_validation_timeout(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
pair = _write_appops_failed_revalidation_pair(
|
||||
deploy_module, monkeypatch, tmp_path, include_sibling=False
|
||||
)
|
||||
|
||||
with pytest.raises(deploy_module.DeployError, match="protected full-green"):
|
||||
deploy_module.verify_appops_failed_green_delivery(pair["revision"])
|
||||
|
||||
|
||||
def test_appops_failed_delivery_keeps_the_original_primary_failure_path(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
pair = _write_appops_failed_revalidation_pair(
|
||||
deploy_module, monkeypatch, tmp_path, include_sibling=False
|
||||
)
|
||||
primary = pair["parent"]
|
||||
primary["status"] = "failed"
|
||||
primary["reason"] = "deployment_failed"
|
||||
primary["validation"]["observed"] = [{
|
||||
"context": "Managed validation / full (push)", "status": "success",
|
||||
}]
|
||||
primary["validation"]["last_result"] = "pass"
|
||||
primary["audit"] = [
|
||||
{"stage": "validation_passed"},
|
||||
{"stage": "deployment_started"},
|
||||
{"stage": "deployment_failed", "reason": "deployment_failed"},
|
||||
]
|
||||
pair["parent_marker"].write_text(json.dumps(primary), encoding="utf-8")
|
||||
pair["parent_marker"].chmod(0o600)
|
||||
|
||||
proof = deploy_module.verify_appops_failed_green_delivery(pair["revision"])
|
||||
|
||||
assert proof["revision"] == pair["revision"]
|
||||
assert proof["source"] == "typed_self_upgrade"
|
||||
assert proof["deliveryStatus"] == "failed"
|
||||
|
||||
|
||||
def test_appops_migration_requires_private_exact_full_green_delivery(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user