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-backend-deploy", [string]$FrontendBaseUrl = "https://contact.yuqei.com", [string]$BackendBaseUrl = "http://192.168.3.90:8001", [string]$ApiUrl = "https://contact.yuqei.com/api/v1", [string]$HealthUrl = "http://192.168.3.90:8001/health", [string]$ReadinessUrl = "https://contact.yuqei.com/api/v1/system/readiness", [string]$ArtifactRoot = "", [switch]$AllowDirtyWorktree, [switch]$SkipReleaseGate, [switch]$IncludeBackupDrill, [switch]$KeepRemoteArchive, [switch]$UseSshpass ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss" $archiveName = "ai-contract-backend-$timestamp.tgz" if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { $ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase7-backend-docker-deploy" } if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) { $ArtifactRoot = Join-Path $repoRoot $ArtifactRoot } $artifactDir = Join-Path $ArtifactRoot $timestamp $summaryJson = Join-Path $artifactDir "backend-docker-deploy-summary.json" $summaryMd = Join-Path $artifactDir "backend-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] $script:SshpassPath = $null 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 ConvertTo-ShellSingleQuote { param([string]$Value) return "'" + ($Value -replace "'", "'`"`"`'") + "'" } function Invoke-Remote { param( [string]$Name, [string]$Command ) $target = "${SshUser}@${ServerHost}" $logPath = Join-Path $artifactDir "$Name.log" $normalizedCommand = ($Command -replace "`r`n", "`n").TrimStart([char]0xFEFF) $encodedCommand = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($normalizedCommand)) $decodeCommand = "set -o pipefail; echo $encodedCommand | base64 -d | bash -se" $remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuote $decodeCommand) if ($script:SshpassPath) { $filePath = $script:SshpassPath $args = @( "-e", "ssh", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $remoteCommand ) } else { $filePath = "ssh" $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $target, $remoteCommand ) } $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & $filePath @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-backend-archive.log" if ($script:SshpassPath) { $filePath = $script:SshpassPath $args = @( "-e", "scp", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $LocalPath, $target ) } else { $filePath = "scp" $args = @( "-i", $SshKeyPath, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10", $LocalPath, $target ) } $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & $filePath @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 } } function Resolve-SshpassPath { $command = Get-Command sshpass -ErrorAction SilentlyContinue if ($command) { return $command.Source } $candidates = @( (Join-Path ${env:ProgramFiles} "Git\usr\bin\sshpass.exe"), (Join-Path ${env:ProgramFiles(x86)} "Git\usr\bin\sshpass.exe"), "C:\Program Files\Git\usr\bin\sshpass.exe", "C:\Program Files (x86)\Git\usr\bin\sshpass.exe" ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } foreach ($candidate in $candidates) { if (Test-Path -LiteralPath $candidate -ErrorAction SilentlyContinue) { return $candidate } } return $null } function Test-ReadablePath { param([string]$Path) try { return (Test-Path -LiteralPath $Path -ErrorAction Stop) } catch { return $false } } if ($UseSshpass -or -not [string]::IsNullOrWhiteSpace($env:SSHPASS)) { $script:SshpassPath = Resolve-SshpassPath if ([string]::IsNullOrWhiteSpace($script:SshpassPath)) { Add-Check -Key "ssh_auth" -Label "SSH authentication" -Status "fail" -Detail "sshpass not found; install Git for Windows sshpass or use SSH key" } elseif ([string]::IsNullOrWhiteSpace($env:SSHPASS)) { Add-Check -Key "ssh_auth" -Label "SSH authentication" -Status "fail" -Detail "SSHPASS must be set when -UseSshpass is used" } else { Add-Check -Key "ssh_auth" -Label "SSH authentication" -Status "pass" -Detail "using sshpass from environment variable" } } elseif (-not (Test-ReadablePath -Path $SshKeyPath)) { Add-Check -Key "ssh_key" -Label "SSH key" -Status "fail" -Detail "SSH key not found or not readable: $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 "warn" -Required $false -Detail "origin does not point to 192.168.3.90; local archive deployment can continue" -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-backend-archive" -Script { Push-Location $repoRoot try { & tar -czf $localArchive ` backend/app ` backend/migrations ` backend/tests ` backend/tools ` backend/vendor ` backend/.dockerignore ` backend/alembic.ini ` backend/Dockerfile ` backend/pytest.ini ` backend/README.md ` backend/requirements.txt 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 "backend archive" -Status "pass" -Detail "Backend archive created" -Meta @{ archive = $localArchive bytes = (Get-Item $localArchive).Length log = $archive.log } } else { Add-Check -Key "archive" -Label "backend archive" -Status "fail" -Detail "Backend 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 "Backend archive uploaded to remote host" -Meta @{ remote_archive = $remoteArchive log = $upload.log } } else { Add-Check -Key "upload" -Label "upload archive" -Status "fail" -Detail "Backend archive upload failed" -Meta @{ remote_archive = $remoteArchive log = $upload.log } } } $remoteBackupPath = "$RemoteBackupRoot/backend-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" if grep -Eq '(replace-with-|change-me|your-)' '.env'; then placeholder_keys="`$(grep -E '(replace-with-|change-me|your-)' '.env' | sed 's/=.*//' | tr '\n' ',' | sed 's/,$//')" echo "REMOTE_ENV_PLACEHOLDER_DETECTED=`$placeholder_keys" exit 3 fi test -d "backend" test -s "`$archive" mkdir -p "`$backup_root" backup="`$backup_root/backend-before-`$stamp.tgz" tar -czf "`$backup" backend test -s "`$backup" rm -rf backend mkdir -p backend tar -xzf "`$archive" --strip-components=1 -C backend test -f backend/Dockerfile test -f backend/app/main.py docker compose -f "`$compose" build backend legal-change-impact-worker docker compose -f "`$compose" up -d backend legal-change-impact-worker docker compose -f "`$compose" ps backend legal-change-impact-worker redis if [ "`$keep_archive" != "true" ]; then rm -f "`$archive"; fi echo "REMOTE_BACKEND_BACKUP=`$backup" "@ $remoteDeploy = Invoke-Remote -Name "02-remote-backend-docker-deploy" -Command $remoteScript if ($remoteDeploy.exit_code -eq 0) { Add-Check -Key "remote_deploy" -Label "remote Docker deploy" -Status "pass" -Detail "Remote backend 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 backend 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-backend-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 use_sshpass = [bool]$UseSshpass } 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 Backend 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: `backend-docker-deploy-summary.json`') | Out-Null $markdown.Add('- Summary Markdown: `backend-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_BACKEND_DOCKER_DEPLOY_$overallStatus $artifactDir" if ($overallStatus -eq "FAIL") { throw "Phase 7 backend Docker deploy failed. See $summaryMd" }