공개 런타임 롤백 경계 강화
This commit is contained in:
parent
ff3c79dfc2
commit
aaebe4450e
7 changed files with 1271 additions and 30 deletions
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import unittest
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -83,6 +84,10 @@ class FreshPublicProvenanceContractTest(unittest.TestCase):
|
|||
"[switch]$RequireFreshPublicProvenance",
|
||||
'throw "-RequireFreshPublicProvenance requires -ForceApiRestart"',
|
||||
'throw "-RequireFreshPublicProvenance forbids -SkipCloudflaredRestart"',
|
||||
'throw "-RequireFreshPublicProvenance requires -SkipEngineRestart;',
|
||||
'throw "-RequireFreshPublicProvenance requires -SkipWebRestart;',
|
||||
'throw "-RequireFreshPublicProvenance forbids DNS route mutation"',
|
||||
"Fresh public promotion requires the canonical HTTPS public health URL",
|
||||
"ExpectedSourceCommit",
|
||||
"ExpectedSourceTree",
|
||||
"ExpectedPythonSha256",
|
||||
|
|
@ -171,6 +176,18 @@ 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' }}
|
||||
$failedPath = Write-FailedFreshPromotionEvidence `
|
||||
-OutputPath $resolved `
|
||||
-FailureStage 'receipt_publish' `
|
||||
-RollbackSucceeded $true `
|
||||
-RollbackResult @{{local_health=$true;public_health=$true}} `
|
||||
-SourceCommit ('a' * 40) `
|
||||
-SourceTree ('b' * 40)
|
||||
$failed = Get-Content -LiteralPath $failedPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ((Split-Path -Leaf $failedPath) -notlike '*.failed.log') {{ throw 'failure evidence suffix mismatch' }}
|
||||
if ($failed.status -ne 'failed_rolled_back') {{ throw 'failure evidence status mismatch' }}
|
||||
if ($failed.failure_stage -ne 'receipt_publish') {{ throw 'failure evidence stage mismatch' }}
|
||||
if (-not $failed.rollback.succeeded) {{ throw 'failure evidence rollback mismatch' }}
|
||||
if (@(Get-ChildItem -LiteralPath (Split-Path -Parent $resolved) -Filter '*.tmp').Count -ne 0) {{
|
||||
throw 'temporary receipt files were not cleaned'
|
||||
}}
|
||||
|
|
@ -271,7 +288,7 @@ if ($preserved.status -ne 'replaced') {{ throw 'failed preflight changed the pri
|
|||
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")
|
||||
PUBLIC_RUNTIME.index("function Save-ManagedEnvironment")
|
||||
]
|
||||
self.assertIn("command_line_sha256 =", projection)
|
||||
self.assertNotIn("command_line =", projection)
|
||||
|
|
@ -297,9 +314,376 @@ if ($preserved.status -ne 'replaced') {{ throw 'failed preflight changed the pri
|
|||
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("argv=p.cmdline()", identity)
|
||||
self.assertIn("chr(0).join(argv)", identity)
|
||||
self.assertIn("argument_list = $argumentList", identity)
|
||||
self.assertIn("environment = $processEnvironment", identity)
|
||||
self.assertIn("command_line_sha256 = $commandLineSha256", identity)
|
||||
|
||||
def test_prior_process_argv_and_environment_round_trip_in_powershell_51(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 Save-CompleteProcessEnvironment") :
|
||||
PUBLIC_RUNTIME.index("function Restore-PriorPublicRuntime")
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
functions_path = root / "rollback-process-functions.ps1"
|
||||
harness_path = root / "rollback-process-harness.ps1"
|
||||
functions_path.write_text(function_source, encoding="utf-8-sig")
|
||||
quoted_functions = str(functions_path).replace("'", "''")
|
||||
quoted_root = str(root).replace("'", "''")
|
||||
quoted_python = sys.executable.replace("'", "''")
|
||||
harness_path.write_text(
|
||||
f"""$ErrorActionPreference = 'Stop'
|
||||
. '{quoted_functions}'
|
||||
$root = '{quoted_root}'
|
||||
$python = '{quoted_python}'
|
||||
if ($null -eq (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) {{
|
||||
function Get-FileHash {{
|
||||
param([string]$LiteralPath, [string]$Algorithm)
|
||||
$stream = [IO.File]::OpenRead($LiteralPath)
|
||||
try {{
|
||||
$hasher = [Security.Cryptography.SHA256]::Create()
|
||||
try {{ $hash = $hasher.ComputeHash($stream) }} finally {{ $hasher.Dispose() }}
|
||||
}} finally {{
|
||||
$stream.Dispose()
|
||||
}}
|
||||
[pscustomobject]@{{Hash = ([BitConverter]::ToString($hash)).Replace('-', '')}}
|
||||
}}
|
||||
}}
|
||||
$pythonSha256 = (Get-FileHash -LiteralPath $python -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$originalEnvironment = Save-CompleteProcessEnvironment
|
||||
try {{
|
||||
[Environment]::SetEnvironmentVariable('VIGNETTE_ROLLBACK_ARGV_TEST', 'caller', 'Process')
|
||||
[Environment]::SetEnvironmentVariable('VIGNETTE_CALLER_ONLY', 'caller-only', 'Process')
|
||||
$callerEnvironment = Save-CompleteProcessEnvironment
|
||||
[Environment]::SetEnvironmentVariable('VIGNETTE_ROLLBACK_ARGV_TEST', 'prior', 'Process')
|
||||
[Environment]::SetEnvironmentVariable('VIGNETTE_CALLER_ONLY', $null, 'Process')
|
||||
$priorEnvironment = Save-CompleteProcessEnvironment
|
||||
Set-CompleteProcessEnvironment -Environment $callerEnvironment
|
||||
|
||||
$output = Join-Path $root 'argv-result.json'
|
||||
$code = "import json,os,sys; open(sys.argv[1], 'w', encoding='utf-8').write(json.dumps({{'argv':sys.argv[2:],'marker':os.environ.get('VIGNETTE_ROLLBACK_ARGV_TEST'),'leak':os.environ.get('VIGNETTE_CALLER_ONLY')}}, ensure_ascii=True))"
|
||||
$expected = @('plain', 'space value', 'quote"value', 'trailing\', '', 'slashes\\before"quote')
|
||||
$identity = [ordered]@{{
|
||||
executable_path = $python
|
||||
executable_sha256 = $pythonSha256
|
||||
cwd = $root
|
||||
argument_list = @('-X', 'utf8', '-c', $code, $output) + $expected
|
||||
environment = $priorEnvironment
|
||||
}}
|
||||
$process = Start-PinnedPriorProcess `
|
||||
-Identity $identity `
|
||||
-Role 'argv-roundtrip' `
|
||||
-StdoutLog (Join-Path $root 'child.out.log') `
|
||||
-StderrLog (Join-Path $root 'child.err.log')
|
||||
$process.WaitForExit()
|
||||
if (-not (Test-Path -LiteralPath $output -PathType Leaf)) {{ throw 'child did not write argv result' }}
|
||||
$actual = Get-Content -LiteralPath $output -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if (@($actual.argv).Count -ne $expected.Count) {{ throw 'argument count mismatch' }}
|
||||
for ($i = 0; $i -lt $expected.Count; $i++) {{
|
||||
if ($actual.argv[$i] -cne $expected[$i]) {{ throw "argument mismatch at $i" }}
|
||||
}}
|
||||
if ($actual.marker -cne 'prior') {{ throw 'prior environment was not inherited' }}
|
||||
if ($null -ne $actual.leak) {{ throw 'caller-only environment leaked into prior process' }}
|
||||
if ($env:VIGNETTE_ROLLBACK_ARGV_TEST -cne 'caller') {{ throw 'caller environment marker was not restored' }}
|
||||
if ($env:VIGNETTE_CALLER_ONLY -cne 'caller-only') {{ throw 'caller-only environment was not restored' }}
|
||||
}} finally {{
|
||||
Set-CompleteProcessEnvironment -Environment $originalEnvironment
|
||||
}}
|
||||
""",
|
||||
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_rollback_restores_prior_openai_voice_contract_in_powershell_51(self) -> None:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
self.skipTest("Windows PowerShell 5.1 is not available")
|
||||
|
||||
voice_contract_source = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Test-PriorVoiceApiReady") :
|
||||
PUBLIC_RUNTIME.index("function Test-RequiredOpenApiPaths")
|
||||
]
|
||||
rollback_source = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Restore-PriorPublicRuntime") :
|
||||
PUBLIC_RUNTIME.index("function Stop-NodeByPortHint")
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
functions_path = root / "rollback-contract-functions.ps1"
|
||||
harness_path = root / "rollback-contract-harness.ps1"
|
||||
functions_path.write_text(
|
||||
voice_contract_source + rollback_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}'
|
||||
$events = New-Object System.Collections.ArrayList
|
||||
$root = '{quoted_root}'
|
||||
function Stop-UvicornByPort {{ param($AppImport,$Port,$TimeoutSec); $null=$events.Add('stop-api'); 11 }}
|
||||
function Restore-ManagedEnvironment {{ param($Snapshot); $null=$events.Add('restore-env') }}
|
||||
function Start-PinnedPriorProcess {{
|
||||
param($Identity,$Role,$StdoutLog,$StderrLog)
|
||||
$null=$events.Add("start-$Role")
|
||||
if ($Role -eq 'api') {{ [pscustomobject]@{{Id=101}} }} else {{ [pscustomobject]@{{Id=202}} }}
|
||||
}}
|
||||
function Wait-ProcessIdentity {{
|
||||
param($ProcessId,$Role,$ExpectedCwd,$TimeoutSec)
|
||||
$null=$events.Add("identity-$Role")
|
||||
[ordered]@{{
|
||||
pid=[int]$ProcessId; started_at_utc='2026-08-09T00:00:00Z';
|
||||
executable_name='runtime.exe'; executable_sha256=('a' * 64);
|
||||
command_line_sha256=('b' * 64); cwd=$root
|
||||
}}
|
||||
}}
|
||||
function Wait-JsonHealth {{
|
||||
param($Uri,$IsHealthy,$TimeoutSec)
|
||||
$null=$events.Add("health-$Uri")
|
||||
if ($Uri -like '*/voice/health') {{
|
||||
$payload=[pscustomobject]@{{
|
||||
status='ok';available=$true;stt_available=$true;tts_available=$true;
|
||||
stt_provider='openai';stt_model='gpt-4o-mini-transcribe';
|
||||
tts_provider='openai';tts_model='gpt-4o-mini-tts';
|
||||
limits=[pscustomobject]@{{uvicorn_ws_max_queue=4}}
|
||||
}}
|
||||
}} else {{
|
||||
$payload=[pscustomobject]@{{environment='prod';db=$true;engine=$true}}
|
||||
}}
|
||||
if (-not (& $IsHealthy $payload)) {{ throw "health predicate failed: $Uri" }}
|
||||
$payload
|
||||
}}
|
||||
function Test-VoiceSidecarReady {{ param($Component); $true }}
|
||||
function Get-CloudflaredProcessesForConfig {{ param($ConfigPath,[switch]$ExactPath); @([pscustomobject]@{{ProcessId=303}}) }}
|
||||
function Stop-ProcessesBounded {{ param($Processes,$TimeoutSec,$Role); $null=$events.Add('stop-cloudflared'); 303 }}
|
||||
function ConvertTo-SafeProcessIdentity {{ param($Identity); $Identity }}
|
||||
|
||||
$configPath = Join-Path $root 'cloudflared.yml'
|
||||
[IO.File]::WriteAllText($configPath, 'tunnel: test')
|
||||
$priorVoice = [ordered]@{{
|
||||
status='ok';available=$true;stt_available=$true;tts_available=$true;
|
||||
stt_provider='openai';stt_model='gpt-4o-mini-transcribe';
|
||||
tts_provider='openai';tts_model='gpt-4o-mini-tts';uvicorn_ws_max_queue=4
|
||||
}}
|
||||
$priorApi=[ordered]@{{executable_sha256=('a' * 64);command_line_sha256=('b' * 64);cwd=$root}}
|
||||
$priorCloud=[ordered]@{{executable_sha256=('a' * 64);command_line_sha256=('b' * 64);cwd=$root}}
|
||||
$result = Restore-PriorPublicRuntime `
|
||||
-PriorApi $priorApi `
|
||||
-PriorCloudflared $priorCloud `
|
||||
-PriorLocalVoiceContract $priorVoice `
|
||||
-PriorPublicVoiceContract $priorVoice `
|
||||
-EnvironmentSnapshot @{{}} `
|
||||
-ConfigPath $configPath `
|
||||
-ApiPortValue 8001 `
|
||||
-HealthUrl 'https://api-vignette.chanpaca.net/health' `
|
||||
-VoiceHealthUrl 'https://api-vignette.chanpaca.net/voice/health' `
|
||||
-TimeoutSec 5
|
||||
foreach ($field in @('local_health','local_voice_health','public_health','public_voice_health')) {{
|
||||
if (-not $result[$field]) {{ throw "rollback result missing $field" }}
|
||||
}}
|
||||
if ($events.IndexOf('stop-api') -ge $events.IndexOf('start-api')) {{ throw 'API restore ordering mismatch' }}
|
||||
if ($events.IndexOf('stop-cloudflared') -ge $events.IndexOf('start-cloudflared')) {{ throw 'cloudflared restore ordering mismatch' }}
|
||||
if ($events.IndexOf('start-api') -ge $events.IndexOf('health-http://127.0.0.1:8001/health')) {{ throw 'local health ordering mismatch' }}
|
||||
if ($events.IndexOf('start-cloudflared') -ge $events.IndexOf('health-https://api-vignette.chanpaca.net/health')) {{ throw 'public health ordering mismatch' }}
|
||||
$changed=[pscustomobject]@{{
|
||||
status='ok';available=$true;stt_available=$true;tts_available=$true;
|
||||
stt_provider='openai';stt_model='changed-model';tts_provider='openai';tts_model='gpt-4o-mini-tts';
|
||||
limits=[pscustomobject]@{{uvicorn_ws_max_queue=4}}
|
||||
}}
|
||||
if (Test-VoiceHealthContract -Health $changed -Expected $priorVoice) {{ throw 'voice contract drift was accepted' }}
|
||||
""",
|
||||
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_engine_log_rotation_cannot_dirty_the_stable_release(self) -> None:
|
||||
self.assertIn('Destination "$logFile.$rotateStamp.bak.log"', PUBLIC_RUNTIME)
|
||||
self.assertNotIn('Destination "$logFile.$rotateStamp.bak"', PUBLIC_RUNTIME)
|
||||
|
||||
def test_failure_evidence_keeps_a_clean_detached_git_source(self) -> None:
|
||||
self.assertIn('$failedPath = "$OutputPath.failed.log"', PUBLIC_RUNTIME)
|
||||
self.assertNotIn('$failedPath = "$OutputPath.failed.json"', PUBLIC_RUNTIME)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
root = Path(temporary_directory)
|
||||
(root / ".gitignore").write_text("*.log\n", encoding="utf-8")
|
||||
(root / "tracked.txt").write_text("release\n", encoding="utf-8")
|
||||
commands = (
|
||||
["git", "init", "--quiet"],
|
||||
["git", "config", "user.name", "Yun Chan"],
|
||||
["git", "config", "user.email", "yun.chan@example.invalid"],
|
||||
["git", "add", ".gitignore", "tracked.txt"],
|
||||
["git", "commit", "--quiet", "-m", "테스트 기준"],
|
||||
["git", "checkout", "--quiet", "--detach", "HEAD"],
|
||||
)
|
||||
for command in commands:
|
||||
subprocess.run(
|
||||
command,
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
failure_evidence = root / "public-runtime-launch-provenance.log.failed.log"
|
||||
failure_evidence.write_text('{"status":"failed_rolled_back"}\n', encoding="utf-8")
|
||||
ignored = subprocess.run(
|
||||
["git", "check-ignore", "--quiet", failure_evidence.name],
|
||||
cwd=root,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(ignored.returncode, 0)
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain", "--untracked-files=normal"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
self.assertEqual(status.stdout, b"")
|
||||
|
||||
def test_fresh_cutover_requires_and_restores_a_pinned_prior_runtime(self) -> None:
|
||||
prior_capture = PUBLIC_RUNTIME.index("$priorApiProcesses = @(")
|
||||
mutation = PUBLIC_RUNTIME.index('$freshFailureStage = "api_cutover"')
|
||||
self.assertLess(prior_capture, mutation)
|
||||
for expected in (
|
||||
"requires exactly one prior API process for transactional rollback",
|
||||
"requires exactly one prior cloudflared process for transactional rollback",
|
||||
"$freshPriorApiIdentity = Wait-ProcessIdentity",
|
||||
"$freshPriorCloudflaredIdentity = Wait-ProcessIdentity",
|
||||
"$freshEnvironmentSnapshot = Save-ManagedEnvironment",
|
||||
"Restore-PriorPublicRuntime `",
|
||||
"Start-PinnedPriorProcess `",
|
||||
"Restored prior API identity drift",
|
||||
"Restored prior cloudflared identity drift",
|
||||
"$freshPromotionCommitted = $true",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, PUBLIC_RUNTIME)
|
||||
|
||||
def test_fresh_transaction_has_no_engine_web_sidecar_or_dns_mutation(self) -> None:
|
||||
contract = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Assert-FreshPublicProvenanceContract") :
|
||||
PUBLIC_RUNTIME.index("$freshMutationStarted = $false")
|
||||
]
|
||||
for expected in (
|
||||
"if (-not $SkipEngineRestart)",
|
||||
"if (-not $SkipWebRestart)",
|
||||
"if ($RouteCloudflareDns)",
|
||||
"$CanonicalPublicHealthUrl",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, contract)
|
||||
|
||||
api_mutation = PUBLIC_RUNTIME.index('$freshFailureStage = "api_cutover"')
|
||||
preflight = PUBLIC_RUNTIME[:api_mutation]
|
||||
self.assertIn("requires exact healthy voice sidecars as an unchanged precondition", preflight)
|
||||
self.assertIn("-Uri $CanonicalPublicHealthUrl", preflight)
|
||||
self.assertIn("-Uri $CanonicalPublicVoiceHealthUrl", preflight)
|
||||
self.assertIn("$health.engine -eq $true", preflight)
|
||||
|
||||
stt_start = PUBLIC_RUNTIME.index("& $WhisperStartScript `")
|
||||
tts_start = PUBLIC_RUNTIME.index("& $MeloTtsStartScript `")
|
||||
self.assertLess(
|
||||
PUBLIC_RUNTIME.index("Fresh public promotion will not mutate local_whisper"),
|
||||
stt_start,
|
||||
)
|
||||
self.assertLess(
|
||||
PUBLIC_RUNTIME.index("Fresh public promotion will not mutate MeloTTS"),
|
||||
tts_start,
|
||||
)
|
||||
|
||||
def test_success_and_rollback_require_canonical_public_engine_and_voice(self) -> None:
|
||||
rollback = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Restore-PriorPublicRuntime") :
|
||||
PUBLIC_RUNTIME.index("function Stop-NodeByPortHint")
|
||||
]
|
||||
self.assertGreaterEqual(rollback.count("$health.engine -eq $true"), 2)
|
||||
self.assertIn("Test-VoiceSidecarReady", rollback)
|
||||
self.assertIn("Test-VoiceHealthContract", rollback)
|
||||
|
||||
success_gate = PUBLIC_RUNTIME.index('$freshFailureStage = "public_health_validation"')
|
||||
receipt = PUBLIC_RUNTIME.index("$provenance = [ordered]@{")
|
||||
committed = PUBLIC_RUNTIME.index("$freshPromotionCommitted = $true")
|
||||
self.assertLess(success_gate, receipt)
|
||||
self.assertLess(receipt, committed)
|
||||
success_section = PUBLIC_RUNTIME[success_gate:receipt]
|
||||
self.assertIn("-Uri $CanonicalPublicHealthUrl", success_section)
|
||||
self.assertIn("-Uri $CanonicalPublicVoiceHealthUrl", success_section)
|
||||
self.assertIn("-Uri $CanonicalPublicOpenApiUrl", success_section)
|
||||
self.assertIn("Test-RequiredOpenApiPaths", success_section)
|
||||
self.assertIn('"/admin/voice-runtime"', PUBLIC_RUNTIME)
|
||||
self.assertIn("$health.engine -eq $true", success_section)
|
||||
self.assertIn("Test-VoiceSidecarReady", success_section)
|
||||
|
||||
def test_failed_cutover_emits_metadata_only_rollback_evidence(self) -> None:
|
||||
failure_writer = PUBLIC_RUNTIME[
|
||||
PUBLIC_RUNTIME.index("function Write-FailedFreshPromotionEvidence") :
|
||||
PUBLIC_RUNTIME.index("function Assert-FreshPublicProvenanceContract")
|
||||
]
|
||||
for expected in (
|
||||
'schema_version = "vignette.public-runtime-launch-failure.v1"',
|
||||
'"failed_rolled_back"',
|
||||
'"failed_rollback"',
|
||||
"failure_stage = $FailureStage",
|
||||
"attempted = $true",
|
||||
"succeeded = $RollbackSucceeded",
|
||||
"Write-Utf8TextAtomically",
|
||||
):
|
||||
with self.subTest(expected=expected):
|
||||
self.assertIn(expected, failure_writer)
|
||||
self.assertNotIn("command_line", failure_writer)
|
||||
self.assertNotIn("executable_path", failure_writer)
|
||||
|
||||
def test_receipt_is_direct_input_for_windows_topology_capture(self) -> None:
|
||||
receipt = PUBLIC_RUNTIME.index(
|
||||
'schema_version = "vignette.public-runtime-launch-provenance.v1"'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue