flnxi-Wu/deploy-registry.ps1
2026-05-25 14:36:37 +08:00

1121 lines
42 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ================================================================
# FLNXI素材库 - 新Registry发布脚本
# 面向客户或内部测试环境的版本化 Registry 发布
# 流程:检查 -> 构建 -> 登录Registry -> 推送镜像 -> 远程部署
# ================================================================
param(
[ValidateSet("DeployApp", "PublishRelease", "PublishAgent", "SyncRuntime", "Status", "Menu")]
[string]$Action = "DeployApp",
[string]$Server = "",
[int]$SshPort = 18000,
[string]$Username = "root",
[SecureString]$Password = $null,
[string]$KeyPath = "",
[string]$PlinkHostKey = "",
[string]$RemotePath = "/root/deploy",
[string]$ComposeFile = "docker-compose.prod.yml",
[string]$ComposeService = "app",
[int]$AppPort = 4320,
[int]$RemoteHealthTimeoutSec = 120,
[switch]$NoRemoteDbBackup,
[switch]$NoRollback,
[string]$RegistryHost = "",
[ValidateSet("http", "https")]
[string]$RegistryScheme = "http",
[string]$RegistryUsername = "flnxi",
[SecureString]$RegistryPassword = $null,
[string]$RegistryNamespace = "flnxi",
[string]$ImageRepository = "material/flnxi-material",
[string]$TestImageRepository = "material-dev/flnxi-material",
[ValidateSet("test", "release")]
[string]$DeployChannel = "release",
[string]$LocalImageName = "flnxi-material",
[string]$Tag = "",
[string]$Version = "",
[string]$AgentRepository = "agent",
[string]$AgentContext = "flnxi-agent-go",
[switch]$NoLatest,
[switch]$PushLatest,
[switch]$NoVersionFileUpdate,
[string]$NextAuthUrl = "",
[string]$AppPublicUrl = "",
[switch]$SkipCheck,
[switch]$SkipBuild,
[switch]$SkipPush,
[switch]$SkipRemote,
[switch]$SkipRegistryLogin,
[switch]$UpdateRemoteEnv,
[switch]$NoUpdateRemoteEnv,
[switch]$FullComposeUp,
[switch]$NoCache,
[switch]$DryRun,
[switch]$Help
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
if ($PSVersionTable.PSVersion.Major -ge 6) {
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
}
} catch {
# 控制台编码设置失败不影响发布流程
}
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "[OK] $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host "[WARN] $Message" -ForegroundColor Yellow
}
function Write-Fail {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
}
function Show-Help {
Write-Host "FLNXI素材库 - 新Registry发布脚本" -ForegroundColor Green
Write-Host ""
Write-Host "常用示例:" -ForegroundColor White
Write-Host " `$registryPassword = Read-Host 'Registry密码' -AsSecureString" -ForegroundColor Yellow
Write-Host " `$sshPassword = Read-Host 'SSH密码' -AsSecureString" -ForegroundColor Yellow
Write-Host " .\deploy-registry.ps1 -Action DeployApp -DeployChannel release -Version 1.0.1 -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow
Write-Host " .\deploy-registry.ps1 -Action DeployApp -DeployChannel test -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow
Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword" -ForegroundColor Yellow
Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -PushLatest -RegistryPassword `$registryPassword" -ForegroundColor Yellow
Write-Host " .\deploy-registry.ps1 -Action Menu -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow
Write-Host ""
Write-Host "只构建和推送,不远程部署:" -ForegroundColor White
Write-Host " .\deploy-registry.ps1 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword -SkipRemote" -ForegroundColor Yellow
Write-Host ""
Write-Host "常用动作:" -ForegroundColor White
Write-Host " -Action DeployApp 构建、推送并部署素材库应用(默认)"
Write-Host " -Action PublishRelease 发布素材库正式版镜像,只推送不远程部署"
Write-Host " -Action PublishAgent 构建并推送 Go Agent 镜像"
Write-Host " -Action SyncRuntime 同步 Postgres / MinIO 等运行依赖镜像"
Write-Host " -Action Status 查看 Registry、本机 Docker、远程容器状态"
Write-Host " -Action Menu 打开交互菜单"
Write-Host ""
Write-Host "主要参数:" -ForegroundColor White
Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入"
Write-Host " -SshPort SSH端口,默认 18000"
Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入"
Write-Host " -RegistryUsername Registry账号,默认 flnxi"
Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入"
Write-Host " -ImageRepository 正式版镜像仓库路径,默认 material/flnxi-material"
Write-Host " -TestImageRepository 测试版镜像仓库路径,默认 material-dev/flnxi-material"
Write-Host " -DeployChannel DeployApp 使用的通道:test 或 release,默认 release;测试发布需显式写 test"
Write-Host " -Tag 镜像标签;test 通道未指定时自动生成 test-年月日-时分,release 通道优先使用 -Version 或 VERSION.txt"
Write-Host " -Version 正式版或 Agent 版本号"
Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)"
Write-Host " -AgentRepository Agent 镜像路径,默认 agent"
Write-Host " -RemotePath 远程部署目录,默认 /root/deploy"
Write-Host " -FullComposeUp 远程执行完整 docker compose up -d;默认只更新 app 服务"
Write-Host " -UpdateRemoteEnv 兼容旧参数;DeployApp 现在默认会把 APP_IMAGE 等写入远程 .env"
Write-Host " -NoUpdateRemoteEnv 只让本次 compose 命令临时生效,不持久写入远程 .env"
Write-Host " -NoRemoteDbBackup 跳过远程 PostgreSQL 备份;默认会在升级前尝试备份 Compose 管理的数据库"
Write-Host " -NoRollback 远程部署失败时不自动恢复旧 APP_IMAGE;默认启用失败回滚"
Write-Host " -RemoteHealthTimeoutSec 远程容器健康等待秒数,默认 120"
Write-Host " -DryRun 只打印将执行的动作,不实际执行"
}
function ConvertTo-PlainText {
param([SecureString]$SecureValue)
if (-not $SecureValue) {
return ""
}
$credential = New-Object System.Management.Automation.PSCredential("placeholder", $SecureValue)
return $credential.GetNetworkCredential().Password
}
function ConvertTo-ShellSingleQuoted {
param([string]$Value)
if ($null -eq $Value) {
$Value = ""
}
return "'" + ($Value -replace "'", "'\''") + "'"
}
function Assert-Tool {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "未找到必要工具:$Name"
}
}
function Assert-RegistryHostConfigured {
if ([string]::IsNullOrWhiteSpace($RegistryHost)) {
throw "请显式传入 -RegistryHost,例如:-RegistryHost registry.example.com:5000。脚本不再默认指向某台测试服务器。"
}
}
function Assert-RemoteServerConfigured {
if ([string]::IsNullOrWhiteSpace($Server)) {
throw "请显式传入 -Server,例如:-Server 192.168.1.20。脚本不再默认指向某台测试服务器。"
}
}
function Write-RegistryHttpNotice {
if ($RegistryScheme -eq "http") {
Write-Warn "当前 Registry 使用 HTTP:$RegistryHost。执行 docker login/pull/push 前,必须先在本机 Docker 和目标机 Docker 配置 insecure-registries。"
}
}
function Invoke-NativeCommand {
param(
[string]$FilePath,
[string[]]$Arguments,
[string]$Label
)
if ($Label) {
Write-Info $Label
}
if ($DryRun) {
Write-Host "[DRYRUN] $FilePath $($Arguments -join ' ')" -ForegroundColor DarkGray
return
}
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Label 失败,退出码:$LASTEXITCODE"
}
}
function Get-RegistryImage {
Assert-RegistryHostConfigured
if ($RegistryHost -match "^https?://") {
throw "RegistryHost 只写主机和端口,不要带 http:// 或 https://"
}
$repo = $ImageRepository.Trim("/")
return "$RegistryHost/$RegistryNamespace/$repo`:$Tag"
}
function Get-RegistryImageFor {
param(
[string]$Repository,
[string]$ImageTag
)
Assert-RegistryHostConfigured
if ($RegistryHost -match "^https?://") {
throw "RegistryHost 只写主机和端口,不要带 http:// 或 https://"
}
return "$RegistryHost/$RegistryNamespace/$($Repository.Trim('/'))`:$ImageTag"
}
function Get-DefaultTestTag {
return "test-$(Get-Date -Format 'yyyyMMdd-HHmm')"
}
function Resolve-DeployRepository {
if ($DeployChannel -eq "release") {
return $ImageRepository
}
return $TestImageRepository
}
function Resolve-DeployTag {
if (-not [string]::IsNullOrWhiteSpace($Tag)) {
return $Tag.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($Version)) {
return $Version.Trim()
}
if ($DeployChannel -eq "release") {
$currentVersion = Read-VersionFile -Path "VERSION.txt"
if (-not [string]::IsNullOrWhiteSpace($currentVersion)) {
return $currentVersion
}
throw "release 通道必须指定 -Tag / -Version,或在 VERSION.txt 中定义版本号"
}
return Get-DefaultTestTag
}
function Get-DefaultAppUrl {
if ([string]::IsNullOrWhiteSpace($Server)) {
return "http://localhost:3000"
}
return "http://${Server}:${AppPort}"
}
function Get-VersionOrPrompt {
param(
[string]$CurrentVersion,
[string]$Prompt
)
if (-not [string]::IsNullOrWhiteSpace($Version)) {
return $Version.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($CurrentVersion)) {
Write-Info "当前版本:$CurrentVersion"
}
$value = Read-Host $Prompt
if ([string]::IsNullOrWhiteSpace($value)) {
throw "版本号不能为空"
}
return $value.Trim()
}
function Read-VersionFile {
param([string]$Path)
if (Test-Path $Path) {
return (Get-Content -LiteralPath $Path -Raw).Trim()
}
return ""
}
function Write-VersionFileIfNeeded {
param(
[string]$Path,
[string]$Value
)
if ($NoVersionFileUpdate) {
return
}
if ($DryRun) {
Write-Host "[DRYRUN] 更新版本文件 $Path -> $Value" -ForegroundColor DarkGray
return
}
$Value | Out-File -LiteralPath $Path -Encoding UTF8 -NoNewline
Write-Success "版本文件已更新:$Path"
}
function Get-RuntimeImageSpecs {
return @(
@{ Source = "postgres:15-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/postgres:15-alpine"; Repository = "runtime/postgres"; Tag = "15-alpine" },
@{ Source = "postgres:16-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/postgres:16-alpine"; Repository = "runtime/postgres"; Tag = "16-alpine" },
@{ Source = "minio/minio:RELEASE.2025-04-22T22-12-26Z"; Mirror = "quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z"; Repository = "runtime/minio"; Tag = "RELEASE.2025-04-22T22-12-26Z" },
@{ Source = "minio/mc:RELEASE.2025-04-16T18-13-26Z"; Mirror = "quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z"; Repository = "runtime/minio-mc"; Tag = "RELEASE.2025-04-16T18-13-26Z" },
@{ Source = "alpine:3.21"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/alpine:3.21"; Repository = "runtime/alpine"; Tag = "3.21" },
@{ Source = "node:18-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/node:18-alpine"; Repository = "runtime/node"; Tag = "18-alpine" },
@{ Source = "node:20-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/node:20-alpine"; Repository = "runtime/node"; Tag = "20-alpine" },
@{ Source = "redis:7-alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/redis:7-alpine"; Repository = "runtime/redis"; Tag = "7-alpine" },
@{ Source = "nginx:alpine"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/nginx:alpine"; Repository = "runtime/nginx"; Tag = "alpine" }
)
}
function Get-ComposeRuntimeEnv {
$postgresImage = Get-RegistryImageFor -Repository "runtime/postgres" -ImageTag "15-alpine"
$minioImage = Get-RegistryImageFor -Repository "runtime/minio" -ImageTag "RELEASE.2025-04-22T22-12-26Z"
$minioMcImage = Get-RegistryImageFor -Repository "runtime/minio-mc" -ImageTag "RELEASE.2025-04-16T18-13-26Z"
return "POSTGRES_IMAGE=$(ConvertTo-ShellSingleQuoted -Value $postgresImage) MINIO_IMAGE=$(ConvertTo-ShellSingleQuoted -Value $minioImage) MINIO_MC_IMAGE=$(ConvertTo-ShellSingleQuoted -Value $minioMcImage)"
}
function Ensure-RegistryPassword {
if ($SkipPush -and $SkipRemote) {
return
}
if ($SkipRegistryLogin -and $SkipPush) {
return
}
if (-not $RegistryPassword) {
$script:RegistryPassword = Read-Host "请输入 Registry 密码" -AsSecureString
}
}
function Ensure-SshPasswordIfNeeded {
if ($SkipRemote) {
return
}
if ($KeyPath -and (Test-Path $KeyPath)) {
return
}
if (-not $Password) {
$script:Password = Read-Host "请输入 SSH 密码" -AsSecureString
}
}
function Test-RegistryEndpoint {
Assert-RegistryHostConfigured
Write-RegistryHttpNotice
if ($SkipPush -and $SkipRemote) {
return
}
$plain = ConvertTo-PlainText -SecureValue $RegistryPassword
$headers = @{}
if ($RegistryUsername -and $plain) {
$pair = "{0}:{1}" -f $RegistryUsername, $plain
$basic = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pair))
$headers.Authorization = "Basic $basic"
}
$uri = "${RegistryScheme}://${RegistryHost}/v2/"
Write-Info "检查 Registry 接口:$uri"
if ($DryRun) {
Write-Host "[DRYRUN] Invoke-WebRequest $uri" -ForegroundColor DarkGray
return
}
try {
$response = Invoke-WebRequest -Uri $uri -Headers $headers -TimeoutSec 8 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
Write-Success "Registry 接口可访问"
} else {
throw "Registry 返回状态码 $($response.StatusCode)"
}
} catch {
throw "Registry 接口检查失败。若报 HTTPS/HTTP 相关错误,请先在 Docker Desktop 和目标机配置 insecure-registries:$RegistryHost。原始错误:$($_.Exception.Message)"
}
}
function Invoke-DockerLogin {
Assert-RegistryHostConfigured
if ($SkipRegistryLogin -or $SkipPush) {
return
}
$plain = ConvertTo-PlainText -SecureValue $RegistryPassword
if ([string]::IsNullOrWhiteSpace($plain)) {
throw "Registry 密码为空,无法登录"
}
Write-Info "登录 Registry:$RegistryHost"
if ($DryRun) {
Write-Host "[DRYRUN] docker login $RegistryHost -u $RegistryUsername --password-stdin" -ForegroundColor DarkGray
return
}
$plain | docker login $RegistryHost -u $RegistryUsername --password-stdin
if ($LASTEXITCODE -ne 0) {
throw "Registry 登录失败。若提示 HTTP response to HTTPS client,请先配置 Docker insecure-registries:$RegistryHost"
}
Write-Success "Registry 登录成功"
}
function Invoke-RemoteCommand {
param(
[string]$Command,
[switch]$NoRegistryPasswordInput
)
if ($SkipRemote) {
return
}
Assert-RemoteServerConfigured
$target = "${Username}@${Server}"
$remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $Command)
$stdinPassword = ""
if ((-not $SkipRegistryLogin) -and (-not $NoRegistryPasswordInput)) {
$stdinPassword = ConvertTo-PlainText -SecureValue $RegistryPassword
if ([string]::IsNullOrWhiteSpace($stdinPassword)) {
throw "Registry 密码为空,无法远程登录 Registry"
}
}
if ($KeyPath -and (Test-Path $KeyPath)) {
Assert-Tool "ssh"
$args = @(
"-p", [string]$SshPort,
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=30",
"-o", "ServerAliveInterval=10",
"-o", "ServerAliveCountMax=3",
"-i", $KeyPath,
$target,
$remoteCommand
)
Write-Info "远程执行部署命令"
if ($DryRun) {
Write-Host "[DRYRUN] ssh -p $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray
return
}
if ($SkipRegistryLogin -or $NoRegistryPasswordInput) {
& ssh @args
} else {
$stdinPassword | ssh @args
}
if ($LASTEXITCODE -ne 0) {
throw "远程部署命令失败,退出码:$LASTEXITCODE"
}
return
}
Assert-Tool "plink.exe"
$plainPassword = ConvertTo-PlainText -SecureValue $Password
if ([string]::IsNullOrWhiteSpace($plainPassword)) {
throw "SSH 密码为空,无法使用 plink 远程部署"
}
$args = @("-ssh", "-P", [string]$SshPort, "-batch")
if (-not [string]::IsNullOrWhiteSpace($PlinkHostKey)) {
$args += @("-hostkey", $PlinkHostKey)
}
$args += @("-pw", $plainPassword, $target, $remoteCommand)
Write-Info "远程执行部署命令"
if ($DryRun) {
Write-Host "[DRYRUN] plink -P $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray
return
}
if ($SkipRegistryLogin -or $NoRegistryPasswordInput) {
& plink.exe @args
} else {
$stdinPassword | plink.exe @args
}
if ($LASTEXITCODE -ne 0) {
throw "远程部署命令失败,退出码:$LASTEXITCODE"
}
}
function New-RemoteDeployScript {
param(
[string]$RegistryImage,
[string]$ResolvedNextAuthUrl,
[string]$ResolvedAppPublicUrl
)
$quotedRemotePath = ConvertTo-ShellSingleQuoted -Value $RemotePath
$quotedComposeFile = ConvertTo-ShellSingleQuoted -Value $ComposeFile
$quotedComposeService = ConvertTo-ShellSingleQuoted -Value $ComposeService
$quotedRegistryImage = ConvertTo-ShellSingleQuoted -Value $RegistryImage
$quotedRegistryHost = ConvertTo-ShellSingleQuoted -Value $RegistryHost
$quotedRegistryUser = ConvertTo-ShellSingleQuoted -Value $RegistryUsername
$quotedNextAuthUrl = ConvertTo-ShellSingleQuoted -Value $ResolvedNextAuthUrl
$quotedAppPublicUrl = ConvertTo-ShellSingleQuoted -Value $ResolvedAppPublicUrl
$quotedServer = ConvertTo-ShellSingleQuoted -Value $Server
$quotedPostgresImage = ConvertTo-ShellSingleQuoted -Value (Get-RegistryImageFor -Repository 'runtime/postgres' -ImageTag '15-alpine')
$quotedMinioImage = ConvertTo-ShellSingleQuoted -Value (Get-RegistryImageFor -Repository 'runtime/minio' -ImageTag 'RELEASE.2025-04-22T22-12-26Z')
$quotedMinioMcImage = ConvertTo-ShellSingleQuoted -Value (Get-RegistryImageFor -Repository 'runtime/minio-mc' -ImageTag 'RELEASE.2025-04-16T18-13-26Z')
$remoteBackupEnabled = if ($NoRemoteDbBackup) { "0" } else { "1" }
$rollbackEnabled = if ($NoRollback) { "0" } else { "1" }
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add("set -Eeuo pipefail")
if (-not $SkipRegistryLogin) {
$lines.Add("IFS= read -r REGISTRY_PASSWORD || { echo '未收到 Registry 密码'; exit 22; }")
}
$lines.Add("cd $quotedRemotePath")
$lines.Add("test -f $quotedComposeFile || { echo '找不到 docker compose 文件:$ComposeFile'; exit 20; }")
$lines.Add("test -f .env || { echo '找不到 .env,请先准备远程部署配置'; exit 21; }")
$lines.Add("compose_file=$quotedComposeFile")
$lines.Add("compose_service=$quotedComposeService")
$lines.Add("target_image=$quotedRegistryImage")
$lines.Add("expected_version='$($RegistryImage.Split(':')[-1])'")
$lines.Add("remote_health_timeout=$RemoteHealthTimeoutSec")
$lines.Add("remote_db_backup_enabled=$remoteBackupEnabled")
$lines.Add("rollback_enabled=$rollbackEnabled")
$lines.Add("export APP_IMAGE=$quotedRegistryImage")
$lines.Add("export NEXTAUTH_URL=$quotedNextAuthUrl")
$lines.Add("export APP_PUBLIC_URL=$quotedAppPublicUrl")
$lines.Add("export SERVER_IP=$quotedServer")
$lines.Add("export POSTGRES_IMAGE=$quotedPostgresImage")
$lines.Add("export MINIO_IMAGE=$quotedMinioImage")
$lines.Add("export MINIO_MC_IMAGE=$quotedMinioMcImage")
$lines.Add(@'
deploy_started_at="$(date +%Y%m%d-%H%M%S)"
backup_dir="./backups/deploy-$deploy_started_at"
mkdir -p "$backup_dir"
env_backup="$backup_dir/.env.before-upgrade"
cp .env "$env_backup"
upsert_env() {
key="$1"
value="$2"
tmp=".env.tmp.$$"
awk -v k="$key" -v v="$value" '
BEGIN { done=0 }
index($0, k "=") == 1 { print k "=" v; done=1; next }
{ print }
END { if (!done) print k "=" v }
' .env > "$tmp"
mv "$tmp" .env
}
read_env_value() {
key="$1"
awk -F= -v k="$key" '
index($0, k "=") == 1 {
value=substr($0, length(k) + 2)
gsub(/^"|"$/, "", value)
gsub(/^'\''|'\''$/, "", value)
print value
}
' .env | tail -n 1
}
get_service_container_id() {
docker compose -f "$compose_file" ps -q "$compose_service" 2>/dev/null || true
}
old_env_app_image="$(read_env_value APP_IMAGE || true)"
old_container_id="$(get_service_container_id)"
old_container_image=""
if [ -n "$old_container_id" ]; then
old_container_image="$(docker inspect "$old_container_id" --format '{{.Config.Image}}' 2>/dev/null || true)"
fi
echo "升级备份目录:$backup_dir"
echo "目标镜像:$target_image"
if [ -n "$old_container_image" ]; then
echo "当前运行镜像:$old_container_image"
elif [ -n "$old_env_app_image" ]; then
echo "当前 .env 镜像:$old_env_app_image"
else
echo "当前未发现已运行的 app 镜像,按首次部署处理"
fi
rollback() {
code="$?"
trap - ERR
if [ "$rollback_enabled" != "1" ]; then
echo "[ROLLBACK] 已通过参数关闭自动回滚,保留现场供人工排查"
exit "$code"
fi
echo "[ROLLBACK] 部署失败,开始恢复旧版本"
if [ -f "$env_backup" ]; then
cp "$env_backup" .env
echo "[ROLLBACK] 已恢复升级前 .env"
fi
rollback_image="$old_container_image"
if [ -z "$rollback_image" ]; then
rollback_image="$old_env_app_image"
fi
if [ -n "$rollback_image" ]; then
export APP_IMAGE="$rollback_image"
docker compose -f "$compose_file" up -d --no-deps --pull never --force-recreate "$compose_service" || true
docker compose -f "$compose_file" ps || true
echo "[ROLLBACK] 已尝试恢复旧镜像:$rollback_image"
else
echo "[ROLLBACK] 未找到旧镜像,无法自动恢复应用容器"
fi
exit "$code"
}
backup_postgres() {
if [ "$remote_db_backup_enabled" != "1" ]; then
echo "已跳过远程数据库备份"
return
fi
postgres_id="$(docker compose -f "$compose_file" ps -q postgres 2>/dev/null || true)"
if [ -z "$postgres_id" ]; then
echo "未发现 Compose 管理的 postgres 服务,可能使用外部数据库;请确认已有外部备份"
return
fi
db_backup="$backup_dir/postgres-$deploy_started_at.dump"
echo "执行数据库备份:$db_backup"
docker compose -f "$compose_file" exec -T postgres sh -c 'pg_dump -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-flnxi}" -F c' > "$db_backup"
test -s "$db_backup" || { echo "数据库备份失败:备份文件为空"; exit 31; }
echo "数据库备份完成:$db_backup"
}
wait_for_app_health() {
container_id="$1"
deadline=$((SECONDS + remote_health_timeout))
last_status=""
while [ "$SECONDS" -lt "$deadline" ]; do
last_status="$(docker inspect "$container_id" --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' 2>/dev/null || true)"
if [ "$last_status" = "healthy" ] || [ "$last_status" = "running" ]; then
echo "容器健康状态:$last_status"
return 0
fi
sleep 3
done
echo "容器健康检查超时,最后状态:$last_status"
return 42
}
verify_running_image() {
container_id="$(get_service_container_id)"
test -n "$container_id" || { echo "部署后找不到 app 容器"; exit 40; }
wait_for_app_health "$container_id"
actual_image="$(docker inspect "$container_id" --format '{{.Config.Image}}')"
if [ "$actual_image" != "$target_image" ]; then
echo "运行镜像不匹配:期望 $target_image,实际 $actual_image"
exit 41
fi
echo "运行镜像校验通过:$actual_image"
actual_version="$(docker compose -f "$compose_file" exec -T "$compose_service" sh -c 'cat /app/VERSION.txt 2>/dev/null || cat VERSION.txt 2>/dev/null || true' | tr -d '\r' | tail -n 1)"
if [ -n "$expected_version" ] && [ -n "$actual_version" ] && [ "$actual_version" != "$expected_version" ]; then
echo "运行版本不匹配:期望 $expected_version,实际 $actual_version"
exit 43
fi
if [ -n "$actual_version" ]; then
echo "运行版本校验通过:$actual_version"
else
echo "未读取到 VERSION.txt,跳过版本文件校验"
fi
docker compose -f "$compose_file" exec -T "$compose_service" npx prisma migrate status --schema=prisma/schema.prisma
echo "数据库迁移状态校验通过"
}
backup_postgres
'@)
if ($UpdateRemoteEnv) {
$lines.Add("upsert_env APP_IMAGE $quotedRegistryImage")
$lines.Add("upsert_env NEXTAUTH_URL $quotedNextAuthUrl")
$lines.Add("upsert_env APP_PUBLIC_URL $quotedAppPublicUrl")
$lines.Add("upsert_env SERVER_IP $quotedServer")
$lines.Add("upsert_env POSTGRES_IMAGE $quotedPostgresImage")
$lines.Add("upsert_env MINIO_IMAGE $quotedMinioImage")
$lines.Add("upsert_env MINIO_MC_IMAGE $quotedMinioMcImage")
}
if (-not $NoRollback) {
$lines.Add("trap rollback ERR")
}
if (-not $SkipRegistryLogin) {
$lines.Add('printf ''%s'' "$REGISTRY_PASSWORD" | docker login ' + "$quotedRegistryHost -u $quotedRegistryUser --password-stdin")
}
$lines.Add("docker pull $quotedRegistryImage")
if ($FullComposeUp) {
$lines.Add('docker compose -f "$compose_file" up -d --pull never')
} else {
$lines.Add('docker compose -f "$compose_file" stop "$compose_service" || true')
$lines.Add('docker compose -f "$compose_file" rm -f "$compose_service" || true')
$lines.Add('docker compose -f "$compose_file" up -d --no-deps --pull never --force-recreate "$compose_service"')
}
$lines.Add("verify_running_image")
if (-not $NoRollback) {
$lines.Add("trap - ERR")
}
$lines.Add('docker compose -f "$compose_file" ps')
$lines.Add('echo "升级部署完成,备份目录:$backup_dir"')
return ($lines -join "`n")
}
function Test-RemoteHealth {
Assert-RemoteServerConfigured
$url = "http://${Server}:${AppPort}/api/health"
Write-Info "检查应用健康状态:$url"
if ($DryRun) {
return
}
$deadline = (Get-Date).AddSeconds(60)
do {
try {
$resp = Invoke-WebRequest -Uri $url -TimeoutSec 5 -ErrorAction Stop
if ($resp.StatusCode -eq 200) {
Write-Success "应用健康检查通过"
return
}
} catch {
Start-Sleep -Seconds 3
}
} while ((Get-Date) -lt $deadline)
throw "部署命令执行完成,但健康检查没有在 60 秒内通过:$url"
}
function Invoke-ProjectCheck {
if (-not $SkipCheck) {
Assert-Tool "npm"
Invoke-NativeCommand -FilePath "npm" -Arguments @("run", "check") -Label "运行项目检查"
}
}
function Invoke-PublishAppImage {
param(
[string]$VersionTag,
[string]$Repository,
[bool]$PushLatest
)
$localImage = "${LocalImageName}:${VersionTag}"
$registryImage = Get-RegistryImageFor -Repository $Repository -ImageTag $VersionTag
$resolvedNextAuthUrl = if ([string]::IsNullOrWhiteSpace($NextAuthUrl)) { Get-DefaultAppUrl } else { $NextAuthUrl }
$resolvedAppPublicUrl = if ([string]::IsNullOrWhiteSpace($AppPublicUrl)) { $resolvedNextAuthUrl } else { $AppPublicUrl }
if (-not $SkipBuild -or -not $SkipPush) {
Assert-Tool "docker"
}
if (-not $SkipBuild) {
$env:DOCKER_BUILDKIT = "1"
$buildArgs = @(
"build",
"--progress=plain",
"-t", $localImage,
"-f", "Dockerfile",
"--build-arg", "DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/placeholder",
"--build-arg", "NEXTAUTH_URL=$resolvedNextAuthUrl",
"--build-arg", "APP_PUBLIC_URL=$resolvedAppPublicUrl",
"--build-arg", "APP_VERSION=$VersionTag"
)
if ($NoCache) {
$buildArgs += "--no-cache"
}
$buildArgs += "."
Invoke-NativeCommand -FilePath "docker" -Arguments $buildArgs -Label "构建本地镜像:$localImage"
}
if (-not $SkipPush) {
Invoke-DockerLogin
Invoke-NativeCommand -FilePath "docker" -Arguments @("image", "inspect", $localImage) -Label "确认本地镜像存在:$localImage"
Invoke-NativeCommand -FilePath "docker" -Arguments @("tag", $localImage, $registryImage) -Label "标记Registry镜像"
Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $registryImage) -Label "推送镜像到Registry"
if ($PushLatest -and $VersionTag -ne "latest") {
$latestImage = Get-RegistryImageFor -Repository $Repository -ImageTag "latest"
Invoke-NativeCommand -FilePath "docker" -Arguments @("tag", $localImage, $latestImage) -Label "标记latest镜像"
Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $latestImage) -Label "推送latest镜像到Registry"
}
}
return $registryImage
}
function Invoke-DeployApp {
Assert-RegistryHostConfigured
if (-not $SkipRemote) {
Assert-RemoteServerConfigured
}
if ($NoUpdateRemoteEnv -and $UpdateRemoteEnv) {
throw "-UpdateRemoteEnv 和 -NoUpdateRemoteEnv 不能同时使用"
}
if (-not $NoUpdateRemoteEnv) {
$script:UpdateRemoteEnv = $true
}
$resolvedNextAuthUrl = if ([string]::IsNullOrWhiteSpace($NextAuthUrl)) { Get-DefaultAppUrl } else { $NextAuthUrl }
$resolvedAppPublicUrl = if ([string]::IsNullOrWhiteSpace($AppPublicUrl)) { $resolvedNextAuthUrl } else { $AppPublicUrl }
$deployTag = Resolve-DeployTag
$deployRepository = Resolve-DeployRepository
$registryImage = Get-RegistryImageFor -Repository $deployRepository -ImageTag $deployTag
Write-Host "========================================" -ForegroundColor Magenta
Write-Host " FLNXI素材库 - 应用发布部署" -ForegroundColor Magenta
Write-Host "========================================" -ForegroundColor Magenta
Write-Info "发布通道:$DeployChannel"
Write-Info "Registry镜像:$registryImage"
Write-Info "远程服务器:${Username}@${Server}:${SshPort}"
Write-Info "远程目录:$RemotePath"
Write-Info "应用入口:$resolvedAppPublicUrl"
if ($UpdateRemoteEnv) {
Write-Info "远程 .env:将持久写入 APP_IMAGE / 访问地址 / Runtime 镜像"
} else {
Write-Warn "远程 .env:本次只临时传入镜像和访问地址,重启或手动 compose 可能不会沿用本次版本"
}
Ensure-RegistryPassword
Ensure-SshPasswordIfNeeded
Invoke-ProjectCheck
Test-RegistryEndpoint
$registryImage = Invoke-PublishAppImage -VersionTag $deployTag -Repository $deployRepository -PushLatest:$false
if (-not $SkipRemote) {
$remoteScript = New-RemoteDeployScript -RegistryImage $registryImage -ResolvedNextAuthUrl $resolvedNextAuthUrl -ResolvedAppPublicUrl $resolvedAppPublicUrl
Invoke-RemoteCommand -Command $remoteScript
Test-RemoteHealth
}
Write-Success "发布流程完成"
Write-Info "镜像:$registryImage"
Write-Info "访问:http://${Server}:${AppPort}"
}
function Invoke-PublishRelease {
Assert-RegistryHostConfigured
Write-Host "========================================" -ForegroundColor Magenta
Write-Host " FLNXI素材库 - 正式版镜像发布" -ForegroundColor Magenta
Write-Host "========================================" -ForegroundColor Magenta
$releaseVersion = Get-VersionOrPrompt -CurrentVersion (Read-VersionFile -Path "VERSION.txt") -Prompt "请输入素材系统正式版本号"
Write-VersionFileIfNeeded -Path "VERSION.txt" -Value $releaseVersion
Ensure-RegistryPassword
Invoke-ProjectCheck
Test-RegistryEndpoint
$shouldPushLatest = $PushLatest -and (-not $NoLatest)
$registryImage = Invoke-PublishAppImage -VersionTag $releaseVersion -Repository $ImageRepository -PushLatest:$shouldPushLatest
Write-Success "素材系统正式版发布完成"
Write-Info "版本镜像:$registryImage"
if ($shouldPushLatest -and $releaseVersion -ne "latest") {
Write-Info "latest镜像:$(Get-RegistryImageFor -Repository $ImageRepository -ImageTag 'latest')"
}
}
function Invoke-PublishAgent {
Assert-RegistryHostConfigured
Write-Host "========================================" -ForegroundColor Magenta
Write-Host " FLNXI素材库 - Agent 镜像发布" -ForegroundColor Magenta
Write-Host "========================================" -ForegroundColor Magenta
if (-not (Test-Path $AgentContext)) {
throw "找不到 Agent 目录:$AgentContext"
}
$versionFile = Join-Path $AgentContext "VERSION.txt"
$agentVersion = Get-VersionOrPrompt -CurrentVersion (Read-VersionFile -Path $versionFile) -Prompt "请输入 Agent 版本号"
Write-VersionFileIfNeeded -Path $versionFile -Value $agentVersion
Ensure-RegistryPassword
Assert-Tool "docker"
Test-RegistryEndpoint
Invoke-DockerLogin
$env:DOCKER_BUILDKIT = "1"
$versionImage = Get-RegistryImageFor -Repository $AgentRepository -ImageTag $agentVersion
$buildArgs = @("build", "--progress=plain", "-t", $versionImage)
$shouldPushLatest = $PushLatest -and (-not $NoLatest)
if ($shouldPushLatest -and $agentVersion -ne "latest") {
$buildArgs += @("-t", (Get-RegistryImageFor -Repository $AgentRepository -ImageTag "latest"))
}
$buildArgs += $AgentContext
Invoke-NativeCommand -FilePath "docker" -Arguments $buildArgs -Label "构建 Agent 镜像:$versionImage"
Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $versionImage) -Label "推送 Agent 版本镜像"
if ($shouldPushLatest -and $agentVersion -ne "latest") {
$latestImage = Get-RegistryImageFor -Repository $AgentRepository -ImageTag "latest"
Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $latestImage) -Label "推送 Agent latest 镜像"
}
Write-Success "Agent 镜像发布完成"
Write-Info "镜像:$versionImage"
}
function Invoke-SyncRuntimeImages {
Assert-RegistryHostConfigured
Write-Host "========================================" -ForegroundColor Magenta
Write-Host " FLNXI素材库 - Runtime 镜像同步" -ForegroundColor Magenta
Write-Host "========================================" -ForegroundColor Magenta
Ensure-RegistryPassword
Assert-Tool "docker"
Test-RegistryEndpoint
Invoke-DockerLogin
$successCount = 0
$failures = New-Object System.Collections.Generic.List[string]
$specs = Get-RuntimeImageSpecs
foreach ($spec in $specs) {
$target = Get-RegistryImageFor -Repository $spec.Repository -ImageTag $spec.Tag
Write-Info "同步 $($spec.Source) -> $target"
if ($DryRun) {
Write-Host "[DRYRUN] docker pull $($spec.Mirror) 或 $($spec.Source)" -ForegroundColor DarkGray
Write-Host "[DRYRUN] docker tag <source> $target" -ForegroundColor DarkGray
Write-Host "[DRYRUN] docker push $target" -ForegroundColor DarkGray
$successCount++
continue
}
$pulledImage = ""
if (-not [string]::IsNullOrWhiteSpace($spec.Mirror)) {
docker pull $spec.Mirror
if ($LASTEXITCODE -eq 0) {
$pulledImage = $spec.Mirror
}
}
if ([string]::IsNullOrWhiteSpace($pulledImage)) {
docker pull $spec.Source
if ($LASTEXITCODE -eq 0) {
$pulledImage = $spec.Source
}
}
if ([string]::IsNullOrWhiteSpace($pulledImage)) {
$failures.Add($spec.Source)
Write-Warn "拉取失败:$($spec.Source)"
continue
}
docker tag $pulledImage $target
if ($LASTEXITCODE -ne 0) {
$failures.Add($spec.Source)
Write-Warn "打标签失败:$($spec.Source)"
continue
}
docker push $target
if ($LASTEXITCODE -ne 0) {
$failures.Add($spec.Source)
Write-Warn "推送失败:$target"
continue
}
$successCount++
Write-Success "完成:$target"
}
Write-Info "Runtime 同步结果:成功 $successCount 个,失败 $($failures.Count) 个"
if ($failures.Count -gt 0) {
throw "部分 Runtime 镜像同步失败:$($failures -join ', ')"
}
}
function Invoke-RegistryApi {
param([string]$Path)
Assert-RegistryHostConfigured
$plain = ConvertTo-PlainText -SecureValue $RegistryPassword
$headers = @{}
if ($RegistryUsername -and $plain) {
$pair = "{0}:{1}" -f $RegistryUsername, $plain
$basic = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($pair))
$headers.Authorization = "Basic $basic"
}
$uri = "${RegistryScheme}://${RegistryHost}${Path}"
return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 10
}
function Invoke-ShowStatus {
Assert-RegistryHostConfigured
Write-Host "========================================" -ForegroundColor Magenta
Write-Host " FLNXI素材库 - 发布状态查看" -ForegroundColor Magenta
Write-Host "========================================" -ForegroundColor Magenta
Ensure-RegistryPassword
Write-Info "Registry:${RegistryScheme}://${RegistryHost}"
if ($DryRun) {
Write-Host "[DRYRUN] 查询 Registry catalog、本地 Docker、远程容器状态" -ForegroundColor DarkGray
return
}
try {
$catalog = Invoke-RegistryApi -Path "/v2/_catalog"
$repos = @($catalog.repositories)
if ($repos.Count -eq 0) {
Write-Warn "Registry 当前没有镜像仓库"
} else {
Write-Host ""
Write-Host "Registry 仓库:" -ForegroundColor White
foreach ($repo in $repos) {
Write-Host " - $repo"
try {
$tags = Invoke-RegistryApi -Path "/v2/$repo/tags/list"
if ($tags.tags) {
Write-Host " tags: $($tags.tags -join ', ')" -ForegroundColor DarkGray
}
} catch {
Write-Host " tags: 查询失败" -ForegroundColor DarkGray
}
}
}
} catch {
Write-Warn "Registry 状态查询失败:$($_.Exception.Message)"
}
if (Get-Command docker -ErrorAction SilentlyContinue) {
Write-Host ""
Write-Host "本机相关镜像:" -ForegroundColor White
docker images "$RegistryHost/$RegistryNamespace/*" --format " {{.Repository}}:{{.Tag}} {{.Size}} {{.CreatedSince}}"
}
if (-not $SkipRemote) {
Ensure-SshPasswordIfNeeded
$remoteStatus = @"
set -e
echo '--- docker ps ---'
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E 'flnxi|postgres|minio|license' || true
echo '--- compose ---'
cd $(ConvertTo-ShellSingleQuoted -Value $RemotePath) 2>/dev/null && docker compose -f $(ConvertTo-ShellSingleQuoted -Value $ComposeFile) ps || true
"@
Invoke-RemoteCommand -Command $remoteStatus -NoRegistryPasswordInput
}
}
function Show-ActionMenu {
Write-Host ""
Write-Host "FLNXI 新 Registry 发布管理" -ForegroundColor Green
Write-Host "1. 应用发布部署(默认正式版通道,测试需显式 -DeployChannel test)"
Write-Host "2. 发布素材系统正式版镜像"
Write-Host "3. 发布 Agent 镜像"
Write-Host "4. 同步 Runtime 镜像"
Write-Host "5. 查看状态"
Write-Host "0. 退出"
}
function Invoke-Menu {
while ($true) {
Show-ActionMenu
$choice = Read-Host "请选择"
switch ($choice) {
"1" { Invoke-DeployApp }
"2" { Invoke-PublishRelease }
"3" { Invoke-PublishAgent }
"4" { Invoke-SyncRuntimeImages }
"5" { Invoke-ShowStatus }
"0" { return }
default { Write-Warn "无效选择,请重新输入" }
}
Write-Host ""
Read-Host "按 Enter 继续"
}
}
function Main {
if ($Help) {
Show-Help
return
}
switch ($Action) {
"DeployApp" { Invoke-DeployApp }
"PublishRelease" { Invoke-PublishRelease }
"PublishAgent" { Invoke-PublishAgent }
"SyncRuntime" { Invoke-SyncRuntimeImages }
"Status" { Invoke-ShowStatus }
"Menu" { Invoke-Menu }
default { throw "未知动作:$Action" }
}
}
try {
Main
} catch {
Write-Fail $_.Exception.Message
exit 1
}