Publish curated Unraid-AppOps-Gateway source from aaf7d11821a86be65c91fead97a6a4758c4fbacd
This commit is contained in:
1 parent
76d7249b5e
commit
837c263cd8
7 files changed
+1674
-60
No files matched your search
@@ -792,6 +792,38 @@ def appops_projectbrain_delivery_revalidate(
|
||||
))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_delivery.preflight", annotations=_READ_ONLY_TOOL)
|
||||
@_tracked("appops.hub_delivery.preflight")
|
||||
def appops_hub_delivery_preflight(revision: str) -> dict[str, Any]:
|
||||
"""Read fixed MCP Hub delivery evidence without importing or deploying a candidate."""
|
||||
if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
|
||||
raise ValueError("Hub delivery revision is invalid")
|
||||
return _json(DeploymentBrokerClient().hub_delivery_operation(
|
||||
"preflight", {"revision": revision},
|
||||
))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="appops.hub_delivery.revalidate", annotations=_OPERATION_RUN_TOOL)
|
||||
@_tracked("appops.hub_delivery.revalidate")
|
||||
def appops_hub_delivery_revalidate(
|
||||
revision: str, expected_preflight_sha256: str, idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Queue one fixed MCP Hub delivery only after its exact native evidence fingerprint is rechecked."""
|
||||
if (not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision)
|
||||
or not isinstance(expected_preflight_sha256, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", expected_preflight_sha256)
|
||||
or not isinstance(idempotency_key, str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", idempotency_key)):
|
||||
raise ValueError("Hub delivery revalidation contract is invalid")
|
||||
return _json(DeploymentBrokerClient().hub_delivery_operation(
|
||||
"revalidate", {
|
||||
"revision": revision,
|
||||
"expectedPreflightSha256": expected_preflight_sha256,
|
||||
"idempotencyKey": idempotency_key,
|
||||
},
|
||||
))
|
||||
|
||||
|
||||
@appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL)
|
||||
@_tracked("server.operation.execute")
|
||||
def execute_operation(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Client for the private, typed deployment broker surface.
|
||||
|
||||
The client only sends a canonical DeploymentPlanV1 to a configured controller.
|
||||
It neither exposes a command field nor calls a Docker socket/Portainer exec
|
||||
endpoint. The controller independently validates repository, revision and
|
||||
runtime topology before it can mutate production.
|
||||
The client sends a canonical DeploymentPlanV1 or a closed fixed-target
|
||||
operation to a configured controller. It neither exposes a command field nor
|
||||
calls a Docker socket/Portainer exec endpoint. The controller independently
|
||||
validates repository, revision and runtime topology before it can mutate
|
||||
production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -200,7 +201,15 @@ class DeploymentBrokerClient:
|
||||
except httpx.RequestError as exc:
|
||||
return {"status": "unreachable", "reason": redact_message(str(exc))}
|
||||
if response.status_code in {401, 403}:
|
||||
return {"status": "unauthorized", "reason": "deployment_broker_authentication_failed"}
|
||||
rejected = {"status": "unauthorized", "reason": "deployment_broker_authentication_failed"}
|
||||
try:
|
||||
auth_payload = response.json()
|
||||
except ValueError:
|
||||
auth_payload = None
|
||||
if ("noMutation" in error_response_fields and isinstance(auth_payload, dict)
|
||||
and isinstance(auth_payload.get("noMutation"), bool)):
|
||||
rejected["noMutation"] = auth_payload["noMutation"]
|
||||
return rejected
|
||||
try:
|
||||
decoded = response.json()
|
||||
except ValueError:
|
||||
@@ -316,6 +325,55 @@ class DeploymentBrokerClient:
|
||||
error_response_fields=frozenset({"noMutation"}),
|
||||
)
|
||||
|
||||
def hub_delivery_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Use the fixed Hub signed-delivery gate without retrying uncertain requests."""
|
||||
if stage not in {"preflight", "revalidate"} or not isinstance(payload, dict):
|
||||
raise ValueError("Hub delivery stage is invalid")
|
||||
if stage == "preflight":
|
||||
if (set(payload) != {"revision"} or not isinstance(payload.get("revision"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", payload["revision"])):
|
||||
raise ValueError("Hub delivery preflight contract is invalid")
|
||||
elif (set(payload) != {"revision", "expectedPreflightSha256", "idempotencyKey"}
|
||||
or not isinstance(payload.get("revision"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", payload["revision"])
|
||||
or not isinstance(payload.get("expectedPreflightSha256"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", payload["expectedPreflightSha256"])
|
||||
or not isinstance(payload.get("idempotencyKey"), str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", payload["idempotencyKey"])):
|
||||
raise ValueError("Hub delivery revalidation contract is invalid")
|
||||
result = self._request(
|
||||
"POST", f"/v1/appops/hub-delivery/{stage}",
|
||||
secret=settings.mcp_token or "", payload=payload,
|
||||
error_response_fields=frozenset({"noMutation"}),
|
||||
)
|
||||
if stage == "revalidate" and (
|
||||
result.get("status") == "unreachable" or type(result.get("noMutation")) is not bool
|
||||
):
|
||||
return {
|
||||
"status": "unknown",
|
||||
"reason": "hub_delivery_outcome_unknown",
|
||||
"noMutation": False,
|
||||
}
|
||||
if stage == "preflight" and result.get("status") == "unreachable":
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"reason": "hub_delivery_preflight_unavailable",
|
||||
"noMutation": True,
|
||||
}
|
||||
if stage == "preflight" and type(result.get("noMutation")) is not bool:
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"reason": "hub_delivery_preflight_response_invalid",
|
||||
"noMutation": True,
|
||||
}
|
||||
if stage == "preflight" and result.get("noMutation") is not True:
|
||||
return {
|
||||
"status": "unknown",
|
||||
"reason": "hub_delivery_preflight_mutation_unexpected",
|
||||
"noMutation": False,
|
||||
}
|
||||
return result
|
||||
|
||||
def registered_legacy_operation(self, stage: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Signed fixed-stage metadata adoption/recovery for existing registered targets."""
|
||||
if stage not in {"observe", "baseline", "preflight", "adopt", "status", "rollback", "delivery"}:
|
||||
|
||||
@@ -22,6 +22,7 @@ spec = importlib.util.spec_from_file_location(
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
REVISION = "a" * 40
|
||||
OLD_REVISION = "c" * 40
|
||||
IMAGE_ID = "sha256:" + "b" * 64
|
||||
ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n"
|
||||
|
||||
@@ -830,7 +831,7 @@ def test_compose_uses_protected_code_and_two_config_views(compose):
|
||||
"source": module._expected_revision_path(REVISION).as_posix(),
|
||||
"target": module.EXECUTABLE_TARGET,
|
||||
"read_only": True,
|
||||
"bind": {"propagation": "rprivate"},
|
||||
"bind": {"propagation": "rslave"},
|
||||
} in volumes
|
||||
assert {
|
||||
"type": "volume",
|
||||
@@ -849,7 +850,7 @@ def test_compose_uses_protected_code_and_two_config_views(compose):
|
||||
"source": module._expected_config_path().as_posix(),
|
||||
"target": module.CONFIG_TARGET,
|
||||
"read_only": True,
|
||||
"bind": {"propagation": "rprivate"},
|
||||
"bind": {"propagation": "rslave"},
|
||||
} in volumes
|
||||
assert len(volumes) == 5
|
||||
volumes.remove(next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET))
|
||||
@@ -899,7 +900,7 @@ def test_compose_migrates_legacy_config_alias_to_explicit_private_bind(compose):
|
||||
"source": module._expected_config_path().as_posix(),
|
||||
"target": module.CONFIG_TARGET,
|
||||
"read_only": True,
|
||||
"bind": {"propagation": "rprivate"},
|
||||
"bind": {"propagation": "rslave"},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -924,8 +925,8 @@ def test_compose_accepts_empty_bind_normalization_and_canonicalizes_both_managed
|
||||
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
|
||||
]
|
||||
assert {item["target"]: item["bind"] for item in managed} == {
|
||||
module.EXECUTABLE_TARGET: {"propagation": "rprivate"},
|
||||
module.CONFIG_TARGET: {"propagation": "rprivate"},
|
||||
module.EXECUTABLE_TARGET: {"propagation": "rslave"},
|
||||
module.CONFIG_TARGET: {"propagation": "rslave"},
|
||||
}
|
||||
|
||||
|
||||
@@ -947,7 +948,7 @@ def test_compose_normalizes_all_observed_live_generated_bind_defaults(compose):
|
||||
@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, "propagation": "rshared"},
|
||||
{"create_host_path": True, "unknown": True},
|
||||
])
|
||||
def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind):
|
||||
@@ -960,7 +961,7 @@ def test_compose_rejects_unsafe_generated_docker_root_bind(compose, bind):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [module.EXECUTABLE_TARGET, module.CONFIG_TARGET])
|
||||
@pytest.mark.parametrize("private", [False, True])
|
||||
@pytest.mark.parametrize("private", [None, "rprivate", "rslave"])
|
||||
def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts(
|
||||
compose, target, private
|
||||
):
|
||||
@@ -968,7 +969,7 @@ def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts
|
||||
volumes = baseline["services"][module.SERVICE]["volumes"]
|
||||
bind = {"create_host_path": True}
|
||||
if private:
|
||||
bind["propagation"] = "rprivate"
|
||||
bind["propagation"] = private
|
||||
if target == module.EXECUTABLE_TARGET:
|
||||
mount = next(item for item in volumes if item["target"] == target)
|
||||
mount["bind"] = bind
|
||||
@@ -983,7 +984,7 @@ def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts
|
||||
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 all(mount["bind"] == {"propagation": "rslave"} for mount in managed)
|
||||
assert rendered["services"]["runner"] == baseline["services"]["runner"]
|
||||
# Only captured source metadata is normalized. Activation stays canonical.
|
||||
module._bound_model(json.dumps(rendered).encode())
|
||||
@@ -996,7 +997,7 @@ def test_compose_normalizes_generated_create_host_path_for_fixed_readonly_mounts
|
||||
@pytest.mark.parametrize("bind", [
|
||||
{"create_host_path": 1},
|
||||
{"create_host_path": "true"},
|
||||
{"create_host_path": True, "propagation": "rslave"},
|
||||
{"create_host_path": True, "propagation": "rshared"},
|
||||
{"create_host_path": True, "selinux": "z"},
|
||||
])
|
||||
def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, target, bind):
|
||||
@@ -1016,12 +1017,12 @@ def test_compose_rejects_unsafe_or_untyped_generated_bind_metadata(compose, targ
|
||||
@pytest.mark.parametrize(
|
||||
("target", "bind"),
|
||||
[
|
||||
(module.EXECUTABLE_TARGET, {"propagation": "rslave"}),
|
||||
(module.EXECUTABLE_TARGET, {"propagation": "rshared"}),
|
||||
(module.EXECUTABLE_TARGET, {"propagation": "shared"}),
|
||||
(module.EXECUTABLE_TARGET, {"propagation": "rprivate", "create_host_path": False}),
|
||||
(module.CONFIG_TARGET, {"propagation": "rslave"}),
|
||||
(module.EXECUTABLE_TARGET, {"propagation": "rslave", "create_host_path": False}),
|
||||
(module.CONFIG_TARGET, {"propagation": "rshared"}),
|
||||
(module.CONFIG_TARGET, {"propagation": "shared"}),
|
||||
(module.CONFIG_TARGET, {"propagation": "rprivate", "create_host_path": False}),
|
||||
(module.CONFIG_TARGET, {"propagation": "rslave", "create_host_path": False}),
|
||||
],
|
||||
)
|
||||
def test_compose_rejects_noncanonical_controller_bind_propagation(compose, target, bind):
|
||||
@@ -1049,7 +1050,7 @@ def test_compose_accepts_already_canonical_controller_mounts_without_duplicates(
|
||||
baseline, _calls = compose
|
||||
volumes = baseline["services"][module.SERVICE]["volumes"]
|
||||
next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET)["bind"] = {
|
||||
"propagation": "rprivate"
|
||||
"propagation": "rslave"
|
||||
}
|
||||
volumes.extend(
|
||||
[
|
||||
@@ -1070,7 +1071,7 @@ def test_compose_accepts_already_canonical_controller_mounts_without_duplicates(
|
||||
"source": module._expected_config_path().as_posix(),
|
||||
"target": module.CONFIG_TARGET,
|
||||
"read_only": True,
|
||||
"bind": {"propagation": "rprivate"},
|
||||
"bind": {"propagation": "rslave"},
|
||||
},
|
||||
]
|
||||
)
|
||||
@@ -1101,8 +1102,129 @@ def test_activation_and_rollback_models_both_pin_managed_bind_propagation(compos
|
||||
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
|
||||
}
|
||||
assert set(managed) == {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}
|
||||
assert managed[module.EXECUTABLE_TARGET]["bind"] == {"propagation": "rprivate"}
|
||||
assert managed[module.CONFIG_TARGET]["bind"] == {"propagation": "rprivate"}
|
||||
assert managed[module.EXECUTABLE_TARGET]["bind"] == {"propagation": "rslave"}
|
||||
assert managed[module.CONFIG_TARGET]["bind"] == {"propagation": "rslave"}
|
||||
|
||||
|
||||
def _legacy_private_compose(revision):
|
||||
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, revision, IMAGE_ID))
|
||||
for mount in value["services"][module.SERVICE]["volumes"]:
|
||||
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
||||
mount["bind"] = {"propagation": "rprivate"}
|
||||
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
def _projected_compose(captured):
|
||||
value = json.loads(captured)
|
||||
for mount in value["services"][module.SERVICE]["volumes"]:
|
||||
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
||||
mount["bind"] = {"propagation": "rslave"}
|
||||
assert mount["read_only"] is True
|
||||
return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rollback", "current_state", "write_expected", "error"),
|
||||
[
|
||||
(False, "raw_target", True, None),
|
||||
(False, "projected_target", False, None),
|
||||
(False, "before", True, None),
|
||||
(True, "raw_target", True, None),
|
||||
(True, "raw_after", True, None),
|
||||
(True, "projected_after", True, None),
|
||||
(True, "projected_target", False, None),
|
||||
(True, "before", True, None),
|
||||
(False, "unrecognized", False, "controller_volume_compose_transition_conflict"),
|
||||
(True, "unrecognized", False, "controller_volume_compose_rollback_conflict"),
|
||||
],
|
||||
)
|
||||
def test_root_compose_projects_legacy_receipts_and_bounds_retries(
|
||||
compose, monkeypatch, tmp_path, rollback, current_state, write_expected, error,
|
||||
):
|
||||
_baseline, _calls = compose
|
||||
before = b"captured-source-compose"
|
||||
after = _legacy_private_compose("c" * 40)
|
||||
rollback_value = _legacy_private_compose("b" * 40)
|
||||
values = {"compose-after.json": after, "compose-rollback.json": rollback_value}
|
||||
raw_target = rollback_value if rollback else after
|
||||
projected_target = _projected_compose(raw_target)
|
||||
current_by_state = {
|
||||
"raw_target": raw_target,
|
||||
"projected_target": projected_target,
|
||||
"raw_after": after,
|
||||
"projected_after": _projected_compose(after),
|
||||
"before": before,
|
||||
"unrecognized": b"unrecognized-compose",
|
||||
}
|
||||
live_file = {"value": current_by_state[current_state], "mode": 0o640}
|
||||
receipt = {
|
||||
"composeBeforeSha256": module._hash(before),
|
||||
"composeAfterSha256": module._hash(after),
|
||||
"composeRollbackSha256": module._hash(rollback_value),
|
||||
}
|
||||
writes = []
|
||||
|
||||
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
|
||||
monkeypatch.setattr(
|
||||
module, "_file_info_at", lambda *_args: (live_file["value"], live_file["mode"])
|
||||
)
|
||||
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
|
||||
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
||||
|
||||
def atomic_at(_parent, name, value, expected, *, private, mode):
|
||||
assert name == "docker-compose.yml"
|
||||
assert expected == live_file["value"]
|
||||
assert private is False
|
||||
assert mode == 0o640
|
||||
writes.append(value)
|
||||
live_file["value"] = value
|
||||
|
||||
monkeypatch.setattr(module, "_atomic_at", atomic_at)
|
||||
backup = tmp_path / "backup"
|
||||
if error:
|
||||
with pytest.raises(module.VolumeTransitionError, match=error):
|
||||
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
|
||||
assert writes == []
|
||||
assert live_file["value"] == current_by_state[current_state]
|
||||
return
|
||||
|
||||
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
|
||||
module._ensure_root_compose(backup, receipt, values, rollback=rollback)
|
||||
assert len(writes) == int(write_expected)
|
||||
assert live_file["value"] == projected_target
|
||||
assert module._hash(after) == receipt["composeAfterSha256"]
|
||||
assert module._hash(rollback_value) == receipt["composeRollbackSha256"]
|
||||
assert values["compose-after.json"] == after
|
||||
assert values["compose-rollback.json"] == rollback_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rollback", [False, True])
|
||||
def test_root_compose_projected_retry_still_checks_file_mode(
|
||||
compose, monkeypatch, tmp_path, rollback,
|
||||
):
|
||||
_baseline, _calls = compose
|
||||
after = module.rendered_model(ORIGINAL_COMPOSE, "c" * 40, IMAGE_ID)
|
||||
rollback_value = module.rendered_model(ORIGINAL_COMPOSE, "b" * 40, IMAGE_ID)
|
||||
projected = rollback_value if rollback else after
|
||||
before = b"captured-source-compose"
|
||||
receipt = {
|
||||
"composeBeforeSha256": module._hash(before),
|
||||
"composeAfterSha256": module._hash(after),
|
||||
"composeRollbackSha256": module._hash(rollback_value),
|
||||
}
|
||||
values = {"compose-after.json": after, "compose-rollback.json": rollback_value}
|
||||
|
||||
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
|
||||
monkeypatch.setattr(module, "_file_info_at", lambda *_args: (projected, 0o600))
|
||||
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
|
||||
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
||||
monkeypatch.setattr(
|
||||
module, "_atomic_at",
|
||||
lambda *_args, **_kwargs: pytest.fail("A projected retry must not rewrite the file"),
|
||||
)
|
||||
|
||||
with pytest.raises(module.VolumeTransitionError, match="compose_mode_invalid"):
|
||||
module._ensure_root_compose(tmp_path / "backup", receipt, values, rollback=rollback)
|
||||
|
||||
|
||||
def test_compose_allows_only_the_fixed_managed_appdata_volume_mount(compose):
|
||||
@@ -2304,6 +2426,70 @@ def test_copy_diagnostic_classifies_missing_passwd_user_without_retaining_user_v
|
||||
assert "fixture-user-name" not in diagnostic
|
||||
|
||||
|
||||
@pytest.mark.parametrize('stderr,condition', [
|
||||
(b'cannot create /private/fixture-secret: No space left on device', 'storage_full'),
|
||||
(b'Cannot connect to the Docker daemon at unix:///private/fixture-secret', 'daemon_unavailable'),
|
||||
])
|
||||
def test_storage_command_diagnostics_never_retain_private_paths(stderr, condition):
|
||||
assert module._command_stderr_condition(stderr, 'compose_up', max_bytes=1024) == condition
|
||||
diagnostic = module._safe_command_diagnostic(('compose_up', 'exit_1', condition))
|
||||
assert diagnostic == f'compose_up_exit_1_{condition}'
|
||||
assert 'fixture-secret' not in diagnostic
|
||||
|
||||
|
||||
@pytest.mark.parametrize('read_only,free_bytes', [(True, 1024**3), (False, 0)])
|
||||
def test_stop_rejects_failed_storage_before_recording_intent_or_stopping(monkeypatch, read_only, free_bytes):
|
||||
receipt = {'phase': 'prepared'}
|
||||
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
|
||||
monkeypatch.setattr(module.os, 'close', lambda _: None)
|
||||
monkeypatch.setattr(module.os, 'statvfs', lambda _: types.SimpleNamespace(
|
||||
f_flag=1 if read_only else 0, f_bavail=free_bytes, f_frsize=1,
|
||||
), raising=False)
|
||||
def forbidden(*args, **kwargs):
|
||||
pytest.fail('Storage rejection must happen before any persisted stop intent or container stop')
|
||||
monkeypatch.setattr(module, '_set_promotion_phase', forbidden)
|
||||
monkeypatch.setattr(module, '_stop_current_controller', forbidden)
|
||||
code = 'controller_volume_storage_read_only' if read_only else 'controller_volume_storage_insufficient'
|
||||
with pytest.raises(module.VolumeTransitionError, match=code):
|
||||
module.stop_promotion(Path('/unused-backup'))
|
||||
assert receipt['phase'] == 'prepared'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('native_read_only_graph', [False, True])
|
||||
def test_stop_allows_healthy_storage_and_preserves_intent_before_stop(monkeypatch, native_read_only_graph):
|
||||
receipt = {'phase': 'prepared'}
|
||||
events = []
|
||||
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
|
||||
monkeypatch.setattr(module.os, 'close', lambda _: None)
|
||||
def filesystem(path):
|
||||
events.append(('storage', path))
|
||||
return types.SimpleNamespace(f_flag=1 if native_read_only_graph and path == module.DOCKER_ROOT else 0, f_bavail=1024**3, f_frsize=1)
|
||||
monkeypatch.setattr(module.os, 'statvfs', filesystem, raising=False)
|
||||
def persist(descriptor, value, phase):
|
||||
events.append(('intent', phase))
|
||||
return {**value, 'phase': phase}
|
||||
monkeypatch.setattr(module, '_set_promotion_phase', persist)
|
||||
monkeypatch.setattr(module, '_stop_current_controller', lambda *a, **kw: events.append(('stop', None)))
|
||||
module.stop_promotion(Path('/unused-backup'))
|
||||
assert events == [('storage', module.ROOT), ('storage', module.DOCKER_ROOT), ('intent', 'stopped'), ('stop', None)]
|
||||
|
||||
|
||||
def test_stop_rejects_full_graph_even_with_native_read_only_bind(monkeypatch):
|
||||
receipt = {'phase': 'prepared'}
|
||||
monkeypatch.setattr(module, '_load_transaction', lambda _: (42, receipt, {}))
|
||||
monkeypatch.setattr(module.os, 'close', lambda _: None)
|
||||
monkeypatch.setattr(module.os, 'statvfs', lambda p: types.SimpleNamespace(
|
||||
f_flag=1 if p == module.DOCKER_ROOT else 0,
|
||||
f_bavail=0 if p == module.DOCKER_ROOT else 1024**3, f_frsize=1,
|
||||
), raising=False)
|
||||
def forbidden(*args, **kwargs):
|
||||
pytest.fail('Full graph must not record a stop or interrupt the controller')
|
||||
monkeypatch.setattr(module, '_set_promotion_phase', forbidden)
|
||||
monkeypatch.setattr(module, '_stop_current_controller', forbidden)
|
||||
with pytest.raises(module.VolumeTransitionError, match='controller_volume_storage_insufficient'):
|
||||
module.stop_promotion(Path('/unused-backup'))
|
||||
|
||||
|
||||
def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
|
||||
baseline, calls = compose
|
||||
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
|
||||
@@ -2319,59 +2505,86 @@ def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
|
||||
assert baseline["name"] == "gitea-deploy-runners"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["stopped", "activated"])
|
||||
def test_rollback_uses_no_dependencies_when_recreating_prior_controller(
|
||||
compose, tmp_path, monkeypatch, phase,
|
||||
@pytest.mark.parametrize(
|
||||
("operation", "phase"),
|
||||
[("activate", "stopped"), ("rollback", "stopped"), ("rollback", "activated")],
|
||||
)
|
||||
def test_promotion_uses_projected_persistent_compose_without_dependencies(
|
||||
compose, tmp_path, monkeypatch, operation, phase,
|
||||
):
|
||||
_baseline, calls = compose
|
||||
previous = REVISION
|
||||
target = "c" * 40
|
||||
previous_compose = module.rendered_model(ORIGINAL_COMPOSE, previous, IMAGE_ID)
|
||||
target_compose = module.rendered_model(ORIGINAL_COMPOSE, target, IMAGE_ID)
|
||||
previous_compose = _legacy_private_compose(previous)
|
||||
target_compose = _legacy_private_compose(target)
|
||||
receipt = {
|
||||
"phase": phase,
|
||||
"imageId": IMAGE_ID,
|
||||
"previousRevision": previous,
|
||||
"sourceRevision": target,
|
||||
"composeBeforeSha256": module._hash(b"captured-source-compose"),
|
||||
"composeAfterSha256": module._hash(target_compose),
|
||||
"composeRollbackSha256": module._hash(previous_compose),
|
||||
}
|
||||
values = {
|
||||
"controller-before.tar": module._release_tar_bytes(
|
||||
previous,
|
||||
({"controller-release.json": (b"previous release", 0o644)}, {"": 0o755}),
|
||||
),
|
||||
"controller-after.tar": b"unused by isolated rollback test",
|
||||
"mirror-before.tar": b"unused by isolated rollback test",
|
||||
"controller-before.tar": b"previous tree",
|
||||
"controller-after.tar": b"target tree",
|
||||
"mirror-before.tar": b"mirror snapshot",
|
||||
"compose-rollback.json": previous_compose,
|
||||
"compose-after.json": target_compose,
|
||||
}
|
||||
current = {
|
||||
"Mounts": [{
|
||||
"Destination": module.EXECUTABLE_TARGET,
|
||||
"Source": module._expected_revision_path(previous).as_posix(),
|
||||
}]
|
||||
}
|
||||
current = {"Mounts": [{
|
||||
"Destination": module.EXECUTABLE_TARGET,
|
||||
"Source": module._expected_revision_path(target).as_posix(),
|
||||
}]}
|
||||
live_file = {"value": target_compose, "mode": 0o640}
|
||||
|
||||
monkeypatch.setattr(module, "_load_transaction", lambda _backup: (1, receipt, values))
|
||||
monkeypatch.setattr(module.os, "close", lambda _descriptor: None)
|
||||
monkeypatch.setattr(module, "_controller_image_inspect", lambda *args, **kwargs: current)
|
||||
monkeypatch.setattr(module, "_read_tar_tree", lambda _value: ({}, {"": 0o755}))
|
||||
monkeypatch.setattr(module, "_verify_release_leaf", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(module, "_stop_current_controller", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_sync_controller_mirror", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_ensure_root_compose", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_stop_current_controller", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_verify_compose_image", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_verify_active_mount", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_set_promotion_phase", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(module, "_directory", lambda *_args: 23)
|
||||
monkeypatch.setattr(
|
||||
module, "_file_info_at", lambda *_args: (live_file["value"], live_file["mode"])
|
||||
)
|
||||
monkeypatch.setattr(module, "_read_backup", lambda *_args: b'{"composeMode":416}')
|
||||
|
||||
module.rollback_promotion(tmp_path / "backup")
|
||||
def atomic_at(_parent, _name, value, expected, **_kwargs):
|
||||
assert expected == live_file["value"]
|
||||
live_file["value"] = value
|
||||
|
||||
monkeypatch.setattr(module, "_atomic_at", atomic_at)
|
||||
calls.clear()
|
||||
if operation == "activate":
|
||||
module.activate_promotion(tmp_path / "backup")
|
||||
raw_target = target_compose
|
||||
else:
|
||||
module.rollback_promotion(tmp_path / "backup")
|
||||
raw_target = previous_compose
|
||||
projected_target = _projected_compose(raw_target)
|
||||
assert live_file["value"] == projected_target
|
||||
|
||||
up_calls = [
|
||||
arguments for arguments, _data in calls
|
||||
if arguments[:2] == ["docker", "--config"] and arguments[3] == "compose" and "up" in arguments
|
||||
(arguments, data) for arguments, data in calls
|
||||
if arguments[:2] == ["docker", "--config"]
|
||||
and arguments[3] == "compose" and "up" in arguments
|
||||
]
|
||||
assert len(up_calls) == 1
|
||||
up_index = up_calls[0].index("up")
|
||||
assert up_calls[0][up_index:] == [
|
||||
up_index = up_calls[0][0].index("up")
|
||||
assert up_calls[0][0][up_index:] == [
|
||||
"up", "-d", "--force-recreate", "--pull", "never", "--no-deps", module.SERVICE
|
||||
]
|
||||
assert up_calls[0][1] == projected_target
|
||||
assert module._hash(target_compose) == receipt["composeAfterSha256"]
|
||||
assert module._hash(previous_compose) == receipt["composeRollbackSha256"]
|
||||
assert values["compose-after.json"] == target_compose
|
||||
assert values["compose-rollback.json"] == previous_compose
|
||||
|
||||
|
||||
def test_installer_has_no_shared_executable_stage_or_compose_path():
|
||||
@@ -3261,3 +3474,108 @@ def test_config_snapshot_keeps_appops_legacy_state_contract_closed(damage):
|
||||
entries["appops-legacy-upgrade/unraid-appops-gateway.json"] = (b"{}", 0o644)
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module._config_tree(entries, directories)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["up", "stop"])
|
||||
def test_legacy_receipt_projection_preserves_evidence_and_readonly_mounts(compose, action):
|
||||
_baseline, calls = compose
|
||||
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
||||
for mount in value["services"][module.SERVICE]["volumes"]:
|
||||
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
||||
mount["bind"] = {"propagation": "rprivate"}
|
||||
captured = json.dumps(value).encode()
|
||||
original_hash = module._hash(captured)
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module._bound_model(captured)
|
||||
module.compose_action(captured, action)
|
||||
projected = json.loads(calls[-1][1])
|
||||
for mount in projected["services"][module.SERVICE]["volumes"]:
|
||||
if mount.get("target") in {module.EXECUTABLE_TARGET, module.CONFIG_TARGET}:
|
||||
assert mount["read_only"] is True
|
||||
assert mount["bind"] == {"propagation": "rslave"}
|
||||
mount["bind"] = {"propagation": "rprivate"}
|
||||
assert projected == value
|
||||
assert module._hash(captured) == original_hash
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["writable", "shared", "extra_option", "foreign_source"])
|
||||
def test_legacy_receipt_projection_rejects_unsafe_mount_before_docker(compose, damage):
|
||||
_baseline, calls = compose
|
||||
value = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
|
||||
mount = next(x for x in value["services"][module.SERVICE]["volumes"]
|
||||
if x.get("target") == module.EXECUTABLE_TARGET)
|
||||
mount["bind"] = {"propagation": "rprivate"}
|
||||
if damage == "writable":
|
||||
mount["read_only"] = False
|
||||
elif damage == "shared":
|
||||
mount["bind"]["propagation"] = "rshared"
|
||||
elif damage == "extra_option":
|
||||
mount["bind"]["create_host_path"] = True
|
||||
else:
|
||||
mount["source"] = "/tmp/untrusted-controller"
|
||||
calls.clear()
|
||||
with pytest.raises(module.VolumeTransitionError):
|
||||
module.compose_action(json.dumps(value).encode(), "up")
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["prepared", "stopped", "activated", "verified"])
|
||||
@pytest.mark.parametrize("failure", ["read_only", "full"])
|
||||
def test_forward_stop_rechecks_storage_after_intent_or_on_resume(monkeypatch, phase, failure):
|
||||
receipt = {"phase": phase, "imageId": IMAGE_ID,
|
||||
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
|
||||
persisted = phase != "prepared"
|
||||
events = []
|
||||
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {
|
||||
"compose-rollback.json": b"old", "compose-after.json": b"new"}))
|
||||
monkeypatch.setattr(module.os, "close", lambda _: None)
|
||||
def persist(_fd, value, new_phase):
|
||||
nonlocal persisted
|
||||
persisted = True
|
||||
return {**value, "phase": new_phase}
|
||||
monkeypatch.setattr(module, "_set_promotion_phase", persist)
|
||||
def filesystem(_path):
|
||||
return types.SimpleNamespace(
|
||||
f_flag=1 if persisted and failure == "read_only" else 0,
|
||||
f_bavail=0 if persisted and failure == "full" else 1024**3,
|
||||
f_frsize=1)
|
||||
monkeypatch.setattr(module.os, "statvfs", filesystem, raising=False)
|
||||
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
|
||||
"State": {"Running": True}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
|
||||
"Source": module._expected_revision_path(OLD_REVISION).as_posix()}]})
|
||||
monkeypatch.setattr(module, "compose_action", lambda *args: events.append(args))
|
||||
code = "controller_volume_storage_read_only" if failure == "read_only" else "controller_volume_storage_insufficient"
|
||||
with pytest.raises(module.VolumeTransitionError, match=code):
|
||||
module.stop_promotion(Path("/unused-backup"))
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_resumed_forward_stop_of_stopped_controller_needs_no_storage(monkeypatch):
|
||||
receipt = {"phase": "stopped", "imageId": IMAGE_ID,
|
||||
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
|
||||
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {}))
|
||||
monkeypatch.setattr(module.os, "close", lambda _: None)
|
||||
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
|
||||
"State": {"Running": False}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
|
||||
"Source": module._expected_revision_path(OLD_REVISION).as_posix()}]})
|
||||
monkeypatch.setattr(module, "_require_writable_promotion_storage",
|
||||
lambda: pytest.fail("Already stopped recovery must remain possible"))
|
||||
monkeypatch.setattr(module, "compose_action", lambda *_: pytest.fail("No stop needed"))
|
||||
module.stop_promotion(Path("/unused-backup"))
|
||||
|
||||
|
||||
def test_rollback_stop_remains_available_without_forward_storage_gate(monkeypatch):
|
||||
receipt = {"phase": "stopped", "imageId": IMAGE_ID,
|
||||
"previousRevision": OLD_REVISION, "sourceRevision": REVISION}
|
||||
events = []
|
||||
monkeypatch.setattr(module, "_load_transaction", lambda _: (42, receipt, {
|
||||
"compose-rollback.json": b"old", "compose-after.json": b"new"}))
|
||||
monkeypatch.setattr(module.os, "close", lambda _: None)
|
||||
monkeypatch.setattr(module, "_controller_image_inspect", lambda *_a, **_kw: {
|
||||
"State": {"Running": True}, "Mounts": [{"Destination": module.EXECUTABLE_TARGET,
|
||||
"Source": module._expected_revision_path(REVISION).as_posix()}]})
|
||||
monkeypatch.setattr(module, "_require_writable_promotion_storage",
|
||||
lambda: pytest.fail("Rollback must not inherit forward capacity gate"))
|
||||
monkeypatch.setattr(module, "compose_action", lambda *args: events.append(args))
|
||||
module.rollback_promotion(Path("/unused-backup"), stop_only=True)
|
||||
assert events == [(b"new", "stop")]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -186,7 +187,11 @@ def test_revalidate_rejects_stale_fingerprint_without_queueing(controller, monke
|
||||
)
|
||||
|
||||
assert code == 409
|
||||
assert result == {"status": "blocked", "reason": "projectbrain_delivery_preflight_changed"}
|
||||
assert result == {
|
||||
"status": "blocked",
|
||||
"reason": "projectbrain_delivery_preflight_changed",
|
||||
"noMutation": True,
|
||||
}
|
||||
assert queued == []
|
||||
|
||||
|
||||
@@ -876,6 +881,29 @@ def test_concurrent_enqueue_creates_one_additive_attempt_and_preserves_original(
|
||||
preflight, hashlib.sha256(b"pb-revalidate-concurrent-2026").hexdigest(),
|
||||
)
|
||||
project = fixture["project"]
|
||||
marker = fixture["inbox"] / f"{fixture['semantic']}.revalidation.json"
|
||||
writer_opened = threading.Event()
|
||||
allow_writer_to_continue = threading.Event()
|
||||
gate_lock = threading.Lock()
|
||||
gate_used = False
|
||||
real_open = controller.os.open
|
||||
|
||||
def gated_open(path, flags, mode=0o777):
|
||||
nonlocal gate_used
|
||||
descriptor = real_open(path, flags, mode)
|
||||
name = Path(path).name
|
||||
if name == marker.name or name.startswith(f".{marker.name}."):
|
||||
with gate_lock:
|
||||
pause = not gate_used
|
||||
gate_used = True
|
||||
if pause:
|
||||
writer_opened.set()
|
||||
if not allow_writer_to_continue.wait(timeout=5):
|
||||
controller.os.close(descriptor)
|
||||
raise TimeoutError("test writer gate timed out")
|
||||
return descriptor
|
||||
|
||||
monkeypatch.setattr(controller.os, "open", gated_open)
|
||||
|
||||
def enqueue():
|
||||
return controller._enqueue_terminal_revalidation(
|
||||
@@ -887,14 +915,173 @@ def test_concurrent_enqueue_creates_one_additive_attempt_and_preserves_original(
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
results = list(workers.map(lambda _index: enqueue(), range(2)))
|
||||
first = workers.submit(enqueue)
|
||||
assert writer_opened.wait(timeout=5)
|
||||
try:
|
||||
assert not marker.exists(), "an incomplete revalidation marker became visible"
|
||||
second = workers.submit(enqueue)
|
||||
assert second.result(timeout=5) == ("awaiting_validation", fixture["semantic"])
|
||||
finally:
|
||||
allow_writer_to_continue.set()
|
||||
assert first.result(timeout=5) == ("awaiting_validation", fixture["semantic"])
|
||||
|
||||
assert results == [("awaiting_validation", fixture["semantic"])] * 2
|
||||
assert fixture["original_path"].read_bytes() == fixture["original_bytes"]
|
||||
attempt_files = list(fixture["inbox"].glob("*.revalidation.json"))
|
||||
assert len(attempt_files) == 1
|
||||
record = controller._read_inbox_record(attempt_files[0])
|
||||
assert record["projectbrain_delivery_revalidation"] == binding
|
||||
assert not list(fixture["inbox"].glob(".*.tmp"))
|
||||
|
||||
|
||||
def test_new_inbox_record_ignores_stale_temporary_file(controller, tmp_path):
|
||||
marker = tmp_path / "stale.json"
|
||||
record = {"record": "complete"}
|
||||
stale = marker.with_name(
|
||||
f".{marker.name}.{controller.os.getpid()}.{controller.threading.get_ident()}.tmp"
|
||||
)
|
||||
stale_bytes = b"stale temporary file from an earlier process\n"
|
||||
stale.write_bytes(stale_bytes)
|
||||
|
||||
controller._write_new_inbox_record(marker, record)
|
||||
|
||||
assert json.loads(marker.read_text(encoding="utf-8")) == record
|
||||
if controller.os.name != "nt":
|
||||
assert marker.stat().st_mode & 0o777 == 0o600
|
||||
assert stale.read_bytes() == stale_bytes
|
||||
assert set(tmp_path.iterdir()) == {marker, stale}
|
||||
|
||||
|
||||
def test_new_inbox_record_collision_preserves_existing_bytes_and_cleans_temp(
|
||||
controller, tmp_path,
|
||||
):
|
||||
marker = tmp_path / "existing.json"
|
||||
original = b'{"existing":true}\n'
|
||||
marker.write_bytes(original)
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
controller._write_new_inbox_record(marker, {"replacement": True})
|
||||
|
||||
assert marker.read_bytes() == original
|
||||
assert not list(tmp_path.glob(".*.tmp"))
|
||||
|
||||
|
||||
def test_new_inbox_record_write_failure_cleans_temporary_file(
|
||||
controller, monkeypatch, tmp_path,
|
||||
):
|
||||
marker = tmp_path / "failed.json"
|
||||
real_fdopen = controller.os.fdopen
|
||||
|
||||
class FailingWriter:
|
||||
def __init__(self, handle):
|
||||
self.handle = handle
|
||||
|
||||
def __enter__(self):
|
||||
self.handle.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return self.handle.__exit__(exc_type, exc, traceback)
|
||||
|
||||
def write(self, _payload):
|
||||
raise OSError("simulated inbox write failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
controller.os,
|
||||
"fdopen",
|
||||
lambda descriptor, mode: FailingWriter(real_fdopen(descriptor, mode)),
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="simulated inbox write failure"):
|
||||
controller._write_new_inbox_record(marker, {"record": True})
|
||||
|
||||
assert not marker.exists()
|
||||
assert not list(tmp_path.glob(".*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_point", ["directory_fsync", "wakeup"])
|
||||
def test_publish_followup_failure_reports_unknown_and_same_key_replay_resolves(
|
||||
controller, monkeypatch, tmp_path, failure_point,
|
||||
):
|
||||
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(controller, "_projectbrain_delivery_mutation_lock", _null_context)
|
||||
preflight = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
||||
idempotency_key = "pb-revalidate-publish-failure-2026"
|
||||
payload = {
|
||||
"revision": fixture["revision"],
|
||||
"expectedPreflightSha256": preflight["preflightSha256"],
|
||||
"idempotencyKey": idempotency_key,
|
||||
}
|
||||
real_fsync = controller._fsync_inbox_root
|
||||
real_wakeup = controller._webhook_wakeup
|
||||
|
||||
if failure_point == "directory_fsync":
|
||||
def fail_fsync():
|
||||
raise OSError("simulated directory fsync failure")
|
||||
|
||||
monkeypatch.setattr(controller, "_fsync_inbox_root", fail_fsync)
|
||||
else:
|
||||
def fail_wakeup():
|
||||
raise RuntimeError("simulated worker wakeup failure")
|
||||
|
||||
monkeypatch.setattr(controller, "_webhook_wakeup", SimpleNamespace(set=fail_wakeup))
|
||||
|
||||
code, result = invoke(controller, "revalidate", payload)
|
||||
|
||||
assert code == 503
|
||||
assert result == {
|
||||
"status": "unknown",
|
||||
"reason": "projectbrain_delivery_enqueue_outcome_unknown",
|
||||
"noMutation": False,
|
||||
}
|
||||
assert fixture["original_path"].read_bytes() == fixture["original_bytes"]
|
||||
marker = fixture["inbox"] / f"{fixture['semantic']}.revalidation.json"
|
||||
record = controller._read_inbox_record(marker)
|
||||
assert record is not None
|
||||
assert record["schema"] == controller._WEBHOOK_REVALIDATION_SCHEMA
|
||||
assert record["status"] == "awaiting_validation"
|
||||
assert record["projectbrain_delivery_revalidation"]["idempotencyKeySha256"] == hashlib.sha256(
|
||||
idempotency_key.encode("ascii"),
|
||||
).hexdigest()
|
||||
assert not list(fixture["inbox"].glob(".*.tmp"))
|
||||
|
||||
if failure_point == "directory_fsync":
|
||||
monkeypatch.setattr(controller, "_fsync_inbox_root", real_fsync)
|
||||
else:
|
||||
monkeypatch.setattr(controller, "_webhook_wakeup", real_wakeup)
|
||||
replay_code, replay = invoke(controller, "revalidate", payload)
|
||||
|
||||
assert replay_code == 202
|
||||
assert replay["status"] == "awaiting_validation"
|
||||
assert replay["delivery"] == fixture["semantic"]
|
||||
assert fixture["original_path"].read_bytes() == fixture["original_bytes"]
|
||||
|
||||
|
||||
def test_revalidation_preflight_refusal_reports_no_mutation(
|
||||
controller, monkeypatch, tmp_path,
|
||||
):
|
||||
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(controller, "_projectbrain_delivery_mutation_lock", _null_context)
|
||||
|
||||
def refuse_snapshot(_revision):
|
||||
raise controller.ProjectBrainDeliveryRefused("projectbrain_delivery_preflight_changed")
|
||||
|
||||
monkeypatch.setattr(controller, "_projectbrain_delivery_snapshot", refuse_snapshot)
|
||||
payload = {
|
||||
"revision": fixture["revision"],
|
||||
"expectedPreflightSha256": "a" * 64,
|
||||
"idempotencyKey": "pb-revalidate-preflight-refusal-2026",
|
||||
}
|
||||
|
||||
code, result = invoke(controller, "revalidate", payload)
|
||||
|
||||
assert code == 409
|
||||
assert result == {
|
||||
"status": "blocked",
|
||||
"reason": "projectbrain_delivery_preflight_changed",
|
||||
"noMutation": True,
|
||||
}
|
||||
assert fixture["original_path"].read_bytes() == fixture["original_bytes"]
|
||||
assert not (fixture["inbox"] / f"{fixture['semantic']}.revalidation.json").exists()
|
||||
|
||||
|
||||
def test_generic_terminal_revalidation_cannot_occupy_projectbrain_sibling(
|
||||
|
||||
Reference in new issue
Block a user