306 lines
14 KiB
Python
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
|