Publish curated Unraid-AppOps-Gateway source from ad0a390e431166c3198a2ac3bec010c2b44f2dea
This commit is contained in:
1 parent
29c1e3d5a1
commit
98d31477f9
35 files changed
+7264
-185
No files matched your search
@@ -38,6 +38,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
|
||||
"/mnt/cache:/mnt/cache",
|
||||
"/mnt/user:/mnt/user",
|
||||
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
|
||||
"/var/lib/docker:/var/lib/docker:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
}
|
||||
assert spec["Cmd"][:2] == ["/bin/bash", "-lc"]
|
||||
@@ -47,6 +48,13 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
|
||||
assert revision in command
|
||||
assert CONTROLLER_VERSION in command
|
||||
assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command
|
||||
assert f"itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX" in command
|
||||
assert "work='/mnt/cache" not in command
|
||||
assert "rm -rf -- \"$work\"" in command
|
||||
assert "chmod 0600 \"$work/controller.tar\"" in command
|
||||
assert "scripts/prepare_controller_hub_release_volume.py" in command
|
||||
assert "scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"" in command
|
||||
assert f"--image-id '{image_id}'" in command
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_paths():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"controller_config_paths_test", ROOT / "ops/gitea-deploy-broker/controller_paths.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_production_code_uses_fixed_protected_mutable_config():
|
||||
paths = load_paths()
|
||||
code = Path("/opt/gitea-deploy")
|
||||
assert paths.controller_config_root(code) == Path("/controller-hub-releases/controller-config")
|
||||
assert paths.controller_readonly_config_root(code) == Path("/controller-config")
|
||||
assert paths.controller_config_root(code) != code
|
||||
|
||||
|
||||
def test_private_install_stage_keeps_its_captured_config(tmp_path):
|
||||
paths = load_paths()
|
||||
stage = tmp_path / "controller"
|
||||
assert paths.controller_config_root(stage) == stage
|
||||
assert paths.controller_readonly_config_root(stage) == stage
|
||||
|
||||
|
||||
def test_config_location_has_no_environment_override(monkeypatch):
|
||||
paths = load_paths()
|
||||
monkeypatch.setenv("ITWORX_CONTROLLER_CONFIG_ROOT", "/untrusted")
|
||||
monkeypatch.setenv("CONTROLLER_CONFIG_ROOT", "/untrusted")
|
||||
assert paths.controller_config_root(Path("/opt/gitea-deploy")) == Path(
|
||||
"/controller-hub-releases/controller-config"
|
||||
)
|
||||
|
||||
|
||||
def test_repopulse_store_keeps_readonly_and_mutable_config_aliases():
|
||||
paths = load_paths()
|
||||
monkeypatch_path = ROOT / "ops/gitea-deploy-broker"
|
||||
import sys
|
||||
|
||||
if str(monkeypatch_path) not in sys.path:
|
||||
sys.path.insert(0, str(monkeypatch_path))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"repopulse_config_alias_test", monkeypatch_path / "repopulse_adoption_store.py"
|
||||
)
|
||||
store = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(store)
|
||||
code = Path("/opt/gitea-deploy")
|
||||
assert store.CONTROLLER_READONLY_ROOT == paths.controller_readonly_config_root(code)
|
||||
assert store.CONTROLLER_RW_ROOT == paths.controller_config_root(code)
|
||||
assert store.CONTROLLER_READONLY_ROOT != store.CONTROLLER_RW_ROOT
|
||||
|
||||
|
||||
def test_registry_updates_use_mutable_config_and_preserve_code(monkeypatch, tmp_path):
|
||||
monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker"))
|
||||
import controller_paths
|
||||
|
||||
config = tmp_path / "config"
|
||||
config.mkdir(mode=0o755)
|
||||
registry = config / "projects.json"
|
||||
registry.write_text("{}\n", encoding="utf-8")
|
||||
registry.chmod(0o600)
|
||||
code = ROOT / "ops/gitea-deploy-broker"
|
||||
original_code = (code / "status_server.py").read_bytes()
|
||||
monkeypatch.setattr(controller_paths, "controller_config_root", lambda _root: config)
|
||||
spec = importlib.util.spec_from_file_location("mutable_config_controller_test", code / "status_server.py")
|
||||
controller = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(controller)
|
||||
assert controller.PROJECTS_PATH == registry
|
||||
assert controller.WEBHOOK_SECRET_ROOT == config / "webhook-secrets"
|
||||
assert controller.APPOPS_DEPLOYMENT_SECRET_ROOT == config / "appops-deployment-secrets"
|
||||
assert controller.GITEA_VALIDATION_TOKEN_FILE == config / "gitea-validation-token"
|
||||
assert controller.PRIVATE_TLS_CONFIG_PATH == config / "private-tls.json"
|
||||
assert controller.CONTROLLER_RELEASE_PATH == code / "controller-release.json"
|
||||
controller._write_projects({"jens/example": {"slug": "example"}})
|
||||
assert controller._load_projects() == {"jens/example": {"slug": "example"}}
|
||||
assert registry.stat().st_mode & 0o777 == 0o600
|
||||
assert (code / "status_server.py").read_bytes() == original_code
|
||||
|
||||
|
||||
def test_status_reads_hub_state_through_the_fixed_pinned_adapter(monkeypatch, tmp_path):
|
||||
monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker"))
|
||||
import mcp_hub_release_store
|
||||
|
||||
code = ROOT / "ops/gitea-deploy-broker"
|
||||
spec = importlib.util.spec_from_file_location("hub_state_controller_test", code / "status_server.py")
|
||||
controller = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(controller)
|
||||
state = tmp_path / "state"
|
||||
state.mkdir()
|
||||
(state / "itworx-mcp-hub.json").write_text('{"live_revision":"untrusted-path"}')
|
||||
(state / "ordinary-project.json").write_text('{"live_revision":"ordinary"}')
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", state)
|
||||
monkeypatch.setattr(
|
||||
mcp_hub_release_store, "read_hub_state_bytes", lambda: b'{"live_revision":"pinned"}'
|
||||
)
|
||||
assert controller._deployment_state("itworx-mcp-hub") == {"live_revision": "pinned"}
|
||||
assert controller._deployment_state("ordinary-project") == {"live_revision": "ordinary"}
|
||||
|
||||
def unsafe():
|
||||
raise mcp_hub_release_store.ReleaseStoreError("state path changed")
|
||||
|
||||
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", unsafe)
|
||||
assert controller._deployment_state("itworx-mcp-hub") == {}
|
||||
|
||||
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'[]')
|
||||
assert controller._deployment_state("itworx-mcp-hub") == {}
|
||||
|
||||
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'\xff')
|
||||
assert controller._deployment_state("itworx-mcp-hub") == {}
|
||||
@@ -0,0 +1,461 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"controller_hub_volume", ROOT / "scripts/prepare_controller_hub_release_volume.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
REVISION = "a" * 40
|
||||
IMAGE_ID = "sha256:" + "b" * 64
|
||||
ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n"
|
||||
|
||||
|
||||
def volume():
|
||||
return {
|
||||
"Name": module.VOLUME,
|
||||
"Driver": "local",
|
||||
"Scope": "local",
|
||||
"Options": None,
|
||||
"Labels": dict(module.LABELS),
|
||||
"Mountpoint": module.MOUNTPOINT.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("Name", "operator-volume"),
|
||||
("Driver", "nfs"),
|
||||
("Scope", "global"),
|
||||
("Options", {"device": "/mnt/cache/appdata"}),
|
||||
("Labels", {}),
|
||||
("Mountpoint", "/mnt/cache/unsafe"),
|
||||
],
|
||||
)
|
||||
def test_unknown_or_redirected_volume_is_never_adopted(key, value):
|
||||
item = volume()
|
||||
item[key] = value
|
||||
with pytest.raises(module.VolumeTransitionError, match="identity_invalid"):
|
||||
module.validate_volume(item)
|
||||
|
||||
|
||||
def test_only_the_exact_local_volume_is_accepted():
|
||||
module.validate_volume(volume())
|
||||
|
||||
|
||||
def model():
|
||||
return {
|
||||
"name": "gitea-deploy-runners",
|
||||
"services": {
|
||||
"deploy-control": {
|
||||
"container_name": module.CONTAINER,
|
||||
"image": "immutable-controller",
|
||||
"volumes": [
|
||||
{
|
||||
"type": "bind",
|
||||
"source": module.DOCKER_ROOT.as_posix(),
|
||||
"target": module.DOCKER_ROOT.as_posix(),
|
||||
"read_only": True,
|
||||
},
|
||||
{
|
||||
"type": "bind",
|
||||
"source": module.CONTROLLER_DIR.as_posix(),
|
||||
"target": module.EXECUTABLE_TARGET,
|
||||
"read_only": True,
|
||||
},
|
||||
],
|
||||
"environment": {"PRIVATE_SETTING": "unchanged"},
|
||||
},
|
||||
"runner": {
|
||||
"image": "pinned-runner",
|
||||
"volumes": [
|
||||
{"type": "bind", "source": "/fixed/data", "target": "/data"}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compose(monkeypatch):
|
||||
baseline = model()
|
||||
calls = []
|
||||
|
||||
def run(arguments, *, data=None, **_kwargs):
|
||||
calls.append((arguments, data))
|
||||
if arguments[:3] == ["docker", "image", "inspect"]:
|
||||
return IMAGE_ID.encode("ascii")
|
||||
if arguments[-2:] == ["--format", "json"]:
|
||||
return data if data and data.startswith(b"{") else json.dumps(baseline).encode()
|
||||
return b""
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
return baseline, calls
|
||||
|
||||
|
||||
def test_compose_uses_protected_code_and_two_config_views(compose):
|
||||
baseline, calls = compose
|
||||
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
||||
service = rendered["services"]["deploy-control"]
|
||||
assert service["image"] == IMAGE_ID
|
||||
assert service["pull_policy"] == "never"
|
||||
volumes = service["volumes"]
|
||||
assert {
|
||||
"type": "bind",
|
||||
"source": module._expected_revision_path(REVISION).as_posix(),
|
||||
"target": module.EXECUTABLE_TARGET,
|
||||
"read_only": True,
|
||||
} in volumes
|
||||
assert {
|
||||
"type": "volume",
|
||||
"source": module.VOLUME,
|
||||
"target": module.TARGET,
|
||||
"volume": {"nocopy": True},
|
||||
} in volumes
|
||||
assert {
|
||||
"type": "bind",
|
||||
"source": module._expected_config_path().as_posix(),
|
||||
"target": module.CONFIG_TARGET,
|
||||
"read_only": True,
|
||||
} in volumes
|
||||
assert len(volumes) == 4
|
||||
volumes.remove(next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET))
|
||||
volumes.remove(next(item for item in volumes if item["target"] == module.TARGET))
|
||||
volumes.remove(next(item for item in volumes if item["target"] == module.CONFIG_TARGET))
|
||||
assert rendered["volumes"] == {
|
||||
module.VOLUME: {"name": module.VOLUME, "external": True}
|
||||
}
|
||||
assert rendered["services"]["runner"] == baseline["services"]["runner"]
|
||||
assert service["environment"] == baseline["services"]["deploy-control"]["environment"]
|
||||
compose_calls = [
|
||||
arguments for arguments, _ in calls if arguments[:2] == ["docker", "compose"]
|
||||
]
|
||||
assert compose_calls
|
||||
assert all("--project-directory" in arguments for arguments in compose_calls)
|
||||
assert all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls)
|
||||
assert all("--env-file" in arguments and "/dev/null" in arguments for arguments in compose_calls)
|
||||
assert all("-f" in arguments and "-" in arguments for arguments in compose_calls)
|
||||
assert calls[0][1] == ORIGINAL_COMPOSE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file"])
|
||||
def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage):
|
||||
baseline, _ = compose
|
||||
if damage == "target_collision":
|
||||
baseline["services"]["deploy-control"]["volumes"].append(
|
||||
{"type": "bind", "source": "/unsafe", "target": module.TARGET}
|
||||
)
|
||||
elif damage == "foreign_volume":
|
||||
baseline["services"]["runner"]["volumes"].append(
|
||||
{"type": "volume", "source": module.VOLUME, "target": "/foreign"}
|
||||
)
|
||||
elif damage == "graph_rw":
|
||||
baseline["services"]["deploy-control"]["volumes"][0]["read_only"] = False
|
||||
elif damage == "relative_bind":
|
||||
baseline["services"]["runner"]["volumes"][0]["source"] = "./unsafe"
|
||||
else:
|
||||
baseline["services"]["deploy-control"]["env_file"] = [".env"]
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
|
||||
|
||||
def test_compose_tag_replacement_cannot_change_controller_startup_image(compose, monkeypatch):
|
||||
_baseline, calls = compose
|
||||
original_run = module._run
|
||||
|
||||
def replaced_tag(arguments, **kwargs):
|
||||
if arguments[:3] == ["docker", "image", "inspect"]:
|
||||
return ("sha256:" + "c" * 64).encode("ascii")
|
||||
return original_run(arguments, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "_run", replaced_tag)
|
||||
with pytest.raises(module.VolumeTransitionError, match="image_reference_changed"):
|
||||
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
assert not any(
|
||||
arguments[-1:] == ["deploy-control"] and "up" in arguments
|
||||
for arguments, _ in calls
|
||||
)
|
||||
|
||||
|
||||
def test_config_snapshot_has_exact_allowlist_and_broker_token():
|
||||
entries = {
|
||||
"projects.json": (b"{}", 0o600),
|
||||
"broker-token": (b"secret", 0o600),
|
||||
"gitea-validation.json": (b"{}", 0o600),
|
||||
"gitea-validation-token": (b"secret", 0o600),
|
||||
"private-tls.json": (b"{}", 0o600),
|
||||
"appops-enrollment-bootstrap": (b"secret", 0o600),
|
||||
"webhook-secrets/forge": (b"secret", 0o600),
|
||||
"appops-deployment-secrets/repopulse": (b"secret", 0o600),
|
||||
"status_server.py": (b"code", 0o644),
|
||||
}
|
||||
directories = {
|
||||
"": 0o755,
|
||||
"webhook-secrets": 0o700,
|
||||
"appops-deployment-secrets": 0o700,
|
||||
}
|
||||
config, config_dirs = module._config_tree(entries, directories)
|
||||
assert set(config) == set(entries) - {"status_server.py"}
|
||||
assert "broker-token" in config
|
||||
assert config_dirs == directories
|
||||
|
||||
|
||||
def test_unknown_config_file_is_rejected():
|
||||
entries = {
|
||||
"projects.json": (b"{}", 0o600),
|
||||
"broker-token": (b"secret", 0o600),
|
||||
"gitea-validation.json": (b"{}", 0o600),
|
||||
"gitea-validation-token": (b"secret", 0o600),
|
||||
"shell.py": (b"unexpected", 0o600),
|
||||
}
|
||||
with pytest.raises(module.VolumeTransitionError, match="config_snapshot_unknown"):
|
||||
module._config_tree(entries, {"": 0o755})
|
||||
|
||||
|
||||
def test_partial_config_tree_is_accepted_only_for_crash_resume():
|
||||
partial = {"projects.json": (b"{}", 0o600), "broker-token": (b"secret", 0o600)}
|
||||
assert module._config_tree(partial, {"": 0o755}, allow_partial=True) == (
|
||||
partial, {"": 0o755}
|
||||
)
|
||||
with pytest.raises(module.VolumeTransitionError, match="snapshot_incomplete"):
|
||||
module._config_tree(partial, {"": 0o755})
|
||||
|
||||
|
||||
def test_config_snapshot_requires_private_mode_for_every_file():
|
||||
entries = {
|
||||
"projects.json": (b"{}", 0o644),
|
||||
"broker-token": (b"secret", 0o600),
|
||||
"gitea-validation.json": (b"{}", 0o600),
|
||||
"gitea-validation-token": (b"secret", 0o600),
|
||||
}
|
||||
with pytest.raises(module.VolumeTransitionError, match="config_file_unsafe"):
|
||||
module._config_tree(entries, {"": 0o755})
|
||||
|
||||
|
||||
def test_previous_private_tls_is_validated_from_the_attested_config_snapshot(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
previous = tmp_path / "previous-controller"
|
||||
previous.mkdir(mode=0o700)
|
||||
(previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8")
|
||||
(previous / "private-tls.json").write_bytes(b"captured private config")
|
||||
calls = []
|
||||
monkeypatch.setattr(module, "_run", lambda arguments, **kwargs: calls.append((arguments, kwargs)) or b"")
|
||||
|
||||
module._validate_previous_private_tls(tmp_path)
|
||||
|
||||
assert calls == [(
|
||||
["python3", str(previous / "status_server.py"), "--check-private-tls-config"],
|
||||
{"max_output": 4096},
|
||||
)]
|
||||
(previous / "private-tls.json").unlink()
|
||||
calls.clear()
|
||||
module._validate_previous_private_tls(tmp_path)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="private TLS snapshot guard requires POSIX symlink semantics")
|
||||
def test_previous_private_tls_rejects_symlinked_snapshot(tmp_path):
|
||||
previous = tmp_path / "previous-controller"
|
||||
previous.mkdir(mode=0o700)
|
||||
(previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8")
|
||||
target = tmp_path / "tls.json"
|
||||
target.write_bytes(b"unsafe")
|
||||
(previous / "private-tls.json").symlink_to(target)
|
||||
|
||||
with pytest.raises(module.VolumeTransitionError, match="private_tls_unsafe"):
|
||||
module._validate_previous_private_tls(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["artifacts", "config"])
|
||||
def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind):
|
||||
command = module._writer_spec(IMAGE_ID, REVISION, kind=kind)
|
||||
assert command[0:3] == ["docker", "create", "--name"]
|
||||
assert module.WRITER_NAME in command
|
||||
assert IMAGE_ID in command
|
||||
assert "--network" in command and command[command.index("--network") + 1] == "none"
|
||||
assert "--read-only" in command and "--cap-drop" in command
|
||||
assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command
|
||||
assert "--mount" in command
|
||||
mount = command[command.index("--mount") + 1]
|
||||
assert mount == f"type=volume,source={module.VOLUME},target={module.TARGET},volume-nocopy"
|
||||
assert not any(item.startswith(("/mnt/", "/var/lib/")) for item in command)
|
||||
|
||||
|
||||
def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
|
||||
baseline, calls = compose
|
||||
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
module.compose_action(rendered, "stop")
|
||||
module.compose_action(rendered, "up")
|
||||
assert calls[-2][0][-2:] == ["stop", module.SERVICE]
|
||||
assert calls[-1][0][-5:] == ["up", "-d", "--force-recreate", "--pull", "never", module.SERVICE][-5:]
|
||||
assert calls[-2][1] == rendered
|
||||
assert calls[-1][1] == rendered
|
||||
assert baseline["name"] == "gitea-deploy-runners"
|
||||
|
||||
|
||||
def test_installer_has_no_shared_executable_stage_or_compose_path():
|
||||
source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
|
||||
assert "mktemp -d -p /tmp \"itworx-controller-upgrade-" in source
|
||||
assert 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE"' not in source
|
||||
assert 'python3 "${work}/${PREPARER_NAME}"' in source
|
||||
assert 'mv -- "$CONTROLLER_DIR"' not in source
|
||||
assert 'cp -a -- "$CONTROLLER_DIR"' not in source
|
||||
assert "ops/gitea-deploy-broker/mcp_hub_release_store.py" in source
|
||||
|
||||
|
||||
def test_installer_release_order_matches_renderer_contract():
|
||||
renderer_spec = importlib.util.spec_from_file_location(
|
||||
"controller_renderer", ROOT / "scripts/prepare_controller_artifact.py"
|
||||
)
|
||||
renderer = importlib.util.module_from_spec(renderer_spec)
|
||||
renderer_spec.loader.exec_module(renderer)
|
||||
assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
|
||||
assert module.RELEASE_FILES[:3] == (
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="dirfd swap test requires POSIX openat")
|
||||
def test_mirror_updates_stay_on_the_open_directory_after_path_swap(tmp_path, monkeypatch):
|
||||
original = tmp_path / "controller"
|
||||
moved = tmp_path / "controller-pinned"
|
||||
original.mkdir()
|
||||
before_entries = {
|
||||
"controller_paths.py": (b"prior paths", 0o644),
|
||||
"mcp_hub_release_store.py": (b"prior Hub helper", 0o644),
|
||||
}
|
||||
for name, (value, _mode) in before_entries.items():
|
||||
(original / name).write_bytes(value)
|
||||
prior = {
|
||||
name: (f"prior:{name}".encode(), 0o644)
|
||||
for name in module.LEGACY_RELEASE_FILES_2_1_13
|
||||
}
|
||||
prior["controller-release.json"] = (b"prior manifest", 0o644)
|
||||
updated = {
|
||||
name: (f"updated:{name}".encode(), 0o644)
|
||||
for name in module.RELEASE_FILES
|
||||
}
|
||||
updated["controller-release.json"] = (b"updated manifest", 0o644)
|
||||
prior_tar = module._release_tar_bytes("b" * 40, (prior, {"": 0o755}))
|
||||
updated_tar = module._release_tar_bytes("c" * 40, (updated, {"": 0o755}))
|
||||
mirror_tar = module._release_tar_bytes("a" * 40, (before_entries, {"": 0o755}))
|
||||
current_path = original
|
||||
monkeypatch.setattr(
|
||||
module, "_directory",
|
||||
lambda _path, _anchor: os.open(current_path, os.O_RDONLY | os.O_DIRECTORY),
|
||||
)
|
||||
read_item = module._file_info_at_optional
|
||||
swapped = False
|
||||
|
||||
def swap_after_first_read(descriptor, name):
|
||||
nonlocal swapped
|
||||
value = read_item(descriptor, name)
|
||||
if not swapped:
|
||||
original.rename(moved)
|
||||
original.mkdir()
|
||||
swapped = True
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(module, "_file_info_at_optional", swap_after_first_read)
|
||||
module._sync_controller_mirror(
|
||||
prior_tar, updated_tar, mirror_tar, direction="forward"
|
||||
)
|
||||
assert swapped and list(original.iterdir()) == []
|
||||
assert (moved / "controller_paths.py").read_bytes() == b"updated:controller_paths.py"
|
||||
assert (moved / "mcp_hub_release_store.py").read_bytes() == b"updated:mcp_hub_release_store.py"
|
||||
|
||||
current_path = moved
|
||||
monkeypatch.setattr(module, "_file_info_at_optional", read_item)
|
||||
module._sync_controller_mirror(
|
||||
updated_tar, prior_tar, mirror_tar, direction="rollback"
|
||||
)
|
||||
assert {path.name: path.read_bytes() for path in moved.iterdir()} == {
|
||||
name: value for name, (value, _mode) in before_entries.items()
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="private /tmp ownership validation requires root on POSIX")
|
||||
def test_private_stage_rejects_symlinked_source_directory():
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
work = Path("/tmp") / f"itworx-controller-upgrade-{uuid.uuid4().hex[:12]}.Ab3xY7"
|
||||
source = work / "source"
|
||||
source.mkdir(parents=True, mode=0o700)
|
||||
source.chmod(0o700)
|
||||
work.chmod(0o700)
|
||||
try:
|
||||
module._validate_private_stage(source)
|
||||
source.rename(work / "source-real")
|
||||
source.symlink_to(work / "source-real", target_is_directory=True)
|
||||
with pytest.raises(module.VolumeTransitionError, match="private_stage_invalid"):
|
||||
module._validate_private_stage(source)
|
||||
finally:
|
||||
shutil.rmtree(work)
|
||||
|
||||
|
||||
def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose):
|
||||
_baseline, calls = compose
|
||||
unsafe = (
|
||||
b"name: gitea-deploy-runners\nservices:\n deploy-control:\n"
|
||||
b" image: x\n env_file: /tmp/secret\n"
|
||||
)
|
||||
with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"):
|
||||
module.rendered_model(unsafe, REVISION, IMAGE_ID)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_capture_program_is_fixed_descriptor_based_and_compiles():
|
||||
legacy = module._controller_capture_source(config_root_in_code=True)
|
||||
protected = module._controller_capture_source(config_root_in_code=False)
|
||||
compile(legacy, "legacy-controller-capture", "exec")
|
||||
compile(protected, "protected-controller-capture", "exec")
|
||||
assert "config_root='/opt/gitea-deploy'" in legacy
|
||||
assert "config_root='/controller-config'" in protected
|
||||
assert "os.O_NOFOLLOW" in legacy and "os.fstat(fd)" in legacy
|
||||
assert "open(path,'rb')" not in legacy
|
||||
|
||||
|
||||
def test_prior_release_capture_must_match_protected_git_tree(monkeypatch):
|
||||
files = module.LEGACY_RELEASE_FILES_2_1_5
|
||||
entries = {name: (f"source:{name}".encode(), 0o644) for name in files}
|
||||
source_revision = "32a6eece0e3e1276946abdc04196c819daf02e2a"
|
||||
source_tree = "e0d7e80b79504b964d968e51e0d21c288cac8f0d"
|
||||
manifest = {
|
||||
"apiVersion": "itworx.controller-artifact/v1",
|
||||
"controllerVersion": "2.1.5",
|
||||
"sourceRepository": "jens/unraid-appops-gateway",
|
||||
"sourceRevision": source_revision,
|
||||
"sourceTree": source_tree,
|
||||
"artifactSha256": module._artifact_digest(entries, files),
|
||||
"builtAt": "2026-10-01T00:00:00.000Z",
|
||||
}
|
||||
entries["controller-release.json"] = (
|
||||
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
|
||||
)
|
||||
identity = module._release_identity(entries)
|
||||
outputs = {"rev-parse": (source_tree + "\n").encode()}
|
||||
outputs.update({
|
||||
f"ops/gitea-deploy-broker/{name}": entries[name][0]
|
||||
for name in files if name != "run_repopulse_adoption.py"
|
||||
})
|
||||
outputs["scripts/run_repopulse_adoption.py"] = entries["run_repopulse_adoption.py"][0]
|
||||
|
||||
def run(arguments, **_kwargs):
|
||||
return outputs[arguments[-1].split(":", 1)[-1]] if arguments[-2] == "show" else outputs["rev-parse"]
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
module._verify_git_source_identity(entries, identity)
|
||||
outputs["ops/gitea-deploy-broker/deploy.py"] = b"replaced source"
|
||||
with pytest.raises(module.VolumeTransitionError, match="source_mismatch"):
|
||||
module._verify_git_source_identity(entries, identity)
|
||||
@@ -181,60 +181,55 @@ def test_successful_reconciliation_can_be_rolled_back_before_controller_swap(tmp
|
||||
assert not journal.exists()
|
||||
|
||||
|
||||
def test_upgrade_stops_old_writer_before_locking_cache_and_reconciles_while_stopped():
|
||||
def test_upgrade_prepares_before_stop_and_reconciles_between_stop_and_activation():
|
||||
script = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
cache_lock = 'flock -x "$cache_lock_fd"'
|
||||
stop = 'writers_stopped=1\ndocker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
|
||||
reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root'
|
||||
backup = 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"'
|
||||
swap = 'mv -- "$CONTROLLER_DIR" "$previous"'
|
||||
prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\'
|
||||
stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"'
|
||||
reconcile = 'python3 "${work}/reconcile_controller_state.py" \\\n --legacy-root'
|
||||
activate = 'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"'
|
||||
|
||||
assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script
|
||||
assert script.index(stop) < script.index(cache_lock)
|
||||
assert script.index(stop) < script.index(backup)
|
||||
assert script.index(cache_lock) < script.index(backup)
|
||||
assert script.index(backup) < script.index(reconcile)
|
||||
assert script.index(reconcile) < script.index(swap)
|
||||
assert 'flock -u "$cache_lock_fd"' not in script
|
||||
assert script.index(prepare) < script.index(stop)
|
||||
assert script.index(stop) < script.index(reconcile)
|
||||
assert script.index(reconcile) < script.index(activate)
|
||||
assert 'flock -x "$promotion_lock_fd"' in script
|
||||
assert 'mv -- "$CONTROLLER_DIR"' not in script
|
||||
assert 'cp -a -- "$CONTROLLER_DIR"' not in script
|
||||
|
||||
|
||||
def test_upgrade_retains_and_validates_optional_private_tls_before_stopping_controller():
|
||||
def test_upgrade_prepares_and_validates_captured_optional_private_tls_before_stopping_controller():
|
||||
script = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
stage_copy = 'cp -a -- "$CONTROLLER_DIR" "${stage}/controller"'
|
||||
tls_gate = (
|
||||
'if [[ -e "${stage}/controller/private-tls.json" '
|
||||
'|| -L "${stage}/controller/private-tls.json" ]]; then\n'
|
||||
' python3 "${stage}/controller/status_server.py" --check-private-tls-config\n'
|
||||
'fi'
|
||||
)
|
||||
stop = (
|
||||
'writers_stopped=1\ndocker compose --project-directory "$ROOT" '
|
||||
'-f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
|
||||
)
|
||||
prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\'
|
||||
stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"'
|
||||
preparer = (ROOT / "scripts/prepare_controller_hub_release_volume.py").read_text(encoding="utf-8")
|
||||
|
||||
assert stage_copy in script
|
||||
assert script.index(stage_copy) < script.index(tls_gate)
|
||||
assert script.index(tls_gate) < script.index(stop)
|
||||
assert 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' in script
|
||||
assert "_validate_previous_private_tls(stage_root)" in preparer
|
||||
assert script.index(prepare) < script.index(stop)
|
||||
|
||||
|
||||
def test_upgrade_rolls_back_state_before_restoring_or_starting_the_old_writer():
|
||||
script = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
restore = script[script.index("restore_previous() {") : script.index("cleanup() {")]
|
||||
|
||||
stop = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
|
||||
stop = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup" --stop-only; then'
|
||||
rollback = '--rollback'
|
||||
restore_controller = 'mv -- "$previous" "$CONTROLLER_DIR"'
|
||||
start = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" up -d --force-recreate "$COMPOSE_SERVICE"'
|
||||
restore_controller = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup"; then'
|
||||
|
||||
assert restore.index(stop) < restore.index(rollback)
|
||||
assert restore.index(rollback) < restore.index(restore_controller)
|
||||
assert restore.index(restore_controller) < restore.index(start)
|
||||
assert "the old writer will remain stopped" in script
|
||||
reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root'
|
||||
assert script.index("reconciliation_active=1", script.index("writers_stopped=1")) < script.index(reconcile)
|
||||
assert "controller remains stopped" in script
|
||||
assert 'mv -- "$previous"' not in restore
|
||||
assert 'docker compose --project-directory "$ROOT"' not in restore
|
||||
reconcile = 'python3 "${work}/reconcile_controller_state.py"'
|
||||
stopped = script.index("writers_stopped=1", script.index(stop))
|
||||
reconcile_index = script.index(reconcile, stopped)
|
||||
activate_index = script.index(
|
||||
'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"',
|
||||
reconcile_index,
|
||||
)
|
||||
assert stopped < reconcile_index < activate_index
|
||||
assert '[[ -f "$migration_journal" && ! -L "$migration_journal" ]]' in restore
|
||||
assert '[[ -e "$migration_journal" || -L "$migration_journal" ]]' in restore
|
||||
|
||||
|
||||
@@ -2226,6 +2226,96 @@ def test_managed_repopulse_render_accepts_only_its_read_only_source_mount(
|
||||
deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/repopulse")
|
||||
|
||||
|
||||
def test_managed_appdata_roots_are_fixed_and_bound_to_each_registration(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
legacy_root = tmp_path / "legacy-appdata"
|
||||
managed_root = tmp_path / "managed-volume" / "_data"
|
||||
monkeypatch.setattr(deploy_module, "LEGACY_MANAGED_APPCONFIG_ROOT", legacy_root)
|
||||
monkeypatch.setattr(deploy_module, "MANAGED_APPCONFIG_ROOT", legacy_root)
|
||||
monkeypatch.setattr(deploy_module, "MANAGED_APPCONFIG_VOLUME_ROOT", managed_root)
|
||||
monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: False)
|
||||
|
||||
assert deploy_module._registered_managed_appdata_root({}) == legacy_root
|
||||
assert deploy_module._registered_managed_appdata_root(
|
||||
{"managed_appdata_root": legacy_root.as_posix()}
|
||||
) == legacy_root
|
||||
with pytest.raises(deploy_module.DeployError, match="not initialized and available"):
|
||||
deploy_module._registered_managed_appdata_root(
|
||||
{"managed_appdata_root": managed_root.as_posix()}
|
||||
)
|
||||
with pytest.raises(deploy_module.DeployError, match="fixed registered roots"):
|
||||
deploy_module._registered_managed_appdata_root(
|
||||
{"managed_appdata_root": (tmp_path / "arbitrary").as_posix()}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: True)
|
||||
assert deploy_module._registered_managed_appdata_root(
|
||||
{"managed_appdata_root": managed_root.as_posix()}
|
||||
) == managed_root
|
||||
|
||||
for root, host in ((legacy_root, "legacy.example"), (managed_root, "managed.example")):
|
||||
env_path = root / "demo" / "deploy.env"
|
||||
env_path.parent.mkdir(parents=True)
|
||||
env_path.write_text(f"APPOPS_PUBLIC_URL=https://{host}\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
env_path.chmod(0o600)
|
||||
|
||||
project = {
|
||||
"appops_managed_target": True,
|
||||
"managed_plan_version": "itworx.deployment/v1",
|
||||
"managed_profile": "internal-webapp",
|
||||
"managed_application_id": "demo",
|
||||
"managed_runtime_environment_keys": ["APPOPS_PUBLIC_URL"],
|
||||
"env_source": (legacy_root / "demo" / "deploy.env").as_posix(),
|
||||
}
|
||||
assert deploy_module.managed_runtime_environment(project) == {
|
||||
"APPOPS_PUBLIC_URL": "https://legacy.example"
|
||||
}
|
||||
project["managed_appdata_root"] = managed_root.as_posix()
|
||||
project["env_source"] = (managed_root / "demo" / "deploy.env").as_posix()
|
||||
assert deploy_module.managed_runtime_environment(project) == {
|
||||
"APPOPS_PUBLIC_URL": "https://managed.example"
|
||||
}
|
||||
|
||||
|
||||
def test_managed_project_path_is_derived_from_its_stored_appdata_root(
|
||||
deploy_module, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: True)
|
||||
monkeypatch.setattr(deploy_module, "git_path_exists", lambda *_args: True)
|
||||
project = {
|
||||
"containers": ["demo"],
|
||||
"source_dir": (deploy_module.EXACT_SOURCE_ROOT / "demo").as_posix(),
|
||||
"exact_source": True,
|
||||
"compose_dir": ".",
|
||||
"compose_files": ["compose.yml"],
|
||||
"compose_project": "demo",
|
||||
"managed_plan_version": "itworx.deployment/v1",
|
||||
"managed_application_id": "demo",
|
||||
"managed_appdata_path": "/srv/example/appdata/demo",
|
||||
"managed_image_repository": "demo",
|
||||
"managed_image_tag": "production",
|
||||
"managed_proxy_network": "itworx-proxy",
|
||||
"managed_internal_port": 8000,
|
||||
"appops_deployment_secret_file": "jens__demo",
|
||||
}
|
||||
|
||||
deploy_module.validate_project_config(Path("repo.git"), project, "a" * 40)
|
||||
managed = {
|
||||
**project,
|
||||
"managed_appdata_root": deploy_module.MANAGED_APPCONFIG_VOLUME_ROOT.as_posix(),
|
||||
"managed_appdata_path": (
|
||||
deploy_module.MANAGED_APPCONFIG_VOLUME_ROOT / "demo"
|
||||
).as_posix(),
|
||||
}
|
||||
deploy_module.validate_project_config(Path("repo.git"), managed, "a" * 40)
|
||||
|
||||
remapped = {**managed, "managed_appdata_path": "/srv/example/appdata/demo"}
|
||||
with pytest.raises(deploy_module.DeployError, match="not derived"):
|
||||
deploy_module.validate_project_config(Path("repo.git"), remapped, "a" * 40)
|
||||
|
||||
|
||||
def test_repopulse_project_registry_requires_its_fixed_build_identity(
|
||||
deploy_module, monkeypatch
|
||||
):
|
||||
@@ -4582,6 +4672,25 @@ def _appops_plan(revision: str) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def test_appops_plan_root_must_match_the_registered_target_root(status_module):
|
||||
project = _appops_project()
|
||||
plan = _appops_plan("a" * 40)
|
||||
assert status_module._appops_plan({"jens/repo-a": project}, plan) == (
|
||||
"jens/repo-a", "a" * 40, project,
|
||||
)
|
||||
|
||||
project["managed_appdata_root"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
project["managed_appdata_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repo-a"
|
||||
alternate_plan = {**plan, "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data"}
|
||||
assert status_module._appops_plan({"jens/repo-a": project}, alternate_plan) == (
|
||||
"jens/repo-a", "a" * 40, project,
|
||||
)
|
||||
assert status_module._appops_plan({"jens/repo-a": project}, plan) is None
|
||||
|
||||
drifted = {**project, "managed_appdata_path": "/srv/example/appdata/repo-a"}
|
||||
assert status_module._appops_plan({"jens/repo-a": drifted}, alternate_plan) is None
|
||||
|
||||
|
||||
def _write_appops_secret(root: Path, name: str, value: bytes) -> None:
|
||||
root.mkdir(mode=0o700)
|
||||
root.chmod(0o700)
|
||||
@@ -5838,6 +5947,7 @@ def _controller_enrollment_payload(*, credential: bool = False) -> dict[str, obj
|
||||
"profile": "internal-webapp-oidc",
|
||||
"container_name": "wsbc-portal",
|
||||
"compose_file": ".itworx/deployment.yml",
|
||||
"trusted_appdata_root": "/srv/example/appdata",
|
||||
"appdata_path": "/srv/example/appdata/wsbc-portal",
|
||||
"data_path": "/srv/example/appdata/wsbc-portal/data",
|
||||
"runtime_uid": 1654,
|
||||
@@ -5871,6 +5981,7 @@ def _repopulse_controller_enrollment_payload(*, credential: bool = False) -> dic
|
||||
"profile": "repopulse-authenticated",
|
||||
"container_name": "repopulse",
|
||||
"compose_file": ".itworx/deployment.yml",
|
||||
"trusted_appdata_root": "/srv/example/appdata",
|
||||
"appdata_path": "/srv/example/appdata/repopulse",
|
||||
"data_path": "/srv/example/appdata/repopulse/data",
|
||||
"runtime_uid": 10001,
|
||||
@@ -5929,8 +6040,10 @@ def test_controller_repopulse_runtime_seal_fixes_gitea_url_and_hides_values(stat
|
||||
env_path = app_root / "deploy.managed.env"
|
||||
env_path.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root)
|
||||
monkeypatch.setattr(status_module, "LEGACY_APPDATA_ROOT", appdata_root)
|
||||
monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources")
|
||||
payload = _repopulse_controller_enrollment_payload(credential=True)
|
||||
payload["trusted_appdata_root"] = appdata_root.as_posix()
|
||||
payload["appdata_path"] = app_root.as_posix()
|
||||
payload["data_path"] = (app_root / "data").as_posix()
|
||||
enrollment = status_module._controller_enrollment(payload, require_credential=True)
|
||||
@@ -6106,6 +6219,76 @@ def test_controller_enrollment_contract_rejects_arbitrary_compose_and_paths(stat
|
||||
assert status_module._controller_enrollment(unsafe, require_credential=True) is None
|
||||
|
||||
|
||||
def test_controller_enrollment_derives_paths_from_the_selected_fixed_root(status_module, monkeypatch):
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", Path(managed_root))
|
||||
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root)
|
||||
alternate = _controller_enrollment_payload()
|
||||
alternate["trusted_appdata_root"] = managed_root
|
||||
alternate["appdata_path"] = managed_root + "/wsbc-portal"
|
||||
alternate["data_path"] = managed_root + "/wsbc-portal/data"
|
||||
|
||||
enrollment = status_module._controller_enrollment(alternate, require_credential=False)
|
||||
assert enrollment is not None
|
||||
project = status_module._enrollment_project(enrollment)
|
||||
assert project["managed_appdata_root"] == managed_root
|
||||
assert project["managed_appdata_path"] == managed_root + "/wsbc-portal"
|
||||
|
||||
unsafe_path = {**alternate, "data_path": "/srv/example/appdata/wsbc-portal/data"}
|
||||
assert status_module._controller_enrollment(unsafe_path, require_credential=False) is None
|
||||
|
||||
unsupported_root = {**alternate, "trusted_appdata_root": "/srv/example/other"}
|
||||
assert status_module._controller_enrollment(unsupported_root, require_credential=False) is None
|
||||
|
||||
|
||||
def test_controller_accepts_legacy_requests_after_managed_marker_is_initialized(status_module, monkeypatch):
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: Path(managed_root))
|
||||
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root)
|
||||
legacy = _controller_enrollment_payload()
|
||||
assert legacy["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
|
||||
enrollment = status_module._controller_enrollment(legacy, require_credential=False)
|
||||
assert enrollment is not None
|
||||
assert enrollment["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
assert "managed_appdata_root" not in status_module._enrollment_project(enrollment)
|
||||
|
||||
|
||||
def test_controller_accepts_legacy_v1_payload_without_root_field(status_module):
|
||||
legacy = _controller_enrollment_payload()
|
||||
legacy.pop("trusted_appdata_root")
|
||||
|
||||
enrollment = status_module._controller_enrollment(legacy, require_credential=False)
|
||||
|
||||
assert enrollment is not None
|
||||
assert enrollment["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
assert "managed_appdata_root" not in status_module._enrollment_project(enrollment)
|
||||
|
||||
|
||||
def test_repopulse_enrollment_remains_bound_to_legacy_appdata_root(status_module, monkeypatch):
|
||||
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", Path("/var/lib/docker/volumes/itworx-managed-appdata/_data"))
|
||||
payload = _repopulse_controller_enrollment_payload()
|
||||
payload["trusted_appdata_root"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
payload["appdata_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repopulse"
|
||||
payload["data_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repopulse/data"
|
||||
|
||||
assert status_module._controller_enrollment(payload, require_credential=False) is None
|
||||
|
||||
|
||||
def test_fixed_repopulse_profile_stays_legacy_with_managed_marker(status_module, monkeypatch):
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: Path(managed_root))
|
||||
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root)
|
||||
|
||||
enrollment = status_module._controller_enrollment(
|
||||
_repopulse_controller_enrollment_payload(),
|
||||
require_credential=False,
|
||||
)
|
||||
|
||||
assert enrollment is not None
|
||||
assert enrollment["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
|
||||
|
||||
def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module, tmp_path, monkeypatch):
|
||||
appdata_root = tmp_path / "appdata"
|
||||
app_root = appdata_root / "wsbc-portal"
|
||||
@@ -6113,8 +6296,10 @@ def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module,
|
||||
env_path = app_root / "deploy.env"
|
||||
env_path.write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root)
|
||||
monkeypatch.setattr(status_module, "LEGACY_APPDATA_ROOT", appdata_root)
|
||||
monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources")
|
||||
payload = _controller_enrollment_payload(credential=True)
|
||||
payload["trusted_appdata_root"] = appdata_root.as_posix()
|
||||
payload["appdata_path"] = app_root.as_posix()
|
||||
payload["data_path"] = (app_root / "data").as_posix()
|
||||
enrollment = status_module._controller_enrollment(payload, require_credential=True)
|
||||
@@ -6154,6 +6339,7 @@ def test_controller_enrollment_replay_requires_the_existing_target_credential(st
|
||||
projects_path.write_text(json.dumps({"jens/windows-support-bundle-collector": project}), encoding="utf-8")
|
||||
monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path)
|
||||
monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secrets_root)
|
||||
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda _root: True)
|
||||
enrollment["target_credential"] = "y" * 64
|
||||
|
||||
status, _evidence = status_module._enroll_controller_target(enrollment)
|
||||
@@ -7316,3 +7502,249 @@ def test_workmesh_backup_receipt_requires_bound_manifest_and_verified_restore_by
|
||||
assert status_module._verified_workmesh_backup_receipt(
|
||||
request_id, candidate, preflight, inputs
|
||||
) is None
|
||||
|
||||
|
||||
def _managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
*,
|
||||
body: bytes,
|
||||
nonce: str,
|
||||
secret: bytes | None,
|
||||
signature: str | None = None,
|
||||
signed_path: str | None = None,
|
||||
):
|
||||
path = status_module.MANAGED_APPDATA_INITIALIZE_PATH
|
||||
timestamp = str(int(status_module.time.time()))
|
||||
headers = {"Content-Type": "application/json", "Content-Length": str(len(body))}
|
||||
if nonce:
|
||||
headers["X-ITWorx-Deployment-Nonce"] = nonce
|
||||
if timestamp:
|
||||
headers["X-ITWorx-Deployment-Timestamp"] = timestamp
|
||||
if signature is None and secret is not None:
|
||||
signature = status_module._request_signature(
|
||||
secret,
|
||||
method="POST",
|
||||
path=signed_path or path,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
raw_body=body,
|
||||
)
|
||||
if signature is not None:
|
||||
headers["X-ITWorx-Deployment-Signature"] = signature
|
||||
|
||||
connection = http.client.HTTPConnection(*server.server_address, timeout=2)
|
||||
connection.request("POST", path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
status = response.status
|
||||
payload = json.loads(response.read())
|
||||
connection.close()
|
||||
return status, payload, headers
|
||||
|
||||
|
||||
def test_managed_appdata_initializer_http_requires_exact_signed_one_time_request(
|
||||
status_module, tmp_path, monkeypatch
|
||||
):
|
||||
secret = b"managed-storage-bootstrap-" + b"x" * 48
|
||||
secret_path = tmp_path / "bootstrap"
|
||||
secret_path.write_bytes(secret)
|
||||
secret_path.chmod(0o600)
|
||||
nonce_root = tmp_path / "nonces"
|
||||
monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path)
|
||||
monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root)
|
||||
monkeypatch.setattr(
|
||||
status_module.Handler,
|
||||
"_managed_storage_initialization_prerequisites",
|
||||
lambda _handler: True,
|
||||
)
|
||||
initialized = []
|
||||
monkeypatch.setattr(
|
||||
status_module,
|
||||
"ensure_managed_appdata_root",
|
||||
lambda: initialized.append(True) or "initialized",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
status_module,
|
||||
"_refresh_appdata_root_selection",
|
||||
lambda: status_module.MANAGED_APPDATA_ROOT,
|
||||
)
|
||||
server = status_module.BoundedThreadingHTTPServer(
|
||||
("127.0.0.1", 0), status_module.Handler
|
||||
)
|
||||
serving = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
serving.start()
|
||||
body = json.dumps(
|
||||
{
|
||||
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
|
||||
"target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
try:
|
||||
success_status, success_payload, success_headers = _managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
body=body,
|
||||
nonce="a" * 32,
|
||||
secret=secret,
|
||||
)
|
||||
assert success_status == 200
|
||||
assert success_payload == {
|
||||
"status": "initialized",
|
||||
"trustedAppdataRoot": status_module.MANAGED_APPDATA_ROOT.as_posix(),
|
||||
"backupLocation": "dockerRootDir",
|
||||
}
|
||||
assert initialized == [True]
|
||||
assert secret.decode("ascii") not in json.dumps(success_payload)
|
||||
|
||||
replay = http.client.HTTPConnection(*server.server_address, timeout=2)
|
||||
replay.request(
|
||||
"POST",
|
||||
status_module.MANAGED_APPDATA_INITIALIZE_PATH,
|
||||
body=body,
|
||||
headers=success_headers,
|
||||
)
|
||||
replay_response = replay.getresponse()
|
||||
replay_payload = json.loads(replay_response.read())
|
||||
replay.close()
|
||||
assert replay_response.status == 403
|
||||
assert replay_payload == {"status": "authentication_rejected"}
|
||||
assert initialized == [True]
|
||||
|
||||
invalid_requests = [
|
||||
_managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
body=body,
|
||||
nonce="b" * 32,
|
||||
secret=secret,
|
||||
signature="0" * 64,
|
||||
),
|
||||
_managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
body=body,
|
||||
nonce="c" * 32,
|
||||
secret=secret,
|
||||
signed_path="/v1/appops/enrollments/capabilities",
|
||||
),
|
||||
_managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
body=body,
|
||||
nonce="d" * 32,
|
||||
secret=None,
|
||||
),
|
||||
]
|
||||
assert [status for status, _payload, _headers in invalid_requests] == [403, 403, 403]
|
||||
assert initialized == [True]
|
||||
|
||||
for index, payload in enumerate(
|
||||
[
|
||||
{
|
||||
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
|
||||
"target": "tower/other-controller",
|
||||
},
|
||||
{
|
||||
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
|
||||
"target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET,
|
||||
"path": "C:/private/sentinel",
|
||||
},
|
||||
],
|
||||
start=1,
|
||||
):
|
||||
raw_body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
rejected_status, rejected_payload, _headers = _managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
body=raw_body,
|
||||
nonce=f"{index:032x}",
|
||||
secret=secret,
|
||||
)
|
||||
assert rejected_status == 400
|
||||
assert rejected_payload == {"status": "invalid_managed_storage_request"}
|
||||
assert "C:/private/sentinel" not in json.dumps(rejected_payload)
|
||||
oversized_status, oversized_payload, _headers = _managed_storage_http_request(
|
||||
status_module,
|
||||
server,
|
||||
body=b"x" * 1025,
|
||||
nonce="e" * 32,
|
||||
secret=None,
|
||||
)
|
||||
assert oversized_status == 413
|
||||
assert oversized_payload == {"status": "invalid_body_size"}
|
||||
assert initialized == [True]
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
serving.join(timeout=2)
|
||||
|
||||
|
||||
def test_managed_appdata_initializer_http_prerequisite_and_refusal_are_sanitized(
|
||||
status_module, tmp_path, monkeypatch
|
||||
):
|
||||
secret = b"managed-storage-bootstrap-" + b"y" * 48
|
||||
secret_path = tmp_path / "bootstrap"
|
||||
secret_path.write_bytes(secret)
|
||||
secret_path.chmod(0o600)
|
||||
monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path)
|
||||
monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", tmp_path / "nonces")
|
||||
prerequisites = {"ready": False}
|
||||
monkeypatch.setattr(
|
||||
status_module.Handler,
|
||||
"_managed_storage_initialization_prerequisites",
|
||||
lambda _handler: prerequisites["ready"],
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
status_module,
|
||||
"ensure_managed_appdata_root",
|
||||
lambda: calls.append(True) or "initialized",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
status_module,
|
||||
"_refresh_appdata_root_selection",
|
||||
lambda: status_module.MANAGED_APPDATA_ROOT,
|
||||
)
|
||||
server = status_module.BoundedThreadingHTTPServer(
|
||||
("127.0.0.1", 0), status_module.Handler
|
||||
)
|
||||
serving = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
serving.start()
|
||||
body = json.dumps(
|
||||
{
|
||||
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
|
||||
"target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
try:
|
||||
status, payload, _headers = _managed_storage_http_request(
|
||||
status_module, server, body=body, nonce="f" * 32, secret=secret
|
||||
)
|
||||
assert status == 503
|
||||
assert payload == {"status": "managed_storage_prerequisite_unavailable"}
|
||||
assert calls == []
|
||||
|
||||
prerequisites["ready"] = True
|
||||
|
||||
def refuse_with_sensitive_diagnostic():
|
||||
raise status_module.UnsafeManagedAppdataRoot(
|
||||
"secret/path sentinel C:/private/appdata"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
status_module, "ensure_managed_appdata_root", refuse_with_sensitive_diagnostic
|
||||
)
|
||||
status, payload, _headers = _managed_storage_http_request(
|
||||
status_module, server, body=body, nonce="1" * 32, secret=secret
|
||||
)
|
||||
assert status == 409
|
||||
assert payload == {"status": "managed_storage_initialization_refused"}
|
||||
assert "secret/path sentinel" not in json.dumps(payload)
|
||||
assert "C:/private/appdata" not in json.dumps(payload)
|
||||
assert secret.decode("ascii") not in json.dumps(payload)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
serving.join(timeout=2)
|
||||
@@ -74,12 +74,14 @@ def _trust():
|
||||
|
||||
|
||||
class _Broker:
|
||||
appdata_root = "/srv/example/appdata"
|
||||
|
||||
def is_configured(self):
|
||||
return True
|
||||
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
assert secret == "b" * 64
|
||||
return {"status": "ready"}
|
||||
return {"status": "ready", "trustedAppdataRoot": self.appdata_root}
|
||||
|
||||
def enrollment_preflight(self, enrollment, *, secret):
|
||||
assert secret == "b" * 64
|
||||
@@ -251,6 +253,25 @@ def test_enrollment_schema_rejects_plaintext_secrets_and_untrusted_topology():
|
||||
raise AssertionError("wildcard hostname must be rejected")
|
||||
|
||||
|
||||
def test_appdata_root_defaults_to_legacy_and_allows_only_the_fixed_managed_root():
|
||||
plan = _plan()
|
||||
assert plan.trusted_appdata_root == "/srv/example/appdata"
|
||||
assert plan.appdata_path == "/srv/example/appdata/wsbc-portal"
|
||||
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
managed = _plan(trusted_appdata_root=managed_root)
|
||||
assert managed.appdata_path == managed_root + "/wsbc-portal"
|
||||
|
||||
plan_payload = _plan().model_dump()
|
||||
plan_payload["trusted_appdata_root"] = "/srv/example/other"
|
||||
trust_payload = _trust().model_dump()
|
||||
trust_payload["trusted_appdata_root"] = "/srv/example/other"
|
||||
with pytest.raises(ValueError):
|
||||
EnrollmentPlanV1.model_validate(plan_payload)
|
||||
with pytest.raises(ValueError):
|
||||
EnrollmentTrustConfigV1.model_validate(trust_payload)
|
||||
|
||||
|
||||
def test_repopulse_profile_accepts_only_its_fixed_authenticated_target():
|
||||
plan = _repopulse_plan()
|
||||
assert plan.application_id == "repopulse"
|
||||
@@ -291,6 +312,34 @@ def test_repopulse_preflight_requires_existing_secret_and_trusted_npm_access_lis
|
||||
assert session.exec(select(EnrollmentJob)).all() == []
|
||||
|
||||
|
||||
def test_repopulse_legacy_profile_remains_usable_when_new_target_root_is_managed(tmp_path):
|
||||
class RecordingBroker(_RepoPulseBroker):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.preflight_payloads = []
|
||||
|
||||
def enrollment_preflight(self, enrollment, *, secret):
|
||||
self.preflight_payloads.append(dict(enrollment))
|
||||
return super().enrollment_preflight(enrollment, secret=secret)
|
||||
|
||||
broker = RecordingBroker()
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
set_secret(session, "REPOPULSE_GITEA_TOKEN", "opaque-gitea-token")
|
||||
EnrollmentPlane.set_trust_config(
|
||||
session,
|
||||
_trust().model_copy(update={
|
||||
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}),
|
||||
)
|
||||
result = _plane(broker=broker, npm=_RepoPulseNpm()).preflight(session, _repopulse_plan())
|
||||
|
||||
assert result["status"] == "ready"
|
||||
assert "trusted_appdata_root_mismatch" not in result["blockers"]
|
||||
assert broker.preflight_payloads[0]["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
|
||||
|
||||
def test_repopulse_enrollment_seals_exact_runtime_values_without_exposing_secrets(tmp_path):
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
@@ -347,6 +396,126 @@ def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path):
|
||||
assert result["controller"]["status"] == "ready"
|
||||
|
||||
|
||||
def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp_path):
|
||||
class AlternateRootBroker(_Broker):
|
||||
appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
result = _plane(broker=AlternateRootBroker()).capabilities(session)
|
||||
|
||||
assert result["targetEnrollment"]["status"] == "unavailable"
|
||||
assert result["controller"]["status"] == "unavailable"
|
||||
assert result["controller"]["reason"] == "trusted_appdata_root_mismatch"
|
||||
|
||||
|
||||
def test_capabilities_treat_roots_as_independent_availability_not_global_selection(tmp_path):
|
||||
legacy_root = "/srv/example/appdata"
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
|
||||
class MultiRootBroker(_Broker):
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
assert secret == "b" * 64
|
||||
return {
|
||||
"status": "ready",
|
||||
"trustedAppdataRoot": managed_root,
|
||||
"availableTrustedAppdataRoots": [
|
||||
{"root": legacy_root, "status": "ready", "reason": ""},
|
||||
{"root": managed_root, "status": "ready", "reason": ""},
|
||||
],
|
||||
"managedStorage": {"status": "ready", "initializationAvailable": False},
|
||||
}
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
plane = _plane(broker=MultiRootBroker())
|
||||
legacy = plane.capabilities(session)
|
||||
assert legacy["targetEnrollment"]["status"] == "ready"
|
||||
assert legacy["managedStorage"]["status"] == "not_selected"
|
||||
assert legacy["availableTrustedAppdataRoots"] == [
|
||||
{"root": legacy_root, "status": "ready"},
|
||||
{"root": managed_root, "status": "ready"},
|
||||
]
|
||||
|
||||
EnrollmentPlane.set_trust_config(
|
||||
session,
|
||||
_trust().model_copy(update={
|
||||
"trusted_appdata_root": managed_root,
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}),
|
||||
)
|
||||
managed = plane.capabilities(session)
|
||||
|
||||
assert managed["targetEnrollment"]["status"] == "ready"
|
||||
assert managed["managedStorage"]["status"] == "ready"
|
||||
assert managed["availableTrustedAppdataRoots"] == [
|
||||
{"root": legacy_root, "status": "ready"},
|
||||
{"root": managed_root, "status": "ready"},
|
||||
]
|
||||
|
||||
|
||||
def test_capabilities_fail_closed_and_hide_unrecognized_controller_roots(tmp_path):
|
||||
class MalformedRootsBroker(_Broker):
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
return {
|
||||
"status": "ready",
|
||||
"trustedAppdataRoot": "/srv/example/appdata",
|
||||
"availableTrustedAppdataRoots": [
|
||||
{"root": "/srv/example/appdata", "status": "ready"},
|
||||
{"root": "/etc/private", "status": "ready", "reason": "sensitive controller path"},
|
||||
],
|
||||
}
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
result = _plane(broker=MalformedRootsBroker()).capabilities(session)
|
||||
|
||||
assert result["targetEnrollment"]["status"] == "unavailable"
|
||||
assert result["availableTrustedAppdataRoots"] == [
|
||||
{"root": "/srv/example/appdata", "status": "unavailable"},
|
||||
{"root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", "status": "unavailable"},
|
||||
]
|
||||
assert "/etc/private" not in repr(result)
|
||||
assert "sensitive controller path" not in repr(result)
|
||||
|
||||
|
||||
def test_enrollment_preflight_binds_root_to_trust_and_controller_payload(tmp_path):
|
||||
class RecordingBroker(_Broker):
|
||||
def __init__(self):
|
||||
self.preflight_payloads = []
|
||||
|
||||
def enrollment_preflight(self, enrollment, *, secret):
|
||||
self.preflight_payloads.append(dict(enrollment))
|
||||
return super().enrollment_preflight(enrollment, secret=secret)
|
||||
|
||||
broker = RecordingBroker()
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
plane = _plane(broker=broker)
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
alternate = _plan(trusted_appdata_root=managed_root)
|
||||
blocked = plane.preflight(session, alternate)
|
||||
assert blocked["status"] == "blocked"
|
||||
assert "trusted_appdata_root_mismatch" in blocked["blockers"]
|
||||
assert broker.preflight_payloads == []
|
||||
|
||||
EnrollmentPlane.set_trust_config(
|
||||
session,
|
||||
_trust().model_copy(update={
|
||||
"trusted_appdata_root": managed_root,
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}),
|
||||
)
|
||||
payload = plane._controller_payload(
|
||||
alternate,
|
||||
enrollment_id="enr-00000000000000000000",
|
||||
)
|
||||
|
||||
assert payload["trusted_appdata_root"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
assert payload["appdata_path"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data/wsbc-portal"
|
||||
assert payload["data_path"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data/wsbc-portal/data"
|
||||
|
||||
|
||||
def test_enrollment_creates_write_only_credentials_exact_policies_and_is_idempotent(tmp_path):
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BROKER_ROOT = ROOT / "ops" / "gitea-deploy-broker"
|
||||
INSTALLER_PATH = BROKER_ROOT / "prepare_managed_appdata_root.py"
|
||||
|
||||
|
||||
def _load_module(name: str, path: Path) -> ModuleType | None:
|
||||
helper_path = BROKER_ROOT / "managed_appdata_paths.py"
|
||||
if not path.is_file() or not helper_path.is_file():
|
||||
return None
|
||||
if str(BROKER_ROOT) not in os.sys.path:
|
||||
os.sys.path.insert(0, str(BROKER_ROOT))
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root_owned_test_tree():
|
||||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
pytest.skip("requires a POSIX root-owned test tree")
|
||||
parent = Path("/root")
|
||||
if parent.is_symlink() or not parent.is_dir():
|
||||
pytest.skip("requires the fixed root-owned /root test parent")
|
||||
base = parent / f"appops-managed-root-test-{uuid.uuid4().hex}"
|
||||
base.mkdir(mode=0o700)
|
||||
base.chmod(0o700)
|
||||
try:
|
||||
yield base
|
||||
finally:
|
||||
if base.parent == parent and base.name.startswith("appops-managed-root-test-"):
|
||||
shutil.rmtree(base)
|
||||
|
||||
|
||||
def _root_safe_path(base: Path, *components: str) -> Path:
|
||||
current = base
|
||||
for component in components:
|
||||
current = current / component
|
||||
current.mkdir(mode=0o700)
|
||||
current.chmod(0o700)
|
||||
return current
|
||||
|
||||
|
||||
def test_open_fixed_directory_walks_real_owned_components_without_symlinks(
|
||||
root_owned_test_tree, monkeypatch
|
||||
):
|
||||
paths = _load_module("managed_appdata_open_real_chain_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
target = _root_safe_path(root_owned_test_tree, "docker", "volumes", "itworx-managed-appdata", "_data")
|
||||
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
|
||||
|
||||
descriptor = paths._open_fixed_directory(target)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
assert (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def test_open_fixed_directory_rejects_symlink_component(root_owned_test_tree, monkeypatch):
|
||||
paths = _load_module("managed_appdata_open_symlink_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
real_target = _root_safe_path(root_owned_test_tree, "real", "managed")
|
||||
alias = root_owned_test_tree / "alias"
|
||||
alias.symlink_to(real_target.parent, target_is_directory=True)
|
||||
target = alias / real_target.name
|
||||
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
paths._open_fixed_directory(target)
|
||||
|
||||
|
||||
def test_open_fixed_directory_rejects_world_writable_ancestor(root_owned_test_tree, monkeypatch):
|
||||
paths = _load_module("managed_appdata_open_world_writable_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
unsafe = root_owned_test_tree / "unsafe"
|
||||
unsafe.mkdir(mode=0o777)
|
||||
unsafe.chmod(0o777)
|
||||
target = _root_safe_path(unsafe, "managed")
|
||||
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
|
||||
|
||||
with pytest.raises(paths.UnsafeManagedAppdataRoot):
|
||||
paths._open_fixed_directory(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner_field", ["uid", "gid"])
|
||||
def test_open_fixed_directory_rejects_wrong_owner_or_group(
|
||||
root_owned_test_tree, monkeypatch, owner_field
|
||||
):
|
||||
paths = _load_module(f"managed_appdata_open_wrong_{owner_field}_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
owner = root_owned_test_tree / "wrong-owner"
|
||||
target = _root_safe_path(root_owned_test_tree, "wrong-owner", "managed")
|
||||
if not hasattr(os, "chown"):
|
||||
pytest.skip("requires POSIX chown for the owned test directory")
|
||||
if owner_field == "uid":
|
||||
os.chown(owner, 65534, 0)
|
||||
else:
|
||||
os.chown(owner, 0, 65534)
|
||||
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
|
||||
|
||||
with pytest.raises(paths.UnsafeManagedAppdataRoot):
|
||||
paths._open_fixed_directory(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("acl_name", ["system.posix_acl_access", "system.posix_acl_default"])
|
||||
def test_open_fixed_directory_rejects_reported_acl(root_owned_test_tree, monkeypatch, acl_name):
|
||||
paths = _load_module("managed_appdata_open_acl_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
acl_component = root_owned_test_tree / "acl"
|
||||
target = _root_safe_path(root_owned_test_tree, "acl", "managed")
|
||||
acl_stat = acl_component.stat()
|
||||
original_listxattr = os.listxattr
|
||||
|
||||
def listxattr(path, *args, **kwargs):
|
||||
if isinstance(path, int):
|
||||
info = os.fstat(path)
|
||||
if (info.st_dev, info.st_ino) == (acl_stat.st_dev, acl_stat.st_ino):
|
||||
return [acl_name]
|
||||
return original_listxattr(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
|
||||
monkeypatch.setattr(paths.os, "listxattr", listxattr)
|
||||
|
||||
with pytest.raises(paths.UnsafeManagedAppdataRoot, match="unreviewed ACL"):
|
||||
paths._open_fixed_directory(target)
|
||||
|
||||
|
||||
def test_fixed_controller_installer_creates_private_root_and_refuses_drift(tmp_path, monkeypatch):
|
||||
installer = _load_module("managed_appdata_installer_test", INSTALLER_PATH)
|
||||
assert installer is not None, "the fixed managed-appdata installer must ship with the controller artifact"
|
||||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
pytest.skip("requires POSIX root ownership semantics")
|
||||
|
||||
target = tmp_path / "volume-root"
|
||||
target.mkdir(mode=0o755)
|
||||
target.chmod(0o755)
|
||||
monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target)
|
||||
monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True)
|
||||
monkeypatch.setattr(installer, "configured_appdata_root", lambda: target)
|
||||
monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY))
|
||||
|
||||
assert installer.ensure_managed_appdata_root() == "initialized"
|
||||
info = target.lstat()
|
||||
assert stat.S_ISDIR(info.st_mode)
|
||||
assert (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700)
|
||||
marker = target / installer.MANAGED_ROOT_MARKER
|
||||
assert marker.read_bytes() == installer.MANAGED_ROOT_MARKER_BYTES
|
||||
assert stat.S_IMODE(marker.lstat().st_mode) == 0o400
|
||||
assert installer.ensure_managed_appdata_root() == "already_initialized"
|
||||
|
||||
target.chmod(0o777)
|
||||
with pytest.raises(installer.UnsafeManagedAppdataRoot):
|
||||
installer.ensure_managed_appdata_root()
|
||||
assert stat.S_IMODE(target.lstat().st_mode) == 0o777
|
||||
|
||||
|
||||
def test_fixed_controller_installer_refuses_existing_data_without_deleting_it(tmp_path, monkeypatch):
|
||||
installer = _load_module("managed_appdata_installer_reject_test", INSTALLER_PATH)
|
||||
assert installer is not None
|
||||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
pytest.skip("requires POSIX root ownership semantics")
|
||||
|
||||
target = tmp_path / "volume-root"
|
||||
target.mkdir(mode=0o755)
|
||||
target.chmod(0o755)
|
||||
monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target)
|
||||
monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True)
|
||||
monkeypatch.setattr(installer, "configured_appdata_root", lambda: target)
|
||||
monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY))
|
||||
|
||||
(target / "existing-data").write_text("keep", encoding="utf-8")
|
||||
with pytest.raises(installer.UnsafeManagedAppdataRoot):
|
||||
installer.ensure_managed_appdata_root()
|
||||
assert (target / "existing-data").read_text(encoding="utf-8") == "keep"
|
||||
assert not (target / installer.MANAGED_ROOT_MARKER).exists()
|
||||
|
||||
|
||||
def test_initialized_volume_is_idempotent_without_enumerating_application_data(tmp_path, monkeypatch):
|
||||
installer = _load_module("managed_appdata_installer_idempotent_test", INSTALLER_PATH)
|
||||
assert installer is not None
|
||||
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
pytest.skip("requires POSIX root ownership semantics")
|
||||
|
||||
target = tmp_path / "volume-root"
|
||||
target.mkdir(mode=0o700)
|
||||
(target / installer.MANAGED_ROOT_MARKER).write_bytes(installer.MANAGED_ROOT_MARKER_BYTES)
|
||||
(target / installer.MANAGED_ROOT_MARKER).chmod(0o400)
|
||||
(target / "app-data").mkdir()
|
||||
monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target)
|
||||
monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True)
|
||||
monkeypatch.setattr(installer, "configured_appdata_root", lambda: target)
|
||||
monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY))
|
||||
monkeypatch.setattr(installer.os, "listdir", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("data must not be enumerated")))
|
||||
|
||||
assert installer.ensure_managed_appdata_root() == "already_initialized"
|
||||
|
||||
|
||||
def _valid_mounts(paths):
|
||||
return [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": paths.VOLUME_NAME,
|
||||
"Source": paths.VOLUME_HOST_ROOT.as_posix(),
|
||||
"Destination": paths.MANAGED_APPDATA_ROOT.as_posix(),
|
||||
"Driver": "local",
|
||||
"RW": True,
|
||||
},
|
||||
{
|
||||
"Type": "bind",
|
||||
"Name": "",
|
||||
"Source": paths.DOCKER_ROOT.as_posix(),
|
||||
"Destination": paths.DOCKER_ROOT.as_posix(),
|
||||
"Driver": "",
|
||||
"RW": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_controller_mount_inventory_accepts_only_exact_volume_and_readonly_docker_root():
|
||||
paths = _load_module("managed_appdata_mounts_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
mounts = _valid_mounts(paths)
|
||||
|
||||
paths.validate_controller_mounts(
|
||||
mounts,
|
||||
controller_id_before="a" * 64,
|
||||
controller_id_after="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate",
|
||||
[
|
||||
lambda m: m[0].update(Name="other-volume"),
|
||||
lambda m: m[0].update(Type="bind"),
|
||||
lambda m: m[0].update(Driver="nfs"),
|
||||
lambda m: m[0].update(Source="/var/lib/docker/volumes/other/_data"),
|
||||
lambda m: m[0].update(Destination="/managed-appdata"),
|
||||
lambda m: m[0].update(RW=False),
|
||||
lambda m: m[1].update(RW=True),
|
||||
lambda m: m.append(dict(m[0])),
|
||||
lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker/volumes", "Destination": "/var/lib/docker/volumes", "RW": True}),
|
||||
lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker/volumes/itworx-managed-appdata/_data/app", "Destination": "/var/lib/docker/volumes/itworx-managed-appdata/_data/app", "RW": True}),
|
||||
lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker", "RW": False}),
|
||||
],
|
||||
)
|
||||
def test_controller_mount_inventory_fails_closed_on_mount_drift(mutate):
|
||||
paths = _load_module("managed_appdata_mounts_reject_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
mounts = _valid_mounts(paths)
|
||||
mutate(mounts)
|
||||
|
||||
with pytest.raises(paths.UnsafeManagedAppdataRoot):
|
||||
paths.validate_controller_mounts(
|
||||
mounts,
|
||||
controller_id_before="b" * 64,
|
||||
controller_id_after="b" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_controller_mount_inventory_rejects_container_replacement_during_inspection():
|
||||
paths = _load_module("managed_appdata_mounts_race_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
|
||||
with pytest.raises(paths.UnsafeManagedAppdataRoot):
|
||||
paths.validate_controller_mounts(
|
||||
_valid_mounts(paths),
|
||||
controller_id_before="c" * 64,
|
||||
controller_id_after="d" * 64,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"override",
|
||||
[
|
||||
{"Name": "other"},
|
||||
{"Driver": "nfs"},
|
||||
{"Scope": "global"},
|
||||
{"Mountpoint": "/var/lib/docker/volumes/other/_data"},
|
||||
{"Options": {"type": "nfs"}},
|
||||
{"Labels": {"itworx.managed-by": "other"}},
|
||||
],
|
||||
)
|
||||
def test_volume_metadata_rejects_drift(override):
|
||||
paths = _load_module("managed_appdata_volume_reject_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None
|
||||
document = {
|
||||
"Name": paths.VOLUME_NAME,
|
||||
"Driver": "local",
|
||||
"Scope": "local",
|
||||
"Mountpoint": paths.VOLUME_HOST_ROOT.as_posix(),
|
||||
"Options": None,
|
||||
"Labels": dict(paths.VOLUME_LABELS),
|
||||
}
|
||||
document.update(override)
|
||||
|
||||
with pytest.raises(paths.UnsafeManagedAppdataRoot):
|
||||
paths.validate_volume_metadata(document, paths.DOCKER_ROOT.as_posix())
|
||||
|
||||
|
||||
def test_controller_rejects_world_writable_legacy_root_without_mutating_it(tmp_path):
|
||||
paths = _load_module("managed_appdata_paths_test", BROKER_ROOT / "managed_appdata_paths.py")
|
||||
assert paths is not None, "the controller must centralize fixed managed-appdata path checks"
|
||||
if os.name != "posix":
|
||||
pytest.skip("requires POSIX permission bits")
|
||||
|
||||
root = tmp_path / "legacy"
|
||||
root.mkdir()
|
||||
paths.LEGACY_APPDATA_ROOT = root
|
||||
root.chmod(0o777)
|
||||
assert paths.appdata_root_is_safe(root) is False
|
||||
assert stat.S_IMODE(root.lstat().st_mode) == 0o777
|
||||
|
||||
root.chmod(0o750)
|
||||
assert paths.appdata_root_is_safe(root) is True
|
||||
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from backend.app.db.models import AppSetting, AuditEvent, JobRun, Policy
|
||||
from backend.app.services.action_execution import (
|
||||
approval_execution_supported,
|
||||
execute_governed_action,
|
||||
mcp_execution_supported,
|
||||
)
|
||||
from backend.app.services.action_registry import (
|
||||
ACTION_RISK,
|
||||
ACTIONS,
|
||||
action_default_target,
|
||||
)
|
||||
from backend.app.services.policy_engine import evaluate_policy
|
||||
|
||||
ACTION = "controller.managed-storage.initialize"
|
||||
TARGET = "tower/deployment-controller"
|
||||
|
||||
|
||||
def test_managed_storage_initialization_is_typed_high_risk_and_fixed_target():
|
||||
assert ACTION in ACTIONS
|
||||
assert ACTION_RISK[ACTION] == "high"
|
||||
assert action_default_target(ACTION) == TARGET
|
||||
assert not approval_execution_supported(ACTION)
|
||||
assert mcp_execution_supported(ACTION)
|
||||
|
||||
|
||||
def test_managed_storage_policy_defaults_to_blocked_and_requires_exact_manual_allow(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'managed-storage-policy.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "blocked"
|
||||
session.add(Policy(
|
||||
id="managed-storage-wildcard",
|
||||
action=ACTION,
|
||||
target="*",
|
||||
voice="blocked",
|
||||
manual="allowed",
|
||||
risk="high",
|
||||
))
|
||||
session.commit()
|
||||
assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "blocked"
|
||||
|
||||
session.add(Policy(
|
||||
id="managed-storage-exact",
|
||||
action=ACTION,
|
||||
target=TARGET,
|
||||
voice="allowed",
|
||||
manual="allowed",
|
||||
risk="high",
|
||||
))
|
||||
session.commit()
|
||||
assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "allowed"
|
||||
assert evaluate_policy(session, ACTION, "tower/other-controller", "manual")["decision"] == "blocked"
|
||||
assert evaluate_policy(session, ACTION, TARGET, "voice")["decision"] == "blocked"
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _executor_engine(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'managed-storage-executor.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def _managed_trust():
|
||||
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
|
||||
|
||||
return EnrollmentTrustConfigV1.model_validate({
|
||||
"controller_identity": "unraid-controller",
|
||||
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
|
||||
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
"approved_networks": ["itworx-mcp-hub-npm"],
|
||||
"allowed_domain_suffixes": ["example.com"],
|
||||
"autonomous_profiles": ["repopulse-authenticated"],
|
||||
"repopulse_proxy_access_list_id": 42,
|
||||
"oidc_issuer": None,
|
||||
"oidc_role_claim": "roles",
|
||||
"administrator_group": None,
|
||||
"support_agent_group": None,
|
||||
"read_only_group": None,
|
||||
})
|
||||
|
||||
|
||||
def _execute_initializer(session):
|
||||
return execute_governed_action(
|
||||
session,
|
||||
action=ACTION,
|
||||
target=TARGET,
|
||||
source="chatgpt-mcp",
|
||||
risk="high",
|
||||
reason="Operator explicitly selected managed storage and acknowledged its backup scope.",
|
||||
original_policy_decision="allowed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("trust_state", ["missing", "legacy"])
|
||||
def test_managed_storage_executor_refuses_missing_selection_or_ack_without_broker_call(
|
||||
tmp_path, monkeypatch, trust_state
|
||||
):
|
||||
from backend.app.services.deployment_broker import DeploymentBrokerClient
|
||||
|
||||
engine = _executor_engine(tmp_path)
|
||||
if trust_state == "legacy":
|
||||
legacy = _managed_trust().model_copy(update={
|
||||
"trusted_appdata_root": "/srv/example/appdata",
|
||||
"managed_appdata_backup_acknowledged": False,
|
||||
})
|
||||
with Session(engine) as setup:
|
||||
setup.add(AppSetting(
|
||||
key="deployment_enrollment:trust",
|
||||
value_json=legacy.model_dump(mode="json"),
|
||||
is_secret=False,
|
||||
))
|
||||
setup.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
DeploymentBrokerClient,
|
||||
"initialize_managed_appdata",
|
||||
lambda *_args, **_kwargs: pytest.fail("broker must not be called without managed trust opt-in"),
|
||||
)
|
||||
with Session(engine) as session:
|
||||
result = _execute_initializer(session)
|
||||
assert result["success"] is False
|
||||
assert result["result"] == "blocked"
|
||||
assert result["auditId"]
|
||||
assert session.exec(select(JobRun)).all() == []
|
||||
event = session.exec(select(AuditEvent)).one()
|
||||
assert event.result == "blocked"
|
||||
assert event.redacted_metadata == {"executor": ACTION, "trustOptIn": False}
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_managed_storage_executor_requires_bootstrap_secret_without_broker_call(tmp_path, monkeypatch):
|
||||
from backend.app.services.deployment_broker import DeploymentBrokerClient
|
||||
from backend.app.services.enrollment_plane import EnrollmentPlane
|
||||
|
||||
engine = _executor_engine(tmp_path)
|
||||
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
|
||||
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
DeploymentBrokerClient,
|
||||
"initialize_managed_appdata",
|
||||
lambda *_args, **_kwargs: pytest.fail("broker must not be called without bootstrap secret"),
|
||||
)
|
||||
with Session(engine) as session:
|
||||
result = _execute_initializer(session)
|
||||
assert result["success"] is False
|
||||
assert result["result"] == "failed"
|
||||
assert result["auditId"]
|
||||
assert session.exec(select(JobRun)).all() == []
|
||||
event = session.exec(select(AuditEvent)).one()
|
||||
assert event.result == "failed"
|
||||
assert event.redacted_metadata == {"executor": ACTION, "secretMaterialReturned": "[REDACTED]"}
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["initialized", "already_initialized"])
|
||||
def test_managed_storage_executor_records_durable_success_without_secret_material(
|
||||
tmp_path, monkeypatch, status
|
||||
):
|
||||
from backend.app.services.deployment_broker import DeploymentBrokerClient
|
||||
from backend.app.services.enrollment_plane import EnrollmentPlane
|
||||
|
||||
engine = _executor_engine(tmp_path)
|
||||
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
|
||||
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret")
|
||||
broker_calls = []
|
||||
|
||||
def initialize(_client, *, secret):
|
||||
broker_calls.append(secret)
|
||||
return {
|
||||
"status": status,
|
||||
"trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"backupLocation": "dockerRootDir",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", initialize)
|
||||
with Session(engine) as session:
|
||||
result = _execute_initializer(session)
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "success"
|
||||
assert result["jobId"] and result["auditId"]
|
||||
assert broker_calls == ["temporary-bootstrap-secret"]
|
||||
|
||||
with Session(engine) as session:
|
||||
job = session.get(JobRun, result["jobId"])
|
||||
event = session.get(AuditEvent, result["auditId"])
|
||||
assert job is not None and job.status == "succeeded"
|
||||
assert event is not None and event.result == "success"
|
||||
assert event.after_state == {
|
||||
"status": status,
|
||||
"trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"backupLocation": "dockerRootDir",
|
||||
}
|
||||
assert event.redacted_metadata["secretMaterialReturned"] == "[REDACTED]"
|
||||
assert "temporary-bootstrap-secret" not in repr(event.model_dump())
|
||||
assert "temporary-bootstrap-secret" not in repr(result)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"broker_result",
|
||||
[
|
||||
{"status": "initialized", "trustedAppdataRoot": "/tmp/wrong-root", "backupLocation": "dockerRootDir"},
|
||||
{"status": "initialized", "trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data", "backupLocation": "external-share"},
|
||||
{
|
||||
"status": "secret-sentinel-/var/lib/private",
|
||||
"trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"backupLocation": "dockerRootDir",
|
||||
},
|
||||
],
|
||||
ids=["wrong-root", "wrong-backup-location", "untrusted-status"],
|
||||
)
|
||||
def test_managed_storage_executor_fails_closed_on_mismatched_broker_attestation(
|
||||
tmp_path, monkeypatch, broker_result
|
||||
):
|
||||
from backend.app.services.deployment_broker import DeploymentBrokerClient
|
||||
from backend.app.services.enrollment_plane import EnrollmentPlane
|
||||
|
||||
engine = _executor_engine(tmp_path)
|
||||
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
|
||||
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret")
|
||||
monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", lambda *_args, **_kwargs: broker_result)
|
||||
with Session(engine) as session:
|
||||
result = _execute_initializer(session)
|
||||
assert result["success"] is False
|
||||
assert result["result"] == "failed"
|
||||
assert "temporary-bootstrap-secret" not in repr(result)
|
||||
assert "/tmp/wrong-root" not in repr(result)
|
||||
assert "secret-sentinel-/var/lib/private" not in repr(result)
|
||||
event = session.get(AuditEvent, result["auditId"])
|
||||
assert event is not None and event.result == "failed"
|
||||
assert event.after_state["trustedAppdataRoot"] is None
|
||||
assert event.after_state["backupLocation"] is None
|
||||
if broker_result["status"].startswith("secret-sentinel"):
|
||||
assert event.after_state["status"] == "refused"
|
||||
assert "/tmp/wrong-root" not in repr(event.model_dump())
|
||||
assert "temporary-bootstrap-secret" not in repr(event.model_dump())
|
||||
assert "secret-sentinel-/var/lib/private" not in repr(event.model_dump())
|
||||
job = session.get(JobRun, result["jobId"])
|
||||
assert job is not None and job.status == "failed"
|
||||
assert "secret-sentinel-/var/lib/private" not in repr(job.model_dump())
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_managed_storage_executor_sanitizes_broker_exception(tmp_path, monkeypatch):
|
||||
from backend.app.services.deployment_broker import DeploymentBrokerClient
|
||||
from backend.app.services.enrollment_plane import EnrollmentPlane
|
||||
|
||||
engine = _executor_engine(tmp_path)
|
||||
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
|
||||
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret")
|
||||
|
||||
def raise_broker_error(*_args, **_kwargs):
|
||||
raise RuntimeError("temporary-bootstrap-secret failed under /var/lib/private")
|
||||
|
||||
monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", raise_broker_error)
|
||||
with Session(engine) as session:
|
||||
result = _execute_initializer(session)
|
||||
assert result["success"] is False
|
||||
assert result["message"] == "Managed storage initialization was refused or unavailable."
|
||||
assert "temporary-bootstrap-secret" not in repr(result)
|
||||
assert "/var/lib/private" not in repr(result)
|
||||
event = session.get(AuditEvent, result["auditId"])
|
||||
assert event is not None and event.result == "failed"
|
||||
assert "temporary-bootstrap-secret" not in repr(event.model_dump())
|
||||
assert "/var/lib/private" not in repr(event.model_dump())
|
||||
engine.dispose()
|
||||
@@ -61,8 +61,8 @@ def test_reconciliation_is_fixed_to_mcp_hub_and_reconciles_only_verified_failed_
|
||||
assert create[2]["json"]["AttachStderr"] is False
|
||||
|
||||
script = command[2]
|
||||
assert "/opt/gitea-deploy/projects.json" in script
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script
|
||||
assert "/controller-config/projects.json" in script
|
||||
assert "/controller-hub-releases/controller-config/projects.json" in script
|
||||
assert "os.path.samefile(registry_path, host_registry_path)" in script
|
||||
assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script
|
||||
assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script
|
||||
@@ -102,7 +102,9 @@ def test_reconciliation_proves_complete_live_compose_topology_and_preserves_actu
|
||||
assert 'not runtime_state.get("Running")' in script
|
||||
assert 'health in {"starting", "unhealthy"}' in script
|
||||
assert 'image_id = str(container.get("Image") or "")' in script
|
||||
assert 'artifacts = {"containers": container_artifacts, "compose_sha256": ""}' in script
|
||||
assert '"containers": container_artifacts' in script
|
||||
assert '"compose_sha256": ""' in script
|
||||
assert '"topology_contract": topology_contract' in script
|
||||
assert '"live_legacy_provenance": True' in script
|
||||
assert '"live_artifacts": artifacts' in script
|
||||
assert '"live_revision": recorded_live' in script
|
||||
@@ -123,6 +125,23 @@ def test_reconciliation_recognizes_only_the_fixed_mcp_hub_oci_source_as_owned():
|
||||
assert '"live_revision": attempt_revision' not in script
|
||||
|
||||
|
||||
def test_reconciliation_requires_the_controller_17_service_topology_migration():
|
||||
result = McpHubDeploymentReconciliation(FakePortainer(exit_code=56)).run()
|
||||
assert result == {
|
||||
"status": "topology_migration_required",
|
||||
"repository": "jens/itworx_mcp_hub",
|
||||
"composeProject": "itworx-mcp-hub",
|
||||
"reason": "MCP Hub 15-to-17 topology migration requires its guarded protected-main deployment",
|
||||
}
|
||||
|
||||
fake = FakePortainer()
|
||||
McpHubDeploymentReconciliation(fake).run()
|
||||
script = fake.calls[0][2]["json"]["Cmd"][2]
|
||||
assert 'topology_contract = "itworx.mcp-hub-topology/17-v1"' in script
|
||||
assert 'recorded_artifacts.get("topology_contract") == topology_contract' in script
|
||||
assert '"topology_contract": topology_contract' in script
|
||||
|
||||
|
||||
def test_owned_image_ancestry_can_fail_closed_to_the_canonical_gitea_store():
|
||||
fake = FakePortainer()
|
||||
McpHubDeploymentReconciliation(fake).run()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,443 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
STORE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "mcp_hub_release_store.py"
|
||||
|
||||
|
||||
def _load_store():
|
||||
spec = importlib.util.spec_from_file_location("mcp_hub_release_store_test", STORE_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return _load_store()
|
||||
|
||||
|
||||
def _volume(store, **overrides):
|
||||
value = {
|
||||
"Name": store.VOLUME_NAME,
|
||||
"Driver": "local",
|
||||
"Scope": "local",
|
||||
"Mountpoint": store.VOLUME_HOST_ROOT.as_posix(),
|
||||
"Options": None,
|
||||
"Labels": dict(store.EXPECTED_LABELS),
|
||||
}
|
||||
return {**value, **overrides}
|
||||
|
||||
|
||||
def test_fixed_volume_contract_rejects_unexpected_options_labels_and_root(store):
|
||||
assert store.validate_volume_metadata(_volume(store), "/var/lib/docker") is None
|
||||
|
||||
bad_documents = [
|
||||
_volume(store, Name="other-volume"),
|
||||
_volume(store, Driver="local-persist"),
|
||||
_volume(store, Scope="global"),
|
||||
_volume(store, Mountpoint="/srv/example/appdata/releases"),
|
||||
_volume(store, Options={"o": "bind", "device": "/tmp"}),
|
||||
_volume(store, Labels={**store.EXPECTED_LABELS, "attacker": "extra"}),
|
||||
_volume(store, Labels={"itworx.managed-by": "deployment-controller"}),
|
||||
]
|
||||
for document in bad_documents:
|
||||
with pytest.raises(store.ReleaseStoreError):
|
||||
store.validate_volume_metadata(document, "/var/lib/docker")
|
||||
with pytest.raises(store.ReleaseStoreError):
|
||||
store.validate_volume_metadata(_volume(store), "/var/lib/docker-alt")
|
||||
|
||||
|
||||
def test_release_store_fails_closed_when_acl_metadata_cannot_be_inspected(store, monkeypatch):
|
||||
def unavailable(_fd):
|
||||
raise OSError("filesystem ACL metadata is unavailable")
|
||||
|
||||
monkeypatch.setattr(store.os, "listxattr", unavailable, raising=False)
|
||||
with pytest.raises(store.ReleaseStoreError, match="cannot inspect filesystem ACLs"):
|
||||
store._require_no_acl(1)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="Hub state reader verifies root-owned fixed storage",
|
||||
)
|
||||
@pytest.mark.parametrize("state_mode", [0o700, 0o755])
|
||||
def test_fixed_hub_state_reader_is_bounded_and_rejects_symlink_or_missing_state(
|
||||
store, tmp_path, monkeypatch, state_mode
|
||||
):
|
||||
controller_root = tmp_path / "controller"
|
||||
state_root = controller_root / "state"
|
||||
controller_root.mkdir(mode=0o700)
|
||||
controller_root.chmod(0o700)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root)
|
||||
|
||||
assert store.read_hub_state_bytes() is None
|
||||
with pytest.raises(store.ReleaseStoreError):
|
||||
store.read_hub_state_bytes(missing_ok=False)
|
||||
|
||||
state_root.mkdir(mode=state_mode)
|
||||
state_root.chmod(state_mode)
|
||||
|
||||
state_file = state_root / store.HUB_STATE_FILENAME
|
||||
state_file.write_bytes(b'{"status":"success"}')
|
||||
state_file.chmod(0o600)
|
||||
assert store.read_hub_state_bytes(missing_ok=False) == b'{"status":"success"}'
|
||||
|
||||
attacker_root = tmp_path / "attacker"
|
||||
attacker_root.mkdir(mode=0o700)
|
||||
attacker_state = attacker_root / store.HUB_STATE_FILENAME
|
||||
attacker_state.write_bytes(b'{"status":"attacker"}')
|
||||
attacker_state.chmod(0o600)
|
||||
state_file.unlink()
|
||||
state_file.symlink_to(attacker_state)
|
||||
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
|
||||
store.read_hub_state_bytes()
|
||||
assert attacker_state.read_bytes() == b'{"status":"attacker"}'
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="Hub state reader verifies root-owned fixed storage",
|
||||
)
|
||||
@pytest.mark.parametrize("state_mode", [0o775, 0o777, 0o750])
|
||||
def test_fixed_hub_state_reader_rejects_unapproved_directory_modes(
|
||||
store, tmp_path, monkeypatch, state_mode
|
||||
):
|
||||
controller = tmp_path / "controller"
|
||||
state = controller / "state"
|
||||
state.mkdir(parents=True)
|
||||
controller.chmod(0o755)
|
||||
state.chmod(state_mode)
|
||||
state_file = state / store.HUB_STATE_FILENAME
|
||||
state_file.write_bytes(b'{"status":"success"}')
|
||||
state_file.chmod(0o600)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state)
|
||||
|
||||
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
|
||||
store.read_hub_state_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="Hub state reader path-swap test requires root-owned POSIX fixtures",
|
||||
)
|
||||
def test_fixed_hub_state_reader_rejects_parent_swap_during_read(store, tmp_path, monkeypatch):
|
||||
controller_root = tmp_path / "controller"
|
||||
state_root = controller_root / "state"
|
||||
state_root.mkdir(parents=True, mode=0o700)
|
||||
controller_root.chmod(0o700)
|
||||
state_file = state_root / store.HUB_STATE_FILENAME
|
||||
state_file.write_bytes(b"trusted")
|
||||
state_file.chmod(0o600)
|
||||
attacker_root = tmp_path / "attacker"
|
||||
attacker_state_root = attacker_root / "state"
|
||||
attacker_state_root.mkdir(parents=True, mode=0o700)
|
||||
attacker_file = attacker_state_root / store.HUB_STATE_FILENAME
|
||||
attacker_file.write_bytes(b"attacker")
|
||||
attacker_file.chmod(0o600)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root)
|
||||
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root)
|
||||
original_read_at = store.read_at
|
||||
|
||||
def read_then_swap(*args, **kwargs):
|
||||
result = original_read_at(*args, **kwargs)
|
||||
controller_root.rename(tmp_path / "controller.saved")
|
||||
controller_root.symlink_to(attacker_root, target_is_directory=True)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(store, "read_at", read_then_swap)
|
||||
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
|
||||
store.read_hub_state_bytes()
|
||||
assert attacker_file.read_bytes() == b"attacker"
|
||||
|
||||
|
||||
_RENDERED_2A948_SERVICES = (
|
||||
"control-api",
|
||||
"connector-appops",
|
||||
"connector-gitea",
|
||||
"connector-ingestion",
|
||||
"connector-knowledge",
|
||||
"connector-mobilityops",
|
||||
"connector-projectbrain",
|
||||
"connector-stitch",
|
||||
"connector-unraid",
|
||||
"contextforge",
|
||||
"dataplane-router",
|
||||
"docker-read-broker",
|
||||
"ingestion-clamd",
|
||||
"postgres",
|
||||
"redis",
|
||||
"web",
|
||||
"worker",
|
||||
)
|
||||
_RENDERED_2A948_RELEASE_BINDS = (
|
||||
("connector-gitea", "services/runtime/connector_gitea.py", "/opt/hub/services/runtime/connector_gitea.py", "file"),
|
||||
("contextforge", "infrastructure/images/contextforge/entrypoint.sh", "/opt/itworx/contextforge-entrypoint.sh", "file"),
|
||||
("contextforge", "infrastructure/images/contextforge/runtime-contract.json", "/opt/itworx/contextforge-runtime-contract.json", "file"),
|
||||
("contextforge", "scripts/contextforge_runtime_contract.py", "/opt/itworx/contextforge-runtime-contract.py", "file"),
|
||||
("control-api", "catalog", "/opt/hub/catalog", "directory"),
|
||||
("dataplane-router", "catalog", "/opt/hub/catalog", "directory"),
|
||||
("dataplane-router", "services/runtime/dataplane.py", "/opt/hub/services/runtime/dataplane.py", "file"),
|
||||
("postgres", "infrastructure/postgres/init", "/docker-entrypoint-initdb.d", "directory"),
|
||||
("postgres", "infrastructure/images/postgres/entrypoint.sh", "/opt/itworx/postgres-entrypoint.sh", "file"),
|
||||
("redis", "infrastructure/images/redis/entrypoint.sh", "/opt/itworx/redis-entrypoint.sh", "file"),
|
||||
("redis", "infrastructure/images/redis/healthcheck.sh", "/opt/itworx/redis-healthcheck.sh", "file"),
|
||||
("web", "services/runtime/web.py", "/opt/hub/services/runtime/web.py", "file"),
|
||||
("worker", "catalog", "/opt/hub/catalog", "directory"),
|
||||
("worker", "services/runtime/state_sync.py", "/opt/hub/services/runtime/state_sync.py", "file"),
|
||||
)
|
||||
|
||||
|
||||
def _rendered_2a948_model(store, revision):
|
||||
"""Mount projection captured from protected Hub main commit 2a948b17."""
|
||||
services = {name: {"volumes": []} for name in _RENDERED_2A948_SERVICES}
|
||||
for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS:
|
||||
services[service]["volumes"].append(
|
||||
{
|
||||
"type": "bind",
|
||||
"source": store.logical_release_path(revision) + "/" + suffix,
|
||||
"target": target,
|
||||
"read_only": True,
|
||||
"mode": "ro",
|
||||
}
|
||||
)
|
||||
# These are rendered non-release binds and must remain unchanged.
|
||||
services["docker-read-broker"]["volumes"].append(
|
||||
{
|
||||
"type": "bind",
|
||||
"source": "/var/run/docker.sock",
|
||||
"target": "/var/run/docker.sock",
|
||||
"read_only": True,
|
||||
}
|
||||
)
|
||||
services["postgres"]["volumes"].append(
|
||||
{"type": "volume", "source": "itworx-mcp-hub-postgres", "target": "/var/lib/postgresql/data"}
|
||||
)
|
||||
return {"services": services}
|
||||
|
||||
|
||||
def test_exact_rendered_2a948_four_file_model_translates_all_release_binds_only(store):
|
||||
revision = "a" * 40
|
||||
model = _rendered_2a948_model(store, revision)
|
||||
|
||||
store.validate_release_mount_inventory(model, revision, source_kind="logical")
|
||||
translated = store.translate_release_mounts(model, revision)
|
||||
store.validate_release_mount_inventory(translated, revision, source_kind="host")
|
||||
|
||||
assert store.RELEASE_BIND_CONTRACT == _RENDERED_2A948_RELEASE_BINDS
|
||||
assert len(_RENDERED_2A948_RELEASE_BINDS) == 14
|
||||
for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS:
|
||||
original = next(
|
||||
mount for mount in model["services"][service]["volumes"]
|
||||
if mount.get("target") == target
|
||||
)
|
||||
observed = next(
|
||||
mount for mount in translated["services"][service]["volumes"]
|
||||
if mount.get("target") == target
|
||||
)
|
||||
assert original["source"] == f"{store.logical_release_path(revision)}/{suffix}"
|
||||
assert observed["source"] == f"{store.host_release_path(revision)}/{suffix}"
|
||||
assert observed["read_only"] is True
|
||||
assert translated["services"]["docker-read-broker"]["volumes"] == model["services"]["docker-read-broker"]["volumes"]
|
||||
assert translated["services"]["postgres"]["volumes"][-1] == model["services"]["postgres"]["volumes"][-1]
|
||||
assert model["services"]["control-api"]["volumes"][0]["source"].startswith(
|
||||
store.logical_release_path(revision)
|
||||
)
|
||||
|
||||
mutations = []
|
||||
missing = copy.deepcopy(model)
|
||||
missing["services"]["redis"]["volumes"].pop(0)
|
||||
mutations.append(missing)
|
||||
writable = copy.deepcopy(model)
|
||||
writable["services"]["worker"]["volumes"][0]["read_only"] = False
|
||||
mutations.append(writable)
|
||||
wrong_service = copy.deepcopy(model)
|
||||
wrong_service["services"]["contextforge"]["volumes"][0]["target"] = "/tmp/entrypoint.sh"
|
||||
mutations.append(wrong_service)
|
||||
undeclared_leaf = copy.deepcopy(model)
|
||||
undeclared_leaf["services"]["control-api"]["volumes"].append(
|
||||
{
|
||||
"type": "bind",
|
||||
"source": f"{store.logical_release_path(revision)}/scripts",
|
||||
"target": "/opt/hub/scripts",
|
||||
"read_only": True,
|
||||
}
|
||||
)
|
||||
mutations.append(undeclared_leaf)
|
||||
release_root = copy.deepcopy(model)
|
||||
release_root["services"]["control-api"]["volumes"][0]["source"] = store.logical_release_path(revision)
|
||||
mutations.append(release_root)
|
||||
volume_ancestor = copy.deepcopy(model)
|
||||
volume_ancestor["services"]["control-api"]["volumes"][0]["source"] = store.DOCKER_ROOT.as_posix()
|
||||
mutations.append(volume_ancestor)
|
||||
for unsafe in mutations:
|
||||
with pytest.raises(store.ReleaseStoreError):
|
||||
store.translate_release_mounts(unsafe, revision)
|
||||
|
||||
|
||||
def test_private_overlay_contains_only_fixed_volume_leaf_sources(store):
|
||||
revision = "b" * 40
|
||||
content = json.loads(store.compose_override_bytes(revision))
|
||||
text = json.dumps(content)
|
||||
assert "${" not in text
|
||||
assert "secret" not in text.lower()
|
||||
mounts = [
|
||||
(service, mount)
|
||||
for service, config in content["services"].items()
|
||||
for mount in config["volumes"]
|
||||
]
|
||||
assert len(mounts) == len(_RENDERED_2A948_RELEASE_BINDS)
|
||||
for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS:
|
||||
mount = next(item for name, item in mounts if name == service and item["target"] == target)
|
||||
assert mount["source"] == f"{store.host_release_path(revision)}/{suffix}"
|
||||
assert mount["type"] == "bind"
|
||||
assert mount["read_only"] is True
|
||||
assert mount["bind"] == {"create_host_path": False}
|
||||
assert "privileged" not in text and "/controller-hub-releases:" not in text
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
|
||||
reason="release-leaf identity checks require root-owned POSIX fixtures",
|
||||
)
|
||||
def test_release_leaf_verifier_allows_only_regular_files_and_declared_directories(store, tmp_path, monkeypatch):
|
||||
revision = "c" * 40
|
||||
volume_root = tmp_path / "volume"
|
||||
releases = volume_root / store.RELEASES_DIRECTORY
|
||||
release = releases / revision
|
||||
release.mkdir(parents=True, mode=0o700)
|
||||
releases.chmod(0o700)
|
||||
unique: dict[str, str] = {}
|
||||
for _service, suffix, _target, kind in store.RELEASE_BIND_CONTRACT:
|
||||
unique[suffix] = kind
|
||||
for suffix, kind in unique.items():
|
||||
target = release.joinpath(*suffix.split("/"))
|
||||
if kind == "directory":
|
||||
target.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
target.write_bytes(b"protected")
|
||||
target.chmod(0o444)
|
||||
for path in sorted(release.rglob("*"), reverse=True):
|
||||
if path.is_dir():
|
||||
path.chmod(0o555)
|
||||
release.chmod(0o555)
|
||||
pinned = store.PinnedDirectory.open(volume_root, check_ancestors=False)
|
||||
fixed = store.FixedReleaseStore(host=pinned, logical=pinned)
|
||||
monkeypatch.setattr(fixed, "assert_current", lambda: None)
|
||||
try:
|
||||
store.verify_release_bind_leaves(fixed, revision)
|
||||
|
||||
bad_file = release / "infrastructure" / "images" / "redis" / "entrypoint.sh"
|
||||
bad_file.parent.chmod(0o755)
|
||||
bad_file.unlink()
|
||||
outside = tmp_path / "outside.sh"
|
||||
outside.write_text("attacker", encoding="utf-8")
|
||||
bad_file.symlink_to(outside)
|
||||
bad_file.parent.chmod(0o555)
|
||||
with pytest.raises(store.ReleaseStoreError, match="symlink"):
|
||||
store.verify_release_bind_leaves(fixed, revision)
|
||||
finally:
|
||||
pinned.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="descriptor identity checks require POSIX openat")
|
||||
def test_pinned_store_refuses_ancestor_symlink_swap_before_path_readback(store, tmp_path):
|
||||
parent = tmp_path / "shared"
|
||||
parent.mkdir(mode=0o700)
|
||||
original = parent / "controller"
|
||||
original.mkdir(mode=0o700)
|
||||
marker = original / "state.json"
|
||||
marker.write_bytes(b"old-state")
|
||||
marker.chmod(0o600)
|
||||
pinned = store.PinnedDirectory.open(original, check_ancestors=False)
|
||||
attacker = tmp_path / "attacker"
|
||||
attacker.mkdir(mode=0o700)
|
||||
(attacker / "state.json").write_bytes(b"attacker-state")
|
||||
moved = parent / "controller.saved"
|
||||
|
||||
try:
|
||||
original.rename(moved)
|
||||
original.symlink_to(attacker, target_is_directory=True)
|
||||
with pytest.raises(store.ReleaseStoreError, match="path identity changed"):
|
||||
pinned.assert_current(original)
|
||||
assert store.read_at(pinned.fd, "state.json", max_bytes=64) == b"old-state"
|
||||
assert (attacker / "state.json").read_bytes() == b"attacker-state"
|
||||
finally:
|
||||
pinned.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="atomic dirfd replacement requires POSIX openat")
|
||||
def test_pinned_atomic_replace_uses_original_inode_and_detects_swapped_path(store, tmp_path):
|
||||
parent = tmp_path / "shared"
|
||||
parent.mkdir(mode=0o700)
|
||||
original = parent / "controller"
|
||||
original.mkdir(mode=0o700)
|
||||
pinned = store.PinnedDirectory.open(original, check_ancestors=False)
|
||||
attacker = tmp_path / "attacker"
|
||||
attacker.mkdir(mode=0o700)
|
||||
|
||||
def swap_path():
|
||||
original.rename(parent / "controller.saved")
|
||||
original.symlink_to(attacker, target_is_directory=True)
|
||||
|
||||
try:
|
||||
with pytest.raises(store.ReleaseStoreError, match="path identity changed"):
|
||||
store.atomic_replace_at(
|
||||
pinned.fd,
|
||||
"state.json",
|
||||
b"new-state",
|
||||
expected=None,
|
||||
after_replace=swap_path,
|
||||
path_for_identity_check=original,
|
||||
)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
store.read_at(pinned.fd, "state.json", max_bytes=64)
|
||||
assert not (attacker / "state.json").exists()
|
||||
finally:
|
||||
pinned.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="atomic dirfd replacement requires POSIX openat")
|
||||
def test_pinned_atomic_replace_restores_previous_bytes_after_ancestor_swap(store, tmp_path):
|
||||
parent = tmp_path / "shared"
|
||||
parent.mkdir(mode=0o700)
|
||||
original = parent / "controller"
|
||||
original.mkdir(mode=0o700)
|
||||
state = original / "state.json"
|
||||
state.write_bytes(b"old-state")
|
||||
state.chmod(0o600)
|
||||
pinned = store.PinnedDirectory.open(original, check_ancestors=False)
|
||||
attacker = tmp_path / "attacker"
|
||||
attacker.mkdir(mode=0o700)
|
||||
|
||||
def swap_path():
|
||||
original.rename(parent / "controller.saved")
|
||||
original.symlink_to(attacker, target_is_directory=True)
|
||||
|
||||
try:
|
||||
with pytest.raises(store.ReleaseStoreError, match="path identity changed"):
|
||||
store.atomic_replace_at(
|
||||
pinned.fd,
|
||||
"state.json",
|
||||
b"candidate-state",
|
||||
expected=b"old-state",
|
||||
path_for_identity_check=original,
|
||||
after_replace=swap_path,
|
||||
)
|
||||
assert store.read_at(pinned.fd, "state.json", max_bytes=64) == b"old-state"
|
||||
assert not (attacker / "state.json").exists()
|
||||
finally:
|
||||
pinned.close()
|
||||
@@ -42,6 +42,16 @@ def _sha(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
def _second_process_lock(path_text, started, locked):
|
||||
import fcntl
|
||||
|
||||
started.set()
|
||||
with open(path_text, "a+") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
locked.set()
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def test_intent_binding_matches_appops_canonical_digest() -> None:
|
||||
module = _load_store()
|
||||
idempotency = _sha("repopulse-adoption-key")
|
||||
@@ -799,19 +809,10 @@ def test_project_flock_blocks_a_second_process(store_env):
|
||||
ready = multiprocessing.Event()
|
||||
acquired = multiprocessing.Event()
|
||||
|
||||
def second_process_lock(path_text, started, locked):
|
||||
import fcntl
|
||||
|
||||
started.set()
|
||||
with open(path_text, "a+") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
locked.set()
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
with store.transaction():
|
||||
assert store.load_intent() is None
|
||||
child = multiprocessing.Process(
|
||||
target=second_process_lock,
|
||||
target=_second_process_lock,
|
||||
args=(str(module.PROJECT_LOCK), ready, acquired),
|
||||
)
|
||||
child.start()
|
||||
|
||||
@@ -202,3 +202,603 @@ def test_operational_users_cannot_change_enrollment_trust(identity_client):
|
||||
sign_in(identity_client, "owner")
|
||||
# Administrator reaches schema validation; an operator is rejected first.
|
||||
assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 422
|
||||
|
||||
|
||||
def _enrollment_trust_payload(*, appdata_root: str, backup_acknowledged: bool) -> dict:
|
||||
return {
|
||||
"controller_identity": "unraid-controller",
|
||||
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
|
||||
"trusted_appdata_root": appdata_root,
|
||||
"managed_appdata_backup_acknowledged": backup_acknowledged,
|
||||
"approved_networks": ["itworx-mcp-hub-npm"],
|
||||
"allowed_domain_suffixes": ["example.com"],
|
||||
"autonomous_profiles": ["repopulse-authenticated"],
|
||||
"repopulse_proxy_access_list_id": 42,
|
||||
"oidc_issuer": None,
|
||||
"oidc_role_claim": "roles",
|
||||
"administrator_group": None,
|
||||
"support_agent_group": None,
|
||||
"read_only_group": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_root", "initial_ack", "requested_root", "requested_ack"),
|
||||
[
|
||||
(
|
||||
"/srv/example/appdata",
|
||||
False,
|
||||
"/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
True,
|
||||
),
|
||||
(
|
||||
"/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
True,
|
||||
"/srv/example/appdata",
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=["initial-managed-selection", "managed-root-reset"],
|
||||
)
|
||||
def test_settings_manage_put_cannot_change_storage_root(
|
||||
identity_client, initial_root, initial_ack, requested_root, requested_ack
|
||||
):
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
|
||||
before = _enrollment_trust_payload(
|
||||
appdata_root=initial_root, backup_acknowledged=initial_ack
|
||||
)
|
||||
with Session(database.engine) as db:
|
||||
db.add(AppSetting(key="deployment_enrollment:trust", value_json=before, is_secret=False))
|
||||
db.commit()
|
||||
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
|
||||
|
||||
sign_in(identity_client, "reader")
|
||||
response = identity_client.put(
|
||||
"/api/enrollments/trust",
|
||||
json=_enrollment_trust_payload(
|
||||
appdata_root=requested_root, backup_acknowledged=requested_ack
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None and saved.value_json == before
|
||||
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
|
||||
|
||||
|
||||
def test_settings_manage_put_cannot_create_initial_managed_trust(identity_client):
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
with Session(database.engine) as db:
|
||||
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
|
||||
|
||||
sign_in(identity_client, "reader")
|
||||
response = identity_client.put(
|
||||
"/api/enrollments/trust",
|
||||
json=_enrollment_trust_payload(
|
||||
appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
backup_acknowledged=True,
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
with Session(database.engine) as db:
|
||||
assert db.get(AppSetting, "deployment_enrollment:trust") is None
|
||||
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
|
||||
|
||||
|
||||
def test_settings_manage_put_can_create_initial_legacy_trust(identity_client):
|
||||
from sqlmodel import Session
|
||||
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting
|
||||
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
sign_in(identity_client, "reader")
|
||||
|
||||
legacy = _enrollment_trust_payload(
|
||||
appdata_root="/srv/example/appdata", backup_acknowledged=False
|
||||
)
|
||||
response = identity_client.put("/api/enrollments/trust", json=legacy, headers=headers)
|
||||
assert response.status_code == 200
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None
|
||||
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
assert saved.value_json["managed_appdata_backup_acknowledged"] is False
|
||||
|
||||
|
||||
def test_settings_manage_put_keeps_other_trust_edits_and_admin_can_select_managed_root(identity_client):
|
||||
from sqlmodel import Session
|
||||
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting
|
||||
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
legacy = _enrollment_trust_payload(
|
||||
appdata_root="/srv/example/appdata", backup_acknowledged=False
|
||||
)
|
||||
with Session(database.engine) as db:
|
||||
db.add(AppSetting(key="deployment_enrollment:trust", value_json=legacy, is_secret=False))
|
||||
db.commit()
|
||||
|
||||
sign_in(identity_client, "reader")
|
||||
unrelated_update = {
|
||||
**legacy,
|
||||
"approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"],
|
||||
}
|
||||
response = identity_client.put(
|
||||
"/api/enrollments/trust", json=unrelated_update, headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None
|
||||
assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"]
|
||||
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
assert saved.value_json["managed_appdata_backup_acknowledged"] is False
|
||||
|
||||
sign_in(identity_client, "owner")
|
||||
managed = _enrollment_trust_payload(
|
||||
appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
backup_acknowledged=True,
|
||||
)
|
||||
response = identity_client.put("/api/enrollments/trust", json=managed, headers=headers)
|
||||
assert response.status_code == 200
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None
|
||||
assert saved.value_json["trusted_appdata_root"] == managed["trusted_appdata_root"]
|
||||
assert saved.value_json["managed_appdata_backup_acknowledged"] is True
|
||||
|
||||
|
||||
def test_settings_manage_put_cas_preserves_concurrent_admin_storage_selection(identity_client):
|
||||
from sqlalchemy import event
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.core.time import utc_now
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
from backend.app.services.enrollment_plane import TRUST_SETTING_KEY
|
||||
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
|
||||
original = _enrollment_trust_payload(
|
||||
appdata_root="/srv/example/appdata", backup_acknowledged=False
|
||||
)
|
||||
with database.engine.connect() as connection:
|
||||
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
with Session(database.engine) as db:
|
||||
db.add(AppSetting(key=TRUST_SETTING_KEY, value_json=original, is_secret=False))
|
||||
db.commit()
|
||||
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
|
||||
|
||||
sign_in(identity_client, "reader")
|
||||
injected = False
|
||||
|
||||
def select_managed_root_before_put_write(
|
||||
_connection, _cursor, statement, parameters, _context, _executemany
|
||||
):
|
||||
nonlocal injected
|
||||
normalized = statement.lstrip().upper()
|
||||
if (
|
||||
not injected
|
||||
and normalized.startswith("UPDATE APP_SETTINGS")
|
||||
and TRUST_SETTING_KEY in str(parameters)
|
||||
):
|
||||
injected = True
|
||||
with Session(database.engine) as concurrent_session:
|
||||
row = concurrent_session.get(AppSetting, TRUST_SETTING_KEY)
|
||||
assert row is not None
|
||||
row.value_json = {
|
||||
**row.value_json,
|
||||
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}
|
||||
row.updated_at = utc_now()
|
||||
concurrent_session.add(row)
|
||||
concurrent_session.commit()
|
||||
|
||||
event.listen(database.engine, "before_cursor_execute", select_managed_root_before_put_write)
|
||||
try:
|
||||
response = identity_client.put(
|
||||
"/api/enrollments/trust",
|
||||
json={
|
||||
**original,
|
||||
"approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
event.remove(database.engine, "before_cursor_execute", select_managed_root_before_put_write)
|
||||
|
||||
assert injected is True
|
||||
assert response.status_code == 409
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, TRUST_SETTING_KEY)
|
||||
assert saved is not None
|
||||
assert saved.value_json["trusted_appdata_root"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
assert saved.value_json["managed_appdata_backup_acknowledged"] is True
|
||||
assert saved.value_json["approved_networks"] == original["approved_networks"]
|
||||
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
|
||||
|
||||
|
||||
def test_settings_manage_initial_legacy_put_loses_if_admin_creates_trust_concurrently(identity_client):
|
||||
from sqlalchemy import event
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
from backend.app.services.enrollment_plane import TRUST_SETTING_KEY
|
||||
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
with database.engine.connect() as connection:
|
||||
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
with Session(database.engine) as db:
|
||||
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
|
||||
|
||||
sign_in(identity_client, "reader")
|
||||
admin_value = _enrollment_trust_payload(
|
||||
appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
backup_acknowledged=True,
|
||||
)
|
||||
injected = False
|
||||
|
||||
def create_admin_trust_before_put_insert(
|
||||
_connection, _cursor, statement, parameters, _context, _executemany
|
||||
):
|
||||
nonlocal injected
|
||||
normalized = statement.lstrip().upper()
|
||||
if (
|
||||
not injected
|
||||
and normalized.startswith("INSERT INTO APP_SETTINGS")
|
||||
and TRUST_SETTING_KEY in str(parameters)
|
||||
):
|
||||
injected = True
|
||||
with Session(database.engine) as concurrent_session:
|
||||
concurrent_session.add(
|
||||
AppSetting(key=TRUST_SETTING_KEY, value_json=admin_value, is_secret=False)
|
||||
)
|
||||
concurrent_session.commit()
|
||||
|
||||
event.listen(database.engine, "before_cursor_execute", create_admin_trust_before_put_insert)
|
||||
try:
|
||||
response = identity_client.put(
|
||||
"/api/enrollments/trust",
|
||||
json=_enrollment_trust_payload(
|
||||
appdata_root="/srv/example/appdata", backup_acknowledged=False
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
event.remove(database.engine, "before_cursor_execute", create_admin_trust_before_put_insert)
|
||||
|
||||
assert injected is True
|
||||
assert response.status_code == 409
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, TRUST_SETTING_KEY)
|
||||
assert saved is not None and saved.value_json == admin_value
|
||||
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
|
||||
|
||||
|
||||
def test_storage_root_patch_is_admin_only_and_preserves_other_trust_fields(identity_client):
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
|
||||
sign_in(identity_client, "owner")
|
||||
original = {
|
||||
"controller_identity": "unraid-controller",
|
||||
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
|
||||
"trusted_appdata_root": "/srv/example/appdata",
|
||||
"managed_appdata_backup_acknowledged": False,
|
||||
"approved_networks": ["itworx-mcp-hub-npm"],
|
||||
"allowed_domain_suffixes": ["example.com"],
|
||||
"autonomous_profiles": ["repopulse-authenticated"],
|
||||
"repopulse_proxy_access_list_id": 42,
|
||||
"oidc_issuer": None,
|
||||
"oidc_role_claim": "roles",
|
||||
"administrator_group": None,
|
||||
"support_agent_group": None,
|
||||
"read_only_group": None,
|
||||
}
|
||||
with Session(database.engine) as db:
|
||||
db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False))
|
||||
db.commit()
|
||||
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
updated = identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={"trusted_appdata_root": managed_root, "managed_appdata_backup_acknowledged": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["trustedAppdataRoot"] == managed_root
|
||||
assert updated.json()["managedAppdataBackupAcknowledged"] is True
|
||||
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None
|
||||
assert saved.value_json == {
|
||||
**original,
|
||||
"trusted_appdata_root": managed_root,
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}
|
||||
event = db.exec(
|
||||
select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update")
|
||||
).one()
|
||||
assert event.before_state == {
|
||||
"trustedAppdataRoot": "/srv/example/appdata",
|
||||
"managedAppdataBackupAcknowledged": False,
|
||||
}
|
||||
assert event.after_state == {
|
||||
"trustedAppdataRoot": managed_root,
|
||||
"managedAppdataBackupAcknowledged": True,
|
||||
}
|
||||
assert event.redacted_metadata == {
|
||||
"backupLocation": "dockerRootDir",
|
||||
"requestSource": "manual",
|
||||
}
|
||||
assert "bootstrap_secret_ref" not in str(event.before_state)
|
||||
|
||||
assert identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={"trusted_appdata_root": managed_root, "managed_appdata_backup_acknowledged": False},
|
||||
headers=headers,
|
||||
).status_code == 422
|
||||
assert identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={
|
||||
"trusted_appdata_root": "/srv/example/other",
|
||||
"managed_appdata_backup_acknowledged": False,
|
||||
},
|
||||
headers=headers,
|
||||
).status_code == 422
|
||||
assert identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={
|
||||
"trusted_appdata_root": managed_root,
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
"bootstrap_secret_ref": "LEAKED_SECRET_REFERENCE",
|
||||
},
|
||||
headers=headers,
|
||||
).status_code == 422
|
||||
|
||||
legacy = identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={"trusted_appdata_root": "/srv/example/appdata", "managed_appdata_backup_acknowledged": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert legacy.status_code == 200
|
||||
assert legacy.json()["trustedAppdataRoot"] == "/srv/example/appdata"
|
||||
assert legacy.json()["managedAppdataBackupAcknowledged"] is False
|
||||
|
||||
|
||||
def test_storage_root_patch_returns_conflict_after_concurrent_trust_change(identity_client, monkeypatch):
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.core.time import utc_now
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
|
||||
from backend.app.services.enrollment_plane import EnrollmentPlane
|
||||
|
||||
sign_in(identity_client, "owner")
|
||||
original = {
|
||||
"controller_identity": "unraid-controller",
|
||||
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
|
||||
"trusted_appdata_root": "/srv/example/appdata",
|
||||
"managed_appdata_backup_acknowledged": False,
|
||||
"approved_networks": ["itworx-mcp-hub-npm"],
|
||||
"allowed_domain_suffixes": ["example.com"],
|
||||
"autonomous_profiles": ["repopulse-authenticated"],
|
||||
"repopulse_proxy_access_list_id": 42,
|
||||
"oidc_issuer": None,
|
||||
"oidc_role_claim": "roles",
|
||||
"administrator_group": None,
|
||||
"support_agent_group": None,
|
||||
"read_only_group": None,
|
||||
}
|
||||
with Session(database.engine) as db:
|
||||
db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False))
|
||||
db.commit()
|
||||
|
||||
stale_trust = EnrollmentTrustConfigV1.model_validate(original)
|
||||
|
||||
def read_stale_after_concurrent_update(_session):
|
||||
with Session(database.engine) as concurrent_session:
|
||||
row = concurrent_session.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert row is not None
|
||||
row.value_json = {**row.value_json, "approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"]}
|
||||
row.updated_at = utc_now()
|
||||
concurrent_session.add(row)
|
||||
concurrent_session.commit()
|
||||
return stale_trust
|
||||
|
||||
monkeypatch.setattr(
|
||||
EnrollmentPlane,
|
||||
"get_trust_config",
|
||||
staticmethod(read_stale_after_concurrent_update),
|
||||
)
|
||||
response = identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={
|
||||
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
},
|
||||
headers={"X-AppOps-Session-CSRF": "1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None
|
||||
assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"]
|
||||
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
assert db.exec(
|
||||
select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update")
|
||||
).all() == []
|
||||
|
||||
|
||||
def test_storage_root_patch_cas_rejects_change_after_snapshot(identity_client, monkeypatch):
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from backend.app.api import routes_enrollments
|
||||
from backend.app.core.time import utc_now
|
||||
from backend.app.db import session as database
|
||||
from backend.app.db.models import AppSetting, AuditEvent
|
||||
|
||||
sign_in(identity_client, "owner")
|
||||
original = {
|
||||
"controller_identity": "unraid-controller",
|
||||
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
|
||||
"trusted_appdata_root": "/srv/example/appdata",
|
||||
"managed_appdata_backup_acknowledged": False,
|
||||
"approved_networks": ["itworx-mcp-hub-npm"],
|
||||
"allowed_domain_suffixes": ["example.com"],
|
||||
"autonomous_profiles": ["repopulse-authenticated"],
|
||||
"repopulse_proxy_access_list_id": 42,
|
||||
"oidc_issuer": None,
|
||||
"oidc_role_claim": "roles",
|
||||
"administrator_group": None,
|
||||
"support_agent_group": None,
|
||||
"read_only_group": None,
|
||||
}
|
||||
with database.engine.connect() as connection:
|
||||
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||
with Session(database.engine) as db:
|
||||
db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False))
|
||||
db.commit()
|
||||
|
||||
actual_sql_update = routes_enrollments.sql_update
|
||||
concurrent_write_done = False
|
||||
|
||||
def write_concurrently_after_snapshot(model):
|
||||
nonlocal concurrent_write_done
|
||||
if model is AppSetting and not concurrent_write_done:
|
||||
concurrent_write_done = True
|
||||
with Session(database.engine) as concurrent_session:
|
||||
row = concurrent_session.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert row is not None
|
||||
row.value_json = {
|
||||
**row.value_json,
|
||||
"approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"],
|
||||
}
|
||||
row.updated_at = utc_now()
|
||||
concurrent_session.add(row)
|
||||
concurrent_session.commit()
|
||||
return actual_sql_update(model)
|
||||
|
||||
# The endpoint has already read and validated its selector snapshot when it
|
||||
# calls sql_update. Commit an independent trust edit at that boundary so the
|
||||
# route's compare-and-swap must reject the now-stale write.
|
||||
monkeypatch.setattr(routes_enrollments, "sql_update", write_concurrently_after_snapshot)
|
||||
response = identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root",
|
||||
json={
|
||||
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
},
|
||||
headers={"X-AppOps-Session-CSRF": "1"},
|
||||
)
|
||||
|
||||
assert concurrent_write_done is True
|
||||
assert response.status_code == 409
|
||||
with Session(database.engine) as db:
|
||||
saved = db.get(AppSetting, "deployment_enrollment:trust")
|
||||
assert saved is not None
|
||||
assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"]
|
||||
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
|
||||
assert db.exec(
|
||||
select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update")
|
||||
).all() == []
|
||||
|
||||
|
||||
def test_read_only_and_operator_cannot_change_storage_root(identity_client):
|
||||
reader_id = sign_in(identity_client, "reader")
|
||||
sign_in(identity_client, "owner")
|
||||
headers = {"X-AppOps-Session-CSRF": "1"}
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "operator", "enabled": True, "permissions": []},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
|
||||
selection = {
|
||||
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}
|
||||
sign_in(identity_client, "reader")
|
||||
assert identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root", json=selection, headers=headers
|
||||
).status_code == 403
|
||||
sign_in(identity_client, "owner")
|
||||
assert identity_client.patch(
|
||||
f"/api/auth/users/{reader_id}",
|
||||
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
|
||||
headers=headers,
|
||||
).status_code == 200
|
||||
sign_in(identity_client, "reader")
|
||||
assert identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root", json=selection, headers=headers
|
||||
).status_code == 403
|
||||
sign_in(identity_client, "read-only")
|
||||
assert identity_client.patch(
|
||||
"/api/enrollments/trust/storage-root", json=selection, headers=headers
|
||||
).status_code == 403
|
||||
Reference in new issue
Block a user