G7 증명과 G8 clean-head 승격 준비

This commit is contained in:
Yun Chan 2026-08-09 18:22:03 +09:00
parent 94c681d450
commit 5221f79e3f
52 changed files with 6876 additions and 506 deletions

View file

@ -8,7 +8,9 @@
스크립트가 하는 :
1. soak / runtime / topology 캡처를 **동시에** 시작하고 전부 끝날 때까지 기다린다.
2. 캡처가 같은 public host 보는지 시작 전에 확인한다(fail-closed).
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`
@ -16,14 +18,20 @@
실제 참가자와 독립 평가자가 있어야 한다.
`--rehearse` 마이크·사람 없이 배관만 확인하는 모드다. 짧은 시간창으로 러너를 실제로 띄워
인자·경로·산출 파일까지 검증하되, soak `--preflight-only` 돌려 장치를 열지 않는다. 실제 50
실행 전에 이걸로 먼저 실패를 뽑아내라.
인자·경로·산출 파일까지 검증하되, 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
@ -40,7 +48,20 @@ RUNTIME_SCRIPT = SCRIPTS / "capture-g7-runtime-evidence.py"
TOPOLOGY_SCRIPT = SCRIPTS / "capture-g7-topology-evidence.py"
CHECKER_SCRIPT = SCRIPTS / "check-g7-external-proof.py"
MIN_PRODUCTION_SECONDS = 3_000.0
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
@ -73,21 +94,82 @@ def host_of(url: str) -> str:
def assert_single_host(*urls: str) -> str:
"""세 캡처가 같은 public host 를 보는지 확인한다."""
"""transport URL 들이 같은 public host 를 보는지 확인한다."""
hosts = {host_of(url) for url in urls if url}
hosts.discard("")
if len(hosts) != 1:
raise WindowError(f"hosts_must_match:{sorted(hosts)}")
# 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")
return int(duration_seconds // interval_seconds) + RUNTIME_SAMPLE_MARGIN
# t=0 샘플과 duration 끝을 덮는 종료 샘플을 모두 포함한다.
return math.ceil(duration_seconds / interval_seconds) + RUNTIME_SAMPLE_MARGIN
def build_soak_leg(args: argparse.Namespace, output: Path) -> Leg:
@ -153,18 +235,10 @@ def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg:
"-X",
"utf8",
str(TOPOLOGY_SCRIPT),
"--compose-project",
args.compose_project,
"--topology-mode",
args.topology_mode,
"--public-host",
host_of(args.wss_url),
"--api-container",
args.api_container,
"--api-image-digest",
args.api_image_digest,
"--caddy-container",
args.caddy_container,
"--caddy-image-digest",
args.caddy_image_digest,
"--samples",
str(samples),
"--interval-seconds",
@ -172,6 +246,54 @@ def build_topology_leg(args: argparse.Namespace, output: Path) -> Leg:
"--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)
@ -237,7 +359,9 @@ def plan_legs(
*,
admin_probe: Callable[[str], Sequence[str]] | None = None,
) -> list[Leg]:
assert_single_host(args.wss_url, args.origin, args.admin_runtime_url)
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"),
@ -306,6 +430,10 @@ def summarize(
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",
@ -313,9 +441,9 @@ def summarize(
{"name": r.name, "returncode": r.returncode, "evidence": str(r.output)}
for r in results
],
"all_legs_passed": all(r.ok for r in results),
"all_legs_passed": all_legs_passed,
"checker_returncode": checker_returncode,
"gate_closed": checker_returncode == 0 and not rehearse,
"gate_closed": gate_closed,
"note": (
"rehearse 는 배관만 확인한다. 게이트는 실제 물리 마이크 50분 실행과 "
"독립 human voice-gain pack 이 있어야 닫힌다."
@ -325,23 +453,86 @@ def summarize(
}
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__)
parser.add_argument("--wss-url", required=True)
parser.add_argument("--origin", required=True)
parser.add_argument("--admin-runtime-url", required=True)
parser.add_argument("--compose-project", required=True)
parser.add_argument("--api-container", required=True)
parser.add_argument("--api-image-digest", required=True)
parser.add_argument("--caddy-container", required=True)
parser.add_argument("--caddy-image-digest", required=True)
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="large-v3")
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)
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)
@ -359,6 +550,39 @@ def validate(args: argparse.Namespace) -> None:
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:
@ -390,7 +614,7 @@ def main(argv: Iterable[str] | None = None) -> int:
results, rehearse=args.rehearse, checker_returncode=checker_returncode
)
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0 if report["all_legs_passed"] else 1
return process_exit_code(report)
if __name__ == "__main__": # pragma: no cover - CLI