#!/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())