NAS 엔진 릴레이 복구 계약 보강
This commit is contained in:
parent
b5f44fe1cb
commit
946661926b
8 changed files with 930 additions and 73 deletions
|
|
@ -2,93 +2,520 @@ param(
|
|||
[Parameter(Mandatory = $true)]
|
||||
[string]$EnvFile,
|
||||
[string]$Workspace = "D:\workspace\vignette",
|
||||
[string]$ListenAddress = "0.0.0.0",
|
||||
[string]$ListenAddress = "127.0.0.1",
|
||||
[ValidateRange(1024, 65535)]
|
||||
[int]$Port = 9100,
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{40}$")]
|
||||
[string]$ExpectedTree,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedScriptSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedPythonSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[0-9a-fA-F]{64}$")]
|
||||
[string]$ExpectedEnvSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpectedConsumerEngineUrl,
|
||||
[string]$RuntimeStateDir = "$env:LOCALAPPDATA\Temp\vignette-nas-preview",
|
||||
[string]$ReceiptPath = "",
|
||||
[ValidateRange(10, 300)]
|
||||
[int]$ReadyTimeoutSec = 120,
|
||||
[switch]$ConfirmNasPreviewLanExposure,
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
function Get-EnvValue {
|
||||
param([string]$Path, [string]$Key)
|
||||
function Get-Sha256Lower {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$stream = [IO.File]::Open(
|
||||
$Path,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::Read,
|
||||
[IO.FileShare]::Read
|
||||
)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = $sha.ComputeHash($stream)
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
return (($bytes | ForEach-Object { $_.ToString("x2") }) -join "")
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
$values = @()
|
||||
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
if ($line -match "^\s*$([Regex]::Escape($Key))\s*=(.*)$") {
|
||||
return $Matches[1].Trim().Trim('"').Trim("'")
|
||||
$values += $Matches[1].Trim().Trim('"').Trim("'")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
if ($values.Count -ne 1) {
|
||||
throw "$Key must appear exactly once in the NAS preview env file"
|
||||
}
|
||||
return $values[0]
|
||||
}
|
||||
|
||||
if (!(Test-Path -LiteralPath $EnvFile)) {
|
||||
function Invoke-GitText {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Repository,
|
||||
[Parameter(Mandatory = $true)][string[]]$GitArguments
|
||||
)
|
||||
|
||||
$output = @(& git -C $Repository @GitArguments)
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "git command failed with exit ${exitCode}: $($GitArguments -join ' ')"
|
||||
}
|
||||
return (($output | ForEach-Object { "$_" }) -join "`n").Trim()
|
||||
}
|
||||
|
||||
function Test-IsPathWithin {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Candidate,
|
||||
[Parameter(Mandatory = $true)][string]$Parent
|
||||
)
|
||||
|
||||
$candidateFull = [IO.Path]::GetFullPath($Candidate).TrimEnd('\')
|
||||
$parentFull = [IO.Path]::GetFullPath($Parent).TrimEnd('\')
|
||||
if ($candidateFull.Equals($parentFull, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $true
|
||||
}
|
||||
return $candidateFull.StartsWith(
|
||||
$parentFull + [IO.Path]::DirectorySeparatorChar,
|
||||
[StringComparison]::OrdinalIgnoreCase
|
||||
)
|
||||
}
|
||||
|
||||
function Assert-NoReparseAncestor {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$full = [IO.Path]::GetFullPath($Path).TrimEnd('\')
|
||||
$root = [IO.Path]::GetPathRoot($full)
|
||||
$current = $root
|
||||
$relative = $full.Substring($root.Length)
|
||||
foreach ($segment in $relative.Split([char[]]@('\'), [StringSplitOptions]::RemoveEmptyEntries)) {
|
||||
$current = Join-Path $current $segment
|
||||
$item = Get-Item -LiteralPath $current -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "External state path contains a reparse-point ancestor: $current"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ListenerOwners {
|
||||
param([Parameter(Mandatory = $true)][int]$LocalPort)
|
||||
|
||||
return @(
|
||||
Get-NetTCPConnection -State Listen -LocalPort $LocalPort -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty OwningProcess -Unique
|
||||
)
|
||||
}
|
||||
|
||||
function Stop-OwnedGateway {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][System.Diagnostics.Process]$OwnedProcess,
|
||||
[Parameter(Mandatory = $true)][int]$LocalPort
|
||||
)
|
||||
|
||||
$OwnedProcess.Refresh()
|
||||
if (!$OwnedProcess.HasExited) {
|
||||
Stop-Process -Id $OwnedProcess.Id -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
$OwnedProcess.WaitForExit(10000) | Out-Null
|
||||
} catch {
|
||||
# The exact process may have exited between Refresh and WaitForExit.
|
||||
}
|
||||
}
|
||||
$deadline = (Get-Date).AddSeconds(10)
|
||||
do {
|
||||
$owners = @(Get-ListenerOwners -LocalPort $LocalPort)
|
||||
if ($owners -notcontains $OwnedProcess.Id) {
|
||||
return
|
||||
}
|
||||
Start-Sleep -Milliseconds 200
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
throw "Failed to remove the owned gateway listener for PID $($OwnedProcess.Id)"
|
||||
}
|
||||
|
||||
function Write-AtomicReceipt {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Json
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
throw "Receipt already exists: $Path"
|
||||
}
|
||||
$temporary = "$Path.tmp.$([Guid]::NewGuid().ToString('N'))"
|
||||
$utf8 = [Text.UTF8Encoding]::new($false)
|
||||
try {
|
||||
[IO.File]::WriteAllText($temporary, $Json, $utf8)
|
||||
$stream = [IO.File]::Open(
|
||||
$temporary,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::ReadWrite,
|
||||
[IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$stream.Flush($true)
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
[IO.File]::Move($temporary, $Path)
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $temporary) {
|
||||
Remove-Item -LiteralPath $temporary -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
throw "git is required for source provenance verification"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $Workspace -PathType Container)) {
|
||||
throw "Workspace directory not found: $Workspace"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $EnvFile -PathType Leaf)) {
|
||||
throw "NAS preview env file not found: $EnvFile"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $Python)) {
|
||||
if (!(Test-Path -LiteralPath $Python -PathType Leaf)) {
|
||||
throw "Python 3.11 not found: $Python"
|
||||
}
|
||||
$apiDir = Join-Path $Workspace "apps\api"
|
||||
if (!(Test-Path -LiteralPath $apiDir)) {
|
||||
if (!(Test-Path -LiteralPath $RuntimeStateDir -PathType Container) -and $CheckOnly) {
|
||||
# Check-only is mutation-free; the actual start may create its external state directory.
|
||||
$runtimeParent = Split-Path -Parent $RuntimeStateDir
|
||||
if (!(Test-Path -LiteralPath $runtimeParent -PathType Container)) {
|
||||
throw "Runtime state parent directory not found: $runtimeParent"
|
||||
}
|
||||
}
|
||||
|
||||
$workspaceResolved = (Resolve-Path -LiteralPath $Workspace).Path.TrimEnd('\')
|
||||
$envResolved = (Resolve-Path -LiteralPath $EnvFile).Path
|
||||
$pythonResolved = (Resolve-Path -LiteralPath $Python).Path
|
||||
$scriptResolved = (Resolve-Path -LiteralPath $PSCommandPath).Path
|
||||
$expectedScriptPath = (Join-Path $workspaceResolved "scripts\start-nas-preview-engine.ps1")
|
||||
if (!$scriptResolved.Equals($expectedScriptPath, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The engine launcher must run from the pinned workspace"
|
||||
}
|
||||
|
||||
$repositoryTop = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"rev-parse", "--show-toplevel"
|
||||
)
|
||||
$repositoryTopResolved = (Resolve-Path -LiteralPath $repositoryTop).Path.TrimEnd('\')
|
||||
if (!$repositoryTopResolved.Equals($workspaceResolved, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Workspace must be the exact Git toplevel"
|
||||
}
|
||||
|
||||
$symbolicRef = @(& git -C $workspaceResolved symbolic-ref -q HEAD)
|
||||
$symbolicExit = $LASTEXITCODE
|
||||
if ($symbolicExit -eq 0) {
|
||||
throw "NAS engine source must be a detached HEAD"
|
||||
}
|
||||
if ($symbolicExit -ne 1) {
|
||||
throw "Unable to verify detached HEAD state"
|
||||
}
|
||||
|
||||
$dirty = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"status", "--porcelain=v1", "--untracked-files=normal"
|
||||
)
|
||||
if ($dirty) {
|
||||
throw "NAS engine source must be tracked-clean and untracked-clean"
|
||||
}
|
||||
|
||||
$actualCommit = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"rev-parse", "HEAD"
|
||||
)
|
||||
$actualTree = Invoke-GitText -Repository $workspaceResolved -GitArguments @(
|
||||
"rev-parse", "HEAD^{tree}"
|
||||
)
|
||||
if (!$actualCommit.Equals($ExpectedCommit, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Source commit does not match ExpectedCommit"
|
||||
}
|
||||
if (!$actualTree.Equals($ExpectedTree, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Source tree does not match ExpectedTree"
|
||||
}
|
||||
|
||||
$actualScriptSha = Get-Sha256Lower -Path $scriptResolved
|
||||
$actualPythonSha = Get-Sha256Lower -Path $pythonResolved
|
||||
$actualEnvSha = Get-Sha256Lower -Path $envResolved
|
||||
if (!$actualScriptSha.Equals($ExpectedScriptSha256, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Launcher SHA-256 does not match ExpectedScriptSha256"
|
||||
}
|
||||
if (!$actualPythonSha.Equals($ExpectedPythonSha256, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Python SHA-256 does not match ExpectedPythonSha256"
|
||||
}
|
||||
if (!$actualEnvSha.Equals($ExpectedEnvSha256, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Env SHA-256 does not match ExpectedEnvSha256"
|
||||
}
|
||||
|
||||
$apiDir = Join-Path $workspaceResolved "apps\api"
|
||||
if (!(Test-Path -LiteralPath $apiDir -PathType Container)) {
|
||||
throw "API directory not found: $apiDir"
|
||||
}
|
||||
|
||||
$secret = Get-EnvValue -Path $EnvFile -Key "ENGINE_GATEWAY_SHARED_SECRET"
|
||||
$parsedAddress = $null
|
||||
if (![Net.IPAddress]::TryParse($ListenAddress, [ref]$parsedAddress)) {
|
||||
throw "ListenAddress must be a literal IP address"
|
||||
}
|
||||
if ($parsedAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "Only an explicit IPv4 ListenAddress is supported"
|
||||
}
|
||||
if ($parsedAddress.Equals([Net.IPAddress]::Any) -or $ListenAddress -eq "255.255.255.255") {
|
||||
throw "Wildcard and broadcast listen addresses are forbidden"
|
||||
}
|
||||
$isLoopback = [Net.IPAddress]::IsLoopback($parsedAddress)
|
||||
if (!$isLoopback) {
|
||||
if (!$ConfirmNasPreviewLanExposure) {
|
||||
throw "Non-loopback binding requires -ConfirmNasPreviewLanExposure"
|
||||
}
|
||||
$assigned = @(
|
||||
Get-NetIPAddress -AddressFamily IPv4 -AddressState Preferred -ErrorAction Stop |
|
||||
Where-Object { $_.IPAddress -eq $ListenAddress }
|
||||
)
|
||||
if ($assigned.Count -ne 1) {
|
||||
throw "ListenAddress must be assigned exactly once on this host"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$consumerUri = [Uri]$ExpectedConsumerEngineUrl
|
||||
} catch {
|
||||
throw "ExpectedConsumerEngineUrl must be an absolute HTTP URL"
|
||||
}
|
||||
if (!$consumerUri.IsAbsoluteUri -or $consumerUri.Scheme -ne "http") {
|
||||
throw "ExpectedConsumerEngineUrl must use http"
|
||||
}
|
||||
if ($consumerUri.Host -ne $ListenAddress -or $consumerUri.Port -ne $Port) {
|
||||
throw "ExpectedConsumerEngineUrl must match ListenAddress and Port exactly"
|
||||
}
|
||||
if ($consumerUri.AbsolutePath -ne "/" -or $consumerUri.Query -or $consumerUri.Fragment) {
|
||||
throw "ExpectedConsumerEngineUrl must not contain a path, query, or fragment"
|
||||
}
|
||||
|
||||
$runtimeFull = [IO.Path]::GetFullPath($RuntimeStateDir)
|
||||
$runtimeLeaf = Split-Path -Leaf $runtimeFull
|
||||
$runtimeParent = Split-Path -Parent $runtimeFull
|
||||
if (!$runtimeLeaf -or $runtimeLeaf -match '[:\\/]') {
|
||||
throw "RuntimeStateDir must end in a plain directory name"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $runtimeParent -PathType Container)) {
|
||||
throw "Runtime state parent directory must be pre-provisioned: $runtimeParent"
|
||||
}
|
||||
Assert-NoReparseAncestor -Path $runtimeParent
|
||||
$runtimeParentResolved = (Resolve-Path -LiteralPath $runtimeParent).Path
|
||||
$runtimeTarget = Join-Path $runtimeParentResolved $runtimeLeaf
|
||||
if (Test-IsPathWithin -Candidate $runtimeTarget -Parent $workspaceResolved) {
|
||||
throw "RuntimeStateDir must resolve outside the source workspace"
|
||||
}
|
||||
if ($ReceiptPath) {
|
||||
$receiptRequestedFull = [IO.Path]::GetFullPath($ReceiptPath)
|
||||
$receiptLeaf = Split-Path -Leaf $receiptRequestedFull
|
||||
if (!$receiptLeaf -or $receiptLeaf -match '[:\\/]') {
|
||||
throw "ReceiptPath must end in a plain file name"
|
||||
}
|
||||
$receiptParent = Split-Path -Parent $receiptRequestedFull
|
||||
if (!(Test-Path -LiteralPath $receiptParent -PathType Container)) {
|
||||
throw "Receipt parent directory must be pre-provisioned: $receiptParent"
|
||||
}
|
||||
Assert-NoReparseAncestor -Path $receiptParent
|
||||
$receiptParentResolved = (Resolve-Path -LiteralPath $receiptParent).Path
|
||||
$receiptFull = Join-Path $receiptParentResolved $receiptLeaf
|
||||
if (Test-IsPathWithin -Candidate $receiptFull -Parent $workspaceResolved) {
|
||||
throw "ReceiptPath must resolve outside the source workspace"
|
||||
}
|
||||
if (Test-Path -LiteralPath $receiptFull) {
|
||||
throw "Receipt already exists: $receiptFull"
|
||||
}
|
||||
} elseif (!$CheckOnly) {
|
||||
throw "ReceiptPath is required for an actual start"
|
||||
}
|
||||
|
||||
$secret = Get-EnvValue -Path $envResolved -Key "ENGINE_GATEWAY_SHARED_SECRET"
|
||||
if ($secret.Length -lt 32 -or $secret.ToLowerInvariant().StartsWith("change-me")) {
|
||||
throw "ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value of at least 32 characters"
|
||||
}
|
||||
|
||||
$existing = Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like "*uvicorn engine_gateway.gateway:app*" -and
|
||||
$_.CommandLine -like "*--port $Port*"
|
||||
}
|
||||
if ($existing) {
|
||||
throw "An engine gateway is already running on the requested preview port $Port"
|
||||
$listenerOwners = @(Get-ListenerOwners -LocalPort $Port)
|
||||
if ($listenerOwners.Count -gt 0) {
|
||||
throw "Port $Port already has a listener; silent reuse is forbidden"
|
||||
}
|
||||
|
||||
$logDir = Join-Path $env:LOCALAPPDATA "Temp\vignette-nas-preview"
|
||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddHHmmss")
|
||||
$outLog = Join-Path $logDir "engine-$Port-$stamp.out.log"
|
||||
$errLog = Join-Path $logDir "engine-$Port-$stamp.err.log"
|
||||
$binding = [ordered]@{
|
||||
source_commit = $actualCommit
|
||||
source_tree = $actualTree
|
||||
launcher_sha256 = $actualScriptSha
|
||||
python_sha256 = $actualPythonSha
|
||||
env_sha256 = $actualEnvSha
|
||||
listen_address = $ListenAddress
|
||||
port = $Port
|
||||
consumer_engine_url = $ExpectedConsumerEngineUrl
|
||||
secret_value_emitted = $false
|
||||
}
|
||||
|
||||
$env:ENGINE_GATEWAY_SHARED_SECRET = $secret
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$process = Start-Process -WindowStyle Hidden -FilePath $Python `
|
||||
-ArgumentList @("-X", "utf8", "-m", "uvicorn", "engine_gateway.gateway:app", "--host", $ListenAddress, "--port", "$Port") `
|
||||
-WorkingDirectory $apiDir `
|
||||
-RedirectStandardOutput $outLog `
|
||||
-RedirectStandardError $errLog `
|
||||
-PassThru
|
||||
Remove-Item Env:ENGINE_GATEWAY_SHARED_SECRET
|
||||
if ($CheckOnly) {
|
||||
[ordered]@{
|
||||
schema = "vignette.nas_preview_engine_preflight.v1"
|
||||
status = "preflight_passed"
|
||||
mutation = $false
|
||||
binding = $binding
|
||||
} | ConvertTo-Json -Depth 5
|
||||
exit 0
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
$health = $null
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
if ($process.HasExited) {
|
||||
$detail = if (Test-Path -LiteralPath $errLog) {
|
||||
(Get-Content -LiteralPath $errLog -Encoding UTF8 -Tail 40) -join "`n"
|
||||
} else {
|
||||
"no stderr log"
|
||||
}
|
||||
throw "Preview engine gateway exited with $($process.ExitCode): $detail"
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $runtimeTarget -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $runtimeTarget | Out-Null
|
||||
}
|
||||
$runtimeResolved = (Resolve-Path -LiteralPath $runtimeTarget).Path
|
||||
if (Test-IsPathWithin -Candidate $runtimeResolved -Parent $workspaceResolved) {
|
||||
throw "Resolved RuntimeStateDir must remain outside the source workspace"
|
||||
}
|
||||
|
||||
# Prove receipt write/flush/delete before the process mutation.
|
||||
$receiptProbe = Join-Path (Split-Path -Parent $receiptFull) (
|
||||
".vignette-nas-engine-receipt-probe-$([Guid]::NewGuid().ToString('N'))"
|
||||
)
|
||||
try {
|
||||
[IO.File]::WriteAllText($receiptProbe, "probe", [Text.UTF8Encoding]::new($false))
|
||||
$probeStream = [IO.File]::Open(
|
||||
$receiptProbe,
|
||||
[IO.FileMode]::Open,
|
||||
[IO.FileAccess]::ReadWrite,
|
||||
[IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2
|
||||
} catch {
|
||||
$health = $null
|
||||
$probeStream.Flush($true)
|
||||
} finally {
|
||||
$probeStream.Dispose()
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $receiptProbe) {
|
||||
Remove-Item -LiteralPath $receiptProbe -Force
|
||||
}
|
||||
} while ($null -eq $health -and (Get-Date) -lt $deadline)
|
||||
|
||||
if ($null -eq $health -or !$health.ok) {
|
||||
throw "Preview engine gateway health timed out on port $Port"
|
||||
}
|
||||
|
||||
Write-Output "Preview engine gateway PID=$($process.Id) port=$Port"
|
||||
Write-Output "Health=$($health | ConvertTo-Json -Compress)"
|
||||
Write-Output "SecretValueEmitted=false"
|
||||
Write-Output "StdoutLog=$outLog"
|
||||
Write-Output "StderrLog=$errLog"
|
||||
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ")
|
||||
$outLog = Join-Path $runtimeResolved "engine-$Port-$stamp.out.log"
|
||||
$errLog = Join-Path $runtimeResolved "engine-$Port-$stamp.err.log"
|
||||
$gatewayProcess = $null
|
||||
$hadPriorSecret = Test-Path Env:ENGINE_GATEWAY_SHARED_SECRET
|
||||
$priorSecret = $env:ENGINE_GATEWAY_SHARED_SECRET
|
||||
$hadPriorPythonUtf8 = Test-Path Env:PYTHONUTF8
|
||||
$priorPythonUtf8 = $env:PYTHONUTF8
|
||||
|
||||
try {
|
||||
try {
|
||||
$env:ENGINE_GATEWAY_SHARED_SECRET = $secret
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$gatewayProcess = Start-Process -WindowStyle Hidden -FilePath $pythonResolved `
|
||||
-ArgumentList @(
|
||||
"-X", "utf8", "-m", "uvicorn", "engine_gateway.gateway:app",
|
||||
"--host", $ListenAddress, "--port", "$Port"
|
||||
) `
|
||||
-WorkingDirectory $apiDir `
|
||||
-RedirectStandardOutput $outLog `
|
||||
-RedirectStandardError $errLog `
|
||||
-PassThru
|
||||
} finally {
|
||||
if ($hadPriorSecret) {
|
||||
$env:ENGINE_GATEWAY_SHARED_SECRET = $priorSecret
|
||||
} else {
|
||||
Remove-Item Env:ENGINE_GATEWAY_SHARED_SECRET -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($hadPriorPythonUtf8) {
|
||||
$env:PYTHONUTF8 = $priorPythonUtf8
|
||||
} else {
|
||||
Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$headers = @{ "X-Vignette-Engine-Token" = $secret }
|
||||
$health = $null
|
||||
$ready = $null
|
||||
$deadline = (Get-Date).AddSeconds($ReadyTimeoutSec)
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$gatewayProcess.Refresh()
|
||||
if ($gatewayProcess.HasExited) {
|
||||
throw "Preview engine gateway exited before readiness with code $($gatewayProcess.ExitCode)"
|
||||
}
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "http://$ListenAddress`:$Port/health" -TimeoutSec 2
|
||||
} catch {
|
||||
$health = $null
|
||||
}
|
||||
} while (($null -eq $health -or !$health.ok) -and (Get-Date) -lt $deadline)
|
||||
|
||||
if ($null -eq $health -or !$health.ok) {
|
||||
throw "Preview engine gateway liveness did not pass"
|
||||
}
|
||||
|
||||
# force=true performs a real provider generation. Call it exactly once after
|
||||
# liveness is established so a failing provider cannot trigger a retry storm.
|
||||
try {
|
||||
$ready = Invoke-RestMethod `
|
||||
-Uri "http://$ListenAddress`:$Port/ready?force=true" `
|
||||
-Headers $headers `
|
||||
-TimeoutSec $ReadyTimeoutSec
|
||||
} catch {
|
||||
$ready = $null
|
||||
}
|
||||
if ($null -eq $ready -or !$ready.ok) {
|
||||
throw "Preview engine gateway generation readiness did not pass"
|
||||
}
|
||||
|
||||
$owners = @(Get-ListenerOwners -LocalPort $Port)
|
||||
if ($owners.Count -ne 1 -or $owners[0] -ne $gatewayProcess.Id) {
|
||||
throw "Gateway listener owner does not match the started process"
|
||||
}
|
||||
|
||||
$receipt = [ordered]@{
|
||||
schema = "vignette.nas_preview_engine_launch_receipt.v1"
|
||||
status = "passed"
|
||||
started_at = [DateTime]::UtcNow.ToString("o")
|
||||
pid = $gatewayProcess.Id
|
||||
binding = $binding
|
||||
checks = [ordered]@{
|
||||
source_detached_clean = $true
|
||||
port_previously_unused = $true
|
||||
listener_owner_matches = $true
|
||||
liveness_ok = $true
|
||||
generation_ready = $true
|
||||
}
|
||||
logs = [ordered]@{
|
||||
stdout = $outLog
|
||||
stderr = $errLog
|
||||
}
|
||||
}
|
||||
Write-AtomicReceipt `
|
||||
-Path $receiptFull `
|
||||
-Json ($receipt | ConvertTo-Json -Depth 6)
|
||||
|
||||
Write-Output "Preview engine gateway PID=$($gatewayProcess.Id) port=$Port"
|
||||
Write-Output "GenerationReady=true"
|
||||
Write-Output "SecretValueEmitted=false"
|
||||
Write-Output "Receipt=$receiptFull"
|
||||
Write-Output "StdoutLog=$outLog"
|
||||
Write-Output "StderrLog=$errLog"
|
||||
} catch {
|
||||
if ($null -ne $gatewayProcess) {
|
||||
Stop-OwnedGateway -OwnedProcess $gatewayProcess -LocalPort $Port
|
||||
}
|
||||
throw
|
||||
}
|
||||
|
|
|
|||
385
scripts/test_start_nas_preview_engine_contract.py
Normal file
385
scripts/test_start_nas_preview_engine_contract.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_SCRIPT = REPO_ROOT / "scripts" / "start-nas-preview-engine.ps1"
|
||||
POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run(command: list[str], *, cwd: Path, timeout: int = 30) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def run_without_pipe_capture(
|
||||
command: list[str], *, cwd: Path, output_dir: Path, timeout: int = 60
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Wait for the launcher process without waiting on inherited pipe EOF."""
|
||||
stdout_path = output_dir / "launcher.stdout.log"
|
||||
stderr_path = output_dir / "launcher.stderr.log"
|
||||
with stdout_path.open("w", encoding="utf-8") as stdout_handle, stderr_path.open(
|
||||
"w", encoding="utf-8"
|
||||
) as stderr_handle:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=stdout_handle,
|
||||
stderr=stderr_handle,
|
||||
)
|
||||
returncode = process.wait(timeout=timeout)
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
returncode,
|
||||
stdout_path.read_text(encoding="utf-8", errors="replace"),
|
||||
stderr_path.read_text(encoding="utf-8", errors="replace"),
|
||||
)
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def wait_for_port_closed(port: int, *, timeout: float = 10.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
if sock.connect_ex(("127.0.0.1", port)) != 0:
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
@unittest.skipUnless(POWERSHELL, "Windows PowerShell is required")
|
||||
class NasPreviewEngineLauncherContractTests(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="vignette-nas-engine-test-")
|
||||
self.root = Path(self.temp.name)
|
||||
self.repo = self.root / "release"
|
||||
self.script = self.repo / "scripts" / SOURCE_SCRIPT.name
|
||||
self.api = self.repo / "apps" / "api"
|
||||
self.env_file = self.root / "preview.env"
|
||||
self.runtime = self.root / "runtime"
|
||||
self.receipts = self.root / "receipts"
|
||||
self.receipts.mkdir()
|
||||
self.script.parent.mkdir(parents=True)
|
||||
self.api.mkdir(parents=True)
|
||||
shutil.copy2(SOURCE_SCRIPT, self.script)
|
||||
self.secret = "nas-preview-engine-test-secret-" + ("x" * 32)
|
||||
self.env_file.write_text(
|
||||
f"ENGINE_GATEWAY_SHARED_SECRET={self.secret}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.started_pid: int | None = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if self.started_pid is not None:
|
||||
subprocess.run(
|
||||
["taskkill.exe", "/PID", str(self.started_pid), "/T", "/F"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.temp.cleanup()
|
||||
|
||||
def _write_gateway(self, *, ready: bool = True) -> None:
|
||||
package = self.api / "engine_gateway"
|
||||
package.mkdir(parents=True, exist_ok=True)
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
ready_literal = "True" if ready else "False"
|
||||
(package / "gateway.py").write_text(
|
||||
"""
|
||||
import os
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
|
||||
app = FastAPI()
|
||||
secret = os.environ["ENGINE_GATEWAY_SHARED_SECRET"]
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/ready")
|
||||
async def ready(force: bool = False, token: str | None = Header(default=None, alias="X-Vignette-Engine-Token")):
|
||||
if token != secret:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
return {"ok": READY, "force": force}
|
||||
""".replace("READY", ready_literal).lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _commit_detached(self) -> tuple[str, str]:
|
||||
commands = [
|
||||
["git", "init", "-q"],
|
||||
["git", "config", "user.name", "Yun Chan"],
|
||||
["git", "config", "user.email", "yunchan@twentyoz.kr"],
|
||||
["git", "add", "--all"],
|
||||
["git", "commit", "-q", "-m", "test fixture"],
|
||||
]
|
||||
for command in commands:
|
||||
result = run(command, cwd=self.repo)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
head = run(["git", "rev-parse", "HEAD"], cwd=self.repo).stdout.strip()
|
||||
tree = run(["git", "rev-parse", "HEAD^{tree}"], cwd=self.repo).stdout.strip()
|
||||
result = run(["git", "checkout", "-q", "--detach", head], cwd=self.repo)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
return head, tree
|
||||
|
||||
def _launcher_args(
|
||||
self,
|
||||
*,
|
||||
port: int,
|
||||
head: str,
|
||||
tree: str,
|
||||
check_only: bool = True,
|
||||
listen_address: str = "127.0.0.1",
|
||||
script_sha: str | None = None,
|
||||
env_sha: str | None = None,
|
||||
ready_timeout: int = 20,
|
||||
) -> list[str]:
|
||||
receipt = self.receipts / f"receipt-{port}.json"
|
||||
args = [
|
||||
str(POWERSHELL),
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(self.script),
|
||||
"-EnvFile",
|
||||
str(self.env_file),
|
||||
"-Workspace",
|
||||
str(self.repo),
|
||||
"-ListenAddress",
|
||||
listen_address,
|
||||
"-Port",
|
||||
str(port),
|
||||
"-Python",
|
||||
sys.executable,
|
||||
"-ExpectedCommit",
|
||||
head,
|
||||
"-ExpectedTree",
|
||||
tree,
|
||||
"-ExpectedScriptSha256",
|
||||
script_sha or sha256_file(self.script),
|
||||
"-ExpectedPythonSha256",
|
||||
sha256_file(Path(sys.executable)),
|
||||
"-ExpectedEnvSha256",
|
||||
env_sha or sha256_file(self.env_file),
|
||||
"-ExpectedConsumerEngineUrl",
|
||||
f"http://{listen_address}:{port}",
|
||||
"-RuntimeStateDir",
|
||||
str(self.runtime),
|
||||
"-ReceiptPath",
|
||||
str(receipt),
|
||||
"-ReadyTimeoutSec",
|
||||
str(ready_timeout),
|
||||
]
|
||||
if check_only:
|
||||
args.append("-CheckOnly")
|
||||
return args
|
||||
|
||||
def test_check_only_is_mutation_free_and_secret_free(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual("preflight_passed", payload["status"])
|
||||
self.assertFalse(payload["mutation"])
|
||||
self.assertFalse(payload["binding"]["secret_value_emitted"])
|
||||
self.assertNotIn(self.secret, result.stdout + result.stderr)
|
||||
self.assertFalse(self.runtime.exists())
|
||||
self.assertEqual([], list(self.receipts.iterdir()))
|
||||
|
||||
def test_attached_or_dirty_source_is_rejected(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
branch = run(["git", "switch", "-q", "-c", "unsafe"], cwd=self.repo)
|
||||
self.assertEqual(0, branch.returncode, branch.stderr)
|
||||
attached = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, attached.returncode)
|
||||
self.assertIn("detached HEAD", attached.stderr)
|
||||
|
||||
detached = run(["git", "checkout", "-q", "--detach", head], cwd=self.repo)
|
||||
self.assertEqual(0, detached.returncode, detached.stderr)
|
||||
(self.repo / "untracked.txt").write_text("unsafe", encoding="utf-8")
|
||||
dirty = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, dirty.returncode)
|
||||
self.assertIn("tracked-clean and untracked-clean", dirty.stderr)
|
||||
|
||||
def test_hash_drift_is_rejected(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run(
|
||||
self._launcher_args(
|
||||
port=port,
|
||||
head=head,
|
||||
tree=tree,
|
||||
script_sha="0" * 64,
|
||||
),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("Launcher SHA-256", result.stderr)
|
||||
|
||||
def test_wildcard_and_unconfirmed_lan_bindings_are_rejected(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
wildcard = run(
|
||||
self._launcher_args(
|
||||
port=free_port(),
|
||||
head=head,
|
||||
tree=tree,
|
||||
listen_address="0.0.0.0",
|
||||
),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, wildcard.returncode)
|
||||
self.assertIn("Wildcard and broadcast", wildcard.stderr)
|
||||
|
||||
unconfirmed = run(
|
||||
self._launcher_args(
|
||||
port=free_port(),
|
||||
head=head,
|
||||
tree=tree,
|
||||
listen_address="192.0.2.1",
|
||||
),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, unconfirmed.returncode)
|
||||
self.assertIn("ConfirmNasPreviewLanExposure", unconfirmed.stderr)
|
||||
|
||||
def test_existing_listener_is_never_reused(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen()
|
||||
port = int(listener.getsockname()[1])
|
||||
result = run(
|
||||
self._launcher_args(port=port, head=head, tree=tree),
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("already has a listener", result.stderr)
|
||||
|
||||
def test_external_junction_cannot_redirect_receipt_into_source(self) -> None:
|
||||
self._write_gateway()
|
||||
source_receipts = self.repo / "source-receipts"
|
||||
source_receipts.mkdir()
|
||||
(source_receipts / ".keep").write_text("pinned\n", encoding="utf-8")
|
||||
head, tree = self._commit_detached()
|
||||
junction = self.root / "receipt-junction"
|
||||
created = run(
|
||||
["cmd.exe", "/c", "mklink", "/J", str(junction), str(source_receipts)],
|
||||
cwd=self.root,
|
||||
)
|
||||
self.assertEqual(0, created.returncode, created.stderr)
|
||||
port = free_port()
|
||||
args = self._launcher_args(port=port, head=head, tree=tree)
|
||||
receipt_index = args.index("-ReceiptPath") + 1
|
||||
args[receipt_index] = str(junction / "receipt.json")
|
||||
result = run(args, cwd=self.root)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("reparse-point ancestor", result.stderr)
|
||||
self.assertFalse((source_receipts / "receipt.json").exists())
|
||||
|
||||
def test_actual_loopback_launch_binds_receipt_and_owner(self) -> None:
|
||||
self._write_gateway()
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run_without_pipe_capture(
|
||||
self._launcher_args(
|
||||
port=port,
|
||||
head=head,
|
||||
tree=tree,
|
||||
check_only=False,
|
||||
),
|
||||
cwd=self.root,
|
||||
output_dir=self.root,
|
||||
timeout=60,
|
||||
)
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
receipt_path = self.receipts / f"receipt-{port}.json"
|
||||
payload = json.loads(receipt_path.read_text(encoding="utf-8"))
|
||||
self.started_pid = int(payload["pid"])
|
||||
self.assertEqual("passed", payload["status"])
|
||||
self.assertEqual(head, payload["binding"]["source_commit"])
|
||||
self.assertEqual(tree, payload["binding"]["source_tree"])
|
||||
self.assertTrue(payload["checks"]["generation_ready"])
|
||||
self.assertTrue(payload["checks"]["listener_owner_matches"])
|
||||
serialized = json.dumps(payload, ensure_ascii=False)
|
||||
self.assertNotIn(self.secret, serialized)
|
||||
self.assertNotIn(self.secret, result.stdout + result.stderr)
|
||||
|
||||
def test_failed_readiness_removes_only_the_owned_process(self) -> None:
|
||||
self._write_gateway(ready=False)
|
||||
head, tree = self._commit_detached()
|
||||
port = free_port()
|
||||
result = run(
|
||||
self._launcher_args(
|
||||
port=port,
|
||||
head=head,
|
||||
tree=tree,
|
||||
check_only=False,
|
||||
ready_timeout=10,
|
||||
),
|
||||
cwd=self.root,
|
||||
timeout=45,
|
||||
)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("generation readiness did not pass", result.stderr)
|
||||
self.assertTrue(wait_for_port_closed(port))
|
||||
self.assertFalse((self.receipts / f"receipt-{port}.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue