from __future__ import annotations import ast import json import os import stat import subprocess import sys import time from types import SimpleNamespace import pytest from backend.app.services.deployment_target_registry import ( DeploymentTargetRegistry, _REGISTER_SCRIPT, ) from test_deployment_target_registry import FakePortainer REPOSITORY = "jens/quiz-monitor" REVISION = "a" * 40 def requested(verify=True): return DeploymentTargetRegistry._project( REPOSITORY, branch="main", compose_file="docker-compose.yml", compose_project="quiz-monitor", containers=["quiz-monitor-quiz-server-1"], health_timeout=180, verify_oci_revision=verify, ) def live(): return { "branch": "main", "slug": "quiz-monitor", "source_dir": "/srv/example/appdata/quiz-monitor/source", "compose_files": ["docker-compose.yml"], "compose_project": "quiz-monitor", "containers": ["quiz-monitor-quiz-server-1"], "legacy_containers": ["QuizMonitor"], "gitea_repository_id": 6, "gitea_webhook_secret_file": "jens__quiz-monitor", "validation": {"validation_gate_version": 1, "required": True, "contexts": ["Managed validation / full (push)"], "timeout_seconds": 1800, "poll_interval_seconds": 5, "max_poll_interval_seconds": 30}, "environment": {"TEST_VALUE": "private-value-$preserve"}, "env_source": "/srv/example/appdata/quiz-monitor/runtime.env", } def descriptor(): return {"apiVersion": "itworx.deployment-target/v1", "applicationId": "quiz-monitor", "primaryService": "quiz-server", "composeFile": "docker-compose.yml", "composeProject": "quiz-monitor", "verifyOciRevision": True} @pytest.fixture def transaction(tmp_path, monkeypatch): pytest.importorskip("fcntl") if os.geteuid() != 0: pytest.skip("fixed root-owned registry transaction requires Linux root") registry = tmp_path / "volume" / "config" / "projects.json" registry.parent.mkdir(parents=True, mode=0o700) registry.parent.parent.chmod(0o700) state = tmp_path / "state" state.mkdir(mode=0o700) bare = tmp_path / "repositories" / "jens" / "quiz-monitor.git" bare.mkdir(parents=True) script = _REGISTER_SCRIPT.replace("/controller-hub-releases/controller-config/projects.json", str(registry)) script = script.replace("/controller-config/projects.json", str(registry)) script = script.replace("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock", str(tmp_path / "deploy.lock")) script = script.replace("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups", str(state / "registry-backups")) script = script.replace("/gitea-repositories", str(tmp_path / "repositories")) def run(current, desired=None, *, source=None, protected=True, head=REVISION, protected_heads=None, mutate_during_gate=False, expire_during_gate=False): desired = desired or requested() projects = {REPOSITORY: current, "jens/unrelated": {"unchanged": "private-other"}} if current is not None else {} original = json.dumps(projects).encode() registry.write_bytes(original) registry.chmod(0o600) calls = [] elapsed = [0] if expire_during_gate: monkeypatch.setattr("time.monotonic", lambda: elapsed[0]) def git(command, **_kwargs): assert command[:5] == ["git", "--no-replace-objects", "-c", f"safe.directory={bare}", f"--git-dir={bare}"] args = command[5:] if args[0] == "rev-parse": text = head if args[1].startswith("refs/heads/") else REVISION return SimpleNamespace(returncode=0, stdout=text + "\n") if args[:2] == ["show", f"{REVISION}:.itworx/deployment-target.json"]: text = source if isinstance(source, str) else json.dumps(source if source is not None else descriptor()) return SimpleNamespace(returncode=0, stdout=text) if args[0] in {"merge-base", "cat-file"}: return SimpleNamespace(returncode=0, stdout="") raise AssertionError(args) def read(path, *, query): assert path == f"/repos/{REPOSITORY}/branches/main" and query == {} calls.append(path) if expire_during_gate: elapsed[0] = 21 if mutate_during_gate and (mutate_during_gate is True or len(calls) == mutate_during_gate): registry.write_bytes(original + b" ") current_head = protected_heads[min(len(calls) - 1, len(protected_heads) - 1)] if protected_heads else head return {"protected": protected, "commit": {"id": current_head}} monkeypatch.setattr("subprocess.run", git) monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace(_registered_legacy_api_reader=lambda repo: read)) monkeypatch.setattr(sys, "argv", ["registration", REPOSITORY, REVISION, "main", json.dumps(desired)]) code = 0 try: exec(compile(script, "fixed-registration-script", "exec"), {}) except SystemExit as error: code = error.code return code, json.loads(registry.read_bytes()), original, registry.read_bytes(), calls, state return run def test_exact_protected_descriptor_strengthens_verification_and_preserves_all_existing_metadata(transaction, capsys): current = live() code, result, _old, _new, calls, state = transaction(current) assert code == 0 assert result[REPOSITORY] == {**current, "verify_oci_revision": True} assert result["jens/unrelated"] == {"unchanged": "private-other"} assert len(calls) == 2 # Fresh protected-main proof at preflight and atomic install. assert len(list((state / "registry-backups").iterdir())) == 1 assert "private" not in capsys.readouterr().out def test_known_absent_defaults_and_legacy_fields_are_an_idempotent_noop(transaction): current = live() code, result, old, new, calls, state = transaction(current, requested(False)) assert code == 0 and old == new and result[REPOSITORY] == current assert not calls and not (state / "registry-backups").exists() @pytest.mark.parametrize("change", [ {"branch": "other"}, {"slug": "other"}, {"compose_project": "other"}, {"containers": ["other-container"]}, {"compose_files": ["other.yml"]}, {"source_dir": "/srv/example/appdata/other/source"}, {"source_dir": "/srv/example/appdata/quiz-monitor/source/../other"}, {"exact_source": "false"}, {"verify_oci_revision": "false"}, {"health_timeout": False}, {"health_timeout": 300}, {"deploy_script": "scripts/privileged.sh"}, {"legacy_containers": ["../escape"]}, {"legacy_containers": ["QuizMonitor", "QuizMonitor"]}, {"legacy_containers": ["quiz-monitor-quiz-server-1"]}, {"validation": {"required": False}}, ]) def test_conflicting_identity_paths_and_unsafe_metadata_never_write(transaction, change): code, _result, old, new, calls, state = transaction({**live(), **change}) assert code == 23 and old == new assert not calls and not (state / "registry-backups").exists() @pytest.mark.parametrize("source", [ {}, {**descriptor(), "verifyOciRevision": False}, {**descriptor(), "verifyOciRevision": "true"}, {**descriptor(), "composeProject": "other"}, {**descriptor(), "composeFile": "other.yml"}, {**descriptor(), "healthTimeout": True}, {**descriptor(), "command": "arbitrary"}, '{"verifyOciRevision":true,"verifyOciRevision":false}', ]) def test_strengthening_requires_exact_boolean_source_contract(transaction, source): code, _result, old, new, _calls, state = transaction(live(), source=source) assert code == 39 and old == new and not (state / "registry-backups").exists() def test_strengthening_rejects_noncurrent_or_unprotected_main(transaction): for options in ({"head": "b" * 40}, {"protected": False}): code, _result, old, new, _calls, state = transaction(live(), **options) assert code == 39 and old == new and not (state / "registry-backups").exists() def test_strengthening_cas_rejects_head_change_after_preflight(transaction): code, result, old, new, calls, _state = transaction(live(), protected_heads=[REVISION, "b" * 40]) assert code == 39 and old == new and len(calls) == 2 assert "verify_oci_revision" not in result[REPOSITORY] def test_delayed_proof_cannot_commit_after_the_registration_polling_window(transaction): code, _result, old, new, calls, state = transaction(live(), expire_during_gate=True) assert code == 39 and old == new and len(calls) == 1 assert not (state / "registry-backups").exists() @pytest.mark.parametrize("gate", [1, 2]) def test_registry_byte_drift_prevents_atomic_install(transaction, gate): code, result, _old, _new, _calls, _state = transaction(live(), mutate_during_gate=gate) assert code == 34 and "verify_oci_revision" not in result[REPOSITORY] def test_verification_cannot_be_weakened(transaction): code, _result, old, new, _calls, state = transaction({**live(), "verify_oci_revision": True}, requested(False)) assert code == 23 and old == new and not (state / "registry-backups").exists() def test_new_registration_keeps_existing_behavior(transaction): code, result, _old, _new, calls, _state = transaction(None) assert code == 0 and result[REPOSITORY] == requested() and not calls def test_registration_api_rejects_string_verification_before_portainer_access(): fake = FakePortainer() result = DeploymentTargetRegistry(fake).register_compose_target(REPOSITORY, REVISION, verify_oci_revision="false") assert result["status"] == "invalid" and not fake.calls @pytest.fixture def original_git_reader(tmp_path, monkeypatch, request): if os.name != "posix" or os.geteuid() != 0: pytest.skip("controller Git mount ownership requires Linux root") work = tmp_path / "work" work.mkdir() repositories = tmp_path / "repositories" bare = repositories / "jens" / "quiz-monitor.git" bare.parent.mkdir(parents=True) monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null") monkeypatch.setenv("GIT_CONFIG_COUNT", "0") def git(*args): return subprocess.check_output(["git", "-C", str(work), *args], text=True).strip() git("init", "-b", "main") git("config", "user.name", "Controller fixture") git("config", "user.email", "controller-fixture@example.invalid") (work / ".itworx").mkdir() source = descriptor() source["verifyOciRevision"] = getattr(request, "param", False) (work / ".itworx/deployment-target.json").write_text(json.dumps(source)) (work / "docker-compose.yml").write_text("services: {}\n") git("add", ".") git("commit", "-m", "original protected source") original = git("rev-parse", "HEAD") replacement_source = {**source, "verifyOciRevision": not source["verifyOciRevision"]} (work / ".itworx/deployment-target.json").write_text(json.dumps(replacement_source)) git("commit", "-am", "replacement object") replacement = git("rev-parse", "HEAD") git("reset", "--hard", original) subprocess.run(["git", "clone", "--bare", str(work), str(bare)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(["git", f"--git-dir={bare}", "replace", original, replacement], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) functions = [node for node in ast.parse(_REGISTER_SCRIPT).body if isinstance(node, ast.FunctionDef) and node.name in {"git_run", "_source_verified_strengthening"}] namespace = {"bare_repo": bare, "subprocess": subprocess, "stat": stat, "time": time, "json": json, "re": __import__("re"), "sys": sys, "branch": "main", "revision": original, "repository": REPOSITORY, "compose_file": "docker-compose.yml", "requested_project": requested(), "strengthening_deadline": time.monotonic() + 20} exec(compile(ast.Module(body=functions, type_ignores=[]), "fixed-git-reader", "exec"), namespace) monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace( _registered_legacy_api_reader=lambda _repo: lambda _path, **_kwargs: { "protected": True, "commit": {"id": original}, }, )) return bare, original, namespace def test_original_descriptor_cannot_be_substituted_by_git_replacement(original_git_reader): bare, original, namespace = original_git_reader # A real replacement keeps the branch SHA but swaps the source descriptor. replaced = subprocess.check_output( ["git", f"--git-dir={bare}", "show", f"{original}:.itworx/deployment-target.json"], text=True, ) assert json.loads(replaced)["verifyOciRevision"] is True result = namespace["git_run"]("show", f"{original}:.itworx/deployment-target.json") assert result.returncode == 0 and json.loads(result.stdout)["verifyOciRevision"] is False assert namespace["_source_verified_strengthening"]() is False @pytest.mark.parametrize("owner", [0, 1000]) def test_read_only_gitea_repository_owner_is_supported(original_git_reader, owner): bare, original, namespace = original_git_reader for directory in (bare.parent.parent, bare.parent, bare): directory.chmod(0o755) os.chown(directory, owner, owner) result = namespace["git_run"]("rev-parse", "refs/heads/main") assert result.returncode == 0 and result.stdout.strip() == original @pytest.mark.parametrize("depth", [0, 1, 2]) @pytest.mark.parametrize("unsafe", ["owner", "writable", "symlink"]) def test_repository_local_trust_rejects_unsafe_path(original_git_reader, depth, unsafe): bare, _original, namespace = original_git_reader directory = (bare.parent.parent, bare.parent, bare)[depth] if unsafe == "owner": os.chown(directory, 1234, 1234) elif unsafe == "writable": directory.chmod(0o775) else: renamed = directory.with_name(directory.name + "-original") directory.rename(renamed) directory.symlink_to(renamed, target_is_directory=True) with pytest.raises(ValueError, match="unsafe mounted repository"): namespace["git_run"]("rev-parse", "refs/heads/main") @pytest.mark.parametrize("original_git_reader", [True], indirect=True) def test_replacement_cannot_hide_valid_original_protected_descriptor(original_git_reader): _bare, _original, namespace = original_git_reader assert namespace["_source_verified_strengthening"]() is True