367 lines
13 KiB
Python
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())
|