fix: complete deployment release gates
This commit is contained in:
@@ -1,27 +1,90 @@
|
||||
param(
|
||||
[string]$RepositoryRoot = "",
|
||||
[string]$ThothctlPath = ""
|
||||
[switch]$DockerStartup,
|
||||
[int]$CommandTimeoutSeconds = 600
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
$script:SensitiveValues = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
function Protect-Output([string]$Value) {
|
||||
$protected = $Value
|
||||
foreach ($secret in $script:SensitiveValues) {
|
||||
if (-not [string]::IsNullOrEmpty($secret)) {
|
||||
$protected = $protected.Replace($secret, "[REDACTED]")
|
||||
}
|
||||
}
|
||||
return $protected -replace '(?i)((?:password|token|api[_-]?key|secret|key)\s*[:=]\s*)[^\s,;]+', '$1[REDACTED]'
|
||||
}
|
||||
|
||||
function Invoke-BoundedNative {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[Parameter(Mandatory = $true)][string]$Label,
|
||||
[string]$WorkingDirectory = "",
|
||||
[int]$TimeoutSeconds = $CommandTimeoutSeconds,
|
||||
[switch]$AllowFailure
|
||||
)
|
||||
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$startInfo.FileName = $FilePath
|
||||
$startInfo.UseShellExecute = $false
|
||||
$startInfo.RedirectStandardOutput = $true
|
||||
$startInfo.RedirectStandardError = $true
|
||||
$startInfo.CreateNoWindow = $true
|
||||
if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) {
|
||||
$startInfo.WorkingDirectory = $WorkingDirectory
|
||||
}
|
||||
foreach ($argument in $Arguments) {
|
||||
[void]$startInfo.ArgumentList.Add($argument)
|
||||
}
|
||||
$process = [System.Diagnostics.Process]::new()
|
||||
$process.StartInfo = $startInfo
|
||||
if (-not $process.Start()) {
|
||||
throw "$Label could not start"
|
||||
}
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
|
||||
$process.Kill($true)
|
||||
[void]$process.WaitForExit(30000)
|
||||
throw "$Label timed out after $TimeoutSeconds seconds"
|
||||
}
|
||||
$stdout = $stdoutTask.GetAwaiter().GetResult()
|
||||
$stderr = $stderrTask.GetAwaiter().GetResult()
|
||||
$result = [pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
StdOut = $stdout
|
||||
StdErr = $stderr
|
||||
}
|
||||
if (-not $AllowFailure -and $result.ExitCode -ne 0) {
|
||||
$diagnostic = Protect-Output (($result.StdOut + "`n" + $result.StdErr).Trim())
|
||||
throw "$Label failed with exit $($result.ExitCode): $diagnostic"
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Write-Utf8File([string]$Path, [string]$Contents) {
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($Path)) | Out-Null
|
||||
[System.IO.File]::WriteAllText($Path, $Contents, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
function ConvertTo-YamlPath([string]$Path) {
|
||||
return $Path.Replace('\', '/').Replace('"', '\"')
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
|
||||
$RepositoryRoot = (& git rev-parse --show-toplevel).Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git could not resolve the repository root"
|
||||
}
|
||||
$resolved = Invoke-BoundedNative -FilePath "git" -Arguments @("rev-parse", "--show-toplevel") -Label "resolve repository root"
|
||||
$RepositoryRoot = $resolved.StdOut.Trim()
|
||||
}
|
||||
$RepositoryRoot = [System.IO.Path]::GetFullPath($RepositoryRoot)
|
||||
|
||||
$tracked = @(& git -C $RepositoryRoot ls-files)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git ls-files failed"
|
||||
}
|
||||
|
||||
$trackedResult = Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $RepositoryRoot, "ls-files") -Label "list tracked files"
|
||||
$tracked = @($trackedResult.StdOut -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
$scriptRelativePath = "scripts/test-windows-clone-contract.ps1"
|
||||
$eolAttribute = (& git -C $RepositoryRoot check-attr eol -- $scriptRelativePath).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $eolAttribute.EndsWith("eol: crlf", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$attribute = Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $RepositoryRoot, "check-attr", "eol", "--", $scriptRelativePath) -Label "read PowerShell eol attribute"
|
||||
if (-not $attribute.StdOut.Trim().EndsWith("eol: crlf", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "the Windows contract script must have the repository eol=crlf attribute"
|
||||
}
|
||||
$scriptBytes = [System.IO.File]::ReadAllBytes((Join-Path $RepositoryRoot $scriptRelativePath))
|
||||
@@ -38,71 +101,203 @@ foreach ($relativePath in $tracked) {
|
||||
$name.Equals("Dockerfile", [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$name.StartsWith("Dockerfile.", [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$name.EndsWith(".Dockerfile", [System.StringComparison]::OrdinalIgnoreCase)
|
||||
if (-not $mustBeLf) {
|
||||
continue
|
||||
}
|
||||
$absolutePath = Join-Path $RepositoryRoot $relativePath
|
||||
$bytes = [System.IO.File]::ReadAllBytes($absolutePath)
|
||||
if ($bytes -contains [byte]0x0D) {
|
||||
$offenders.Add($relativePath)
|
||||
if ($mustBeLf) {
|
||||
$bytes = [System.IO.File]::ReadAllBytes((Join-Path $RepositoryRoot $relativePath))
|
||||
if ($bytes -contains [byte]0x0D) {
|
||||
$offenders.Add($relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($offenders.Count -ne 0) {
|
||||
throw "CR byte 0x0D found in tracked LF contract files: $($offenders -join ', ')"
|
||||
}
|
||||
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("thothii-windows-contract-" + [guid]::NewGuid().ToString("N"))
|
||||
$savedEnvironment = @{
|
||||
THT_WORKSPACE_GIT_REMOTE = $env:THT_WORKSPACE_GIT_REMOTE
|
||||
PI_AUTH_FILE = $env:PI_AUTH_FILE
|
||||
THT_SECRETS_FILE = $env:THT_SECRETS_FILE
|
||||
}
|
||||
try {
|
||||
[System.IO.Directory]::CreateDirectory($temporaryRoot) | Out-Null
|
||||
$piAuth = Join-Path $temporaryRoot "pi-auth.json"
|
||||
$secrets = Join-Path $temporaryRoot "thothii.secrets"
|
||||
[System.IO.File]::WriteAllText($piAuth, "{}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($secrets, "THT_MODEL_API_KEY=windows-contract`n", [System.Text.UTF8Encoding]::new($false))
|
||||
$runId = [guid]::NewGuid().ToString("N")
|
||||
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ThothII Task 13 path with spaces " + $runId)
|
||||
$spacedRepository = Join-Path $temporaryRoot "Task 13 path with spaces"
|
||||
$fixtureRoot = Join-Path $temporaryRoot "Disposable Fixture Data"
|
||||
$project = "thothii-win-" + $runId.Substring(0, 12)
|
||||
$runLabel = "windows-" + $runId
|
||||
$coreImage = "task13-windows-core-$($runId.Substring(0, 16)):local"
|
||||
$frontendImage = "task13-windows-frontend-$($runId.Substring(0, 16)):local"
|
||||
$composeArguments = @()
|
||||
$startupAttempted = $false
|
||||
$cleanupSucceeded = $true
|
||||
$savedEnvironment = @{}
|
||||
|
||||
$env:THT_WORKSPACE_GIT_REMOTE = "https://git.example.invalid/platform/thoth-workspaces.git"
|
||||
$env:PI_AUTH_FILE = $piAuth
|
||||
$env:THT_SECRETS_FILE = $secrets
|
||||
$composeFiles = @(
|
||||
"--project-directory", $RepositoryRoot,
|
||||
"-f", (Join-Path $RepositoryRoot "compose.yaml"),
|
||||
"-f", (Join-Path $RepositoryRoot "deploy/compose.local.yaml")
|
||||
)
|
||||
$services = @(& docker compose @composeFiles config --services)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker Compose could not render the Windows clone"
|
||||
try {
|
||||
[System.IO.Directory]::CreateDirectory($spacedRepository) | Out-Null
|
||||
foreach ($relativePath in $tracked) {
|
||||
$source = Join-Path $RepositoryRoot $relativePath
|
||||
$destination = Join-Path $spacedRepository $relativePath
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
Copy-Item -LiteralPath $source -Destination $destination
|
||||
}
|
||||
if ((($services | Sort-Object) -join ",") -ne "core,frontend") {
|
||||
|
||||
$thothctl = Join-Path $spacedRepository "dist/thothctl/thothctl-windows-amd64.exe"
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($thothctl)) | Out-Null
|
||||
Invoke-BoundedNative -FilePath "go" -Arguments @("build", "-trimpath", "-o", $thothctl, "./cmd/thothctl") `
|
||||
-WorkingDirectory (Join-Path $spacedRepository "tools/thothctl") -Label "build native Windows thothctl in spaced path" | Out-Null
|
||||
Invoke-BoundedNative -FilePath $thothctl -Arguments @("--help") -Label "invoke native Windows thothctl from spaced path" | Out-Null
|
||||
|
||||
$piAuth = Join-Path $fixtureRoot "Pi Auth/pi-auth.json"
|
||||
$secrets = Join-Path $fixtureRoot "Secrets/thothii.secrets"
|
||||
$secretValue = "windows-contract-$runId"
|
||||
$script:SensitiveValues.Add($secretValue)
|
||||
Write-Utf8File $piAuth "{}`n"
|
||||
Write-Utf8File $secrets "THT_MODEL_API_KEY=$secretValue`n"
|
||||
|
||||
$remote = Join-Path $fixtureRoot "Workspace Remote/remote.git"
|
||||
$seed = Join-Path $fixtureRoot "Workspace Seed"
|
||||
[System.IO.Directory]::CreateDirectory((Join-Path $seed "workspaces")) | Out-Null
|
||||
Invoke-BoundedNative -FilePath "git" -Arguments @("init", "--bare", "--initial-branch=main", $remote) -Label "initialize Windows bare registry" | Out-Null
|
||||
Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "init", "--initial-branch=main") -Label "initialize Windows registry seed" | Out-Null
|
||||
Write-Utf8File (Join-Path $seed "workspaces/task13-windows.yaml") @"
|
||||
workspace:
|
||||
schema_version: 2
|
||||
id: task13-windows
|
||||
name: Task 13 Windows
|
||||
language: en
|
||||
dwh:
|
||||
engine: postgres
|
||||
database: warehouse
|
||||
schema: public
|
||||
supported_transports: [postgres_direct]
|
||||
"@
|
||||
Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "add", "workspaces/task13-windows.yaml") -Label "stage Windows registry seed" | Out-Null
|
||||
Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "-c", "user.name=Task 13 Windows", "-c", "user.email=task13-windows@example.invalid", "commit", "-m", "Seed Windows smoke") -Label "commit Windows registry seed" | Out-Null
|
||||
Invoke-BoundedNative -FilePath "git" -Arguments @("-C", $seed, "push", $remote, "HEAD:main") -Label "push Windows registry seed" | Out-Null
|
||||
|
||||
$envFile = Join-Path $fixtureRoot "Config/local.env"
|
||||
$override = Join-Path $fixtureRoot "Config/compose.windows.yaml"
|
||||
$installation = Join-Path $fixtureRoot "Config/thothii installation.yaml"
|
||||
Write-Utf8File $envFile @"
|
||||
THOTH_HTTP_PORT=0
|
||||
THOTH_CORE_HTTP_PORT=0
|
||||
PI_AUTH_FILE=$piAuth
|
||||
THT_SECRETS_FILE=$secrets
|
||||
THT_WORKSPACE_GIT_REMOTE=/fixtures/remote.git
|
||||
THT_WORKSPACE_GIT_BRANCH=main
|
||||
THT_WORKSPACE_INSTALLATION_ID=task13-windows
|
||||
"@
|
||||
$remoteYaml = ConvertTo-YamlPath $remote
|
||||
Write-Utf8File $override @"
|
||||
services:
|
||||
core:
|
||||
image: $coreImage
|
||||
build:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
volumes:
|
||||
- "$remoteYaml:/fixtures/remote.git:ro"
|
||||
frontend:
|
||||
image: $frontendImage
|
||||
build:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
networks:
|
||||
thothii:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
volumes:
|
||||
settings:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
pi-state:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
workspace-registry:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
sessions:
|
||||
labels:
|
||||
io.thothii.task13.run: "$runLabel"
|
||||
"@
|
||||
$repoYaml = ConvertTo-YamlPath $spacedRepository
|
||||
$envYaml = ConvertTo-YamlPath $envFile
|
||||
$overrideYaml = ConvertTo-YamlPath $override
|
||||
Write-Utf8File $installation @"
|
||||
profile: local
|
||||
projectDirectory: "$repoYaml"
|
||||
envFile: "$envYaml"
|
||||
overrides:
|
||||
- "$overrideYaml"
|
||||
"@
|
||||
|
||||
$composeArguments = @(
|
||||
"compose", "--project-name", $project, "--project-directory", $spacedRepository,
|
||||
"--env-file", $envFile,
|
||||
"-f", (Join-Path $spacedRepository "compose.yaml"),
|
||||
"-f", (Join-Path $spacedRepository "deploy/compose.local.yaml"),
|
||||
"-f", $override
|
||||
)
|
||||
$render = Invoke-BoundedNative -FilePath "docker" -Arguments ($composeArguments + @("config", "--services")) -Label "render Windows Compose from spaced path"
|
||||
$services = @($render.StdOut -split "`r?`n" | Where-Object { $_ } | Sort-Object)
|
||||
if (($services -join ",") -ne "core,frontend") {
|
||||
throw "rendered Windows stack must contain exactly core and frontend"
|
||||
}
|
||||
& docker compose @composeFiles config --quiet
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Docker Compose rejected the Windows clone"
|
||||
}
|
||||
Invoke-BoundedNative -FilePath "docker" -Arguments ($composeArguments + @("config", "--quiet")) -Label "validate Windows Compose from spaced path" | Out-Null
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ThothctlPath)) {
|
||||
$ThothctlPath = Join-Path $RepositoryRoot "dist/thothctl/thothctl-windows-amd64.exe"
|
||||
}
|
||||
$ThothctlPath = [System.IO.Path]::GetFullPath($ThothctlPath)
|
||||
if (-not [System.IO.File]::Exists($ThothctlPath)) {
|
||||
throw "Windows thothctl binary is missing: $ThothctlPath"
|
||||
}
|
||||
& $ThothctlPath --help | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Windows thothctl invocation failed"
|
||||
if ($DockerStartup) {
|
||||
Invoke-BoundedNative -FilePath "docker" -Arguments @("info") -Label "verify Windows Docker Desktop readiness" -TimeoutSeconds 60 | Out-Null
|
||||
$startupAttempted = $true
|
||||
Invoke-BoundedNative -FilePath "docker" -Arguments ($composeArguments + @("build", "--pull")) -Label "build two-service Windows stack" | Out-Null
|
||||
Invoke-BoundedNative -FilePath "docker" -Arguments ($composeArguments + @("up", "--detach", "--wait", "--wait-timeout", "180")) -Label "start two-service Windows stack" -TimeoutSeconds 300 | Out-Null
|
||||
$running = Invoke-BoundedNative -FilePath "docker" -Arguments ($composeArguments + @("ps", "--status", "running", "--services")) -Label "inspect running Windows services"
|
||||
$runningServices = @($running.StdOut -split "`r?`n" | Where-Object { $_ } | Sort-Object)
|
||||
if (($runningServices -join ",") -ne "core,frontend") {
|
||||
throw "bounded Windows startup did not leave exactly core and frontend running"
|
||||
}
|
||||
Invoke-BoundedNative -FilePath $thothctl -Arguments @("--installation", $installation, "status") -Label "invoke installation-aware Windows thothctl in spaced path" | Out-Null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($startupAttempted -and $composeArguments.Count -ne 0) {
|
||||
try {
|
||||
foreach ($kind in @("container", "volume", "network")) {
|
||||
$listArgs = if ($kind -eq "container") { @($kind, "ls", "-aq") } else { @($kind, "ls", "-q") }
|
||||
$listed = Invoke-BoundedNative -FilePath "docker" -Arguments ($listArgs + @("--filter", "label=com.docker.compose.project=$project")) -Label "enumerate Windows project $kind resources" -TimeoutSeconds 30
|
||||
foreach ($id in @($listed.StdOut -split "`r?`n" | Where-Object { $_ })) {
|
||||
$format = if ($kind -eq "container") { '{{ index .Config.Labels "io.thothii.task13.run" }}' } else { '{{ index .Labels "io.thothii.task13.run" }}' }
|
||||
$inspected = Invoke-BoundedNative -FilePath "docker" -Arguments @($kind, "inspect", "--format", $format, $id) -Label "inspect Windows project $kind ownership" -TimeoutSeconds 30
|
||||
if ($inspected.StdOut.Trim() -ne $runLabel) {
|
||||
throw "refusing to remove foreign Windows project $kind resource"
|
||||
}
|
||||
}
|
||||
}
|
||||
Invoke-BoundedNative -FilePath "docker" -Arguments ($composeArguments + @("down", "--volumes", "--remove-orphans", "--timeout", "10")) -Label "remove exact Windows Compose project" -TimeoutSeconds 60 | Out-Null
|
||||
foreach ($image in @($frontendImage, $coreImage)) {
|
||||
$inspection = Invoke-BoundedNative -FilePath "docker" -Arguments @("image", "inspect", "--format", '{{ index .Config.Labels "io.thothii.task13.run" }}', $image) -Label "inspect Windows image ownership" -TimeoutSeconds 30 -AllowFailure
|
||||
if ($inspection.ExitCode -eq 0) {
|
||||
if ($inspection.StdOut.Trim() -ne $runLabel) {
|
||||
throw "refusing to remove foreign Windows image $image"
|
||||
}
|
||||
Invoke-BoundedNative -FilePath "docker" -Arguments @("image", "rm", $image) -Label "remove exact Windows image" -TimeoutSeconds 60 | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$cleanupSucceeded = $false
|
||||
Write-Error (Protect-Output $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
foreach ($name in $savedEnvironment.Keys) {
|
||||
[System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process")
|
||||
}
|
||||
if ([System.IO.Directory]::Exists($temporaryRoot)) {
|
||||
if ($cleanupSucceeded -and [System.IO.Directory]::Exists($temporaryRoot)) {
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "Windows clone, LF-byte, Compose render, and thothctl invocation contracts passed."
|
||||
if (-not $cleanupSucceeded) {
|
||||
throw "Windows cleanup proof failed; fixture path retained for recovery"
|
||||
}
|
||||
if ($DockerStartup) {
|
||||
Write-Output "Windows spaced-path build, native thothctl, bounded two-service startup, and exact cleanup passed."
|
||||
} else {
|
||||
Write-Output "Windows spaced-path clone, LF-byte, Compose render, and native thothctl build/invocation contracts passed; Docker startup mode was not requested."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user