1453 lines
57 KiB
Python
1453 lines
57 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from email.message import Message
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from test_deploy_broker import BROKER_ROOT, _load_module
|
|
|
|
REPOSITORY = "jens/projectbrain"
|
|
REVISION = "2713a23f6240bdabc1c6c45805a6264b9aaedad4"
|
|
PREFIX = "/v1/appops/projectbrain-delivery/"
|
|
PREFLIGHT_SHA = "a" * 64
|
|
ORIGINAL_FILE_SHA = "b" * 64
|
|
DELIVERY_SHA = "c" * 64
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(monkeypatch):
|
|
module = _load_module(
|
|
"projectbrain_delivery_revalidation_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"test-key-" + b"x" * 48)
|
|
monkeypatch.setattr(module, "_require_controller_release", lambda: {"status": "ready"})
|
|
return module
|
|
|
|
|
|
def invoke(module, stage: str, payload: dict[str, object], *, trusted: bool = True):
|
|
handler = module.Handler.__new__(module.Handler)
|
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
|
handler.path = PREFIX + stage
|
|
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: trusted
|
|
responses = []
|
|
handler._send_json = lambda status, data: responses.append((status, json.loads(data)))
|
|
handler._single_header = lambda name: (
|
|
"0" * 32 if name == "X-ITWorx-Deployment-Nonce"
|
|
else "0" if name in {"X-ITWorx-Deployment-Timestamp", "X-ITWorx-Deployment-Signature"}
|
|
else handler.headers.get(name)
|
|
)
|
|
handler._handle_projectbrain_delivery()
|
|
return responses[0]
|
|
|
|
|
|
def snapshot():
|
|
return {
|
|
"status": "ready",
|
|
"repository": REPOSITORY,
|
|
"revision": REVISION,
|
|
"originalDelivery": DELIVERY_SHA,
|
|
"liveRevision": "246c0a7f35e32f54fa1bf8a3f720b01bb00dd9af",
|
|
"liveReceiptSha256": "d" * 64,
|
|
"imageId": "sha256:" + "e" * 64,
|
|
"validationRunId": 22470,
|
|
"validationRunAttempt": 1,
|
|
"validationJobCount": 14,
|
|
"preflightSha256": PREFLIGHT_SHA,
|
|
"_originalFileSha256": ORIGINAL_FILE_SHA,
|
|
"_semanticDigest": DELIVERY_SHA,
|
|
"_stateSha256": "1" * 64,
|
|
"_receiptSha256": "d" * 64,
|
|
"_runtimeSha256": "2" * 64,
|
|
"_profileSha256": "3" * 64,
|
|
"_targetScriptSha256": "6" * 64,
|
|
"_controllerArtifactSha256": "7" * 64,
|
|
"_environmentFingerprint": "4" * 64,
|
|
"_validationEvidenceSha256": "5" * 64,
|
|
}
|
|
|
|
|
|
def test_preflight_is_signed_read_only_and_returns_only_bounded_snapshot_metadata(
|
|
controller, monkeypatch
|
|
):
|
|
calls = []
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_snapshot", lambda revision: calls.append(revision) or snapshot())
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_mutation_lock", _null_context)
|
|
monkeypatch.setattr(
|
|
controller,
|
|
"_enqueue_terminal_revalidation",
|
|
lambda *_a, **_k: pytest.fail("preflight must not write a revalidation record"),
|
|
)
|
|
|
|
code, result = invoke(controller, "preflight", {"revision": REVISION})
|
|
|
|
assert code == 200
|
|
assert result == {
|
|
"status": "ready",
|
|
"repository": REPOSITORY,
|
|
"revision": REVISION,
|
|
"originalDelivery": DELIVERY_SHA,
|
|
"liveRevision": "246c0a7f35e32f54fa1bf8a3f720b01bb00dd9af",
|
|
"liveReceiptSha256": "d" * 64,
|
|
"imageId": "sha256:" + "e" * 64,
|
|
"validationRunId": 22470,
|
|
"validationRunAttempt": 1,
|
|
"validationJobCount": 14,
|
|
"preflightSha256": PREFLIGHT_SHA,
|
|
}
|
|
assert calls == [REVISION]
|
|
|
|
|
|
def test_preflight_authenticates_before_reading_project_state(controller, monkeypatch):
|
|
monkeypatch.setattr(controller, "_self_upgrade_signature_is_valid", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_snapshot", lambda *_a: pytest.fail("unauthenticated read"))
|
|
|
|
assert invoke(controller, "preflight", {"revision": REVISION})[0] == 403
|
|
|
|
|
|
def test_preflight_reports_busy_without_sampling_during_deployment(controller, monkeypatch):
|
|
monkeypatch.setattr(
|
|
controller,
|
|
"_projectbrain_delivery_mutation_lock",
|
|
lambda: _raise_busy(controller),
|
|
)
|
|
monkeypatch.setattr(
|
|
controller,
|
|
"_projectbrain_delivery_snapshot",
|
|
lambda *_a: pytest.fail("preflight must not read an incoherent deployment snapshot"),
|
|
)
|
|
|
|
code, result = invoke(controller, "preflight", {"revision": REVISION})
|
|
|
|
assert code == 409
|
|
assert result == {"status": "blocked", "reason": "projectbrain_deployment_active"}
|
|
|
|
|
|
def test_revalidate_rechecks_fingerprint_and_binds_only_idempotency_digest(
|
|
controller, monkeypatch
|
|
):
|
|
observed = []
|
|
queued = []
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_snapshot", lambda revision: observed.append(revision) or snapshot())
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_mutation_lock", lambda: _null_context())
|
|
monkeypatch.setattr(
|
|
controller,
|
|
"_request_terminal_revalidation",
|
|
lambda *args, **kwargs: queued.append((args, kwargs)) or {
|
|
"status": "awaiting_validation", "delivery": DELIVERY_SHA,
|
|
},
|
|
)
|
|
key = "pb-revalidate-2026-10-07"
|
|
|
|
code, result = invoke(
|
|
controller,
|
|
"revalidate",
|
|
{"revision": REVISION, "expectedPreflightSha256": PREFLIGHT_SHA, "idempotencyKey": key},
|
|
)
|
|
|
|
assert code == 202
|
|
assert result["status"] == "awaiting_validation"
|
|
assert result["repository"] == REPOSITORY
|
|
assert result["revision"] == REVISION
|
|
assert result["delivery"] == DELIVERY_SHA
|
|
assert observed == [REVISION]
|
|
assert queued[0][0][0:2] == (REPOSITORY, REVISION)
|
|
assert queued[0][1]["expected_original_sha256"] == ORIGINAL_FILE_SHA
|
|
assert queued[0][1]["projectbrain_delivery_binding"]["idempotencyKeySha256"] == hashlib.sha256(key.encode()).hexdigest()
|
|
assert queued[0][1]["allow_projectbrain_terminal_idempotent"] is True
|
|
assert key not in json.dumps(queued)
|
|
|
|
|
|
def test_revalidate_rejects_stale_fingerprint_without_queueing(controller, monkeypatch):
|
|
queued = []
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_snapshot", lambda _revision: snapshot())
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_mutation_lock", lambda: _null_context())
|
|
monkeypatch.setattr(controller, "_request_terminal_revalidation", lambda *a, **k: queued.append((a, k)))
|
|
|
|
code, result = invoke(
|
|
controller,
|
|
"revalidate",
|
|
{"revision": REVISION, "expectedPreflightSha256": "f" * 64, "idempotencyKey": "pb-revalidate-2026-10-07"},
|
|
)
|
|
|
|
assert code == 409
|
|
assert result == {
|
|
"status": "blocked",
|
|
"reason": "projectbrain_delivery_preflight_changed",
|
|
"noMutation": True,
|
|
}
|
|
assert queued == []
|
|
|
|
|
|
def test_revalidate_rejects_unknown_or_unbounded_fields_before_snapshot(controller, monkeypatch):
|
|
monkeypatch.setattr(controller, "_projectbrain_delivery_snapshot", lambda *_a: pytest.fail("invalid input read state"))
|
|
|
|
assert invoke(controller, "preflight", {"revision": REVISION, "repository": REPOSITORY})[0] == 400
|
|
assert invoke(
|
|
controller,
|
|
"revalidate",
|
|
{"revision": REVISION, "expectedPreflightSha256": PREFLIGHT_SHA, "idempotencyKey": "short"},
|
|
)[0] == 400
|
|
|
|
|
|
def _null_context():
|
|
from contextlib import nullcontext
|
|
|
|
return nullcontext()
|
|
|
|
|
|
def _raise_busy(module):
|
|
raise module.ProjectBrainDeliveryRefused("projectbrain_deployment_active")
|
|
|
|
|
|
def _git(cwd: Path, *arguments: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *arguments], cwd=cwd, capture_output=True, text=True, check=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _snapshot_fixture(controller, monkeypatch, tmp_path, *, target_root_env=False):
|
|
repository = tmp_path / "source"
|
|
repository.mkdir()
|
|
_git(repository, "init", "-b", "main")
|
|
_git(repository, "config", "user.email", "projectbrain-test@example.invalid")
|
|
_git(repository, "config", "user.name", "ProjectBrain test")
|
|
(repository / "scripts").mkdir()
|
|
deploy_script = repository / "scripts" / "deploy-unraid-single.sh"
|
|
deploy_script.write_text("#!/bin/sh\nprintf 'live\\n'\n", encoding="utf-8")
|
|
(repository / "README.md").write_text("live\n", encoding="utf-8")
|
|
_git(repository, "add", ".")
|
|
_git(repository, "commit", "-m", "live")
|
|
live_revision = _git(repository, "rev-parse", "HEAD")
|
|
deploy_script.write_text("#!/bin/sh\nprintf 'target\\n'\n", encoding="utf-8")
|
|
(repository / "README.md").write_text("target\n", encoding="utf-8")
|
|
if target_root_env:
|
|
(repository / ".env").write_text(
|
|
"PROJECTBRAIN_PRIVATE_TEST=fixture-only\n",
|
|
encoding="utf-8",
|
|
)
|
|
_git(repository, "add", ".")
|
|
_git(repository, "commit", "-m", "target")
|
|
revision = _git(repository, "rev-parse", "HEAD")
|
|
repository_path = repository / ".git"
|
|
|
|
validation = {
|
|
"validation_gate_version": 1,
|
|
"required": True,
|
|
"contexts": ["Managed validation / full (push)"],
|
|
"timeout_seconds": 1800,
|
|
"poll_interval_seconds": 5,
|
|
"max_poll_interval_seconds": 30,
|
|
}
|
|
project = {
|
|
"slug": "projectbrain",
|
|
"branch": "main",
|
|
"source_dir": "/srv/example/appdata/brain",
|
|
"deploy_script": "scripts/deploy-unraid-single.sh",
|
|
"archive_deploy_script": None,
|
|
"containers": ["projectbrain"],
|
|
"compose_project": "projectbrain",
|
|
"compose_files": ["docker-compose.yml"],
|
|
"gitea_repository_id": 7,
|
|
"environment": {
|
|
"PROJECTBRAIN_DEPLOY_SKIP_PULL": "true",
|
|
"PROJECTBRAIN_DEPLOY_REF": "",
|
|
"VITE_PROJECTBRAIN_COMMIT": "{revision}",
|
|
},
|
|
"gitea_webhook_secret_file": "jens__projectbrain",
|
|
"verify_oci_revision": False,
|
|
"health_timeout": 180,
|
|
"validation": validation,
|
|
}
|
|
inbox = tmp_path / "inbox"
|
|
inbox.mkdir(mode=0o700)
|
|
monkeypatch.setattr(controller, "WEBHOOK_INBOX_ROOT", inbox)
|
|
monkeypatch.setattr(controller, "_load_projects", lambda: {REPOSITORY: project})
|
|
monkeypatch.setattr(controller, "_is_current_branch_head", lambda *_a: True)
|
|
monkeypatch.setattr(controller, "_repository_path_for_delivery", lambda *_a: repository_path)
|
|
controller_release = {
|
|
"artifactSha256": "8" * 64,
|
|
"sourceRepository": "jens/unraid-appops-gateway",
|
|
"sourceRevision": "9" * 40,
|
|
"sourceTree": "a" * 40,
|
|
"builtAt": "2026-10-07T12:00:00.000Z",
|
|
"controllerVersion": "test",
|
|
"apiVersion": "itworx.controller-release/v1",
|
|
}
|
|
monkeypatch.setattr(controller, "_require_controller_release", lambda: controller_release)
|
|
|
|
semantic = controller._webhook_semantic_digest(7, "refs/heads/main", revision)
|
|
now = int(time.time())
|
|
original = {
|
|
"schema": controller._WEBHOOK_SCHEMA,
|
|
"semantic_sha256": semantic,
|
|
"gitea_repository_id": 7,
|
|
"ref": "refs/heads/main",
|
|
"repository": REPOSITORY,
|
|
"revision": revision,
|
|
"source": "gitea_webhook",
|
|
"status": "validation_timeout",
|
|
"reason": "validation_timeout",
|
|
"delivery": str(uuid.uuid4()),
|
|
"body_sha256": "f" * 64,
|
|
"validation": controller._validation_snapshot(
|
|
controller._validation_policy(REPOSITORY, project), now,
|
|
),
|
|
"audit": [{"at": now, "stage": "validation_timeout", "reason": "validation_timeout"}],
|
|
"received_at": now - 1800,
|
|
"updated_at": now,
|
|
}
|
|
original_path = inbox / f"{semantic}.json"
|
|
controller._write_new_inbox_record(original_path, original)
|
|
|
|
tree = _git(repository, "rev-parse", f"{live_revision}^{{tree}}")
|
|
tree_digest = hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest()
|
|
image_id = "sha256:" + "e" * 64
|
|
built_at = "2026-10-07T11:00:00.000Z"
|
|
receipt = {
|
|
"schema": "gitea-deploy-receipt.v1",
|
|
"repository": REPOSITORY,
|
|
"revision": live_revision,
|
|
"tree": tree,
|
|
"treeDigest": tree_digest,
|
|
"build": {
|
|
"id": live_revision[:12],
|
|
"repository": REPOSITORY,
|
|
"revision": live_revision,
|
|
"tree": tree,
|
|
"treeDigest": tree_digest,
|
|
"builtAt": built_at,
|
|
},
|
|
"containers": {"projectbrain": {"image_id": image_id}},
|
|
"live": None,
|
|
}
|
|
state = {
|
|
"schema_version": 3,
|
|
"repository": REPOSITORY,
|
|
"live_revision": live_revision,
|
|
"live_status": "healthy",
|
|
"live_legacy_provenance": False,
|
|
"live_receipt": receipt,
|
|
}
|
|
monkeypatch.setattr(controller, "_deployment_state", lambda _slug: state)
|
|
container = {
|
|
"Id": "d" * 64,
|
|
"Image": image_id,
|
|
"Name": "/projectbrain",
|
|
"State": {"Running": True, "Status": "running", "Health": {"Status": "healthy"}},
|
|
"Config": {"Image": "projectbrain-unraid:codex-oidc-20260826"},
|
|
"Mounts": [{
|
|
"Type": "bind",
|
|
"Source": "/srv/example/appdata/projectbrain",
|
|
"Destination": "/var/lib/projectbrain",
|
|
"RW": True,
|
|
}],
|
|
}
|
|
monkeypatch.setattr(controller, "_container_inventory", lambda: {"projectbrain": container})
|
|
|
|
run_id = 22470
|
|
run = {
|
|
"id": run_id,
|
|
"run_number": 381,
|
|
"run_attempt": 1,
|
|
"created_at": "2026-10-07T12:00:00Z",
|
|
"path": "managed-validation.yml@refs/heads/main",
|
|
"event": "push",
|
|
"head_branch": "main",
|
|
"head_sha": revision,
|
|
"repository": {"full_name": REPOSITORY},
|
|
"head_repository": {"full_name": REPOSITORY},
|
|
"pull_requests": [],
|
|
"status": "completed",
|
|
"conclusion": "success",
|
|
}
|
|
jobs = [{
|
|
"id": 39165,
|
|
"name": "full",
|
|
"run_id": run_id,
|
|
"run_attempt": 1,
|
|
"head_sha": revision,
|
|
"head_branch": "main",
|
|
"status": "completed",
|
|
"conclusion": "success",
|
|
}]
|
|
|
|
def api_json(path, *, query):
|
|
if path.endswith("/branches/main"):
|
|
return {"protected": True, "commit": {"id": revision}}
|
|
if path.endswith("/actions/runs"):
|
|
return {"total_count": 1, "workflow_runs": [run]}
|
|
if path.endswith(f"/actions/runs/{run_id}/jobs"):
|
|
return {"total_count": len(jobs), "jobs": jobs}
|
|
raise AssertionError(f"unexpected bounded validation API path: {path}; query={query}")
|
|
|
|
adoption = _load_module(
|
|
"projectbrain_delivery_validation_evidence_test",
|
|
BROKER_ROOT / "registered_legacy_adoption.py",
|
|
)
|
|
monkeypatch.setitem(sys.modules, "registered_legacy_adoption", adoption)
|
|
monkeypatch.setattr(controller, "_registered_legacy_api_reader", lambda _repository: api_json)
|
|
|
|
deploy = _load_module("projectbrain_delivery_snapshot_deploy_test", BROKER_ROOT / "deploy.py")
|
|
def snapshot_binding(_project, observed_container):
|
|
mounts = observed_container.get("Mounts") or []
|
|
mount = mounts[0] if len(mounts) == 1 else {}
|
|
return {
|
|
"app_dir": "/srv/example/appdata/brain",
|
|
"data_path": mount.get("Source", ""),
|
|
"container_name": "projectbrain",
|
|
"image_ref": str(dict(observed_container.get("Config") or {}).get("Image") or ""),
|
|
"image_id": str(observed_container.get("Image") or ""),
|
|
}
|
|
|
|
monkeypatch.setattr(deploy, "projectbrain_snapshot_binding", snapshot_binding)
|
|
monkeypatch.setattr(deploy, "projectbrain_rollback_environment", lambda *_a: {})
|
|
environment = {"PROJECTBRAIN_OWNER_PASSWORD": "test-only-secret"}
|
|
monkeypatch.setattr(deploy, "static_projectbrain_environment", lambda _path: dict(environment))
|
|
monkeypatch.setitem(sys.modules, "deploy", deploy)
|
|
run_process = subprocess.run
|
|
|
|
def bounded_run(command, **kwargs):
|
|
if command[0] == "docker":
|
|
return subprocess.CompletedProcess(command, 0, stdout=image_id + "\n", stderr="")
|
|
return run_process(command, **kwargs)
|
|
|
|
monkeypatch.setattr(controller.subprocess, "run", bounded_run)
|
|
return {
|
|
"project": project,
|
|
"state": state,
|
|
"receipt": receipt,
|
|
"container": container,
|
|
"jobs": jobs,
|
|
"environment": environment,
|
|
"original_path": original_path,
|
|
"original": original,
|
|
"original_bytes": original_path.read_bytes(),
|
|
"inbox": inbox,
|
|
"repository_path": repository_path,
|
|
"live_revision": live_revision,
|
|
"revision": revision,
|
|
"semantic": semantic,
|
|
"image_id": image_id,
|
|
"controller_release": controller_release,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("exact_source", ["omitted", False])
|
|
def test_projectbrain_fixed_profile_accepts_only_the_existing_source_mode(
|
|
controller, monkeypatch, tmp_path, exact_source,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
project = dict(fixture["project"])
|
|
if exact_source == "omitted":
|
|
project.pop("exact_source", None)
|
|
else:
|
|
project["exact_source"] = exact_source
|
|
|
|
accepted = controller._projectbrain_delivery_project(project)
|
|
|
|
assert accepted["_repository_name"] == REPOSITORY
|
|
assert accepted["source_dir"] == "/srv/example/appdata/brain"
|
|
if exact_source == "omitted":
|
|
assert "exact_source" not in accepted
|
|
else:
|
|
assert accepted["exact_source"] is False
|
|
|
|
|
|
@pytest.mark.parametrize("exact_source", [True, "false", 0, None])
|
|
def test_projectbrain_fixed_profile_rejects_other_source_modes(
|
|
controller, monkeypatch, tmp_path, exact_source,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
project = {**fixture["project"], "exact_source": exact_source}
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_project(project)
|
|
assert failure.value.code == "projectbrain_target_contract_changed"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "value"),
|
|
[
|
|
("env_source", None),
|
|
("env_source", False),
|
|
("env_source", 0),
|
|
("env_source", ""),
|
|
("compose_dir", None),
|
|
("compose_dir", False),
|
|
("compose_dir", 0),
|
|
("compose_dir", ""),
|
|
],
|
|
)
|
|
def test_projectbrain_profile_requires_unconfigured_external_environment_and_compose_dir(
|
|
controller, monkeypatch, tmp_path, key, value,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
project = {**fixture["project"], key: value}
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_project(project)
|
|
assert failure.value.code == "projectbrain_target_contract_changed"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "value"),
|
|
[
|
|
("PROJECTBRAIN_DEPLOY_SKIP_PULL", "false"),
|
|
("PROJECTBRAIN_DEPLOY_SKIP_PULL", False),
|
|
("PROJECTBRAIN_DEPLOY_SKIP_PULL", 1),
|
|
("PROJECTBRAIN_DEPLOY_REF", "main"),
|
|
("PROJECTBRAIN_DEPLOY_REF", None),
|
|
("PROJECTBRAIN_DEPLOY_REF", False),
|
|
("PROJECTBRAIN_DEPLOY_REF", 0),
|
|
("VITE_PROJECTBRAIN_COMMIT", "a" * 40),
|
|
("VITE_PROJECTBRAIN_COMMIT", None),
|
|
("VITE_PROJECTBRAIN_COMMIT", False),
|
|
],
|
|
)
|
|
def test_projectbrain_profile_rejects_execution_override_drift(
|
|
controller, monkeypatch, tmp_path, key, value,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
project = {
|
|
**fixture["project"],
|
|
"environment": dict(fixture["project"]["environment"]),
|
|
}
|
|
project["environment"][key] = value
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_project(project)
|
|
assert failure.value.code == "projectbrain_target_contract_changed"
|
|
|
|
|
|
@pytest.mark.parametrize("deploy_ref", ["omitted", ""])
|
|
def test_projectbrain_profile_accepts_only_absent_or_empty_deploy_ref(
|
|
controller, monkeypatch, tmp_path, deploy_ref,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
project = {
|
|
**fixture["project"],
|
|
"environment": dict(fixture["project"]["environment"]),
|
|
}
|
|
if deploy_ref == "omitted":
|
|
project["environment"].pop("PROJECTBRAIN_DEPLOY_REF")
|
|
else:
|
|
project["environment"]["PROJECTBRAIN_DEPLOY_REF"] = deploy_ref
|
|
|
|
assert controller._projectbrain_delivery_project(project)["environment"] == (
|
|
project["environment"]
|
|
)
|
|
|
|
|
|
def test_actual_projectbrain_profile_passes_real_deploy_source_validation_and_binding(
|
|
controller, tmp_path,
|
|
):
|
|
repository = tmp_path / "repository"
|
|
repository.mkdir()
|
|
_git(repository, "init", "-b", "main")
|
|
_git(repository, "config", "user.email", "projectbrain-test@example.invalid")
|
|
_git(repository, "config", "user.name", "ProjectBrain test")
|
|
(repository / "scripts").mkdir()
|
|
(repository / "scripts" / "deploy-unraid-single.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
(repository / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
|
|
_git(repository, "add", ".")
|
|
_git(repository, "commit", "-m", "live")
|
|
revision = _git(repository, "rev-parse", "HEAD")
|
|
project = {
|
|
"slug": "projectbrain",
|
|
"branch": "main",
|
|
"source_dir": "/srv/example/appdata/brain",
|
|
"deploy_script": "scripts/deploy-unraid-single.sh",
|
|
"archive_deploy_script": None,
|
|
"containers": ["projectbrain"],
|
|
"compose_project": "projectbrain",
|
|
"compose_files": ["docker-compose.yml"],
|
|
"gitea_repository_id": 7,
|
|
"environment": {
|
|
"PROJECTBRAIN_DEPLOY_SKIP_PULL": "true",
|
|
"PROJECTBRAIN_DEPLOY_REF": "",
|
|
"VITE_PROJECTBRAIN_COMMIT": "{revision}",
|
|
},
|
|
}
|
|
deploy = _load_module(
|
|
"projectbrain_delivery_deploy_profile_test",
|
|
BROKER_ROOT / "deploy.py",
|
|
)
|
|
deploy.validate_project_config(repository / ".git", project, revision)
|
|
|
|
mount_source = str(tmp_path / "projectbrain-data")
|
|
container = {
|
|
"Config": {"Image": "projectbrain-unraid:codex-oidc-20260826"},
|
|
"Image": "sha256:" + "a" * 64,
|
|
"Mounts": [{
|
|
"Type": "bind",
|
|
"Source": mount_source,
|
|
"Destination": "/var/lib/projectbrain",
|
|
"RW": True,
|
|
}],
|
|
}
|
|
binding = deploy.projectbrain_snapshot_binding(
|
|
{**project, "_repository_name": REPOSITORY},
|
|
container,
|
|
)
|
|
|
|
assert binding["app_dir"] == str(Path(project["source_dir"]).resolve())
|
|
assert binding["data_path"] == mount_source
|
|
assert binding["image_id"] == container["Image"]
|
|
assert controller._projectbrain_delivery_project(project)["source_dir"] == project["source_dir"]
|
|
|
|
exact_source_project = {**project, "exact_source": True}
|
|
with pytest.raises(deploy.DeployError, match="Exact source directory is outside"):
|
|
deploy.validate_project_config(repository / ".git", exact_source_project, revision)
|
|
|
|
|
|
def test_snapshot_binds_full_profile_target_script_controller_artifact_and_real_push_evidence(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
|
|
first = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
fixture["project"]["health_timeout"] = 181
|
|
second = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
fixture["controller_release"]["artifactSha256"] = "9" * 64
|
|
third = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
|
|
assert first["status"] == second["status"] == third["status"] == "ready"
|
|
assert first["_profileSha256"] != second["_profileSha256"]
|
|
assert first["preflightSha256"] != second["preflightSha256"]
|
|
assert second["_controllerArtifactSha256"] != third["_controllerArtifactSha256"]
|
|
assert second["preflightSha256"] != third["preflightSha256"]
|
|
assert first["_targetScriptSha256"] == controller._projectbrain_delivery_target_script_sha256(
|
|
fixture["repository_path"], fixture["revision"],
|
|
)
|
|
assert first["_targetScriptSha256"] != controller._projectbrain_delivery_target_script_sha256(
|
|
fixture["repository_path"], fixture["live_revision"],
|
|
)
|
|
assert first["_controllerArtifactSha256"] == "8" * 64
|
|
assert first["validationRunId"] == 22470
|
|
assert first["validationJobCount"] == 1
|
|
assert "test-only-secret" not in json.dumps(controller._projectbrain_delivery_public_snapshot(first))
|
|
|
|
|
|
def test_snapshot_rejects_target_revision_tracking_private_root_environment_file(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(
|
|
controller,
|
|
monkeypatch,
|
|
tmp_path,
|
|
target_root_env=True,
|
|
)
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
|
|
assert failure.value.code == "projectbrain_source_tracks_private_environment"
|
|
|
|
|
|
def test_snapshot_rejects_incomplete_full_job_identity_and_extra_required_context(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
adoption = sys.modules["registered_legacy_adoption"]
|
|
monkeypatch.setattr(adoption, "_validation_evidence", lambda *_a: {
|
|
"revision": fixture["revision"],
|
|
"branch": "main",
|
|
"event": "push",
|
|
"runId": 22470,
|
|
"runNumber": 381,
|
|
"runAttempt": 1,
|
|
"workflowPath": "managed-validation.yml@refs/heads/main",
|
|
"validationContext": "Managed validation / full (push)",
|
|
"jobCount": 1,
|
|
"jobIds": [],
|
|
})
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
assert failure.value.code == "projectbrain_full_push_validation_invalid"
|
|
|
|
monkeypatch.setattr(adoption, "_validation_evidence", lambda *_args: {
|
|
"revision": fixture["revision"],
|
|
"branch": "main",
|
|
"event": "push",
|
|
"runId": 22470,
|
|
"runNumber": 381,
|
|
"runAttempt": 1,
|
|
"workflowPath": "managed-validation.yml@refs/heads/main",
|
|
"validationContext": "Managed validation / full (push)",
|
|
"jobCount": 1,
|
|
"jobIds": [39165],
|
|
})
|
|
fixture["project"]["validation"]["contexts"].append("Extra required status (push)")
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
assert failure.value.code == "projectbrain_validation_policy_unavailable"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("change", "expected_code"),
|
|
[
|
|
("unhealthy", "projectbrain_runtime_unhealthy"),
|
|
("mount", "projectbrain_runtime_binding_changed"),
|
|
("receipt_image", "projectbrain_live_image_changed"),
|
|
],
|
|
)
|
|
def test_snapshot_rejects_unhealthy_runtime_mount_or_unreceipted_image(
|
|
controller, monkeypatch, tmp_path, change, expected_code,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
if change == "unhealthy":
|
|
fixture["container"]["State"]["Health"]["Status"] = "unhealthy"
|
|
elif change == "mount":
|
|
fixture["container"]["Mounts"][0]["Source"] = "/srv/example/appdata/other"
|
|
else:
|
|
fixture["receipt"]["containers"]["projectbrain"]["image_id"] = "sha256:" + "f" * 64
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
assert failure.value.code == expected_code
|
|
|
|
|
|
def test_snapshot_binds_private_environment_and_rejects_deployment_history_in_original(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
first = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
fixture["environment"]["PROJECTBRAIN_OWNER_PASSWORD"] = "changed-test-only-secret"
|
|
second = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
assert first["preflightSha256"] != second["preflightSha256"]
|
|
assert "changed-test-only-secret" not in json.dumps(controller._projectbrain_delivery_public_snapshot(second))
|
|
|
|
changed_original = dict(fixture["original"])
|
|
changed_original["audit"] = [
|
|
*changed_original["audit"],
|
|
{"at": int(time.time()), "stage": "deployment_started"},
|
|
]
|
|
fixture["original_path"].write_bytes(
|
|
controller._projectbrain_delivery_canonical(changed_original) + b"\n",
|
|
)
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
assert failure.value.code == "projectbrain_original_delivery_not_revalidatable"
|
|
|
|
|
|
def test_snapshot_rejects_private_vite_revision_override_without_exposing_value(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
fixture["environment"]["VITE_PROJECTBRAIN_COMMIT"] = "test-only-private-value"
|
|
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
|
|
assert failure.value.code == "projectbrain_private_environment_overrides_revision"
|
|
assert "test-only-private-value" not in str(failure.value)
|
|
|
|
|
|
def test_deploy_gate_binds_original_bytes_and_signed_sibling_without_rewriting_history(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
initial = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
idempotency_hash = hashlib.sha256(b"pb-revalidate-2026-10-07").hexdigest()
|
|
binding = controller._projectbrain_delivery_signed_binding(initial, idempotency_hash)
|
|
attempt = {
|
|
"schema": controller._WEBHOOK_REVALIDATION_SCHEMA,
|
|
"semantic_sha256": fixture["semantic"],
|
|
"revalidation_of": fixture["semantic"],
|
|
"gitea_repository_id": 7,
|
|
"ref": "refs/heads/main",
|
|
"repository": REPOSITORY,
|
|
"revision": fixture["revision"],
|
|
"status": "deploying",
|
|
"projectbrain_delivery_revalidation": binding,
|
|
}
|
|
marker = fixture["inbox"] / f"{fixture['semantic']}.revalidation.json"
|
|
controller._write_new_inbox_record(marker, attempt)
|
|
|
|
assert controller._projectbrain_delivery_deploy_gate(
|
|
REPOSITORY, fixture["revision"], initial["preflightSha256"],
|
|
)
|
|
assert fixture["original_path"].read_bytes() == fixture["original_bytes"]
|
|
|
|
changed_original = dict(fixture["original"])
|
|
changed_original["operator_note"] = "whole-record drift"
|
|
fixture["original_path"].write_bytes(
|
|
controller._projectbrain_delivery_canonical(changed_original) + b"\n",
|
|
)
|
|
assert not controller._projectbrain_delivery_deploy_gate(
|
|
REPOSITORY, fixture["revision"], initial["preflightSha256"],
|
|
)
|
|
|
|
fixture["original_path"].write_bytes(fixture["original_bytes"])
|
|
fixture["original_path"].write_bytes(fixture["original_bytes"] + b" ")
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused):
|
|
controller._projectbrain_read_original_delivery(fixture["semantic"])
|
|
|
|
fixture["original_path"].write_bytes(fixture["original_bytes"])
|
|
attempt["projectbrain_delivery_revalidation"] = {**binding, "hmac": "0" * 64}
|
|
marker.write_bytes(controller._projectbrain_delivery_canonical(attempt) + b"\n")
|
|
assert not controller._projectbrain_delivery_deploy_gate(
|
|
REPOSITORY, fixture["revision"], initial["preflightSha256"],
|
|
)
|
|
|
|
|
|
def test_same_idempotency_key_replays_progressed_marker_and_changed_key_conflicts(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
preflight = snapshot()
|
|
preflight["_semanticDigest"] = fixture["semantic"]
|
|
preflight["revision"] = fixture["revision"]
|
|
key = "pb-revalidate-2026-10-07"
|
|
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
|
binding = controller._projectbrain_delivery_signed_binding(preflight, key_hash)
|
|
record = {
|
|
"schema": controller._WEBHOOK_REVALIDATION_SCHEMA,
|
|
"semantic_sha256": fixture["semantic"],
|
|
"revalidation_of": fixture["semantic"],
|
|
"gitea_repository_id": 7,
|
|
"ref": "refs/heads/main",
|
|
"repository": REPOSITORY,
|
|
"revision": fixture["revision"],
|
|
"status": "deployed",
|
|
"projectbrain_delivery_revalidation": binding,
|
|
}
|
|
marker = fixture["inbox"] / f"{fixture['semantic']}.revalidation.json"
|
|
controller._write_new_inbox_record(marker, record)
|
|
|
|
assert controller._projectbrain_existing_revalidation(
|
|
fixture["revision"], key_hash, PREFLIGHT_SHA,
|
|
) == {"status": "deployed", "delivery": fixture["semantic"]}
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as failure:
|
|
controller._projectbrain_existing_revalidation(
|
|
fixture["revision"], hashlib.sha256(b"another-key-2026").hexdigest(), PREFLIGHT_SHA,
|
|
)
|
|
assert failure.value.code == "projectbrain_idempotency_conflict"
|
|
|
|
|
|
def test_same_key_replay_returns_the_existing_deploying_phase(controller, monkeypatch, tmp_path):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
preflight = snapshot()
|
|
preflight["_semanticDigest"] = fixture["semantic"]
|
|
preflight["revision"] = fixture["revision"]
|
|
key = "pb-revalidate-active-2026"
|
|
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
|
binding = controller._projectbrain_delivery_signed_binding(preflight, key_hash)
|
|
record = {
|
|
"schema": controller._WEBHOOK_REVALIDATION_SCHEMA,
|
|
"semantic_sha256": fixture["semantic"],
|
|
"revalidation_of": fixture["semantic"],
|
|
"gitea_repository_id": 7,
|
|
"ref": "refs/heads/main",
|
|
"repository": REPOSITORY,
|
|
"revision": fixture["revision"],
|
|
"status": "deploying",
|
|
"projectbrain_delivery_revalidation": binding,
|
|
}
|
|
controller._write_new_inbox_record(
|
|
fixture["inbox"] / f"{fixture['semantic']}.revalidation.json", record,
|
|
)
|
|
|
|
assert controller._projectbrain_existing_revalidation(
|
|
fixture["revision"], key_hash, PREFLIGHT_SHA,
|
|
) == {"status": "deploying", "delivery": fixture["semantic"]}
|
|
|
|
|
|
def test_concurrent_enqueue_creates_one_additive_attempt_and_preserves_original(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
preflight = controller._projectbrain_delivery_snapshot(fixture["revision"])
|
|
binding = controller._projectbrain_delivery_signed_binding(
|
|
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(
|
|
REPOSITORY,
|
|
fixture["revision"],
|
|
project,
|
|
expected_original_sha256=preflight["_originalFileSha256"],
|
|
projectbrain_delivery_binding=binding,
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as workers:
|
|
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 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(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
fixture = _snapshot_fixture(controller, monkeypatch, tmp_path)
|
|
|
|
result = controller._enqueue_terminal_revalidation(
|
|
REPOSITORY,
|
|
fixture["revision"],
|
|
fixture["project"],
|
|
)
|
|
|
|
assert result == ("invalid", "")
|
|
assert fixture["original_path"].read_bytes() == fixture["original_bytes"]
|
|
assert not (fixture["inbox"] / f"{fixture['semantic']}.revalidation.json").exists()
|
|
|
|
|
|
def test_deploy_rejection_precedes_source_sync_under_project_and_global_locks(
|
|
monkeypatch, tmp_path,
|
|
):
|
|
deploy = _load_module("projectbrain_delivery_deploy_gate_test", BROKER_ROOT / "deploy.py")
|
|
revision = "a" * 40
|
|
project = {
|
|
"slug": "projectbrain",
|
|
"branch": "main",
|
|
"source_dir": str(tmp_path / "source"),
|
|
"exact_source": True,
|
|
"deploy_script": "scripts/deploy-unraid-single.sh",
|
|
"containers": ["projectbrain"],
|
|
"compose_project": "projectbrain",
|
|
"compose_files": ["docker-compose.yml"],
|
|
}
|
|
active_locks = []
|
|
project_lock = deploy.STATE_ROOT / "locks" / "projectbrain.lock"
|
|
|
|
@contextmanager
|
|
def lock(path, _description):
|
|
active_locks.append(path)
|
|
try:
|
|
yield
|
|
finally:
|
|
active_locks.pop()
|
|
|
|
monkeypatch.setattr(deploy, "exclusive_lock", lock)
|
|
monkeypatch.setattr(deploy, "exclusive_global_deploy_lock", lambda: lock(
|
|
deploy.GLOBAL_DEPLOY_LOCK, "global",
|
|
))
|
|
monkeypatch.setattr(deploy, "load_projects", lambda: {REPOSITORY: dict(project)})
|
|
monkeypatch.setattr(deploy, "effective_mcp_hub_project", lambda _repo, config: config)
|
|
monkeypatch.setattr(deploy, "repository_path", lambda *_a: tmp_path)
|
|
monkeypatch.setattr(deploy, "bind_appops_self_upgrade_recovery", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "bind_registered_legacy_request", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "validate_commit", lambda *_a: revision)
|
|
monkeypatch.setattr(deploy, "deployment_metadata", lambda *_a: {"revision": revision})
|
|
monkeypatch.setattr(deploy, "validate_project_config", lambda *_a: None)
|
|
monkeypatch.setattr(
|
|
deploy,
|
|
"sync_source",
|
|
lambda *_a, **_k: pytest.fail("source sync must not run after a stale preflight"),
|
|
)
|
|
monkeypatch.setattr(
|
|
deploy,
|
|
"copy_environment",
|
|
lambda *_a, **_k: pytest.fail("private environment copy must not run after a stale preflight"),
|
|
)
|
|
gate_calls = []
|
|
|
|
def gate(_repository, _revision, _fingerprint):
|
|
gate_calls.append(list(active_locks))
|
|
return False
|
|
|
|
monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace(
|
|
_projectbrain_delivery_deploy_gate=gate,
|
|
))
|
|
monkeypatch.setenv(deploy.PROJECTBRAIN_DELIVERY_PREFLIGHT_ENV, "b" * 64)
|
|
monkeypatch.setattr(sys, "argv", ["deploy.py", "deploy", REPOSITORY, revision])
|
|
|
|
with pytest.raises(deploy.DeployError, match="preflight changed before deployment"):
|
|
deploy.main()
|
|
|
|
assert gate_calls == [[project_lock, deploy.GLOBAL_DEPLOY_LOCK]]
|
|
|
|
|
|
@pytest.mark.parametrize("restore_fails", [False, True])
|
|
def test_second_projectbrain_preflight_failure_restores_tracked_source_and_records_live_state(
|
|
monkeypatch, tmp_path, restore_fails,
|
|
):
|
|
deploy = _load_module("projectbrain_delivery_second_gate_test", BROKER_ROOT / "deploy.py")
|
|
repository_path = tmp_path / "repository"
|
|
repository_path.mkdir()
|
|
_git(repository_path, "init", "-b", "main")
|
|
_git(repository_path, "config", "user.email", "projectbrain-test@example.invalid")
|
|
_git(repository_path, "config", "user.name", "ProjectBrain test")
|
|
source_path = repository_path / "README.md"
|
|
source_path.write_text("prior live source\n", encoding="utf-8")
|
|
_git(repository_path, "add", ".")
|
|
_git(repository_path, "commit", "-m", "prior")
|
|
prior_revision = _git(repository_path, "rev-parse", "HEAD")
|
|
source_path.write_text("candidate source\n", encoding="utf-8")
|
|
_git(repository_path, "add", ".")
|
|
_git(repository_path, "commit", "-m", "candidate")
|
|
target_revision = _git(repository_path, "rev-parse", "HEAD")
|
|
repository_git_dir = repository_path / ".git"
|
|
source_dir = tmp_path / "source"
|
|
deploy.sync_source(repository_git_dir, prior_revision, source_dir, "projectbrain")
|
|
private_env_path = source_dir / ".env"
|
|
private_env_path.write_text("PROJECTBRAIN_PRIVATE_TEST=fixture-only\n", encoding="utf-8")
|
|
private_env_before = private_env_path.read_bytes()
|
|
|
|
project = {
|
|
"slug": "projectbrain",
|
|
"branch": "main",
|
|
"source_dir": str(source_dir),
|
|
"deploy_script": "scripts/deploy-unraid-single.sh",
|
|
"containers": ["projectbrain"],
|
|
"compose_project": "projectbrain",
|
|
"compose_files": ["docker-compose.yml"],
|
|
}
|
|
previous_state = {
|
|
"schema_version": 3,
|
|
"repository": REPOSITORY,
|
|
"live_revision": prior_revision,
|
|
"live_status": "healthy",
|
|
"live_legacy_provenance": False,
|
|
"live_artifacts": {"containers": {"projectbrain": {"image_id": "sha256:" + "a" * 64}}},
|
|
"live_receipt": {"revision": prior_revision, "testOnly": True},
|
|
}
|
|
active_locks = []
|
|
project_lock = deploy.STATE_ROOT / "locks" / "projectbrain.lock"
|
|
|
|
@contextmanager
|
|
def lock(path, _description):
|
|
active_locks.append(path)
|
|
try:
|
|
yield
|
|
finally:
|
|
active_locks.pop()
|
|
|
|
monkeypatch.setattr(deploy, "exclusive_lock", lock)
|
|
monkeypatch.setattr(deploy, "exclusive_global_deploy_lock", lambda: lock(
|
|
deploy.GLOBAL_DEPLOY_LOCK, "global",
|
|
))
|
|
monkeypatch.setattr(deploy, "load_projects", lambda: {REPOSITORY: dict(project)})
|
|
monkeypatch.setattr(deploy, "effective_mcp_hub_project", lambda _repo, config: config)
|
|
monkeypatch.setattr(deploy, "repository_path", lambda *_a: repository_git_dir)
|
|
monkeypatch.setattr(deploy, "repository_path_from_project", lambda _project: repository_git_dir)
|
|
monkeypatch.setattr(deploy, "bind_appops_self_upgrade_recovery", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "bind_registered_legacy_request", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "mcp_hub_prepare_current_receipt_model", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "validate_commit", lambda *_a: target_revision)
|
|
monkeypatch.setattr(deploy, "deployment_metadata", lambda *_a: {
|
|
"revision": target_revision,
|
|
"tree": "f" * 40,
|
|
})
|
|
monkeypatch.setattr(deploy, "validate_project_config", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "read_state", lambda _project: dict(previous_state))
|
|
monkeypatch.setattr(deploy, "verified_current_receipt", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "validate_forward_revision", lambda *_a: None)
|
|
monkeypatch.setattr(deploy, "_mcp_hub_assert_no_unresolved_migration_intent", lambda *_a: None)
|
|
|
|
source_syncs = []
|
|
actual_sync_source = deploy.sync_source
|
|
|
|
def record_sync(repo, revision, target, slug, clean_untracked_paths=None, *, exact=False):
|
|
source_syncs.append((revision, tuple(active_locks)))
|
|
if restore_fails and revision == prior_revision:
|
|
raise deploy.DeployError("fixture source restore failure")
|
|
return actual_sync_source(
|
|
repo, revision, target, slug, clean_untracked_paths, exact=exact,
|
|
)
|
|
|
|
monkeypatch.setattr(deploy, "sync_source", record_sync)
|
|
environment_copies = []
|
|
actual_copy_environment = deploy.copy_environment
|
|
|
|
def record_copy_environment(config, target):
|
|
environment_copies.append((tuple(active_locks), config.get("env_source")))
|
|
return actual_copy_environment(config, target)
|
|
|
|
monkeypatch.setattr(deploy, "copy_environment", record_copy_environment)
|
|
|
|
gate_calls = []
|
|
gate_results = iter((True, False))
|
|
|
|
def gate(_repository, _revision, _fingerprint):
|
|
gate_calls.append(tuple(active_locks))
|
|
return next(gate_results)
|
|
|
|
monkeypatch.setitem(sys.modules, "status_server", SimpleNamespace(
|
|
_projectbrain_delivery_deploy_gate=gate,
|
|
))
|
|
state_updates = []
|
|
monkeypatch.setattr(
|
|
deploy,
|
|
"write_state",
|
|
lambda repository, config, revision, status, message, **kwargs: state_updates.append({
|
|
"repository": repository,
|
|
"project": config,
|
|
"revision": revision,
|
|
"status": status,
|
|
"message": message,
|
|
**kwargs,
|
|
}),
|
|
)
|
|
monkeypatch.setattr(
|
|
deploy,
|
|
"capture_compose_rollback",
|
|
lambda *_a: pytest.fail("production snapshot must not run after a stale second gate"),
|
|
)
|
|
monkeypatch.setattr(
|
|
deploy,
|
|
"deploy_project",
|
|
lambda *_a: pytest.fail("production mutation must not run after a stale second gate"),
|
|
)
|
|
monkeypatch.setenv(deploy.PROJECTBRAIN_DELIVERY_PREFLIGHT_ENV, "b" * 64)
|
|
monkeypatch.setattr(sys, "argv", ["deploy.py", "deploy", REPOSITORY, target_revision])
|
|
|
|
with pytest.raises(deploy.DeployError, match="preflight changed before deployment"):
|
|
deploy.main()
|
|
|
|
assert len(gate_calls) == 2
|
|
assert gate_calls == [
|
|
(project_lock, deploy.GLOBAL_DEPLOY_LOCK),
|
|
(project_lock, deploy.GLOBAL_DEPLOY_LOCK),
|
|
]
|
|
assert source_syncs == [
|
|
(target_revision, (project_lock,)),
|
|
(prior_revision, (project_lock, deploy.GLOBAL_DEPLOY_LOCK)),
|
|
]
|
|
assert environment_copies == [((project_lock,), None)] + (
|
|
[] if restore_fails else [((project_lock, deploy.GLOBAL_DEPLOY_LOCK), None)]
|
|
)
|
|
assert source_dir.joinpath("README.md").read_text(encoding="utf-8") == (
|
|
"candidate source\n" if restore_fails else "prior live source\n"
|
|
)
|
|
assert private_env_path.read_bytes() == private_env_before
|
|
assert len(state_updates) == 1
|
|
state = state_updates[0]
|
|
assert state["status"] == "failed"
|
|
assert state["live_revision"] == prior_revision
|
|
assert state["live_artifacts"] == previous_state["live_artifacts"]
|
|
assert state["live_receipt"] == previous_state["live_receipt"]
|
|
assert state["live_status"] == ("unknown" if restore_fails else "healthy")
|
|
if restore_fails:
|
|
assert "source rollback error: fixture source restore failure" in state["message"]
|
|
|
|
|
|
def test_snapshot_lock_is_nonblocking_and_uses_project_then_global_flock(
|
|
controller, monkeypatch, tmp_path,
|
|
):
|
|
project_lock = tmp_path / "project.lock"
|
|
global_lock = tmp_path / "global.lock"
|
|
controller.PROJECTBRAIN_DELIVERY_PROJECT_LOCK_PATH = project_lock
|
|
controller.PROJECTBRAIN_DELIVERY_LOCK_PATH = global_lock
|
|
descriptors = {11: project_lock, 12: global_lock}
|
|
opened = []
|
|
acquired = []
|
|
released = []
|
|
closed = []
|
|
busy = False
|
|
|
|
fake_os = SimpleNamespace(
|
|
name="posix",
|
|
O_RDWR=1,
|
|
O_NOFOLLOW=2,
|
|
open=lambda path, _flags: _open_fake(path, descriptors, opened),
|
|
fstat=lambda _descriptor: SimpleNamespace(
|
|
st_mode=0o100600,
|
|
st_nlink=1,
|
|
st_uid=0,
|
|
),
|
|
geteuid=lambda: 0,
|
|
close=lambda descriptor: closed.append(descriptor),
|
|
)
|
|
|
|
def flock(descriptor, flags):
|
|
if flags == 6 and busy and descriptors[descriptor] == global_lock:
|
|
raise BlockingIOError
|
|
if flags == 6:
|
|
acquired.append(descriptors[descriptor])
|
|
elif flags == 8:
|
|
released.append(descriptors[descriptor])
|
|
|
|
monkeypatch.setattr(controller, "os", fake_os)
|
|
monkeypatch.setitem(sys.modules, "fcntl", SimpleNamespace(
|
|
LOCK_EX=2,
|
|
LOCK_NB=4,
|
|
LOCK_UN=8,
|
|
flock=flock,
|
|
))
|
|
|
|
with controller._projectbrain_delivery_mutation_lock(), pytest.raises(
|
|
controller.ProjectBrainDeliveryRefused,
|
|
) as thread_busy:
|
|
controller._projectbrain_delivery_mutation_lock().__enter__()
|
|
assert thread_busy.value.code == "projectbrain_delivery_operation_busy"
|
|
assert opened == [project_lock, global_lock]
|
|
assert acquired == [project_lock, global_lock]
|
|
assert released == [global_lock, project_lock]
|
|
assert closed == [12, 11]
|
|
|
|
busy = True
|
|
opened.clear()
|
|
acquired.clear()
|
|
released.clear()
|
|
closed.clear()
|
|
with pytest.raises(controller.ProjectBrainDeliveryRefused) as process_busy:
|
|
controller._projectbrain_delivery_mutation_lock().__enter__()
|
|
assert process_busy.value.code == "projectbrain_deployment_active"
|
|
assert opened == [project_lock, global_lock]
|
|
assert acquired == [project_lock]
|
|
assert released == [project_lock]
|
|
assert closed == [12, 11]
|
|
|
|
|
|
def test_deployment_broker_client_and_mcp_tool_keep_fixed_projectbrain_contract(monkeypatch):
|
|
from backend.app import mcp_server
|
|
from backend.app.services.deployment_broker import DeploymentBrokerClient, settings
|
|
|
|
monkeypatch.setattr(settings, "mcp_token", "t" * 48)
|
|
client = DeploymentBrokerClient(base_url="https://controller.example", verify_tls=True)
|
|
requests = []
|
|
monkeypatch.setattr(client, "_request", lambda *args, **kwargs: requests.append((args, kwargs)) or {"status": "ready"})
|
|
|
|
assert client.projectbrain_delivery_operation("preflight", {"revision": REVISION}) == {"status": "ready"}
|
|
assert requests[-1][0][:2] == ("POST", PREFIX + "preflight")
|
|
assert requests[-1][1]["payload"] == {"revision": REVISION}
|
|
revalidate_payload = {
|
|
"revision": REVISION,
|
|
"expectedPreflightSha256": PREFLIGHT_SHA,
|
|
"idempotencyKey": "pb-revalidate-2026-10-07",
|
|
}
|
|
assert client.projectbrain_delivery_operation("revalidate", revalidate_payload) == {"status": "ready"}
|
|
assert requests[-1][0][:2] == ("POST", PREFIX + "revalidate")
|
|
assert requests[-1][1]["payload"] == revalidate_payload
|
|
with pytest.raises(ValueError):
|
|
client.projectbrain_delivery_operation("preflight", {"revision": REVISION, "repository": REPOSITORY})
|
|
|
|
tool_calls = []
|
|
monkeypatch.setattr(
|
|
mcp_server,
|
|
"DeploymentBrokerClient",
|
|
lambda: SimpleNamespace(projectbrain_delivery_operation=lambda stage, payload: tool_calls.append((stage, payload)) or {"status": "ready"}),
|
|
)
|
|
assert mcp_server.appops_projectbrain_delivery_preflight(REVISION) == {"status": "ready"}
|
|
with pytest.raises(ValueError):
|
|
mcp_server.appops_projectbrain_delivery_revalidate(
|
|
REVISION, PREFLIGHT_SHA, "short",
|
|
)
|
|
assert mcp_server.appops_projectbrain_delivery_revalidate(
|
|
REVISION, PREFLIGHT_SHA, "pb-revalidate-2026-10-07",
|
|
) == {"status": "ready"}
|
|
assert tool_calls == [
|
|
("preflight", {"revision": REVISION}),
|
|
("revalidate", {
|
|
"revision": REVISION,
|
|
"expectedPreflightSha256": PREFLIGHT_SHA,
|
|
"idempotencyKey": "pb-revalidate-2026-10-07",
|
|
}),
|
|
]
|
|
|
|
|
|
def _open_fake(path, descriptor_paths, opened):
|
|
descriptor = 11 if not opened else 12
|
|
opened.append(Path(path))
|
|
descriptor_paths[descriptor] = Path(path)
|
|
return descriptor
|