src/public/Deployment/Invoke-AitherRingDeployment.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Manages ring-based deployments. .DESCRIPTION Provides functions to query ring status, promote between rings, rollback deployments, and view deployment history. Rings (progressive deployment): - Ring 0 (dev): Local Docker Compose — all changes land here - Ring 1 (staging): demo.aitherium.com — Docker containers on remote host - Ring 2 (prod): Stable release — GitHub Pages + remote backend Promotion paths: - dev → staging: Build + push Docker images, deploy to remote host - staging → prod: After soak time, promote to stable release - dev → prod: Goes through staging automatically (2-step) This module integrates with: - AitherWatch (health checks) - AitherFlow (GitHub workflow triggers) - Atlas (pipeline orchestration) - Strata (deployment history ingestion) - GHCR (Docker image registry) .EXAMPLE Get-AitherRingStatus Get-AitherRingStatus -Ring staging .EXAMPLE Invoke-AitherRingPromotion -From dev -To staging -Approve Invoke-AitherRingPromotion -From staging -To prod -Approve .EXAMPLE Get-AitherRingHistory -Ring staging -Last 10 .NOTES Category: Deployment Dependencies: Docker, GitHub CLI (for prod promotion), SSH (for staging) #> # ═══════════════════════════════════════════════════════════════ # CONFIGURATION # ═══════════════════════════════════════════════════════════════ function Get-RingConfig { <# .SYNOPSIS Loads the ring deployment configuration from rings.yaml. #> [CmdletBinding()] param() $configPath = Join-Path $PSScriptRoot "../../../../config/rings.yaml" if (-not (Test-Path $configPath)) { # Try from workspace root $roots = @( (Join-Path $env:AITHEROS_ROOT "config/rings.yaml"), (Join-Path (Get-Location) "config/rings.yaml") ) foreach ($r in $roots) { if (Test-Path $r) { $configPath = $r; break } } } if (-not (Test-Path $configPath)) { Write-Error "Ring config not found. Expected at: config/rings.yaml" return $null } # Use PowerShell-YAML if available, otherwise parse manually try { if (Get-Module -ListAvailable powershell-yaml -ErrorAction SilentlyContinue) { Import-Module powershell-yaml -ErrorAction Stop return Get-Content $configPath -Raw | ConvertFrom-Yaml } } catch {} # Fallback: return raw content for callers to parse return @{ _raw = Get-Content $configPath -Raw _path = $configPath } } function Get-AitherRingStatus { <# .SYNOPSIS Shows the current status of all deployment rings or a specific ring. .PARAMETER Ring Specific ring to check: dev, staging, prod. If omitted, shows all. .PARAMETER Detailed Show detailed health check info per service. .EXAMPLE Get-AitherRingStatus Get-AitherRingStatus -Ring staging -Detailed #> [CmdletBinding()] param( [ValidateSet("dev", "staging", "prod", "all")] [string]$Ring = "all", [switch]$Detailed ) $rings = @() # ── Ring 0: Dev (local) ── if ($Ring -in @("all", "dev")) { $devStatus = @{ Ring = "dev" Name = "Development" Id = 0 Emoji = "🔧" Status = "unknown" Services = @{ Online = 0; Total = 0 } Veil = "unknown" LastDeploy = $null Endpoint = "http://localhost:3000" } # Check Watch for service health try { $watchUrl = "http://localhost:8082/status" $watchData = Invoke-RestMethod -Uri $watchUrl -TimeoutSec 10 -ErrorAction Stop $devStatus.Services.Online = $watchData.summary.online $devStatus.Services.Total = $watchData.summary.total $devStatus.Status = if ($watchData.summary.online -gt 0) { "healthy" } else { "degraded" } } catch { $devStatus.Status = "offline" } # Check Veil try { $veilResp = Invoke-WebRequest -Uri "http://localhost:3000" -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop $devStatus.Veil = if ($veilResp.StatusCode -eq 200) { "online" } else { "error" } } catch { $devStatus.Veil = "offline" } # Check last deploy from history $historyFile = Join-Path (Get-Location) "logs/ring-deployments.jsonl" if (Test-Path $historyFile) { $lastDev = Get-Content $historyFile | ForEach-Object { $_ | ConvertFrom-Json } | Where-Object { $_.ring -eq "dev" } | Select-Object -Last 1 if ($lastDev) { $devStatus.LastDeploy = $lastDev.timestamp } } $rings += [PSCustomObject]$devStatus } # ── Ring 1: Staging (demo.aitherium.com Docker) ── if ($Ring -in @("all", "staging")) { $stagingStatus = @{ Ring = "staging" Name = "Staging" Id = 1 Emoji = "🧪" Status = "unknown" Services = @{ Online = "N/A"; Total = "N/A" } Veil = "unknown" LastDeploy = $null Endpoint = "https://demo.aitherium.com" } # Check staging Veil try { $stagingResp = Invoke-WebRequest -Uri "https://demo.aitherium.com" -TimeoutSec 15 -UseBasicParsing -ErrorAction Stop $stagingStatus.Veil = if ($stagingResp.StatusCode -eq 200) { "online" } else { "error" } $stagingStatus.Status = "healthy" } catch { $stagingStatus.Veil = "offline" $stagingStatus.Status = "offline" } # Try to get remote service count via staging API try { $stagingWatch = Invoke-RestMethod -Uri "https://demo.aitherium.com/api/watch/status" -TimeoutSec 10 -ErrorAction Stop $stagingStatus.Services.Online = $stagingWatch.summary.online $stagingStatus.Services.Total = $stagingWatch.summary.total } catch { # Staging watch may not be exposed — that's OK } # Check last staging deploy from history $historyFile = Join-Path (Get-Location) "logs/ring-deployments.jsonl" if (Test-Path $historyFile) { $lastStaging = Get-Content $historyFile | ForEach-Object { $_ | ConvertFrom-Json } | Where-Object { $_.ring -eq "staging" } | Select-Object -Last 1 if ($lastStaging) { $stagingStatus.LastDeploy = $lastStaging.timestamp } } $rings += [PSCustomObject]$stagingStatus } # ── Ring 2: Prod ── if ($Ring -in @("all", "prod")) { $prodStatus = @{ Ring = "prod" Name = "Production" Id = 2 Emoji = "🚀" Status = "unknown" Services = @{ Online = "N/A"; Total = "N/A" } Veil = "unknown" LastDeploy = $null Endpoint = "https://demo.aitherium.com" } # Check prod Veil try { $prodResp = Invoke-WebRequest -Uri "https://demo.aitherium.com" -TimeoutSec 15 -UseBasicParsing -ErrorAction Stop $prodStatus.Veil = if ($prodResp.StatusCode -eq 200) { "online" } else { "error" } $prodStatus.Status = "healthy" } catch { $prodStatus.Veil = "offline" $prodStatus.Status = "offline" } # Check last prod deploy from history $historyFile = Join-Path (Get-Location) "logs/ring-deployments.jsonl" if (Test-Path $historyFile) { $lastProd = Get-Content $historyFile | ForEach-Object { $_ | ConvertFrom-Json } | Where-Object { $_.ring -eq "prod" } | Select-Object -Last 1 if ($lastProd) { $prodStatus.LastDeploy = $lastProd.timestamp } } $rings += [PSCustomObject]$prodStatus } # ── Display ── Write-Host "" Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ AITHEROS RING DEPLOYMENT STATUS ║" -ForegroundColor Cyan Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan foreach ($r in $rings) { $statusColor = switch ($r.Status) { "healthy" { "Green" } "degraded" { "Yellow" } "offline" { "Red" } default { "Gray" } } $statusIcon = switch ($r.Status) { "healthy" { "✓" } "degraded" { "⚠" } "offline" { "✗" } default { "?" } } Write-Host "" Write-Host " $($r.Emoji) Ring $($r.Id): $($r.Name)" -ForegroundColor White Write-Host " ├─ Status: $statusIcon $($r.Status)" -ForegroundColor $statusColor Write-Host " ├─ Veil: $($r.Veil)" -ForegroundColor $(if ($r.Veil -eq "online") { "Green" } else { "Red" }) if ($r.Ring -eq "dev") { Write-Host " ├─ Services: $($r.Services.Online)/$($r.Services.Total) online" -ForegroundColor $(if ($r.Services.Online -gt 0) { "Green" } else { "Yellow" }) } Write-Host " ├─ Endpoint: $($r.Endpoint)" -ForegroundColor DarkGray Write-Host " └─ Last: $(if ($r.LastDeploy) { $r.LastDeploy } else { 'never' })" -ForegroundColor DarkGray } Write-Host "" return $rings } function Invoke-AitherRingPromotion { <# .SYNOPSIS Promotes a deployment from one ring to another. .DESCRIPTION Runs promotion gates (health check, tests, build validation), then triggers the target ring deployment. Promotion paths: - dev → staging: Builds Docker images, pushes to GHCR, deploys to remote host - staging → prod: Verifies staging health, triggers GitHub Pages deploy, tags release - dev → prod: Automatically goes through staging first (2-step promotion) .PARAMETER From Source ring (default: dev) .PARAMETER To Target ring (default: staging) .PARAMETER Approve Auto-approve the manual gate (skip interactive prompt) .PARAMETER SkipTests Skip test gate (use with caution) .PARAMETER SkipBuild Skip build validation gate .PARAMETER DryRun Show what would happen without executing .EXAMPLE Invoke-AitherRingPromotion -From dev -To staging -Approve Invoke-AitherRingPromotion -From staging -To prod -Approve Invoke-AitherRingPromotion -From dev -To prod -Approve # 2-step #> [CmdletBinding(SupportsShouldProcess)] param( [ValidateSet("dev", "staging")] [string]$From = "dev", [ValidateSet("staging", "prod")] [string]$To = "staging", [switch]$Approve, [switch]$SkipTests, [switch]$SkipBuild, [switch]$DryRun, [switch]$Force ) # ── Handle dev → prod as 2-step ── if ($From -eq "dev" -and $To -eq "prod") { Write-Host "`n ℹ dev → prod promotion goes through staging automatically." -ForegroundColor Cyan Write-Host " Step 1: dev → staging" -ForegroundColor White Write-Host " Step 2: staging → prod" -ForegroundColor White Write-Host "" # Step 1: dev → staging $step1Params = @{ From = "dev"; To = "staging" } if ($Approve) { $step1Params.Approve = $true } if ($SkipTests) { $step1Params.SkipTests = $true } if ($SkipBuild) { $step1Params.SkipBuild = $true } if ($DryRun) { $step1Params.DryRun = $true } if ($Force) { $step1Params.Force = $true } Invoke-AitherRingPromotion @step1Params if (-not $DryRun -and $LASTEXITCODE -ne 0) { Write-Host " ✗ Step 1 failed — aborting prod promotion." -ForegroundColor Red return } Write-Host "`n ═══ Step 2: staging → prod ═══`n" -ForegroundColor Magenta # Step 2: staging → prod $step2Params = @{ From = "staging"; To = "prod" } if ($Approve) { $step2Params.Approve = $true } if ($DryRun) { $step2Params.DryRun = $true } if ($Force) { $step2Params.Force = $true } # Don't skip tests/build for the prod leg Invoke-AitherRingPromotion @step2Params return } $startTime = Get-Date $version = if (Test-Path "VERSION") { (Get-Content "VERSION" -Raw).Trim() } else { "0.0.1" } $commitHash = git rev-parse --short HEAD 2>$null $commitMsg = git log -1 --pretty=%s 2>$null Write-Host "" Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Magenta Write-Host "║ RING PROMOTION: $($From.ToUpper()) → $($To.ToUpper()) ║" -ForegroundColor Magenta Write-Host "╠═══════════════════════════════════════════════════════════╣" -ForegroundColor Magenta Write-Host "║ Version: $($version.PadRight(45))║" -ForegroundColor White Write-Host "║ Commit: $("$commitHash — $commitMsg".Substring(0, [Math]::Min("$commitHash — $commitMsg".Length, 45)).PadRight(45))║" -ForegroundColor White Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Magenta if ($DryRun) { Write-Host "`n [DRY RUN] Would execute promotion gates..." -ForegroundColor Yellow } $gates = @() $allPassed = $true # ══════════════════════════════════════════════════════════════ # GATES (vary by promotion path) # ══════════════════════════════════════════════════════════════ if ($From -eq "dev" -and $To -eq "staging") { # ── Gate 1: Health Check ── Write-Host "`n ┌─ Gate 1: Dev Health Check" -ForegroundColor Cyan if (-not $DryRun) { try { $watchData = Invoke-RestMethod -Uri "http://localhost:8082/status" -TimeoutSec 15 -ErrorAction Stop $online = $watchData.summary.online $total = $watchData.summary.total $healthy = $online -gt 0 Write-Host " │ Services: $online/$total online" -ForegroundColor $(if ($healthy) { "Green" } else { "Red" }) $gates += @{ Name = "Health Check"; Passed = $healthy; Detail = "$online/$total services" } if (-not $healthy) { $allPassed = $false } } catch { Write-Host " │ Watch unavailable: $_" -ForegroundColor Red $gates += @{ Name = "Health Check"; Passed = $false; Detail = "Watch offline" } $allPassed = $false } } else { Write-Host " │ [DRY RUN] Would check Watch status" -ForegroundColor Yellow $gates += @{ Name = "Health Check"; Passed = $true; Detail = "DRY RUN" } } Write-Host " └─ $(if ($gates[-1].Passed) { '✓ PASSED' } else { '✗ FAILED' })" -ForegroundColor $(if ($gates[-1].Passed) { "Green" } else { "Red" }) # ── Gate 2: Tests ── if (-not $SkipTests) { Write-Host "`n ┌─ Gate 2: Test Suite" -ForegroundColor Cyan if (-not $DryRun) { try { # Push-Location handled by ProjectContext $testResult = python -m pytest dev/tests/ --tb=line -q 2>&1 $testExitCode = $LASTEXITCODE Pop-Location $testPassed = $testExitCode -eq 0 Write-Host " │ Exit code: $testExitCode" -ForegroundColor $(if ($testPassed) { "Green" } else { "Red" }) $gates += @{ Name = "Test Suite"; Passed = $testPassed; Detail = "pytest exit $testExitCode" } if (-not $testPassed -and -not $Force) { $allPassed = $false } } catch { Write-Host " │ Tests failed to run: $_" -ForegroundColor Red $gates += @{ Name = "Test Suite"; Passed = $false; Detail = $_.Exception.Message } if (-not $Force) { $allPassed = $false } } } else { Write-Host " │ [DRY RUN] Would run pytest" -ForegroundColor Yellow $gates += @{ Name = "Test Suite"; Passed = $true; Detail = "DRY RUN" } } Write-Host " └─ $(if ($gates[-1].Passed) { '✓ PASSED' } else { '✗ FAILED' })" -ForegroundColor $(if ($gates[-1].Passed) { "Green" } else { "Red" }) } else { Write-Host "`n ── Gate 2: Test Suite [SKIPPED]" -ForegroundColor DarkGray $gates += @{ Name = "Test Suite"; Passed = $true; Detail = "SKIPPED" } } # ── Gate 3: Docker Build ── if (-not $SkipBuild) { Write-Host "`n ┌─ Gate 3: Docker Build" -ForegroundColor Cyan if (-not $DryRun) { try { Write-Host " │ Building Docker images for staging..." -ForegroundColor Gray $ringCtx = Get-AitherProjectContext $buildOutput = docker compose -f $ringCtx.ComposeFile --profile core build 2>&1 $buildExitCode = $LASTEXITCODE $buildPassed = $buildExitCode -eq 0 Write-Host " │ Build exit code: $buildExitCode" -ForegroundColor $(if ($buildPassed) { "Green" } else { "Red" }) $gates += @{ Name = "Docker Build"; Passed = $buildPassed; Detail = "docker build exit $buildExitCode" } if (-not $buildPassed) { $allPassed = $false } } catch { Write-Host " │ Build failed: $_" -ForegroundColor Red $gates += @{ Name = "Docker Build"; Passed = $false; Detail = $_.Exception.Message } $allPassed = $false } } else { Write-Host " │ [DRY RUN] Would build Docker images" -ForegroundColor Yellow $gates += @{ Name = "Docker Build"; Passed = $true; Detail = "DRY RUN" } } Write-Host " └─ $(if ($gates[-1].Passed) { '✓ PASSED' } else { '✗ FAILED' })" -ForegroundColor $(if ($gates[-1].Passed) { "Green" } else { "Red" }) } else { Write-Host "`n ── Gate 3: Docker Build [SKIPPED]" -ForegroundColor DarkGray $gates += @{ Name = "Docker Build"; Passed = $true; Detail = "SKIPPED" } } } elseif ($From -eq "staging" -and $To -eq "prod") { # ── Gate 1: Staging Health ── Write-Host "`n ┌─ Gate 1: Staging Health Check" -ForegroundColor Cyan if (-not $DryRun) { try { $stagingResp = Invoke-WebRequest -Uri "https://demo.aitherium.com" -TimeoutSec 15 -UseBasicParsing -ErrorAction Stop $healthy = $stagingResp.StatusCode -eq 200 Write-Host " │ Staging HTTP: $($stagingResp.StatusCode)" -ForegroundColor $(if ($healthy) { "Green" } else { "Red" }) $gates += @{ Name = "Staging Health"; Passed = $healthy; Detail = "HTTP $($stagingResp.StatusCode)" } if (-not $healthy) { $allPassed = $false } } catch { Write-Host " │ Staging unreachable: $_" -ForegroundColor Red $gates += @{ Name = "Staging Health"; Passed = $false; Detail = "Unreachable" } $allPassed = $false } } else { Write-Host " │ [DRY RUN] Would check staging health" -ForegroundColor Yellow $gates += @{ Name = "Staging Health"; Passed = $true; Detail = "DRY RUN" } } Write-Host " └─ $(if ($gates[-1].Passed) { '✓ PASSED' } else { '✗ FAILED' })" -ForegroundColor $(if ($gates[-1].Passed) { "Green" } else { "Red" }) # ── Gate 2: Smoke Tests ── Write-Host "`n ┌─ Gate 2: Staging Smoke Tests" -ForegroundColor Cyan if (-not $DryRun) { $smokePassed = $true $smokeDetails = @() $smokeUrls = @( @{ Url = "https://demo.aitherium.com"; Name = "Veil" }, @{ Url = "https://demo.aitherium.com/api/health"; Name = "API" } ) foreach ($check in $smokeUrls) { try { $resp = Invoke-WebRequest -Uri $check.Url -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop Write-Host " │ $($check.Name): ✓ HTTP $($resp.StatusCode)" -ForegroundColor Green $smokeDetails += "$($check.Name)=OK" } catch { Write-Host " │ $($check.Name): ✗ Failed" -ForegroundColor Red $smokeDetails += "$($check.Name)=FAIL" $smokePassed = $false } } $gates += @{ Name = "Smoke Tests"; Passed = $smokePassed; Detail = ($smokeDetails -join ", ") } if (-not $smokePassed -and -not $Force) { $allPassed = $false } } else { Write-Host " │ [DRY RUN] Would run smoke tests" -ForegroundColor Yellow $gates += @{ Name = "Smoke Tests"; Passed = $true; Detail = "DRY RUN" } } Write-Host " └─ $(if ($gates[-1].Passed) { '✓ PASSED' } else { '✗ FAILED' })" -ForegroundColor $(if ($gates[-1].Passed) { "Green" } else { "Red" }) } # ── Manual Approval Gate (always last) ── if (-not $Approve) { Write-Host "`n ┌─ Gate: Manual Approval" -ForegroundColor Cyan if (-not $DryRun) { Write-Host " │" -ForegroundColor Cyan Write-Host " │ Gate Summary:" -ForegroundColor White foreach ($g in $gates) { $icon = if ($g.Passed) { "✓" } else { "✗" } $color = if ($g.Passed) { "Green" } else { "Red" } Write-Host " │ $icon $($g.Name): $($g.Detail)" -ForegroundColor $color } Write-Host " │" -ForegroundColor Cyan if (-not $allPassed -and -not $Force) { Write-Host " │ ✗ Cannot promote — gates failed. Use -Force to override." -ForegroundColor Red $gates += @{ Name = "Manual Approval"; Passed = $false; Detail = "Blocked by failed gates" } Write-Host " └─ ✗ BLOCKED" -ForegroundColor Red Write-RingHistory -Ring $To -Action "promote" -Status "blocked" -Version $version -Commit $commitHash -Gates $gates return } $response = Read-Host " │ Approve promotion $($From.ToUpper()) → $($To.ToUpper())? (y/N)" if ($response -notin @("y", "Y", "yes")) { Write-Host " └─ ✗ REJECTED" -ForegroundColor Red $gates += @{ Name = "Manual Approval"; Passed = $false; Detail = "User rejected" } Write-RingHistory -Ring $To -Action "promote" -Status "rejected" -Version $version -Commit $commitHash -Gates $gates return } $gates += @{ Name = "Manual Approval"; Passed = $true; Detail = "Approved" } } Write-Host " └─ ✓ APPROVED" -ForegroundColor Green } else { $gates += @{ Name = "Manual Approval"; Passed = $true; Detail = "Auto-approved" } } # ── Pre-flight check ── if (-not $allPassed -and -not $Force -and -not $Approve) { Write-Host "`n ✗ Promotion aborted — gates failed." -ForegroundColor Red Write-RingHistory -Ring $To -Action "promote" -Status "failed" -Version $version -Commit $commitHash -Gates $gates return } # ══════════════════════════════════════════════════════════════ # EXECUTE PROMOTION # ══════════════════════════════════════════════════════════════ Write-Host "`n ═══ EXECUTING PROMOTION: $($From.ToUpper()) → $($To.ToUpper()) ═══" -ForegroundColor Green if ($To -eq "staging") { # ── Deploy to Staging ── if (-not $DryRun) { $tag = "ring-staging-v$version-$(Get-Date -Format 'yyyyMMdd-HHmm')" $commitTag = $commitHash # Step 1: Tag images with commit SHA Write-Host " → Tagging: $tag" -ForegroundColor Gray git tag $tag 2>$null # Step 2: Push tag Write-Host " → Pushing tag to origin..." -ForegroundColor Gray git push origin $tag 2>$null # Step 3: Build and push Docker images to GHCR $registry = "ghcr.io/aitherium" Write-Host " → Building and pushing images to $registry..." -ForegroundColor Gray $imagesToPush = @("aitheros-veil", "aitheros-genesis") $pushSucceeded = $true foreach ($img in $imagesToPush) { Write-Host " → Tagging $img → $registry/$($img):$commitTag" -ForegroundColor DarkGray docker tag "$($img):latest" "$registry/$($img):$commitTag" 2>$null docker tag "$($img):latest" "$registry/$($img):staging" 2>$null Write-Host " → Pushing $registry/$($img):$commitTag" -ForegroundColor DarkGray $pushResult = docker push "$registry/$($img):$commitTag" 2>&1 if ($LASTEXITCODE -ne 0) { Write-Host " ⚠ Push failed for $img (may need: docker login ghcr.io)" -ForegroundColor Yellow $pushSucceeded = $false } docker push "$registry/$($img):staging" 2>&1 | Out-Null } if (-not $pushSucceeded) { Write-Host " ⚠ Some images failed to push. Trying remote deploy anyway..." -ForegroundColor Yellow } # Step 4: Deploy to remote host via SSH or GitHub Actions $deployed = $false # Try GitHub Actions first (preferred — no SSH key needed) if (Get-Command gh -ErrorAction SilentlyContinue) { try { Write-Host " → Triggering ring-deploy workflow for staging..." -ForegroundColor Gray gh workflow run ring-deploy.yml -f target_ring=staging -f skip_tests=true 2>&1 $deployed = $true Write-Host " ✓ Workflow triggered via gh CLI" -ForegroundColor Green } catch { Write-Warning "gh workflow trigger failed: $_" } } # Fallback: SSH deployment if (-not $deployed) { $sshKey = $env:STAGING_SSH_KEY $sshHost = $env:STAGING_HOST ?? "demo.aitherium.com" $sshUser = $env:STAGING_USER ?? "aitheros" if ($sshKey -and (Test-Path $sshKey)) { Write-Host " → Deploying to $sshUser@$sshHost via SSH..." -ForegroundColor Gray try { $sshCmd = "cd /opt/aitheros && docker compose pull && docker compose --profile core up -d" ssh -i $sshKey -o StrictHostKeyChecking=no "$sshUser@$sshHost" $sshCmd 2>&1 $deployed = $true Write-Host " ✓ SSH deployment completed" -ForegroundColor Green } catch { Write-Warning "SSH deployment failed: $_" } } } if (-not $deployed) { Write-Host " ⚠ Could not deploy automatically." -ForegroundColor Yellow Write-Host " → Set STAGING_HOST, STAGING_USER, STAGING_SSH_KEY env vars for SSH deploy" -ForegroundColor Yellow Write-Host " → Or use: gh workflow run ring-deploy.yml -f target_ring=staging" -ForegroundColor Yellow } # Step 5: Emit Flux event try { $eventBody = @{ event = "ring.promoted" data = @{ source = $From target = $To version = $version commit = $commitHash tag = $tag timestamp = (Get-Date -Format "o") } } | ConvertTo-Json -Depth 5 Invoke-RestMethod -Uri "http://localhost:8117/api/v1/events/emit" ` -Method POST -ContentType "application/json" -Body $eventBody -TimeoutSec 5 -ErrorAction SilentlyContinue } catch {} } else { Write-Host " [DRY RUN] Would build images, push to GHCR, deploy to staging" -ForegroundColor Yellow } } elseif ($To -eq "prod") { # ── Deploy to Prod ── if (-not $DryRun) { # Step 1: Tag the commit $tag = "ring-prod-v$version-$(Get-Date -Format 'yyyyMMdd-HHmm')" Write-Host " → Tagging: $tag" -ForegroundColor Gray git tag $tag 2>$null # Step 2: Push tag Write-Host " → Pushing tag to origin..." -ForegroundColor Gray git push origin $tag 2>$null # Step 3: Trigger GitHub Actions deploy workflow Write-Host " → Triggering deploy-veil workflow..." -ForegroundColor Gray $triggered = $false if (Get-Command gh -ErrorAction SilentlyContinue) { try { gh workflow run deploy-veil.yml -f confirm=deploy 2>&1 $triggered = $true Write-Host " ✓ Workflow triggered via gh CLI" -ForegroundColor Green } catch { Write-Warning "gh CLI trigger failed, trying API..." } } if (-not $triggered) { try { $body = @{ workflow = "deploy-veil.yml" inputs = @{ confirm = "deploy" } } | ConvertTo-Json Invoke-RestMethod -Uri "http://localhost:8165/api/v1/workflows/dispatch" ` -Method POST -ContentType "application/json" -Body $body -TimeoutSec 15 $triggered = $true Write-Host " ✓ Workflow triggered via AitherFlow" -ForegroundColor Green } catch { Write-Warning "AitherFlow trigger failed: $_" } } if (-not $triggered) { Write-Host " ⚠ Could not trigger workflow automatically." -ForegroundColor Yellow Write-Host " → Manual: gh workflow run deploy-veil.yml -f confirm=deploy" -ForegroundColor Yellow } # Step 4: Emit Flux event try { $eventBody = @{ event = "ring.promoted" data = @{ source = $From target = $To version = $version commit = $commitHash tag = $tag gates = $gates timestamp = (Get-Date -Format "o") } } | ConvertTo-Json -Depth 5 Invoke-RestMethod -Uri "http://localhost:8117/api/v1/events/emit" ` -Method POST -ContentType "application/json" -Body $eventBody -TimeoutSec 5 -ErrorAction SilentlyContinue } catch {} } else { Write-Host " [DRY RUN] Would tag, push, and trigger deploy-veil workflow" -ForegroundColor Yellow } } # Log to history $duration = (Get-Date) - $startTime Write-RingHistory -Ring $To -Action "promote" -Status "success" -Version $version -Commit $commitHash -Gates $gates -Duration $duration.TotalSeconds Write-Host "" Write-Host " ✓ Promotion complete! ($([math]::Round($duration.TotalSeconds))s)" -ForegroundColor Green $targetUrl = switch ($To) { "staging" { "https://demo.aitherium.com" } "prod" { "https://demo.aitherium.com" } default { "http://localhost:3000" } } Write-Host " → $($To.ToUpper()) endpoint: $targetUrl" -ForegroundColor White Write-Host "" } function Write-RingHistory { <# .SYNOPSIS Writes a deployment event to the ring history log. #> [CmdletBinding()] param( [string]$Ring, [string]$Action, [string]$Status, [string]$Version, [string]$Commit, [array]$Gates = @(), [double]$Duration = 0 ) $entry = @{ timestamp = (Get-Date -Format "o") ring = $Ring action = $Action status = $Status version = $Version commit = $Commit gates = $Gates duration_seconds = $Duration user = $env:USERNAME ?? $env:USER ?? "unknown" machine = $env:COMPUTERNAME ?? (hostname) } | ConvertTo-Json -Compress -Depth 5 $logDir = Join-Path (Get-Location) "logs" if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } $logFile = Join-Path $logDir "ring-deployments.jsonl" $entry | Add-Content -Path $logFile -Encoding UTF8 # Also ingest to Strata if available try { $strataBody = @{ type = "deployment" source = "ring-deploy" data = ($entry | ConvertFrom-Json) } | ConvertTo-Json -Depth 5 Invoke-RestMethod -Uri "http://localhost:8136/api/v1/ingest/deployment" ` -Method POST -ContentType "application/json" -Body $strataBody -TimeoutSec 3 -ErrorAction SilentlyContinue } catch { # Strata may not be running } } function Get-AitherRingHistory { <# .SYNOPSIS Shows deployment history for a ring. .PARAMETER Ring Ring to show history for (dev, staging, prod, or all) .PARAMETER Last Number of entries to show (default: 20) .EXAMPLE Get-AitherRingHistory -Ring staging -Last 5 #> [CmdletBinding()] param( [ValidateSet("dev", "staging", "prod", "all")] [string]$Ring = "all", [int]$Last = 20 ) $logFile = Join-Path (Get-Location) "logs/ring-deployments.jsonl" if (-not (Test-Path $logFile)) { Write-Host " No deployment history found." -ForegroundColor Yellow return @() } $entries = Get-Content $logFile | ForEach-Object { try { $_ | ConvertFrom-Json } catch {} } if ($Ring -ne "all") { $entries = $entries | Where-Object { $_.ring -eq $Ring } } $entries = $entries | Select-Object -Last $Last Write-Host "" Write-Host " Ring Deployment History (last $Last)" -ForegroundColor Cyan Write-Host " ─────────────────────────────────────" -ForegroundColor DarkCyan foreach ($e in $entries) { $statusIcon = switch ($e.status) { "success" { "✓" } "failed" { "✗" } "rejected" { "⊘" } "blocked" { "⊘" } default { "?" } } $statusColor = switch ($e.status) { "success" { "Green" } "failed" { "Red" } "rejected" { "Yellow" } "blocked" { "Yellow" } default { "Gray" } } $time = if ($e.timestamp) { [datetime]::Parse($e.timestamp).ToString("yyyy-MM-dd HH:mm") } else { "?" } Write-Host " $statusIcon [$time] $($e.ring.ToUpper()) — $($e.action) v$($e.version) ($($e.commit)) — $($e.status)" -ForegroundColor $statusColor } Write-Host "" return $entries } # Export handled by build.ps1 |