G7 증명과 G8 clean-head 승격 준비
This commit is contained in:
parent
94c681d450
commit
5221f79e3f
52 changed files with 6876 additions and 506 deletions
|
|
@ -5,22 +5,121 @@
|
|||
# start-public-runtime.ps1(-SkipWebRestart) 로 필요한 프로세스만 복구한다.
|
||||
# 멱등: 어느 단계든 이미 살아있으면 건드리지 않는다. 수동으로 여러 번 실행해도 안전.
|
||||
#
|
||||
# 등록(로그온 시 자동 실행, 숨김 창):
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1
|
||||
# 또는 수동:
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\boot-public-runtime.ps1
|
||||
# 등록(로그온 시 자동 실행, 숨김 창)은 register-boot-task.ps1가 생성하는
|
||||
# commit/tree/script SHA pin 인자를 사용한다. 핀 없는 직접 실행은 fail-closed한다.
|
||||
|
||||
param(
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StableSourceRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedSourceCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedSourceTree,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedBootScriptSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedStartScriptSha256,
|
||||
[string]$DockerDesktop = "C:\Program Files\Docker\Docker\Docker Desktop.exe",
|
||||
[int]$DaemonTimeoutSec = 360,
|
||||
[int]$DbTimeoutSec = 90,
|
||||
[int]$DbPort = 55432,
|
||||
[int]$ApiPort = 8001,
|
||||
[string]$BootLog = "D:\workspace\vignette\boot-public-runtime.log"
|
||||
[string]$BootLog = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Continue" # 부트 스크립트는 끝까지 로깅하고 종료한다.
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
|
||||
$expectedBootScript = Join-Path $resolvedSourceRoot "scripts\boot-public-runtime.ps1"
|
||||
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
|
||||
|
||||
function Invoke-GitText {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$value = & git.exe -C $resolvedSourceRoot @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
|
||||
}
|
||||
return (@($value) -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
function Assert-StableSourceProvenance {
|
||||
foreach ($requiredScript in @($expectedBootScript, $startScript)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredScript -PathType Leaf)) {
|
||||
throw "Pinned public runtime script not found at $requiredScript"
|
||||
}
|
||||
}
|
||||
|
||||
$runningBootScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
if (-not [string]::Equals(
|
||||
$runningBootScript,
|
||||
(Resolve-Path -LiteralPath $expectedBootScript).Path,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Boot recovery is not executing from the pinned stable source root"
|
||||
}
|
||||
|
||||
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
|
||||
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
|
||||
if (-not [string]::Equals(
|
||||
$resolvedGitRoot,
|
||||
$resolvedSourceRoot,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Stable source root does not match its Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
|
||||
$symbolicHeadExit = $LASTEXITCODE
|
||||
if ($symbolicHeadExit -eq 0) {
|
||||
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
|
||||
}
|
||||
if ($symbolicHeadExit -ne 1) {
|
||||
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
|
||||
}
|
||||
|
||||
$actualCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
|
||||
$actualTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
|
||||
if ($actualCommit -ne $ExpectedSourceCommit.ToLowerInvariant()) {
|
||||
throw "Stable source commit drift: expected=$ExpectedSourceCommit actual=$actualCommit"
|
||||
}
|
||||
if ($actualTree -ne $ExpectedSourceTree.ToLowerInvariant()) {
|
||||
throw "Stable source tree drift: expected=$ExpectedSourceTree actual=$actualTree"
|
||||
}
|
||||
|
||||
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
|
||||
if ($dirty) {
|
||||
throw "Stable source is not clean; refusing boot recovery"
|
||||
}
|
||||
foreach ($relativePath in @(
|
||||
"scripts/boot-public-runtime.ps1",
|
||||
"scripts/start-public-runtime.ps1"
|
||||
)) {
|
||||
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
|
||||
}
|
||||
|
||||
$actualBootScriptSha256 = (Get-FileHash -LiteralPath $expectedBootScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actualStartScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualBootScriptSha256 -ne $ExpectedBootScriptSha256.ToLowerInvariant()) {
|
||||
throw "Pinned boot script SHA256 drift"
|
||||
}
|
||||
if ($actualStartScriptSha256 -ne $ExpectedStartScriptSha256.ToLowerInvariant()) {
|
||||
throw "Pinned start script SHA256 drift"
|
||||
}
|
||||
}
|
||||
|
||||
# Docker/DB/process mutation보다 먼저 stable source를 매 실행 재검증한다.
|
||||
Assert-StableSourceProvenance
|
||||
|
||||
if (!$BootLog) {
|
||||
$BootLog = Join-Path $resolvedSourceRoot "boot-public-runtime.log"
|
||||
}
|
||||
|
||||
$ErrorActionPreference = "Continue" # provenance 이후 부트 복구는 끝까지 로깅한다.
|
||||
|
||||
function Write-BootLog([string]$Message) {
|
||||
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
|
||||
|
|
@ -121,13 +220,10 @@ Write-BootLog "postgres 127.0.0.1:$DbPort up"
|
|||
if ((Test-ApiControlPlaneHealthy) -and (Test-EngineHealthy)) {
|
||||
Write-BootLog "control plane and engine already healthy; skipping runtime restart"
|
||||
} else {
|
||||
$pub = Join-Path $Workspace "scripts\start-public-runtime.ps1"
|
||||
if (-not (Test-Path -LiteralPath $pub)) {
|
||||
Write-BootLog "ERROR: start-public-runtime.ps1 not found at $pub"
|
||||
exit 1
|
||||
}
|
||||
Write-BootLog "running start-public-runtime.ps1 -SkipWebRestart"
|
||||
$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $pub -SkipWebRestart 2>&1
|
||||
$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript `
|
||||
-Workspace $resolvedSourceRoot `
|
||||
-SkipWebRestart 2>&1
|
||||
$out | ForEach-Object { Write-BootLog (" pub> " + $_) }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-BootLog "ERROR: start-public-runtime.ps1 exit $LASTEXITCODE"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,8 +23,8 @@ DEFAULT_LOCAL_DEVELOPMENT = REPO_ROOT / "docs" / "guides" / "local-development.m
|
|||
DEFAULT_TODO = REPO_ROOT / "docs" / "TODO.md"
|
||||
|
||||
EXPECTED_STATUS_COUNTS = {
|
||||
"done": 33,
|
||||
"doing": 2,
|
||||
"done": 32,
|
||||
"doing": 3,
|
||||
"planned": 0,
|
||||
}
|
||||
EXPECTED_OWNER_COLUMN_COUNTS = {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
"""Fail-closed validator for the complete G7 external evidence gate.
|
||||
|
||||
All four artifacts are required: authenticated public physical-microphone soak,
|
||||
process-local voice runtime sampling, pinned Linux topology sampling, and an
|
||||
independent human-held-out voice-gain pack. No artifact may contain raw audio or
|
||||
transcripts, and synthetic evidence cannot satisfy this gate.
|
||||
process-local voice runtime sampling, pinned deployment topology sampling, and
|
||||
an independent human-held-out voice-gain pack. No artifact may contain raw audio
|
||||
or transcripts, and synthetic evidence cannot satisfy this gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -13,6 +13,7 @@ import argparse
|
|||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import ntpath
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
|
@ -29,6 +30,7 @@ from app.services.g7_voice_gain_evidence import evaluate_human_voice_gain # noq
|
|||
|
||||
|
||||
MINIMUM_SOAK_SECONDS = 3_000.0
|
||||
PINNED_PSUTIL_VERSION = "6.1.1"
|
||||
|
||||
# 이 게이트는 특정 벤더가 아니라 **운영하기로 결정한 provider** 를 강제한다.
|
||||
# 2026-08-08 소유자 결정: STT 는 노트북 상주 faster-whisper(`local_whisper`),
|
||||
|
|
@ -82,6 +84,15 @@ def _iso(value: object) -> datetime | None:
|
|||
return parsed.astimezone(UTC)
|
||||
|
||||
|
||||
def _lower_hex(value: object, lengths: set[int]) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) in lengths
|
||||
and value == value.lower()
|
||||
and all(character in "0123456789abcdef" for character in value)
|
||||
)
|
||||
|
||||
|
||||
def validate_public_soak(payload: dict[str, Any], errors: list[str]) -> None:
|
||||
prefix = "voice_soak"
|
||||
_require(
|
||||
|
|
@ -327,57 +338,11 @@ def validate_runtime(payload: dict[str, Any], errors: list[str]) -> None:
|
|||
_require(errors, counters.get(field) == 0, f"{prefix}:{field}")
|
||||
|
||||
|
||||
def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
|
||||
def _validate_linux_compose_topology(
|
||||
payload: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
prefix = "topology"
|
||||
_require(
|
||||
errors,
|
||||
payload.get("schema_version") == "vignette.g7-topology-evidence.v1",
|
||||
f"{prefix}:schema",
|
||||
)
|
||||
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
|
||||
scope = payload.get("scope")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(scope, dict) and scope.get("metadata_only") is True,
|
||||
f"{prefix}:privacy",
|
||||
)
|
||||
if isinstance(scope, dict):
|
||||
for field in (
|
||||
"raw_command_output_retained",
|
||||
"socket_endpoints_retained",
|
||||
"request_payloads_retained",
|
||||
"audio_retained",
|
||||
"transcripts_retained",
|
||||
):
|
||||
_require(errors, scope.get(field) is False, f"{prefix}:{field}")
|
||||
edge = scope.get("cloudflare_edge")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(edge, dict)
|
||||
and edge.get("internal_queue_measured") is False
|
||||
and edge.get("evidence_boundary") == "separate_external_artifact_required",
|
||||
f"{prefix}:edge_boundary",
|
||||
)
|
||||
requested = payload.get("requested")
|
||||
completed = payload.get("samples_completed")
|
||||
_require(errors, isinstance(requested, dict), f"{prefix}:requested")
|
||||
if isinstance(requested, dict):
|
||||
sample_count = requested.get("samples")
|
||||
interval = _number(requested.get("interval_seconds"))
|
||||
_require(
|
||||
errors,
|
||||
isinstance(sample_count, int)
|
||||
and sample_count == completed
|
||||
and sample_count > 1,
|
||||
f"{prefix}:sample_count",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
interval is not None
|
||||
and isinstance(completed, int)
|
||||
and (completed - 1) * interval >= MINIMUM_SOAK_SECONDS,
|
||||
f"{prefix}:coverage",
|
||||
)
|
||||
targets = payload.get("targets")
|
||||
_require(
|
||||
errors,
|
||||
|
|
@ -442,6 +407,499 @@ def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
|
|||
and (_number(host_tcp.get("connections_max")) or 0) > 0,
|
||||
f"{prefix}:host_tcp",
|
||||
)
|
||||
|
||||
|
||||
def _validate_windows_topology(
|
||||
payload: dict[str, Any],
|
||||
errors: list[str],
|
||||
) -> None:
|
||||
prefix = "topology"
|
||||
requested = payload.get("requested")
|
||||
_require(errors, isinstance(requested, dict), f"{prefix}:windows_requested")
|
||||
if not isinstance(requested, dict):
|
||||
return
|
||||
expected_git_sha = requested.get("git_sha")
|
||||
_require(
|
||||
errors,
|
||||
_lower_hex(expected_git_sha, {40, 64}),
|
||||
f"{prefix}:windows_git_sha",
|
||||
)
|
||||
source_pin = requested.get("source_pin")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(source_pin, dict),
|
||||
f"{prefix}:windows_source_pin",
|
||||
)
|
||||
expected_tree_sha = (
|
||||
source_pin.get("git_tree_sha") if isinstance(source_pin, dict) else None
|
||||
)
|
||||
expected_scripts = (
|
||||
source_pin.get("script_sha256") if isinstance(source_pin, dict) else None
|
||||
)
|
||||
expected_dependencies = (
|
||||
source_pin.get("runtime_dependencies") if isinstance(source_pin, dict) else None
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
_lower_hex(expected_tree_sha, {40, 64}),
|
||||
f"{prefix}:windows_git_tree_sha_pin",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(expected_scripts, dict)
|
||||
and set(expected_scripts) == {"runner", "collector", "checker"}
|
||||
and all(_lower_hex(value, {64}) for value in expected_scripts.values()),
|
||||
f"{prefix}:windows_script_sha256_pin",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(expected_dependencies, dict)
|
||||
and expected_dependencies == {"psutil": PINNED_PSUTIL_VERSION},
|
||||
f"{prefix}:windows_psutil_version_pin",
|
||||
)
|
||||
|
||||
source_provenance = payload.get("source_provenance")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(source_provenance, dict),
|
||||
f"{prefix}:windows_source_provenance",
|
||||
)
|
||||
if isinstance(source_provenance, dict):
|
||||
observed_scripts = source_provenance.get("script_sha256")
|
||||
observed_dependencies = source_provenance.get("runtime_dependencies")
|
||||
_require(
|
||||
errors,
|
||||
source_provenance.get("detached_head") is True,
|
||||
f"{prefix}:windows_detached_head",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
source_provenance.get("tracked_clean") is True,
|
||||
f"{prefix}:windows_tracked_clean",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
source_provenance.get("git_sha") == expected_git_sha,
|
||||
f"{prefix}:windows_source_git_sha",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
source_provenance.get("git_tree_sha") == expected_tree_sha,
|
||||
f"{prefix}:windows_source_git_tree_sha",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(observed_scripts, dict)
|
||||
and isinstance(expected_scripts, dict)
|
||||
and observed_scripts == expected_scripts,
|
||||
f"{prefix}:windows_source_script_sha256",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(observed_dependencies, dict)
|
||||
and isinstance(expected_dependencies, dict)
|
||||
and observed_dependencies == expected_dependencies,
|
||||
f"{prefix}:windows_source_psutil_version",
|
||||
)
|
||||
repo_root = requested.get("repo_root")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(repo_root, str) and ntpath.isabs(repo_root),
|
||||
f"{prefix}:windows_repo_root",
|
||||
)
|
||||
api_listen_port = requested.get("api_listen_port")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(api_listen_port, int)
|
||||
and not isinstance(api_listen_port, bool)
|
||||
and 1 <= api_listen_port <= 65_535,
|
||||
f"{prefix}:windows_api_listen_port",
|
||||
)
|
||||
requested_roles = requested.get("roles")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(requested_roles, dict)
|
||||
and set(requested_roles) == {"api", "cloudflared"},
|
||||
f"{prefix}:windows_requested_roles",
|
||||
)
|
||||
targets = payload.get("targets")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(targets, dict) and set(targets) == {"api", "cloudflared"},
|
||||
f"{prefix}:targets",
|
||||
)
|
||||
capture_started = _iso(payload.get("started_at_utc"))
|
||||
target_pids: list[int] = []
|
||||
if isinstance(targets, dict) and isinstance(requested_roles, dict):
|
||||
for role in ("api", "cloudflared"):
|
||||
target = targets.get(role)
|
||||
expectation = requested_roles.get(role)
|
||||
_require(errors, isinstance(target, dict), f"{prefix}:{role}_target")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(expectation, dict),
|
||||
f"{prefix}:{role}_expectation",
|
||||
)
|
||||
if not isinstance(target, dict) or not isinstance(expectation, dict):
|
||||
continue
|
||||
_require(
|
||||
errors,
|
||||
target.get("role") == role,
|
||||
f"{prefix}:{role}_role",
|
||||
)
|
||||
pid = target.get("pid")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(pid, int) and not isinstance(pid, bool) and pid > 0,
|
||||
f"{prefix}:{role}_pid",
|
||||
)
|
||||
if isinstance(pid, int) and not isinstance(pid, bool):
|
||||
target_pids.append(pid)
|
||||
_require(
|
||||
errors,
|
||||
pid == expectation.get("pid"),
|
||||
f"{prefix}:{role}_pid_pin",
|
||||
)
|
||||
executable_sha256 = target.get("executable_sha256")
|
||||
_require(
|
||||
errors,
|
||||
_lower_hex(executable_sha256, {64})
|
||||
and executable_sha256 == expectation.get("expected_executable_sha256"),
|
||||
f"{prefix}:{role}_executable_sha256",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
_lower_hex(target.get("command_line_sha256"), {64}),
|
||||
f"{prefix}:{role}_command_line_sha256",
|
||||
)
|
||||
executable_name = target.get("executable_name")
|
||||
expected_name = expectation.get("expected_executable_name")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(executable_name, str)
|
||||
and isinstance(expected_name, str)
|
||||
and executable_name.casefold() == expected_name.casefold(),
|
||||
f"{prefix}:{role}_executable_name",
|
||||
)
|
||||
cwd = target.get("cwd")
|
||||
expected_cwd = expectation.get("expected_cwd")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(cwd, str)
|
||||
and isinstance(expected_cwd, str)
|
||||
and ntpath.normcase(ntpath.normpath(cwd))
|
||||
== ntpath.normcase(ntpath.normpath(expected_cwd)),
|
||||
f"{prefix}:{role}_cwd",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
target.get("git_sha") == expected_git_sha,
|
||||
f"{prefix}:{role}_git_sha",
|
||||
)
|
||||
target_started = _iso(target.get("started_at"))
|
||||
_require(
|
||||
errors,
|
||||
capture_started is not None
|
||||
and target_started is not None
|
||||
and target_started <= capture_started,
|
||||
f"{prefix}:{role}_process_start",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
len(target_pids) == 2 and len(set(target_pids)) == 2,
|
||||
f"{prefix}:windows_distinct_pids",
|
||||
)
|
||||
|
||||
completed = payload.get("samples_completed")
|
||||
samples = payload.get("samples")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(samples, list)
|
||||
and isinstance(completed, int)
|
||||
and len(samples) == completed,
|
||||
f"{prefix}:windows_samples",
|
||||
)
|
||||
capture_ended = _iso(payload.get("ended_at_utc"))
|
||||
metric_values: dict[str, dict[str, list[float]]] = {
|
||||
role: {
|
||||
field: []
|
||||
for field in (
|
||||
"rss_bytes",
|
||||
"peak_rss_bytes",
|
||||
"cpu_time_seconds",
|
||||
"cpu_percent",
|
||||
"handles",
|
||||
"threads",
|
||||
)
|
||||
}
|
||||
for role in ("api", "cloudflared")
|
||||
}
|
||||
tcp_values: dict[str, dict[str, list[int]]] = {
|
||||
role: {field: [] for field in ("connections", "established", "listeners")}
|
||||
for role in ("api", "cloudflared")
|
||||
}
|
||||
listener_owned: list[int] = []
|
||||
listener_conflicts: list[int] = []
|
||||
host_connections: list[int] = []
|
||||
if isinstance(samples, list):
|
||||
previous_cpu: dict[str, float] = {}
|
||||
for index, sample in enumerate(samples, start=1):
|
||||
if not isinstance(sample, dict):
|
||||
errors.append(f"{prefix}:windows_sample_shape")
|
||||
continue
|
||||
_require(
|
||||
errors,
|
||||
sample.get("sequence") == index,
|
||||
f"{prefix}:windows_sample_sequence",
|
||||
)
|
||||
observed_at = _iso(sample.get("observed_at_utc"))
|
||||
_require(
|
||||
errors,
|
||||
capture_started is not None
|
||||
and capture_ended is not None
|
||||
and observed_at is not None
|
||||
and capture_started <= observed_at <= capture_ended,
|
||||
f"{prefix}:windows_sample_time",
|
||||
)
|
||||
processes = sample.get("processes")
|
||||
process_tcp = sample.get("process_tcp")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(processes, dict)
|
||||
and set(processes) == {"api", "cloudflared"},
|
||||
f"{prefix}:windows_sample_processes",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(process_tcp, dict)
|
||||
and set(process_tcp) == {"api", "cloudflared"},
|
||||
f"{prefix}:windows_sample_process_tcp",
|
||||
)
|
||||
for role in ("api", "cloudflared"):
|
||||
metrics = processes.get(role) if isinstance(processes, dict) else None
|
||||
tcp = process_tcp.get(role) if isinstance(process_tcp, dict) else None
|
||||
_require(
|
||||
errors,
|
||||
isinstance(metrics, dict),
|
||||
f"{prefix}:{role}_sample_metrics",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(tcp, dict),
|
||||
f"{prefix}:{role}_sample_tcp",
|
||||
)
|
||||
if isinstance(metrics, dict):
|
||||
for field in metric_values[role]:
|
||||
numeric = _number(metrics.get(field))
|
||||
positive = field != "cpu_percent"
|
||||
_require(
|
||||
errors,
|
||||
numeric is not None
|
||||
and numeric >= 0
|
||||
and (not positive or numeric > 0),
|
||||
f"{prefix}:{role}_{field}_sample",
|
||||
)
|
||||
if numeric is not None:
|
||||
metric_values[role][field].append(numeric)
|
||||
cpu_time = _number(metrics.get("cpu_time_seconds"))
|
||||
if cpu_time is not None:
|
||||
_require(
|
||||
errors,
|
||||
cpu_time >= previous_cpu.get(role, 0.0),
|
||||
f"{prefix}:{role}_cpu_time_monotonic",
|
||||
)
|
||||
previous_cpu[role] = cpu_time
|
||||
if isinstance(tcp, dict):
|
||||
for field in tcp_values[role]:
|
||||
value = tcp.get(field)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(value, int)
|
||||
and not isinstance(value, bool)
|
||||
and value >= 0,
|
||||
f"{prefix}:{role}_tcp_{field}_sample",
|
||||
)
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
tcp_values[role][field].append(value)
|
||||
if role == "api":
|
||||
_require(
|
||||
errors,
|
||||
(_number(tcp.get("listeners")) or 0) > 0,
|
||||
f"{prefix}:api_listener_sample",
|
||||
)
|
||||
else:
|
||||
_require(
|
||||
errors,
|
||||
(_number(tcp.get("established")) or 0) > 0,
|
||||
f"{prefix}:cloudflared_tunnel_sample",
|
||||
)
|
||||
listener = sample.get("api_listener")
|
||||
host_tcp = sample.get("host_tcp")
|
||||
_require(errors, isinstance(listener, dict), f"{prefix}:listener_sample")
|
||||
_require(errors, isinstance(host_tcp, dict), f"{prefix}:host_tcp_sample")
|
||||
if isinstance(listener, dict):
|
||||
owned = listener.get("owned_listener_count")
|
||||
conflicts = listener.get("conflicting_listener_count")
|
||||
_require(
|
||||
errors,
|
||||
listener.get("port") == api_listen_port,
|
||||
f"{prefix}:listener_port_sample",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(owned, int) and not isinstance(owned, bool) and owned > 0,
|
||||
f"{prefix}:listener_owner_sample",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(conflicts, int)
|
||||
and not isinstance(conflicts, bool)
|
||||
and conflicts == 0,
|
||||
f"{prefix}:listener_conflict_sample",
|
||||
)
|
||||
if isinstance(owned, int) and not isinstance(owned, bool):
|
||||
listener_owned.append(owned)
|
||||
if isinstance(conflicts, int) and not isinstance(conflicts, bool):
|
||||
listener_conflicts.append(conflicts)
|
||||
if isinstance(host_tcp, dict):
|
||||
connections = host_tcp.get("connections")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(connections, int)
|
||||
and not isinstance(connections, bool)
|
||||
and connections > 0,
|
||||
f"{prefix}:host_tcp_connections_sample",
|
||||
)
|
||||
if isinstance(connections, int) and not isinstance(connections, bool):
|
||||
host_connections.append(connections)
|
||||
|
||||
summary = payload.get("summary")
|
||||
_require(errors, isinstance(summary, dict), f"{prefix}:summary")
|
||||
if isinstance(summary, dict):
|
||||
process_summary = summary.get("processes")
|
||||
listener_summary = summary.get("api_listener")
|
||||
host_summary = summary.get("host_tcp")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(process_summary, dict)
|
||||
and set(process_summary) == {"api", "cloudflared"},
|
||||
f"{prefix}:windows_process_summary",
|
||||
)
|
||||
if isinstance(process_summary, dict):
|
||||
for role in ("api", "cloudflared"):
|
||||
values = process_summary.get(role)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(values, dict),
|
||||
f"{prefix}:{role}_summary",
|
||||
)
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
for field, observations in metric_values[role].items():
|
||||
_require(
|
||||
errors,
|
||||
bool(observations)
|
||||
and _number(values.get(f"{field}_max")) == max(observations),
|
||||
f"{prefix}:{role}_{field}_max",
|
||||
)
|
||||
for field, observations in tcp_values[role].items():
|
||||
_require(
|
||||
errors,
|
||||
bool(observations)
|
||||
and _number(values.get(f"tcp_{field}_max"))
|
||||
== max(observations),
|
||||
f"{prefix}:{role}_tcp_{field}_max",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(listener_summary, dict)
|
||||
and listener_summary.get("port") == api_listen_port
|
||||
and bool(listener_owned)
|
||||
and listener_summary.get("owned_listener_count_min")
|
||||
== min(listener_owned)
|
||||
and bool(listener_conflicts)
|
||||
and listener_summary.get("conflicting_listener_count_max")
|
||||
== max(listener_conflicts),
|
||||
f"{prefix}:windows_listener_summary",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
isinstance(host_summary, dict)
|
||||
and bool(host_connections)
|
||||
and host_summary.get("connections_max") == max(host_connections),
|
||||
f"{prefix}:host_tcp",
|
||||
)
|
||||
|
||||
|
||||
def validate_topology(payload: dict[str, Any], errors: list[str]) -> None:
|
||||
prefix = "topology"
|
||||
_require(
|
||||
errors,
|
||||
payload.get("schema_version") == "vignette.g7-topology-evidence.v1",
|
||||
f"{prefix}:schema",
|
||||
)
|
||||
_require(errors, payload.get("status") == "passed", f"{prefix}:status")
|
||||
scope = payload.get("scope")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(scope, dict) and scope.get("metadata_only") is True,
|
||||
f"{prefix}:privacy",
|
||||
)
|
||||
if isinstance(scope, dict):
|
||||
for field in (
|
||||
"raw_command_output_retained",
|
||||
"socket_endpoints_retained",
|
||||
"request_payloads_retained",
|
||||
"audio_retained",
|
||||
"transcripts_retained",
|
||||
):
|
||||
_require(errors, scope.get(field) is False, f"{prefix}:{field}")
|
||||
edge = scope.get("cloudflare_edge")
|
||||
_require(
|
||||
errors,
|
||||
isinstance(edge, dict)
|
||||
and edge.get("internal_queue_measured") is False
|
||||
and edge.get("evidence_boundary") == "separate_external_artifact_required",
|
||||
f"{prefix}:edge_boundary",
|
||||
)
|
||||
requested = payload.get("requested")
|
||||
completed = payload.get("samples_completed")
|
||||
_require(errors, isinstance(requested, dict), f"{prefix}:requested")
|
||||
if isinstance(requested, dict):
|
||||
sample_count = requested.get("samples")
|
||||
interval = _number(requested.get("interval_seconds"))
|
||||
_require(
|
||||
errors,
|
||||
isinstance(sample_count, int)
|
||||
and sample_count == completed
|
||||
and sample_count > 1,
|
||||
f"{prefix}:sample_count",
|
||||
)
|
||||
_require(
|
||||
errors,
|
||||
interval is not None
|
||||
and isinstance(completed, int)
|
||||
and (completed - 1) * interval >= MINIMUM_SOAK_SECONDS,
|
||||
f"{prefix}:coverage",
|
||||
)
|
||||
topology_mode = payload.get("topology_mode")
|
||||
_require(
|
||||
errors,
|
||||
topology_mode in (None, "linux_compose", "windows_host"),
|
||||
f"{prefix}:mode",
|
||||
)
|
||||
if topology_mode == "windows_host":
|
||||
_require(
|
||||
errors,
|
||||
isinstance(scope, dict)
|
||||
and scope.get("topology_boundary")
|
||||
== "windows_host_api_and_cloudflared_processes"
|
||||
and scope.get("configured_listener_port_retained") is True,
|
||||
f"{prefix}:windows_scope",
|
||||
)
|
||||
_validate_windows_topology(payload, errors)
|
||||
elif topology_mode in (None, "linux_compose"):
|
||||
_validate_linux_compose_topology(payload, errors)
|
||||
_require(errors, payload.get("failure_type") is None, f"{prefix}:failure")
|
||||
|
||||
|
||||
|
|
@ -489,11 +947,20 @@ def validate_binding(
|
|||
else:
|
||||
windows.append((start, end))
|
||||
if len(windows) == 3:
|
||||
overlap_started = max(start for start, _ in windows)
|
||||
overlap_ended = min(end for _, end in windows)
|
||||
_require(
|
||||
errors,
|
||||
max(start for start, _ in windows) <= min(end for _, end in windows),
|
||||
overlap_started <= overlap_ended,
|
||||
"binding:no_concurrent_overlap",
|
||||
)
|
||||
if overlap_started <= overlap_ended:
|
||||
_require(
|
||||
errors,
|
||||
(overlap_ended - overlap_started).total_seconds()
|
||||
>= MINIMUM_SOAK_SECONDS,
|
||||
"binding:concurrent_overlap_below_3000_seconds",
|
||||
)
|
||||
|
||||
|
||||
def artifact_sha256(path: Path) -> str:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
param(
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StableSourceRoot,
|
||||
[string]$TaskName = "VignettePublicRuntimeWatchdog",
|
||||
[int]$IntervalMinutes = 5,
|
||||
[string[]]$AdditionalPublicHealthUrls = @(),
|
||||
|
|
@ -14,11 +15,72 @@ if ($IntervalMinutes -lt 1) {
|
|||
throw "IntervalMinutes must be 1 or greater"
|
||||
}
|
||||
|
||||
$watchScript = Join-Path $Workspace "scripts\watch-public-runtime.ps1"
|
||||
if (!(Test-Path $watchScript)) {
|
||||
throw "Watchdog script not found at $watchScript"
|
||||
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
|
||||
$installerScript = Join-Path $resolvedSourceRoot "scripts\install-public-runtime-task.ps1"
|
||||
$watchScript = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime.ps1"
|
||||
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
|
||||
|
||||
function Invoke-GitText {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$value = & git.exe -C $resolvedSourceRoot @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
|
||||
}
|
||||
return (@($value) -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
foreach ($requiredScript in @($installerScript, $watchScript, $startScript)) {
|
||||
if (!(Test-Path -LiteralPath $requiredScript -PathType Leaf)) {
|
||||
throw "Public runtime script not found at $requiredScript"
|
||||
}
|
||||
}
|
||||
|
||||
$runningInstaller = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
if (-not [string]::Equals(
|
||||
$runningInstaller,
|
||||
(Resolve-Path -LiteralPath $installerScript).Path,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Watchdog installer is not executing from the pinned stable source root"
|
||||
}
|
||||
|
||||
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
|
||||
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
|
||||
if (-not [string]::Equals(
|
||||
$resolvedGitRoot,
|
||||
$resolvedSourceRoot,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Stable source root does not match its Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
|
||||
$symbolicHeadExit = $LASTEXITCODE
|
||||
if ($symbolicHeadExit -eq 0) {
|
||||
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
|
||||
}
|
||||
if ($symbolicHeadExit -ne 1) {
|
||||
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
|
||||
}
|
||||
|
||||
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
|
||||
if ($dirty) {
|
||||
throw "Stable source is not clean; refusing watchdog installation"
|
||||
}
|
||||
foreach ($relativePath in @(
|
||||
"scripts/install-public-runtime-task.ps1",
|
||||
"scripts/watch-public-runtime.ps1",
|
||||
"scripts/start-public-runtime.ps1"
|
||||
)) {
|
||||
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
|
||||
}
|
||||
|
||||
$sourceCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
|
||||
$sourceTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
|
||||
$watchdogSha256 = (Get-FileHash -LiteralPath $watchScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$startScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$powershell = (Get-Command powershell.exe).Source
|
||||
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
|
||||
|
|
@ -26,7 +88,11 @@ $actionArguments = @(
|
|||
"-NoProfile",
|
||||
"-ExecutionPolicy Bypass",
|
||||
"-File `"$watchScript`"",
|
||||
"-Workspace `"$Workspace`""
|
||||
"-StableSourceRoot `"$resolvedSourceRoot`"",
|
||||
"-ExpectedSourceCommit $sourceCommit",
|
||||
"-ExpectedSourceTree $sourceTree",
|
||||
"-ExpectedWatchdogSha256 $watchdogSha256",
|
||||
"-ExpectedStartScriptSha256 $startScriptSha256"
|
||||
)
|
||||
if ($SkipPublicHealth) {
|
||||
$actionArguments += "-SkipPublicHealth"
|
||||
|
|
@ -42,7 +108,7 @@ if ($AdditionalPublicHealthUrls.Count -gt 0) {
|
|||
$action = New-ScheduledTaskAction `
|
||||
-Execute $powershell `
|
||||
-Argument ($actionArguments -join " ") `
|
||||
-WorkingDirectory $Workspace
|
||||
-WorkingDirectory $resolvedSourceRoot
|
||||
|
||||
$logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
|
||||
$repeatTrigger = New-ScheduledTaskTrigger `
|
||||
|
|
@ -65,7 +131,7 @@ $principal = New-ScheduledTaskPrincipal `
|
|||
-LogonType Interactive `
|
||||
-RunLevel Limited
|
||||
|
||||
$description = "Runs Vignette public runtime watchdog as $userId. Secrets stay in the user profile and apps/api/.env; the task command stores no secrets."
|
||||
$description = "Runs Vignette public runtime watchdog as $userId from detached clean commit $sourceCommit. Secrets stay in the user profile and apps/api/.env; the task command stores no secrets."
|
||||
$task = New-ScheduledTask `
|
||||
-Action $action `
|
||||
-Trigger @($logonTrigger, $repeatTrigger) `
|
||||
|
|
@ -77,6 +143,8 @@ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
|
|||
|
||||
Write-Output "Installed scheduled task '$TaskName' for $userId"
|
||||
Write-Output "Action: $powershell $($actionArguments -join ' ')"
|
||||
Write-Output "Pinned source: root=$resolvedSourceRoot commit=$sourceCommit tree=$sourceTree"
|
||||
Write-Output "Pinned scripts: watchdog_sha256=$watchdogSha256 start_sha256=$startScriptSha256"
|
||||
Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon"
|
||||
if ($AdditionalPublicHealthUrls.Count -gt 0) {
|
||||
Write-Output "Additional public health URLs: $($AdditionalPublicHealthUrls -join ', ')"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from urllib.parse import parse_qs, urlsplit
|
|||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 9882
|
||||
DEFAULT_MODEL = "large-v3"
|
||||
DEFAULT_MODEL = "small"
|
||||
DEFAULT_LANGUAGE = "ko"
|
||||
DEFAULT_SAMPLE_RATE = 16_000
|
||||
DEFAULT_CHANNELS = 1
|
||||
|
|
@ -546,6 +546,7 @@ async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진
|
|||
await send(
|
||||
{
|
||||
"type": "ready",
|
||||
"provider": "local_whisper",
|
||||
"model": transcriber.model_name,
|
||||
"device": transcriber.device,
|
||||
"compute_type": transcriber.compute_type,
|
||||
|
|
@ -579,6 +580,7 @@ async def serve(args: argparse.Namespace) -> int: # pragma: no cover - I/O 진
|
|||
json.dumps(
|
||||
{
|
||||
"ready": True,
|
||||
"provider": "local_whisper",
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"model": transcriber.model_name,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ def encode_wav(samples: Iterable[float], sample_rate: int) -> bytes:
|
|||
def health_payload(synthesizer: Synthesizer, *, speakers: Iterable[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"provider": "melotts",
|
||||
"model": MODEL_ID,
|
||||
"language": DEFAULT_LANGUAGE,
|
||||
"license": LICENSE_ID,
|
||||
|
|
|
|||
282
scripts/probe-public-voice-sidecars.py
Normal file
282
scripts/probe-public-voice-sidecars.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail-closed, metadata-only readiness probe for the public voice sidecars.
|
||||
|
||||
The probe never sends audio or synthesis text. It proves the local Whisper
|
||||
WebSocket protocol by validating its first ``ready`` frame, and proves MeloTTS
|
||||
through its loopback ``/health`` document. Only bounded, non-PII metadata is
|
||||
printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
from urllib.request import ProxyHandler, Request, build_opener
|
||||
|
||||
|
||||
DEFAULT_STT_URL = "ws://127.0.0.1:9882/v1/listen"
|
||||
DEFAULT_TTS_URL = "http://127.0.0.1:9883"
|
||||
DEFAULT_STT_PROVIDER = "local_whisper"
|
||||
DEFAULT_STT_MODEL = "small"
|
||||
DEFAULT_STT_LANGUAGE = "ko"
|
||||
DEFAULT_STT_DEVICE = "cpu"
|
||||
DEFAULT_TTS_PROVIDER = "melotts"
|
||||
DEFAULT_TTS_MODEL = "melotts-korean"
|
||||
DEFAULT_TTS_LANGUAGE = "KR"
|
||||
MAX_METADATA_BYTES = 64 * 1024
|
||||
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
"""A stable, non-sensitive readiness failure code."""
|
||||
|
||||
|
||||
def _loopback_parts(url: str, *, schemes: set[str]) -> Any:
|
||||
parts = urlsplit(url)
|
||||
if (
|
||||
parts.scheme not in schemes
|
||||
or parts.hostname not in LOOPBACK_HOSTS
|
||||
or parts.username is not None
|
||||
or parts.password is not None
|
||||
or parts.fragment
|
||||
):
|
||||
raise ProbeError("loopback_url_required")
|
||||
return parts
|
||||
|
||||
|
||||
def build_stt_probe_url(
|
||||
base_url: str,
|
||||
*,
|
||||
model: str,
|
||||
language: str,
|
||||
) -> str:
|
||||
parts = _loopback_parts(base_url, schemes={"ws"})
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query.update(
|
||||
{
|
||||
"model": model,
|
||||
"language": language,
|
||||
"sample_rate": "16000",
|
||||
"channels": "1",
|
||||
}
|
||||
)
|
||||
return urlunsplit(
|
||||
(parts.scheme, parts.netloc, parts.path, urlencode(query), "")
|
||||
)
|
||||
|
||||
|
||||
def validate_stt_ready(
|
||||
payload: object,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
language: str,
|
||||
device: str,
|
||||
) -> dict[str, object]:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ProbeError("stt_ready_not_object")
|
||||
expected = {
|
||||
"type": "ready",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"language": language,
|
||||
"device": device,
|
||||
"sample_rate": 16_000,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if payload.get(key) != value:
|
||||
raise ProbeError(f"stt_{key}_mismatch")
|
||||
expected_compute_type = "int8" if device == "cpu" else "float16"
|
||||
if payload.get("compute_type") != expected_compute_type:
|
||||
raise ProbeError("stt_compute_type_mismatch")
|
||||
return {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"language": language,
|
||||
"device": device,
|
||||
"compute_type": expected_compute_type,
|
||||
"sample_rate": 16_000,
|
||||
}
|
||||
|
||||
|
||||
def validate_tts_health(
|
||||
payload: object,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
language: str,
|
||||
) -> dict[str, object]:
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ProbeError("tts_health_not_object")
|
||||
expected = {
|
||||
"status": "ok",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"language": language,
|
||||
"license": "MIT",
|
||||
"reference_policy": "pretrained-multispeaker-no-external-reference",
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if payload.get(key) != value:
|
||||
raise ProbeError(f"tts_{key}_mismatch")
|
||||
sample_rate = payload.get("sample_rate")
|
||||
speakers = payload.get("speakers")
|
||||
if not isinstance(sample_rate, int) or sample_rate <= 0:
|
||||
raise ProbeError("tts_sample_rate_invalid")
|
||||
if not isinstance(speakers, list) or not speakers:
|
||||
raise ProbeError("tts_speakers_missing")
|
||||
return {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"language": language,
|
||||
"license": "MIT",
|
||||
"sample_rate": sample_rate,
|
||||
}
|
||||
|
||||
|
||||
async def probe_stt(
|
||||
url: str,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
language: str,
|
||||
device: str,
|
||||
timeout_seconds: float,
|
||||
) -> dict[str, object]:
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
probe_url = build_stt_probe_url(url, model=model, language=language)
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
async with connect(
|
||||
probe_url,
|
||||
max_size=MAX_METADATA_BYTES,
|
||||
max_queue=1,
|
||||
ping_interval=None,
|
||||
) as socket:
|
||||
frame = await socket.recv()
|
||||
except ProbeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ProbeError("stt_connection_failed") from exc
|
||||
if not isinstance(frame, str) or len(frame.encode("utf-8")) > MAX_METADATA_BYTES:
|
||||
raise ProbeError("stt_ready_frame_invalid")
|
||||
try:
|
||||
payload = json.loads(frame)
|
||||
except ValueError as exc:
|
||||
raise ProbeError("stt_ready_json_invalid") from exc
|
||||
return validate_stt_ready(
|
||||
payload,
|
||||
provider=provider,
|
||||
model=model,
|
||||
language=language,
|
||||
device=device,
|
||||
)
|
||||
|
||||
|
||||
def probe_tts(
|
||||
url: str,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
language: str,
|
||||
timeout_seconds: float,
|
||||
) -> dict[str, object]:
|
||||
parts = _loopback_parts(url, schemes={"http"})
|
||||
health_path = parts.path.rstrip("/") + "/health"
|
||||
health_url = urlunsplit((parts.scheme, parts.netloc, health_path, "", ""))
|
||||
request = Request(
|
||||
health_url,
|
||||
method="GET",
|
||||
headers={"Accept": "application/json", "User-Agent": "vignette-readiness/1"},
|
||||
)
|
||||
try:
|
||||
with build_opener(ProxyHandler({})).open(
|
||||
request, timeout=timeout_seconds
|
||||
) as response:
|
||||
content_type = response.headers.get_content_type()
|
||||
body = response.read(MAX_METADATA_BYTES + 1)
|
||||
status = response.status
|
||||
except Exception as exc:
|
||||
raise ProbeError("tts_connection_failed") from exc
|
||||
if status != 200:
|
||||
raise ProbeError("tts_health_status_invalid")
|
||||
if content_type != "application/json" or len(body) > MAX_METADATA_BYTES:
|
||||
raise ProbeError("tts_health_response_invalid")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise ProbeError("tts_health_json_invalid") from exc
|
||||
return validate_tts_health(
|
||||
payload,
|
||||
provider=provider,
|
||||
model=model,
|
||||
language=language,
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--component", choices=("all", "stt", "tts"), default="all")
|
||||
parser.add_argument("--stt-url", default=DEFAULT_STT_URL)
|
||||
parser.add_argument("--stt-provider", default=DEFAULT_STT_PROVIDER)
|
||||
parser.add_argument("--stt-model", default=DEFAULT_STT_MODEL)
|
||||
parser.add_argument("--stt-language", default=DEFAULT_STT_LANGUAGE)
|
||||
parser.add_argument("--stt-device", choices=("cpu", "cuda"), default=DEFAULT_STT_DEVICE)
|
||||
parser.add_argument("--tts-url", default=DEFAULT_TTS_URL)
|
||||
parser.add_argument("--tts-provider", default=DEFAULT_TTS_PROVIDER)
|
||||
parser.add_argument("--tts-model", default=DEFAULT_TTS_MODEL)
|
||||
parser.add_argument("--tts-language", default=DEFAULT_TTS_LANGUAGE)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=5.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
||||
if not 0.1 <= args.timeout_seconds <= 30:
|
||||
print('{"ok":false,"error":"timeout_invalid"}', file=sys.stderr)
|
||||
return 2
|
||||
result: dict[str, object] = {
|
||||
"schema_version": "vignette.voice-sidecar-readiness.v1",
|
||||
"ok": True,
|
||||
}
|
||||
try:
|
||||
if args.component in {"all", "stt"}:
|
||||
result["stt"] = asyncio.run(
|
||||
probe_stt(
|
||||
args.stt_url,
|
||||
provider=args.stt_provider,
|
||||
model=args.stt_model,
|
||||
language=args.stt_language,
|
||||
device=args.stt_device,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
)
|
||||
)
|
||||
if args.component in {"all", "tts"}:
|
||||
result["tts"] = probe_tts(
|
||||
args.tts_url,
|
||||
provider=args.tts_provider,
|
||||
model=args.tts_model,
|
||||
language=args.tts_language,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
)
|
||||
except ProbeError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"ok": False, "component": args.component, "error": str(exc)},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
print(json.dumps(result, separators=(",", ":"), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -4,21 +4,100 @@
|
|||
# actual recovery. Re-run to refresh (-Force). Remove with:
|
||||
# Unregister-ScheduledTask -TaskName VignettePublicRuntime -Confirm:$false
|
||||
#
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts\register-boot-task.ps1 `
|
||||
# -StableSourceRoot D:\workspace\vignette-public-runtime-<commit>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StableSourceRoot,
|
||||
[string]$TaskName = "VignettePublicRuntime"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$TaskName = "VignettePublicRuntime"
|
||||
$BootScript = "D:\workspace\vignette\scripts\boot-public-runtime.ps1"
|
||||
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
|
||||
$RegisterScript = Join-Path $resolvedSourceRoot "scripts\register-boot-task.ps1"
|
||||
$BootScript = Join-Path $resolvedSourceRoot "scripts\boot-public-runtime.ps1"
|
||||
$StartScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
|
||||
$UserName = "$env:USERDOMAIN\$env:USERNAME"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BootScript)) {
|
||||
throw "boot script not found: $BootScript"
|
||||
function Invoke-GitText {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$value = & git.exe -C $resolvedSourceRoot @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
|
||||
}
|
||||
return (@($value) -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
foreach ($requiredScript in @($RegisterScript, $BootScript, $StartScript)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredScript -PathType Leaf)) {
|
||||
throw "Public runtime script not found: $requiredScript"
|
||||
}
|
||||
}
|
||||
|
||||
$runningRegisterScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
if (-not [string]::Equals(
|
||||
$runningRegisterScript,
|
||||
(Resolve-Path -LiteralPath $RegisterScript).Path,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Boot task registrar is not executing from the pinned stable source root"
|
||||
}
|
||||
|
||||
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
|
||||
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
|
||||
if (-not [string]::Equals(
|
||||
$resolvedGitRoot,
|
||||
$resolvedSourceRoot,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Stable source root does not match its Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
|
||||
$symbolicHeadExit = $LASTEXITCODE
|
||||
if ($symbolicHeadExit -eq 0) {
|
||||
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
|
||||
}
|
||||
if ($symbolicHeadExit -ne 1) {
|
||||
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
|
||||
}
|
||||
|
||||
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
|
||||
if ($dirty) {
|
||||
throw "Stable source is not clean; refusing boot task registration"
|
||||
}
|
||||
foreach ($relativePath in @(
|
||||
"scripts/register-boot-task.ps1",
|
||||
"scripts/boot-public-runtime.ps1",
|
||||
"scripts/start-public-runtime.ps1"
|
||||
)) {
|
||||
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
|
||||
}
|
||||
|
||||
$sourceCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
|
||||
$sourceTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
|
||||
$bootScriptSha256 = (Get-FileHash -LiteralPath $BootScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$startScriptSha256 = (Get-FileHash -LiteralPath $StartScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$bootArguments = @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy Bypass",
|
||||
"-WindowStyle Hidden",
|
||||
"-File `"$BootScript`"",
|
||||
"-StableSourceRoot `"$resolvedSourceRoot`"",
|
||||
"-ExpectedSourceCommit $sourceCommit",
|
||||
"-ExpectedSourceTree $sourceTree",
|
||||
"-ExpectedBootScriptSha256 $bootScriptSha256",
|
||||
"-ExpectedStartScriptSha256 $startScriptSha256"
|
||||
)
|
||||
|
||||
$action = New-ScheduledTaskAction `
|
||||
-Execute "powershell.exe" `
|
||||
-Argument ("-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"" + $BootScript + "`"")
|
||||
-Argument ($bootArguments -join " ") `
|
||||
-WorkingDirectory $resolvedSourceRoot
|
||||
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserName
|
||||
|
||||
|
|
@ -41,7 +120,7 @@ Register-ScheduledTask `
|
|||
-Trigger $trigger `
|
||||
-Principal $principal `
|
||||
-Settings $settings `
|
||||
-Description "Vignette public runtime auto-recovery (Docker + postgres + engine/api + cloudflared) at logon" `
|
||||
-Description "Vignette public runtime auto-recovery from detached clean commit $sourceCommit at logon" `
|
||||
-Force | Out-Null
|
||||
|
||||
$task = Get-ScheduledTask -TaskName $TaskName
|
||||
|
|
@ -50,6 +129,8 @@ Write-Output ("State : " + $task.State)
|
|||
Write-Output ("User : " + $principal.UserId)
|
||||
Write-Output ("Trigger : AtLogOn (" + $UserName + ")")
|
||||
Write-Output ("Command : " + $action.Execute + " " + $action.Argument)
|
||||
Write-Output ("Source : root=" + $resolvedSourceRoot + " commit=" + $sourceCommit + " tree=" + $sourceTree)
|
||||
Write-Output ("Hashes : boot=" + $bootScriptSha256 + " start=" + $startScriptSha256)
|
||||
Write-Output ""
|
||||
Write-Output ("Run now : powershell -NoProfile -ExecutionPolicy Bypass -File " + $BootScript)
|
||||
Write-Output ("Run now : " + $action.Execute + " " + $action.Argument)
|
||||
Write-Output ("Unregister : Unregister-ScheduledTask -TaskName " + $TaskName + " -Confirm:`$false")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
"""Fail-closed G8 release agent for the isolated Vignette NAS preview.
|
||||
|
||||
The default is a read-only dry run. ``--execute`` is intentionally narrow:
|
||||
it accepts only the dedicated Tailnet preview, builds a clean HEAD+patch
|
||||
it accepts only the dedicated Tailnet preview, builds either the default
|
||||
verified HEAD+patch candidate or an explicitly requested clean-HEAD archive
|
||||
candidate, runs release/API/Web/session gates, snapshots the current API and
|
||||
Web images, promotes only a changed deterministic SHA, and rolls back the
|
||||
previous images when any post-promotion proof fails.
|
||||
|
|
@ -54,6 +55,7 @@ SSOT_REQUIRED_PATHS = (
|
|||
# operator surface has exercised its browser contract. Keep this list explicit
|
||||
# so a newly added goal cannot disappear behind a broad directory glob.
|
||||
RELEASE_UI_E2E_SPECS = (
|
||||
"e2e/insecure-context-uuid.spec.ts",
|
||||
"e2e/session-layout.spec.ts",
|
||||
"e2e/session-persistence.spec.ts",
|
||||
"e2e/self-directed-learning-loop.spec.ts",
|
||||
|
|
@ -66,6 +68,36 @@ RELEASE_UI_E2E_SPECS = (
|
|||
"e2e/multimodal-alliance.spec.ts",
|
||||
"e2e/continuous-improvement-admin.spec.ts",
|
||||
)
|
||||
# The UUID source-contract spec intentionally imports ``/src/lib/uuid.ts`` and
|
||||
# scans the local source tree, so it belongs to the Vite candidate gate above.
|
||||
# A production NAS build serves the SPA HTML for ``/src/*``. The postdeploy
|
||||
# gate therefore exercises the exact built review routes that previously
|
||||
# crashed on insecure HTTP, while retaining every production-origin-compatible
|
||||
# release spec. The real returned-practice DB closed loop remains an explicit
|
||||
# disposable-DB-only gate and must never be silently skipped against NAS.
|
||||
POSTDEPLOY_NAS_E2E_SPECS = (
|
||||
"e2e/session-layout.spec.ts",
|
||||
"e2e/session-persistence.spec.ts",
|
||||
"e2e/self-directed-learning-loop.spec.ts",
|
||||
"e2e/alliance-pulse.spec.ts",
|
||||
"e2e/outcome-trajectory.spec.ts",
|
||||
"e2e/rupture-repair.spec.ts",
|
||||
"e2e/deliberate-practice.spec.ts",
|
||||
"e2e/calibration-transfer.spec.ts",
|
||||
"e2e/supervision-research.spec.ts",
|
||||
"e2e/multimodal-alliance.spec.ts",
|
||||
"e2e/continuous-improvement-admin.spec.ts",
|
||||
)
|
||||
POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",)
|
||||
POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = (
|
||||
"e2e/returned-practice-db-closed-loop.spec.ts",
|
||||
)
|
||||
SOURCE_ONLY_E2E_PORT = 5198
|
||||
RELEASE_BROWSER_PROJECTS = (
|
||||
"chromium-desktop",
|
||||
"chromium-mobile",
|
||||
"chromium-single-run",
|
||||
)
|
||||
RELEASE_E2E_TIMEOUT_SECONDS = 15 * 60
|
||||
|
||||
# Existing preview volumes do not replay docker-entrypoint-initdb.d. These
|
||||
|
|
@ -100,8 +132,10 @@ REQUIRED_OPENAPI_PATHS = {
|
|||
|
||||
IMAGE_ID_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
|
||||
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
||||
GIT_OBJECT_RE = re.compile(r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")
|
||||
SSH_TARGET_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?100\.116\.83\.60$")
|
||||
RELEASE_KEY_RE = re.compile(r"^[a-f0-9]{16}-\d{8}T\d{6}Z-\d+$")
|
||||
SOURCE_MODES = ("patch", "clean-head")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -147,6 +181,7 @@ class ReleaseAgentConfig:
|
|||
repo_root: Path = REPO_ROOT
|
||||
manifest_path: Path = DEFAULT_MANIFEST
|
||||
hunk_map_path: Path = DEFAULT_HUNK_MAP
|
||||
source_mode: str = "patch"
|
||||
execute: bool = False
|
||||
milestone: MaterialMilestone | None = None
|
||||
target: DeploymentTarget = NAS_PREVIEW_TARGET
|
||||
|
|
@ -215,6 +250,8 @@ class RuntimeProbe(Protocol):
|
|||
class CandidateManager(Protocol):
|
||||
def materialize(self, base_commit: str, patch_path: str) -> Path: ...
|
||||
|
||||
def materialize_archive(self, archive_path: Path, expected_sha256: str) -> Path: ...
|
||||
|
||||
def cleanup(self) -> None: ...
|
||||
|
||||
|
||||
|
|
@ -226,6 +263,16 @@ def sha256_bytes(value: bytes) -> str:
|
|||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> tuple[str, int]:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
return digest.hexdigest(), size
|
||||
|
||||
|
||||
def validate_target(target: DeploymentTarget) -> None:
|
||||
if target != NAS_PREVIEW_TARGET:
|
||||
raise ValueError(
|
||||
|
|
@ -237,6 +284,15 @@ def validate_target(target: DeploymentTarget) -> None:
|
|||
raise ValueError("isolated NAS preview root must be an absolute canonical path")
|
||||
|
||||
|
||||
def validate_source_repo_root(repo_root: Path) -> None:
|
||||
if not repo_root.is_absolute():
|
||||
raise StageFailure("source_repository", "source repository root must be absolute")
|
||||
if not repo_root.exists():
|
||||
raise StageFailure("source_repository", "source repository root does not exist")
|
||||
if not repo_root.is_dir():
|
||||
raise StageFailure("source_repository", "source repository root is not a directory")
|
||||
|
||||
|
||||
def validate_required_release_payload(manifest: dict[str, Any]) -> None:
|
||||
classifications = manifest.get("classifications")
|
||||
related = classifications.get("related") if isinstance(classifications, dict) else None
|
||||
|
|
@ -437,6 +493,45 @@ class CleanCandidateManager:
|
|||
_normalize_candidate_file_to_lf(shell_script, "shell script")
|
||||
return root
|
||||
|
||||
def materialize_archive(self, archive_path: Path, expected_sha256: str) -> Path:
|
||||
"""Extract an exact clean-HEAD archive without applying a patch."""
|
||||
|
||||
if not SHA256_RE.fullmatch(expected_sha256):
|
||||
raise StageFailure("candidate_archive_binding", "expected archive SHA is invalid")
|
||||
try:
|
||||
actual_sha256, _ = sha256_file(archive_path)
|
||||
except OSError as exc:
|
||||
raise StageFailure("candidate_archive_binding", str(exc)) from exc
|
||||
if actual_sha256 != expected_sha256:
|
||||
raise StageFailure(
|
||||
"candidate_archive_binding",
|
||||
f"archive SHA drifted: expected={expected_sha256} actual={actual_sha256}",
|
||||
)
|
||||
|
||||
self._temp = tempfile.TemporaryDirectory(prefix="vignette-release-agent-")
|
||||
root = Path(self._temp.name) / "candidate"
|
||||
try:
|
||||
root.mkdir(parents=True)
|
||||
with tarfile.open(archive_path, "r:") as archive:
|
||||
_safe_extract(archive, root)
|
||||
generated_api = root / "apps/web/src/lib/api.gen.ts"
|
||||
if not generated_api.is_file():
|
||||
raise StageFailure("candidate_archive", "generated API contract is missing")
|
||||
# git archive stores canonical LF blobs, while a Windows checkout can
|
||||
# materialize this generated file with CRLF. openapi-typescript
|
||||
# --check compares bytes, so apply the same platform normalization as
|
||||
# patch-mode after the archive SHA has already been verified.
|
||||
_normalize_candidate_file_to_lf(generated_api, "generated API contract")
|
||||
for shell_script in sorted(root.rglob("*.sh")):
|
||||
_normalize_candidate_file_to_lf(shell_script, "shell script")
|
||||
except StageFailure:
|
||||
self.cleanup()
|
||||
raise
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
self.cleanup()
|
||||
raise StageFailure("candidate_archive", str(exc)) from exc
|
||||
return root
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._temp is not None:
|
||||
self._temp.cleanup()
|
||||
|
|
@ -970,6 +1065,8 @@ class ReleaseAgent:
|
|||
self.runtime_probe = runtime_probe
|
||||
self.candidate_manager = candidate_manager
|
||||
self.stage_evidence: list[dict[str, Any]] = []
|
||||
self.source_evidence: dict[str, Any] | None = None
|
||||
self._source_temp: tempfile.TemporaryDirectory[str] | None = None
|
||||
|
||||
def _run(
|
||||
self,
|
||||
|
|
@ -1089,6 +1186,152 @@ class ReleaseAgent:
|
|||
raise StageFailure("release_determinism", "builder patch SHA is invalid")
|
||||
return reports[1]
|
||||
|
||||
def _clean_head_identity(self, suffix: str) -> tuple[str, str]:
|
||||
status = self._run(
|
||||
f"clean_head_worktree_{suffix}",
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=no"],
|
||||
cwd=self.config.repo_root,
|
||||
timeout=120,
|
||||
)
|
||||
if status.stdout.strip():
|
||||
raise StageFailure(
|
||||
"clean_head_worktree",
|
||||
"tracked worktree/index changes are forbidden in clean-head mode",
|
||||
)
|
||||
|
||||
head_result = self._run(
|
||||
f"clean_head_head_{suffix}",
|
||||
["git", "rev-parse", "--verify", "HEAD"],
|
||||
cwd=self.config.repo_root,
|
||||
timeout=120,
|
||||
)
|
||||
tree_result = self._run(
|
||||
f"clean_head_tree_{suffix}",
|
||||
["git", "rev-parse", "--verify", "HEAD^{tree}"],
|
||||
cwd=self.config.repo_root,
|
||||
timeout=120,
|
||||
)
|
||||
head = head_result.stdout.strip()
|
||||
tree = tree_result.stdout.strip()
|
||||
if not GIT_OBJECT_RE.fullmatch(head):
|
||||
raise StageFailure("clean_head_identity", "Git HEAD is invalid")
|
||||
if not GIT_OBJECT_RE.fullmatch(tree):
|
||||
raise StageFailure("clean_head_identity", "Git tree is invalid")
|
||||
return head, tree
|
||||
|
||||
def _validate_clean_head_worktree_root(self) -> None:
|
||||
result = self._run(
|
||||
"clean_head_repository",
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=self.config.repo_root,
|
||||
timeout=120,
|
||||
)
|
||||
reported_root = result.stdout.strip()
|
||||
if not reported_root:
|
||||
raise StageFailure("clean_head_repository", "Git worktree root is missing")
|
||||
try:
|
||||
actual_root = Path(reported_root).resolve(strict=True)
|
||||
expected_root = self.config.repo_root.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise StageFailure("clean_head_repository", str(exc)) from exc
|
||||
if actual_root != expected_root:
|
||||
raise StageFailure(
|
||||
"clean_head_repository",
|
||||
f"source repository is not the Git worktree root: {actual_root}",
|
||||
)
|
||||
|
||||
def _build_clean_head_source_twice(self) -> dict[str, Any]:
|
||||
self._validate_clean_head_worktree_root()
|
||||
before_head, before_tree = self._clean_head_identity("before")
|
||||
self._source_temp = tempfile.TemporaryDirectory(prefix="vignette-release-source-")
|
||||
source_root = Path(self._source_temp.name)
|
||||
archives: list[Path] = []
|
||||
archive_reports: list[dict[str, Any]] = []
|
||||
for run_number in (1, 2):
|
||||
archive_path = source_root / f"clean-head-{run_number}.tar"
|
||||
self._run(
|
||||
f"clean_head_archive_run_{run_number}",
|
||||
[
|
||||
"git",
|
||||
"archive",
|
||||
"--format=tar",
|
||||
"--output",
|
||||
str(archive_path),
|
||||
before_head,
|
||||
],
|
||||
cwd=self.config.repo_root,
|
||||
timeout=900,
|
||||
)
|
||||
try:
|
||||
archive_sha256, archive_size = sha256_file(archive_path)
|
||||
except OSError as exc:
|
||||
raise StageFailure("clean_head_archive", str(exc)) from exc
|
||||
archives.append(archive_path)
|
||||
archive_reports.append(
|
||||
{
|
||||
"sha256": archive_sha256,
|
||||
"bytes": archive_size,
|
||||
}
|
||||
)
|
||||
|
||||
after_head, after_tree = self._clean_head_identity("after")
|
||||
if (before_head, before_tree) != (after_head, after_tree):
|
||||
raise StageFailure(
|
||||
"clean_head_identity",
|
||||
"Git HEAD/tree changed while clean-head archives were built",
|
||||
)
|
||||
if archive_reports[0] != archive_reports[1]:
|
||||
raise StageFailure(
|
||||
"clean_head_determinism",
|
||||
f"two clean-head archives differ: {archive_reports}",
|
||||
)
|
||||
archive_sha256 = archive_reports[1]["sha256"]
|
||||
if not SHA256_RE.fullmatch(archive_sha256):
|
||||
raise StageFailure("clean_head_determinism", "archive SHA is invalid")
|
||||
return {
|
||||
"source_mode": "clean-head",
|
||||
"head": after_head,
|
||||
"tree": after_tree,
|
||||
"archive_sha256": archive_sha256,
|
||||
"archive_bytes": archive_reports[1]["bytes"],
|
||||
"archive_path": archives[1],
|
||||
"deterministic_runs": 2,
|
||||
}
|
||||
|
||||
def _prepare_source(self) -> tuple[dict[str, Any], str]:
|
||||
if self.config.source_mode == "patch":
|
||||
release = self._build_release_twice()
|
||||
self._check_manifest_binding(release)
|
||||
self.source_evidence = {
|
||||
"repo_root": str(self.config.repo_root),
|
||||
"base_commit": release["base_commit"],
|
||||
"patch": {
|
||||
"sha256": release["patch_sha256"],
|
||||
"bytes": release["patch_bytes"],
|
||||
"files": release["patch_files"],
|
||||
"output": release["output_patch"],
|
||||
"deterministic_runs": 2,
|
||||
},
|
||||
}
|
||||
return release, release["patch_sha256"]
|
||||
if self.config.source_mode == "clean-head":
|
||||
release = self._build_clean_head_source_twice()
|
||||
self.source_evidence = {
|
||||
"repo_root": str(self.config.repo_root),
|
||||
"head": release["head"],
|
||||
"tree": release["tree"],
|
||||
"archive": {
|
||||
"sha256": release["archive_sha256"],
|
||||
"bytes": release["archive_bytes"],
|
||||
"deterministic_runs": release["deterministic_runs"],
|
||||
},
|
||||
}
|
||||
return release, release["archive_sha256"]
|
||||
raise StageFailure(
|
||||
"configuration",
|
||||
f"unsupported source mode: {self.config.source_mode!r}",
|
||||
)
|
||||
|
||||
def _check_manifest_binding(self, release: dict[str, Any]) -> None:
|
||||
result = self._run(
|
||||
"release_manifest",
|
||||
|
|
@ -1169,6 +1412,33 @@ class ReleaseAgent:
|
|||
self._run("web_typecheck", ["npm.cmd", "run", "typecheck"], cwd=web, timeout=300)
|
||||
self._run("web_build", ["npm.cmd", "run", "build"], cwd=web, timeout=600)
|
||||
|
||||
# This spec imports /src/lib/uuid.ts and therefore must run against a
|
||||
# source Vite server, not the production Compose build (which correctly
|
||||
# serves the SPA document for /src/*). CI=1 prevents reuse of an
|
||||
# unrelated long-lived Vite process on the workstation.
|
||||
source_e2e_env = os.environ.copy()
|
||||
source_e2e_env.pop("PLAYWRIGHT_BASE_URL", None)
|
||||
source_e2e_env.pop("PLAYWRIGHT_SKIP_WEB_SERVER", None)
|
||||
source_e2e_env["PLAYWRIGHT_HOST"] = "127.0.0.1"
|
||||
source_e2e_env["PLAYWRIGHT_PORT"] = str(SOURCE_ONLY_E2E_PORT)
|
||||
source_e2e_env["CI"] = "1"
|
||||
self._run(
|
||||
"source_insecure_context_e2e",
|
||||
[
|
||||
"node.exe",
|
||||
str(web / "node_modules/@playwright/test/cli.js"),
|
||||
"test",
|
||||
*POSTDEPLOY_SOURCE_ONLY_E2E_SPECS,
|
||||
"--project=chromium-desktop",
|
||||
"--project=chromium-mobile",
|
||||
"--workers=1",
|
||||
"--reporter=line",
|
||||
],
|
||||
cwd=web,
|
||||
env=source_e2e_env,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
stack_attempted = False
|
||||
stack_env: Path | None = None
|
||||
project = candidate_compose_project(desired_sha)
|
||||
|
|
@ -1207,7 +1477,7 @@ class ReleaseAgent:
|
|||
"node.exe",
|
||||
str(web / "node_modules/@playwright/test/cli.js"),
|
||||
"test",
|
||||
*RELEASE_UI_E2E_SPECS,
|
||||
*POSTDEPLOY_NAS_E2E_SPECS,
|
||||
"--project=chromium-desktop",
|
||||
"--project=chromium-mobile",
|
||||
"--project=chromium-single-run",
|
||||
|
|
@ -1288,20 +1558,29 @@ class ReleaseAgent:
|
|||
"node.exe",
|
||||
str(web / "node_modules/@playwright/test/cli.js"),
|
||||
"test",
|
||||
"e2e/session-persistence.spec.ts",
|
||||
"--project=chromium-single-run",
|
||||
*POSTDEPLOY_NAS_E2E_SPECS,
|
||||
*(f"--project={project}" for project in RELEASE_BROWSER_PROJECTS),
|
||||
"--workers=1",
|
||||
"--grep",
|
||||
"persists the browser SSE stream into DB-backed review",
|
||||
"--reporter=line",
|
||||
],
|
||||
cwd=web,
|
||||
env=env,
|
||||
timeout=300,
|
||||
timeout=RELEASE_E2E_TIMEOUT_SECONDS,
|
||||
)
|
||||
return {
|
||||
"status": "passed",
|
||||
"test": "session-persistence: browser SSE stream -> DB-backed review",
|
||||
"base_url": self.config.target.base_url,
|
||||
"specs": list(POSTDEPLOY_NAS_E2E_SPECS),
|
||||
"projects": list(RELEASE_BROWSER_PROJECTS),
|
||||
"source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
|
||||
"separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS),
|
||||
"uuid_runtime_route_specs": [
|
||||
"e2e/session-persistence.spec.ts",
|
||||
"e2e/self-directed-learning-loop.spec.ts",
|
||||
"e2e/rupture-repair.spec.ts",
|
||||
"e2e/deliberate-practice.spec.ts",
|
||||
"e2e/calibration-transfer.spec.ts",
|
||||
],
|
||||
"physical_microphone_used": False,
|
||||
"stdout_sha256": sha256_bytes(result.stdout.encode("utf-8")),
|
||||
}
|
||||
|
|
@ -1312,6 +1591,8 @@ class ReleaseAgent:
|
|||
"captured_at": utc_now(),
|
||||
"ok": False,
|
||||
"mode": "execute" if self.config.execute else "dry-run",
|
||||
"source_mode": self.config.source_mode,
|
||||
"source": self.source_evidence,
|
||||
"target": asdict(self.config.target),
|
||||
"milestone": asdict(self.config.milestone) if self.config.milestone else None,
|
||||
"desired_sha": desired_sha,
|
||||
|
|
@ -1342,10 +1623,9 @@ class ReleaseAgent:
|
|||
desired_sha: str | None = None
|
||||
try:
|
||||
validate_target(self.config.target)
|
||||
validate_source_repo_root(self.config.repo_root)
|
||||
validate_milestone(self.config.milestone)
|
||||
release = self._build_release_twice()
|
||||
desired_sha = release["patch_sha256"]
|
||||
self._check_manifest_binding(release)
|
||||
release, desired_sha = self._prepare_source()
|
||||
active_state = self.deployment.read_active_state()
|
||||
active_sha = active_state.get("active_sha") if active_state else None
|
||||
if active_sha is not None and (
|
||||
|
|
@ -1391,9 +1671,14 @@ class ReleaseAgent:
|
|||
"remote release-agent state is untracked; adopt it only after an image/Compose rollback snapshot audit",
|
||||
)
|
||||
|
||||
candidate = self.candidate_manager.materialize(
|
||||
release["base_commit"], release["output_patch"]
|
||||
)
|
||||
if self.config.source_mode == "clean-head":
|
||||
candidate = self.candidate_manager.materialize_archive(
|
||||
release["archive_path"], release["archive_sha256"]
|
||||
)
|
||||
else:
|
||||
candidate = self.candidate_manager.materialize(
|
||||
release["base_commit"], release["output_patch"]
|
||||
)
|
||||
self._run_candidate_gates(candidate, desired_sha)
|
||||
snapshot = self.deployment.snapshot()
|
||||
self._validate_snapshot_binding(active_state, snapshot)
|
||||
|
|
@ -1472,6 +1757,9 @@ class ReleaseAgent:
|
|||
finally:
|
||||
if candidate is not None:
|
||||
self.candidate_manager.cleanup()
|
||||
if self._source_temp is not None:
|
||||
self._source_temp.cleanup()
|
||||
self._source_temp = None
|
||||
|
||||
|
||||
def load_milestone(path: Path) -> MaterialMilestone:
|
||||
|
|
@ -1502,6 +1790,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
parser.add_argument("--milestone", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||
parser.add_argument("--hunk-map", type=Path, default=DEFAULT_HUNK_MAP)
|
||||
parser.add_argument(
|
||||
"--source-mode",
|
||||
choices=SOURCE_MODES,
|
||||
default="patch",
|
||||
help="Release source: verified patch (default) or exact clean Git HEAD archive",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-repo-root",
|
||||
type=Path,
|
||||
default=REPO_ROOT,
|
||||
help="Absolute Git worktree root used as the release source",
|
||||
)
|
||||
parser.add_argument("--nas-env-file", type=Path, default=DEFAULT_NAS_ENV)
|
||||
parser.add_argument("--evidence-out", type=Path)
|
||||
parser.add_argument("--ssh-target", help="Required for live state inspection/execution")
|
||||
|
|
@ -1560,9 +1860,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||
else:
|
||||
deployment = StaticDeploymentState(args.current_deployment_sha)
|
||||
config = ReleaseAgentConfig(
|
||||
repo_root=REPO_ROOT,
|
||||
repo_root=args.source_repo_root,
|
||||
manifest_path=args.manifest,
|
||||
hunk_map_path=args.hunk_map,
|
||||
source_mode=args.source_mode,
|
||||
execute=args.execute,
|
||||
milestone=milestone,
|
||||
target=NAS_PREVIEW_TARGET,
|
||||
|
|
@ -1575,7 +1876,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
runner=runner,
|
||||
deployment=deployment,
|
||||
runtime_probe=HttpRuntimeProbe(NAS_PREVIEW_TARGET.base_url),
|
||||
candidate_manager=CleanCandidateManager(REPO_ROOT, runner),
|
||||
candidate_manager=CleanCandidateManager(args.source_repo_root, runner),
|
||||
)
|
||||
report = agent.run()
|
||||
if args.json:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
param(
|
||||
[int]$Port = 9882,
|
||||
[ValidateSet('large-v3', 'large-v3-turbo', 'medium', 'small', 'base')]
|
||||
[string]$Model = 'large-v3',
|
||||
[string]$Model = 'small',
|
||||
[ValidateSet('auto', 'cuda', 'cpu')]
|
||||
[string]$Device = 'auto',
|
||||
[string]$Device = 'cpu',
|
||||
[int]$WaitReadySeconds = 180
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
[int]$ApiPort = 8001,
|
||||
[int]$WebPort = 5174,
|
||||
[int]$EnginePort = 9099,
|
||||
[int]$WhisperPort = 9882,
|
||||
[int]$MeloTtsPort = 9883,
|
||||
[int]$VoiceSidecarReadySeconds = 300,
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
|
||||
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
|
||||
|
|
@ -11,7 +14,16 @@
|
|||
[switch]$SkipWebRestart,
|
||||
[switch]$RouteCloudflareDns,
|
||||
[string]$CloudflareTunnelName = "vignette",
|
||||
[switch]$SkipCloudflaredRestart
|
||||
[switch]$SkipCloudflaredRestart,
|
||||
[switch]$RequireFreshPublicProvenance,
|
||||
[string]$ExpectedSourceCommit = "",
|
||||
[string]$ExpectedSourceTree = "",
|
||||
[string]$ExpectedPythonSha256 = "",
|
||||
[string]$ExpectedCloudflaredSha256 = "",
|
||||
[string]$ExpectedCloudflaredConfigSha256 = "",
|
||||
[string]$RuntimeProvenancePath = "",
|
||||
[ValidateRange(1, 60)]
|
||||
[int]$ProcessStopTimeoutSeconds = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
|
@ -31,6 +43,16 @@ $EngineOutLog = Join-Path $ApiDir "engine.public.out.log"
|
|||
$EngineErrLog = Join-Path $ApiDir "engine.public.err.log"
|
||||
$WebOutLog = Join-Path $Workspace "web.public.out.log"
|
||||
$WebErrLog = Join-Path $Workspace "web.public.err.log"
|
||||
$WhisperStartScript = Join-Path $Workspace "scripts\start-local-whisper-stt.ps1"
|
||||
$MeloTtsStartScript = Join-Path $Workspace "scripts\start-melotts.ps1"
|
||||
$VoiceSidecarProbe = Join-Path $Workspace "scripts\probe-public-voice-sidecars.py"
|
||||
$WhisperModel = "small"
|
||||
$WhisperLanguage = "ko"
|
||||
$WhisperDevice = "cpu"
|
||||
$MeloTtsModel = "melotts-korean"
|
||||
$MeloTtsLanguage = "KR"
|
||||
$PublicApiHostnames = @("api-vignette.chanpaca.net", "api-vnet.18ka.net")
|
||||
$PublicWebHostnames = @("vnet.18ka.net")
|
||||
|
||||
function Get-JsonHealth {
|
||||
param(
|
||||
|
|
@ -64,6 +86,76 @@ function Wait-JsonHealth {
|
|||
throw "Timed out waiting for healthy response from $Uri"
|
||||
}
|
||||
|
||||
function Test-PortListener {
|
||||
param([int]$Port)
|
||||
|
||||
$listener = Get-NetTCPConnection `
|
||||
-State Listen `
|
||||
-LocalPort $Port `
|
||||
-ErrorAction SilentlyContinue `
|
||||
| Select-Object -First 1
|
||||
return $null -ne $listener
|
||||
}
|
||||
|
||||
function Test-VoiceSidecarReady {
|
||||
param(
|
||||
[ValidateSet("stt", "tts")]
|
||||
[string]$Component
|
||||
)
|
||||
|
||||
$probeArgs = @(
|
||||
"-X", "utf8", $VoiceSidecarProbe,
|
||||
"--component", $Component,
|
||||
"--stt-url", "ws://127.0.0.1:$WhisperPort/v1/listen",
|
||||
"--stt-provider", "local_whisper",
|
||||
"--stt-model", $WhisperModel,
|
||||
"--stt-language", $WhisperLanguage,
|
||||
"--stt-device", $WhisperDevice,
|
||||
"--tts-url", "http://127.0.0.1:$MeloTtsPort",
|
||||
"--tts-provider", "melotts",
|
||||
"--tts-model", $MeloTtsModel,
|
||||
"--tts-language", $MeloTtsLanguage,
|
||||
"--timeout-seconds", "5"
|
||||
)
|
||||
& $Python @probeArgs 1>$null 2>$null
|
||||
return $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
function Wait-VoiceSidecarReady {
|
||||
param(
|
||||
[ValidateSet("stt", "tts")]
|
||||
[string]$Component,
|
||||
[int]$TimeoutSec
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
do {
|
||||
if (Test-VoiceSidecarReady -Component $Component) {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-VoiceApiReady {
|
||||
param([object]$Health)
|
||||
|
||||
return (
|
||||
$null -ne $Health -and
|
||||
$Health.status -eq "ok" -and
|
||||
$Health.available -eq $true -and
|
||||
$Health.stt_available -eq $true -and
|
||||
$Health.tts_available -eq $true -and
|
||||
$Health.stt_provider -eq "local_whisper" -and
|
||||
$Health.stt_model -eq $WhisperModel -and
|
||||
$Health.tts_provider -eq "melotts" -and
|
||||
$Health.tts_model -eq $MeloTtsModel -and
|
||||
$Health.limits.uvicorn_ws_max_queue -eq 4
|
||||
)
|
||||
}
|
||||
|
||||
function Test-EngineReady {
|
||||
param(
|
||||
[int]$Port,
|
||||
|
|
@ -124,22 +216,155 @@ function Wait-HttpStatus {
|
|||
throw "Timed out waiting for HTTP response from $Uri"
|
||||
}
|
||||
|
||||
function Stop-ProcessesBounded {
|
||||
param(
|
||||
[object[]]$Processes,
|
||||
[int]$TimeoutSec,
|
||||
[string]$Role
|
||||
)
|
||||
|
||||
$processIds = @(
|
||||
$Processes |
|
||||
ForEach-Object { [int]$_.ProcessId } |
|
||||
Sort-Object -Unique
|
||||
)
|
||||
foreach ($processId in $processIds) {
|
||||
Stop-Process -Id $processId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
do {
|
||||
$remaining = @(
|
||||
$processIds |
|
||||
Where-Object { $null -ne (Get-Process -Id $_ -ErrorAction SilentlyContinue) }
|
||||
)
|
||||
if ($remaining.Count -eq 0) {
|
||||
return $processIds
|
||||
}
|
||||
Start-Sleep -Milliseconds 200
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
throw "Timed out stopping $Role process IDs: $($remaining -join ',')"
|
||||
}
|
||||
|
||||
function Stop-UvicornByPort {
|
||||
param(
|
||||
[string]$AppImport,
|
||||
[int]$Port
|
||||
[int]$Port,
|
||||
[int]$TimeoutSec = 15
|
||||
)
|
||||
|
||||
# Name 조건이 없으면 같은 문자열을 인자로 들고 있는 셸/래퍼 프로세스까지 매칭해
|
||||
# 호출자 자신을 죽일 수 있다. 대상은 항상 python 프로세스다.
|
||||
Get-CimInstance Win32_Process |
|
||||
$processes = @(
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.Name -like "python*" -and
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*uvicorn $AppImport*" -and
|
||||
$_.CommandLine -like "*--port $Port*"
|
||||
} |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
|
||||
}
|
||||
)
|
||||
return @(
|
||||
Stop-ProcessesBounded `
|
||||
-Processes $processes `
|
||||
-TimeoutSec $TimeoutSec `
|
||||
-Role "uvicorn $AppImport on port $Port"
|
||||
)
|
||||
}
|
||||
|
||||
function Get-CloudflaredProcessesForConfig {
|
||||
param(
|
||||
[string]$ConfigPath,
|
||||
[switch]$ExactPath
|
||||
)
|
||||
|
||||
$configLeaf = Split-Path -Leaf $ConfigPath
|
||||
return @(
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.Name -eq "cloudflared.exe" -and
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*--config*" -and
|
||||
(
|
||||
$_.CommandLine.IndexOf($ConfigPath, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 -or
|
||||
(-not $ExactPath -and $_.CommandLine -like "*$configLeaf*")
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function Wait-ProcessIdentity {
|
||||
param(
|
||||
[int]$ProcessId,
|
||||
[string]$Role,
|
||||
[string]$ExpectedCwd,
|
||||
[int]$TimeoutSec = 15
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
do {
|
||||
$process = Get-CimInstance Win32_Process `
|
||||
-Filter "ProcessId = $ProcessId" `
|
||||
-ErrorAction SilentlyContinue
|
||||
if (
|
||||
$null -ne $process -and
|
||||
$process.ExecutablePath -and
|
||||
$process.CommandLine
|
||||
) {
|
||||
$identityProbeArgs = @(
|
||||
"-X", "utf8", "-c",
|
||||
"import hashlib,psutil,sys; from datetime import UTC,datetime; p=psutil.Process(int(sys.argv[1])); print(p.cwd()); print(datetime.fromtimestamp(p.create_time(), UTC).isoformat().replace('+00:00', 'Z')); print(hashlib.sha256(chr(0).join(p.cmdline()).encode('utf-8', errors='strict')).hexdigest())",
|
||||
"$ProcessId"
|
||||
)
|
||||
$identityProbe = @(& $Python @identityProbeArgs)
|
||||
if ($LASTEXITCODE -ne 0 -or $identityProbe.Count -ne 3) {
|
||||
throw "Could not prove $Role psutil identity for PID $ProcessId"
|
||||
}
|
||||
$actualCwd = $identityProbe[0].Trim()
|
||||
$startedAtUtc = $identityProbe[1].Trim()
|
||||
$commandLineSha256 = $identityProbe[2].Trim().ToLowerInvariant()
|
||||
if (-not $actualCwd -or $startedAtUtc -notmatch "Z$" -or $commandLineSha256 -notmatch "^[0-9a-f]{64}$") {
|
||||
throw "$Role psutil identity is incomplete for PID $ProcessId"
|
||||
}
|
||||
if (-not [string]::Equals(
|
||||
[System.IO.Path]::GetFullPath($actualCwd),
|
||||
[System.IO.Path]::GetFullPath($ExpectedCwd),
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "$Role working directory drift: expected=$ExpectedCwd actual=$actualCwd"
|
||||
}
|
||||
return [ordered]@{
|
||||
role = $Role
|
||||
pid = [int]$process.ProcessId
|
||||
started_at_utc = $startedAtUtc
|
||||
executable_path = $process.ExecutablePath
|
||||
executable_name = Split-Path -Leaf $process.ExecutablePath
|
||||
executable_sha256 = (Get-FileHash -LiteralPath $process.ExecutablePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
command_line = $process.CommandLine
|
||||
command_line_sha256 = $commandLineSha256
|
||||
cwd = $actualCwd
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 200
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
throw "Timed out reading $Role process identity for PID $ProcessId"
|
||||
}
|
||||
|
||||
function ConvertTo-SafeProcessIdentity {
|
||||
param([System.Collections.IDictionary]$Identity)
|
||||
|
||||
# Raw command line이나 executable full path는 config/token을 우발적으로
|
||||
# 영구 보존할 수 있다. topology 결속에 필요한 비밀 비포함 투영만 기록한다.
|
||||
return [ordered]@{
|
||||
pid = [int]$Identity.pid
|
||||
started_at_utc = $Identity.started_at_utc
|
||||
executable_name = $Identity.executable_name
|
||||
executable_sha256 = $Identity.executable_sha256
|
||||
command_line_sha256 = $Identity.command_line_sha256
|
||||
cwd = $Identity.cwd
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-NodeByPortHint {
|
||||
|
|
@ -160,13 +385,232 @@ function ConvertTo-CompactJson {
|
|||
ConvertTo-Json -InputObject $Value -Compress
|
||||
}
|
||||
|
||||
function Invoke-StableGitText {
|
||||
param(
|
||||
[string]$SourceRoot,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
$value = & git.exe -C $SourceRoot @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
|
||||
}
|
||||
return (@($value) -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
function Initialize-RuntimeProvenanceOutput {
|
||||
param([string]$OutputPath)
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
|
||||
throw "Fresh public provenance output path must be absolute"
|
||||
}
|
||||
|
||||
try {
|
||||
$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
|
||||
$outputDirectory = Split-Path -Parent $resolvedOutputPath
|
||||
if (-not $outputDirectory) {
|
||||
throw "Fresh public provenance output path has no parent directory"
|
||||
}
|
||||
if (Test-Path -LiteralPath $resolvedOutputPath -PathType Container) {
|
||||
throw "Fresh public provenance output path is a directory: $resolvedOutputPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
|
||||
throw "Fresh public provenance output directory is unavailable: $outputDirectory"
|
||||
}
|
||||
|
||||
# 기존 receipt가 잠겨 있거나 read-only라면 프로세스 교체 전에 실패해야 한다.
|
||||
# sibling probe 두 개를 atomic replace해 디렉터리의 create/flush/replace/delete
|
||||
# 권한도 미리 검증한다. 실제 receipt 내용은 이 단계에서 건드리지 않는다.
|
||||
if (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf) {
|
||||
$attributes = [System.IO.File]::GetAttributes($resolvedOutputPath)
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
throw "Fresh public provenance output is read-only: $resolvedOutputPath"
|
||||
}
|
||||
$existingStream = [System.IO.File]::Open(
|
||||
$resolvedOutputPath,
|
||||
[System.IO.FileMode]::Open,
|
||||
[System.IO.FileAccess]::ReadWrite,
|
||||
[System.IO.FileShare]::Read
|
||||
)
|
||||
$existingStream.Dispose()
|
||||
}
|
||||
|
||||
$probeId = [Guid]::NewGuid().ToString("N")
|
||||
$probeSource = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.source.tmp"
|
||||
$probeTarget = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.target.tmp"
|
||||
$probeBackup = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($resolvedOutputPath)).$probeId.backup.tmp"
|
||||
try {
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($probeSource, "probe-source", $encoding)
|
||||
[System.IO.File]::WriteAllText($probeTarget, "probe-target", $encoding)
|
||||
[System.IO.File]::Replace($probeSource, $probeTarget, $probeBackup)
|
||||
[System.IO.File]::Delete($probeTarget)
|
||||
[System.IO.File]::Delete($probeBackup)
|
||||
} finally {
|
||||
foreach ($probePath in @($probeSource, $probeTarget, $probeBackup)) {
|
||||
if ($probePath -and [System.IO.File]::Exists($probePath)) {
|
||||
[System.IO.File]::Delete($probePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw "Fresh public provenance output preflight failed before runtime mutation: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
return $resolvedOutputPath
|
||||
}
|
||||
|
||||
function Write-Utf8TextAtomically {
|
||||
param(
|
||||
[string]$OutputPath,
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$outputDirectory = Split-Path -Parent $OutputPath
|
||||
$temporaryPath = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($OutputPath)).$([Guid]::NewGuid().ToString('N')).tmp"
|
||||
$backupPath = Join-Path $outputDirectory ".$([System.IO.Path]::GetFileName($OutputPath)).$([Guid]::NewGuid().ToString('N')).backup.tmp"
|
||||
$published = $false
|
||||
try {
|
||||
$encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$bytes = $encoding.GetBytes($Value)
|
||||
$stream = [System.IO.FileStream]::new(
|
||||
$temporaryPath,
|
||||
[System.IO.FileMode]::CreateNew,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush($true)
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
if ([System.IO.File]::Exists($OutputPath)) {
|
||||
[System.IO.File]::Replace($temporaryPath, $OutputPath, $backupPath)
|
||||
} elseif (Test-Path -LiteralPath $OutputPath) {
|
||||
throw "Fresh public provenance output became a non-file before commit: $OutputPath"
|
||||
} else {
|
||||
[System.IO.File]::Move($temporaryPath, $OutputPath)
|
||||
}
|
||||
$published = $true
|
||||
} finally {
|
||||
if ([System.IO.File]::Exists($temporaryPath)) {
|
||||
[System.IO.File]::Delete($temporaryPath)
|
||||
}
|
||||
if ([System.IO.File]::Exists($backupPath)) {
|
||||
try {
|
||||
[System.IO.File]::Delete($backupPath)
|
||||
} catch {
|
||||
if ($published) {
|
||||
Write-Warning "Atomic provenance receipt was published, but its temporary backup could not be removed: $backupPath"
|
||||
} else {
|
||||
throw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FreshPublicProvenanceContract {
|
||||
param(
|
||||
[string]$SourceRoot,
|
||||
[string]$SourceCommit,
|
||||
[string]$SourceTree,
|
||||
[string]$PythonPath,
|
||||
[string]$PythonSha256,
|
||||
[string]$CloudflaredPath,
|
||||
[string]$CloudflaredSha256,
|
||||
[string]$ConfigPath,
|
||||
[string]$ConfigSha256
|
||||
)
|
||||
|
||||
if (-not $ForceApiRestart) {
|
||||
throw "-RequireFreshPublicProvenance requires -ForceApiRestart"
|
||||
}
|
||||
if ($SkipCloudflaredRestart) {
|
||||
throw "-RequireFreshPublicProvenance forbids -SkipCloudflaredRestart"
|
||||
}
|
||||
foreach ($sourcePin in @($SourceCommit, $SourceTree)) {
|
||||
if ($sourcePin -notmatch "^[0-9a-fA-F]{40}$") {
|
||||
throw "Fresh public provenance requires exact source commit and tree pins"
|
||||
}
|
||||
}
|
||||
foreach ($shaPin in @($PythonSha256, $CloudflaredSha256, $ConfigSha256)) {
|
||||
if ($shaPin -notmatch "^[0-9a-fA-F]{64}$") {
|
||||
throw "Fresh public provenance requires exact Python, cloudflared, and config SHA256 pins"
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedSourceRoot = (Resolve-Path -LiteralPath $SourceRoot).Path
|
||||
$expectedStartScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
|
||||
$runningStartScript = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
if (-not [string]::Equals(
|
||||
$runningStartScript,
|
||||
(Resolve-Path -LiteralPath $expectedStartScript).Path,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Fresh public promotion must execute the launcher from the pinned stable source root"
|
||||
}
|
||||
|
||||
$gitRoot = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--show-toplevel")
|
||||
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
|
||||
if (-not [string]::Equals(
|
||||
$resolvedGitRoot,
|
||||
$resolvedSourceRoot,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Fresh public promotion source root does not match its Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
|
||||
$symbolicHeadExit = $LASTEXITCODE
|
||||
if ($symbolicHeadExit -eq 0) {
|
||||
throw "Fresh public promotion requires detached HEAD, not branch $symbolicHead"
|
||||
}
|
||||
if ($symbolicHeadExit -ne 1) {
|
||||
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
|
||||
}
|
||||
|
||||
$actualCommit = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--verify", "HEAD")
|
||||
$actualTree = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
|
||||
if ($actualCommit -ne $SourceCommit.ToLowerInvariant()) {
|
||||
throw "Fresh public source commit drift: expected=$SourceCommit actual=$actualCommit"
|
||||
}
|
||||
if ($actualTree -ne $SourceTree.ToLowerInvariant()) {
|
||||
throw "Fresh public source tree drift: expected=$SourceTree actual=$actualTree"
|
||||
}
|
||||
$dirty = Invoke-StableGitText -SourceRoot $resolvedSourceRoot -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
|
||||
if ($dirty) {
|
||||
throw "Fresh public promotion requires a clean stable source"
|
||||
}
|
||||
|
||||
foreach ($pin in @(
|
||||
[pscustomobject]@{ Path = $PythonPath; Sha256 = $PythonSha256; Label = "Python" },
|
||||
[pscustomobject]@{ Path = $CloudflaredPath; Sha256 = $CloudflaredSha256; Label = "cloudflared" },
|
||||
[pscustomobject]@{ Path = $ConfigPath; Sha256 = $ConfigSha256; Label = "cloudflared config" }
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $pin.Path -PathType Leaf)) {
|
||||
throw "Pinned $($pin.Label) file not found at $($pin.Path)"
|
||||
}
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $pin.Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualSha256 -ne $pin.Sha256.ToLowerInvariant()) {
|
||||
throw "Pinned $($pin.Label) SHA256 drift"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-CloudflaredIngress {
|
||||
param(
|
||||
[string]$ConfigPath,
|
||||
[string[]]$ApiHostnames,
|
||||
[string[]]$WebHostnames,
|
||||
[int]$ApiPortValue,
|
||||
[int]$WebPortValue
|
||||
[int]$WebPortValue,
|
||||
[switch]$RequireUnchanged
|
||||
)
|
||||
|
||||
$lines = Get-Content -Encoding UTF8 -Path $ConfigPath
|
||||
|
|
@ -196,7 +640,21 @@ function Set-CloudflaredIngress {
|
|||
}
|
||||
$nextLines += " - service: http_status:404"
|
||||
|
||||
Set-Content -Encoding UTF8 -Path $ConfigPath -Value $nextLines
|
||||
$matches = $lines.Count -eq $nextLines.Count
|
||||
if ($matches) {
|
||||
for ($i = 0; $i -lt $lines.Count; $i++) {
|
||||
if ($lines[$i] -cne $nextLines[$i]) {
|
||||
$matches = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($RequireUnchanged -and -not $matches) {
|
||||
throw "Pinned cloudflared config ingress does not match the requested public topology"
|
||||
}
|
||||
if (-not $matches) {
|
||||
Set-Content -Encoding UTF8 -Path $ConfigPath -Value $nextLines
|
||||
}
|
||||
}
|
||||
|
||||
if (!(Test-Path $Python)) {
|
||||
|
|
@ -208,6 +666,46 @@ if (!(Test-Path $ApiDir)) {
|
|||
if (!(Test-Path $WebDir)) {
|
||||
throw "Web directory not found at $WebDir"
|
||||
}
|
||||
foreach ($voiceScript in @($WhisperStartScript, $MeloTtsStartScript, $VoiceSidecarProbe)) {
|
||||
if (!(Test-Path -LiteralPath $voiceScript)) {
|
||||
throw "Voice sidecar prerequisite not found at $voiceScript"
|
||||
}
|
||||
}
|
||||
if ($RequireFreshPublicProvenance) {
|
||||
if (!(Test-Path -LiteralPath $Cloudflared -PathType Leaf)) {
|
||||
throw "cloudflared not found at $Cloudflared"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $CloudflaredConfig -PathType Leaf)) {
|
||||
throw "cloudflared config not found at $CloudflaredConfig"
|
||||
}
|
||||
if (-not $RuntimeProvenancePath) {
|
||||
$RuntimeProvenancePath = Join-Path $Workspace "public-runtime-launch-provenance.log"
|
||||
}
|
||||
|
||||
Assert-FreshPublicProvenanceContract `
|
||||
-SourceRoot $Workspace `
|
||||
-SourceCommit $ExpectedSourceCommit `
|
||||
-SourceTree $ExpectedSourceTree `
|
||||
-PythonPath $Python `
|
||||
-PythonSha256 $ExpectedPythonSha256 `
|
||||
-CloudflaredPath $Cloudflared `
|
||||
-CloudflaredSha256 $ExpectedCloudflaredSha256 `
|
||||
-ConfigPath $CloudflaredConfig `
|
||||
-ConfigSha256 $ExpectedCloudflaredConfigSha256
|
||||
|
||||
$resolvedRuntimeProvenancePath = Initialize-RuntimeProvenanceOutput `
|
||||
-OutputPath $RuntimeProvenancePath
|
||||
|
||||
# 승격 모드에서 config를 재작성하면 사전 pin과 실제 tunnel 입력이 달라진다.
|
||||
# exact ingress가 이미 들어 있는 경우에만 이후 프로세스 mutation으로 진행한다.
|
||||
Set-CloudflaredIngress `
|
||||
-ConfigPath $CloudflaredConfig `
|
||||
-ApiHostnames $PublicApiHostnames `
|
||||
-WebHostnames $PublicWebHostnames `
|
||||
-ApiPortValue $ApiPort `
|
||||
-WebPortValue $WebPort `
|
||||
-RequireUnchanged
|
||||
}
|
||||
|
||||
# 재기동 판정은 /health(프로세스 liveness)가 아니라 /ready(실제 claude -p 생성)로 한다.
|
||||
# 프로세스는 살아 있는데 그 프로세스의 claude 세션만 죽은 상태는 /health를 통과하므로,
|
||||
|
|
@ -222,7 +720,12 @@ if ($SkipEngineRestart) {
|
|||
throw "Engine gateway is not ready on http://127.0.0.1:$EnginePort/ready"
|
||||
}
|
||||
} elseif (-not $engineReady) {
|
||||
Stop-UvicornByPort -AppImport "engine_gateway.gateway:app" -Port $EnginePort
|
||||
$null = @(
|
||||
Stop-UvicornByPort `
|
||||
-AppImport "engine_gateway.gateway:app" `
|
||||
-Port $EnginePort `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds
|
||||
)
|
||||
|
||||
# Start-Process는 리다이렉트 대상 로그를 덮어쓴다. 직전 사고 로그를 보존해야
|
||||
# 재기동 후에도 원인을 추적할 수 있다.
|
||||
|
|
@ -255,6 +758,12 @@ $env:AUTH_DEV_LOGIN_ENABLED = "false"
|
|||
$env:AUTO_SEED_PERSONAS = "false"
|
||||
$env:ALLOW_SEED_PERSONA_FALLBACK = "false"
|
||||
$env:VIGNETTE_VOICE_POC_SAMPLE_TTS = "false"
|
||||
$env:VIGNETTE_VOICE_STT_PROVIDER = "local_whisper"
|
||||
$env:VIGNETTE_LOCAL_WHISPER_STT_URL = "ws://127.0.0.1:$WhisperPort/v1/listen"
|
||||
$env:VIGNETTE_LOCAL_WHISPER_STT_MODEL = $WhisperModel
|
||||
$env:VIGNETTE_LOCAL_WHISPER_STT_LANGUAGE = $WhisperLanguage
|
||||
$env:VIGNETTE_VOICE_TTS_PROVIDER = "melotts"
|
||||
$env:VIGNETTE_MELOTTS_TTS_URL = "http://127.0.0.1:$MeloTtsPort"
|
||||
$env:FRONTEND_BASE_URL = "https://vignette.chanpaca.net"
|
||||
$frontendOrigins = @("https://vignette.chanpaca.net", "https://vnet.18ka.net", "https://vignette-b1q.pages.dev")
|
||||
$localViteOrigins = @()
|
||||
|
|
@ -268,30 +777,117 @@ $env:FRONTEND_ORIGIN_MAP = ConvertTo-CompactJson -Value ([ordered]@{
|
|||
"api-vnet.18ka.net" = "https://vnet.18ka.net"
|
||||
})
|
||||
|
||||
# 포트 리스너만으로는 올바른 provider/model을 증명하지 못한다. 첫 WS ready
|
||||
# 프레임과 MeloTTS health metadata가 운영 계약과 정확히 일치할 때만 API를
|
||||
# 유지하거나 재시작한다. 잘못된 기존 리스너는 소유권을 추측해 종료하지 않는다.
|
||||
if (-not (Test-VoiceSidecarReady -Component "stt")) {
|
||||
if (Test-PortListener -Port $WhisperPort) {
|
||||
throw "Port $WhisperPort is occupied but does not expose the exact local_whisper/$WhisperModel/$WhisperDevice protocol"
|
||||
}
|
||||
& $WhisperStartScript `
|
||||
-Port $WhisperPort `
|
||||
-Model $WhisperModel `
|
||||
-Device $WhisperDevice `
|
||||
-WaitReadySeconds 0
|
||||
if (-not (Wait-VoiceSidecarReady -Component "stt" -TimeoutSec $VoiceSidecarReadySeconds)) {
|
||||
throw "local_whisper/$WhisperModel/$WhisperDevice did not become exactly ready before the API restart gate"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-VoiceSidecarReady -Component "tts")) {
|
||||
if (Test-PortListener -Port $MeloTtsPort) {
|
||||
throw "Port $MeloTtsPort is occupied but does not expose the exact melotts/$MeloTtsModel health contract"
|
||||
}
|
||||
& $MeloTtsStartScript `
|
||||
-Port $MeloTtsPort `
|
||||
-Language $MeloTtsLanguage `
|
||||
-Device "cpu" `
|
||||
-WaitReadySeconds 0
|
||||
if (-not (Wait-VoiceSidecarReady -Component "tts" -TimeoutSec $VoiceSidecarReadySeconds)) {
|
||||
throw "melotts/$MeloTtsModel did not become exactly ready before the API restart gate"
|
||||
}
|
||||
}
|
||||
|
||||
# 두 sidecar를 한 번 더 함께 검사해 개별 probe 사이의 TOCTOU를 닫는다.
|
||||
if (-not (Test-VoiceSidecarReady -Component "stt") -or -not (Test-VoiceSidecarReady -Component "tts")) {
|
||||
throw "Voice sidecar readiness changed before the API restart gate"
|
||||
}
|
||||
|
||||
$health = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/health"
|
||||
$apiControlPlaneReady = $null -ne $health -and $health.environment -eq "prod" -and $health.db
|
||||
$voiceHealth = Get-JsonHealth -Uri "http://127.0.0.1:$ApiPort/voice/health"
|
||||
$apiControlPlaneReady = (
|
||||
$null -ne $health -and
|
||||
$health.environment -eq "prod" -and
|
||||
$health.db -eq $true -and
|
||||
$health.engine -eq $true -and
|
||||
(Test-VoiceApiReady -Health $voiceHealth)
|
||||
)
|
||||
$proc = $null
|
||||
$apiStoppedProcessIds = @()
|
||||
$apiLaunchIdentity = $null
|
||||
if ($apiControlPlaneReady -and -not $ForceApiRestart) {
|
||||
Write-Output "Admin/auth control plane already healthy; skipping API restart"
|
||||
Write-Output "Production API and exact local voice stack already healthy; skipping API restart"
|
||||
} else {
|
||||
Stop-UvicornByPort -AppImport "app.main:app" -Port $ApiPort
|
||||
$apiStoppedProcessIds = @(
|
||||
Stop-UvicornByPort `
|
||||
-AppImport "app.main:app" `
|
||||
-Port $ApiPort `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds
|
||||
)
|
||||
|
||||
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$ApiPort") `
|
||||
-ArgumentList @(
|
||||
"-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "$ApiPort",
|
||||
"--ws", "websockets",
|
||||
"--ws-max-queue", "4"
|
||||
) `
|
||||
-WorkingDirectory $ApiDir `
|
||||
-RedirectStandardOutput $OutLog `
|
||||
-RedirectStandardError $ErrLog `
|
||||
-PassThru
|
||||
|
||||
if ($RequireFreshPublicProvenance) {
|
||||
if ($apiStoppedProcessIds -contains $proc.Id) {
|
||||
throw "Fresh public API did not receive a replacement PID"
|
||||
}
|
||||
$apiLaunchIdentity = Wait-ProcessIdentity `
|
||||
-ProcessId $proc.Id `
|
||||
-Role "api" `
|
||||
-ExpectedCwd $ApiDir `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds
|
||||
if ($apiLaunchIdentity.executable_sha256 -ne $ExpectedPythonSha256.ToLowerInvariant()) {
|
||||
throw "Fresh public API executable SHA256 does not match the pinned Python"
|
||||
}
|
||||
foreach ($requiredArgument in @("uvicorn", "app.main:app", "--port", "$ApiPort", "--ws-max-queue", "4")) {
|
||||
if ($apiLaunchIdentity.command_line.IndexOf($requiredArgument, [System.StringComparison]::Ordinal) -lt 0) {
|
||||
throw "Fresh public API command line is missing required argument: $requiredArgument"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$health = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$ApiPort/health" `
|
||||
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db } `
|
||||
-IsHealthy {
|
||||
param($health)
|
||||
$health.environment -eq "prod" -and
|
||||
$health.db -eq $true -and
|
||||
$health.engine -eq $true
|
||||
} `
|
||||
-TimeoutSec 30
|
||||
$voiceHealth = Wait-JsonHealth `
|
||||
-Uri "http://127.0.0.1:$ApiPort/voice/health" `
|
||||
-IsHealthy { param($health) Test-VoiceApiReady -Health $health } `
|
||||
-TimeoutSec 30
|
||||
}
|
||||
if ($health.environment -ne "prod" -or -not $health.db) {
|
||||
if ($health.environment -ne "prod" -or -not $health.db -or -not $health.engine) {
|
||||
throw "Admin/auth control plane is not production-safe: $($health | ConvertTo-Json -Compress)"
|
||||
}
|
||||
if (-not (Test-VoiceApiReady -Health $voiceHealth)) {
|
||||
throw "Public voice API does not match the exact local provider/model contract: $($voiceHealth | ConvertTo-Json -Compress)"
|
||||
}
|
||||
|
||||
if (!$SkipWebRestart) {
|
||||
Stop-NodeByPortHint -Port $WebPort
|
||||
|
|
@ -316,6 +912,9 @@ if (!$SkipWebRestart) {
|
|||
Wait-HttpStatus -Uri "http://127.0.0.1:$WebPort/" -TimeoutSec 30 | Out-Null
|
||||
}
|
||||
|
||||
$cloudflaredProcess = $null
|
||||
$cloudflaredLaunchIdentity = $null
|
||||
$cloudflaredStoppedProcessIds = @()
|
||||
if (!$SkipCloudflaredRestart) {
|
||||
if (!(Test-Path $Cloudflared)) {
|
||||
throw "cloudflared not found at $Cloudflared"
|
||||
|
|
@ -324,11 +923,8 @@ if (!$SkipCloudflaredRestart) {
|
|||
throw "cloudflared config not found at $CloudflaredConfig"
|
||||
}
|
||||
|
||||
$apiHostnames = @("api-vignette.chanpaca.net", "api-vnet.18ka.net")
|
||||
$webHostnames = @("vnet.18ka.net")
|
||||
|
||||
if ($RouteCloudflareDns) {
|
||||
foreach ($hostname in ($webHostnames + $apiHostnames)) {
|
||||
foreach ($hostname in ($PublicWebHostnames + $PublicApiHostnames)) {
|
||||
& $Cloudflared tunnel route dns $CloudflareTunnelName $hostname
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "cloudflared DNS route failed for $hostname"
|
||||
|
|
@ -336,25 +932,152 @@ if (!$SkipCloudflaredRestart) {
|
|||
}
|
||||
}
|
||||
|
||||
Set-CloudflaredIngress `
|
||||
-ConfigPath $CloudflaredConfig `
|
||||
-ApiHostnames $apiHostnames `
|
||||
-WebHostnames $webHostnames `
|
||||
-ApiPortValue $ApiPort `
|
||||
-WebPortValue $WebPort
|
||||
|
||||
$cloudflaredProcess = Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -eq "cloudflared.exe" -and $_.CommandLine -like "*vignette-config.yml*" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $cloudflaredProcess) {
|
||||
Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
|
||||
-ArgumentList @("tunnel", "--config", $CloudflaredConfig, "run") `
|
||||
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
|
||||
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
|
||||
-PassThru | Out-Null
|
||||
} else {
|
||||
Write-Output "Cloudflared already running; skipping tunnel restart"
|
||||
if (-not $RequireFreshPublicProvenance) {
|
||||
Set-CloudflaredIngress `
|
||||
-ConfigPath $CloudflaredConfig `
|
||||
-ApiHostnames $PublicApiHostnames `
|
||||
-WebHostnames $PublicWebHostnames `
|
||||
-ApiPortValue $ApiPort `
|
||||
-WebPortValue $WebPort
|
||||
}
|
||||
|
||||
$resolvedCloudflaredConfig = (Resolve-Path -LiteralPath $CloudflaredConfig).Path
|
||||
if ($RequireFreshPublicProvenance) {
|
||||
$existingCloudflaredProcesses = @(
|
||||
Get-CloudflaredProcessesForConfig `
|
||||
-ConfigPath $resolvedCloudflaredConfig `
|
||||
-ExactPath
|
||||
)
|
||||
} else {
|
||||
$existingCloudflaredProcesses = @(
|
||||
Get-CloudflaredProcessesForConfig -ConfigPath $resolvedCloudflaredConfig
|
||||
)
|
||||
}
|
||||
$cloudflaredStoppedProcessIds = @(
|
||||
Stop-ProcessesBounded `
|
||||
-Processes $existingCloudflaredProcesses `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds `
|
||||
-Role "cloudflared for $resolvedCloudflaredConfig"
|
||||
)
|
||||
|
||||
$cloudflaredProcess = Start-Process -WindowStyle Hidden -FilePath $Cloudflared `
|
||||
-ArgumentList @("tunnel", "--config", $resolvedCloudflaredConfig, "run") `
|
||||
-WorkingDirectory $Workspace `
|
||||
-RedirectStandardOutput (Join-Path $Workspace "cloudflared.public.out.log") `
|
||||
-RedirectStandardError (Join-Path $Workspace "cloudflared.public.err.log") `
|
||||
-PassThru
|
||||
|
||||
if ($cloudflaredStoppedProcessIds -contains $cloudflaredProcess.Id) {
|
||||
throw "Cloudflared did not receive a replacement PID"
|
||||
}
|
||||
if ($RequireFreshPublicProvenance) {
|
||||
$cloudflaredLaunchIdentity = Wait-ProcessIdentity `
|
||||
-ProcessId $cloudflaredProcess.Id `
|
||||
-Role "cloudflared" `
|
||||
-ExpectedCwd $Workspace `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds
|
||||
if ($cloudflaredLaunchIdentity.executable_sha256 -ne $ExpectedCloudflaredSha256.ToLowerInvariant()) {
|
||||
throw "Fresh cloudflared executable SHA256 does not match its pin"
|
||||
}
|
||||
if ($cloudflaredLaunchIdentity.command_line.IndexOf($resolvedCloudflaredConfig, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
||||
throw "Fresh cloudflared command line is not pinned to the expected config"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($RequireFreshPublicProvenance) {
|
||||
if ($null -eq $apiLaunchIdentity -or $null -eq $cloudflaredLaunchIdentity) {
|
||||
throw "Fresh public promotion did not produce both API and cloudflared identities"
|
||||
}
|
||||
|
||||
$apiFinalIdentity = Wait-ProcessIdentity `
|
||||
-ProcessId $apiLaunchIdentity.pid `
|
||||
-Role "api" `
|
||||
-ExpectedCwd $ApiDir `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds
|
||||
$cloudflaredFinalIdentity = Wait-ProcessIdentity `
|
||||
-ProcessId $cloudflaredLaunchIdentity.pid `
|
||||
-Role "cloudflared" `
|
||||
-ExpectedCwd $Workspace `
|
||||
-TimeoutSec $ProcessStopTimeoutSeconds
|
||||
foreach ($identityPair in @(
|
||||
[pscustomobject]@{ Role = "api"; Launch = $apiLaunchIdentity; Final = $apiFinalIdentity },
|
||||
[pscustomobject]@{ Role = "cloudflared"; Launch = $cloudflaredLaunchIdentity; Final = $cloudflaredFinalIdentity }
|
||||
)) {
|
||||
foreach ($field in @("pid", "started_at_utc", "executable_sha256", "command_line_sha256", "cwd")) {
|
||||
if ($identityPair.Launch[$field].ToString() -cne $identityPair.Final[$field].ToString()) {
|
||||
throw "Fresh $($identityPair.Role) provenance drifted before receipt: $field"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$finalConfigSha256 = (Get-FileHash -LiteralPath $CloudflaredConfig -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($finalConfigSha256 -ne $ExpectedCloudflaredConfigSha256.ToLowerInvariant()) {
|
||||
throw "Pinned cloudflared config drifted before provenance receipt"
|
||||
}
|
||||
$psutilVersionArgs = @("-X", "utf8", "-c", "import importlib.metadata; print(importlib.metadata.version('psutil'))")
|
||||
$psutilVersion = (@(& $Python @psutilVersionArgs) -join [Environment]::NewLine).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $psutilVersion) {
|
||||
throw "Could not record the psutil version used for process provenance"
|
||||
}
|
||||
$safeApiIdentity = ConvertTo-SafeProcessIdentity -Identity $apiFinalIdentity
|
||||
$safeCloudflaredIdentity = ConvertTo-SafeProcessIdentity -Identity $cloudflaredFinalIdentity
|
||||
|
||||
$provenance = [ordered]@{
|
||||
schema_version = "vignette.public-runtime-launch-provenance.v1"
|
||||
status = "passed"
|
||||
captured_at_utc = (Get-Date).ToUniversalTime().ToString("o")
|
||||
source = [ordered]@{
|
||||
repo_root = (Resolve-Path -LiteralPath $Workspace).Path
|
||||
git_commit = $ExpectedSourceCommit.ToLowerInvariant()
|
||||
git_tree = $ExpectedSourceTree.ToLowerInvariant()
|
||||
launcher_sha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
clean_detached_head = $true
|
||||
}
|
||||
config = [ordered]@{
|
||||
path = (Resolve-Path -LiteralPath $CloudflaredConfig).Path
|
||||
sha256 = $finalConfigSha256
|
||||
}
|
||||
replacement = [ordered]@{
|
||||
api_stopped_pids = @($apiStoppedProcessIds)
|
||||
cloudflared_stopped_pids = @($cloudflaredStoppedProcessIds)
|
||||
api_new_pid = [int]$apiFinalIdentity.pid
|
||||
cloudflared_new_pid = [int]$cloudflaredFinalIdentity.pid
|
||||
}
|
||||
processes = [ordered]@{
|
||||
api = $safeApiIdentity
|
||||
cloudflared = $safeCloudflaredIdentity
|
||||
}
|
||||
topology_inputs = [ordered]@{
|
||||
repo_root = (Resolve-Path -LiteralPath $Workspace).Path
|
||||
git_sha = $ExpectedSourceCommit.ToLowerInvariant()
|
||||
api_pid = [int]$apiFinalIdentity.pid
|
||||
api_started_at_utc = $apiFinalIdentity.started_at_utc
|
||||
api_executable_name = $apiFinalIdentity.executable_name
|
||||
api_executable_sha256 = $apiFinalIdentity.executable_sha256
|
||||
api_command_line_sha256 = $apiFinalIdentity.command_line_sha256
|
||||
api_cwd = $apiFinalIdentity.cwd
|
||||
api_listen_port = $ApiPort
|
||||
cloudflared_pid = [int]$cloudflaredFinalIdentity.pid
|
||||
cloudflared_started_at_utc = $cloudflaredFinalIdentity.started_at_utc
|
||||
cloudflared_executable_name = $cloudflaredFinalIdentity.executable_name
|
||||
cloudflared_executable_sha256 = $cloudflaredFinalIdentity.executable_sha256
|
||||
cloudflared_command_line_sha256 = $cloudflaredFinalIdentity.command_line_sha256
|
||||
cloudflared_cwd = $cloudflaredFinalIdentity.cwd
|
||||
psutil_version = $psutilVersion
|
||||
}
|
||||
}
|
||||
$provenanceJson = ConvertTo-Json -InputObject $provenance -Depth 8
|
||||
try {
|
||||
Write-Utf8TextAtomically `
|
||||
-OutputPath $resolvedRuntimeProvenancePath `
|
||||
-Value ($provenanceJson + [Environment]::NewLine)
|
||||
} catch {
|
||||
# 새 PID들은 이미 health/identity gate를 통과했지만, atomic receipt가 없으면
|
||||
# 승격 성공으로 간주할 수 없다. 기존 receipt는 보존되고 호출은 non-zero로 끝난다.
|
||||
throw "Fresh public promotion failed closed after runtime replacement: no atomic passed receipt was published. Re-run the pinned promotion after fixing the receipt destination. $($_.Exception.Message)"
|
||||
}
|
||||
Write-Output "Fresh public provenance: $resolvedRuntimeProvenancePath"
|
||||
}
|
||||
|
||||
if ($engineReady) {
|
||||
|
|
@ -371,3 +1094,4 @@ if (!$SkipWebRestart) {
|
|||
Write-Output "Public vnet web preview running on http://127.0.0.1:$WebPort"
|
||||
}
|
||||
Write-Output "Health: $($health | ConvertTo-Json -Compress)"
|
||||
Write-Output "Voice health: $($voiceHealth | ConvertTo-Json -Compress)"
|
||||
|
|
|
|||
|
|
@ -265,6 +265,153 @@ def topology() -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def windows_topology() -> dict[str, object]:
|
||||
process_metrics = {
|
||||
"api": {
|
||||
"rss_bytes": 200_000,
|
||||
"peak_rss_bytes": 240_000,
|
||||
"cpu_time_seconds": 2.0,
|
||||
"cpu_percent": 3.0,
|
||||
"handles": 40,
|
||||
"threads": 8,
|
||||
},
|
||||
"cloudflared": {
|
||||
"rss_bytes": 100_000,
|
||||
"peak_rss_bytes": 120_000,
|
||||
"cpu_time_seconds": 1.0,
|
||||
"cpu_percent": 1.5,
|
||||
"handles": 20,
|
||||
"threads": 4,
|
||||
},
|
||||
}
|
||||
process_tcp = {
|
||||
"api": {"connections": 2, "established": 1, "listeners": 1},
|
||||
"cloudflared": {"connections": 4, "established": 4, "listeners": 0},
|
||||
}
|
||||
samples = [
|
||||
{
|
||||
"sequence": index + 1,
|
||||
"observed_at_utc": "2026-08-07T00:30:00Z",
|
||||
"processes": copy.deepcopy(process_metrics),
|
||||
"process_tcp": copy.deepcopy(process_tcp),
|
||||
"host_tcp": {"connections": 20},
|
||||
"api_listener": {
|
||||
"port": 8001,
|
||||
"owned_listener_count": 1,
|
||||
"conflicting_listener_count": 0,
|
||||
},
|
||||
}
|
||||
for index in range(31)
|
||||
]
|
||||
return {
|
||||
"schema_version": "vignette.g7-topology-evidence.v1",
|
||||
"topology_mode": "windows_host",
|
||||
"status": "passed",
|
||||
"started_at_utc": "2026-08-07T00:00:00Z",
|
||||
"ended_at_utc": "2026-08-07T01:00:00Z",
|
||||
"scope": {
|
||||
"metadata_only": True,
|
||||
"raw_command_output_retained": False,
|
||||
"socket_endpoints_retained": False,
|
||||
"request_payloads_retained": False,
|
||||
"audio_retained": False,
|
||||
"transcripts_retained": False,
|
||||
"topology_boundary": "windows_host_api_and_cloudflared_processes",
|
||||
"configured_listener_port_retained": True,
|
||||
"cloudflare_edge": {
|
||||
"internal_queue_measured": False,
|
||||
"evidence_boundary": "separate_external_artifact_required",
|
||||
},
|
||||
},
|
||||
"requested": {
|
||||
"public_host": "api.example.test",
|
||||
"repo_root": r"D:\workspace\vignette",
|
||||
"git_sha": "5" * 40,
|
||||
"source_pin": {
|
||||
"git_tree_sha": "8" * 40,
|
||||
"script_sha256": {
|
||||
"runner": "9" * 64,
|
||||
"collector": "a" * 64,
|
||||
"checker": "b" * 64,
|
||||
},
|
||||
"runtime_dependencies": {"psutil": "6.1.1"},
|
||||
},
|
||||
"samples": 31,
|
||||
"interval_seconds": 100.0,
|
||||
"api_listen_port": 8001,
|
||||
"roles": {
|
||||
"api": {
|
||||
"pid": 301,
|
||||
"expected_executable_name": "python.exe",
|
||||
"expected_executable_sha256": "3" * 64,
|
||||
"expected_cwd": r"D:\workspace\vignette\apps\api",
|
||||
},
|
||||
"cloudflared": {
|
||||
"pid": 302,
|
||||
"expected_executable_name": "cloudflared.exe",
|
||||
"expected_executable_sha256": "4" * 64,
|
||||
"expected_cwd": r"D:\workspace\vignette",
|
||||
},
|
||||
},
|
||||
},
|
||||
"source_provenance": {
|
||||
"detached_head": True,
|
||||
"tracked_clean": True,
|
||||
"git_sha": "5" * 40,
|
||||
"git_tree_sha": "8" * 40,
|
||||
"script_sha256": {
|
||||
"runner": "9" * 64,
|
||||
"collector": "a" * 64,
|
||||
"checker": "b" * 64,
|
||||
},
|
||||
"runtime_dependencies": {"psutil": "6.1.1"},
|
||||
},
|
||||
"samples_completed": 31,
|
||||
"targets": {
|
||||
"api": {
|
||||
"role": "api",
|
||||
"pid": 301,
|
||||
"started_at": "2026-08-06T00:00:00Z",
|
||||
"executable_name": "python.exe",
|
||||
"executable_sha256": "3" * 64,
|
||||
"command_line_sha256": "6" * 64,
|
||||
"git_sha": "5" * 40,
|
||||
"cwd": r"D:\workspace\vignette\apps\api",
|
||||
},
|
||||
"cloudflared": {
|
||||
"role": "cloudflared",
|
||||
"pid": 302,
|
||||
"started_at": "2026-08-06T00:00:00Z",
|
||||
"executable_name": "cloudflared.exe",
|
||||
"executable_sha256": "4" * 64,
|
||||
"command_line_sha256": "7" * 64,
|
||||
"git_sha": "5" * 40,
|
||||
"cwd": r"D:\workspace\vignette",
|
||||
},
|
||||
},
|
||||
"samples": samples,
|
||||
"summary": {
|
||||
"processes": {
|
||||
role: {
|
||||
**{f"{field}_max": value for field, value in metrics.items()},
|
||||
**{
|
||||
f"tcp_{field}_max": value
|
||||
for field, value in process_tcp[role].items()
|
||||
},
|
||||
}
|
||||
for role, metrics in process_metrics.items()
|
||||
},
|
||||
"api_listener": {
|
||||
"port": 8001,
|
||||
"owned_listener_count_min": 1,
|
||||
"conflicting_listener_count_max": 0,
|
||||
},
|
||||
"host_tcp": {"connections_max": 20},
|
||||
},
|
||||
"failure_type": None,
|
||||
}
|
||||
|
||||
|
||||
class G7ExternalProofTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
|
|
@ -286,6 +433,97 @@ class G7ExternalProofTests(unittest.TestCase):
|
|||
self.assertTrue(gain["passed"])
|
||||
self.assertGreaterEqual(gain["held_out_participants"], 30)
|
||||
|
||||
def test_windows_host_topology_passes_without_weakening_compose(self) -> None:
|
||||
errors: list[str] = []
|
||||
payload = windows_topology()
|
||||
compose_payload = topology()
|
||||
compose_payload["topology_mode"] = "linux_compose"
|
||||
|
||||
self.checker.validate_topology(payload, errors)
|
||||
self.checker.validate_topology(compose_payload, errors)
|
||||
self.checker.validate_binding(public_soak(), runtime(), payload, errors)
|
||||
|
||||
self.assertEqual([], errors)
|
||||
|
||||
def test_windows_host_identity_listener_and_summary_fail_closed(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
lambda payload: payload["targets"]["api"].__setitem__("pid", 999),
|
||||
"topology:api_pid_pin",
|
||||
),
|
||||
(
|
||||
lambda payload: payload["samples"][0]["api_listener"].__setitem__(
|
||||
"owned_listener_count", 0
|
||||
),
|
||||
"topology:listener_owner_sample",
|
||||
),
|
||||
(
|
||||
lambda payload: payload["summary"]["processes"]["api"].__setitem__(
|
||||
"rss_bytes_max", 1
|
||||
),
|
||||
"topology:api_rss_bytes_max",
|
||||
),
|
||||
)
|
||||
for mutate, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
payload = windows_topology()
|
||||
mutate(payload)
|
||||
errors: list[str] = []
|
||||
self.checker.validate_topology(payload, errors)
|
||||
self.assertIn(failure, errors)
|
||||
|
||||
def test_windows_source_and_toolchain_provenance_fail_closed(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
lambda payload: payload["source_provenance"].__setitem__(
|
||||
"detached_head", False
|
||||
),
|
||||
"topology:windows_detached_head",
|
||||
),
|
||||
(
|
||||
lambda payload: payload["source_provenance"].__setitem__(
|
||||
"tracked_clean", False
|
||||
),
|
||||
"topology:windows_tracked_clean",
|
||||
),
|
||||
(
|
||||
lambda payload: payload["source_provenance"].__setitem__(
|
||||
"git_tree_sha", "c" * 40
|
||||
),
|
||||
"topology:windows_source_git_tree_sha",
|
||||
),
|
||||
(
|
||||
lambda payload: payload["source_provenance"]["script_sha256"].__setitem__(
|
||||
"runner", "c" * 64
|
||||
),
|
||||
"topology:windows_source_script_sha256",
|
||||
),
|
||||
(
|
||||
lambda payload: payload["source_provenance"][
|
||||
"runtime_dependencies"
|
||||
].__setitem__("psutil", "6.1.0"),
|
||||
"topology:windows_source_psutil_version",
|
||||
),
|
||||
)
|
||||
for mutate, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
payload = windows_topology()
|
||||
mutate(payload)
|
||||
errors: list[str] = []
|
||||
self.checker.validate_topology(payload, errors)
|
||||
self.assertIn(failure, errors)
|
||||
|
||||
unpinned_version = windows_topology()
|
||||
unpinned_version["requested"]["source_pin"]["runtime_dependencies"][
|
||||
"psutil"
|
||||
] = "6.1.0"
|
||||
unpinned_version["source_provenance"]["runtime_dependencies"][
|
||||
"psutil"
|
||||
] = "6.1.0"
|
||||
errors = []
|
||||
self.checker.validate_topology(unpinned_version, errors)
|
||||
self.assertIn("topology:windows_psutil_version_pin", errors)
|
||||
|
||||
def test_decided_local_stack_is_accepted(self) -> None:
|
||||
"""2026-08-08 소유자 결정: 노트북 faster-whisper STT + MeloTTS TTS (둘 다 MIT)."""
|
||||
|
||||
|
|
@ -353,6 +591,44 @@ class G7ExternalProofTests(unittest.TestCase):
|
|||
self.assertIn("voice_soak:physical_microphone_used", errors)
|
||||
self.assertIn("binding:no_concurrent_overlap", errors)
|
||||
|
||||
def test_concurrent_window_requires_full_3000_second_intersection(self) -> None:
|
||||
exact_errors: list[str] = []
|
||||
exact_voice = public_soak()
|
||||
exact_voice["ended_at_utc"] = "2026-08-07T00:50:00Z"
|
||||
exact_runtime = runtime()
|
||||
exact_runtime["ended_at_utc"] = "2026-08-07T00:50:00Z"
|
||||
exact_topology = topology()
|
||||
exact_topology["ended_at_utc"] = "2026-08-07T00:50:00Z"
|
||||
|
||||
self.checker.validate_binding(
|
||||
exact_voice,
|
||||
exact_runtime,
|
||||
exact_topology,
|
||||
exact_errors,
|
||||
)
|
||||
|
||||
self.assertEqual([], exact_errors)
|
||||
|
||||
short_errors: list[str] = []
|
||||
short_voice = public_soak()
|
||||
short_voice["ended_at_utc"] = "2026-08-07T00:50:00Z"
|
||||
short_runtime = runtime()
|
||||
short_runtime["started_at_utc"] = "2026-08-07T00:00:01Z"
|
||||
short_runtime["ended_at_utc"] = "2026-08-07T00:50:00Z"
|
||||
short_topology = topology()
|
||||
short_topology["ended_at_utc"] = "2026-08-07T00:50:00Z"
|
||||
self.checker.validate_binding(
|
||||
short_voice,
|
||||
short_runtime,
|
||||
short_topology,
|
||||
short_errors,
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"binding:concurrent_overlap_below_3000_seconds",
|
||||
short_errors,
|
||||
)
|
||||
|
||||
def test_runtime_fallback_and_short_topology_fail(self) -> None:
|
||||
errors: list[str] = []
|
||||
runtime_payload = runtime()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
|
|
@ -11,6 +12,11 @@ from typing import Sequence
|
|||
|
||||
|
||||
RUNNER_PATH = Path(__file__).with_name("capture-g7-topology-evidence.py")
|
||||
REPO_ROOT = RUNNER_PATH.resolve().parents[1]
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def load_runner():
|
||||
|
|
@ -27,6 +33,16 @@ API_ID = "a" * 64
|
|||
CADDY_ID = "c" * 64
|
||||
API_DIGEST = "sha256:" + "1" * 64
|
||||
CADDY_DIGEST = "sha256:" + "2" * 64
|
||||
API_EXE_DIGEST = "3" * 64
|
||||
CLOUDFLARED_EXE_DIGEST = "4" * 64
|
||||
GIT_SHA = "5" * 40
|
||||
GIT_TREE_SHA = "8" * 40
|
||||
PSUTIL_VERSION = "6.1.1"
|
||||
SCRIPT_DIGESTS = {
|
||||
"runner": file_sha256(REPO_ROOT / "scripts/run-g7-external-proof-window.py"),
|
||||
"collector": file_sha256(REPO_ROOT / "scripts/capture-g7-topology-evidence.py"),
|
||||
"checker": file_sha256(REPO_ROOT / "scripts/check-g7-external-proof.py"),
|
||||
}
|
||||
|
||||
|
||||
def inspect_item(
|
||||
|
|
@ -114,9 +130,17 @@ class FixtureRunner:
|
|||
self,
|
||||
*,
|
||||
inspect_outputs: list[str] | None = None,
|
||||
git_outputs: list[str] | None = None,
|
||||
git_branch: str = "HEAD\n",
|
||||
git_status: str = "",
|
||||
git_tree_sha: str = GIT_TREE_SHA,
|
||||
unavailable: set[str] | None = None,
|
||||
) -> None:
|
||||
self.inspect_outputs = list(inspect_outputs or [stable_inspect()])
|
||||
self.git_outputs = list(git_outputs or [GIT_SHA + "\n"])
|
||||
self.git_branch = git_branch
|
||||
self.git_status = git_status
|
||||
self.git_tree_sha = git_tree_sha
|
||||
self.unavailable = unavailable or set()
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
|
|
@ -134,6 +158,21 @@ class FixtureRunner:
|
|||
return stats_output()
|
||||
if command == ("ss", "-tinm"):
|
||||
return SS_OUTPUT
|
||||
if command and command[0] == "git":
|
||||
operation = command[3:]
|
||||
if operation == ("rev-parse", "--abbrev-ref", "HEAD"):
|
||||
return self.git_branch
|
||||
if operation == (
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"--untracked-files=no",
|
||||
):
|
||||
return self.git_status
|
||||
if operation == ("rev-parse", "--verify", "HEAD^{tree}"):
|
||||
return self.git_tree_sha + "\n"
|
||||
if len(self.git_outputs) > 1:
|
||||
return self.git_outputs.pop(0)
|
||||
return self.git_outputs[0]
|
||||
raise AssertionError(f"unexpected command: {command!r}")
|
||||
|
||||
|
||||
|
|
@ -170,6 +209,106 @@ class FixtureSource:
|
|||
return list(self.directories[path])
|
||||
|
||||
|
||||
class FixtureWindowsSource:
|
||||
def __init__(
|
||||
self,
|
||||
module,
|
||||
*,
|
||||
identity_overrides: dict[str, list[dict[str, object]]] | None = None,
|
||||
tcp_overrides: dict[str, object] | None = None,
|
||||
psutil_version: str = PSUTIL_VERSION,
|
||||
) -> None:
|
||||
self.module = module
|
||||
self.identity_overrides = identity_overrides or {}
|
||||
self.tcp_overrides = tcp_overrides or {}
|
||||
self.psutil_version = psutil_version
|
||||
self.metric_calls = {"api": 0, "cloudflared": 0}
|
||||
|
||||
def dependency_version(self, name: str) -> str:
|
||||
if name != "psutil":
|
||||
raise AssertionError(f"unexpected dependency: {name}")
|
||||
return self.psutil_version
|
||||
|
||||
def _base_identity(self, role: str, git_sha: str) -> dict[str, object]:
|
||||
if role == "api":
|
||||
return {
|
||||
"role": role,
|
||||
"pid": 301,
|
||||
"started_at": "2026-08-07T00:00:00Z",
|
||||
"executable_name": "python.exe",
|
||||
"executable_sha256": API_EXE_DIGEST,
|
||||
"command_line_sha256": "6" * 64,
|
||||
"git_sha": git_sha,
|
||||
"cwd": r"D:\workspace\vignette\apps\api",
|
||||
}
|
||||
return {
|
||||
"role": role,
|
||||
"pid": 302,
|
||||
"started_at": "2026-08-07T00:00:00Z",
|
||||
"executable_name": "cloudflared.exe",
|
||||
"executable_sha256": CLOUDFLARED_EXE_DIGEST,
|
||||
"command_line_sha256": "7" * 64,
|
||||
"git_sha": git_sha,
|
||||
"cwd": r"D:\workspace\vignette",
|
||||
}
|
||||
|
||||
def read_identity(self, *, pid: int, role: str, git_sha: str):
|
||||
values = self._base_identity(role, git_sha)
|
||||
overrides = self.identity_overrides.get(role, [])
|
||||
if overrides:
|
||||
values.update(overrides[0])
|
||||
if len(overrides) > 1:
|
||||
overrides.pop(0)
|
||||
self.assert_pid(role, pid)
|
||||
return self.module.WindowsProcessIdentity(**values)
|
||||
|
||||
def assert_pid(self, role: str, pid: int) -> None:
|
||||
expected = 301 if role == "api" else 302
|
||||
if pid != expected:
|
||||
raise AssertionError(f"unexpected {role} pid: {pid}")
|
||||
|
||||
def read_metrics(self, *, pid: int, role: str) -> dict[str, int | float]:
|
||||
self.assert_pid(role, pid)
|
||||
self.metric_calls[role] += 1
|
||||
factor = 2 if role == "api" else 1
|
||||
return {
|
||||
"rss_bytes": 100_000 * factor,
|
||||
"peak_rss_bytes": 120_000 * factor,
|
||||
"cpu_time_seconds": float(self.metric_calls[role] * factor),
|
||||
"cpu_percent": 1.5 * factor,
|
||||
"handles": 20 * factor,
|
||||
"threads": 4 * factor,
|
||||
}
|
||||
|
||||
def read_tcp_metrics(
|
||||
self,
|
||||
*,
|
||||
api_pid: int,
|
||||
cloudflared_pid: int,
|
||||
api_listen_port: int,
|
||||
) -> dict[str, object]:
|
||||
self.assert_pid("api", api_pid)
|
||||
self.assert_pid("cloudflared", cloudflared_pid)
|
||||
payload: dict[str, object] = {
|
||||
"processes": {
|
||||
"api": {"connections": 2, "established": 1, "listeners": 1},
|
||||
"cloudflared": {
|
||||
"connections": 4,
|
||||
"established": 4,
|
||||
"listeners": 0,
|
||||
},
|
||||
},
|
||||
"host_tcp": {"connections": 20},
|
||||
"api_listener": {
|
||||
"port": api_listen_port,
|
||||
"owned_listener_count": 1,
|
||||
"conflicting_listener_count": 0,
|
||||
},
|
||||
}
|
||||
payload.update(self.tcp_overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class G7TopologyEvidenceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
|
|
@ -191,6 +330,31 @@ class G7TopologyEvidenceTests(unittest.TestCase):
|
|||
values.update(overrides)
|
||||
return self.runner_module.CaptureConfig(**values)
|
||||
|
||||
def windows_config(self, **overrides):
|
||||
values = {
|
||||
"public_host": "api.example.test",
|
||||
"repo_root": r"D:\workspace\vignette",
|
||||
"git_sha": GIT_SHA,
|
||||
"git_tree_sha": GIT_TREE_SHA,
|
||||
"runner_script_sha256": SCRIPT_DIGESTS["runner"],
|
||||
"collector_script_sha256": SCRIPT_DIGESTS["collector"],
|
||||
"checker_script_sha256": SCRIPT_DIGESTS["checker"],
|
||||
"psutil_version": PSUTIL_VERSION,
|
||||
"api_pid": 301,
|
||||
"api_executable_name": "python.exe",
|
||||
"api_executable_sha256": API_EXE_DIGEST,
|
||||
"api_cwd": r"D:\workspace\vignette\apps\api",
|
||||
"api_listen_port": 8001,
|
||||
"cloudflared_pid": 302,
|
||||
"cloudflared_executable_name": "cloudflared.exe",
|
||||
"cloudflared_executable_sha256": CLOUDFLARED_EXE_DIGEST,
|
||||
"cloudflared_cwd": r"D:\workspace\vignette",
|
||||
"samples": 2,
|
||||
"interval_seconds": 1.0,
|
||||
}
|
||||
values.update(overrides)
|
||||
return self.runner_module.WindowsHostConfig(**values)
|
||||
|
||||
def test_parsers_emit_numeric_metadata_without_endpoints(self) -> None:
|
||||
source = FixtureSource()
|
||||
targets = self.runner_module.pin_targets(self.config(), FixtureRunner(), source)
|
||||
|
|
@ -231,6 +395,7 @@ class G7TopologyEvidenceTests(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertEqual("passed", evidence["status"])
|
||||
self.assertEqual("linux_compose", evidence["topology_mode"])
|
||||
self.assertEqual(1, evidence["samples_completed"])
|
||||
self.assertEqual(API_ID, evidence["targets"]["api"]["container_id"])
|
||||
self.assertEqual(API_DIGEST, evidence["targets"]["api"]["image_digest"])
|
||||
|
|
@ -328,6 +493,201 @@ class G7TopologyEvidenceTests(unittest.TestCase):
|
|||
self.runner_module.validate_config(config)
|
||||
self.assertEqual(failure, str(caught.exception))
|
||||
|
||||
def test_windows_capture_pins_processes_listener_and_high_water(self) -> None:
|
||||
source = FixtureWindowsSource(self.runner_module)
|
||||
evidence = self.runner_module.capture_windows_evidence(
|
||||
self.windows_config(),
|
||||
FixtureRunner(),
|
||||
source,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
|
||||
self.assertEqual("passed", evidence["status"])
|
||||
self.assertEqual("windows_host", evidence["topology_mode"])
|
||||
self.assertEqual({"api", "cloudflared"}, set(evidence["targets"]))
|
||||
self.assertEqual(
|
||||
{
|
||||
"detached_head": True,
|
||||
"tracked_clean": True,
|
||||
"git_sha": GIT_SHA,
|
||||
"git_tree_sha": GIT_TREE_SHA,
|
||||
"script_sha256": SCRIPT_DIGESTS,
|
||||
"runtime_dependencies": {"psutil": PSUTIL_VERSION},
|
||||
},
|
||||
evidence["source_provenance"],
|
||||
)
|
||||
self.assertEqual(GIT_SHA, evidence["targets"]["api"]["git_sha"])
|
||||
self.assertEqual(
|
||||
API_EXE_DIGEST,
|
||||
evidence["targets"]["api"]["executable_sha256"],
|
||||
)
|
||||
self.assertEqual(
|
||||
r"D:\workspace\vignette\apps\api",
|
||||
evidence["targets"]["api"]["cwd"],
|
||||
)
|
||||
summary = evidence["summary"]
|
||||
self.assertEqual(4.0, summary["processes"]["api"]["cpu_time_seconds_max"])
|
||||
self.assertEqual(1, summary["api_listener"]["owned_listener_count_min"])
|
||||
self.assertEqual(4, summary["processes"]["cloudflared"]["tcp_established_max"])
|
||||
serialized = json.dumps(evidence)
|
||||
self.assertNotIn("127.0.0.1", serialized)
|
||||
|
||||
def test_windows_identity_and_git_drift_fail_closed(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
FixtureRunner(),
|
||||
FixtureWindowsSource(
|
||||
self.runner_module,
|
||||
identity_overrides={
|
||||
"api": [{"started_at": "2999-01-01T00:00:00Z"}]
|
||||
},
|
||||
),
|
||||
"process_started_at_future:api",
|
||||
),
|
||||
(
|
||||
FixtureRunner(),
|
||||
FixtureWindowsSource(
|
||||
self.runner_module,
|
||||
identity_overrides={
|
||||
"api": [
|
||||
{},
|
||||
{"command_line_sha256": "8" * 64},
|
||||
]
|
||||
},
|
||||
),
|
||||
"process_command_line_drift:api",
|
||||
),
|
||||
(
|
||||
FixtureRunner(git_outputs=[GIT_SHA + "\n", "9" * 40 + "\n"]),
|
||||
FixtureWindowsSource(self.runner_module),
|
||||
"git_sha_drift",
|
||||
),
|
||||
)
|
||||
for runner, source, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
|
||||
self.runner_module.capture_windows_evidence(
|
||||
self.windows_config(samples=1),
|
||||
runner,
|
||||
source,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
self.assertEqual(failure, str(caught.exception))
|
||||
|
||||
def test_windows_source_and_toolchain_provenance_fail_closed(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
FixtureRunner(git_branch="master\n"),
|
||||
FixtureWindowsSource(self.runner_module),
|
||||
self.windows_config(samples=1),
|
||||
"git_head_not_detached",
|
||||
),
|
||||
(
|
||||
FixtureRunner(git_status=" M scripts/check-g7-external-proof.py\n"),
|
||||
FixtureWindowsSource(self.runner_module),
|
||||
self.windows_config(samples=1),
|
||||
"git_tracked_worktree_dirty",
|
||||
),
|
||||
(
|
||||
FixtureRunner(git_tree_sha="9" * 40),
|
||||
FixtureWindowsSource(self.runner_module),
|
||||
self.windows_config(samples=1),
|
||||
"git_tree_sha_drift",
|
||||
),
|
||||
(
|
||||
FixtureRunner(),
|
||||
FixtureWindowsSource(self.runner_module),
|
||||
self.windows_config(samples=1, checker_script_sha256="9" * 64),
|
||||
"evidence_script_sha256_drift:checker",
|
||||
),
|
||||
(
|
||||
FixtureRunner(),
|
||||
FixtureWindowsSource(self.runner_module, psutil_version="6.1.0"),
|
||||
self.windows_config(samples=1),
|
||||
"psutil_version_drift",
|
||||
),
|
||||
)
|
||||
for runner, source, config, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
|
||||
self.runner_module.capture_windows_evidence(
|
||||
config,
|
||||
runner,
|
||||
source,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
self.assertEqual(failure, str(caught.exception))
|
||||
|
||||
def test_windows_listener_and_tunnel_connection_are_required(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
{
|
||||
"api_listener": {
|
||||
"port": 8001,
|
||||
"owned_listener_count": 0,
|
||||
"conflicting_listener_count": 0,
|
||||
}
|
||||
},
|
||||
"api_listener_not_owned",
|
||||
),
|
||||
(
|
||||
{
|
||||
"processes": {
|
||||
"api": {
|
||||
"connections": 2,
|
||||
"established": 1,
|
||||
"listeners": 1,
|
||||
},
|
||||
"cloudflared": {
|
||||
"connections": 0,
|
||||
"established": 0,
|
||||
"listeners": 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
"cloudflared_tunnel_connection_missing",
|
||||
),
|
||||
)
|
||||
for overrides, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
source = FixtureWindowsSource(
|
||||
self.runner_module,
|
||||
tcp_overrides=overrides,
|
||||
)
|
||||
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
|
||||
self.runner_module.capture_windows_evidence(
|
||||
self.windows_config(samples=1),
|
||||
FixtureRunner(),
|
||||
source,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
self.assertEqual(failure, str(caught.exception))
|
||||
|
||||
def test_windows_config_rejects_shared_pid_and_outside_cwd(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
self.windows_config(cloudflared_pid=301),
|
||||
"process_targets_not_distinct",
|
||||
),
|
||||
(
|
||||
self.windows_config(api_cwd=r"C:\elsewhere"),
|
||||
"api_cwd_outside_repo",
|
||||
),
|
||||
(
|
||||
self.windows_config(git_tree_sha="not-a-tree"),
|
||||
"git_tree_sha_invalid",
|
||||
),
|
||||
(
|
||||
self.windows_config(psutil_version="6.1.0"),
|
||||
"psutil_version_invalid",
|
||||
),
|
||||
)
|
||||
for config, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
with self.assertRaises(self.runner_module.EvidenceFailure) as caught:
|
||||
self.runner_module.validate_windows_config(config)
|
||||
self.assertEqual(failure, str(caught.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ class FakeTranscriber:
|
|||
|
||||
def default_options(**overrides):
|
||||
options = {
|
||||
"model": "large-v3",
|
||||
"model": MODULE.DEFAULT_MODEL,
|
||||
"language": "ko",
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"channels": 1,
|
||||
|
|
@ -403,6 +403,10 @@ class CliTest(unittest.TestCase):
|
|||
with self.assertRaises(SystemExit):
|
||||
parser.parse_args(["--host", "0.0.0.0"])
|
||||
|
||||
def test_default_model_matches_the_cpu_public_runtime_contract(self) -> None:
|
||||
parser = MODULE.build_parser()
|
||||
self.assertEqual(parser.parse_args([]).model, "small")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ class HealthPayloadTest(unittest.TestCase):
|
|||
def test_health_declares_the_permissive_license_and_no_reference(self) -> None:
|
||||
payload = MODULE.health_payload(FakeSynthesizer(), speakers=["KR"])
|
||||
self.assertEqual(payload["status"], "ok")
|
||||
self.assertEqual(payload["provider"], "melotts")
|
||||
self.assertEqual(payload["model"], "melotts-korean")
|
||||
self.assertEqual(payload["license"], "MIT")
|
||||
self.assertEqual(payload["language"], "KR")
|
||||
self.assertEqual(payload["sample_rate"], 44_100)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ from __future__ import annotations
|
|||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
import tarfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -31,12 +31,25 @@ class FakeRunner:
|
|||
module: Any
|
||||
fail_stage: str | None = None
|
||||
builder_shas: tuple[str, str] = ("a" * 64, "a" * 64)
|
||||
tracked_status: str = ""
|
||||
clean_archive_payloads: tuple[bytes, bytes] = (b"clean archive", b"clean archive")
|
||||
clean_head: str = "b" * 40
|
||||
clean_tree: str = "c" * 40
|
||||
calls: list[str] = field(default_factory=list)
|
||||
commands: dict[str, list[str]] = field(default_factory=dict)
|
||||
working_directories: dict[str, Path | None] = field(default_factory=dict)
|
||||
environments: dict[str, dict[str, str] | None] = field(default_factory=dict)
|
||||
timeouts: dict[str, int | None] = field(default_factory=dict)
|
||||
event_log: list[str] | None = None
|
||||
|
||||
def run(self, stage: str, argv: list[str], **_: Any):
|
||||
def run(self, stage: str, argv: list[str], **kwargs: Any):
|
||||
self.calls.append(stage)
|
||||
self.commands[stage] = list(argv)
|
||||
self.working_directories[stage] = kwargs.get("cwd")
|
||||
self.environments[stage] = kwargs.get("env")
|
||||
self.timeouts[stage] = kwargs.get("timeout")
|
||||
if self.event_log is not None:
|
||||
self.event_log.append(f"runner:{stage}")
|
||||
if stage == self.fail_stage:
|
||||
raise self.module.StageFailure(stage, "synthetic failure")
|
||||
if stage.startswith("release_patch_run_"):
|
||||
|
|
@ -57,6 +70,19 @@ class FakeRunner:
|
|||
return self.module.CommandResult(0, json.dumps(payload), "")
|
||||
if stage == "release_manifest":
|
||||
return self.module.CommandResult(0, json.dumps({"ok": True}), "")
|
||||
if stage == "clean_head_repository":
|
||||
return self.module.CommandResult(0, str(kwargs["cwd"].resolve()) + "\n", "")
|
||||
if stage.startswith("clean_head_worktree_"):
|
||||
return self.module.CommandResult(0, self.tracked_status, "")
|
||||
if stage.startswith("clean_head_head_"):
|
||||
return self.module.CommandResult(0, self.clean_head + "\n", "")
|
||||
if stage.startswith("clean_head_tree_"):
|
||||
return self.module.CommandResult(0, self.clean_tree + "\n", "")
|
||||
if stage.startswith("clean_head_archive_run_"):
|
||||
run_index = int(stage.rsplit("_", 1)[1]) - 1
|
||||
output_index = argv.index("--output")
|
||||
Path(argv[output_index + 1]).write_bytes(self.clean_archive_payloads[run_index])
|
||||
return self.module.CommandResult(0, "", "")
|
||||
return self.module.CommandResult(0, f"{stage}: passed", "")
|
||||
|
||||
|
||||
|
|
@ -67,9 +93,15 @@ class FakeDeployment:
|
|||
rollback_matches: bool = True
|
||||
tracked_api_image: str = "sha256:" + "1" * 64
|
||||
calls: list[str] = field(default_factory=list)
|
||||
event_log: list[str] | None = None
|
||||
|
||||
def _record(self, event: str) -> None:
|
||||
self.calls.append(event)
|
||||
if self.event_log is not None:
|
||||
self.event_log.append(f"deployment:{event}")
|
||||
|
||||
def read_active_state(self):
|
||||
self.calls.append("read_active_state")
|
||||
self._record("read_active_state")
|
||||
if self.active_sha is None:
|
||||
return None
|
||||
return {
|
||||
|
|
@ -84,7 +116,7 @@ class FakeDeployment:
|
|||
}
|
||||
|
||||
def snapshot(self):
|
||||
self.calls.append("snapshot")
|
||||
self._record("snapshot")
|
||||
return self.module.DeploymentSnapshot(
|
||||
api_image="sha256:" + "1" * 64,
|
||||
web_image="sha256:" + "2" * 64,
|
||||
|
|
@ -94,7 +126,7 @@ class FakeDeployment:
|
|||
)
|
||||
|
||||
def promote(self, candidate: Path, desired_sha: str, snapshot: Any):
|
||||
self.calls.append("promote")
|
||||
self._record("promote")
|
||||
return {
|
||||
"active_sha": desired_sha,
|
||||
"api_image": "sha256:" + "3" * 64,
|
||||
|
|
@ -104,16 +136,16 @@ class FakeDeployment:
|
|||
}
|
||||
|
||||
def commit_active_state(self, state: dict[str, Any]):
|
||||
self.calls.append("commit_active_state")
|
||||
self._record("commit_active_state")
|
||||
self.active_sha = state["active_sha"]
|
||||
|
||||
def rollback(self, snapshot: Any):
|
||||
self.calls.append("rollback")
|
||||
self._record("rollback")
|
||||
self.active_sha = snapshot.active_sha
|
||||
return {"api_image": snapshot.api_image, "web_image": snapshot.web_image}
|
||||
|
||||
def verify_rollback(self, snapshot: Any):
|
||||
self.calls.append("verify_rollback")
|
||||
self._record("verify_rollback")
|
||||
return {
|
||||
"ok": self.rollback_matches,
|
||||
"api_image": snapshot.api_image if self.rollback_matches else "sha256:" + "9" * 64,
|
||||
|
|
@ -164,9 +196,20 @@ class FakeCandidate:
|
|||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
self.cleaned = False
|
||||
self.materialization_mode: str | None = None
|
||||
self.archive_sha256: str | None = None
|
||||
self.archive_bytes: bytes | None = None
|
||||
|
||||
def materialize(self, base_commit: str, patch_path: str):
|
||||
del base_commit, patch_path
|
||||
self.materialization_mode = "patch"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
return self.root
|
||||
|
||||
def materialize_archive(self, archive_path: Path, expected_sha256: str):
|
||||
self.materialization_mode = "clean-head"
|
||||
self.archive_sha256 = expected_sha256
|
||||
self.archive_bytes = archive_path.read_bytes()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
return self.root
|
||||
|
||||
|
|
@ -268,17 +311,38 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
execute: bool,
|
||||
fail_stage: str | None = None,
|
||||
builder_shas: tuple[str, str] = ("a" * 64, "a" * 64),
|
||||
source_mode: str = "patch",
|
||||
tracked_status: str = "",
|
||||
clean_archive_payloads: tuple[bytes, bytes] = (
|
||||
b"clean archive",
|
||||
b"clean archive",
|
||||
),
|
||||
source_repo_root: Path | None = None,
|
||||
runtime_fail_on_call: int | None = None,
|
||||
rollback_matches: bool = True,
|
||||
):
|
||||
runner = FakeRunner(self.agent_module, fail_stage, builder_shas)
|
||||
deployment = FakeDeployment(self.agent_module, active_sha, rollback_matches)
|
||||
event_log: list[str] = []
|
||||
runner = FakeRunner(
|
||||
module=self.agent_module,
|
||||
fail_stage=fail_stage,
|
||||
builder_shas=builder_shas,
|
||||
tracked_status=tracked_status,
|
||||
clean_archive_payloads=clean_archive_payloads,
|
||||
event_log=event_log,
|
||||
)
|
||||
deployment = FakeDeployment(
|
||||
self.agent_module,
|
||||
active_sha,
|
||||
rollback_matches,
|
||||
event_log=event_log,
|
||||
)
|
||||
runtime = FakeRuntimeProbe(self.agent_module, runtime_fail_on_call)
|
||||
candidate = FakeCandidate(self.root / "candidate")
|
||||
config = self.agent_module.ReleaseAgentConfig(
|
||||
repo_root=self.root,
|
||||
repo_root=source_repo_root or self.root,
|
||||
manifest_path=self.manifest_path,
|
||||
hunk_map_path=self.root / "docs/ops/hunk-map.json",
|
||||
source_mode=source_mode,
|
||||
execute=execute,
|
||||
milestone=self.milestone,
|
||||
target=self.agent_module.NAS_PREVIEW_TARGET,
|
||||
|
|
@ -340,6 +404,157 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
runner.calls,
|
||||
)
|
||||
|
||||
def test_parser_keeps_patch_and_controller_repo_defaults(self) -> None:
|
||||
parser = self.agent_module.build_parser()
|
||||
default_args = parser.parse_args(["--milestone", "milestone.json"])
|
||||
detached_root = self.root / "detached"
|
||||
clean_args = parser.parse_args(
|
||||
[
|
||||
"--milestone",
|
||||
"milestone.json",
|
||||
"--source-mode",
|
||||
"clean-head",
|
||||
"--source-repo-root",
|
||||
str(detached_root),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual("patch", default_args.source_mode)
|
||||
self.assertEqual(self.agent_module.REPO_ROOT, default_args.source_repo_root)
|
||||
self.assertEqual("clean-head", clean_args.source_mode)
|
||||
self.assertEqual(detached_root, clean_args.source_repo_root)
|
||||
|
||||
def test_source_repo_root_must_be_an_absolute_existing_directory(self) -> None:
|
||||
relative = Path("detached-source")
|
||||
missing = self.root / "missing"
|
||||
source_file = self.root / "source-file"
|
||||
source_file.write_text("not a directory\n", encoding="utf-8")
|
||||
|
||||
for source_root, message in (
|
||||
(relative, "must be absolute"),
|
||||
(missing, "does not exist"),
|
||||
(source_file, "not a directory"),
|
||||
):
|
||||
with self.subTest(source_root=source_root):
|
||||
with self.assertRaisesRegex(self.agent_module.StageFailure, message):
|
||||
self.agent_module.validate_source_repo_root(source_root)
|
||||
|
||||
def test_clean_head_override_runs_every_git_command_in_source_repo(self) -> None:
|
||||
detached_root = self.root / "detached-source"
|
||||
detached_root.mkdir()
|
||||
archive_payload = b"detached clean-head archive"
|
||||
agent, runner, _, _, candidate = self.make_agent(
|
||||
active_sha="e" * 64,
|
||||
execute=True,
|
||||
source_mode="clean-head",
|
||||
clean_archive_payloads=(archive_payload, archive_payload),
|
||||
source_repo_root=detached_root,
|
||||
)
|
||||
|
||||
report = agent.run()
|
||||
|
||||
git_stages = [
|
||||
stage
|
||||
for stage in runner.calls
|
||||
if stage == "clean_head_repository"
|
||||
or stage.startswith("clean_head_worktree_")
|
||||
or stage.startswith("clean_head_head_")
|
||||
or stage.startswith("clean_head_tree_")
|
||||
or stage.startswith("clean_head_archive_run_")
|
||||
]
|
||||
self.assertEqual(9, len(git_stages))
|
||||
self.assertEqual(
|
||||
{detached_root},
|
||||
{runner.working_directories[stage] for stage in git_stages},
|
||||
)
|
||||
self.assertEqual(str(detached_root), report["source"]["repo_root"])
|
||||
self.assertEqual("clean-head", candidate.materialization_mode)
|
||||
|
||||
def test_clean_head_rejects_tracked_changes_and_ignores_untracked_in_query(self) -> None:
|
||||
agent, runner, deployment, _, candidate = self.make_agent(
|
||||
active_sha="e" * 64,
|
||||
execute=True,
|
||||
source_mode="clean-head",
|
||||
tracked_status=" M docs/TODO.md\n",
|
||||
)
|
||||
|
||||
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
|
||||
agent.run()
|
||||
|
||||
self.assertEqual("clean_head_worktree", raised.exception.report["failed_stage"])
|
||||
self.assertEqual(
|
||||
["clean_head_repository", "clean_head_worktree_before"],
|
||||
runner.calls,
|
||||
)
|
||||
self.assertIn(
|
||||
"--untracked-files=no",
|
||||
runner.commands["clean_head_worktree_before"],
|
||||
)
|
||||
self.assertEqual([], deployment.calls)
|
||||
self.assertIsNone(candidate.materialization_mode)
|
||||
|
||||
def test_clean_head_non_git_source_fails_before_identity_or_deployment(self) -> None:
|
||||
agent, runner, deployment, _, candidate = self.make_agent(
|
||||
active_sha="e" * 64,
|
||||
execute=True,
|
||||
source_mode="clean-head",
|
||||
fail_stage="clean_head_repository",
|
||||
)
|
||||
|
||||
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
|
||||
agent.run()
|
||||
|
||||
self.assertEqual("clean_head_repository", raised.exception.report["failed_stage"])
|
||||
self.assertEqual(["clean_head_repository"], runner.calls)
|
||||
self.assertEqual([], deployment.calls)
|
||||
self.assertIsNone(candidate.materialization_mode)
|
||||
|
||||
def test_clean_head_archive_runs_must_be_byte_identical(self) -> None:
|
||||
agent, runner, deployment, _, candidate = self.make_agent(
|
||||
active_sha="e" * 64,
|
||||
execute=True,
|
||||
source_mode="clean-head",
|
||||
clean_archive_payloads=(b"first archive", b"second archive"),
|
||||
)
|
||||
|
||||
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
|
||||
agent.run()
|
||||
|
||||
self.assertEqual("clean_head_determinism", raised.exception.report["failed_stage"])
|
||||
self.assertEqual(2, len([stage for stage in runner.calls if "archive_run" in stage]))
|
||||
self.assertEqual([], deployment.calls)
|
||||
self.assertIsNone(candidate.materialization_mode)
|
||||
|
||||
def test_clean_head_evidence_binds_exact_head_tree_and_archive(self) -> None:
|
||||
archive_payload = b"deterministic clean-head archive"
|
||||
expected_sha = self.agent_module.sha256_bytes(archive_payload)
|
||||
agent, runner, deployment, _, candidate = self.make_agent(
|
||||
active_sha="e" * 64,
|
||||
execute=True,
|
||||
source_mode="clean-head",
|
||||
clean_archive_payloads=(archive_payload, archive_payload),
|
||||
)
|
||||
|
||||
report = agent.run()
|
||||
|
||||
self.assertTrue(report["ok"])
|
||||
self.assertEqual(expected_sha, report["desired_sha"])
|
||||
self.assertEqual("clean-head", report["source_mode"])
|
||||
self.assertEqual("b" * 40, report["source"]["head"])
|
||||
self.assertEqual("c" * 40, report["source"]["tree"])
|
||||
self.assertEqual(expected_sha, report["source"]["archive"]["sha256"])
|
||||
self.assertEqual(len(archive_payload), report["source"]["archive"]["bytes"])
|
||||
self.assertEqual(2, report["source"]["archive"]["deterministic_runs"])
|
||||
self.assertEqual("clean-head", candidate.materialization_mode)
|
||||
self.assertEqual(expected_sha, candidate.archive_sha256)
|
||||
self.assertEqual(archive_payload, candidate.archive_bytes)
|
||||
self.assertNotIn("release_manifest", runner.calls)
|
||||
self.assertFalse(any(stage.startswith("release_patch_run_") for stage in runner.calls))
|
||||
self.assertIn("promote", deployment.calls)
|
||||
evidence = json.loads((self.root / "evidence.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["source"], evidence["source"])
|
||||
self.assertEqual("clean-head", evidence["source_mode"])
|
||||
|
||||
def test_same_deployment_sha_is_a_runtime_checked_noop(self) -> None:
|
||||
agent, runner, deployment, runtime, candidate = self.make_agent(
|
||||
active_sha="a" * 64,
|
||||
|
|
@ -402,6 +617,8 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
report = agent.run()
|
||||
self.assertTrue(report["ok"])
|
||||
self.assertEqual("deployed_ssot_sync_required", report["status"])
|
||||
self.assertEqual("patch", report["source_mode"])
|
||||
self.assertEqual("a" * 64, report["source"]["patch"]["sha256"])
|
||||
self.assertEqual("a" * 64, report["active_sha"])
|
||||
self.assertEqual("e" * 64, report["previous_sha"])
|
||||
self.assertEqual(
|
||||
|
|
@ -415,6 +632,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
"web_api_contract",
|
||||
"web_typecheck",
|
||||
"web_build",
|
||||
"source_insecure_context_e2e",
|
||||
"session_e2e",
|
||||
"postdeploy_browser_review",
|
||||
],
|
||||
|
|
@ -426,12 +644,87 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(1, runtime.calls)
|
||||
self.assertTrue(candidate.cleaned)
|
||||
postdeploy_command = runner.commands["postdeploy_browser_review"]
|
||||
test_index = postdeploy_command.index("test")
|
||||
first_flag = next(
|
||||
index
|
||||
for index in range(test_index + 1, len(postdeploy_command))
|
||||
if postdeploy_command[index].startswith("--")
|
||||
)
|
||||
self.assertEqual(
|
||||
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
|
||||
tuple(postdeploy_command[test_index + 1 : first_flag]),
|
||||
)
|
||||
self.assertNotIn("--grep", postdeploy_command)
|
||||
self.assertEqual(
|
||||
{f"--project={project}" for project in self.agent_module.RELEASE_BROWSER_PROJECTS},
|
||||
{value for value in postdeploy_command if value.startswith("--project=")},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.agent_module.RELEASE_E2E_TIMEOUT_SECONDS,
|
||||
runner.timeouts["postdeploy_browser_review"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.agent_module.NAS_PREVIEW_TARGET.base_url,
|
||||
runner.environments["postdeploy_browser_review"]["PLAYWRIGHT_BASE_URL"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"1",
|
||||
runner.environments["postdeploy_browser_review"]["PLAYWRIGHT_SKIP_WEB_SERVER"],
|
||||
)
|
||||
self.assertIn(
|
||||
"e2e/self-directed-learning-loop.spec.ts",
|
||||
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
|
||||
)
|
||||
self.assertIn(
|
||||
"e2e/deliberate-practice.spec.ts",
|
||||
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"e2e/insecure-context-uuid.spec.ts",
|
||||
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"e2e/returned-practice-db-closed-loop.spec.ts",
|
||||
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
|
||||
)
|
||||
self.assertEqual(
|
||||
set(self.agent_module.RELEASE_UI_E2E_SPECS),
|
||||
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS)
|
||||
| set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
|
||||
)
|
||||
self.assertLess(
|
||||
runner.event_log.index("runner:postdeploy_browser_review"),
|
||||
runner.event_log.index("deployment:commit_active_state"),
|
||||
)
|
||||
self.assertTrue(
|
||||
set(self.agent_module.RELEASE_UI_E2E_SPECS).issubset(
|
||||
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS).issubset(
|
||||
set(runner.commands["session_e2e"])
|
||||
)
|
||||
)
|
||||
self.assertNotIn(
|
||||
"e2e/insecure-context-uuid.spec.ts",
|
||||
runner.commands["session_e2e"],
|
||||
)
|
||||
source_command = runner.commands["source_insecure_context_e2e"]
|
||||
self.assertIn("e2e/insecure-context-uuid.spec.ts", source_command)
|
||||
self.assertIn("--project=chromium-desktop", source_command)
|
||||
self.assertIn("--project=chromium-mobile", source_command)
|
||||
source_environment = runner.environments["source_insecure_context_e2e"]
|
||||
self.assertNotIn("PLAYWRIGHT_BASE_URL", source_environment)
|
||||
self.assertNotIn("PLAYWRIGHT_SKIP_WEB_SERVER", source_environment)
|
||||
self.assertEqual("127.0.0.1", source_environment["PLAYWRIGHT_HOST"])
|
||||
self.assertEqual(
|
||||
str(self.agent_module.SOURCE_ONLY_E2E_PORT),
|
||||
source_environment["PLAYWRIGHT_PORT"],
|
||||
)
|
||||
self.assertEqual("1", source_environment["CI"])
|
||||
self.assertEqual(15 * 60, self.agent_module.RELEASE_E2E_TIMEOUT_SECONDS)
|
||||
self.assertIn(
|
||||
"e2e/insecure-context-uuid.spec.ts",
|
||||
self.agent_module.RELEASE_UI_E2E_SPECS,
|
||||
)
|
||||
self.assertEqual("patch", candidate.materialization_mode)
|
||||
self.assertEqual(
|
||||
(
|
||||
"14_continuous_improvement.sql",
|
||||
|
|
@ -445,6 +738,18 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
self.assertEqual("e" * 64, evidence["previous_sha"])
|
||||
self.assertEqual("a" * 64, evidence["deployment"]["desired_sha"])
|
||||
self.assertEqual("required", evidence["ssot_sync"]["status"])
|
||||
self.assertEqual(
|
||||
list(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS),
|
||||
evidence["browser_review_proof"]["specs"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["e2e/insecure-context-uuid.spec.ts"],
|
||||
evidence["browser_review_proof"]["source_only_candidate_specs"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["e2e/returned-practice-db-closed-loop.spec.ts"],
|
||||
evidence["browser_review_proof"]["separate_disposable_db_specs"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"docs/dev_dashboard.html",
|
||||
|
|
@ -480,7 +785,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
self.assertTrue(candidate.cleaned)
|
||||
|
||||
def test_rollback_image_mismatch_remains_a_hard_failure(self) -> None:
|
||||
agent, _, deployment, _, _ = self.make_agent(
|
||||
agent, runner, deployment, _, _ = self.make_agent(
|
||||
active_sha="e" * 64,
|
||||
execute=True,
|
||||
fail_stage="postdeploy_browser_review",
|
||||
|
|
@ -491,6 +796,11 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
self.assertEqual("failed", raised.exception.report["rollback"]["status"])
|
||||
self.assertIn("rollback image proof mismatch", raised.exception.report["rollback"]["error"])
|
||||
self.assertIn("verify_rollback", deployment.calls)
|
||||
self.assertNotIn("commit_active_state", deployment.calls)
|
||||
self.assertLess(
|
||||
runner.event_log.index("runner:postdeploy_browser_review"),
|
||||
runner.event_log.index("deployment:rollback"),
|
||||
)
|
||||
|
||||
def test_postpromotion_runtime_retries_only_transient_readiness_failures(self) -> None:
|
||||
agent, _, deployment, _, candidate = self.make_agent(
|
||||
|
|
@ -653,6 +963,50 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
|
|||
self.assertEqual(b"candidate\r\n", (candidate / "README.md").read_bytes())
|
||||
manager.cleanup()
|
||||
|
||||
def test_clean_head_candidate_is_archive_only_with_platform_normalization(self) -> None:
|
||||
runner = CandidateArchiveRunner(self.agent_module)
|
||||
manager = self.agent_module.CleanCandidateManager(self.root, runner)
|
||||
archive_path = self.root / "clean-head.tar"
|
||||
source = self.root / "source.txt"
|
||||
source.write_bytes(b"exact committed bytes\r\n")
|
||||
generated_api = self.root / "api.gen.ts"
|
||||
generated_api.write_bytes(b"export type Exact = true;\r\n")
|
||||
shell_script = self.root / "99_app_role.sh"
|
||||
shell_script.write_bytes(b"#!/usr/bin/env bash\r\necho ready\r\n")
|
||||
with tarfile.open(archive_path, "w") as archive:
|
||||
archive.add(source, arcname="source.txt")
|
||||
archive.add(generated_api, arcname="apps/web/src/lib/api.gen.ts")
|
||||
archive.add(shell_script, arcname="infra/db/init/99_app_role.sh")
|
||||
expected_sha = self.agent_module.sha256_bytes(archive_path.read_bytes())
|
||||
|
||||
candidate = manager.materialize_archive(archive_path, expected_sha)
|
||||
|
||||
self.assertEqual(b"exact committed bytes\r\n", (candidate / "source.txt").read_bytes())
|
||||
self.assertEqual(
|
||||
b"export type Exact = true;\n",
|
||||
(candidate / "apps/web/src/lib/api.gen.ts").read_bytes(),
|
||||
)
|
||||
self.assertEqual(
|
||||
b"#!/usr/bin/env bash\necho ready\n",
|
||||
(candidate / "infra/db/init/99_app_role.sh").read_bytes(),
|
||||
)
|
||||
self.assertEqual([], runner.calls)
|
||||
manager.cleanup()
|
||||
|
||||
def test_clean_head_candidate_rejects_archive_sha_drift(self) -> None:
|
||||
runner = CandidateArchiveRunner(self.agent_module)
|
||||
manager = self.agent_module.CleanCandidateManager(self.root, runner)
|
||||
archive_path = self.root / "clean-head.tar"
|
||||
archive_path.write_bytes(b"not the bound archive")
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
self.agent_module.StageFailure,
|
||||
"archive SHA drifted",
|
||||
):
|
||||
manager.materialize_archive(archive_path, "a" * 64)
|
||||
|
||||
self.assertEqual([], runner.calls)
|
||||
|
||||
def test_active_state_rejects_noncanonical_compose_path_before_snapshot(self) -> None:
|
||||
root = "/volume1/docker/vignette-preview-20260807"
|
||||
runner = ActiveStateRunner(
|
||||
|
|
|
|||
408
scripts/test_public_runtime_watchdog_provenance.py
Normal file
408
scripts/test_public_runtime_watchdog_provenance.py
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
WATCHDOG = SCRIPTS / "watch-public-runtime.ps1"
|
||||
INSTALLER = SCRIPTS / "install-public-runtime-task.ps1"
|
||||
BOOT = SCRIPTS / "boot-public-runtime.ps1"
|
||||
BOOT_REGISTER = SCRIPTS / "register-boot-task.ps1"
|
||||
HIDDEN_TRIGGER = SCRIPTS / "watch-public-runtime-hidden.vbs"
|
||||
START = SCRIPTS / "start-public-runtime.ps1"
|
||||
REPO_ROOT = SCRIPTS.parent
|
||||
RUNBOOK = REPO_ROOT / "docs" / "ops" / "public-runtime-watchdog.md"
|
||||
LOCAL_DEVELOPMENT = REPO_ROOT / "docs" / "guides" / "local-development.md"
|
||||
WATCHDOG_SOURCE = WATCHDOG.read_text(encoding="utf-8")
|
||||
INSTALLER_SOURCE = INSTALLER.read_text(encoding="utf-8")
|
||||
BOOT_SOURCE = BOOT.read_text(encoding="utf-8")
|
||||
BOOT_REGISTER_SOURCE = BOOT_REGISTER.read_text(encoding="utf-8")
|
||||
HIDDEN_TRIGGER_SOURCE = HIDDEN_TRIGGER.read_text(encoding="utf-8")
|
||||
RUNBOOK_SOURCE = RUNBOOK.read_text(encoding="utf-8")
|
||||
LOCAL_DEVELOPMENT_SOURCE = LOCAL_DEVELOPMENT.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
class PublicRuntimeWatchdogProvenanceTest(unittest.TestCase):
|
||||
def test_watchdog_requires_exact_source_and_script_pins(self) -> None:
|
||||
for expected in (
|
||||
"[string]$StableSourceRoot",
|
||||
"[string]$ExpectedSourceCommit",
|
||||
"[string]$ExpectedSourceTree",
|
||||
"[string]$ExpectedWatchdogSha256",
|
||||
"[string]$ExpectedStartScriptSha256",
|
||||
'symbolic-ref --quiet HEAD',
|
||||
'"status", "--porcelain=v1", "--untracked-files=normal"',
|
||||
'"ls-files", "--error-unmatch"',
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, WATCHDOG_SOURCE)
|
||||
self.assertGreaterEqual(WATCHDOG_SOURCE.count("[Parameter(Mandatory = $true)]"), 5)
|
||||
|
||||
def test_provenance_gate_precedes_health_and_runtime_recovery(self) -> None:
|
||||
gate_comment = WATCHDOG_SOURCE.index(
|
||||
"# health probe, failcount 기록, 프로세스 재기동보다 먼저"
|
||||
)
|
||||
gate = WATCHDOG_SOURCE.index("Assert-StableSourceProvenance", gate_comment)
|
||||
health = WATCHDOG_SOURCE.index("$checks = @(")
|
||||
restart = WATCHDOG_SOURCE.index("& $startScript @startArgs")
|
||||
failcount_write = WATCHDOG_SOURCE.index("Set-FailCount 0", health)
|
||||
self.assertLess(gate, health)
|
||||
self.assertLess(gate, restart)
|
||||
self.assertLess(gate, failcount_write)
|
||||
|
||||
def test_installer_pins_a_detached_clean_release_in_task_action(self) -> None:
|
||||
for expected in (
|
||||
"[Parameter(Mandatory = $true)]",
|
||||
"[string]$StableSourceRoot",
|
||||
"symbolic-ref --quiet HEAD",
|
||||
'"status", "--porcelain=v1", "--untracked-files=normal"',
|
||||
'"-ExpectedSourceCommit $sourceCommit"',
|
||||
'"-ExpectedSourceTree $sourceTree"',
|
||||
'"-ExpectedWatchdogSha256 $watchdogSha256"',
|
||||
'"-ExpectedStartScriptSha256 $startScriptSha256"',
|
||||
"-WorkingDirectory $resolvedSourceRoot",
|
||||
"Watchdog installer is not executing from the pinned stable source root",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, INSTALLER_SOURCE)
|
||||
dirty_gate = INSTALLER_SOURCE.index("Stable source is not clean")
|
||||
task_registration = INSTALLER_SOURCE.index("Register-ScheduledTask")
|
||||
self.assertLess(dirty_gate, task_registration)
|
||||
|
||||
def test_boot_recovery_uses_the_same_gate_before_docker_or_process_mutation(self) -> None:
|
||||
for expected in (
|
||||
"[string]$StableSourceRoot",
|
||||
"[string]$ExpectedSourceCommit",
|
||||
"[string]$ExpectedSourceTree",
|
||||
"[string]$ExpectedBootScriptSha256",
|
||||
"[string]$ExpectedStartScriptSha256",
|
||||
"symbolic-ref --quiet HEAD",
|
||||
'"status", "--porcelain=v1", "--untracked-files=normal"',
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, BOOT_SOURCE)
|
||||
gate_comment = BOOT_SOURCE.index(
|
||||
"# Docker/DB/process mutation보다 먼저 stable source를 매 실행 재검증한다."
|
||||
)
|
||||
gate = BOOT_SOURCE.index("Assert-StableSourceProvenance", gate_comment)
|
||||
docker_mutation = BOOT_SOURCE.index("docker.exe update")
|
||||
runtime_recovery = BOOT_SOURCE.index("-File $startScript")
|
||||
self.assertLess(gate, docker_mutation)
|
||||
self.assertLess(gate, runtime_recovery)
|
||||
|
||||
def test_boot_task_registration_pins_the_same_detached_release(self) -> None:
|
||||
for expected in (
|
||||
"[string]$StableSourceRoot",
|
||||
"symbolic-ref --quiet HEAD",
|
||||
'"status", "--porcelain=v1", "--untracked-files=normal"',
|
||||
'"-ExpectedSourceCommit $sourceCommit"',
|
||||
'"-ExpectedSourceTree $sourceTree"',
|
||||
'"-ExpectedBootScriptSha256 $bootScriptSha256"',
|
||||
'"-ExpectedStartScriptSha256 $startScriptSha256"',
|
||||
"-WorkingDirectory $resolvedSourceRoot",
|
||||
"Boot task registrar is not executing from the pinned stable source root",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, BOOT_REGISTER_SOURCE)
|
||||
dirty_gate = BOOT_REGISTER_SOURCE.index("Stable source is not clean")
|
||||
task_registration = BOOT_REGISTER_SOURCE.index("Register-ScheduledTask")
|
||||
self.assertLess(dirty_gate, task_registration)
|
||||
|
||||
def test_hidden_trigger_never_executes_a_workspace_script_directly(self) -> None:
|
||||
for expected in (
|
||||
"VignettePublicRuntimeWatchdog",
|
||||
"-StableSourceRoot",
|
||||
"-ExpectedSourceCommit",
|
||||
"-ExpectedSourceTree",
|
||||
"-ExpectedWatchdogSha256",
|
||||
"-ExpectedStartScriptSha256",
|
||||
"WorkingDirectory",
|
||||
"Pinned watchdog script path does not match its working directory",
|
||||
"Pinned watchdog source root does not match its working directory",
|
||||
"Start-ScheduledTask",
|
||||
"Legacy shared-worktree watchdog action is forbidden",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, HIDDEN_TRIGGER_SOURCE)
|
||||
self.assertIn(
|
||||
"Join-Path $root 'scripts\\watch-public-runtime.ps1'",
|
||||
HIDDEN_TRIGGER_SOURCE,
|
||||
)
|
||||
self.assertNotIn('objShell.Run "powershell.exe', HIDDEN_TRIGGER_SOURCE)
|
||||
self.assertNotIn("D:\\workspace\\vignette", HIDDEN_TRIGGER_SOURCE)
|
||||
marker_gate = HIDDEN_TRIGGER_SOURCE.index("foreach($marker in $required)")
|
||||
task_trigger = HIDDEN_TRIGGER_SOURCE.rindex("Start-ScheduledTask")
|
||||
self.assertLess(marker_gate, task_trigger)
|
||||
|
||||
def test_active_docs_only_show_the_pinned_release_workflow(self) -> None:
|
||||
stale_commands = (
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\watch-public-runtime.ps1 -CheckOnly",
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File scripts\\install-public-runtime-task.ps1 -RunNow",
|
||||
)
|
||||
for source in (RUNBOOK_SOURCE, LOCAL_DEVELOPMENT_SOURCE):
|
||||
for stale in stale_commands:
|
||||
with self.subTest(document=source[:32], stale=stale):
|
||||
self.assertNotIn(stale, source)
|
||||
for expected in (
|
||||
"detached HEAD",
|
||||
"-StableSourceRoot",
|
||||
"-ExpectedSourceCommit",
|
||||
"-ExpectedSourceTree",
|
||||
"-ExpectedWatchdogSha256",
|
||||
"-ExpectedStartScriptSha256",
|
||||
"--porcelain=v1 --untracked-files=normal",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, RUNBOOK_SOURCE)
|
||||
self.assertIn("-StableSourceRoot", LOCAL_DEVELOPMENT_SOURCE)
|
||||
self.assertIn("watch-public-runtime-hidden.vbs", LOCAL_DEVELOPMENT_SOURCE)
|
||||
|
||||
@unittest.skipUnless(shutil.which("git.exe"), "git.exe is unavailable")
|
||||
@unittest.skipUnless(shutil.which("powershell.exe"), "Windows PowerShell 5.1 is unavailable")
|
||||
def test_dirty_detached_release_fails_before_health_or_runtime_mutation(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="vignette-watchdog-") as temp:
|
||||
root = Path(temp)
|
||||
scripts = root / "scripts"
|
||||
scripts.mkdir()
|
||||
copied_watchdog = scripts / WATCHDOG.name
|
||||
copied_installer = scripts / INSTALLER.name
|
||||
copied_boot = scripts / BOOT.name
|
||||
copied_boot_register = scripts / BOOT_REGISTER.name
|
||||
copied_start = scripts / START.name
|
||||
shutil.copy2(WATCHDOG, copied_watchdog)
|
||||
shutil.copy2(INSTALLER, copied_installer)
|
||||
shutil.copy2(BOOT, copied_boot)
|
||||
shutil.copy2(BOOT_REGISTER, copied_boot_register)
|
||||
shutil.copy2(START, copied_start)
|
||||
|
||||
self._git(root, "init")
|
||||
self._git(root, "config", "user.name", "Watchdog Contract Test")
|
||||
self._git(root, "config", "user.email", "watchdog-test@example.invalid")
|
||||
self._git(
|
||||
root,
|
||||
"add",
|
||||
"scripts/watch-public-runtime.ps1",
|
||||
"scripts/install-public-runtime-task.ps1",
|
||||
"scripts/boot-public-runtime.ps1",
|
||||
"scripts/register-boot-task.ps1",
|
||||
"scripts/start-public-runtime.ps1",
|
||||
)
|
||||
self._git(root, "commit", "-m", "watchdog fixture")
|
||||
self._git(root, "checkout", "--detach")
|
||||
commit = self._git(root, "rev-parse", "HEAD").stdout.strip()
|
||||
tree = self._git(root, "rev-parse", "HEAD^{tree}").stdout.strip()
|
||||
watchdog_hash = sha256(copied_watchdog)
|
||||
boot_hash = sha256(copied_boot)
|
||||
start_hash = sha256(copied_start)
|
||||
|
||||
with copied_start.open("ab") as stream:
|
||||
stream.write(b"\n# dirty fixture\n")
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
shutil.which("powershell.exe") or "powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(copied_watchdog),
|
||||
"-StableSourceRoot",
|
||||
str(root),
|
||||
"-ExpectedSourceCommit",
|
||||
commit,
|
||||
"-ExpectedSourceTree",
|
||||
tree,
|
||||
"-ExpectedWatchdogSha256",
|
||||
watchdog_hash,
|
||||
"-ExpectedStartScriptSha256",
|
||||
start_hash,
|
||||
"-CheckOnly",
|
||||
"-SkipPublicHealth",
|
||||
"-SkipCloudflaredRestart",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
)
|
||||
detail = f"{completed.stdout}\n{completed.stderr}"
|
||||
self.assertNotEqual(completed.returncode, 0, detail)
|
||||
self.assertIn("Stable source is not clean", detail)
|
||||
self.assertFalse((root / "public-runtime-watchdog.failcount").exists())
|
||||
self.assertFalse((root / "public-runtime-watchdog.log").exists())
|
||||
|
||||
boot_completed = subprocess.run(
|
||||
[
|
||||
shutil.which("powershell.exe") or "powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(copied_boot),
|
||||
"-StableSourceRoot",
|
||||
str(root),
|
||||
"-ExpectedSourceCommit",
|
||||
commit,
|
||||
"-ExpectedSourceTree",
|
||||
tree,
|
||||
"-ExpectedBootScriptSha256",
|
||||
boot_hash,
|
||||
"-ExpectedStartScriptSha256",
|
||||
start_hash,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
)
|
||||
boot_detail = f"{boot_completed.stdout}\n{boot_completed.stderr}"
|
||||
self.assertNotEqual(boot_completed.returncode, 0, boot_detail)
|
||||
self.assertIn("Stable source is not clean", boot_detail)
|
||||
self.assertFalse((root / "boot-public-runtime.log").exists())
|
||||
|
||||
for registrar in (copied_installer, copied_boot_register):
|
||||
register_completed = subprocess.run(
|
||||
[
|
||||
shutil.which("powershell.exe") or "powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(registrar),
|
||||
"-StableSourceRoot",
|
||||
str(root),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
)
|
||||
register_detail = (
|
||||
f"{register_completed.stdout}\n{register_completed.stderr}"
|
||||
)
|
||||
with self.subTest(registrar=registrar.name):
|
||||
self.assertNotEqual(register_completed.returncode, 0, register_detail)
|
||||
self.assertIn("Stable source is not clean", register_detail)
|
||||
|
||||
def test_windows_powershell_51_parser_accepts_scripts(self) -> None:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
self.skipTest("Windows PowerShell 5.1 is unavailable")
|
||||
for script in (WATCHDOG, INSTALLER, BOOT, BOOT_REGISTER):
|
||||
escaped = str(script.resolve()).replace("'", "''")
|
||||
command = (
|
||||
"$tokens=$null; $errors=$null; "
|
||||
"[System.Management.Automation.Language.Parser]::ParseFile("
|
||||
f"'{escaped}', [ref]$tokens, [ref]$errors) | Out-Null; "
|
||||
"if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }; "
|
||||
"exit 0"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[powershell, "-NoProfile", "-Command", command],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
with self.subTest(script=script.name):
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
|
||||
def test_windows_script_host_accepts_hidden_trigger_syntax(self) -> None:
|
||||
cscript = shutil.which("cscript.exe")
|
||||
if cscript is None:
|
||||
self.skipTest("Windows Script Host is unavailable")
|
||||
completed = subprocess.run(
|
||||
[cscript, "//nologo", str(HIDDEN_TRIGGER), "syntax-only"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
|
||||
def test_hidden_trigger_command_accepts_only_a_structurally_pinned_task(self) -> None:
|
||||
cscript = shutil.which("cscript.exe")
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if cscript is None or powershell is None:
|
||||
self.skipTest("Windows Script Host or PowerShell 5.1 is unavailable")
|
||||
rendered = subprocess.run(
|
||||
[cscript, "//nologo", str(HIDDEN_TRIGGER), "print-command"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
self.assertEqual(rendered.returncode, 0, rendered.stderr)
|
||||
prefix = (
|
||||
"powershell.exe -NoProfile -ExecutionPolicy Bypass "
|
||||
'-WindowStyle Hidden -Command "'
|
||||
)
|
||||
wrapped = rendered.stdout.strip()
|
||||
self.assertTrue(wrapped.startswith(prefix), wrapped)
|
||||
self.assertTrue(wrapped.endswith('"'), wrapped)
|
||||
command = wrapped[len(prefix) : -1].replace('""', '"')
|
||||
|
||||
root = r"C:\Pinned Vignette Release"
|
||||
arguments = (
|
||||
f'-File "{root}\\scripts\\watch-public-runtime.ps1" '
|
||||
f'-StableSourceRoot "{root}" '
|
||||
f"-ExpectedSourceCommit {'a' * 40} "
|
||||
f"-ExpectedSourceTree {'b' * 40} "
|
||||
f"-ExpectedWatchdogSha256 {'c' * 64} "
|
||||
f"-ExpectedStartScriptSha256 {'d' * 64}"
|
||||
)
|
||||
fixture = (
|
||||
"$script:watchdogTriggered=$false;"
|
||||
"function Get-ScheduledTask { param($TaskName,$ErrorAction) "
|
||||
"$action=[pscustomobject]@{"
|
||||
"Execute='C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';"
|
||||
f"WorkingDirectory='{root}';Arguments='{arguments}'"
|
||||
"};[pscustomobject]@{Actions=@($action)}};"
|
||||
"function Start-ScheduledTask { param($TaskName) "
|
||||
"$script:watchdogTriggered=$true };"
|
||||
f"{command};"
|
||||
"if(-not $script:watchdogTriggered){exit 9};exit 0"
|
||||
)
|
||||
checked = subprocess.run(
|
||||
[powershell, "-NoProfile", "-Command", fixture],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
self.assertEqual(checked.returncode, 0, checked.stderr)
|
||||
|
||||
def _git(self, root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
completed = subprocess.run(
|
||||
[shutil.which("git.exe") or "git.exe", "-C", str(root), *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr)
|
||||
return completed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
144
scripts/test_public_voice_sidecar_probe.py
Normal file
144
scripts/test_public_voice_sidecar_probe.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("probe-public-voice-sidecars.py")
|
||||
SPEC = importlib.util.spec_from_file_location("public_voice_sidecar_probe", SCRIPT_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class SttProbeContractTest(unittest.TestCase):
|
||||
def _payload(self, **overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"type": "ready",
|
||||
"provider": "local_whisper",
|
||||
"model": "small",
|
||||
"language": "ko",
|
||||
"device": "cpu",
|
||||
"compute_type": "int8",
|
||||
"sample_rate": 16_000,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
def test_exact_public_metadata_is_accepted(self) -> None:
|
||||
result = MODULE.validate_stt_ready(
|
||||
self._payload(),
|
||||
provider="local_whisper",
|
||||
model="small",
|
||||
language="ko",
|
||||
device="cpu",
|
||||
)
|
||||
self.assertEqual(result["compute_type"], "int8")
|
||||
|
||||
def test_provider_model_device_and_compute_type_mismatch_fail_closed(self) -> None:
|
||||
for field, value in (
|
||||
("provider", "deepgram"),
|
||||
("model", "large-v3"),
|
||||
("device", "cuda"),
|
||||
("compute_type", "float16"),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
with self.assertRaises(MODULE.ProbeError):
|
||||
MODULE.validate_stt_ready(
|
||||
self._payload(**{field: value}),
|
||||
provider="local_whisper",
|
||||
model="small",
|
||||
language="ko",
|
||||
device="cpu",
|
||||
)
|
||||
|
||||
def test_probe_url_replaces_query_with_exact_stream_contract(self) -> None:
|
||||
url = MODULE.build_stt_probe_url(
|
||||
"ws://127.0.0.1:9882/v1/listen?model=base&channels=2",
|
||||
model="small",
|
||||
language="ko",
|
||||
)
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
self.assertEqual(query["model"], ["small"])
|
||||
self.assertEqual(query["language"], ["ko"])
|
||||
self.assertEqual(query["sample_rate"], ["16000"])
|
||||
self.assertEqual(query["channels"], ["1"])
|
||||
|
||||
def test_non_loopback_urls_and_embedded_credentials_are_rejected(self) -> None:
|
||||
for url in (
|
||||
"wss://example.com/v1/listen",
|
||||
"ws://user:secret@127.0.0.1:9882/v1/listen",
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
with self.assertRaises(MODULE.ProbeError):
|
||||
MODULE.build_stt_probe_url(url, model="small", language="ko")
|
||||
|
||||
|
||||
class TtsProbeContractTest(unittest.TestCase):
|
||||
def _payload(self, **overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"status": "ok",
|
||||
"provider": "melotts",
|
||||
"model": "melotts-korean",
|
||||
"language": "KR",
|
||||
"license": "MIT",
|
||||
"reference_policy": "pretrained-multispeaker-no-external-reference",
|
||||
"sample_rate": 44_100,
|
||||
"speakers": ["KR"],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
def test_exact_public_metadata_is_accepted(self) -> None:
|
||||
result = MODULE.validate_tts_health(
|
||||
self._payload(),
|
||||
provider="melotts",
|
||||
model="melotts-korean",
|
||||
language="KR",
|
||||
)
|
||||
self.assertEqual(result["license"], "MIT")
|
||||
|
||||
def test_provider_model_license_and_reference_mismatch_fail_closed(self) -> None:
|
||||
for field, value in (
|
||||
("provider", "openai"),
|
||||
("model", "other"),
|
||||
("license", "unknown"),
|
||||
("reference_policy", "external-reference"),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
with self.assertRaises(MODULE.ProbeError):
|
||||
MODULE.validate_tts_health(
|
||||
self._payload(**{field: value}),
|
||||
provider="melotts",
|
||||
model="melotts-korean",
|
||||
language="KR",
|
||||
)
|
||||
|
||||
def test_empty_speaker_catalog_and_invalid_sample_rate_fail_closed(self) -> None:
|
||||
for override in ({"speakers": []}, {"sample_rate": 0}):
|
||||
with self.subTest(override=override):
|
||||
with self.assertRaises(MODULE.ProbeError):
|
||||
MODULE.validate_tts_health(
|
||||
self._payload(**override),
|
||||
provider="melotts",
|
||||
model="melotts-korean",
|
||||
language="KR",
|
||||
)
|
||||
|
||||
|
||||
class CliContractTest(unittest.TestCase):
|
||||
def test_defaults_are_the_public_local_stack(self) -> None:
|
||||
args = MODULE.build_parser().parse_args([])
|
||||
self.assertEqual(args.stt_provider, "local_whisper")
|
||||
self.assertEqual(args.stt_model, "small")
|
||||
self.assertEqual(args.stt_device, "cpu")
|
||||
self.assertEqual(args.tts_provider, "melotts")
|
||||
self.assertEqual(args.tts_model, "melotts-korean")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -18,20 +18,38 @@ SPEC.loader.exec_module(MODULE)
|
|||
def args(**overrides):
|
||||
base = {
|
||||
"wss_url": "wss://api.example.test/voice/ws",
|
||||
"origin": "https://api.example.test",
|
||||
"origin": "https://web.example.test",
|
||||
"allowed_browser_origins": ["https://web.example.test"],
|
||||
"admin_runtime_url": "https://api.example.test/admin/voice-runtime",
|
||||
"topology_mode": "linux-compose",
|
||||
"compose_project": "vignette",
|
||||
"api_container": "vignette-api-1",
|
||||
"api_image_digest": "sha256:" + "a" * 64,
|
||||
"caddy_container": "vignette-proxy-1",
|
||||
"caddy_image_digest": "sha256:" + "b" * 64,
|
||||
"repo_root": None,
|
||||
"git_sha": None,
|
||||
"git_tree_sha": None,
|
||||
"runner_script_sha256": None,
|
||||
"collector_script_sha256": None,
|
||||
"checker_script_sha256": None,
|
||||
"psutil_version": None,
|
||||
"api_pid": None,
|
||||
"api_executable_name": None,
|
||||
"api_executable_sha256": None,
|
||||
"api_cwd": None,
|
||||
"api_listen_port": None,
|
||||
"cloudflared_pid": None,
|
||||
"cloudflared_executable_name": None,
|
||||
"cloudflared_executable_sha256": None,
|
||||
"cloudflared_cwd": None,
|
||||
"expected_stt_provider": "local_whisper",
|
||||
"expected_stt_model": "large-v3",
|
||||
"expected_stt_model": "small",
|
||||
"expected_tts_provider": "melotts",
|
||||
"expected_tts_model": "melotts-korean",
|
||||
"microphone_device": "",
|
||||
"confirm_physical_capture": False,
|
||||
"duration_seconds": 3_000.0,
|
||||
"duration_seconds": 3_120.0,
|
||||
"runtime_interval_seconds": 100.0,
|
||||
"topology_interval_seconds": 100.0,
|
||||
"human_voice_gain": Path("pack.json"),
|
||||
|
|
@ -50,33 +68,110 @@ class FakeCompleted:
|
|||
|
||||
|
||||
class HostAlignmentTest(unittest.TestCase):
|
||||
def test_matching_hosts_are_accepted(self) -> None:
|
||||
def test_matching_transport_hosts_are_accepted(self) -> None:
|
||||
host = MODULE.assert_single_host(
|
||||
"wss://api.example.test/voice/ws",
|
||||
"https://api.example.test",
|
||||
"https://api.example.test/admin/voice-runtime",
|
||||
)
|
||||
self.assertEqual(host, "api.example.test")
|
||||
|
||||
def test_mismatched_hosts_fail_closed(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError):
|
||||
def test_mismatched_transport_hosts_fail_closed_without_echoing_hosts(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError) as caught:
|
||||
MODULE.assert_single_host(
|
||||
"wss://api.example.test/voice/ws", "https://other.example.test"
|
||||
"wss://api.example.test/voice/ws",
|
||||
"https://private-tenant.example.test/admin/voice-runtime",
|
||||
)
|
||||
self.assertEqual("transport_hosts_must_match", str(caught.exception))
|
||||
self.assertNotIn("private-tenant", str(caught.exception))
|
||||
|
||||
def test_plan_rejects_a_split_window(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError):
|
||||
def test_browser_origin_is_independent_from_transport_host(self) -> None:
|
||||
legs = MODULE.plan_legs(
|
||||
args(microphone_device="mic", confirm_physical_capture=True),
|
||||
Path("out"),
|
||||
admin_probe=lambda origin: ["/admin/voice-runtime"],
|
||||
)
|
||||
self.assertEqual(legs[0].argv[legs[0].argv.index("--origin") + 1],
|
||||
"https://web.example.test")
|
||||
|
||||
def test_plan_rejects_a_split_transport_window(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError) as caught:
|
||||
MODULE.plan_legs(
|
||||
args(admin_runtime_url="https://elsewhere.test/admin/voice-runtime",
|
||||
microphone_device="mic", confirm_physical_capture=True),
|
||||
Path("out"),
|
||||
admin_probe=lambda origin: ["/admin/voice-runtime"],
|
||||
)
|
||||
self.assertEqual("transport_hosts_must_match", str(caught.exception))
|
||||
|
||||
def test_browser_origin_requires_an_explicit_allowlist_match(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError) as caught:
|
||||
MODULE.plan_legs(
|
||||
args(origin="https://unlisted.example.test"),
|
||||
Path("out"),
|
||||
admin_probe=lambda origin: ["/admin/voice-runtime"],
|
||||
)
|
||||
self.assertEqual("browser_origin_not_allowed", str(caught.exception))
|
||||
|
||||
def test_added_origin_does_not_remove_the_public_default(self) -> None:
|
||||
normalized = MODULE.assert_browser_origin_allowed(
|
||||
MODULE.DEFAULT_BROWSER_ORIGIN, ["https://preview.example.test"]
|
||||
)
|
||||
self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, normalized)
|
||||
|
||||
def test_transport_schemes_fail_closed_without_reflecting_urls(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
"ws://private-wss.example.test/voice/ws?token=secret",
|
||||
"https://api.example.test/admin/voice-runtime",
|
||||
"wss_url_scheme_invalid",
|
||||
),
|
||||
(
|
||||
"wss://api.example.test/voice/ws",
|
||||
"http://private-admin.example.test/admin/voice-runtime?email=secret",
|
||||
"admin_runtime_url_scheme_invalid",
|
||||
),
|
||||
)
|
||||
for wss_url, admin_url, expected in cases:
|
||||
with self.subTest(expected=expected):
|
||||
with self.assertRaises(MODULE.WindowError) as caught:
|
||||
MODULE.assert_transport_schemes(wss_url, admin_url)
|
||||
self.assertEqual(expected, str(caught.exception))
|
||||
self.assertNotIn("secret", str(caught.exception))
|
||||
|
||||
def test_browser_origin_query_is_rejected_without_echoing_it(self) -> None:
|
||||
secret_query = "https://web.example.test?email=person@example.test"
|
||||
with self.assertRaises(MODULE.WindowError) as caught:
|
||||
MODULE.assert_browser_origin_allowed(
|
||||
secret_query, ["https://web.example.test"]
|
||||
)
|
||||
self.assertEqual("browser_origin_invalid", str(caught.exception))
|
||||
self.assertNotIn("person@example.test", str(caught.exception))
|
||||
|
||||
def test_public_parser_defaults_match_browser_and_transport_contract(self) -> None:
|
||||
parser = MODULE.build_parser()
|
||||
parsed = parser.parse_args(["--out-dir", "out"])
|
||||
self.assertEqual(MODULE.DEFAULT_WSS_URL, parsed.wss_url)
|
||||
self.assertEqual(MODULE.DEFAULT_BROWSER_ORIGIN, parsed.origin)
|
||||
self.assertEqual(MODULE.DEFAULT_ADMIN_RUNTIME_URL, parsed.admin_runtime_url)
|
||||
self.assertEqual(3_120.0, parsed.duration_seconds)
|
||||
self.assertIsNone(parsed.allowed_browser_origins)
|
||||
help_text = parser.format_help()
|
||||
for expected in (
|
||||
"https://vignette.chanpaca.net",
|
||||
"wss://api-vignette.chanpaca.net/voice/ws",
|
||||
"stt_provider=local_whisper stt_model=small",
|
||||
"tts_provider=melotts tts_model=melotts-korean",
|
||||
"3120초",
|
||||
):
|
||||
self.assertIn(expected, help_text)
|
||||
|
||||
|
||||
class SamplePlanTest(unittest.TestCase):
|
||||
def test_samples_cover_the_whole_window(self) -> None:
|
||||
self.assertEqual(MODULE.sample_plan(3_000.0, 100.0), 31)
|
||||
self.assertEqual(MODULE.sample_plan(3_120.0, 100.0), 33)
|
||||
|
||||
def test_fractional_interval_always_gets_a_terminal_sample(self) -> None:
|
||||
self.assertEqual(MODULE.sample_plan(3_121.0, 100.0), 33)
|
||||
|
||||
def test_invalid_plan_fails_closed(self) -> None:
|
||||
for duration, interval in ((0, 100.0), (3_000.0, 0)):
|
||||
|
|
@ -115,11 +210,14 @@ class ConsentGateTest(unittest.TestCase):
|
|||
self.assertNotIn("--confirm-physical-capture", leg.argv)
|
||||
self.assertNotIn("--microphone-device", leg.argv)
|
||||
|
||||
def test_production_window_shorter_than_fifty_minutes_is_rejected(self) -> None:
|
||||
def test_three_thousand_second_capture_is_rejected_without_skew_margin(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError) as ctx:
|
||||
MODULE.validate(args(duration_seconds=600.0))
|
||||
MODULE.validate(args(duration_seconds=3_000.0))
|
||||
self.assertEqual(str(ctx.exception), "production_window_too_short")
|
||||
|
||||
def test_fifty_two_minute_capture_is_accepted(self) -> None:
|
||||
MODULE.validate(args(duration_seconds=3_120.0))
|
||||
|
||||
def test_production_run_requires_the_human_pack(self) -> None:
|
||||
with self.assertRaises(MODULE.WindowError) as ctx:
|
||||
MODULE.validate(args(human_voice_gain=None))
|
||||
|
|
@ -145,16 +243,88 @@ class LegCompositionTest(unittest.TestCase):
|
|||
leg.argv[leg.argv.index("--public-host") + 1], "api.example.test"
|
||||
)
|
||||
|
||||
def test_all_three_legs_share_one_window_length(self) -> None:
|
||||
def test_windows_topology_leg_passes_exact_process_pins(self) -> None:
|
||||
windows_args = args(
|
||||
topology_mode="windows-host",
|
||||
compose_project=None,
|
||||
api_container=None,
|
||||
api_image_digest=None,
|
||||
caddy_container=None,
|
||||
caddy_image_digest=None,
|
||||
repo_root=r"D:\workspace\vignette",
|
||||
git_sha="5" * 40,
|
||||
git_tree_sha="6" * 40,
|
||||
runner_script_sha256="7" * 64,
|
||||
collector_script_sha256="8" * 64,
|
||||
checker_script_sha256="9" * 64,
|
||||
psutil_version="7.0.0",
|
||||
api_pid=301,
|
||||
api_executable_name="python.exe",
|
||||
api_executable_sha256="3" * 64,
|
||||
api_cwd=r"D:\workspace\vignette\apps\api",
|
||||
api_listen_port=8001,
|
||||
cloudflared_pid=302,
|
||||
cloudflared_executable_name="cloudflared.exe",
|
||||
cloudflared_executable_sha256="4" * 64,
|
||||
cloudflared_cwd=r"D:\workspace\vignette",
|
||||
)
|
||||
MODULE.validate(windows_args)
|
||||
leg = MODULE.build_topology_leg(windows_args, Path("topology.json"))
|
||||
|
||||
self.assertEqual("windows-host", leg.argv[leg.argv.index("--topology-mode") + 1])
|
||||
self.assertEqual("301", leg.argv[leg.argv.index("--api-pid") + 1])
|
||||
self.assertEqual(
|
||||
"6" * 40, leg.argv[leg.argv.index("--git-tree-sha") + 1]
|
||||
)
|
||||
self.assertEqual(
|
||||
"7" * 64, leg.argv[leg.argv.index("--runner-script-sha256") + 1]
|
||||
)
|
||||
self.assertEqual(
|
||||
"8" * 64,
|
||||
leg.argv[leg.argv.index("--collector-script-sha256") + 1],
|
||||
)
|
||||
self.assertEqual(
|
||||
"9" * 64, leg.argv[leg.argv.index("--checker-script-sha256") + 1]
|
||||
)
|
||||
self.assertEqual("7.0.0", leg.argv[leg.argv.index("--psutil-version") + 1])
|
||||
self.assertEqual(
|
||||
"4" * 64,
|
||||
leg.argv[leg.argv.index("--cloudflared-executable-sha256") + 1],
|
||||
)
|
||||
self.assertNotIn("--compose-project", leg.argv)
|
||||
|
||||
def test_each_topology_mode_requires_its_own_pins(self) -> None:
|
||||
cases = (
|
||||
(args(topology_mode="unknown"), "topology_mode_invalid"),
|
||||
(args(compose_project=None), "topology_argument_required:compose-project"),
|
||||
(
|
||||
args(
|
||||
topology_mode="windows-host",
|
||||
compose_project=None,
|
||||
api_container=None,
|
||||
api_image_digest=None,
|
||||
caddy_container=None,
|
||||
caddy_image_digest=None,
|
||||
),
|
||||
"topology_argument_required:repo-root",
|
||||
),
|
||||
)
|
||||
for invalid, failure in cases:
|
||||
with self.subTest(failure=failure):
|
||||
with self.assertRaises(MODULE.WindowError) as caught:
|
||||
MODULE.validate(invalid)
|
||||
self.assertEqual(failure, str(caught.exception))
|
||||
|
||||
def test_all_three_legs_share_the_margin_extended_window_length(self) -> None:
|
||||
legs = MODULE.plan_legs(
|
||||
args(rehearse=True, duration_seconds=3_000.0), Path("out"),
|
||||
args(rehearse=True, duration_seconds=3_120.0), Path("out"),
|
||||
admin_probe=lambda origin: ["/admin/voice-runtime"],
|
||||
)
|
||||
self.assertEqual([leg.name for leg in legs][1:], ["runtime", "topology"])
|
||||
for leg in legs[1:]:
|
||||
samples = int(leg.argv[leg.argv.index("--samples") + 1])
|
||||
interval = float(leg.argv[leg.argv.index("--interval-seconds") + 1])
|
||||
self.assertGreaterEqual(samples * interval, 3_000.0)
|
||||
self.assertGreaterEqual((samples - 1) * interval, 3_120.0)
|
||||
|
||||
|
||||
class WindowExecutionTest(unittest.TestCase):
|
||||
|
|
@ -256,6 +426,31 @@ class ReportTest(unittest.TestCase):
|
|||
self.assertTrue(closed["gate_closed"])
|
||||
self.assertFalse(open_gate["gate_closed"])
|
||||
|
||||
def test_production_process_exit_is_bound_to_checker_and_closed_gate(self) -> None:
|
||||
closed = MODULE.summarize(
|
||||
self._results(), rehearse=False, checker_returncode=0
|
||||
)
|
||||
checker_failed = MODULE.summarize(
|
||||
self._results(), rehearse=False, checker_returncode=1
|
||||
)
|
||||
leg_failed = MODULE.summarize(
|
||||
self._results((0, 1, 0)), rehearse=False, checker_returncode=0
|
||||
)
|
||||
self.assertEqual(0, MODULE.process_exit_code(closed))
|
||||
self.assertEqual(1, MODULE.process_exit_code(checker_failed))
|
||||
self.assertEqual(1, MODULE.process_exit_code(leg_failed))
|
||||
|
||||
def test_rehearse_exit_reports_leg_health_but_never_closes_gate(self) -> None:
|
||||
passed = MODULE.summarize(
|
||||
self._results(), rehearse=True, checker_returncode=None
|
||||
)
|
||||
failed = MODULE.summarize(
|
||||
self._results((0, 1, 0)), rehearse=True, checker_returncode=None
|
||||
)
|
||||
self.assertEqual(0, MODULE.process_exit_code(passed))
|
||||
self.assertEqual(1, MODULE.process_exit_code(failed))
|
||||
self.assertFalse(passed["gate_closed"])
|
||||
|
||||
def test_failed_leg_is_reported(self) -> None:
|
||||
report = MODULE.summarize(
|
||||
self._results((0, 1, 0)), rehearse=False, checker_returncode=None
|
||||
|
|
|
|||
331
scripts/test_start_public_runtime_contract.py
Normal file
331
scripts/test_start_public_runtime_contract.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
PUBLIC_RUNTIME = (SCRIPTS / "start-public-runtime.ps1").read_text(encoding="utf-8")
|
||||
WHISPER_START = (SCRIPTS / "start-local-whisper-stt.ps1").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
class PublicRuntimeVoiceContractTest(unittest.TestCase):
|
||||
def test_public_provider_model_and_loopback_environment_are_explicit(self) -> None:
|
||||
for expected in (
|
||||
'$WhisperModel = "small"',
|
||||
'$WhisperDevice = "cpu"',
|
||||
'$MeloTtsModel = "melotts-korean"',
|
||||
'$env:VIGNETTE_VOICE_STT_PROVIDER = "local_whisper"',
|
||||
'$env:VIGNETTE_LOCAL_WHISPER_STT_MODEL = $WhisperModel',
|
||||
'$env:VIGNETTE_VOICE_TTS_PROVIDER = "melotts"',
|
||||
'"ws://127.0.0.1:$WhisperPort/v1/listen"',
|
||||
'"http://127.0.0.1:$MeloTtsPort"',
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, PUBLIC_RUNTIME)
|
||||
|
||||
def test_existing_listeners_require_exact_protocol_metadata(self) -> None:
|
||||
self.assertIn('Test-VoiceSidecarReady -Component "stt"', PUBLIC_RUNTIME)
|
||||
self.assertIn('Test-VoiceSidecarReady -Component "tts"', PUBLIC_RUNTIME)
|
||||
self.assertIn("if (Test-PortListener -Port $WhisperPort)", PUBLIC_RUNTIME)
|
||||
self.assertIn("if (Test-PortListener -Port $MeloTtsPort)", PUBLIC_RUNTIME)
|
||||
self.assertIn("does not expose the exact local_whisper", PUBLIC_RUNTIME)
|
||||
self.assertIn("does not expose the exact melotts", PUBLIC_RUNTIME)
|
||||
|
||||
def test_sidecar_fail_closed_gate_precedes_api_mutation(self) -> None:
|
||||
sidecar_gate = PUBLIC_RUNTIME.index(
|
||||
'# 두 sidecar를 한 번 더 함께 검사해 개별 probe 사이의 TOCTOU를 닫는다.'
|
||||
)
|
||||
api_stop = PUBLIC_RUNTIME.index("$apiStoppedProcessIds = @(")
|
||||
self.assertLess(sidecar_gate, api_stop)
|
||||
self.assertLess(
|
||||
PUBLIC_RUNTIME.index("Voice sidecar readiness changed"),
|
||||
api_stop,
|
||||
)
|
||||
|
||||
def test_api_skip_and_post_start_checks_require_exact_voice_health(self) -> None:
|
||||
for expected in (
|
||||
'$Health.stt_provider -eq "local_whisper"',
|
||||
"$Health.stt_model -eq $WhisperModel",
|
||||
'$Health.tts_provider -eq "melotts"',
|
||||
"$Health.tts_model -eq $MeloTtsModel",
|
||||
"$Health.limits.uvicorn_ws_max_queue -eq 4",
|
||||
"$health.engine -eq $true",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, PUBLIC_RUNTIME)
|
||||
self.assertGreaterEqual(
|
||||
PUBLIC_RUNTIME.count("Test-VoiceApiReady -Health"),
|
||||
3,
|
||||
)
|
||||
|
||||
def test_api_uvicorn_websocket_queue_is_pinned(self) -> None:
|
||||
start = PUBLIC_RUNTIME.index("$apiStoppedProcessIds = @(")
|
||||
end = PUBLIC_RUNTIME.index("Start-Sleep -Seconds 3", start)
|
||||
api_section = PUBLIC_RUNTIME[start:end]
|
||||
self.assertIn('"app.main:app"', api_section)
|
||||
self.assertIn('"--ws", "websockets"', api_section)
|
||||
self.assertIn('"--ws-max-queue", "4"', api_section)
|
||||
|
||||
def test_standalone_whisper_launcher_matches_public_cpu_default(self) -> None:
|
||||
self.assertIn("[string]$Model = 'small'", WHISPER_START)
|
||||
self.assertIn("[string]$Device = 'cpu'", WHISPER_START)
|
||||
|
||||
|
||||
class FreshPublicProvenanceContractTest(unittest.TestCase):
|
||||
def test_fresh_mode_is_explicit_and_fail_closed(self) -> None:
|
||||
for expected in (
|
||||
"[switch]$RequireFreshPublicProvenance",
|
||||
'throw "-RequireFreshPublicProvenance requires -ForceApiRestart"',
|
||||
'throw "-RequireFreshPublicProvenance forbids -SkipCloudflaredRestart"',
|
||||
"ExpectedSourceCommit",
|
||||
"ExpectedSourceTree",
|
||||
"ExpectedPythonSha256",
|
||||
"ExpectedCloudflaredSha256",
|
||||
"ExpectedCloudflaredConfigSha256",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, PUBLIC_RUNTIME)
|
||||
|
||||
def test_fresh_gate_precedes_every_runtime_mutation(self) -> None:
|
||||
gate = PUBLIC_RUNTIME.index("Assert-FreshPublicProvenanceContract `")
|
||||
receipt_preflight = PUBLIC_RUNTIME.index(
|
||||
"$resolvedRuntimeProvenancePath = Initialize-RuntimeProvenanceOutput"
|
||||
)
|
||||
exact_ingress = PUBLIC_RUNTIME.index("-RequireUnchanged", gate)
|
||||
engine_probe = PUBLIC_RUNTIME.index("$engineHealth = Get-JsonHealth")
|
||||
self.assertLess(gate, receipt_preflight)
|
||||
self.assertLess(receipt_preflight, exact_ingress)
|
||||
self.assertLess(gate, exact_ingress)
|
||||
self.assertLess(exact_ingress, engine_probe)
|
||||
self.assertIn("requires detached HEAD", PUBLIC_RUNTIME)
|
||||
self.assertIn("requires a clean stable source", PUBLIC_RUNTIME)
|
||||
|
||||
def test_receipt_preflight_proves_sibling_atomic_replace_capability(self) -> None:
|
||||
preflight = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Initialize-RuntimeProvenanceOutput") :
|
||||
PUBLIC_RUNTIME.index("function Write-Utf8TextAtomically")
|
||||
]
|
||||
for expected in (
|
||||
"output preflight failed before runtime mutation",
|
||||
"[System.IO.FileMode]::Open",
|
||||
"[System.IO.FileAccess]::ReadWrite",
|
||||
"[System.IO.File]::Replace($probeSource, $probeTarget, $probeBackup)",
|
||||
"[System.IO.File]::Delete($probeTarget)",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, preflight)
|
||||
|
||||
def test_receipt_commit_is_atomic_and_failure_is_explicit(self) -> None:
|
||||
writer = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Write-Utf8TextAtomically") :
|
||||
PUBLIC_RUNTIME.index("function Assert-FreshPublicProvenanceContract")
|
||||
]
|
||||
for expected in (
|
||||
"[System.IO.FileMode]::CreateNew",
|
||||
"$stream.Flush($true)",
|
||||
"[System.IO.File]::Replace($temporaryPath, $OutputPath, $backupPath)",
|
||||
"[System.IO.File]::Move($temporaryPath, $OutputPath)",
|
||||
"[System.IO.File]::Delete($temporaryPath)",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, writer)
|
||||
|
||||
receipt = PUBLIC_RUNTIME[PUBLIC_RUNTIME.index("$provenance = [ordered]@{") :]
|
||||
self.assertIn("Write-Utf8TextAtomically `", receipt)
|
||||
self.assertIn("no atomic passed receipt was published", receipt)
|
||||
self.assertNotIn(
|
||||
"[System.IO.File]::WriteAllText(\n $resolvedRuntimeProvenancePath",
|
||||
receipt,
|
||||
)
|
||||
|
||||
def test_receipt_helpers_work_in_windows_powershell(self) -> None:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
self.skipTest("Windows PowerShell 5.1 is not available")
|
||||
|
||||
function_source = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Initialize-RuntimeProvenanceOutput") :
|
||||
PUBLIC_RUNTIME.index("function Assert-FreshPublicProvenanceContract")
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
functions_path = root / "receipt-functions.ps1"
|
||||
harness_path = root / "receipt-harness.ps1"
|
||||
functions_path.write_text(function_source, encoding="utf-8-sig")
|
||||
quoted_functions = str(functions_path).replace("'", "''")
|
||||
quoted_root = str(root).replace("'", "''")
|
||||
harness_path.write_text(
|
||||
f"""$ErrorActionPreference = 'Stop'
|
||||
. '{quoted_functions}'
|
||||
$target = Join-Path '{quoted_root}' 'nested\\receipt.json'
|
||||
$resolved = Initialize-RuntimeProvenanceOutput -OutputPath $target
|
||||
Write-Utf8TextAtomically -OutputPath $resolved -Value '{{"status":"passed"}}'
|
||||
$receipt = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($receipt.status -ne 'passed') {{ throw 'atomic receipt content mismatch' }}
|
||||
Write-Utf8TextAtomically -OutputPath $resolved -Value '{{"status":"replaced"}}'
|
||||
$replacement = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($replacement.status -ne 'replaced') {{ throw 'atomic replacement mismatch' }}
|
||||
if (@(Get-ChildItem -LiteralPath (Split-Path -Parent $resolved) -Filter '*.tmp').Count -ne 0) {{
|
||||
throw 'temporary receipt files were not cleaned'
|
||||
}}
|
||||
[System.IO.File]::SetAttributes($resolved, [System.IO.FileAttributes]::ReadOnly)
|
||||
$preflightFailed = $false
|
||||
try {{
|
||||
Initialize-RuntimeProvenanceOutput -OutputPath $resolved | Out-Null
|
||||
}} catch {{
|
||||
$preflightFailed = $true
|
||||
}} finally {{
|
||||
[System.IO.File]::SetAttributes($resolved, [System.IO.FileAttributes]::Normal)
|
||||
}}
|
||||
if (-not $preflightFailed) {{ throw 'read-only receipt preflight did not fail' }}
|
||||
$preserved = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($preserved.status -ne 'replaced') {{ throw 'failed preflight changed the prior receipt' }}
|
||||
""",
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
powershell,
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(harness_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(
|
||||
completed.returncode,
|
||||
0,
|
||||
msg=f"stdout={completed.stdout}\nstderr={completed.stderr}",
|
||||
)
|
||||
|
||||
def test_force_api_restart_keeps_regular_healthy_semantics(self) -> None:
|
||||
self.assertIn(
|
||||
"if ($apiControlPlaneReady -and -not $ForceApiRestart)",
|
||||
PUBLIC_RUNTIME,
|
||||
)
|
||||
fresh_guard = PUBLIC_RUNTIME.index(
|
||||
'throw "-RequireFreshPublicProvenance requires -ForceApiRestart"'
|
||||
)
|
||||
api_skip = PUBLIC_RUNTIME.index(
|
||||
"if ($apiControlPlaneReady -and -not $ForceApiRestart)"
|
||||
)
|
||||
self.assertLess(fresh_guard, api_skip)
|
||||
|
||||
def test_api_and_tunnel_are_bounded_replacements_with_exact_cwds(self) -> None:
|
||||
api = PUBLIC_RUNTIME.index("$apiStoppedProcessIds = @(")
|
||||
cloud = PUBLIC_RUNTIME.index("$cloudflaredStoppedProcessIds = @(")
|
||||
receipt = PUBLIC_RUNTIME.index("$provenance = [ordered]@{")
|
||||
api_section = PUBLIC_RUNTIME[api:cloud]
|
||||
cloud_section = PUBLIC_RUNTIME[cloud:receipt]
|
||||
self.assertIn("Stop-UvicornByPort `", api_section)
|
||||
self.assertIn("-TimeoutSec $ProcessStopTimeoutSeconds", api_section)
|
||||
self.assertIn("-WorkingDirectory $ApiDir", api_section)
|
||||
self.assertIn("did not receive a replacement PID", api_section)
|
||||
self.assertIn("Stop-ProcessesBounded `", cloud_section)
|
||||
self.assertIn("-TimeoutSec $ProcessStopTimeoutSeconds", cloud_section)
|
||||
self.assertIn("-WorkingDirectory $Workspace", cloud_section)
|
||||
self.assertIn("Cloudflared did not receive a replacement PID", cloud_section)
|
||||
self.assertNotIn("Cloudflared already running; skipping", PUBLIC_RUNTIME)
|
||||
|
||||
def test_tunnel_command_and_config_are_pinned_before_receipt(self) -> None:
|
||||
for expected in (
|
||||
'"tunnel", "--config", $resolvedCloudflaredConfig, "run"',
|
||||
"command line is not pinned to the expected config",
|
||||
"Pinned cloudflared config drifted before provenance receipt",
|
||||
"finalConfigSha256",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, PUBLIC_RUNTIME)
|
||||
|
||||
def test_fresh_tunnel_stop_matches_only_the_resolved_full_config_path(self) -> None:
|
||||
cloud = PUBLIC_RUNTIME.index("$resolvedCloudflaredConfig =")
|
||||
start = PUBLIC_RUNTIME.index(
|
||||
"$cloudflaredProcess = Start-Process", cloud
|
||||
)
|
||||
cloud_section = PUBLIC_RUNTIME[cloud:start]
|
||||
self.assertIn("if ($RequireFreshPublicProvenance)", cloud_section)
|
||||
self.assertIn("-ConfigPath $resolvedCloudflaredConfig `", cloud_section)
|
||||
self.assertIn("-ExactPath", cloud_section)
|
||||
matcher = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Get-CloudflaredProcessesForConfig") :
|
||||
PUBLIC_RUNTIME.index("function Wait-ProcessIdentity")
|
||||
]
|
||||
self.assertIn("-not $ExactPath", matcher)
|
||||
self.assertIn("IndexOf($ConfigPath", matcher)
|
||||
|
||||
def test_receipt_projects_no_raw_command_or_config_contents(self) -> None:
|
||||
projection = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function ConvertTo-SafeProcessIdentity") :
|
||||
PUBLIC_RUNTIME.index("function Stop-NodeByPortHint")
|
||||
]
|
||||
self.assertIn("command_line_sha256 =", projection)
|
||||
self.assertNotIn("command_line =", projection)
|
||||
self.assertNotIn("executable_path =", projection)
|
||||
|
||||
receipt = PUBLIC_RUNTIME.index("$provenance = [ordered]@{")
|
||||
receipt_section = PUBLIC_RUNTIME[receipt:]
|
||||
self.assertIn("api = $safeApiIdentity", receipt_section)
|
||||
self.assertIn("cloudflared = $safeCloudflaredIdentity", receipt_section)
|
||||
self.assertNotIn("api = $apiFinalIdentity", receipt_section)
|
||||
self.assertNotIn("cloudflared = $cloudflaredFinalIdentity", receipt_section)
|
||||
config_projection = receipt_section[
|
||||
receipt_section.index("config = [ordered]@{") :
|
||||
receipt_section.index("replacement = [ordered]@{")
|
||||
].lower()
|
||||
self.assertNotIn("token", config_projection)
|
||||
self.assertNotIn("credential", config_projection)
|
||||
self.assertNotIn("contents", config_projection)
|
||||
|
||||
def test_process_start_and_command_hash_match_topology_psutil_algorithm(self) -> None:
|
||||
identity = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Wait-ProcessIdentity") :
|
||||
PUBLIC_RUNTIME.index("function ConvertTo-SafeProcessIdentity")
|
||||
]
|
||||
self.assertIn("datetime.fromtimestamp(p.create_time(), UTC)", identity)
|
||||
self.assertIn("chr(0).join(p.cmdline())", identity)
|
||||
self.assertIn("command_line_sha256 = $commandLineSha256", identity)
|
||||
|
||||
def test_receipt_is_direct_input_for_windows_topology_capture(self) -> None:
|
||||
receipt = PUBLIC_RUNTIME.index(
|
||||
'schema_version = "vignette.public-runtime-launch-provenance.v1"'
|
||||
)
|
||||
receipt_section = PUBLIC_RUNTIME[receipt:]
|
||||
for expected in (
|
||||
"git_commit = $ExpectedSourceCommit.ToLowerInvariant()",
|
||||
"git_tree = $ExpectedSourceTree.ToLowerInvariant()",
|
||||
"api_stopped_pids",
|
||||
"cloudflared_stopped_pids",
|
||||
"api_new_pid",
|
||||
"cloudflared_new_pid",
|
||||
"started_at_utc",
|
||||
"executable_sha256",
|
||||
"command_line_sha256",
|
||||
"api_pid",
|
||||
"api_executable_name",
|
||||
"api_cwd",
|
||||
"cloudflared_pid",
|
||||
"cloudflared_executable_name",
|
||||
"cloudflared_cwd",
|
||||
"psutil_version",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, receipt_section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,3 +1,36 @@
|
|||
Option Explicit
|
||||
|
||||
If WScript.Arguments.Count > 0 Then
|
||||
If LCase(WScript.Arguments(0)) = "syntax-only" Then
|
||||
WScript.Quit 0
|
||||
End If
|
||||
End If
|
||||
|
||||
Dim objShell
|
||||
Dim command
|
||||
Dim exitCode
|
||||
|
||||
Set objShell = CreateObject("WScript.Shell")
|
||||
cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""D:\workspace\vignette\scripts\watch-public-runtime.ps1"" -Workspace ""D:\workspace\vignette"""
|
||||
objShell.Run cmd, 0, True
|
||||
command = "$ErrorActionPreference='Stop';" & _
|
||||
"$task=Get-ScheduledTask -TaskName 'VignettePublicRuntimeWatchdog' -ErrorAction Stop;" & _
|
||||
"$action=@($task.Actions)[0];" & _
|
||||
"if([IO.Path]::GetFileName($action.Execute) -ne 'powershell.exe'){throw 'Pinned watchdog task must execute powershell.exe'};" & _
|
||||
"$root=$action.WorkingDirectory;if([string]::IsNullOrWhiteSpace($root)){throw 'Pinned watchdog working directory is missing'};" & _
|
||||
"$quote=[char]34;$expectedScript=Join-Path $root 'scripts\watch-public-runtime.ps1';" & _
|
||||
"$expectedFileArg='-File '+$quote+$expectedScript+$quote;$expectedRootArg='-StableSourceRoot '+$quote+$root+$quote;" & _
|
||||
"if($action.Arguments.IndexOf($expectedFileArg,[StringComparison]::OrdinalIgnoreCase) -lt 0){throw 'Pinned watchdog script path does not match its working directory'};" & _
|
||||
"if($action.Arguments.IndexOf($expectedRootArg,[StringComparison]::OrdinalIgnoreCase) -lt 0){throw 'Pinned watchdog source root does not match its working directory'};" & _
|
||||
"$required=@('-StableSourceRoot','-ExpectedSourceCommit','-ExpectedSourceTree','-ExpectedWatchdogSha256','-ExpectedStartScriptSha256');" & _
|
||||
"foreach($marker in $required){if($action.Arguments.IndexOf($marker,[StringComparison]::Ordinal) -lt 0){throw ('Unpinned watchdog task action: missing '+$marker)}};" & _
|
||||
"if($action.Arguments -match '(?i)(?:^|\s)-Workspace(?:\s|$)'){throw 'Legacy shared-worktree watchdog action is forbidden'};" & _
|
||||
"Start-ScheduledTask -TaskName 'VignettePublicRuntimeWatchdog'"
|
||||
command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command """ & _
|
||||
Replace(command, """", """""") & """"
|
||||
If WScript.Arguments.Count > 0 Then
|
||||
If LCase(WScript.Arguments(0)) = "print-command" Then
|
||||
WScript.Echo command
|
||||
WScript.Quit 0
|
||||
End If
|
||||
End If
|
||||
exitCode = objShell.Run(command, 0, True)
|
||||
WScript.Quit exitCode
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
param(
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StableSourceRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedSourceCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedSourceTree,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedWatchdogSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedStartScriptSha256,
|
||||
[int]$ApiPort = 8001,
|
||||
[int]$WebPort = 5174,
|
||||
[int]$EnginePort = 9099,
|
||||
|
|
@ -20,12 +33,97 @@
|
|||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
|
||||
$expectedWatchdogPath = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime.ps1"
|
||||
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
|
||||
|
||||
function Invoke-GitText {
|
||||
param([string[]]$Arguments)
|
||||
|
||||
$value = & git.exe -C $resolvedSourceRoot @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Stable source Git command failed (exit=$LASTEXITCODE): git $($Arguments -join ' ')"
|
||||
}
|
||||
return (@($value) -join [Environment]::NewLine).Trim()
|
||||
}
|
||||
|
||||
function Assert-StableSourceProvenance {
|
||||
if (-not (Test-Path -LiteralPath $expectedWatchdogPath -PathType Leaf)) {
|
||||
throw "Pinned watchdog script not found at $expectedWatchdogPath"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $startScript -PathType Leaf)) {
|
||||
throw "Pinned start script not found at $startScript"
|
||||
}
|
||||
|
||||
$runningWatchdogPath = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
if (-not [string]::Equals(
|
||||
$runningWatchdogPath,
|
||||
(Resolve-Path -LiteralPath $expectedWatchdogPath).Path,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Watchdog is not executing from the pinned stable source root"
|
||||
}
|
||||
|
||||
$gitRoot = Invoke-GitText -Arguments @("rev-parse", "--show-toplevel")
|
||||
$resolvedGitRoot = (Resolve-Path -LiteralPath $gitRoot).Path
|
||||
if (-not [string]::Equals(
|
||||
$resolvedGitRoot,
|
||||
$resolvedSourceRoot,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Stable source root does not match its Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicHead = & git.exe -C $resolvedSourceRoot symbolic-ref --quiet HEAD
|
||||
$symbolicHeadExit = $LASTEXITCODE
|
||||
if ($symbolicHeadExit -eq 0) {
|
||||
throw "Stable source must be a detached HEAD, not branch $symbolicHead"
|
||||
}
|
||||
if ($symbolicHeadExit -ne 1) {
|
||||
throw "Could not prove detached HEAD (git exit=$symbolicHeadExit)"
|
||||
}
|
||||
|
||||
$actualCommit = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD")
|
||||
$actualTree = Invoke-GitText -Arguments @("rev-parse", "--verify", "HEAD^{tree}")
|
||||
if ($actualCommit -ne $ExpectedSourceCommit.ToLowerInvariant()) {
|
||||
throw "Stable source commit drift: expected=$ExpectedSourceCommit actual=$actualCommit"
|
||||
}
|
||||
if ($actualTree -ne $ExpectedSourceTree.ToLowerInvariant()) {
|
||||
throw "Stable source tree drift: expected=$ExpectedSourceTree actual=$actualTree"
|
||||
}
|
||||
|
||||
$dirty = Invoke-GitText -Arguments @("status", "--porcelain=v1", "--untracked-files=normal")
|
||||
if ($dirty) {
|
||||
throw "Stable source is not clean; refusing runtime recovery"
|
||||
}
|
||||
|
||||
foreach ($relativePath in @(
|
||||
"scripts/watch-public-runtime.ps1",
|
||||
"scripts/start-public-runtime.ps1"
|
||||
)) {
|
||||
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
|
||||
}
|
||||
|
||||
$actualWatchdogSha256 = (Get-FileHash -LiteralPath $expectedWatchdogPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$actualStartScriptSha256 = (Get-FileHash -LiteralPath $startScript -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualWatchdogSha256 -ne $ExpectedWatchdogSha256.ToLowerInvariant()) {
|
||||
throw "Pinned watchdog SHA256 drift"
|
||||
}
|
||||
if ($actualStartScriptSha256 -ne $ExpectedStartScriptSha256.ToLowerInvariant()) {
|
||||
throw "Pinned start script SHA256 drift"
|
||||
}
|
||||
}
|
||||
|
||||
# health probe, failcount 기록, 프로세스 재기동보다 먼저 source provenance를 닫는다.
|
||||
# 검증 실패는 운영 프로세스를 그대로 보존한 채 non-zero로 끝난다.
|
||||
Assert-StableSourceProvenance
|
||||
|
||||
if (!$LogPath) {
|
||||
$LogPath = Join-Path $Workspace "public-runtime-watchdog.log"
|
||||
$LogPath = Join-Path $resolvedSourceRoot "public-runtime-watchdog.log"
|
||||
}
|
||||
|
||||
# 연속 실패 카운터(재시작 debounce용). 워치독은 매 실행마다 새 프로세스라 파일로 유지한다.
|
||||
$FailCountPath = Join-Path $Workspace "public-runtime-watchdog.failcount"
|
||||
$FailCountPath = Join-Path $resolvedSourceRoot "public-runtime-watchdog.failcount"
|
||||
|
||||
function Get-FailCount {
|
||||
if (Test-Path $FailCountPath) {
|
||||
|
|
@ -130,11 +228,6 @@ function Test-CloudflaredProcess {
|
|||
}
|
||||
}
|
||||
|
||||
$startScript = Join-Path $PSScriptRoot "start-public-runtime.ps1"
|
||||
if (!(Test-Path $startScript)) {
|
||||
throw "Start script not found at $startScript"
|
||||
}
|
||||
|
||||
# engine 판정은 /health(프로세스 liveness)가 아니라 /ready(실제 claude -p 생성)로 한다.
|
||||
# /health는 ok:true만 보므로 "프로세스는 살아 있고 그 프로세스의 claude 세션만 죽은"
|
||||
# 상태를 통과시킨다(2026-08-07 공개 런타임: engine=false인데 워치독 lastResult=0).
|
||||
|
|
@ -197,7 +290,7 @@ if ($failCount -lt $FailuresBeforeRestart) {
|
|||
}
|
||||
|
||||
$startArgs = @{
|
||||
Workspace = $Workspace
|
||||
Workspace = $resolvedSourceRoot
|
||||
ApiPort = $ApiPort
|
||||
WebPort = $WebPort
|
||||
EnginePort = $EnginePort
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue