chore: add production backup drill

This commit is contained in:
Codex
2026-05-01 08:42:06 +08:00
parent beb14828ca
commit 83eeb4f61e
5 changed files with 393 additions and 13 deletions
+27 -1
View File
@@ -70,7 +70,33 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\rotate-admin-pas
## 四、备份恢复口径
阶段 5 下一步将新增 `scripts/run-backup-drill.ps1`。当前原则先固定:
执行备份与恢复演练:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\run-backup-drill.ps1
```
默认远端备份目录:
```text
/opt/ai-contract-platform/backups/phase5/<timestamp>/
```
默认本地产物目录:
```text
project-docs/qa-artifacts/phase5-backup-drill/<timestamp>/
```
当前备份范围:
1. PostgreSQL custom-format dump:`postgres.dump`
2. PostgreSQL 隔离临时库恢复验证
3. 部署配置、迁移、脚本和文档压缩包:`project-config-docs-scripts.tgz`
4. MinIO `contracts` bucket 对象清单和镜像
5. `manifest.tsv` 与 `sha256sums.txt`
执行原则:
1. 生产备份可以读取生产数据。
2. 恢复验证必须在隔离目录或临时数据库执行。
@@ -18,7 +18,7 @@
阶段 1 已完成源码闭环与生产入口闭环,生产无人 E2E `18/18` 通过。阶段 2 已完成 Review 重试、Knowledge Base 文档重试、Review -> Chat -> Contract 上下文联动、首页交付工作台和阶段 2 自动化无人闭环,本地与生产 E2E 均 `7/7` 通过。阶段 3 已完成系统 readiness、试点交付准备页、默认账号说明、演示样本数据、依赖健康门禁和阶段 3 自动化无人闭环,本地与生产 E2E 均 `5/5` 通过。
当前后续重点为“生产运行、安全、监控、备份与依赖治理”:默认密码轮换已完成,阶段 5 Step 1 生产健康巡检自动化已完成并 PASS,下一步进入备份恢复演练。
当前后续重点为“生产运行、安全、监控、备份与依赖治理”:默认密码轮换已完成,阶段 5 Step 1 生产健康巡检自动化和 Step 2 备份恢复演练均已 PASS,下一步进入依赖治理专项。
---
@@ -210,4 +210,4 @@ AI合同平台当前已经不是从零建设阶段。登录、合同审查、Rev
## 六、当前一句话结论
AI合同平台阶段 1、阶段 2 和阶段 3 均已完成闭环交付验证,阶段 4 已完成默认管理员密码轮换闭环,阶段 5 已启动并完成生产健康巡检自动化,下一步进入备份恢复演练。
AI合同平台阶段 1、阶段 2 和阶段 3 均已完成闭环交付验证,阶段 4 已完成默认管理员密码轮换闭环,阶段 5 已启动并完成生产健康巡检与备份恢复演练,下一步进入依赖治理专项。
@@ -11,12 +11,12 @@
| gstack 安装校验 | PASS | 自动化执行前已返回 `GSTACK_OK` |
| 代码提交与推送 | PASS | 已推送到 `origin/main`,远端为 `192.168.3.90` 仓库 |
| Step 1:生产健康巡检自动化 | PASS | `scripts/run-production-health-check.ps1` 已实现并完成生产巡检 |
| Step 2:备份与恢复演练 | PENDING | 下一步进入 |
| Step 3:依赖治理专项 | PENDING | 待备份基线后处理 |
| Step 2:备份与恢复演练 | PASS | `scripts/run-backup-drill.ps1` 已实现并完成生产备份/隔离恢复验证 |
| Step 3:依赖治理专项 | PENDING | 下一步进入 |
| Step 4:安全基线检查 | PENDING | 将复用 Step 1 readiness 与默认密码验证 |
| Step 5:生产回归闭环 | PENDING | 待运维加固动作后复跑阶段 1/2/3 |
阶段 5 Step 1 当前判定:PASS。
阶段 5 当前判定:Step 1、Step 2 均已 PASS。
---
@@ -86,12 +86,60 @@ default-password-login.json
## 五、下一步
阶段 5 下一步进入 Step 2:备份与恢复演练。
阶段 5 下一步进入 Step 3:依赖治理专项。
优先交付:
1. `scripts/run-backup-drill.ps1`
2. PostgreSQL 备份文件生成和校验
3. 部署配置、脚本、文档备份
4. 隔离恢复可用性验证
5. 备份恢复演练记录
1. `npm audit --json` 风险记录
2. high/critical 漏洞清零验证
3. moderate 漏洞修复或延期说明
4. 后端 `pip check` 依赖一致性验证
5. 依赖治理执行记录
---
## 六、Step 2 备份与恢复演练
新增脚本:
```powershell
scripts\run-backup-drill.ps1
```
本次演练产物:
| 项目 | 路径 |
|---|---|
| 本地产物目录 | `project-docs/qa-artifacts/phase5-backup-drill/2026-05-01T08-40-59/` |
| 远端备份目录 | `/opt/ai-contract-platform/backups/phase5/2026-05-01T08-40-59` |
| 数据库备份 | `/opt/ai-contract-platform/backups/phase5/2026-05-01T08-40-59/postgres.dump` |
| 配置/文档/脚本备份 | `/opt/ai-contract-platform/backups/phase5/2026-05-01T08-40-59/project-config-docs-scripts.tgz` |
| 对象存储镜像 | `/opt/ai-contract-platform/backups/phase5/2026-05-01T08-40-59/minio-contracts` |
本次验证结果:
| 检查项 | 结果 | 说明 |
|---|---|---|
| 远端备份工具预检 | PASS | `docker compose`、`pg_dump`、`pg_restore`、`tar`、`mc` 可用 |
| PostgreSQL 备份 | PASS | 生成 custom-format dump |
| PostgreSQL 隔离恢复 | PASS | 恢复到临时库 `restore_check_2026_05_01T08_40_59` 并验证 schema 后删除 |
| 配置/文档/脚本备份 | PASS | 生成 `project-config-docs-scripts.tgz` |
| MinIO 对象存储备份 | PASS | 生成对象清单并镜像 `contracts` bucket,当前对象数 `1` |
| 备份清单与校验和 | PASS | 生成 `manifest.tsv` 与 `sha256sums.txt` |
远端备份清单:
```text
./manifest.tsv 0
./minio-objects.txt 114
./postgres.dump 127859
./project-config-docs-scripts.tgz 159067
./restore-schema.sql 18284
```
核心校验和:
```text
f6598a0d3b98fc1b5e6520955b1f54b20617bad61bf2628795b6a7b2ae6c161e postgres.dump
8c831b371aac6b3c4c98074eb6df03e78ef4131c14ef8dd1cd7c3fd8189d033f project-config-docs-scripts.tgz
```
@@ -177,7 +177,10 @@ $env:AI_CONTRACT_ADMIN_PASSWORD = '<管理员实际密码>'
- Step 1 已完成:`scripts/run-production-health-check.ps1`
- 生产巡检结果:PASS
- 巡检产物:`project-docs/qa-artifacts/phase5-production-health/2026-05-01T08-32-50/`
- 下一步:Step 2 备份与恢复演练
- Step 2 已完成:`scripts/run-backup-drill.ps1`
- 备份恢复演练结果:PASS
- 备份演练产物:`project-docs/qa-artifacts/phase5-backup-drill/2026-05-01T08-40-59/`
- 下一步:Step 3 依赖治理专项
---
+303
View File
@@ -0,0 +1,303 @@
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/phase5",
[string]$DatabaseName = "ai_contract_platform",
[string]$PostgresUser = "postgres",
[string]$MinioEndpoint = "http://192.168.3.90:9000",
[string]$MinioAccessKey = "minioadmin",
[string]$MinioSecretKey = "minioadmin",
[string]$MinioBucket = "contracts",
[string]$ArtifactRoot = ""
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
$safeStamp = $timestamp -replace '[^0-9A-Za-z]', '_'
$restoreDbName = "restore_check_$safeStamp"
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase5-backup-drill"
}
$artifactDir = Join-Path $ArtifactRoot $timestamp
$remoteBackupDir = "$RemoteBackupRoot/$timestamp"
$summaryJson = Join-Path $artifactDir "backup-drill-summary.json"
$summaryMd = Join-Path $artifactDir "backup-drill-summary.md"
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) {
throw "gstack is required for all AI-assisted work in this repo."
}
if (-not (Test-Path $SshKeyPath)) {
throw "SSH key not found: $SshKeyPath"
}
$checks = New-Object System.Collections.Generic.List[object]
function Add-Check {
param(
[string]$Key,
[string]$Label,
[ValidateSet("pass", "fail", "warn")]
[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-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-ScpFromRemote {
param(
[string]$RemotePath,
[string]$LocalPath
)
$target = "${SshUser}@${ServerHost}:$RemotePath"
$args = @(
"-i", $SshKeyPath,
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=10",
$target,
$LocalPath
)
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & scp @args 2>&1
$exitCode = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
return [ordered]@{
exit_code = $exitCode
output = ($output -join [Environment]::NewLine)
}
}
$preflightCommand = "set -e; mkdir -p '$remoteBackupDir'; cd '$RemoteProjectPath'; docker compose -f '$ComposeFile' ps; docker compose -f '$ComposeFile' exec -T postgres pg_dump --version; docker compose -f '$ComposeFile' exec -T postgres pg_restore --version; command -v tar; command -v mc"
$preflight = Invoke-Remote -Name "01-preflight" -Command $preflightCommand
if ($preflight.exit_code -eq 0) {
Add-Check -Key "remote_preflight" -Label "Remote backup prerequisites" -Status "pass" -Detail "Docker compose, pg_dump, pg_restore, tar and mc are available" -Meta @{
log = $preflight.log
}
} else {
Add-Check -Key "remote_preflight" -Label "Remote backup prerequisites" -Status "fail" -Detail "Remote prerequisites failed" -Meta @{
log = $preflight.log
}
}
$dumpPath = "$remoteBackupDir/postgres.dump"
$dumpCommand = "set -e; cd '$RemoteProjectPath'; docker compose -f '$ComposeFile' exec -T postgres pg_dump -U '$PostgresUser' -d '$DatabaseName' -Fc > '$dumpPath'; test -s '$dumpPath'; ls -lh '$dumpPath'"
$dump = Invoke-Remote -Name "02-postgres-dump" -Command $dumpCommand
if ($dump.exit_code -eq 0) {
Add-Check -Key "postgres_dump" -Label "PostgreSQL backup" -Status "pass" -Detail "Custom-format PostgreSQL dump created on remote host" -Meta @{
remote_path = $dumpPath
log = $dump.log
}
} else {
Add-Check -Key "postgres_dump" -Label "PostgreSQL backup" -Status "fail" -Detail "PostgreSQL dump failed" -Meta @{
remote_path = $dumpPath
log = $dump.log
}
}
$restoreCommand = @"
set -e
cd '$RemoteProjectPath'
restore_db='$restoreDbName'
dump_path='$dumpPath'
trap "docker compose -f '$ComposeFile' exec -T postgres dropdb -U '$PostgresUser' --if-exists \"`$restore_db\" >/dev/null 2>&1 || true" EXIT
docker compose -f '$ComposeFile' exec -T postgres dropdb -U '$PostgresUser' --if-exists "`$restore_db"
docker compose -f '$ComposeFile' exec -T postgres createdb -U '$PostgresUser' "`$restore_db"
cat "`$dump_path" | docker compose -f '$ComposeFile' exec -T postgres pg_restore -U '$PostgresUser' -d "`$restore_db" --no-owner
docker compose -f '$ComposeFile' exec -T postgres pg_dump -U '$PostgresUser' -d "`$restore_db" -s > '$remoteBackupDir/restore-schema.sql'
grep -q "CREATE TABLE" '$remoteBackupDir/restore-schema.sql'
docker compose -f '$ComposeFile' exec -T postgres dropdb -U '$PostgresUser' "`$restore_db"
trap - EXIT
echo "restore_schema_verified=true"
"@ -replace "`r?`n", "; "
$restore = Invoke-Remote -Name "03-postgres-restore-check" -Command $restoreCommand
if ($restore.exit_code -eq 0) {
Add-Check -Key "postgres_restore_check" -Label "PostgreSQL restore drill" -Status "pass" -Detail "Dump restored into isolated temporary database and schema was verified" -Meta @{
restore_db = $restoreDbName
log = $restore.log
}
} else {
Add-Check -Key "postgres_restore_check" -Label "PostgreSQL restore drill" -Status "fail" -Detail "Restore check failed; temporary database name is recorded for cleanup review" -Meta @{
restore_db = $restoreDbName
log = $restore.log
}
}
$configArchivePath = "$remoteBackupDir/project-config-docs-scripts.tgz"
$configCommand = "set -e; cd '$RemoteProjectPath'; tar --exclude='project-docs/qa-artifacts' --exclude='backups' -czf '$configArchivePath' docker-compose.server.yml backend/Dockerfile backend/requirements.txt backend/alembic.ini backend/migrations frontend/Dockerfile frontend/package.json frontend/package-lock.json scripts project-docs; test -s '$configArchivePath'; ls -lh '$configArchivePath'"
$configBackup = Invoke-Remote -Name "04-config-docs-scripts-backup" -Command $configCommand
if ($configBackup.exit_code -eq 0) {
Add-Check -Key "config_docs_scripts_backup" -Label "Config docs scripts backup" -Status "pass" -Detail "Deployment config, docs and scripts archive created" -Meta @{
remote_path = $configArchivePath
log = $configBackup.log
}
} else {
Add-Check -Key "config_docs_scripts_backup" -Label "Config docs scripts backup" -Status "fail" -Detail "Config/docs/scripts backup failed" -Meta @{
remote_path = $configArchivePath
log = $configBackup.log
}
}
$minioDir = "$remoteBackupDir/minio-$MinioBucket"
$minioCommand = @"
set -e
mkdir -p '$minioDir'
mc alias set phase5 '$MinioEndpoint' '$MinioAccessKey' '$MinioSecretKey' >/dev/null
mc ls --recursive "phase5/$MinioBucket" > '$remoteBackupDir/minio-objects.txt'
mc mirror --overwrite "phase5/$MinioBucket" '$minioDir'
object_count=`$(find '$minioDir' -type f | wc -l)
echo "object_count=`$object_count"
du -sh '$minioDir' || true
"@ -replace "`r?`n", "; "
$minioBackup = Invoke-Remote -Name "05-minio-backup" -Command $minioCommand
if ($minioBackup.exit_code -eq 0) {
Add-Check -Key "object_storage_backup" -Label "Object storage backup" -Status "pass" -Detail "MinIO object list and mirror completed" -Meta @{
remote_path = $minioDir
log = $minioBackup.log
}
} else {
Add-Check -Key "object_storage_backup" -Label "Object storage backup" -Status "fail" -Detail "MinIO object list or mirror failed" -Meta @{
remote_path = $minioDir
log = $minioBackup.log
}
}
$manifestCommand = "set -e; cd '$remoteBackupDir'; find . -type f -maxdepth 4 -printf '%p`t%s`n' | sort > manifest.tsv; sha256sum postgres.dump project-config-docs-scripts.tgz > sha256sums.txt; cat manifest.tsv; cat sha256sums.txt"
$manifest = Invoke-Remote -Name "06-manifest" -Command $manifestCommand
if ($manifest.exit_code -eq 0) {
Add-Check -Key "backup_manifest" -Label "Backup manifest" -Status "pass" -Detail "Manifest and sha256 sums generated" -Meta @{
remote_path = "$remoteBackupDir/manifest.tsv"
log = $manifest.log
}
} else {
Add-Check -Key "backup_manifest" -Label "Backup manifest" -Status "fail" -Detail "Manifest generation failed" -Meta @{
remote_path = "$remoteBackupDir/manifest.tsv"
log = $manifest.log
}
}
foreach ($remoteFile in @("manifest.tsv", "sha256sums.txt", "minio-objects.txt")) {
$localFile = Join-Path $artifactDir $remoteFile
$scpResult = Invoke-ScpFromRemote -RemotePath "$remoteBackupDir/$remoteFile" -LocalPath $localFile
$scpLogPath = Join-Path $artifactDir "scp-$remoteFile.log"
$scpResult.output | Set-Content -Encoding UTF8 -Path $scpLogPath
}
$failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
$warningsList = @($checks | Where-Object { $_.status -eq "warn" })
$overallStatus = if ($failedRequired.Count -gt 0) { "FAIL" } elseif ($warningsList.Count -gt 0) { "WARN" } else { "PASS" }
$summary = [ordered]@{
name = "phase5-backup-drill"
status = $overallStatus
generated_at = (Get-Date).ToUniversalTime().ToString("o")
artifact_dir = $artifactDir
remote_backup_dir = $remoteBackupDir
restore_database = $restoreDbName
targets = [ordered]@{
server_host = $ServerHost
remote_project_path = $RemoteProjectPath
compose_file = $ComposeFile
database = $DatabaseName
minio_endpoint = $MinioEndpoint
minio_bucket = $MinioBucket
}
checks = $checks
totals = [ordered]@{
total = $checks.Count
failed_required = $failedRequired.Count
warnings = $warningsList.Count
}
}
Save-Json -Path $summaryJson -Value $summary
$markdown = New-Object System.Collections.Generic.List[string]
$markdown.Add("# Phase 5 Backup Drill") | Out-Null
$markdown.Add("") | Out-Null
$markdown.Add("- Status: $overallStatus") | Out-Null
$markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null
$markdown.Add("- Remote backup dir: $remoteBackupDir") | Out-Null
$markdown.Add("- Local artifact dir: $artifactDir") | Out-Null
$markdown.Add("- Restore database: $restoreDbName") | 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("Local artifacts:") | Out-Null
$markdown.Add("") | Out-Null
$markdown.Add('- `backup-drill-summary.json`') | Out-Null
$markdown.Add('- `manifest.tsv`') | Out-Null
$markdown.Add('- `sha256sums.txt`') | Out-Null
$markdown.Add('- `minio-objects.txt`') | Out-Null
$markdown.Add('- `*.log`') | Out-Null
$markdown | Set-Content -Encoding UTF8 -Path $summaryMd
Write-Host "PHASE5_BACKUP_DRILL_$overallStatus $artifactDir"
if ($overallStatus -eq "FAIL") {
throw "Phase 5 backup drill failed. See $summaryMd"
}