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

367 lines
13 KiB
Python

from __future__ import annotations
import importlib.util
import os
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
MODULE = (
Path(__file__).resolve().parents[2]
/ "ops/gitea-deploy-broker/registered_build_context.py"
)
def load():
spec = importlib.util.spec_from_file_location(
"registered_build_context_test", MODULE
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def git(path, *args):
return (
subprocess.run(
["git", "-C", str(path), *args],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
.stdout.decode()
.strip()
)
@pytest.fixture
def inputs(tmp_path):
checkout = tmp_path / "working"
checkout.mkdir()
git(checkout, "init", "-q")
git(checkout, "config", "user.email", "test@example.invalid")
git(checkout, "config", "user.name", "Test")
(checkout / "server").mkdir()
(checkout / "server/Dockerfile").write_text("FROM scratch\nCOPY . /app\n")
(checkout / "server/main.py").write_text("print('protected')\n")
(checkout / "run.sh").write_text("#!/bin/sh\nexit 0\n")
(checkout / "run.sh").chmod(0o755)
git(checkout, "add", ".")
git(checkout, "update-index", "--chmod=+x", "run.sh")
git(checkout, "commit", "-qm", "protected source")
revision = git(checkout, "rev-parse", "HEAD")
bare = tmp_path / "repo.git"
subprocess.run(
["git", "clone", "--bare", "--quiet", str(checkout), str(bare)], check=True
)
live = tmp_path / "live"
live.mkdir()
(live / "server").mkdir()
(live / "server/stale.py").write_text("untracked")
(live / ".env").write_text("PRIVATE_VALUE=do-not-copy")
project = {"slug": "quiz-monitor", "source_dir": str(live), "compose_dir": ""}
model = {
"services": {
"quiz-server": {
"build": {"context": str(live), "dockerfile": "server/Dockerfile"}
}
}
}
return bare, revision, project, model, tmp_path / "state", live, checkout
def prepare(module, inputs):
return module.prepare_build_contexts(*inputs[:5])
def test_exact_archive_omits_live_untracked_files_and_preserves_live_tree(inputs):
result = prepare(load(), inputs)
tree = Path(result["quiz-server"])
assert tree.is_absolute()
assert (tree / "server/main.py").read_text() == "print('protected')\n"
assert not (tree / "server/stale.py").exists()
assert not (tree / ".env").exists()
assert (inputs[5] / "server/stale.py").read_text() == "untracked"
assert (inputs[5] / ".env").read_text() == "PRIVATE_VALUE=do-not-copy"
assert (tree.parent / "manifest.json").is_file()
assert not (tree / "manifest.json").exists()
assert prepare(load(), inputs) == result
if os.name != "nt":
assert tree.parent.stat().st_mode & 0o777 == 0o700
assert (tree / "run.sh").stat().st_mode & 0o777 == 0o755
@pytest.mark.parametrize("change", ["content", "extra", "missing", "mode", "manifest"])
def test_cached_context_drift_fails_without_replacing_source(inputs, change):
module = load()
tree = Path(prepare(module, inputs)["quiz-server"])
if change == "content":
(tree / "server/main.py").write_text("tampered")
elif change == "extra":
(tree / "untracked.py").write_text("tampered")
elif change == "missing":
(tree / "server/main.py").unlink()
elif change == "mode":
if os.name == "nt":
pytest.skip("POSIX permissions are verified on Linux")
(tree / "run.sh").chmod(0o644)
else:
(tree.parent / "manifest.json").write_text("{}")
with pytest.raises(module.RegisteredBuildContextError):
prepare(module, inputs)
assert (inputs[5] / "server/stale.py").is_file()
@pytest.mark.parametrize(
"build",
[
{"context": "../outside", "dockerfile": "Dockerfile"},
{"context": "https://example.invalid/source.git"},
{"context": ".", "dockerfile": "../outside/Dockerfile"},
{"context": ".", "dockerfile": "not-tracked"},
{"context": ".", "additional_contexts": {"other": "/elsewhere"}},
{"context": ".", "dockerfile_inline": "FROM scratch"},
],
)
def test_external_or_untracked_build_inputs_are_rejected(inputs, build):
module = load()
inputs[3]["services"]["quiz-server"]["build"] = build
with pytest.raises(module.RegisteredBuildContextError):
prepare(module, inputs)
def test_subdirectory_context_is_remapped_with_exact_revision(inputs):
inputs[3]["services"]["quiz-server"]["build"] = {
"context": "server",
"dockerfile": "Dockerfile",
}
result = prepare(load(), inputs)
assert Path(result["quiz-server"]).name == "server"
assert (Path(result["quiz-server"]) / "main.py").is_file()
def test_revision_is_not_a_branch_and_old_archive_survives_new_commit(inputs):
module = load()
result = prepare(module, inputs)
inputs[6].joinpath("server/main.py").write_text("changed main")
git(inputs[6], "commit", "-qam", "advance main")
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
assert prepare(module, inputs) == result
assert (
Path(result["quiz-server"]).joinpath("server/main.py").read_text()
== "print('protected')\n"
)
with pytest.raises(module.RegisteredBuildContextError):
module.prepare_build_contexts(inputs[0], "main", *inputs[2:5])
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary")
def test_symlinked_live_context_or_archived_file_is_rejected(inputs):
module = load()
(inputs[5] / "link").symlink_to(inputs[5] / "server", target_is_directory=True)
inputs[3]["services"]["quiz-server"]["build"] = {
"context": "link",
"dockerfile": "Dockerfile",
}
with pytest.raises(module.RegisteredBuildContextError):
prepare(module, inputs)
inputs[6].joinpath("tracked-link").symlink_to("server/main.py")
git(inputs[6], "add", "tracked-link")
git(inputs[6], "commit", "-qm", "unsupported tracked symlink")
revision = git(inputs[6], "rev-parse", "HEAD")
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
inputs[3]["services"]["quiz-server"]["build"] = {
"context": ".",
"dockerfile": "server/Dockerfile",
}
with pytest.raises(module.RegisteredBuildContextError):
module.prepare_build_contexts(inputs[0], revision, *inputs[2:5])
@pytest.mark.parametrize(
"field", ["secrets", "ssh", "additional_contexts", "dockerfile_inline"]
)
def test_build_cannot_request_host_credentials(inputs, field):
module = load()
inputs[3]["services"]["quiz-server"]["build"][field] = ["default"]
with pytest.raises(module.RegisteredBuildContextError):
prepare(module, inputs)
def test_tracked_runtime_env_is_rejected_before_cache_creation(inputs):
module = load()
inputs[6].joinpath(".env").write_text("PRIVATE_CONFIG=value")
git(inputs[6], "add", ".env")
git(inputs[6], "commit", "-qm", "unsupported runtime env")
revision = git(inputs[6], "rev-parse", "HEAD")
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
with pytest.raises(module.RegisteredBuildContextError):
module.prepare_build_contexts(inputs[0], revision, *inputs[2:5])
assert not inputs[4].exists()
def test_git_export_attributes_cannot_change_exact_archive_bytes(inputs):
module = load()
inputs[6].joinpath(".gitattributes").write_text("server/main.py export-ignore\n")
git(inputs[6], "add", ".gitattributes")
git(inputs[6], "commit", "-qm", "unsupported export omission")
revision = git(inputs[6], "rev-parse", "HEAD")
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
with pytest.raises(module.RegisteredBuildContextError):
module.prepare_build_contexts(inputs[0], revision, *inputs[2:5])
assert not inputs[4].joinpath("registered-builds/quiz-monitor", revision).exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary")
def test_cached_directory_link_is_rejected_without_touching_link_target(inputs):
module = load()
tree = Path(prepare(module, inputs)["quiz-server"])
tree.rename(tree.parent / "original")
tree.symlink_to(inputs[5], target_is_directory=True)
with pytest.raises(module.RegisteredBuildContextError):
prepare(module, inputs)
assert (inputs[5] / ".env").is_file()
def test_tree_size_bound_prevents_archive_execution(inputs, monkeypatch):
module = load()
monkeypatch.setattr(module, "MAX_BYTES", 1)
with pytest.raises(module.RegisteredBuildContextError, match="bound_exceeded"):
prepare(module, inputs)
assert not inputs[4].exists()
@pytest.mark.skipif(
os.name == "nt" or os.getuid() != 0,
reason="observed Linux read-only Gitea mount ownership",
)
def test_gitea_owned_readonly_repository_uses_exact_safe_directory(inputs, monkeypatch):
module = load()
os.chown(inputs[0], 1000, 1000)
commands = []
original = module.subprocess.Popen
def spy(command, **kwargs):
commands.append(command)
return original(command, **kwargs)
monkeypatch.setattr(module.subprocess, "Popen", spy)
result = prepare(module, inputs)
assert Path(result["quiz-server"]).joinpath("server/main.py").is_file()
assert commands
assert all(
command[:4]
== ["git", "--no-replace-objects", "-c", f"safe.directory={inputs[0]}"]
for command in commands
)
@pytest.mark.skipif(
os.name == "nt" or os.getuid() != 0, reason="Linux repository UID boundary"
)
@pytest.mark.parametrize("owner,mode", [(1001, 0o755), (1000, 0o775), (0, 0o777)])
def test_unexpected_or_writable_repository_is_rejected(inputs, owner, mode):
module = load()
os.chown(inputs[0], owner, owner)
inputs[0].chmod(mode)
with pytest.raises(module.RegisteredBuildContextError):
prepare(module, inputs)
assert not inputs[4].exists()
def test_windows_path_identity_uses_creation_time_not_inconsistent_ctime(monkeypatch):
module = load()
monkeypatch.setattr(module, "os", SimpleNamespace(name="nt"))
descriptor = SimpleNamespace(
st_dev=1,
st_ino=2,
st_size=3,
st_mtime_ns=40,
st_ctime_ns=40,
st_birthtime_ns=30,
)
named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 30})
assert module._path_identity(descriptor) == module._path_identity(named)
named.st_ino = 9
assert module._path_identity(descriptor) != module._path_identity(named)
def test_posix_path_identity_preserves_change_time(monkeypatch):
module = load()
monkeypatch.setattr(module, "os", SimpleNamespace(name="posix"))
descriptor = SimpleNamespace(
st_dev=1,
st_ino=2,
st_size=3,
st_mtime_ns=40,
st_ctime_ns=40,
)
named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 41})
assert module._path_identity(descriptor) != module._path_identity(named)
def test_descriptor_metadata_changed_during_read_is_rejected(tmp_path, monkeypatch):
module = load()
path = tmp_path / "tracked"
raw = b"tracked bytes"
path.write_bytes(raw)
path.chmod(0o644)
original = module.os.fstat
calls = 0
def changed(descriptor):
nonlocal calls
calls += 1
info = original(descriptor)
if calls == 1:
return info
fields = {
name: getattr(info, name) for name in dir(info) if name.startswith("st_")
}
fields["st_ctime_ns"] += 1
return SimpleNamespace(**fields)
monkeypatch.setattr(module.os, "fstat", changed)
with pytest.raises(module.RegisteredBuildContextError, match="file_changed"):
module._file(
path, {"size": len(raw), "oid": module._blob_digest(raw), "mode": 0o644}
)
def test_git_replacement_refs_cannot_substitute_other_commit_contents(inputs):
checkout, bare, protected = inputs[6], inputs[0], inputs[1]
checkout.joinpath("server/main.py").write_text("replacement commit bytes\n")
git(checkout, "commit", "-qam", "replacement commit")
other = git(checkout, "rev-parse", "HEAD")
git(bare, "fetch", "--quiet", str(checkout), "HEAD")
git(bare, "replace", protected, other)
assert (
git(bare, "show", f"{protected}:server/main.py") == "replacement commit bytes"
)
tree = Path(prepare(load(), inputs)["quiz-server"])
assert tree.joinpath("server/main.py").read_text() == "print('protected')\n"
@pytest.mark.parametrize(
"name", ["C:/outside.txt", "D:outside.txt", "server/file:stream"]
)
def test_git_manifest_rejects_windows_drive_and_stream_paths_before_extraction(
tmp_path, monkeypatch, name
):
module = load()
revision, tree, blob = "a" * 40, "b" * 40, "c" * 40
responses = iter(
[
f"{revision}\n".encode(),
f"{tree}\n".encode(),
f"100644 blob {blob} 1\t{name}\0".encode(),
]
)
monkeypatch.setattr(module, "_git_output", lambda *_args: next(responses))
with pytest.raises(module.RegisteredBuildContextError, match="path_invalid"):
module._manifest(tmp_path, revision)
assert not list(tmp_path.iterdir())