462 lines
18 KiB
Python
462 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
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 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 all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls)
|
|
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
|
|
|
|
|
|
@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
|
|
)
|
|
|
|
|
|
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 == []
|
|
|
|
|
|
@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]
|
|
assert calls[-1][0][-5:] == ["up", "-d", "--force-recreate", "--pull", "never", module.SERVICE][-5:]
|
|
assert calls[-2][1] == rendered
|
|
assert calls[-1][1] == rendered
|
|
assert baseline["name"] == "gitea-deploy-runners"
|
|
|
|
|
|
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", "deploy.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)
|
|
|
|
|
|
def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose):
|
|
_baseline, calls = compose
|
|
unsafe = (
|
|
b"name: gitea-deploy-runners\nservices:\n deploy-control:\n"
|
|
b" image: x\n env_file: /tmp/secret\n"
|
|
)
|
|
with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"):
|
|
module.rendered_model(unsafe, REVISION, IMAGE_ID)
|
|
assert calls == []
|
|
|
|
|
|
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
|
|
|
|
|
|
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]
|
|
|
|
def run(arguments, **_kwargs):
|
|
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)
|
|
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)
|