vignette/scripts/test_serve_nas_preview_rollback_executor.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

963 lines
38 KiB
Python

from __future__ import annotations
import hashlib
import importlib.util
import io
import json
import os
import re
import stat
import subprocess
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
SCRIPT_PATH = Path(__file__).with_name("serve-nas-preview-rollback-executor.py")
SPEC = importlib.util.spec_from_file_location(
"serve_nas_preview_rollback_executor", SCRIPT_PATH
)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
OLD_IMAGES = MODULE.ImagePair("sha256:" + "1" * 64, "sha256:" + "2" * 64)
DESIRED_IMAGES = MODULE.ImagePair("sha256:" + "3" * 64, "sha256:" + "4" * 64)
JOURNAL_KEY = b"j" * 32
def _resolved_config_payload(
images: MODULE.ImagePair = DESIRED_IMAGES,
) -> dict[str, object]:
return {
"name": MODULE.TARGET_PROJECT,
"networks": {
"vignette": {
"driver": "bridge",
"name": f"{MODULE.TARGET_PROJECT}_vignette",
}
},
"services": {
"api": {
"image": images.api_image,
"networks": {"vignette": None},
"volumes": [
{
"type": "volume",
"source": "apiuploads",
"target": "/app/uploads",
}
],
},
"db": {},
"proxy": {},
"web": {
"image": images.web_image,
"networks": {"vignette": None},
},
},
"volumes": {
"apiuploads": {},
"caddydata": {},
"pgdata": {},
},
}
def _artifact_payload(**changes: object) -> dict[str, object]:
payload: dict[str, object] = {
"idempotency_key": "10000000-0000-0000-0000-000000000001",
"approval_event_id": "20000000-0000-0000-0000-000000000001",
"authorization_evidence_refs": ["audit://g8/human-approval/1"],
"artifact_id": "rollback-preview-release-v1",
"artifact_sha256": "a" * 64,
"artifact_record_id": "40000000-0000-0000-0000-000000000001",
"artifact_provenance_uri": "repo://vignette/releases/rollback-v1",
"target_id": "30000000-0000-0000-0000-000000000001",
"subject_id": "candidate-release-v2",
"rollback_target_id": "stable-release-v1",
"compose_file": (
"/volume1/docker/vignette-preview-20260807/.rollback-executor/"
"runtime/rollback-preview-release-v1/docker-compose.yml"
),
"compose_sha256": "b" * 64,
"env_file": (
"/volume1/docker/vignette-preview-20260807/.rollback-executor/"
"runtime/rollback-preview-release-v1/.env"
),
"env_sha256": "c" * 64,
"resolved_config_sha256": MODULE._resolved_policy_sha256(
_resolved_config_payload()
),
"api_image": DESIRED_IMAGES.api_image,
"web_image": DESIRED_IMAGES.web_image,
}
payload.update(changes)
return payload
def _manifest_payload(**artifact_changes: object) -> dict[str, object]:
return {
"schema_version": MODULE.MANIFEST_SCHEMA,
"target": {
"compose_project": MODULE.TARGET_PROJECT,
"remote_root": str(MODULE.TARGET_ROOT),
"services": list(MODULE.TARGET_SERVICES),
"health_url": MODULE.TARGET_HEALTH_URL,
},
"artifacts": [_artifact_payload(**artifact_changes)],
}
def _request_payload(**changes: object) -> dict[str, object]:
payload: dict[str, object] = {
"schema_version": MODULE.REQUEST_SCHEMA,
"idempotency_key": "10000000-0000-0000-0000-000000000001",
"approval_event_id": "20000000-0000-0000-0000-000000000001",
"rollback_scope": "runtime",
"target_kind": "release_gate",
"target_id": "30000000-0000-0000-0000-000000000001",
"subject_id": "candidate-release-v2",
"rollback_target_id": "stable-release-v1",
"artifact_record_id": "40000000-0000-0000-0000-000000000001",
"artifact_id": "rollback-preview-release-v1",
"artifact_sha256": "a" * 64,
"artifact_provenance_uri": "repo://vignette/releases/rollback-v1",
"authorization_evidence_refs": ["audit://g8/human-approval/1"],
}
payload.update(changes)
return payload
class FakeRuntime:
def __init__(
self,
*,
fail_first_apply: bool = False,
fail_restore_verify: bool = False,
) -> None:
self.current = OLD_IMAGES
self.fail_first_apply = fail_first_apply
self.fail_restore_verify = fail_restore_verify
self.apply_calls: list[MODULE.ImagePair] = []
self.snapshot_calls = 0
self.ensure_calls: list[MODULE.ImagePair] = []
def snapshot(self) -> MODULE.ImagePair:
self.snapshot_calls += 1
return self.current
def ensure_images_present(self, images: MODULE.ImagePair) -> None:
self.ensure_calls.append(images)
def apply(
self,
artifact: MODULE.RollbackArtifact,
images: MODULE.ImagePair,
execution_id: str,
) -> None:
del artifact, execution_id
self.apply_calls.append(images)
self.current = images
if self.fail_first_apply and len(self.apply_calls) == 1:
raise MODULE.CommandFailure("synthetic_partial_failure")
def verify(self, images: MODULE.ImagePair) -> None:
if self.fail_restore_verify and len(self.apply_calls) >= 2:
raise MODULE.CommandFailure("synthetic_restore_mismatch")
if self.current != images:
raise MODULE.CommandFailure("synthetic_image_mismatch")
class FakeHealth:
def __init__(self, *, failures: int = 0) -> None:
self.failures = failures
self.calls = 0
def wait_ready(self) -> None:
self.calls += 1
if self.calls <= self.failures:
raise MODULE.CommandFailure("synthetic_health_failure")
class CaptureCommandRunner:
def __init__(self) -> None:
self.calls: list[tuple[str, list[str], float]] = []
def run(
self,
operation: str,
argv: list[str],
*,
timeout: float,
) -> MODULE.ProcessResult:
self.calls.append((operation, argv, timeout))
if operation == "inspect_preview_network":
return MODULE.ProcessResult(
0,
f"{MODULE.TARGET_PROJECT}|vignette\n",
)
if operation == "inspect_preview_volume":
return MODULE.ProcessResult(
0,
f"{MODULE.TARGET_PROJECT}|apiuploads\n",
)
if operation == "compose_policy":
return MODULE.ProcessResult(
0,
json.dumps(_resolved_config_payload()),
)
return MODULE.ProcessResult(0, "")
class StatefulDockerRunner(CaptureCommandRunner):
def __init__(self) -> None:
super().__init__()
self.current = OLD_IMAGES
self.recreate_calls = 0
@staticmethod
def _override_images(argv: list[str]) -> MODULE.ImagePair:
last_flag = len(argv) - 1 - argv[::-1].index("-f")
override = Path(argv[last_flag + 1])
values = re.findall(
r"sha256:[a-f0-9]{64}",
override.read_text(encoding="utf-8"),
)
if len(values) != 2:
raise AssertionError("override image pair missing")
return MODULE.ImagePair(values[0], values[1])
def run(
self,
operation: str,
argv: list[str],
*,
timeout: float,
) -> MODULE.ProcessResult:
self.calls.append((operation, argv, timeout))
if operation.endswith("_project"):
return MODULE.ProcessResult(0, MODULE.TARGET_PROJECT + "\n")
if operation.endswith("_service"):
service = "api" if "api" in operation else "web"
return MODULE.ProcessResult(0, service + "\n")
if operation == "inspect_api_image":
return MODULE.ProcessResult(0, self.current.api_image + "\n")
if operation == "inspect_web_image":
return MODULE.ProcessResult(0, self.current.web_image + "\n")
if operation.startswith("verify_") and operation.endswith("_image_present"):
return MODULE.ProcessResult(0, argv[-1] + "\n")
if operation == "inspect_preview_network":
return MODULE.ProcessResult(0, f"{MODULE.TARGET_PROJECT}|vignette\n")
if operation == "inspect_preview_volume":
return MODULE.ProcessResult(0, f"{MODULE.TARGET_PROJECT}|apiuploads\n")
if operation == "compose_policy":
images = self._override_images(argv)
return MODULE.ProcessResult(0, json.dumps(_resolved_config_payload(images)))
if operation == "compose_recreate":
self.current = self._override_images(argv)
self.recreate_calls += 1
return MODULE.ProcessResult(0, "")
raise AssertionError(f"unexpected operation: {operation}")
class FailSuccessJournal(MODULE.DurableJournal):
def __init__(self, state_dir: Path) -> None:
super().__init__(state_dir, integrity_key=JOURNAL_KEY)
self.failed_once = False
def append(self, event: dict[str, object]) -> dict[str, object]:
if event.get("event") == "executed" and not self.failed_once:
self.failed_once = True
raise MODULE.ExecutorError(
"synthetic_journal_commit_failure", http_status=500
)
return super().append(event)
class NasPreviewRollbackExecutorTest(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory(prefix="vignette-g8-executor-test-")
self.root = Path(self.temp.name)
self.manifest_path = self.root / "rollback-manifest.json"
self.state_dir = self.root / "state"
self.state_dir.mkdir()
def tearDown(self) -> None:
self.temp.cleanup()
def write_manifest(
self, payload: dict[str, object] | None = None
) -> tuple[MODULE.RollbackManifest, str]:
raw = json.dumps(
payload or _manifest_payload(),
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
self.manifest_path.write_bytes(raw)
if os.name != "nt":
self.manifest_path.chmod(0o600)
digest = hashlib.sha256(raw).hexdigest()
return MODULE.load_manifest(self.manifest_path, digest), digest
def make_executor(
self,
*,
runtime: FakeRuntime | None = None,
health: FakeHealth | None = None,
) -> tuple[MODULE.NasPreviewRollbackExecutor, FakeRuntime, FakeHealth]:
manifest, _ = self.write_manifest()
actual_runtime = runtime or FakeRuntime()
actual_health = health or FakeHealth()
executor = MODULE.NasPreviewRollbackExecutor(
manifest=manifest,
runtime=actual_runtime,
health=actual_health,
journal=MODULE.DurableJournal(
self.state_dir,
integrity_key=JOURNAL_KEY,
),
)
return executor, actual_runtime, actual_health
def test_default_serve_mode_is_disabled_twice(self) -> None:
_, digest = self.write_manifest()
with mock.patch.dict(os.environ, {MODULE.ENABLED_ENV: "false"}, clear=False):
with self.assertRaisesRegex(SystemExit, "explicit CLI and environment"):
MODULE.main(
[
"--manifest",
str(self.manifest_path),
"--manifest-sha256",
digest,
]
)
with mock.patch.dict(os.environ, {MODULE.ENABLED_ENV: "true"}, clear=False):
with self.assertRaisesRegex(SystemExit, "explicit CLI and environment"):
MODULE.main(
[
"--manifest",
str(self.manifest_path),
"--manifest-sha256",
digest,
]
)
def test_check_config_is_non_mutating_and_needs_no_token(self) -> None:
_, digest = self.write_manifest()
stdout = io.StringIO()
with (
mock.patch.dict(os.environ, {}, clear=True),
mock.patch("sys.stdout", stdout),
):
result = MODULE.main(
[
"--manifest",
str(self.manifest_path),
"--manifest-sha256",
digest,
"--check-config",
]
)
self.assertEqual(0, result)
self.assertFalse(json.loads(stdout.getvalue())["enabled"])
self.assertEqual([], list(self.state_dir.iterdir()))
def test_authentication_uses_constant_time_byte_comparison(self) -> None:
with mock.patch.object(
MODULE.hmac, "compare_digest", return_value=False
) as compare:
self.assertFalse(MODULE.authenticate("wrong", "configured-secret"))
compare.assert_called_once_with(b"wrong", b"configured-secret")
def test_authenticated_health_check_is_read_only(self) -> None:
executor, runtime, health = self.make_executor()
token = "x" * 32
server = MODULE.QuietThreadingHTTPServer(
("127.0.0.1", 0),
MODULE._handler(token=token, executor=executor),
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
endpoint = f"http://127.0.0.1:{server.server_port}/healthz"
try:
with self.assertRaises(urllib.error.HTTPError) as unauthorized:
urllib.request.urlopen(endpoint, timeout=2)
self.assertEqual(403, unauthorized.exception.code)
request = urllib.request.Request(
endpoint,
headers={MODULE.TOKEN_HEADER: token},
)
with urllib.request.urlopen(request, timeout=2) as response:
payload = json.load(response)
self.assertEqual("ok", payload["status"])
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
self.assertEqual(0, runtime.snapshot_calls)
self.assertEqual(0, health.calls)
def test_only_runtime_release_gate_requests_are_accepted(self) -> None:
with self.assertRaisesRegex(MODULE.ExecutorError, "unsupported_rollback_scope"):
MODULE.RollbackRequest.parse(_request_payload(rollback_scope="model"))
with self.assertRaisesRegex(MODULE.ExecutorError, "unsupported_target_kind"):
MODULE.RollbackRequest.parse(
_request_payload(target_kind="model_change_gate")
)
def test_malicious_artifact_identifier_is_rejected_before_lookup(self) -> None:
for artifact_id in ("../rollback", "/tmp/rollback", "rollback\\artifact"):
with self.subTest(artifact_id=artifact_id):
with self.assertRaisesRegex(
MODULE.ExecutorError, "invalid_artifact_id"
):
MODULE.RollbackRequest.parse(
_request_payload(artifact_id=artifact_id)
)
def test_manifest_rejects_path_escape_and_unapproved_service_target(self) -> None:
payload = _manifest_payload(
compose_file=(
"/volume1/docker/vignette-preview-20260807/"
"releases/../prod/infra/docker-compose.yml"
)
)
with self.assertRaisesRegex(MODULE.ExecutorError, "invalid_compose_file"):
self.write_manifest(payload)
payload = _manifest_payload()
assert isinstance(payload["target"], dict)
payload["target"]["services"] = ["api", "web", "proxy", "db"]
with self.assertRaisesRegex(MODULE.ExecutorError, "manifest_target_invalid"):
self.write_manifest(payload)
def test_manifest_sha_is_pinned_and_permissions_are_checked(self) -> None:
self.write_manifest()
with self.assertRaisesRegex(MODULE.ExecutorError, "manifest_sha256_mismatch"):
MODULE.load_manifest(self.manifest_path, "f" * 64)
if os.name != "nt":
self.manifest_path.chmod(0o622)
digest = hashlib.sha256(self.manifest_path.read_bytes()).hexdigest()
with self.assertRaisesRegex(MODULE.ExecutorError, "permissions_unsafe"):
MODULE.load_manifest(self.manifest_path, digest)
def test_posix_host_chain_requires_current_owner_and_nonwritable_ancestors(
self,
) -> None:
root = Path("/volume1/docker/vignette-preview-20260807")
state = root / ".rollback-executor"
runtime = state / "runtime"
artifact_dir = runtime / "rollback-preview-release-v1"
manifest = state / "rollback-manifest.json"
compose = artifact_dir / "docker-compose.yml"
env_file = artifact_dir / ".env"
directories = {root, state, runtime, artifact_dir}
expected_paths = {*directories, manifest, compose, env_file}
owner = 1001
metadata = {
path: SimpleNamespace(
st_uid=owner,
st_mode=(stat.S_IFDIR | 0o755)
if path in directories
else (stat.S_IFREG | 0o400),
)
for path in expected_paths
}
MODULE._validate_posix_host_path_chain(
root=root,
state_dir=state,
manifest_path=manifest,
runtime_paths=(compose, env_file),
current_euid=owner,
lstat_func=metadata.__getitem__,
)
for unsafe_path in expected_paths:
with self.subTest(unsafe_path=unsafe_path):
original = metadata[unsafe_path]
metadata[unsafe_path] = SimpleNamespace(
st_uid=owner,
st_mode=original.st_mode | stat.S_IWGRP,
)
with self.assertRaisesRegex(
MODULE.ExecutorError,
"host_path_permissions_unsafe",
):
MODULE._validate_posix_host_path_chain(
root=root,
state_dir=state,
manifest_path=manifest,
runtime_paths=(compose, env_file),
current_euid=owner,
lstat_func=metadata.__getitem__,
)
metadata[unsafe_path] = original
def test_posix_host_chain_rejects_root_0777_foreign_owner_and_ancestor_swap(
self,
) -> None:
root = Path("/volume1/docker/vignette-preview-20260807")
state = root / ".rollback-executor"
runtime = state / "runtime"
artifact_dir = runtime / "rollback-preview-release-v1"
manifest = state / "rollback-manifest.json"
compose = artifact_dir / "docker-compose.yml"
env_file = artifact_dir / ".env"
owner = 1001
def metadata() -> dict[Path, SimpleNamespace]:
directories = {root, state, runtime, artifact_dir}
return {
path: SimpleNamespace(
st_uid=owner,
st_mode=(stat.S_IFDIR | 0o755)
if path in directories
else (stat.S_IFREG | 0o400),
)
for path in {*directories, manifest, compose, env_file}
}
cases = (
(
"permissions_unsafe",
root,
SimpleNamespace(st_uid=owner, st_mode=stat.S_IFDIR | 0o777),
),
(
"owner_unsafe",
runtime,
SimpleNamespace(st_uid=2002, st_mode=stat.S_IFDIR | 0o755),
),
(
"symlink_unsafe",
artifact_dir,
SimpleNamespace(st_uid=owner, st_mode=stat.S_IFLNK | 0o777),
),
)
for error, unsafe_path, replacement in cases:
with self.subTest(error=error):
current = metadata()
current[unsafe_path] = replacement
with self.assertRaisesRegex(MODULE.ExecutorError, error):
MODULE._validate_posix_host_path_chain(
root=root,
state_dir=state,
manifest_path=manifest,
runtime_paths=(compose, env_file),
current_euid=owner,
lstat_func=current.__getitem__,
)
def test_manifest_cannot_reuse_one_approval_for_a_second_command(self) -> None:
payload = _manifest_payload()
assert isinstance(payload["artifacts"], list)
payload["artifacts"].append(
_artifact_payload(
artifact_id="rollback-preview-release-v2",
artifact_sha256="d" * 64,
artifact_record_id="40000000-0000-0000-0000-000000000002",
idempotency_key="10000000-0000-0000-0000-000000000002",
compose_file=(
"/volume1/docker/vignette-preview-20260807/.rollback-executor/"
"runtime/rollback-preview-release-v2/docker-compose.yml"
),
env_file=(
"/volume1/docker/vignette-preview-20260807/.rollback-executor/"
"runtime/rollback-preview-release-v2/.env"
),
)
)
with self.assertRaisesRegex(MODULE.ExecutorError, "artifact_duplicate"):
self.write_manifest(payload)
def test_artifact_hash_and_every_request_binding_are_exact(self) -> None:
manifest, _ = self.write_manifest()
hash_mismatch = MODULE.RollbackRequest.parse(
_request_payload(artifact_sha256="b" * 64)
)
with self.assertRaisesRegex(MODULE.ExecutorError, "artifact_not_allowlisted"):
manifest.bind(hash_mismatch)
record_mismatch = MODULE.RollbackRequest.parse(
_request_payload(artifact_record_id="40000000-0000-0000-0000-000000000002")
)
with self.assertRaisesRegex(MODULE.ExecutorError, "binding_mismatch"):
manifest.bind(record_mismatch)
for changes in (
{"idempotency_key": "10000000-0000-0000-0000-000000000002"},
{"approval_event_id": "20000000-0000-0000-0000-000000000002"},
{"authorization_evidence_refs": ["audit://g8/forged-approval"]},
):
with self.subTest(changes=changes):
mismatch = MODULE.RollbackRequest.parse(_request_payload(**changes))
with self.assertRaisesRegex(MODULE.ExecutorError, "binding_mismatch"):
manifest.bind(mismatch)
def test_success_receipt_is_durable_and_replay_runs_no_docker(self) -> None:
executor, runtime, health = self.make_executor()
request = MODULE.RollbackRequest.parse(_request_payload())
first = executor.execute(request)
second = executor.execute(request)
self.assertEqual(first, second)
self.assertEqual("executed", first["status"])
self.assertEqual(1, runtime.snapshot_calls)
self.assertEqual([DESIRED_IMAGES], runtime.apply_calls)
self.assertEqual(1, health.calls)
self.assertEqual(
["started", "prepared", "executed"],
[record["event"] for record in executor.journal.read()],
)
self.assertTrue(first["evidence_refs"][0].startswith("audit://"))
def test_idempotency_key_cannot_be_rebound_to_changed_request(self) -> None:
executor, runtime, _ = self.make_executor()
request = MODULE.RollbackRequest.parse(_request_payload())
executor.journal.append(
{
"event": "started",
"execution_id": "nas-g8-" + "f" * 24,
"idempotency_key": request.idempotency_key,
"request_hash": "f" * 64,
"manifest_sha256": executor.manifest.content_sha256,
"artifact_binding_sha256": "e" * 64,
}
)
with self.assertRaisesRegex(
MODULE.ExecutorError, "idempotency_binding_conflict"
):
executor.execute(request)
self.assertEqual([], runtime.apply_calls)
def test_incomplete_started_record_is_closed_without_runtime_mutation(self) -> None:
executor, runtime, _ = self.make_executor()
request = MODULE.RollbackRequest.parse(_request_payload())
executor.journal.append(
{
"event": "started",
"execution_id": "nas-g8-" + "a" * 24,
"idempotency_key": request.idempotency_key,
"request_hash": MODULE._sha256(request.canonical_payload()),
"manifest_sha256": executor.manifest.content_sha256,
"artifact_binding_sha256": "e" * 64,
}
)
executor.recover_incomplete()
self.assertEqual([], runtime.apply_calls)
terminal = executor.journal.read()[-1]
self.assertEqual("failed", terminal["event"])
self.assertEqual("not_started", terminal["rollback_status"])
def test_incomplete_prepared_record_restores_previous_images_on_restart(
self,
) -> None:
runtime = FakeRuntime()
runtime.current = DESIRED_IMAGES
executor, runtime, health = self.make_executor(runtime=runtime)
request = MODULE.RollbackRequest.parse(_request_payload())
artifact = next(iter(executor.manifest.artifacts.values()))
request_hash = MODULE._sha256(request.canonical_payload())
execution_id = "nas-g8-" + "b" * 24
executor.journal.append(
{
"event": "started",
"execution_id": execution_id,
"idempotency_key": request.idempotency_key,
"request_hash": request_hash,
"manifest_sha256": executor.manifest.content_sha256,
"artifact_binding_sha256": MODULE._sha256(MODULE.asdict(artifact)),
}
)
executor.journal.append(
{
"event": "prepared",
"execution_id": execution_id,
"idempotency_key": request.idempotency_key,
"request_hash": request_hash,
"artifact_id": artifact.artifact_id,
"artifact_sha256": artifact.artifact_sha256,
"artifact_binding_sha256": MODULE._sha256(MODULE.asdict(artifact)),
"previous_api_image": OLD_IMAGES.api_image,
"previous_web_image": OLD_IMAGES.web_image,
}
)
executor.recover_incomplete()
self.assertEqual([OLD_IMAGES], runtime.apply_calls)
self.assertEqual(OLD_IMAGES, runtime.current)
self.assertEqual(1, health.calls)
self.assertEqual("verified", executor.journal.read()[-1]["rollback_status"])
def test_partial_compose_failure_restores_previous_exact_images(self) -> None:
runtime = FakeRuntime(fail_first_apply=True)
executor, runtime, health = self.make_executor(runtime=runtime)
request = MODULE.RollbackRequest.parse(_request_payload())
with self.assertRaisesRegex(MODULE.ExecutorError, "execution_failed"):
executor.execute(request)
self.assertEqual([DESIRED_IMAGES, OLD_IMAGES], runtime.apply_calls)
self.assertEqual(OLD_IMAGES, runtime.current)
self.assertEqual(1, health.calls)
terminal = executor.journal.read()[-1]
self.assertEqual("verified", terminal["rollback_status"])
def test_failed_postdeploy_health_restores_and_rechecks_previous_runtime(
self,
) -> None:
health = FakeHealth(failures=1)
executor, runtime, health = self.make_executor(health=health)
with self.assertRaisesRegex(MODULE.ExecutorError, "execution_failed"):
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
self.assertEqual([DESIRED_IMAGES, OLD_IMAGES], runtime.apply_calls)
self.assertEqual(OLD_IMAGES, runtime.current)
self.assertEqual(2, health.calls)
def test_real_docker_controller_policy_allows_exact_previous_image_restore(
self,
) -> None:
manifest, _ = self.write_manifest()
artifact = next(iter(manifest.artifacts.values()))
runner = StatefulDockerRunner()
controller = MODULE.DockerRuntimeController(runner, state_dir=self.state_dir)
executor = MODULE.NasPreviewRollbackExecutor(
manifest=manifest,
runtime=controller,
health=FakeHealth(failures=1),
journal=MODULE.DurableJournal(
self.state_dir,
integrity_key=JOURNAL_KEY,
),
)
def runtime_hash(path: Path) -> str:
if path.name == "docker-compose.yml":
return artifact.compose_sha256
return artifact.env_sha256
with mock.patch.object(
MODULE,
"_sha256_regular_file",
side_effect=runtime_hash,
):
with self.assertRaisesRegex(MODULE.ExecutorError, "execution_failed"):
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
self.assertEqual(2, runner.recreate_calls)
self.assertEqual(OLD_IMAGES, runner.current)
self.assertEqual("verified", executor.journal.read()[-1]["rollback_status"])
def test_unverified_restore_never_emits_success_receipt(self) -> None:
runtime = FakeRuntime(fail_first_apply=True, fail_restore_verify=True)
executor, runtime, _ = self.make_executor(runtime=runtime)
with self.assertRaisesRegex(MODULE.ExecutorError, "rollback_unverified"):
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
terminal = executor.journal.read()[-1]
self.assertEqual("failed", terminal["event"])
self.assertEqual("unverified", terminal["rollback_status"])
self.assertNotIn("receipt", terminal)
self.assertFalse(executor.ready)
with self.assertRaisesRegex(MODULE.ExecutorError, "rollback_unverified"):
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
blocked_runtime = FakeRuntime(fail_restore_verify=True)
blocked_runtime.apply_calls.append(DESIRED_IMAGES)
restarted = MODULE.NasPreviewRollbackExecutor(
manifest=executor.manifest,
runtime=blocked_runtime,
health=FakeHealth(),
journal=executor.journal,
)
with self.assertRaisesRegex(MODULE.ExecutorError, "rollback_unverified"):
restarted.recover_incomplete()
self.assertFalse(restarted.ready)
def test_receipt_journal_failure_restores_runtime_before_failing(self) -> None:
manifest, _ = self.write_manifest()
runtime = FakeRuntime()
executor = MODULE.NasPreviewRollbackExecutor(
manifest=manifest,
runtime=runtime,
health=FakeHealth(),
journal=FailSuccessJournal(self.state_dir),
)
with self.assertRaisesRegex(MODULE.ExecutorError, "execution_failed"):
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
self.assertEqual([DESIRED_IMAGES, OLD_IMAGES], runtime.apply_calls)
self.assertEqual(OLD_IMAGES, runtime.current)
terminal = executor.journal.read()[-1]
self.assertEqual("failed", terminal["event"])
self.assertEqual("verified", terminal["rollback_status"])
def test_docker_apply_uses_exact_argv_allowlist_without_destructive_verbs(
self,
) -> None:
runner = CaptureCommandRunner()
artifact = MODULE.RollbackArtifact.parse(_artifact_payload())
controller = MODULE.DockerRuntimeController(runner, state_dir=self.state_dir)
with mock.patch.object(
MODULE,
"_sha256_regular_file",
side_effect=[
artifact.compose_sha256,
artifact.env_sha256,
artifact.compose_sha256,
artifact.env_sha256,
],
):
controller.apply(artifact, DESIRED_IMAGES, "nas-g8-" + "a" * 24)
operation, argv, timeout = next(
call for call in runner.calls if call[0] == "compose_recreate"
)
self.assertEqual("compose_recreate", operation)
self.assertEqual(300, timeout)
self.assertIsInstance(argv, list)
self.assertEqual(["api", "web"], argv[-2:])
self.assertEqual(MODULE.TARGET_PROJECT, argv[argv.index("-p") + 1])
self.assertIn("--no-build", argv)
self.assertIn("--no-deps", argv)
self.assertEqual("never", argv[argv.index("--pull") + 1])
self.assertFalse(
{"down", "stop", "rm", "prune", "volume", "network"} & set(argv)
)
self.assertFalse(
any(";" in item or "&&" in item or "||" in item for item in argv)
)
self.assertEqual([], list(self.state_dir.glob("*.override.yml")))
def test_resolved_compose_policy_rejects_privilege_and_bind_mounts(self) -> None:
privileged = _resolved_config_payload()
privileged["services"]["api"]["privileged"] = True
with self.assertRaisesRegex(MODULE.CommandFailure, "compose_privilege_policy"):
MODULE.DockerRuntimeController._validate_resolved_config(
privileged,
DESIRED_IMAGES,
)
bind_mount = _resolved_config_payload()
bind_mount["services"]["api"]["volumes"] = [
{
"type": "bind",
"source": "/var/run/docker.sock",
"target": "/var/run/docker.sock",
}
]
with self.assertRaisesRegex(MODULE.CommandFailure, "compose_volume_policy"):
MODULE.DockerRuntimeController._validate_resolved_config(
bind_mount,
DESIRED_IMAGES,
)
def test_resolved_compose_hash_mismatch_blocks_before_recreate(self) -> None:
runner = CaptureCommandRunner()
artifact = MODULE.RollbackArtifact.parse(
_artifact_payload(resolved_config_sha256="f" * 64)
)
controller = MODULE.DockerRuntimeController(runner, state_dir=self.state_dir)
with mock.patch.object(
MODULE,
"_sha256_regular_file",
side_effect=[artifact.compose_sha256, artifact.env_sha256],
):
with self.assertRaisesRegex(
MODULE.CommandFailure,
"resolved_config_sha256_mismatch",
):
controller.apply(artifact, DESIRED_IMAGES, "nas-g8-" + "c" * 24)
self.assertNotIn("compose_recreate", [call[0] for call in runner.calls])
def test_subprocess_runner_explicitly_disables_shell(self) -> None:
completed = subprocess.CompletedProcess(["docker", "version"], 0, "ok\n", "")
with mock.patch.object(MODULE.subprocess, "run", return_value=completed) as run:
result = MODULE.SubprocessRunner().run(
"probe",
["docker", "version"],
timeout=3,
)
self.assertEqual("ok\n", result.stdout)
_, kwargs = run.call_args
self.assertIs(kwargs["shell"], False)
self.assertIsInstance(run.call_args.args[0], list)
def test_journal_tampering_fails_closed(self) -> None:
executor, _, _ = self.make_executor()
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
path = executor.journal.path
raw = path.read_text(encoding="utf-8").replace(
'"event":"executed"', '"event":"failed"'
)
path.write_text(raw, encoding="utf-8")
with self.assertRaisesRegex(MODULE.ExecutorError, "journal_integrity_failure"):
executor.journal.read()
def test_journal_chain_is_authenticated_by_executor_secret(self) -> None:
executor, _, _ = self.make_executor()
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
wrong_key_journal = MODULE.DurableJournal(
self.state_dir,
integrity_key=b"k" * 32,
)
with self.assertRaisesRegex(MODULE.ExecutorError, "journal_integrity_failure"):
wrong_key_journal.read()
def test_new_approval_manifest_reads_existing_journal_with_stable_key(self) -> None:
executor, _, _ = self.make_executor()
executor.execute(MODULE.RollbackRequest.parse(_request_payload()))
second_payload = _manifest_payload(
artifact_id="rollback-preview-release-v2",
artifact_sha256="d" * 64,
artifact_record_id="40000000-0000-0000-0000-000000000002",
idempotency_key="10000000-0000-0000-0000-000000000002",
approval_event_id="20000000-0000-0000-0000-000000000002",
target_id="30000000-0000-0000-0000-000000000002",
compose_file=(
"/volume1/docker/vignette-preview-20260807/.rollback-executor/"
"runtime/rollback-preview-release-v2/docker-compose.yml"
),
env_file=(
"/volume1/docker/vignette-preview-20260807/.rollback-executor/"
"runtime/rollback-preview-release-v2/.env"
),
)
second_manifest, _ = self.write_manifest(second_payload)
restarted = MODULE.NasPreviewRollbackExecutor(
manifest=second_manifest,
runtime=FakeRuntime(),
health=FakeHealth(),
journal=MODULE.DurableJournal(
self.state_dir,
integrity_key=JOURNAL_KEY,
),
)
restarted.recover_incomplete()
self.assertEqual(3, len(restarted.journal.read()))
if __name__ == "__main__":
unittest.main()