diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 new file mode 100644 index 0000000..59899ca --- /dev/null +++ b/deploy-registry.ps1 @@ -0,0 +1,1120 @@ +# ================================================================ +# 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 $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 +}