vignette/scripts/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

1835 lines
68 KiB
Python

#!/usr/bin/env python3
"""Authenticated, fail-closed rollback executor for the isolated NAS preview.
The service is disabled unless both ``--enable`` and
``VIGNETTE_NAS_ROLLBACK_EXECUTOR_ENABLED=true`` are present. It accepts only
the G8 ``runtime``/``release_gate`` rollback command, binds that command to an
offline-provisioned manifest, and recreates only the audited preview services.
The executor never builds, pulls, stops a Compose project, or mutates a
database, volume, or network. Docker is invoked with argument arrays and
``shell=False``. An execution that fails after mutation restores the exact
API/Web image IDs observed immediately before the attempt.
"""
from __future__ import annotations
import argparse
import contextlib
import hashlib
import hmac
import json
import os
import re
import shutil
import ssl
import stat
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Protocol
from uuid import UUID
TOKEN_HEADER = "X-Vignette-Rollback-Executor-Token"
TOKEN_ENV = "VIGNETTE_NAS_ROLLBACK_EXECUTOR_TOKEN"
JOURNAL_KEY_ENV = "VIGNETTE_NAS_ROLLBACK_EXECUTOR_JOURNAL_KEY"
ENABLED_ENV = "VIGNETTE_NAS_ROLLBACK_EXECUTOR_ENABLED"
MAX_BODY_BYTES = 65_536
MAX_HEALTH_BODY_BYTES = 65_536
TARGET_PROJECT = "vignette-preview-20260807"
TARGET_ROOT = PurePosixPath("/volume1/docker/vignette-preview-20260807")
TARGET_SERVICES = ("api", "web")
TARGET_HEALTH_URL = "http://127.0.0.1:8088/api/health"
TARGET_DOCKER_SOCKET = "unix:///var/run/docker.sock"
DEFAULT_STATE_DIR = Path(str(TARGET_ROOT / ".rollback-executor"))
REQUEST_SCHEMA = "oas.rollback-executor.v1"
MANIFEST_SCHEMA = "vignette.nas-preview-rollback-manifest.v1"
JOURNAL_SCHEMA = "vignette.nas-preview-rollback-journal.v1"
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
IMAGE_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
ARTIFACT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,239}$")
EXECUTION_ID_RE = re.compile(r"^nas-g8-[a-f0-9]{24}$")
RELEASE_KEY_RE = re.compile(r"^[a-f0-9]{16}-\d{8}T\d{6}Z-\d+$")
SAFE_REF_PREFIXES = ("https://", "audit://", "db://", "repo://")
REQUEST_FIELDS = frozenset(
{
"schema_version",
"idempotency_key",
"approval_event_id",
"rollback_scope",
"target_kind",
"target_id",
"subject_id",
"rollback_target_id",
"artifact_record_id",
"artifact_id",
"artifact_sha256",
"artifact_provenance_uri",
"authorization_evidence_refs",
}
)
class ExecutorError(RuntimeError):
"""Expected fail-closed error with a non-sensitive external code."""
def __init__(self, code: str, *, http_status: int = 422):
self.code = code
self.http_status = http_status
super().__init__(code)
class CommandFailure(RuntimeError):
"""A command failed; stdout/stderr are intentionally not retained."""
def __init__(self, operation: str, returncode: int | None = None):
self.operation = operation
self.returncode = returncode
super().__init__(operation)
def _canonical_bytes(value: Any) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def _sha256(value: Any) -> str:
return hashlib.sha256(_canonical_bytes(value)).hexdigest()
def _resolved_policy_sha256(value: dict[str, Any]) -> str:
"""Hash resolved policy while images remain a separate exact binding."""
normalized = json.loads(_canonical_bytes(value))
services = normalized.get("services")
if not isinstance(services, dict):
raise CommandFailure("compose_config_policy")
for service_name in ("api", "web"):
service = services.get(service_name)
if not isinstance(service, dict):
raise CommandFailure("compose_config_policy")
service.pop("image", None)
return _sha256(normalized)
def _write_all(descriptor: int, value: bytes) -> None:
offset = 0
while offset < len(value):
written = os.write(descriptor, value[offset:])
if written <= 0:
raise OSError("short write")
offset += written
def _fsync_directory(path: Path) -> None:
if os.name == "nt" or not hasattr(os, "O_DIRECTORY"):
return
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _utc_now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _canonical_uuid(value: Any, field: str) -> str:
if not isinstance(value, str):
raise ExecutorError(f"invalid_{field}")
try:
parsed = UUID(value)
except ValueError as exc:
raise ExecutorError(f"invalid_{field}") from exc
if str(parsed) != value:
raise ExecutorError(f"invalid_{field}")
return value
def _strict_string(value: Any, field: str, *, maximum: int) -> str:
if not isinstance(value, str) or not value or len(value) > maximum:
raise ExecutorError(f"invalid_{field}")
return value
def _validate_absolute_ref(value: Any, field: str) -> str:
value = _strict_string(value, field, maximum=500)
if not value.startswith(SAFE_REF_PREFIXES):
raise ExecutorError(f"invalid_{field}")
return value
@dataclass(frozen=True)
class RollbackRequest:
schema_version: str
idempotency_key: str
approval_event_id: str
rollback_scope: str
target_kind: str
target_id: str
subject_id: str
rollback_target_id: str
artifact_record_id: str
artifact_id: str
artifact_sha256: str
artifact_provenance_uri: str
authorization_evidence_refs: tuple[str, ...]
@classmethod
def parse(cls, payload: Any) -> RollbackRequest:
if not isinstance(payload, dict) or set(payload) != REQUEST_FIELDS:
raise ExecutorError("invalid_request_shape")
if payload.get("schema_version") != REQUEST_SCHEMA:
raise ExecutorError("invalid_request_schema")
if payload.get("rollback_scope") != "runtime":
raise ExecutorError("unsupported_rollback_scope")
if payload.get("target_kind") != "release_gate":
raise ExecutorError("unsupported_target_kind")
artifact_id = _strict_string(
payload.get("artifact_id"), "artifact_id", maximum=240
)
if ARTIFACT_ID_RE.fullmatch(artifact_id) is None:
raise ExecutorError("invalid_artifact_id")
artifact_sha256 = payload.get("artifact_sha256")
if (
not isinstance(artifact_sha256, str)
or SHA256_RE.fullmatch(artifact_sha256) is None
):
raise ExecutorError("invalid_artifact_sha256")
raw_refs = payload.get("authorization_evidence_refs")
if not isinstance(raw_refs, list) or not 1 <= len(raw_refs) <= 100:
raise ExecutorError("invalid_authorization_evidence_refs")
refs = tuple(
_validate_absolute_ref(value, "authorization_evidence_ref")
for value in raw_refs
)
if len(set(refs)) != len(refs):
raise ExecutorError("duplicate_authorization_evidence_refs")
return cls(
schema_version=REQUEST_SCHEMA,
idempotency_key=_canonical_uuid(
payload.get("idempotency_key"), "idempotency_key"
),
approval_event_id=_canonical_uuid(
payload.get("approval_event_id"), "approval_event_id"
),
rollback_scope="runtime",
target_kind="release_gate",
target_id=_canonical_uuid(payload.get("target_id"), "target_id"),
subject_id=_strict_string(
payload.get("subject_id"), "subject_id", maximum=180
),
rollback_target_id=_strict_string(
payload.get("rollback_target_id"), "rollback_target_id", maximum=180
),
artifact_record_id=_canonical_uuid(
payload.get("artifact_record_id"), "artifact_record_id"
),
artifact_id=artifact_id,
artifact_sha256=artifact_sha256,
artifact_provenance_uri=_validate_absolute_ref(
payload.get("artifact_provenance_uri"), "artifact_provenance_uri"
),
authorization_evidence_refs=refs,
)
def canonical_payload(self) -> dict[str, Any]:
payload = asdict(self)
payload["authorization_evidence_refs"] = list(self.authorization_evidence_refs)
return payload
def _validate_compose_file(value: Any, artifact_id: str) -> str:
value = _strict_string(value, "compose_file", maximum=500)
candidate = PurePosixPath(value)
if (
not candidate.is_absolute()
or ".." in candidate.parts
or str(candidate) != value
):
raise ExecutorError("invalid_compose_file")
try:
relative = candidate.relative_to(TARGET_ROOT)
except ValueError as exc:
raise ExecutorError("invalid_compose_file") from exc
immutable = relative.parts == (
".rollback-executor",
"runtime",
artifact_id,
"docker-compose.yml",
)
if not immutable:
raise ExecutorError("invalid_compose_file")
return value
def _validate_env_file(value: Any, artifact_id: str) -> str:
value = _strict_string(value, "env_file", maximum=500)
expected = str(
TARGET_ROOT / ".rollback-executor" / "runtime" / artifact_id / ".env"
)
if value != expected:
raise ExecutorError("invalid_env_file")
return value
@dataclass(frozen=True)
class RollbackArtifact:
idempotency_key: str
approval_event_id: str
authorization_evidence_refs: tuple[str, ...]
artifact_id: str
artifact_sha256: str
artifact_record_id: str
artifact_provenance_uri: str
target_id: str
subject_id: str
rollback_target_id: str
compose_file: str
compose_sha256: str
env_file: str
env_sha256: str
resolved_config_sha256: str
api_image: str
web_image: str
@classmethod
def parse(cls, payload: Any) -> RollbackArtifact:
expected = {
"idempotency_key",
"approval_event_id",
"authorization_evidence_refs",
"artifact_id",
"artifact_sha256",
"artifact_record_id",
"artifact_provenance_uri",
"target_id",
"subject_id",
"rollback_target_id",
"compose_file",
"compose_sha256",
"env_file",
"env_sha256",
"resolved_config_sha256",
"api_image",
"web_image",
}
if not isinstance(payload, dict) or set(payload) != expected:
raise ExecutorError("invalid_manifest_artifact_shape")
artifact_id = _strict_string(
payload.get("artifact_id"), "artifact_id", maximum=240
)
if ARTIFACT_ID_RE.fullmatch(artifact_id) is None:
raise ExecutorError("invalid_artifact_id")
artifact_sha256 = payload.get("artifact_sha256")
if (
not isinstance(artifact_sha256, str)
or SHA256_RE.fullmatch(artifact_sha256) is None
):
raise ExecutorError("invalid_artifact_sha256")
api_image = payload.get("api_image")
web_image = payload.get("web_image")
if not isinstance(api_image, str) or IMAGE_RE.fullmatch(api_image) is None:
raise ExecutorError("invalid_api_image")
if not isinstance(web_image, str) or IMAGE_RE.fullmatch(web_image) is None:
raise ExecutorError("invalid_web_image")
raw_refs = payload.get("authorization_evidence_refs")
if not isinstance(raw_refs, list) or not 1 <= len(raw_refs) <= 100:
raise ExecutorError("invalid_authorization_evidence_refs")
refs = tuple(
_validate_absolute_ref(value, "authorization_evidence_ref")
for value in raw_refs
)
if len(set(refs)) != len(refs):
raise ExecutorError("duplicate_authorization_evidence_refs")
compose_sha256 = payload.get("compose_sha256")
env_sha256 = payload.get("env_sha256")
resolved_config_sha256 = payload.get("resolved_config_sha256")
if (
not isinstance(compose_sha256, str)
or SHA256_RE.fullmatch(compose_sha256) is None
):
raise ExecutorError("invalid_compose_sha256")
if not isinstance(env_sha256, str) or SHA256_RE.fullmatch(env_sha256) is None:
raise ExecutorError("invalid_env_sha256")
if (
not isinstance(resolved_config_sha256, str)
or SHA256_RE.fullmatch(resolved_config_sha256) is None
):
raise ExecutorError("invalid_resolved_config_sha256")
return cls(
idempotency_key=_canonical_uuid(
payload.get("idempotency_key"), "idempotency_key"
),
approval_event_id=_canonical_uuid(
payload.get("approval_event_id"), "approval_event_id"
),
authorization_evidence_refs=refs,
artifact_id=artifact_id,
artifact_sha256=artifact_sha256,
artifact_record_id=_canonical_uuid(
payload.get("artifact_record_id"), "artifact_record_id"
),
artifact_provenance_uri=_validate_absolute_ref(
payload.get("artifact_provenance_uri"), "artifact_provenance_uri"
),
target_id=_canonical_uuid(payload.get("target_id"), "target_id"),
subject_id=_strict_string(
payload.get("subject_id"), "subject_id", maximum=180
),
rollback_target_id=_strict_string(
payload.get("rollback_target_id"), "rollback_target_id", maximum=180
),
compose_file=_validate_compose_file(
payload.get("compose_file"), artifact_id
),
compose_sha256=compose_sha256,
env_file=_validate_env_file(payload.get("env_file"), artifact_id),
env_sha256=env_sha256,
resolved_config_sha256=resolved_config_sha256,
api_image=api_image,
web_image=web_image,
)
def matches(self, request: RollbackRequest) -> bool:
return all(
(
self.idempotency_key == request.idempotency_key,
self.approval_event_id == request.approval_event_id,
self.authorization_evidence_refs == request.authorization_evidence_refs,
self.artifact_id == request.artifact_id,
self.artifact_sha256 == request.artifact_sha256,
self.artifact_record_id == request.artifact_record_id,
self.artifact_provenance_uri == request.artifact_provenance_uri,
self.target_id == request.target_id,
self.subject_id == request.subject_id,
self.rollback_target_id == request.rollback_target_id,
)
)
@dataclass(frozen=True)
class RollbackManifest:
artifacts: dict[tuple[str, str], RollbackArtifact]
content_sha256: str
def bind(self, request: RollbackRequest) -> RollbackArtifact:
artifact = self.artifacts.get((request.artifact_id, request.artifact_sha256))
if artifact is None:
raise ExecutorError("artifact_not_allowlisted", http_status=403)
if not artifact.matches(request):
raise ExecutorError("artifact_request_binding_mismatch", http_status=409)
return artifact
def _read_regular_file(path: Path) -> bytes:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise ExecutorError("manifest_unreadable", http_status=500) from exc
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
raise ExecutorError("manifest_not_regular", http_status=500)
if os.name != "nt" and info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ExecutorError("manifest_permissions_unsafe", http_status=500)
if os.name != "nt" and info.st_uid != os.geteuid():
raise ExecutorError("manifest_owner_unsafe", http_status=500)
with os.fdopen(descriptor, "rb", closefd=False) as handle:
return handle.read(MAX_BODY_BYTES + 1)
finally:
os.close(descriptor)
def _sha256_regular_file(path: Path, *, maximum_bytes: int = 8_388_608) -> str:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise CommandFailure("runtime_input_unreadable") from exc
digest = hashlib.sha256()
total = 0
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
raise CommandFailure("runtime_input_not_regular")
if os.name != "nt" and (
info.st_uid != os.geteuid() or info.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
):
raise CommandFailure("runtime_input_permissions_unsafe")
while True:
chunk = os.read(descriptor, 65_536)
if not chunk:
break
total += len(chunk)
if total > maximum_bytes:
raise CommandFailure("runtime_input_too_large")
digest.update(chunk)
finally:
os.close(descriptor)
return digest.hexdigest()
def load_manifest(path: Path, expected_sha256: str) -> RollbackManifest:
if SHA256_RE.fullmatch(expected_sha256) is None:
raise ExecutorError("invalid_manifest_sha256", http_status=500)
raw = _read_regular_file(path)
if not raw or len(raw) > MAX_BODY_BYTES:
raise ExecutorError("manifest_size_rejected", http_status=500)
actual_sha256 = hashlib.sha256(raw).hexdigest()
if not hmac.compare_digest(actual_sha256, expected_sha256):
raise ExecutorError("manifest_sha256_mismatch", http_status=500)
try:
payload = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ExecutorError("manifest_json_invalid", http_status=500) from exc
if not isinstance(payload, dict) or set(payload) != {
"schema_version",
"target",
"artifacts",
}:
raise ExecutorError("manifest_shape_invalid", http_status=500)
if payload.get("schema_version") != MANIFEST_SCHEMA:
raise ExecutorError("manifest_schema_invalid", http_status=500)
target = payload.get("target")
if not isinstance(target, dict) or target != {
"compose_project": TARGET_PROJECT,
"remote_root": str(TARGET_ROOT),
"services": list(TARGET_SERVICES),
"health_url": TARGET_HEALTH_URL,
}:
raise ExecutorError("manifest_target_invalid", http_status=500)
raw_artifacts = payload.get("artifacts")
if not isinstance(raw_artifacts, list) or not raw_artifacts:
raise ExecutorError("manifest_artifacts_invalid", http_status=500)
artifacts: dict[tuple[str, str], RollbackArtifact] = {}
artifact_ids: set[str] = set()
idempotency_keys: set[str] = set()
approval_event_ids: set[str] = set()
for raw_artifact in raw_artifacts:
artifact = RollbackArtifact.parse(raw_artifact)
key = (artifact.artifact_id, artifact.artifact_sha256)
if (
key in artifacts
or artifact.artifact_id in artifact_ids
or artifact.idempotency_key in idempotency_keys
or artifact.approval_event_id in approval_event_ids
):
raise ExecutorError("manifest_artifact_duplicate", http_status=500)
artifacts[key] = artifact
artifact_ids.add(artifact.artifact_id)
idempotency_keys.add(artifact.idempotency_key)
approval_event_ids.add(artifact.approval_event_id)
return RollbackManifest(artifacts=artifacts, content_sha256=actual_sha256)
@dataclass(frozen=True)
class ImagePair:
api_image: str
web_image: str
class RuntimeController(Protocol):
def snapshot(self) -> ImagePair: ...
def ensure_images_present(self, images: ImagePair) -> None: ...
def apply(
self, artifact: RollbackArtifact, images: ImagePair, execution_id: str
) -> None: ...
def verify(self, images: ImagePair) -> None: ...
class HealthProbe(Protocol):
def wait_ready(self) -> None: ...
@dataclass(frozen=True)
class ProcessResult:
returncode: int
stdout: str
class CommandRunner(Protocol):
def run(
self, operation: str, argv: list[str], *, timeout: float
) -> ProcessResult: ...
class SubprocessRunner:
"""Execute one fixed argv without logging arguments or command output."""
def run(self, operation: str, argv: list[str], *, timeout: float) -> ProcessResult:
if (
not isinstance(argv, list)
or not argv
or any(not isinstance(item, str) for item in argv)
):
raise TypeError("argv must be a non-empty string list")
try:
completed = subprocess.run(
argv,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
shell=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CommandFailure(operation) from exc
if completed.returncode != 0:
raise CommandFailure(operation, completed.returncode)
return ProcessResult(completed.returncode, completed.stdout)
class DockerRuntimeController:
"""Narrow Docker adapter for one exact Compose project and three services."""
def __init__(
self,
runner: CommandRunner,
*,
state_dir: Path = DEFAULT_STATE_DIR,
docker_bin: str = "docker",
):
if not isinstance(docker_bin, str) or not docker_bin:
raise ValueError("docker binary is required")
self.runner = runner
self.state_dir = state_dir
self.docker_bin = docker_bin
@property
def _docker(self) -> list[str]:
return [self.docker_bin, "--host", TARGET_DOCKER_SOCKET]
@staticmethod
def _container(service: str) -> str:
if service not in {"api", "web"}:
raise ValueError("service is not inspectable")
return f"{TARGET_PROJECT}-{service}-1"
def _inspect(self, service: str, template: str, operation: str) -> str:
result = self.runner.run(
operation,
[
*self._docker,
"inspect",
f"--format={template}",
self._container(service),
],
timeout=15,
)
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if len(lines) != 1:
raise CommandFailure(operation)
return lines[0]
def _assert_identity(self, service: str) -> None:
project = self._inspect(
service,
'{{ index .Config.Labels "com.docker.compose.project" }}',
f"inspect_{service}_project",
)
compose_service = self._inspect(
service,
'{{ index .Config.Labels "com.docker.compose.service" }}',
f"inspect_{service}_service",
)
if project != TARGET_PROJECT or compose_service != service:
raise CommandFailure(f"identity_{service}")
def _assert_dependencies(self) -> None:
network_name = f"{TARGET_PROJECT}_vignette"
network = self.runner.run(
"inspect_preview_network",
[
*self._docker,
"network",
"inspect",
'--format={{ index .Labels "com.docker.compose.project" }}|'
'{{ index .Labels "com.docker.compose.network" }}',
network_name,
],
timeout=15,
)
if network.stdout.strip() != f"{TARGET_PROJECT}|vignette":
raise CommandFailure("preview_network_identity")
volume_name = f"{TARGET_PROJECT}_apiuploads"
volume = self.runner.run(
"inspect_preview_volume",
[
*self._docker,
"volume",
"inspect",
'--format={{ index .Labels "com.docker.compose.project" }}|'
'{{ index .Labels "com.docker.compose.volume" }}',
volume_name,
],
timeout=15,
)
if volume.stdout.strip() != f"{TARGET_PROJECT}|apiuploads":
raise CommandFailure("preview_volume_identity")
def snapshot(self) -> ImagePair:
for service in ("api", "web"):
self._assert_identity(service)
pair = ImagePair(
api_image=self._inspect("api", "{{.Image}}", "inspect_api_image"),
web_image=self._inspect("web", "{{.Image}}", "inspect_web_image"),
)
self._validate_images(pair)
return pair
@staticmethod
def _validate_images(images: ImagePair) -> None:
if IMAGE_RE.fullmatch(images.api_image) is None:
raise CommandFailure("invalid_api_image")
if IMAGE_RE.fullmatch(images.web_image) is None:
raise CommandFailure("invalid_web_image")
def ensure_images_present(self, images: ImagePair) -> None:
self._validate_images(images)
for service, image in (("api", images.api_image), ("web", images.web_image)):
result = self.runner.run(
f"verify_{service}_image_present",
[
*self._docker,
"image",
"inspect",
"--format={{.Id}}",
image,
],
timeout=15,
)
lines = [
line.strip() for line in result.stdout.splitlines() if line.strip()
]
if lines != [image]:
raise CommandFailure(f"verify_{service}_image_present")
@staticmethod
def _resolved_networks(service: dict[str, Any]) -> set[str]:
networks = service.get("networks", {})
if isinstance(networks, dict):
return set(networks)
if isinstance(networks, list) and all(
isinstance(item, str) for item in networks
):
return set(networks)
raise CommandFailure("compose_network_policy")
@staticmethod
def _is_configured(service: dict[str, Any], key: str) -> bool:
if key not in service:
return False
return service[key] not in (None, False, "", [], {})
@classmethod
def _validate_resolved_config(
cls,
payload: Any,
images: ImagePair,
) -> None:
if not isinstance(payload, dict):
raise CommandFailure("compose_config_policy")
if set(payload) - {"name", "networks", "services", "volumes"}:
raise CommandFailure("compose_top_level_policy")
services = payload.get("services")
if not isinstance(services, dict) or set(services) != {
"api",
"db",
"proxy",
"web",
}:
raise CommandFailure("compose_service_policy")
forbidden = {
"cap_add",
"cgroup",
"cgroup_parent",
"command",
"configs",
"container_name",
"device_cgroup_rules",
"devices",
"entrypoint",
"env_file",
"extends",
"ipc",
"network_mode",
"pid",
"ports",
"privileged",
"pull_policy",
"runtime",
"secrets",
"security_opt",
"sysctls",
"userns_mode",
"uts",
"volumes_from",
}
for service_name, expected_image in (
("api", images.api_image),
("web", images.web_image),
):
service = services.get(service_name)
if not isinstance(service, dict) or service.get("image") != expected_image:
raise CommandFailure("compose_image_policy")
if any(cls._is_configured(service, key) for key in forbidden):
raise CommandFailure("compose_privilege_policy")
if cls._resolved_networks(service) != {"vignette"}:
raise CommandFailure("compose_network_policy")
api_volumes = services["api"].get("volumes", [])
if not isinstance(api_volumes, list) or len(api_volumes) != 1:
raise CommandFailure("compose_volume_policy")
api_upload = api_volumes[0]
allowed_volume_sources = {
"apiuploads",
f"{TARGET_PROJECT}_apiuploads",
}
if (
not isinstance(api_upload, dict)
or api_upload.get("type") != "volume"
or api_upload.get("source") not in allowed_volume_sources
or api_upload.get("target") != "/app/uploads"
or api_upload.get("read_only") is True
):
raise CommandFailure("compose_volume_policy")
if services["web"].get("volumes", []) not in (None, []):
raise CommandFailure("compose_volume_policy")
networks = payload.get("networks", {})
if not isinstance(networks, dict) or set(networks) != {"vignette"}:
raise CommandFailure("compose_network_policy")
network = networks["vignette"]
if not isinstance(network, dict):
raise CommandFailure("compose_network_policy")
if network.get("external") is True:
raise CommandFailure("compose_network_policy")
if network.get("driver", "bridge") != "bridge":
raise CommandFailure("compose_network_policy")
if network.get("name") not in (
None,
"vignette",
f"{TARGET_PROJECT}_vignette",
):
raise CommandFailure("compose_network_policy")
volumes = payload.get("volumes", {})
if not isinstance(volumes, dict) or set(volumes) != {
"apiuploads",
"caddydata",
"pgdata",
}:
raise CommandFailure("compose_volume_policy")
if any(
isinstance(config, dict) and config.get("external") is True
for config in volumes.values()
):
raise CommandFailure("compose_volume_policy")
def _check_compose_policy(
self,
artifact: RollbackArtifact,
images: ImagePair,
compose_argv: list[str],
) -> None:
config_result = self.runner.run(
"compose_policy",
[*compose_argv, "config", "--format", "json"],
timeout=30,
)
try:
resolved_config = json.loads(config_result.stdout)
except json.JSONDecodeError as exc:
raise CommandFailure("compose_config_policy") from exc
self._validate_resolved_config(resolved_config, images)
resolved_sha256 = _resolved_policy_sha256(resolved_config)
if not hmac.compare_digest(
resolved_sha256,
artifact.resolved_config_sha256,
):
raise CommandFailure("resolved_config_sha256_mismatch")
def _verify_runtime_inputs(self, artifact: RollbackArtifact) -> None:
actual_compose_sha = _sha256_regular_file(Path(artifact.compose_file))
actual_env_sha = _sha256_regular_file(Path(artifact.env_file))
if not hmac.compare_digest(actual_compose_sha, artifact.compose_sha256):
raise CommandFailure("compose_sha256_mismatch")
if not hmac.compare_digest(actual_env_sha, artifact.env_sha256):
raise CommandFailure("env_sha256_mismatch")
def apply(
self, artifact: RollbackArtifact, images: ImagePair, execution_id: str
) -> None:
self._validate_images(images)
self._verify_runtime_inputs(artifact)
self._assert_dependencies()
if EXECUTION_ID_RE.fullmatch(execution_id) is None:
raise CommandFailure("invalid_execution_id")
override = self.state_dir / f"{execution_id}.override.yml"
content = (
"services:\n"
" api:\n"
f" image: {images.api_image}\n"
" web:\n"
f" image: {images.web_image}\n"
)
descriptor: int | None = None
try:
descriptor = os.open(
override,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
_write_all(descriptor, content.encode("ascii"))
os.fsync(descriptor)
os.close(descriptor)
descriptor = None
compose_argv = [
*self._docker,
"compose",
"-p",
TARGET_PROJECT,
"--env-file",
artifact.env_file,
"-f",
artifact.compose_file,
"-f",
str(override),
]
self._check_compose_policy(artifact, images, compose_argv)
argv = [
*compose_argv,
"up",
"-d",
"--no-build",
"--no-deps",
"--pull",
"never",
"--force-recreate",
*TARGET_SERVICES,
]
self.runner.run("compose_recreate", argv, timeout=300)
self._verify_runtime_inputs(artifact)
self._check_compose_policy(artifact, images, compose_argv)
self._assert_dependencies()
except OSError as exc:
raise CommandFailure("write_override") from exc
finally:
if descriptor is not None:
os.close(descriptor)
with contextlib.suppress(OSError):
override.unlink()
def verify(self, images: ImagePair) -> None:
actual = self.snapshot()
if actual != images:
raise CommandFailure("image_verification_mismatch")
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(
self,
req: urllib.request.Request,
fp: Any,
code: int,
msg: str,
headers: Any,
newurl: str,
) -> None:
return None
class BoundedHealthProbe:
def __init__(
self,
*,
timeout_seconds: float = 60,
interval_seconds: float = 2,
request_timeout_seconds: float = 3,
):
if not 1 <= timeout_seconds <= 300:
raise ValueError("health timeout must be between 1 and 300 seconds")
if not 0.1 <= interval_seconds <= 10:
raise ValueError("health interval must be between 0.1 and 10 seconds")
if not 0.5 <= request_timeout_seconds <= 10:
raise ValueError(
"health request timeout must be between 0.5 and 10 seconds"
)
self.timeout_seconds = timeout_seconds
self.interval_seconds = interval_seconds
self.request_timeout_seconds = request_timeout_seconds
self._opener = urllib.request.build_opener(_NoRedirect)
def _ready(self) -> bool:
request = urllib.request.Request(
TARGET_HEALTH_URL,
headers={"Accept": "application/json"},
method="GET",
)
try:
with self._opener.open(
request, timeout=self.request_timeout_seconds
) as response:
if response.status != 200:
return False
raw = response.read(MAX_HEALTH_BODY_BYTES + 1)
except (OSError, urllib.error.URLError, ValueError):
return False
if len(raw) > MAX_HEALTH_BODY_BYTES:
return False
try:
payload = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError):
return False
return (
isinstance(payload, dict)
and payload.get("status") == "ok"
and payload.get("db") is True
and payload.get("engine") is True
)
def wait_ready(self) -> None:
deadline = time.monotonic() + self.timeout_seconds
while True:
if self._ready():
return
remaining = deadline - time.monotonic()
if remaining <= 0:
raise CommandFailure("health_timeout")
time.sleep(min(self.interval_seconds, remaining))
class ProcessFileLock:
"""Kernel-backed lock; a crashed process releases it automatically."""
def __init__(self, path: Path):
self.path = path
self._handle: Any = None
def __enter__(self) -> ProcessFileLock:
if self.path.is_symlink():
raise ExecutorError("lock_path_unsafe", http_status=500)
flags = os.O_RDWR | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(self.path, flags, 0o600)
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
os.close(descriptor)
raise ExecutorError("lock_path_unsafe", http_status=500)
if os.name != "nt" and (
info.st_uid != os.geteuid()
or info.st_mode & (stat.S_IRWXG | stat.S_IRWXO)
):
os.close(descriptor)
raise ExecutorError("lock_permissions_unsafe", http_status=500)
self._handle = os.fdopen(descriptor, "r+b", closefd=True)
if os.name == "nt":
import msvcrt
self._handle.seek(0)
if self._handle.read(1) == b"":
self._handle.write(b"0")
self._handle.flush()
self._handle.seek(0)
msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if self._handle is not None:
self._handle.close()
self._handle = None
raise ExecutorError("executor_busy", http_status=503) from exc
return self
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
assert self._handle is not None
try:
if os.name == "nt":
import msvcrt
self._handle.seek(0)
msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
finally:
self._handle.close()
self._handle = None
class DurableJournal:
def __init__(self, state_dir: Path, *, integrity_key: bytes):
if not isinstance(integrity_key, bytes) or len(integrity_key) < 32:
raise ValueError("journal integrity key must contain at least 32 bytes")
self.state_dir = state_dir
self.path = state_dir / "journal.jsonl"
self.lock_path = state_dir / "executor.lock"
self._integrity_key = integrity_key
self._integrity_key_id = hashlib.sha256(integrity_key).hexdigest()[:16]
def _record_hash(self, value: dict[str, Any]) -> str:
return hmac.new(
self._integrity_key,
_canonical_bytes(value),
hashlib.sha256,
).hexdigest()
def lock(self) -> ProcessFileLock:
return ProcessFileLock(self.lock_path)
def read(self) -> list[dict[str, Any]]:
if not self.path.exists():
return []
if self.path.is_symlink():
raise ExecutorError("journal_path_unsafe", http_status=500)
records: list[dict[str, Any]] = []
previous_hash = "0" * 64
try:
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(self.path, flags)
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
os.close(descriptor)
raise ExecutorError("journal_path_unsafe", http_status=500)
if os.name != "nt" and (
info.st_uid != os.geteuid()
or info.st_mode & (stat.S_IRWXG | stat.S_IRWXO)
):
os.close(descriptor)
raise ExecutorError("journal_permissions_unsafe", http_status=500)
with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
for raw_line in handle:
if not raw_line.endswith("\n"):
raise ExecutorError("journal_truncated", http_status=500)
record = json.loads(raw_line)
if not isinstance(record, dict):
raise ExecutorError("journal_invalid", http_status=500)
record_hash = record.get("record_hash")
unhashed = dict(record)
unhashed.pop("record_hash", None)
if (
record.get("schema_version") != JOURNAL_SCHEMA
or record.get("integrity_key_id") != self._integrity_key_id
or record.get("previous_hash") != previous_hash
or not isinstance(record_hash, str)
or SHA256_RE.fullmatch(record_hash) is None
or not hmac.compare_digest(
self._record_hash(unhashed), record_hash
)
):
raise ExecutorError(
"journal_integrity_failure", http_status=500
)
records.append(record)
previous_hash = record_hash
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ExecutorError("journal_unreadable", http_status=500) from exc
return records
def append(self, event: dict[str, Any]) -> dict[str, Any]:
records = self.read()
previous_hash = records[-1]["record_hash"] if records else "0" * 64
record = {
"schema_version": JOURNAL_SCHEMA,
"recorded_at": _utc_now(),
"integrity_key_id": self._integrity_key_id,
"previous_hash": previous_hash,
**event,
}
record["record_hash"] = self._record_hash(record)
raw = _canonical_bytes(record) + b"\n"
try:
if self.path.is_symlink():
raise ExecutorError("journal_path_unsafe", http_status=500)
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(self.path, flags, 0o600)
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode):
raise ExecutorError("journal_path_unsafe", http_status=500)
if os.name != "nt" and (
info.st_uid != os.geteuid()
or info.st_mode & (stat.S_IRWXG | stat.S_IRWXO)
):
raise ExecutorError("journal_permissions_unsafe", http_status=500)
_write_all(descriptor, raw)
os.fsync(descriptor)
finally:
os.close(descriptor)
_fsync_directory(self.state_dir)
except OSError as exc:
raise ExecutorError("journal_write_failure", http_status=500) from exc
return record
def latest_by_key(self, key: str) -> dict[str, Any] | None:
matches = [
record for record in self.read() if record.get("idempotency_key") == key
]
return matches[-1] if matches else None
def latest_records(self) -> dict[str, dict[str, Any]]:
latest: dict[str, dict[str, Any]] = {}
for record in self.read():
key = record.get("idempotency_key")
if isinstance(key, str):
latest[key] = record
return latest
class NasPreviewRollbackExecutor:
def __init__(
self,
*,
manifest: RollbackManifest,
runtime: RuntimeController,
health: HealthProbe,
journal: DurableJournal,
):
self.manifest = manifest
self.runtime = runtime
self.health = health
self.journal = journal
self._blocked = False
@property
def ready(self) -> bool:
return not self._blocked
@staticmethod
def _execution_id(request_hash: str, idempotency_key: str) -> str:
digest = hashlib.sha256(
f"{idempotency_key}|{request_hash}".encode("ascii")
).hexdigest()
return f"nas-g8-{digest[:24]}"
@staticmethod
def _receipt(
request: RollbackRequest,
execution_id: str,
evidence_ref: str,
) -> dict[str, Any]:
return {
"schema_version": REQUEST_SCHEMA,
"status": "executed",
"execution_id": execution_id,
"idempotency_key": request.idempotency_key,
"rollback_scope": request.rollback_scope,
"target_kind": request.target_kind,
"target_id": request.target_id,
"artifact_record_id": request.artifact_record_id,
"artifact_sha256": request.artifact_sha256,
"evidence_refs": [evidence_ref],
}
def _restore_previous(
self,
artifact: RollbackArtifact,
previous: ImagePair,
execution_id: str,
) -> None:
self.runtime.ensure_images_present(previous)
self.runtime.apply(artifact, previous, execution_id)
self.runtime.verify(previous)
self.health.wait_ready()
self.runtime.verify(previous)
def recover_incomplete(self) -> None:
"""Conservatively restore pre-mutation images after a process crash."""
with self.journal.lock():
for key, record in self.journal.latest_records().items():
event = record.get("event")
unresolved_failure = (
event == "failed" and record.get("rollback_status") == "unverified"
)
if event not in {"started", "prepared"} and not unresolved_failure:
continue
common = {
"event": "failed",
"execution_id": record.get("execution_id"),
"idempotency_key": key,
"request_hash": record.get("request_hash"),
"failure_code": "recovered_incomplete_execution",
}
if event == "started":
self.journal.append({**common, "rollback_status": "not_started"})
continue
artifact = self.manifest.artifacts.get(
(record.get("artifact_id"), record.get("artifact_sha256"))
)
try:
if artifact is None:
raise CommandFailure("recovery_artifact_missing")
if record.get("artifact_binding_sha256") != _sha256(
asdict(artifact)
):
raise CommandFailure("recovery_artifact_mismatch")
api_image = record.get("previous_api_image")
web_image = record.get("previous_web_image")
if (
not isinstance(api_image, str)
or IMAGE_RE.fullmatch(api_image) is None
or not isinstance(web_image, str)
or IMAGE_RE.fullmatch(web_image) is None
):
raise CommandFailure("recovery_images_invalid")
execution_id = record.get("execution_id")
if (
not isinstance(execution_id, str)
or EXECUTION_ID_RE.fullmatch(execution_id) is None
):
raise CommandFailure("recovery_execution_id_invalid")
self._restore_previous(
artifact,
ImagePair(api_image, web_image),
execution_id,
)
except Exception:
self._blocked = True
self.journal.append({**common, "rollback_status": "unverified"})
raise ExecutorError(
"rollback_unverified", http_status=503
) from None
self.journal.append({**common, "rollback_status": "verified"})
def execute(self, request: RollbackRequest) -> dict[str, Any]:
if self._blocked:
raise ExecutorError("rollback_unverified", http_status=503)
artifact = self.manifest.bind(request)
request_hash = _sha256(request.canonical_payload())
execution_id = self._execution_id(request_hash, request.idempotency_key)
evidence_ref = (
f"audit://vignette-nas-preview/rollback-executions/{execution_id}"
)
with self.journal.lock():
existing = self.journal.latest_by_key(request.idempotency_key)
if existing is not None:
if existing.get("request_hash") != request_hash:
raise ExecutorError("idempotency_binding_conflict", http_status=409)
if existing.get("event") == "executed":
receipt = existing.get("receipt")
if not isinstance(receipt, dict):
raise ExecutorError("journal_receipt_invalid", http_status=500)
return receipt
raise ExecutorError("idempotency_terminal_conflict", http_status=409)
self.journal.append(
{
"event": "started",
"execution_id": execution_id,
"idempotency_key": request.idempotency_key,
"request_hash": request_hash,
"manifest_sha256": self.manifest.content_sha256,
"artifact_binding_sha256": _sha256(asdict(artifact)),
}
)
previous: ImagePair | None = None
prepared = False
failure_code = "execution_failed"
receipt = self._receipt(request, execution_id, evidence_ref)
try:
previous = self.runtime.snapshot()
desired = ImagePair(artifact.api_image, artifact.web_image)
self.runtime.ensure_images_present(desired)
self.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": _sha256(asdict(artifact)),
"previous_api_image": previous.api_image,
"previous_web_image": previous.web_image,
}
)
prepared = True
self.runtime.apply(artifact, desired, execution_id)
self.runtime.verify(desired)
self.health.wait_ready()
self.runtime.verify(desired)
self.journal.append(
{
"event": "executed",
"execution_id": execution_id,
"idempotency_key": request.idempotency_key,
"request_hash": request_hash,
"receipt": receipt,
}
)
except Exception:
rollback_status = "not_started"
if previous is not None and prepared:
try:
self._restore_previous(artifact, previous, execution_id)
except Exception:
rollback_status = "unverified"
failure_code = "rollback_unverified"
else:
rollback_status = "verified"
failure_event: dict[str, Any] = {
"event": "failed",
"execution_id": execution_id,
"idempotency_key": request.idempotency_key,
"request_hash": request_hash,
"failure_code": failure_code,
"rollback_status": rollback_status,
}
if previous is not None and prepared:
failure_event.update(
{
"artifact_id": artifact.artifact_id,
"artifact_sha256": artifact.artifact_sha256,
"artifact_binding_sha256": _sha256(asdict(artifact)),
"previous_api_image": previous.api_image,
"previous_web_image": previous.web_image,
}
)
self.journal.append(failure_event)
if rollback_status == "unverified":
self._blocked = True
raise ExecutorError(failure_code, http_status=503) from None
return receipt
def authenticate(presented: str, configured: str) -> bool:
"""Always use the constant-time primitive for token comparison."""
if not isinstance(presented, str):
presented = ""
return hmac.compare_digest(presented.encode("utf-8"), configured.encode("utf-8"))
class QuietThreadingHTTPServer(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 16
def __init__(self, *args: Any, **kwargs: Any):
self._request_slots = threading.BoundedSemaphore(16)
super().__init__(*args, **kwargs)
def process_request(self, request: Any, client_address: Any) -> None:
if not self._request_slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self._request_slots.release()
raise
def process_request_thread(self, request: Any, client_address: Any) -> None:
try:
super().process_request_thread(request, client_address)
finally:
self._request_slots.release()
def handle_error(self, request: Any, client_address: Any) -> None:
return None
def _handler(
*, token: str, executor: NasPreviewRollbackExecutor
) -> type[BaseHTTPRequestHandler]:
class Handler(BaseHTTPRequestHandler):
server_version = "VignetteRollbackExecutor/1"
sys_version = ""
def setup(self) -> None:
super().setup()
self.connection.settimeout(5)
def log_message(self, format: str, *args: object) -> None:
return None
def _json(self, status: int, payload: dict[str, Any]) -> None:
body = _canonical_bytes(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if self.path != "/healthz":
self._json(404, {"detail": "not_found"})
return
if not authenticate(self.headers.get(TOKEN_HEADER, ""), token):
self._json(403, {"detail": "forbidden"})
return
if not executor.ready:
self._json(503, {"detail": "rollback_unverified"})
return
self._json(
200,
{
"status": "ok",
"target": TARGET_PROJECT,
"manifest_sha256": executor.manifest.content_sha256,
},
)
def do_POST(self) -> None:
if self.path != "/rollback":
self._json(404, {"detail": "not_found"})
return
if not authenticate(self.headers.get(TOKEN_HEADER, ""), token):
self._json(403, {"detail": "forbidden"})
return
if self.headers.get("Transfer-Encoding") is not None:
self._json(400, {"detail": "invalid_transport"})
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._json(400, {"detail": "invalid_content_length"})
return
if content_length < 1 or content_length > MAX_BODY_BYTES:
self._json(413, {"detail": "request_size_rejected"})
return
try:
deadline = time.monotonic() + 5
remaining = content_length
chunks: list[bytes] = []
while remaining:
timeout = deadline - time.monotonic()
if timeout <= 0:
raise TimeoutError
self.connection.settimeout(timeout)
chunk = self.rfile.read1(min(remaining, 65_536))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
raw = b"".join(chunks)
if len(raw) != content_length:
self._json(400, {"detail": "incomplete_body"})
return
payload = json.loads(raw)
request = RollbackRequest.parse(payload)
receipt = executor.execute(request)
except (TimeoutError, OSError):
self._json(408, {"detail": "request_timeout"})
return
except (UnicodeDecodeError, json.JSONDecodeError):
self._json(400, {"detail": "invalid_json"})
return
except ExecutorError as exc:
self._json(exc.http_status, {"detail": exc.code})
return
self._json(200, receipt)
return Handler
def _validate_posix_host_path_chain(
*,
root: Path,
state_dir: Path,
manifest_path: Path,
runtime_paths: tuple[Path, ...],
current_euid: int,
lstat_func: Callable[[Path], os.stat_result],
) -> None:
expected: dict[Path, str] = {
root: "directory",
state_dir: "directory",
state_dir / "runtime": "directory",
manifest_path: "file",
}
for leaf in (manifest_path, *runtime_paths):
try:
relative = leaf.relative_to(root)
except ValueError as exc:
raise ExecutorError("host_path_outside_target", http_status=500) from exc
cursor = root
for part in relative.parts[:-1]:
cursor /= part
expected[cursor] = "directory"
expected[leaf] = "file"
for path, expected_kind in sorted(
expected.items(), key=lambda item: len(item[0].parts)
):
try:
info = lstat_func(path)
except OSError as exc:
raise ExecutorError("host_path_unavailable", http_status=500) from exc
if stat.S_ISLNK(info.st_mode):
raise ExecutorError("host_path_symlink_unsafe", http_status=500)
if info.st_uid != current_euid:
raise ExecutorError("host_path_owner_unsafe", http_status=500)
if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ExecutorError("host_path_permissions_unsafe", http_status=500)
actual_kind_ok = (
stat.S_ISDIR(info.st_mode)
if expected_kind == "directory"
else stat.S_ISREG(info.st_mode)
)
if not actual_kind_ok:
raise ExecutorError("host_path_type_unsafe", http_status=500)
def _require_secure_host_paths(
manifest_path: Path,
state_dir: Path,
*,
runtime_paths: tuple[Path, ...] = (),
) -> None:
if state_dir != DEFAULT_STATE_DIR:
raise ExecutorError("state_dir_not_allowlisted", http_status=500)
if state_dir.is_symlink():
raise ExecutorError("state_dir_not_canonical", http_status=500)
try:
resolved_root = Path(str(TARGET_ROOT)).resolve(strict=True)
resolved_manifest = manifest_path.resolve(strict=True)
resolved_state = state_dir.resolve(strict=True)
resolved_runtime_paths = tuple(
path.resolve(strict=True) for path in runtime_paths
)
except OSError as exc:
raise ExecutorError("host_path_unavailable", http_status=500) from exc
if resolved_root != Path(str(TARGET_ROOT)):
raise ExecutorError("target_root_not_canonical", http_status=500)
if resolved_state != state_dir:
raise ExecutorError("state_dir_not_canonical", http_status=500)
try:
resolved_manifest.relative_to(resolved_root)
resolved_state.relative_to(resolved_root)
except ValueError as exc:
raise ExecutorError("host_path_outside_target", http_status=500) from exc
if any(
resolved != original
for resolved, original in zip(resolved_runtime_paths, runtime_paths)
):
raise ExecutorError("runtime_path_not_canonical", http_status=500)
if os.name != "nt":
_validate_posix_host_path_chain(
root=Path(str(TARGET_ROOT)),
state_dir=state_dir,
manifest_path=manifest_path,
runtime_paths=runtime_paths,
current_euid=os.geteuid(),
lstat_func=lambda path: path.lstat(),
)
def _enabled(value: str | None) -> bool:
return isinstance(value, str) and value.strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _configured_token() -> str:
token = os.environ.get(TOKEN_ENV, "")
forbidden = {"change-me", "changeme", "placeholder", "secret", "test-token"}
if len(token) < 32 or token.strip().lower() in forbidden:
raise ExecutorError("executor_token_invalid", http_status=500)
return token
def _configured_journal_key() -> bytes:
value = os.environ.get(JOURNAL_KEY_ENV, "")
if len(value) < 32:
raise ExecutorError("journal_key_invalid", http_status=500)
return hashlib.sha256(value.encode("utf-8")).digest()
def _secure_docker_binary() -> str:
if any(
os.environ.get(name)
for name in ("DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST")
):
raise ExecutorError("docker_context_not_local", http_status=500)
discovered = shutil.which("docker")
if discovered is None:
raise ExecutorError("docker_binary_unavailable", http_status=500)
try:
resolved = Path(discovered).resolve(strict=True)
info = resolved.stat()
except OSError as exc:
raise ExecutorError("docker_binary_unavailable", http_status=500) from exc
allowlist = {
Path("/usr/bin/docker"),
Path("/usr/local/bin/docker"),
Path("/var/packages/ContainerManager/target/usr/bin/docker"),
}
if resolved not in allowlist:
raise ExecutorError("docker_binary_not_allowlisted", http_status=500)
if info.st_uid != 0 or info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
raise ExecutorError("docker_binary_permissions_unsafe", http_status=500)
socket_path = Path("/var/run/docker.sock")
try:
socket_info = socket_path.stat()
except OSError as exc:
raise ExecutorError("docker_socket_unavailable", http_status=500) from exc
if (
not stat.S_ISSOCK(socket_info.st_mode)
or socket_info.st_uid != 0
or socket_info.st_mode & stat.S_IWOTH
):
raise ExecutorError("docker_socket_permissions_unsafe", http_status=500)
return str(resolved)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--manifest-sha256", required=True)
parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE_DIR)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=18149)
parser.add_argument("--health-timeout-seconds", type=float, default=60)
parser.add_argument("--health-interval-seconds", type=float, default=2)
parser.add_argument("--health-request-timeout-seconds", type=float, default=3)
parser.add_argument("--tls-cert", type=Path)
parser.add_argument("--tls-key", type=Path)
parser.add_argument("--enable", action="store_true")
parser.add_argument("--check-config", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
manifest = load_manifest(args.manifest, args.manifest_sha256)
if args.check_config:
print(
json.dumps(
{
"ok": True,
"enabled": False,
"manifest_sha256": manifest.content_sha256,
"artifacts": len(manifest.artifacts),
"target": TARGET_PROJECT,
},
separators=(",", ":"),
)
)
return 0
if not args.enable or not _enabled(os.environ.get(ENABLED_ENV)):
raise SystemExit(
"executor is disabled; explicit CLI and environment opt-in required"
)
token = _configured_token()
journal_integrity_key = _configured_journal_key()
docker_bin = _secure_docker_binary()
runtime_paths = tuple(
path
for artifact in manifest.artifacts.values()
for path in (Path(artifact.compose_file), Path(artifact.env_file))
)
_require_secure_host_paths(
args.manifest,
args.state_dir,
runtime_paths=runtime_paths,
)
for artifact in manifest.artifacts.values():
for path in (Path(artifact.compose_file), Path(artifact.env_file)):
if (
not path.is_file()
or path.is_symlink()
or path.resolve(strict=True) != path
):
raise ExecutorError("runtime_path_unavailable", http_status=500)
health = BoundedHealthProbe(
timeout_seconds=args.health_timeout_seconds,
interval_seconds=args.health_interval_seconds,
request_timeout_seconds=args.health_request_timeout_seconds,
)
journal = DurableJournal(
args.state_dir,
integrity_key=journal_integrity_key,
)
with journal.lock():
journal.read()
executor = NasPreviewRollbackExecutor(
manifest=manifest,
runtime=DockerRuntimeController(
SubprocessRunner(),
state_dir=args.state_dir,
docker_bin=docker_bin,
),
health=health,
journal=journal,
)
executor.recover_incomplete()
if (args.tls_cert is None) != (args.tls_key is None):
raise SystemExit("--tls-cert and --tls-key must be configured together")
tls_enabled = args.tls_cert is not None
if not tls_enabled and args.host not in {"127.0.0.1", "::1", "localhost"}:
raise SystemExit("non-loopback binding requires TLS")
server = QuietThreadingHTTPServer(
(args.host, args.port),
_handler(token=token, executor=executor),
)
if tls_enabled:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(args.tls_cert, args.tls_key)
server.socket = context.wrap_socket(server.socket, server_side=True)
print(
json.dumps(
{
"ready": True,
"enabled": True,
"host": args.host,
"port": args.port,
"tls": tls_enabled,
"target": TARGET_PROJECT,
},
separators=(",", ":"),
),
flush=True,
)
server.serve_forever()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ExecutorError as exc:
print(
json.dumps({"ok": False, "error": exc.code}, separators=(",", ":")),
file=sys.stderr,
)
raise SystemExit(2) from None