G7 증명과 G8 clean-head 승격 준비
This commit is contained in:
parent
94c681d450
commit
5221f79e3f
52 changed files with 6876 additions and 506 deletions
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue