1629 lines
63 KiB
PowerShell
1629 lines
63 KiB
PowerShell
# ================================================================
|
||
# 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 <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
|
||
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
|
||
}
|