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 산출물은 커밋에서 제외했다.
1835 lines
68 KiB
Python
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
|