G8 실제 rollback 증명 종료와 비-secure origin 회기 리뷰 크래시 수정

G8 마지막 게이트인 receipt-bound 실제 image rollback을 격리 NAS
vignette-preview-20260807 에서 실행해 종료했다.

Gate6 계약 정정:
감사 대상 current API 이미지가 com.docker.compose.project/service/version
image label 을 갖고 있어 "helper 의 compose label 0개" 계약은 감사되지 않은
다른 이미지를 쓰지 않는 한 성립하지 않는다. 계약을 key 부재가 아니라
소속(membership) 으로 바꿔 launch-nas-preview-g8-helpers.py 에 구현했다.
image 상속 label 을 baseline 으로 읽고 container 의 모든 compose label 이
baseline 과 같거나 선언된 격리 override 인지 검사하며, 최종 project 는
target 이 아니고 service 는 api/web/db/proxy 가 아니어야 한다. docker run
argv 에 target label 을 주입하면 fake-runner 테스트가 먼저 깨진다 (37/37).

실행 결과:
- rollback-old  receipt nas-g8-723eeef22eab05e63e3fafb0 -> 79ec../c530..
- restore-current receipt nas-g8-2738846cf2cf4fbe8ce0fc26 -> 52e0../6fdb..
- release gate/approval 각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2,
  audit.ci_human_approval_event authorize_rollback 2, silent auto-promotion 0
- HMAC journal 6-record 체인 검증, health 3/3, OpenAPI 126, auth 401, Web 200
- helper 0, listener 0, 비밀 env 파기. down/volume rm/prune 미실행, 공개 런타임 미접촉
- 계획했던 Windows SSH 터널은 NAS sshd 가 direct-tcpip 를 거부해 사용할 수 없어
  sshd 설정 변경 대신 같은 격리 계약의 NAS-side probe 컨테이너로 실행했다

비-secure origin 크래시 수정:
배포된 NAS 프리뷰(평문 HTTP, 비-localhost)에 회기 스펙을 돌려 24건 실패를 확인했고
원인은 하나였다. crypto.randomUUID 는 secure context 전용인데 제품 코드 18곳이
fallback 없이 호출했고 RuptureRepairCard 는 렌더 시점 호출이라 회기 리뷰 라우트
전체가 error boundary 로 떨어졌다. 릴리스 게이트 108/108 은 localhost 후보 스택에서만
돌아 이 경로를 밟은 적이 없다. src/lib/uuid.ts 의 randomUuid() 로 통일하고 fallback 도
crypto.getRandomValues 를 우선 사용해 idempotency key 의 예측 불가능성을 유지했다.
회귀는 insecure-context-uuid.spec.ts 6/6 으로 고정했다(직접 호출 0건 검사 포함).
이 수정은 아직 NAS 에 배포하지 않았다.

검증:
API 898, gateway 58, executor 28, probe 11, helper launcher 37, release agent 21,
ruff clean, web api-types/typecheck/build, SSOT FAIL 0, SSOT unit 5/5,
dashboard E2E 10/10, 학생 폐루프 실 DB 브라우저 4/4(일회용 클론),
crypto 수정 후 기존 스펙 회귀 70/70, 복원된 NAS 실제 브라우저 SSE->DB 리뷰 PASS.

부수 발견(열린 항목):
공개 API 가 engine=false 로 degraded 인데 워치독이 이를 감지하지 못한다. engine 판정이
게이트웨이 /health 의 ok 만 보고 claude readiness probe 를 돌리지 않기 때문이다.
같은 .env 와 같은 CLI 로 새 게이트웨이를 다른 포트에 띄우면 즉시 ready 이므로 상주
프로세스의 세션만 죽은 형태다. TODO A절과 대시보드에 기록했다.

이 커밋은 파일 단위로 담겼다. 위 파일들에는 이전 세션의 미커밋 G0~G8 작업이 함께
들어 있으며, hunk 를 쪼개면 대시보드/체커/TODO 정합성이 깨져 SSOT 체커가 실패한다.
This commit is contained in:
Yun Chan 2026-08-07 22:17:20 +09:00
parent 76d0b9ae9b
commit 93dd8f82d7
22 changed files with 10057 additions and 473 deletions

View file

@ -0,0 +1,718 @@
#!/usr/bin/env python3
"""Launch and verify the isolated G8 rollback helper containers on the NAS.
The exact current preview API image carries ``com.docker.compose.*`` *image*
labels (``project=vignette-preview-20260807``, ``service=api``,
``version=2.20.1``). Every container started from that image inherits them, so
the earlier "helper must expose zero ``com.docker.compose.*`` keys" rule can
never hold while the audited runtime image is reused. Requiring zero keys would
only push an operator toward a different, unaudited image.
The enforced contract is therefore *membership*, not key absence:
1. The helper must not belong to the target Compose project.
2. Image-inherited labels are the baseline. Every ``com.docker.compose.*`` key
on the helper container must either equal that baseline or be one of the
explicitly declared isolated overrides.
3. The effective ``com.docker.compose.project`` must never be the target project.
4. The effective ``com.docker.compose.service`` must never be ``api``, ``web``,
``db`` or ``proxy``.
5. The ``docker run`` argv must never carry a target project/service label.
6. The exact container name, container id and ``docker inspect`` labels are
recorded as evidence.
Docker is always invoked with argument arrays through an injectable runner, so
the argv contract is pinned by fake-runner tests instead of by live execution.
No command in this module stops, removes, recreates or prunes anything that
belongs to the target Compose project.
"""
from __future__ import annotations
import argparse
import json
import re
import shlex
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import PurePosixPath
from typing import Any, Iterable, Protocol
TARGET_PROJECT = "vignette-preview-20260807"
TARGET_ROOT = PurePosixPath("/volume1/docker/vignette-preview-20260807")
TARGET_SERVICES = ("api", "web", "db", "proxy")
TARGET_DOCKER_SOCKET = "/var/run/docker.sock"
STATE_DIR = TARGET_ROOT / ".rollback-executor"
MANIFEST_PATH = STATE_DIR / "rollback-manifest.json"
EXECUTOR_SCRIPT = STATE_DIR / "bin" / "serve-nas-preview-rollback-executor.py"
COMPOSE_LABEL_PREFIX = "com.docker.compose."
PROJECT_LABEL = f"{COMPOSE_LABEL_PREFIX}project"
SERVICE_LABEL = f"{COMPOSE_LABEL_PREFIX}service"
HELPER_LABEL = "net.chanpaca.vignette.g8-helper"
HELPER_RUN_LABEL = "net.chanpaca.vignette.g8-helper-run"
HELPER_PROJECT_PREFIX = "vignette-g8-helper-"
HELPER_ROLES = {
"executor": "g8-rollback-executor",
"control-plane": "g8-rollback-control-plane",
"probe": "g8-rollback-probe",
}
DOCKER_BINARY_SOURCE = "/volume1/@appstore/ContainerManager/usr/bin/docker"
DOCKER_BINARY_TARGET = "/usr/local/bin/docker"
COMPOSE_PLUGIN_SOURCE = "/volume1/@appstore/ContainerManager/usr/bin/docker-compose"
COMPOSE_PLUGIN_TARGET = "/usr/local/lib/docker/cli-plugins/docker-compose"
HELPER_UID = 1028
HELPER_GID = 100
HELPER_SUPPLEMENTAL_GID = 101
EXECUTOR_HOST = "127.0.0.1"
EXECUTOR_PORT = 18149
CONTROL_PLANE_HOST = "127.0.0.1"
CONTROL_PLANE_PORT = 8018
CONTROL_PLANE_BASE_URL = f"http://{CONTROL_PLANE_HOST}:{CONTROL_PLANE_PORT}"
PREVIEW_BASE_URL = "http://127.0.0.1:8088"
PROBE_SCRIPT = STATE_DIR / "bin" / "probe-nas-preview-g8-rollback.py"
EVIDENCE_DIR = STATE_DIR / "evidence"
PLANS_DIR = STATE_DIR / "plans"
PLAN_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
RUN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{5,31}$")
IMAGE_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
CONTAINER_ID_RE = re.compile(r"^[a-f0-9]{64}$")
EVIDENCE_SCHEMA = "vignette.nas-preview-g8-helper-isolation.v1"
class HelperError(RuntimeError):
"""Fail-closed error carrying a stable, non-sensitive code."""
def __init__(self, code: str):
self.code = code
super().__init__(code)
class CommandRunner(Protocol):
def run(
self, operation: str, argv: list[str], *, timeout: float
) -> str: ...
@dataclass(frozen=True)
class Mount:
source: str
target: str
read_only: bool = True
def as_argument(self) -> str:
parts = [
"type=bind",
f"source={self.source}",
f"target={self.target}",
]
if self.read_only:
parts.append("readonly")
return ",".join(parts)
@dataclass(frozen=True)
class HelperSpec:
"""One isolated helper container derived from the audited runtime image."""
role: str
run_id: str
image: str
env_file: str
entrypoint: str
command: tuple[str, ...]
mounts: tuple[Mount, ...] = ()
tmpfs: tuple[str, ...] = ()
supplemental_groups: tuple[int, ...] = ()
read_only_rootfs: bool = True
def __post_init__(self) -> None:
if self.role not in HELPER_ROLES:
raise HelperError("helper_role_unknown")
if RUN_ID_RE.fullmatch(self.run_id) is None:
raise HelperError("helper_run_id_invalid")
if IMAGE_RE.fullmatch(self.image) is None:
raise HelperError("helper_image_invalid")
if not self.env_file.startswith(f"{STATE_DIR}/"):
raise HelperError("helper_env_file_outside_state_dir")
if not self.command:
raise HelperError("helper_command_required")
@property
def isolated_project(self) -> str:
return f"{HELPER_PROJECT_PREFIX}{self.run_id}"
@property
def isolated_service(self) -> str:
return HELPER_ROLES[self.role]
@property
def container_name(self) -> str:
return f"{self.isolated_service}-{self.run_id}"
@property
def isolated_labels(self) -> dict[str, str]:
"""Compose labels this launcher deliberately overrides on the helper."""
return {
PROJECT_LABEL: self.isolated_project,
SERVICE_LABEL: self.isolated_service,
}
@property
def provenance_labels(self) -> dict[str, str]:
return {
HELPER_LABEL: self.role,
HELPER_RUN_LABEL: self.run_id,
}
def executor_spec(*, run_id: str, image: str, manifest_sha256: str) -> HelperSpec:
if SHA256_RE.fullmatch(manifest_sha256) is None:
raise HelperError("manifest_sha256_invalid")
return HelperSpec(
role="executor",
run_id=run_id,
image=image,
env_file=str(STATE_DIR / f"executor-{run_id}.env"),
entrypoint="python3.11",
command=(
"-B",
str(EXECUTOR_SCRIPT),
"--manifest",
str(MANIFEST_PATH),
"--manifest-sha256",
manifest_sha256,
"--host",
EXECUTOR_HOST,
"--port",
str(EXECUTOR_PORT),
"--enable",
),
mounts=(
Mount(str(TARGET_ROOT), str(TARGET_ROOT), read_only=True),
Mount(str(STATE_DIR), str(STATE_DIR), read_only=False),
Mount(DOCKER_BINARY_SOURCE, DOCKER_BINARY_TARGET, read_only=True),
Mount(COMPOSE_PLUGIN_SOURCE, COMPOSE_PLUGIN_TARGET, read_only=True),
Mount(TARGET_DOCKER_SOCKET, TARGET_DOCKER_SOCKET, read_only=False),
),
tmpfs=("/tmp:rw,nosuid,nodev,size=16m",),
supplemental_groups=(HELPER_SUPPLEMENTAL_GID,),
)
def control_plane_spec(*, run_id: str, image: str) -> HelperSpec:
return HelperSpec(
role="control-plane",
run_id=run_id,
image=image,
env_file=str(STATE_DIR / f"control-plane-{run_id}.env"),
entrypoint="uvicorn",
command=(
"app.main:app",
"--host",
CONTROL_PLANE_HOST,
"--port",
str(CONTROL_PLANE_PORT),
"--workers",
"1",
),
tmpfs=(
"/tmp:rw,nosuid,nodev,size=16m",
"/app/uploads:rw,nosuid,nodev,size=16m",
),
)
def probe_spec(*, run_id: str, image: str, plan_name: str) -> HelperSpec:
"""One-shot probe client. The NAS sshd forbids TCP forwarding, so the
control-plane probe runs from an isolated NAS-side container on loopback
instead of through a Windows SSH tunnel. It never mounts the Docker socket.
"""
if PLAN_NAME_RE.fullmatch(plan_name) is None:
raise HelperError("probe_plan_name_invalid")
return HelperSpec(
role="probe",
run_id=run_id,
image=image,
env_file=str(STATE_DIR / f"probe-{run_id}.env"),
entrypoint="python3.11",
command=(
"-B",
str(PROBE_SCRIPT),
"execute",
"--plan",
str(PLANS_DIR / f"{plan_name}.plan.json"),
"--control-plane-base-url",
CONTROL_PLANE_BASE_URL,
"--preview-base-url",
PREVIEW_BASE_URL,
"--timeout",
"180",
"--output",
str(EVIDENCE_DIR / f"{plan_name}-{run_id}.result.json"),
),
mounts=(
Mount(str(TARGET_ROOT), str(TARGET_ROOT), read_only=True),
Mount(str(EVIDENCE_DIR), str(EVIDENCE_DIR), read_only=False),
),
tmpfs=("/tmp:rw,nosuid,nodev,size=16m",),
)
def build_run_argv(spec: HelperSpec, *, docker_bin: str = "docker") -> list[str]:
"""Exact ``docker run`` argv for one isolated helper container."""
argv = [
docker_bin,
"run",
"--detach",
"--name",
spec.container_name,
"--restart",
"no",
"--network",
"host",
"--user",
f"{HELPER_UID}:{HELPER_GID}",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"256",
"--env-file",
spec.env_file,
]
for group in spec.supplemental_groups:
argv += ["--group-add", str(group)]
if spec.read_only_rootfs:
argv.append("--read-only")
for entry in spec.tmpfs:
argv += ["--tmpfs", entry]
for mount in spec.mounts:
argv += ["--mount", mount.as_argument()]
for key, value in sorted(
{**spec.isolated_labels, **spec.provenance_labels}.items()
):
argv += ["--label", f"{key}={value}"]
argv += ["--entrypoint", spec.entrypoint, spec.image, *spec.command]
assert_argv_isolation(argv, spec)
return argv
def assert_argv_isolation(argv: Iterable[str], spec: HelperSpec) -> None:
"""Reject any argv that would enlist the helper into the target project."""
items = list(argv)
for index, item in enumerate(items):
if item != "--label":
continue
if index + 1 >= len(items):
raise HelperError("helper_label_argument_missing")
key, separator, value = items[index + 1].partition("=")
if not separator:
raise HelperError("helper_label_argument_missing")
if key == PROJECT_LABEL and value != spec.isolated_project:
raise HelperError("helper_argv_targets_project")
if key == SERVICE_LABEL and value != spec.isolated_service:
raise HelperError("helper_argv_targets_service")
if not key.startswith(COMPOSE_LABEL_PREFIX):
continue
if key not in spec.isolated_labels:
raise HelperError("helper_argv_undeclared_compose_label")
for forbidden in ("--label-file", "--network-alias"):
if forbidden in items:
raise HelperError("helper_argv_forbidden_flag")
if any(item.startswith("--network=") or item == "--network" for item in items):
network_values = [
items[index + 1]
for index, item in enumerate(items)
if item == "--network" and index + 1 < len(items)
]
network_values += [
item.split("=", 1)[1] for item in items if item.startswith("--network=")
]
if any(value != "host" for value in network_values):
raise HelperError("helper_argv_network_not_isolated")
def verify_container_isolation(
*,
spec: HelperSpec,
baseline_labels: dict[str, str],
container_labels: dict[str, str],
target_project_members: Iterable[str],
) -> dict[str, Any]:
"""Check the helper against image baseline and target-project membership."""
if not isinstance(baseline_labels, dict) or not isinstance(
container_labels, dict
):
raise HelperError("helper_labels_unreadable")
inherited: dict[str, str] = {}
overridden: dict[str, str] = {}
for key, value in sorted(container_labels.items()):
if not key.startswith(COMPOSE_LABEL_PREFIX):
continue
if key in spec.isolated_labels:
if value != spec.isolated_labels[key]:
raise HelperError("helper_isolated_label_mismatch")
overridden[key] = value
continue
if baseline_labels.get(key) != value:
raise HelperError("helper_compose_label_drift")
inherited[key] = value
if set(overridden) != set(spec.isolated_labels):
raise HelperError("helper_isolated_label_missing")
if container_labels.get(PROJECT_LABEL) == TARGET_PROJECT:
raise HelperError("helper_joined_target_project")
if container_labels.get(SERVICE_LABEL) in TARGET_SERVICES:
raise HelperError("helper_claimed_target_service")
if container_labels.get(HELPER_LABEL) != spec.role:
raise HelperError("helper_provenance_label_missing")
members = list(target_project_members)
if any(not isinstance(name, str) for name in members):
raise HelperError("helper_membership_unreadable")
if spec.container_name in members:
raise HelperError("helper_listed_in_target_project")
return {
"baseline_compose_labels": {
key: value
for key, value in sorted(baseline_labels.items())
if key.startswith(COMPOSE_LABEL_PREFIX)
},
"inherited_compose_labels": inherited,
"isolated_override_labels": overridden,
"target_project_members": sorted(members),
}
class SubprocessRunner:
"""Run one fixed argv without echoing arguments or command output."""
def run(self, operation: str, argv: list[str], *, timeout: float) -> str:
if (
not isinstance(argv, list)
or not argv
or any(not isinstance(item, str) for item in argv)
):
raise HelperError("helper_argv_invalid")
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 HelperError(f"command_failed_{operation}") from exc
if completed.returncode != 0:
raise HelperError(f"command_failed_{operation}")
return completed.stdout
@dataclass
class SshCommandRunner:
"""Send one argv to the NAS over SSH with explicit POSIX quoting."""
destination: str
identity_file: str
inner: CommandRunner = field(default_factory=SubprocessRunner)
def run(self, operation: str, argv: list[str], *, timeout: float) -> str:
remote = " ".join(shlex.quote(item) for item in argv)
ssh_argv = [
"ssh",
"-i",
self.identity_file,
"-o",
"IdentitiesOnly=yes",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
self.destination,
remote,
]
return self.inner.run(operation, ssh_argv, timeout=timeout)
class HelperLauncher:
"""Start, verify and stop isolated helper containers through one runner."""
def __init__(self, runner: CommandRunner, *, docker_bin: str = "docker"):
self.runner = runner
self.docker_bin = docker_bin
def image_labels(self, image: str) -> dict[str, str]:
if IMAGE_RE.fullmatch(image) is None:
raise HelperError("helper_image_invalid")
raw = self.runner.run(
"inspect_image_labels",
[
self.docker_bin,
"image",
"inspect",
"--format={{json .Config.Labels}}",
image,
],
timeout=30,
)
return _parse_labels(raw)
def container_labels(self, container: str) -> dict[str, str]:
raw = self.runner.run(
"inspect_container_labels",
[
self.docker_bin,
"inspect",
"--format={{json .Config.Labels}}",
container,
],
timeout=30,
)
return _parse_labels(raw)
def container_id(self, container: str) -> str:
raw = self.runner.run(
"inspect_container_id",
[self.docker_bin, "inspect", "--format={{.Id}}", container],
timeout=30,
).strip()
if CONTAINER_ID_RE.fullmatch(raw) is None:
raise HelperError("helper_container_id_invalid")
return raw
def container_exit_code(self, container: str) -> int:
raw = self.runner.run(
"inspect_container_exit_code",
[self.docker_bin, "inspect", "--format={{.State.ExitCode}}", container],
timeout=30,
).strip()
try:
return int(raw)
except ValueError as exc:
raise HelperError("helper_exit_code_invalid") from exc
def container_running(self, container: str) -> bool:
raw = self.runner.run(
"inspect_container_state",
[self.docker_bin, "inspect", "--format={{.State.Running}}", container],
timeout=30,
).strip()
return raw == "true"
def target_project_members(self) -> list[str]:
raw = self.runner.run(
"list_target_project_members",
[
self.docker_bin,
"ps",
"--all",
"--filter",
f"label={PROJECT_LABEL}={TARGET_PROJECT}",
"--format={{.Names}}",
],
timeout=30,
)
return [line.strip() for line in raw.splitlines() if line.strip()]
def start(self, spec: HelperSpec) -> str:
argv = build_run_argv(spec, docker_bin=self.docker_bin)
self.runner.run("run_helper", argv, timeout=120)
return self.container_id(spec.container_name)
def verify(self, spec: HelperSpec) -> dict[str, Any]:
container_id = self.container_id(spec.container_name)
evidence = verify_container_isolation(
spec=spec,
baseline_labels=self.image_labels(spec.image),
container_labels=self.container_labels(spec.container_name),
target_project_members=self.target_project_members(),
)
return {
"schema_version": EVIDENCE_SCHEMA,
"role": spec.role,
"run_id": spec.run_id,
"image": spec.image,
"container_name": spec.container_name,
"container_id": container_id,
"container_running": self.container_running(spec.container_name),
"isolated_project": spec.isolated_project,
"isolated_service": spec.isolated_service,
"target_project": TARGET_PROJECT,
"run_argv": build_run_argv(spec, docker_bin=self.docker_bin),
**evidence,
}
def wait(
self, spec: HelperSpec, *, timeout_seconds: float = 300.0
) -> dict[str, Any]:
"""Block until a one-shot helper exits and report its exit code."""
if not 1 <= timeout_seconds <= 1800:
raise HelperError("helper_wait_timeout_invalid")
raw = self.runner.run(
"wait_helper",
[self.docker_bin, "wait", spec.container_name],
timeout=timeout_seconds,
).strip()
try:
exit_code = int(raw.splitlines()[-1])
except (ValueError, IndexError) as exc:
raise HelperError("helper_exit_code_invalid") from exc
return {
"schema_version": EVIDENCE_SCHEMA,
"role": spec.role,
"run_id": spec.run_id,
"container_name": spec.container_name,
"container_id": self.container_id(spec.container_name),
"exit_code": exit_code,
"container_running": self.container_running(spec.container_name),
}
def stop(self, spec: HelperSpec) -> None:
"""Stop and remove one helper container by its exact isolated name."""
if spec.container_name in self.target_project_members():
raise HelperError("helper_listed_in_target_project")
labels = self.container_labels(spec.container_name)
if labels.get(PROJECT_LABEL) != spec.isolated_project:
raise HelperError("helper_stop_project_mismatch")
if labels.get(HELPER_LABEL) != spec.role:
raise HelperError("helper_stop_provenance_mismatch")
self.runner.run(
"stop_helper",
[self.docker_bin, "stop", "--time", "20", spec.container_name],
timeout=90,
)
self.runner.run(
"remove_helper",
[self.docker_bin, "rm", spec.container_name],
timeout=60,
)
def _parse_labels(raw: str) -> dict[str, str]:
try:
value = json.loads(raw.strip() or "null")
except json.JSONDecodeError as exc:
raise HelperError("helper_labels_unreadable") from exc
if value is None:
return {}
if not isinstance(value, dict) or any(
not isinstance(key, str) or not isinstance(item, str)
for key, item in value.items()
):
raise HelperError("helper_labels_unreadable")
return value
def build_spec(args: argparse.Namespace) -> HelperSpec:
if args.role == "executor":
return executor_spec(
run_id=args.run_id,
image=args.image,
manifest_sha256=args.manifest_sha256,
)
if args.role == "probe":
return probe_spec(
run_id=args.run_id, image=args.image, plan_name=args.plan_name
)
return control_plane_spec(run_id=args.run_id, image=args.image)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"command", choices=("plan", "start", "verify", "wait", "stop")
)
parser.add_argument("--role", required=True, choices=sorted(HELPER_ROLES))
parser.add_argument("--run-id", required=True)
parser.add_argument("--image", required=True)
parser.add_argument("--manifest-sha256", default="")
parser.add_argument("--plan-name", default="")
parser.add_argument("--wait-seconds", type=float, default=300.0)
parser.add_argument("--ssh-destination")
parser.add_argument("--ssh-identity-file")
parser.add_argument("--docker-bin", default="docker")
parser.add_argument("--output")
return parser
def _runner(args: argparse.Namespace) -> CommandRunner:
if not args.ssh_destination or not args.ssh_identity_file:
raise HelperError("ssh_target_required")
return SshCommandRunner(
destination=args.ssh_destination,
identity_file=args.ssh_identity_file,
)
def main(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
try:
spec = build_spec(args)
if args.command == "plan":
payload: dict[str, Any] = {
"schema_version": EVIDENCE_SCHEMA,
"role": spec.role,
"run_id": spec.run_id,
"image": spec.image,
"container_name": spec.container_name,
"isolated_project": spec.isolated_project,
"isolated_service": spec.isolated_service,
"target_project": TARGET_PROJECT,
"env_file": spec.env_file,
"run_argv": build_run_argv(spec, docker_bin=args.docker_bin),
}
else:
launcher = HelperLauncher(_runner(args), docker_bin=args.docker_bin)
if args.command == "start":
launcher.start(spec)
payload = launcher.verify(spec)
elif args.command == "verify":
payload = launcher.verify(spec)
elif args.command == "wait":
payload = launcher.wait(spec, timeout_seconds=args.wait_seconds)
else:
launcher.stop(spec)
payload = {
"schema_version": EVIDENCE_SCHEMA,
"role": spec.role,
"run_id": spec.run_id,
"container_name": spec.container_name,
"stopped": True,
}
except HelperError as exc:
print(
json.dumps({"ok": False, "error": exc.code}, separators=(",", ":")),
file=sys.stderr,
)
return 2
text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
with open(args.output, "w", encoding="utf-8", newline="\n") as handle:
handle.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())