327 lines
14 KiB
PowerShell
327 lines
14 KiB
PowerShell
param(
|
|
[string]$ServerHost = "47.94.178.212",
|
|
[string]$SshUser = "root",
|
|
[string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519",
|
|
[string]$RemoteProjectPath = "/opt/ai-contract-platform-frontend",
|
|
[string]$ComposeFile = "docker-compose.frontend.aliyun.yml",
|
|
[string]$RemoteBackupRoot = "/opt/ai-contract-platform-frontend/backups/frontend-aliyun-deploy",
|
|
[string]$RemoteRollbackRoot = "/opt/ai-contract-platform-frontend/backups/frontend-aliyun-rollback",
|
|
[string]$BackupPath = "",
|
|
[string]$NginxConfName = "contact.yuqei.com.conf",
|
|
[string]$BackendTunnelBaseUrl = "http://10.20.0.11:8001",
|
|
[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]$ListBackups,
|
|
[switch]$DryRun,
|
|
[switch]$AllowDirtyWorktree,
|
|
[switch]$SkipReleaseGate,
|
|
[switch]$SkipNginxReload
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
|
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss-fff"
|
|
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\frontend-aliyun-docker-rollback"
|
|
}
|
|
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
|
|
}
|
|
$artifactDir = Join-Path $ArtifactRoot $timestamp
|
|
$summaryJson = Join-Path $artifactDir "frontend-aliyun-docker-rollback-summary.json"
|
|
$summaryMd = Join-Path $artifactDir "frontend-aliyun-docker-rollback-summary.md"
|
|
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
|
|
|
|
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-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 Convert-KeyValueOutput {
|
|
param([string]$Text)
|
|
$data = @{}
|
|
foreach ($line in ($Text -split "`r?`n")) {
|
|
if ($line -match '^([A-Z0-9_]+)=(.*)$') {
|
|
$data[$matches[1]] = $matches[2]
|
|
}
|
|
}
|
|
return $data
|
|
}
|
|
|
|
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; rollback 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 development validation" -Meta @{ artifact = $gitStatusPath }
|
|
}
|
|
} else {
|
|
Add-Check -Key "working_tree" -Label "working tree" -Status "pass" -Detail "Working tree is clean" -Meta @{ artifact = $gitStatusPath }
|
|
}
|
|
|
|
$backupListScript = @"
|
|
set -euo pipefail
|
|
backup_root='$RemoteBackupRoot'
|
|
if ls "`$backup_root"/frontend-aliyun-before-*.tgz >/dev/null 2>&1; then
|
|
i=0
|
|
for f in `$(ls -1t "`$backup_root"/frontend-aliyun-before-*.tgz | head -20); do
|
|
i=`$((i+1))
|
|
size="`$(stat -c %s "`$f")"
|
|
epoch="`$(stat -c %Y "`$f")"
|
|
echo "BACKUP_ITEM=`$i,`$f,`$size,`$epoch"
|
|
done
|
|
echo "BACKUP_COUNT=`$i"
|
|
else
|
|
echo "BACKUP_COUNT=0"
|
|
fi
|
|
"@
|
|
|
|
$backupList = Invoke-Remote -Name "01-list-frontend-aliyun-backups" -Command $backupListScript
|
|
$backupData = Convert-KeyValueOutput -Text $backupList.output
|
|
$backupCount = if ($backupData.BACKUP_COUNT) { [int]$backupData.BACKUP_COUNT } else { 0 }
|
|
$availableBackups = @()
|
|
foreach ($line in ($backupList.output -split "`r?`n")) {
|
|
if ($line -match '^BACKUP_ITEM=(\d+),([^,]+),(\d+),(\d+)$') {
|
|
$availableBackups += [ordered]@{ index = [int]$matches[1]; path = $matches[2]; size = [int64]$matches[3]; epoch = [int64]$matches[4] }
|
|
}
|
|
}
|
|
$autoSelectedBackup = $null
|
|
if ($availableBackups.Count -gt 0) {
|
|
$autoSelectedBackup = ($availableBackups | Sort-Object index | Select-Object -First 1).path
|
|
}
|
|
if ($backupList.exit_code -eq 0 -and $backupCount -gt 0) {
|
|
Add-Check -Key "backup_list" -Label "frontend Aliyun backup list" -Status "pass" -Detail "Found $backupCount frontend Aliyun backup(s)" -Meta @{ selected_backup = $autoSelectedBackup; log = $backupList.log }
|
|
} else {
|
|
Add-Check -Key "backup_list" -Label "frontend Aliyun backup list" -Status "fail" -Detail "No frontend Aliyun backup is available for rollback" -Meta @{ log = $backupList.log }
|
|
}
|
|
|
|
if ($ListBackups) {
|
|
Add-Check -Key "rollback_action" -Label "rollback action" -Status "skip" -Required $false -Detail "Listed backups only; no remote mutation"
|
|
} else {
|
|
$failedBeforeRollback = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
|
if ($failedBeforeRollback.Count -eq 0) {
|
|
$dryRunFlag = if ($DryRun) { "true" } else { "false" }
|
|
$skipNginxFlag = if ($SkipNginxReload) { "true" } else { "false" }
|
|
$requestedBackup = if ([string]::IsNullOrWhiteSpace($BackupPath)) { $autoSelectedBackup } else { $BackupPath }
|
|
$rollbackScript = @"
|
|
set -euo pipefail
|
|
project='$RemoteProjectPath'
|
|
compose='$ComposeFile'
|
|
backup_root='$RemoteBackupRoot'
|
|
rollback_root='$RemoteRollbackRoot'
|
|
requested='$requestedBackup'
|
|
stamp='$timestamp'
|
|
dry_run='$dryRunFlag'
|
|
skip_nginx='$skipNginxFlag'
|
|
backend_base='$BackendTunnelBaseUrl'
|
|
nginx_conf='$NginxConfName'
|
|
cd "`$project"
|
|
test "`$PWD" = "`$project"
|
|
selected="`$requested"
|
|
case "`$selected" in
|
|
"`$backup_root"/frontend-aliyun-before-*.tgz) ;;
|
|
*) echo "ROLLBACK_SELECTED_BACKUP=`$selected"; echo "ROLLBACK_ERROR=selected backup is outside backup root"; exit 2 ;;
|
|
esac
|
|
test -s "`$selected"
|
|
echo "ROLLBACK_SELECTED_BACKUP=`$selected"
|
|
echo "ROLLBACK_SELECTED_BACKUP_SIZE=`$(stat -c %s "`$selected")"
|
|
if [ "`$dry_run" = "true" ]; then
|
|
tar -tzf "`$selected" >/dev/null
|
|
echo "ROLLBACK_DRY_RUN=1"
|
|
exit 0
|
|
fi
|
|
mkdir -p "`$rollback_root"
|
|
current_backup="`$rollback_root/frontend-aliyun-current-before-rollback-`$stamp.tgz"
|
|
tar -czf "`$current_backup" --ignore-failed-read -C "`$project" frontend "`$compose" infra -C /etc/nginx/conf.d "`$nginx_conf" 2>/dev/null || true
|
|
test -f "`$current_backup"
|
|
rm -rf frontend infra/nginx
|
|
mkdir -p "`$project"
|
|
tar -xzf "`$selected" -C "`$project"
|
|
test -f frontend/package.json
|
|
test -f "`$compose"
|
|
test -f "infra/nginx/`$nginx_conf"
|
|
BACKEND_BASE_URL="`$backend_base" docker compose -f "`$compose" build --no-cache frontend
|
|
BACKEND_BASE_URL="`$backend_base" docker compose -f "`$compose" up -d frontend
|
|
docker compose -f "`$compose" ps frontend
|
|
install -m 0644 "infra/nginx/`$nginx_conf" "/etc/nginx/conf.d/`$nginx_conf"
|
|
nginx -t
|
|
if [ "`$skip_nginx" != "true" ]; then systemctl reload nginx; fi
|
|
echo "ROLLBACK_CURRENT_BACKUP=`$current_backup"
|
|
echo "ROLLBACK_CURRENT_BACKUP_SIZE=`$(stat -c %s "`$current_backup")"
|
|
"@
|
|
$rollback = Invoke-Remote -Name "02-remote-frontend-aliyun-rollback" -Command $rollbackScript
|
|
$rollbackData = Convert-KeyValueOutput -Text $rollback.output
|
|
if ($rollback.exit_code -eq 0 -and $DryRun) {
|
|
Add-Check -Key "rollback_action" -Label "rollback action" -Status "pass" -Detail "Rollback dry run validated selected backup without remote mutation" -Meta @{ selected_backup = $rollbackData.ROLLBACK_SELECTED_BACKUP; log = $rollback.log }
|
|
} elseif ($rollback.exit_code -eq 0) {
|
|
Add-Check -Key "rollback_action" -Label "rollback action" -Status "pass" -Detail "Remote frontend Aliyun rollback, rebuild, restart and Nginx reload completed" -Meta @{ selected_backup = $rollbackData.ROLLBACK_SELECTED_BACKUP; current_backup = $rollbackData.ROLLBACK_CURRENT_BACKUP; log = $rollback.log }
|
|
} else {
|
|
Add-Check -Key "rollback_action" -Label "rollback action" -Status "fail" -Detail "Remote frontend Aliyun rollback failed" -Meta @{ exit_code = $rollback.exit_code; log = $rollback.log }
|
|
}
|
|
}
|
|
}
|
|
|
|
$failedBeforeGate = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
|
if ($ListBackups -or $DryRun) {
|
|
Add-Check -Key "release_gate" -Label "post-rollback release gate" -Status "skip" -Required $false -Detail "Skipped because no rollback mutation was performed"
|
|
} elseif ($SkipReleaseGate) {
|
|
Add-Check -Key "release_gate" -Label "post-rollback release gate" -Status "skip" -Required $false -Detail "Skipped by -SkipReleaseGate"
|
|
} elseif ($failedBeforeGate.Count -eq 0) {
|
|
$gateScript = Join-Path $repoRoot "scripts\run-production-release-gate.ps1"
|
|
$gateLog = Join-Path $artifactDir "03-post-rollback-release-gate.log"
|
|
$gateArgs = @(
|
|
"-ServerHost", "192.168.3.90",
|
|
"-FrontendBaseUrl", $FrontendBaseUrl,
|
|
"-BackendBaseUrl", $BackendBaseUrl,
|
|
"-ApiUrl", $ApiUrl,
|
|
"-HealthUrl", $HealthUrl,
|
|
"-ReadinessUrl", $ReadinessUrl,
|
|
"-ExpectedRemoteServices", "backend,legal-change-impact-worker,redis",
|
|
"-ArtifactRoot", (Join-Path $artifactDir "release-gate")
|
|
)
|
|
if (-not $AllowDirtyWorktree) { $gateArgs += "-RequireCleanWorktree" }
|
|
$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-rollback 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-rollback release gate" -Status "pass" -Detail "Release gate passed" -Meta @{ artifact_dir = $gateArtifact; log = $gateLog }
|
|
} else {
|
|
Add-Check -Key "release_gate" -Label "post-rollback 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 = "frontend-aliyun-docker-rollback"
|
|
status = $overallStatus
|
|
generated_at = (Get-Date).ToUniversalTime().ToString("o")
|
|
artifact_dir = $artifactDir
|
|
branch = $branch
|
|
commit = $commit
|
|
remote = $remoteUrl
|
|
targets = [ordered]@{
|
|
server_host = $ServerHost
|
|
remote_project_path = $RemoteProjectPath
|
|
compose_file = $ComposeFile
|
|
remote_backup_root = $RemoteBackupRoot
|
|
remote_rollback_root = $RemoteRollbackRoot
|
|
frontend_base_url = $FrontendBaseUrl
|
|
backend_base_url = $BackendBaseUrl
|
|
backend_tunnel_base_url = $BackendTunnelBaseUrl
|
|
}
|
|
options = [ordered]@{
|
|
backup_path = $BackupPath
|
|
list_backups = [bool]$ListBackups
|
|
dry_run = [bool]$DryRun
|
|
allow_dirty_worktree = [bool]$AllowDirtyWorktree
|
|
skip_release_gate = [bool]$SkipReleaseGate
|
|
skip_nginx_reload = [bool]$SkipNginxReload
|
|
}
|
|
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("# Frontend Aliyun Docker Rollback") | Out-Null
|
|
$markdown.Add("") | Out-Null
|
|
$markdown.Add("- Status: $overallStatus") | Out-Null
|
|
$markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null
|
|
$markdown.Add("- Frontend: $FrontendBaseUrl") | Out-Null
|
|
$markdown.Add("- Artifact dir: $artifactDir") | 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('- Summary JSON: `frontend-aliyun-docker-rollback-summary.json`') | Out-Null
|
|
$markdown.Add('- Summary Markdown: `frontend-aliyun-docker-rollback-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 "FRONTEND_ALIYUN_DOCKER_ROLLBACK_$overallStatus $artifactDir"
|
|
if ($overallStatus -eq "FAIL") {
|
|
throw "Frontend Aliyun Docker rollback failed. See $summaryMd"
|
|
}
|