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

939 lines
40 KiB
Python

from __future__ import annotations
import ast
import importlib.util
import io
import json
import os
import subprocess
import sys
import tarfile
import types
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location(
"controller_hub_volume", ROOT / "scripts/prepare_controller_hub_release_volume.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
REVISION = "a" * 40
IMAGE_ID = "sha256:" + "b" * 64
ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n"
def volume():
return {
"Name": module.VOLUME,
"Driver": "local",
"Scope": "local",
"Options": None,
"Labels": dict(module.LABELS),
"Mountpoint": module.MOUNTPOINT.as_posix(),
}
@pytest.mark.parametrize(
("key", "value"),
[
("Name", "operator-volume"),
("Driver", "nfs"),
("Scope", "global"),
("Options", {"device": "/mnt/cache/appdata"}),
("Labels", {}),
("Mountpoint", "/mnt/cache/unsafe"),
],
)
def test_unknown_or_redirected_volume_is_never_adopted(key, value):
item = volume()
item[key] = value
with pytest.raises(module.VolumeTransitionError, match="identity_invalid"):
module.validate_volume(item)
def test_only_the_exact_local_volume_is_accepted():
module.validate_volume(volume())
def writer_inventory(inherited_labels):
identifier = "c" * 64
spec = module._writer_spec(IMAGE_ID, REVISION, kind="config")
return {
"Id": identifier, "Name": f"/{module.WRITER_NAME}", "Image": IMAGE_ID,
"Config": {
"Image": IMAGE_ID, "User": "0:0", "Entrypoint": ["python3"], "Cmd": spec[-2:],
"Labels": {**inherited_labels, module.WRITER_LABEL: "true",
module.WRITER_TARGET_LABEL: "config", module.WRITER_REVISION_LABEL: REVISION},
},
"HostConfig": {
"NetworkMode": "none", "Binds": [], "Privileged": False, "ReadonlyRootfs": True,
"CpuQuota": module.WRITER_CPU_QUOTA, "CpuPeriod": module.WRITER_CPU_PERIOD,
"Memory": module.WRITER_MEMORY_BYTES, "PidsLimit": module.WRITER_PIDS_LIMIT,
"CapDrop": ["ALL"], "Tmpfs": {"/tmp": "rw,nosuid,nodev,size=16777216,mode=1777"},
"SecurityOpt": ["no-new-privileges:true"],
},
"Mounts": [{"Type": "volume", "Name": module.VOLUME,
"Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, "RW": True}],
}
@pytest.mark.parametrize("damage", [None, "changed_image_label", "extra_label", "reserved_image_label", "falsey_labels"])
def test_writer_labels_are_bound_to_exact_image_inheritance(monkeypatch, damage):
inherited = {"org.opencontainers.image.revision": "d" * 40, "io.buildah.version": "1.40.0"}
item = writer_inventory(inherited)
if damage == "changed_image_label":
item["Config"]["Labels"]["org.opencontainers.image.revision"] = "e" * 40
elif damage == "extra_label":
item["Config"]["Labels"]["unexpected"] = "value"
elif damage == "reserved_image_label":
inherited[module.WRITER_LABEL] = "inherited-marker"
elif damage == "falsey_labels":
inherited = []
def run(arguments, **kwargs):
if arguments == ["docker", "inspect", item["Id"]]:
return json.dumps([item]).encode()
assert arguments == ["docker", "image", "inspect", IMAGE_ID]
return json.dumps([{"Id": IMAGE_ID, "Config": {"Labels": inherited}}]).encode()
monkeypatch.setattr(module, "_run", run)
if damage:
with pytest.raises(module.VolumeTransitionError, match="writer_.*invalid"):
module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config")
else:
assert module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config") == item
@pytest.mark.parametrize("damage", [None, "tmpfs_projection", "extra_bind", "wrong_volume", "read_only"])
def test_writer_accepts_declared_tmpfs_without_inspect_mount_projection(monkeypatch, damage):
item = writer_inventory({})
if damage == "tmpfs_projection":
item["Mounts"].append({"Type": "tmpfs", "Destination": "/tmp"})
elif damage == "extra_bind":
item["Mounts"].append({"Type": "bind", "Destination": "/foreign"})
elif damage == "wrong_volume":
item["Mounts"][0]["Name"] = "foreign-volume"
elif damage == "read_only":
item["Mounts"][0]["RW"] = False
def run(arguments, **kwargs):
if arguments == ["docker", "inspect", item["Id"]]:
return json.dumps([item]).encode()
assert arguments == ["docker", "image", "inspect", IMAGE_ID]
return json.dumps([{"Id": IMAGE_ID, "Config": {"Labels": None}}]).encode()
monkeypatch.setattr(module, "_run", run)
if damage in {"extra_bind", "wrong_volume", "read_only"}:
with pytest.raises(module.VolumeTransitionError, match="writer_mount_invalid"):
module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config")
else:
module._inspect_writer(item["Id"], IMAGE_ID, REVISION, kind="config")
@pytest.mark.parametrize("name", ["foreign-project", "", None, "${COMPOSE_PROJECT_NAME}"])
def test_source_compose_name_cannot_be_overridden_by_fixed_project_flag(monkeypatch, name):
def never_compose(*args, **kwargs):
pytest.fail("Foreign source project reached Compose")
monkeypatch.setattr(module, "_run", never_compose)
source = f"name: {json.dumps(name)}\nservices:\n deploy-control:\n image: immutable-controller\n".encode()
with pytest.raises(module.VolumeTransitionError, match="project_invalid"):
module.rendered_model(source, REVISION, IMAGE_ID)
def test_compose_without_explicit_name_retains_canonical_project(monkeypatch):
def run(arguments, *, data=None, **kwargs):
if arguments[:3] == ["docker", "image", "inspect"]:
return IMAGE_ID.encode()
normalized = model()
normalized["name"] = (
arguments[arguments.index("--project-name") + 1]
if "--project-name" in arguments else "data"
)
return json.dumps(normalized).encode()
monkeypatch.setattr(module, "_run", run)
original = b"services:\n deploy-control:\n image: immutable-controller\n"
rendered = json.loads(module.rendered_model(original, REVISION, IMAGE_ID))
assert rendered["name"] == "gitea-deploy-runners"
def model():
return {
"name": "gitea-deploy-runners",
"services": {
"deploy-control": {
"container_name": module.CONTAINER,
"image": "immutable-controller",
"volumes": [
{
"type": "bind",
"source": module.DOCKER_ROOT.as_posix(),
"target": module.DOCKER_ROOT.as_posix(),
"read_only": True,
},
{
"type": "bind",
"source": module.CONTROLLER_DIR.as_posix(),
"target": module.EXECUTABLE_TARGET,
"read_only": True,
},
],
"environment": {"PRIVATE_SETTING": "unchanged"},
},
"runner": {
"image": "pinned-runner",
"volumes": [
{"type": "bind", "source": "/fixed/data", "target": "/data"}
],
},
},
}
@pytest.fixture
def compose(monkeypatch):
baseline = model()
calls = []
def run(arguments, *, data=None, **_kwargs):
calls.append((arguments, data))
if arguments[:3] == ["docker", "image", "inspect"]:
return IMAGE_ID.encode("ascii")
if arguments[-2:] == ["--format", "json"]:
return data if data and data.startswith(b"{") else json.dumps(baseline).encode()
return b""
monkeypatch.setattr(module, "_run", run)
return baseline, calls
def test_compose_uses_protected_code_and_two_config_views(compose):
baseline, calls = compose
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
service = rendered["services"]["deploy-control"]
assert service["image"] == IMAGE_ID
assert service["pull_policy"] == "never"
volumes = service["volumes"]
assert {
"type": "bind",
"source": module._expected_revision_path(REVISION).as_posix(),
"target": module.EXECUTABLE_TARGET,
"read_only": True,
} in volumes
assert {
"type": "volume",
"source": module.VOLUME,
"target": module.TARGET,
"volume": {"nocopy": True},
} in volumes
assert {
"type": "bind",
"source": module._expected_config_path().as_posix(),
"target": module.CONFIG_TARGET,
"read_only": True,
} in volumes
assert len(volumes) == 4
volumes.remove(next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET))
volumes.remove(next(item for item in volumes if item["target"] == module.TARGET))
volumes.remove(next(item for item in volumes if item["target"] == module.CONFIG_TARGET))
assert rendered["volumes"] == {
module.VOLUME: {"name": module.VOLUME, "external": True}
}
assert rendered["services"]["runner"] == baseline["services"]["runner"]
assert service["environment"] == baseline["services"]["deploy-control"]["environment"]
compose_calls = [
arguments for arguments, _ in calls if arguments[:2] == ["docker", "compose"]
]
assert compose_calls
assert all("--project-directory" in arguments for arguments in compose_calls)
assert module.ROOT.as_posix() in compose_calls[0]
assert all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls[1:])
assert all("--env-file" in arguments and "/dev/null" in arguments for arguments in compose_calls)
assert all("-f" in arguments and "-" in arguments for arguments in compose_calls)
assert calls[0][1] == ORIGINAL_COMPOSE
def test_source_relative_controller_bind_uses_original_compose_directory(monkeypatch):
def run(arguments, *, data=None, **kwargs):
if arguments[:3] == ["docker", "image", "inspect"]:
return IMAGE_ID.encode()
normalized = model()
directory = arguments[arguments.index("--project-directory") + 1]
if arguments[-2:] == ["--format", "json"]:
normalized["services"][module.SERVICE]["volumes"][1]["source"] = directory + "/controller"
return json.dumps(normalized).encode()
monkeypatch.setattr(module, "_run", run)
original = ORIGINAL_COMPOSE + b" volumes:\n - ./controller:/opt/gitea-deploy:ro\n"
rendered = json.loads(module.rendered_model(original, REVISION, IMAGE_ID))
mounts = rendered["services"][module.SERVICE]["volumes"]
assert next(m["source"] for m in mounts if m["target"] == module.EXECUTABLE_TARGET) == module._mount_source_for_revision(REVISION)
@pytest.mark.parametrize("damage", ["target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file"])
def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage):
baseline, _ = compose
if damage == "target_collision":
baseline["services"]["deploy-control"]["volumes"].append(
{"type": "bind", "source": "/unsafe", "target": module.TARGET}
)
elif damage == "foreign_volume":
baseline["services"]["runner"]["volumes"].append(
{"type": "volume", "source": module.VOLUME, "target": "/foreign"}
)
elif damage == "graph_rw":
baseline["services"]["deploy-control"]["volumes"][0]["read_only"] = False
elif damage == "relative_bind":
baseline["services"]["runner"]["volumes"][0]["source"] = "./unsafe"
else:
baseline["services"]["deploy-control"]["env_file"] = [".env"]
with pytest.raises(module.VolumeTransitionError):
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
def test_compose_tag_replacement_cannot_change_controller_startup_image(compose, monkeypatch):
_baseline, calls = compose
original_run = module._run
def replaced_tag(arguments, **kwargs):
if arguments[:3] == ["docker", "image", "inspect"]:
return ("sha256:" + "c" * 64).encode("ascii")
return original_run(arguments, **kwargs)
monkeypatch.setattr(module, "_run", replaced_tag)
with pytest.raises(module.VolumeTransitionError, match="image_reference_changed"):
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
assert not any(
arguments[-1:] == ["deploy-control"] and "up" in arguments
for arguments, _ in calls
)
@pytest.mark.parametrize("damage", [None, "foreign_reference", "wrong_project", "wrong_service", "unhealthy", "digest_reference", "wrong_image", "normalized_reference", "interpolation"])
def test_drifted_tag_requires_healthy_current_controller_provenance(compose, monkeypatch, damage):
baseline, calls = compose
reference = "registry.example/controller:stable"
if damage == "digest_reference":
reference = "registry.example/controller@sha256:" + "c" * 64
baseline["services"][module.SERVICE]["image"] = reference
current = {
"Image": IMAGE_ID,
"Config": {"Image": reference, "Labels": {
"com.docker.compose.project": "gitea-deploy-runners",
"com.docker.compose.service": module.SERVICE,
}},
"State": {"Health": {"Status": "healthy"}},
}
if damage == "foreign_reference":
current["Config"]["Image"] = "registry.example/foreign:stable"
elif damage == "wrong_project":
current["Config"]["Labels"]["com.docker.compose.project"] = "foreign"
elif damage == "wrong_service":
current["Config"]["Labels"]["com.docker.compose.service"] = "foreign"
elif damage == "unhealthy":
current["State"]["Health"]["Status"] = "unhealthy"
elif damage == "wrong_image":
current["Image"] = "sha256:" + "c" * 64
original_run = module._run
def drifted_tag(arguments, **kwargs):
if arguments[:3] == ["docker", "image", "inspect"]:
return ("sha256:" + "c" * 64).encode("ascii")
return original_run(arguments, **kwargs)
monkeypatch.setattr(module, "_run", drifted_tag)
source_reference = reference
if damage == "normalized_reference":
source_reference = "registry.example/foreign:stable"
elif damage == "interpolation":
source_reference = "${CONTROLLER_IMAGE}"
original = f"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: {source_reference}\n".encode()
if damage:
with pytest.raises(module.VolumeTransitionError, match="image_reference_(changed|invalid)"):
module.rendered_model(original, REVISION, IMAGE_ID, current_container=current)
else:
rendered = json.loads(module.rendered_model(original, REVISION, IMAGE_ID, current_container=current))
assert rendered["services"][module.SERVICE]["image"] == IMAGE_ID
assert rendered["services"][module.SERVICE]["pull_policy"] == "never"
assert not any("up" in arguments or "pull" in arguments for arguments, _ in calls)
def test_config_snapshot_has_exact_allowlist_and_broker_token():
entries = {
"projects.json": (b"{}", 0o600),
"broker-token": (b"secret", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"secret", 0o600),
"private-tls.json": (b"{}", 0o600),
"appops-enrollment-bootstrap": (b"secret", 0o600),
"webhook-secrets/forge": (b"secret", 0o600),
"appops-deployment-secrets/repopulse": (b"secret", 0o600),
"status_server.py": (b"code", 0o644),
}
directories = {
"": 0o755,
"webhook-secrets": 0o700,
"appops-deployment-secrets": 0o700,
}
config, config_dirs = module._config_tree(entries, directories)
assert set(config) == set(entries) - {"status_server.py"}
assert "broker-token" in config
assert config_dirs == directories
def test_unknown_config_file_is_rejected():
entries = {
"projects.json": (b"{}", 0o600),
"broker-token": (b"secret", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"secret", 0o600),
"shell.py": (b"unexpected", 0o600),
}
with pytest.raises(module.VolumeTransitionError, match="config_snapshot_unknown"):
module._config_tree(entries, {"": 0o755})
def test_partial_config_tree_is_accepted_only_for_crash_resume():
partial = {"projects.json": (b"{}", 0o600), "broker-token": (b"secret", 0o600)}
assert module._config_tree(partial, {"": 0o755}, allow_partial=True) == (
partial, {"": 0o755}
)
with pytest.raises(module.VolumeTransitionError, match="snapshot_incomplete"):
module._config_tree(partial, {"": 0o755})
def test_config_snapshot_requires_private_mode_for_every_file():
entries = {
"projects.json": (b"{}", 0o644),
"broker-token": (b"secret", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"secret", 0o600),
}
with pytest.raises(module.VolumeTransitionError, match="config_file_unsafe"):
module._config_tree(entries, {"": 0o755})
def test_previous_private_tls_is_validated_from_the_attested_config_snapshot(
tmp_path, monkeypatch
):
previous = tmp_path / "previous-controller"
previous.mkdir(mode=0o700)
(previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8")
(previous / "private-tls.json").write_bytes(b"captured private config")
calls = []
monkeypatch.setattr(module, "_run", lambda arguments, **kwargs: calls.append((arguments, kwargs)) or b"")
module._validate_previous_private_tls(tmp_path)
assert calls == [(
["python3", str(previous / "status_server.py"), "--check-private-tls-config"],
{"max_output": 4096},
)]
(previous / "private-tls.json").unlink()
calls.clear()
module._validate_previous_private_tls(tmp_path)
assert calls == []
def test_staged_config_checks_run_new_artifact_with_copied_config_and_bounded_output(
tmp_path, monkeypatch
):
stage_root = tmp_path / "source"
stage_root.mkdir(mode=0o700)
release_tree = (
{
"status_server.py": (b"new status server", 0o644),
"controller-release.json": (b"new release identity", 0o644),
},
{"": 0o755},
)
config_tree = (
{
"projects.json": (b"{\"repo\":{}}", 0o600),
"broker-token": (b"private broker token", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"private validation token", 0o600),
"private-tls.json": (b"private TLS config", 0o600),
"webhook-secrets/repo": (b"private webhook secret", 0o600),
},
{"": 0o755, "webhook-secrets": 0o700},
)
calls = []
validation_root = stage_root / "validation"
def run(arguments, *, data=None, max_output=module.MAX_BYTES):
calls.append((arguments, max_output))
assert (validation_root / "status_server.py").read_bytes() == b"new status server"
assert (validation_root / "projects.json").read_bytes() == b"{\"repo\":{}}"
assert (validation_root / "broker-token").read_bytes() == b"private broker token"
assert (validation_root / "webhook-secrets" / "repo").read_bytes() == b"private webhook secret"
return b"validated"
monkeypatch.setattr(module, "_write_private_tree", _write_test_tree)
monkeypatch.setattr(module, "_run", run)
validator = getattr(module, "_validate_staged_configuration", None)
assert callable(validator), "staged configuration validation helper is required"
validator(stage_root, release_tree, config_tree)
assert calls == [
(["python3", str(validation_root / "status_server.py"), "--check-artifact"], 4096),
(["python3", str(validation_root / "status_server.py"), "--check-config"], 4096),
(["python3", str(validation_root / "status_server.py"), "--check-validation-config"], 4096),
(["python3", str(validation_root / "status_server.py"), "--check-validation-api"], 4096),
(["python3", str(validation_root / "status_server.py"), "--check-private-tls-config"], 4096),
]
assert not validation_root.exists()
def test_staged_config_check_failure_is_sanitized_and_cleans_private_validation_tree(
tmp_path, monkeypatch
):
stage_root = tmp_path / "source"
stage_root.mkdir(mode=0o700)
release_tree = (
{
"status_server.py": (b"new status server", 0o644),
"controller-release.json": (b"new release identity", 0o644),
},
{"": 0o755},
)
config_tree = (
{
"projects.json": (b"{}", 0o600),
"broker-token": (b"top-secret-broker", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"top-secret-validation", 0o600),
},
{"": 0o755},
)
class FailedProcess:
returncode = 1
stdout = b"token=top-secret-validation"
stderr = b"secret=top-secret-broker"
monkeypatch.setattr(module, "_write_private_tree", _write_test_tree)
monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: FailedProcess())
validator = getattr(module, "_validate_staged_configuration", None)
assert callable(validator), "staged configuration validation helper is required"
with pytest.raises(module.VolumeTransitionError, match="command_failed") as error:
validator(stage_root, release_tree, config_tree)
assert "top-secret" not in str(error.value)
assert not (stage_root / "validation").exists()
def _write_test_tree(path, entries, directories):
path.mkdir(mode=0o700)
for relative, _mode in sorted(directories.items(), key=lambda item: item[0].count("/")):
if relative:
(path / relative).mkdir(parents=True, exist_ok=True)
for relative, (contents, _mode) in entries.items():
destination = path / relative
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(contents)
@pytest.mark.skipif(os.name == "nt", reason="private TLS snapshot guard requires POSIX symlink semantics")
def test_previous_private_tls_rejects_symlinked_snapshot(tmp_path):
previous = tmp_path / "previous-controller"
previous.mkdir(mode=0o700)
(previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8")
target = tmp_path / "tls.json"
target.write_bytes(b"unsafe")
(previous / "private-tls.json").symlink_to(target)
with pytest.raises(module.VolumeTransitionError, match="private_tls_unsafe"):
module._validate_previous_private_tls(tmp_path)
@pytest.mark.parametrize("kind", ["artifacts", "config"])
def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind):
command = module._writer_spec(IMAGE_ID, REVISION, kind=kind)
assert command[0:3] == ["docker", "create", "--name"]
assert module.WRITER_NAME in command
assert IMAGE_ID in command
assert "--network" in command and command[command.index("--network") + 1] == "none"
assert "--read-only" in command and "--cap-drop" in command
assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command
assert "--mount" in command
mount = command[command.index("--mount") + 1]
assert mount == f"type=volume,source={module.VOLUME},target={module.TARGET},volume-nocopy"
assert not any(item.startswith(("/mnt/", "/var/lib/")) for item in command)
def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
baseline, calls = compose
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
module.compose_action(rendered, "stop")
module.compose_action(rendered, "up")
assert calls[-2][0][-2:] == ["stop", module.SERVICE]
up_index = calls[-1][0].index("up")
assert calls[-1][0][up_index:] == [
"up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE
]
assert calls[-2][1] == rendered
assert calls[-1][1] == rendered
assert baseline["name"] == "gitea-deploy-runners"
def test_rollback_uses_no_dependencies_when_recreating_prior_controller(compose, tmp_path, monkeypatch):
_baseline, calls = compose
previous = REVISION
target = "c" * 40
previous_compose = module.rendered_model(ORIGINAL_COMPOSE, previous, IMAGE_ID)
target_compose = module.rendered_model(ORIGINAL_COMPOSE, target, IMAGE_ID)
receipt = {
"phase": "activated",
"imageId": IMAGE_ID,
"previousRevision": previous,
"sourceRevision": target,
}
values = {
"controller-before.tar": module._release_tar_bytes(
previous,
({"controller-release.json": (b"previous release", 0o644)}, {"": 0o755}),
),
"controller-after.tar": b"unused by isolated rollback test",
"mirror-before.tar": b"unused by isolated rollback test",
"compose-rollback.json": previous_compose,
"compose-after.json": target_compose,
}
current = {
"Mounts": [{
"Destination": module.EXECUTABLE_TARGET,
"Source": module._expected_revision_path(previous).as_posix(),
}]
}
monkeypatch.setattr(module, "_load_transaction", lambda _backup: (1, receipt, values))
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
monkeypatch.setattr(module, "_controller_image_inspect", lambda *args, **kwargs: current)
monkeypatch.setattr(module, "_verify_release_leaf", lambda *args, **kwargs: True)
monkeypatch.setattr(module, "_stop_current_controller", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_sync_controller_mirror", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_ensure_root_compose", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_verify_compose_image", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_verify_active_mount", lambda *args, **kwargs: None)
monkeypatch.setattr(module, "_set_promotion_phase", lambda *args, **kwargs: None)
module.rollback_promotion(tmp_path / "backup")
up_calls = [
arguments for arguments, _data in calls
if arguments[:2] == ["docker", "compose"] and "up" in arguments
]
assert len(up_calls) == 1
up_index = up_calls[0].index("up")
assert up_calls[0][up_index:] == [
"up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE
]
def test_installer_has_no_shared_executable_stage_or_compose_path():
source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
assert "mktemp -d -p /tmp \"itworx-controller-upgrade-" in source
assert 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE"' not in source
assert 'python3 "${work}/${PREPARER_NAME}"' in source
assert 'mv -- "$CONTROLLER_DIR"' not in source
assert 'cp -a -- "$CONTROLLER_DIR"' not in source
assert "ops/gitea-deploy-broker/mcp_hub_release_store.py" in source
def test_installer_release_order_matches_renderer_contract():
renderer_spec = importlib.util.spec_from_file_location(
"controller_renderer", ROOT / "scripts/prepare_controller_artifact.py"
)
renderer = importlib.util.module_from_spec(renderer_spec)
renderer_spec.loader.exec_module(renderer)
assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
assert module.RELEASE_FILES[:3] == (
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py"
)
@pytest.mark.skipif(os.name == "nt", reason="dirfd swap test requires POSIX openat")
def test_mirror_updates_stay_on_the_open_directory_after_path_swap(tmp_path, monkeypatch):
original = tmp_path / "controller"
moved = tmp_path / "controller-pinned"
original.mkdir()
before_entries = {
"controller_paths.py": (b"prior paths", 0o644),
"mcp_hub_release_store.py": (b"prior Hub helper", 0o644),
}
for name, (value, _mode) in before_entries.items():
(original / name).write_bytes(value)
prior = {
name: (f"prior:{name}".encode(), 0o644)
for name in module.LEGACY_RELEASE_FILES_2_1_13
}
prior["controller-release.json"] = (b"prior manifest", 0o644)
updated = {
name: (f"updated:{name}".encode(), 0o644)
for name in module.RELEASE_FILES
}
updated["controller-release.json"] = (b"updated manifest", 0o644)
prior_tar = module._release_tar_bytes("b" * 40, (prior, {"": 0o755}))
updated_tar = module._release_tar_bytes("c" * 40, (updated, {"": 0o755}))
mirror_tar = module._release_tar_bytes("a" * 40, (before_entries, {"": 0o755}))
current_path = original
monkeypatch.setattr(
module, "_directory",
lambda _path, _anchor: os.open(current_path, os.O_RDONLY | os.O_DIRECTORY),
)
read_item = module._file_info_at_optional
swapped = False
def swap_after_first_read(descriptor, name):
nonlocal swapped
value = read_item(descriptor, name)
if not swapped:
original.rename(moved)
original.mkdir()
swapped = True
return value
monkeypatch.setattr(module, "_file_info_at_optional", swap_after_first_read)
module._sync_controller_mirror(
prior_tar, updated_tar, mirror_tar, direction="forward"
)
assert swapped and list(original.iterdir()) == []
assert (moved / "controller_paths.py").read_bytes() == b"updated:controller_paths.py"
assert (moved / "mcp_hub_release_store.py").read_bytes() == b"updated:mcp_hub_release_store.py"
current_path = moved
monkeypatch.setattr(module, "_file_info_at_optional", read_item)
module._sync_controller_mirror(
updated_tar, prior_tar, mirror_tar, direction="rollback"
)
assert {path.name: path.read_bytes() for path in moved.iterdir()} == {
name: value for name, (value, _mode) in before_entries.items()
}
@pytest.mark.skipif(os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
reason="private /tmp ownership validation requires root on POSIX")
def test_private_stage_rejects_symlinked_source_directory():
import shutil
import uuid
work = Path("/tmp") / f"itworx-controller-upgrade-{uuid.uuid4().hex[:12]}.Ab3xY7"
source = work / "source"
source.mkdir(parents=True, mode=0o700)
source.chmod(0o700)
work.chmod(0o700)
try:
module._validate_private_stage(source)
source.rename(work / "source-real")
source.symlink_to(work / "source-real", target_is_directory=True)
with pytest.raises(module.VolumeTransitionError, match="private_stage_invalid"):
module._validate_private_stage(source)
finally:
shutil.rmtree(work)
@pytest.mark.parametrize("directive", ["env_file", "label_file"])
def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose, directive):
_baseline, calls = compose
unsafe = (
b"name: gitea-deploy-runners\nservices:\n deploy-control:\n"
+ f" image: x\n {directive}: /tmp/secret\n".encode()
)
with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"):
module.rendered_model(unsafe, REVISION, IMAGE_ID)
assert calls == []
@pytest.mark.parametrize(
"source",
[
b"services:\n app:\n 'env_file': /tmp/secret\n",
b"services: {app: {\"include\": ./untrusted.yml}}\n",
b"services:\n - \"build\": ./untrusted\n",
b"services:\n app:\n \"\\u0065nv_file\": /tmp/secret\n",
],
)
def test_source_compose_preflight_rejects_quoted_or_escaped_file_keys(source):
with pytest.raises(module.VolumeTransitionError):
module._preflight_source_compose(source)
def test_preparer_uses_the_pinned_bundled_yaml_parser():
assert module.yaml.__version__ == "6.0.2"
assert module.yaml.__name__.startswith("_appops_controller_yaml_")
assert module.yaml.__with_libyaml__ is False
assert Path(module.yaml.__file__).resolve().parent == ROOT / "scripts/vendor/yaml"
def test_preparer_does_not_replace_a_host_cached_yaml_module(monkeypatch):
host_yaml = types.ModuleType("yaml")
host_yaml.__file__ = "/host/site-packages/yaml/__init__.py"
monkeypatch.setitem(sys.modules, "yaml", host_yaml)
original_sys_path = sys.path.copy()
module_name = "_controller_volume_import_isolation_test"
spec = importlib.util.spec_from_file_location(
module_name, ROOT / "scripts/prepare_controller_hub_release_volume.py"
)
assert spec is not None and spec.loader is not None
isolated_module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, module_name, isolated_module)
spec.loader.exec_module(isolated_module)
assert sys.modules["yaml"] is host_yaml
assert isolated_module.yaml.__name__.startswith("_appops_controller_yaml_")
assert isolated_module.yaml.safe_load(b"services: {}") == {"services": {}}
assert sys.path == original_sys_path
def test_source_compose_preflight_ignores_comments_strings_and_block_scalars():
source = (
b"name: gitea-deploy-runners\n"
b"# include: ./disabled.yml\n"
b"services:\n app:\n"
b" command: |\n"
b" echo 'env_file: is command text'\n"
b" echo include: is command text\n"
b" labels: {note: \"build: is a label value\"}\n"
)
module._preflight_source_compose(source)
def test_source_compose_preflight_rejects_unresolvable_alias_keys():
source = b"services:\n app:\n *unsafe-key: value\n"
with pytest.raises(module.VolumeTransitionError, match="compose_source_invalid"):
module._preflight_source_compose(source)
def test_source_compose_preflight_bounds_recursive_list_aliases():
source = b"services: {app: {image: fixed}}\nx-loop: &a [*a]\n"
module._preflight_source_compose(source)
@pytest.mark.parametrize(
"source",
[
b"services: {}\nconfigs:\n tls-material:\n file: /etc/ssl/private/key.pem\n",
b"services: {}\nsecrets: {gitea-token: {environment: GITEA_TOKEN}}\n",
],
)
def test_source_compose_preflight_rejects_local_config_and_secret_sources(source):
with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"):
module._preflight_source_compose(source)
def test_source_compose_preflight_allows_external_config_and_service_environment_fields():
source = (
b"configs:\n tls-material:\n external: true\n"
b"services:\n app:\n environment:\n file: no-local-file-read\n"
)
module._preflight_source_compose(source)
def test_capture_program_is_fixed_descriptor_based_and_compiles():
legacy = module._controller_capture_source(config_root_in_code=True)
protected = module._controller_capture_source(config_root_in_code=False)
compile(legacy, "legacy-controller-capture", "exec")
compile(protected, "protected-controller-capture", "exec")
assert "config_root='/opt/gitea-deploy'" in legacy
assert "config_root='/controller-config'" in protected
assert "os.O_NOFOLLOW" in legacy and "os.fstat(fd)" in legacy
assert "open(path,'rb')" not in legacy
@pytest.mark.parametrize("legacy_config", [True, False])
def test_capture_archive_roundtrips_through_a_nonseekable_pipe(legacy_config):
source = module._controller_capture_source(config_root_in_code=legacy_config)
statements = ast.parse(source).body
start = next(i for i, node in enumerate(statements)
if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "stream"
for target in node.targets))
end = next(i for i, node in enumerate(statements[start:], start)
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Attribute)
and isinstance(node.value.func.value, ast.Name)
and node.value.func.value.id == "stream" and node.value.func.attr == "close")
archive_program = ast.unparse(ast.Module(body=statements[start:end + 1], type_ignores=[]))
fixture = (
"import io,sys,tarfile\n"
"records={'deploy.py':(b'fixture source',0o644),'secrets/key':(b'fixture secret',0o600)}\n"
"dirs={'secrets':0o700}\n"
)
result = subprocess.run([sys.executable, "-c", fixture + archive_program],
capture_output=True, timeout=10, check=False)
assert result.returncode == 0, result.stderr.decode("utf-8", "replace")
with tarfile.open(fileobj=io.BytesIO(result.stdout), mode="r:") as archive:
assert archive.getnames() == ["secrets", "deploy.py", "secrets/key"]
assert archive.getmember("secrets").mode == 0o700
assert archive.getmember("secrets/key").mode == 0o600
assert archive.extractfile("deploy.py").read() == b"fixture source"
assert archive.extractfile("secrets/key").read() == b"fixture secret"
assert all(member.uid == member.gid == 0 for member in archive.getmembers())
def test_prior_release_capture_must_match_protected_git_tree(monkeypatch):
files = module.LEGACY_RELEASE_FILES_2_1_5
entries = {name: (f"source:{name}".encode(), 0o644) for name in files}
source_revision = "32a6eece0e3e1276946abdc04196c819daf02e2a"
source_tree = "e0d7e80b79504b964d968e51e0d21c288cac8f0d"
manifest = {
"apiVersion": "itworx.controller-artifact/v1",
"controllerVersion": "2.1.5",
"sourceRepository": "jens/unraid-appops-gateway",
"sourceRevision": source_revision,
"sourceTree": source_tree,
"artifactSha256": module._artifact_digest(entries, files),
"builtAt": "2026-10-01T00:00:00.000Z",
}
entries["controller-release.json"] = (
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
)
identity = module._release_identity(entries)
outputs = {"rev-parse": (source_tree + "\n").encode()}
outputs.update({
f"ops/gitea-deploy-broker/{name}": entries[name][0]
for name in files if name != "run_repopulse_adoption.py"
})
outputs["scripts/run_repopulse_adoption.py"] = entries["run_repopulse_adoption.py"][0]
controller_id = "c" * 64
def run(arguments, **_kwargs):
assert arguments[:5] == ["docker", "exec", controller_id, "git",
f"--git-dir={module.SOURCE_GIT_DIR}"]
return outputs[arguments[-1].split(":", 1)[-1]] if arguments[-2] == "show" else outputs["rev-parse"]
monkeypatch.setattr(module, "_run", run)
module._verify_git_source_identity(entries, identity, controller_id=controller_id)
outputs["ops/gitea-deploy-broker/deploy.py"] = b"replaced source"
with pytest.raises(module.VolumeTransitionError, match="source_mismatch"):
module._verify_git_source_identity(entries, identity, controller_id=controller_id)
@pytest.mark.parametrize("controller_id", ["gitea-deploy-control", "", "a" * 63, "a" * 65])
def test_prior_release_source_verification_requires_pinned_container_id(controller_id, monkeypatch):
monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: pytest.fail("Docker must not run"))
with pytest.raises(module.VolumeTransitionError, match="container_invalid"):
module._verify_git_source_identity({}, {}, controller_id=controller_id)
@pytest.mark.parametrize(
("name", "revision", "version", "captured", "source", "accepted"),
[
("run_repopulse_adoption.py", "034c9f844d2455850ba37b31379b7edaa786583e", "2.1.12", b"one\r\ntwo\r\n", b"one\ntwo\n", True),
("deploy.py", "034c9f844d2455850ba37b31379b7edaa786583e", "2.1.12", b"one\r\ntwo\r\n", b"one\ntwo\n", False),
("run_repopulse_adoption.py", "a" * 40, "2.1.12", b"one\r\ntwo\r\n", b"one\ntwo\n", False),
("run_repopulse_adoption.py", "034c9f844d2455850ba37b31379b7edaa786583e", "2.1.13", b"one\r\ntwo\r\n", b"one\ntwo\n", False),
("run_repopulse_adoption.py", "034c9f844d2455850ba37b31379b7edaa786583e", "2.1.12", b"one\r\ntwo\n", b"one\ntwo\n", False),
("run_repopulse_adoption.py", "034c9f844d2455850ba37b31379b7edaa786583e", "2.1.12", b"one\r\nchanged\r\n", b"one\ntwo\n", False),
("run_repopulse_adoption.py", "034c9f844d2455850ba37b31379b7edaa786583e", "2.1.12", b"one\r\r\n", b"one\r\n", False),
],
)
def test_legacy_crlf_compatibility_is_limited_to_one_attested_handoff(
name, revision, version, captured, source, accepted,
):
identity = {"sourceRevision": revision, "controllerVersion": version}
assert module._matches_legacy_crlf_source(name, captured, source, identity) is accepted