From b295d18fec0913170a2afe772e952f56e92d9052 Mon Sep 17 00:00:00 2001 From: yuqei Date: Sat, 16 May 2026 12:15:40 +0800 Subject: [PATCH] feat: add backend docker deploy path --- ...助手-正式生产部署演练与上线检查清单-2026-05-16.md | 11 + .../deployment-package.md | 15 +- scripts/deploy-backend-docker.ps1 | 426 ++++++++++++++++++ .../run-production-deployment-rehearsal.ps1 | 1 + 4 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 scripts/deploy-backend-docker.ps1 diff --git a/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md b/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md index 49d44b70..96d58a34 100644 --- a/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md +++ b/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md @@ -118,6 +118,17 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\run-production-deplo | 9 | 生产回归 | Phase 1/2/3 关键业务回归通过 | | 10 | 回滚路径 | `rollback-frontend-docker.ps1` 可用,最近有回滚证据或演练计划 | +## 部署顺序建议 + +当本次改动同时包含前端页面和后端 API 字段时,建议先部署后端,再部署前端: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-backend-docker.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-frontend-docker.ps1 +``` + +如果只发布前端,而前端依赖后端新字段,生产回归可能在浏览器阶段失败。此时应优先补齐后端部署,再复跑生产发布门禁。 + ## 当前已确认的证据 | 证据 | 路径 | diff --git a/project-docs/phase10-delivery-package/deployment-package.md b/project-docs/phase10-delivery-package/deployment-package.md index 812e4373..2c935705 100644 --- a/project-docs/phase10-delivery-package/deployment-package.md +++ b/project-docs/phase10-delivery-package/deployment-package.md @@ -20,6 +20,12 @@ One-click frontend Docker release: powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-frontend-docker.ps1 ``` +One-click backend Docker release: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-backend-docker.ps1 +``` + Post-release gate: ```powershell @@ -35,10 +41,11 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\rollback-fronten ## Required Evidence Before Handover 1. `PHASE6_RELEASE_GATE_PASS` or a newer release gate with failed required count `0`. -2. Frontend Docker deploy evidence under `project-docs/qa-artifacts/phase7-frontend-docker-deploy/`. -3. Frontend Docker rollback evidence under `project-docs/qa-artifacts/phase7-frontend-docker-rollback/`. -4. Production watchdog evidence under `project-docs/qa-artifacts/phase7-production-watchdog/`. -5. Business quality gate evidence under `project-docs/qa-artifacts/phase9-business-quality-gate/`. +2. Backend Docker deploy evidence under `project-docs/qa-artifacts/phase7-backend-docker-deploy/` when backend API contracts changed. +3. Frontend Docker deploy evidence under `project-docs/qa-artifacts/phase7-frontend-docker-deploy/`. +4. Frontend Docker rollback evidence under `project-docs/qa-artifacts/phase7-frontend-docker-rollback/`. +5. Production watchdog evidence under `project-docs/qa-artifacts/phase7-production-watchdog/`. +6. Business quality gate evidence under `project-docs/qa-artifacts/phase9-business-quality-gate/`. ## Upgrade Rule diff --git a/scripts/deploy-backend-docker.ps1 b/scripts/deploy-backend-docker.ps1 new file mode 100644 index 00000000..b031fced --- /dev/null +++ b/scripts/deploy-backend-docker.ps1 @@ -0,0 +1,426 @@ +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 = "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-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] + +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-backend-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-backend-archive" -Script { + Push-Location $repoRoot + try { + & tar -czf $localArchive ` + --exclude=backend/.venv ` + --exclude=backend/.pytest_cache ` + --exclude=backend/__pycache__ ` + --exclude=backend/app/__pycache__ ` + --exclude=backend/tests/__pycache__ ` + backend + 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" +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 --no-cache backend +docker compose -f "`$compose" up -d backend +docker compose -f "`$compose" ps backend frontend postgres redis +if [ "`$keep_archive" != "true" ]; then rm -f "`$archive"; fi +echo "REMOTE_BACKEND_BACKUP=`$backup" +"@ -replace "`r?`n", "; " + $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 + } + 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" +} diff --git a/scripts/run-production-deployment-rehearsal.ps1 b/scripts/run-production-deployment-rehearsal.ps1 index 1f1f4550..43b8da3f 100644 --- a/scripts/run-production-deployment-rehearsal.ps1 +++ b/scripts/run-production-deployment-rehearsal.ps1 @@ -200,6 +200,7 @@ $requiredScripts = @( "run-production-health-check.ps1", "run-production-regression.ps1", "run-backup-drill.ps1", + "deploy-backend-docker.ps1", "deploy-frontend-docker.ps1", "rollback-frontend-docker.ps1" )