param( [string]$ServerHost = "192.168.3.90", [string]$SshUser = "root", [string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519", [string]$RemoteProjectPath = "/opt/ai-contract-platform", [string]$ComposeFile = "docker-compose.server.yml", [string]$RemoteBackupRoot = "/opt/ai-contract-platform/backups/phase7-deploy", [string]$FrontendBaseUrl = "http://192.168.3.90:3003", [string]$BackendBaseUrl = "http://192.168.3.90:8001", [string]$ApiUrl = "http://192.168.3.90:8001/api/v1", [string]$HealthUrl = "http://192.168.3.90:8001/health", [string]$ReadinessUrl = "http://192.168.3.90:8001/api/v1/system/readiness", [string]$ArtifactRoot = "", [switch]$AllowDirtyWorktree, [switch]$SkipReleaseGate, [switch]$IncludeBackupDrill, [switch]$KeepRemoteArchive ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss" $archiveName = "ai-contract-frontend-$timestamp.tgz" if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { $ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase7-frontend-docker-deploy" } $artifactDir = Join-Path $ArtifactRoot $timestamp $summaryJson = Join-Path $artifactDir "frontend-docker-deploy-summary.json" $summaryMd = Join-Path $artifactDir "frontend-docker-deploy-summary.md" $gitStatusPath = Join-Path $artifactDir "git-status.txt" $localArchive = Join-Path $artifactDir $archiveName $remoteArchive = "/tmp/$archiveName" New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null $checks = New-Object System.Collections.Generic.List[object] function Add-Check { param( [string]$Key, [string]$Label, [ValidateSet("pass", "fail", "warn", "skip")] [string]$Status, [string]$Detail, [bool]$Required = $true, [hashtable]$Meta = @{} ) $checks.Add([ordered]@{ key = $Key label = $Label status = $Status required = $Required detail = $Detail meta = $Meta }) | Out-Null } function Save-Json { param( [string]$Path, [object]$Value ) $Value | ConvertTo-Json -Depth 12 | Set-Content -Encoding UTF8 -Path $Path } function Invoke-LocalStep { param( [string]$Name, [scriptblock]$Script ) $logPath = Join-Path $artifactDir "$Name.log" $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & $Script 2>&1 $exitCode = if ($LASTEXITCODE -is [int]) { $LASTEXITCODE } else { 0 } } catch { $output = @($_.Exception.Message) $exitCode = 1 } finally { $ErrorActionPreference = $previousErrorActionPreference } ($output -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $logPath return [ordered]@{ exit_code = $exitCode log = $logPath output = ($output -join [Environment]::NewLine) } } function Invoke-Remote { param( [string]$Name, [string]$Command ) $target = "${SshUser}@${ServerHost}" $logPath = Join-Path $artifactDir "$Name.log" $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $Command ) $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & ssh @args 2>&1 $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference } $outputText = ($output -join [Environment]::NewLine) $outputText | Set-Content -Encoding UTF8 -Path $logPath return [ordered]@{ exit_code = $exitCode output = $outputText log = $logPath } } function Invoke-ScpToRemote { param( [string]$LocalPath, [string]$RemotePath ) $target = "${SshUser}@${ServerHost}:$RemotePath" $logPath = Join-Path $artifactDir "upload-frontend-archive.log" $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $LocalPath, $target ) $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & scp @args 2>&1 $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference } ($output -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $logPath return [ordered]@{ exit_code = $exitCode log = $logPath } } if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) { Add-Check -Key "gstack" -Label "gstack preflight" -Status "fail" -Detail "gstack is missing" } else { Add-Check -Key "gstack" -Label "gstack preflight" -Status "pass" -Detail "GSTACK_OK" } if (-not (Test-Path $SshKeyPath)) { Add-Check -Key "ssh_key" -Label "SSH key" -Status "fail" -Detail "SSH key not found: $SshKeyPath" } else { Add-Check -Key "ssh_key" -Label "SSH key" -Status "pass" -Detail "SSH key exists" } $branch = (git -C $repoRoot rev-parse --abbrev-ref HEAD).Trim() $commit = (git -C $repoRoot rev-parse HEAD).Trim() $remoteUrl = (git -C $repoRoot remote get-url origin).Trim() $gitStatus = git -C $repoRoot status --short ($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath if ($gitStatus) { if ($AllowDirtyWorktree) { Add-Check -Key "working_tree" -Label "working tree" -Status "warn" -Required $false -Detail "Working tree has uncommitted changes; deployment allowed by -AllowDirtyWorktree" -Meta @{ artifact = $gitStatusPath } } else { Add-Check -Key "working_tree" -Label "working tree" -Status "fail" -Detail "Working tree has uncommitted changes; pass -AllowDirtyWorktree for a development-only deploy" -Meta @{ artifact = $gitStatusPath } } } else { Add-Check -Key "working_tree" -Label "working tree" -Status "pass" -Detail "Working tree is clean" -Meta @{ artifact = $gitStatusPath } } if ($remoteUrl -match "192\.168\.3\.90") { Add-Check -Key "origin_remote" -Label "origin remote" -Status "pass" -Detail "origin points to 192.168.3.90" -Meta @{ branch = $branch commit = $commit remote = $remoteUrl } } else { Add-Check -Key "origin_remote" -Label "origin remote" -Status "fail" -Detail "origin does not point to 192.168.3.90" -Meta @{ branch = $branch commit = $commit remote = $remoteUrl } } $failedPreflight = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedPreflight.Count -eq 0) { $archive = Invoke-LocalStep -Name "01-create-frontend-archive" -Script { Push-Location $repoRoot try { & tar -czf $localArchive --exclude=frontend/node_modules --exclude=frontend/.next frontend if ($LASTEXITCODE -eq 0 -and (Test-Path $localArchive)) { Get-Item $localArchive | Select-Object FullName, Length } } finally { Pop-Location } } if ($archive.exit_code -eq 0 -and (Test-Path $localArchive)) { Add-Check -Key "archive" -Label "frontend archive" -Status "pass" -Detail "Frontend archive created" -Meta @{ archive = $localArchive bytes = (Get-Item $localArchive).Length log = $archive.log } } else { Add-Check -Key "archive" -Label "frontend archive" -Status "fail" -Detail "Frontend archive creation failed" -Meta @{ log = $archive.log } } } $failedBeforeUpload = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedBeforeUpload.Count -eq 0) { $upload = Invoke-ScpToRemote -LocalPath $localArchive -RemotePath $remoteArchive if ($upload.exit_code -eq 0) { Add-Check -Key "upload" -Label "upload archive" -Status "pass" -Detail "Frontend archive uploaded to remote host" -Meta @{ remote_archive = $remoteArchive log = $upload.log } } else { Add-Check -Key "upload" -Label "upload archive" -Status "fail" -Detail "Frontend archive upload failed" -Meta @{ remote_archive = $remoteArchive log = $upload.log } } } $remoteBackupPath = "$RemoteBackupRoot/frontend-before-$timestamp.tgz" $failedBeforeRemote = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($failedBeforeRemote.Count -eq 0) { $keepArchiveFlag = if ($KeepRemoteArchive) { "true" } else { "false" } $remoteScript = @" set -euo pipefail project='$RemoteProjectPath' compose='$ComposeFile' archive='$remoteArchive' backup_root='$RemoteBackupRoot' stamp='$timestamp' keep_archive='$keepArchiveFlag' cd "`$project" test "`$PWD" = "`$project" test -f "`$compose" test -f ".env" test -d "frontend" test -s "`$archive" mkdir -p "`$backup_root" backup="`$backup_root/frontend-before-`$stamp.tgz" tar -czf "`$backup" frontend test -s "`$backup" rm -rf frontend tar -xzf "`$archive" test -f frontend/package.json docker compose -f "`$compose" build --no-cache frontend docker compose -f "`$compose" up -d frontend docker compose -f "`$compose" ps backend frontend if [ "`$keep_archive" != "true" ]; then rm -f "`$archive"; fi echo "REMOTE_FRONTEND_BACKUP=`$backup" "@ -replace "`r?`n", "; " $remoteDeploy = Invoke-Remote -Name "02-remote-frontend-docker-deploy" -Command $remoteScript if ($remoteDeploy.exit_code -eq 0) { Add-Check -Key "remote_deploy" -Label "remote Docker deploy" -Status "pass" -Detail "Remote frontend backup, replacement, build and restart completed" -Meta @{ remote_backup = $remoteBackupPath log = $remoteDeploy.log } } else { Add-Check -Key "remote_deploy" -Label "remote Docker deploy" -Status "fail" -Detail "Remote frontend Docker deploy failed" -Meta @{ remote_backup = $remoteBackupPath log = $remoteDeploy.log } } } $failedBeforeGate = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) if ($SkipReleaseGate) { Add-Check -Key "release_gate" -Label "post-deploy release gate" -Status "skip" -Required $false -Detail "Skipped by -SkipReleaseGate" } elseif ($failedBeforeGate.Count -eq 0) { if ([string]::IsNullOrWhiteSpace($env:AI_CONTRACT_ADMIN_PASSWORD)) { Add-Check -Key "release_gate" -Label "post-deploy release gate" -Status "fail" -Detail "AI_CONTRACT_ADMIN_PASSWORD must be set in the current process" } else { $gateArgs = @( "-ServerHost", $ServerHost, "-FrontendBaseUrl", $FrontendBaseUrl, "-BackendBaseUrl", $BackendBaseUrl, "-ApiUrl", $ApiUrl, "-HealthUrl", $HealthUrl, "-ReadinessUrl", $ReadinessUrl, "-ArtifactRoot", (Join-Path $artifactDir "release-gate") ) if (-not $AllowDirtyWorktree) { $gateArgs += "-RequireCleanWorktree" } if ($IncludeBackupDrill) { $gateArgs += "-IncludeBackupDrill" } $gateScript = Join-Path $repoRoot "scripts\run-production-release-gate.ps1" $gateLog = Join-Path $artifactDir "03-post-deploy-release-gate.log" $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $gateOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $gateScript @gateArgs 2>&1 $gateExit = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference } $gateText = ($gateOutput -join [Environment]::NewLine) $gateText | Set-Content -Encoding UTF8 -Path $gateLog $gateArtifact = $null $gateMatch = [regex]::Match($gateText, '([A-Z]:\\[^\r\n]+)$', [System.Text.RegularExpressions.RegexOptions]::Multiline) if ($gateMatch.Success) { $gateArtifact = $gateMatch.Groups[1].Value.Trim() } if ($gateExit -eq 0 -and $gateText -match "_WARN") { Add-Check -Key "release_gate" -Label "post-deploy release gate" -Status "warn" -Required $false -Detail "Release gate completed with warnings" -Meta @{ artifact_dir = $gateArtifact log = $gateLog } } elseif ($gateExit -eq 0) { Add-Check -Key "release_gate" -Label "post-deploy release gate" -Status "pass" -Detail "Release gate passed" -Meta @{ artifact_dir = $gateArtifact log = $gateLog } } else { Add-Check -Key "release_gate" -Label "post-deploy release gate" -Status "fail" -Detail "Release gate failed" -Meta @{ artifact_dir = $gateArtifact log = $gateLog } } } } $failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) $warningsList = @($checks | Where-Object { $_.status -eq "warn" }) $skippedList = @($checks | Where-Object { $_.status -eq "skip" }) $overallStatus = if ($failedRequired.Count -gt 0) { "FAIL" } elseif ($warningsList.Count -gt 0) { "WARN" } else { "PASS" } $summary = [ordered]@{ name = "phase7-frontend-docker-deploy" status = $overallStatus generated_at = (Get-Date).ToUniversalTime().ToString("o") artifact_dir = $artifactDir branch = $branch commit = $commit remote = $remoteUrl local_archive = $localArchive remote_archive = $remoteArchive remote_backup = $remoteBackupPath targets = [ordered]@{ server_host = $ServerHost remote_project_path = $RemoteProjectPath compose_file = $ComposeFile frontend_base_url = $FrontendBaseUrl backend_base_url = $BackendBaseUrl } options = [ordered]@{ allow_dirty_worktree = [bool]$AllowDirtyWorktree skip_release_gate = [bool]$SkipReleaseGate include_backup_drill = [bool]$IncludeBackupDrill keep_remote_archive = [bool]$KeepRemoteArchive } checks = $checks totals = [ordered]@{ total = $checks.Count failed_required = $failedRequired.Count warnings = $warningsList.Count skipped = $skippedList.Count } } Save-Json -Path $summaryJson -Value $summary $markdown = New-Object System.Collections.Generic.List[string] $markdown.Add("# Phase 7 Frontend Docker Deploy") | Out-Null $markdown.Add("") | Out-Null $markdown.Add("- Status: $overallStatus") | Out-Null $markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null $markdown.Add("- Branch: $branch") | Out-Null $markdown.Add("- Commit: $commit") | Out-Null $markdown.Add("- Artifact dir: $artifactDir") | Out-Null $markdown.Add("- Remote backup: $remoteBackupPath") | Out-Null $markdown.Add("") | Out-Null $markdown.Add("| Check | Required | Status | Detail |") | Out-Null $markdown.Add("|---|---:|---|---|") | Out-Null foreach ($check in $checks) { $markdown.Add("| $($check.label) | $($check.required) | $($check.status.ToUpperInvariant()) | $($check.detail) |") | Out-Null } $markdown.Add("") | Out-Null $markdown.Add("Artifacts:") | Out-Null $markdown.Add("") | Out-Null $markdown.Add(('- Local archive: `{0}`' -f $localArchive)) | Out-Null $markdown.Add('- Summary JSON: `frontend-docker-deploy-summary.json`') | Out-Null $markdown.Add('- Summary Markdown: `frontend-docker-deploy-summary.md`') | Out-Null foreach ($check in $checks) { if ($check.meta.log) { $markdown.Add(('- {0} log: `{1}`' -f $check.label, $check.meta.log)) | Out-Null } if ($check.meta.artifact_dir) { $markdown.Add(('- {0} artifact: `{1}`' -f $check.label, $check.meta.artifact_dir)) | Out-Null } } $markdown | Set-Content -Encoding UTF8 -Path $summaryMd Write-Host "PHASE7_FRONTEND_DOCKER_DEPLOY_$overallStatus $artifactDir" if ($overallStatus -eq "FAIL") { throw "Phase 7 frontend Docker deploy failed. See $summaryMd" }