621 lines
22 KiB
Python
621 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""G7 외부 종료 증거 3종을 하나의 겹치는 시간창에서 동시에 잡는 오케스트레이터.
|
|
|
|
`check-g7-external-proof.py` 는 네 artifact 를 요구하고, 그 중 세 개는 **같은 public host 의
|
|
겹치는 시간창**이어야 한다. 지금까지는 운영자가 세 명령을 따로 띄우고 시계를 손으로 맞춰야 했다.
|
|
50분짜리 실행에서 그건 한 번 어긋나면 처음부터 다시 해야 하는 종류의 취약점이다.
|
|
|
|
이 스크립트가 하는 일:
|
|
|
|
1. soak / runtime / topology 캡처를 **동시에** 시작하고 전부 끝날 때까지 기다린다.
|
|
2. WSS / admin / topology transport 가 같은 public API host 를 보는지 시작 전에
|
|
확인한다(fail-closed). 브라우저 ``Origin`` 은 API host 와 섞지 않고 별도
|
|
allowlist 로 검증한다.
|
|
3. 끝나면 human voice-gain pack 을 더해 `check-g7-external-proof.py` 를 그대로 돌린다.
|
|
|
|
이 스크립트는 게이트를 **약화시키지 않는다**. 물리 마이크 캡처는 `--confirm-physical-capture` 와
|
|
`--microphone-device` 가 둘 다 있어야 시작하고, human pack 은 만들어 주지 않는다. 네 번째 artifact 는
|
|
실제 참가자와 독립 평가자가 있어야 한다.
|
|
|
|
`--rehearse` 는 마이크·사람 없이 배관만 확인하는 모드다. 짧은 시간창으로 세 러너를 실제로 띄워
|
|
인자·경로·산출 파일까지 검증하되, soak 은 `--preflight-only` 로 돌려 장치를 열지 않는다. 실제 실행은
|
|
52분을 캡처해 시작 skew 를 제외한 유효 교집합 50분을 증명한다. 그 전에 이걸로 먼저 실패를 뽑아내라.
|
|
|
|
공개 기본 계약은 브라우저 ``Origin=https://vignette.chanpaca.net`` 과 transport
|
|
host ``api-vignette.chanpaca.net`` 이 의도적으로 다른 구성이다. 인증 WSS 의
|
|
``ready`` 는 기본적으로 ``local_whisper/small`` 과 ``melotts/melotts-korean`` 을
|
|
정확히 반환해야 한다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import subprocess
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Sequence
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPTS = REPO_ROOT / "scripts"
|
|
|
|
SOAK_SCRIPT = SCRIPTS / "soak-public-voice-websocket.py"
|
|
RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py"
|
|
TOPOLOGY_SCRIPT = SCRIPTS / "capture-g7-topology-evidence.py"
|
|
CHECKER_SCRIPT = SCRIPTS / "check-g7-external-proof.py"
|
|
|
|
DEFAULT_WSS_URL = "wss://api-vignette.chanpaca.net/voice/ws"
|
|
DEFAULT_BROWSER_ORIGIN = "https://vignette.chanpaca.net"
|
|
DEFAULT_ADMIN_RUNTIME_URL = (
|
|
"https://api-vignette.chanpaca.net/admin/voice-runtime"
|
|
)
|
|
DEFAULT_ALLOWED_BROWSER_ORIGINS = (DEFAULT_BROWSER_ORIGIN,)
|
|
|
|
# canonical checker 가 요구하는 실제 artifact 교집합은 3,000초다. 세 캡처의
|
|
# 시작 skew 와 마지막 sampler tick 때문에 실제 캡처는 최소 120초 더 길게 잡는다.
|
|
MIN_REQUIRED_OVERLAP_SECONDS = 3_000.0
|
|
CAPTURE_START_SKEW_MARGIN_SECONDS = 120.0
|
|
MIN_PRODUCTION_SECONDS = (
|
|
MIN_REQUIRED_OVERLAP_SECONDS + CAPTURE_START_SKEW_MARGIN_SECONDS
|
|
)
|
|
RUNTIME_SAMPLE_MARGIN = 1
|
|
|
|
|
|
class WindowError(RuntimeError):
|
|
"""Fail-closed 오케스트레이션 오류."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Leg:
|
|
name: str
|
|
argv: list[str]
|
|
output: Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LegResult:
|
|
name: str
|
|
returncode: int
|
|
output: Path
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.returncode == 0
|
|
|
|
|
|
def host_of(url: str) -> str:
|
|
from urllib.parse import urlsplit
|
|
|
|
return (urlsplit(url).hostname or "").lower()
|
|
|
|
|
|
def assert_single_host(*urls: str) -> str:
|
|
"""transport URL 들이 같은 public host 를 보는지 확인한다."""
|
|
|
|
hosts = {host_of(url) for url in urls if url}
|
|
hosts.discard("")
|
|
if len(hosts) != 1:
|
|
# URL, query, tenant 이름을 오류에 반사하지 않는다.
|
|
raise WindowError("transport_hosts_must_match")
|
|
return hosts.pop()
|
|
|
|
|
|
def assert_transport_schemes(wss_url: str, admin_runtime_url: str) -> None:
|
|
"""공개 proof transport 를 암호화된 WSS/HTTPS 로만 고정한다."""
|
|
|
|
from urllib.parse import urlsplit
|
|
|
|
try:
|
|
wss = urlsplit(wss_url)
|
|
admin = urlsplit(admin_runtime_url)
|
|
except ValueError as exc:
|
|
raise WindowError("transport_url_invalid") from exc
|
|
if wss.scheme.lower() != "wss" or not wss.hostname:
|
|
raise WindowError("wss_url_scheme_invalid")
|
|
if admin.scheme.lower() != "https" or not admin.hostname:
|
|
raise WindowError("admin_runtime_url_scheme_invalid")
|
|
|
|
|
|
def normalize_browser_origin(value: str) -> str:
|
|
"""브라우저 Origin 직렬화만 허용하고 안전하게 정규화한다."""
|
|
|
|
from urllib.parse import urlsplit
|
|
|
|
try:
|
|
parsed = urlsplit(value)
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise WindowError("browser_origin_invalid") from exc
|
|
if (
|
|
parsed.scheme.lower() != "https"
|
|
or not parsed.hostname
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or parsed.path not in ("", "/")
|
|
or parsed.query
|
|
or parsed.fragment
|
|
):
|
|
raise WindowError("browser_origin_invalid")
|
|
host = parsed.hostname.lower()
|
|
authority = host if port in (None, 443) else f"{host}:{port}"
|
|
return f"https://{authority}"
|
|
|
|
|
|
def assert_browser_origin_allowed(
|
|
origin: str, allowed_origins: Sequence[str] | None
|
|
) -> str:
|
|
"""실제 브라우저 Origin 을 transport host 와 독립된 allowlist 로 검증한다."""
|
|
|
|
# CLI 옵션은 기본 공개 frontend 를 대체하지 않고 추가한다.
|
|
configured = (*DEFAULT_ALLOWED_BROWSER_ORIGINS, *(allowed_origins or ()))
|
|
try:
|
|
normalized_allowed = {normalize_browser_origin(item) for item in configured}
|
|
except WindowError as exc:
|
|
raise WindowError("browser_origin_allowlist_invalid") from exc
|
|
normalized = normalize_browser_origin(origin)
|
|
if normalized not in normalized_allowed:
|
|
# 입력 Origin 을 반사하면 query/PII 가 로그에 남을 수 있으므로 코드만 낸다.
|
|
raise WindowError("browser_origin_not_allowed")
|
|
return normalized
|
|
|
|
|
|
def sample_plan(duration_seconds: float, interval_seconds: float) -> int:
|
|
"""시간창을 덮기에 충분한 샘플 수. 모자라면 겹침 검증이 깨진다."""
|
|
|
|
if duration_seconds <= 0 or interval_seconds <= 0:
|
|
raise WindowError("invalid_sampling_plan")
|
|
# t=0 샘플과 duration 끝을 덮는 종료 샘플을 모두 포함한다.
|
|
return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN
|
|
|
|
|
|
def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg:
|
|
argv = [
|
|
sys.executable,
|
|
"-X",
|
|
"utf8",
|
|
str(SOAK_SCRIPT),
|
|
"--wss-url",
|
|
args.wss_url,
|
|
"--origin",
|
|
args.origin,
|
|
"--expected-stt-provider",
|
|
args.expected_stt_provider,
|
|
"--expected-stt-model",
|
|
args.expected_stt_model,
|
|
"--expected-tts-provider",
|
|
args.expected_tts_provider,
|
|
"--expected-tts-model",
|
|
args.expected_tts_model,
|
|
"--evidence-output",
|
|
str(output),
|
|
]
|
|
if args.rehearse:
|
|
# 배관 확인 전용. 장치를 열지 않는다.
|
|
argv.append("--preflight-only")
|
|
return Leg("voice_soak_preflight", argv, output)
|
|
if not args.microphone_device or not args.confirm_physical_capture:
|
|
raise WindowError("physical_microphone_consent_required")
|
|
argv += [
|
|
"--microphone-device",
|
|
args.microphone_device,
|
|
"--confirm-physical-capture",
|
|
"--duration-seconds",
|
|
str(args.duration_seconds),
|
|
]
|
|
return Leg("voice_soak", argv, output)
|
|
|
|
|
|
def build_runtime_leg(args: argparse.Namespace, output: Path) -> Leg:
|
|
samples = sample_plan(args.duration_seconds, args.runtime_interval_seconds)
|
|
argv = [
|
|
sys.executable,
|
|
"-X",
|
|
"utf8",
|
|
str(RUNTIME_SCRIPT),
|
|
"--url",
|
|
args.admin_runtime_url,
|
|
"--samples",
|
|
str(samples),
|
|
"--interval-seconds",
|
|
str(args.runtime_interval_seconds),
|
|
"--evidence-output",
|
|
str(output),
|
|
]
|
|
return Leg("runtime", argv, output)
|
|
|
|
|
|
def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg:
|
|
samples = sample_plan(args.duration_seconds, args.topology_interval_seconds)
|
|
argv = [
|
|
sys.executable,
|
|
"-X",
|
|
"utf8",
|
|
str(TOPOLOGY_SCRIPT),
|
|
"--topology-mode",
|
|
args.topology_mode,
|
|
"--public-host",
|
|
host_of(args.wss_url),
|
|
"--samples",
|
|
str(samples),
|
|
"--interval-seconds",
|
|
str(args.topology_interval_seconds),
|
|
"--evidence-output",
|
|
str(output),
|
|
]
|
|
if args.topology_mode == "linux-compose":
|
|
argv += [
|
|
"--compose-project",
|
|
args.compose_project,
|
|
"--api-container",
|
|
args.api_container,
|
|
"--api-image-digest",
|
|
args.api_image_digest,
|
|
"--caddy-container",
|
|
args.caddy_container,
|
|
"--caddy-image-digest",
|
|
args.caddy_image_digest,
|
|
]
|
|
else:
|
|
argv += [
|
|
"--repo-root",
|
|
args.repo_root,
|
|
"--git-sha",
|
|
args.git_sha,
|
|
"--git-tree-sha",
|
|
args.git_tree_sha,
|
|
"--runner-script-sha256",
|
|
args.runner_script_sha256,
|
|
"--collector-script-sha256",
|
|
args.collector_script_sha256,
|
|
"--checker-script-sha256",
|
|
args.checker_script_sha256,
|
|
"--psutil-version",
|
|
args.psutil_version,
|
|
"--api-pid",
|
|
str(args.api_pid),
|
|
"--api-executable-name",
|
|
args.api_executable_name,
|
|
"--api-executable-sha256",
|
|
args.api_executable_sha256,
|
|
"--api-cwd",
|
|
args.api_cwd,
|
|
"--api-listen-port",
|
|
str(args.api_listen_port),
|
|
"--cloudflared-pid",
|
|
str(args.cloudflared_pid),
|
|
"--cloudflared-executable-name",
|
|
args.cloudflared_executable_name,
|
|
"--cloudflared-executable-sha256",
|
|
args.cloudflared_executable_sha256,
|
|
"--cloudflared-cwd",
|
|
args.cloudflared_cwd,
|
|
]
|
|
return Leg("topology", argv, output)
|
|
|
|
|
|
ADMIN_RUNTIME_PATH = "/admin/voice-runtime"
|
|
# Cloudflare 앞단은 자동화 클라이언트에 error code 1010 으로 **모든 경로**에 403 을 준다.
|
|
# 그래서 미인증 상태 코드로는 "endpoint 없음"과 "edge 차단"을 구분할 수 없다.
|
|
# 배포 여부는 OpenAPI spec 의 paths 로만 판정한다.
|
|
_BROWSER_UA = (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/140.0 Safari/537.36"
|
|
)
|
|
|
|
|
|
def assert_admin_endpoint_deployed(
|
|
url: str, *, paths_probe: Callable[[str], Sequence[str]] | None = None
|
|
) -> str:
|
|
"""관리자 runtime endpoint 가 그 배포에 실제로 존재하는지 먼저 본다.
|
|
|
|
2026-08-08 확인: 공개 런타임은 119 paths 구버전이라 `/admin/voice-runtime` 이
|
|
아예 없었다. 이 상태로 50분 창을 돌리면 마이크와 사람을 다 준비하고도 artifact 2
|
|
에서 실패한다. 그래서 시작 전에 막는다.
|
|
|
|
미인증 HTTP 상태로 판정하지 않는다. Cloudflare 가 자동화 클라이언트에 모든 경로를
|
|
403(error code 1010)으로 돌려주므로 그 신호로는 배포 누락을 알 수 없다. OpenAPI
|
|
spec 의 `paths` 만 authoritative 하게 쓴다.
|
|
"""
|
|
|
|
from urllib.parse import urlsplit
|
|
|
|
parsed = urlsplit(url)
|
|
if parsed.path.rstrip("/") != ADMIN_RUNTIME_PATH:
|
|
raise WindowError("admin_runtime_url_unexpected_path")
|
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
paths = (paths_probe or _openapi_paths)(origin)
|
|
if ADMIN_RUNTIME_PATH not in set(paths):
|
|
raise WindowError("admin_voice_runtime_not_deployed")
|
|
return origin
|
|
|
|
|
|
def _openapi_paths(origin: str) -> Sequence[str]: # pragma: no cover - 네트워크 I/O
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
request = urllib.request.Request(
|
|
f"{origin}/openapi.json",
|
|
headers={"User-Agent": _BROWSER_UA, "Accept": "application/json"},
|
|
method="GET",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
document = json.loads(response.read(8 * 1024 * 1024))
|
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
raise WindowError("openapi_unreachable") from exc
|
|
paths = document.get("paths") if isinstance(document, dict) else None
|
|
if not isinstance(paths, dict):
|
|
raise WindowError("openapi_shape_invalid")
|
|
return list(paths)
|
|
|
|
|
|
def plan_legs(
|
|
args: argparse.Namespace,
|
|
out_dir: Path,
|
|
*,
|
|
admin_probe: Callable[[str], Sequence[str]] | None = None,
|
|
) -> list[Leg]:
|
|
assert_transport_schemes(args.wss_url, args.admin_runtime_url)
|
|
assert_single_host(args.wss_url, args.admin_runtime_url)
|
|
assert_browser_origin_allowed(args.origin, args.allowed_browser_origins)
|
|
assert_admin_endpoint_deployed(args.admin_runtime_url, paths_probe=admin_probe)
|
|
return [
|
|
build_soak_leg(args, out_dir / "voice-soak.json"),
|
|
build_runtime_leg(args, out_dir / "runtime.json"),
|
|
build_topology_leg(args, out_dir / "topology.json"),
|
|
]
|
|
|
|
|
|
def _run(leg: Leg, *, timeout: float, runner: Callable[..., Any]) -> LegResult:
|
|
completed = runner(
|
|
leg.argv,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=timeout,
|
|
shell=False,
|
|
)
|
|
return LegResult(leg.name, int(completed.returncode), leg.output)
|
|
|
|
|
|
def run_window(
|
|
legs: Sequence[Leg],
|
|
*,
|
|
timeout: float,
|
|
runner: Callable[..., Any] | None = None,
|
|
) -> list[LegResult]:
|
|
"""세 캡처를 동시에 시작한다. 하나가 죽어도 나머지를 끝까지 돌린다."""
|
|
|
|
call = runner or subprocess.run
|
|
with ThreadPoolExecutor(max_workers=len(legs)) as pool:
|
|
futures = [
|
|
pool.submit(_run, leg, timeout=timeout, runner=call) for leg in legs
|
|
]
|
|
return [future.result() for future in futures]
|
|
|
|
|
|
def build_checker_argv(
|
|
results: Sequence[LegResult], human_pack: Path
|
|
) -> list[str]:
|
|
by_name = {result.name: result.output for result in results}
|
|
soak = by_name.get("voice_soak") or by_name.get("voice_soak_preflight")
|
|
if soak is None:
|
|
raise WindowError("soak_evidence_missing")
|
|
return [
|
|
sys.executable,
|
|
"-X",
|
|
"utf8",
|
|
str(CHECKER_SCRIPT),
|
|
"--voice-soak",
|
|
str(soak),
|
|
"--runtime",
|
|
str(by_name["runtime"]),
|
|
"--topology",
|
|
str(by_name["topology"]),
|
|
"--human-voice-gain",
|
|
str(human_pack),
|
|
"--json",
|
|
]
|
|
|
|
|
|
def summarize(
|
|
results: Sequence[LegResult],
|
|
*,
|
|
rehearse: bool,
|
|
checker_returncode: int | None,
|
|
) -> dict[str, Any]:
|
|
all_legs_passed = all(r.ok for r in results)
|
|
gate_closed = (
|
|
all_legs_passed and checker_returncode == 0 and not rehearse
|
|
)
|
|
return {
|
|
"schema_version": "vignette.g7-external-proof-window.v1",
|
|
"mode": "rehearse" if rehearse else "production",
|
|
"legs": [
|
|
{"name": r.name, "returncode": r.returncode, "evidence": str(r.output)}
|
|
for r in results
|
|
],
|
|
"all_legs_passed": all_legs_passed,
|
|
"checker_returncode": checker_returncode,
|
|
"gate_closed": gate_closed,
|
|
"note": (
|
|
"rehearse 는 배관만 확인한다. 게이트는 실제 물리 마이크 50분 실행과 "
|
|
"독립 human voice-gain pack 이 있어야 닫힌다."
|
|
if rehearse
|
|
else "네 artifact 가 모두 통과해야 게이트가 닫힌다."
|
|
),
|
|
}
|
|
|
|
|
|
def process_exit_code(report: dict[str, Any]) -> int:
|
|
"""프로세스 성공을 실제 gate closure 에 fail-closed 결속한다."""
|
|
|
|
if report.get("mode") == "production":
|
|
return 0 if (
|
|
report.get("checker_returncode") == 0
|
|
and report.get("gate_closed") is True
|
|
) else 1
|
|
return 0 if report.get("all_legs_passed") is True else 1
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=(
|
|
"공개 기본값:\n"
|
|
" browser Origin https://vignette.chanpaca.net\n"
|
|
" WSS transport wss://api-vignette.chanpaca.net/voice/ws\n"
|
|
" admin transport https://api-vignette.chanpaca.net/admin/voice-runtime\n"
|
|
"인증 WSS ready 기대값:\n"
|
|
" stt_provider=local_whisper stt_model=small\n"
|
|
" tts_provider=melotts tts_model=melotts-korean\n"
|
|
"실제 캡처 기본값: 3120초(유효 교집합 최소 3000초 + skew margin 120초)\n"
|
|
"추가 frontend Origin 은 --allowed-browser-origin 을 반복 지정한다."
|
|
),
|
|
)
|
|
parser.add_argument("--wss-url", default=DEFAULT_WSS_URL)
|
|
parser.add_argument(
|
|
"--origin",
|
|
default=DEFAULT_BROWSER_ORIGIN,
|
|
help="WebSocket Origin 헤더에 넣을 실제 브라우저 HTTPS origin",
|
|
)
|
|
parser.add_argument(
|
|
"--allowed-browser-origin",
|
|
dest="allowed_browser_origins",
|
|
action="append",
|
|
help=(
|
|
"허용할 브라우저 HTTPS origin. 반복 가능하며 생략 시 공개 frontend만 허용"
|
|
),
|
|
)
|
|
parser.add_argument("--admin-runtime-url", default=DEFAULT_ADMIN_RUNTIME_URL)
|
|
parser.add_argument(
|
|
"--topology-mode",
|
|
choices=("linux-compose", "windows-host"),
|
|
default="linux-compose",
|
|
)
|
|
parser.add_argument("--compose-project")
|
|
parser.add_argument("--api-container")
|
|
parser.add_argument("--api-image-digest")
|
|
parser.add_argument("--caddy-container")
|
|
parser.add_argument("--caddy-image-digest")
|
|
parser.add_argument("--repo-root")
|
|
parser.add_argument("--git-sha")
|
|
parser.add_argument("--git-tree-sha")
|
|
parser.add_argument("--runner-script-sha256")
|
|
parser.add_argument("--collector-script-sha256")
|
|
parser.add_argument("--checker-script-sha256")
|
|
parser.add_argument("--psutil-version")
|
|
parser.add_argument("--api-pid", type=int)
|
|
parser.add_argument("--api-executable-name")
|
|
parser.add_argument("--api-executable-sha256")
|
|
parser.add_argument("--api-cwd")
|
|
parser.add_argument("--api-listen-port", type=int)
|
|
parser.add_argument("--cloudflared-pid", type=int)
|
|
parser.add_argument("--cloudflared-executable-name")
|
|
parser.add_argument("--cloudflared-executable-sha256")
|
|
parser.add_argument("--cloudflared-cwd")
|
|
parser.add_argument("--expected-stt-provider", default="local_whisper")
|
|
parser.add_argument("--expected-stt-model", default="small")
|
|
parser.add_argument("--expected-tts-provider", default="melotts")
|
|
parser.add_argument("--expected-tts-model", default="melotts-korean")
|
|
parser.add_argument("--microphone-device", default="")
|
|
parser.add_argument("--confirm-physical-capture", action="store_true")
|
|
parser.add_argument(
|
|
"--duration-seconds",
|
|
type=float,
|
|
default=MIN_PRODUCTION_SECONDS,
|
|
help="실제 캡처는 최소 3120초; 유효 artifact 교집합 기준은 3000초",
|
|
)
|
|
parser.add_argument("--runtime-interval-seconds", type=float, default=100.0)
|
|
parser.add_argument("--topology-interval-seconds", type=float, default=100.0)
|
|
parser.add_argument("--human-voice-gain", type=Path)
|
|
parser.add_argument("--out-dir", type=Path, required=True)
|
|
parser.add_argument(
|
|
"--rehearse",
|
|
action="store_true",
|
|
help="마이크 없이 배관만 확인한다. 게이트를 닫지 않는다.",
|
|
)
|
|
return parser
|
|
|
|
|
|
def validate(args: argparse.Namespace) -> None:
|
|
if not args.rehearse and args.duration_seconds < MIN_PRODUCTION_SECONDS:
|
|
raise WindowError("production_window_too_short")
|
|
if not args.rehearse and args.human_voice_gain is None:
|
|
raise WindowError("human_voice_gain_pack_required")
|
|
if args.topology_mode not in ("linux-compose", "windows-host"):
|
|
raise WindowError("topology_mode_invalid")
|
|
if args.topology_mode == "linux-compose":
|
|
required = (
|
|
"compose_project",
|
|
"api_container",
|
|
"api_image_digest",
|
|
"caddy_container",
|
|
"caddy_image_digest",
|
|
)
|
|
else:
|
|
required = (
|
|
"repo_root",
|
|
"git_sha",
|
|
"git_tree_sha",
|
|
"runner_script_sha256",
|
|
"collector_script_sha256",
|
|
"checker_script_sha256",
|
|
"psutil_version",
|
|
"api_pid",
|
|
"api_executable_name",
|
|
"api_executable_sha256",
|
|
"api_cwd",
|
|
"api_listen_port",
|
|
"cloudflared_pid",
|
|
"cloudflared_executable_name",
|
|
"cloudflared_executable_sha256",
|
|
"cloudflared_cwd",
|
|
)
|
|
for field in required:
|
|
value = getattr(args, field)
|
|
if value is None or (isinstance(value, str) and not value.strip()):
|
|
raise WindowError(f"topology_argument_required:{field.replace('_', '-')}")
|
|
|
|
|
|
def main(argv: Iterable[str] | None = None) -> int:
|
|
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
|
try:
|
|
validate(args)
|
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
legs = plan_legs(args, args.out_dir)
|
|
except WindowError as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
|
return 2
|
|
|
|
results = run_window(legs, timeout=args.duration_seconds + 900)
|
|
|
|
checker_returncode: int | None = None
|
|
if all(result.ok for result in results) and args.human_voice_gain is not None:
|
|
checker = subprocess.run(
|
|
build_checker_argv(results, args.human_voice_gain),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
shell=False,
|
|
)
|
|
checker_returncode = checker.returncode
|
|
|
|
report = summarize(
|
|
results, rehearse=args.rehearse, checker_returncode=checker_returncode
|
|
)
|
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
return process_exit_code(report)
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - CLI
|
|
raise SystemExit(main())
|