528 lines
22 KiB
PowerShell
528 lines
22 KiB
PowerShell
param(
|
|
[string]$ServerHost = "47.94.178.212",
|
|
[string]$SshUser = "root",
|
|
[string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519",
|
|
[string]$RemoteProjectPath = "/opt/apps/ai-contract-web",
|
|
[string]$RemoteBackupRoot = "/opt/apps/ai-contract-web/backups/frontend-aliyun-deploy",
|
|
[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]$AllowDirtyWorktree,
|
|
[switch]$SkipReleaseGate,
|
|
[switch]$SkipNginxReload,
|
|
[switch]$KeepRemoteArchive,
|
|
[switch]$SkipLocalBuild,
|
|
[switch]$UseSshpass
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
|
$timestamp = Get-Date -Format "yyyyMMddHHmmss"
|
|
$archiveName = "ai-contract-web-release-$timestamp.tgz"
|
|
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\frontend-aliyun-docker-deploy"
|
|
}
|
|
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
|
|
}
|
|
$artifactDir = Join-Path $ArtifactRoot $timestamp
|
|
$summaryJson = Join-Path $artifactDir "frontend-aliyun-docker-deploy-summary.json"
|
|
$summaryMd = Join-Path $artifactDir "frontend-aliyun-docker-deploy-summary.md"
|
|
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
|
|
$releaseRoot = Join-Path $artifactDir "release"
|
|
$releaseApp = Join-Path $releaseRoot "app"
|
|
$releaseNginx = Join-Path $releaseRoot "nginx"
|
|
$releaseMainNginx = Join-Path $releaseRoot "main-nginx"
|
|
$localArchive = Join-Path $artifactDir $archiveName
|
|
$remoteArchive = "/tmp/$archiveName"
|
|
$remoteReleasePath = "$RemoteProjectPath/releases/$timestamp"
|
|
$remoteBackupPath = "$RemoteBackupRoot/frontend-aliyun-before-$timestamp.tgz"
|
|
|
|
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
|
|
}
|
|
$outputText = ($output -join [Environment]::NewLine)
|
|
$outputText | Set-Content -Encoding UTF8 -Path $logPath
|
|
return [ordered]@{ exit_code = $exitCode; log = $logPath; output = $outputText }
|
|
}
|
|
|
|
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-frontend-aliyun-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
|
|
}
|
|
}
|
|
|
|
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 ($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 }
|
|
}
|
|
|
|
$requiredFiles = @(
|
|
(Join-Path $repoRoot "frontend\package.json"),
|
|
(Join-Path $repoRoot "frontend\package-lock.json"),
|
|
(Join-Path $repoRoot "deploy\docker-compose.yml"),
|
|
(Join-Path $repoRoot "deploy\web-gateway.conf"),
|
|
(Join-Path $repoRoot "infra\nginx\$NginxConfName")
|
|
)
|
|
$missingLocal = @($requiredFiles | Where-Object { -not (Test-Path $_) })
|
|
if ($missingLocal.Count -eq 0) {
|
|
Add-Check -Key "local_inputs" -Label "local deploy inputs" -Status "pass" -Detail "Frontend package, compose and Nginx configs are present"
|
|
} else {
|
|
Add-Check -Key "local_inputs" -Label "local deploy inputs" -Status "fail" -Detail "Missing local deploy inputs: $($missingLocal -join ', ')"
|
|
}
|
|
|
|
$failedPreflight = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
|
if ($failedPreflight.Count -eq 0 -and -not $SkipLocalBuild) {
|
|
$build = Invoke-LocalStep -Name "01-frontend-build" -Script {
|
|
Push-Location (Join-Path $repoRoot "frontend")
|
|
try {
|
|
$env:BACKEND_BASE_URL = $BackendTunnelBaseUrl
|
|
$env:NEXT_PUBLIC_API_BASE_URL = "/api/v1"
|
|
$env:NEXT_TELEMETRY_DISABLED = "1"
|
|
& npm run build
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
if ($build.exit_code -eq 0) {
|
|
Add-Check -Key "frontend_build" -Label "frontend production build" -Status "pass" -Detail "npm run build completed with production API settings" -Meta @{ log = $build.log }
|
|
} else {
|
|
Add-Check -Key "frontend_build" -Label "frontend production build" -Status "fail" -Detail "npm run build failed" -Meta @{ log = $build.log }
|
|
}
|
|
} elseif ($SkipLocalBuild) {
|
|
Add-Check -Key "frontend_build" -Label "frontend production build" -Status "skip" -Required $false -Detail "Skipped by -SkipLocalBuild; existing frontend/.next/standalone will be packaged"
|
|
}
|
|
|
|
$failedBeforePackage = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
|
if ($failedBeforePackage.Count -eq 0) {
|
|
$package = Invoke-LocalStep -Name "02-package-frontend-release" -Script {
|
|
$standaloneRoot = Join-Path $repoRoot "frontend\.next\standalone"
|
|
if (-not (Test-Path $standaloneRoot -PathType Container)) {
|
|
throw "Next standalone output not found: $standaloneRoot"
|
|
}
|
|
|
|
Remove-Item -Recurse -Force -LiteralPath $releaseRoot -ErrorAction SilentlyContinue
|
|
New-Item -ItemType Directory -Force -Path $releaseApp, $releaseNginx, $releaseMainNginx | Out-Null
|
|
Copy-Item -Recurse -Force -Path (Join-Path $standaloneRoot "*") -Destination $releaseApp
|
|
Copy-Item -Force -Path (Join-Path $repoRoot "frontend\package.json") -Destination (Join-Path $releaseApp "package.json")
|
|
Copy-Item -Force -Path (Join-Path $repoRoot "deploy\docker-compose.yml") -Destination (Join-Path $releaseRoot "docker-compose.yml")
|
|
Copy-Item -Force -Path (Join-Path $repoRoot "deploy\web-gateway.conf") -Destination (Join-Path $releaseNginx "web-gateway.conf")
|
|
Copy-Item -Force -Path (Join-Path $repoRoot "infra\nginx\$NginxConfName") -Destination (Join-Path $releaseMainNginx $NginxConfName)
|
|
@'
|
|
FROM node:22-alpine
|
|
ENV NODE_ENV=production
|
|
ENV HOSTNAME=0.0.0.0
|
|
ENV PORT=3000
|
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
WORKDIR /app
|
|
COPY . /app
|
|
EXPOSE 3000
|
|
CMD ["node", "server.js"]
|
|
'@ | Set-Content -Encoding ASCII -Path (Join-Path $releaseApp "Dockerfile")
|
|
|
|
Push-Location $releaseRoot
|
|
try {
|
|
& tar -czf $localArchive .
|
|
if ($LASTEXITCODE -eq 0 -and (Test-Path $localArchive)) {
|
|
Get-Item $localArchive | Select-Object FullName, Length
|
|
}
|
|
} finally {
|
|
Pop-Location
|
|
}
|
|
}
|
|
if ($package.exit_code -eq 0 -and (Test-Path $localArchive)) {
|
|
Add-Check -Key "archive" -Label "frontend Aliyun release archive" -Status "pass" -Detail "Standalone release archive created" -Meta @{ archive = $localArchive; bytes = (Get-Item $localArchive).Length; log = $package.log }
|
|
} else {
|
|
Add-Check -Key "archive" -Label "frontend Aliyun release archive" -Status "fail" -Detail "Standalone release archive creation failed" -Meta @{ log = $package.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 Aliyun release archive uploaded" -Meta @{ remote_archive = $remoteArchive; log = $upload.log }
|
|
} else {
|
|
Add-Check -Key "upload" -Label "upload archive" -Status "fail" -Detail "Frontend Aliyun release archive upload failed" -Meta @{ remote_archive = $remoteArchive; log = $upload.log }
|
|
}
|
|
}
|
|
|
|
$failedBeforeRemote = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
|
|
if ($failedBeforeRemote.Count -eq 0) {
|
|
$keepArchiveFlag = if ($KeepRemoteArchive) { "true" } else { "false" }
|
|
$skipNginxFlag = if ($SkipNginxReload) { "true" } else { "false" }
|
|
$remoteScript = @"
|
|
set -euo pipefail
|
|
project='$RemoteProjectPath'
|
|
archive='$remoteArchive'
|
|
backup_root='$RemoteBackupRoot'
|
|
release='$remoteReleasePath'
|
|
stamp='$timestamp'
|
|
nginx_conf='$NginxConfName'
|
|
backend_base='$BackendTunnelBaseUrl'
|
|
keep_archive='$keepArchiveFlag'
|
|
skip_nginx='$skipNginxFlag'
|
|
|
|
mkdir -p "`$project/releases" "`$backup_root" "`$project/logs/nginx"
|
|
current="`$project/current"
|
|
backup="`$backup_root/frontend-aliyun-before-`$stamp.tgz"
|
|
if [ -e "`$current" ]; then
|
|
resolved="`$(readlink -f "`$current" || true)"
|
|
if [ -n "`$resolved" ] && [ -d "`$resolved" ]; then
|
|
tar -czf "`$backup" -C "`$resolved" .
|
|
else
|
|
tar -czf "`$backup" --files-from /dev/null
|
|
fi
|
|
else
|
|
tar -czf "`$backup" --files-from /dev/null
|
|
fi
|
|
test -f "`$backup"
|
|
|
|
rm -rf "`$release"
|
|
mkdir -p "`$release"
|
|
tar -xzf "`$archive" -C "`$release"
|
|
test -f "`$release/docker-compose.yml"
|
|
test -f "`$release/app/server.js"
|
|
test -f "`$release/app/Dockerfile"
|
|
test -f "`$release/nginx/web-gateway.conf"
|
|
test -f "`$release/main-nginx/`$nginx_conf"
|
|
|
|
if grep -q 'ssl_certificate' "`$release/main-nginx/`$nginx_conf"; then
|
|
test -f "/etc/letsencrypt/live/contact.yuqei.com/fullchain.pem"
|
|
test -f "/etc/letsencrypt/live/contact.yuqei.com/privkey.pem"
|
|
fi
|
|
|
|
cd "`$release"
|
|
BACKEND_BASE_URL="`$backend_base" NEXT_PUBLIC_API_BASE_URL="/api/v1" docker compose -f docker-compose.yml build --no-cache ai-contract-web
|
|
ln -sfn "`$release" "`$current"
|
|
cd "`$current"
|
|
BACKEND_BASE_URL="`$backend_base" NEXT_PUBLIC_API_BASE_URL="/api/v1" docker compose -f docker-compose.yml up -d ai-contract-web ai-contract-web-gateway
|
|
docker compose -f docker-compose.yml ps ai-contract-web ai-contract-web-gateway
|
|
|
|
install -m 0644 "main-nginx/`$nginx_conf" "/etc/nginx/conf.d/`$nginx_conf"
|
|
nginx -t
|
|
if [ "`$skip_nginx" != "true" ]; then systemctl reload nginx; fi
|
|
if [ "`$keep_archive" != "true" ]; then rm -f "`$archive"; fi
|
|
|
|
echo "REMOTE_FRONTEND_RELEASE=`$release"
|
|
echo "REMOTE_FRONTEND_BACKUP=`$backup"
|
|
"@
|
|
$remoteDeploy = Invoke-Remote -Name "03-remote-frontend-aliyun-deploy" -Command $remoteScript
|
|
$remoteData = Convert-KeyValueOutput -Text $remoteDeploy.output
|
|
if ($remoteDeploy.exit_code -eq 0) {
|
|
Add-Check -Key "remote_deploy" -Label "remote Aliyun Docker deploy" -Status "pass" -Detail "Remote release extraction, Docker restart and Nginx reload completed" -Meta @{ remote_release = $remoteData.REMOTE_FRONTEND_RELEASE; remote_backup = $remoteData.REMOTE_FRONTEND_BACKUP; log = $remoteDeploy.log }
|
|
} else {
|
|
Add-Check -Key "remote_deploy" -Label "remote Aliyun Docker deploy" -Status "fail" -Detail "Remote frontend Aliyun Docker deploy failed" -Meta @{ remote_release = $remoteReleasePath; 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) {
|
|
$gateScript = Join-Path $repoRoot "scripts\run-production-release-gate.ps1"
|
|
$gateLog = Join-Path $artifactDir "04-post-deploy-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-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 = "frontend-aliyun-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_release = $remoteReleasePath
|
|
remote_backup = $remoteBackupPath
|
|
targets = [ordered]@{
|
|
server_host = $ServerHost
|
|
remote_project_path = $RemoteProjectPath
|
|
frontend_base_url = $FrontendBaseUrl
|
|
backend_base_url = $BackendBaseUrl
|
|
backend_tunnel_base_url = $BackendTunnelBaseUrl
|
|
api_url = $ApiUrl
|
|
health_url = $HealthUrl
|
|
readiness_url = $ReadinessUrl
|
|
}
|
|
options = [ordered]@{
|
|
allow_dirty_worktree = [bool]$AllowDirtyWorktree
|
|
skip_release_gate = [bool]$SkipReleaseGate
|
|
skip_nginx_reload = [bool]$SkipNginxReload
|
|
keep_remote_archive = [bool]$KeepRemoteArchive
|
|
skip_local_build = [bool]$SkipLocalBuild
|
|
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("# Frontend Aliyun 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("- Frontend: $FrontendBaseUrl") | Out-Null
|
|
$markdown.Add("- Backend tunnel: $BackendTunnelBaseUrl") | Out-Null
|
|
$markdown.Add("- Artifact dir: $artifactDir") | Out-Null
|
|
$markdown.Add("- Remote release: $remoteReleasePath") | 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-aliyun-docker-deploy-summary.json`') | Out-Null
|
|
$markdown.Add('- Summary Markdown: `frontend-aliyun-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 }
|
|
if ($check.meta.remote_release) { $markdown.Add(('- {0} remote release: `{1}`' -f $check.label, $check.meta.remote_release)) | Out-Null }
|
|
if ($check.meta.remote_backup) { $markdown.Add(('- {0} remote backup: `{1}`' -f $check.label, $check.meta.remote_backup)) | Out-Null }
|
|
}
|
|
$markdown | Set-Content -Encoding UTF8 -Path $summaryMd
|
|
|
|
Write-Host "FRONTEND_ALIYUN_DOCKER_DEPLOY_$overallStatus $artifactDir"
|
|
if ($overallStatus -eq "FAIL") {
|
|
throw "Frontend Aliyun Docker deploy failed. See $summaryMd"
|
|
}
|