아바타 저장소 승격 계약을 완성

This commit is contained in:
Yun Chan 2026-08-29 23:58:33 +09:00
parent ac9b702688
commit ccdcfcd2f5
36 changed files with 14734 additions and 222 deletions

View file

@ -13,6 +13,12 @@
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-fA-F]{64}$")]
[string]$ExpectedStartScriptSha256,
[ValidatePattern("^$|^[0-9a-fA-F]{64}$")]
[string]$ExpectedPythonSha256 = "",
[ValidatePattern("^$|^[0-9a-fA-F]{64}$")]
[string]$ExpectedCloudflaredSha256 = "",
[ValidatePattern("^$|^[0-9a-fA-F]{64}$")]
[string]$ExpectedCloudflaredConfigSha256 = "",
[int]$ApiPort = 8001,
[int]$WebPort = 5174,
[int]$EnginePort = 9099,
@ -23,6 +29,15 @@
[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",
[Parameter(Mandatory = $true)]
[string]$UserUploadDir,
[Parameter(Mandatory = $true)]
[string]$UserUploadManifestPath,
[Parameter(Mandatory = $true)]
[ValidatePattern("^[0-9a-f]{64}$")]
[string]$ExpectedUserUploadManifestSha256,
[Parameter(Mandatory = $true)]
[string]$UserUploadWriteFreezePath,
[string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health",
[string[]]$AdditionalPublicHealthUrls = @(),
# 게이트웨이가 shared secret으로 떠 있으면 /ready는 인증이 필요하다(/health만 면제).
@ -41,6 +56,10 @@ $resolvedSourceRoot = (Resolve-Path -LiteralPath $StableSourceRoot).Path
$expectedWatchdogPath = Join-Path $resolvedSourceRoot "scripts\watch-public-runtime.ps1"
$startScript = Join-Path $resolvedSourceRoot "scripts\start-public-runtime.ps1"
$voiceSidecarProbe = Join-Path $resolvedSourceRoot "scripts\probe-public-voice-sidecars.py"
$uploadRootContract = Join-Path $resolvedSourceRoot "scripts\public-runtime-upload-root.ps1"
$uploadRootProbe = Join-Path $resolvedSourceRoot "scripts\probe-public-runtime-upload-root.py"
$uploadManifestProbe = Join-Path $resolvedSourceRoot "scripts\validate-public-runtime-upload-manifest.py"
$databaseIdentityHelper = Join-Path $resolvedSourceRoot "scripts\public_runtime_database_identity.py"
function Invoke-GitText {
param([string[]]$Arguments)
@ -62,6 +81,11 @@ function Assert-StableSourceProvenance {
if (-not (Test-Path -LiteralPath $voiceSidecarProbe -PathType Leaf)) {
throw "Pinned voice sidecar probe not found at $voiceSidecarProbe"
}
foreach ($uploadContractFile in @($uploadRootContract, $uploadRootProbe, $uploadManifestProbe, $databaseIdentityHelper)) {
if (-not (Test-Path -LiteralPath $uploadContractFile -PathType Leaf)) {
throw "Pinned upload-root contract file not found at $uploadContractFile"
}
}
$runningWatchdogPath = (Resolve-Path -LiteralPath $PSCommandPath).Path
if (-not [string]::Equals(
@ -108,7 +132,13 @@ function Assert-StableSourceProvenance {
foreach ($relativePath in @(
"scripts/watch-public-runtime.ps1",
"scripts/start-public-runtime.ps1",
"scripts/probe-public-voice-sidecars.py"
"scripts/probe-public-voice-sidecars.py",
"scripts/public-runtime-upload-root.ps1",
"scripts/probe-public-runtime-upload-root.py",
"scripts/validate-public-runtime-upload-manifest.py",
"scripts/public_runtime_database_identity.py",
"apps/api/app/upload_storage.py",
"apps/api/app/upload_runtime.py"
)) {
Invoke-GitText -Arguments @("ls-files", "--error-unmatch", "--", $relativePath) | Out-Null
}
@ -121,11 +151,45 @@ function Assert-StableSourceProvenance {
if ($actualStartScriptSha256 -ne $ExpectedStartScriptSha256.ToLowerInvariant()) {
throw "Pinned start script SHA256 drift"
}
foreach ($pin in @(
[pscustomobject]@{ Path = $Python; Expected = $ExpectedPythonSha256; Role = "Python" },
[pscustomobject]@{ Path = $Cloudflared; Expected = $ExpectedCloudflaredSha256; Role = "cloudflared" },
[pscustomobject]@{ Path = $CloudflaredConfig; Expected = $ExpectedCloudflaredConfigSha256; Role = "cloudflared config" }
)) {
if ([string]::IsNullOrWhiteSpace([string]$pin.Expected)) {
continue
}
if (-not (Test-Path -LiteralPath $pin.Path -PathType Leaf)) {
throw "Pinned $($pin.Role) is unavailable"
}
$actual = (Get-FileHash -LiteralPath $pin.Path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -cne ([string]$pin.Expected).ToLowerInvariant()) {
throw "Pinned $($pin.Role) SHA256 drift"
}
}
}
# health probe, failcount 기록, 프로세스 재기동보다 먼저 source provenance를 닫는다.
# 검증 실패는 운영 프로세스를 그대로 보존한 채 non-zero로 끝난다.
Assert-StableSourceProvenance
. $uploadRootContract
$uploadRootResolveArgs = @{
SourceRoot = $resolvedSourceRoot
UploadRoot = $UserUploadDir
}
if (-not $CheckOnly) {
$uploadRootResolveArgs["ProbeWritable"] = $true
}
$resolvedUserUploadDir = Resolve-PublicRuntimeUploadRoot @uploadRootResolveArgs
$resolvedUserUploadManifestPath = Resolve-PublicRuntimePrivateStatePath `
-SourceRoot $resolvedSourceRoot `
-UploadRoot $resolvedUserUploadDir `
-StatePath $UserUploadManifestPath `
-RequireFile
$resolvedUserUploadWriteFreezePath = Resolve-PublicRuntimePrivateStatePath `
-SourceRoot $resolvedSourceRoot `
-UploadRoot $resolvedUserUploadDir `
-StatePath $UserUploadWriteFreezePath
if (!$LogPath) {
$LogPath = Join-Path $resolvedSourceRoot "public-runtime-watchdog.log"
@ -337,7 +401,11 @@ function Test-PublicRuntimeHardDown {
($FailedNames -contains "web-preview") -and
($FailedNames -contains "voice-sidecars")
)
return ($FailedNames -contains "cloudflared") -or $allLocalSurfacesDown
return (
($FailedNames -contains "cloudflared") -or
($FailedNames -contains "api-upload-root") -or
$allLocalSurfacesDown
)
}
# engine 판정은 /health(프로세스 liveness)가 아니라 /ready(실제 claude -p 생성)로 한다.
@ -345,6 +413,57 @@ function Test-PublicRuntimeHardDown {
# 상태를 통과시킨다(2026-08-07 공개 런타임: engine=false인데 워치독 lastResult=0).
# /ready는 게이트웨이 readiness 캐시(ENGINE_READY_TTL_SECONDS)를 그대로 쓰므로
# 매 주기 LLM 호출로 이어지지 않는다. 콜드 스폰 여유로 타임아웃만 넉넉히 준다.
$uploadManifestCheck = Test-PublicRuntimeUploadManifest `
-PythonPath $Python `
-ProbePath $uploadManifestProbe `
-UploadRoot $resolvedUserUploadDir `
-ManifestPath $resolvedUserUploadManifestPath `
-ExpectedManifestSha256 $ExpectedUserUploadManifestSha256 `
-ExpectedWriteFreezePath $resolvedUserUploadWriteFreezePath
$expectedDatabaseTargetSha256 = "0000000000000000000000000000000000000000000000000000000000000000"
if ($uploadManifestCheck.Ok) {
$candidateDatabaseTargetSha256 = [string]$uploadManifestCheck.Payload.database_target_sha256
if ($candidateDatabaseTargetSha256 -match "^[0-9a-f]{64}$") {
$expectedDatabaseTargetSha256 = $candidateDatabaseTargetSha256
}
}
if (Test-Path -LiteralPath $resolvedUserUploadWriteFreezePath -PathType Leaf) {
$promotionHealth = $null
try {
$promotionHealth = Invoke-RestMethod `
-Uri "http://127.0.0.1:$ApiPort/health" `
-TimeoutSec 5
} catch {
$promotionHealth = $null
}
$promotionFreeze = $null
if ($null -ne $promotionHealth) {
$promotionFreeze = $promotionHealth.upload_write_freeze
}
if (
$uploadManifestCheck.Ok -and
$null -ne $promotionFreeze -and
$promotionFreeze.capable -eq $true -and
$promotionFreeze.active -eq $true -and
$promotionFreeze.valid -eq $true -and
[int]$promotionFreeze.in_flight -eq 0 -and
[string]$promotionFreeze.token_sha256 -ceq
[string]$uploadManifestCheck.Payload.write_freeze_token_sha256
) {
if ($CheckOnly) {
Write-Output "promotion-in-progress: valid drained upload freeze is active; runtime mutation skipped"
} else {
Write-WatchdogLog "promotion-in-progress: valid drained upload freeze is active; runtime mutation skipped"
}
exit 0
}
if ($CheckOnly) {
Write-Output "unhealthy: upload freeze sentinel exists without exact active/drained API proof; runtime mutation refused"
} else {
Write-WatchdogLog "ERROR: upload freeze sentinel exists without exact active/drained API proof; refusing runtime mutation"
}
exit 1
}
$checks = @(
(Test-JsonHealth `
-Name "engine" `
@ -355,8 +474,27 @@ $checks = @(
(Test-JsonHealth `
-Name "api" `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -and
$health.engine -and
$health.upload_write_freeze.capable -eq $true -and
$health.upload_write_freeze.active -eq $false -and
$health.upload_write_freeze.valid -eq $true
} `
-TimeoutSec 60),
$uploadManifestCheck,
(Test-PublicRuntimeApiUploadRoot `
-PythonPath $Python `
-ProbePath $uploadRootProbe `
-ExpectedUploadRoot $resolvedUserUploadDir `
-ExpectedApiCwd (Join-Path $resolvedSourceRoot "apps\api") `
-ExpectedManifestPath $resolvedUserUploadManifestPath `
-ExpectedManifestSha256 $ExpectedUserUploadManifestSha256 `
-ExpectedWriteFreezePath $resolvedUserUploadWriteFreezePath `
-ExpectedDatabaseTargetSha256 $expectedDatabaseTargetSha256 `
-ApiPort $ApiPort),
(Test-JsonHealth `
-Name "voice-api" `
-Uri "http://127.0.0.1:$ApiPort/voice/health" `
@ -389,7 +527,7 @@ if (!$SkipPublicHealth) {
$failed = @($checks | Where-Object { -not $_.Ok })
if ($CheckOnly) {
if ($failed.Count -eq 0) {
Write-Output "healthy: $($checks.Name -join ', ')"
Write-Output "healthy: $($checks.Name -join ', '); user_upload_root=$resolvedUserUploadDir"
exit 0
}
Write-Output "unhealthy: $((($failed | ForEach-Object { "$($_.Name)=$($_.Detail)" }) -join '; '))"
@ -418,6 +556,10 @@ if ($failCount -lt $FailuresBeforeRestart -and -not $hardDown) {
if ($hardDown -and $failCount -lt $FailuresBeforeRestart) {
Write-WatchdogLog "immediate restart: public runtime hard-down detected"
}
if ($failedNames -contains "api-upload-manifest") {
Write-WatchdogLog "ERROR: immutable upload migration receipt or current DB inventory is invalid; refusing runtime mutation"
exit 1
}
# DB가 죽어 있으면 start-public-runtime.ps1으로는 절대 복구되지 않는다(DB 기동은 boot 담당).
# 먼저 되살리고, 그래도 안 되면 재시작을 아예 시도하지 않는다 — 고칠 수 없는 대상에
@ -442,6 +584,10 @@ $startArgs = @{
Python = $Python
Cloudflared = $Cloudflared
CloudflaredConfig = $CloudflaredConfig
UserUploadDir = $resolvedUserUploadDir
UserUploadManifestPath = $resolvedUserUploadManifestPath
ExpectedUserUploadManifestSha256 = $ExpectedUserUploadManifestSha256
UserUploadWriteFreezePath = $resolvedUserUploadWriteFreezePath
}
if (($checks | Where-Object { $_.Name -eq "web-preview" }).Ok) {
$startArgs["SkipWebRestart"] = $true
@ -463,11 +609,46 @@ try {
$apiAfter = Test-JsonHealth `
-Name "api" `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-IsHealthy {
param($health)
$health.environment -eq "prod" -and
$health.db -and
$health.engine -and
$health.upload_write_freeze.capable -eq $true -and
$health.upload_write_freeze.active -eq $false -and
$health.upload_write_freeze.valid -eq $true
} `
-TimeoutSec 90
if (!$apiAfter.Ok) {
throw "Public API still unhealthy after restart: $($apiAfter.Detail)"
}
$uploadManifestAfter = Test-PublicRuntimeUploadManifest `
-PythonPath $Python `
-ProbePath $uploadManifestProbe `
-UploadRoot $resolvedUserUploadDir `
-ManifestPath $resolvedUserUploadManifestPath `
-ExpectedManifestSha256 $ExpectedUserUploadManifestSha256 `
-ExpectedWriteFreezePath $resolvedUserUploadWriteFreezePath
if (-not $uploadManifestAfter.Ok) {
throw "Public upload migration receipt or current DB inventory is invalid after restart"
}
$expectedDatabaseTargetSha256 = [string]$uploadManifestAfter.Payload.database_target_sha256
if ($expectedDatabaseTargetSha256 -notmatch "^[0-9a-f]{64}$") {
throw "Public upload inventory proof did not return a valid database target identity after restart"
}
$apiUploadRootAfter = Test-PublicRuntimeApiUploadRoot `
-PythonPath $Python `
-ProbePath $uploadRootProbe `
-ExpectedUploadRoot $resolvedUserUploadDir `
-ExpectedApiCwd (Join-Path $resolvedSourceRoot "apps\api") `
-ExpectedManifestPath $resolvedUserUploadManifestPath `
-ExpectedManifestSha256 $ExpectedUserUploadManifestSha256 `
-ExpectedWriteFreezePath $resolvedUserUploadWriteFreezePath `
-ExpectedDatabaseTargetSha256 $expectedDatabaseTargetSha256 `
-ApiPort $ApiPort
if (-not $apiUploadRootAfter.Ok) {
throw "Public API still uses the wrong upload root after restart: $($apiUploadRootAfter.Detail)"
}
$voiceApiAfter = Test-JsonHealth `
-Name "voice-api" `