Publish curated Unraid-AppOps-Gateway source from ba66029223865607deddc234e283c11127324eb8
This commit is contained in:
1 parent
820244dbb3
commit
8dd4b40252
52 files changed
+11814
-288
No files matched your search
@@ -42,7 +42,7 @@ def _set_npm_config():
|
||||
settings.apis["NPM_URL"] = "https://npm.example.test"
|
||||
settings.apis["NPM_IDENTITY"] = "admin@example.test"
|
||||
settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE"
|
||||
settings.apis["NPM_VERIFY_TLS"] = False
|
||||
settings.apis["NPM_VERIFY_TLS"] = True
|
||||
settings.apis["NPM_TIMEOUT_SECONDS"] = "10"
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -24,6 +27,10 @@ CONTROLLER_IMAGE_ID = "sha256:c474d435a4f55ac5205570da0caa47e103f89a6c2ef96ea458
|
||||
CONTROLLER_IMAGE_REF = "docker.gitea.com/runner-images:ubuntu-latest"
|
||||
CONTROLLER_REVISION = "034c9f844d2455850ba37b31379b7edaa786583e"
|
||||
CONTROLLER_TREE = "6b8e92942e6e5eca8a9aa9da0a1509b2019d7b7b"
|
||||
MODERN_CONTROLLER_CONTAINER_ID = "9093c8e29d9064f9948918e5884f9adc5db6b740991e2934902f93c6ea813e01"
|
||||
MODERN_CONTROLLER_REVISION = "e854334923cca574482d22b8424ba3ce46ec6aa1"
|
||||
MODERN_CONTROLLER_TREE = "cf2a117f9222b952deb9902c646116612f3a7ea0"
|
||||
MODERN_CONTROLLER_ARTIFACT_SHA256 = "e88739bf091c8bac77ea0db5b97300666a1baa8ba984bbf6ae91e3bf25719e08"
|
||||
REVISION = "a" * 40
|
||||
TREE = "b" * 40
|
||||
CALLER_ID = "c" * 64
|
||||
@@ -247,15 +254,39 @@ def _modern_controller_mounts(source_revision: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _modern_controller_inspect(source_revision: str) -> dict[str, Any]:
|
||||
def _modern_controller_source_mounts(source_revision: str) -> list[dict[str, Any]]:
|
||||
mounts = [dict(mount) for mount in _modern_controller_mounts(source_revision)]
|
||||
mounts = [mount for mount in mounts if mount["Destination"] != "/forge-master-key-backup"]
|
||||
for mount in mounts:
|
||||
if mount["Destination"] in {"/opt/gitea-deploy", "/controller-config"}:
|
||||
mount["Propagation"] = "rslave"
|
||||
return mounts
|
||||
|
||||
|
||||
def _modern_controller_inspect(
|
||||
source_revision: str,
|
||||
*,
|
||||
identifier: str = MODERN_CONTROLLER_CONTAINER_ID,
|
||||
) -> dict[str, Any]:
|
||||
controller = _controller_inspect()
|
||||
controller.update({"Id": "e" * 64, "Image": CONTROLLER_IMAGE_ID})
|
||||
controller.update({"Id": identifier, "Image": CONTROLLER_IMAGE_ID})
|
||||
controller["State"].update({"Dead": False, "Paused": False, "Restarting": False})
|
||||
controller["Config"]["Image"] = CONTROLLER_IMAGE_ID
|
||||
controller["Config"]["Labels"] = _modern_controller_labels()
|
||||
controller["Mounts"] = _modern_controller_mounts(source_revision)
|
||||
return controller
|
||||
|
||||
|
||||
def _modern_controller_source_inspect(
|
||||
source_revision: str,
|
||||
*,
|
||||
identifier: str = MODERN_CONTROLLER_CONTAINER_ID,
|
||||
) -> dict[str, Any]:
|
||||
controller = _modern_controller_inspect(source_revision, identifier=identifier)
|
||||
controller["Mounts"] = _modern_controller_source_mounts(source_revision)
|
||||
return controller
|
||||
|
||||
|
||||
def _appops_inspect() -> dict[str, Any]:
|
||||
labels = _appops_labels()
|
||||
return {
|
||||
@@ -480,7 +511,9 @@ class FakeDocker:
|
||||
def start_container(self, identifier: str) -> None:
|
||||
self.started.append(identifier)
|
||||
source_revision = self.created[-1]["Labels"]["itworx.source.revision"]
|
||||
self.containers["gitea-deploy-control"] = _modern_controller_inspect(source_revision)
|
||||
self.containers["gitea-deploy-control"] = _modern_controller_inspect(
|
||||
source_revision, identifier="e" * 64
|
||||
)
|
||||
|
||||
def remove_retired_failed_container(self, identifier: str) -> None:
|
||||
assert identifier == RETIRED_HELPER_ID
|
||||
@@ -505,6 +538,8 @@ class FakeProtectedGit:
|
||||
self.main_checks = 0
|
||||
self.fail_current_main_at: int | None = None
|
||||
self.retired_tree_valid = True
|
||||
self.controller_release_checks: list[bootstrap.ControllerBaseline] = []
|
||||
self.invalid_controller_release: bootstrap.ControllerBaseline | None = None
|
||||
|
||||
def verify_current_main(self, revision: str, tree: str) -> None:
|
||||
self.main_checks += 1
|
||||
@@ -517,6 +552,11 @@ class FakeProtectedGit:
|
||||
if (revision, tree) != (LEGACY_REVISION, LEGACY_TREE):
|
||||
raise bootstrap.BootstrapError("controller_promoter_legacy_provenance_invalid")
|
||||
|
||||
def verify_controller_release(self, baseline: bootstrap.ControllerBaseline) -> None:
|
||||
self.controller_release_checks.append(baseline)
|
||||
if baseline not in bootstrap.CONTROLLER_BASELINES or baseline == self.invalid_controller_release:
|
||||
raise bootstrap.BootstrapError("controller_promoter_controller_provenance_invalid")
|
||||
|
||||
def verify_retired_modern_revision(self, revision: str, tree: str) -> None:
|
||||
if (revision, tree) != (
|
||||
bootstrap.RETIRED_FAILED_MODERN_REVISION,
|
||||
@@ -570,6 +610,15 @@ def _health(
|
||||
}
|
||||
|
||||
|
||||
def _modern_baseline_health() -> dict[str, Any]:
|
||||
return _health(
|
||||
revision=MODERN_CONTROLLER_REVISION,
|
||||
tree=MODERN_CONTROLLER_TREE,
|
||||
version="2.1.14",
|
||||
artifact_sha256=MODERN_CONTROLLER_ARTIFACT_SHA256,
|
||||
)
|
||||
|
||||
|
||||
def _run_bootstrap(
|
||||
docker: FakeDocker,
|
||||
git: FakeProtectedGit,
|
||||
@@ -610,6 +659,176 @@ def _run_bootstrap(
|
||||
)
|
||||
|
||||
|
||||
def test_protected_git_reproduces_pinned_release_with_exact_handoff_crlf_conversion(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
git_executable = shutil.which("git")
|
||||
if git_executable is None:
|
||||
pytest.skip("git is required to validate the pinned source archives")
|
||||
|
||||
probe = subprocess.run(
|
||||
[git_executable, "cat-file", "-e", f"{CONTROLLER_REVISION}^{{commit}}"],
|
||||
cwd=ROOT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
pytest.skip("the checkout does not expose the retained historical commits to Git")
|
||||
|
||||
def local_git_output(arguments: list[str], *, limit: int = bootstrap.MAX_GIT_BLOB_BYTES) -> bytes:
|
||||
result = subprocess.run(
|
||||
[git_executable, "-c", f"safe.directory={ROOT}", *arguments],
|
||||
cwd=ROOT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise bootstrap.BootstrapError("controller_promoter_protected_git_invalid")
|
||||
if len(result.stdout) > limit:
|
||||
raise bootstrap.BootstrapError("controller_promoter_protected_git_invalid")
|
||||
return result.stdout
|
||||
|
||||
monkeypatch.setattr(bootstrap, "_git_output", local_git_output)
|
||||
repository = bootstrap.ProtectedGitRepository()
|
||||
for baseline in (
|
||||
bootstrap.LEGACY_CONTROLLER_BASELINE,
|
||||
bootstrap.MODERN_CONTROLLER_BASELINE,
|
||||
):
|
||||
repository.verify_controller_release(baseline)
|
||||
with pytest.raises(
|
||||
bootstrap.BootstrapError,
|
||||
match="controller_promoter_controller_provenance_invalid",
|
||||
):
|
||||
repository.verify_controller_release(
|
||||
replace(baseline, tree="f" * 40)
|
||||
)
|
||||
archive_paths = [
|
||||
"scripts/run_repopulse_adoption.py"
|
||||
if name == "run_repopulse_adoption.py"
|
||||
else f"ops/gitea-deploy-broker/{name}"
|
||||
for name in baseline.artifact_files
|
||||
]
|
||||
archive = local_git_output(
|
||||
["archive", "--format=tar", baseline.revision, "--", *archive_paths],
|
||||
limit=bootstrap.MAX_CONTROLLER_ARTIFACT_ARCHIVE_BYTES,
|
||||
)
|
||||
assert bootstrap._controller_artifact_archive_digest(
|
||||
archive, baseline
|
||||
) == baseline.artifact_sha256
|
||||
|
||||
with pytest.raises(
|
||||
bootstrap.BootstrapError,
|
||||
match="controller_promoter_controller_provenance_invalid",
|
||||
):
|
||||
bootstrap._controller_artifact_source_bytes(
|
||||
replace(baseline, revision="f" * 40),
|
||||
bootstrap.CONTROLLER_CRLF_HANDOFF,
|
||||
b"source",
|
||||
)
|
||||
|
||||
corrupted_archive = _rewrite_controller_archive(
|
||||
archive,
|
||||
mutate_path="ops/gitea-deploy-broker/deploy.py",
|
||||
mutate=lambda content: content + b"# changed\n",
|
||||
)
|
||||
assert bootstrap._controller_artifact_archive_digest(
|
||||
corrupted_archive, baseline
|
||||
) != baseline.artifact_sha256
|
||||
|
||||
renamed_archive = _rewrite_controller_archive(
|
||||
archive,
|
||||
rename_path="scripts/run_repopulse_adoption.py",
|
||||
new_path="scripts/unreviewed.py",
|
||||
)
|
||||
with pytest.raises(
|
||||
bootstrap.BootstrapError,
|
||||
match="controller_promoter_controller_provenance_invalid",
|
||||
):
|
||||
bootstrap._controller_artifact_archive_digest(renamed_archive, baseline)
|
||||
|
||||
handoff_path = "scripts/run_repopulse_adoption.py"
|
||||
corrupted_handoff = _rewrite_controller_archive(
|
||||
archive,
|
||||
mutate_path=handoff_path,
|
||||
mutate=lambda content: content + b"# changed\n",
|
||||
)
|
||||
with pytest.raises(
|
||||
bootstrap.BootstrapError,
|
||||
match="controller_promoter_controller_provenance_invalid",
|
||||
):
|
||||
bootstrap._controller_artifact_archive_digest(corrupted_handoff, baseline)
|
||||
|
||||
crlf_source_archive = _rewrite_controller_archive(
|
||||
archive,
|
||||
mutate_path=handoff_path,
|
||||
mutate=lambda content: content.replace(b"\n", b"\r\r\n"),
|
||||
)
|
||||
with pytest.raises(
|
||||
bootstrap.BootstrapError,
|
||||
match="controller_promoter_controller_provenance_invalid",
|
||||
):
|
||||
bootstrap._controller_artifact_archive_digest(crlf_source_archive, baseline)
|
||||
|
||||
|
||||
def _rewrite_controller_archive(
|
||||
archive: bytes,
|
||||
*,
|
||||
rename_path: str | None = None,
|
||||
new_path: str | None = None,
|
||||
mutate_path: str | None = None,
|
||||
mutate: Any | None = None,
|
||||
) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as original:
|
||||
with tarfile.open(fileobj=output, mode="w:") as rewritten:
|
||||
for member in original.getmembers():
|
||||
if member.isdir():
|
||||
rewritten.addfile(member)
|
||||
continue
|
||||
body = original.extractfile(member)
|
||||
assert body is not None
|
||||
content = body.read()
|
||||
if member.name == rename_path:
|
||||
assert new_path is not None
|
||||
member.name = new_path
|
||||
if member.name == mutate_path:
|
||||
assert mutate is not None
|
||||
content = mutate(content)
|
||||
member.size = len(content)
|
||||
rewritten.addfile(member, io.BytesIO(content))
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def test_controller_source_provenance_failure_precedes_health_and_side_effects():
|
||||
helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
docker = FakeDocker(_legacy_helper_spec())
|
||||
git = FakeProtectedGit(helper_source)
|
||||
git.invalid_controller_release = bootstrap.MODERN_CONTROLLER_BASELINE
|
||||
|
||||
with pytest.raises(
|
||||
bootstrap.BootstrapError,
|
||||
match="controller_promoter_controller_provenance_invalid",
|
||||
):
|
||||
_run_bootstrap(
|
||||
docker,
|
||||
git,
|
||||
health_sequence=[_modern_baseline_health()],
|
||||
)
|
||||
|
||||
assert git.controller_release_checks == [bootstrap.MODERN_CONTROLLER_BASELINE]
|
||||
assert docker.removed == []
|
||||
assert docker.created == []
|
||||
assert docker.started == []
|
||||
|
||||
|
||||
def _retired_failed_child(helper_source: str) -> dict[str, Any]:
|
||||
spec = bootstrap.render_modern_helper_spec(
|
||||
helper_source,
|
||||
@@ -678,6 +897,87 @@ def test_initial_controller_runtime_accepts_exact_current_compose_container():
|
||||
)
|
||||
|
||||
|
||||
def test_modern_source_baseline_accepts_its_fixed_source_mount_profile():
|
||||
docker = FakeDocker(_legacy_helper_spec())
|
||||
docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect(
|
||||
MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
base_image_labels = bootstrap._inspect_controller_base_image(docker)
|
||||
|
||||
assert (
|
||||
bootstrap._verify_controller_runtime(
|
||||
docker,
|
||||
health_payload=_modern_baseline_health(),
|
||||
base_image_labels=base_image_labels,
|
||||
)
|
||||
== base_image_labels
|
||||
)
|
||||
|
||||
|
||||
def test_modern_source_mount_profile_is_distinct_from_postpromotion_topology():
|
||||
source_mounts = _modern_controller_source_mounts(MODERN_CONTROLLER_REVISION)
|
||||
desired_mounts = _modern_controller_mounts(MODERN_CONTROLLER_REVISION)
|
||||
|
||||
assert len(source_mounts) == 10
|
||||
assert len(desired_mounts) == 11
|
||||
assert bootstrap._modern_controller_source_mounts_match(
|
||||
source_mounts, MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
assert not bootstrap._modern_controller_source_mounts_match(
|
||||
desired_mounts, MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
assert not bootstrap._modern_controller_source_mounts_match(
|
||||
source_mounts, "9" * 40
|
||||
)
|
||||
assert bootstrap._modern_controller_mounts_match(
|
||||
desired_mounts, MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
|
||||
docker = FakeDocker(_legacy_helper_spec())
|
||||
docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect(
|
||||
MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
assert not bootstrap._verified_live_controller(
|
||||
docker, source_revision=MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
"missing_mount",
|
||||
"extra_backup_mount",
|
||||
"artifact_propagation",
|
||||
"config_propagation",
|
||||
"integer_rw",
|
||||
"duplicate_mount",
|
||||
],
|
||||
)
|
||||
def test_modern_source_mount_profile_rejects_topology_drift(mutation: str):
|
||||
mounts = _modern_controller_source_mounts(MODERN_CONTROLLER_REVISION)
|
||||
if mutation == "missing_mount":
|
||||
mounts.pop()
|
||||
elif mutation == "extra_backup_mount":
|
||||
backup = next(
|
||||
mount
|
||||
for mount in _modern_controller_mounts(MODERN_CONTROLLER_REVISION)
|
||||
if mount["Destination"] == "/forge-master-key-backup"
|
||||
)
|
||||
mounts.append(backup)
|
||||
elif mutation == "artifact_propagation":
|
||||
mounts[3]["Propagation"] = "rprivate"
|
||||
elif mutation == "config_propagation":
|
||||
mounts[9]["Propagation"] = "rprivate"
|
||||
elif mutation == "integer_rw":
|
||||
mounts[1]["RW"] = 0
|
||||
elif mutation == "duplicate_mount":
|
||||
mounts.append(dict(mounts[0]))
|
||||
|
||||
assert not bootstrap._modern_controller_source_mounts_match(
|
||||
mounts, MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
|
||||
|
||||
def test_immutable_legacy_helper_source_fixture_passes_bootstrap_identity_guard():
|
||||
legacy_spec = _legacy_helper_spec()
|
||||
docker = FakeDocker(legacy_spec)
|
||||
@@ -1002,7 +1302,13 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch()
|
||||
result = _run_bootstrap(
|
||||
docker,
|
||||
git,
|
||||
health_sequence=[legacy_health, legacy_health, legacy_health, _health()],
|
||||
health_sequence=[
|
||||
legacy_health,
|
||||
legacy_health,
|
||||
legacy_health,
|
||||
legacy_health,
|
||||
_health(),
|
||||
],
|
||||
)
|
||||
|
||||
assert result["status"] == "promoted"
|
||||
@@ -1015,6 +1321,174 @@ def test_exact_failed_historical_child_is_removed_before_current_helper_launch()
|
||||
) == 1
|
||||
|
||||
|
||||
def test_current_214_baseline_is_verified_and_bound_to_new_helper():
|
||||
helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
docker = FakeDocker(_legacy_helper_spec())
|
||||
docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect(
|
||||
MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
git = FakeProtectedGit(helper_source)
|
||||
|
||||
result = _run_bootstrap(
|
||||
docker,
|
||||
git,
|
||||
health_sequence=[_modern_baseline_health(), _health(), _health()],
|
||||
)
|
||||
|
||||
assert result["status"] == "promoted"
|
||||
assert git.controller_release_checks == [bootstrap.MODERN_CONTROLLER_BASELINE]
|
||||
assert docker.created[0]["Labels"] == {
|
||||
"itworx.controller.promoter": "true",
|
||||
"itworx.source.repository": "jens/unraid-appops-gateway",
|
||||
"itworx.source.revision": REVISION,
|
||||
"itworx.controller.version": "2.1.14",
|
||||
"itworx.controller.bootstrap.target.id": MODERN_CONTROLLER_CONTAINER_ID,
|
||||
"itworx.controller.bootstrap.target.image": CONTROLLER_IMAGE_ID,
|
||||
"itworx.controller.bootstrap.target.revision": MODERN_CONTROLLER_REVISION,
|
||||
"itworx.controller.bootstrap.target.tree": MODERN_CONTROLLER_TREE,
|
||||
"itworx.controller.bootstrap.target.version": "2.1.14",
|
||||
}
|
||||
|
||||
|
||||
def test_retired_failed_child_is_removed_only_after_current_214_profile_rechecks():
|
||||
helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
failed_child = _retired_failed_child(helper_source)
|
||||
docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child)
|
||||
docker.containers["gitea-deploy-control"] = _modern_controller_source_inspect(
|
||||
MODERN_CONTROLLER_REVISION
|
||||
)
|
||||
git = FakeProtectedGit(helper_source)
|
||||
|
||||
result = _run_bootstrap(
|
||||
docker,
|
||||
git,
|
||||
health_sequence=[
|
||||
_modern_baseline_health(),
|
||||
_modern_baseline_health(),
|
||||
_modern_baseline_health(),
|
||||
_modern_baseline_health(),
|
||||
_health(),
|
||||
_health(),
|
||||
],
|
||||
)
|
||||
|
||||
assert result["status"] == "promoted"
|
||||
assert docker.removed == [RETIRED_HELPER_ID]
|
||||
assert docker.created[0]["Labels"]["itworx.controller.bootstrap.target.id"] == (
|
||||
MODERN_CONTROLLER_CONTAINER_ID
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
"container_id",
|
||||
"image_id",
|
||||
"source_revision",
|
||||
"source_tree",
|
||||
"artifact_digest",
|
||||
"version",
|
||||
"compose_label",
|
||||
"extra_label",
|
||||
"missing_label",
|
||||
"extra_mount",
|
||||
"missing_mount",
|
||||
"writable_mount",
|
||||
"integer_rw_mount",
|
||||
"missing_rw_mount",
|
||||
"malformed_mount_source",
|
||||
"malformed_mount_name",
|
||||
"mount_alias",
|
||||
"dead_controller",
|
||||
"paused_controller",
|
||||
"restarting_controller",
|
||||
"missing_state_flag",
|
||||
"health_revision",
|
||||
"health_tree",
|
||||
"health_artifact",
|
||||
"health_version",
|
||||
],
|
||||
)
|
||||
def test_retired_cleanup_refuses_any_current_214_identity_or_topology_drift(mutation: str):
|
||||
helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
failed_child = _retired_failed_child(helper_source)
|
||||
docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child)
|
||||
controller = _modern_controller_source_inspect(MODERN_CONTROLLER_REVISION)
|
||||
health = _modern_baseline_health()
|
||||
if mutation == "container_id":
|
||||
controller["Id"] = "9" * 64
|
||||
elif mutation == "image_id":
|
||||
controller["Image"] = "sha256:" + "9" * 64
|
||||
elif mutation == "source_revision":
|
||||
controller["Mounts"][3]["Source"] = (
|
||||
"/var/lib/docker/volumes/itworx-controller-hub-releases/_data/"
|
||||
+ "9" * 40
|
||||
)
|
||||
elif mutation == "source_tree":
|
||||
health["artifact"]["sourceTree"] = "9" * 40
|
||||
elif mutation == "artifact_digest":
|
||||
health["artifact"]["artifactSha256"] = "9" * 64
|
||||
elif mutation == "version":
|
||||
controller["Config"]["Labels"]["org.opencontainers.image.version"] = "forged"
|
||||
elif mutation == "compose_label":
|
||||
controller["Config"]["Labels"]["com.docker.compose.service"] = "other"
|
||||
elif mutation == "extra_label":
|
||||
controller["Config"]["Labels"]["unexpected"] = "value"
|
||||
elif mutation == "missing_label":
|
||||
del controller["Config"]["Labels"]["tech.itworx.role"]
|
||||
elif mutation == "extra_mount":
|
||||
controller["Mounts"].append(dict(controller["Mounts"][0]))
|
||||
controller["Mounts"][-1]["Destination"] = "/unexpected"
|
||||
elif mutation == "missing_mount":
|
||||
controller["Mounts"].pop()
|
||||
elif mutation == "writable_mount":
|
||||
controller["Mounts"][1]["RW"] = True
|
||||
elif mutation == "integer_rw_mount":
|
||||
controller["Mounts"][1]["RW"] = 0
|
||||
elif mutation == "missing_rw_mount":
|
||||
del controller["Mounts"][1]["RW"]
|
||||
elif mutation == "malformed_mount_source":
|
||||
controller["Mounts"][1]["Source"] = 123
|
||||
elif mutation == "malformed_mount_name":
|
||||
controller["Mounts"][8]["Name"] = None
|
||||
elif mutation == "mount_alias":
|
||||
controller["Mounts"][9]["Source"] = "/tmp/controller-config"
|
||||
elif mutation == "dead_controller":
|
||||
controller["State"]["Dead"] = True
|
||||
elif mutation == "paused_controller":
|
||||
controller["State"]["Paused"] = True
|
||||
elif mutation == "restarting_controller":
|
||||
controller["State"]["Restarting"] = True
|
||||
elif mutation == "missing_state_flag":
|
||||
del controller["State"]["Dead"]
|
||||
elif mutation == "health_revision":
|
||||
health["artifact"]["sourceRevision"] = "9" * 40
|
||||
elif mutation == "health_tree":
|
||||
health["artifact"]["sourceTree"] = "9" * 40
|
||||
elif mutation == "health_artifact":
|
||||
health["artifact"]["artifactSha256"] = "9" * 64
|
||||
elif mutation == "health_version":
|
||||
health["artifact"]["controllerVersion"] = "2.1.13"
|
||||
docker.containers["gitea-deploy-control"] = controller
|
||||
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
_run_bootstrap(
|
||||
docker,
|
||||
FakeProtectedGit(helper_source),
|
||||
health_sequence=[health, health, health],
|
||||
)
|
||||
|
||||
assert docker.removed == []
|
||||
assert docker.created == []
|
||||
assert docker.started == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import bootstrap_controller_artifact_promoter as bootstrap
|
||||
from backend.tests import test_controller_artifact_bootstrap as original
|
||||
|
||||
FAILED_REVISION = "1" * 40
|
||||
FAILED_TREE = "2" * 40
|
||||
FAILED_ID = "3" * 64
|
||||
|
||||
|
||||
class Git(original.FakeProtectedGit):
|
||||
def verify_failed_modern_ancestor(self, revision, current_revision):
|
||||
if (
|
||||
revision != FAILED_REVISION
|
||||
or current_revision != self.revision
|
||||
or not self.retired_tree_valid
|
||||
):
|
||||
raise bootstrap.BootstrapError(
|
||||
"controller_promoter_failed_helper_not_ancestor"
|
||||
)
|
||||
return FAILED_TREE
|
||||
|
||||
def read_blob(self, revision, path):
|
||||
if revision == FAILED_REVISION:
|
||||
self.blobs_read.append((revision, path))
|
||||
assert path == bootstrap.MODERN_HELPER_SOURCE_PATH
|
||||
return self.helper_source.encode()
|
||||
return super().read_blob(revision, path)
|
||||
|
||||
|
||||
class Docker(original.FakeDocker):
|
||||
def inspect_container(self, reference):
|
||||
if reference == FAILED_ID:
|
||||
return self.existing_child
|
||||
return super().inspect_container(reference)
|
||||
|
||||
def remove_retired_failed_container(self, identifier):
|
||||
assert identifier == FAILED_ID
|
||||
self.removed.append(identifier)
|
||||
self.existing_child = None
|
||||
|
||||
|
||||
def inputs():
|
||||
source = (original.ROOT / bootstrap.MODERN_HELPER_SOURCE_PATH).read_text()
|
||||
child = original._retired_failed_child(source)
|
||||
baseline = bootstrap.MODERN_CONTROLLER_BASELINE
|
||||
spec = bootstrap.render_modern_helper_spec(
|
||||
source,
|
||||
revision=FAILED_REVISION,
|
||||
image_id=baseline.image_id,
|
||||
current_controller_id=baseline.container_id,
|
||||
current_controller_revision=baseline.revision,
|
||||
current_controller_tree=baseline.tree,
|
||||
current_controller_version=baseline.version,
|
||||
)
|
||||
child["Id"] = FAILED_ID
|
||||
child["Config"].update(
|
||||
{
|
||||
"Hostname": original.HOST_HOSTNAME,
|
||||
"Cmd": spec["Cmd"],
|
||||
"Labels": {**original._controller_labels(), **spec["Labels"]},
|
||||
"Env": ["PATH=/usr/bin"],
|
||||
"WorkingDir": "/tmp",
|
||||
"Entrypoint": None,
|
||||
"Domainname": "",
|
||||
"AttachStdin": False,
|
||||
"AttachStdout": False,
|
||||
"AttachStderr": False,
|
||||
"Tty": False,
|
||||
"OpenStdin": False,
|
||||
"StdinOnce": False,
|
||||
"OnBuild": None,
|
||||
}
|
||||
)
|
||||
child["State"].update({"Paused": False, "Restarting": False, "Dead": False})
|
||||
child["HostConfig"].update(copy.deepcopy(bootstrap.FAILED_HELPER_HOST_DEFAULTS))
|
||||
child["HostConfig"]["RestartPolicy"] = {"Name": "no", "MaximumRetryCount": 0}
|
||||
docker = Docker(original._legacy_helper_spec(), existing_child=child)
|
||||
docker.containers[bootstrap.CONTROLLER_NAME] = (
|
||||
original._modern_controller_source_inspect(baseline.revision)
|
||||
)
|
||||
docker.images[baseline.image_id]["Config"].update(
|
||||
{
|
||||
"Env": ["PATH=/usr/bin"],
|
||||
"WorkingDir": "/tmp",
|
||||
"Entrypoint": None,
|
||||
"Shell": ["/bin/bash", "-c"],
|
||||
}
|
||||
)
|
||||
return docker, Git(source), child
|
||||
|
||||
|
||||
def run(docker, git):
|
||||
return original._run_bootstrap(
|
||||
docker,
|
||||
git,
|
||||
health_sequence=[
|
||||
original._modern_baseline_health(),
|
||||
original._modern_baseline_health(),
|
||||
original._modern_baseline_health(),
|
||||
original._modern_baseline_health(),
|
||||
original._health(),
|
||||
original._health(),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_noncurrent_protected_ancestor_failure_releases_only_its_verified_name():
|
||||
docker, git, child = inputs()
|
||||
result = run(docker, git)
|
||||
assert result["status"] == "promoted"
|
||||
assert docker.removed == [FAILED_ID]
|
||||
assert (
|
||||
git.blobs_read.count((FAILED_REVISION, bootstrap.MODERN_HELPER_SOURCE_PATH))
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
docker.created[0]["Labels"]["itworx.controller.bootstrap.target.id"]
|
||||
== bootstrap.MODERN_CONTROLLER_ID
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
"running",
|
||||
"succeeded",
|
||||
"bool_exit",
|
||||
"paused",
|
||||
"dead",
|
||||
"restarting",
|
||||
"source",
|
||||
"target",
|
||||
"command",
|
||||
"entrypoint",
|
||||
"environment",
|
||||
"mount",
|
||||
"host_capability",
|
||||
"controller",
|
||||
"appops",
|
||||
"ancestor",
|
||||
],
|
||||
)
|
||||
def test_noncurrent_failed_child_recovery_refuses_unattested_identity_or_baseline(
|
||||
change,
|
||||
):
|
||||
docker, git, child = inputs()
|
||||
if change == "running":
|
||||
child["State"].update(Running=True, Status="running")
|
||||
elif change == "succeeded":
|
||||
child["State"]["ExitCode"] = 0
|
||||
elif change == "bool_exit":
|
||||
child["State"]["ExitCode"] = True
|
||||
elif change in {"paused", "dead", "restarting"}:
|
||||
child["State"][change.capitalize()] = True
|
||||
elif change == "source":
|
||||
child["Config"]["Labels"]["itworx.source.repository"] = "other/repository"
|
||||
elif change == "target":
|
||||
child["Config"]["Labels"]["itworx.controller.bootstrap.target.id"] = "9" * 64
|
||||
elif change == "command":
|
||||
child["Config"]["Cmd"][2] += "\necho changed"
|
||||
elif change == "entrypoint":
|
||||
child["Config"]["Entrypoint"] = ["/bin/sh"]
|
||||
elif change == "environment":
|
||||
child["Config"]["Env"].append("CUSTOM_VARIABLE=extra")
|
||||
elif change == "mount":
|
||||
child["Mounts"][2]["RW"] = True
|
||||
elif change == "host_capability":
|
||||
child["HostConfig"]["CapAdd"] = ["SYS_ADMIN"]
|
||||
elif change == "controller":
|
||||
docker.containers[bootstrap.CONTROLLER_NAME]["Id"] = "9" * 64
|
||||
elif change == "appops":
|
||||
docker.containers[original.APPOPS_CONTAINER_ID]["Image"] = "sha256:" + "9" * 64
|
||||
else:
|
||||
git.retired_tree_valid = False
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
run(docker, git)
|
||||
assert docker.removed == docker.created == docker.started == []
|
||||
|
||||
|
||||
def test_noncurrent_child_identity_drift_on_final_named_reread_refuses_delete():
|
||||
docker, git, child = inputs()
|
||||
docker.named_inspect_override = copy.deepcopy(child)
|
||||
docker.named_inspect_override["Id"] = "9" * 64
|
||||
docker.named_inspect_override_at = 3
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
run(docker, git)
|
||||
assert not docker.removed
|
||||
|
||||
|
||||
def test_dynamic_failed_child_delete_retains_nonforce_and_no_volume_policy(monkeypatch):
|
||||
client = bootstrap.DockerSocketClient()
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"_request",
|
||||
lambda method, path, **kw: (calls.append((method, path)) or (204, b"")),
|
||||
)
|
||||
client.remove_retired_failed_container(FAILED_ID)
|
||||
assert calls == [("DELETE", f"/containers/{FAILED_ID}?force=false&v=false")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
["cpu", "dns", "masked_paths", "logging", "boolean_default", "unknown_option"],
|
||||
)
|
||||
def test_failed_child_requires_complete_known_host_defaults(change):
|
||||
docker, git, child = inputs()
|
||||
if change == "cpu":
|
||||
child["HostConfig"]["CpuShares"] = 100
|
||||
elif change == "dns":
|
||||
child["HostConfig"]["Dns"] = ["192.0.2.1"]
|
||||
elif change == "masked_paths":
|
||||
child["HostConfig"]["MaskedPaths"] = []
|
||||
elif change == "logging":
|
||||
child["HostConfig"]["LogConfig"]["Type"] = "none"
|
||||
elif change == "boolean_default":
|
||||
child["HostConfig"]["CpuShares"] = False
|
||||
else:
|
||||
child["HostConfig"]["UnreviewedOption"] = None
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
run(docker, git)
|
||||
assert not docker.removed
|
||||
|
||||
|
||||
def test_raw_docker_api_nil_mount_omission_matches_cli_null():
|
||||
docker, git, child = inputs()
|
||||
child["HostConfig"].pop("Mounts")
|
||||
assert run(docker, git)["status"] == "promoted"
|
||||
|
||||
|
||||
def test_appops_drift_on_final_baseline_reread_refuses_cleanup():
|
||||
docker, git, child = inputs()
|
||||
docker.appops_inspect_override = copy.deepcopy(
|
||||
docker.containers[original.APPOPS_CONTAINER_ID]
|
||||
)
|
||||
docker.appops_inspect_override["State"]["Running"] = False
|
||||
docker.appops_inspect_override_at = 3
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
run(docker, git)
|
||||
assert not docker.removed
|
||||
|
||||
|
||||
def test_protected_main_drift_immediately_before_delete_refuses_cleanup():
|
||||
docker, git, child = inputs()
|
||||
git.fail_current_main_at = 5
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
run(docker, git)
|
||||
assert not docker.removed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier", ["", "short", "A" * 64, "3" * 63, "../containers/other"]
|
||||
)
|
||||
def test_delete_adapter_rejects_noncanonical_full_container_identity(
|
||||
monkeypatch, identifier
|
||||
):
|
||||
client = bootstrap.DockerSocketClient()
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"_request",
|
||||
lambda *_args, **_kw: pytest.fail("unexpected Docker mutation"),
|
||||
)
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
client.remove_retired_failed_container(identifier)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ["ancestor", "unrelated", "replace_ref"])
|
||||
def test_git_ancestor_proof_reads_original_protected_objects(
|
||||
tmp_path, monkeypatch, scenario
|
||||
):
|
||||
def git(*args):
|
||||
return (
|
||||
subprocess.run(
|
||||
["git", "-C", str(tmp_path), *args],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
.stdout.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
git("init", "-q", "-b", "main")
|
||||
git("config", "user.name", "Test")
|
||||
git("config", "user.email", "test@example.invalid")
|
||||
path = tmp_path / "tracked"
|
||||
path.write_text("protected ancestor")
|
||||
git("add", ".")
|
||||
git("commit", "-qm", "ancestor")
|
||||
old = git("rev-parse", "HEAD")
|
||||
tree = git("rev-parse", "HEAD^{tree}")
|
||||
path.write_text("new protected main")
|
||||
git("commit", "-qam", "main")
|
||||
current = git("rev-parse", "HEAD")
|
||||
monkeypatch.setattr(bootstrap, "PROTECTED_REPO", str(tmp_path / ".git"))
|
||||
proof = bootstrap.ProtectedGitRepository()
|
||||
if scenario == "replace_ref":
|
||||
git("replace", old, current)
|
||||
assert git("rev-parse", f"{old}^{{tree}}") != tree
|
||||
if scenario == "unrelated":
|
||||
git("checkout", "-qb", "side", old)
|
||||
path.write_text("unrelated child")
|
||||
git("commit", "-qam", "side")
|
||||
old = git("rev-parse", "HEAD")
|
||||
with pytest.raises(bootstrap.BootstrapError):
|
||||
proof.verify_failed_modern_ancestor(old, current)
|
||||
else:
|
||||
assert proof.verify_failed_modern_ancestor(old, current) == tree
|
||||
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
DIAGNOSTIC = (
|
||||
"APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC "
|
||||
"code=helper_failed phase=source_archive exitCode=19"
|
||||
)
|
||||
SAFE_DIAGNOSTICS = {"code": "helper_failed", "phase": "source_archive", "exitCode": 19}
|
||||
SECRET = "must-never-escape-helper-log"
|
||||
|
||||
|
||||
def _docker_frame(stream_type, payload):
|
||||
return bytes([stream_type, 0, 0, 0]) + len(payload).to_bytes(4, "big") + payload
|
||||
|
||||
|
||||
def _default_docker_logs():
|
||||
marker = DIAGNOSTIC.encode()
|
||||
split_at = len(marker) // 2
|
||||
return b"".join(
|
||||
(
|
||||
_docker_frame(1, b"helper started\n"),
|
||||
_docker_frame(2, f"token={SECRET}\n".encode() + marker[:split_at]),
|
||||
_docker_frame(2, marker[split_at:] + b"\n"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload=None):
|
||||
self.payload = payload or {}
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class _Portainer:
|
||||
def __init__(self, *, exit_code=19, logs=None, running=False):
|
||||
self.exit_code = exit_code
|
||||
self.logs = logs if logs is not None else _default_docker_logs()
|
||||
self.running = running
|
||||
self.requests = []
|
||||
self.request_kwargs = []
|
||||
|
||||
def _config(self):
|
||||
return SimpleNamespace(endpoint_id="1")
|
||||
|
||||
def list_containers(self):
|
||||
return {"containers": []}
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
self.requests.append((method, path))
|
||||
self.request_kwargs.append(kwargs)
|
||||
if method == "POST" and path.endswith("/containers/create"):
|
||||
return _Response({"Id": "helper-123"})
|
||||
if method == "GET" and path.endswith("/helper-123/json"):
|
||||
return _Response(
|
||||
{"State": {"Running": self.running, "ExitCode": self.exit_code}}
|
||||
)
|
||||
if method == "GET" and path.endswith("/helper-123/logs"):
|
||||
return httpx.Response(status_code=200, content=self.logs)
|
||||
return _Response()
|
||||
|
||||
def get_container_logs(self, container_name, tail=100):
|
||||
return {"status": "ok", "container": container_name, "logs": self.logs}
|
||||
|
||||
|
||||
def _session(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'controller-diagnostics.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def test_helper_spec_emits_only_fixed_failure_markers():
|
||||
from backend.app.services.controller_artifact_helper_spec import (
|
||||
helper_container_spec,
|
||||
)
|
||||
|
||||
command = helper_container_spec(revision="a" * 40, image_id="sha256:" + "b" * 64)["Cmd"][2]
|
||||
assert "APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC" in command
|
||||
assert "phase=source_commit" in command
|
||||
assert "phase=source_archive" in command
|
||||
assert "phase=upgrade" in command
|
||||
assert "phase=cleanup" in command
|
||||
assert "exitCode=" in command
|
||||
|
||||
|
||||
def test_generated_helper_command_parses_without_evaluating_command_substitutions():
|
||||
from backend.app.services.controller_artifact_helper_spec import (
|
||||
helper_container_spec,
|
||||
)
|
||||
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash is required to parse the generated helper command")
|
||||
command = helper_container_spec(
|
||||
revision="a" * 40,
|
||||
image_id="sha256:" + "b" * 64,
|
||||
)["Cmd"][2]
|
||||
result = subprocess.run(
|
||||
[bash, "-n"],
|
||||
input=command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_failed_workspace_validation_does_not_run_cleanup_or_remove_unvalidated_path():
|
||||
from backend.app.services.controller_artifact_helper_spec import (
|
||||
helper_container_spec,
|
||||
)
|
||||
|
||||
bash = shutil.which("bash")
|
||||
if bash is None:
|
||||
pytest.skip("bash is required to exercise the generated helper command")
|
||||
command = helper_container_spec(
|
||||
revision="a" * 40,
|
||||
image_id="sha256:" + "b" * 64,
|
||||
)["Cmd"][2]
|
||||
shell_stubs = """\
|
||||
stat() {
|
||||
case "$*" in
|
||||
*"%a:%u:%g"*) printf '1777:0:0\\n' ;;
|
||||
*) printf '999:700\\n' ;;
|
||||
esac
|
||||
}
|
||||
mktemp() { printf '/tmp\\n'; }
|
||||
rm() { printf 'RM_WOULD_RUN\\n'; }
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[bash, "-s"],
|
||||
input=shell_stubs + command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=False,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "phase=workspace_validate" in result.stderr
|
||||
assert "RM_WOULD_RUN" not in result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_diagnostic_parser_accepts_fixed_marker_and_returns_no_log_text():
|
||||
from backend.app.services import controller_artifact_promoter as promoter
|
||||
|
||||
parser = getattr(promoter, "parse_helper_diagnostics", None)
|
||||
assert callable(parser), "the helper diagnostic parser is not implemented"
|
||||
assert parser(f"safe status line\n{DIAGNOSTIC}", 19) == SAFE_DIAGNOSTICS
|
||||
|
||||
|
||||
def test_docker_log_demux_reads_stdout_stderr_and_markers_across_frames():
|
||||
from backend.app.services import controller_artifact_promoter as promoter
|
||||
|
||||
demux = getattr(promoter, "_decode_docker_log_stream", None)
|
||||
assert callable(demux), "the bounded Docker stdcopy log demux is not implemented"
|
||||
marker = DIAGNOSTIC.encode()
|
||||
split_at = len(marker) // 2
|
||||
framed = b"".join(
|
||||
(
|
||||
_docker_frame(1, b"stdout line\n"),
|
||||
_docker_frame(2, b"stderr prefix\n" + marker[:split_at]),
|
||||
_docker_frame(2, marker[split_at:] + b"\n"),
|
||||
)
|
||||
)
|
||||
assert demux(framed) == f"stdout line\nstderr prefix\n{DIAGNOSTIC}\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"framed",
|
||||
[
|
||||
b"",
|
||||
_docker_frame(3, b"unknown stream"),
|
||||
bytes([2, 1, 0, 0]) + (1).to_bytes(4, "big") + b"x",
|
||||
bytes([2, 0, 0, 0]),
|
||||
_docker_frame(2, b"truncated")[:-1],
|
||||
_docker_frame(2, b"\xff"),
|
||||
bytes([2, 0, 0, 0]) + (4001).to_bytes(4, "big"),
|
||||
b"".join(_docker_frame(1, b"x") for _ in range(201)),
|
||||
],
|
||||
)
|
||||
def test_docker_log_demux_rejects_invalid_truncated_or_oversized_streams(framed):
|
||||
from backend.app.services import controller_artifact_promoter as promoter
|
||||
|
||||
demux = getattr(promoter, "_decode_docker_log_stream", None)
|
||||
assert callable(demux), "the bounded Docker stdcopy log demux is not implemented"
|
||||
assert demux(framed) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("log_text", "exit_code"),
|
||||
[
|
||||
("APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=unlisted exitCode=19", 19),
|
||||
("APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=source_archive exitCode=19 extra=secret", 19),
|
||||
("APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=source_archive exitCode=256", 19),
|
||||
(DIAGNOSTIC, 256),
|
||||
(DIAGNOSTIC + "\n" + DIAGNOSTIC, 19),
|
||||
("x" * 4001 + "\n" + DIAGNOSTIC, 19),
|
||||
(DIAGNOSTIC, True),
|
||||
],
|
||||
)
|
||||
def test_diagnostic_parser_rejects_malformed_oversized_or_adversarial_input(log_text, exit_code):
|
||||
from backend.app.services import controller_artifact_promoter as promoter
|
||||
|
||||
parser = getattr(promoter, "parse_helper_diagnostics", None)
|
||||
assert callable(parser), "the helper diagnostic parser is not implemented"
|
||||
expected_exit = exit_code if type(exit_code) is int and 0 <= exit_code <= 255 else None
|
||||
assert parser(log_text, exit_code) == {
|
||||
"code": "helper_failed",
|
||||
"phase": "unknown",
|
||||
"exitCode": expected_exit,
|
||||
}
|
||||
|
||||
|
||||
def test_helper_failure_diagnostics_reach_job_audit_and_operation_result_without_logs(tmp_path, monkeypatch):
|
||||
from backend.app.db.models import AuditEvent, JobRun
|
||||
from backend.app.services import action_execution
|
||||
from backend.app.services.controller_artifact_promoter import (
|
||||
ControllerArtifactPromoter,
|
||||
)
|
||||
|
||||
portainer = _Portainer()
|
||||
promoted = ControllerArtifactPromoter(portainer=portainer)._run_helper(
|
||||
revision="a" * 40,
|
||||
image_id="sha256:" + "b" * 64,
|
||||
)
|
||||
assert promoted["status"] == "failed"
|
||||
assert promoted["diagnostics"] == SAFE_DIAGNOSTICS
|
||||
assert "logTail" not in promoted
|
||||
assert any(
|
||||
method == "GET" and path.endswith("/helper-123/logs")
|
||||
for method, path in portainer.requests
|
||||
)
|
||||
log_request_index = next(
|
||||
index
|
||||
for index, (method, path) in enumerate(portainer.requests)
|
||||
if method == "GET" and path.endswith("/helper-123/logs")
|
||||
)
|
||||
assert portainer.request_kwargs[log_request_index]["params"] == {
|
||||
"stdout": 1,
|
||||
"stderr": 1,
|
||||
"tail": 200,
|
||||
}
|
||||
assert SECRET not in json.dumps(promoted)
|
||||
|
||||
monkeypatch.setattr(ControllerArtifactPromoter, "run", lambda self: promoted)
|
||||
with _session(tmp_path) as session:
|
||||
result = action_execution._execute_controller_artifact_promote(
|
||||
session,
|
||||
source="operator",
|
||||
risk="high",
|
||||
reason="diagnose the failed promotion",
|
||||
original_policy_decision="approval_required",
|
||||
)
|
||||
job = session.get(JobRun, result["jobId"])
|
||||
audit = session.get(AuditEvent, result["auditId"])
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["diagnostics"] == SAFE_DIAGNOSTICS
|
||||
assert job is not None and "phase=source_archive" in job.result_summary
|
||||
assert audit is not None
|
||||
assert audit.after_state["diagnostics"] == SAFE_DIAGNOSTICS
|
||||
assert audit.redacted_metadata["diagnostics"] == SAFE_DIAGNOSTICS
|
||||
durable = json.dumps(
|
||||
{
|
||||
"operation": result,
|
||||
"jobSummary": job.result_summary,
|
||||
"auditAfter": audit.after_state,
|
||||
"auditMetadata": audit.redacted_metadata,
|
||||
}
|
||||
)
|
||||
assert SECRET not in durable
|
||||
assert "logTail" not in durable
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"logs",
|
||||
[
|
||||
b"",
|
||||
_docker_frame(
|
||||
2,
|
||||
b"APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=unknown exitCode=19",
|
||||
),
|
||||
_docker_frame(
|
||||
2,
|
||||
b"APPOPS_CONTROLLER_PROMOTION_DIAGNOSTIC code=helper_failed phase=source_archive exitCode=19 extra=value",
|
||||
),
|
||||
_docker_frame(2, b"x" * 4001),
|
||||
_docker_frame(2, b"truncated")[:-1],
|
||||
],
|
||||
)
|
||||
def test_missing_or_unparseable_helper_phase_is_explicitly_unknown(logs):
|
||||
from backend.app.services.controller_artifact_promoter import (
|
||||
ControllerArtifactPromoter,
|
||||
)
|
||||
|
||||
result = ControllerArtifactPromoter(portainer=_Portainer(exit_code=19, logs=logs))._run_helper(
|
||||
revision="a" * 40,
|
||||
image_id="sha256:" + "b" * 64,
|
||||
)
|
||||
assert result["status"] == "failed"
|
||||
assert result["diagnostics"] == {
|
||||
"code": "helper_failed",
|
||||
"phase": "unknown",
|
||||
"exitCode": 19,
|
||||
}
|
||||
assert "logTail" not in result
|
||||
assert SECRET not in json.dumps(result)
|
||||
|
||||
|
||||
def test_successful_helper_remains_promoted_and_never_returns_logs():
|
||||
from backend.app.services.controller_artifact_promoter import (
|
||||
ControllerArtifactPromoter,
|
||||
)
|
||||
|
||||
portainer = _Portainer(exit_code=0)
|
||||
portainer.get_container_logs = lambda *args, **kwargs: pytest.fail("success must not fetch helper logs")
|
||||
result = ControllerArtifactPromoter(portainer=portainer)._run_helper(
|
||||
revision="a" * 40,
|
||||
image_id="sha256:" + "b" * 64,
|
||||
)
|
||||
assert result == {"status": "promoted", "revision": "a" * 40}
|
||||
assert not any(path.endswith("/logs") for _, path in portainer.requests)
|
||||
|
||||
|
||||
def test_helper_timeout_remains_outcome_unknown_without_cleanup_or_log_fetch(monkeypatch):
|
||||
from backend.app.services import controller_artifact_promoter as promoter
|
||||
|
||||
portainer = _Portainer(running=True)
|
||||
portainer.get_container_logs = lambda *args, **kwargs: pytest.fail(
|
||||
"a running helper has no terminal diagnostic to read"
|
||||
)
|
||||
clock_values = iter((100.0, 341.0))
|
||||
monkeypatch.setattr(promoter.time, "monotonic", lambda: next(clock_values))
|
||||
|
||||
result = promoter.ControllerArtifactPromoter(portainer=portainer)._run_helper(
|
||||
revision="a" * 40,
|
||||
image_id="sha256:" + "b" * 64,
|
||||
)
|
||||
assert result == {
|
||||
"status": "outcome_unknown",
|
||||
"revision": "a" * 40,
|
||||
"reason": "controller_promoter_timeout",
|
||||
"helperId": "helper-123",
|
||||
}
|
||||
assert not any(method == "DELETE" for method, _ in portainer.requests)
|
||||
assert not any(path.endswith("/logs") for _, path in portainer.requests)
|
||||
@@ -24,6 +24,29 @@ 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"
|
||||
# Frozen historical layouts; these fixtures must not follow the acceptance registry.
|
||||
HISTORICAL_PRE_CANDIDATE_RELEASE_FILES = (
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py",
|
||||
"managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py",
|
||||
"runner_capacity_controller.py", "runner_capacity_policy.py",
|
||||
"runner_fleet_upgrade_v2.py", "status_server.py",
|
||||
"repopulse_adoption_store.py", "repopulse_registry_restore.py",
|
||||
"repopulse_adoption_executor.py", "repopulse_adoption_effects.py",
|
||||
"repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py",
|
||||
"stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py",
|
||||
"stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py",
|
||||
)
|
||||
HISTORICAL_CANDIDATE_HELPER_RELEASE_FILES = (
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py",
|
||||
"managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py",
|
||||
"runner_capacity_controller.py", "runner_capacity_policy.py",
|
||||
"runner_fleet_upgrade_v2.py", "status_server.py", "prepare_mcp_hub_candidate.py",
|
||||
"repopulse_adoption_store.py", "repopulse_registry_restore.py",
|
||||
"repopulse_adoption_executor.py", "repopulse_adoption_effects.py",
|
||||
"repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py",
|
||||
"stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py",
|
||||
"stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py",
|
||||
)
|
||||
HISTORICAL_CONFIG_ROOT_RELEASE_FILES = (
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py",
|
||||
"deploy.py", "managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py",
|
||||
@@ -279,6 +302,220 @@ def test_promotion_does_not_prepare_backup_volume_when_staged_configuration_fail
|
||||
assert "backup_volume_prepared" not in events
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failed_step", "expected_phase", "error_code"),
|
||||
[
|
||||
("old_tls", "old_tls", "controller_volume_private_tls_unsafe"),
|
||||
("config", "config", "controller_volume_config_missing"),
|
||||
("release_build", "release_build", "controller_volume_source_archive_incomplete"),
|
||||
("staged_config", "staged_config", "controller_volume_command_failed"),
|
||||
("old_release", "old_release", "controller_volume_writer_failed"),
|
||||
("new_release", "new_release", "controller_volume_writer_failed"),
|
||||
],
|
||||
)
|
||||
def test_prepare_reports_only_fixed_failure_phase_and_code(
|
||||
monkeypatch, tmp_path, capsys, failed_step, expected_phase, error_code
|
||||
):
|
||||
_prepare_promotion_doubles(monkeypatch, tmp_path)
|
||||
def fail(*_args, **_kwargs):
|
||||
raise module.VolumeTransitionError(error_code)
|
||||
|
||||
if failed_step == "old_tls":
|
||||
monkeypatch.setattr(module, "_validate_previous_private_tls", fail)
|
||||
elif failed_step == "config":
|
||||
monkeypatch.setattr(module, "_read_config_leaf", fail)
|
||||
elif failed_step == "release_build":
|
||||
monkeypatch.setattr(module, "_prepare_merged_tree", fail)
|
||||
elif failed_step == "staged_config":
|
||||
monkeypatch.setattr(module, "_validate_staged_configuration", fail)
|
||||
else:
|
||||
installed = 0
|
||||
|
||||
def install(*_args, **_kwargs):
|
||||
nonlocal installed
|
||||
installed += 1
|
||||
if installed == (1 if failed_step == "old_release" else 2):
|
||||
fail()
|
||||
|
||||
monkeypatch.setattr(module, "_install_release_leaf", install)
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"preparer", "prepare", "--stage-root", str(tmp_path / "stage"),
|
||||
"--source-revision", "d" * 40, "--source-tree", "e" * 40,
|
||||
"--controller-version", "2.2.0", "--image-id", IMAGE_ID,
|
||||
])
|
||||
with pytest.raises(SystemExit) as failure:
|
||||
module.main()
|
||||
assert str(failure.value) == (
|
||||
"controller protected Hub volume transition failed "
|
||||
f"phase={expected_phase} code={error_code}"
|
||||
)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [
|
||||
module.VolumeTransitionError("controller_volume_writer_failed\nTOKEN=private"),
|
||||
module.VolumeTransitionError("controller_volume_unreviewed_code"),
|
||||
ValueError("TOKEN=private"),
|
||||
])
|
||||
def test_prepare_diagnostic_suppresses_untrusted_error_and_phase(monkeypatch, tmp_path, error):
|
||||
def forged_prepare(*_args, **kwargs):
|
||||
kwargs["on_phase"]("old_release\nTOKEN=private")
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(module, "prepare_promotion", forged_prepare)
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"preparer", "prepare", "--stage-root", str(tmp_path / "stage"),
|
||||
"--source-revision", "d" * 40, "--source-tree", "e" * 40,
|
||||
"--controller-version", "2.2.0", "--image-id", IMAGE_ID,
|
||||
])
|
||||
with pytest.raises(SystemExit) as failure:
|
||||
module.main()
|
||||
assert str(failure.value) == (
|
||||
"controller protected Hub volume transition failed phase=prepare code=unclassified"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stderr", "expected_condition"),
|
||||
[
|
||||
(b"container rootfs is marked read-only at /private/path TOKEN=fixture-secret",
|
||||
"rootfs_read_only"),
|
||||
(b"read-only file system at /private/path TOKEN=fixture-secret", "mount_read_only"),
|
||||
(b"permission denied: /private/path TOKEN=fixture-secret", "permission_denied"),
|
||||
(b"no such file or directory: /private/path TOKEN=fixture-secret",
|
||||
"destination_missing"),
|
||||
(b"No such container: private-container-id TOKEN=fixture-secret", "container_missing"),
|
||||
(b"client version is too old TOKEN=fixture-secret", "api_version_unsupported"),
|
||||
(b"unrecognized failure TOKEN=fixture-secret", None),
|
||||
],
|
||||
)
|
||||
def test_writer_command_failure_is_categorized_without_retaining_stderr(
|
||||
monkeypatch, stderr, expected_condition
|
||||
):
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda *_args, **_kwargs: types.SimpleNamespace(
|
||||
returncode=1, stdout=b"", stderr=stderr,
|
||||
))
|
||||
|
||||
with pytest.raises(module.VolumeTransitionError) as failure:
|
||||
module._run(
|
||||
["docker", "cp", "-", "container:/private/path"],
|
||||
data=b"TOKEN=fixture-secret",
|
||||
diagnostic_action="writer_copy",
|
||||
)
|
||||
|
||||
assert failure.value.args == ("controller_volume_command_failed",)
|
||||
assert failure.value.command_failure == (
|
||||
"writer_copy", "exit_1", expected_condition,
|
||||
)
|
||||
diagnostic = module._safe_command_diagnostic(failure.value.command_failure)
|
||||
assert diagnostic is not None
|
||||
assert "fixture-secret" not in repr(failure.value)
|
||||
assert "fixture-secret" not in diagnostic
|
||||
assert "private/path" not in diagnostic
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_kind", "expected_outcome"),
|
||||
[("timeout", "timeout"), ("spawn", "spawn_failed"), ("overflow", "output_limit")],
|
||||
)
|
||||
def test_writer_command_failure_diagnostics_bound_non_exit_outcomes(
|
||||
monkeypatch, failure_kind, expected_outcome
|
||||
):
|
||||
def run(*_args, **_kwargs):
|
||||
if failure_kind == "timeout":
|
||||
raise subprocess.TimeoutExpired(cmd=["docker", "cp"], timeout=45,
|
||||
stderr=b"TOKEN=fixture-secret")
|
||||
if failure_kind == "spawn":
|
||||
raise OSError("TOKEN=fixture-secret")
|
||||
return types.SimpleNamespace(returncode=0, stdout=b"oversized", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
kwargs = {"max_output": 1} if failure_kind == "overflow" else {}
|
||||
with pytest.raises(module.VolumeTransitionError) as failure:
|
||||
module._run(["docker", "cp", "-", "container:/target"],
|
||||
diagnostic_action="writer_copy", **kwargs)
|
||||
|
||||
assert failure.value.command_failure == ("writer_copy", expected_outcome, None)
|
||||
|
||||
|
||||
def test_prepare_reports_only_allowlisted_command_and_stderr_conditions(monkeypatch, tmp_path):
|
||||
def failed_prepare(*_args, **kwargs):
|
||||
kwargs["on_phase"]("old_release")
|
||||
raise module.VolumeTransitionError(
|
||||
"controller_volume_command_failed",
|
||||
command_failure=("writer_copy", "exit_1", "rootfs_read_only"),
|
||||
cleanup_failure=("writer_cleanup", "exit_2", "permission_denied"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module, "prepare_promotion", failed_prepare)
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"preparer", "prepare", "--stage-root", str(tmp_path / "stage"),
|
||||
"--source-revision", "d" * 40, "--source-tree", "e" * 40,
|
||||
"--controller-version", "2.2.0", "--image-id", IMAGE_ID,
|
||||
])
|
||||
with pytest.raises(SystemExit) as failure:
|
||||
module.main()
|
||||
|
||||
assert str(failure.value) == (
|
||||
"controller protected Hub volume transition failed phase=old_release "
|
||||
"code=controller_volume_command_failed "
|
||||
"diagnostic=writer_copy_exit_1_rootfs_read_only "
|
||||
"cleanupDiagnostic=writer_cleanup_exit_2_permission_denied"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
("docker cp /private/file", "exit_1", None),
|
||||
("writer_copy", "exit_1", "TOKEN=fixture-secret"),
|
||||
("writer_copy", "exit_999", None),
|
||||
("writer_copy", "exit_1", "unknown_condition"),
|
||||
["writer_copy", "exit_1", None],
|
||||
])
|
||||
def test_command_diagnostic_rejects_unreviewed_fields(value):
|
||||
assert module._safe_command_diagnostic(value) is None
|
||||
|
||||
|
||||
def test_release_writer_cleanup_failure_preserves_primary_copy_failure(monkeypatch):
|
||||
partial, expected = _release_with_config()
|
||||
monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: partial)
|
||||
monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(module, "_remove_stale_writer", lambda _image_id: None)
|
||||
monkeypatch.setattr(module, "_inspect_writer", lambda *_args, **_kwargs: {})
|
||||
calls = []
|
||||
writer_id = "f" * 64
|
||||
|
||||
def run(arguments, *, diagnostic_action=None, **_kwargs):
|
||||
calls.append(diagnostic_action)
|
||||
if diagnostic_action == "writer_create":
|
||||
return writer_id.encode()
|
||||
if diagnostic_action == "writer_wait":
|
||||
return b"0"
|
||||
if diagnostic_action == "writer_copy":
|
||||
raise module.VolumeTransitionError(
|
||||
"controller_volume_command_failed",
|
||||
command_failure=("writer_copy", "exit_1", "rootfs_read_only"),
|
||||
)
|
||||
if diagnostic_action == "writer_cleanup":
|
||||
raise module.VolumeTransitionError(
|
||||
"controller_volume_command_failed",
|
||||
command_failure=("writer_cleanup", "exit_2", "permission_denied"),
|
||||
)
|
||||
return b""
|
||||
|
||||
monkeypatch.setattr(module, "_run", run)
|
||||
with pytest.raises(module.VolumeTransitionError) as failure:
|
||||
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
||||
|
||||
assert failure.value.command_failure == (
|
||||
"writer_copy", "exit_1", "rootfs_read_only",
|
||||
)
|
||||
assert failure.value.cleanup_failure == (
|
||||
"writer_cleanup", "exit_2", "permission_denied",
|
||||
)
|
||||
assert calls[-2:] == ["writer_copy", "writer_cleanup"]
|
||||
|
||||
|
||||
def writer_inventory(inherited_labels):
|
||||
identifier = "c" * 64
|
||||
spec = module._writer_spec(IMAGE_ID, REVISION, kind="config")
|
||||
@@ -542,6 +779,90 @@ def test_compose_accepts_empty_bind_normalization_and_canonicalizes_both_managed
|
||||
}
|
||||
|
||||
|
||||
def test_compose_normalizes_all_observed_live_generated_bind_defaults(compose):
|
||||
baseline, _calls = compose
|
||||
volumes = baseline["services"][module.SERVICE]["volumes"]
|
||||
for mount in volumes:
|
||||
mount["bind"] = {"create_host_path": True}
|
||||
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
||||
graph = next(mount for mount in rendered["services"][module.SERVICE]["volumes"]
|
||||
if mount["target"] == module.DOCKER_ROOT.as_posix())
|
||||
assert graph == {
|
||||
"type": "bind", "source": module.DOCKER_ROOT.as_posix(),
|
||||
"target": module.DOCKER_ROOT.as_posix(), "read_only": True,
|
||||
}
|
||||
module._bound_model(json.dumps(rendered).encode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bind", [
|
||||
{"create_host_path": 1}, {"create_host_path": "true"},
|
||||
{"create_host_path": False},
|
||||
{"create_host_path": True, "propagation": "rslave"},
|
||||
{"create_host_path": True, "unknown": True},
|
||||
])
|
||||
def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind):
|
||||
baseline, _calls = compose
|
||||
graph = next(mount for mount in baseline["services"][module.SERVICE]["volumes"]
|
||||
if mount["target"] == module.DOCKER_ROOT.as_posix())
|
||||
graph["bind"] = bind
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET])
|
||||
@pytest.mark.parametrize("private", [False, True])
|
||||
def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts(
|
||||
compose, target, private
|
||||
):
|
||||
baseline, _calls = compose
|
||||
volumes = baseline["services"][module.SERVICE]["volumes"]
|
||||
bind = {"create_host_path": True}
|
||||
if private:
|
||||
bind["propagation"] = "rprivate"
|
||||
if target == module.EXECUTABLE_TARGET:
|
||||
mount = next(item for item in volumes if item["target"] == target)
|
||||
mount["bind"] = bind
|
||||
else:
|
||||
volumes.append({
|
||||
"type": "bind", "source": module._expected_config_path().as_posix(),
|
||||
"target": target, "read_only": True, "bind": bind,
|
||||
})
|
||||
|
||||
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
||||
managed = [mount for mount in rendered["services"][module.SERVICE]["volumes"]
|
||||
if mount["target"] in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}]
|
||||
assert len(managed) == 2
|
||||
assert all(mount["read_only"] is True for mount in managed)
|
||||
assert all(mount["bind"] == {"propagation": "rprivate"} for mount in managed)
|
||||
assert rendered["services"]["runner"] == baseline["services"]["runner"]
|
||||
# Only captured source metadata is normalized. Activation stays canonical.
|
||||
module._bound_model(json.dumps(rendered).encode())
|
||||
next(mount for mount in managed if mount["target"] == target)["bind"]["create_host_path"] = True
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module._bound_model(json.dumps(rendered).encode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET])
|
||||
@pytest.mark.parametrize("bind", [
|
||||
{"create_host_path": 1},
|
||||
{"create_host_path": "true"},
|
||||
{"create_host_path": True, "propagation": "rslave"},
|
||||
{"create_host_path": True, "selinux": "z"},
|
||||
])
|
||||
def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, target, bind):
|
||||
baseline, _calls = compose
|
||||
volumes = baseline["services"][module.SERVICE]["volumes"]
|
||||
if target == module.EXECUTABLE_TARGET:
|
||||
next(mount for mount in volumes if mount["target"] == target)["bind"] = bind
|
||||
else:
|
||||
volumes.append({
|
||||
"type": "bind", "source": module._expected_config_path().as_posix(),
|
||||
"target": target, "read_only": True, "bind": bind,
|
||||
})
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "bind"),
|
||||
[
|
||||
@@ -869,6 +1190,117 @@ def _forge_secret_entries():
|
||||
}
|
||||
|
||||
|
||||
def _release_with_config(*, forge_secrets=False):
|
||||
release = {
|
||||
name: (f'fixed:{name}\n'.encode(), 0o644)
|
||||
for name in module.RELEASE_FILES
|
||||
}
|
||||
manifest = {
|
||||
'apiVersion': 'itworx.controller-artifact/v1',
|
||||
'controllerVersion': '2.1.14',
|
||||
'sourceRepository': 'jens/unraid-appops-gateway',
|
||||
'sourceRevision': REVISION,
|
||||
'sourceTree': 'c' * 40,
|
||||
'artifactSha256': module._artifact_digest(release),
|
||||
'builtAt': '2026-10-04T00:00:00Z',
|
||||
}
|
||||
release['controller-release.json'] = (json.dumps(manifest).encode(), 0o644)
|
||||
config = {'broker-token': (b'fixture-token', 0o600)}
|
||||
directories = {'': 0o755}
|
||||
if forge_secrets:
|
||||
config.update(_forge_secret_entries())
|
||||
directories[module.FORGE_SECRET_DIRECTORY] = 0o700
|
||||
return (release, {'': 0o755}), (release | config, directories)
|
||||
|
||||
|
||||
def _fake_release_writer(monkeypatch, state):
|
||||
writer_id = 'f' * 64
|
||||
calls = []
|
||||
|
||||
def run(arguments, *, data=None, **_kwargs):
|
||||
calls.append(arguments)
|
||||
if arguments[:2] == ['docker', 'create']:
|
||||
return writer_id.encode()
|
||||
if arguments[:2] == ['docker', 'wait']:
|
||||
return b'0'
|
||||
if arguments[:2] == ['docker', 'cp']:
|
||||
state['tree'] = module._read_tar_tree(data)
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode='r:') as archive:
|
||||
archive_mode = '-a' in arguments
|
||||
state['owners'] = {
|
||||
member.name: ((0, 0) if archive_mode else (member.uid, member.gid))
|
||||
for member in archive.getmembers() if member.isfile()
|
||||
}
|
||||
return b''
|
||||
|
||||
monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None)
|
||||
monkeypatch.setattr(module, '_remove_stale_writer', lambda _image_id: None)
|
||||
monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {})
|
||||
monkeypatch.setattr(module, '_run', run)
|
||||
return calls
|
||||
|
||||
|
||||
def test_existing_exact_release_subset_is_augmented_and_read_back(monkeypatch):
|
||||
old, expected = _release_with_config()
|
||||
state = {'tree': old}
|
||||
monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: state['tree'])
|
||||
calls = _fake_release_writer(monkeypatch, state)
|
||||
|
||||
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
||||
|
||||
assert state['tree'] == expected
|
||||
assert len([call for call in calls if call[:2] == ['docker', 'create']]) == 1
|
||||
assert len([call for call in calls if call[:2] == ['docker', 'cp']]) == 1
|
||||
|
||||
|
||||
def test_divergent_existing_release_refuses_before_writer(monkeypatch):
|
||||
old, expected = _release_with_config()
|
||||
damaged = dict(old[0])
|
||||
damaged['status_server.py'] = (b'tampered', 0o644)
|
||||
monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: (damaged, old[1]))
|
||||
calls = _fake_release_writer(monkeypatch, {'tree': None})
|
||||
|
||||
with pytest.raises(module.VolumeTransitionError, match='release_collision'):
|
||||
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
||||
assert not any(call[:2] == ['docker', 'create'] for call in calls)
|
||||
|
||||
|
||||
def test_complete_existing_release_uses_no_writer(monkeypatch):
|
||||
_old, expected = _release_with_config()
|
||||
monkeypatch.setattr(module, '_read_release_leaf', lambda _revision: expected)
|
||||
calls = _fake_release_writer(monkeypatch, {'tree': None})
|
||||
|
||||
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
||||
|
||||
assert not any(call[:2] == ['docker', 'create'] for call in calls)
|
||||
|
||||
|
||||
def test_release_copy_preserves_fixed_forge_file_owners_on_readback(monkeypatch):
|
||||
_old, expected = _release_with_config(forge_secrets=True)
|
||||
state = {'tree': None, 'owners': {}}
|
||||
|
||||
def read_leaf(_revision):
|
||||
if state['tree'] is not None:
|
||||
for name, owner in module.FORGE_SECRET_OWNERS.items():
|
||||
if state['owners'].get(name) != owner:
|
||||
raise module.VolumeTransitionError('controller_volume_tree_file_unsafe')
|
||||
return state['tree']
|
||||
|
||||
monkeypatch.setattr(module, '_read_release_leaf', read_leaf)
|
||||
calls = _fake_release_writer(monkeypatch, state)
|
||||
|
||||
module._install_release_leaf(REVISION, expected, IMAGE_ID)
|
||||
|
||||
assert state['tree'] == expected
|
||||
assert all(state['owners'][name] == owner
|
||||
for name, owner in module.FORGE_SECRET_OWNERS.items())
|
||||
assert len([call for call in calls if call[:2] == ['docker', 'cp']]) == 1
|
||||
assert next(call for call in calls if call[:2] == ['docker', 'cp']) == [
|
||||
'docker', 'cp', '-',
|
||||
f"{'f' * 64}:{module.TARGET}/{module.ARTIFACTS}/{REVISION}",
|
||||
]
|
||||
|
||||
|
||||
def test_complete_forge_secret_bundle_has_exact_export_modes_and_owners():
|
||||
entries = {
|
||||
'projects.json': (b'{}', 0o600),
|
||||
@@ -931,7 +1363,7 @@ def test_forge_secret_tar_owner_drift_is_rejected():
|
||||
module._read_tar_tree(output.getvalue())
|
||||
|
||||
|
||||
def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypatch):
|
||||
def test_config_writer_uses_tar_ownership_without_copy_archive_mode(monkeypatch):
|
||||
tree = (
|
||||
{
|
||||
'projects.json': (b'{}', 0o600),
|
||||
@@ -942,8 +1374,10 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat
|
||||
{'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700},
|
||||
)
|
||||
writer_id = 'f' * 64
|
||||
expected_archive = module._release_tar_bytes('a' * 40, tree)
|
||||
commands = []
|
||||
copy_payloads = []
|
||||
copy_owners = {}
|
||||
|
||||
def run(arguments, *, data=None, **_kwargs):
|
||||
commands.append(arguments)
|
||||
@@ -953,11 +1387,17 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat
|
||||
return b'0'
|
||||
if arguments[:2] == ['docker', 'cp']:
|
||||
copy_payloads.append(data)
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode='r:') as archive:
|
||||
archive_mode = '-a' in arguments
|
||||
copy_owners.update({
|
||||
member.name: ((0, 0) if archive_mode else (member.uid, member.gid))
|
||||
for member in archive.getmembers() if member.isfile()
|
||||
})
|
||||
return b''
|
||||
|
||||
monkeypatch.setattr(module, '_read_partial_config_leaf', lambda: None)
|
||||
monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None)
|
||||
monkeypatch.setattr(module, '_release_tar_bytes', lambda *_args, **_kwargs: b'archive')
|
||||
monkeypatch.setattr(module, '_release_tar_bytes', lambda *_args, **_kwargs: expected_archive)
|
||||
monkeypatch.setattr(module, '_remove_stale_writer', lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(module, '_writer_spec', lambda *_args, **_kwargs: ['docker', 'create'])
|
||||
monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {})
|
||||
@@ -967,9 +1407,11 @@ def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypat
|
||||
module._install_config_tree(tree, IMAGE_ID)
|
||||
|
||||
assert [command for command in commands if command[:2] == ['docker', 'cp']] == [[
|
||||
'docker', 'cp', '-a', '-', f'{writer_id}:{module.TARGET}/{module.CONFIG_DIR}',
|
||||
'docker', 'cp', '-', f'{writer_id}:{module.TARGET}/{module.CONFIG_DIR}',
|
||||
]]
|
||||
assert copy_payloads == [b'archive']
|
||||
assert copy_payloads == [expected_archive]
|
||||
assert all(copy_owners[name] == owner
|
||||
for name, owner in module.FORGE_SECRET_OWNERS.items())
|
||||
|
||||
|
||||
def test_partial_forge_bundle_fails_during_promotion_preparation_before_stop(
|
||||
@@ -1215,6 +1657,7 @@ def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind):
|
||||
assert module.WRITER_NAME in command
|
||||
assert IMAGE_ID in command
|
||||
assert "--network" in command and command[command.index("--network") + 1] == "none"
|
||||
assert command[command.index("--user") + 1] == "0:0"
|
||||
assert "--read-only" in command and "--cap-drop" in command
|
||||
assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command
|
||||
assert "--mount" in command
|
||||
@@ -1223,6 +1666,21 @@ def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind):
|
||||
assert not any(item.startswith(("/mnt/", "/var/lib/")) for item in command)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["writer_copy", "config_writer_copy"])
|
||||
def test_copy_diagnostic_classifies_missing_passwd_user_without_retaining_user_value(action):
|
||||
stderr = (b'Error response from daemon: getent unable to find entry '
|
||||
b'"fixture-user-name" in passwd database')
|
||||
|
||||
assert module._command_stderr_condition(stderr, action, max_bytes=1024) == (
|
||||
"passwd_user_lookup_failed"
|
||||
)
|
||||
diagnostic = module._safe_command_diagnostic(
|
||||
(action, "exit_1", "passwd_user_lookup_failed")
|
||||
)
|
||||
assert diagnostic == f"{action}_exit_1_passwd_user_lookup_failed"
|
||||
assert "fixture-user-name" not in diagnostic
|
||||
|
||||
|
||||
def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
|
||||
baseline, calls = compose
|
||||
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
@@ -1307,9 +1765,44 @@ def test_installer_release_order_matches_renderer_contract():
|
||||
renderer = importlib.util.module_from_spec(renderer_spec)
|
||||
renderer_spec.loader.exec_module(renderer)
|
||||
assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
|
||||
assert len(renderer._ARTIFACT_FILES) == len(set(renderer._ARTIFACT_FILES))
|
||||
assert module.RELEASE_FILES[:3] == (
|
||||
"controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py"
|
||||
)
|
||||
candidate_helper = "prepare_mcp_hub_candidate.py"
|
||||
assert candidate_helper in module.RELEASE_FILES
|
||||
assert candidate_helper in renderer._ARTIFACT_FILES
|
||||
assert module.RELEASE_FILES.count(candidate_helper) == 1
|
||||
previous_files = module.LEGACY_RELEASE_FILES_PRE_CANDIDATE_HELPER
|
||||
assert len(previous_files) == 20
|
||||
previous_with_helper = module.LEGACY_RELEASE_FILES_WITH_CANDIDATE_HELPER
|
||||
assert len(previous_with_helper) == 21
|
||||
assert previous_with_helper == tuple(
|
||||
[*previous_files[:previous_files.index("status_server.py") + 1], candidate_helper,
|
||||
*previous_files[previous_files.index("status_server.py") + 1:]]
|
||||
)
|
||||
assert module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT == HISTORICAL_CONFIG_ROOT_RELEASE_FILES
|
||||
assert module._KNOWN_RELEASE_FILE_SETS == (
|
||||
module.RELEASE_FILES,
|
||||
module.LEGACY_RELEASE_FILES_PRE_COMPONENT,
|
||||
module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER,
|
||||
module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER,
|
||||
module.LEGACY_RELEASE_FILES_BEFORE_FORGE,
|
||||
previous_files,
|
||||
previous_with_helper,
|
||||
HISTORICAL_CONFIG_ROOT_RELEASE_FILES,
|
||||
module.LEGACY_RELEASE_FILES_2_1_13,
|
||||
module.LEGACY_RELEASE_FILES_2_1_5,
|
||||
)
|
||||
assert all(
|
||||
candidate_helper not in legacy
|
||||
for legacy in (
|
||||
previous_files,
|
||||
module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT,
|
||||
module.LEGACY_RELEASE_FILES_2_1_13,
|
||||
module.LEGACY_RELEASE_FILES_2_1_5,
|
||||
)
|
||||
)
|
||||
assert module.RELEASE_FILES[module.RELEASE_FILES.index("forge_first_install.py") + 1] == (
|
||||
"forge_database_credentials.py"
|
||||
)
|
||||
@@ -1319,7 +1812,7 @@ def test_installer_release_order_matches_renderer_contract():
|
||||
)
|
||||
assert module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER == tuple(
|
||||
name for name in module.RELEASE_FILES
|
||||
if name not in {"forge_backup_paths.py", "forge_secret_bundle.py"}
|
||||
if name not in {"forge_backup_paths.py", "forge_secret_bundle.py", "immutable_service_promotion.py", "registered_legacy_adoption.py", "registered_legacy_transport.py", "registered_build_context.py"}
|
||||
)
|
||||
assert module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER == tuple(
|
||||
name for name in module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER
|
||||
@@ -1339,6 +1832,106 @@ def test_installer_release_order_matches_renderer_contract():
|
||||
]
|
||||
|
||||
|
||||
def test_pre_candidate_20_file_manifest_remains_accepted_and_current_digest_requires_helper():
|
||||
files = HISTORICAL_PRE_CANDIDATE_RELEASE_FILES
|
||||
assert module.LEGACY_RELEASE_FILES_PRE_CANDIDATE_HELPER == files
|
||||
entries = {name: (f"pre-candidate:{name}".encode(), 0o644) for name in files}
|
||||
manifest = {
|
||||
"apiVersion": "itworx.controller-artifact/v1",
|
||||
"controllerVersion": "2.1.14",
|
||||
"sourceRepository": "jens/unraid-appops-gateway",
|
||||
"sourceRevision": "b" * 40,
|
||||
"sourceTree": "c" * 40,
|
||||
"artifactSha256": module._artifact_digest(entries, files),
|
||||
"builtAt": "2026-10-02T00:00:00.000Z",
|
||||
}
|
||||
entries["controller-release.json"] = (
|
||||
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
|
||||
)
|
||||
|
||||
identity = module._release_identity(entries)
|
||||
assert identity["artifactSha256"] == manifest["artifactSha256"]
|
||||
assert module._matching_release_files(entries, manifest["artifactSha256"]) == files
|
||||
|
||||
current_entries = {name: (f"current:{name}".encode(), 0o644) for name in module.RELEASE_FILES}
|
||||
assert module._artifact_digest(current_entries) == module._artifact_digest(
|
||||
current_entries, module.RELEASE_FILES
|
||||
)
|
||||
del current_entries["prepare_mcp_hub_candidate.py"]
|
||||
with pytest.raises(module.VolumeTransitionError, match="release_incomplete"):
|
||||
module._artifact_digest(current_entries)
|
||||
|
||||
|
||||
def test_previous_21_file_helper_manifest_remains_accepted():
|
||||
files = HISTORICAL_CANDIDATE_HELPER_RELEASE_FILES
|
||||
assert module.LEGACY_RELEASE_FILES_WITH_CANDIDATE_HELPER == files
|
||||
assert len(files) == 21
|
||||
assert files.count("prepare_mcp_hub_candidate.py") == 1
|
||||
entries = {name: (f"previous-helper:{name}".encode(), 0o644) for name in files}
|
||||
manifest = {
|
||||
"apiVersion": "itworx.controller-artifact/v1",
|
||||
"controllerVersion": "2.1.14",
|
||||
"sourceRepository": "jens/unraid-appops-gateway",
|
||||
"sourceRevision": "d" * 40,
|
||||
"sourceTree": "e" * 40,
|
||||
"artifactSha256": module._artifact_digest(entries, files),
|
||||
"builtAt": "2026-10-02T00:00:00.000Z",
|
||||
}
|
||||
entries["controller-release.json"] = (
|
||||
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
|
||||
)
|
||||
|
||||
identity = module._release_identity(entries)
|
||||
assert identity["artifactSha256"] == manifest["artifactSha256"]
|
||||
assert module._matching_release_files(entries, manifest["artifactSha256"]) == files
|
||||
|
||||
|
||||
def test_candidate_preparation_helper_is_staged_archived_and_digest_bound(tmp_path):
|
||||
if os.name == "nt":
|
||||
pytest.skip("release staging requires POSIX descriptor-based file checks")
|
||||
helper_name = "prepare_mcp_hub_candidate.py"
|
||||
helper_source = ROOT / "ops" / "gitea-deploy-broker" / helper_name
|
||||
helper_bytes = helper_source.read_bytes()
|
||||
stage_root = tmp_path / "source"
|
||||
ops = stage_root / "ops" / "gitea-deploy-broker"
|
||||
scripts = stage_root / "scripts"
|
||||
ops.mkdir(parents=True, mode=0o700)
|
||||
scripts.mkdir(mode=0o700)
|
||||
for name in module.RELEASE_FILES:
|
||||
folder = scripts if name == "run_repopulse_adoption.py" else ops
|
||||
contents = helper_bytes if name == helper_name else f"source:{name}\n".encode()
|
||||
(folder / name).write_bytes(contents)
|
||||
staged = module._stage_source_files(stage_root)
|
||||
assert staged[helper_name] == (helper_bytes, 0o644)
|
||||
|
||||
revision = "a" * 40
|
||||
tree = "b" * 40
|
||||
entries, directories = module._prepare_merged_tree(
|
||||
stage_root, ({}, {"": 0o755}), revision, tree, "2.1.14"
|
||||
)
|
||||
artifact_digest = module._release_identity(entries)["artifactSha256"]
|
||||
assert artifact_digest == module._artifact_digest(entries)
|
||||
archive = module._release_tar_bytes(revision, (entries, directories))
|
||||
extracted, _ = module._read_tar_tree(archive)
|
||||
assert extracted[helper_name][0] == helper_bytes
|
||||
assert module._release_identity(extracted)["artifactSha256"] == artifact_digest
|
||||
|
||||
tampered = dict(extracted)
|
||||
tampered[helper_name] = (helper_bytes + b"# altered after manifest\n", 0o644)
|
||||
with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"):
|
||||
module._release_identity(tampered)
|
||||
|
||||
|
||||
def test_installer_explicit_archive_and_install_contract_includes_candidate_helper():
|
||||
source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
|
||||
archive_paths = source.split("source_paths=(", 1)[1].split("\n)", 1)[0]
|
||||
installed_modules = source.split("module_files=(", 1)[1].split("\n)", 1)[0]
|
||||
assert "ops/gitea-deploy-broker/prepare_mcp_hub_candidate.py" in archive_paths
|
||||
assert "prepare_mcp_hub_candidate.py" in installed_modules
|
||||
powershell = (ROOT / "scripts/upgrade_deployment_controller.ps1").read_text(encoding="utf-8")
|
||||
assert "ops/gitea-deploy-broker" in powershell.split("& git archive", 1)[1].splitlines()[0]
|
||||
|
||||
|
||||
def test_current_release_digest_binds_the_role_password_helper():
|
||||
entries = {name: (f"source:{name}".encode(), 0o644) for name in module.RELEASE_FILES}
|
||||
expected = module._artifact_digest(entries)
|
||||
@@ -1970,3 +2563,75 @@ def test_current_e854_crlf_exception_integrates_with_git_source_verification(mon
|
||||
git_outputs[other_path] = b"tampered protected Git source"
|
||||
with pytest.raises(module.VolumeTransitionError, match="source_mismatch"):
|
||||
module._verify_git_source_identity(entries, identity, controller_id=controller_id)
|
||||
|
||||
|
||||
def _current_controller_with_graph_mount():
|
||||
return {
|
||||
"Id": "c" * 64, "Image": IMAGE_ID, "Name": "/gitea-deploy-control",
|
||||
"State": {"Running": True},
|
||||
"Mounts": [
|
||||
{"Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(),
|
||||
"Destination": module.EXECUTABLE_TARGET, "RW": False, "Propagation": "rslave"},
|
||||
{"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker",
|
||||
"RW": False, "Propagation": "rslave"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_current_controller_accepts_only_fixed_readonly_docker_root_ancestor(monkeypatch):
|
||||
item = _current_controller_with_graph_mount()
|
||||
monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: json.dumps([item]).encode())
|
||||
assert module._controller_image_inspect(IMAGE_ID) == item
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["writable", "redirected_target", "child_source", "parent_source", "volume_type", "named_volume_alias", "duplicate_graph", "shared_propagation", "private_propagation"])
|
||||
def test_current_controller_rejects_other_docker_root_appdata_aliases(monkeypatch, damage):
|
||||
item = _current_controller_with_graph_mount()
|
||||
graph = item["Mounts"][1]
|
||||
if damage == "writable":
|
||||
graph["RW"] = True
|
||||
elif damage == "redirected_target":
|
||||
graph["Destination"] = "/graph-alias"
|
||||
elif damage == "child_source":
|
||||
graph["Source"] = "/var/lib/docker/volumes"
|
||||
elif damage == "parent_source":
|
||||
graph["Source"] = "/var/lib"
|
||||
elif damage == "volume_type":
|
||||
graph["Type"] = "volume"
|
||||
elif damage == "named_volume_alias":
|
||||
graph["Name"] = module.MANAGED_APPDATA_VOLUME
|
||||
elif damage == "duplicate_graph":
|
||||
item["Mounts"].append(dict(graph))
|
||||
elif damage == "shared_propagation":
|
||||
graph["Propagation"] = "rshared"
|
||||
else:
|
||||
graph["Propagation"] = "rprivate"
|
||||
monkeypatch.setattr(module, "_run", lambda *_args, **_kwargs: json.dumps([item]).encode())
|
||||
with pytest.raises(module.VolumeTransitionError, match="managed_appdata_mount_alias"):
|
||||
module._controller_image_inspect(IMAGE_ID)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("populated", [False, True])
|
||||
def test_config_snapshot_preserves_fixed_appops_legacy_intent_directory(populated):
|
||||
entries = {name: (b"{}", 0o600) for name in ("projects.json", "broker-token", "gitea-validation.json", "gitea-validation-token")}
|
||||
if populated:
|
||||
entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b'{"schema":"itworx.appops-legacy-upgrade-intent/v1"}', 0o600)
|
||||
directories = {"": 0o755, "appops-legacy-upgrade": 0o700}
|
||||
assert module._config_tree(entries, directories) == (entries, directories)
|
||||
assert module._read_tar_tree(module._release_tar_bytes(REVISION, (entries, directories))) == (entries, directories)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["unknown_file", "nested_directory", "public_directory", "public_file"])
|
||||
def test_config_snapshot_keeps_appops_legacy_state_contract_closed(damage):
|
||||
entries = {name: (b"{}", 0o600) for name in ("projects.json", "broker-token", "gitea-validation.json", "gitea-validation-token")}
|
||||
directories = {"": 0o755, "appops-legacy-upgrade": 0o700}
|
||||
if damage == "unknown_file":
|
||||
entries["appops-legacy-upgrade/shell.py"] = (b"unexpected", 0o600)
|
||||
elif damage == "nested_directory":
|
||||
directories["appops-legacy-upgrade/nested"] = 0o700
|
||||
elif damage == "public_directory":
|
||||
directories["appops-legacy-upgrade"] = 0o755
|
||||
else:
|
||||
entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b"{}", 0o644)
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module._config_tree(entries, directories)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Runtime readiness is independent of storage for enrolling a new target."""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
from email.message import Message
|
||||
|
||||
import pytest
|
||||
from test_deploy_broker import BROKER_ROOT, _load_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(monkeypatch, tmp_path):
|
||||
module = _load_module("appops_runtime_capabilities_test", BROKER_ROOT / "status_server.py")
|
||||
repository_root = tmp_path / "repos"
|
||||
repository_root.mkdir()
|
||||
secret_path = tmp_path / "bootstrap"
|
||||
secret = b"runtime-capabilities-test-" + b"x" * 48
|
||||
secret_path.write_bytes(secret)
|
||||
secret_path.chmod(0o600)
|
||||
monkeypatch.setattr(module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path)
|
||||
monkeypatch.setattr(module, "APPOPS_NONCE_ROOT", tmp_path / "nonces")
|
||||
monkeypatch.setattr(module, "REPOSITORY_ROOT", repository_root)
|
||||
monkeypatch.setattr(module, "_controller_release", lambda: {"revision": "a" * 40})
|
||||
monkeypatch.setattr(module, "_projects_registry_is_safe", lambda: True)
|
||||
monkeypatch.setattr(module, "_load_projects", lambda: {"owner/existing": {}})
|
||||
monkeypatch.setattr(module, "_refresh_appdata_root_selection", lambda: None)
|
||||
monkeypatch.setattr(module, "trusted_appdata_roots_readiness", lambda: [
|
||||
{"root": "/srv/example/appdata", "status": "unavailable", "reason": "unsafe_permissions"},
|
||||
])
|
||||
monkeypatch.setattr(module, "managed_storage_readiness", lambda: {"status": "needs_initialization"})
|
||||
server = module.BoundedThreadingHTTPServer(("127.0.0.1", 0), module.Handler)
|
||||
serving = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
serving.start()
|
||||
counter = 0
|
||||
|
||||
def request(*, authenticated=True, replay_headers=None):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
body = b"{}"
|
||||
path = "/v1/appops/enrollments/capabilities"
|
||||
timestamp = str(int(module.time.time()))
|
||||
nonce = f"{counter:032x}"
|
||||
headers = replay_headers or {
|
||||
"Content-Type": "application/json",
|
||||
"X-ITWorx-Deployment-Timestamp": timestamp,
|
||||
"X-ITWorx-Deployment-Nonce": nonce,
|
||||
"X-ITWorx-Deployment-Signature": module._request_signature(
|
||||
secret, method="POST", path=path, timestamp=timestamp, nonce=nonce, raw_body=body,
|
||||
) if authenticated else "0" * 64,
|
||||
}
|
||||
client = http.client.HTTPConnection(*server.server_address, timeout=2)
|
||||
client.request("POST", path, body=body, headers=headers)
|
||||
response = client.getresponse()
|
||||
payload = json.loads(response.read())
|
||||
client.close()
|
||||
assert secret.decode() not in json.dumps(payload)
|
||||
return response.status, payload, headers
|
||||
|
||||
yield module, request
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
serving.join(timeout=2)
|
||||
|
||||
|
||||
def test_registered_runtime_ready_without_new_enrollment_storage(controller):
|
||||
_, request = controller
|
||||
status, payload, headers = request()
|
||||
assert status == 200
|
||||
assert payload["status"] == "ready"
|
||||
assert payload["registry"] == {"status": "ready", "registeredProjectCount": 1}
|
||||
assert payload["enrollmentReadiness"] == {
|
||||
"status": "unavailable", "reason": "trusted_appdata_roots_unavailable",
|
||||
}
|
||||
for name in ("deploymentApply", "deploymentVerify", "rollback", "exactTargetPolicy", "hmacV2", "replayProtection"):
|
||||
assert payload["features"][name] is True
|
||||
for name in ("enrollment", "targetRegistration", "runtimeSecretSealing"):
|
||||
assert payload["features"][name] is False
|
||||
assert request(replay_headers=headers)[0] == 403
|
||||
assert request(authenticated=False)[0] == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unavailable", ["registry", "registry_content", "artifact", "repository"])
|
||||
def test_runtime_prerequisites_remain_fail_closed(controller, monkeypatch, tmp_path, unavailable):
|
||||
module, request = controller
|
||||
if unavailable == "registry":
|
||||
monkeypatch.setattr(module, "_projects_registry_is_safe", lambda: False)
|
||||
elif unavailable == "registry_content":
|
||||
def invalid_registry():
|
||||
raise ValueError("invalid registry")
|
||||
monkeypatch.setattr(module, "_load_projects", invalid_registry)
|
||||
elif unavailable == "artifact":
|
||||
monkeypatch.setattr(module, "_controller_release", lambda: None)
|
||||
else:
|
||||
monkeypatch.setattr(module, "REPOSITORY_ROOT", tmp_path / "missing")
|
||||
status, payload, _ = request()
|
||||
assert status == 503
|
||||
assert payload["reason"] == "controller_runtime_prerequisite_unavailable"
|
||||
for name in ("deploymentApply", "deploymentVerify", "rollback", "exactTargetPolicy", "enrollment", "targetRegistration"):
|
||||
assert payload["features"][name] is False
|
||||
|
||||
|
||||
def test_enrollment_recovers_when_safe_root_becomes_ready(controller, monkeypatch):
|
||||
module, request = controller
|
||||
assert request()[1]["features"]["enrollment"] is False
|
||||
monkeypatch.setattr(module, "trusted_appdata_roots_readiness", lambda: [{"status": "ready"}])
|
||||
status, payload, _ = request()
|
||||
assert status == 200
|
||||
assert payload["enrollmentReadiness"] == {"status": "ready", "reason": ""}
|
||||
for name in ("enrollment", "targetRegistration", "runtimeSecretSealing"):
|
||||
assert payload["features"][name] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("current,reason", [
|
||||
({"status": "blocked", "checks": [{"name": "required_push_validation", "passed": False}]},
|
||||
"controller_preflight_failed_required_push_validation"),
|
||||
({"status": "preflight_ok", "currentRevision": "b" * 40, "checks": []},
|
||||
"current_revision_changed"),
|
||||
])
|
||||
def test_self_upgrade_reports_failed_gate_before_missing_current_revision(controller, monkeypatch, current, reason):
|
||||
module, _ = controller
|
||||
monkeypatch.setattr(module, "_self_upgrade_preflight", lambda _revision: dict(current))
|
||||
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_args, **_kwargs: True)
|
||||
intent = {"repository": module.SELF_UPGRADE_REPOSITORY, "revision": "c" * 40,
|
||||
"reason": "Validated infrastructure repair", "expectedCurrentRevision": "a" * 40}
|
||||
body = json.dumps(intent).encode()
|
||||
handler = module.Handler.__new__(module.Handler)
|
||||
handler.path = module.SELF_UPGRADE_PREFLIGHT_PATH
|
||||
handler.headers = Message()
|
||||
handler.headers["Content-Length"] = str(len(body))
|
||||
handler.headers["Content-Type"] = "application/json"
|
||||
handler.rfile = io.BytesIO(body)
|
||||
handler._trusted_client = lambda: True
|
||||
responses = []
|
||||
handler._send_json = lambda status, payload: responses.append((status, json.loads(payload)))
|
||||
handler._handle_self_upgrade()
|
||||
assert responses[0][0] == 409
|
||||
assert responses[0][1]["reason"] == reason
|
||||
@@ -764,6 +764,41 @@ def test_appops_legacy_intent_accepts_only_the_reviewed_dockerfile_blob_pair(
|
||||
deploy_module._appops_validate_intent_payload(unknown_update)
|
||||
|
||||
|
||||
def test_new_database_pair_keeps_protected_full_and_runtime_intent_proofs_strict(
|
||||
deploy_module,
|
||||
):
|
||||
_repository, _metadata, _source, _main, _backup, intent = (
|
||||
_appops_rollback_intent_fixture(deploy_module, "c" * 40, "a" * 64)
|
||||
)
|
||||
|
||||
def rebind_migration_proof(value):
|
||||
proof = value["migrationProof"]
|
||||
evidence = value["rollbackSnapshot"]["appops_legacy"]
|
||||
evidence["sourceProofSha256"] = deploy_module._canonical_json_sha256(proof["source"])
|
||||
evidence["protectedMainSha256"] = deploy_module._canonical_json_sha256(proof["protectedMain"])
|
||||
evidence["migrationProofSha256"] = deploy_module._canonical_json_sha256(proof)
|
||||
|
||||
intent["migrationProof"]["source"]["databaseTree"] = (
|
||||
"21d29c1799118fdc43946c2b6fd5585ccc2c4db1"
|
||||
)
|
||||
rebind_migration_proof(intent)
|
||||
deploy_module._appops_validate_intent_payload(intent)
|
||||
|
||||
weak_protected = copy.deepcopy(intent)
|
||||
weak_protected["migrationProof"]["protectedMain"]["validationContext"] = (
|
||||
"Managed validation / partial (push)"
|
||||
)
|
||||
rebind_migration_proof(weak_protected)
|
||||
with pytest.raises(deploy_module.DeployError, match="protected-main proof is invalid"):
|
||||
deploy_module._appops_validate_intent_payload(weak_protected)
|
||||
|
||||
incomplete_runtime = copy.deepcopy(intent)
|
||||
incomplete_runtime["migrationProof"]["runtime"].pop("healthSha256")
|
||||
rebind_migration_proof(incomplete_runtime)
|
||||
with pytest.raises(deploy_module.DeployError, match="legacy runtime proof is invalid"):
|
||||
deploy_module._appops_validate_intent_payload(incomplete_runtime)
|
||||
|
||||
|
||||
def test_appops_cli_rollback_retries_after_state_write_crash_without_synthetic_receipt(
|
||||
deploy_module, monkeypatch, tmp_path, capsys
|
||||
):
|
||||
@@ -1537,6 +1572,47 @@ def test_appops_state_write_rejects_compare_and_swap_drift(deploy_module, monkey
|
||||
assert path.read_bytes() == original
|
||||
|
||||
|
||||
def test_registered_legacy_state_cas_drift_preserves_external_state_and_records_recovery(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
repository = "jens/quiz-monitor"
|
||||
request_id = "6f9619ff-8b86-4011-b42d-00c04fc964ff"
|
||||
revision = "a" * 40
|
||||
state_root = tmp_path / "state"
|
||||
state_root.mkdir()
|
||||
monkeypatch.setattr(deploy_module, "STATE_ROOT", state_root)
|
||||
monkeypatch.setattr(deploy_module, "_registered_legacy_receipt_key", lambda: b"k" * 32)
|
||||
project = {
|
||||
"slug": "quiz-monitor", "branch": "main", "compose_project": "quiz-monitor",
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"containers": ["quiz-monitor-quiz-server-1"],
|
||||
"source_dir": "/srv/example/appdata/quiz-monitor/source",
|
||||
"_registered_legacy_request_id": request_id,
|
||||
"_registered_legacy_authorization": {
|
||||
"requestId": request_id, "revision": revision,
|
||||
"expectedStateSha256": "0" * 64,
|
||||
},
|
||||
}
|
||||
path = state_root / "quiz-monitor.json"
|
||||
external_state = b'{"schema_version":3,"live_revision":"independent-update"}\n'
|
||||
path.write_bytes(external_state)
|
||||
|
||||
with pytest.raises(deploy_module.DeployError, match="compare-and-swap"):
|
||||
deploy_module.write_state(
|
||||
repository, project, revision, "failed", "deployment failed",
|
||||
expected_state_sha256="0" * 64,
|
||||
)
|
||||
|
||||
assert path.read_bytes() == external_state
|
||||
helper = deploy_module._load_registered_legacy_adoption()
|
||||
outcome = helper.status(
|
||||
{"repository": repository, "requestId": request_id},
|
||||
registry={repository: project}, receipt_key=b"k" * 32, state_root=state_root,
|
||||
)
|
||||
assert outcome["status"] == "recovery-required"
|
||||
assert outcome["reasonCode"] == "controller_state_cas_failed"
|
||||
|
||||
|
||||
def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
|
||||
"""Build the reviewed DB and Dockerfile transitions without old Git history."""
|
||||
repository = tmp_path / "reviewed-source.git"
|
||||
@@ -1601,6 +1677,7 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
|
||||
forge_seed_rows = (
|
||||
b' Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."),\n',
|
||||
b' Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."),\n',
|
||||
b' Policy(id="policy-forge-route-stage", action="forge.route.stage", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Staging the fixed disabled Forge proxy host is default-denied; voice remains blocked."),\n',
|
||||
)
|
||||
for row in forge_seed_rows:
|
||||
assert current_init.count(row) == 1
|
||||
@@ -1629,7 +1706,15 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
|
||||
old_tree = git("rev-parse", f"{old_revision}^{{tree}}")
|
||||
old_database_tree = git("rev-parse", f"{old_revision}:backend/app/db")
|
||||
|
||||
(db / "init_db.py").write_bytes(current_init)
|
||||
candidate_init = current_init
|
||||
if candidate_mutation == "previous_reviewed_seed_tree":
|
||||
route_seed = forge_seed_rows[2]
|
||||
assert candidate_init.count(route_seed) == 1
|
||||
candidate_init = candidate_init.replace(route_seed, b"", 1)
|
||||
elif candidate_mutation == "legacy_database_unchanged":
|
||||
candidate_init = old_init
|
||||
|
||||
(db / "init_db.py").write_bytes(candidate_init)
|
||||
if candidate_mutation == "extra_database_file":
|
||||
(db / "unreviewed.py").write_text("UNREVIEWED = True\n", encoding="utf-8")
|
||||
elif candidate_mutation == "models_content":
|
||||
@@ -1637,21 +1722,47 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
|
||||
elif candidate_mutation == "session_content":
|
||||
(db / "session.py").write_bytes(normalize(current_db / "session.py") + b"\n# unreviewed\n")
|
||||
elif candidate_mutation == "init_db_content":
|
||||
(db / "init_db.py").write_bytes(current_init + b"\n# unreviewed database behavior\n")
|
||||
(db / "init_db.py").write_bytes(candidate_init + b"\n# unreviewed database behavior\n")
|
||||
elif candidate_mutation == "forge_policy_changed":
|
||||
changed_init = current_init.replace(
|
||||
changed_init = candidate_init.replace(
|
||||
b'action="forge.oidc.provision"',
|
||||
b'action="forge.oidc.provision-unreviewed"',
|
||||
1,
|
||||
)
|
||||
assert changed_init != current_init
|
||||
(db / "init_db.py").write_bytes(changed_init)
|
||||
elif candidate_mutation in {
|
||||
"route_stage_manual_allowed", "route_stage_voice_allowed", "route_stage_risk_lowered",
|
||||
}:
|
||||
route_seed = forge_seed_rows[2]
|
||||
replacements = {
|
||||
"route_stage_manual_allowed": (b'manual="blocked"', b'manual="allowed"'),
|
||||
"route_stage_voice_allowed": (b'voice="blocked"', b'voice="allowed"'),
|
||||
"route_stage_risk_lowered": (b'risk="critical"', b'risk="high"'),
|
||||
}
|
||||
old_field, new_field = replacements[candidate_mutation]
|
||||
assert route_seed.count(old_field) == 1
|
||||
candidate_init = candidate_init.replace(route_seed, route_seed.replace(old_field, new_field, 1), 1)
|
||||
(db / "init_db.py").write_bytes(candidate_init)
|
||||
elif candidate_mutation == "unknown_policy_row":
|
||||
route_seed = forge_seed_rows[2]
|
||||
unknown_policy = (
|
||||
b' Policy(id="policy-forge-route-stage-unreviewed", '
|
||||
b'action="forge.route.stage.unreviewed", target="itworx-forge", '
|
||||
b'voice="blocked", manual="blocked", risk="critical", '
|
||||
b'reason="Unreviewed policy row."),\n'
|
||||
)
|
||||
assert candidate_init.count(route_seed) == 1
|
||||
candidate_init = candidate_init.replace(route_seed, route_seed + unknown_policy, 1)
|
||||
(db / "init_db.py").write_bytes(candidate_init)
|
||||
elif candidate_mutation == "database_file_mode":
|
||||
(db / "session.py").chmod(0o755)
|
||||
elif candidate_mutation == "entrypoint_content":
|
||||
entrypoint.write_bytes(entrypoint.read_bytes() + b"# unreviewed entrypoint\n")
|
||||
elif candidate_mutation not in {
|
||||
None, "dockerfile_one_byte", "dockerfile_legacy_unchanged",
|
||||
None, "previous_reviewed_seed_tree", "legacy_database_unchanged",
|
||||
"route_stage_manual_allowed", "route_stage_voice_allowed", "route_stage_risk_lowered",
|
||||
"unknown_policy_row", "dockerfile_one_byte", "dockerfile_legacy_unchanged",
|
||||
}:
|
||||
raise AssertionError("unknown fixture mutation")
|
||||
|
||||
@@ -1673,6 +1784,11 @@ def _build_reviewed_appops_seed_history(tmp_path, *, candidate_mutation=None):
|
||||
(working / "Dockerfile").write_bytes(candidate_dockerfile)
|
||||
|
||||
git("add", "--all")
|
||||
if candidate_mutation == "database_file_mode":
|
||||
# Windows does not preserve chmod's executable bit on the worktree;
|
||||
# stage the real Git mode mutation so this remains a meaningful tree
|
||||
# identity negative fixture on every platform.
|
||||
git("update-index", "--chmod=+x", "--", "backend/app/db/session.py")
|
||||
git("commit", "-m", "candidate source")
|
||||
candidate_revision = git("rev-parse", "HEAD")
|
||||
git("push", "origin", "HEAD:refs/heads/main")
|
||||
@@ -1729,7 +1845,7 @@ def test_appops_legacy_source_proof_accepts_the_exact_main_184_dockerfile_update
|
||||
assert proof == {
|
||||
"legacyRevision": history["oldRevision"],
|
||||
"legacyTree": history["oldTree"],
|
||||
"databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a",
|
||||
"databaseTree": "21d29c1799118fdc43946c2b6fd5585ccc2c4db1",
|
||||
"entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8",
|
||||
"candidateRevision": history["candidateRevision"],
|
||||
"composeBlob": "243aee2c6924775bed7706373a5777f4e619e053",
|
||||
@@ -1755,6 +1871,26 @@ def test_appops_legacy_source_proof_keeps_the_old_dockerfile_candidate_valid(
|
||||
assert proof["dockerfileBlob"] == "73e083dcfef25402665ace5a0bf07a083cf37432"
|
||||
|
||||
|
||||
def test_appops_legacy_source_proof_keeps_the_previous_reviewed_seed_tree_valid(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(
|
||||
tmp_path, candidate_mutation="previous_reviewed_seed_tree"
|
||||
)
|
||||
assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
|
||||
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
proof = deploy_module.verify_appops_legacy_source(
|
||||
history["repository"],
|
||||
_reviewed_appops_source_project(deploy_module),
|
||||
history["candidateRevision"],
|
||||
)
|
||||
|
||||
assert proof["databaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
|
||||
|
||||
def test_appops_legacy_source_proof_rejects_a_one_byte_dockerfile_change(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
):
|
||||
@@ -1772,12 +1908,59 @@ def test_appops_legacy_source_proof_rejects_a_one_byte_dockerfile_change(
|
||||
)
|
||||
|
||||
|
||||
def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair(
|
||||
deploy_module, monkeypatch, tmp_path
|
||||
def test_appops_legacy_source_bindings_remain_fixed_for_the_new_reviewed_tree(
|
||||
deploy_module, monkeypatch, tmp_path,
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(tmp_path)
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
mutations = (
|
||||
("_repository_name", "jens/other-repository"),
|
||||
("branch", "release"),
|
||||
("gitea_repository_id", 10),
|
||||
("source_dir", "/tmp/unreviewed-source"),
|
||||
("compose_project", "unreviewed-project"),
|
||||
("compose_files", ["other-compose.yml"]),
|
||||
("containers", ["OtherContainer"]),
|
||||
("verify_oci_revision", False),
|
||||
)
|
||||
for field, value in mutations:
|
||||
project = _reviewed_appops_source_project(deploy_module)
|
||||
project[field] = value
|
||||
with pytest.raises(deploy_module.DeployError, match="fixed legacy source or resolved Compose inputs"):
|
||||
deploy_module.verify_appops_legacy_source(
|
||||
history["repository"], project, history["candidateRevision"],
|
||||
)
|
||||
|
||||
for field, value in (
|
||||
("APPOPS_DATA_PATH", "/tmp/other-data"),
|
||||
("APPOPS_PUBLISH_HOST", "127.0.0.1"),
|
||||
("APPOPS_PROXY_NETWORK", "unreviewed-network"),
|
||||
):
|
||||
project = _reviewed_appops_source_project(deploy_module)
|
||||
project["environment"][field] = value
|
||||
with pytest.raises(deploy_module.DeployError, match="fixed legacy source or resolved Compose inputs"):
|
||||
deploy_module.verify_appops_legacy_source(
|
||||
history["repository"], project, history["candidateRevision"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("candidate_mutation", "expected_database_tree"),
|
||||
[
|
||||
(None, "21d29c1799118fdc43946c2b6fd5585ccc2c4db1"),
|
||||
("previous_reviewed_seed_tree", "c87c9aff5a0f0e55b9209e2f085cf1663876520a"),
|
||||
("legacy_database_unchanged", "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"),
|
||||
],
|
||||
)
|
||||
def test_appops_schema_compatibility_accepts_only_the_two_reviewed_pairs_or_same_tree(
|
||||
deploy_module, monkeypatch, tmp_path, candidate_mutation, expected_database_tree,
|
||||
):
|
||||
history = _build_reviewed_appops_seed_history(
|
||||
tmp_path, candidate_mutation=candidate_mutation,
|
||||
)
|
||||
assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
|
||||
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
assert history["candidateDatabaseTree"] == expected_database_tree
|
||||
assert history["candidateEntrypoint"] == "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8"
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
@@ -1789,7 +1972,7 @@ def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair(
|
||||
assert proof == {
|
||||
"legacyRevision": history["oldRevision"],
|
||||
"legacyTree": history["oldTree"],
|
||||
"databaseTree": "c87c9aff5a0f0e55b9209e2f085cf1663876520a",
|
||||
"databaseTree": expected_database_tree,
|
||||
"entrypointBlob": "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8",
|
||||
"candidateRevision": history["candidateRevision"],
|
||||
}
|
||||
@@ -1803,6 +1986,10 @@ def test_appops_schema_compatibility_accepts_the_exact_reviewed_seed_tree_pair(
|
||||
"session_content",
|
||||
"init_db_content",
|
||||
"forge_policy_changed",
|
||||
"unknown_policy_row",
|
||||
"route_stage_manual_allowed",
|
||||
"route_stage_voice_allowed",
|
||||
"route_stage_risk_lowered",
|
||||
"database_file_mode",
|
||||
"entrypoint_content",
|
||||
],
|
||||
@@ -1815,10 +2002,13 @@ def test_appops_seed_tree_exception_rejects_every_unreviewed_tree_or_entrypoint_
|
||||
)
|
||||
assert history["oldDatabaseTree"] == "d7f8a6188f6c6e0c69bce0e547df31d36ed84a28"
|
||||
if mutation == "entrypoint_content":
|
||||
assert history["candidateDatabaseTree"] == "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
assert history["candidateDatabaseTree"] == "21d29c1799118fdc43946c2b6fd5585ccc2c4db1"
|
||||
assert history["candidateEntrypoint"] != "4f71ff74e67d7f15e3cf6b3e79b654158dfea2f8"
|
||||
else:
|
||||
assert history["candidateDatabaseTree"] != "c87c9aff5a0f0e55b9209e2f085cf1663876520a"
|
||||
assert history["candidateDatabaseTree"] not in {
|
||||
"c87c9aff5a0f0e55b9209e2f085cf1663876520a",
|
||||
"21d29c1799118fdc43946c2b6fd5585ccc2c4db1",
|
||||
}
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_REVISION", history["oldRevision"])
|
||||
monkeypatch.setattr(deploy_module, "APPOPS_LEGACY_OLD_TREE", history["oldTree"])
|
||||
|
||||
@@ -3126,8 +3316,9 @@ def test_deployment_environment_clears_unverified_build_date(deploy_module, tmp_
|
||||
assert environment["APPOPS_BUILD_DATE"] == ""
|
||||
|
||||
|
||||
def test_deployment_environment_allows_project_build_date_override(deploy_module, tmp_path):
|
||||
def test_deployment_environment_allows_unverified_project_build_date_override(deploy_module, tmp_path):
|
||||
project = _project(tmp_path)
|
||||
project["verify_oci_revision"] = False
|
||||
project["environment"] = {"APPOPS_BUILD_DATE": "operator-configured-value"}
|
||||
repository = "jens/unraid-appops-gateway"
|
||||
revision = "a" * 40
|
||||
@@ -7573,6 +7764,8 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m
|
||||
renderer = importlib.util.module_from_spec(renderer_spec)
|
||||
renderer_spec.loader.exec_module(renderer)
|
||||
assert renderer._ARTIFACT_FILES == status_module._CONTROLLER_ARTIFACT_FILES
|
||||
candidate_helper = "prepare_mcp_hub_candidate.py"
|
||||
assert candidate_helper in status_module._CONTROLLER_ARTIFACT_FILES
|
||||
forge_first_install_index = status_module._CONTROLLER_ARTIFACT_FILES.index(
|
||||
"forge_first_install.py"
|
||||
)
|
||||
@@ -7588,6 +7781,9 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m
|
||||
else status_module.ROOT / filename
|
||||
)
|
||||
(artifact_root / filename).write_bytes(source.read_bytes())
|
||||
assert (artifact_root / candidate_helper).read_bytes() == (
|
||||
status_module.ROOT / candidate_helper
|
||||
).read_bytes()
|
||||
monkeypatch.setattr(status_module, "ROOT", artifact_root)
|
||||
monkeypatch.setattr(status_module, "CONTROLLER_RELEASE_PATH", artifact_root / "controller-release.json")
|
||||
digest = status_module._controller_artifact_digest()
|
||||
@@ -7608,7 +7804,9 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m
|
||||
monkeypatch.setattr(status_module.sys, "argv", ["status_server.py", "--check-artifact"])
|
||||
status_module.main()
|
||||
assert "validated controller artifact 2.0.0" in capsys.readouterr().out
|
||||
(artifact_root / "deploy.py").write_text("tampered", encoding="utf-8")
|
||||
(artifact_root / candidate_helper).write_text("tampered", encoding="utf-8")
|
||||
assert status_module._controller_release() is None
|
||||
(artifact_root / candidate_helper).unlink()
|
||||
assert status_module._controller_release() is None
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,21 @@ class _Broker:
|
||||
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
assert secret == "b" * 64
|
||||
return {"status": "ready", "trustedAppdataRoot": self.appdata_root}
|
||||
return {
|
||||
"status": "ready",
|
||||
"trustedAppdataRoot": self.appdata_root,
|
||||
"features": {
|
||||
"deploymentApply": True,
|
||||
"deploymentVerify": True,
|
||||
"rollback": True,
|
||||
"exactTargetPolicy": True,
|
||||
"hmacV2": True,
|
||||
"replayProtection": True,
|
||||
"enrollment": True,
|
||||
"targetRegistration": True,
|
||||
"runtimeSecretSealing": True,
|
||||
},
|
||||
}
|
||||
|
||||
def enrollment_preflight(self, enrollment, *, secret):
|
||||
assert secret == "b" * 64
|
||||
@@ -209,6 +223,30 @@ def _plane(portainer=None, broker=None, npm=None, authentik=None):
|
||||
)
|
||||
|
||||
|
||||
class _CompensatingBroker(_Broker):
|
||||
def unenroll_target(self, _target, *, secret):
|
||||
assert secret == "b" * 64
|
||||
return {"status": "rolled_back"}
|
||||
|
||||
|
||||
class _CompensatingNpm(_Npm):
|
||||
def remove_managed_proxy_host(self, *, host_id, ownership_id):
|
||||
return {"success": True, "status": "removed"}
|
||||
|
||||
|
||||
class _CompensatingAuthentik(_Authentik):
|
||||
def remove_deployment_oidc_application(self, **_kwargs):
|
||||
return {"success": True, "status": "removed"}
|
||||
|
||||
|
||||
def _compensating_plane(broker=None):
|
||||
return _plane(
|
||||
broker=broker or _CompensatingBroker(),
|
||||
npm=_CompensatingNpm(),
|
||||
authentik=_CompensatingAuthentik(),
|
||||
)
|
||||
|
||||
|
||||
def _configure(session):
|
||||
set_secret(session, "ENROLLMENT_BOOTSTRAP", "b" * 64)
|
||||
EnrollmentPlane.set_trust_config(session, _trust())
|
||||
@@ -405,8 +443,9 @@ def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp
|
||||
result = _plane(broker=AlternateRootBroker()).capabilities(session)
|
||||
|
||||
assert result["targetEnrollment"]["status"] == "unavailable"
|
||||
assert result["controller"]["status"] == "unavailable"
|
||||
assert result["controller"]["reason"] == "trusted_appdata_root_mismatch"
|
||||
assert result["controller"]["status"] == "ready"
|
||||
assert result["controller"]["reason"] == ""
|
||||
assert result["enrollmentReadiness"]["reason"] == "trusted_appdata_root_unavailable"
|
||||
|
||||
|
||||
def test_capabilities_treat_roots_as_independent_availability_not_global_selection(tmp_path):
|
||||
@@ -424,6 +463,17 @@ def test_capabilities_treat_roots_as_independent_availability_not_global_selecti
|
||||
{"root": managed_root, "status": "ready", "reason": ""},
|
||||
],
|
||||
"managedStorage": {"status": "ready", "initializationAvailable": False},
|
||||
"features": {
|
||||
"deploymentApply": True,
|
||||
"deploymentVerify": True,
|
||||
"rollback": True,
|
||||
"exactTargetPolicy": True,
|
||||
"hmacV2": True,
|
||||
"replayProtection": True,
|
||||
"enrollment": True,
|
||||
"targetRegistration": True,
|
||||
"runtimeSecretSealing": True,
|
||||
},
|
||||
}
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
@@ -454,6 +504,123 @@ def test_capabilities_treat_roots_as_independent_availability_not_global_selecti
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_capabilities_remain_available_when_selected_enrollment_root_is_unavailable(tmp_path):
|
||||
legacy_root = "/srv/example/appdata"
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
|
||||
class CoreReadyBroker(_Broker):
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
assert secret == "b" * 64
|
||||
return {
|
||||
"status": "ready",
|
||||
"reason": "",
|
||||
"availableTrustedAppdataRoots": [
|
||||
{"root": legacy_root, "status": "unavailable", "reason": "legacy_appdata_root_unavailable"},
|
||||
{"root": managed_root, "status": "unavailable", "reason": "managed_appdata_volume_missing"},
|
||||
],
|
||||
"managedStorage": {
|
||||
"status": "unavailable",
|
||||
"reason": "managed_appdata_volume_missing",
|
||||
"initializationAvailable": False,
|
||||
},
|
||||
"features": {
|
||||
"deploymentApply": True,
|
||||
"deploymentVerify": True,
|
||||
"rollback": True,
|
||||
"exactTargetPolicy": True,
|
||||
"hmacV2": True,
|
||||
"replayProtection": True,
|
||||
"enrollment": False,
|
||||
"targetRegistration": False,
|
||||
"runtimeSecretSealing": False,
|
||||
},
|
||||
"enrollmentReadiness": {
|
||||
"status": "unavailable",
|
||||
"reason": "legacy_appdata_root_unavailable",
|
||||
},
|
||||
"registry": {"status": "ready", "registeredProjectCount": 48},
|
||||
}
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
result = _plane(broker=CoreReadyBroker()).capabilities(session)
|
||||
|
||||
assert result["controller"] == {"status": "ready", "reason": ""}
|
||||
assert result["targetEnrollment"]["status"] == "unavailable"
|
||||
assert result["runtimeDeployment"]["status"] == "ready"
|
||||
assert result["rollback"]["status"] == "ready"
|
||||
assert result["verification"]["status"] == "ready"
|
||||
assert result["enrollmentReadiness"] == {
|
||||
"status": "unavailable",
|
||||
"reason": "legacy_appdata_root_unavailable",
|
||||
}
|
||||
assert result["availableTrustedAppdataRoots"] == [
|
||||
{"root": legacy_root, "status": "unavailable", "reason": "legacy_appdata_root_unavailable"},
|
||||
{"root": managed_root, "status": "unavailable", "reason": "managed_appdata_volume_missing"},
|
||||
]
|
||||
|
||||
|
||||
def test_runtime_capabilities_fail_closed_when_controller_feature_attestation_is_missing(tmp_path):
|
||||
class MissingFeaturesBroker(_Broker):
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
return {"status": "ready", "trustedAppdataRoot": self.appdata_root}
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
result = _plane(broker=MissingFeaturesBroker()).capabilities(session)
|
||||
|
||||
assert result["controller"]["status"] == "ready"
|
||||
assert result["runtimeDeployment"]["status"] == "unavailable"
|
||||
assert result["rollback"]["status"] == "unavailable"
|
||||
assert result["verification"]["status"] == "unavailable"
|
||||
assert result["targetEnrollment"]["status"] == "unavailable"
|
||||
|
||||
|
||||
def test_managed_storage_capability_preserves_only_bounded_controller_reason(tmp_path):
|
||||
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
|
||||
|
||||
class ManagedStorageUnavailableBroker(_Broker):
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
return {
|
||||
"status": "ready",
|
||||
"availableTrustedAppdataRoots": [
|
||||
{"root": "/srv/example/appdata", "status": "ready"},
|
||||
{"root": managed_root, "status": "unavailable", "reason": "managed_appdata_volume_missing"},
|
||||
],
|
||||
"managedStorage": {
|
||||
"status": "unavailable",
|
||||
"reason": "managed_appdata_volume_missing",
|
||||
"initializationAvailable": False,
|
||||
},
|
||||
"features": {
|
||||
"deploymentApply": True,
|
||||
"deploymentVerify": True,
|
||||
"rollback": True,
|
||||
"exactTargetPolicy": True,
|
||||
"hmacV2": True,
|
||||
"replayProtection": True,
|
||||
"enrollment": False,
|
||||
"targetRegistration": False,
|
||||
"runtimeSecretSealing": False,
|
||||
},
|
||||
}
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
EnrollmentPlane.set_trust_config(
|
||||
session,
|
||||
_trust().model_copy(update={
|
||||
"trusted_appdata_root": managed_root,
|
||||
"managed_appdata_backup_acknowledged": True,
|
||||
}),
|
||||
)
|
||||
result = _plane(broker=ManagedStorageUnavailableBroker()).capabilities(session)
|
||||
|
||||
assert result["managedStorage"]["status"] == "unavailable"
|
||||
assert result["managedStorage"]["reason"] == "managed_appdata_volume_missing"
|
||||
assert result["enrollmentReadiness"]["reason"] == "managed_appdata_volume_missing"
|
||||
|
||||
|
||||
def test_capabilities_fail_closed_and_hide_unrecognized_controller_roots(tmp_path):
|
||||
class MalformedRootsBroker(_Broker):
|
||||
def enrollment_capabilities(self, *, secret):
|
||||
@@ -543,6 +710,186 @@ def test_enrollment_creates_write_only_credentials_exact_policies_and_is_idempot
|
||||
assert len(session.exec(select(EnrollmentJob)).all()) == 1
|
||||
|
||||
|
||||
def test_enrollment_policy_id_collision_blocks_before_mutation_and_leaves_no_partial_policy(tmp_path):
|
||||
class RecordingBroker(_CompensatingBroker):
|
||||
enroll_calls = 0
|
||||
|
||||
def enroll_target(self, *args, **kwargs):
|
||||
self.enroll_calls += 1
|
||||
return super().enroll_target(*args, **kwargs)
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
collision = Policy(
|
||||
id="enrollment-wsbc-portal-deployment-rollback",
|
||||
action="deployment.rollback",
|
||||
target="wsbc-portal",
|
||||
voice="blocked",
|
||||
manual="allowed",
|
||||
risk="high",
|
||||
reason="pre-existing exact-target policy",
|
||||
provenance_json={"owner": "pre-existing"},
|
||||
)
|
||||
session.add(collision)
|
||||
session.commit()
|
||||
|
||||
broker = RecordingBroker()
|
||||
result = _compensating_plane(broker).enroll(
|
||||
session, _plan(), idempotency_key="enroll-policy-collision-0001"
|
||||
)
|
||||
|
||||
# A pre-existing rollback policy must never leave behind the apply half
|
||||
# of the enrollment-generated pair, even after failure compensation.
|
||||
assert session.get(Policy, "enrollment-wsbc-portal-deployment-apply") is None
|
||||
assert session.get(Policy, collision.id).provenance_json == {"owner": "pre-existing"}
|
||||
assert result["enrollment"]["status"] == "preflight_failed"
|
||||
assert result["job"]["status"] == "preflight_failed"
|
||||
assert "exact_policy_id_collision" in result["job"]["preflight"]["blockers"]
|
||||
assert broker.enroll_calls == 0
|
||||
assert session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() == [collision]
|
||||
|
||||
|
||||
def test_late_policy_id_collision_rolls_back_the_whole_pair(tmp_path, monkeypatch):
|
||||
class FailedControllerCompensationBroker(_CompensatingBroker):
|
||||
def unenroll_target(self, _target, *, secret):
|
||||
assert secret == "b" * 64
|
||||
return {"status": "rollback_failed"}
|
||||
|
||||
collision = Policy(
|
||||
id="enrollment-wsbc-portal-deployment-rollback",
|
||||
action="deployment.rollback",
|
||||
target="wsbc-portal",
|
||||
voice="blocked",
|
||||
manual="allowed",
|
||||
risk="high",
|
||||
reason="concurrent exact-target policy",
|
||||
provenance_json={"owner": "concurrent"},
|
||||
)
|
||||
checkpoint = EnrollmentPlane._checkpoint
|
||||
|
||||
def add_collision_after_preflight(session, enrollment, job, *, stage, evidence):
|
||||
checkpoint(session, enrollment, job, stage=stage, evidence=evidence)
|
||||
if stage == "installing_policy":
|
||||
session.add(collision)
|
||||
session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
EnrollmentPlane, "_checkpoint", staticmethod(add_collision_after_preflight)
|
||||
)
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
result = _compensating_plane(FailedControllerCompensationBroker()).enroll(
|
||||
session, _plan(), idempotency_key="fixture-" + "x" * 32
|
||||
)
|
||||
|
||||
assert result["job"]["preflight"]["status"] == "ready"
|
||||
assert result["enrollment"]["status"] == "compensation_required"
|
||||
assert result["job"]["status"] == "compensation_required"
|
||||
assert result["job"]["evidence"]["compensation"] == {
|
||||
"completed": False,
|
||||
"outcomes": {
|
||||
"oidc": "removed",
|
||||
"proxy": "removed",
|
||||
"controller": "rollback_failed",
|
||||
},
|
||||
}
|
||||
assert session.get(Policy, "enrollment-wsbc-portal-deployment-apply") is None
|
||||
assert session.get(Policy, collision.id).provenance_json == {"owner": "concurrent"}
|
||||
assert session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() == [collision]
|
||||
|
||||
|
||||
def test_final_ready_commit_failure_rolls_back_the_policy_pair(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'enrollment.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
_configure(session)
|
||||
plane = _plane()
|
||||
commit = session.commit
|
||||
|
||||
def fail_after_policy_pair_is_staged():
|
||||
ready = session.exec(
|
||||
select(Enrollment).where(Enrollment.status == "ready")
|
||||
).first()
|
||||
policies = session.exec(
|
||||
select(Policy).where(Policy.target == "wsbc-portal")
|
||||
).all()
|
||||
if ready is not None and {policy.action for policy in policies} == {
|
||||
"deployment.apply", "deployment.rollback",
|
||||
}:
|
||||
raise RuntimeError("simulated final enrollment commit failure")
|
||||
commit()
|
||||
|
||||
session.commit = fail_after_policy_pair_is_staged
|
||||
with pytest.raises(RuntimeError, match="final enrollment commit failure"):
|
||||
plane.enroll(session, _plan(), idempotency_key="enroll-final-commit-failure-0001")
|
||||
|
||||
# A request-scoped MCP session is closed when the operation raises; prove
|
||||
# the pending pair was not committed by reading through a fresh session.
|
||||
with Session(engine) as verification:
|
||||
enrollment = verification.exec(select(Enrollment)).one()
|
||||
job = verification.exec(select(EnrollmentJob)).one()
|
||||
assert enrollment.status == "provisioning"
|
||||
assert job.stage == "installing_policy"
|
||||
assert verification.exec(
|
||||
select(Policy).where(Policy.target == "wsbc-portal")
|
||||
).all() == []
|
||||
|
||||
|
||||
def test_concurrent_policy_id_collision_during_flush_rolls_back_pending_pair(tmp_path):
|
||||
collision = Policy(
|
||||
id="enrollment-wsbc-portal-deployment-rollback",
|
||||
action="deployment.rollback",
|
||||
target="wsbc-portal",
|
||||
voice="blocked",
|
||||
manual="allowed",
|
||||
risk="high",
|
||||
reason="concurrent exact-target policy",
|
||||
provenance_json={"owner": "concurrent"},
|
||||
)
|
||||
with _session(tmp_path) as session:
|
||||
_configure(session)
|
||||
flush = session.flush
|
||||
inserted = False
|
||||
|
||||
def flush_with_concurrent_collision(*args, **kwargs):
|
||||
nonlocal inserted
|
||||
staged_ids = {
|
||||
policy.id for policy in session.new if isinstance(policy, Policy)
|
||||
}
|
||||
if not inserted and staged_ids == {
|
||||
"enrollment-wsbc-portal-deployment-apply",
|
||||
"enrollment-wsbc-portal-deployment-rollback",
|
||||
}:
|
||||
inserted = True
|
||||
with Session(session.get_bind()) as concurrent_session:
|
||||
concurrent_session.add(collision)
|
||||
concurrent_session.commit()
|
||||
return flush(*args, **kwargs)
|
||||
|
||||
session.flush = flush_with_concurrent_collision
|
||||
result = _compensating_plane().enroll(
|
||||
session, _plan(), idempotency_key="enroll-policy-concurrent-0001"
|
||||
)
|
||||
|
||||
assert inserted is True
|
||||
assert result["job"]["preflight"]["status"] == "ready"
|
||||
assert result["enrollment"]["status"] == "failed"
|
||||
assert session.get(Policy, "enrollment-wsbc-portal-deployment-apply") is None
|
||||
concurrent_policy = session.get(
|
||||
Policy, "enrollment-wsbc-portal-deployment-rollback"
|
||||
)
|
||||
assert concurrent_policy is not None
|
||||
assert concurrent_policy.provenance_json == {"owner": "concurrent"}
|
||||
policies = session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all()
|
||||
assert [(policy.id, policy.action, policy.provenance_json) for policy in policies] == [
|
||||
(
|
||||
"enrollment-wsbc-portal-deployment-rollback",
|
||||
"deployment.rollback",
|
||||
{"owner": "concurrent"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_interrupted_external_enrollment_requires_explicit_recovery(tmp_path):
|
||||
class InterruptedBroker(_Broker):
|
||||
def enroll_target(self, _enrollment, *, secret, idempotency_key):
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.deployment_target_registry import (
|
||||
DeploymentTargetRegistry,
|
||||
_REGISTER_SCRIPT,
|
||||
)
|
||||
from test_deployment_target_registry import FakePortainer
|
||||
|
||||
REPOSITORY = "jens/quiz-monitor"
|
||||
REVISION = "a" * 40
|
||||
|
||||
|
||||
def requested(verify=True):
|
||||
return DeploymentTargetRegistry._project(
|
||||
REPOSITORY, branch="main", compose_file="docker-compose.yml",
|
||||
compose_project="quiz-monitor", containers=["quiz-monitor-quiz-server-1"],
|
||||
health_timeout=180, verify_oci_revision=verify,
|
||||
)
|
||||
|
||||
|
||||
def live():
|
||||
return {
|
||||
"branch": "main", "slug": "quiz-monitor",
|
||||
"source_dir": "/srv/example/appdata/quiz-monitor/source",
|
||||
"compose_files": ["docker-compose.yml"], "compose_project": "quiz-monitor",
|
||||
"containers": ["quiz-monitor-quiz-server-1"], "legacy_containers": ["QuizMonitor"],
|
||||
"gitea_repository_id": 6, "gitea_webhook_secret_file": "jens__quiz-monitor",
|
||||
"validation": {"validation_gate_version": 1, "required": True,
|
||||
"contexts": ["Managed validation / full (push)"],
|
||||
"timeout_seconds": 1800, "poll_interval_seconds": 5,
|
||||
"max_poll_interval_seconds": 30},
|
||||
"environment": {"TEST_VALUE": "private-value-$preserve"},
|
||||
"env_source": "/srv/example/appdata/quiz-monitor/runtime.env",
|
||||
}
|
||||
|
||||
|
||||
def descriptor():
|
||||
return {"apiVersion": "itworx.deployment-target/v1", "applicationId": "quiz-monitor",
|
||||
"primaryService": "quiz-server", "composeFile": "docker-compose.yml",
|
||||
"composeProject": "quiz-monitor", "verifyOciRevision": True}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transaction(tmp_path, monkeypatch):
|
||||
pytest.importorskip("fcntl")
|
||||
if os.geteuid() != 0:
|
||||
pytest.skip("fixed root-owned registry transaction requires Linux root")
|
||||
registry = tmp_path / "volume" / "config" / "projects.json"
|
||||
registry.parent.mkdir(parents=True, mode=0o700)
|
||||
registry.parent.parent.chmod(0o700)
|
||||
state = tmp_path / "state"
|
||||
state.mkdir(mode=0o700)
|
||||
bare = tmp_path / "repositories" / "jens" / "quiz-monitor.git"
|
||||
bare.mkdir(parents=True)
|
||||
script = _REGISTER_SCRIPT.replace("/controller-hub-releases/controller-config/projects.json", str(registry))
|
||||
script = script.replace("/controller-config/projects.json", str(registry))
|
||||
script = script.replace("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock", str(tmp_path / "deploy.lock"))
|
||||
script = script.replace("/mnt/cache/appdata/gitea-deploy-runners/state/registry-backups", str(state / "registry-backups"))
|
||||
script = script.replace("/gitea-repositories", str(tmp_path / "repositories"))
|
||||
|
||||
def run(current, desired=None, *, source=None, protected=True, head=REVISION,
|
||||
protected_heads=None, mutate_during_gate=False, expire_during_gate=False):
|
||||
desired = desired or requested()
|
||||
projects = {REPOSITORY: current, "jens/unrelated": {"unchanged": "private-other"}} if current is not None else {}
|
||||
original = json.dumps(projects).encode()
|
||||
registry.write_bytes(original)
|
||||
registry.chmod(0o600)
|
||||
calls = []
|
||||
elapsed = [0]
|
||||
if expire_during_gate:
|
||||
monkeypatch.setattr("time.monotonic", lambda: elapsed[0])
|
||||
|
||||
def git(command, **_kwargs):
|
||||
assert command[:5] == ["git", "--no-replace-objects", "-c",
|
||||
f"safe.directory={bare}", f"--git-dir={bare}"]
|
||||
args = command[5:]
|
||||
if args[0] == "rev-parse":
|
||||
text = head if args[1].startswith("refs/heads/") else REVISION
|
||||
return SimpleNamespace(returncode=0, stdout=text + "\n")
|
||||
if args[:2] == ["show", f"{REVISION}:.itworx/deployment-target.json"]:
|
||||
text = source if isinstance(source, str) else json.dumps(source if source is not None else descriptor())
|
||||
return SimpleNamespace(returncode=0, stdout=text)
|
||||
if args[0] in {"merge-base", "cat-file"}:
|
||||
return SimpleNamespace(returncode=0, stdout="")
|
||||
raise AssertionError(args)
|
||||
|
||||
def read(path, *, query):
|
||||
assert path == f"/repos/{REPOSITORY}/branches/main" and query == {}
|
||||
calls.append(path)
|
||||
if expire_during_gate:
|
||||
elapsed[0] = 21
|
||||
if mutate_during_gate and (mutate_during_gate is True or len(calls) == mutate_during_gate):
|
||||
registry.write_bytes(original + b" ")
|
||||
current_head = protected_heads[min(len(calls) - 1, len(protected_heads) - 1)] if protected_heads else head
|
||||
return {"protected": protected, "commit": {"id": current_head}}
|
||||
|
||||
monkeypatch.setattr("subprocess.run", git)
|
||||
monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace(_registered_legacy_api_reader=lambda repo: read))
|
||||
monkeypatch.setattr(sys, "argv", ["registration", REPOSITORY, REVISION, "main", json.dumps(desired)])
|
||||
code = 0
|
||||
try:
|
||||
exec(compile(script, "fixed-registration-script", "exec"), {})
|
||||
except SystemExit as error:
|
||||
code = error.code
|
||||
return code, json.loads(registry.read_bytes()), original, registry.read_bytes(), calls, state
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def test_exact_protected_descriptor_strengthens_verification_and_preserves_all_existing_metadata(transaction, capsys):
|
||||
current = live()
|
||||
code, result, _old, _new, calls, state = transaction(current)
|
||||
assert code == 0
|
||||
assert result[REPOSITORY] == {**current, "verify_oci_revision": True}
|
||||
assert result["jens/unrelated"] == {"unchanged": "private-other"}
|
||||
assert len(calls) == 2 # Fresh protected-main proof at preflight and atomic install.
|
||||
assert len(list((state / "registry-backups").iterdir())) == 1
|
||||
assert "private" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_known_absent_defaults_and_legacy_fields_are_an_idempotent_noop(transaction):
|
||||
current = live()
|
||||
code, result, old, new, calls, state = transaction(current, requested(False))
|
||||
assert code == 0 and old == new and result[REPOSITORY] == current
|
||||
assert not calls and not (state / "registry-backups").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
{"branch": "other"}, {"slug": "other"}, {"compose_project": "other"},
|
||||
{"containers": ["other-container"]}, {"compose_files": ["other.yml"]},
|
||||
{"source_dir": "/srv/example/appdata/other/source"}, {"source_dir": "/srv/example/appdata/quiz-monitor/source/../other"},
|
||||
{"exact_source": "false"}, {"verify_oci_revision": "false"}, {"health_timeout": False},
|
||||
{"health_timeout": 300}, {"deploy_script": "scripts/privileged.sh"},
|
||||
{"legacy_containers": ["../escape"]}, {"legacy_containers": ["QuizMonitor", "QuizMonitor"]},
|
||||
{"legacy_containers": ["quiz-monitor-quiz-server-1"]},
|
||||
{"validation": {"required": False}},
|
||||
])
|
||||
def test_conflicting_identity_paths_and_unsafe_metadata_never_write(transaction, change):
|
||||
code, _result, old, new, calls, state = transaction({**live(), **change})
|
||||
assert code == 23 and old == new
|
||||
assert not calls and not (state / "registry-backups").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [
|
||||
{}, {**descriptor(), "verifyOciRevision": False},
|
||||
{**descriptor(), "verifyOciRevision": "true"},
|
||||
{**descriptor(), "composeProject": "other"},
|
||||
{**descriptor(), "composeFile": "other.yml"},
|
||||
{**descriptor(), "healthTimeout": True},
|
||||
{**descriptor(), "command": "arbitrary"},
|
||||
'{"verifyOciRevision":true,"verifyOciRevision":false}',
|
||||
])
|
||||
def test_strengthening_requires_exact_boolean_source_contract(transaction, source):
|
||||
code, _result, old, new, _calls, state = transaction(live(), source=source)
|
||||
assert code == 39 and old == new and not (state / "registry-backups").exists()
|
||||
|
||||
|
||||
def test_strengthening_rejects_noncurrent_or_unprotected_main(transaction):
|
||||
for options in ({"head": "b" * 40}, {"protected": False}):
|
||||
code, _result, old, new, _calls, state = transaction(live(), **options)
|
||||
assert code == 39 and old == new and not (state / "registry-backups").exists()
|
||||
|
||||
|
||||
def test_strengthening_cas_rejects_head_change_after_preflight(transaction):
|
||||
code, result, old, new, calls, _state = transaction(live(), protected_heads=[REVISION, "b" * 40])
|
||||
assert code == 39 and old == new and len(calls) == 2
|
||||
assert "verify_oci_revision" not in result[REPOSITORY]
|
||||
|
||||
|
||||
def test_delayed_proof_cannot_commit_after_the_registration_polling_window(transaction):
|
||||
code, _result, old, new, calls, state = transaction(live(), expire_during_gate=True)
|
||||
assert code == 39 and old == new and len(calls) == 1
|
||||
assert not (state / "registry-backups").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gate", [1, 2])
|
||||
def test_registry_byte_drift_prevents_atomic_install(transaction, gate):
|
||||
code, result, _old, _new, _calls, _state = transaction(live(), mutate_during_gate=gate)
|
||||
assert code == 34 and "verify_oci_revision" not in result[REPOSITORY]
|
||||
|
||||
|
||||
def test_verification_cannot_be_weakened(transaction):
|
||||
code, _result, old, new, _calls, state = transaction({**live(), "verify_oci_revision": True}, requested(False))
|
||||
assert code == 23 and old == new and not (state / "registry-backups").exists()
|
||||
|
||||
|
||||
def test_new_registration_keeps_existing_behavior(transaction):
|
||||
code, result, _old, _new, calls, _state = transaction(None)
|
||||
assert code == 0 and result[REPOSITORY] == requested() and not calls
|
||||
|
||||
|
||||
def test_registration_api_rejects_string_verification_before_portainer_access():
|
||||
fake = FakePortainer()
|
||||
result = DeploymentTargetRegistry(fake).register_compose_target(REPOSITORY, REVISION, verify_oci_revision="false")
|
||||
assert result["status"] == "invalid" and not fake.calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def original_git_reader(tmp_path, monkeypatch, request):
|
||||
if os.name != "posix" or os.geteuid() != 0:
|
||||
pytest.skip("controller Git mount ownership requires Linux root")
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
repositories = tmp_path / "repositories"
|
||||
bare = repositories / "jens" / "quiz-monitor.git"
|
||||
bare.parent.mkdir(parents=True)
|
||||
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||
monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
monkeypatch.setenv("GIT_CONFIG_COUNT", "0")
|
||||
|
||||
def git(*args):
|
||||
return subprocess.check_output(["git", "-C", str(work), *args], text=True).strip()
|
||||
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "Controller fixture")
|
||||
git("config", "user.email", "controller-fixture@example.invalid")
|
||||
(work / ".itworx").mkdir()
|
||||
source = descriptor()
|
||||
source["verifyOciRevision"] = getattr(request, "param", False)
|
||||
(work / ".itworx/deployment-target.json").write_text(json.dumps(source))
|
||||
(work / "docker-compose.yml").write_text("services: {}\n")
|
||||
git("add", ".")
|
||||
git("commit", "-m", "original protected source")
|
||||
original = git("rev-parse", "HEAD")
|
||||
replacement_source = {**source, "verifyOciRevision": not source["verifyOciRevision"]}
|
||||
(work / ".itworx/deployment-target.json").write_text(json.dumps(replacement_source))
|
||||
git("commit", "-am", "replacement object")
|
||||
replacement = git("rev-parse", "HEAD")
|
||||
git("reset", "--hard", original)
|
||||
subprocess.run(["git", "clone", "--bare", str(work), str(bare)], check=True,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.run(["git", f"--git-dir={bare}", "replace", original, replacement],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
functions = [node for node in ast.parse(_REGISTER_SCRIPT).body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name in {"git_run", "_source_verified_strengthening"}]
|
||||
namespace = {"bare_repo": bare, "subprocess": subprocess, "stat": stat,
|
||||
"time": time, "json": json, "re": __import__("re"), "sys": sys,
|
||||
"branch": "main", "revision": original, "repository": REPOSITORY,
|
||||
"compose_file": "docker-compose.yml", "requested_project": requested(),
|
||||
"strengthening_deadline": time.monotonic() + 20}
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), "fixed-git-reader", "exec"), namespace)
|
||||
monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace(
|
||||
_registered_legacy_api_reader=lambda _repo: lambda _path, **_kwargs: {
|
||||
"protected": True, "commit": {"id": original},
|
||||
},
|
||||
))
|
||||
return bare, original, namespace
|
||||
|
||||
|
||||
def test_original_descriptor_cannot_be_substituted_by_git_replacement(original_git_reader):
|
||||
bare, original, namespace = original_git_reader
|
||||
# A real replacement keeps the branch SHA but swaps the source descriptor.
|
||||
replaced = subprocess.check_output(
|
||||
["git", f"--git-dir={bare}", "show", f"{original}:.itworx/deployment-target.json"],
|
||||
text=True,
|
||||
)
|
||||
assert json.loads(replaced)["verifyOciRevision"] is True
|
||||
result = namespace["git_run"]("show", f"{original}:.itworx/deployment-target.json")
|
||||
assert result.returncode == 0 and json.loads(result.stdout)["verifyOciRevision"] is False
|
||||
assert namespace["_source_verified_strengthening"]() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner", [0, 1000])
|
||||
def test_read_only_gitea_repository_owner_is_supported(original_git_reader, owner):
|
||||
bare, original, namespace = original_git_reader
|
||||
for directory in (bare.parent.parent, bare.parent, bare):
|
||||
directory.chmod(0o755)
|
||||
os.chown(directory, owner, owner)
|
||||
result = namespace["git_run"]("rev-parse", "refs/heads/main")
|
||||
assert result.returncode == 0 and result.stdout.strip() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("depth", [0, 1, 2])
|
||||
@pytest.mark.parametrize("unsafe", ["owner", "writable", "symlink"])
|
||||
def test_repository_local_trust_rejects_unsafe_path(original_git_reader, depth, unsafe):
|
||||
bare, _original, namespace = original_git_reader
|
||||
directory = (bare.parent.parent, bare.parent, bare)[depth]
|
||||
if unsafe == "owner":
|
||||
os.chown(directory, 1234, 1234)
|
||||
elif unsafe == "writable":
|
||||
directory.chmod(0o775)
|
||||
else:
|
||||
renamed = directory.with_name(directory.name + "-original")
|
||||
directory.rename(renamed)
|
||||
directory.symlink_to(renamed, target_is_directory=True)
|
||||
with pytest.raises(ValueError, match="unsafe mounted repository"):
|
||||
namespace["git_run"]("rev-parse", "refs/heads/main")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("original_git_reader", [True], indirect=True)
|
||||
def test_replacement_cannot_hide_valid_original_protected_descriptor(original_git_reader):
|
||||
_bare, _original, namespace = original_git_reader
|
||||
assert namespace["_source_verified_strengthening"]() is True
|
||||
@@ -0,0 +1,761 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.db.models import Policy
|
||||
from backend.app.services.action_registry import ACTION_RISK, ACTIONS, action_default_target
|
||||
from backend.app.services.policy_engine import evaluate_policy
|
||||
|
||||
|
||||
_ACTION = "forge.route.stage"
|
||||
_TARGET = "itworx-forge"
|
||||
_NETWORK = "itworx-mcp-hub-npm"
|
||||
|
||||
|
||||
def _session(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'forge-route-stage.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def _policy(target: str = _TARGET, *, manual: str = "allowed") -> Policy:
|
||||
return Policy(
|
||||
id=f"route-{target}",
|
||||
action=_ACTION,
|
||||
target=target,
|
||||
manual=manual,
|
||||
voice="blocked",
|
||||
risk="critical",
|
||||
reason="test policy",
|
||||
)
|
||||
|
||||
|
||||
def test_route_stage_policy_requires_one_exact_manual_critical_grant(tmp_path):
|
||||
with _session(tmp_path) as session:
|
||||
session.add(_policy())
|
||||
session.commit()
|
||||
|
||||
decision = evaluate_policy(session, _ACTION, _TARGET, "manual")
|
||||
|
||||
assert _ACTION in ACTIONS
|
||||
assert ACTION_RISK[_ACTION] == "critical"
|
||||
assert action_default_target(_ACTION) == _TARGET
|
||||
assert decision == {
|
||||
"decision": "allowed",
|
||||
"reason": "Forge route staging requires one exact-target manual policy; voice is always blocked.",
|
||||
"risk": "critical",
|
||||
"policy_id": "route-itworx-forge",
|
||||
"scope_decision": "allowed",
|
||||
}
|
||||
|
||||
|
||||
def test_route_stage_policy_rejects_wildcard_even_when_manual_is_allowed(tmp_path):
|
||||
with _session(tmp_path) as session:
|
||||
session.add(_policy("*"))
|
||||
session.commit()
|
||||
|
||||
manual = evaluate_policy(session, _ACTION, _TARGET, "manual")
|
||||
voice = evaluate_policy(session, _ACTION, _TARGET, "voice")
|
||||
|
||||
assert manual["decision"] == "blocked"
|
||||
assert manual["policy_id"] is None
|
||||
assert voice["decision"] == "blocked"
|
||||
|
||||
|
||||
def test_route_stage_seed_is_default_denied_and_duplicate_exact_rows_do_not_grant(tmp_path):
|
||||
from backend.app.db.init_db import _seed_policies
|
||||
|
||||
seeded = [policy for policy in _seed_policies() if policy.action == _ACTION]
|
||||
assert len(seeded) == 1
|
||||
assert seeded[0].target == _TARGET
|
||||
assert seeded[0].manual == "blocked"
|
||||
assert seeded[0].voice == "blocked"
|
||||
assert seeded[0].risk == "critical"
|
||||
|
||||
with _session(tmp_path) as session:
|
||||
first = _policy()
|
||||
second = _policy()
|
||||
second.id = "route-duplicate"
|
||||
session.add(first)
|
||||
session.add(second)
|
||||
session.commit()
|
||||
|
||||
decision = evaluate_policy(session, _ACTION, _TARGET, "manual")
|
||||
|
||||
assert decision["decision"] == "blocked"
|
||||
assert decision["policy_id"] is None
|
||||
|
||||
|
||||
class _DockerResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _docker_container(name, *, container_id, networks, state="running", labels=None, image="image"):
|
||||
return {
|
||||
"Id": container_id,
|
||||
"Names": [f"/{name}"],
|
||||
"Image": image,
|
||||
"State": state,
|
||||
"Labels": labels or {},
|
||||
"NetworkSettings": {
|
||||
"Networks": networks if isinstance(networks, dict) else {network: {} for network in networks}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _forge_network_inventory():
|
||||
return [
|
||||
_docker_container(
|
||||
"NginxProxyManager",
|
||||
container_id="a" * 64,
|
||||
networks={_NETWORK: {"NetworkID": "1" * 64, "EndpointID": "2" * 64}},
|
||||
image="jlesage/nginx-proxy-manager:2.15.1",
|
||||
),
|
||||
_docker_container(
|
||||
"itworx-forge-itworx-forge-1",
|
||||
container_id="b" * 64,
|
||||
networks={
|
||||
"forge_app": {"NetworkID": "3" * 64, "EndpointID": "4" * 64},
|
||||
_NETWORK: {"NetworkID": "1" * 64, "EndpointID": "5" * 64},
|
||||
},
|
||||
labels={
|
||||
"com.docker.compose.project": "itworx-forge",
|
||||
"com.docker.compose.service": "itworx-forge",
|
||||
},
|
||||
image="gitea.example.com/owner/itworx-forge-frontdoor@sha256:" + "c" * 64,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _portainer(monkeypatch, payload, *, status_code=200):
|
||||
from backend.app.connectors import portainer as portainer_module
|
||||
from backend.app.connectors.portainer import PortainerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"PORTAINER_URL": "https://portainer.example",
|
||||
"PORTAINER_API_KEY": "test-api-key-not-a-real-secret",
|
||||
"PORTAINER_ENDPOINT_ID": "1",
|
||||
"PORTAINER_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
class _Client:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def get(self, *_args, **_kwargs):
|
||||
return _DockerResponse(payload, status_code=status_code)
|
||||
|
||||
monkeypatch.setattr(portainer_module.httpx, "Client", _Client)
|
||||
return PortainerConnector()
|
||||
|
||||
|
||||
def test_portainer_route_network_proof_requires_unique_running_fixed_containers(monkeypatch):
|
||||
connector = _portainer(monkeypatch, _forge_network_inventory())
|
||||
|
||||
result = connector.forge_route_network_readiness()
|
||||
|
||||
assert result == {
|
||||
"status": "ready",
|
||||
"network": _NETWORK,
|
||||
"npmAttached": True,
|
||||
"frontdoorAttached": True,
|
||||
}
|
||||
|
||||
|
||||
def test_portainer_route_network_proof_fails_closed_on_missing_membership(monkeypatch):
|
||||
payload = _forge_network_inventory()
|
||||
payload[0]["NetworkSettings"]["Networks"] = {"bridge": {}}
|
||||
connector = _portainer(monkeypatch, payload)
|
||||
|
||||
result = connector.forge_route_network_readiness()
|
||||
|
||||
assert result["status"] == "not_ready"
|
||||
assert result["npmAttached"] is False
|
||||
assert result["frontdoorAttached"] is True
|
||||
assert "a" * 64 not in repr(result)
|
||||
|
||||
|
||||
def test_portainer_route_network_proof_rejects_duplicate_or_unavailable_inventory(monkeypatch):
|
||||
duplicate = _forge_network_inventory() + [_forge_network_inventory()[0]]
|
||||
assert _portainer(monkeypatch, duplicate).forge_route_network_readiness()["status"] == "unknown"
|
||||
|
||||
connector = _portainer(monkeypatch, {"not": "a list"})
|
||||
assert connector.forge_route_network_readiness()["status"] == "unknown"
|
||||
|
||||
|
||||
def test_portainer_route_network_proof_requires_distinct_docker_containers(monkeypatch):
|
||||
same_container = _docker_container(
|
||||
"NginxProxyManager",
|
||||
container_id="a" * 64,
|
||||
networks={_NETWORK: {"NetworkID": "1" * 64, "EndpointID": "2" * 64}},
|
||||
labels={
|
||||
"com.docker.compose.project": "itworx-forge",
|
||||
"com.docker.compose.service": "itworx-forge",
|
||||
},
|
||||
image="jlesage/nginx-proxy-manager:2.15.1",
|
||||
)
|
||||
|
||||
result = _portainer(monkeypatch, [same_container]).forge_route_network_readiness()
|
||||
|
||||
assert result["status"] == "unknown"
|
||||
|
||||
|
||||
def test_portainer_route_network_proof_requires_same_network_identity(monkeypatch):
|
||||
inventory = _forge_network_inventory()
|
||||
inventory[1]["NetworkSettings"]["Networks"][_NETWORK]["NetworkID"] = "9" * 64
|
||||
|
||||
result = _portainer(monkeypatch, inventory).forge_route_network_readiness()
|
||||
|
||||
assert result["status"] == "unknown"
|
||||
|
||||
|
||||
def test_portainer_route_network_proof_requires_https_verified_tls_and_200(monkeypatch):
|
||||
from backend.app.connectors.portainer import PortainerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"PORTAINER_URL": "http://portainer.example",
|
||||
"PORTAINER_API_KEY": "test-api-key-not-a-real-secret",
|
||||
"PORTAINER_ENDPOINT_ID": "1",
|
||||
"PORTAINER_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
PortainerConnector,
|
||||
"_request",
|
||||
lambda *args, **kwargs: calls.append((args, kwargs)) or _DockerResponse(_forge_network_inventory()),
|
||||
)
|
||||
assert PortainerConnector().forge_route_network_readiness()["status"] == "unknown"
|
||||
assert calls == []
|
||||
assert _portainer(monkeypatch, _forge_network_inventory(), status_code=302).forge_route_network_readiness()["status"] == "unknown"
|
||||
|
||||
malformed = dict(settings.apis)
|
||||
malformed.update({"PORTAINER_URL": "https://[bad", "PORTAINER_VERIFY_TLS": True})
|
||||
monkeypatch.setattr(settings, "apis", malformed)
|
||||
assert PortainerConnector().forge_route_network_readiness()["status"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("network_entry", [None, {}, {"NetworkID": "", "EndpointID": ""}])
|
||||
def test_portainer_route_network_proof_requires_valid_network_membership_identity(monkeypatch, network_entry):
|
||||
inventory = _forge_network_inventory()
|
||||
inventory[0]["NetworkSettings"]["Networks"][_NETWORK] = network_entry
|
||||
result = _portainer(monkeypatch, inventory).forge_route_network_readiness()
|
||||
assert result["status"] == "unknown"
|
||||
assert result["npmAttached"] is None
|
||||
|
||||
|
||||
def test_portainer_route_uses_one_verified_tls_config_snapshot_without_generic_reread(monkeypatch):
|
||||
from backend.app.connectors import portainer as portainer_module
|
||||
from backend.app.connectors.portainer import PortainerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"PORTAINER_URL": "https://portainer.example",
|
||||
"PORTAINER_API_KEY": "snapshot-api-key",
|
||||
"PORTAINER_ENDPOINT_ID": "7",
|
||||
"PORTAINER_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
calls = []
|
||||
|
||||
class _PinnedClient:
|
||||
def __init__(self, *, timeout, verify, follow_redirects):
|
||||
calls.append(("client", timeout, verify, follow_redirects))
|
||||
changed = dict(settings.apis)
|
||||
changed["PORTAINER_URL"] = "http://attacker.example"
|
||||
changed["PORTAINER_API_KEY"] = "changed-key"
|
||||
changed["PORTAINER_VERIFY_TLS"] = False
|
||||
monkeypatch.setattr(settings, "apis", changed)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def get(self, url, *, headers, follow_redirects):
|
||||
calls.append(("get", url, headers, follow_redirects))
|
||||
return _DockerResponse(_forge_network_inventory(), status_code=200)
|
||||
|
||||
monkeypatch.setattr(portainer_module.httpx, "Client", _PinnedClient)
|
||||
monkeypatch.setattr(
|
||||
PortainerConnector,
|
||||
"_request",
|
||||
lambda *_args, **_kwargs: pytest.fail("route proof must use its captured verified HTTPS config"),
|
||||
)
|
||||
|
||||
result = PortainerConnector().forge_route_network_readiness()
|
||||
|
||||
assert result["status"] == "ready"
|
||||
assert calls[0] == ("client", 8.0, True, False)
|
||||
assert calls[1][0] == "get"
|
||||
assert calls[1][1] == "https://portainer.example/api/endpoints/7/docker/containers/json?all=1"
|
||||
assert calls[1][2]["X-API-Key"] == "snapshot-api-key"
|
||||
assert calls[1][3] is False
|
||||
|
||||
|
||||
def test_npm_forge_route_inventory_requires_bounded_bare_array():
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
parse = NginxProxyManagerConnector._parse_forge_route_hosts
|
||||
with pytest.raises(RuntimeError, match="npm_forge_route_inventory_invalid"):
|
||||
parse({"data": [{"domain_names": ["forge.example.com"]}]})
|
||||
|
||||
with pytest.raises(RuntimeError, match="npm_forge_route_inventory_invalid"):
|
||||
parse([{"domain_names": ["forge.example.com"]}, "malformed row"])
|
||||
|
||||
with pytest.raises(RuntimeError, match="npm_forge_route_inventory_invalid"):
|
||||
parse([{"id": index} for index in range(1001)])
|
||||
|
||||
assert parse([{"domain_names": ["forge.example.com"]}]) == [
|
||||
{"domain_names": ["forge.example.com"]}
|
||||
]
|
||||
|
||||
|
||||
def test_npm_route_session_pins_endpoint_and_verified_tls_across_inventory_and_post(monkeypatch):
|
||||
from backend.app.connectors import nginx_proxy_manager as npm_module
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"NPM_URL": "https://npm.example",
|
||||
"NPM_IDENTITY": "operator@example.test",
|
||||
"NPM_SECRET": "route-session-secret",
|
||||
"NPM_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
calls = []
|
||||
api_calls = []
|
||||
permissions = {
|
||||
"id": 7,
|
||||
"is_disabled": False,
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "manage"},
|
||||
}
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, timeout, verify, follow_redirects):
|
||||
calls.append(("client", timeout, verify, follow_redirects))
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, *, json, headers, follow_redirects):
|
||||
calls.append(("login", url, json, headers, follow_redirects))
|
||||
return _Response({"token": "opaque-test-token"})
|
||||
|
||||
def request(self, method, url, *, headers, follow_redirects, **kwargs):
|
||||
calls.append(("request", method, url, headers, follow_redirects, kwargs))
|
||||
api_calls.append((method, url, kwargs))
|
||||
if url.endswith("/users/me"):
|
||||
return _Response(permissions)
|
||||
if url.endswith("/nginx/proxy-hosts") and method == "GET":
|
||||
return _Response([])
|
||||
if url.endswith("/nginx/proxy-hosts") and method == "POST":
|
||||
return _Response({"id": 31, **kwargs["json"]}, status_code=201)
|
||||
pytest.fail("route session used an unsupported NPM endpoint")
|
||||
|
||||
monkeypatch.setattr(npm_module.httpx, "Client", _Client)
|
||||
connector = NginxProxyManagerConnector()
|
||||
route_session = connector.forge_route_session()
|
||||
assert route_session is not None
|
||||
assert route_session.verified_tls is True
|
||||
assert route_session.list_forge_route_hosts() == []
|
||||
created = route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32)
|
||||
assert isinstance(created, dict)
|
||||
assert [call[:2] for call in api_calls] == [
|
||||
("GET", "https://npm.example/api/users/me"),
|
||||
("GET", "https://npm.example/api/nginx/proxy-hosts"),
|
||||
("GET", "https://npm.example/api/users/me"),
|
||||
("POST", "https://npm.example/api/nginx/proxy-hosts"),
|
||||
]
|
||||
assert api_calls[0][2] == {"params": {"expand": "permissions"}}
|
||||
assert api_calls[1][2] == {}
|
||||
assert api_calls[2][2] == {"params": {"expand": "permissions"}}
|
||||
request_calls = [call for call in calls if call[0] == "request"]
|
||||
assert all(call[3]["Authorization"] == "Bearer opaque-test-token" for call in request_calls)
|
||||
create_call = next(call for call in calls if call[0] == "request" and call[1] == "POST")
|
||||
assert create_call[2] == "https://npm.example/api/nginx/proxy-hosts"
|
||||
assert create_call[4] is False
|
||||
assert create_call[5]["json"] == {
|
||||
"domain_names": ["forge.example.com"],
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "itworx-forge",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": 0,
|
||||
"certificate_id": 0,
|
||||
"enabled": False,
|
||||
"ssl_forced": False,
|
||||
"http2_support": False,
|
||||
"hsts_enabled": False,
|
||||
"hsts_subdomains": False,
|
||||
"caching_enabled": False,
|
||||
"block_exploits": True,
|
||||
"allow_websocket_upgrade": False,
|
||||
"trust_forwarded_proto": False,
|
||||
"locations": [],
|
||||
"meta": {"appops_forge_route_id": "froute-" + "e" * 32},
|
||||
"advanced_config": "",
|
||||
}
|
||||
|
||||
changed = dict(settings.apis)
|
||||
changed["NPM_URL"] = "https://different-npm.example"
|
||||
monkeypatch.setattr(settings, "apis", changed)
|
||||
with pytest.raises(RuntimeError, match="npm_configuration_changed"):
|
||||
route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32)
|
||||
|
||||
assert calls[0] == ("client", 10.0, True, False)
|
||||
assert calls[1][0] == "login"
|
||||
assert calls[1][1] == "https://npm.example/api/tokens"
|
||||
assert calls[1][2]["secret"] == "route-session-secret"
|
||||
assert calls[1][4] is False
|
||||
assert calls[3][1:3] == ("GET", "https://npm.example/api/users/me")
|
||||
assert calls[3][4] is False
|
||||
assert all(call[2] is True and call[3] is False for call in calls if call[0] == "client")
|
||||
assert sum(call[0] == "request" and call[1] == "POST" for call in calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user", "status_code", "invalid_json"),
|
||||
[
|
||||
({}, 200, False),
|
||||
({"id": 7, "is_disabled": False}, 200, False),
|
||||
(
|
||||
{"id": 7, "is_disabled": False, "permissions": {"visibility": "all"}},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 7,
|
||||
"is_disabled": False,
|
||||
"permissions": {"visibility": "user", "proxy_hosts": "manage"},
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 7,
|
||||
"is_disabled": False,
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "view"},
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 7,
|
||||
"is_disabled": True,
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "manage"},
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": True,
|
||||
"is_disabled": False,
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "manage"},
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 0,
|
||||
"is_disabled": False,
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "manage"},
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 7,
|
||||
"is_disabled": "false",
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "manage"},
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 7,
|
||||
"is_disabled": False,
|
||||
"permissions": ["all", "manage"],
|
||||
},
|
||||
200,
|
||||
False,
|
||||
),
|
||||
(
|
||||
{
|
||||
"id": 7,
|
||||
"is_disabled": False,
|
||||
"permissions": {"visibility": "all", "proxy_hosts": "manage"},
|
||||
},
|
||||
302,
|
||||
False,
|
||||
),
|
||||
(None, 200, True),
|
||||
],
|
||||
ids=(
|
||||
"absent-user",
|
||||
"absent-permissions",
|
||||
"partial-permissions",
|
||||
"wrong-visibility",
|
||||
"wrong-capability",
|
||||
"disabled-user",
|
||||
"boolean-user-id",
|
||||
"zero-user-id",
|
||||
"string-disabled-flag",
|
||||
"malformed-permissions",
|
||||
"redirect",
|
||||
"malformed-json",
|
||||
),
|
||||
)
|
||||
def test_npm_route_session_denies_incomplete_permissions_before_inventory(
|
||||
monkeypatch, user, status_code, invalid_json
|
||||
):
|
||||
from backend.app.connectors import nginx_proxy_manager as npm_module
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"NPM_URL": "https://npm.example",
|
||||
"NPM_IDENTITY": "operator@example.test",
|
||||
"NPM_SECRET": "route-session-secret",
|
||||
"NPM_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
api_calls = []
|
||||
|
||||
class _Response:
|
||||
def __init__(self, payload, response_status, invalid_json_response=False):
|
||||
self._payload = payload
|
||||
self.status_code = response_status
|
||||
self._invalid_json_response = invalid_json_response
|
||||
|
||||
def json(self):
|
||||
if self._invalid_json_response:
|
||||
raise ValueError("malformed response")
|
||||
return self._payload
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, timeout, verify, follow_redirects):
|
||||
assert verify is True
|
||||
assert follow_redirects is False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, *, json, headers, follow_redirects):
|
||||
return _Response({"token": "opaque-test-token"}, 200)
|
||||
|
||||
def request(self, method, url, *, headers, follow_redirects, **kwargs):
|
||||
api_calls.append((method, url, kwargs))
|
||||
assert url.endswith("/users/me")
|
||||
return _Response(user, status_code, invalid_json)
|
||||
|
||||
monkeypatch.setattr(npm_module.httpx, "Client", _Client)
|
||||
route_session = NginxProxyManagerConnector().forge_route_session()
|
||||
assert route_session is not None
|
||||
|
||||
from backend.app.connectors.nginx_proxy_manager import _NpmForgeRoutePermissionDenied
|
||||
|
||||
with pytest.raises(_NpmForgeRoutePermissionDenied):
|
||||
route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32)
|
||||
|
||||
assert api_calls == [
|
||||
("GET", "https://npm.example/api/users/me", {"params": {"expand": "permissions"}})
|
||||
]
|
||||
assert not any(method == "POST" for method, _url, _kwargs in api_calls)
|
||||
assert all(url.endswith("/users/me") for _method, url, _kwargs in api_calls)
|
||||
|
||||
|
||||
def test_npm_route_session_treats_permission_probe_transport_failure_as_preflight_denial(monkeypatch):
|
||||
import httpx
|
||||
|
||||
from backend.app.connectors import nginx_proxy_manager as npm_module
|
||||
from backend.app.connectors.nginx_proxy_manager import (
|
||||
NginxProxyManagerConnector,
|
||||
_NpmForgeRoutePermissionDenied,
|
||||
)
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"NPM_URL": "https://npm.example",
|
||||
"NPM_IDENTITY": "operator@example.test",
|
||||
"NPM_SECRET": "route-session-secret",
|
||||
"NPM_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
api_calls = []
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {"token": "opaque-test-token"}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, timeout, verify, follow_redirects):
|
||||
assert verify is True
|
||||
assert follow_redirects is False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, *, json, headers, follow_redirects):
|
||||
return _Response()
|
||||
|
||||
def request(self, method, url, *, headers, follow_redirects, **kwargs):
|
||||
api_calls.append((method, url, kwargs))
|
||||
request = httpx.Request(method, url)
|
||||
raise httpx.ConnectTimeout("private transport detail", request=request)
|
||||
|
||||
monkeypatch.setattr(npm_module.httpx, "Client", _Client)
|
||||
route_session = NginxProxyManagerConnector().forge_route_session()
|
||||
assert route_session is not None
|
||||
|
||||
with pytest.raises(_NpmForgeRoutePermissionDenied):
|
||||
route_session.list_forge_route_hosts()
|
||||
|
||||
assert api_calls == [
|
||||
("GET", "https://npm.example/api/users/me", {"params": {"expand": "permissions"}})
|
||||
]
|
||||
|
||||
|
||||
def test_npm_route_session_rechecks_permission_drift_before_single_post(monkeypatch):
|
||||
from backend.app.connectors import nginx_proxy_manager as npm_module
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"NPM_URL": "https://npm.example",
|
||||
"NPM_IDENTITY": "operator@example.test",
|
||||
"NPM_SECRET": "route-session-secret",
|
||||
"NPM_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
api_calls = []
|
||||
user_responses = [
|
||||
{"id": 7, "is_disabled": False, "permissions": {"visibility": "all", "proxy_hosts": "manage"}},
|
||||
{"id": 7, "is_disabled": False, "permissions": {"visibility": "all", "proxy_hosts": "view"}},
|
||||
]
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, timeout, verify, follow_redirects):
|
||||
assert verify is True
|
||||
assert follow_redirects is False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, *, json, headers, follow_redirects):
|
||||
return _Response({"token": "opaque-test-token"})
|
||||
|
||||
def request(self, method, url, *, headers, follow_redirects, **kwargs):
|
||||
api_calls.append((method, url, kwargs))
|
||||
if url.endswith("/users/me"):
|
||||
return _Response(user_responses.pop(0))
|
||||
if url.endswith("/nginx/proxy-hosts") and method == "GET":
|
||||
return _Response([])
|
||||
pytest.fail("permission drift must prevent the NPM POST")
|
||||
|
||||
monkeypatch.setattr(npm_module.httpx, "Client", _Client)
|
||||
route_session = NginxProxyManagerConnector().forge_route_session()
|
||||
assert route_session is not None
|
||||
assert route_session.list_forge_route_hosts() == []
|
||||
|
||||
from backend.app.connectors.nginx_proxy_manager import _NpmForgeRoutePermissionDenied
|
||||
|
||||
with pytest.raises(_NpmForgeRoutePermissionDenied):
|
||||
route_session.create_forge_disabled_proxy_host("froute-" + "e" * 32)
|
||||
|
||||
assert [call[:2] for call in api_calls] == [
|
||||
("GET", "https://npm.example/api/users/me"),
|
||||
("GET", "https://npm.example/api/nginx/proxy-hosts"),
|
||||
("GET", "https://npm.example/api/users/me"),
|
||||
]
|
||||
assert not any(method == "POST" for method, _url, _kwargs in api_calls)
|
||||
|
||||
|
||||
def test_npm_route_session_refuses_insecure_config_before_login(monkeypatch):
|
||||
from backend.app.connectors import nginx_proxy_manager as npm_module
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
apis = dict(settings.apis)
|
||||
apis.update(
|
||||
{
|
||||
"NPM_URL": "http://npm.example",
|
||||
"NPM_IDENTITY": "operator@example.test",
|
||||
"NPM_SECRET": "route-session-secret",
|
||||
"NPM_VERIFY_TLS": True,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(settings, "apis", apis)
|
||||
monkeypatch.setattr(
|
||||
npm_module.httpx,
|
||||
"Client",
|
||||
lambda **_kwargs: pytest.fail("insecure NPM configuration must be rejected before login"),
|
||||
)
|
||||
|
||||
assert NginxProxyManagerConnector().forge_route_session() is None
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
from backend.app.services import user_access
|
||||
|
||||
_REAL_CURRENT_USER = user_access.current_user
|
||||
_JOB_ID = "forge-install-" + "a" * 64
|
||||
_PATH = f"/api/deployments/forge-bootstrap/first-install/{_JOB_ID}/route"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forge_route_operator_client(tmp_path, monkeypatch: pytest.MonkeyPatch):
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.db import session as db_session
|
||||
from backend.app.db.init_db import init_db
|
||||
from backend.app.main import app
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'forge-route-api.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
monkeypatch.setattr(db_session, "engine", engine)
|
||||
monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false")
|
||||
monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False)
|
||||
monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False)
|
||||
monkeypatch.setattr(settings, "auth_admin_emails", [], raising=False)
|
||||
monkeypatch.setattr(settings, "data_dir", str(tmp_path))
|
||||
monkeypatch.setattr(user_access, "current_user", _REAL_CURRENT_USER)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
init_db()
|
||||
client = TestClient(app, client=("198.18.10.20", 42000))
|
||||
yield client
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _sign_in(client: TestClient, subject: str) -> None:
|
||||
from backend.app.db import session as db_session
|
||||
from backend.app.services.user_access import issue_session, provision_user
|
||||
|
||||
with Session(db_session.engine) as session:
|
||||
user = provision_user(
|
||||
session,
|
||||
"authentik",
|
||||
"https://auth.example/application/o/appops/",
|
||||
{"sub": subject, "email": f"{subject}@example.test"},
|
||||
)
|
||||
cookie = issue_session(session, user)
|
||||
client.cookies.set("appops_user_session", cookie)
|
||||
|
||||
|
||||
def _safe_result(status="host_staged_disabled"):
|
||||
staged = status == "host_staged_disabled"
|
||||
return {
|
||||
"success": staged,
|
||||
"status": status,
|
||||
"message": "forge_route_host_staged_disabled_not_routed" if staged else "forge_route_operation_not_found",
|
||||
"target": "itworx-forge",
|
||||
"jobId": _JOB_ID,
|
||||
"hostname": "forge.example.com",
|
||||
"upstream": "http://itworx-forge:8080",
|
||||
"routeState": "host_staged_disabled" if staged else "not_routed",
|
||||
"routed": False,
|
||||
}
|
||||
|
||||
|
||||
def test_route_post_requires_operator_csrf_strict_empty_body_and_bounded_key(
|
||||
forge_route_operator_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
forge_route_stage,
|
||||
"stage_forge_route",
|
||||
lambda session, **kwargs: calls.append((session, kwargs)) or _safe_result(),
|
||||
)
|
||||
|
||||
unauthenticated = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={"Idempotency-Key": "forge-route-api-0001"},
|
||||
content=b"",
|
||||
)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
_sign_in(forge_route_operator_client, "owner")
|
||||
missing_csrf = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={"Idempotency-Key": "forge-route-api-0001"},
|
||||
content=b"",
|
||||
)
|
||||
assert missing_csrf.status_code == 403
|
||||
assert calls == []
|
||||
|
||||
invalid_body = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={
|
||||
"X-AppOps-Session-CSRF": "1",
|
||||
"Idempotency-Key": "forge-route-api-0001",
|
||||
},
|
||||
json={},
|
||||
)
|
||||
assert invalid_body.status_code == 400
|
||||
|
||||
invalid_key = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={
|
||||
"X-AppOps-Session-CSRF": "1",
|
||||
"Idempotency-Key": "bad key",
|
||||
},
|
||||
content=b"",
|
||||
)
|
||||
assert invalid_key.status_code == 400
|
||||
|
||||
oversized_key = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={
|
||||
"X-AppOps-Session-CSRF": "1",
|
||||
"Idempotency-Key": "x" * 129,
|
||||
},
|
||||
content=b"",
|
||||
)
|
||||
assert oversized_key.status_code == 400
|
||||
|
||||
duplicate_key = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers=[
|
||||
("X-AppOps-Session-CSRF", "1"),
|
||||
("Idempotency-Key", "forge-route-api-duplicate-01"),
|
||||
("Idempotency-Key", "forge-route-api-duplicate-02"),
|
||||
],
|
||||
content=b"",
|
||||
)
|
||||
assert duplicate_key.status_code == 400
|
||||
assert calls == []
|
||||
|
||||
accepted = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={
|
||||
"X-AppOps-Session-CSRF": "1",
|
||||
"Idempotency-Key": "forge-route-api-0001",
|
||||
},
|
||||
content=b"",
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
assert accepted.json() == _safe_result()
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == {
|
||||
"job_id": _JOB_ID,
|
||||
"idempotency_key": "forge-route-api-0001",
|
||||
"actor_identity": "authentik:owner",
|
||||
}
|
||||
|
||||
|
||||
def test_route_post_denies_read_only_user_before_service(
|
||||
forge_route_operator_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
monkeypatch.setattr(
|
||||
forge_route_stage,
|
||||
"stage_forge_route",
|
||||
lambda *_args, **_kwargs: pytest.fail("read-only user must not reach the executor"),
|
||||
)
|
||||
_sign_in(forge_route_operator_client, "reader")
|
||||
|
||||
denied = forge_route_operator_client.post(
|
||||
_PATH,
|
||||
headers={
|
||||
"X-AppOps-Session-CSRF": "1",
|
||||
"Idempotency-Key": "forge-route-api-0002",
|
||||
},
|
||||
content=b"",
|
||||
)
|
||||
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_route_get_is_authenticated_and_delegates_only_safe_reconcile(
|
||||
forge_route_operator_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
forge_route_stage,
|
||||
"reconcile_forge_route",
|
||||
lambda session, **kwargs: calls.append((session, kwargs)) or _safe_result("not_found"),
|
||||
)
|
||||
|
||||
unauthenticated = forge_route_operator_client.get(_PATH)
|
||||
assert unauthenticated.status_code == 401
|
||||
|
||||
_sign_in(forge_route_operator_client, "reader")
|
||||
response = forge_route_operator_client.get(_PATH)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == _safe_result("not_found")
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == {"job_id": _JOB_ID, "actor_identity": "authentik:reader"}
|
||||
|
||||
invalid_body = forge_route_operator_client.request("GET", _PATH, content=b"unexpected")
|
||||
assert invalid_body.status_code == 400
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_empty_request_body_guard_rejects_first_nonempty_stream_chunk():
|
||||
import asyncio
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from backend.app.api.routes_deployments import _require_empty_forge_route_request
|
||||
|
||||
receive_calls = []
|
||||
|
||||
async def receive():
|
||||
receive_calls.append(True)
|
||||
if len(receive_calls) == 1:
|
||||
return {"type": "http.request", "body": b"x", "more_body": True}
|
||||
return {"type": "http.request", "body": b"oversized remainder", "more_body": False}
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": _PATH,
|
||||
"raw_path": _PATH.encode(),
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("testserver", 80),
|
||||
"scheme": "http",
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(_require_empty_forge_route_request(request))
|
||||
|
||||
assert error.value.status_code == 400
|
||||
assert len(receive_calls) == 1
|
||||
@@ -0,0 +1,904 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from contextlib import nullcontext
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
|
||||
_JOB_ID = "forge-install-" + "d" * 64
|
||||
_REVISION = "059" + "a" * 37
|
||||
_APP_OPS_REVISION = "b" * 40
|
||||
_WORKSPACE_DIGEST = "sha256:" + "b" * 64
|
||||
_FRONTDOOR_DIGEST = "sha256:" + "c" * 64
|
||||
_NETWORK = "itworx-mcp-hub-npm"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def route_db(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'forge-route-stage.db'}")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield engine, session
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _job(session, *, status="deployed"):
|
||||
from backend.app.db.models import DeploymentJob
|
||||
from backend.app.services.forge_bootstrap_apply import _sha256
|
||||
|
||||
plan = {
|
||||
"api_version": "itworx.forge-first-install/v1",
|
||||
"revision": _REVISION,
|
||||
"workspace_image_digest": _WORKSPACE_DIGEST,
|
||||
"frontdoor_image_digest": _FRONTDOOR_DIGEST,
|
||||
"preflight_job_id": "forge-preflight-" + "e" * 16,
|
||||
"preflight_sha256": "f" * 64,
|
||||
}
|
||||
historical = {
|
||||
"apiVersion": "itworx.forge-first-install/v1",
|
||||
"status": "deployed",
|
||||
"request": "2" * 64,
|
||||
"repository": "jens/itworx-forge",
|
||||
"revision": _REVISION,
|
||||
"operation": "forge_first_install",
|
||||
"workspaceImageDigest": _WORKSPACE_DIGEST,
|
||||
"frontdoorImageDigest": _FRONTDOOR_DIGEST,
|
||||
"receipt": {
|
||||
"revision": _REVISION,
|
||||
"workspaceImageDigest": _WORKSPACE_DIGEST,
|
||||
"frontdoorImageDigest": _FRONTDOOR_DIGEST,
|
||||
"migration": {"service": "migrate", "status": "completed", "result": "succeeded"},
|
||||
"health": {
|
||||
"status": "healthy",
|
||||
"services": ["api", "dashboard", "itworx-forge", "postgres", "worker"],
|
||||
},
|
||||
},
|
||||
}
|
||||
job = DeploymentJob(
|
||||
id=_JOB_ID,
|
||||
application_id="itworx-forge",
|
||||
repository="jens/itworx-forge",
|
||||
revision=_REVISION,
|
||||
plan_sha256=_sha256(plan),
|
||||
idempotency_sha256="1" * 64,
|
||||
status=status,
|
||||
stage="controller_deployed" if status == "deployed" else "applying",
|
||||
plan_json=plan,
|
||||
preflight_json={
|
||||
"apiVersion": "itworx.forge-bootstrap-preflight/v1",
|
||||
"jobId": plan["preflight_job_id"],
|
||||
"sha256": plan["preflight_sha256"],
|
||||
},
|
||||
evidence_json={"profile": "forge-first-install", "controller": historical},
|
||||
broker_request_id="2" * 64,
|
||||
completed_at=datetime.now(UTC) if status == "deployed" else None,
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return job
|
||||
|
||||
|
||||
def _ready(job_id=_JOB_ID, *, status="ready", observed_at=None, migration_completed=True):
|
||||
from backend.app.schemas.forge_runtime_readiness import _RUNTIME_SERVICES
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"message": {
|
||||
"ready": "forge_runtime_ready",
|
||||
"not_ready": "forge_runtime_not_ready",
|
||||
"unavailable": "forge_runtime_readiness_unavailable",
|
||||
}[status],
|
||||
"target": "itworx-forge",
|
||||
"jobId": job_id,
|
||||
"revision": _REVISION,
|
||||
"workspaceImageDigest": _WORKSPACE_DIGEST,
|
||||
"frontdoorImageDigest": _FRONTDOOR_DIGEST,
|
||||
"observedAt": int(datetime.now(UTC).timestamp()) if observed_at is None else observed_at,
|
||||
"health": {
|
||||
"status": "healthy" if status == "ready" else "not_ready",
|
||||
"services": [
|
||||
{"name": name, "status": "healthy" if status == "ready" else "not_ready"}
|
||||
for name in _RUNTIME_SERVICES
|
||||
],
|
||||
},
|
||||
"migrationCompleted": migration_completed,
|
||||
"controllerArtifact": {
|
||||
"controllerVersion": "2.4.1",
|
||||
"sourceRepository": "jens/unraid-appops-gateway",
|
||||
"sourceRevision": _APP_OPS_REVISION,
|
||||
"sourceTree": "3" * 40,
|
||||
"artifactSha256": "4" * 64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _route_host(owner: str, *, enabled=False, domains=None):
|
||||
return {
|
||||
"id": 31,
|
||||
"domain_names": ["forge.example.com"] if domains is None else domains,
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "itworx-forge",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": 0,
|
||||
"certificate_id": 0,
|
||||
"enabled": enabled,
|
||||
"ssl_forced": False,
|
||||
"http2_support": False,
|
||||
"hsts_enabled": False,
|
||||
"hsts_subdomains": False,
|
||||
"caching_enabled": False,
|
||||
"block_exploits": True,
|
||||
"allow_websocket_upgrade": False,
|
||||
"trust_forwarded_proto": False,
|
||||
"locations": [],
|
||||
"advanced_config": "",
|
||||
"meta": {"appops_forge_route_id": owner},
|
||||
}
|
||||
|
||||
|
||||
class _FakeNpm:
|
||||
def __init__(
|
||||
self,
|
||||
hosts=None,
|
||||
*,
|
||||
create_error=None,
|
||||
permission_denied=False,
|
||||
host_enabled=False,
|
||||
endpoint="https://npm.example",
|
||||
):
|
||||
self.hosts = list(hosts or [])
|
||||
self.verified_tls = True
|
||||
self.endpoint_sha256 = hashlib.sha256(endpoint.encode("ascii")).hexdigest()
|
||||
self.create_error = create_error
|
||||
self.permission_denied = permission_denied
|
||||
self.host_enabled = host_enabled
|
||||
self.calls = []
|
||||
self.ownership_id = None
|
||||
|
||||
def forge_route_session(self):
|
||||
return self
|
||||
|
||||
def list_forge_route_hosts(self):
|
||||
self.calls.append("GET")
|
||||
return list(self.hosts)
|
||||
|
||||
def create_forge_disabled_proxy_host(self, ownership_id):
|
||||
if self.permission_denied:
|
||||
self.calls.append("PERMISSION_DENIED")
|
||||
from backend.app.connectors.nginx_proxy_manager import _NpmForgeRoutePermissionDenied
|
||||
|
||||
raise _NpmForgeRoutePermissionDenied()
|
||||
self.calls.append("POST")
|
||||
self.ownership_id = ownership_id
|
||||
if self.create_error:
|
||||
raise RuntimeError("secret-looking raw upstream detail must not escape")
|
||||
self.hosts.append(_route_host(ownership_id, enabled=self.host_enabled))
|
||||
return {"id": 31}
|
||||
|
||||
|
||||
class _FakePortainer:
|
||||
def __init__(self, result=None):
|
||||
self.result = result or {
|
||||
"status": "ready",
|
||||
"network": _NETWORK,
|
||||
"npmAttached": True,
|
||||
"frontdoorAttached": True,
|
||||
}
|
||||
self.calls = 0
|
||||
|
||||
def forge_route_network_readiness(self):
|
||||
self.calls += 1
|
||||
return self.result
|
||||
|
||||
|
||||
def _allow_route_policy(session):
|
||||
from backend.app.db.models import Policy
|
||||
|
||||
policy = Policy(
|
||||
id="policy-forge-route-stage",
|
||||
action="forge.route.stage",
|
||||
target="itworx-forge",
|
||||
manual="allowed",
|
||||
voice="blocked",
|
||||
risk="critical",
|
||||
)
|
||||
session.add(policy)
|
||||
session.commit()
|
||||
return policy
|
||||
|
||||
|
||||
def _wire_service(monkeypatch, *, ready=None, after_readiness=None):
|
||||
from backend.app.services import forge_route_stage
|
||||
from backend.app.services.forge_bootstrap_apply import ForgeBootstrapApplyService
|
||||
|
||||
monkeypatch.setattr(forge_route_stage, "_forge_oidc_process_lock", lambda: nullcontext())
|
||||
monkeypatch.setattr(forge_route_stage, "_secret_store_ready", lambda: True)
|
||||
|
||||
def current_readiness(_service, session, job_id):
|
||||
if after_readiness is not None:
|
||||
after_readiness(session)
|
||||
return ready or _ready(job_id)
|
||||
|
||||
monkeypatch.setattr(ForgeBootstrapApplyService, "current_readiness", current_readiness)
|
||||
monkeypatch.setattr(forge_route_stage, "_audit", lambda *args, **kwargs: True)
|
||||
return forge_route_stage
|
||||
|
||||
|
||||
def test_stage_persists_encrypted_one_shot_intent_before_single_fixed_post(
|
||||
route_db, monkeypatch
|
||||
):
|
||||
from backend.app.db.models import Secret
|
||||
from backend.app.services.secret_store import get_secret
|
||||
|
||||
engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm()
|
||||
fake_portainer = _FakePortainer()
|
||||
stage = _wire_service(monkeypatch)
|
||||
observed_intent = []
|
||||
original_create = fake_npm.create_forge_disabled_proxy_host
|
||||
observer_errors = []
|
||||
|
||||
def inspect_intent_before_post(owner):
|
||||
try:
|
||||
with Session(engine) as observer:
|
||||
secret_ref = stage._journal_secret_ref(job.id)
|
||||
row = observer.get(Secret, secret_ref)
|
||||
assert row is not None
|
||||
assert "route-stage-key-0001" not in row.encrypted_value
|
||||
assert owner not in row.encrypted_value
|
||||
record = json.loads(get_secret(observer, secret_ref, touch=False))
|
||||
assert record["phase"] == "pending"
|
||||
assert record["idempotencyKeySha256"] == __import__("hashlib").sha256(
|
||||
b"route-stage-key-0001"
|
||||
).hexdigest()
|
||||
assert record["ownershipId"] == owner
|
||||
expected_endpoint = hashlib.sha256(b"https://npm.example").hexdigest()
|
||||
assert record["npmEndpointSha256"] == expected_endpoint
|
||||
assert "npm.example" not in json.dumps(record)
|
||||
assert "nonce" not in record
|
||||
observed_intent.append(record)
|
||||
except Exception as exc:
|
||||
observer_errors.append(repr(exc))
|
||||
raise
|
||||
return original_create(owner)
|
||||
|
||||
fake_npm.create_forge_disabled_proxy_host = inspect_intent_before_post
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0001",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=fake_portainer,
|
||||
)
|
||||
|
||||
assert observed_intent, {"result": result, "calls": fake_npm.calls, "errors": observer_errors}
|
||||
assert result["status"] == "host_staged_disabled", result
|
||||
assert result["routeState"] == "host_staged_disabled"
|
||||
assert result["routed"] is False
|
||||
assert result["hostname"] == "forge.example.com"
|
||||
assert fake_npm.calls == ["GET", "POST", "GET"]
|
||||
assert fake_portainer.calls == 1
|
||||
assert observed_intent[0]["revision"] == _REVISION
|
||||
assert observed_intent[0]["desired"]["trust_forwarded_proto"] is False
|
||||
assert observed_intent[0]["desired"]["locations"] == []
|
||||
assert observed_intent[0]["workspaceImageDigest"] == _WORKSPACE_DIGEST
|
||||
assert observed_intent[0]["frontdoorImageDigest"] == _FRONTDOOR_DIGEST
|
||||
assert "secret-looking raw upstream detail" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_stage_records_permission_denial_as_aborted_before_post(route_db, monkeypatch):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm(permission_denied=True)
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0001",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
state, record = forge_route_stage._load_record(session, job.id)
|
||||
assert result["status"] == "blocked"
|
||||
assert result["message"] == "forge_route_npm_permission_required"
|
||||
assert fake_npm.calls == ["GET", "PERMISSION_DENIED"]
|
||||
assert "POST" not in fake_npm.calls
|
||||
assert state == "present" and record is not None
|
||||
assert record["phase"] == "aborted_before_post"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"readiness,network",
|
||||
[
|
||||
(_ready(status="not_ready"), None),
|
||||
(_ready(observed_at=1), None),
|
||||
(_ready(migration_completed=False), None),
|
||||
(None, {"status": "ready", "network": _NETWORK, "npmAttached": True, "frontdoorAttached": "true"}),
|
||||
],
|
||||
)
|
||||
def test_stage_fails_closed_on_stale_or_incomplete_readiness_or_network(
|
||||
route_db, monkeypatch, readiness, network
|
||||
):
|
||||
engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm()
|
||||
fake_portainer = _FakePortainer(network) if network is not None else _FakePortainer()
|
||||
stage = _wire_service(monkeypatch, ready=readiness)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0002",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=fake_portainer,
|
||||
)
|
||||
|
||||
assert result["status"] == "blocked"
|
||||
assert "POST" not in fake_npm.calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("jobId", "forge-install-" + "9" * 64),
|
||||
("revision", "f" * 40),
|
||||
("workspaceImageDigest", "sha256:" + "9" * 64),
|
||||
("frontdoorImageDigest", "sha256:" + "8" * 64),
|
||||
],
|
||||
)
|
||||
def test_stage_fails_closed_on_wrong_current_readiness_identity(route_db, monkeypatch, field, value):
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm()
|
||||
readiness = _ready(job.id)
|
||||
readiness[field] = value
|
||||
stage = _wire_service(monkeypatch, ready=readiness)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-wrong-readiness-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == "blocked"
|
||||
assert fake_npm.calls.count("POST") == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["job_status", "repository", "target", "profile_receipt"])
|
||||
def test_stage_fails_closed_on_nonexact_first_install_job(route_db, monkeypatch, case):
|
||||
from backend.app.services.forge_bootstrap_apply import _sha256
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
if case == "job_status":
|
||||
job.status = "applying"
|
||||
elif case == "repository":
|
||||
job.repository = "jens/another-repository"
|
||||
elif case == "target":
|
||||
job.application_id = "another-target"
|
||||
else:
|
||||
job.plan_json = {**job.plan_json, "frontdoor_image_digest": "sha256:" + "9" * 64}
|
||||
job.plan_sha256 = _sha256(job.plan_json)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
fake_npm = _FakeNpm()
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-nonexact-job-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == "blocked"
|
||||
assert fake_npm.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain,expected_status",
|
||||
[
|
||||
("*.example.com", "conflict"),
|
||||
("*.tech", "conflict"),
|
||||
("*.unrelated.example", "host_staged_disabled"),
|
||||
],
|
||||
)
|
||||
def test_stage_blocks_covering_wildcards_but_allows_unrelated_wildcard(
|
||||
route_db, monkeypatch, domain, expected_status
|
||||
):
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm([_route_host("froute-" + "8" * 32, domains=[domain])])
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-wildcard-0001",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == expected_status
|
||||
assert fake_npm.calls.count("POST") == (1 if expected_status == "host_staged_disabled" else 0)
|
||||
|
||||
|
||||
def test_stage_rejects_malformed_npm_domain_inventory_without_post(route_db, monkeypatch):
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm([_route_host("froute-" + "8" * 32, domains=["bad..example.com"])])
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-malformed-domain-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert fake_npm.calls == ["GET"]
|
||||
|
||||
|
||||
def test_stage_lock_time_policy_recheck_prevents_npm_post(route_db, monkeypatch):
|
||||
engine, session = route_db
|
||||
job = _job(session)
|
||||
policy = _allow_route_policy(session)
|
||||
fake_npm = _FakeNpm()
|
||||
stage = _wire_service(
|
||||
monkeypatch,
|
||||
after_readiness=lambda active_session: _block_route_policy(active_session),
|
||||
)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0003",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
session.refresh(policy)
|
||||
assert result["status"] == "blocked"
|
||||
assert policy.manual == "blocked"
|
||||
assert "POST" not in fake_npm.calls
|
||||
|
||||
|
||||
def test_readiness_expiring_after_intent_commit_aborts_before_npm_post(route_db, monkeypatch):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm()
|
||||
fake_time = [1000]
|
||||
monkeypatch.setattr(forge_route_stage.time, "time", lambda: fake_time[0])
|
||||
stage = _wire_service(monkeypatch, ready=_ready(job.id, observed_at=1000))
|
||||
original_save = stage._save_record
|
||||
first_save = [True]
|
||||
|
||||
def save_then_expire(active_session, record):
|
||||
result = original_save(active_session, record)
|
||||
if first_save[0]:
|
||||
first_save[0] = False
|
||||
fake_time[0] = 1031
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(stage, "_save_record", save_then_expire)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-expired-ready-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == "blocked"
|
||||
assert result["message"] == "forge_route_current_readiness_expired"
|
||||
assert "POST" not in fake_npm.calls
|
||||
|
||||
|
||||
def _block_route_policy(session):
|
||||
from backend.app.db.models import Policy
|
||||
|
||||
policy = session.exec(select(Policy).where(Policy.action == "forge.route.stage")).one()
|
||||
policy.manual = "blocked"
|
||||
session.add(policy)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_foreign_host_collision_is_conflict_and_never_adopted(route_db, monkeypatch):
|
||||
engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm([_route_host("froute-" + "9" * 32)])
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0004",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == "conflict"
|
||||
assert "POST" not in fake_npm.calls
|
||||
assert fake_npm.hosts[0]["meta"]["appops_forge_route_id"] == "froute-" + "9" * 32
|
||||
|
||||
|
||||
def test_get_reconciliation_audits_phase_change_once_and_skips_unchanged_write(route_db, monkeypatch):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm(create_error=RuntimeError("uncertain"))
|
||||
stage = _wire_service(monkeypatch)
|
||||
first = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-get-audit-0001",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
assert first["status"] == "outcome_unknown"
|
||||
fake_npm.create_error = None
|
||||
fake_npm.hosts = [_route_host(fake_npm.ownership_id)]
|
||||
audit_calls = []
|
||||
def audit_and_commit(session, **kwargs):
|
||||
audit_calls.append(kwargs)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(forge_route_stage, "_audit", audit_and_commit)
|
||||
|
||||
reconciled = forge_route_stage.reconcile_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
actor_identity="authentik:operator-42",
|
||||
npm=fake_npm,
|
||||
)
|
||||
assert reconciled["status"] == "host_staged_disabled"
|
||||
assert len(audit_calls) == 1
|
||||
assert audit_calls[0]["actor_identity"] == "authentik:operator-42"
|
||||
assert audit_calls[0]["decision"] == "allowed"
|
||||
assert audit_calls[0]["policy_id"] is None
|
||||
assert audit_calls[0]["result"]["routed"] is False
|
||||
assert audit_calls[0]["idempotency_hash"] == hashlib.sha256(
|
||||
b"route-stage-get-audit-0001"
|
||||
).hexdigest()
|
||||
|
||||
original_save = forge_route_stage._save_record
|
||||
writes = []
|
||||
|
||||
def track_save(session, record):
|
||||
writes.append(record["phase"])
|
||||
return original_save(session, record)
|
||||
|
||||
monkeypatch.setattr(forge_route_stage, "_save_record", track_save)
|
||||
polled = forge_route_stage.reconcile_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
actor_identity="authentik:operator-42",
|
||||
npm=fake_npm,
|
||||
)
|
||||
assert polled["status"] == "host_staged_disabled"
|
||||
assert writes == []
|
||||
assert len(audit_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_message", ["timeout", "npm_api_error"])
|
||||
def test_uncertain_post_replays_and_reconciles_with_get_only(route_db, monkeypatch, error_message):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm(create_error=RuntimeError(error_message))
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
first = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0005",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
replay = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0005",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
other_key = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-other",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert first["status"] == "outcome_unknown"
|
||||
assert replay["status"] == "outcome_unknown"
|
||||
assert other_key["status"] == "conflict", other_key
|
||||
assert fake_npm.calls.count("POST") == 1
|
||||
assert fake_npm.calls[:4] == ["GET", "POST", "GET", "GET"]
|
||||
|
||||
fake_npm.create_error = None
|
||||
fake_npm.hosts = [_route_host(fake_npm.ownership_id)]
|
||||
reconciled = forge_route_stage.reconcile_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
)
|
||||
|
||||
assert reconciled["status"] == "host_staged_disabled"
|
||||
assert fake_npm.calls.count("POST") == 1
|
||||
|
||||
|
||||
def test_reconcile_refuses_changed_npm_endpoint_without_get(route_db, monkeypatch):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
original_npm = _FakeNpm(
|
||||
create_error=RuntimeError("timeout"), endpoint="https://npm.example/npm-a"
|
||||
)
|
||||
stage = _wire_service(monkeypatch)
|
||||
first = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-endpoint-bind-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=original_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
assert first["status"] == "outcome_unknown"
|
||||
state, record = forge_route_stage._load_record(session, job.id)
|
||||
assert state == "present" and record is not None
|
||||
assert record["npmEndpointSha256"] == hashlib.sha256(
|
||||
b"https://npm.example/npm-a"
|
||||
).hexdigest()
|
||||
assert "/npm-a" not in json.dumps(record)
|
||||
changed_npm = _FakeNpm(endpoint="https://npm.example/npm-b")
|
||||
|
||||
reconciled = forge_route_stage.reconcile_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
actor_identity="authentik:owner",
|
||||
npm=changed_npm,
|
||||
)
|
||||
|
||||
assert first["status"] == "outcome_unknown"
|
||||
assert reconciled["status"] == "conflict"
|
||||
assert reconciled["message"] == "forge_route_endpoint_binding_changed"
|
||||
assert changed_npm.calls == []
|
||||
|
||||
|
||||
def test_concurrent_same_key_requests_serialize_to_one_npm_post(route_db, monkeypatch):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Lock
|
||||
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
engine, session = route_db
|
||||
job = _job(session)
|
||||
job_id = job.id
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm()
|
||||
stage = _wire_service(monkeypatch)
|
||||
route_lock = Lock()
|
||||
monkeypatch.setattr(forge_route_stage, "_forge_oidc_process_lock", lambda: route_lock)
|
||||
|
||||
def call_stage():
|
||||
with Session(engine) as worker_session:
|
||||
return stage.stage_forge_route(
|
||||
worker_session,
|
||||
job_id=job_id,
|
||||
idempotency_key="route-stage-concurrent-0001",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda _index: call_stage(), range(2)))
|
||||
|
||||
assert [item["status"] for item in results] == [
|
||||
"host_staged_disabled",
|
||||
"host_staged_disabled",
|
||||
]
|
||||
assert fake_npm.calls.count("POST") == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conflicting_hosts", [
|
||||
lambda owner: [_route_host("froute-" + "9" * 32)],
|
||||
lambda owner: [_route_host(owner), _route_host(owner)],
|
||||
])
|
||||
def test_reconciliation_never_adopts_wrong_or_duplicate_ownership_marker(
|
||||
route_db, monkeypatch, conflicting_hosts
|
||||
):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm(create_error=RuntimeError("timeout"))
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
first = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-marker-conflict-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
assert first["status"] == "outcome_unknown"
|
||||
fake_npm.hosts = conflicting_hosts(fake_npm.ownership_id)
|
||||
|
||||
reconciled = forge_route_stage.reconcile_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
)
|
||||
|
||||
assert reconciled["status"] == "conflict"
|
||||
assert fake_npm.calls.count("POST") == 1
|
||||
|
||||
|
||||
def test_stage_refuses_string_boolean_or_enabled_host_readback(route_db, monkeypatch):
|
||||
engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
fake_npm = _FakeNpm(host_enabled="false")
|
||||
stage = _wire_service(monkeypatch)
|
||||
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-key-0006",
|
||||
actor_identity="authentik:owner",
|
||||
npm=fake_npm,
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
|
||||
assert result["status"] == "conflict"
|
||||
assert fake_npm.calls.count("POST") == 1
|
||||
assert fake_npm.hosts[0]["enabled"] == "false"
|
||||
|
||||
|
||||
def test_readiness_binds_distinct_authenticated_controller_revision(route_db, monkeypatch):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
profile = forge_route_stage._verified_job(session, job.id)[1]
|
||||
readiness = _ready(job.id)
|
||||
|
||||
identity = forge_route_stage._readiness_identity(readiness, job=job, profile=profile)
|
||||
|
||||
assert identity is not None
|
||||
assert identity["revision"] == _REVISION
|
||||
assert identity["controllerArtifact"]["sourceRevision"] == _APP_OPS_REVISION
|
||||
assert identity["revision"] != identity["controllerArtifact"]["sourceRevision"]
|
||||
|
||||
readiness["controllerArtifact"]["sourceRepository"] = "jens/itworx-forge"
|
||||
assert forge_route_stage._readiness_identity(readiness, job=job, profile=profile) is None
|
||||
|
||||
|
||||
def test_record_validation_recomputes_readiness_identity_digest(route_db, monkeypatch):
|
||||
from backend.app.services import forge_route_stage
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
stage = _wire_service(monkeypatch)
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-record-hash-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=_FakeNpm(),
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
assert result["status"] == "host_staged_disabled"
|
||||
state, record = forge_route_stage._load_record(session, job.id)
|
||||
assert state == "present" and record is not None
|
||||
|
||||
record["readinessIdentity"]["observedAt"] += 1
|
||||
assert forge_route_stage._record_valid(record, job.id) is False
|
||||
|
||||
|
||||
def test_record_loader_rejects_duplicate_keys_and_noncanonical_json(route_db, monkeypatch):
|
||||
from backend.app.db.models import Secret
|
||||
from backend.app.services import forge_route_stage
|
||||
from backend.app.services.secret_store import get_secret
|
||||
|
||||
_engine, session = route_db
|
||||
job = _job(session)
|
||||
_allow_route_policy(session)
|
||||
stage = _wire_service(monkeypatch)
|
||||
result = stage.stage_forge_route(
|
||||
session,
|
||||
job_id=job.id,
|
||||
idempotency_key="route-stage-record-json-01",
|
||||
actor_identity="authentik:owner",
|
||||
npm=_FakeNpm(),
|
||||
portainer=_FakePortainer(),
|
||||
)
|
||||
assert result["status"] == "host_staged_disabled"
|
||||
secret_ref = forge_route_stage._journal_secret_ref(job.id)
|
||||
assert session.get(Secret, secret_ref) is not None
|
||||
original_get_secret = get_secret(session, secret_ref, touch=False)
|
||||
assert original_get_secret is not None
|
||||
|
||||
monkeypatch.setattr(forge_route_stage, "get_secret", lambda *_args, **_kwargs: original_get_secret[:-1] + ',"phase":"pending"}')
|
||||
assert forge_route_stage._load_record(session, job.id) == ("invalid", None)
|
||||
|
||||
monkeypatch.setattr(forge_route_stage, "get_secret", lambda *_args, **_kwargs: " " + original_get_secret)
|
||||
assert forge_route_stage._load_record(session, job.id) == ("invalid", None)
|
||||
|
||||
|
||||
def test_route_host_readback_requires_exact_false_trust_forwarded_proto():
|
||||
from backend.app.services.forge_route_stage import (
|
||||
_desired_state,
|
||||
_host_is_exact_disabled,
|
||||
)
|
||||
|
||||
owner = "froute-" + "a" * 32
|
||||
desired = _desired_state(owner)
|
||||
assert desired["trust_forwarded_proto"] is False
|
||||
assert desired["locations"] == []
|
||||
record = {"desired": desired, "ownershipId": owner}
|
||||
host = _route_host(owner)
|
||||
|
||||
assert _host_is_exact_disabled(host, record) is True
|
||||
for untrusted_value in (True, 0, "false", None):
|
||||
changed = dict(host)
|
||||
changed["trust_forwarded_proto"] = untrusted_value
|
||||
assert _host_is_exact_disabled(changed, record) is False
|
||||
|
||||
missing = dict(host)
|
||||
missing.pop("trust_forwarded_proto")
|
||||
assert _host_is_exact_disabled(missing, record) is False
|
||||
@@ -180,19 +180,70 @@ def test_descriptor_can_select_primary_service_for_multi_service_project():
|
||||
assert contract is not None
|
||||
assert contract["applicationId"] == "api"
|
||||
assert contract["composeProject"] == "api"
|
||||
assert contract["primaryService"] == "api"
|
||||
assert contract["serviceNames"] == ["api", "worker"]
|
||||
assert contract["containers"] == ["api-api-1", "api-worker-1"]
|
||||
|
||||
|
||||
def test_descriptor_cannot_diverge_compose_project_from_application_id():
|
||||
with pytest.raises(ValueError, match="composeProject must equal applicationId"):
|
||||
def test_descriptor_separates_application_identity_compose_project_and_primary_service():
|
||||
descriptor = {
|
||||
"apiVersion": "itworx.deployment-target/v1",
|
||||
"applicationId": "quizmonitor",
|
||||
"composeProject": "quiz-monitor",
|
||||
"primaryService": "quiz-server",
|
||||
"composeFile": "docker-compose.yml",
|
||||
}
|
||||
service = FakeComposeInspection(
|
||||
[".itworx", "docker-compose.yml"],
|
||||
{
|
||||
".itworx/deployment-target.json": json.dumps(descriptor),
|
||||
"docker-compose.yml": "services:\n quiz-server:\n image: example/quiz-server:1\n",
|
||||
},
|
||||
)
|
||||
|
||||
contract = service._compose_contract(
|
||||
FakeClient(), repository_name="QuizMonitor", revision="d" * 40
|
||||
)
|
||||
|
||||
assert contract == {
|
||||
"applicationId": "quizmonitor",
|
||||
"composeFile": "docker-compose.yml",
|
||||
"composeProject": "quiz-monitor",
|
||||
"primaryService": "quiz-server",
|
||||
"containers": ["quiz-monitor-quiz-server-1"],
|
||||
"serviceNames": ["quiz-server"],
|
||||
"healthTimeout": 180,
|
||||
"verifyOciRevision": False,
|
||||
}
|
||||
|
||||
|
||||
def test_descriptor_rejects_invalid_compose_project_and_missing_primary_service():
|
||||
with pytest.raises(ValueError, match="composeProject is invalid"):
|
||||
GiteaProjectAutoRegistration._safe_descriptor(
|
||||
{
|
||||
"apiVersion": "itworx.deployment-target/v1",
|
||||
"applicationId": "api",
|
||||
"composeProject": "different",
|
||||
"applicationId": "quizmonitor",
|
||||
"composeProject": "../quiz-monitor",
|
||||
"primaryService": "quiz-server",
|
||||
},
|
||||
"Example",
|
||||
"QuizMonitor",
|
||||
)
|
||||
|
||||
service = FakeComposeInspection(
|
||||
[".itworx", "compose.yml"],
|
||||
{
|
||||
".itworx/deployment-target.json": json.dumps({
|
||||
"apiVersion": "itworx.deployment-target/v1",
|
||||
"applicationId": "quizmonitor",
|
||||
"composeProject": "quiz-monitor",
|
||||
"primaryService": "quiz-server",
|
||||
}),
|
||||
"compose.yml": "services:\n web:\n image: example/web:1\n",
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match="primaryService must name a Compose service"):
|
||||
service._compose_contract(
|
||||
FakeClient(), repository_name="QuizMonitor", revision="e" * 40
|
||||
)
|
||||
|
||||
|
||||
@@ -236,6 +287,39 @@ def test_sync_catalogs_every_repo_but_only_activates_proven_compose_targets():
|
||||
assert all(item.voice == "blocked" for item in policies)
|
||||
|
||||
|
||||
def test_sync_registers_quizmonitor_identity_without_changing_compose_project_or_service():
|
||||
registry = FakeTargetRegistry()
|
||||
contract = {
|
||||
"applicationId": "quizmonitor",
|
||||
"composeFile": "docker-compose.yml",
|
||||
"composeProject": "quiz-monitor",
|
||||
"primaryService": "quiz-server",
|
||||
"containers": ["quiz-monitor-quiz-server-1"],
|
||||
"serviceNames": ["quiz-server"],
|
||||
"healthTimeout": 180,
|
||||
"verifyOciRevision": False,
|
||||
}
|
||||
service = FakeProjectSync(
|
||||
[repo("QuizMonitor")],
|
||||
{"QuizMonitor": contract},
|
||||
target_registry=registry,
|
||||
)
|
||||
|
||||
with make_session() as session:
|
||||
result = service.sync(session)
|
||||
catalog = GiteaProjectAutoRegistration.catalog(session)
|
||||
|
||||
assert result["summary"]["registered"] == 1
|
||||
assert len(registry.calls) == 1
|
||||
repository, revision, kwargs = registry.calls[0]
|
||||
assert repository == "jens/quizmonitor"
|
||||
assert kwargs["compose_project"] == "quiz-monitor"
|
||||
assert kwargs["containers"] == ["quiz-monitor-quiz-server-1"]
|
||||
assert catalog["projects"][0]["applicationId"] == "quizmonitor"
|
||||
assert catalog["projects"][0]["composeProject"] == "quiz-monitor"
|
||||
assert catalog["projects"][0]["primaryService"] == "quiz-server"
|
||||
|
||||
|
||||
def test_unchanged_catalog_entries_do_not_repeat_registration_work():
|
||||
registry = FakeTargetRegistry()
|
||||
repositories = [repo("Deployable")]
|
||||
|
||||
@@ -194,6 +194,266 @@ def test_run_identity_requires_exact_protected_push_and_no_pull_request(modules)
|
||||
assert not importer._run_matches({**run, **change}, revision)
|
||||
|
||||
|
||||
def test_connector_ingestion_binding_is_scoped_to_authenticated_run_attempt(modules):
|
||||
controller = modules.controller
|
||||
revision = "a" * 40
|
||||
run_id, attempt = 21531, 2
|
||||
repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES["connector-ingestion"]
|
||||
archive_ref = f"{repository}:{revision}-run-{run_id}-attempt-{attempt}"
|
||||
digest = "d" * 64
|
||||
image_ref = f"{archive_ref}@sha256:{digest}"
|
||||
image_id = "sha256:" + "e" * 64
|
||||
|
||||
binding = controller.mcp_hub_connector_ingestion_binding(
|
||||
revision, run_id, attempt, image_ref, image_id
|
||||
)
|
||||
|
||||
assert binding == {
|
||||
"workflowRunId": run_id,
|
||||
"workflowRunAttempt": attempt,
|
||||
"archiveRef": archive_ref,
|
||||
"imageRef": image_ref,
|
||||
"imageId": image_id,
|
||||
}
|
||||
for invalid_ref in (
|
||||
f"{repository}:{revision}-run-{run_id}@sha256:{digest}",
|
||||
f"{repository}:{revision}-run-{run_id}-attempt-1@sha256:{digest}",
|
||||
f"attacker.example/{repository.rsplit('/', 1)[-1]}:{revision}-run-{run_id}-attempt-{attempt}@sha256:{digest}",
|
||||
f"{archive_ref}@sha256:{'F' * 64}",
|
||||
f"{archive_ref}@sha256:{digest}:extra",
|
||||
):
|
||||
with pytest.raises(controller.DeployError):
|
||||
controller.mcp_hub_connector_ingestion_binding(
|
||||
revision, run_id, attempt, invalid_ref, image_id
|
||||
)
|
||||
with pytest.raises(controller.DeployError):
|
||||
controller.mcp_hub_connector_ingestion_binding(
|
||||
revision, run_id, 0, image_ref, image_id
|
||||
)
|
||||
|
||||
|
||||
def test_candidate_ingestion_binding_overrides_only_fixed_hub_render_environment(
|
||||
modules,
|
||||
):
|
||||
controller = modules.controller
|
||||
revision, tree = "a" * 40, "b" * 40
|
||||
run_id, attempt = 21531, 2
|
||||
repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES["connector-ingestion"]
|
||||
archive_ref = f"{repository}:{revision}-run-{run_id}-attempt-{attempt}"
|
||||
image_ref = f"{archive_ref}@sha256:{'d' * 64}"
|
||||
binding = controller.mcp_hub_connector_ingestion_binding(
|
||||
revision, run_id, attempt, image_ref, "sha256:" + "e" * 64
|
||||
)
|
||||
project = {
|
||||
"compose_project": controller.MCP_HUB_PROJECT,
|
||||
"_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT,
|
||||
"_mcp_hub_connector_ingestion_binding": binding,
|
||||
"environment": {"CONNECTOR_INGESTION_IMAGE": "attacker.invalid/image:latest"},
|
||||
}
|
||||
metadata = {
|
||||
"id": revision[:12],
|
||||
"repository": controller.MCP_HUB_REPOSITORY,
|
||||
"revision": revision,
|
||||
"tree": tree,
|
||||
"treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(),
|
||||
"builtAt": "2026-10-03T12:00:00.000Z",
|
||||
}
|
||||
|
||||
environment = controller.deployment_environment(
|
||||
project, revision, controller.MCP_HUB_REPOSITORY, metadata
|
||||
)
|
||||
|
||||
assert environment["CONNECTOR_INGESTION_IMAGE"] == image_ref
|
||||
|
||||
project["_mcp_hub_connector_ingestion_binding"]["archiveRef"] += "-tampered"
|
||||
with pytest.raises(controller.DeployError):
|
||||
controller.deployment_environment(
|
||||
project, revision, controller.MCP_HUB_REPOSITORY, metadata
|
||||
)
|
||||
|
||||
|
||||
def test_registered_hub_cannot_supply_private_ingestion_binding(modules):
|
||||
controller = modules.controller
|
||||
raw = {
|
||||
"branch": "main",
|
||||
"slug": controller.MCP_HUB_PROJECT,
|
||||
"compose_project": controller.MCP_HUB_PROJECT,
|
||||
"containers": list(controller.MCP_HUB_LEGACY_CONTAINERS),
|
||||
"source_dir": (
|
||||
controller.MCP_HUB_APPDATA_ROOT / "deployment" / "repository"
|
||||
).as_posix(),
|
||||
"compose_dir": "infrastructure/compose",
|
||||
"compose_files": list(controller.MCP_HUB_LEGACY_COMPOSE_FILES),
|
||||
"env_source": (controller.MCP_HUB_APPDATA_ROOT / "deploy.env").as_posix(),
|
||||
"_mcp_hub_connector_ingestion_binding": {"imageRef": "attacker.invalid/x"},
|
||||
}
|
||||
|
||||
effective = controller.effective_mcp_hub_project(
|
||||
controller.MCP_HUB_REPOSITORY, raw
|
||||
)
|
||||
|
||||
assert "_mcp_hub_connector_ingestion_binding" not in effective
|
||||
|
||||
|
||||
def test_import_manifest_ingestion_binding_rejects_wrong_run_or_archive_tag(modules):
|
||||
importer = modules.importer
|
||||
revision, tree = "a" * 40, "b" * 40
|
||||
run = {"id": 21531, "run_attempt": 2}
|
||||
repository = importer.controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[
|
||||
"connector-ingestion"
|
||||
]
|
||||
archive_ref = f"{repository}:{revision}-run-{run['id']}-attempt-{run['run_attempt']}"
|
||||
row = {
|
||||
"imageRef": f"{archive_ref}@sha256:{'d' * 64}",
|
||||
"archiveRef": archive_ref,
|
||||
"imageId": "sha256:" + "e" * 64,
|
||||
}
|
||||
manifest = {
|
||||
"schema": importer.ARTIFACT_SCHEMA,
|
||||
"repository": importer.REPOSITORY,
|
||||
"sourceRevision": revision,
|
||||
"sourceTree": tree,
|
||||
"workflowPath": importer.WORKFLOW_PATH,
|
||||
"workflowRunId": run["id"],
|
||||
"artifactName": importer.ARTIFACT_PREFIX + revision,
|
||||
"images": {
|
||||
service: row if service == "connector-ingestion" else {}
|
||||
for service in importer.controller.MCP_HUB_SERVICES
|
||||
},
|
||||
}
|
||||
|
||||
binding = importer._manifest_ingestion_binding(
|
||||
manifest, revision=revision, tree=tree, run=run
|
||||
)
|
||||
assert binding["imageRef"] == row["imageRef"]
|
||||
assert binding["archiveRef"] == archive_ref
|
||||
|
||||
for changed in (
|
||||
{**manifest, "workflowRunId": run["id"] + 1},
|
||||
{
|
||||
**manifest,
|
||||
"images": {
|
||||
**manifest["images"],
|
||||
"connector-ingestion": {
|
||||
**row,
|
||||
"archiveRef": archive_ref.replace("-attempt-2", "-attempt-1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
):
|
||||
with pytest.raises(importer.CandidateRejected):
|
||||
importer._manifest_ingestion_binding(
|
||||
changed, revision=revision, tree=tree, run=run
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("drift", [None, "image_id", "repo_digest", "provenance"])
|
||||
def test_import_immutably_pulls_and_reads_back_connector_ingestion(
|
||||
modules, monkeypatch, drift
|
||||
):
|
||||
importer = modules.importer
|
||||
controller = modules.controller
|
||||
revision, tree, run_id, attempt = "a" * 40, "b" * 40, 21531, 2
|
||||
built_at = "2026-10-03T12:00:00.000Z"
|
||||
build = {
|
||||
"id": revision[:12],
|
||||
"repository": controller.MCP_HUB_REPOSITORY,
|
||||
"revision": revision,
|
||||
"tree": tree,
|
||||
"treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(),
|
||||
"builtAt": built_at,
|
||||
}
|
||||
labels = {
|
||||
"org.opencontainers.image.source": controller.MCP_HUB_OCI_IMAGE_SOURCE,
|
||||
"org.opencontainers.image.revision": revision,
|
||||
"org.opencontainers.image.source-tree": tree,
|
||||
"org.opencontainers.image.version": revision[:12],
|
||||
"org.opencontainers.image.created": built_at,
|
||||
"org.opencontainers.image.source-date-epoch": "1790985600",
|
||||
}
|
||||
images = {}
|
||||
for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1):
|
||||
external = importer.EXTERNAL_IMAGES.get(service)
|
||||
if external:
|
||||
image_ref, archive_ref = external[0], external[1]
|
||||
elif service == "connector-ingestion":
|
||||
repo = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]
|
||||
archive_ref = f"{repo}:{revision}-run-{run_id}-attempt-{attempt}"
|
||||
image_ref = f"{archive_ref}@sha256:{'d' * 64}"
|
||||
elif service == "contextforge":
|
||||
image_ref = archive_ref = f"itworx-mcp-hub/contextforge-schema:{revision}"
|
||||
else:
|
||||
image_ref = archive_ref = f"fixture/{service}:{revision}"
|
||||
images[service] = {
|
||||
"imageRef": image_ref,
|
||||
"archiveRef": archive_ref,
|
||||
"imageId": "sha256:" + f"{index:064x}",
|
||||
}
|
||||
ingestion = images["connector-ingestion"]
|
||||
immutable_ref = ingestion["imageRef"]
|
||||
repo = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES["connector-ingestion"]
|
||||
immutable_inspect = {
|
||||
"Id": ingestion["imageId"],
|
||||
"RepoDigests": [f"{repo}@sha256:{'d' * 64}"],
|
||||
"Config": {"User": "10001:10001", "Labels": dict(labels)},
|
||||
}
|
||||
tagged_inspect = {"Id": ingestion["imageId"], "Config": {"Labels": dict(labels)}}
|
||||
if drift == "image_id":
|
||||
immutable_inspect["Id"] = "sha256:" + "f" * 64
|
||||
elif drift == "repo_digest":
|
||||
immutable_inspect["RepoDigests"] = []
|
||||
elif drift == "provenance":
|
||||
immutable_inspect["Config"]["Labels"][
|
||||
"org.opencontainers.image.source-tree"
|
||||
] = "c" * 40
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_inspect_image",
|
||||
lambda ref: tagged_inspect if ref == ingestion["archiveRef"] else immutable_inspect,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_require_loaded_image",
|
||||
lambda _service, row, **_kwargs: {
|
||||
"Id": row["imageId"],
|
||||
"Config": {"User": "10001:10001", "Labels": dict(labels)},
|
||||
},
|
||||
)
|
||||
docker_calls = []
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_fixed_docker",
|
||||
lambda *args, **kwargs: docker_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
if drift is None:
|
||||
result = importer._verify_loaded_images(
|
||||
images,
|
||||
revision,
|
||||
build,
|
||||
"1790985600",
|
||||
pull_external=True,
|
||||
ingestion_run_id=run_id,
|
||||
ingestion_run_attempt=attempt,
|
||||
)
|
||||
assert result["connector-ingestion"] == ingestion["imageId"]
|
||||
assert any(
|
||||
call[0] == ("pull", immutable_ref)
|
||||
for call in docker_calls
|
||||
)
|
||||
else:
|
||||
with pytest.raises(importer.CandidateRejected):
|
||||
importer._verify_loaded_images(
|
||||
images,
|
||||
revision,
|
||||
build,
|
||||
"1790985600",
|
||||
pull_external=True,
|
||||
ingestion_run_id=run_id,
|
||||
ingestion_run_attempt=attempt,
|
||||
)
|
||||
|
||||
|
||||
def test_selected_run_and_jobs_accept_the_live_gitea_job_shape(modules):
|
||||
importer = modules.importer
|
||||
revision = "a" * 40
|
||||
@@ -353,12 +613,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
images = {}
|
||||
for index, service in enumerate(controller.MCP_HUB_SERVICES, start=1):
|
||||
external = importer.EXTERNAL_IMAGES.get(service)
|
||||
image_ref = (
|
||||
external[0]
|
||||
if external is not None
|
||||
else f"registry.example/itworx/{service}:{revision[:12]}"
|
||||
)
|
||||
archive_ref = external[1] if external is not None else image_ref
|
||||
if external is not None:
|
||||
image_ref, archive_ref = external[0], external[1]
|
||||
elif service == "connector-ingestion":
|
||||
repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]
|
||||
archive_ref = f"{repository}:{revision}-run-21531-attempt-2"
|
||||
image_ref = f"{archive_ref}@sha256:{'f' * 64}"
|
||||
else:
|
||||
image_ref = f"registry.example/itworx/{service}:{revision[:12]}"
|
||||
archive_ref = image_ref
|
||||
model_services[service] = {"image": image_ref}
|
||||
images[service] = {
|
||||
"imageRef": image_ref,
|
||||
@@ -449,8 +712,8 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
)
|
||||
monkeypatch.setattr(importer, "_remote_main_tree", lambda *_args: tree)
|
||||
monkeypatch.setattr(importer, "_fixed_workflow", lambda *_args: None)
|
||||
run = {"id": 21531, "run_attempt": 1}
|
||||
run_state = {"attempt": 1}
|
||||
run = {"id": 21531, "run_attempt": 2}
|
||||
run_state = {"attempt": 2}
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_selected_run",
|
||||
@@ -467,14 +730,35 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
monkeypatch.setattr(importer, "_candidate_artifact", lambda *_args: artifact)
|
||||
monkeypatch.setattr(importer, "_docker_root", lambda: tmp_path)
|
||||
monkeypatch.setattr(importer, "_check_disk_headroom", lambda **_kwargs: None)
|
||||
monkeypatch.setattr(importer, "_parse_artifact_zip", lambda *_args, **_kwargs: (
|
||||
{},
|
||||
report_data,
|
||||
hashlib.sha256(artifact_bytes).hexdigest(),
|
||||
"d" * 64,
|
||||
"e" * 64,
|
||||
123,
|
||||
))
|
||||
artifact_manifest = {
|
||||
"schema": importer.ARTIFACT_SCHEMA,
|
||||
"repository": importer.REPOSITORY,
|
||||
"sourceRevision": revision,
|
||||
"sourceTree": tree,
|
||||
"workflowPath": importer.WORKFLOW_PATH,
|
||||
"workflowRunId": run["id"],
|
||||
"artifactName": importer.ARTIFACT_PREFIX + revision,
|
||||
"images": {
|
||||
service: {
|
||||
"imageRef": row["imageRef"],
|
||||
"imageId": row["imageId"],
|
||||
"archiveRef": row["archiveRef"],
|
||||
}
|
||||
for service, row in images.items()
|
||||
},
|
||||
}
|
||||
|
||||
def fake_parse_artifact(*_args, **_kwargs):
|
||||
return (
|
||||
artifact_manifest,
|
||||
report_data,
|
||||
hashlib.sha256(artifact_bytes).hexdigest(),
|
||||
"d" * 64,
|
||||
"e" * 64,
|
||||
123,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(importer, "_parse_artifact_zip", fake_parse_artifact)
|
||||
monkeypatch.setattr(
|
||||
importer,
|
||||
"_validate_manifest",
|
||||
@@ -486,11 +770,17 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
|
||||
def fake_fixed_docker(*args, **_kwargs):
|
||||
if rerun_during_load and args and args[0] == "load":
|
||||
run_state["attempt"] = 2
|
||||
run_state["attempt"] = 3
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(importer, "_fixed_docker", fake_fixed_docker)
|
||||
monkeypatch.setattr(importer, "_verify_loaded_images", lambda *_args, **_kwargs: {})
|
||||
verified_runs = []
|
||||
|
||||
def verify_loaded(_images, _revision, _build, _epoch, **kwargs):
|
||||
verified_runs.append(kwargs)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(importer, "_verify_loaded_images", verify_loaded)
|
||||
monkeypatch.setattr(
|
||||
controller,
|
||||
"_mcp_hub_check_candidate_exceptions",
|
||||
@@ -502,7 +792,18 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
lambda *_args: draft,
|
||||
)
|
||||
|
||||
def fake_plan(_repository, _revision, *, built_at, staging_directory, **_kwargs):
|
||||
planned_bindings = []
|
||||
|
||||
def fake_plan(
|
||||
_repository,
|
||||
_revision,
|
||||
*,
|
||||
built_at,
|
||||
staging_directory,
|
||||
connector_ingestion_binding,
|
||||
**_kwargs,
|
||||
):
|
||||
planned_bindings.append(connector_ingestion_binding)
|
||||
Path(staging_directory).mkdir(mode=0o700)
|
||||
frozen = {
|
||||
"schema": prepare.BUILD_SCHEMA,
|
||||
@@ -519,7 +820,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
|
||||
monkeypatch.setattr(prepare, "plan_candidate", fake_plan)
|
||||
|
||||
def fake_capture(_repository, _revision, *, staging_directory, **_kwargs):
|
||||
def fake_capture(
|
||||
_repository,
|
||||
_revision,
|
||||
*,
|
||||
staging_directory,
|
||||
connector_ingestion_binding,
|
||||
**_kwargs,
|
||||
):
|
||||
assert connector_ingestion_binding == planned_bindings[0]
|
||||
image_rows = {
|
||||
service: {
|
||||
"imageRef": images[service]["imageRef"],
|
||||
@@ -570,6 +879,15 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
_private_file(destination, artifact_bytes)
|
||||
return None
|
||||
|
||||
def fake_no_overwrite_publish(stage, final_directory):
|
||||
if final_directory.exists() or final_directory.is_symlink():
|
||||
importer._reject(
|
||||
"MCP Hub candidate destination already exists; import never overwrites"
|
||||
)
|
||||
final_directory.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
stage.rename(final_directory)
|
||||
|
||||
monkeypatch.setattr(importer, "_publish_candidate", fake_no_overwrite_publish)
|
||||
candidate_directory = state_root / "mcp-hub-release-candidates" / revision
|
||||
if rerun_during_load:
|
||||
with pytest.raises(importer.CandidateRejected, match="run changed"):
|
||||
@@ -589,7 +907,22 @@ def test_fake_local_import_publishes_candidate_and_requires_review_for_findings(
|
||||
download_artifact=download_artifact,
|
||||
)
|
||||
assert candidate_directory.is_dir()
|
||||
assert stat.S_IMODE(candidate_directory.stat().st_mode) == 0o700
|
||||
assert planned_bindings[0]["workflowRunId"] == run["id"]
|
||||
assert planned_bindings[0]["workflowRunAttempt"] == run["run_attempt"]
|
||||
assert verified_runs == [
|
||||
{
|
||||
"pull_external": True,
|
||||
"ingestion_run_id": run["id"],
|
||||
"ingestion_run_attempt": run["run_attempt"],
|
||||
},
|
||||
{
|
||||
"pull_external": False,
|
||||
"ingestion_run_id": run["id"],
|
||||
"ingestion_run_attempt": run["run_attempt"],
|
||||
},
|
||||
]
|
||||
if importer.os.name != "nt":
|
||||
assert stat.S_IMODE(candidate_directory.stat().st_mode) == 0o700
|
||||
candidate_before_review = (candidate_directory / "candidate.json").read_bytes()
|
||||
receipt_before_review = (candidate_directory / "import-receipt.json").read_bytes()
|
||||
collision_stage = tmp_path / "second-candidate"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from email.message import Message
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_deploy_broker import BROKER_ROOT, _load_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(monkeypatch):
|
||||
module = _load_module("component_transport_test", BROKER_ROOT / "status_server.py")
|
||||
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True)
|
||||
monkeypatch.setattr(module, "_self_upgrade_secret", lambda: b"transport-test-key-" + b"x" * 48)
|
||||
projects = {module.REGISTERED_AUTO_APPLY_REPOSITORY: {"branch": "main", "validation": {"required": True}},
|
||||
"other/unchanged": {"branch": "main"}}
|
||||
monkeypatch.setattr(module, "_load_projects", lambda: projects)
|
||||
monkeypatch.setattr(module, "_write_projects", lambda updated: (projects.clear(), projects.update(updated)))
|
||||
monkeypatch.setattr(module, "_validation_policy", lambda *_a: {"required": True})
|
||||
return module, projects
|
||||
|
||||
|
||||
def call(module, path, payload, method="_handle_registered_auto_apply"):
|
||||
handler = module.Handler.__new__(module.Handler)
|
||||
body = json.dumps(payload).encode()
|
||||
handler.path = path
|
||||
handler.headers = Message()
|
||||
handler.headers["Content-Length"] = str(len(body))
|
||||
handler.headers["Content-Type"] = "application/json"
|
||||
handler.rfile = io.BytesIO(body)
|
||||
handler._trusted_client = lambda: True
|
||||
responses = []
|
||||
handler._send_json = lambda status, data: responses.append((status, json.loads(data)))
|
||||
getattr(handler, method)()
|
||||
return responses[0]
|
||||
|
||||
|
||||
def intent(module, projects):
|
||||
project = projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]
|
||||
return {"repository": module.REGISTERED_AUTO_APPLY_REPOSITORY, "held": True,
|
||||
"expectedRegistrySha256": hashlib.sha256(json.dumps(project, sort_keys=True, separators=(",", ":")).encode()).hexdigest(),
|
||||
"reason": "Limit rollout to the validated Gitea component", "requestId": "11111111-1111-4111-8111-111111111111"}
|
||||
|
||||
|
||||
def test_hold_is_cas_bound_and_retains_registered_policy(controller):
|
||||
module, projects = controller
|
||||
payload = intent(module, projects)
|
||||
original = dict(projects[module.REGISTERED_AUTO_APPLY_REPOSITORY])
|
||||
code, report = call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)
|
||||
assert code == 200 and report["status"] == "held" and report["autoApply"] is False
|
||||
updated = projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]
|
||||
assert updated["branch"] == original["branch"]
|
||||
assert updated["validation"] == original["validation"]
|
||||
assert projects["other/unchanged"] == {"branch": "main"}
|
||||
assert payload["reason"] not in json.dumps(updated)
|
||||
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["reason"] == "registered_target_policy_changed"
|
||||
code, preflight = call(module, module.REGISTERED_AUTO_APPLY_PATH + "/preflight", {"repository": payload["repository"]})
|
||||
assert code == 200 and preflight["autoApply"] is False
|
||||
payload.update(held=False, expectedRegistrySha256=preflight["registrySha256"])
|
||||
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["status"] == "released"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [{"repository": "other/unchanged"}, {"held": "true"}, {"image": "caller/image"},
|
||||
{"requestId": "bad"}, {"expectedRegistrySha256": "0" * 64}])
|
||||
def test_hold_rejects_unknown_target_input_and_stale_fingerprint(controller, change):
|
||||
module, projects = controller
|
||||
before = json.dumps(projects, sort_keys=True)
|
||||
code, _ = call(module, module.REGISTERED_AUTO_APPLY_PATH, {**intent(module, projects), **change})
|
||||
assert code in {400, 409}
|
||||
assert json.dumps(projects, sort_keys=True) == before
|
||||
|
||||
|
||||
def test_hold_authentication_and_inflight_deployment_remain_fail_closed(controller, monkeypatch):
|
||||
module, projects = controller
|
||||
payload = intent(module, projects)
|
||||
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
|
||||
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[0] == 403
|
||||
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: True)
|
||||
module._registered_auto_apply_lock.acquire()
|
||||
try:
|
||||
assert call(module, module.REGISTERED_AUTO_APPLY_PATH, payload)[1]["reason"] == "registered_target_deployment_active"
|
||||
finally:
|
||||
module._registered_auto_apply_lock.release()
|
||||
|
||||
|
||||
def test_execution_rechecks_hold_under_same_lock(controller, monkeypatch):
|
||||
module, projects = controller
|
||||
invoked = []
|
||||
def execute(command, **_kwargs):
|
||||
assert module._registered_auto_apply_lock.acquire(blocking=False) is False
|
||||
invoked.append(command)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
monkeypatch.setattr(module.subprocess, "run", execute)
|
||||
projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]["auto_apply"] = False
|
||||
assert module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40).returncode == 4
|
||||
assert invoked == []
|
||||
projects[module.REGISTERED_AUTO_APPLY_REPOSITORY]["auto_apply"] = True
|
||||
assert module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40).returncode == 0
|
||||
assert invoked == [["python3", str(module.ROOT / "deploy.py"), "deploy", module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40]]
|
||||
|
||||
|
||||
def test_component_authentication_blocks_before_reading_or_mutating(controller, monkeypatch):
|
||||
module, _ = controller
|
||||
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
|
||||
assert call(module, "/v1/appops/hub-components/apply", {"image": "caller/image"}, "_handle_hub_component")[0] == 403
|
||||
|
||||
|
||||
def test_component_worker_serializes_hold_and_records_sanitized_failure(controller, monkeypatch):
|
||||
module, _ = controller
|
||||
failures = []
|
||||
plan_id = "a" * 64
|
||||
def apply(*_args, **_kwargs):
|
||||
assert module._registered_auto_apply_lock.acquire(blocking=False) is False
|
||||
raise RuntimeError("secret runtime configuration must never escape")
|
||||
fake = SimpleNamespace(apply=apply, PromotionRejected=ValueError,
|
||||
record_worker_failure=lambda *args, **_kw: failures.append(args))
|
||||
monkeypatch.setitem(sys.modules, "immutable_service_promotion", fake)
|
||||
module._component_worker_plans.add(plan_id)
|
||||
module._run_component_worker({"planId": plan_id}, b"x" * 64)
|
||||
assert failures == [(plan_id, "component_worker_failed")]
|
||||
assert plan_id not in module._component_worker_plans
|
||||
assert module._registered_auto_apply_lock.acquire(blocking=False)
|
||||
module._registered_auto_apply_lock.release()
|
||||
|
||||
|
||||
def test_component_queue_is_idempotent_and_failed_start_is_durable(controller, monkeypatch):
|
||||
module, _ = controller
|
||||
failures, starts = [], []
|
||||
plan_id = "a" * 64
|
||||
fake = SimpleNamespace(accept_apply=lambda *_a, **_k: {"status": "queued", "planId": plan_id},
|
||||
PromotionRejected=ValueError,
|
||||
record_worker_failure=lambda *args, **_kw: failures.append(args))
|
||||
monkeypatch.setitem(sys.modules, "immutable_service_promotion", fake)
|
||||
monkeypatch.setattr(module.threading, "Thread", lambda **kwargs: SimpleNamespace(start=lambda: starts.append(kwargs)))
|
||||
payload = {"planId": plan_id}
|
||||
assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[0] == 202
|
||||
assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[0] == 202
|
||||
assert len(starts) == 1
|
||||
module._component_worker_plans.clear()
|
||||
def fail_start(**_kwargs):
|
||||
raise RuntimeError("thread unavailable")
|
||||
monkeypatch.setattr(module.threading, "Thread", fail_start)
|
||||
assert call(module, "/v1/appops/hub-components/apply", payload, "_handle_hub_component")[1]["reason"] == "component_worker_start_failed"
|
||||
assert failures == [(plan_id, "component_worker_start_failed")]
|
||||
assert not module._component_worker_plans
|
||||
|
||||
|
||||
@pytest.mark.parametrize("validated", [False, True])
|
||||
def test_hold_retains_push_validation_and_candidate_preparation_without_deploy(controller, monkeypatch, tmp_path, validated):
|
||||
module, projects = controller
|
||||
repository = module.REGISTERED_AUTO_APPLY_REPOSITORY
|
||||
projects[repository]["auto_apply"] = False
|
||||
revision = "a" * 40
|
||||
semantic = module._webhook_semantic_digest(34, "refs/heads/main", revision)
|
||||
marker = tmp_path / (semantic + ".json")
|
||||
record = {"schema": module._WEBHOOK_SCHEMA, "repository": repository, "gitea_repository_id": 34,
|
||||
"ref": "refs/heads/main", "revision": revision, "semantic_sha256": semantic,
|
||||
"body_sha256": "b" * 64, "status": "validation_passed" if validated else "awaiting_validation"}
|
||||
monkeypatch.setattr(module, "_read_inbox_record", lambda _path: record)
|
||||
monkeypatch.setattr(module, "_webhook_policy", lambda *_a: (34, "main"))
|
||||
monkeypatch.setattr(module, "_record_policy_for_processing", lambda *_a: True)
|
||||
monkeypatch.setattr(module, "_process_validation", lambda *_a: False)
|
||||
monkeypatch.setattr(module, "_atomic_inbox_update", lambda *_a: None)
|
||||
monkeypatch.setattr(module, "_supersede_older_deliveries", lambda *_a: None)
|
||||
prepared = []
|
||||
monkeypatch.setattr(module, "_prepare_hub_candidate_delivery", lambda *_a: prepared.append(revision) or True)
|
||||
monkeypatch.setattr(module, "_run_registered_deploy", lambda *_a: pytest.fail("held target must never deploy"))
|
||||
module._process_webhook_record(marker)
|
||||
assert prepared == ([revision] if validated else [])
|
||||
assert record["status"] == ("validation_passed" if validated else "awaiting_validation")
|
||||
if validated:
|
||||
assert record["reason"] == "registered_target_auto_apply_held"
|
||||
@@ -0,0 +1,667 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"immutable_promotion_test",
|
||||
ROOT / "ops/gitea-deploy-broker/immutable_service_promotion.py",
|
||||
)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
KEY = b"test-receipt-key" * 3
|
||||
REVISION = "b" * 40
|
||||
|
||||
|
||||
class Runtime:
|
||||
def __init__(self, path):
|
||||
self.receipt_root = path / "receipts"
|
||||
self.state = {
|
||||
"target": {
|
||||
"containerId": "a" * 64,
|
||||
"imageId": "sha256:" + "a" * 64,
|
||||
"revision": m.OLD_REVISION,
|
||||
"spec": {
|
||||
"image": "sha256:" + "a" * 64,
|
||||
"environment": {"GITEA_CONNECTOR_BUILD_SHA": m.OLD_REVISION},
|
||||
},
|
||||
},
|
||||
"nonTargets": {
|
||||
"postgres": {"containerId": "c" * 64, "imageId": "sha256:" + "c" * 64}
|
||||
},
|
||||
}
|
||||
self.calls = []
|
||||
self.fail_health = False
|
||||
self.fail_rollback = False
|
||||
self.artifact = {
|
||||
"revision": REVISION,
|
||||
"imageId": "sha256:" + "b" * 64,
|
||||
"candidateSha256": "d" * 64,
|
||||
}
|
||||
|
||||
def lock(self):
|
||||
return contextlib.nullcontext()
|
||||
|
||||
def snapshot(self):
|
||||
return copy.deepcopy(self.state)
|
||||
|
||||
def candidate(self, revision, **kwargs):
|
||||
self.calls.append("candidate")
|
||||
assert revision == REVISION
|
||||
return copy.deepcopy(self.artifact)
|
||||
|
||||
desired = m.ControllerRuntime.desired
|
||||
|
||||
def promote(self, desired):
|
||||
self.calls.append("promote")
|
||||
self.state["target"] = {**copy.deepcopy(desired), "containerId": "e" * 64}
|
||||
|
||||
def verify(self, desired, baseline, *, rollback=False):
|
||||
if self.fail_health and not rollback:
|
||||
raise ValueError("private diagnostic must not escape")
|
||||
assert self.state["nonTargets"] == baseline["nonTargets"]
|
||||
return {"containerId": self.state["target"]["containerId"]}
|
||||
|
||||
def rollback(self, baseline, desired):
|
||||
self.calls.append("rollback")
|
||||
if self.fail_rollback:
|
||||
raise ValueError("secret rollback diagnostic")
|
||||
self.state = copy.deepcopy(baseline)
|
||||
|
||||
def verify_record(self, record):
|
||||
self.calls.append("verify_record")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path):
|
||||
runtime = Runtime(tmp_path)
|
||||
request = {
|
||||
"repository": "Jens/ITWorx_MCP_Hub",
|
||||
"service": m.SERVICE,
|
||||
"revision": REVISION,
|
||||
"expectedBaseline": m.digest(runtime.state),
|
||||
"reason": "Repair verified Gitea compatibility",
|
||||
"requestId": "d6fe0ba3-81f0-4ed6-b36d-10b12dbf55e4",
|
||||
}
|
||||
return runtime, request
|
||||
|
||||
|
||||
def plan(runtime, request):
|
||||
return m.preflight(request, api_json=None, receipt_key=KEY, runtime=runtime)
|
||||
|
||||
|
||||
def arguments(request, result):
|
||||
return {**request, "planId": result["planId"], "planDigest": result["planDigest"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
{"service": "postgres"},
|
||||
{"repository": "Jens/other"},
|
||||
{"revision": "main"},
|
||||
{"revision": "B" * 40},
|
||||
{"requestId": "bad"},
|
||||
{"image": "evil"},
|
||||
{"command": "sh"},
|
||||
{"expectedBaseline": "bad"},
|
||||
{"reason": "line\nbreak"},
|
||||
],
|
||||
)
|
||||
def test_closed_request_rejects_untrusted_target_or_extra_fields(fixture, change):
|
||||
runtime, request = fixture
|
||||
with pytest.raises(m.PromotionRejected):
|
||||
plan(runtime, {**request, **change})
|
||||
assert not runtime.calls
|
||||
|
||||
|
||||
def test_preflight_is_readonly_and_receipts_do_not_expose_configuration(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
assert result["status"] == "prepared"
|
||||
assert result["expiresAt"] - result["createdAt"] == 300
|
||||
assert runtime.calls == ["candidate"]
|
||||
assert "spec" not in json.dumps(result) and "environment" not in json.dumps(result)
|
||||
assert result["planId"] == m.digest(m.request(request))
|
||||
|
||||
|
||||
def test_queue_is_durable_idempotent_and_no_runtime_operation(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
runtime.calls.clear()
|
||||
args = arguments(request, result)
|
||||
queued = m.accept_apply(args, receipt_key=KEY, runtime=runtime)
|
||||
assert queued["status"] == "queued"
|
||||
assert m.accept_apply(args, receipt_key=KEY, runtime=runtime) == queued
|
||||
assert runtime.calls == []
|
||||
assert len(list(runtime.receipt_root.rglob("*.json"))) == 2
|
||||
|
||||
|
||||
def test_apply_rechecks_artifact_and_baseline_then_only_promotes_once(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
args = arguments(request, result)
|
||||
m.accept_apply(args, receipt_key=KEY, runtime=runtime)
|
||||
applied = m.apply(
|
||||
args, api_json=None, download_artifact=None, receipt_key=KEY, runtime=runtime
|
||||
)
|
||||
assert applied["status"] == "applied"
|
||||
assert runtime.calls == ["candidate", "candidate", "promote"]
|
||||
assert (
|
||||
m.apply(
|
||||
args,
|
||||
api_json=None,
|
||||
download_artifact=None,
|
||||
receipt_key=KEY,
|
||||
runtime=runtime,
|
||||
)
|
||||
== applied
|
||||
)
|
||||
assert runtime.calls.count("promote") == 1
|
||||
assert runtime.state["nonTargets"]["postgres"]["containerId"] == "c" * 64
|
||||
assert (
|
||||
m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime)
|
||||
== applied
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_baseline_drift_blocks_before_any_import_or_write(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
runtime.calls.clear()
|
||||
runtime.state["nonTargets"]["postgres"]["containerId"] = "f" * 64
|
||||
with pytest.raises(m.PromotionRejected, match="baseline_changed"):
|
||||
m.apply(
|
||||
arguments(request, result),
|
||||
api_json=None,
|
||||
download_artifact=None,
|
||||
receipt_key=KEY,
|
||||
runtime=runtime,
|
||||
)
|
||||
assert not runtime.calls
|
||||
|
||||
|
||||
def test_artifact_change_after_preflight_blocks_without_promoting(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
runtime.artifact["candidateSha256"] = "f" * 64
|
||||
with pytest.raises(m.PromotionRejected, match="artifact_changed"):
|
||||
m.apply(
|
||||
arguments(request, result),
|
||||
api_json=None,
|
||||
download_artifact=None,
|
||||
receipt_key=KEY,
|
||||
runtime=runtime,
|
||||
)
|
||||
assert "promote" not in runtime.calls
|
||||
|
||||
|
||||
def test_health_failure_restores_exact_baseline_and_records_rollback(fixture):
|
||||
runtime, request = fixture
|
||||
before = runtime.snapshot()
|
||||
result = plan(runtime, request)
|
||||
runtime.fail_health = True
|
||||
outcome = m.apply(
|
||||
arguments(request, result),
|
||||
api_json=None,
|
||||
download_artifact=None,
|
||||
receipt_key=KEY,
|
||||
runtime=runtime,
|
||||
)
|
||||
assert outcome["status"] == "rolled_back"
|
||||
assert runtime.snapshot() == before
|
||||
assert "private" not in json.dumps(outcome)
|
||||
|
||||
|
||||
def test_rollback_failure_is_durable_and_cannot_blindly_retry(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
runtime.fail_health = runtime.fail_rollback = True
|
||||
args = arguments(request, result)
|
||||
with pytest.raises(m.PromotionRejected, match="rollback_recovery_required"):
|
||||
m.apply(
|
||||
args,
|
||||
api_json=None,
|
||||
download_artifact=None,
|
||||
receipt_key=KEY,
|
||||
runtime=runtime,
|
||||
)
|
||||
assert (
|
||||
m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime)[
|
||||
"status"
|
||||
]
|
||||
== "recovery_required"
|
||||
)
|
||||
with pytest.raises(m.PromotionRejected, match="recovery_required"):
|
||||
m.apply(
|
||||
args,
|
||||
api_json=None,
|
||||
download_artifact=None,
|
||||
receipt_key=KEY,
|
||||
runtime=runtime,
|
||||
)
|
||||
assert runtime.calls.count("promote") == 1
|
||||
|
||||
|
||||
def test_expired_plan_and_different_intent_are_rejected(fixture, monkeypatch):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
args = arguments(request, result)
|
||||
with pytest.raises(m.PromotionRejected, match="plan_mismatch"):
|
||||
m.accept_apply(
|
||||
{**args, "reason": "Changed reason"}, receipt_key=KEY, runtime=runtime
|
||||
)
|
||||
monkeypatch.setattr(m.time, "time", lambda: result["expiresAt"] + 1)
|
||||
with pytest.raises(m.PromotionRejected, match="plan_expired"):
|
||||
m.accept_apply(args, receipt_key=KEY, runtime=runtime)
|
||||
|
||||
|
||||
def test_signature_tampering_chain_gaps_and_weak_key_fail_closed(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
path = next(runtime.receipt_root.rglob("*.json"))
|
||||
data = json.loads(path.read_text())
|
||||
data["record"]["revision"] = "f" * 40
|
||||
path.write_text(json.dumps(data))
|
||||
with pytest.raises(m.PromotionRejected, match="signature_invalid"):
|
||||
m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime)
|
||||
with pytest.raises(m.PromotionRejected, match="key_invalid"):
|
||||
m.ReceiptStore(runtime.receipt_root, b"weak")
|
||||
|
||||
|
||||
def test_async_rejection_records_only_safe_reason_and_never_overwrites_recovery(
|
||||
fixture,
|
||||
):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
m.accept_apply(arguments(request, result), receipt_key=KEY, runtime=runtime)
|
||||
rejected = m.record_worker_failure(
|
||||
result["planId"], "secret=raw error!", receipt_key=KEY, runtime=runtime
|
||||
)
|
||||
assert rejected["status"] == "rejected"
|
||||
assert rejected["reasonCode"] == "component_worker_failed"
|
||||
assert (
|
||||
m.record_worker_failure(
|
||||
result["planId"], "later", receipt_key=KEY, runtime=runtime
|
||||
)
|
||||
== rejected
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
runtime = m.ControllerRuntime.__new__(m.ControllerRuntime)
|
||||
runtime.docker = lambda *_args: [
|
||||
{
|
||||
"Id": "sha256:" + "a" * 64,
|
||||
"Config": {
|
||||
"Labels": {
|
||||
"org.opencontainers.image.revision": m.OLD_REVISION,
|
||||
"org.opencontainers.image.source": "https://gitea.example.com/jens/itworx-mcp-hub",
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
runtime.runtime_fingerprint = lambda: {
|
||||
"blob": m.ABI_BLOBS["services/runtime/connector_gitea.py"][0]
|
||||
}
|
||||
env = {
|
||||
"SERVICE_NAME": "connector-gitea",
|
||||
"GITEA_CONNECTOR_BUILD_SHA": m.OLD_REVISION,
|
||||
"GITEA_ENDPOINT": "https://gitea.example.com",
|
||||
"GITEA_TOKEN_FILE": "/run/secrets/gitea_mcp_token",
|
||||
"GITEA_WRITE_TOKEN_FILE": "/run/secrets/gitea_mcp_write_token",
|
||||
"SECRET_EXPECTED_UID": "99",
|
||||
"SECRET_EXPECTED_GID": "100",
|
||||
"CONNECTOR_IDENTITY_KEY_FILE": "/run/secrets/connector_identity_signing_key",
|
||||
"GPG_KEY": "public image fingerprint",
|
||||
}
|
||||
mounts = [
|
||||
{
|
||||
"Type": "bind",
|
||||
"RW": False,
|
||||
"Source": "/srv/example/appdata/itworx-mcp-hub/secrets/" + name,
|
||||
"Destination": "/run/secrets/" + name,
|
||||
}
|
||||
for name in (
|
||||
"gitea_mcp_token",
|
||||
"gitea_mcp_write_token",
|
||||
"connector_identity_signing_key",
|
||||
)
|
||||
]
|
||||
mounts.append(
|
||||
{
|
||||
"Type": "bind",
|
||||
"RW": False,
|
||||
"Source": f"/srv/example/appdata/itworx-mcp-hub/deployment/releases/{m.OLD_REVISION}/services/runtime/connector_gitea.py",
|
||||
"Destination": "/opt/hub/services/runtime/connector_gitea.py",
|
||||
}
|
||||
)
|
||||
row = {
|
||||
"Id": "a" * 64,
|
||||
"Image": "sha256:" + "a" * 64,
|
||||
"Config": {
|
||||
"Entrypoint": ["python", "-m", "services.runtime"],
|
||||
"Cmd": None,
|
||||
"User": "99:100",
|
||||
"WorkingDir": "/opt/hub",
|
||||
"Tty": False,
|
||||
"OpenStdin": False,
|
||||
"ExposedPorts": {"8080/tcp": {}},
|
||||
"Env": [k + "=" + v for k, v in env.items()],
|
||||
"Labels": {},
|
||||
"Healthcheck": {
|
||||
"Interval": 60000000000,
|
||||
"Retries": 5,
|
||||
"StartPeriod": 10000000000,
|
||||
"Timeout": 3000000000,
|
||||
"Test": [
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)",
|
||||
],
|
||||
},
|
||||
},
|
||||
"HostConfig": {
|
||||
"ReadonlyRootfs": True,
|
||||
"Privileged": False,
|
||||
"CapDrop": ["ALL"],
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
"Memory": 536870912,
|
||||
"MemorySwap": -1,
|
||||
"MemoryReservation": 0,
|
||||
"NetworkMode": "itworx-mcp-hub-connectors",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {"max-file": "3", "max-size": "10m"},
|
||||
},
|
||||
"ShmSize": 67108864,
|
||||
"AutoRemove": False,
|
||||
"CgroupnsMode": "private",
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger",
|
||||
],
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap",
|
||||
],
|
||||
"NanoCpus": 1000000000,
|
||||
"PidsLimit": 128,
|
||||
"Tmpfs": {"/tmp": "size=512m,mode=1777"},
|
||||
"IpcMode": "private",
|
||||
"RestartPolicy": {"Name": "unless-stopped", "MaximumRetryCount": 0},
|
||||
},
|
||||
"Mounts": mounts,
|
||||
"NetworkSettings": {
|
||||
"Networks": {
|
||||
n: {"Aliases": [m.CONTAINER, m.SERVICE]}
|
||||
for n in (
|
||||
"itworx-mcp-hub-connectors",
|
||||
"itworx-mcp-hub-observe",
|
||||
"itworx-mcp-hub-egress",
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
return runtime, row
|
||||
|
||||
|
||||
def test_known_live_target_preserves_public_metadata_and_only_image_build_sha_changes(
|
||||
adapter,
|
||||
):
|
||||
runtime, row = adapter
|
||||
target = runtime.target(row)
|
||||
desired = runtime.desired(
|
||||
{"target": target}, {"imageId": "sha256:" + "b" * 64, "revision": REVISION}
|
||||
)
|
||||
changed = copy.deepcopy(desired)
|
||||
changed.update(imageId=target["imageId"], revision=target["revision"])
|
||||
changed["spec"]["image"] = target["imageId"]
|
||||
changed["spec"]["environment"]["GITEA_CONNECTOR_BUILD_SHA"] = m.OLD_REVISION
|
||||
assert changed == target
|
||||
assert target["spec"]["environment"]["SECRET_EXPECTED_UID"] == "99"
|
||||
assert target["spec"]["environment"]["GPG_KEY"] == "public image fingerprint"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("Privileged", True),
|
||||
("CapAdd", ["SYS_ADMIN"]),
|
||||
("Devices", [{"PathOnHost": "/dev/sda"}]),
|
||||
("ExtraHosts", ["attacker:127.0.0.1"]),
|
||||
("PidMode", "host"),
|
||||
("Memory", 0),
|
||||
("ReadonlyRootfs", False),
|
||||
],
|
||||
)
|
||||
def test_unexpected_security_runtime_composition_is_rejected(adapter, field, value):
|
||||
runtime, row = adapter
|
||||
row["HostConfig"][field] = value
|
||||
with pytest.raises(m.PromotionRejected, match="configuration_unexpected"):
|
||||
runtime.target(row)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key,value",
|
||||
[
|
||||
("GITEA_TOKEN", "secret"),
|
||||
("GITEA_ENDPOINT", "https://attacker.invalid"),
|
||||
("PYTHONPATH", "/tmp/evil"),
|
||||
("SECRET_EXPECTED_UID", "0"),
|
||||
],
|
||||
)
|
||||
def test_env_drift_inline_secret_or_alternate_upstream_is_rejected(adapter, key, value):
|
||||
runtime, row = adapter
|
||||
row["Config"]["Env"] = [
|
||||
v for v in row["Config"]["Env"] if not v.startswith(key + "=")
|
||||
]
|
||||
row["Config"]["Env"].append(key + "=" + value)
|
||||
with pytest.raises(m.PromotionRejected):
|
||||
runtime.target(row)
|
||||
|
||||
|
||||
def test_fixed_promote_contains_only_one_service_no_database_or_build_commands(
|
||||
adapter, tmp_path, monkeypatch
|
||||
):
|
||||
runtime, row = adapter
|
||||
runtime.receipt_root = tmp_path
|
||||
target = runtime.target(row)
|
||||
calls = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
config = json.loads(Path(argv[5]).read_text())
|
||||
assert set(config["services"]) == {m.SERVICE}
|
||||
assert (
|
||||
config["services"][m.SERVICE]["environment"]
|
||||
== target["spec"]["environment"]
|
||||
)
|
||||
assert (
|
||||
kwargs["stdout"] == m.subprocess.DEVNULL
|
||||
and kwargs["stderr"] == m.subprocess.DEVNULL
|
||||
)
|
||||
return types.SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(m.subprocess, "run", run)
|
||||
runtime.docker = lambda *_args: [{"Id": "d" * 64}]
|
||||
runtime.promote(target)
|
||||
assert calls[0][:5] == [
|
||||
"docker",
|
||||
"compose",
|
||||
"--project-name",
|
||||
"itworx-mcp-hub",
|
||||
"--file",
|
||||
]
|
||||
assert calls[0][6:] == [
|
||||
"up",
|
||||
"--detach",
|
||||
"--no-deps",
|
||||
"--no-build",
|
||||
"--pull",
|
||||
"never",
|
||||
"connector-gitea",
|
||||
]
|
||||
assert not list(tmp_path.glob(".component-*"))
|
||||
|
||||
|
||||
def source_archive(
|
||||
*, uid=0, regular=True, mode=0o664, content=b"reviewed", name="connector_gitea.py"
|
||||
):
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.uid = uid
|
||||
member.gid = 100
|
||||
member.mode = mode
|
||||
member.mtime = 1234
|
||||
member.type = tarfile.REGTYPE if regular else tarfile.SYMTYPE
|
||||
member.size = len(content) if regular else 0
|
||||
archive.addfile(member, io.BytesIO(content) if regular else None)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
{"uid": 99},
|
||||
{"uid": 0, "regular": False},
|
||||
{"uid": 0, "regular": True, "mode": 0o666},
|
||||
{"uid": 0, "regular": True, "mode": 0o664},
|
||||
],
|
||||
)
|
||||
def test_runtime_source_change_symlink_unowned_or_writable_file_rejected(
|
||||
value, monkeypatch
|
||||
):
|
||||
runtime = m.ControllerRuntime.__new__(m.ControllerRuntime)
|
||||
raw = source_archive(
|
||||
uid=value.get("uid", 0),
|
||||
regular=value.get("regular", True),
|
||||
mode=value.get("mode", 0o664),
|
||||
content=b"changed",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
m.subprocess,
|
||||
"run",
|
||||
lambda *_args, **_kwargs: types.SimpleNamespace(returncode=0, stdout=raw),
|
||||
)
|
||||
with pytest.raises(m.PromotionRejected, match="runtime_source_unreviewed"):
|
||||
runtime.runtime_fingerprint()
|
||||
|
||||
|
||||
def test_source_fingerprint_uses_readonly_archive_even_when_target_is_stopped(
|
||||
monkeypatch,
|
||||
):
|
||||
runtime = m.ControllerRuntime.__new__(m.ControllerRuntime)
|
||||
data = b"reviewed"
|
||||
blob = m.hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest()
|
||||
monkeypatch.setitem(
|
||||
m.ABI_BLOBS, "services/runtime/connector_gitea.py", (blob, "new")
|
||||
)
|
||||
|
||||
def run(argv, **kwargs):
|
||||
assert argv == [
|
||||
"docker",
|
||||
"cp",
|
||||
m.CONTAINER + ":/opt/hub/services/runtime/connector_gitea.py",
|
||||
"-",
|
||||
]
|
||||
assert kwargs["stderr"] == m.subprocess.DEVNULL
|
||||
return types.SimpleNamespace(returncode=0, stdout=source_archive(content=data))
|
||||
|
||||
monkeypatch.setattr(m.subprocess, "run", run)
|
||||
assert runtime.runtime_fingerprint() == {
|
||||
"uid": 0,
|
||||
"gid": 100,
|
||||
"mode": 0o664,
|
||||
"size": 8,
|
||||
"mtime": "1234",
|
||||
"blob": blob,
|
||||
}
|
||||
|
||||
|
||||
def test_nonterminal_status_never_waits_for_the_deployment_lock(fixture):
|
||||
runtime, request = fixture
|
||||
result = plan(runtime, request)
|
||||
m.accept_apply(arguments(request, result), receipt_key=KEY, runtime=runtime)
|
||||
runtime.lock = lambda: pytest.fail("status must not block behind runtime promotion")
|
||||
assert (
|
||||
m.status({"planId": result["planId"]}, receipt_key=KEY, runtime=runtime)[
|
||||
"status"
|
||||
]
|
||||
== "queued"
|
||||
)
|
||||
|
||||
|
||||
def test_component_requires_explicit_auto_apply_hold():
|
||||
runtime = m.ControllerRuntime.__new__(m.ControllerRuntime)
|
||||
runtime.controller = types.SimpleNamespace(
|
||||
load_projects=lambda: {m.REPOSITORY: {"auto_apply": True}}
|
||||
)
|
||||
with pytest.raises(m.PromotionRejected, match="auto_apply_hold_required"):
|
||||
runtime.snapshot()
|
||||
|
||||
|
||||
def test_rollback_never_overwrites_a_recreated_or_edited_operator_target(adapter):
|
||||
runtime, row = adapter
|
||||
before = runtime.target(row)
|
||||
baseline = {"target": before, "nonTargets": {"postgres": "unchanged"}}
|
||||
desired = runtime.desired(
|
||||
baseline, {"revision": REVISION, "imageId": "sha256:" + "b" * 64}
|
||||
)
|
||||
runtime.promoted_id = "d" * 64
|
||||
observed = {
|
||||
"target": {**desired, "containerId": "e" * 64},
|
||||
"nonTargets": baseline["nonTargets"],
|
||||
}
|
||||
runtime.snapshot = lambda: observed
|
||||
runtime.promote = lambda *_: pytest.fail("must never overwrite operator container")
|
||||
with pytest.raises(m.PromotionRejected, match="rollback_cas_failed"):
|
||||
runtime.rollback(baseline, desired)
|
||||
observed["target"]["containerId"] = runtime.promoted_id
|
||||
observed["target"]["runtimeFingerprint"] = {"blob": "changed"}
|
||||
with pytest.raises(m.PromotionRejected, match="rollback_cas_failed"):
|
||||
runtime.rollback(baseline, desired)
|
||||
|
||||
|
||||
def test_rollback_loaded_image_provenance_must_match_bound_revision(adapter):
|
||||
runtime, row = adapter
|
||||
runtime.docker = lambda *_: [
|
||||
{
|
||||
"Id": row["Image"],
|
||||
"Config": {"Labels": {"org.opencontainers.image.revision": "f" * 40}},
|
||||
}
|
||||
]
|
||||
with pytest.raises(m.PromotionRejected, match="rollback_image_unverified"):
|
||||
runtime.target(row)
|
||||
|
||||
|
||||
def test_baseline_digest_changes_when_bound_source_stat_or_blob_changes(fixture):
|
||||
runtime, _request = fixture
|
||||
runtime.state["target"]["runtimeFingerprint"] = {"blob": "old", "inode": 5}
|
||||
before = m.digest(runtime.state)
|
||||
runtime.state["target"]["runtimeFingerprint"]["inode"] = 6
|
||||
assert m.digest(runtime.state) != before
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -16,3 +17,47 @@ def test_preflight_preserves_docs_only_fast_path():
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
assert "runtime_change" in text
|
||||
assert "*.md|*.mdx|docs/*" in text
|
||||
|
||||
|
||||
def test_backend_budget_is_bounded_without_weakening_validation():
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
expected_timeouts = {
|
||||
"preflight": 10,
|
||||
"backend": 60,
|
||||
"frontend": 30,
|
||||
"security": 20,
|
||||
"polyglot": 30,
|
||||
"full": 10,
|
||||
}
|
||||
for job, expected_minutes in expected_timeouts.items():
|
||||
match = re.search(
|
||||
rf"(?ms)^ {re.escape(job)}:\n(?P<body>.*?)(?=^ [a-z][a-z0-9_-]*:\n|\Z)",
|
||||
text,
|
||||
)
|
||||
assert match is not None, f"missing managed-validation job {job}"
|
||||
timeout = re.search(r"(?m)^ timeout-minutes: (\d+)\s*$", match["body"])
|
||||
assert timeout is not None, f"missing timeout for {job}"
|
||||
assert int(timeout.group(1)) == expected_minutes
|
||||
|
||||
for lane in ("preflight", "backend", "frontend", "security", "polyglot", "full"):
|
||||
assert f" {lane}:" in text
|
||||
assert "needs: [preflight, backend, frontend, security, polyglot]" in text
|
||||
assert "All managed validation lanes passed." in text
|
||||
|
||||
backend = re.search(
|
||||
r"(?ms)^ backend:\n(?P<body>.*?)(?=^ [a-z][a-z0-9_-]*:\n|\Z)",
|
||||
text,
|
||||
)
|
||||
assert backend is not None
|
||||
backend_body = backend["body"]
|
||||
assert 'if [[ "${profile}" == test || "${profile}" == full ]]; then' in backend_body
|
||||
pytest_commands = [
|
||||
line.strip() for line in backend_body.splitlines() if "-m pytest " in line
|
||||
]
|
||||
assert pytest_commands == [
|
||||
'"${py}" -m pytest backend/tests -q --durations=20'
|
||||
]
|
||||
assert "continue-on-error" not in backend_body
|
||||
assert "--ignore" not in backend_body
|
||||
assert "--deselect" not in backend_body
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
@@ -64,6 +65,8 @@ def harness(modules, tmp_path, monkeypatch):
|
||||
state = types.SimpleNamespace(
|
||||
source=tmp_path / "releases" / revision,
|
||||
compose_marker="baseline",
|
||||
clock=now,
|
||||
ingestion_digest=None,
|
||||
images={},
|
||||
mutations=[],
|
||||
inspect_calls=[],
|
||||
@@ -119,9 +122,13 @@ def harness(modules, tmp_path, monkeypatch):
|
||||
"validate_commit",
|
||||
lambda _repo, branch, commit: commit if branch == "main" else "",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
controller, "deployment_metadata", lambda *_args, **_kwargs: dict(metadata)
|
||||
)
|
||||
def deployment_metadata(_repo, _revision, _repository, *, built_at=None):
|
||||
result = dict(metadata)
|
||||
result["builtAt"] = built_at or state.clock
|
||||
datetime.fromisoformat(result["builtAt"].replace("Z", "+00:00"))
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(controller, "deployment_metadata", deployment_metadata)
|
||||
monkeypatch.setattr(controller, "mcp_hub_source_date_epoch", lambda *_args: epoch)
|
||||
|
||||
def stage(_project, _repo, commit):
|
||||
@@ -144,11 +151,18 @@ def harness(modules, tmp_path, monkeypatch):
|
||||
for index, service in enumerate(controller.MCP_HUB_SERVICES, 1):
|
||||
if service in controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES:
|
||||
local = controller.MCP_HUB_LOCAL_BUILD_IMAGE_REPOSITORIES.get(service)
|
||||
reference = (
|
||||
f"{local}:{revision[:12]}"
|
||||
if local
|
||||
else f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{index:064x}"
|
||||
)
|
||||
if service == "connector-ingestion":
|
||||
reference = (
|
||||
f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{state.ingestion_digest}"
|
||||
if state.ingestion_digest
|
||||
else ""
|
||||
)
|
||||
else:
|
||||
reference = (
|
||||
f"{local}:{revision[:12]}"
|
||||
if local
|
||||
else f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]}@sha256:{index:064x}"
|
||||
)
|
||||
elif service == "contextforge":
|
||||
reference = f"itworx-mcp-hub/contextforge-schema:{revision}"
|
||||
elif service == "ingestion-clamd":
|
||||
@@ -174,6 +188,13 @@ def harness(modules, tmp_path, monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(utility, "_is_host_root", lambda: True)
|
||||
|
||||
utility.freeze_candidate(controller.MCP_HUB_REPOSITORY, revision)
|
||||
freeze_dir = tmp_path / "mcp-hub-source-freezes" / revision
|
||||
freeze_doc = json.loads((freeze_dir / "freeze.json").read_text())
|
||||
# Publication happens after freeze and advances the wall clock. The plan
|
||||
# uses the immutable timestamp and the now-available registry digest.
|
||||
state.clock = "2026-10-04T02:03:04.567Z"
|
||||
state.ingestion_digest = "f" * 64
|
||||
plan = utility.plan_candidate(controller.MCP_HUB_REPOSITORY, revision)
|
||||
candidate_dir = tmp_path / "mcp-hub-release-candidates" / revision
|
||||
plan_doc = json.loads((candidate_dir / "build.json").read_text())
|
||||
@@ -255,6 +276,8 @@ def harness(modules, tmp_path, monkeypatch):
|
||||
now=now,
|
||||
plan=plan,
|
||||
plan_doc=plan_doc,
|
||||
freeze_dir=freeze_dir,
|
||||
freeze_doc=freeze_doc,
|
||||
)
|
||||
|
||||
|
||||
@@ -265,13 +288,225 @@ def test_plan_freezes_protected_metadata_without_candidate_or_approval(harness):
|
||||
assert document["repository"] == h.controller.MCP_HUB_REPOSITORY
|
||||
assert document["revision"] == h.revision
|
||||
assert document["build"] == h.metadata
|
||||
assert document["build"]["builtAt"] == h.freeze_doc["build"]["builtAt"]
|
||||
assert document["build"]["builtAt"] != h.state.clock
|
||||
assert document["sourceDateEpoch"] == h.epoch
|
||||
assert len(document["composeSha256"]) == 64
|
||||
assert h.state.ingestion_digest in str(h.model)
|
||||
assert not (h.candidate_dir / "candidate.json").exists()
|
||||
assert not (h.candidate_dir / "approval.json").exists()
|
||||
assert h.state.mutations == []
|
||||
|
||||
|
||||
def _remove_candidate_and_freeze(h):
|
||||
shutil.rmtree(h.candidate_dir, ignore_errors=True)
|
||||
shutil.rmtree(h.freeze_dir.parent, ignore_errors=True)
|
||||
|
||||
|
||||
def _prepare_freeze_dir(h):
|
||||
h.freeze_dir.parent.mkdir(mode=0o700)
|
||||
h.freeze_dir.mkdir(mode=0o700)
|
||||
|
||||
|
||||
def test_plan_requires_existing_freeze_before_any_candidate_write(harness):
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
with pytest.raises(
|
||||
h.controller.DeployError,
|
||||
match="run freeze, publish the fixed ingestion image, then run plan",
|
||||
):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
def test_freeze_is_non_replayable(harness):
|
||||
h = harness
|
||||
original = (h.freeze_dir / "freeze.json").read_bytes()
|
||||
with pytest.raises(h.controller.DeployError, match="never overwritten|already exists"):
|
||||
h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert (h.freeze_dir / "freeze.json").read_bytes() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
("revision", "freeze record.*source identity"),
|
||||
("tree", "freeze record.*source identity"),
|
||||
("calendar", "freeze record.*source identity"),
|
||||
("epoch", "freeze record.*source epoch"),
|
||||
("duplicate", "unavailable or invalid|duplicate"),
|
||||
("partial", "unavailable or invalid|partial"),
|
||||
],
|
||||
)
|
||||
def test_plan_rejects_invalid_freeze_records_without_candidate_writes(
|
||||
harness, mutation, message
|
||||
):
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
_prepare_freeze_dir(h)
|
||||
record = dict(h.freeze_doc)
|
||||
if mutation == "revision":
|
||||
record["revision"] = "c" * 40
|
||||
payload = json.dumps(record).encode()
|
||||
elif mutation == "tree":
|
||||
record["build"] = dict(record["build"])
|
||||
record["build"]["tree"] = "d" * 40
|
||||
payload = json.dumps(record).encode()
|
||||
elif mutation == "calendar":
|
||||
record["build"] = dict(record["build"])
|
||||
record["build"]["builtAt"] = "2026-99-99T00:00:00.000Z"
|
||||
payload = json.dumps(record).encode()
|
||||
elif mutation == "epoch":
|
||||
record["sourceDateEpoch"] = "1790888864"
|
||||
payload = json.dumps(record).encode()
|
||||
elif mutation == "duplicate":
|
||||
payload = b'{"schema":"duplicate","schema":"duplicate"}'
|
||||
else:
|
||||
payload = b'{"schema":'
|
||||
(h.freeze_dir / "freeze.json").write_bytes(payload)
|
||||
(h.freeze_dir / "freeze.json").chmod(0o600)
|
||||
with pytest.raises(h.controller.DeployError, match=message):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
def test_plan_rejects_symlink_freeze_file_without_candidate_writes(harness, tmp_path):
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
_prepare_freeze_dir(h)
|
||||
outside = tmp_path / "outside.json"
|
||||
outside.write_text(json.dumps(h.freeze_doc))
|
||||
try:
|
||||
(h.freeze_dir / "freeze.json").symlink_to(outside)
|
||||
except OSError:
|
||||
pytest.skip("symlink creation is unavailable on this filesystem")
|
||||
with pytest.raises(
|
||||
h.controller.DeployError,
|
||||
match="unavailable or invalid|unsafe|not a regular file",
|
||||
):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
def test_plan_rejects_fifo_freeze_record_without_blocking(harness):
|
||||
if os.name == "nt" or not hasattr(os, "mkfifo"):
|
||||
pytest.skip("FIFO files are a POSIX filesystem contract")
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
_prepare_freeze_dir(h)
|
||||
os.mkfifo(h.freeze_dir / "freeze.json", 0o600)
|
||||
with pytest.raises(h.controller.DeployError, match="not a regular file"):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
def test_plan_rejects_unsafe_freeze_directory_mode_without_candidate_writes(harness):
|
||||
h = harness
|
||||
if os.name == "nt":
|
||||
pytest.skip("POSIX private-mode enforcement is a Linux controller contract")
|
||||
_remove_candidate_and_freeze(h)
|
||||
_prepare_freeze_dir(h)
|
||||
(h.freeze_dir / "freeze.json").write_text(json.dumps(h.freeze_doc))
|
||||
(h.freeze_dir / "freeze.json").chmod(0o600)
|
||||
h.freeze_dir.chmod(0o755)
|
||||
with pytest.raises(h.controller.DeployError, match="private|root-owned|ACL"):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fault", ["file_mode", "owner", "dir_owner", "acl", "dir_acl"]
|
||||
)
|
||||
def test_plan_rejects_unsafe_freeze_file_metadata_without_candidate_writes(
|
||||
harness, monkeypatch, fault
|
||||
):
|
||||
h = harness
|
||||
if fault == "file_mode" and os.name == "nt":
|
||||
pytest.skip("POSIX private-mode enforcement is a Linux controller contract")
|
||||
_remove_candidate_and_freeze(h)
|
||||
_prepare_freeze_dir(h)
|
||||
freeze_file = h.freeze_dir / "freeze.json"
|
||||
freeze_file.write_text(json.dumps(h.freeze_doc))
|
||||
freeze_file.chmod(0o600)
|
||||
if fault == "file_mode":
|
||||
freeze_file.chmod(0o644)
|
||||
match = "unsafe"
|
||||
elif fault in {"owner", "dir_owner"}:
|
||||
original = h.controller._root_owned
|
||||
denied_path = h.freeze_dir if fault == "dir_owner" else freeze_file
|
||||
monkeypatch.setattr(
|
||||
h.controller,
|
||||
"_root_owned",
|
||||
lambda path: False if Path(path) == denied_path else original(path),
|
||||
)
|
||||
match = "root-owned|unsafe|owner"
|
||||
else:
|
||||
original = h.controller._mcp_hub_require_no_posix_acl
|
||||
|
||||
def reject_freeze_acl(path):
|
||||
if Path(path) == (h.freeze_dir if fault == "dir_acl" else freeze_file):
|
||||
raise h.controller.DeployError("freeze ACL denied")
|
||||
original(path)
|
||||
|
||||
monkeypatch.setattr(h.controller, "_mcp_hub_require_no_posix_acl", reject_freeze_acl)
|
||||
match = "ACL"
|
||||
with pytest.raises(h.controller.DeployError, match=match):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
def test_freeze_rejects_nonexact_local_main_head_without_writes(harness, monkeypatch):
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
monkeypatch.setattr(h.controller, "output", lambda _args: "c" * 40)
|
||||
with pytest.raises(h.controller.DeployError, match="exact protected main head"):
|
||||
h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.freeze_dir.parent.exists()
|
||||
|
||||
|
||||
def test_freeze_rejects_staged_source_mismatch_without_freeze_record(harness, monkeypatch):
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
monkeypatch.setattr(h.controller, "exact_source_matches_revision", lambda *_args: False)
|
||||
with pytest.raises(h.controller.DeployError, match="Staged MCP Hub source differs"):
|
||||
h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.freeze_dir.parent.exists()
|
||||
|
||||
|
||||
def test_freeze_rejects_invalid_epoch_before_staging_or_freeze_record(harness, monkeypatch):
|
||||
h = harness
|
||||
_remove_candidate_and_freeze(h)
|
||||
monkeypatch.setattr(h.controller, "mcp_hub_source_date_epoch", lambda *_args: "invalid")
|
||||
monkeypatch.setattr(
|
||||
h.controller,
|
||||
"mcp_hub_stage_release_source",
|
||||
lambda *_args: pytest.fail("invalid epoch must fail before source staging"),
|
||||
)
|
||||
with pytest.raises(h.controller.DeployError, match="source epoch"):
|
||||
h.utility.freeze_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.freeze_dir.parent.exists()
|
||||
|
||||
|
||||
def test_plan_rejects_staged_source_drift_before_candidate_writes(harness, monkeypatch):
|
||||
h = harness
|
||||
shutil.rmtree(h.candidate_dir)
|
||||
monkeypatch.setattr(h.controller, "exact_source_matches_revision", lambda *_args: False)
|
||||
with pytest.raises(h.controller.DeployError, match="Staged MCP Hub source differs"):
|
||||
h.utility.plan_candidate(h.controller.MCP_HUB_REPOSITORY, h.revision)
|
||||
assert not h.candidate_dir.exists()
|
||||
|
||||
|
||||
def test_freeze_wrong_repository_and_nonroot_fail_before_writes(modules, monkeypatch, tmp_path):
|
||||
controller, utility = modules
|
||||
monkeypatch.setattr(utility, "_is_host_root", lambda: True)
|
||||
monkeypatch.setattr(controller, "STATE_ROOT", tmp_path)
|
||||
with pytest.raises(controller.DeployError, match="fixed MCP Hub repository"):
|
||||
utility.freeze_candidate("someone/else", "a" * 40)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
monkeypatch.setattr(utility, "_is_host_root", lambda: False)
|
||||
with pytest.raises(controller.DeployError, match="host root"):
|
||||
utility.freeze_candidate(controller.MCP_HUB_REPOSITORY, "a" * 40)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp(
|
||||
harness, monkeypatch
|
||||
):
|
||||
@@ -334,7 +569,7 @@ def test_plan_candidate_freezes_the_selected_workflow_creation_timestamp(
|
||||
plan = json.loads(
|
||||
(staging / "build.json").read_text()
|
||||
)
|
||||
assert observed == [built_at]
|
||||
assert observed == [built_at, built_at]
|
||||
assert plan["build"]["builtAt"] == built_at
|
||||
assert len(compose_calls) == 1
|
||||
|
||||
@@ -349,6 +584,7 @@ def test_candidate_preparation_rejects_staging_path_outside_private_import_ancho
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
built_at="2026-10-03T12:34:56.789Z",
|
||||
staging_directory=staging,
|
||||
)
|
||||
assert not staging.exists()
|
||||
@@ -363,16 +599,22 @@ def test_candidate_preparation_rejects_symlinked_staging_destination(harness):
|
||||
target = h.controller.STATE_ROOT / "untrusted-target"
|
||||
target.mkdir(mode=0o700)
|
||||
staging = token / revision
|
||||
staging.symlink_to(target, target_is_directory=True)
|
||||
try:
|
||||
staging.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("symlink creation is unavailable on this filesystem")
|
||||
with pytest.raises(h.controller.DeployError):
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
built_at="2026-10-03T12:34:56.789Z",
|
||||
staging_directory=staging,
|
||||
)
|
||||
|
||||
|
||||
def test_candidate_preparation_rejects_nonprivate_staging_parent(harness):
|
||||
if os.name == "nt":
|
||||
pytest.skip("POSIX private-mode enforcement is a Linux controller contract")
|
||||
h = harness
|
||||
revision = h.revision
|
||||
anchor = h.controller.STATE_ROOT / "mcp-hub-release-imports" / revision
|
||||
@@ -384,6 +626,7 @@ def test_candidate_preparation_rejects_nonprivate_staging_parent(harness):
|
||||
h.utility.plan_candidate(
|
||||
h.controller.MCP_HUB_REPOSITORY,
|
||||
revision,
|
||||
built_at="2026-10-03T12:34:56.789Z",
|
||||
staging_directory=staging,
|
||||
)
|
||||
|
||||
@@ -447,6 +690,10 @@ def test_capture_emits_candidate_and_pending_only_envelope_without_apply_calls(
|
||||
digests = json.loads((h.candidate_dir / "image-digests.json").read_text())
|
||||
assert result["status"] == "pending_review"
|
||||
assert set(candidate["images"]) == set(h.controller.MCP_HUB_SERVICES)
|
||||
assert candidate["images"]["connector-ingestion"]["imageRef"].endswith(
|
||||
"@sha256:" + h.state.ingestion_digest
|
||||
)
|
||||
assert candidate["build"]["builtAt"] == h.freeze_doc["build"]["builtAt"]
|
||||
assert digests["images"]["web"]["tag"] == candidate["images"]["web"]["imageRef"]
|
||||
assert digests["images"]["web"]["imageId"] == candidate["images"]["web"]["imageId"]
|
||||
candidate_bytes = (h.candidate_dir / "candidate.json").read_bytes()
|
||||
|
||||
@@ -385,10 +385,25 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main():
|
||||
node for node in parsed.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "fixed_image_reference"
|
||||
)
|
||||
declared = {
|
||||
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04",
|
||||
"redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48",
|
||||
}
|
||||
postgres = (
|
||||
"gitea.example.com/jens/itworx-mcp-hub/postgres:"
|
||||
"wp400-gosu-v2-20261002-a94e0b4d2029@sha256:"
|
||||
"a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73"
|
||||
)
|
||||
redis = (
|
||||
"gitea.example.com/jens/itworx-mcp-hub/redis:"
|
||||
"wp400-remediation-20261002-4e18f61c@sha256:"
|
||||
"7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2"
|
||||
)
|
||||
previous_postgres = (
|
||||
"postgres:18.6@sha256:"
|
||||
"0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04"
|
||||
)
|
||||
previous_redis = (
|
||||
"redis:8.10.2@sha256:"
|
||||
"7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48"
|
||||
)
|
||||
declared = {previous_postgres, previous_redis, postgres, redis}
|
||||
namespace = {
|
||||
"re": re,
|
||||
"main_declares_external_image": lambda image_ref: image_ref in declared,
|
||||
@@ -399,13 +414,19 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main():
|
||||
fixed = namespace["fixed_image_reference"]
|
||||
|
||||
assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a")
|
||||
assert fixed(
|
||||
"postgres",
|
||||
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04",
|
||||
)
|
||||
assert fixed("postgres", "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04")
|
||||
assert fixed("redis", "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236")
|
||||
assert fixed("redis", "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48")
|
||||
assert not fixed("postgres", "postgres:18.6@sha256:" + "a" * 64)
|
||||
assert fixed("postgres", postgres)
|
||||
assert fixed("redis", redis)
|
||||
assert not fixed("redis", postgres)
|
||||
assert not fixed("postgres", postgres[:-1] + "4")
|
||||
assert not fixed("postgres", postgres.replace("wp400-gosu-v2-20261002-a94e0b4d2029", "wp400-gosu-v2-20261002-wrong"))
|
||||
assert not fixed("postgres", postgres.replace("gitea.example.com/jens/itworx-mcp-hub", "gitea.example.com/other/itworx-mcp-hub"))
|
||||
assert not fixed("redis", redis[:-1] + "3")
|
||||
assert not fixed("redis", redis.replace("wp400-remediation-20261002-4e18f61c", "wp400-remediation-20261002-wrong"))
|
||||
assert not fixed("redis", redis.replace("gitea.example.com/jens/itworx-mcp-hub", "gitea.example.com/other/itworx-mcp-hub"))
|
||||
assert not fixed("postgres", "postgres:19.0@sha256:" + "a" * 64)
|
||||
assert not fixed("redis", "redis:8.10.3@sha256:" + "b" * 64)
|
||||
for service in ("web", "control-api", "dataplane-router", "connector-ingestion"):
|
||||
assert fixed(service, "sha256:" + "a" * 64)
|
||||
@@ -413,8 +434,58 @@ def test_external_candidate_pins_are_exact_and_declared_by_protected_main():
|
||||
assert not fixed(service, "sha256:" + "a" * 64)
|
||||
|
||||
namespace["main_declares_external_image"] = lambda _image_ref: False
|
||||
assert not fixed("postgres", "postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04")
|
||||
assert not fixed("redis", "redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48")
|
||||
assert fixed("postgres", "postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a")
|
||||
assert not fixed("postgres", previous_postgres)
|
||||
assert fixed("redis", "redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236")
|
||||
assert not fixed("redis", previous_redis)
|
||||
assert not fixed("postgres", postgres)
|
||||
assert not fixed("redis", redis)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service, image_ref", [
|
||||
(
|
||||
"postgres",
|
||||
"gitea.example.com/jens/itworx-mcp-hub/postgres:"
|
||||
"wp400-gosu-v2-20261002-a94e0b4d2029@sha256:"
|
||||
"a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73",
|
||||
),
|
||||
(
|
||||
"redis",
|
||||
"gitea.example.com/jens/itworx-mcp-hub/redis:"
|
||||
"wp400-remediation-20261002-4e18f61c@sha256:"
|
||||
"7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2",
|
||||
),
|
||||
])
|
||||
def test_new_db_pin_requires_exact_protected_main_compose_declaration(service, image_ref):
|
||||
fake = FakePortainer()
|
||||
McpHubDeploymentReconciliation(fake).run()
|
||||
script = fake.calls[0][2]["json"]["Cmd"][2]
|
||||
parsed = ast.parse(script)
|
||||
function = next(
|
||||
node for node in parsed.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "main_declares_external_image"
|
||||
)
|
||||
main_revision = "a" * 40
|
||||
content = {"value": f"services:\n {service}:\n image: {image_ref}\n"}
|
||||
calls = []
|
||||
|
||||
def git_run(*args):
|
||||
calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 0, stdout=content["value"])
|
||||
|
||||
namespace = {"re": re, "main_revision": main_revision, "git_run": git_run}
|
||||
exec(compile(ast.Module(body=[function], type_ignores=[]), "main-image-declaration-test", "exec"), namespace)
|
||||
declares = namespace["main_declares_external_image"]
|
||||
assert declares(image_ref)
|
||||
assert calls == [("show", f"{main_revision}:infrastructure/compose/docker-compose.yml")]
|
||||
|
||||
calls.clear()
|
||||
content["value"] = f"services:\n {service}:\n image: {image_ref[:-1]}0\n"
|
||||
assert not declares(image_ref)
|
||||
assert calls == [
|
||||
("show", f"{main_revision}:infrastructure/compose/docker-compose.yml"),
|
||||
("show", f"{main_revision}:infrastructure/compose/docker-compose.prod.yml"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -268,15 +268,49 @@ def test_exact_legacy_target_derives_only_the_fixed_17_service_contract(controll
|
||||
assert target["containers"] == list(controller.MCP_HUB_LEGACY_CONTAINERS)
|
||||
assert controller.effective_mcp_hub_project("jens/other", target) is target
|
||||
|
||||
def test_postgres_candidate_pin_matches_the_reviewed_release_evidence(controller):
|
||||
expected = (
|
||||
def test_db_candidate_pins_and_observed_transition_refs_are_exact(controller):
|
||||
postgres = (
|
||||
"gitea.example.com/jens/itworx-mcp-hub/postgres:"
|
||||
"wp400-gosu-v2-20261002-a94e0b4d2029@sha256:"
|
||||
"a60d81e06987671ae903757f1b71cdfbbff4d481b42610e4de2b7dcfa32aad73"
|
||||
)
|
||||
redis = (
|
||||
"gitea.example.com/jens/itworx-mcp-hub/redis:"
|
||||
"wp400-remediation-20261002-4e18f61c@sha256:"
|
||||
"7d826b44f245ad32e8fe4bf40d42eae95855c3942c5f2e42784ee8fd3f9547a2"
|
||||
)
|
||||
assert controller.MCP_HUB_POSTGRES_IMAGE == postgres
|
||||
assert controller.MCP_HUB_REDIS_IMAGE == redis
|
||||
assert controller.MCP_HUB_POSTGRES_PREVIOUS_IMAGE == (
|
||||
"postgres:18.6@sha256:0377e72c5289ed2f98cf61b1a9c2db9eb9d300317fe14244492fbc94343b3d04"
|
||||
)
|
||||
assert controller.MCP_HUB_POSTGRES_IMAGE == expected
|
||||
assert controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS["postgres"] == (
|
||||
controller.MCP_HUB_POSTGRES_LEGACY_IMAGE,
|
||||
expected,
|
||||
assert controller.MCP_HUB_REDIS_PREVIOUS_IMAGE == (
|
||||
"redis:8.10.2@sha256:7ef5b5cec96495a04ca7feff88a9492efeab8053fb284d24bdd73344c9245a48"
|
||||
)
|
||||
assert controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS == {
|
||||
"postgres": (
|
||||
(controller.MCP_HUB_POSTGRES_LEGACY_IMAGE, controller.MCP_HUB_POSTGRES_PREVIOUS_IMAGE),
|
||||
postgres,
|
||||
),
|
||||
"redis": (
|
||||
(controller.MCP_HUB_REDIS_LEGACY_IMAGE, controller.MCP_HUB_REDIS_PREVIOUS_IMAGE),
|
||||
redis,
|
||||
),
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("service", ["postgres", "redis"])
|
||||
def test_db_transition_accepts_only_new_forward_pin_against_exact_observed_refs(controller, service):
|
||||
observed_refs, candidate_ref = controller.MCP_HUB_OBSERVED_EXTERNAL_IMAGE_TRANSITIONS[service]
|
||||
assert controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref, candidate_ref)
|
||||
for old_ref in observed_refs:
|
||||
assert controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref, old_ref)
|
||||
assert not controller._mcp_hub_observed_external_ref_allowed(service, old_ref, old_ref)
|
||||
assert not controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref, old_ref + "-unexpected")
|
||||
assert not controller._mcp_hub_observed_external_ref_allowed(service, candidate_ref + "-unexpected", candidate_ref)
|
||||
other_candidate = (
|
||||
controller.MCP_HUB_REDIS_IMAGE if service == "postgres" else controller.MCP_HUB_POSTGRES_IMAGE
|
||||
)
|
||||
assert not controller._mcp_hub_observed_external_ref_allowed(service, other_candidate, candidate_ref)
|
||||
|
||||
def test_installed_legacy_registry_shape_is_adapted_only_in_memory(controller):
|
||||
target = {
|
||||
@@ -1402,7 +1436,7 @@ def test_private_backup_artifacts_publish_atomically_without_overwrite(
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"image_ref_name",
|
||||
["MCP_HUB_POSTGRES_IMAGE", "MCP_HUB_POSTGRES_LEGACY_IMAGE"],
|
||||
["MCP_HUB_POSTGRES_IMAGE", "MCP_HUB_POSTGRES_PREVIOUS_IMAGE", "MCP_HUB_POSTGRES_LEGACY_IMAGE"],
|
||||
)
|
||||
def test_live_mount_inventory_includes_volume_identity_source_and_read_only_state(
|
||||
controller, monkeypatch, image_ref_name
|
||||
|
||||
@@ -54,6 +54,7 @@ def prepared(controller, tmp_path, monkeypatch):
|
||||
project = {
|
||||
"_mcp_hub_contract": controller.MCP_HUB_TOPOLOGY_CONTRACT,
|
||||
"_repository_path": str(tmp_path / "hub.git"),
|
||||
"compose_project": controller.MCP_HUB_PROJECT,
|
||||
"_deployment_metadata": metadata,
|
||||
"_mcp_hub_source_date_epoch": "1790888863",
|
||||
}
|
||||
@@ -89,6 +90,10 @@ def prepared(controller, tmp_path, monkeypatch):
|
||||
ref = f"fixture/{service}@sha256:" + f"{index:064x}"
|
||||
if service == "contextforge":
|
||||
ref = f"itworx-mcp-hub/contextforge-schema:{revision}"
|
||||
elif service == "connector-ingestion":
|
||||
repository = controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES[service]
|
||||
archive_ref = f"{repository}:{revision}-run-21531-attempt-2"
|
||||
ref = f"{archive_ref}@sha256:" + f"{index:064x}"
|
||||
model["services"][service] = {"image": ref}
|
||||
vuln = {
|
||||
"SchemaVersion": 2,
|
||||
@@ -142,6 +147,27 @@ def prepared(controller, tmp_path, monkeypatch):
|
||||
}
|
||||
candidate_sha = write("candidate.json", candidate)
|
||||
exceptions_sha = write("exceptions.json", {"exceptions": []})
|
||||
write(
|
||||
"import-receipt.json",
|
||||
{
|
||||
"schema": "itworx.mcp-hub-import-receipt/v1",
|
||||
"repository": controller.MCP_HUB_REPOSITORY,
|
||||
"sourceRevision": revision,
|
||||
"sourceTree": metadata["tree"],
|
||||
"workflowRunId": 21531,
|
||||
"workflowRunAttempt": 2,
|
||||
"artifactId": 882,
|
||||
"artifactName": "hub-release-candidate-" + revision,
|
||||
"artifactZipSha256": "1" * 64,
|
||||
"manifestSha256": "2" * 64,
|
||||
"dockerTarSha256": "3" * 64,
|
||||
"candidateSha256": candidate_sha,
|
||||
"draftExceptionsSha256": exceptions_sha,
|
||||
"imageIds": {
|
||||
service: image["imageId"] for service, image in images.items()
|
||||
},
|
||||
},
|
||||
)
|
||||
write(
|
||||
"approval.json",
|
||||
{
|
||||
@@ -162,8 +188,17 @@ def prepared(controller, tmp_path, monkeypatch):
|
||||
v["imageRef"]: {
|
||||
"Id": v["imageId"],
|
||||
"Config": {"User": "10001:10001", "Labels": dict(labels)},
|
||||
**(
|
||||
{
|
||||
"RepoDigests": [
|
||||
f"{controller.MCP_HUB_OWNED_IMAGE_REPOSITORIES['connector-ingestion']}@{v['imageRef'].rsplit('@', 1)[1]}"
|
||||
]
|
||||
}
|
||||
if service == "connector-ingestion"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for v in images.values()
|
||||
for service, v in images.items()
|
||||
}
|
||||
monkeypatch.setattr(controller, "inspect_image", lambda ref: facts.get(ref))
|
||||
return types.SimpleNamespace(
|
||||
@@ -182,6 +217,9 @@ def prepared(controller, tmp_path, monkeypatch):
|
||||
def test_fresh_complete_candidate_freezes_metadata_and_checks_explicit_gate(
|
||||
controller, prepared
|
||||
):
|
||||
prepared.project["environment"] = {
|
||||
"CONNECTOR_INGESTION_IMAGE": "attacker.invalid/connector:latest"
|
||||
}
|
||||
result = controller.mcp_hub_load_release_candidate(
|
||||
prepared.project, prepared.revision
|
||||
)
|
||||
@@ -191,6 +229,35 @@ def test_fresh_complete_candidate_freezes_metadata_and_checks_explicit_gate(
|
||||
controller.mcp_hub_require_candidate_images(
|
||||
prepared.project, prepared.model, prepared.revision
|
||||
)
|
||||
binding = prepared.project["_mcp_hub_connector_ingestion_binding"]
|
||||
environment = controller.deployment_environment(
|
||||
prepared.project,
|
||||
prepared.revision,
|
||||
controller.MCP_HUB_REPOSITORY,
|
||||
prepared.metadata,
|
||||
)
|
||||
assert environment["CONNECTOR_INGESTION_IMAGE"] == binding["imageRef"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["missing", "attempt", "image_id"])
|
||||
def test_import_receipt_is_required_and_binds_connector_ingestion_attempt(
|
||||
controller, prepared, mutation
|
||||
):
|
||||
path = prepared.directory / "import-receipt.json"
|
||||
if mutation == "missing":
|
||||
path.unlink()
|
||||
else:
|
||||
receipt = json.loads(path.read_text(encoding="utf-8"))
|
||||
if mutation == "attempt":
|
||||
receipt["workflowRunAttempt"] = 1
|
||||
else:
|
||||
receipt["imageIds"]["connector-ingestion"] = "sha256:" + "f" * 64
|
||||
prepared.write("import-receipt.json", receipt)
|
||||
with pytest.raises(controller.DeployError):
|
||||
controller.mcp_hub_load_release_candidate(
|
||||
prepared.project, prepared.revision
|
||||
)
|
||||
assert prepared.checks == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -435,7 +502,8 @@ def test_compose_operations_pin_all_scanned_ids_without_changing_source_model(
|
||||
|
||||
def compose(project):
|
||||
path = Path(project["_mcp_hub_image_override_file"])
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
if controller.os.name != "nt":
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
observed.append((path, json.loads(path.read_text())))
|
||||
return prepared.directory, ["docker", "compose", "-f", str(path)]
|
||||
|
||||
@@ -775,6 +843,11 @@ def test_actual_high_findings_are_bound_to_current_service_tags(controller, prep
|
||||
}
|
||||
prepared.candidate["summarySha256"] = prepared.write("scan-summary.json", summary)
|
||||
candidate_sha = prepared.write("candidate.json", prepared.candidate)
|
||||
receipt = json.loads(
|
||||
(prepared.directory / "import-receipt.json").read_text(encoding="utf-8")
|
||||
)
|
||||
receipt["candidateSha256"] = candidate_sha
|
||||
prepared.write("import-receipt.json", receipt)
|
||||
prepared.write(
|
||||
"approval.json",
|
||||
{
|
||||
@@ -804,6 +877,11 @@ def test_fixable_high_findings_cannot_be_approved_around(controller, prepared):
|
||||
"vulnerability-web.json", report
|
||||
)
|
||||
candidate_sha = prepared.write("candidate.json", prepared.candidate)
|
||||
receipt = json.loads(
|
||||
(prepared.directory / "import-receipt.json").read_text(encoding="utf-8")
|
||||
)
|
||||
receipt["candidateSha256"] = candidate_sha
|
||||
prepared.write("import-receipt.json", receipt)
|
||||
prepared.write(
|
||||
"approval.json",
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ def _set_npm_config(
|
||||
settings.apis["NPM_URL"] = url
|
||||
settings.apis["NPM_IDENTITY"] = identity
|
||||
settings.apis["NPM_SECRET"] = secret
|
||||
settings.apis["NPM_VERIFY_TLS"] = False
|
||||
settings.apis["NPM_VERIFY_TLS"] = True
|
||||
settings.apis["NPM_TIMEOUT_SECONDS"] = "10"
|
||||
return previous
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def npm_settings(monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
previous = dict(settings.apis)
|
||||
settings.apis.update(
|
||||
{
|
||||
"NPM_URL": "https://npm.example.test",
|
||||
"NPM_IDENTITY": "operator@example.test",
|
||||
"NPM_SECRET": "fixture password value",
|
||||
"NPM_VERIFY_TLS": True,
|
||||
"NPM_TIMEOUT_SECONDS": "3",
|
||||
}
|
||||
)
|
||||
yield settings.apis
|
||||
settings.apis.clear()
|
||||
settings.apis.update(previous)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "verify_tls", "reason"),
|
||||
[
|
||||
("http://npm.example.test", True, "npm_https_required"),
|
||||
("https://user:fixture-password@npm.example.test", True, "npm_url_invalid"),
|
||||
("https://npm.example.test/?token=fixture-password", True, "npm_url_invalid"),
|
||||
("https://npm.example.test/#fixture-password", True, "npm_url_invalid"),
|
||||
("https://npm.example.test:", True, "npm_url_invalid"),
|
||||
("https://npm.example.test/base/../api", True, "npm_url_invalid"),
|
||||
("https://[broken", True, "npm_url_invalid"),
|
||||
("https://npm.example.test", False, "npm_tls_verification_required"),
|
||||
],
|
||||
)
|
||||
def test_unsafe_npm_transport_is_rejected_before_client_construction(
|
||||
npm_settings, monkeypatch, url, verify_tls, reason
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
npm_settings["NPM_URL"] = url
|
||||
npm_settings["NPM_VERIFY_TLS"] = verify_tls
|
||||
client_calls = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
|
||||
lambda **kwargs: client_calls.append(kwargs),
|
||||
)
|
||||
|
||||
connector = NginxProxyManagerConnector()
|
||||
with pytest.raises(RuntimeError, match=f"^{reason}$") as error:
|
||||
connector._login()
|
||||
|
||||
assert str(error.value) == reason
|
||||
assert client_calls == []
|
||||
assert "fixture password value" not in str(error.value)
|
||||
assert "npm.example.test" not in str(error.value)
|
||||
|
||||
|
||||
def test_missing_verify_setting_defaults_to_verified_https(npm_settings, monkeypatch):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
npm_settings.pop("NPM_VERIFY_TLS")
|
||||
clients = []
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {"token": "fixture bearer"}
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs):
|
||||
clients.append(kwargs)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, *_args, **_kwargs):
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
|
||||
)
|
||||
connector = NginxProxyManagerConnector()
|
||||
|
||||
assert connector._login() == "fixture bearer"
|
||||
assert clients == [{"timeout": 3.0, "verify": True, "follow_redirects": False}]
|
||||
|
||||
|
||||
def test_invalid_url_is_not_echoed_in_health_metadata(npm_settings, monkeypatch):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
npm_settings["NPM_URL"] = (
|
||||
"https://operator:fixture-password@npm.example.test/?token=fixture-password"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
|
||||
lambda **_kwargs: pytest.fail("invalid URL must be rejected before HTTP"),
|
||||
)
|
||||
|
||||
result = NginxProxyManagerConnector().health()
|
||||
|
||||
assert result["status"] == "unreachable"
|
||||
assert result["message"] == "npm_url_invalid"
|
||||
assert result["url"] == ""
|
||||
assert "fixture-password" not in str(result)
|
||||
assert "operator@npm.example.test" not in str(result)
|
||||
|
||||
|
||||
def test_login_and_api_request_preserve_base_path_without_following_redirects(
|
||||
npm_settings, monkeypatch
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
npm_settings["NPM_URL"] = "https://npm.example.test/manager/"
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def __init__(self, status_code, payload, headers=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs):
|
||||
assert kwargs == {
|
||||
"timeout": 3.0,
|
||||
"verify": True,
|
||||
"follow_redirects": False,
|
||||
}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
calls.append(("POST", url, kwargs))
|
||||
return Response(200, {"token": "fixture bearer"})
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
calls.append((method, url, kwargs))
|
||||
return Response(200, {"ok": True})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
|
||||
)
|
||||
connector = NginxProxyManagerConnector()
|
||||
|
||||
assert connector._request("GET", "/nginx/proxy-hosts") == {"ok": True}
|
||||
assert [call[:2] for call in calls] == [
|
||||
("POST", "https://npm.example.test/manager/api/tokens"),
|
||||
("GET", "https://npm.example.test/manager/api/nginx/proxy-hosts"),
|
||||
]
|
||||
assert "Authorization" not in calls[0][2].get("headers", {})
|
||||
|
||||
|
||||
def test_redirect_responses_are_not_followed_or_reported_as_success(
|
||||
npm_settings, monkeypatch
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def __init__(self):
|
||||
self.status_code = 302
|
||||
self.headers = {"location": "https://attacker.example.test/collect"}
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs):
|
||||
assert kwargs["follow_redirects"] is False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
calls.append((url, kwargs))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
|
||||
)
|
||||
connector = NginxProxyManagerConnector()
|
||||
|
||||
with pytest.raises(RuntimeError, match="^npm_redirect_denied$"):
|
||||
connector._login()
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "https://npm.example.test/api/tokens"
|
||||
assert calls[0][1]["json"]["secret"] == "fixture password value"
|
||||
|
||||
|
||||
def test_api_redirect_does_not_forward_bearer_token(npm_settings, monkeypatch):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.headers = {"location": "https://attacker.example.test/collect"}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs):
|
||||
assert kwargs["follow_redirects"] is False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
calls.append(("POST", url, kwargs))
|
||||
return Response(200, {"token": "fixture bearer"})
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
calls.append((method, url, kwargs))
|
||||
return Response(302)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
|
||||
)
|
||||
connector = NginxProxyManagerConnector()
|
||||
|
||||
with pytest.raises(RuntimeError, match="^npm_redirect_denied$"):
|
||||
connector._request("GET", "/nginx/proxy-hosts", follow_redirects=True)
|
||||
|
||||
assert [call[:2] for call in calls] == [
|
||||
("POST", "https://npm.example.test/api/tokens"),
|
||||
("GET", "https://npm.example.test/api/nginx/proxy-hosts"),
|
||||
]
|
||||
assert calls[1][2]["headers"]["Authorization"] == "Bearer fixture bearer"
|
||||
assert calls[1][2]["follow_redirects"] is False
|
||||
|
||||
|
||||
def test_login_error_body_cannot_echo_configured_secret(npm_settings, monkeypatch):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
class Response:
|
||||
status_code = 500
|
||||
text = "login failed for fixture password value"
|
||||
|
||||
class Client:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, *_args, **_kwargs):
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
|
||||
)
|
||||
connector = NginxProxyManagerConnector()
|
||||
|
||||
with pytest.raises(RuntimeError, match="^npm_api_error$") as error:
|
||||
connector._login()
|
||||
|
||||
assert "fixture password value" not in str(error.value)
|
||||
assert connector.last_error == "npm_api_error"
|
||||
|
||||
|
||||
def test_cached_token_is_cleared_before_unsafe_transport_reuse(npm_settings, monkeypatch):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
connector = NginxProxyManagerConnector()
|
||||
connector._config()
|
||||
connector._runtime_token = "stale fixture bearer"
|
||||
connector._read_cache["proxy_hosts"] = {"success": True, "items": ["stale"]}
|
||||
npm_settings["NPM_URL"] = "http://npm.example.test"
|
||||
client_calls = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
|
||||
lambda **kwargs: client_calls.append(kwargs),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="^npm_https_required$"):
|
||||
connector._login()
|
||||
|
||||
assert connector._runtime_token is None
|
||||
assert connector._read_cache == {}
|
||||
assert client_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed_key,changed_value",
|
||||
[
|
||||
("NPM_URL", "https://other.example.test"),
|
||||
("NPM_IDENTITY", "different@example.test"),
|
||||
("NPM_SECRET", "different fixture password"),
|
||||
],
|
||||
)
|
||||
def test_cached_token_and_read_cache_are_bound_to_transport_config(
|
||||
npm_settings, monkeypatch, changed_key, changed_value
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
connector = NginxProxyManagerConnector()
|
||||
connector._config()
|
||||
connector._runtime_token = "cached fixture bearer"
|
||||
connector._read_cache["proxy_hosts"] = {"success": True, "items": ["old"]}
|
||||
npm_settings[changed_key] = changed_value
|
||||
requested = []
|
||||
monkeypatch.setattr(
|
||||
connector,
|
||||
"_request",
|
||||
lambda method, path, **_kwargs: requested.append((method, path)) or [],
|
||||
)
|
||||
|
||||
result = connector.proxy_hosts()
|
||||
|
||||
assert result["items"] == []
|
||||
assert requested == [("GET", "/nginx/proxy-hosts")]
|
||||
assert connector._runtime_token is None
|
||||
assert connector._read_cache["proxy_hosts"] is result
|
||||
|
||||
|
||||
def test_disabled_tls_clears_cached_state_before_read_cache_return(
|
||||
npm_settings, monkeypatch
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
connector = NginxProxyManagerConnector()
|
||||
connector._config()
|
||||
connector._runtime_token = "cached fixture bearer"
|
||||
connector._read_cache["proxy_hosts"] = {"success": True, "items": ["old"]}
|
||||
npm_settings["NPM_VERIFY_TLS"] = False
|
||||
monkeypatch.setattr(
|
||||
connector,
|
||||
"_request",
|
||||
lambda *_args, **_kwargs: pytest.fail("unsafe transport must not request"),
|
||||
)
|
||||
|
||||
result = connector.proxy_hosts()
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["message"] == "npm_tls_verification_required"
|
||||
assert connector._runtime_token is None
|
||||
assert connector._read_cache == {}
|
||||
|
||||
|
||||
def test_malformed_tls_setting_is_rejected_and_url_metadata_is_hidden(
|
||||
npm_settings, monkeypatch
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
npm_settings["NPM_VERIFY_TLS"] = "maybe"
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
|
||||
lambda **_kwargs: pytest.fail("invalid TLS setting must be rejected before HTTP"),
|
||||
)
|
||||
|
||||
result = NginxProxyManagerConnector().health()
|
||||
|
||||
assert result["status"] == "unreachable"
|
||||
assert result["message"] == "npm_config_invalid"
|
||||
assert result["url"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed_key,changed_value",
|
||||
[
|
||||
("NPM_URL", "https://other.example.test"),
|
||||
("NPM_IDENTITY", "different@example.test"),
|
||||
("NPM_SECRET", "different fixture password"),
|
||||
],
|
||||
)
|
||||
def test_changed_credentials_require_a_new_login(
|
||||
npm_settings, monkeypatch, changed_key, changed_value
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
token_calls = []
|
||||
token_urls = []
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, token):
|
||||
self._token = token
|
||||
|
||||
def json(self):
|
||||
return {"token": self._token}
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs):
|
||||
assert kwargs["verify"] is True
|
||||
assert kwargs["follow_redirects"] is False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
token_urls.append(url)
|
||||
token_calls.append(kwargs["json"])
|
||||
return Response(f"fixture-token-{len(token_calls)}")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client", Client
|
||||
)
|
||||
connector = NginxProxyManagerConnector()
|
||||
|
||||
assert connector._login() == "fixture-token-1"
|
||||
npm_settings[changed_key] = changed_value
|
||||
assert connector._login() == "fixture-token-2"
|
||||
assert len(token_calls) == 2
|
||||
assert token_urls[0] == "https://npm.example.test/api/tokens"
|
||||
expected_url = npm_settings["NPM_URL"].rstrip("/") + "/api/tokens"
|
||||
assert token_urls[1] == expected_url
|
||||
assert token_calls[1]["identity"] == npm_settings["NPM_IDENTITY"]
|
||||
assert token_calls[1]["secret"] == npm_settings["NPM_SECRET"]
|
||||
|
||||
|
||||
def test_missing_config_rejects_before_client_even_with_cached_token(
|
||||
npm_settings, monkeypatch
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
connector = NginxProxyManagerConnector()
|
||||
connector._config()
|
||||
connector._runtime_token = "stale fixture bearer"
|
||||
npm_settings["NPM_URL"] = None
|
||||
client_calls = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.connectors.nginx_proxy_manager.httpx.Client",
|
||||
lambda **kwargs: client_calls.append(kwargs),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="^missing_config$"):
|
||||
connector._login()
|
||||
|
||||
assert connector._runtime_token is None
|
||||
assert client_calls == []
|
||||
|
||||
|
||||
def test_missing_config_check_clears_cached_context_before_configuration_returns(
|
||||
npm_settings,
|
||||
):
|
||||
from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
|
||||
connector = NginxProxyManagerConnector()
|
||||
connector._config()
|
||||
connector._runtime_token = "stale fixture bearer"
|
||||
connector._read_cache["proxy_hosts"] = {"success": True, "items": []}
|
||||
npm_settings["NPM_SECRET"] = None
|
||||
|
||||
assert connector.is_configured() is False
|
||||
assert connector._runtime_token is None
|
||||
assert connector._read_cache == {}
|
||||
|
||||
|
||||
def test_missing_npm_tls_environment_default_does_not_inherit_global_disable(
|
||||
monkeypatch,
|
||||
):
|
||||
import builtins
|
||||
|
||||
from backend.app.core import config
|
||||
|
||||
monkeypatch.setenv("APPOPS_VERIFY_TLS", "false")
|
||||
monkeypatch.delenv("NPM_VERIFY_TLS", raising=False)
|
||||
for name in (
|
||||
"APPOPS_MCP_TOKEN_FILE",
|
||||
"APPOPS_N8N_TOKEN_FILE",
|
||||
"APPOPS_SECRET_KEY_FILE",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setattr(config, "_secret_value", lambda *_args: None)
|
||||
monkeypatch.setattr(
|
||||
builtins,
|
||||
"open",
|
||||
lambda *_args, **_kwargs: pytest.fail("config test must not read secret files"),
|
||||
)
|
||||
|
||||
configured = config.Settings()
|
||||
|
||||
assert configured.verify_tls is False
|
||||
assert configured.apis["NPM_VERIFY_TLS"] is True
|
||||
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
MODULE = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "ops/gitea-deploy-broker/registered_build_context.py"
|
||||
)
|
||||
|
||||
|
||||
def load():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"registered_build_context_test", MODULE
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def git(path, *args):
|
||||
return (
|
||||
subprocess.run(
|
||||
["git", "-C", str(path), *args],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
.stdout.decode()
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inputs(tmp_path):
|
||||
checkout = tmp_path / "working"
|
||||
checkout.mkdir()
|
||||
git(checkout, "init", "-q")
|
||||
git(checkout, "config", "user.email", "test@example.invalid")
|
||||
git(checkout, "config", "user.name", "Test")
|
||||
(checkout / "server").mkdir()
|
||||
(checkout / "server/Dockerfile").write_text("FROM scratch\nCOPY . /app\n")
|
||||
(checkout / "server/main.py").write_text("print('protected')\n")
|
||||
(checkout / "run.sh").write_text("#!/bin/sh\nexit 0\n")
|
||||
(checkout / "run.sh").chmod(0o755)
|
||||
git(checkout, "add", ".")
|
||||
git(checkout, "update-index", "--chmod=+x", "run.sh")
|
||||
git(checkout, "commit", "-qm", "protected source")
|
||||
revision = git(checkout, "rev-parse", "HEAD")
|
||||
bare = tmp_path / "repo.git"
|
||||
subprocess.run(
|
||||
["git", "clone", "--bare", "--quiet", str(checkout), str(bare)], check=True
|
||||
)
|
||||
live = tmp_path / "live"
|
||||
live.mkdir()
|
||||
(live / "server").mkdir()
|
||||
(live / "server/stale.py").write_text("untracked")
|
||||
(live / ".env").write_text("PRIVATE_VALUE=do-not-copy")
|
||||
project = {"slug": "quiz-monitor", "source_dir": str(live), "compose_dir": ""}
|
||||
model = {
|
||||
"services": {
|
||||
"quiz-server": {
|
||||
"build": {"context": str(live), "dockerfile": "server/Dockerfile"}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bare, revision, project, model, tmp_path / "state", live, checkout
|
||||
|
||||
|
||||
def prepare(module, inputs):
|
||||
return module.prepare_build_contexts(*inputs[:5])
|
||||
|
||||
|
||||
def test_exact_archive_omits_live_untracked_files_and_preserves_live_tree(inputs):
|
||||
result = prepare(load(), inputs)
|
||||
tree = Path(result["quiz-server"])
|
||||
assert tree.is_absolute()
|
||||
assert (tree / "server/main.py").read_text() == "print('protected')\n"
|
||||
assert not (tree / "server/stale.py").exists()
|
||||
assert not (tree / ".env").exists()
|
||||
assert (inputs[5] / "server/stale.py").read_text() == "untracked"
|
||||
assert (inputs[5] / ".env").read_text() == "PRIVATE_VALUE=do-not-copy"
|
||||
assert (tree.parent / "manifest.json").is_file()
|
||||
assert not (tree / "manifest.json").exists()
|
||||
assert prepare(load(), inputs) == result
|
||||
if os.name != "nt":
|
||||
assert tree.parent.stat().st_mode & 0o777 == 0o700
|
||||
assert (tree / "run.sh").stat().st_mode & 0o777 == 0o755
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["content", "extra", "missing", "mode", "manifest"])
|
||||
def test_cached_context_drift_fails_without_replacing_source(inputs, change):
|
||||
module = load()
|
||||
tree = Path(prepare(module, inputs)["quiz-server"])
|
||||
if change == "content":
|
||||
(tree / "server/main.py").write_text("tampered")
|
||||
elif change == "extra":
|
||||
(tree / "untracked.py").write_text("tampered")
|
||||
elif change == "missing":
|
||||
(tree / "server/main.py").unlink()
|
||||
elif change == "mode":
|
||||
if os.name == "nt":
|
||||
pytest.skip("POSIX permissions are verified on Linux")
|
||||
(tree / "run.sh").chmod(0o644)
|
||||
else:
|
||||
(tree.parent / "manifest.json").write_text("{}")
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
prepare(module, inputs)
|
||||
assert (inputs[5] / "server/stale.py").is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"build",
|
||||
[
|
||||
{"context": "../outside", "dockerfile": "Dockerfile"},
|
||||
{"context": "https://example.invalid/source.git"},
|
||||
{"context": ".", "dockerfile": "../outside/Dockerfile"},
|
||||
{"context": ".", "dockerfile": "not-tracked"},
|
||||
{"context": ".", "additional_contexts": {"other": "/elsewhere"}},
|
||||
{"context": ".", "dockerfile_inline": "FROM scratch"},
|
||||
],
|
||||
)
|
||||
def test_external_or_untracked_build_inputs_are_rejected(inputs, build):
|
||||
module = load()
|
||||
inputs[3]["services"]["quiz-server"]["build"] = build
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
prepare(module, inputs)
|
||||
|
||||
|
||||
def test_subdirectory_context_is_remapped_with_exact_revision(inputs):
|
||||
inputs[3]["services"]["quiz-server"]["build"] = {
|
||||
"context": "server",
|
||||
"dockerfile": "Dockerfile",
|
||||
}
|
||||
result = prepare(load(), inputs)
|
||||
assert Path(result["quiz-server"]).name == "server"
|
||||
assert (Path(result["quiz-server"]) / "main.py").is_file()
|
||||
|
||||
|
||||
def test_revision_is_not_a_branch_and_old_archive_survives_new_commit(inputs):
|
||||
module = load()
|
||||
result = prepare(module, inputs)
|
||||
inputs[6].joinpath("server/main.py").write_text("changed main")
|
||||
git(inputs[6], "commit", "-qam", "advance main")
|
||||
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
|
||||
assert prepare(module, inputs) == result
|
||||
assert (
|
||||
Path(result["quiz-server"]).joinpath("server/main.py").read_text()
|
||||
== "print('protected')\n"
|
||||
)
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
module.prepare_build_contexts(inputs[0], "main", *inputs[2:5])
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary")
|
||||
def test_symlinked_live_context_or_archived_file_is_rejected(inputs):
|
||||
module = load()
|
||||
(inputs[5] / "link").symlink_to(inputs[5] / "server", target_is_directory=True)
|
||||
inputs[3]["services"]["quiz-server"]["build"] = {
|
||||
"context": "link",
|
||||
"dockerfile": "Dockerfile",
|
||||
}
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
prepare(module, inputs)
|
||||
inputs[6].joinpath("tracked-link").symlink_to("server/main.py")
|
||||
git(inputs[6], "add", "tracked-link")
|
||||
git(inputs[6], "commit", "-qm", "unsupported tracked symlink")
|
||||
revision = git(inputs[6], "rev-parse", "HEAD")
|
||||
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
|
||||
inputs[3]["services"]["quiz-server"]["build"] = {
|
||||
"context": ".",
|
||||
"dockerfile": "server/Dockerfile",
|
||||
}
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
module.prepare_build_contexts(inputs[0], revision, *inputs[2:5])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ["secrets", "ssh", "additional_contexts", "dockerfile_inline"]
|
||||
)
|
||||
def test_build_cannot_request_host_credentials(inputs, field):
|
||||
module = load()
|
||||
inputs[3]["services"]["quiz-server"]["build"][field] = ["default"]
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
prepare(module, inputs)
|
||||
|
||||
|
||||
def test_tracked_runtime_env_is_rejected_before_cache_creation(inputs):
|
||||
module = load()
|
||||
inputs[6].joinpath(".env").write_text("PRIVATE_CONFIG=value")
|
||||
git(inputs[6], "add", ".env")
|
||||
git(inputs[6], "commit", "-qm", "unsupported runtime env")
|
||||
revision = git(inputs[6], "rev-parse", "HEAD")
|
||||
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
module.prepare_build_contexts(inputs[0], revision, *inputs[2:5])
|
||||
assert not inputs[4].exists()
|
||||
|
||||
|
||||
def test_git_export_attributes_cannot_change_exact_archive_bytes(inputs):
|
||||
module = load()
|
||||
inputs[6].joinpath(".gitattributes").write_text("server/main.py export-ignore\n")
|
||||
git(inputs[6], "add", ".gitattributes")
|
||||
git(inputs[6], "commit", "-qm", "unsupported export omission")
|
||||
revision = git(inputs[6], "rev-parse", "HEAD")
|
||||
git(inputs[0], "fetch", "--quiet", str(inputs[6]), "+HEAD:refs/heads/main")
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
module.prepare_build_contexts(inputs[0], revision, *inputs[2:5])
|
||||
assert not inputs[4].joinpath("registered-builds/quiz-monitor", revision).exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink boundary")
|
||||
def test_cached_directory_link_is_rejected_without_touching_link_target(inputs):
|
||||
module = load()
|
||||
tree = Path(prepare(module, inputs)["quiz-server"])
|
||||
tree.rename(tree.parent / "original")
|
||||
tree.symlink_to(inputs[5], target_is_directory=True)
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
prepare(module, inputs)
|
||||
assert (inputs[5] / ".env").is_file()
|
||||
|
||||
|
||||
def test_tree_size_bound_prevents_archive_execution(inputs, monkeypatch):
|
||||
module = load()
|
||||
monkeypatch.setattr(module, "MAX_BYTES", 1)
|
||||
with pytest.raises(module.RegisteredBuildContextError, match="bound_exceeded"):
|
||||
prepare(module, inputs)
|
||||
assert not inputs[4].exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or os.getuid() != 0,
|
||||
reason="observed Linux read-only Gitea mount ownership",
|
||||
)
|
||||
def test_gitea_owned_readonly_repository_uses_exact_safe_directory(inputs, monkeypatch):
|
||||
module = load()
|
||||
os.chown(inputs[0], 1000, 1000)
|
||||
commands = []
|
||||
original = module.subprocess.Popen
|
||||
|
||||
def spy(command, **kwargs):
|
||||
commands.append(command)
|
||||
return original(command, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "Popen", spy)
|
||||
result = prepare(module, inputs)
|
||||
assert Path(result["quiz-server"]).joinpath("server/main.py").is_file()
|
||||
assert commands
|
||||
assert all(
|
||||
command[:4]
|
||||
== ["git", "--no-replace-objects", "-c", f"safe.directory={inputs[0]}"]
|
||||
for command in commands
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.name == "nt" or os.getuid() != 0, reason="Linux repository UID boundary"
|
||||
)
|
||||
@pytest.mark.parametrize("owner,mode", [(1001, 0o755), (1000, 0o775), (0, 0o777)])
|
||||
def test_unexpected_or_writable_repository_is_rejected(inputs, owner, mode):
|
||||
module = load()
|
||||
os.chown(inputs[0], owner, owner)
|
||||
inputs[0].chmod(mode)
|
||||
with pytest.raises(module.RegisteredBuildContextError):
|
||||
prepare(module, inputs)
|
||||
assert not inputs[4].exists()
|
||||
|
||||
|
||||
def test_windows_path_identity_uses_creation_time_not_inconsistent_ctime(monkeypatch):
|
||||
module = load()
|
||||
monkeypatch.setattr(module, "os", SimpleNamespace(name="nt"))
|
||||
descriptor = SimpleNamespace(
|
||||
st_dev=1,
|
||||
st_ino=2,
|
||||
st_size=3,
|
||||
st_mtime_ns=40,
|
||||
st_ctime_ns=40,
|
||||
st_birthtime_ns=30,
|
||||
)
|
||||
named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 30})
|
||||
assert module._path_identity(descriptor) == module._path_identity(named)
|
||||
named.st_ino = 9
|
||||
assert module._path_identity(descriptor) != module._path_identity(named)
|
||||
|
||||
|
||||
def test_posix_path_identity_preserves_change_time(monkeypatch):
|
||||
module = load()
|
||||
monkeypatch.setattr(module, "os", SimpleNamespace(name="posix"))
|
||||
descriptor = SimpleNamespace(
|
||||
st_dev=1,
|
||||
st_ino=2,
|
||||
st_size=3,
|
||||
st_mtime_ns=40,
|
||||
st_ctime_ns=40,
|
||||
)
|
||||
named = SimpleNamespace(**{**vars(descriptor), "st_ctime_ns": 41})
|
||||
assert module._path_identity(descriptor) != module._path_identity(named)
|
||||
|
||||
|
||||
def test_descriptor_metadata_changed_during_read_is_rejected(tmp_path, monkeypatch):
|
||||
module = load()
|
||||
path = tmp_path / "tracked"
|
||||
raw = b"tracked bytes"
|
||||
path.write_bytes(raw)
|
||||
path.chmod(0o644)
|
||||
original = module.os.fstat
|
||||
calls = 0
|
||||
|
||||
def changed(descriptor):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
info = original(descriptor)
|
||||
if calls == 1:
|
||||
return info
|
||||
fields = {
|
||||
name: getattr(info, name) for name in dir(info) if name.startswith("st_")
|
||||
}
|
||||
fields["st_ctime_ns"] += 1
|
||||
return SimpleNamespace(**fields)
|
||||
|
||||
monkeypatch.setattr(module.os, "fstat", changed)
|
||||
with pytest.raises(module.RegisteredBuildContextError, match="file_changed"):
|
||||
module._file(
|
||||
path, {"size": len(raw), "oid": module._blob_digest(raw), "mode": 0o644}
|
||||
)
|
||||
|
||||
|
||||
def test_git_replacement_refs_cannot_substitute_other_commit_contents(inputs):
|
||||
checkout, bare, protected = inputs[6], inputs[0], inputs[1]
|
||||
checkout.joinpath("server/main.py").write_text("replacement commit bytes\n")
|
||||
git(checkout, "commit", "-qam", "replacement commit")
|
||||
other = git(checkout, "rev-parse", "HEAD")
|
||||
git(bare, "fetch", "--quiet", str(checkout), "HEAD")
|
||||
git(bare, "replace", protected, other)
|
||||
assert (
|
||||
git(bare, "show", f"{protected}:server/main.py") == "replacement commit bytes"
|
||||
)
|
||||
tree = Path(prepare(load(), inputs)["quiz-server"])
|
||||
assert tree.joinpath("server/main.py").read_text() == "print('protected')\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["C:/outside.txt", "D:outside.txt", "server/file:stream"]
|
||||
)
|
||||
def test_git_manifest_rejects_windows_drive_and_stream_paths_before_extraction(
|
||||
tmp_path, monkeypatch, name
|
||||
):
|
||||
module = load()
|
||||
revision, tree, blob = "a" * 40, "b" * 40, "c" * 40
|
||||
responses = iter(
|
||||
[
|
||||
f"{revision}\n".encode(),
|
||||
f"{tree}\n".encode(),
|
||||
f"100644 blob {blob} 1\t{name}\0".encode(),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(module, "_git_output", lambda *_args: next(responses))
|
||||
with pytest.raises(module.RegisteredBuildContextError, match="path_invalid"):
|
||||
module._manifest(tmp_path, revision)
|
||||
assert not list(tmp_path.iterdir())
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_deploy_broker import BROKER_ROOT, _load_module
|
||||
|
||||
|
||||
@pytest.mark.parametrize("repository", ["jens/quiz-monitor", "jens/another-registered-app"])
|
||||
def test_verified_registered_build_uses_controller_metadata(repository, monkeypatch):
|
||||
module = _load_module("registered_build_metadata", BROKER_ROOT / "deploy.py")
|
||||
revision = "a" * 40
|
||||
metadata = {"repository": repository, "revision": revision, "tree": "b" * 40,
|
||||
"id": "protected-build-42", "builtAt": "2026-10-07T00:00:00Z"}
|
||||
monkeypatch.setenv("APPOPS_SOURCE_TREE", "stale-inherited-tree")
|
||||
project = {"compose_project": "existing-project", "verify_oci_revision": True,
|
||||
"environment": {"APPOPS_SOURCE_TREE": "wrong-tree", "APPOPS_BUILD_ID": "wrong-id",
|
||||
"APPOPS_BUILD_DATE": "wrong-date", "USER_SETTING": "preserved$literal"}}
|
||||
actual = module.deployment_environment(project, revision, repository, metadata)
|
||||
assert {key: actual[key] for key in (
|
||||
"APPOPS_SOURCE_REVISION", "APPOPS_SOURCE_REPOSITORY", "APPOPS_SOURCE_TREE",
|
||||
"APPOPS_BUILD_ID", "APPOPS_BUILD_DATE", "COMPOSE_PROJECT_NAME", "USER_SETTING",
|
||||
)} == {"APPOPS_SOURCE_REVISION": revision, "APPOPS_SOURCE_REPOSITORY": repository,
|
||||
"APPOPS_SOURCE_TREE": metadata["tree"], "APPOPS_BUILD_ID": metadata["id"],
|
||||
"APPOPS_BUILD_DATE": metadata["builtAt"], "COMPOSE_PROJECT_NAME": "existing-project",
|
||||
"USER_SETTING": "preserved$literal"}
|
||||
|
||||
|
||||
def test_unverified_target_retains_existing_environment_overrides():
|
||||
module = _load_module("unverified_build_metadata", BROKER_ROOT / "deploy.py")
|
||||
project = {"compose_project": "existing-project", "environment": {
|
||||
"APPOPS_SOURCE_TREE": "existing-tree", "APPOPS_BUILD_ID": "existing-id",
|
||||
"APPOPS_BUILD_DATE": "existing-date"}}
|
||||
actual = module.deployment_environment(project, "a" * 40, "jens/another-app")
|
||||
assert actual["APPOPS_SOURCE_TREE"] == "existing-tree"
|
||||
assert actual["APPOPS_BUILD_ID"] == "existing-id"
|
||||
assert actual["APPOPS_BUILD_DATE"] == "existing-date"
|
||||
|
||||
|
||||
def test_unbound_target_does_not_enter_legacy_build_adoption(monkeypatch):
|
||||
module = _load_module("unbound_build_metadata", BROKER_ROOT / "deploy.py")
|
||||
monkeypatch.setattr(module, "compose_command", lambda *_: pytest.fail("unbound target rendered"))
|
||||
module.prepare_registered_legacy_build({}, "a" * 40, "jens/quiz-monitor", {})
|
||||
|
||||
|
||||
def test_adopted_target_environment_excludes_controller_secrets(monkeypatch):
|
||||
module = _load_module("isolated_legacy_environment", BROKER_ROOT / "deploy.py")
|
||||
monkeypatch.setenv("UNRELATED_CONTROLLER_CREDENTIAL", "private-controller-value")
|
||||
project = {"compose_project": "quiz-monitor", "_registered_legacy_request_id":
|
||||
"6f9619ff-8b86-4011-b42d-00c04fc964ff", "environment": {
|
||||
"OPENRGB_HOST": "preserved-value"}}
|
||||
environment = module.deployment_environment(project, "a" * 40, "jens/quiz-monitor")
|
||||
assert "UNRELATED_CONTROLLER_CREDENTIAL" not in environment
|
||||
assert environment["OPENRGB_HOST"] == "preserved-value"
|
||||
assert environment["APPOPS_SOURCE_REVISION"] == "a" * 40
|
||||
|
||||
|
||||
def test_legacy_build_binds_archive_and_private_runtime_configuration(tmp_path, monkeypatch):
|
||||
module = _load_module("legacy_build_metadata", BROKER_ROOT / "deploy.py")
|
||||
revision = "a" * 40
|
||||
repository = "jens/quiz-monitor"
|
||||
project = {"_registered_legacy_request_id": "6f9619ff-8b86-4011-b42d-00c04fc964ff",
|
||||
"_repository_path": str(tmp_path / "repository.git")}
|
||||
model = {"services": {"quiz-server": {"build": {"context": str(tmp_path)}}}}
|
||||
contexts = {"quiz-server": str(tmp_path / "verified-archive")}
|
||||
environment = {"OPENRGB_HOST": "preserved-value", "APPOPS_SOURCE_REVISION": revision}
|
||||
observed = {}
|
||||
|
||||
def archive(repo_path, target_revision, target_project, rendered_model, source_root):
|
||||
observed["archive"] = (repo_path, target_revision, target_project, rendered_model, source_root)
|
||||
return contexts
|
||||
|
||||
def configuration(repo, target_project, request_id, target_revision, rendered_model,
|
||||
context_map, supplied_environment, **kwargs):
|
||||
observed["configuration"] = (repo, request_id, target_revision, rendered_model,
|
||||
context_map, supplied_environment, kwargs)
|
||||
return {"overlayPath": str(tmp_path / "private-compose.json")}
|
||||
|
||||
monkeypatch.setattr(module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"]))
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda *_, **__: SimpleNamespace(
|
||||
returncode=0, stdout=json.dumps(model)))
|
||||
monkeypatch.setattr(module, "_load_registered_build_context", lambda: SimpleNamespace(
|
||||
prepare_build_contexts=archive))
|
||||
monkeypatch.setattr(module, "_load_registered_legacy_adoption", lambda: SimpleNamespace(
|
||||
prepare_build_configuration=configuration))
|
||||
monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32)
|
||||
module.prepare_registered_legacy_build(project, revision, repository, environment)
|
||||
assert observed["archive"][1:] == (revision, project, model, module.EXACT_SOURCE_ROOT)
|
||||
assert observed["configuration"][:6] == (
|
||||
repository, project["_registered_legacy_request_id"], revision, model, contexts, environment)
|
||||
assert project["_registered_legacy_compose_override_file"] == str(tmp_path / "private-compose.json")
|
||||
assert project["_registered_legacy_compose_environment"] == environment
|
||||
assert project["_registered_legacy_compose_environment"] is not environment
|
||||
|
||||
|
||||
def test_failed_source_render_cannot_prepare_archive_or_runtime(tmp_path, monkeypatch):
|
||||
module = _load_module("failed_render_build_metadata", BROKER_ROOT / "deploy.py")
|
||||
project = {"_registered_legacy_request_id": "6f9619ff-8b86-4011-b42d-00c04fc964ff"}
|
||||
monkeypatch.setattr(module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"]))
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda *_, **__: SimpleNamespace(returncode=1, stdout=""))
|
||||
monkeypatch.setattr(module, "_load_registered_build_context", lambda: pytest.fail("unsafe archive preparation"))
|
||||
with pytest.raises(module.DeployError, match="could not be rendered"):
|
||||
module.prepare_registered_legacy_build(project, "a" * 40, "jens/quiz-monitor", {})
|
||||
|
||||
|
||||
def test_registered_metadata_and_source_ignore_git_replacement_refs(tmp_path, monkeypatch):
|
||||
module = _load_module("registered_original_git_objects", BROKER_ROOT / "deploy.py")
|
||||
monkeypatch.delenv("GIT_NO_REPLACE_OBJECTS", raising=False)
|
||||
|
||||
def git(*arguments):
|
||||
return subprocess.check_output(["git", "-C", str(tmp_path), *arguments], text=True).strip()
|
||||
|
||||
git("init", "-b", "main")
|
||||
git("config", "user.name", "Test")
|
||||
git("config", "user.email", "test@example.invalid")
|
||||
tracked = tmp_path / "source.txt"
|
||||
tracked.write_text("protected original\n")
|
||||
git("add", "source.txt")
|
||||
git("commit", "-qm", "protected source")
|
||||
protected, original_tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
||||
tracked.write_text("substituted source\n")
|
||||
git("commit", "-qam", "replacement source")
|
||||
replacement = git("rev-parse", "HEAD")
|
||||
git("reset", "--hard", protected)
|
||||
git("replace", protected, replacement)
|
||||
assert git("show", f"{protected}:source.txt") == "substituted source"
|
||||
monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", "6f9619ff-8b86-4011-b42d-00c04fc964ff")
|
||||
# Restore the process variable after testing the production binding.
|
||||
monkeypatch.setenv("GIT_NO_REPLACE_OBJECTS", "0")
|
||||
project = {}
|
||||
module.bind_registered_legacy_request(project, "deploy")
|
||||
assert module.validate_commit(tmp_path / ".git", "main", protected) == protected
|
||||
assert module.deployment_metadata(tmp_path / ".git", protected, "jens/quiz-monitor")["tree"] == original_tree
|
||||
assert git("show", f"{protected}:source.txt") == "protected original"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,request_id", [("rollback", "6f9619ff-8b86-4011-b42d-00c04fc964ff"),
|
||||
("deploy", "invalid")])
|
||||
def test_invalid_registered_binding_cannot_change_git_reads(monkeypatch, mode, request_id):
|
||||
module = _load_module("invalid_registered_git_binding", BROKER_ROOT / "deploy.py")
|
||||
monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", request_id)
|
||||
monkeypatch.delenv("GIT_NO_REPLACE_OBJECTS", raising=False)
|
||||
with pytest.raises(module.DeployError, match="binding is invalid"):
|
||||
module.bind_registered_legacy_request({}, mode)
|
||||
assert "GIT_NO_REPLACE_OBJECTS" not in module.os.environ
|
||||
|
||||
|
||||
def test_registered_compose_promotion_keeps_sealed_local_image(tmp_path, monkeypatch):
|
||||
module = _load_module("registered_no_mutable_pull", BROKER_ROOT / "deploy.py")
|
||||
project = {"source_dir": str(tmp_path), "_registered_legacy_request_id":
|
||||
"6f9619ff-8b86-4011-b42d-00c04fc964ff"}
|
||||
commands, events = [], []
|
||||
monkeypatch.setattr(module, "deployment_environment", lambda *_: {})
|
||||
monkeypatch.setattr(module, "prepare_registered_legacy_build", lambda *_: None)
|
||||
monkeypatch.setattr(module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"]))
|
||||
monkeypatch.setattr(module, "prepare_compose_migration", lambda *_: [])
|
||||
monkeypatch.setattr(module, "workmesh_transition_enabled", lambda *_: False)
|
||||
monkeypatch.setattr(module, "_registered_legacy_receipt_key", lambda: b"k" * 32)
|
||||
monkeypatch.setattr(module, "_load_registered_legacy_adoption", lambda: SimpleNamespace(
|
||||
prepare_candidate_deployment=lambda *_, **__: events.append("sealed"),
|
||||
verify_candidate_pre_mutation=lambda *_, **__: events.append("checked"),
|
||||
record_candidate_runtime=lambda *_, **__: events.append("observed")))
|
||||
|
||||
def run(command, **_):
|
||||
commands.append(command)
|
||||
if "up" in command:
|
||||
assert events == ["sealed", "checked"]
|
||||
|
||||
monkeypatch.setattr(module, "run", run)
|
||||
module.deploy_project(project, "a" * 40, "jens/quiz-monitor")
|
||||
assert commands[-1] == ["docker", "compose", "up", "--detach", "--no-build", "--pull", "never"]
|
||||
assert events[-1] == "observed"
|
||||
@@ -0,0 +1,389 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODULE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "registered_legacy_adoption.py"
|
||||
REPOSITORY = "jens/quiz-monitor"
|
||||
REQUEST_ID = "6f9619ff-8b86-4011-b42d-00c04fc964ff"
|
||||
TARGET_SHA = "a" * 40
|
||||
KEY = b"k" * 32
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("registered_legacy_adoption_test", MODULE_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module():
|
||||
return _load_module()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project():
|
||||
return {
|
||||
"slug": "quiz-monitor",
|
||||
"branch": "main",
|
||||
"compose_project": "quiz-monitor",
|
||||
"compose_files": ["docker-compose.yml"],
|
||||
"containers": ["quiz-monitor-quiz-server-1"],
|
||||
"source_dir": "/srv/example/appdata/quiz-monitor/source",
|
||||
"verify_oci_revision": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def baseline_inputs():
|
||||
state_bytes = b'{"schema_version":3,"live_revision":"0c9c1747","live_status":"unknown"}\n'
|
||||
image_id = "sha256:" + "a" * 64
|
||||
observation = {
|
||||
"sourceFingerprint": "1" * 64,
|
||||
"runtimeFingerprint": "2" * 64,
|
||||
"imageIDs": {"quiz-monitor-quiz-server-1": image_id},
|
||||
"runtimeConfiguration": {
|
||||
"environment": {"OPENRGB_HOST": "198.19.1.2", "OPENRGB_TOKEN": "private-test-value"},
|
||||
"composeModel": {"services": {"quiz-server": {"ports": ["1205:8000"]}}},
|
||||
},
|
||||
"artifacts": {
|
||||
"containers": {
|
||||
"quiz-monitor-quiz-server-1": {
|
||||
"image_id": image_id,
|
||||
"container_id": "b" * 64,
|
||||
"image_ref": "quiz-monitor:latest",
|
||||
"service": "quiz-server",
|
||||
"running": True,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
return state_bytes, observation
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def __init__(self, observation):
|
||||
self.observation = observation
|
||||
self.restored = []
|
||||
self.snapshots = []
|
||||
|
||||
def observe(self, repository, target, *, receipt_key):
|
||||
assert repository == REPOSITORY
|
||||
assert target["slug"] == "quiz-monitor"
|
||||
assert receipt_key == KEY
|
||||
return copy.deepcopy(self.observation)
|
||||
|
||||
def snapshot(self, repository, target, observation, request_id, *, receipt_key):
|
||||
value = {
|
||||
"revision": "unknown",
|
||||
"legacy_provenance": True,
|
||||
"containers": [
|
||||
{
|
||||
"name": "quiz-monitor-quiz-server-1",
|
||||
"image_id": observation["imageIDs"]["quiz-monitor-quiz-server-1"],
|
||||
"image_ref": "quiz-monitor:latest",
|
||||
"rollback_ref": "gitea-deploy-rollback/quiz-monitor-0:legacy-6f9619ff",
|
||||
"was_running": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
self.snapshots.append(value)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
def restore(self, repository, target, snapshot, current_revision, *, receipt_key):
|
||||
self.restored.append((repository, target["slug"], snapshot, current_revision))
|
||||
return copy.deepcopy(self.observation)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
def api_json(path, *, query):
|
||||
if path == f"/repos/{REPOSITORY}/branches/main":
|
||||
return {"protected": True, "commit": {"id": TARGET_SHA}}
|
||||
if path == f"/repos/{REPOSITORY}/actions/runs":
|
||||
assert query == {
|
||||
"event": "push", "branch": "main", "head_sha": TARGET_SHA,
|
||||
"page": "1", "limit": "100",
|
||||
}
|
||||
return {"total_count": 1, "workflow_runs": [{
|
||||
"id": 7, "run_number": 19, "run_attempt": 1,
|
||||
"event": "push", "head_sha": TARGET_SHA, "head_branch": "main",
|
||||
"path": "managed-validation.yml@refs/heads/main",
|
||||
"display_title": "Managed validation / full (push)",
|
||||
"created_at": "2026-10-07T12:00:00Z", "status": "completed",
|
||||
"conclusion": "success", "repository": {"full_name": REPOSITORY},
|
||||
"head_repository": {"full_name": REPOSITORY}, "pull_requests": [],
|
||||
}]}
|
||||
if path == f"/repos/{REPOSITORY}/actions/runs/7/jobs":
|
||||
assert query == {"page": "1", "limit": "100"}
|
||||
return {"total_count": 1, "jobs": [{
|
||||
"id": 9, "name": "full", "run_id": 7, "run_attempt": 1,
|
||||
"head_sha": TARGET_SHA, "head_branch": "main",
|
||||
"status": "completed", "conclusion": "success",
|
||||
}]}
|
||||
raise AssertionError(f"unexpected Gitea read: {path}")
|
||||
return api_json
|
||||
|
||||
|
||||
def _read_state(state_bytes):
|
||||
state = json.loads(state_bytes.decode("utf-8")) if state_bytes else {}
|
||||
return lambda _repository, _project: (state, state_bytes)
|
||||
|
||||
|
||||
def _baseline(module, project, state_bytes, observation, tmp_path):
|
||||
(tmp_path / "quiz-monitor.json").write_bytes(state_bytes)
|
||||
return module.baseline(
|
||||
{"repository": REPOSITORY, "requestId": REQUEST_ID},
|
||||
registry={REPOSITORY: project},
|
||||
read_state=_read_state(state_bytes),
|
||||
runtime=_Runtime(observation), receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_registered_target_policy_accepts_bounded_main_compose_and_rejects_special_targets(
|
||||
module, project
|
||||
):
|
||||
assert module.validate_registered_legacy_target(REPOSITORY, project) == "quiz-monitor"
|
||||
for repository, mutate in [
|
||||
("jens/unraid-appops-gateway", lambda candidate: None),
|
||||
("jens/workmesh", lambda candidate: None),
|
||||
(REPOSITORY, lambda candidate: candidate.update(managed_plan_version="itworx.deployment/v1")),
|
||||
(REPOSITORY, lambda candidate: candidate.update(deploy_script="scripts/deploy.sh")),
|
||||
(REPOSITORY, lambda candidate: candidate.update(containers=[f"quiz-monitor-svc-{n}" for n in range(9)])),
|
||||
(REPOSITORY, lambda candidate: candidate.update(compose_files=["../other/docker-compose.yml"])),
|
||||
]:
|
||||
candidate = copy.deepcopy(project)
|
||||
mutate(candidate)
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.validate_registered_legacy_target(repository, candidate)
|
||||
|
||||
|
||||
def test_rendered_compose_must_match_observed_environment_mounts_and_published_ports(module):
|
||||
model = {"services": {"quiz-server": {
|
||||
"environment": {"OPENRGB_HOST": "198.19.1.2"},
|
||||
"volumes": [{"type": "bind", "source": "/srv/example/appdata/quiz-monitor/config",
|
||||
"target": "/config", "read_only": False}],
|
||||
"ports": [{"target": 8000, "published": "1205", "protocol": "tcp",
|
||||
"host_ip": "0.0.0.0"}],
|
||||
}}, "networks": {"default": {"name": "quiz-monitor_default"}}}
|
||||
container = {"quiz-monitor-quiz-server-1": {
|
||||
"Config": {"Env": ["OPENRGB_HOST=198.19.1.2"]},
|
||||
"Mounts": [{"Type": "bind", "Source": "/srv/example/appdata/quiz-monitor/config",
|
||||
"Destination": "/config", "RW": True}],
|
||||
"HostConfig": {"NetworkMode": "quiz-monitor_default"},
|
||||
"NetworkSettings": {"Networks": {"quiz-monitor_default": {}},
|
||||
"Ports": {"8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "1205"}]}},
|
||||
}}
|
||||
module.validate_compose_runtime(model, container)
|
||||
wrong = copy.deepcopy(container)
|
||||
wrong["quiz-monitor-quiz-server-1"]["NetworkSettings"]["Ports"]["8000/tcp"][0]["HostPort"] = "1206"
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.validate_compose_runtime(model, wrong)
|
||||
|
||||
|
||||
def test_rendered_compose_rejects_changed_command_security_and_named_volume_identity(module):
|
||||
model = {"services": {"quiz-server": {
|
||||
"image": "quiz-monitor:latest", "command": ["python", "server.py"],
|
||||
"security_opt": ["no-new-privileges:true"],
|
||||
"volumes": [{"type": "volume", "source": "quiz-config", "target": "/config"}],
|
||||
}}, "volumes": {"quiz-config": {"name": "quiz-monitor-config"}},
|
||||
"networks": {"default": {"name": "quiz-monitor_default"}}}
|
||||
original = {"quiz-monitor-quiz-server-1": {
|
||||
"Config": {"Cmd": ["python", "server.py"], "Labels": {"com.docker.compose.service": "quiz-server"}},
|
||||
"HostConfig": {"SecurityOpt": ["no-new-privileges:true"], "NetworkMode": "quiz-monitor_default"},
|
||||
"Mounts": [{"Type": "volume", "Name": "quiz-monitor-config", "Destination": "/config", "RW": True}],
|
||||
"NetworkSettings": {"Networks": {"quiz-monitor_default": {}}, "Ports": {}},
|
||||
}}
|
||||
module.validate_compose_runtime(model, original, compose_project="quiz-monitor")
|
||||
changed = copy.deepcopy(original)
|
||||
changed["quiz-monitor-quiz-server-1"]["Config"]["Cmd"] = ["sh", "-c", "unexpected"]
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.validate_compose_runtime(model, changed, compose_project="quiz-monitor")
|
||||
changed = copy.deepcopy(original)
|
||||
changed["quiz-monitor-quiz-server-1"]["HostConfig"]["SecurityOpt"] = []
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.validate_compose_runtime(model, changed, compose_project="quiz-monitor")
|
||||
changed = copy.deepcopy(original)
|
||||
changed["quiz-monitor-quiz-server-1"]["Mounts"][0]["Name"] = "unexpected-volume"
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.validate_compose_runtime(model, changed, compose_project="quiz-monitor")
|
||||
|
||||
|
||||
def test_baseline_binds_exact_runtime_state_without_revealing_stale_sha_or_secrets(
|
||||
module, project, baseline_inputs, tmp_path
|
||||
):
|
||||
state_bytes, observation = baseline_inputs
|
||||
result = _baseline(module, project, state_bytes, observation, tmp_path)
|
||||
|
||||
assert result["previousRevision"] == "unknown"
|
||||
assert result["expectedStateSha256"] == hashlib.sha256(state_bytes).hexdigest()
|
||||
assert result["runtimeFingerprint"] == "2" * 64
|
||||
assert result["imageIDs"] == observation["imageIDs"]
|
||||
assert "0c9c1747" not in json.dumps(result)
|
||||
assert "private-test-value" not in json.dumps(result)
|
||||
root = tmp_path / "registered-legacy-adoption" / "quiz-monitor"
|
||||
assert (root / f"{REQUEST_ID}.baseline.json").is_file()
|
||||
private = root / f"{REQUEST_ID}.private.json"
|
||||
assert private.is_file()
|
||||
assert "private-test-value" not in (root / f"{REQUEST_ID}.baseline.json").read_text()
|
||||
|
||||
|
||||
def test_preflight_requires_latest_exact_main_push_and_registered_full_job(
|
||||
module, project, baseline_inputs, api, tmp_path
|
||||
):
|
||||
state_bytes, observation = baseline_inputs
|
||||
baseline = _baseline(module, project, state_bytes, observation, tmp_path)
|
||||
result = module.preflight(
|
||||
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
|
||||
"expectedStateSha256": baseline["expectedStateSha256"]},
|
||||
registry={REPOSITORY: project}, read_state=_read_state(state_bytes),
|
||||
runtime=_Runtime(observation), api_json=api, receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
assert result["status"] == "preflight-ok"
|
||||
assert result["revision"] == TARGET_SHA
|
||||
assert result["validationEvidenceSha256"]
|
||||
assert result["preflightSha256"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["pr_event", "wrong_sha", "failed_latest", "missing_full_job", "unprotected"])
|
||||
def test_preflight_rejects_noncanonical_or_failed_latest_validation(
|
||||
module, project, baseline_inputs, api, tmp_path, invalid
|
||||
):
|
||||
state_bytes, observation = baseline_inputs
|
||||
baseline = _baseline(module, project, state_bytes, observation, tmp_path)
|
||||
|
||||
def bad_api(path, *, query):
|
||||
value = api(path, query=query)
|
||||
if path.endswith("/actions/runs"):
|
||||
run = value["workflow_runs"][0]
|
||||
if invalid == "pr_event":
|
||||
run["event"] = "pull_request"
|
||||
elif invalid == "wrong_sha":
|
||||
run["head_sha"] = "b" * 40
|
||||
elif invalid == "failed_latest":
|
||||
run["run_attempt"] = 2
|
||||
run["conclusion"] = "failure"
|
||||
elif path.endswith("/branches/main") and invalid == "unprotected":
|
||||
value["protected"] = False
|
||||
elif path.endswith("/jobs") and invalid == "missing_full_job":
|
||||
value["jobs"][0]["name"] = "backend"
|
||||
return value
|
||||
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.preflight(
|
||||
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
|
||||
"expectedStateSha256": baseline["expectedStateSha256"]},
|
||||
registry={REPOSITORY: project}, read_state=_read_state(state_bytes),
|
||||
runtime=_Runtime(observation), api_json=bad_api, receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
def test_adoption_rechecks_gate_and_cas_then_saves_unknown_rollback_proof(
|
||||
module, project, baseline_inputs, api, tmp_path
|
||||
):
|
||||
state_bytes, observation = baseline_inputs
|
||||
(tmp_path / "quiz-monitor.json").write_bytes(state_bytes)
|
||||
runtime = _Runtime(observation)
|
||||
registry = {REPOSITORY: project}
|
||||
read_state = _read_state(state_bytes)
|
||||
baseline = module.baseline(
|
||||
{"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry,
|
||||
read_state=read_state, runtime=runtime, receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
preflight = module.preflight(
|
||||
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
|
||||
"expectedStateSha256": baseline["expectedStateSha256"]},
|
||||
registry=registry, read_state=read_state, runtime=runtime, api_json=api,
|
||||
receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
# Adoption independently rereads main and validation rather than trusting the saved preflight.
|
||||
calls = []
|
||||
|
||||
def rechecked_api(path, *, query):
|
||||
calls.append(path)
|
||||
return api(path, query=query)
|
||||
|
||||
result = module.adopt(
|
||||
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
|
||||
"expectedStateSha256": baseline["expectedStateSha256"],
|
||||
"preflightSha256": preflight["preflightSha256"]},
|
||||
registry=registry, read_state=read_state, runtime=runtime, api_json=rechecked_api,
|
||||
receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
assert result["status"] == "adopted"
|
||||
assert result["previousRevision"] == "unknown"
|
||||
assert any(path.endswith("/actions/runs") for path in calls)
|
||||
receipt = tmp_path / "registered-legacy-adoption" / "quiz-monitor" / f"{REQUEST_ID}.receipt.json"
|
||||
assert receipt.is_file()
|
||||
assert "private-test-value" not in receipt.read_text()
|
||||
|
||||
snapshot = module.capture_deployment_snapshot(
|
||||
REPOSITORY, project, json.loads(state_bytes), request_id=REQUEST_ID,
|
||||
target_revision=TARGET_SHA, receipt_key=KEY, state_root=tmp_path, runtime=runtime,
|
||||
)
|
||||
assert snapshot["revision"] == "unknown"
|
||||
assert snapshot["legacy_provenance"] is True
|
||||
assert snapshot["containers"][0]["image_id"] == observation["imageIDs"]["quiz-monitor-quiz-server-1"]
|
||||
assert snapshot["registered_legacy"]["snapshotSha256"]
|
||||
assert module.status(
|
||||
{"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry,
|
||||
read_state=read_state, receipt_key=KEY, state_root=tmp_path,
|
||||
)["status"] == "adopted"
|
||||
|
||||
|
||||
def test_adoption_rejects_a_changed_controller_state_before_snapshot(
|
||||
module, project, baseline_inputs, api, tmp_path
|
||||
):
|
||||
state_bytes, observation = baseline_inputs
|
||||
current_bytes = [state_bytes]
|
||||
def read_state(_repository, _project):
|
||||
return json.loads(current_bytes[0].decode("utf-8")), current_bytes[0]
|
||||
runtime = _Runtime(observation)
|
||||
registry = {REPOSITORY: project}
|
||||
baseline = module.baseline(
|
||||
{"repository": REPOSITORY, "requestId": REQUEST_ID}, registry=registry,
|
||||
read_state=read_state, runtime=runtime, receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
preflight = module.preflight(
|
||||
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
|
||||
"expectedStateSha256": baseline["expectedStateSha256"]},
|
||||
registry=registry, read_state=read_state, runtime=runtime, api_json=api,
|
||||
receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
current_bytes[0] += b" "
|
||||
with pytest.raises(module.RegisteredLegacyAdoptionError):
|
||||
module.adopt(
|
||||
{"repository": REPOSITORY, "revision": TARGET_SHA, "requestId": REQUEST_ID,
|
||||
"expectedStateSha256": baseline["expectedStateSha256"],
|
||||
"preflightSha256": preflight["preflightSha256"]},
|
||||
registry=registry, read_state=read_state, runtime=runtime, api_json=api,
|
||||
receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
assert not (tmp_path / "registered-legacy-adoption" / "quiz-monitor" / f"{REQUEST_ID}.receipt.json").exists()
|
||||
|
||||
|
||||
def test_status_is_nonblocking_and_worker_failure_is_durable_and_terminal_safe(
|
||||
module, project, baseline_inputs, tmp_path
|
||||
):
|
||||
state_bytes, observation = baseline_inputs
|
||||
_baseline(module, project, state_bytes, observation, tmp_path)
|
||||
pending = module.record_worker_failure(
|
||||
REPOSITORY, REQUEST_ID, "worker_start_failed", receipt_key=KEY, state_root=tmp_path,
|
||||
)
|
||||
assert pending["status"] == "recovery-required"
|
||||
assert module.record_worker_failure(
|
||||
REPOSITORY, REQUEST_ID, "second_failure", receipt_key=KEY, state_root=tmp_path,
|
||||
)["status"] == "recovery-required"
|
||||
assert module.status(
|
||||
{"repository": REPOSITORY, "requestId": REQUEST_ID},
|
||||
registry={REPOSITORY: project}, read_state=_read_state(state_bytes),
|
||||
receipt_key=KEY, state_root=tmp_path,
|
||||
)["status"] == "recovery-required"
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_hub_component_transport import call
|
||||
from test_hub_component_transport import controller as _controller_fixture
|
||||
|
||||
REPO = "jens/quiz-monitor"
|
||||
REQUEST = "11111111-1111-4111-8111-111111111111"
|
||||
PREFIX = "/v1/appops/registered-legacy/"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(monkeypatch):
|
||||
return _controller_fixture.__wrapped__(monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adoption(monkeypatch):
|
||||
observations = []
|
||||
def status(payload, **_kwargs):
|
||||
return {"status": "adopted", "repository": payload["repository"], "requestId": payload["requestId"],
|
||||
"revision": "a" * 40, "expectedStateSha256": "b" * 64}
|
||||
fake = SimpleNamespace(RegisteredLegacyAdoptionError=ValueError, read_registered_state=lambda *_a: ({}, None),
|
||||
status=status, baseline=lambda payload, **_k: {"status": "ready", **payload},
|
||||
accept_rollback=lambda payload, **_k: {"status": "rollback_pending", **payload},
|
||||
record_worker_failure=lambda *args, **_k: observations.append(args))
|
||||
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", fake)
|
||||
return fake, observations
|
||||
|
||||
|
||||
def invoke(module, stage, payload):
|
||||
return call(module, PREFIX + stage, payload, "_handle_registered_legacy")
|
||||
|
||||
|
||||
def rollback_payload():
|
||||
return {"repository": REPO, "requestId": REQUEST, "revision": "a" * 40, "expectedStateSha256": "b" * 64}
|
||||
|
||||
|
||||
def test_generic_authentication_rejects_before_helper_loading(controller, monkeypatch):
|
||||
module, _ = controller
|
||||
monkeypatch.setattr(module, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
|
||||
assert invoke(module, "adopt", {"image": "attacker/image"})[0] == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [{"repository": "Jens/quiz-monitor"}, {"repository": "../escape"},
|
||||
{"requestId": "bad"}, {"requestId": 123}])
|
||||
def test_generic_envelope_rejects_invalid_identity_before_helpers(controller, adoption, change):
|
||||
module, _ = controller
|
||||
assert invoke(module, "baseline", {"repository": REPO, "requestId": REQUEST, **change})[0] == 400
|
||||
|
||||
|
||||
def test_delivery_requires_adopted_exact_target_and_preserves_original_revalidation(controller, adoption, monkeypatch):
|
||||
module, _ = controller
|
||||
called = []
|
||||
monkeypatch.setattr(module, "_request_terminal_revalidation", lambda *args, **kwargs: called.append((args, kwargs)) or {"status": "awaiting_validation"})
|
||||
payload = {"repository": REPO, "requestId": REQUEST, "revision": "a" * 40}
|
||||
assert invoke(module, "delivery", {**payload, "revision": "c" * 40})[1]["reason"] == "registered_delivery_adoption_required"
|
||||
assert invoke(module, "delivery", {**payload, "image": "caller/image"})[1]["reason"] == "registered_delivery_contract_invalid"
|
||||
assert not called
|
||||
assert invoke(module, "delivery", payload)[1]["status"] == "awaiting_validation"
|
||||
assert called == [((REPO, "a" * 40), {"registered_legacy_request_id": REQUEST})]
|
||||
|
||||
|
||||
def test_only_internal_adopted_retry_binds_request_to_deployer_environment(controller, monkeypatch):
|
||||
module, _ = controller
|
||||
called = []
|
||||
monkeypatch.setenv("APPOPS_REGISTERED_LEGACY_REQUEST_ID", "inherited-untrusted-value")
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda args, **kwargs: called.append((args, kwargs)) or SimpleNamespace(returncode=0))
|
||||
module._run_registered_deploy(REPO, "a" * 40)
|
||||
assert "APPOPS_REGISTERED_LEGACY_REQUEST_ID" not in called[-1][1]["env"]
|
||||
module._run_registered_deploy(REPO, "a" * 40, registered_legacy_request_id=REQUEST)
|
||||
assert called[-1][1]["env"]["APPOPS_REGISTERED_LEGACY_REQUEST_ID"] == REQUEST
|
||||
assert REQUEST not in called[-1][0]
|
||||
with pytest.raises(ValueError):
|
||||
module._run_registered_deploy(REPO, "a" * 40, registered_legacy_request_id="bad")
|
||||
with pytest.raises(ValueError):
|
||||
module._run_registered_deploy(module.REGISTERED_AUTO_APPLY_REPOSITORY, "a" * 40, registered_legacy_request_id=REQUEST)
|
||||
|
||||
|
||||
def test_rollback_queue_is_bounded_idempotent_and_rejects_changed_binding(controller, adoption, monkeypatch):
|
||||
module, _ = controller
|
||||
starts = []
|
||||
monkeypatch.setattr(module.threading, "Thread", lambda **kwargs: SimpleNamespace(start=lambda: starts.append(kwargs)))
|
||||
payload = rollback_payload()
|
||||
assert invoke(module, "rollback", payload)[0] == 202
|
||||
assert invoke(module, "rollback", payload)[0] == 200
|
||||
assert len(starts) == 1
|
||||
assert invoke(module, "rollback", {**payload, "expectedStateSha256": "c" * 64})[1]["reason"] == "registered_rollback_binding_changed"
|
||||
assert invoke(module, "rollback", {**payload, "requestId": "22222222-2222-4222-8222-222222222222"})[1]["reason"] == "registered_rollback_capacity_busy"
|
||||
|
||||
|
||||
def test_rollback_start_failure_is_recorded_and_capacity_released(controller, adoption, monkeypatch):
|
||||
module, _ = controller
|
||||
_, observations = adoption
|
||||
def fail_start(**_kwargs):
|
||||
raise RuntimeError("thread start unavailable")
|
||||
monkeypatch.setattr(module.threading, "Thread", fail_start)
|
||||
assert invoke(module, "rollback", rollback_payload())[1]["reason"] == "registered_rollback_worker_start_failed"
|
||||
assert observations == [(REPO, REQUEST, "registered_rollback_worker_start_failed")]
|
||||
assert not module._registered_legacy_worker_requests
|
||||
|
||||
|
||||
def test_worker_never_discloses_raw_failure_and_releases_capacity(controller, adoption):
|
||||
module, _ = controller
|
||||
fake, observations = adoption
|
||||
def fail(*_args, **_kwargs):
|
||||
raise RuntimeError("private environment credential")
|
||||
fake.rollback = fail
|
||||
module._registered_legacy_worker_requests.add(REPO + "/" + REQUEST)
|
||||
module._run_registered_legacy_rollback(rollback_payload(), b"x" * 64)
|
||||
assert observations == [(REPO, REQUEST, "registered_rollback_worker_failed")]
|
||||
assert not module._registered_legacy_worker_requests
|
||||
|
||||
|
||||
def test_failed_delivery_retry_requires_explicit_adoption_and_keeps_original_record(controller, monkeypatch, tmp_path):
|
||||
module, _ = controller
|
||||
revision = "a" * 40
|
||||
semantic = module._webhook_semantic_digest(6, "refs/heads/main", revision)
|
||||
original = {"schema": module._WEBHOOK_SCHEMA, "semantic_sha256": semantic,
|
||||
"gitea_repository_id": 6, "ref": "refs/heads/main", "repository": REPO,
|
||||
"revision": revision, "body_sha256": "b" * 64, "validation": {},
|
||||
"status": "failed", "reason": "deployment_failed"}
|
||||
written = []
|
||||
monkeypatch.setattr(module, "WEBHOOK_INBOX_ROOT", tmp_path)
|
||||
monkeypatch.setattr(module, "_secure_inbox_root", lambda: None)
|
||||
monkeypatch.setattr(module, "_webhook_policy", lambda *_a: (6, "main", "fixed-secret-file"))
|
||||
monkeypatch.setattr(module, "_validation_policy", lambda *_a: {})
|
||||
monkeypatch.setattr(module, "_validation_snapshot_matches", lambda *_a: True)
|
||||
monkeypatch.setattr(module, "_validation_snapshot", lambda *_a: {"required": True})
|
||||
monkeypatch.setattr(module, "_read_inbox_record", lambda _p: original)
|
||||
monkeypatch.setattr(module, "_is_current_branch_head", lambda *_a: True)
|
||||
monkeypatch.setattr(module, "_write_new_inbox_record", lambda path, record: written.append((path, record)))
|
||||
assert module._enqueue_terminal_revalidation(REPO, revision, {})[0] == "not_revalidatable"
|
||||
assert module._enqueue_terminal_revalidation(REPO, revision, {}, registered_legacy_request_id="bad")[0] == "invalid"
|
||||
assert not written
|
||||
assert module._enqueue_terminal_revalidation(REPO, revision, {}, registered_legacy_request_id=REQUEST)[0] == "awaiting_validation"
|
||||
path, record = written[0]
|
||||
assert path.name == semantic + "." + REQUEST + ".revalidation.json"
|
||||
assert record["registered_legacy_request_id"] == REQUEST
|
||||
assert record["validation"] == {"required": True}
|
||||
assert original["status"] == "failed" and original["reason"] == "deployment_failed"
|
||||
|
||||
|
||||
def test_webhook_cannot_inject_registered_adoption_binding(controller, monkeypatch, tmp_path):
|
||||
module, _ = controller
|
||||
monkeypatch.setattr(module, "_read_inbox_record", lambda _p: {
|
||||
"schema": module._WEBHOOK_SCHEMA, "status": "validation_passed",
|
||||
"registered_legacy_request_id": REQUEST,
|
||||
})
|
||||
monkeypatch.setattr(module, "_load_projects", lambda: pytest.fail("injected webhook must be rejected before processing"))
|
||||
module._process_webhook_record(tmp_path / "a.json")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_deploy_broker import BROKER_ROOT, _load_module
|
||||
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.syspath_prepend(str(BROKER_ROOT))
|
||||
transport = _load_module("registered_transport_test", BROKER_ROOT / "registered_legacy_transport.py")
|
||||
REPO = "jens/quiz-monitor"
|
||||
PREFIX = "/repos/" + REPO
|
||||
|
||||
|
||||
def payload(path, query=None):
|
||||
return {"repository": REPO, "path": PREFIX + path, "query": query or {},
|
||||
"headers": {"Accept": "application/json", "Authorization": "token " + "x" * 40,
|
||||
"User-Agent": transport.transport.USER_AGENT}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,query", [
|
||||
("/branches/main", {}),
|
||||
("/actions/runs", {"event": "push", "branch": "main", "head_sha": "a" * 40, "page": "1", "limit": "100"}),
|
||||
("/actions/runs/123/jobs", {"page": "10", "limit": "100"}),
|
||||
])
|
||||
def test_only_exact_main_metadata_is_admitted(path, query):
|
||||
assert transport.allowed(REPO, PREFIX + path, query)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,query", [
|
||||
("/branches/feature", {}), ("/branches/main", {"extra": "1"}),
|
||||
("/actions/runs/123/jobs", {"page": "11", "limit": "100"}),
|
||||
("/actions/runs", {"event": "pull_request", "branch": "main", "head_sha": "a" * 40, "page": "1", "limit": "100"}),
|
||||
("/actions/artifacts/123/zip", {}), ("/branches/main/../../admin", {}),
|
||||
])
|
||||
def test_other_branches_runs_paths_and_artifacts_are_denied(path, query):
|
||||
assert not transport.allowed(REPO, PREFIX + path, query)
|
||||
|
||||
|
||||
def test_repository_and_headers_cannot_escape_read_scope():
|
||||
assert not transport.allowed("jens/quiz-monitor/../other", PREFIX + "/branches/main", {})
|
||||
assert not transport.allowed(REPO, "/repos/jens/other/branches/main", {})
|
||||
request = payload("/branches/main")
|
||||
request["headers"]["Authorization"] = "token invalid\nheader"
|
||||
with pytest.raises(ValueError, match="headers_invalid"):
|
||||
transport.execute(request)
|
||||
|
||||
|
||||
def test_fixed_get_uses_bounded_reader_without_redirects_or_secret_output():
|
||||
observed = []
|
||||
class Response(io.BytesIO):
|
||||
status = 200
|
||||
def open_request(request, timeout):
|
||||
observed.append((request.full_url, request.get_method(), timeout))
|
||||
return Response(b'{"commit":{"id":"abc"}}')
|
||||
result = transport.execute(payload("/branches/main"), opener=SimpleNamespace(open=open_request))
|
||||
assert result == b'{"commit":{"id":"abc"}}'
|
||||
assert observed == [("https://gitea.example.com/api/v1" + PREFIX + "/branches/main", "GET", 10)]
|
||||
Reference in new issue
Block a user