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

306 lines
14 KiB
Python

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