vignette/scripts/start-nas-preview-engine.ps1
2026-08-12 15:53:22 +09:00

521 lines
17 KiB
PowerShell

param(
[Parameter(Mandatory = $true)]
[string]$EnvFile,
[string]$Workspace = "D:\workspace\vignette",
[string]$ListenAddress = "127.0.0.1",
[ValidateRange(1024, 65535)]
[int]$Port = 9100,
[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-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*=(.*)$") {
$values += $Matches[1].Trim().Trim('"').Trim("'")
}
}
if ($values.Count -ne 1) {
throw "$Key must appear exactly once in the NAS preview env file"
}
return $values[0]
}
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 -PathType Leaf)) {
throw "Python 3.11 not found: $Python"
}
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"
}
$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"
}
$listenerOwners = @(Get-ListenerOwners -LocalPort $Port)
if ($listenerOwners.Count -gt 0) {
throw "Port $Port already has a listener; silent reuse is forbidden"
}
$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
}
if ($CheckOnly) {
[ordered]@{
schema = "vignette.nas_preview_engine_preflight.v1"
status = "preflight_passed"
mutation = $false
binding = $binding
} | ConvertTo-Json -Depth 5
exit 0
}
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 {
$probeStream.Flush($true)
} finally {
$probeStream.Dispose()
}
} finally {
if (Test-Path -LiteralPath $receiptProbe) {
Remove-Item -LiteralPath $receiptProbe -Force
}
}
$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
}