From d825f5045d2e186d04b00ffc0644fe795604a787 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Fri, 9 Oct 2026 15:30:32 +0000 Subject: [PATCH] Publish curated Unraid-AppOps-Gateway source from b6d6a3e1657000c6e0a482c8056a3fbebbafd711 --- PUBLIC_SOURCE_EXPORT.md | 2 +- PUBLIC_SOURCE_MANIFEST.json | 36 ++- .../controller_artifact_helper_spec.py | 2 +- ...controller_artifact_helper_spec_2_1_14.txt | 128 ++++++++++ .../test_appops_staged_source_recovery.py | 225 ++++++++++++++++++ .../test_controller_artifact_bootstrap.py | 68 ++++-- ...tifact_bootstrap_created_child_recovery.py | 2 +- ...rtifact_bootstrap_failed_child_recovery.py | 4 +- .../test_controller_artifact_promoter.py | 6 +- .../test_controller_state_reconciliation.py | 3 +- .../tests/test_forge_database_credentials.py | 5 +- 11 files changed, 438 insertions(+), 43 deletions(-) create mode 100644 backend/tests/fixtures/controller_artifact_helper_spec_2_1_14.txt create mode 100644 backend/tests/test_appops_staged_source_recovery.py diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index eb98984..582ef75 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `28831bd43867347a132f1c2b0f388632b9cb561e`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `b6d6a3e1657000c6e0a482c8056a3fbebbafd711`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index f47ac97..bd81dff 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "28831bd43867347a132f1c2b0f388632b9cb561e", + "sourceRevision": "b6d6a3e1657000c6e0a482c8056a3fbebbafd711", "files": [ { "path": ".dockerignore", @@ -524,7 +524,7 @@ }, { "path": "backend/app/services/controller_artifact_helper_spec.py", - "sha256": "dc456d0005b7181098d99028fa8ea110fb53b037aac1c031c98a9101a61183bd", + "sha256": "b6340734fcba86a341b2c29e7930ed30016d19352acd4a015d1e194c415655ac", "bytes": 6029 }, { @@ -872,6 +872,11 @@ "sha256": "fa4f78d15225f1d0e3dad8f1cd7283dfbe17b411d5158ade8effb0111ff7efa7", "bytes": 1072 }, + { + "path": "backend/tests/fixtures/controller_artifact_helper_spec_2_1_14.txt", + "sha256": "1da47a3f061ef0b89158c9f646bb7018ce88b81c0687d8b27abe65b74df67fea", + "bytes": 4948 + }, { "path": "backend/tests/test_ai_remediation_planner.py", "sha256": "784b83960cdff238e0ee25caaf83cfd1cb7c3cfc40976036b28bd256f6626e44", @@ -922,6 +927,11 @@ "sha256": "447788187bb37bcaac7d99df8fdbe8caa76c0e71acea3c5c8f86faf3972d572d", "bytes": 16611 }, + { + "path": "backend/tests/test_appops_staged_source_recovery.py", + "sha256": "9c420f0d2355edee8232b420f322b60f69b701b68da22213e9207d98ca030611", + "bytes": 13283 + }, { "path": "backend/tests/test_authentik_connector.py", "sha256": "54b24a90e7238aaed22d0dfbae70d9772006d20cdb9dbd43d781af48892dcd94", @@ -939,22 +949,22 @@ }, { "path": "backend/tests/test_controller_artifact_bootstrap.py", - "sha256": "3389f7cc8225ff2f03b46aa79a3a70dae7a6198d1ab52fd0879638bcf4c92f83", - "bytes": 80172 + "sha256": "d9acae805eedcf50e90222ce52f3a43ad101e558d7c1b49d4f55968b208e957f", + "bytes": 82161 }, { "path": "backend/tests/test_controller_artifact_bootstrap_created_child_recovery.py", - "sha256": "a9613e4bad2d66cdf51a840b96674b857b6b82ef125a11345ab9fa2a61735ffd", - "bytes": 21205 + "sha256": "8be4fabde7f2092e6b8f98fb35d96055acab207716466a85baf8f844ae1a7869", + "bytes": 21192 }, { "path": "backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py", - "sha256": "5da80eed7ef420bee44cec4e20fe45bd4918245c6236a4481aeffe71c939d82f", - "bytes": 18314 + "sha256": "7bc8baa74edffb086b4f3dccf9f69184aaf62761a43b57e7912966d786ad32dd", + "bytes": 18295 }, { "path": "backend/tests/test_controller_artifact_promoter.py", - "sha256": "8eee2bdaf60422824f605637b1820c431fdacea305e1b68b3618fffa0dc000ee", + "sha256": "e1f987217790868228daba95ab564b4fb9e44798a80070e6c1d76d0b1ab6fe0a", "bytes": 7570 }, { @@ -994,8 +1004,8 @@ }, { "path": "backend/tests/test_controller_state_reconciliation.py", - "sha256": "b6b5e82f7818fbfa7cc2faf44dad0aaa23e812fbfa9f09f3e738c80cd74237e1", - "bytes": 11300 + "sha256": "bbe33d1faf84f4a230be6d2e2cb65a8d6d81dde4abb91663aac37ef3ef7fe24a", + "bytes": 11387 }, { "path": "backend/tests/test_deluge_connector.py", @@ -1084,8 +1094,8 @@ }, { "path": "backend/tests/test_forge_database_credentials.py", - "sha256": "fdab22c57d546a7a1bd912abc63777ca4a1e03c3826ddcebb9301553130c42a3", - "bytes": 17599 + "sha256": "b28732574a2d53891208b6f192f5ce5da51fb3975ae642834d37d3eb489550da", + "bytes": 17826 }, { "path": "backend/tests/test_forge_first_install_controller.py", diff --git a/backend/app/services/controller_artifact_helper_spec.py b/backend/app/services/controller_artifact_helper_spec.py index e954d58..cf635d2 100644 --- a/backend/app/services/controller_artifact_helper_spec.py +++ b/backend/app/services/controller_artifact_helper_spec.py @@ -5,7 +5,7 @@ from typing import Any SOURCE_REPOSITORY = "jens/unraid-appops-gateway" HELPER_NAME = "itworx-controller-artifact-promoter-modern" -CONTROLLER_VERSION = "2.1.14" +CONTROLLER_VERSION = "2.1.17" HELPER_LABEL = "itworx.controller.promoter" _REVISION = re.compile(r"^[0-9a-f]{40}$") _IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$") diff --git a/backend/tests/fixtures/controller_artifact_helper_spec_2_1_14.txt b/backend/tests/fixtures/controller_artifact_helper_spec_2_1_14.txt new file mode 100644 index 0000000..ea491d1 --- /dev/null +++ b/backend/tests/fixtures/controller_artifact_helper_spec_2_1_14.txt @@ -0,0 +1,128 @@ +from __future__ import annotations + +import re +from typing import Any + +SOURCE_REPOSITORY = "jens/unraid-appops-gateway" +HELPER_NAME = "itworx-controller-artifact-promoter-modern" +CONTROLLER_VERSION = "2.1.14" +HELPER_LABEL = "itworx.controller.promoter" +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$") +_CONTAINER_ID = re.compile(r"^[0-9a-f]{64}$") +_VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") + + +def helper_container_spec( + *, + revision: str, + image_id: str, + current_controller_id: str | None = None, + current_controller_revision: str | None = None, + current_controller_tree: str | None = None, + current_controller_version: str | None = None, +) -> dict[str, Any]: + if not _REVISION.fullmatch(revision): + raise ValueError("controller_promoter_revision_invalid") + if not _IMAGE_ID.fullmatch(image_id): + raise ValueError("controller_promoter_image_invalid") + + bootstrap_values = ( + current_controller_id, + current_controller_revision, + current_controller_tree, + current_controller_version, + ) + if any(value is not None for value in bootstrap_values): + if ( + not _CONTAINER_ID.fullmatch(current_controller_id or "") + or not _REVISION.fullmatch(current_controller_revision or "") + or not _REVISION.fullmatch(current_controller_tree or "") + or not _VERSION.fullmatch(current_controller_version or "") + ): + raise ValueError("controller_promoter_bootstrap_binding_invalid") + + command = f"""set -Eeuo pipefail +umask 077 +revision='{revision}' +version='{CONTROLLER_VERSION}' +image_id='{image_id}' +repo='/gitea-repositories/jens/unraid-appops-gateway.git' +[[ \"$(stat -c '%a:%u:%g' /tmp)\" == '1777:0:0' ]] +work=\"$(mktemp -d -p /tmp itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX)\" +[[ -d \"$work\" && ! -L \"$work\" && \"$(stat -c '%u:%a' \"$work\")\" == '0:700' ]] +cleanup() {{ rm -rf -- \"$work\"; }} +trap cleanup EXIT +command -v git >/dev/null +command -v docker >/dev/null +docker compose version >/dev/null +command -v python3 >/dev/null +command -v tar >/dev/null +command -v curl >/dev/null +git --git-dir=\"$repo\" cat-file -e \"$revision^{{commit}}\" +tree=\"$(git --git-dir=\"$repo\" rev-parse \"$revision^{{tree}}\")\" +git --git-dir=\"$repo\" archive --format=tar \"$revision\" \\ + ops/gitea-deploy-broker \\ + scripts/prepare_controller_artifact.py \\ + scripts/reconcile_controller_state.py \\ + scripts/prepare_controller_hub_release_volume.py \\ + scripts/vendor \\ + scripts/run_repopulse_adoption.py \ + scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\" +chmod 0600 \"$work/controller.tar\" +archive_sha=\"$(sha256sum \"$work/controller.tar\" | cut -d ' ' -f 1)\" +git --git-dir=\"$repo\" show \"$revision:scripts/upgrade_deployment_controller.sh\" > \"$work/upgrade.sh\" +chmod 0700 \"$work/upgrade.sh\" +bash \"$work/upgrade.sh\" \\ + --archive \"$work/controller.tar\" \\ + --archive-sha256 \"$archive_sha\" \\ + --source-repository '{SOURCE_REPOSITORY}' \\ + --source-revision \"$revision\" \\ + --source-tree \"$tree\" \\ + --controller-version \"$version\" \\ + --image-id \"$image_id\" \\ + --confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2 +""" + + labels = { + HELPER_LABEL: "true", + "itworx.source.repository": SOURCE_REPOSITORY, + "itworx.source.revision": revision, + "itworx.controller.version": CONTROLLER_VERSION, + } + if current_controller_id is not None: + labels.update( + { + "itworx.controller.bootstrap.target.id": current_controller_id, + "itworx.controller.bootstrap.target.image": image_id, + "itworx.controller.bootstrap.target.revision": current_controller_revision or "", + "itworx.controller.bootstrap.target.tree": current_controller_tree or "", + "itworx.controller.bootstrap.target.version": current_controller_version or "", + } + ) + + return { + "Image": image_id, + "User": "0:0", + "Cmd": ["/bin/bash", "-lc", command], + "Labels": labels, + "HostConfig": { + "Binds": [ + "/mnt/cache:/mnt/cache", + "/mnt/user:/mnt/user", + "/var/lib/docker:/var/lib/docker:ro", + "/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro", + "/var/run/docker.sock:/var/run/docker.sock", + ], + "NetworkMode": "host", + "Privileged": False, + "ReadonlyRootfs": True, + "CpuPeriod": 100000, + "CpuQuota": 100000, + "Memory": 1024 * 1024 * 1024, + "PidsLimit": 128, + "Tmpfs": {"/tmp": "rw,nosuid,nodev,size=268435456,mode=1777"}, + "SecurityOpt": ["no-new-privileges:true"], + "RestartPolicy": {"Name": "no"}, + }, + } diff --git a/backend/tests/test_appops_staged_source_recovery.py b/backend/tests/test_appops_staged_source_recovery.py new file mode 100644 index 0000000..975d099 --- /dev/null +++ b/backend/tests/test_appops_staged_source_recovery.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import copy +import importlib.util +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER = ROOT / "ops/gitea-deploy-broker" +REPOSITORY = "jens/unraid-appops-gateway" +REVISION = "c" * 40 +SOURCE = "/srv/example/appdata/unraid-appops-gateway/source" + + +@pytest.fixture +def recovery(monkeypatch): + monkeypatch.syspath_prepend(str(BROKER)) + if "fcntl" not in sys.modules: + monkeypatch.setitem(sys.modules, "fcntl", SimpleNamespace(LOCK_EX=2, LOCK_UN=8, flock=lambda *_: None)) + spec = importlib.util.spec_from_file_location("staged_source_deploy_test", BROKER / "deploy.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + pointer = {"revision": module.APPOPS_LEGACY_OLD_REVISION, "liveRecordSha256": "f" * 64} + active = {"pointer": pointer, "live": {"containerId": "1" * 64, + "runtimeFingerprint": "2" * 64, "sourceFingerprint": "3" * 64}} + snapshot = {"revision": module.APPOPS_LEGACY_OLD_REVISION, "legacy_provenance": True, + "receipt": {}, "appops_runtime_model": pointer, "appops_data_backup": {"safe": True}} + metadata = {"builtAt": "2026-10-09T00:00:00.000Z"} + payload = {"kind": "followup", "repository": REPOSITORY, "revision": REVISION, + "phase": "source_staged", "expectedStateSha256": "4" * 64, + "priorModelPointer": pointer, "priorSnapshot": snapshot, "registryFingerprint": "5" * 64, + "preMutationRuntimeFingerprint": "2" * 64, "preMutationRuntimeSha256": "6" * 64, + "sourceFingerprint": "3" * 64, "metadata": metadata} + pending = {"sha256": "7" * 64, "payload": payload} + identity = {"revision": module.APPOPS_LEGACY_OLD_REVISION, "containerId": "1" * 64, + "runtimeFingerprint": "2" * 64, "runtimeCasSha256": "6" * 64} + state = {"live_status": "healthy"} + facts = {"stateSha": "4" * 64, "registry": "5" * 64, "source": "8" * 64, + "postSource": "3" * 64, "active": active, "identity": identity} + calls = [] + reader = SimpleNamespace( + PROJECTS_PATH=Path("registry"), + _self_upgrade_pending_model_transaction=lambda: pending, + _self_upgrade_active_compose_model=lambda *_args, **_kwargs: facts["active"], + _self_upgrade_verify_rollback_model=lambda *_: True, + _self_upgrade_read_fixed_file=lambda *_args, **_kwargs: b"registry", + _self_upgrade_secret=lambda: b"private-key", + _self_upgrade_mac=lambda *_: facts["registry"], + _self_upgrade_model_runtime_identity=lambda *_: facts["identity"], + _self_upgrade_source_fingerprint=lambda *_: facts["source"], + _self_upgrade_source_is_exact_legacy=lambda *_: facts["postSource"], + ) + monkeypatch.setattr(module, "require_host_root", lambda: None) + monkeypatch.setattr(module, "_appops_status_reader", lambda: reader) + monkeypatch.setattr(module, "read_appops_state_preimage", lambda _: (state, b"state", facts["stateSha"])) + monkeypatch.setattr(module, "validate_commit", lambda *_: REVISION) + monkeypatch.setattr(module, "deployment_metadata", lambda *_args, **_kwargs: metadata) + monkeypatch.setattr(module, "verify_appops_data_backup", lambda *_: calls.append("backup-verified")) + monkeypatch.setattr(module, "_verify_appops_staged_source", lambda *_: calls.append("candidate-verified")) + monkeypatch.setattr(module, "_appops_source_untracked_fingerprint", lambda *_: "9" * 64) + monkeypatch.setattr(module, "tracked_files", lambda *_: []) + monkeypatch.setattr(module, "write_source_metadata", lambda *_: None) + monkeypatch.setattr(module, "sync_source", lambda _repo, _revision, _root, _slug, paths, **_kwargs: calls.append(("restore", copy.deepcopy(paths)))) + monkeypatch.setattr(module, "inspect_container", lambda _: {"Id": "1" * 64}) + monkeypatch.setattr(module, "_appops_update_model_transaction", lambda _project, phase, bindings: calls.append((phase, bindings))) + project = {"slug": "unraid-appops-gateway", "source_dir": SOURCE, "clean_untracked_paths": ["backend"]} + return module, project, pending, facts, calls, reader + + +def test_signed_staged_source_restores_without_runtime_or_state_writes(recovery): + module, project, pending, _facts, calls, _reader = recovery + _, fingerprint = module.inspect_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION) + result = module.resume_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION, fingerprint) + assert result["status"] == "source-restored" + phases = [item[0] for item in calls if isinstance(item, tuple)] + assert phases == ["rolling_back", "restore", "rolled_back"] + restored = next(item[1] for item in calls if isinstance(item, tuple) and item[0] == "restore") + assert restored == [] + assert project["clean_untracked_paths"] == ["backend"] + assert calls[-1][1] == {"restoredLiveReference": pending["payload"]["priorModelPointer"]} + + +@pytest.mark.parametrize("drift", ["target", "kind", "revision", "phase", "imageSeal", "state", "registry", + "container", "runtime", "runtimeCas", "priorModel", "sourceAuthority", "sourceUnsafe"]) +def test_staged_source_recovery_rejects_drift_before_any_write(recovery, drift): + module, project, pending, facts, calls, _reader = recovery + payload = pending["payload"] + if drift == "target": + project["source_dir"] = "/other" + elif drift in {"kind", "revision", "phase"}: + payload[drift] = "foreign" + elif drift == "imageSeal": + payload["candidateImageId"] = "sha256:" + "a" * 64 + elif drift in {"state", "registry"}: + facts["stateSha" if drift == "state" else "registry"] = "a" * 64 + elif drift in {"container", "runtime", "runtimeCas"}: + facts["identity"][{"container": "containerId", "runtime": "runtimeFingerprint", "runtimeCas": "runtimeCasSha256"}[drift]] = "a" * 64 + elif drift == "priorModel": + payload["priorModelPointer"] = {"foreign": True} + elif drift == "sourceAuthority": + payload["sourceFingerprint"] = "a" * 64 + else: + facts["source"] = None + with pytest.raises(module.DeployError): + module.inspect_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION) + assert not any(isinstance(item, tuple) for item in calls) + + +def test_source_rollback_requires_fresh_preflight(recovery): + module, project, _pending, _facts, calls, _reader = recovery + with pytest.raises(module.DeployError, match="preflight changed"): + module.resume_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION, "0" * 64) + assert not any(isinstance(item, tuple) for item in calls) + + +def test_source_rollback_never_seals_failed_source_restoration(recovery): + module, project, _pending, facts, calls, _reader = recovery + _, fingerprint = module.inspect_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION) + facts["postSource"] = "0" * 64 + with pytest.raises(module.DeployError, match="signed unchanged preimage"): + module.resume_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION, fingerprint) + assert not any(isinstance(item, tuple) and item[0] == "rolled_back" for item in calls) + + +def test_source_rollback_reconciles_crash_after_exact_source_restore(recovery): + module, project, pending, facts, calls, _reader = recovery + pending["payload"].update(phase="rolling_back", failureCode="staged_source_restore", sourceRestoreIntent={"safe": True}) + facts["source"] = "3" * 64 + _, fingerprint = module.inspect_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION) + module.resume_appops_staged_source_rollback(project, REPOSITORY, Path("git"), REVISION, fingerprint) + assert "candidate-verified" not in calls + assert [item[0] for item in calls if isinstance(item, tuple)] == ["restore", "rolled_back"] + + +def test_staged_source_verifier_rejects_edited_tracked_file_and_unknown_files(recovery, monkeypatch, tmp_path): + _module, _project, _pending, _facts, _calls, _reader = recovery + # Restore the real verifier instead of the orchestration fixture adapter. + spec = importlib.util.spec_from_file_location("staged_source_real_verifier", BROKER / "deploy.py") + real = importlib.util.module_from_spec(spec) + spec.loader.exec_module(real) + source = tmp_path / "source" + (source / ".gitea-deploy").mkdir(parents=True) + (source / ".gitea-deploy/revision").write_text(REVISION + "\n") + (source / ".gitea-deploy/tracked-files.txt").write_text("tracked.py\n") + (source / "tracked.py").write_bytes(b"committed") + def extract(_repo, _revision, target): + (target / "tracked.py").write_bytes(b"committed") + monkeypatch.setattr(real, "tracked_files", lambda *_: ["tracked.py"]) + monkeypatch.setattr(real, "extract_revision", extract) + project = {"source_dir": str(source)} + real._verify_appops_staged_source(Path("git"), project, REVISION) + (source / "tracked.py").write_bytes(b"altered!!") + with pytest.raises(real.DeployError, match="committed source changed"): + real._verify_appops_staged_source(Path("git"), project, REVISION) + (source / "tracked.py").write_bytes(b"committed") + (source / "unknown.txt").write_bytes(b"foreign") + with pytest.raises(real.DeployError, match="unknown file"): + real._verify_appops_staged_source(Path("git"), project, REVISION) + + +def test_controller_promotion_holds_nonblocking_production_lock_before_stop(): + script = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text() + assert script.index('flock -n -x "$deployment_lock_fd"') < script.index('"${work}/${PREPARER_NAME}" prepare') + assert script.index('flock -n -x "$deployment_lock_fd"') < script.index('"${work}/${PREPARER_NAME}" ensure') + assert script.index('flock -n -x "$promotion_lock_fd"') < script.index('"${work}/${PREPARER_NAME}" ensure') + assert script.index('flock -n -x "$deployment_lock_fd"') < script.index('stop --backup "$backup"') + assert "production deployment is active; controller promotion refused" in script + + +@pytest.mark.skipif(os.name == "nt" or getattr(os, "geteuid", lambda: -1)() != 0, + reason="real controller manifest ownership requires root POSIX validation") +@pytest.mark.parametrize("interruption", ["after_delete", "during_extract", "between_manifests", "foreign_bytes", "foreign_file"]) +def test_partial_own_source_restore_accepts_only_known_git_states(recovery, monkeypatch, tmp_path, interruption): + module, _project, _pending, _facts, _calls, reader = recovery + root = tmp_path / "source" + root.mkdir() + (root / ".gitea-deploy").mkdir() + candidate = {"same.py": b"candidate contents", "candidate-only.py": b"candidate only"} + old = {"same.py": b"old contents", "old-only.py": b"old only"} + candidate_files, old_files = sorted(candidate), sorted(old) + def files(_repo, revision): + return candidate_files if revision == REVISION else old_files + def extract(_repo, revision, target): + for name, raw in (candidate if revision == REVISION else old).items(): + (target / name).write_bytes(raw) + (target / name).chmod(0o644) + monkeypatch.setattr(module, "tracked_files", files) + monkeypatch.setattr(module, "extract_revision", extract) + extract(Path("git"), REVISION, root) + manifests = root / ".gitea-deploy" + (manifests / "revision").write_text(REVISION + "\n") + (manifests / "tracked-files.txt").write_text("\n".join(candidate_files) + "\n") + for path in manifests.iterdir(): + path.chmod(0o644) + os.chown(path, 100, 101) + (root / "candidate-only.py").unlink() + if interruption == "during_extract": + (root / "same.py").write_bytes(b"old con") + elif interruption == "between_manifests": + (root / "same.py").write_bytes(old["same.py"]) + (root / "old-only.py").write_bytes(old["old-only.py"]) + (manifests / "tracked-files.txt").write_text("old-on") + elif interruption == "foreign_bytes": + (root / "same.py").write_bytes(b"foreign mutation") + elif interruption == "foreign_file": + (root / "extra.py").write_bytes(b"unknown") + intent = {"trackedOwners": {name: [0, 0] for name in candidate_files}, "untrackedFingerprint": "9" * 64} + args = (Path("git"), {"source_dir": str(root)}, REVISION, intent, reader, b"private-key") + if interruption.startswith("foreign"): + with pytest.raises(module.DeployError): + module._verify_appops_partial_source_restore(*args) + else: + module._verify_appops_partial_source_restore(*args) + + +def test_partial_source_restore_rejects_untracked_secret_drift(recovery, monkeypatch): + module, project, _pending, _facts, _calls, reader = recovery + monkeypatch.setattr(module, "_appops_source_untracked_fingerprint", lambda *_: "a" * 64) + with pytest.raises(module.DeployError, match="untracked files changed"): + module._verify_appops_partial_source_restore(Path("git"), project, REVISION, + {"untrackedFingerprint": "9" * 64}, reader, b"private-key") diff --git a/backend/tests/test_controller_artifact_bootstrap.py b/backend/tests/test_controller_artifact_bootstrap.py index 6d62b12..39e5d66 100644 --- a/backend/tests/test_controller_artifact_bootstrap.py +++ b/backend/tests/test_controller_artifact_bootstrap.py @@ -37,6 +37,7 @@ CALLER_ID = "c" * 64 MODERN_HELPER_ID = "f" * 64 RETIRED_HELPER_ID = bootstrap.RETIRED_FAILED_MODERN_HELPER_ID HOST_HOSTNAME = "Tower" +RETIRED_MODERN_HELPER_SOURCE = (ROOT / "backend/tests/fixtures/controller_artifact_helper_spec_2_1_14.txt").read_text(encoding="utf-8") LEGACY_HELPER_SOURCE_FIXTURE = ''' @@ -570,6 +571,8 @@ class FakeProtectedGit: self.blobs_read.append((revision, path)) if revision not in {self.revision, bootstrap.RETIRED_FAILED_MODERN_REVISION} or path != "backend/app/services/controller_artifact_helper_spec.py": raise bootstrap.BootstrapError("controller_promoter_source_blob_invalid") + if revision == bootstrap.RETIRED_FAILED_MODERN_REVISION: + return RETIRED_MODERN_HELPER_SOURCE.encode("utf-8") return self.helper_source.encode("utf-8") def read_legacy_blob(self, revision: str, path: str) -> bytes: @@ -586,7 +589,7 @@ def _health( *, revision: str = REVISION, tree: str = TREE, - version: str = "2.1.14", + version: str = "2.1.17", artifact_sha256: str = "1" * 64, ) -> dict[str, Any]: return { @@ -829,15 +832,16 @@ def test_controller_source_provenance_failure_precedes_health_and_side_effects() assert docker.started == [] -def _retired_failed_child(helper_source: str) -> dict[str, Any]: +def _retired_failed_child() -> dict[str, Any]: spec = bootstrap.render_modern_helper_spec( - helper_source, + RETIRED_MODERN_HELPER_SOURCE, revision=bootstrap.RETIRED_FAILED_MODERN_REVISION, image_id=CONTROLLER_IMAGE_ID, current_controller_id=CONTROLLER_CONTAINER_ID, current_controller_revision=CONTROLLER_REVISION, current_controller_tree=CONTROLLER_TREE, current_controller_version="2.1.12", + helper_version="2.1.14", ) child = _inspect_from_spec( "itworx-controller-artifact-promoter-modern", @@ -1295,7 +1299,7 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch() version="2.1.12", artifact_sha256=bootstrap.LEGACY_CONTROLLER_ARTIFACT_SHA256, ) - failed_child = _retired_failed_child(helper_source) + failed_child = _retired_failed_child() docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) git = FakeProtectedGit(helper_source) @@ -1343,7 +1347,7 @@ def test_current_214_baseline_is_verified_and_bound_to_new_helper(): "itworx.controller.promoter": "true", "itworx.source.repository": "jens/unraid-appops-gateway", "itworx.source.revision": REVISION, - "itworx.controller.version": "2.1.14", + "itworx.controller.version": "2.1.17", "itworx.controller.bootstrap.target.id": MODERN_CONTROLLER_CONTAINER_ID, "itworx.controller.bootstrap.target.image": CONTROLLER_IMAGE_ID, "itworx.controller.bootstrap.target.revision": MODERN_CONTROLLER_REVISION, @@ -1356,7 +1360,7 @@ def test_retired_failed_child_is_removed_only_after_current_214_profile_rechecks helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( encoding="utf-8" ) - failed_child = _retired_failed_child(helper_source) + failed_child = _retired_failed_child() docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect( MODERN_CONTROLLER_REVISION @@ -1417,7 +1421,7 @@ def test_retired_cleanup_refuses_any_current_214_identity_or_topology_drift(muta helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( encoding="utf-8" ) - failed_child = _retired_failed_child(helper_source) + failed_child = _retired_failed_child() docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) controller = _modern_controller_source_inspect(MODERN_CONTROLLER_REVISION) health = _modern_baseline_health() @@ -1515,7 +1519,7 @@ def test_retired_helper_cleanup_fails_closed_on_any_identity_or_state_drift(muta helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( encoding="utf-8" ) - failed_child = _retired_failed_child(helper_source) + failed_child = _retired_failed_child() docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) git = FakeProtectedGit(helper_source) if mutation == "running": @@ -1614,7 +1618,7 @@ def test_same_failed_source_revision_is_never_retried_or_removed(): ) docker = FakeDocker( _legacy_helper_spec(bootstrap.RETIRED_FAILED_MODERN_REVISION), - existing_child=_retired_failed_child(helper_source), + existing_child=_retired_failed_child(), ) git = FakeProtectedGit(helper_source) git.revision = bootstrap.RETIRED_FAILED_MODERN_REVISION @@ -1626,16 +1630,14 @@ def test_same_failed_source_revision_is_never_retried_or_removed(): artifact_sha256=bootstrap.LEGACY_CONTROLLER_ARTIFACT_SHA256, ) - result = _run_bootstrap( - docker, - git, - health_sequence=[legacy_health], - source_revision=git.revision, - source_tree=git.tree, - ) - - assert result["status"] == "failed" - assert result["exitCode"] == 1 + with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_retired_helper_retry_refused"): + _run_bootstrap( + docker, + git, + health_sequence=[legacy_health], + source_revision=git.revision, + source_tree=git.tree, + ) assert docker.removed == [] assert docker.created == [] @@ -1824,7 +1826,7 @@ def test_successful_child_requires_exact_source_tree_version_and_health(): assert result["status"] == "promoted" assert result["revision"] == REVISION - assert result["controllerVersion"] == "2.1.14" + assert result["controllerVersion"] == "2.1.17" assert result["artifactSha256"] == "1" * 64 assert docker.started == [MODERN_HELPER_ID] assert docker.created[0]["Image"] == CONTROLLER_IMAGE_ID @@ -2079,3 +2081,29 @@ def test_modern_upgrade_path_still_requires_private_archive_and_image_id( assert "archive is outside the helper private temporary directory" in result.stderr if modern_call == "missing-image": assert "image ID" in result.stderr + + +def test_current_helper_version_is_distinct_from_fixed_controller_baselines(): + source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_bytes() + namespace = bootstrap._safe_modern_spec_namespace(source) + assert namespace["CONTROLLER_VERSION"] == "2.1.17" + assert bootstrap._verified_historical_helper_version(source) == "2.1.17" + assert [baseline.version for baseline in bootstrap.CONTROLLER_BASELINES] == ["2.1.12", "2.1.14"] + with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"): + bootstrap._safe_modern_spec_namespace(RETIRED_MODERN_HELPER_SOURCE.encode("utf-8")) + assert bootstrap._verified_historical_helper_version(RETIRED_MODERN_HELPER_SOURCE.encode("utf-8")) == "2.1.14" + + +@pytest.mark.parametrize("version", ["2.1.16", "2.1.18", "9.9.9"]) +def test_current_and_historical_helper_reject_unreviewed_target_versions(version): + source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_bytes() + foreign = source.replace(b'CONTROLLER_VERSION = "2.1.17"', f'CONTROLLER_VERSION = "{version}"'.encode()) + with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"): + bootstrap._safe_modern_spec_namespace(foreign) + with pytest.raises(bootstrap.BootstrapError, match="controller_promoter_modern_spec_identity_invalid"): + bootstrap._verified_historical_helper_version(foreign) + + +@pytest.mark.parametrize("version", ["2.1.14", "2.1.16", "2.1.18"]) +def test_promoted_health_cannot_substitute_a_baseline_or_unknown_version(version): + assert not bootstrap._health_matches(_health(version=version), REVISION, TREE) diff --git a/backend/tests/test_controller_artifact_bootstrap_created_child_recovery.py b/backend/tests/test_controller_artifact_bootstrap_created_child_recovery.py index bf4cf5f..23085c9 100644 --- a/backend/tests/test_controller_artifact_bootstrap_created_child_recovery.py +++ b/backend/tests/test_controller_artifact_bootstrap_created_child_recovery.py @@ -173,7 +173,7 @@ def _inputs(*, current_child: bool) -> tuple[Docker, Git, dict[str, Any], str]: current_controller_tree=baseline.tree, current_controller_version=baseline.version, ) - child = original._retired_failed_child(helper_source) + child = original._retired_failed_child() child["Id"] = child_id child["Image"] = baseline.image_id child["State"] = _created_state() diff --git a/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py b/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py index 5c2255e..27d62dc 100644 --- a/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py +++ b/backend/tests/test_controller_artifact_bootstrap_failed_child_recovery.py @@ -48,7 +48,7 @@ class Docker(original.FakeDocker): def inputs(): source = (original.ROOT / bootstrap.MODERN_HELPER_SOURCE_PATH).read_text() - child = original._retired_failed_child(source) + child = original._retired_failed_child() baseline = bootstrap.MODERN_CONTROLLER_BASELINE spec = bootstrap.render_modern_helper_spec( source, @@ -212,7 +212,7 @@ def historical_inputs(): helper_source = ( original.ROOT / "backend/app/services/controller_artifact_helper_spec.py" ).read_text(encoding="utf-8") - child = original._retired_failed_child(helper_source) + child = original._retired_failed_child() docker = original.FakeDocker( original._legacy_helper_spec(), existing_child=child ) diff --git a/backend/tests/test_controller_artifact_promoter.py b/backend/tests/test_controller_artifact_promoter.py index b8cfd09..90e6582 100644 --- a/backend/tests/test_controller_artifact_promoter.py +++ b/backend/tests/test_controller_artifact_promoter.py @@ -46,7 +46,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded(): spec = helper_container_spec(revision=revision, image_id=image_id) assert HELPER_NAME == "itworx-controller-artifact-promoter-modern" - assert CONTROLLER_VERSION == "2.1.14" + assert CONTROLLER_VERSION == "2.1.17" assert re.fullmatch(r"2\.1\.\d+", CONTROLLER_VERSION) assert spec["Image"] == image_id assert spec["HostConfig"]["Privileged"] is False @@ -79,7 +79,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded(): assert revision in command assert "--image-id" in command assert CONTROLLER_VERSION in command - assert "version='2.1.14'" in command + assert "version='2.1.17'" in command assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command archive_start = command.index('git --git-dir="$repo" archive') archive_end = command.index('> "$work/controller.tar"', archive_start) @@ -110,7 +110,7 @@ def test_controller_promoter_bootstrap_child_spec_binds_legacy_target_identity() "itworx.controller.promoter": "true", "itworx.source.repository": "jens/unraid-appops-gateway", "itworx.source.revision": revision, - "itworx.controller.version": "2.1.14", + "itworx.controller.version": "2.1.17", "itworx.controller.bootstrap.target.id": target_id, "itworx.controller.bootstrap.target.image": image_id, "itworx.controller.bootstrap.target.revision": target_revision, diff --git a/backend/tests/test_controller_state_reconciliation.py b/backend/tests/test_controller_state_reconciliation.py index 047540b..681580c 100644 --- a/backend/tests/test_controller_state_reconciliation.py +++ b/backend/tests/test_controller_state_reconciliation.py @@ -197,7 +197,8 @@ def test_upgrade_prepares_before_stop_and_reconciles_between_stop_and_activation assert script.index(prepare) < script.index(stop) assert script.index(stop) < script.index(reconcile) assert script.index(reconcile) < script.index(activate) - assert 'flock -x "$promotion_lock_fd"' in script + assert 'flock -n -x "$promotion_lock_fd"' in script + assert script.index('flock -n -x "$promotion_lock_fd"') < script.index(prepare) assert 'mv -- "$CONTROLLER_DIR"' not in script assert 'cp -a -- "$CONTROLLER_DIR"' not in script diff --git a/backend/tests/test_forge_database_credentials.py b/backend/tests/test_forge_database_credentials.py index ed80230..aadabb1 100644 --- a/backend/tests/test_forge_database_credentials.py +++ b/backend/tests/test_forge_database_credentials.py @@ -95,6 +95,7 @@ def _install_fake_docker( "if behavior == 'echo-received':\n" " os.write(2, first.encode() + b'\\n')\n" "if behavior == 'exit-after-first':\n" + " open(marker, 'a').write('first-input-validated\\n')\n" " raise SystemExit(8)\n" "os.write(2, b'Enter it again: ')\n" "second = sys.stdin.readline().rstrip('\\r\\n')\n" @@ -288,7 +289,8 @@ def test_any_password_input_without_success_becomes_unknown( states, calls, journal = _journal( {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} ) - monkeypatch.setattr(module, "PROMPT_TIMEOUT_SECONDS", 0.15) + # Prove the post-input branch, rather than timing out Python startup under load. + monkeypatch.setattr(module, "PROMPT_TIMEOUT_SECONDS", 2.0) monkeypatch.setattr(module, "REMOTE_TIMEOUT_SECONDS", 1) monkeypatch.setattr(module, "REMOTE_KILL_AFTER_SECONDS", 1) monkeypatch.setattr(module, "REMOTE_TERMINATION_MARGIN_SECONDS", 0.1) @@ -296,6 +298,7 @@ def test_any_password_input_without_success_becomes_unknown( result = _run(module, tmp_path, journal) + assert marker.read_text(encoding="utf-8") == "first-input-validated\n" assert result["status"] == "outcome_unknown" assert result["reason"] == "psql_result_uncertain" assert states[module.RUNTIME_ROLE] == "unknown"