# ================================================================ # FLNXI素材库 - 新Registry发布脚本 # 面向客户或内部测试环境的版本化 Registry 发布 # 流程:检查 -> 构建 -> 登录Registry -> 推送镜像 -> 远程部署 # ================================================================ param( [ValidateSet("DeployApp", "PublishRelease", "PublishAgent", "PublishPremiumModule", "SyncRuntime", "Status", "Menu")] [string]$Action = "DeployApp", [string]$Server = "", [int]$SshPort = 22, [string]$Username = "root", [SecureString]$Password = $null, [switch]$RemoteSudo, [SecureString]$RemoteSudoPassword = $null, [string]$KeyPath = "", [string]$PlinkHostKey = "", [string]$RemotePath = "/opt/flnxi", [string]$ComposeFile = "docker-compose.prod.yml", [string]$ComposeService = "app", [int]$AppPort = 4320, [int]$RemoteHealthTimeoutSec = 120, [switch]$NoRemoteDbBackup, [switch]$NoRemoteObjectStorageBackup, [switch]$ExternalDataBackupConfirmed, [string]$RemoteBackupHelperImage = "busybox:1.36", [switch]$NoRollback, [string]$RegistryHost = "", [ValidateSet("http", "https")] [string]$RegistryScheme = "http", [string]$RegistryUsername = "flnxi-publish", [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", [string]$PremiumModuleId = "paid-core", [string]$PremiumModuleContextRoot = "premium-modules", [string]$PremiumModuleRepositoryPrefix = "premium", [switch]$AllowMissingPremiumModuleDigest, [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 PublishPremiumModule -PremiumModuleId paid-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -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 PublishPremiumModule 构建并推送付费模块镜像,输出 license-server 所需 digest" 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端口,默认 22;内部测试机或客户自定义端口必须显式传入" Write-Host " -RemoteSudo 远端 Docker 需要 sudo 时启用;常见于飞牛 / NAS 普通管理员账号" Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入" Write-Host " -RegistryUsername Registry账号,默认 flnxi-publish" 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 " -PremiumModuleId 付费模块 ID,默认 paid-core" Write-Host " -PremiumModuleContextRoot 付费模块源码根目录,默认 premium-modules" Write-Host " -PremiumModuleRepositoryPrefix 付费模块镜像仓库前缀,默认 premium" Write-Host " -AllowMissingPremiumModuleDigest 允许付费模块发布后暂时不输出 digest;正式商用不建议启用" Write-Host " -RemotePath 远程部署目录,默认 /opt/flnxi;已有客户环境必须传入真实部署目录" 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 " -NoRemoteObjectStorageBackup 跳过远程 MinIO 数据卷备份;默认会在升级前尝试备份 Compose 管理的素材文件" Write-Host " -ExternalDataBackupConfirmed 使用外部 PostgreSQL / S3 时,确认客户侧备份已完成后才允许继续升级" Write-Host " -RemoteBackupHelperImage 远程备份对象存储卷时使用的 helper 镜像,默认 busybox:1.36" 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 Normalize-RemoteShellScript { param([string]$Script) if ($null -eq $Script) { return "" } return (($Script -replace "`r`n", "`n") -replace "`r", "") } 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-PremiumModuleEnvKey { param([string]$ModuleId) return (($ModuleId -replace '[^a-zA-Z0-9]', '_').ToUpperInvariant()) } function Get-PushedImageDigest { param([string]$Image) if ($DryRun) { return "sha256:dryrun" } $repoDigestsRaw = "" try { $repoDigestsRaw = docker image inspect $Image --format "{{json .RepoDigests}}" } catch { $repoDigestsRaw = "" } if (-not [string]::IsNullOrWhiteSpace($repoDigestsRaw)) { try { $repoDigests = $repoDigestsRaw | ConvertFrom-Json foreach ($repoDigest in $repoDigests) { if ($repoDigest -match '@(sha256:[a-f0-9]+)$') { return $Matches[1] } } } catch { Write-Warn "解析本地 RepoDigests 失败:$($_.Exception.Message)" } } if (Get-Command docker -ErrorAction SilentlyContinue) { $digest = docker buildx imagetools inspect $Image --format "{{.Manifest.Digest}}" 2>$null if ($LASTEXITCODE -eq 0 -and $digest -match '^sha256:[a-f0-9]+$') { return $digest.Trim() } } return "" } 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 = "busybox:1.36"; Mirror = "registry.cn-hangzhou.aliyuncs.com/library/busybox:1.36"; Repository = "runtime/busybox"; Tag = "1.36" }, @{ 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 ($DryRun) { return } 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 Ensure-RemoteSudoPasswordIfNeeded { if ($SkipRemote -or -not $RemoteSudo) { return } if ($RemoteSudoPassword) { return } if ($Password) { $script:RemoteSudoPassword = $Password return } $script:RemoteSudoPassword = Read-Host "请输入远程 sudo 密码" -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 接口可访问" } elseif ($response.StatusCode -eq 401) { Write-Success "Registry 接口可访问(需要认证)" } else { throw "Registry 返回状态码 $($response.StatusCode)" } } catch { $statusCode = $null try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = $null } if ($statusCode -eq 401) { Write-Success "Registry 接口可访问(需要认证)" return } 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 ($DryRun) { Write-Info "登录 Registry:$RegistryHost" Write-Host "[DRYRUN] docker login $RegistryHost -u $RegistryUsername --password-stdin" -ForegroundColor DarkGray return } if ([string]::IsNullOrWhiteSpace($plain)) { throw "Registry 密码为空,无法登录" } Write-Info "登录 Registry:$RegistryHost" $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}" $normalizedCommand = Normalize-RemoteShellScript -Script $Command $remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $normalizedCommand) $stdinLines = New-Object System.Collections.Generic.List[string] if ($RemoteSudo) { $sudoPasswordPlain = ConvertTo-PlainText -SecureValue $RemoteSudoPassword if ([string]::IsNullOrWhiteSpace($sudoPasswordPlain)) { throw "远程 sudo 密码为空,无法执行需要 sudo 的 Docker 命令" } $stdinLines.Add($sudoPasswordPlain) } if ((-not $SkipRegistryLogin) -and (-not $NoRegistryPasswordInput)) { $stdinRegistryPassword = ConvertTo-PlainText -SecureValue $RegistryPassword if ([string]::IsNullOrWhiteSpace($stdinRegistryPassword)) { throw "Registry 密码为空,无法远程登录 Registry" } $stdinLines.Add($stdinRegistryPassword) } $stdinPayload = "" if ($stdinLines.Count -gt 0) { $stdinPayload = ($stdinLines -join "`n") + "`n" } 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 ($stdinLines.Count -eq 0) { & ssh @args } else { $stdinPayload | 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 ($stdinLines.Count -eq 0) { & plink.exe @args } else { $stdinPayload | 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') $quotedRemoteBackupHelperImage = ConvertTo-ShellSingleQuoted -Value $RemoteBackupHelperImage $remoteBackupEnabled = if ($NoRemoteDbBackup) { "0" } else { "1" } $remoteObjectBackupEnabled = if ($NoRemoteObjectStorageBackup) { "0" } else { "1" } $externalDataBackupConfirmed = if ($ExternalDataBackupConfirmed) { "1" } else { "0" } $rollbackEnabled = if ($NoRollback) { "0" } else { "1" } $remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" } $lines = New-Object System.Collections.Generic.List[string] $lines.Add("set -Eeuo pipefail") if ($RemoteSudo) { $lines.Add("IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }") } 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("remote_object_backup_enabled=$remoteObjectBackupEnabled") $lines.Add("external_data_backup_confirmed=$externalDataBackupConfirmed") $lines.Add("remote_backup_helper_image=$quotedRemoteBackupHelperImage") $lines.Add("rollback_enabled=$rollbackEnabled") $lines.Add("remote_sudo_enabled=$remoteSudoEnabled") $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" db_backup="" object_backup="" minio_volume_name="" sudo_auth() { if [ "${remote_sudo_enabled:-0}" = "1" ]; then printf '%s\n' "$SUDO_PASSWORD" | sudo -S -p '' true fi } docker() { if [ "${remote_sudo_enabled:-0}" = "1" ]; then sudo_auth sudo -n docker "$@" else command docker "$@" fi } 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(/\r$/, "", value) gsub(/^"|"$/, "", value) gsub(/^'\''|'\''$/, "", value) print value } ' .env | tail -n 1 } shell_single_quote() { printf "'" printf '%s' "$1" | sed "s/'/'\\\\''/g" printf "'" } write_deploy_context() { context_file="./backups/LAST_DEPLOY_CONTEXT.env" { printf 'compose_file=%s\n' "$(shell_single_quote "$compose_file")" printf 'compose_service=%s\n' "$(shell_single_quote "$compose_service")" printf 'backup_dir=%s\n' "$(shell_single_quote "$backup_dir")" printf 'env_backup=%s\n' "$(shell_single_quote "$env_backup")" printf 'old_container_image=%s\n' "$(shell_single_quote "$old_container_image")" printf 'old_env_app_image=%s\n' "$(shell_single_quote "$old_env_app_image")" printf 'rollback_enabled=%s\n' "$(shell_single_quote "$rollback_enabled")" } > "$context_file" echo "回滚上下文:$context_file" } is_external_database() { database_url="$(read_env_value DATABASE_URL || true)" if [ -z "$database_url" ]; then return 1 fi db_host="$(printf '%s' "$database_url" | sed -E 's#^[^@]*@([^/:?]+).*$#\1#')" if [ "$db_host" = "$database_url" ]; then return 1 fi [ "$db_host" != "postgres" ] } is_external_object_storage() { endpoint="$(read_env_value S3_ENDPOINT || true)" if [ -z "$endpoint" ]; then return 1 fi storage_host="$(printf '%s' "$endpoint" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*$#\1#')" if [ "$storage_host" = "$endpoint" ]; then return 1 fi [ "$storage_host" != "minio" ] } get_service_container_id() { docker compose -f "$compose_file" ps -q "$compose_service" 2>/dev/null || true } write_restore_plan() { plan_file="$backup_dir/RESTORE_PLAN.txt" { echo "FLNXI 升级恢复分级" echo "备份目录:$backup_dir" echo "" echo "L1:只恢复应用镜像和 .env。适用于拉镜像失败、容器启动失败、未执行数据库或素材结构变更的场景。" echo "L2:恢复应用镜像 + PostgreSQL。适用于本次升级已经执行数据库迁移,且需要回到升级前数据结构的场景。" echo "L3:恢复应用镜像 + PostgreSQL + MinIO/S3 素材文件。适用于数据库和素材对象都可能被本次升级写入或调整的场景。" echo "" echo "本次备份文件:" echo "- .env:$env_backup" if [ -n "${db_backup:-}" ] && [ -s "$db_backup" ]; then echo "- PostgreSQL:$db_backup" else echo "- PostgreSQL:未生成 Compose 内置数据库备份,请检查是否使用外部数据库或已跳过备份。" fi if [ -n "${object_backup:-}" ] && [ -s "$object_backup" ]; then echo "- MinIO 数据卷:$object_backup" else echo "- MinIO 数据卷:未生成 Compose 内置对象存储备份,请检查是否使用外部 S3/MinIO 或已跳过备份。" fi echo "" echo "恢复命令示例:" echo "L1 应用回滚:" echo " cp \"$env_backup\" .env" echo " docker compose -f \"$compose_file\" up -d --no-deps --pull never --force-recreate \"$compose_service\"" if [ -n "${db_backup:-}" ] && [ -s "$db_backup" ]; then echo "" echo "L2 数据库恢复(执行前必须停写入并确认备份来源):" echo " docker compose -f \"$compose_file\" stop \"$compose_service\"" echo " docker compose -f \"$compose_file\" exec -T postgres sh -c 'pg_restore -U \"\${POSTGRES_USER:-postgres}\" -d \"\${POSTGRES_DB:-flnxi}\" --clean --if-exists --no-owner --no-privileges' < \"$db_backup\"" fi if [ -n "${object_backup:-}" ] && [ -s "$object_backup" ]; then object_backup_name="$(basename "$object_backup")" [ -n "${minio_volume_name:-}" ] || minio_volume_name="flnxi-minio-data" echo "" echo "L3 对象存储恢复(执行前必须确认不会覆盖新上传素材):" echo " docker compose -f \"$compose_file\" stop \"$compose_service\" minio" echo " docker run --rm -v \"${minio_volume_name}:/target\" -v \"$(pwd)/${backup_dir#./}:/backup:ro\" \"$remote_backup_helper_image\" sh -c 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/$object_backup_name'" echo " docker compose -f \"$compose_file\" up -d" fi echo "" echo "恢复后必须验收:docker compose -f \"$compose_file\" ps,并确认 /api/health 返回 200。" } > "$plan_file" echo "恢复分级说明:$plan_file" } 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 write_deploy_context rollback() { code="$?" trap - ERR if [ "$rollback_enabled" != "1" ]; then echo "[ROLLBACK] 已通过参数关闭自动回滚,保留现场供人工排查" exit "$code" fi echo "[ROLLBACK] 部署失败,开始恢复旧版本" echo "[ROLLBACK] 自动回滚只恢复应用镜像和 .env,不自动恢复数据库;如本次升级包含数据库迁移,请使用升级前数据库备份人工确认恢复" 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 write_restore_plan || true echo "[ROLLBACK] 如需要恢复数据库或素材文件,请按 RESTORE_PLAN.txt 的 L2/L3 步骤人工确认后执行" exit "$code" } if [ "$rollback_enabled" = "1" ]; then trap rollback ERR fi enter_maintenance_for_backup() { if [ -n "$old_container_id" ]; then echo "进入升级维护窗口:暂时停止 app 服务,避免备份期间继续写入数据库或素材文件" docker compose -f "$compose_file" stop "$compose_service" || true printf '%s\n' "已在升级备份前停止 app 服务,以减少数据库与对象存储备份不一致风险。" > "$backup_dir/APP_STOPPED_FOR_BACKUP.txt" else echo "未发现运行中的 app 服务,跳过停写入步骤" fi } backup_postgres() { if [ "$remote_db_backup_enabled" != "1" ]; then echo "已跳过远程数据库备份" return fi if is_external_database; then echo "检测到 DATABASE_URL 指向外部数据库,跳过 Compose 内置 postgres 备份" printf '%s\n' "检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。" > "$backup_dir/EXTERNAL_DATABASE_BACKUP_REQUIRED.txt" if [ "$external_data_backup_confirmed" != "1" ]; then echo "外部数据库备份未确认,已中止升级。确认客户侧数据库备份完成后,重新执行并加 -ExternalDataBackupConfirmed。" exit 33 fi 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" } backup_minio_volume() { if [ "$remote_object_backup_enabled" != "1" ]; then echo "已跳过远程对象存储备份" printf '%s\n' "已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" > "$backup_dir/OBJECT_STORAGE_BACKUP_SKIPPED.txt" return fi if is_external_object_storage; then echo "检测到 S3_ENDPOINT 指向外部对象存储,跳过 Compose 内置 MinIO 卷备份" printf '%s\n' "检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。" > "$backup_dir/EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt" if [ "$external_data_backup_confirmed" != "1" ]; then echo "外部对象存储备份未确认,已中止升级。确认客户侧素材对象备份完成后,重新执行并加 -ExternalDataBackupConfirmed。" exit 34 fi return fi minio_id="$(docker compose -f "$compose_file" ps -q minio 2>/dev/null || true)" if [ -z "$minio_id" ]; then echo "未发现 Compose 管理的 minio 服务,可能使用外部对象存储;请确认已有外部对象存储备份" printf '%s\n' "未发现 Compose 管理的 minio 服务。若使用外部 S3 / MinIO,请按客户现场方案单独备份对象存储。" > "$backup_dir/OBJECT_STORAGE_EXTERNAL.txt" return fi minio_volume_name="$(read_env_value MINIO_VOLUME_NAME || true)" if [ -z "$minio_volume_name" ]; then minio_volume_name="flnxi-minio-data" fi if ! docker volume inspect "$minio_volume_name" >/dev/null 2>&1; then echo "未找到 MinIO 数据卷:$minio_volume_name;请确认是否使用外部对象存储或自定义卷名" printf '%s\n' "未找到 MinIO 数据卷:$minio_volume_name。若使用外部 S3 / MinIO,请按客户现场方案单独备份对象存储。" > "$backup_dir/OBJECT_STORAGE_VOLUME_MISSING.txt" return fi object_backup="$backup_dir/minio-data-$deploy_started_at.tar.gz" echo "执行对象存储备份:$object_backup" docker run --rm -v "${minio_volume_name}:/source:ro" -v "$(pwd)/${backup_dir#./}:/backup" "$remote_backup_helper_image" sh -c 'cd /source && tar -czf /backup/minio-data.tar.gz .' test -s "$backup_dir/minio-data.tar.gz" || { echo "对象存储备份失败:备份文件为空"; exit 32; } mv "$backup_dir/minio-data.tar.gz" "$object_backup" echo "对象存储备份完成:$object_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 "数据库迁移状态校验通过" } enter_maintenance_for_backup backup_postgres backup_minio_volume write_restore_plan '@) 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 $SkipRegistryLogin) { $lines.Add('printf ''%s'' "$REGISTRY_PASSWORD" | docker login ' + "$quotedRegistryHost -u $quotedRegistryUser --password-stdin") } $lines.Add("docker pull $quotedRegistryImage") if ($FullComposeUp) { $lines.Add('echo "预拉 Runtime 镜像(完整 compose 升级)"') $lines.Add("docker pull $quotedPostgresImage") $lines.Add("docker pull $quotedMinioImage") $lines.Add("docker pull $quotedMinioMcImage") $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 New-RemoteRollbackScript { param([string]$Reason) $quotedRemotePath = ConvertTo-ShellSingleQuoted -Value $RemotePath $quotedReason = ConvertTo-ShellSingleQuoted -Value $Reason $remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" } $sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" } return @" set -Eeuo pipefail $sudoReadLine remote_sudo_enabled=$remoteSudoEnabled sudo_auth() { if [ "`${remote_sudo_enabled:-0}" = "1" ]; then printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true fi } docker() { if [ "`${remote_sudo_enabled:-0}" = "1" ]; then sudo_auth sudo -n docker "`$@" else command docker "`$@" fi } cd $quotedRemotePath reason=$quotedReason context_file="./backups/LAST_DEPLOY_CONTEXT.env" echo "[ROLLBACK] 本地最终健康检查失败,准备远程回滚:`$reason" if [ ! -f "`$context_file" ]; then echo "[ROLLBACK] 找不到回滚上下文:`$context_file" exit 50 fi . "`$context_file" if [ "`${rollback_enabled:-1}" != "1" ]; then echo "[ROLLBACK] 当前部署已关闭自动回滚,保留现场供人工排查" exit 51 fi if [ -n "`${env_backup:-}" ] && [ -f "`$env_backup" ]; then cp "`$env_backup" .env echo "[ROLLBACK] 已恢复升级前 .env" else echo "[ROLLBACK] 找不到升级前 .env 备份:`${env_backup:-}" exit 52 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:-docker-compose.prod.yml}" up -d --no-deps --pull never --force-recreate "`${compose_service:-app}" docker compose -f "`${compose_file:-docker-compose.prod.yml}" ps echo "[ROLLBACK] 已恢复旧镜像:`$rollback_image" else echo "[ROLLBACK] 未找到旧镜像,无法自动恢复应用容器" exit 53 fi "@ } function Test-RemoteHealth { Assert-RemoteServerConfigured $baseUrl = if (-not [string]::IsNullOrWhiteSpace($AppPublicUrl)) { $AppPublicUrl } elseif (-not [string]::IsNullOrWhiteSpace($NextAuthUrl)) { $NextAuthUrl } else { "http://${Server}:${AppPort}" } $url = $baseUrl.TrimEnd("/") + "/api/health" Write-Info "检查应用健康状态:$url" if ($DryRun) { return } $deadline = (Get-Date).AddSeconds($RemoteHealthTimeoutSec) 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 "部署命令执行完成,但健康检查没有在 $RemoteHealthTimeoutSec 秒内通过:$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 Ensure-RemoteSudoPasswordIfNeeded 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 try { Test-RemoteHealth } catch { $healthError = $_.Exception.Message if ($NoRollback) { Write-Warn "应用健康检查失败,但已关闭自动回滚:$healthError" } else { Write-Warn "应用健康检查失败,尝试远程回滚:$healthError" $rollbackScript = New-RemoteRollbackScript -Reason $healthError try { Invoke-RemoteCommand -Command $rollbackScript -NoRegistryPasswordInput } catch { Write-Warn "远程回滚命令执行失败:$($_.Exception.Message)" } } throw } } 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-PublishPremiumModule { Assert-RegistryHostConfigured Write-Host "========================================" -ForegroundColor Magenta Write-Host " FLNXI素材库 - 付费模块镜像发布" -ForegroundColor Magenta Write-Host "========================================" -ForegroundColor Magenta $moduleId = $PremiumModuleId.Trim() if ([string]::IsNullOrWhiteSpace($moduleId)) { throw "PremiumModuleId 不能为空" } $moduleContext = Join-Path $PremiumModuleContextRoot $moduleId if (-not (Test-Path $moduleContext)) { throw "找不到付费模块目录:$moduleContext" } $dockerfile = Join-Path $moduleContext "Dockerfile" if (-not (Test-Path $dockerfile)) { throw "付费模块缺少 Dockerfile:$dockerfile" } $versionFile = Join-Path $moduleContext "VERSION.txt" $currentVersion = Read-VersionFile -Path $versionFile $moduleVersion = if (-not [string]::IsNullOrWhiteSpace($Tag)) { $Tag.Trim() } else { Get-VersionOrPrompt -CurrentVersion $currentVersion -Prompt "请输入付费模块 $moduleId 版本号" } Write-VersionFileIfNeeded -Path $versionFile -Value $moduleVersion $moduleRepository = "$($PremiumModuleRepositoryPrefix.Trim('/'))/$moduleId" $registryImage = Get-RegistryImageFor -Repository $moduleRepository -ImageTag $moduleVersion $localImage = "flnxi-premium-$moduleId`:$moduleVersion" Assert-Tool "docker" if (-not $SkipPush) { Ensure-RegistryPassword Test-RegistryEndpoint Invoke-DockerLogin } $env:DOCKER_BUILDKIT = "1" $buildArgs = @( "build", "--progress=plain", "-t", $localImage, "-t", $registryImage, "-f", $dockerfile ) if ($NoCache) { $buildArgs += "--no-cache" } $buildArgs += $moduleContext Invoke-NativeCommand -FilePath "docker" -Arguments $buildArgs -Label "构建付费模块镜像:$registryImage" if ($SkipPush) { Write-Warn "已跳过推送付费模块镜像;未推送时无法作为客户拉取源使用" } else { Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $registryImage) -Label "推送付费模块镜像" } $digest = if ($SkipPush) { "" } else { Get-PushedImageDigest -Image $registryImage } if ([string]::IsNullOrWhiteSpace($digest) -and (-not $SkipPush) -and (-not $AllowMissingPremiumModuleDigest)) { throw "未能自动读取付费模块镜像 digest。请确认 Registry 可通过 docker image inspect 或 docker buildx imagetools inspect 读取 digest;如只是临时内部调试,可显式传入 -AllowMissingPremiumModuleDigest。" } if ([string]::IsNullOrWhiteSpace($digest)) { Write-Warn "未能自动读取镜像 digest;请在 Registry 管理界面或 docker buildx imagetools inspect 中确认" } $envKey = Get-PremiumModuleEnvKey -ModuleId $moduleId Write-Success "付费模块发布完成" Write-Info "模块:$moduleId" Write-Info "镜像:$registryImage" if (-not [string]::IsNullOrWhiteSpace($digest)) { Write-Info "Digest:$digest" } Write-Host "" Write-Host "请写入 license-server 环境变量:" -ForegroundColor Yellow Write-Host "PREMIUM_MODULE_${envKey}_IMAGE=$registryImage" -ForegroundColor Yellow Write-Host "PREMIUM_MODULE_${envKey}_TAG=$moduleVersion" -ForegroundColor Yellow if (-not [string]::IsNullOrWhiteSpace($digest)) { Write-Host "PREMIUM_MODULE_${envKey}_DIGEST=$digest" -ForegroundColor Yellow } } 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 Ensure-RemoteSudoPasswordIfNeeded $remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" } $sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" } $remoteStatus = @" set -e $sudoReadLine remote_sudo_enabled=$remoteSudoEnabled sudo_auth() { if [ "`${remote_sudo_enabled:-0}" = "1" ]; then printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true fi } docker() { if [ "`${remote_sudo_enabled:-0}" = "1" ]; then sudo_auth sudo -n docker "`$@" else command docker "`$@" fi } 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. 发布付费模块镜像" Write-Host "5. 同步 Runtime 镜像" Write-Host "6. 查看状态" 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-PublishPremiumModule } "5" { Invoke-SyncRuntimeImages } "6" { 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 } "PublishPremiumModule" { Invoke-PublishPremiumModule } "SyncRuntime" { Invoke-SyncRuntimeImages } "Status" { Invoke-ShowStatus } "Menu" { Invoke-Menu } default { throw "未知动作:$Action" } } } try { Main } catch { Write-Fail $_.Exception.Message exit 1 }