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