Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -0,0 +1,366 @@
|
||||
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())
|
||||
Reference in new issue
Block a user