3582 lines
157 KiB
Python
3582 lines
157 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import hashlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import stat
|
|
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
|
|
OLD_REVISION = "c" * 40
|
|
IMAGE_ID = "sha256:" + "b" * 64
|
|
ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def compose_config_root(monkeypatch, tmp_path):
|
|
# Production uses the fixed /tmp tmpfs; tests never write outside their tree.
|
|
monkeypatch.setattr(module, "_COMPOSE_CONFIG_ROOT", tmp_path)
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["config", "stop", "up"])
|
|
def test_compose_uses_private_ephemeral_cli_config_with_readonly_root(
|
|
monkeypatch, tmp_path, operation,
|
|
):
|
|
directories = []
|
|
|
|
def run(arguments, *, data=None, diagnostic_action=None, **kwargs):
|
|
# Compose up attempts to mkdir /root/.docker even for a dry run. The
|
|
# controller promoter must keep its root filesystem read-only.
|
|
assert arguments[:2] == ["docker", "--config"]
|
|
directory = Path(arguments[2])
|
|
directories.append(directory)
|
|
assert directory.parent == tmp_path
|
|
assert directory.name.startswith("itworx-controller-compose-")
|
|
assert directory.is_dir() and not directory.is_symlink()
|
|
if os.name == "posix":
|
|
assert stat.S_IMODE(directory.stat().st_mode) == 0o700
|
|
assert directory.stat().st_uid == os.getuid()
|
|
assert arguments[3] == "compose"
|
|
assert diagnostic_action == f"compose_{operation}"
|
|
assert data == b"captured-private-compose"
|
|
assert arguments[-1] == operation
|
|
assert arguments[arguments.index("--env-file") + 1] == "/dev/null"
|
|
assert arguments[arguments.index("-f") + 1] == "-"
|
|
(directory / "generated-client-state").write_bytes(b"private-client-state")
|
|
return b"ok"
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
for _ in range(2):
|
|
assert module._compose([operation], b"captured-private-compose") == b"ok"
|
|
assert directories[0] != directories[1]
|
|
assert all(not directory.exists() for directory in directories)
|
|
|
|
|
|
def test_compose_private_cli_config_is_cleaned_after_command_failure(monkeypatch):
|
|
directories = []
|
|
failure = module.VolumeTransitionError(
|
|
"controller_volume_command_failed",
|
|
command_failure=("compose_up", "exit_1", "mount_read_only"),
|
|
)
|
|
|
|
def run(arguments, **kwargs):
|
|
directory = Path(arguments[2])
|
|
directories.append(directory)
|
|
(directory / "client-state").write_bytes(b"private-state")
|
|
raise failure
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
with pytest.raises(module.VolumeTransitionError) as caught:
|
|
module._compose(["up"], b"private-compose")
|
|
assert caught.value is failure
|
|
assert directories and all(not directory.exists() for directory in directories)
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["compose_config", "compose_stop", "compose_up", "image_inspect"])
|
|
def test_compose_boundary_diagnostic_never_discloses_stderr(monkeypatch, action):
|
|
stderr = b"mkdir /root/.docker: read-only file system fixture-secret-token"
|
|
monkeypatch.setattr(module.subprocess, "run", lambda *args, **kwargs: types.SimpleNamespace(
|
|
returncode=1, stdout=b"", stderr=stderr,
|
|
))
|
|
with pytest.raises(module.VolumeTransitionError) as caught:
|
|
module._run(["docker", "fixed-operation"], diagnostic_action=action)
|
|
diagnostic = module._safe_command_diagnostic(caught.value.command_failure)
|
|
assert diagnostic == f"{action}_exit_1_mount_read_only"
|
|
assert "fixture-secret-token" not in diagnostic
|
|
assert "/root/.docker" not in diagnostic
|
|
|
|
# Frozen historical layouts; these fixtures must not follow the acceptance registry.
|
|
HISTORICAL_PRE_CANDIDATE_RELEASE_FILES = (
|
|
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py",
|
|
"managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py",
|
|
"runner_capacity_controller.py", "runner_capacity_policy.py",
|
|
"runner_fleet_upgrade_v2.py", "status_server.py",
|
|
"repopulse_adoption_store.py", "repopulse_registry_restore.py",
|
|
"repopulse_adoption_executor.py", "repopulse_adoption_effects.py",
|
|
"repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py",
|
|
"stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py",
|
|
"stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py",
|
|
)
|
|
HISTORICAL_CANDIDATE_HELPER_RELEASE_FILES = (
|
|
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py",
|
|
"managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py",
|
|
"runner_capacity_controller.py", "runner_capacity_policy.py",
|
|
"runner_fleet_upgrade_v2.py", "status_server.py", "prepare_mcp_hub_candidate.py",
|
|
"repopulse_adoption_store.py", "repopulse_registry_restore.py",
|
|
"repopulse_adoption_executor.py", "repopulse_adoption_effects.py",
|
|
"repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py",
|
|
"stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py",
|
|
"stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py",
|
|
)
|
|
HISTORICAL_CONFIG_ROOT_RELEASE_FILES = (
|
|
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py",
|
|
"deploy.py", "managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py",
|
|
"runner_capacity_controller.py", "runner_capacity_policy.py", "runner_fleet_upgrade_v2.py",
|
|
"runner_profiles.py", "runner_admission_policy.py", "runner_admission_controller.py",
|
|
"runner_resource_envelope.py", "runner_fleet_specialize.py", "runner_statistics_binding.py",
|
|
"status_server.py", "repopulse_adoption_store.py", "repopulse_registry_restore.py",
|
|
"repopulse_adoption_executor.py", "repopulse_adoption_effects.py",
|
|
"repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py",
|
|
"stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py",
|
|
"stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py",
|
|
)
|
|
|
|
|
|
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 backup_volume():
|
|
return {
|
|
"Name": "itworx-forge-master-key-backup",
|
|
"Driver": "local",
|
|
"Scope": "local",
|
|
"Options": None,
|
|
"Labels": {
|
|
"itworx.managed-by": "deployment-controller",
|
|
"itworx.purpose": "forge-master-key-backup",
|
|
},
|
|
"Mountpoint": "/var/lib/docker/volumes/itworx-forge-master-key-backup/_data",
|
|
}
|
|
|
|
|
|
def test_backup_volume_identity_is_exact_and_never_repairs_existing_metadata():
|
|
module.validate_backup_volume(backup_volume())
|
|
bad = backup_volume()
|
|
bad["Labels"]["itworx.purpose"] = "foreign"
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.validate_backup_volume(bad)
|
|
|
|
|
|
def test_promotion_preparer_creates_only_the_fixed_backup_volume_if_absent(monkeypatch, tmp_path):
|
|
commands = []
|
|
present = False
|
|
|
|
def run(arguments, **_kwargs):
|
|
nonlocal present
|
|
commands.append(arguments)
|
|
if arguments == ["docker", "info", "--format", "{{json .DockerRootDir}}"]:
|
|
return b'"/var/lib/docker"'
|
|
if arguments == ["docker", "volume", "ls", "--format", "{{.Name}}"]:
|
|
return b"" if not present else b"itworx-forge-master-key-backup\n"
|
|
if arguments[:3] == ["docker", "volume", "create"]:
|
|
present = True
|
|
return b"itworx-forge-master-key-backup\n"
|
|
if arguments == ["docker", "volume", "inspect", "itworx-forge-master-key-backup"]:
|
|
return json.dumps([backup_volume()]).encode()
|
|
raise AssertionError(arguments)
|
|
|
|
directory_calls = []
|
|
monkeypatch.setattr(module, "_run", run)
|
|
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
|
|
|
def directory(path, anchor):
|
|
directory_calls.append((path, anchor))
|
|
return 23
|
|
|
|
monkeypatch.setattr(module, "_directory", directory)
|
|
module.ensure_backup_volume(create=True)
|
|
|
|
create = [arguments for arguments in commands if arguments[:3] == ["docker", "volume", "create"]]
|
|
assert create == [[
|
|
"docker", "volume", "create", "--driver", "local",
|
|
"--label", "itworx.managed-by=deployment-controller",
|
|
"--label", "itworx.purpose=forge-master-key-backup",
|
|
"itworx-forge-master-key-backup",
|
|
]]
|
|
assert directory_calls == [
|
|
(module.DOCKER_ROOT / "volumes", module.DOCKER_ROOT),
|
|
(module.BACKUP_MOUNTPOINT, module.DOCKER_ROOT),
|
|
]
|
|
|
|
|
|
def test_existing_backup_volume_is_validated_without_relabel_or_recreation(monkeypatch, tmp_path):
|
|
commands = []
|
|
|
|
def run(arguments, **_kwargs):
|
|
commands.append(arguments)
|
|
if arguments == ["docker", "info", "--format", "{{json .DockerRootDir}}"]:
|
|
return b'"/var/lib/docker"'
|
|
if arguments == ["docker", "volume", "ls", "--format", "{{.Name}}"]:
|
|
return b"itworx-forge-master-key-backup\n"
|
|
if arguments == ["docker", "volume", "inspect", "itworx-forge-master-key-backup"]:
|
|
item = backup_volume()
|
|
item["CreatedAt"] = "2000-01-01T00:00:00Z"
|
|
return json.dumps([item]).encode()
|
|
raise AssertionError(arguments)
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
|
monkeypatch.setattr(
|
|
module, "_directory",
|
|
lambda _path, _anchor: 23,
|
|
)
|
|
module.ensure_backup_volume(create=False)
|
|
assert not any(arguments[:3] == ["docker", "volume", "create"] for arguments in commands)
|
|
|
|
|
|
def test_missing_backup_volume_is_not_created_outside_promotion(monkeypatch, tmp_path):
|
|
commands = []
|
|
|
|
def run(arguments, **_kwargs):
|
|
commands.append(arguments)
|
|
if arguments == ["docker", "info", "--format", "{{json .DockerRootDir}}"]:
|
|
return b'"/var/lib/docker"'
|
|
if arguments == ["docker", "volume", "ls", "--format", "{{.Name}}"]:
|
|
return b""
|
|
raise AssertionError(arguments)
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
|
monkeypatch.setattr(
|
|
module, "_directory",
|
|
lambda _path, _anchor: 23,
|
|
)
|
|
with pytest.raises(module.VolumeTransitionError, match="backup_volume_missing"):
|
|
module.ensure_backup_volume(create=False)
|
|
assert not any(arguments[:3] == ["docker", "volume", "create"] for arguments in commands)
|
|
|
|
|
|
def _prepare_promotion_doubles(monkeypatch, tmp_path, *, config_valid=True):
|
|
events = []
|
|
controller_id = "c" * 64
|
|
current = {
|
|
"Id": controller_id,
|
|
"Mounts": [{
|
|
"Destination": module.EXECUTABLE_TARGET,
|
|
"Source": module.CONTROLLER_DIR.as_posix(),
|
|
}],
|
|
}
|
|
previous_identity = {
|
|
"sourceRevision": "b" * 40,
|
|
"sourceTree": "c" * 40,
|
|
"controllerVersion": "2.1.0",
|
|
}
|
|
config_tree = ({}, {"": 0o755})
|
|
monkeypatch.setattr(module, "_validate_private_stage", lambda _stage: events.append("stage_validated"))
|
|
monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
module, "_controller_image_inspect",
|
|
lambda _image_id: (events.append("controller_inspected") or current),
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "_snapshot_attested_controller",
|
|
lambda *_args, **_kwargs: ({}, {"": 0o755}, previous_identity),
|
|
)
|
|
monkeypatch.setattr(module, "_validate_previous_private_tls", lambda _stage: None)
|
|
monkeypatch.setattr(module, "_release_tree", lambda *_args, **_kwargs: ({}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_config_tree", lambda *_args, **_kwargs: config_tree)
|
|
monkeypatch.setattr(module, "_read_config_leaf", lambda: config_tree)
|
|
monkeypatch.setattr(module, "_prepare_merged_tree", lambda *_args, **_kwargs: ({}, {"": 0o755}))
|
|
|
|
def validate_staged(*_args, **_kwargs):
|
|
events.append("config_validated")
|
|
if not config_valid:
|
|
raise module.VolumeTransitionError("staged_config_invalid")
|
|
|
|
monkeypatch.setattr(module, "_validate_staged_configuration", validate_staged)
|
|
monkeypatch.setattr(
|
|
module, "_install_release_leaf",
|
|
lambda revision, *_args, **_kwargs: events.append(f"release_installed:{revision}"),
|
|
)
|
|
monkeypatch.setattr(module, "_read_root_compose", lambda: (ORIGINAL_COMPOSE, 0o644))
|
|
monkeypatch.setattr(module, "_mirror_snapshot_tar", lambda: b"mirror")
|
|
monkeypatch.setattr(
|
|
module, "rendered_model",
|
|
lambda *_args, **_kwargs: (events.append("compose_rendered") or b"rendered-compose"),
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "_verify_compose_image",
|
|
lambda *_args, **_kwargs: events.append("compose_verified"),
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "_verify_promotion_source_attestation",
|
|
lambda *_args, **_kwargs: events.append("source_attested"),
|
|
)
|
|
monkeypatch.setattr(
|
|
module, "ensure_backup_volume",
|
|
lambda **_kwargs: events.append("backup_volume_prepared"),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(module, "_release_tar_bytes", lambda *_args, **_kwargs: b"tar")
|
|
backup = tmp_path / "promotion-backup"
|
|
backup.mkdir(mode=0o700)
|
|
backup_fd = 23
|
|
monkeypatch.setattr(module, "_create_backup", lambda: (backup, backup_fd))
|
|
monkeypatch.setattr(module, "_write_backup", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
|
return events
|
|
|
|
|
|
def test_promotion_prepares_backup_volume_after_source_stage_controller_and_config_checks(
|
|
monkeypatch, tmp_path
|
|
):
|
|
events = _prepare_promotion_doubles(monkeypatch, tmp_path)
|
|
module.prepare_promotion(
|
|
tmp_path / "stage",
|
|
source_revision="d" * 40,
|
|
source_tree="e" * 40,
|
|
controller_version="2.2.0",
|
|
image_id=IMAGE_ID,
|
|
)
|
|
assert events.index("stage_validated") < events.index("config_validated")
|
|
assert events.index("config_validated") < events.index("backup_volume_prepared")
|
|
assert events.index("compose_rendered") < events.index("backup_volume_prepared")
|
|
assert events.count("compose_verified") == 2
|
|
assert events.index("compose_verified") < events.index("backup_volume_prepared")
|
|
assert events.count("source_attested") == 4
|
|
attestation_after_backup = events.index(
|
|
"source_attested", events.index("backup_volume_prepared") + 1
|
|
)
|
|
assert attestation_after_backup < events.index("release_installed:" + "b" * 40)
|
|
|
|
|
|
def test_promotion_rejects_config_leaf_change_during_staged_validation(monkeypatch, tmp_path):
|
|
events = _prepare_promotion_doubles(monkeypatch, tmp_path)
|
|
original_config = ({}, {"": 0o755})
|
|
changed_config = ({"changed.json": (b"changed", 0o600)}, {"": 0o755})
|
|
reads = 0
|
|
|
|
def read_config():
|
|
nonlocal reads
|
|
reads += 1
|
|
if reads == 1:
|
|
return original_config
|
|
events.append("config_changed")
|
|
return changed_config
|
|
|
|
monkeypatch.setattr(module, "_read_config_leaf", read_config)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="controller_volume_config_changed"):
|
|
module.prepare_promotion(
|
|
tmp_path / "stage",
|
|
source_revision="d" * 40,
|
|
source_tree="e" * 40,
|
|
controller_version="2.2.0",
|
|
image_id=IMAGE_ID,
|
|
)
|
|
|
|
assert reads == 2
|
|
assert events.index("config_validated") < events.index("config_changed")
|
|
assert not any(event.startswith("release_installed:") for event in events)
|
|
|
|
|
|
def test_promotion_rejects_legacy_config_source_change_before_config_install(monkeypatch, tmp_path):
|
|
events = _prepare_promotion_doubles(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(module, "_read_config_leaf", lambda: None)
|
|
monkeypatch.setattr(
|
|
module, "_config_tree",
|
|
lambda entries, _directories, **_kwargs: (
|
|
({"changed.json": (b"changed", 0o600)}, {"": 0o755})
|
|
if "changed.json" in entries else ({}, {"": 0o755})
|
|
),
|
|
)
|
|
identity = {
|
|
"sourceRevision": "b" * 40,
|
|
"sourceTree": "c" * 40,
|
|
"controllerVersion": "2.1.0",
|
|
}
|
|
monkeypatch.setattr(
|
|
module, "_capture_controller_source",
|
|
lambda *_args: ({"changed.json": (b"changed", 0o600)}, {"": 0o755}, identity),
|
|
)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="controller_volume_config_changed"):
|
|
module.prepare_promotion(
|
|
tmp_path / "stage",
|
|
source_revision="d" * 40,
|
|
source_tree="e" * 40,
|
|
controller_version="2.2.0",
|
|
image_id=IMAGE_ID,
|
|
)
|
|
|
|
assert not any(event.startswith("release_installed:") for event in events)
|
|
|
|
|
|
def test_promotion_does_not_prepare_backup_volume_when_staged_configuration_fails(
|
|
monkeypatch, tmp_path
|
|
):
|
|
events = _prepare_promotion_doubles(monkeypatch, tmp_path, config_valid=False)
|
|
with pytest.raises(module.VolumeTransitionError, match="staged_config_invalid"):
|
|
module.prepare_promotion(
|
|
tmp_path / "stage",
|
|
source_revision="d" * 40,
|
|
source_tree="e" * 40,
|
|
controller_version="2.2.0",
|
|
image_id=IMAGE_ID,
|
|
)
|
|
assert "backup_volume_prepared" not in events
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("failed_step", "expected_phase", "error_code"),
|
|
[
|
|
("old_tls", "old_tls", "controller_volume_private_tls_unsafe"),
|
|
("config", "config", "controller_volume_config_missing"),
|
|
("release_build", "release_build", "controller_volume_source_archive_incomplete"),
|
|
("staged_config", "staged_config", "controller_volume_command_failed"),
|
|
("old_release", "old_release", "controller_volume_writer_failed"),
|
|
("new_release", "new_release", "controller_volume_writer_failed"),
|
|
],
|
|
)
|
|
def test_prepare_reports_only_fixed_failure_phase_and_code(
|
|
monkeypatch, tmp_path, capsys, failed_step, expected_phase, error_code
|
|
):
|
|
_prepare_promotion_doubles(monkeypatch, tmp_path)
|
|
def fail(*_args, **_kwargs):
|
|
raise module.VolumeTransitionError(error_code)
|
|
|
|
if failed_step == "old_tls":
|
|
monkeypatch.setattr(module, "_validate_previous_private_tls", fail)
|
|
elif failed_step == "config":
|
|
monkeypatch.setattr(module, "_read_config_leaf", fail)
|
|
elif failed_step == "release_build":
|
|
monkeypatch.setattr(module, "_prepare_merged_tree", fail)
|
|
elif failed_step == "staged_config":
|
|
monkeypatch.setattr(module, "_validate_staged_configuration", fail)
|
|
else:
|
|
installed = 0
|
|
|
|
def install(*_args, **_kwargs):
|
|
nonlocal installed
|
|
installed += 1
|
|
if installed == (1 if failed_step == "old_release" else 2):
|
|
fail()
|
|
|
|
monkeypatch.setattr(module, "_install_release_leaf", install)
|
|
|
|
monkeypatch.setattr(sys, "argv", [
|
|
"preparer", "prepare", "--stage-root", str(tmp_path / "stage"),
|
|
"--source-revision", "d" * 40, "--source-tree", "e" * 40,
|
|
"--controller-version", "2.2.0", "--image-id", IMAGE_ID,
|
|
])
|
|
with pytest.raises(SystemExit) as failure:
|
|
module.main()
|
|
assert str(failure.value) == (
|
|
"controller protected Hub volume transition failed "
|
|
f"phase={expected_phase} code={error_code}"
|
|
)
|
|
assert capsys.readouterr().out == ""
|
|
|
|
|
|
@pytest.mark.parametrize("error", [
|
|
module.VolumeTransitionError("controller_volume_writer_failed\nTOKEN=private"),
|
|
module.VolumeTransitionError("controller_volume_unreviewed_code"),
|
|
ValueError("TOKEN=private"),
|
|
])
|
|
def test_prepare_diagnostic_suppresses_untrusted_error_and_phase(monkeypatch, tmp_path, error):
|
|
def forged_prepare(*_args, **kwargs):
|
|
kwargs["on_phase"]("old_release\nTOKEN=private")
|
|
raise error
|
|
|
|
monkeypatch.setattr(module, "prepare_promotion", forged_prepare)
|
|
monkeypatch.setattr(sys, "argv", [
|
|
"preparer", "prepare", "--stage-root", str(tmp_path / "stage"),
|
|
"--source-revision", "d" * 40, "--source-tree", "e" * 40,
|
|
"--controller-version", "2.2.0", "--image-id", IMAGE_ID,
|
|
])
|
|
with pytest.raises(SystemExit) as failure:
|
|
module.main()
|
|
assert str(failure.value) == (
|
|
"controller protected Hub volume transition failed phase=prepare code=unclassified"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stderr", "expected_condition"),
|
|
[
|
|
(b"container rootfs is marked read-only at /private/path TOKEN=fixture-secret",
|
|
"rootfs_read_only"),
|
|
(b"read-only file system at /private/path TOKEN=fixture-secret", "mount_read_only"),
|
|
(b"permission denied: /private/path TOKEN=fixture-secret", "permission_denied"),
|
|
(b"no such file or directory: /private/path TOKEN=fixture-secret",
|
|
"destination_missing"),
|
|
(b"No such container: private-container-id TOKEN=fixture-secret", "container_missing"),
|
|
(b"client version is too old TOKEN=fixture-secret", "api_version_unsupported"),
|
|
(b"unrecognized failure TOKEN=fixture-secret", None),
|
|
],
|
|
)
|
|
def test_writer_command_failure_is_categorized_without_retaining_stderr(
|
|
monkeypatch, stderr, expected_condition
|
|
):
|
|
monkeypatch.setattr(module.subprocess, "run", lambda *_args, **_kwargs: types.SimpleNamespace(
|
|
returncode=1, stdout=b"", stderr=stderr,
|
|
))
|
|
|
|
with pytest.raises(module.VolumeTransitionError) as failure:
|
|
module._run(
|
|
["docker", "cp", "-", "container:/private/path"],
|
|
data=b"TOKEN=fixture-secret",
|
|
diagnostic_action="writer_copy",
|
|
)
|
|
|
|
assert failure.value.args == ("controller_volume_command_failed",)
|
|
assert failure.value.command_failure == (
|
|
"writer_copy", "exit_1", expected_condition,
|
|
)
|
|
diagnostic = module._safe_command_diagnostic(failure.value.command_failure)
|
|
assert diagnostic is not None
|
|
assert "fixture-secret" not in repr(failure.value)
|
|
assert "fixture-secret" not in diagnostic
|
|
assert "private/path" not in diagnostic
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("failure_kind", "expected_outcome"),
|
|
[("timeout", "timeout"), ("spawn", "spawn_failed"), ("overflow", "output_limit")],
|
|
)
|
|
def test_writer_command_failure_diagnostics_bound_non_exit_outcomes(
|
|
monkeypatch, failure_kind, expected_outcome
|
|
):
|
|
def run(*_args, **_kwargs):
|
|
if failure_kind == "timeout":
|
|
raise subprocess.TimeoutExpired(cmd=["docker", "cp"], timeout=45,
|
|
stderr=b"TOKEN=fixture-secret")
|
|
if failure_kind == "spawn":
|
|
raise OSError("TOKEN=fixture-secret")
|
|
return types.SimpleNamespace(returncode=0, stdout=b"oversized", stderr=b"")
|
|
|
|
monkeypatch.setattr(module.subprocess, "run", run)
|
|
kwargs = {"max_output": 1} if failure_kind == "overflow" else {}
|
|
with pytest.raises(module.VolumeTransitionError) as failure:
|
|
module._run(["docker", "cp", "-", "container:/target"],
|
|
diagnostic_action="writer_copy", **kwargs)
|
|
|
|
assert failure.value.command_failure == ("writer_copy", expected_outcome, None)
|
|
|
|
|
|
def test_prepare_reports_only_allowlisted_command_and_stderr_conditions(monkeypatch, tmp_path):
|
|
def failed_prepare(*_args, **kwargs):
|
|
kwargs["on_phase"]("old_release")
|
|
raise module.VolumeTransitionError(
|
|
"controller_volume_command_failed",
|
|
command_failure=("writer_copy", "exit_1", "rootfs_read_only"),
|
|
cleanup_failure=("writer_cleanup", "exit_2", "permission_denied"),
|
|
)
|
|
|
|
monkeypatch.setattr(module, "prepare_promotion", failed_prepare)
|
|
monkeypatch.setattr(sys, "argv", [
|
|
"preparer", "prepare", "--stage-root", str(tmp_path / "stage"),
|
|
"--source-revision", "d" * 40, "--source-tree", "e" * 40,
|
|
"--controller-version", "2.2.0", "--image-id", IMAGE_ID,
|
|
])
|
|
with pytest.raises(SystemExit) as failure:
|
|
module.main()
|
|
|
|
assert str(failure.value) == (
|
|
"controller protected Hub volume transition failed phase=old_release "
|
|
"code=controller_volume_command_failed "
|
|
"diagnostic=writer_copy_exit_1_rootfs_read_only "
|
|
"cleanupDiagnostic=writer_cleanup_exit_2_permission_denied"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("value", [
|
|
("docker cp /private/file", "exit_1", None),
|
|
("writer_copy", "exit_1", "TOKEN=fixture-secret"),
|
|
("writer_copy", "exit_999", None),
|
|
("writer_copy", "exit_1", "unknown_condition"),
|
|
["writer_copy", "exit_1", None],
|
|
])
|
|
def test_command_diagnostic_rejects_unreviewed_fields(value):
|
|
assert module._safe_command_diagnostic(value) is None
|
|
|
|
|
|
def test_release_writer_cleanup_failure_preserves_primary_copy_failure(monkeypatch):
|
|
partial, expected = _release_with_config()
|
|
monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: partial)
|
|
monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, "_remove_stale_writer", lambda _image_id: None)
|
|
monkeypatch.setattr(module, "_inspect_writer", lambda *_args, **_kwargs: {})
|
|
calls = []
|
|
writer_id = "f" * 64
|
|
|
|
def run(arguments, *, diagnostic_action=None, **_kwargs):
|
|
calls.append(diagnostic_action)
|
|
if diagnostic_action == "writer_create":
|
|
return writer_id.encode()
|
|
if diagnostic_action == "writer_wait":
|
|
return b"0"
|
|
if diagnostic_action == "writer_copy":
|
|
raise module.VolumeTransitionError(
|
|
"controller_volume_command_failed",
|
|
command_failure=("writer_copy", "exit_1", "rootfs_read_only"),
|
|
)
|
|
if diagnostic_action == "writer_cleanup":
|
|
raise module.VolumeTransitionError(
|
|
"controller_volume_command_failed",
|
|
command_failure=("writer_cleanup", "exit_2", "permission_denied"),
|
|
)
|
|
return b""
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
with pytest.raises(module.VolumeTransitionError) as failure:
|
|
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
|
|
|
assert failure.value.command_failure == (
|
|
"writer_copy", "exit_1", "rootfs_read_only",
|
|
)
|
|
assert failure.value.cleanup_failure == (
|
|
"writer_cleanup", "exit_2", "permission_denied",
|
|
)
|
|
assert calls[-2:] == ["writer_copy", "writer_cleanup"]
|
|
|
|
|
|
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,
|
|
"bind": {"propagation": "rslave"},
|
|
} in volumes
|
|
assert {
|
|
"type": "volume",
|
|
"source": module.VOLUME,
|
|
"target": module.TARGET,
|
|
"volume": {"nocopy": True},
|
|
} in volumes
|
|
assert {
|
|
"type": "volume",
|
|
"source": "itworx-forge-master-key-backup",
|
|
"target": "/forge-master-key-backup",
|
|
"volume": {"nocopy": True},
|
|
} in volumes
|
|
assert {
|
|
"type": "bind",
|
|
"source": module._expected_config_path().as_posix(),
|
|
"target": module.CONFIG_TARGET,
|
|
"read_only": True,
|
|
"bind": {"propagation": "rslave"},
|
|
} in volumes
|
|
assert len(volumes) == 5
|
|
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"] == "/forge-master-key-backup"))
|
|
volumes.remove(next(item for item in volumes if item["target"] == module.CONFIG_TARGET))
|
|
assert rendered["volumes"] == {
|
|
module.VOLUME: {"name": module.VOLUME, "external": True},
|
|
"itworx-forge-master-key-backup": {
|
|
"name": "itworx-forge-master-key-backup", "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", "--config"] and arguments[3] == "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_compose_migrates_legacy_config_alias_to_explicit_private_bind(compose):
|
|
baseline, _calls = compose
|
|
baseline["services"][module.SERVICE]["volumes"].append(
|
|
{
|
|
"type": "bind",
|
|
"source": module._expected_config_path().as_posix(),
|
|
"target": module.CONFIG_TARGET,
|
|
"read_only": True,
|
|
}
|
|
)
|
|
|
|
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
aliases = [
|
|
mount for mount in rendered["services"][module.SERVICE]["volumes"]
|
|
if mount.get("target") == module.CONFIG_TARGET
|
|
]
|
|
assert aliases == [
|
|
{
|
|
"type": "bind",
|
|
"source": module._expected_config_path().as_posix(),
|
|
"target": module.CONFIG_TARGET,
|
|
"read_only": True,
|
|
"bind": {"propagation": "rslave"},
|
|
}
|
|
]
|
|
|
|
|
|
def test_compose_accepts_empty_bind_normalization_and_canonicalizes_both_managed_binds(compose):
|
|
baseline, _calls = compose
|
|
volumes = baseline["services"][module.SERVICE]["volumes"]
|
|
next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET)["bind"] = {}
|
|
volumes.append(
|
|
{
|
|
"type": "bind",
|
|
"source": module._expected_config_path().as_posix(),
|
|
"target": module.CONFIG_TARGET,
|
|
"read_only": True,
|
|
"bind": {},
|
|
}
|
|
)
|
|
|
|
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
managed = [
|
|
mount for mount in rendered["services"][module.SERVICE]["volumes"]
|
|
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
|
|
]
|
|
assert {item["target"]: item["bind"] for item in managed} == {
|
|
module.EXECUTABLE_TARGET: {"propagation": "rslave"},
|
|
module.CONFIG_TARGET: {"propagation": "rslave"},
|
|
}
|
|
|
|
|
|
def test_compose_normalizes_all_observed_live_generated_bind_defaults(compose):
|
|
baseline, _calls = compose
|
|
volumes = baseline["services"][module.SERVICE]["volumes"]
|
|
for mount in volumes:
|
|
mount["bind"] = {"create_host_path": True}
|
|
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
graph = next(mount for mount in rendered["services"][module.SERVICE]["volumes"]
|
|
if mount["target"] == module.DOCKER_ROOT.as_posix())
|
|
assert graph == {
|
|
"type": "bind", "source": module.DOCKER_ROOT.as_posix(),
|
|
"target": module.DOCKER_ROOT.as_posix(), "read_only": True,
|
|
}
|
|
module._bound_model(json.dumps(rendered).encode())
|
|
|
|
|
|
@pytest.mark.parametrize("bind", [
|
|
{"create_host_path": 1}, {"create_host_path": "true"},
|
|
{"create_host_path": False},
|
|
{"create_host_path": True, "propagation": "rshared"},
|
|
{"create_host_path": True, "unknown": True},
|
|
])
|
|
def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind):
|
|
baseline, _calls = compose
|
|
graph = next(mount for mount in baseline["services"][module.SERVICE]["volumes"]
|
|
if mount["target"] == module.DOCKER_ROOT.as_posix())
|
|
graph["bind"] = bind
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
|
|
|
|
|
@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET])
|
|
@pytest.mark.parametrize("private", [None, "rprivate", "rslave"])
|
|
def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts(
|
|
compose, target, private
|
|
):
|
|
baseline, _calls = compose
|
|
volumes = baseline["services"][module.SERVICE]["volumes"]
|
|
bind = {"create_host_path": True}
|
|
if private:
|
|
bind["propagation"] = private
|
|
if target == module.EXECUTABLE_TARGET:
|
|
mount = next(item for item in volumes if item["target"] == target)
|
|
mount["bind"] = bind
|
|
else:
|
|
volumes.append({
|
|
"type": "bind", "source": module._expected_config_path().as_posix(),
|
|
"target": target, "read_only": True, "bind": bind,
|
|
})
|
|
|
|
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
managed = [mount for mount in rendered["services"][module.SERVICE]["volumes"]
|
|
if mount["target"] in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}]
|
|
assert len(managed) == 2
|
|
assert all(mount["read_only"] is True for mount in managed)
|
|
assert all(mount["bind"] == {"propagation": "rslave"} for mount in managed)
|
|
assert rendered["services"]["runner"] == baseline["services"]["runner"]
|
|
# Only captured source metadata is normalized. Activation stays canonical.
|
|
module._bound_model(json.dumps(rendered).encode())
|
|
next(mount for mount in managed if mount["target"] == target)["bind"]["create_host_path"] = True
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module._bound_model(json.dumps(rendered).encode())
|
|
|
|
|
|
@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET])
|
|
@pytest.mark.parametrize("bind", [
|
|
{"create_host_path": 1},
|
|
{"create_host_path": "true"},
|
|
{"create_host_path": True, "propagation": "rshared"},
|
|
{"create_host_path": True, "selinux": "z"},
|
|
])
|
|
def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, target, bind):
|
|
baseline, _calls = compose
|
|
volumes = baseline["services"][module.SERVICE]["volumes"]
|
|
if target == module.EXECUTABLE_TARGET:
|
|
next(mount for mount in volumes if mount["target"] == target)["bind"] = bind
|
|
else:
|
|
volumes.append({
|
|
"type": "bind", "source": module._expected_config_path().as_posix(),
|
|
"target": target, "read_only": True, "bind": bind,
|
|
})
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("target", "bind"),
|
|
[
|
|
(module.EXECUTABLE_TARGET, {"propagation": "rshared"}),
|
|
(module.EXECUTABLE_TARGET, {"propagation": "shared"}),
|
|
(module.EXECUTABLE_TARGET, {"propagation": "rslave", "create_host_path": False}),
|
|
(module.CONFIG_TARGET, {"propagation": "rshared"}),
|
|
(module.CONFIG_TARGET, {"propagation": "shared"}),
|
|
(module.CONFIG_TARGET, {"propagation": "rslave", "create_host_path": False}),
|
|
],
|
|
)
|
|
def test_compose_rejects_noncanonical_controller_bind_propagation(compose, target, bind):
|
|
baseline, _calls = compose
|
|
volumes = baseline["services"][module.SERVICE]["volumes"]
|
|
if target == module.EXECUTABLE_TARGET:
|
|
mount = next(item for item in volumes if item["target"] == target)
|
|
mount["bind"] = bind
|
|
else:
|
|
volumes.append(
|
|
{
|
|
"type": "bind",
|
|
"source": module._expected_config_path().as_posix(),
|
|
"target": target,
|
|
"read_only": True,
|
|
"bind": bind,
|
|
}
|
|
)
|
|
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
|
|
|
|
|
def test_compose_accepts_already_canonical_controller_mounts_without_duplicates(compose):
|
|
baseline, _calls = compose
|
|
volumes = baseline["services"][module.SERVICE]["volumes"]
|
|
next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET)["bind"] = {
|
|
"propagation": "rslave"
|
|
}
|
|
volumes.extend(
|
|
[
|
|
{
|
|
"type": "volume",
|
|
"source": module.VOLUME,
|
|
"target": module.TARGET,
|
|
"volume": {"nocopy": True},
|
|
},
|
|
{
|
|
"type": "volume",
|
|
"source": module.BACKUP_VOLUME,
|
|
"target": module.BACKUP_TARGET,
|
|
"volume": {"nocopy": True},
|
|
},
|
|
{
|
|
"type": "bind",
|
|
"source": module._expected_config_path().as_posix(),
|
|
"target": module.CONFIG_TARGET,
|
|
"read_only": True,
|
|
"bind": {"propagation": "rslave"},
|
|
},
|
|
]
|
|
)
|
|
baseline["volumes"] = {
|
|
module.VOLUME: {"name": module.VOLUME, "external": True},
|
|
module.BACKUP_VOLUME: {"name": module.BACKUP_VOLUME, "external": True},
|
|
}
|
|
|
|
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
final_mounts = rendered["services"][module.SERVICE]["volumes"]
|
|
assert len(final_mounts) == len(volumes)
|
|
assert sum(item.get("target") == module.EXECUTABLE_TARGET for item in final_mounts) == 1
|
|
assert sum(item.get("target") == module.TARGET for item in final_mounts) == 1
|
|
assert sum(item.get("target") == module.BACKUP_TARGET for item in final_mounts) == 1
|
|
assert sum(item.get("target") == module.CONFIG_TARGET for item in final_mounts) == 1
|
|
|
|
|
|
def test_activation_and_rollback_models_both_pin_managed_bind_propagation(compose):
|
|
_baseline, _calls = compose
|
|
old_model = json.loads(module.rendered_model(ORIGINAL_COMPOSE, "b" * 40, IMAGE_ID))
|
|
next_model = json.loads(module.rendered_model(ORIGINAL_COMPOSE, "c" * 40, IMAGE_ID))
|
|
|
|
for rendered in (old_model, next_model):
|
|
mounts = rendered["services"][module.SERVICE]["volumes"]
|
|
managed = {
|
|
mount["target"]: mount
|
|
for mount in mounts
|
|
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
|
|
}
|
|
assert set(managed) == {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
|
|
assert managed[module.EXECUTABLE_TARGET]["bind"] == {"propagation": "rslave"}
|
|
assert managed[module.CONFIG_TARGET]["bind"] == {"propagation": "rslave"}
|
|
|
|
|
|
def _legacy_private_compose(revision):
|
|
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, revision, IMAGE_ID))
|
|
for mount in value["services"][module.SERVICE]["volumes"]:
|
|
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
|
mount["bind"] = {"propagation": "rprivate"}
|
|
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
|
|
|
|
|
|
def _projected_compose(captured):
|
|
value = json.loads(captured)
|
|
for mount in value["services"][module.SERVICE]["volumes"]:
|
|
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
|
mount["bind"] = {"propagation": "rslave"}
|
|
assert mount["read_only"] is True
|
|
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("rollback", "current_state", "write_expected", "error"),
|
|
[
|
|
(False, "raw_target", True, None),
|
|
(False, "projected_target", False, None),
|
|
(False, "before", True, None),
|
|
(True, "raw_target", True, None),
|
|
(True, "raw_after", True, None),
|
|
(True, "projected_after", True, None),
|
|
(True, "projected_target", False, None),
|
|
(True, "before", True, None),
|
|
(False, "unrecognized", False, "controller_volume_compose_transition_conflict"),
|
|
(True, "unrecognized", False, "controller_volume_compose_rollback_conflict"),
|
|
],
|
|
)
|
|
def test_root_compose_projects_legacy_receipts_and_bounds_retries(
|
|
compose, monkeypatch, tmp_path, rollback, current_state, write_expected, error,
|
|
):
|
|
_baseline, _calls = compose
|
|
before = b"captured-source-compose"
|
|
after = _legacy_private_compose("c" * 40)
|
|
rollback_value = _legacy_private_compose("b" * 40)
|
|
values = {"compose-after.json": after, "compose-rollback.json": rollback_value}
|
|
raw_target = rollback_value if rollback else after
|
|
projected_target = _projected_compose(raw_target)
|
|
current_by_state = {
|
|
"raw_target": raw_target,
|
|
"projected_target": projected_target,
|
|
"raw_after": after,
|
|
"projected_after": _projected_compose(after),
|
|
"before": before,
|
|
"unrecognized": b"unrecognized-compose",
|
|
}
|
|
live_file = {"value": current_by_state[current_state], "mode": 0o640}
|
|
receipt = {
|
|
"composeBeforeSha256": module._hash(before),
|
|
"composeAfterSha256": module._hash(after),
|
|
"composeRollbackSha256": module._hash(rollback_value),
|
|
}
|
|
writes = []
|
|
|
|
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
|
|
monkeypatch.setattr(
|
|
module, "_file_info_at", lambda *_args: (live_file["value"], live_file["mode"])
|
|
)
|
|
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
|
|
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
|
|
|
def atomic_at(_parent, name, value, expected, *, private, mode):
|
|
assert name == "docker-compose.yml"
|
|
assert expected == live_file["value"]
|
|
assert private is False
|
|
assert mode == 0o640
|
|
writes.append(value)
|
|
live_file["value"] = value
|
|
|
|
monkeypatch.setattr(module, "_atomic_at", atomic_at)
|
|
backup = tmp_path / "backup"
|
|
if error:
|
|
with pytest.raises(module.VolumeTransitionError, match=error):
|
|
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
|
|
assert writes == []
|
|
assert live_file["value"] == current_by_state[current_state]
|
|
return
|
|
|
|
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
|
|
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
|
|
assert len(writes) == int(write_expected)
|
|
assert live_file["value"] == projected_target
|
|
assert module._hash(after) == receipt["composeAfterSha256"]
|
|
assert module._hash(rollback_value) == receipt["composeRollbackSha256"]
|
|
assert values["compose-after.json"] == after
|
|
assert values["compose-rollback.json"] == rollback_value
|
|
|
|
|
|
@pytest.mark.parametrize("rollback", [False, True])
|
|
def test_root_compose_projected_retry_still_checks_file_mode(
|
|
compose, monkeypatch, tmp_path, rollback,
|
|
):
|
|
_baseline, _calls = compose
|
|
after = module.rendered_model(ORIGINAL_COMPOSE, "c" * 40, IMAGE_ID)
|
|
rollback_value = module.rendered_model(ORIGINAL_COMPOSE, "b" * 40, IMAGE_ID)
|
|
projected = rollback_value if rollback else after
|
|
before = b"captured-source-compose"
|
|
receipt = {
|
|
"composeBeforeSha256": module._hash(before),
|
|
"composeAfterSha256": module._hash(after),
|
|
"composeRollbackSha256": module._hash(rollback_value),
|
|
}
|
|
values = {"compose-after.json": after, "compose-rollback.json": rollback_value}
|
|
|
|
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
|
|
monkeypatch.setattr(module, "_file_info_at", lambda *_args: (projected, 0o600))
|
|
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
|
|
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
|
monkeypatch.setattr(
|
|
module, "_atomic_at",
|
|
lambda *_args, **_kwargs: pytest.fail("A projected retry must not rewrite the file"),
|
|
)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="compose_mode_invalid"):
|
|
module._ensure_root_compose(tmp_path / "backup", receipt, values, rollback=rollback)
|
|
|
|
|
|
def test_compose_allows_only_the_fixed_managed_appdata_volume_mount(compose):
|
|
baseline, _calls = compose
|
|
mount = {
|
|
"type": "volume",
|
|
"source": module.MANAGED_APPDATA_VOLUME,
|
|
"target": module.MANAGED_APPDATA_MOUNTPOINT.as_posix(),
|
|
}
|
|
baseline["services"][module.SERVICE]["volumes"].append(mount)
|
|
baseline["volumes"] = {
|
|
module.MANAGED_APPDATA_VOLUME: {
|
|
"name": module.MANAGED_APPDATA_VOLUME,
|
|
"external": True,
|
|
}
|
|
}
|
|
|
|
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
assert mount in rendered["services"][module.SERVICE]["volumes"]
|
|
assert rendered["volumes"][module.MANAGED_APPDATA_VOLUME] == {
|
|
"name": module.MANAGED_APPDATA_VOLUME,
|
|
"external": True,
|
|
}
|
|
|
|
|
|
def test_compose_rejects_a_managed_appdata_target_alias(compose):
|
|
baseline, _calls = compose
|
|
baseline["services"][module.SERVICE]["volumes"].append({
|
|
"type": "volume",
|
|
"source": module.MANAGED_APPDATA_VOLUME,
|
|
"target": "/var/lib/docker/volumes/foreign/_data",
|
|
})
|
|
baseline["volumes"] = {
|
|
module.MANAGED_APPDATA_VOLUME: {
|
|
"name": module.MANAGED_APPDATA_VOLUME,
|
|
"external": True,
|
|
}
|
|
}
|
|
with pytest.raises(module.VolumeTransitionError, match="managed_appdata"):
|
|
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
|
|
|
|
|
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",
|
|
"backup_target_collision", "backup_volume_alias", "backup_target_child_alias",
|
|
"backup_definition_collision",
|
|
],
|
|
)
|
|
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"
|
|
elif damage == "env_file":
|
|
baseline["services"]["deploy-control"]["env_file"] = [".env"]
|
|
elif damage == "backup_target_collision":
|
|
baseline["services"]["deploy-control"]["volumes"].append(
|
|
{"type": "bind", "source": "/foreign", "target": "/forge-master-key-backup"}
|
|
)
|
|
elif damage == "backup_volume_alias":
|
|
baseline["services"]["runner"]["volumes"].append(
|
|
{
|
|
"type": "volume", "source": "itworx-forge-master-key-backup",
|
|
"target": "/foreign-backup",
|
|
}
|
|
)
|
|
elif damage == "backup_target_child_alias":
|
|
baseline["services"]["runner"]["volumes"].append(
|
|
{"type": "bind", "source": "/foreign", "target": "/forge-master-key-backup/state"}
|
|
)
|
|
else:
|
|
baseline["volumes"] = {
|
|
"itworx-forge-master-key-backup": {"name": "foreign", "external": True}
|
|
}
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
|
|
|
|
|
def test_pre_promotion_accepts_the_healthy_controller_without_the_new_mount(compose):
|
|
baseline, _calls = compose
|
|
reference = "registry.example/controller:stable"
|
|
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"}},
|
|
"Mounts": [
|
|
{
|
|
"Type": "volume", "Name": module.VOLUME,
|
|
"Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET,
|
|
"RW": True,
|
|
}
|
|
],
|
|
}
|
|
original = f"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: {reference}\n".encode()
|
|
rendered = json.loads(
|
|
module.rendered_model(original, REVISION, IMAGE_ID, current_container=current)
|
|
)
|
|
assert not any(mount.get("Destination") == "/forge-master-key-backup" for mount in current["Mounts"])
|
|
assert any(
|
|
mount.get("target") == "/forge-master-key-backup"
|
|
and mount.get("source") == "itworx-forge-master-key-backup"
|
|
for mount in rendered["services"][module.SERVICE]["volumes"]
|
|
)
|
|
|
|
|
|
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()
|
|
with pytest.raises(module.VolumeTransitionError, match="image_reference_(changed|invalid)"):
|
|
module.rendered_model(original, REVISION, IMAGE_ID, current_container=current)
|
|
assert not any("up" in arguments or "pull" in arguments for arguments, _ in calls)
|
|
|
|
|
|
def _tag_drift_container():
|
|
return {
|
|
"Id": "d" * 64,
|
|
"Image": IMAGE_ID,
|
|
"Name": f"/{module.CONTAINER}",
|
|
"State": {"Running": True, "Health": {"Status": "healthy"}},
|
|
"Config": {
|
|
"Image": "docker.gitea.com/runner-images:ubuntu-latest",
|
|
"Labels": {
|
|
"com.docker.compose.project": "gitea-deploy-runners",
|
|
"com.docker.compose.service": "deploy-control",
|
|
"com.docker.compose.oneoff": "False",
|
|
},
|
|
},
|
|
"Mounts": [
|
|
{
|
|
"Type": "bind",
|
|
"Source": module.CONTROLLER_DIR.as_posix(),
|
|
"Destination": module.EXECUTABLE_TARGET,
|
|
"RW": False,
|
|
},
|
|
{
|
|
"Type": "bind",
|
|
"Source": module._expected_config_path().as_posix(),
|
|
"Destination": module.CONFIG_TARGET,
|
|
"RW": False,
|
|
},
|
|
{
|
|
"Type": "volume",
|
|
"Name": module.VOLUME,
|
|
"Source": module.MOUNTPOINT.as_posix(),
|
|
"Destination": module.TARGET,
|
|
"RW": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
_DEFAULT_SOURCE_IMAGE_ID = object()
|
|
|
|
|
|
def _install_tag_drift_docker_fixture(
|
|
monkeypatch, *, source_image_id=_DEFAULT_SOURCE_IMAGE_ID, source_reference=None,
|
|
expected_image_output=IMAGE_ID, mutate=None
|
|
):
|
|
source_ref = "docker.gitea.com/runner-images:ubuntu-latest"
|
|
if source_reference is not None:
|
|
source_ref = source_reference
|
|
if source_image_id is _DEFAULT_SOURCE_IMAGE_ID:
|
|
source_image_id = "sha256:" + "c" * 64
|
|
container = _tag_drift_container()
|
|
if mutate is not None:
|
|
mutate(container)
|
|
compose_model = model()
|
|
compose_model["services"][module.SERVICE]["image"] = source_ref
|
|
|
|
def run(arguments, *, data=None, **_kwargs):
|
|
if arguments[:2] == ["docker", "inspect"]:
|
|
return json.dumps([container]).encode("utf-8")
|
|
if arguments[:3] == ["docker", "image", "inspect"]:
|
|
reference = arguments[-1]
|
|
if reference == source_ref:
|
|
if source_image_id is None:
|
|
return b""
|
|
return (source_image_id + "\n").encode("ascii")
|
|
if reference == IMAGE_ID:
|
|
return (expected_image_output + "\n").encode("ascii") if expected_image_output else b""
|
|
return b"sha256:" + b"c" * 64 + b"\n"
|
|
if arguments[-2:] == ["--format", "json"]:
|
|
return json.dumps(compose_model).encode("utf-8")
|
|
return b""
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
return container, source_ref, source_image_id
|
|
|
|
|
|
def test_observed_runner_tag_drift_proof_pins_both_rendered_models(monkeypatch):
|
|
container, source_ref, source_image_id = _install_tag_drift_docker_fixture(monkeypatch)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
|
|
proof = module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
rollback = json.loads(module.rendered_model(
|
|
original, REVISION, IMAGE_ID, source_tag_drift_proof=proof
|
|
))
|
|
forward = json.loads(module.rendered_model(
|
|
original, "c" * 40, IMAGE_ID, source_tag_drift_proof=proof
|
|
))
|
|
|
|
assert proof.source_reference == source_ref
|
|
assert proof.source_image_id == source_image_id
|
|
for rendered in (rollback, forward):
|
|
assert rendered["services"][module.SERVICE]["image"] == IMAGE_ID
|
|
assert rendered["services"][module.SERVICE]["pull_policy"] == "never"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutate", "source_image_id", "expected_error"),
|
|
[
|
|
(lambda item: item["Config"].__setitem__("Image", "wrong:tag"), None, "source_tag_drift_container_invalid"),
|
|
(lambda item: item["Config"]["Labels"].__setitem__("com.docker.compose.project", "other"), None, "source_tag_drift_container_invalid"),
|
|
(lambda item: item["Config"]["Labels"].__setitem__("com.docker.compose.service", "runner"), None, "source_tag_drift_container_invalid"),
|
|
(lambda item: item["Config"]["Labels"].__setitem__("com.docker.compose.oneoff", "True"), None, "source_tag_drift_container_invalid"),
|
|
(lambda item: item["State"].__setitem__("Running", False), None, "container_not_running"),
|
|
(lambda item: item.__setitem__("Name", "/renamed-controller"), None, "container_image_changed"),
|
|
(lambda item: item["Mounts"].__setitem__(0, {"Type": "bind", "Source": "/unsafe", "Destination": module.EXECUTABLE_TARGET, "RW": False}), None, "executable_mount_invalid"),
|
|
(lambda item: item["Mounts"][1].__setitem__("RW", True), None, "config_alias_invalid"),
|
|
(None, None, "image_reference_invalid"),
|
|
(None, IMAGE_ID, "source_tag_drift_not_observed"),
|
|
(None, "not-an-image-id", "image_reference_invalid"),
|
|
],
|
|
)
|
|
def test_observed_runner_tag_drift_rejects_unattested_or_non_drift_cases(
|
|
monkeypatch, mutate, source_image_id, expected_error
|
|
):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(
|
|
monkeypatch, source_image_id=source_image_id, mutate=mutate
|
|
)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match=expected_error):
|
|
module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
|
|
|
|
def test_observed_runner_tag_drift_rejects_wrong_source_tag(monkeypatch):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(
|
|
monkeypatch, source_reference="docker.gitea.com/runner-images@sha256:" + "c" * 64
|
|
)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images@sha256:" + b"c" * 64 + b"\n"
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_reference_invalid"):
|
|
module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
|
|
|
|
def test_observed_runner_tag_drift_rejects_missing_local_expected_image(monkeypatch):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(
|
|
monkeypatch, expected_image_output=""
|
|
)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_expected_image_missing"):
|
|
module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
|
|
|
|
def test_rendered_model_rejects_altered_drift_proof(monkeypatch):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(monkeypatch)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
proof = module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
altered = proof._replace(compose_sha256="0" * 64)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_proof_invalid"):
|
|
module.rendered_model(original, REVISION, IMAGE_ID, source_tag_drift_proof=altered)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutate", "expected_error"),
|
|
[
|
|
(lambda item: item.__setitem__("Id", "e" * 64), "container_changed_during_tag_drift_check"),
|
|
(lambda item: item.__setitem__("Image", "sha256:" + "e" * 64), "container_image_changed"),
|
|
(lambda item: item["Config"].__setitem__("Image", "other:tag"), "source_tag_drift_container_invalid"),
|
|
(lambda item: item["Config"]["Labels"].__setitem__("com.docker.compose.project", "other"), "source_tag_drift_container_invalid"),
|
|
(lambda item: item["Mounts"][1].__setitem__("RW", True), "config_alias_invalid"),
|
|
(lambda item: item["Mounts"].append({"Type": "bind", "Source": "/other", "Destination": "/extra", "RW": False}), "source_tag_drift_proof_invalid"),
|
|
(lambda item: item["Config"].__setitem__("User", "unexpected"), "source_tag_drift_proof_invalid"),
|
|
],
|
|
)
|
|
def test_rendered_model_revalidates_live_container_and_mount_proof(
|
|
monkeypatch, mutate, expected_error
|
|
):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(monkeypatch)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
proof = module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
mutate(container)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match=expected_error):
|
|
module.rendered_model(original, REVISION, IMAGE_ID, source_tag_drift_proof=proof)
|
|
|
|
|
|
def test_rendered_model_rejects_tag_that_resolves_back_to_expected_image(monkeypatch):
|
|
container, source_ref, _observed_id = _install_tag_drift_docker_fixture(monkeypatch)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
proof = module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
original_run = module._run
|
|
|
|
def moved_tag(arguments, **kwargs):
|
|
if arguments[:3] == ["docker", "image", "inspect"] and arguments[-1] == source_ref:
|
|
return (IMAGE_ID + "\n").encode("ascii")
|
|
return original_run(arguments, **kwargs)
|
|
|
|
monkeypatch.setattr(module, "_run", moved_tag)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_changed"):
|
|
module.rendered_model(original, REVISION, IMAGE_ID, source_tag_drift_proof=proof)
|
|
|
|
|
|
def test_rendered_model_rejects_missing_expected_image_after_proof(monkeypatch):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(monkeypatch)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
proof = module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
original_run = module._run
|
|
|
|
def missing_expected(arguments, **kwargs):
|
|
if arguments[:3] == ["docker", "image", "inspect"] and arguments[-1] == IMAGE_ID:
|
|
return b""
|
|
return original_run(arguments, **kwargs)
|
|
|
|
monkeypatch.setattr(module, "_run", missing_expected)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_expected_image_missing"):
|
|
module.rendered_model(original, REVISION, IMAGE_ID, source_tag_drift_proof=proof)
|
|
|
|
|
|
def test_rendered_model_rejects_reuse_with_changed_captured_compose_bytes(monkeypatch):
|
|
container, _source_ref, _observed_id = _install_tag_drift_docker_fixture(monkeypatch)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
proof = module._attest_observed_runner_tag_drift(original, container, IMAGE_ID)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_proof_invalid"):
|
|
module.rendered_model(original + b"# changed captured source\n", REVISION,
|
|
IMAGE_ID, source_tag_drift_proof=proof)
|
|
|
|
|
|
def test_observed_runner_tag_drift_rechecks_same_container_around_image_inspection(monkeypatch):
|
|
source_ref = "docker.gitea.com/runner-images:ubuntu-latest"
|
|
first = _tag_drift_container()
|
|
second = _tag_drift_container()
|
|
second["Id"] = "e" * 64
|
|
compose_model = model()
|
|
compose_model["services"][module.SERVICE]["image"] = source_ref
|
|
inspect_count = 0
|
|
|
|
def run(arguments, *, data=None, **_kwargs):
|
|
nonlocal inspect_count
|
|
if arguments[:2] == ["docker", "inspect"]:
|
|
inspect_count += 1
|
|
return json.dumps([first if inspect_count == 1 else second]).encode("utf-8")
|
|
if arguments[:3] == ["docker", "image", "inspect"]:
|
|
reference = arguments[-1]
|
|
return (("sha256:" + "c" * 64) if reference == source_ref else IMAGE_ID).encode("ascii")
|
|
if arguments[-2:] == ["--format", "json"]:
|
|
return json.dumps(compose_model).encode("utf-8")
|
|
return b""
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="container_changed_during_tag_drift_check"):
|
|
module._attest_observed_runner_tag_drift(original, first, IMAGE_ID)
|
|
|
|
|
|
def test_observed_runner_tag_drift_rejects_mount_change_during_tag_inspection(monkeypatch):
|
|
source_ref = "docker.gitea.com/runner-images:ubuntu-latest"
|
|
first = _tag_drift_container()
|
|
second = _tag_drift_container()
|
|
second["Mounts"][1]["RW"] = True
|
|
compose_model = model()
|
|
compose_model["services"][module.SERVICE]["image"] = source_ref
|
|
inspect_count = 0
|
|
|
|
def run(arguments, *, data=None, **_kwargs):
|
|
nonlocal inspect_count
|
|
if arguments[:2] == ["docker", "inspect"]:
|
|
inspect_count += 1
|
|
return json.dumps([first if inspect_count == 1 else second]).encode("utf-8")
|
|
if arguments[:3] == ["docker", "image", "inspect"]:
|
|
reference = arguments[-1]
|
|
return (("sha256:" + "c" * 64) if reference == source_ref else IMAGE_ID).encode("ascii")
|
|
if arguments[-2:] == ["--format", "json"]:
|
|
return json.dumps(compose_model).encode("utf-8")
|
|
return b""
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="config_alias_invalid"):
|
|
module._attest_observed_runner_tag_drift(original, first, IMAGE_ID)
|
|
|
|
|
|
def test_observed_runner_tag_drift_rejects_config_change_during_tag_inspection(monkeypatch):
|
|
source_ref = "docker.gitea.com/runner-images:ubuntu-latest"
|
|
first = _tag_drift_container()
|
|
second = _tag_drift_container()
|
|
second["Config"]["User"] = "unexpected"
|
|
compose_model = model()
|
|
compose_model["services"][module.SERVICE]["image"] = source_ref
|
|
inspect_count = 0
|
|
|
|
def run(arguments, *, data=None, **_kwargs):
|
|
nonlocal inspect_count
|
|
if arguments[:2] == ["docker", "inspect"]:
|
|
inspect_count += 1
|
|
return json.dumps([first if inspect_count == 1 else second]).encode("utf-8")
|
|
if arguments[:3] == ["docker", "image", "inspect"]:
|
|
reference = arguments[-1]
|
|
return (("sha256:" + "c" * 64) if reference == source_ref else IMAGE_ID).encode("ascii")
|
|
if arguments[-2:] == ["--format", "json"]:
|
|
return json.dumps(compose_model).encode("utf-8")
|
|
return b""
|
|
|
|
monkeypatch.setattr(module, "_run", run)
|
|
original = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: docker.gitea.com/runner-images:ubuntu-latest\n"
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="container_changed_during_tag_drift_check"):
|
|
module._attest_observed_runner_tag_drift(original, first, IMAGE_ID)
|
|
|
|
|
|
def test_cli_explicit_tag_drift_switch_is_accepted_only_for_prepare(monkeypatch, tmp_path):
|
|
calls = []
|
|
monkeypatch.setattr(sys, "argv", [
|
|
"prepare_controller_hub_release_volume.py", "prepare",
|
|
"--stage-root", str(tmp_path), "--source-revision", REVISION,
|
|
"--source-tree", REVISION, "--controller-version", "2.1.13",
|
|
"--image-id", IMAGE_ID, "--allow-observed-runner-tag-drift",
|
|
])
|
|
monkeypatch.setattr(
|
|
module, "prepare_promotion",
|
|
lambda *args, **kwargs: calls.append(kwargs) or tmp_path / "backup",
|
|
)
|
|
|
|
module.main()
|
|
|
|
assert len(calls) == 1
|
|
assert {key: value for key, value in calls[0].items() if key != "on_phase"} == {
|
|
"source_revision": REVISION,
|
|
"source_tree": REVISION,
|
|
"controller_version": "2.1.13",
|
|
"image_id": IMAGE_ID,
|
|
"allow_observed_runner_tag_drift": True,
|
|
}
|
|
assert callable(calls[0]["on_phase"])
|
|
|
|
|
|
def test_cli_rejects_tag_drift_switch_for_non_prepare_operation(monkeypatch):
|
|
monkeypatch.setattr(sys, "argv", [
|
|
"prepare_controller_hub_release_volume.py", "activate",
|
|
"--backup", "/unused", "--allow-observed-runner-tag-drift",
|
|
])
|
|
|
|
with pytest.raises(SystemExit, match="source_tag_recovery_operation_invalid"):
|
|
module.main()
|
|
|
|
|
|
@pytest.mark.parametrize("allow_recovery", [False, True])
|
|
def test_promotion_image_proof_failures_precede_persistent_volume_writes(
|
|
monkeypatch, tmp_path, allow_recovery
|
|
):
|
|
original = b"captured source compose with exact original bytes\n"
|
|
current = _tag_drift_container()
|
|
old_revision = "c" * 40
|
|
config_tree = ({"projects.json": (b"{}", 0o600)}, {"": 0o755})
|
|
compose_model = model()
|
|
compose_model["services"][module.SERVICE]["image"] = "docker.gitea.com/runner-images:ubuntu-latest"
|
|
proof = module._ObservedRunnerTagDriftProof(
|
|
current["Id"], IMAGE_ID, module.OBSERVED_RUNNER_IMAGE_REF,
|
|
"sha256:" + "c" * 64, module._hash(original),
|
|
module._tag_drift_config_identity(current), (),
|
|
)
|
|
stage_validation = []
|
|
writes = []
|
|
|
|
monkeypatch.setattr(module, "_validate_private_stage", lambda *_args: None)
|
|
monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, "ensure_backup_volume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_args, **_kwargs: current)
|
|
monkeypatch.setattr(module, "_snapshot_attested_controller", lambda *_args: (
|
|
{"controller-release.json": (b"captured release", 0o644)},
|
|
{"": 0o755},
|
|
{"sourceRevision": old_revision, "sourceTree": "d" * 40},
|
|
))
|
|
monkeypatch.setattr(module, "_read_root_compose", lambda: (original, 0o600))
|
|
monkeypatch.setattr(module, "_attest_observed_runner_tag_drift", lambda *_args: proof)
|
|
monkeypatch.setattr(module, "_validate_previous_private_tls", lambda *_args: None)
|
|
monkeypatch.setattr(module, "_release_tree", lambda *_args: ({"deploy.py": (b"old", 0o644)}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_config_tree", lambda *_args, **_kwargs: config_tree)
|
|
monkeypatch.setattr(module, "_read_config_leaf", lambda: None)
|
|
monkeypatch.setattr(module, "_prepare_merged_tree", lambda *_args: ({"deploy.py": (b"new", 0o644)}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_validate_staged_configuration", lambda *_args: stage_validation.append(True))
|
|
monkeypatch.setattr(module, "_mirror_snapshot_tar", lambda: b"mirror")
|
|
monkeypatch.setattr(module, "_source_compose_model", lambda _original: (
|
|
compose_model, compose_model["services"][module.SERVICE]
|
|
))
|
|
if allow_recovery:
|
|
def invalidate_after_stage(*_args):
|
|
assert stage_validation
|
|
raise module.VolumeTransitionError("controller_volume_source_tag_drift_changed")
|
|
|
|
monkeypatch.setattr(module, "_verify_tag_drift_proof", invalidate_after_stage)
|
|
else:
|
|
monkeypatch.setattr(
|
|
module, "_verify_source_compose_image",
|
|
lambda *_args: (_ for _ in ()).throw(
|
|
module.VolumeTransitionError("controller_volume_image_reference_changed")
|
|
),
|
|
)
|
|
monkeypatch.setattr(module, "_verify_compose_image", lambda *_args: None)
|
|
monkeypatch.setattr(module, "_install_config_tree", lambda *_args: writes.append("config"))
|
|
monkeypatch.setattr(module, "_install_release_leaf", lambda *_args: writes.append("release"))
|
|
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.prepare_promotion(
|
|
tmp_path, source_revision=REVISION, source_tree="e" * 40,
|
|
controller_version="2.1.13", image_id=IMAGE_ID,
|
|
allow_observed_runner_tag_drift=allow_recovery,
|
|
)
|
|
|
|
assert stage_validation
|
|
assert writes == []
|
|
|
|
|
|
def test_promotion_revalidates_drift_proof_immediately_before_first_volume_write(
|
|
monkeypatch, tmp_path
|
|
):
|
|
original = b"captured source compose with exact original bytes\n"
|
|
current = _tag_drift_container()
|
|
old_revision = "c" * 40
|
|
config_tree = ({"projects.json": (b"{}", 0o600)}, {"": 0o755})
|
|
stage_validation = []
|
|
proof = module._ObservedRunnerTagDriftProof(
|
|
current["Id"], IMAGE_ID, module.OBSERVED_RUNNER_IMAGE_REF,
|
|
"sha256:" + "c" * 64, module._hash(original),
|
|
module._tag_drift_config_identity(current), (),
|
|
)
|
|
writes = []
|
|
proof_checks = 0
|
|
image_checks = 0
|
|
|
|
def source_model(_original):
|
|
compose_model = model()
|
|
compose_model["services"][module.SERVICE]["image"] = module.OBSERVED_RUNNER_IMAGE_REF
|
|
return compose_model, compose_model["services"][module.SERVICE]
|
|
|
|
monkeypatch.setattr(module, "_validate_private_stage", lambda *_args: None)
|
|
monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, "ensure_backup_volume", lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_args, **_kwargs: current)
|
|
monkeypatch.setattr(module, "_snapshot_attested_controller", lambda *_args: (
|
|
{"controller-release.json": (b"captured release", 0o644)},
|
|
{"": 0o755},
|
|
{"sourceRevision": old_revision, "sourceTree": "d" * 40},
|
|
))
|
|
monkeypatch.setattr(module, "_read_root_compose", lambda: (original, 0o600))
|
|
monkeypatch.setattr(module, "_attest_observed_runner_tag_drift", lambda *_args: proof)
|
|
monkeypatch.setattr(module, "_validate_previous_private_tls", lambda *_args: None)
|
|
monkeypatch.setattr(module, "_release_tree", lambda *_args: ({"deploy.py": (b"old", 0o644)}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_config_tree", lambda *_args, **_kwargs: config_tree)
|
|
monkeypatch.setattr(module, "_read_config_leaf", lambda: config_tree)
|
|
monkeypatch.setattr(module, "_prepare_merged_tree", lambda *_args: ({"deploy.py": (b"new", 0o644)}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_validate_staged_configuration", lambda *_args: stage_validation.append(True))
|
|
monkeypatch.setattr(module, "_mirror_snapshot_tar", lambda: b"mirror")
|
|
monkeypatch.setattr(module, "_source_compose_model", source_model)
|
|
monkeypatch.setattr(module, "_compose", lambda *_args, **_kwargs: b"")
|
|
|
|
def verify_tag_proof(*_args):
|
|
nonlocal proof_checks
|
|
proof_checks += 1
|
|
assert stage_validation
|
|
if proof_checks == 4:
|
|
raise module.VolumeTransitionError("controller_volume_source_tag_drift_changed")
|
|
|
|
def verify_image(*_args):
|
|
nonlocal image_checks
|
|
image_checks += 1
|
|
|
|
monkeypatch.setattr(module, "_verify_tag_drift_proof", verify_tag_proof)
|
|
monkeypatch.setattr(module, "_verify_compose_image", verify_image)
|
|
monkeypatch.setattr(module, "_install_config_tree", lambda *_args: writes.append("config"))
|
|
monkeypatch.setattr(module, "_install_release_leaf", lambda *_args: writes.append("release"))
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match="source_tag_drift_changed"):
|
|
module.prepare_promotion(
|
|
tmp_path, source_revision=REVISION, source_tree="e" * 40,
|
|
controller_version="2.1.13", image_id=IMAGE_ID,
|
|
allow_observed_runner_tag_drift=True,
|
|
)
|
|
|
|
assert proof_checks == 4
|
|
assert image_checks == 2
|
|
assert writes == []
|
|
|
|
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 _forge_secret_entries():
|
|
return {
|
|
name: (f'fixture:{index}'.encode(), module.FORGE_SECRET_MODE)
|
|
for index, name in enumerate(sorted(module.FORGE_SECRET_OWNERS))
|
|
}
|
|
|
|
|
|
def _release_with_config(*, forge_secrets=False):
|
|
release = {
|
|
name: (f'fixed:{name}\n'.encode(), 0o644)
|
|
for name in module.RELEASE_FILES
|
|
}
|
|
manifest = {
|
|
'apiVersion': 'itworx.controller-artifact/v1',
|
|
'controllerVersion': '2.1.14',
|
|
'sourceRepository': 'jens/unraid-appops-gateway',
|
|
'sourceRevision': REVISION,
|
|
'sourceTree': 'c' * 40,
|
|
'artifactSha256': module._artifact_digest(release),
|
|
'builtAt': '2026-10-04T00:00:00Z',
|
|
}
|
|
release['controller-release.json'] = (json.dumps(manifest).encode(), 0o644)
|
|
config = {'broker-token': (b'fixture-token', 0o600)}
|
|
directories = {'': 0o755}
|
|
if forge_secrets:
|
|
config.update(_forge_secret_entries())
|
|
directories[module.FORGE_SECRET_DIRECTORY] = 0o700
|
|
return (release, {'': 0o755}), (release | config, directories)
|
|
|
|
|
|
def _fake_release_writer(monkeypatch, state):
|
|
writer_id = 'f' * 64
|
|
calls = []
|
|
|
|
def run(arguments, *, data=None, **_kwargs):
|
|
calls.append(arguments)
|
|
if arguments[:2] == ['docker', 'create']:
|
|
return writer_id.encode()
|
|
if arguments[:2] == ['docker', 'wait']:
|
|
return b'0'
|
|
if arguments[:2] == ['docker', 'cp']:
|
|
state['tree'] = module._read_tar_tree(data)
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode='r:') as archive:
|
|
archive_mode = '-a' in arguments
|
|
state['owners'] = {
|
|
member.name: ((0, 0) if archive_mode else (member.uid, member.gid))
|
|
for member in archive.getmembers() if member.isfile()
|
|
}
|
|
return b''
|
|
|
|
monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, '_remove_stale_writer', lambda _image_id: None)
|
|
monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {})
|
|
monkeypatch.setattr(module, '_run', run)
|
|
return calls
|
|
|
|
|
|
def test_existing_exact_release_subset_is_augmented_and_read_back(monkeypatch):
|
|
old, expected = _release_with_config()
|
|
state = {'tree': old}
|
|
monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: state['tree'])
|
|
calls = _fake_release_writer(monkeypatch, state)
|
|
|
|
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
|
|
|
assert state['tree'] == expected
|
|
assert len([call for call in calls if call[:2] == ['docker', 'create']]) == 1
|
|
assert len([call for call in calls if call[:2] == ['docker', 'cp']]) == 1
|
|
|
|
|
|
def test_divergent_existing_release_refuses_before_writer(monkeypatch):
|
|
old, expected = _release_with_config()
|
|
damaged = dict(old[0])
|
|
damaged['status_server.py'] = (b'tampered', 0o644)
|
|
monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: (damaged, old[1]))
|
|
calls = _fake_release_writer(monkeypatch, {'tree': None})
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match='release_collision'):
|
|
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
|
assert not any(call[:2] == ['docker', 'create'] for call in calls)
|
|
|
|
|
|
def test_complete_existing_release_uses_no_writer(monkeypatch):
|
|
_old, expected = _release_with_config()
|
|
monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: expected)
|
|
calls = _fake_release_writer(monkeypatch, {'tree': None})
|
|
|
|
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
|
|
|
assert not any(call[:2] == ['docker', 'create'] for call in calls)
|
|
|
|
|
|
def test_release_copy_preserves_fixed_forge_file_owners_on_readback(monkeypatch):
|
|
_old, expected = _release_with_config(forge_secrets=True)
|
|
state = {'tree': None, 'owners': {}}
|
|
|
|
def read_leaf(_revision):
|
|
if state['tree'] is not None:
|
|
for name, owner in module.FORGE_SECRET_OWNERS.items():
|
|
if state['owners'].get(name) != owner:
|
|
raise module.VolumeTransitionError('controller_volume_tree_file_unsafe')
|
|
return state['tree']
|
|
|
|
monkeypatch.setattr(module, '_read_release_leaf', read_leaf)
|
|
calls = _fake_release_writer(monkeypatch, state)
|
|
|
|
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
|
|
|
assert state['tree'] == expected
|
|
assert all(state['owners'][name] == owner
|
|
for name, owner in module.FORGE_SECRET_OWNERS.items())
|
|
assert len([call for call in calls if call[:2] == ['docker', 'cp']]) == 1
|
|
assert next(call for call in calls if call[:2] == ['docker', 'cp']) == [
|
|
'docker', 'cp', '-',
|
|
f"{'f' * 64}:{module.TARGET}/{module.ARTIFACTS}/{REVISION}",
|
|
]
|
|
|
|
|
|
def test_complete_forge_secret_bundle_has_exact_export_modes_and_owners():
|
|
entries = {
|
|
'projects.json': (b'{}', 0o600),
|
|
'broker-token': (b'fixture-token', 0o600),
|
|
'gitea-validation.json': (b'{}', 0o600),
|
|
'gitea-validation-token': (b'fixture-validation-token', 0o600),
|
|
} | _forge_secret_entries()
|
|
directories = {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700}
|
|
|
|
config, config_dirs = module._config_tree(entries, directories)
|
|
archive_bytes = module._release_tar_bytes(REVISION, (config, config_dirs))
|
|
restored, restored_dirs = module._read_tar_tree(archive_bytes)
|
|
|
|
assert restored == config
|
|
assert restored_dirs == config_dirs
|
|
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode='r:') as archive:
|
|
for name, owner in module.FORGE_SECRET_OWNERS.items():
|
|
member = archive.getmember(name)
|
|
assert (member.uid, member.gid) == owner
|
|
assert member.mode == module.FORGE_SECRET_MODE
|
|
assert archive.getmember('broker-token').uid == 0
|
|
assert archive.getmember('broker-token').gid == 0
|
|
|
|
|
|
@pytest.mark.parametrize('damage', ['partial', 'unknown', 'wrong_mode', 'nested'])
|
|
def test_forge_secret_snapshot_requires_the_complete_exact_private_tree(damage):
|
|
entries = {
|
|
'projects.json': (b'{}', 0o600),
|
|
'broker-token': (b'fixture-token', 0o600),
|
|
'gitea-validation.json': (b'{}', 0o600),
|
|
'gitea-validation-token': (b'fixture-validation-token', 0o600),
|
|
} | _forge_secret_entries()
|
|
directories = {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700}
|
|
if damage == 'partial':
|
|
entries.pop(min(module.FORGE_SECRET_OWNERS))
|
|
elif damage == 'unknown':
|
|
entries[f'{module.FORGE_SECRET_DIRECTORY}/unexpected.secret'] = (b'x', 0o400)
|
|
elif damage == 'wrong_mode':
|
|
name = min(module.FORGE_SECRET_OWNERS)
|
|
entries[name] = (entries[name][0], 0o600)
|
|
else:
|
|
directories[f'{module.FORGE_SECRET_DIRECTORY}/nested'] = 0o700
|
|
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module._config_tree(entries, directories)
|
|
|
|
|
|
def test_forge_secret_tar_owner_drift_is_rejected():
|
|
output = io.BytesIO()
|
|
with tarfile.open(fileobj=output, mode='w', format=tarfile.USTAR_FORMAT) as archive:
|
|
member = tarfile.TarInfo(
|
|
f'{module.FORGE_SECRET_DIRECTORY}/runtime_database_url.secret'
|
|
)
|
|
member.size = 1
|
|
member.mode = module.FORGE_SECRET_MODE
|
|
member.uid = member.gid = 0
|
|
archive.addfile(member, io.BytesIO(b'x'))
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match='entry_unsafe'):
|
|
module._read_tar_tree(output.getvalue())
|
|
|
|
|
|
def test_config_writer_uses_tar_ownership_without_copy_archive_mode(monkeypatch):
|
|
tree = (
|
|
{
|
|
'projects.json': (b'{}', 0o600),
|
|
'broker-token': (b'fixture-token', 0o600),
|
|
'gitea-validation.json': (b'{}', 0o600),
|
|
'gitea-validation-token': (b'fixture-validation-token', 0o600),
|
|
} | _forge_secret_entries(),
|
|
{'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700},
|
|
)
|
|
writer_id = 'f' * 64
|
|
expected_archive = module._release_tar_bytes('a' * 40, tree)
|
|
commands = []
|
|
copy_payloads = []
|
|
copy_owners = {}
|
|
|
|
def run(arguments, *, data=None, **_kwargs):
|
|
commands.append(arguments)
|
|
if arguments == ['docker', 'create']:
|
|
return writer_id.encode()
|
|
if arguments[:2] == ['docker', 'wait']:
|
|
return b'0'
|
|
if arguments[:2] == ['docker', 'cp']:
|
|
copy_payloads.append(data)
|
|
with tarfile.open(fileobj=io.BytesIO(data), mode='r:') as archive:
|
|
archive_mode = '-a' in arguments
|
|
copy_owners.update({
|
|
member.name: ((0, 0) if archive_mode else (member.uid, member.gid))
|
|
for member in archive.getmembers() if member.isfile()
|
|
})
|
|
return b''
|
|
|
|
monkeypatch.setattr(module, '_read_partial_config_leaf', lambda: None)
|
|
monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None)
|
|
monkeypatch.setattr(module, '_release_tar_bytes', lambda *_args, **_kwargs: expected_archive)
|
|
monkeypatch.setattr(module, '_remove_stale_writer', lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(module, '_writer_spec', lambda *_args, **_kwargs: ['docker', 'create'])
|
|
monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {})
|
|
monkeypatch.setattr(module, '_read_config_leaf', lambda: tree)
|
|
monkeypatch.setattr(module, '_run', run)
|
|
|
|
module._install_config_tree(tree, IMAGE_ID)
|
|
|
|
assert [command for command in commands if command[:2] == ['docker', 'cp']] == [[
|
|
'docker', 'cp', '-', f'{writer_id}:{module.TARGET}/{module.CONFIG_DIR}',
|
|
]]
|
|
assert copy_payloads == [expected_archive]
|
|
assert all(copy_owners[name] == owner
|
|
for name, owner in module.FORGE_SECRET_OWNERS.items())
|
|
|
|
|
|
def test_partial_forge_bundle_fails_during_promotion_preparation_before_stop(
|
|
monkeypatch, tmp_path
|
|
):
|
|
valid_config_tree = module._config_tree
|
|
events = _prepare_promotion_doubles(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(module, '_config_tree', valid_config_tree)
|
|
partial = {
|
|
'projects.json': (b'{}', 0o600),
|
|
'broker-token': (b'fixture-token', 0o600),
|
|
'gitea-validation.json': (b'{}', 0o600),
|
|
'gitea-validation-token': (b'fixture-validation-token', 0o600),
|
|
min(module.FORGE_SECRET_OWNERS): (b'fixture', module.FORGE_SECRET_MODE),
|
|
}
|
|
directories = {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700}
|
|
identity = {
|
|
'sourceRevision': 'b' * 40,
|
|
'sourceTree': 'c' * 40,
|
|
'controllerVersion': '2.1.0',
|
|
}
|
|
monkeypatch.setattr(
|
|
module, '_snapshot_attested_controller',
|
|
lambda *_args, **_kwargs: (partial, directories, identity),
|
|
)
|
|
|
|
with pytest.raises(module.VolumeTransitionError, match='forge_secret_bundle_incomplete'):
|
|
module.prepare_promotion(
|
|
tmp_path / 'stage',
|
|
source_revision='d' * 40,
|
|
source_tree='e' * 40,
|
|
controller_version='2.2.0',
|
|
image_id=IMAGE_ID,
|
|
)
|
|
|
|
assert 'backup_volume_prepared' not in events
|
|
|
|
|
|
@pytest.mark.skipif(os.name == 'nt' or not hasattr(os, 'geteuid') or os.geteuid() != 0,
|
|
reason='fixed Forge ownership roundtrip requires Linux root')
|
|
def test_forge_secret_filesystem_capture_and_private_stage_keep_exact_owner(tmp_path):
|
|
config_root = tmp_path / 'config'
|
|
config_root.mkdir(mode=0o700)
|
|
secret_root = config_root / module.FORGE_SECRET_DIRECTORY
|
|
secret_root.mkdir(mode=0o700)
|
|
for index, (relative, owner) in enumerate(sorted(module.FORGE_SECRET_OWNERS.items())):
|
|
path = config_root / relative
|
|
path.write_bytes(f'fixture:{index}'.encode())
|
|
os.chown(path, *owner)
|
|
path.chmod(module.FORGE_SECRET_MODE)
|
|
|
|
descriptor = os.open(config_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
try:
|
|
entries, directories = module._read_tree_fd(descriptor, allow_forge_secrets=True)
|
|
finally:
|
|
os.close(descriptor)
|
|
config, config_dirs = module._config_tree(entries, directories, allow_partial=True)
|
|
payload = module._release_tar_bytes(REVISION, (config, config_dirs))
|
|
roundtrip, roundtrip_dirs = module._read_tar_tree(payload)
|
|
assert roundtrip == config
|
|
assert roundtrip_dirs == config_dirs
|
|
|
|
staged = tmp_path / 'private-stage'
|
|
module._write_private_tree(staged, config, config_dirs)
|
|
for relative, owner in module.FORGE_SECRET_OWNERS.items():
|
|
info = (staged / relative).stat(follow_symlinks=False)
|
|
assert (info.st_uid, info.st_gid) == owner
|
|
assert stat.S_IMODE(info.st_mode) == module.FORGE_SECRET_MODE
|
|
|
|
|
|
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 command[command.index("--user") + 1] == "0:0"
|
|
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)
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["writer_copy", "config_writer_copy"])
|
|
def test_copy_diagnostic_classifies_missing_passwd_user_without_retaining_user_value(action):
|
|
stderr = (b'Error response from daemon: getent unable to find entry '
|
|
b'"fixture-user-name" in passwd database')
|
|
|
|
assert module._command_stderr_condition(stderr, action, max_bytes=1024) == (
|
|
"passwd_user_lookup_failed"
|
|
)
|
|
diagnostic = module._safe_command_diagnostic(
|
|
(action, "exit_1", "passwd_user_lookup_failed")
|
|
)
|
|
assert diagnostic == f"{action}_exit_1_passwd_user_lookup_failed"
|
|
assert "fixture-user-name" not in diagnostic
|
|
|
|
|
|
@pytest.mark.parametrize('stderr,condition', [
|
|
(b'cannot create /private/fixture-secret: No space left on device', 'storage_full'),
|
|
(b'Cannot connect to the Docker daemon at unix:///private/fixture-secret', 'daemon_unavailable'),
|
|
])
|
|
def test_storage_command_diagnostics_never_retain_private_paths(stderr, condition):
|
|
assert module._command_stderr_condition(stderr, 'compose_up', max_bytes=1024) == condition
|
|
diagnostic = module._safe_command_diagnostic(('compose_up', 'exit_1', condition))
|
|
assert diagnostic == f'compose_up_exit_1_{condition}'
|
|
assert 'fixture-secret' not in diagnostic
|
|
|
|
|
|
@pytest.mark.parametrize('read_only,free_bytes', [(True, 1024**3), (False, 0)])
|
|
def test_stop_rejects_failed_storage_before_recording_intent_or_stopping(monkeypatch, read_only, free_bytes):
|
|
receipt = {'phase': 'prepared'}
|
|
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
|
|
monkeypatch.setattr(module.os, 'close', lambda _: None)
|
|
monkeypatch.setattr(module.os, 'statvfs', lambda _: types.SimpleNamespace(
|
|
f_flag=1 if read_only else 0, f_bavail=free_bytes, f_frsize=1,
|
|
), raising=False)
|
|
def forbidden(*args, **kwargs):
|
|
pytest.fail('Storage rejection must happen before any persisted stop intent or container stop')
|
|
monkeypatch.setattr(module, '_set_promotion_phase', forbidden)
|
|
monkeypatch.setattr(module, '_stop_current_controller', forbidden)
|
|
code = 'controller_volume_storage_read_only' if read_only else 'controller_volume_storage_insufficient'
|
|
with pytest.raises(module.VolumeTransitionError, match=code):
|
|
module.stop_promotion(Path('/unused-backup'))
|
|
assert receipt['phase'] == 'prepared'
|
|
|
|
|
|
@pytest.mark.parametrize('native_read_only_graph', [False, True])
|
|
def test_stop_allows_healthy_storage_and_preserves_intent_before_stop(monkeypatch, native_read_only_graph):
|
|
receipt = {'phase': 'prepared'}
|
|
events = []
|
|
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
|
|
monkeypatch.setattr(module.os, 'close', lambda _: None)
|
|
def filesystem(path):
|
|
events.append(('storage', path))
|
|
return types.SimpleNamespace(f_flag=1 if native_read_only_graph and path == module.DOCKER_ROOT else 0, f_bavail=1024**3, f_frsize=1)
|
|
monkeypatch.setattr(module.os, 'statvfs', filesystem, raising=False)
|
|
def persist(descriptor, value, phase):
|
|
events.append(('intent', phase))
|
|
return {**value, 'phase': phase}
|
|
monkeypatch.setattr(module, '_set_promotion_phase', persist)
|
|
monkeypatch.setattr(module, '_stop_current_controller', lambda *a, **kw: events.append(('stop', None)))
|
|
module.stop_promotion(Path('/unused-backup'))
|
|
assert events == [('storage', module.ROOT), ('storage', module.DOCKER_ROOT), ('intent', 'stopped'), ('stop', None)]
|
|
|
|
|
|
def test_stop_rejects_full_graph_even_with_native_read_only_bind(monkeypatch):
|
|
receipt = {'phase': 'prepared'}
|
|
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
|
|
monkeypatch.setattr(module.os, 'close', lambda _: None)
|
|
monkeypatch.setattr(module.os, 'statvfs', lambda p: types.SimpleNamespace(
|
|
f_flag=1 if p == module.DOCKER_ROOT else 0,
|
|
f_bavail=0 if p == module.DOCKER_ROOT else 1024**3, f_frsize=1,
|
|
), raising=False)
|
|
def forbidden(*args, **kwargs):
|
|
pytest.fail('Full graph must not record a stop or interrupt the controller')
|
|
monkeypatch.setattr(module, '_set_promotion_phase', forbidden)
|
|
monkeypatch.setattr(module, '_stop_current_controller', forbidden)
|
|
with pytest.raises(module.VolumeTransitionError, match='controller_volume_storage_insufficient'):
|
|
module.stop_promotion(Path('/unused-backup'))
|
|
|
|
|
|
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"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("operation", "phase"),
|
|
[("activate", "stopped"), ("rollback", "stopped"), ("rollback", "activated")],
|
|
)
|
|
def test_promotion_uses_projected_persistent_compose_without_dependencies(
|
|
compose, tmp_path, monkeypatch, operation, phase,
|
|
):
|
|
_baseline, calls = compose
|
|
previous = REVISION
|
|
target = "c" * 40
|
|
previous_compose = _legacy_private_compose(previous)
|
|
target_compose = _legacy_private_compose(target)
|
|
receipt = {
|
|
"phase": phase,
|
|
"imageId": IMAGE_ID,
|
|
"previousRevision": previous,
|
|
"sourceRevision": target,
|
|
"composeBeforeSha256": module._hash(b"captured-source-compose"),
|
|
"composeAfterSha256": module._hash(target_compose),
|
|
"composeRollbackSha256": module._hash(previous_compose),
|
|
}
|
|
values = {
|
|
"controller-before.tar": b"previous tree",
|
|
"controller-after.tar": b"target tree",
|
|
"mirror-before.tar": b"mirror snapshot",
|
|
"compose-rollback.json": previous_compose,
|
|
"compose-after.json": target_compose,
|
|
}
|
|
current = {"Mounts": [{
|
|
"Destination": module.EXECUTABLE_TARGET,
|
|
"Source": module._expected_revision_path(target).as_posix(),
|
|
}]}
|
|
live_file = {"value": target_compose, "mode": 0o640}
|
|
|
|
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, "_read_tar_tree", lambda _value: ({}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_verify_release_leaf", lambda *args, **kwargs: True)
|
|
monkeypatch.setattr(module, "_sync_controller_mirror", lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(module, "_stop_current_controller", 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)
|
|
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
|
|
monkeypatch.setattr(
|
|
module, "_file_info_at", lambda *_args: (live_file["value"], live_file["mode"])
|
|
)
|
|
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
|
|
|
|
def atomic_at(_parent, _name, value, expected, **_kwargs):
|
|
assert expected == live_file["value"]
|
|
live_file["value"] = value
|
|
|
|
monkeypatch.setattr(module, "_atomic_at", atomic_at)
|
|
calls.clear()
|
|
if operation == "activate":
|
|
module.activate_promotion(tmp_path / "backup")
|
|
raw_target = target_compose
|
|
else:
|
|
module.rollback_promotion(tmp_path / "backup")
|
|
raw_target = previous_compose
|
|
projected_target = _projected_compose(raw_target)
|
|
assert live_file["value"] == projected_target
|
|
|
|
up_calls = [
|
|
(arguments, data) for arguments, data in calls
|
|
if arguments[:2] == ["docker", "--config"]
|
|
and arguments[3] == "compose" and "up" in arguments
|
|
]
|
|
assert len(up_calls) == 1
|
|
up_index = up_calls[0][0].index("up")
|
|
assert up_calls[0][0][up_index:] == [
|
|
"up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE
|
|
]
|
|
assert up_calls[0][1] == projected_target
|
|
assert module._hash(target_compose) == receipt["composeAfterSha256"]
|
|
assert module._hash(previous_compose) == receipt["composeRollbackSha256"]
|
|
assert values["compose-after.json"] == target_compose
|
|
assert values["compose-rollback.json"] == previous_compose
|
|
|
|
|
|
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 len(renderer._ARTIFACT_FILES) == len(set(renderer._ARTIFACT_FILES))
|
|
assert module.RELEASE_FILES[:3] == (
|
|
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py"
|
|
)
|
|
candidate_helper = "prepare_mcp_hub_candidate.py"
|
|
assert candidate_helper in module.RELEASE_FILES
|
|
assert candidate_helper in renderer._ARTIFACT_FILES
|
|
assert module.RELEASE_FILES.count(candidate_helper) == 1
|
|
previous_files = module.LEGACY_RELEASE_FILES_PRE_CANDIDATE_HELPER
|
|
assert len(previous_files) == 20
|
|
previous_with_helper = module.LEGACY_RELEASE_FILES_WITH_CANDIDATE_HELPER
|
|
assert len(previous_with_helper) == 21
|
|
assert previous_with_helper == tuple(
|
|
[*previous_files[:previous_files.index("status_server.py") + 1], candidate_helper,
|
|
*previous_files[previous_files.index("status_server.py") + 1:]]
|
|
)
|
|
assert module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT == HISTORICAL_CONFIG_ROOT_RELEASE_FILES
|
|
assert module._KNOWN_RELEASE_FILE_SETS == (
|
|
module.RELEASE_FILES,
|
|
module.LEGACY_RELEASE_FILES_PRE_COMPONENT,
|
|
module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER,
|
|
module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER,
|
|
module.LEGACY_RELEASE_FILES_BEFORE_FORGE,
|
|
previous_files,
|
|
previous_with_helper,
|
|
HISTORICAL_CONFIG_ROOT_RELEASE_FILES,
|
|
module.LEGACY_RELEASE_FILES_2_1_13,
|
|
module.LEGACY_RELEASE_FILES_2_1_5,
|
|
)
|
|
assert all(
|
|
candidate_helper not in legacy
|
|
for legacy in (
|
|
previous_files,
|
|
module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT,
|
|
module.LEGACY_RELEASE_FILES_2_1_13,
|
|
module.LEGACY_RELEASE_FILES_2_1_5,
|
|
)
|
|
)
|
|
assert module.RELEASE_FILES[module.RELEASE_FILES.index("forge_first_install.py") + 1] == (
|
|
"forge_database_credentials.py"
|
|
)
|
|
helper_index = module.RELEASE_FILES.index("forge_database_credentials.py") + 1
|
|
assert module.RELEASE_FILES[helper_index : helper_index + 2] == (
|
|
"forge_backup_paths.py", "forge_secret_bundle.py"
|
|
)
|
|
assert module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER == tuple(
|
|
name for name in module.RELEASE_FILES
|
|
if name not in {"forge_backup_paths.py", "forge_secret_bundle.py", "immutable_service_promotion.py", "registered_legacy_adoption.py", "registered_legacy_transport.py", "registered_build_context.py"}
|
|
)
|
|
assert module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER == tuple(
|
|
name for name in module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER
|
|
if name != "forge_database_credentials.py"
|
|
)
|
|
installer = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
|
|
module_list = installer.split("module_files=(", 1)[1].split(")", 1)[0].split()
|
|
module_helper_index = module_list.index("forge_database_credentials.py") + 1
|
|
assert module_list[module_helper_index : module_helper_index + 2] == [
|
|
"forge_backup_paths.py", "forge_secret_bundle.py"
|
|
]
|
|
source_list = installer.split("source_paths=(", 1)[1].split(")", 1)[0].split()
|
|
source_helper_index = source_list.index("ops/gitea-deploy-broker/forge_database_credentials.py") + 1
|
|
assert source_list[source_helper_index : source_helper_index + 2] == [
|
|
"ops/gitea-deploy-broker/forge_backup_paths.py",
|
|
"ops/gitea-deploy-broker/forge_secret_bundle.py",
|
|
]
|
|
|
|
|
|
def test_pre_candidate_20_file_manifest_remains_accepted_and_current_digest_requires_helper():
|
|
files = HISTORICAL_PRE_CANDIDATE_RELEASE_FILES
|
|
assert module.LEGACY_RELEASE_FILES_PRE_CANDIDATE_HELPER == files
|
|
entries = {name: (f"pre-candidate:{name}".encode(), 0o644) for name in files}
|
|
manifest = {
|
|
"apiVersion": "itworx.controller-artifact/v1",
|
|
"controllerVersion": "2.1.14",
|
|
"sourceRepository": "jens/unraid-appops-gateway",
|
|
"sourceRevision": "b" * 40,
|
|
"sourceTree": "c" * 40,
|
|
"artifactSha256": module._artifact_digest(entries, files),
|
|
"builtAt": "2026-10-02T00:00:00.000Z",
|
|
}
|
|
entries["controller-release.json"] = (
|
|
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
|
|
)
|
|
|
|
identity = module._release_identity(entries)
|
|
assert identity["artifactSha256"] == manifest["artifactSha256"]
|
|
assert module._matching_release_files(entries, manifest["artifactSha256"]) == files
|
|
|
|
current_entries = {name: (f"current:{name}".encode(), 0o644) for name in module.RELEASE_FILES}
|
|
assert module._artifact_digest(current_entries) == module._artifact_digest(
|
|
current_entries, module.RELEASE_FILES
|
|
)
|
|
del current_entries["prepare_mcp_hub_candidate.py"]
|
|
with pytest.raises(module.VolumeTransitionError, match="release_incomplete"):
|
|
module._artifact_digest(current_entries)
|
|
|
|
|
|
def test_previous_21_file_helper_manifest_remains_accepted():
|
|
files = HISTORICAL_CANDIDATE_HELPER_RELEASE_FILES
|
|
assert module.LEGACY_RELEASE_FILES_WITH_CANDIDATE_HELPER == files
|
|
assert len(files) == 21
|
|
assert files.count("prepare_mcp_hub_candidate.py") == 1
|
|
entries = {name: (f"previous-helper:{name}".encode(), 0o644) for name in files}
|
|
manifest = {
|
|
"apiVersion": "itworx.controller-artifact/v1",
|
|
"controllerVersion": "2.1.14",
|
|
"sourceRepository": "jens/unraid-appops-gateway",
|
|
"sourceRevision": "d" * 40,
|
|
"sourceTree": "e" * 40,
|
|
"artifactSha256": module._artifact_digest(entries, files),
|
|
"builtAt": "2026-10-02T00:00:00.000Z",
|
|
}
|
|
entries["controller-release.json"] = (
|
|
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
|
|
)
|
|
|
|
identity = module._release_identity(entries)
|
|
assert identity["artifactSha256"] == manifest["artifactSha256"]
|
|
assert module._matching_release_files(entries, manifest["artifactSha256"]) == files
|
|
|
|
|
|
def test_candidate_preparation_helper_is_staged_archived_and_digest_bound(tmp_path):
|
|
if os.name == "nt":
|
|
pytest.skip("release staging requires POSIX descriptor-based file checks")
|
|
helper_name = "prepare_mcp_hub_candidate.py"
|
|
helper_source = ROOT / "ops" / "gitea-deploy-broker" / helper_name
|
|
helper_bytes = helper_source.read_bytes()
|
|
stage_root = tmp_path / "source"
|
|
ops = stage_root / "ops" / "gitea-deploy-broker"
|
|
scripts = stage_root / "scripts"
|
|
ops.mkdir(parents=True, mode=0o700)
|
|
scripts.mkdir(mode=0o700)
|
|
for name in module.RELEASE_FILES:
|
|
folder = scripts if name == "run_repopulse_adoption.py" else ops
|
|
contents = helper_bytes if name == helper_name else f"source:{name}\n".encode()
|
|
(folder / name).write_bytes(contents)
|
|
staged = module._stage_source_files(stage_root)
|
|
assert staged[helper_name] == (helper_bytes, 0o644)
|
|
|
|
revision = "a" * 40
|
|
tree = "b" * 40
|
|
entries, directories = module._prepare_merged_tree(
|
|
stage_root, ({}, {"": 0o755}), revision, tree, "2.1.14"
|
|
)
|
|
artifact_digest = module._release_identity(entries)["artifactSha256"]
|
|
assert artifact_digest == module._artifact_digest(entries)
|
|
archive = module._release_tar_bytes(revision, (entries, directories))
|
|
extracted, _ = module._read_tar_tree(archive)
|
|
assert extracted[helper_name][0] == helper_bytes
|
|
assert module._release_identity(extracted)["artifactSha256"] == artifact_digest
|
|
|
|
tampered = dict(extracted)
|
|
tampered[helper_name] = (helper_bytes + b"# altered after manifest\n", 0o644)
|
|
with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"):
|
|
module._release_identity(tampered)
|
|
|
|
|
|
def test_installer_explicit_archive_and_install_contract_includes_candidate_helper():
|
|
source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
|
|
archive_paths = source.split("source_paths=(", 1)[1].split("\n)", 1)[0]
|
|
installed_modules = source.split("module_files=(", 1)[1].split("\n)", 1)[0]
|
|
assert "ops/gitea-deploy-broker/prepare_mcp_hub_candidate.py" in archive_paths
|
|
assert "prepare_mcp_hub_candidate.py" in installed_modules
|
|
powershell = (ROOT / "scripts/upgrade_deployment_controller.ps1").read_text(encoding="utf-8")
|
|
assert "ops/gitea-deploy-broker" in powershell.split("& git archive", 1)[1].splitlines()[0]
|
|
|
|
|
|
def test_current_release_digest_binds_the_role_password_helper():
|
|
entries = {name: (f"source:{name}".encode(), 0o644) for name in module.RELEASE_FILES}
|
|
expected = module._artifact_digest(entries)
|
|
assert module._matching_release_files(entries, expected) == module.RELEASE_FILES
|
|
|
|
entries["forge_database_credentials.py"] = (b"tampered helper", 0o644)
|
|
with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"):
|
|
module._matching_release_files(entries, expected)
|
|
|
|
|
|
def test_previous_role_helper_release_digest_remains_recognized_and_bound():
|
|
files = module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER
|
|
entries = {name: (f"source:{name}".encode(), 0o644) for name in files}
|
|
expected = module._artifact_digest(entries, files)
|
|
assert module._matching_release_files(entries, expected) == files
|
|
|
|
entries["forge_database_credentials.py"] = (b"tampered role helper", 0o644)
|
|
with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"):
|
|
module._matching_release_files(entries, expected)
|
|
|
|
|
|
def test_historical_config_root_manifest_order_and_digest_remain_exact():
|
|
files = HISTORICAL_CONFIG_ROOT_RELEASE_FILES
|
|
assert module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT == files
|
|
entries = {name: (f"source:{name}".encode(), 0o644) for name in files}
|
|
expected = module._artifact_digest(entries, files)
|
|
assert module._matching_release_files(entries, expected) == files
|
|
|
|
entries["status_server.py"] = (b"tampered historical file", 0o644)
|
|
with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"):
|
|
module._matching_release_files(entries, expected)
|
|
|
|
|
|
def test_activated_controller_must_have_the_exact_backup_mount(monkeypatch):
|
|
mounts = [
|
|
{
|
|
"Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(),
|
|
"Destination": module.EXECUTABLE_TARGET, "RW": False,
|
|
},
|
|
{
|
|
"Type": "volume", "Name": module.VOLUME,
|
|
"Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, "RW": True,
|
|
},
|
|
{
|
|
"Type": "bind", "Source": module._expected_config_path().as_posix(),
|
|
"Destination": module.CONFIG_TARGET, "RW": False,
|
|
},
|
|
{
|
|
"Type": "bind", "Source": module.DOCKER_ROOT.as_posix(),
|
|
"Destination": module.DOCKER_ROOT.as_posix(), "RW": False,
|
|
},
|
|
{
|
|
"Type": "volume", "Name": "itworx-forge-master-key-backup",
|
|
"Source": "/var/lib/docker/volumes/itworx-forge-master-key-backup/_data",
|
|
"Destination": "/forge-master-key-backup", "RW": True,
|
|
},
|
|
]
|
|
item = {"Mounts": mounts}
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda _image_id: item)
|
|
monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: ({}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_release_identity", lambda _entries: {"sourceRevision": REVISION})
|
|
monkeypatch.setattr(module, "_read_config_leaf", lambda: ({}, {"": 0o755}))
|
|
|
|
module._verify_active_mount(REVISION, IMAGE_ID)
|
|
mounts.pop()
|
|
with pytest.raises(module.VolumeTransitionError, match="backup_mount"):
|
|
module._verify_active_mount(REVISION, IMAGE_ID)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"damage",
|
|
["backup_readonly", "backup_name", "backup_source", "backup_alias", "docker_root_rw"],
|
|
)
|
|
def test_activated_controller_rejects_backup_or_docker_root_mount_drift(
|
|
monkeypatch, damage
|
|
):
|
|
mounts = [
|
|
{
|
|
"Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(),
|
|
"Destination": module.EXECUTABLE_TARGET, "RW": False,
|
|
},
|
|
{
|
|
"Type": "volume", "Name": module.VOLUME,
|
|
"Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, "RW": True,
|
|
},
|
|
{
|
|
"Type": "bind", "Source": module._expected_config_path().as_posix(),
|
|
"Destination": module.CONFIG_TARGET, "RW": False,
|
|
},
|
|
{
|
|
"Type": "bind", "Source": module.DOCKER_ROOT.as_posix(),
|
|
"Destination": module.DOCKER_ROOT.as_posix(), "RW": False,
|
|
},
|
|
{
|
|
"Type": "volume", "Name": module.BACKUP_VOLUME,
|
|
"Source": module.BACKUP_MOUNTPOINT.as_posix(),
|
|
"Destination": module.BACKUP_TARGET, "RW": True,
|
|
},
|
|
]
|
|
if damage == "backup_readonly":
|
|
mounts[-1]["RW"] = False
|
|
elif damage == "backup_name":
|
|
mounts[-1]["Name"] = "foreign-volume"
|
|
elif damage == "backup_source":
|
|
mounts[-1]["Source"] = "/var/lib/docker/volumes/foreign/_data"
|
|
elif damage == "backup_alias":
|
|
mounts.append({
|
|
"Type": "bind", "Source": "/foreign", "Destination": f"{module.BACKUP_TARGET}/child",
|
|
"RW": True,
|
|
})
|
|
else:
|
|
mounts[-2]["RW"] = True
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda _image_id: {"Mounts": mounts})
|
|
monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: ({}, {"": 0o755}))
|
|
monkeypatch.setattr(module, "_release_identity", lambda _entries: {"sourceRevision": REVISION})
|
|
monkeypatch.setattr(module, "_read_config_leaf", lambda: ({}, {"": 0o755}))
|
|
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module._verify_active_mount(REVISION, IMAGE_ID)
|
|
|
|
|
|
@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")
|
|
@pytest.mark.parametrize("tmp_mode", [0o700, 0o1777])
|
|
def test_private_stage_accepts_root_owned_tmp_with_only_approved_modes(monkeypatch, tmp_mode):
|
|
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:
|
|
tmp_info = os.stat("/tmp", follow_symlinks=False)
|
|
original_fstat = os.fstat
|
|
|
|
def fstat_with_tmp_mode(descriptor):
|
|
info = original_fstat(descriptor)
|
|
if (info.st_dev, info.st_ino) == (tmp_info.st_dev, tmp_info.st_ino):
|
|
return types.SimpleNamespace(
|
|
st_uid=info.st_uid, st_gid=info.st_gid,
|
|
st_mode=stat.S_IFDIR | tmp_mode, st_dev=info.st_dev,
|
|
)
|
|
return info
|
|
|
|
monkeypatch.setattr(os, "fstat", fstat_with_tmp_mode)
|
|
module._validate_private_stage(source)
|
|
finally:
|
|
monkeypatch.undo()
|
|
shutil.rmtree(work)
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
|
reason="private /tmp ownership validation requires root on POSIX")
|
|
@pytest.mark.parametrize(
|
|
("target_name", "changes"),
|
|
[
|
|
("tmp", {"st_mode": stat.S_IFDIR | 0o777}),
|
|
("tmp", {"st_uid": 1000}),
|
|
("tmp", {"st_gid": 1000}),
|
|
("work", {"st_mode": stat.S_IFDIR | 0o755}),
|
|
("source", {"st_mode": stat.S_IFDIR | 0o755}),
|
|
("work", {"st_uid": 1000}),
|
|
("work", {"st_gid": 1000}),
|
|
("source", {"st_uid": 1000}),
|
|
("source", {"st_gid": 1000}),
|
|
("source", {"st_dev": 123456}),
|
|
],
|
|
)
|
|
def test_private_stage_rejects_unsafe_tmp_and_child_metadata(monkeypatch, target_name, changes):
|
|
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)
|
|
targets = {"tmp": Path("/tmp"), "work": work, "source": source}
|
|
target_info = os.stat(targets[target_name], follow_symlinks=False)
|
|
original_fstat = os.fstat
|
|
|
|
def fstat_with_unsafe_metadata(descriptor):
|
|
info = original_fstat(descriptor)
|
|
if (info.st_dev, info.st_ino) == (target_info.st_dev, target_info.st_ino):
|
|
values = {
|
|
"st_uid": info.st_uid, "st_gid": info.st_gid,
|
|
"st_mode": info.st_mode, "st_dev": info.st_dev,
|
|
}
|
|
values.update(changes)
|
|
return types.SimpleNamespace(**values)
|
|
return info
|
|
|
|
try:
|
|
monkeypatch.setattr(os, "fstat", fstat_with_unsafe_metadata)
|
|
with pytest.raises(module.VolumeTransitionError, match="private_stage_invalid"):
|
|
module._validate_private_stage(source)
|
|
finally:
|
|
monkeypatch.undo()
|
|
shutil.rmtree(work)
|
|
|
|
|
|
@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_acl_and_symlinked_source_directory(monkeypatch):
|
|
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)
|
|
source_info = os.stat(source, follow_symlinks=False)
|
|
original_listxattr = os.listxattr
|
|
|
|
def listxattr_with_acl(descriptor):
|
|
info = os.fstat(descriptor)
|
|
values = original_listxattr(descriptor)
|
|
if (info.st_dev, info.st_ino) == (source_info.st_dev, source_info.st_ino):
|
|
return [*values, "system.posix_acl_access"]
|
|
return values
|
|
|
|
try:
|
|
monkeypatch.setattr(os, "listxattr", listxattr_with_acl)
|
|
with pytest.raises(module.VolumeTransitionError, match="acl_unsafe"):
|
|
module._validate_private_stage(source)
|
|
monkeypatch.setattr(os, "listxattr", original_listxattr)
|
|
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)
|
|
assert '__FORGE_' not in source
|
|
assert 'runtime_database_url.secret' in source
|
|
assert '/forge-master-key-backup' not in source
|
|
compile(source, 'controller-capture', 'exec')
|
|
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"
|
|
"forge_secret_owners={}\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())
|
|
|
|
|
|
@pytest.mark.parametrize("files", [
|
|
module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER,
|
|
module.LEGACY_RELEASE_FILES_2_1_5,
|
|
module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT,
|
|
module.LEGACY_RELEASE_FILES_BEFORE_FORGE,
|
|
])
|
|
def test_prior_release_capture_must_match_protected_git_tree(monkeypatch, files):
|
|
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
|
|
|
|
|
|
def test_current_e854_crlf_capture_requires_exact_full_release_identity():
|
|
checked_out_source = (ROOT / "scripts/run_repopulse_adoption.py").read_bytes()
|
|
source = checked_out_source.replace(b"\r\n", b"\n")
|
|
captured = source.replace(b"\n", b"\r\n")
|
|
assert hashlib.sha256(source).hexdigest() == (
|
|
"9c66fae21f1eec27215af08f7bb7d24bafc6f10757393b56c42721fa99dccc66"
|
|
)
|
|
assert hashlib.sha256(captured).hexdigest() == (
|
|
"e3036cef0f6e1934297de2d35c0381f93441bbf6f28bd18c94c3f47fc116e4e1"
|
|
)
|
|
identity = {
|
|
"sourceRevision": "e854334923cca574482d22b8424ba3ce46ec6aa1",
|
|
"sourceTree": "cf2a117f9222b952deb9902c646116612f3a7ea0",
|
|
"controllerVersion": "2.1.14",
|
|
"artifactSha256": "e88739bf091c8bac77ea0db5b97300666a1baa8ba984bbf6ae91e3bf25719e08",
|
|
}
|
|
|
|
assert module._matches_legacy_crlf_source(
|
|
"run_repopulse_adoption.py", captured, source, identity
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "field", "value", "change_capture"),
|
|
[
|
|
("run_repopulse_adoption.py", "sourceRevision", "f" * 40, False),
|
|
("run_repopulse_adoption.py", "sourceTree", "f" * 40, False),
|
|
("run_repopulse_adoption.py", "controllerVersion", "2.1.15", False),
|
|
("run_repopulse_adoption.py", "artifactSha256", "f" * 64, False),
|
|
("other.py", "", "", False),
|
|
("run_repopulse_adoption.py", "", "", True),
|
|
],
|
|
)
|
|
def test_current_e854_crlf_compatibility_rejects_near_misses(
|
|
name, field, value, change_capture,
|
|
):
|
|
checked_out_source = (ROOT / "scripts/run_repopulse_adoption.py").read_bytes()
|
|
source = checked_out_source.replace(b"\r\n", b"\n")
|
|
captured = source.replace(b"\n", b"\r\n")
|
|
identity = {
|
|
"sourceRevision": "e854334923cca574482d22b8424ba3ce46ec6aa1",
|
|
"sourceTree": "cf2a117f9222b952deb9902c646116612f3a7ea0",
|
|
"controllerVersion": "2.1.14",
|
|
"artifactSha256": "e88739bf091c8bac77ea0db5b97300666a1baa8ba984bbf6ae91e3bf25719e08",
|
|
}
|
|
if field:
|
|
identity[field] = value
|
|
if change_capture:
|
|
captured = captured.replace(b"\r\n", b"\n", 1)
|
|
|
|
assert not module._matches_legacy_crlf_source(name, captured, source, identity)
|
|
|
|
|
|
def test_current_e854_crlf_exception_integrates_with_git_source_verification(monkeypatch):
|
|
revision = "e854334923cca574482d22b8424ba3ce46ec6aa1"
|
|
tree = "cf2a117f9222b952deb9902c646116612f3a7ea0"
|
|
artifact_sha = "e88739bf091c8bac77ea0db5b97300666a1baa8ba984bbf6ae91e3bf25719e08"
|
|
files = module.LEGACY_RELEASE_FILES_BEFORE_FORGE
|
|
assert len(files) == 30
|
|
checked_out_script = (ROOT / "scripts/run_repopulse_adoption.py").read_bytes()
|
|
runner_source = checked_out_script.replace(b"\r\n", b"\n")
|
|
runner_capture = runner_source.replace(b"\n", b"\r\n")
|
|
assert hashlib.sha256(runner_source).hexdigest() == (
|
|
"9c66fae21f1eec27215af08f7bb7d24bafc6f10757393b56c42721fa99dccc66"
|
|
)
|
|
assert hashlib.sha256(runner_capture).hexdigest() == (
|
|
"e3036cef0f6e1934297de2d35c0381f93441bbf6f28bd18c94c3f47fc116e4e1"
|
|
)
|
|
source_by_name = {}
|
|
entries = {}
|
|
for name in files:
|
|
path = (ROOT / "scripts/run_repopulse_adoption.py" if name == "run_repopulse_adoption.py"
|
|
else ROOT / "ops/gitea-deploy-broker" / name)
|
|
source = runner_source if name == "run_repopulse_adoption.py" else path.read_bytes()
|
|
source = source.replace(b"\r\n", b"\n")
|
|
source_by_name[name] = source
|
|
entries[name] = (
|
|
runner_capture if name == "run_repopulse_adoption.py" else source,
|
|
0o644,
|
|
)
|
|
identity_data = {
|
|
"apiVersion": "itworx.controller-artifact/v1",
|
|
"controllerVersion": "2.1.14",
|
|
"sourceRepository": "jens/unraid-appops-gateway",
|
|
"sourceRevision": revision,
|
|
"sourceTree": tree,
|
|
"artifactSha256": artifact_sha,
|
|
"builtAt": "2026-09-30T00:00:00Z",
|
|
}
|
|
entries["controller-release.json"] = (
|
|
json.dumps(identity_data, separators=(",", ":")).encode(), 0o644
|
|
)
|
|
identity = {key: str(value) for key, value in identity_data.items()}
|
|
git_outputs = {path: source for name, source in source_by_name.items() for path in [
|
|
"scripts/run_repopulse_adoption.py"
|
|
if name == "run_repopulse_adoption.py"
|
|
else f"ops/gitea-deploy-broker/{name}"
|
|
]}
|
|
controller_id = "c" * 64
|
|
|
|
def select_attested_files(captured, expected):
|
|
assert expected == artifact_sha
|
|
assert all(name in captured for name in files)
|
|
return files
|
|
|
|
def run(arguments, **_kwargs):
|
|
assert arguments[:5] == [
|
|
"docker", "exec", controller_id, "git", f"--git-dir={module.SOURCE_GIT_DIR}"
|
|
]
|
|
if arguments[-2] == "rev-parse":
|
|
return f"{tree}\n".encode()
|
|
return git_outputs[arguments[-1].split(":", 1)[-1]]
|
|
|
|
monkeypatch.setattr(module, "_matching_release_files", select_attested_files)
|
|
monkeypatch.setattr(module, "_run", run)
|
|
module._verify_git_source_identity(entries, identity, controller_id=controller_id)
|
|
|
|
other_file = next(name for name in files if name != "run_repopulse_adoption.py")
|
|
other_path = f"ops/gitea-deploy-broker/{other_file}"
|
|
git_outputs[other_path] = b"tampered protected Git source"
|
|
with pytest.raises(module.VolumeTransitionError, match="source_mismatch"):
|
|
module._verify_git_source_identity(entries, identity, controller_id=controller_id)
|
|
|
|
|
|
def _current_controller_with_graph_mount():
|
|
return {
|
|
"Id": "c" * 64, "Image": IMAGE_ID, "Name": "/gitea-deploy-control",
|
|
"State": {"Running": True},
|
|
"Mounts": [
|
|
{"Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(),
|
|
"Destination": module.EXECUTABLE_TARGET, "RW": False, "Propagation": "rslave"},
|
|
{"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker",
|
|
"RW": False, "Propagation": "rslave"},
|
|
],
|
|
}
|
|
|
|
|
|
def test_current_controller_accepts_only_fixed_readonly_docker_root_ancestor(monkeypatch):
|
|
item = _current_controller_with_graph_mount()
|
|
monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: json.dumps([item]).encode())
|
|
assert module._controller_image_inspect(IMAGE_ID) == item
|
|
|
|
|
|
@pytest.mark.parametrize("damage", ["writable", "redirected_target", "child_source", "parent_source", "volume_type", "named_volume_alias", "duplicate_graph", "shared_propagation", "private_propagation"])
|
|
def test_current_controller_rejects_other_docker_root_appdata_aliases(monkeypatch, damage):
|
|
item = _current_controller_with_graph_mount()
|
|
graph = item["Mounts"][1]
|
|
if damage == "writable":
|
|
graph["RW"] = True
|
|
elif damage == "redirected_target":
|
|
graph["Destination"] = "/graph-alias"
|
|
elif damage == "child_source":
|
|
graph["Source"] = "/var/lib/docker/volumes"
|
|
elif damage == "parent_source":
|
|
graph["Source"] = "/var/lib"
|
|
elif damage == "volume_type":
|
|
graph["Type"] = "volume"
|
|
elif damage == "named_volume_alias":
|
|
graph["Name"] = module.MANAGED_APPDATA_VOLUME
|
|
elif damage == "duplicate_graph":
|
|
item["Mounts"].append(dict(graph))
|
|
elif damage == "shared_propagation":
|
|
graph["Propagation"] = "rshared"
|
|
else:
|
|
graph["Propagation"] = "rprivate"
|
|
monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: json.dumps([item]).encode())
|
|
with pytest.raises(module.VolumeTransitionError, match="managed_appdata_mount_alias"):
|
|
module._controller_image_inspect(IMAGE_ID)
|
|
|
|
|
|
@pytest.mark.parametrize("populated", [False, True])
|
|
def test_config_snapshot_preserves_fixed_appops_legacy_intent_directory(populated):
|
|
entries = {name: (b"{}", 0o600) for name in ("projects.json", "broker-token", "gitea-validation.json", "gitea-validation-token")}
|
|
if populated:
|
|
entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b'{"schema":"itworx.appops-legacy-upgrade-intent/v1"}', 0o600)
|
|
directories = {"": 0o755, "appops-legacy-upgrade": 0o700}
|
|
assert module._config_tree(entries, directories) == (entries, directories)
|
|
assert module._read_tar_tree(module._release_tar_bytes(REVISION, (entries, directories))) == (entries, directories)
|
|
|
|
|
|
@pytest.mark.parametrize("damage", ["unknown_file", "nested_directory", "public_directory", "public_file"])
|
|
def test_config_snapshot_keeps_appops_legacy_state_contract_closed(damage):
|
|
entries = {name: (b"{}", 0o600) for name in ("projects.json", "broker-token", "gitea-validation.json", "gitea-validation-token")}
|
|
directories = {"": 0o755, "appops-legacy-upgrade": 0o700}
|
|
if damage == "unknown_file":
|
|
entries["appops-legacy-upgrade/shell.py"] = (b"unexpected", 0o600)
|
|
elif damage == "nested_directory":
|
|
directories["appops-legacy-upgrade/nested"] = 0o700
|
|
elif damage == "public_directory":
|
|
directories["appops-legacy-upgrade"] = 0o755
|
|
else:
|
|
entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b"{}", 0o644)
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module._config_tree(entries, directories)
|
|
|
|
|
|
@pytest.mark.parametrize("action", ["up", "stop"])
|
|
def test_legacy_receipt_projection_preserves_evidence_and_readonly_mounts(compose, action):
|
|
_baseline, calls = compose
|
|
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
for mount in value["services"][module.SERVICE]["volumes"]:
|
|
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
|
mount["bind"] = {"propagation": "rprivate"}
|
|
captured = json.dumps(value).encode()
|
|
original_hash = module._hash(captured)
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module._bound_model(captured)
|
|
module.compose_action(captured, action)
|
|
projected = json.loads(calls[-1][1])
|
|
for mount in projected["services"][module.SERVICE]["volumes"]:
|
|
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
|
assert mount["read_only"] is True
|
|
assert mount["bind"] == {"propagation": "rslave"}
|
|
mount["bind"] = {"propagation": "rprivate"}
|
|
assert projected == value
|
|
assert module._hash(captured) == original_hash
|
|
|
|
|
|
@pytest.mark.parametrize("damage", ["writable", "shared", "extra_option", "foreign_source"])
|
|
def test_legacy_receipt_projection_rejects_unsafe_mount_before_docker(compose, damage):
|
|
_baseline, calls = compose
|
|
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
|
mount = next(x for x in value["services"][module.SERVICE]["volumes"]
|
|
if x.get("target") == module.EXECUTABLE_TARGET)
|
|
mount["bind"] = {"propagation": "rprivate"}
|
|
if damage == "writable":
|
|
mount["read_only"] = False
|
|
elif damage == "shared":
|
|
mount["bind"]["propagation"] = "rshared"
|
|
elif damage == "extra_option":
|
|
mount["bind"]["create_host_path"] = True
|
|
else:
|
|
mount["source"] = "/tmp/untrusted-controller"
|
|
calls.clear()
|
|
with pytest.raises(module.VolumeTransitionError):
|
|
module.compose_action(json.dumps(value).encode(), "up")
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("phase", ["prepared", "stopped", "activated", "verified"])
|
|
@pytest.mark.parametrize("failure", ["read_only", "full"])
|
|
def test_forward_stop_rechecks_storage_after_intent_or_on_resume(monkeypatch, phase, failure):
|
|
receipt = {"phase": phase, "imageId": IMAGE_ID,
|
|
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
|
|
persisted = phase != "prepared"
|
|
events = []
|
|
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {
|
|
"compose-rollback.json": b"old", "compose-after.json": b"new"}))
|
|
monkeypatch.setattr(module.os, "close", lambda _: None)
|
|
def persist(_fd, value, new_phase):
|
|
nonlocal persisted
|
|
persisted = True
|
|
return {**value, "phase": new_phase}
|
|
monkeypatch.setattr(module, "_set_promotion_phase", persist)
|
|
def filesystem(_path):
|
|
return types.SimpleNamespace(
|
|
f_flag=1 if persisted and failure == "read_only" else 0,
|
|
f_bavail=0 if persisted and failure == "full" else 1024**3,
|
|
f_frsize=1)
|
|
monkeypatch.setattr(module.os, "statvfs", filesystem, raising=False)
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
|
|
"State": {"Running": True}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
|
|
"Source": module._expected_revision_path(OLD_REVISION).as_posix()}]})
|
|
monkeypatch.setattr(module, "compose_action", lambda *args: events.append(args))
|
|
code = "controller_volume_storage_read_only" if failure == "read_only" else "controller_volume_storage_insufficient"
|
|
with pytest.raises(module.VolumeTransitionError, match=code):
|
|
module.stop_promotion(Path("/unused-backup"))
|
|
assert events == []
|
|
|
|
|
|
def test_resumed_forward_stop_of_stopped_controller_needs_no_storage(monkeypatch):
|
|
receipt = {"phase": "stopped", "imageId": IMAGE_ID,
|
|
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
|
|
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {}))
|
|
monkeypatch.setattr(module.os, "close", lambda _: None)
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
|
|
"State": {"Running": False}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
|
|
"Source": module._expected_revision_path(OLD_REVISION).as_posix()}]})
|
|
monkeypatch.setattr(module, "_require_writable_promotion_storage",
|
|
lambda: pytest.fail("Already stopped recovery must remain possible"))
|
|
monkeypatch.setattr(module, "compose_action", lambda *_: pytest.fail("No stop needed"))
|
|
module.stop_promotion(Path("/unused-backup"))
|
|
|
|
|
|
def test_rollback_stop_remains_available_without_forward_storage_gate(monkeypatch):
|
|
receipt = {"phase": "stopped", "imageId": IMAGE_ID,
|
|
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
|
|
events = []
|
|
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {
|
|
"compose-rollback.json": b"old", "compose-after.json": b"new"}))
|
|
monkeypatch.setattr(module.os, "close", lambda _: None)
|
|
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
|
|
"State": {"Running": True}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
|
|
"Source": module._expected_revision_path(REVISION).as_posix()}]})
|
|
monkeypatch.setattr(module, "_require_writable_promotion_storage",
|
|
lambda: pytest.fail("Rollback must not inherit forward capacity gate"))
|
|
monkeypatch.setattr(module, "compose_action", lambda *args: events.append(args))
|
|
module.rollback_promotion(Path("/unused-backup"), stop_only=True)
|
|
assert events == [(b"new", "stop")]
|