今天连续发生多次热修复/旧版本发布把别人刚上线的改动冲掉(种菜损耗运费口径被撤回两次)。 发布脚本读取线上 .deployed-sha 后:本地没有该提交先 git fetch 再判断;线上提交不是本次发布版本的祖先时直接拒绝(服务器不做任何变更); 新增 -AllowRollback / --allow-rollback 仅供负责人决定撤回时使用。AGENTS.md 第 8 节补充热修复须基于线上版本并立即合入 main。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
706 lines
29 KiB
PowerShell
706 lines
29 KiB
PowerShell
[CmdletBinding()]
|
||
param(
|
||
[string]$Server = '218.93.190.75',
|
||
|
||
[string]$KeyPath,
|
||
[Security.SecureString]$Password,
|
||
[switch]$UpdatePassword,
|
||
|
||
[string]$SshUser = 'root',
|
||
[string]$RemotePath = '/opt/caiwuxitong',
|
||
[string]$AppUrl,
|
||
[string]$BuilderName = 'caiwu-builder',
|
||
[string]$BuildkitImage = 'm.daocloud.io/docker.io/moby/buildkit:buildx-stable-1',
|
||
[string]$DatabaseImage = 'mariadb:11.4',
|
||
[string]$DatabaseMirrorImage = 'm.daocloud.io/docker.io/library/mariadb:11.4',
|
||
[int]$KeepImages = 2,
|
||
[ValidatePattern('^\d+(KB|MB|GB|TB)$')]
|
||
[string]$BuildCacheMaxUsedSpace = '24GB',
|
||
[ValidateRange(24, 8760)]
|
||
[int]$BuildCacheMaxAgeHours = 720,
|
||
[switch]$BootstrapServer,
|
||
[switch]$ConfigureDingTalkCallback,
|
||
[switch]$AllowNonMain,
|
||
[switch]$AllowMigrations,
|
||
# 仅在负责人明确决定撤回线上某次发布时使用;默认禁止覆盖不在本次版本里的线上提交。
|
||
[switch]$AllowRollback,
|
||
[switch]$SkipRegctl
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
$scriptRoot = $PSScriptRoot
|
||
$repoRoot = (Resolve-Path (Join-Path $scriptRoot '..')).Path
|
||
. (Join-Path $scriptRoot 'deploy-auth.ps1')
|
||
$authSession = $null
|
||
$sshOptions = @()
|
||
$tunnelSshOptions = @()
|
||
$scpOptions = @()
|
||
if ($IsMacOS) { $scpOptions += '-O' }
|
||
$sshControlPath = if ($IsMacOS) { "/tmp/caiwu-ssh-$PID-$([Guid]::NewGuid().ToString('N')).sock" } else { $null }
|
||
$sshTarget = "${SshUser}@${Server}"
|
||
$imageName = 'caiwu_akaunting'
|
||
$targetPlatform = 'linux/amd64'
|
||
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N'))
|
||
$localTar = Join-Path $tempRoot 'image.tar'
|
||
$localArchive = Join-Path $tempRoot 'image.tar.gz'
|
||
$remoteArchive = $null
|
||
$bootstrapRemote = $null
|
||
$sshTunnel = $null
|
||
$buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
|
||
$regctlVersion = 'v0.11.5'
|
||
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
|
||
|
||
function Invoke-Native {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[scriptblock]$Command,
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$FailureMessage
|
||
)
|
||
|
||
& $Command
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw $FailureMessage
|
||
}
|
||
}
|
||
|
||
function Invoke-NativeWithRetry {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[scriptblock]$Command,
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$FailureMessage,
|
||
[int]$Attempts = 3,
|
||
[int]$DelaySeconds = 3
|
||
)
|
||
|
||
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||
& $Command
|
||
if ($LASTEXITCODE -eq 0) {
|
||
return
|
||
}
|
||
|
||
if ($attempt -lt $Attempts) {
|
||
Write-Warning "$FailureMessage 正在重试($attempt/$Attempts)。"
|
||
Start-Sleep -Seconds $DelaySeconds
|
||
}
|
||
}
|
||
|
||
throw $FailureMessage
|
||
}
|
||
|
||
function Get-PrivateEnvironmentValue {
|
||
param([Parameter(Mandatory = $true)][string]$Name)
|
||
|
||
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
||
if (-not $value) {
|
||
$value = [Environment]::GetEnvironmentVariable($Name, 'User')
|
||
}
|
||
|
||
if (-not $value) {
|
||
$credentialFile = Join-Path $repoRoot 'runtime/qiniu-business-storage.env'
|
||
if (Test-Path -LiteralPath $credentialFile) {
|
||
foreach ($line in Get-Content -LiteralPath $credentialFile) {
|
||
if ($line -match "^\s*$([Regex]::Escape($Name))\s*=\s*(.+?)\s*$") {
|
||
$value = $Matches[1].Trim().Trim('"').Trim("'")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($value -match "[`r`n]") {
|
||
throw "本机私有配置中的 $Name 格式无效。"
|
||
}
|
||
|
||
return $value
|
||
}
|
||
|
||
function Ensure-QiniuBusinessStorageRuntime {
|
||
$remoteEnvironment = "$RemotePath/runtime/qiniu-storage.env"
|
||
$remoteReadyCommand = @(
|
||
"test -f '$remoteEnvironment'"
|
||
"grep -q '^BUSINESS_STORAGE_DRIVER=qiniu$' '$remoteEnvironment'"
|
||
"grep -q '^QINIU_ACCESS_KEY=.' '$remoteEnvironment'"
|
||
"grep -q '^QINIU_SECRET_KEY=.' '$remoteEnvironment'"
|
||
) -join ' && '
|
||
|
||
& ssh @sshOptions $sshTarget $remoteReadyCommand
|
||
$remoteReady = $LASTEXITCODE -eq 0
|
||
|
||
if ($remoteReady) {
|
||
Write-Host '服务器私有业务文件存储配置已存在。'
|
||
return
|
||
}
|
||
|
||
$accessKey = Get-PrivateEnvironmentValue -Name 'QINIU_ACCESS_KEY'
|
||
$secretKey = Get-PrivateEnvironmentValue -Name 'QINIU_SECRET_KEY'
|
||
if (-not $accessKey -or -not $secretKey) {
|
||
throw '服务器尚未配置私有业务文件存储,且本机没有可用的七牛凭据。'
|
||
}
|
||
|
||
$environmentContent = @(
|
||
'BUSINESS_STORAGE_DRIVER=qiniu'
|
||
"QINIU_ACCESS_KEY=$accessKey"
|
||
"QINIU_SECRET_KEY=$secretKey"
|
||
) -join "`n"
|
||
|
||
$pendingEnvironment = "$remoteEnvironment.pending"
|
||
$environmentContent | & ssh `
|
||
@sshOptions `
|
||
$sshTarget `
|
||
"umask 077; cat > '$pendingEnvironment'; chmod 600 '$pendingEnvironment'; mv '$pendingEnvironment' '$remoteEnvironment'"
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw '写入服务器私有业务文件存储配置失败。'
|
||
}
|
||
|
||
Write-Host '服务器私有业务文件存储配置已安全写入。'
|
||
}
|
||
|
||
function Remove-OldProjectImages {
|
||
param([string]$CurrentImage)
|
||
|
||
$projectImages = @(
|
||
docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' |
|
||
Where-Object { $_ -and $_ -ne '<none>:<none>' } |
|
||
Select-Object -Unique
|
||
)
|
||
|
||
$keep = @($CurrentImage)
|
||
foreach ($candidate in $projectImages) {
|
||
if ($keep.Count -ge $KeepImages) { break }
|
||
if ($candidate -notin $keep) { $keep += $candidate }
|
||
}
|
||
|
||
foreach ($candidate in $projectImages) {
|
||
if ($candidate -notin $keep) {
|
||
docker image rm $candidate 2>$null | Out-Null
|
||
}
|
||
}
|
||
}
|
||
|
||
function Compress-Gzip {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$Source,
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$Destination
|
||
)
|
||
|
||
$inputStream = $null
|
||
$outputStream = $null
|
||
$gzipStream = $null
|
||
try {
|
||
$inputStream = [IO.File]::OpenRead($Source)
|
||
$outputStream = [IO.File]::Create($Destination)
|
||
$gzipStream = [IO.Compression.GZipStream]::new(
|
||
$outputStream,
|
||
[IO.Compression.CompressionLevel]::Fastest
|
||
)
|
||
$inputStream.CopyTo($gzipStream)
|
||
}
|
||
finally {
|
||
if ($gzipStream) { $gzipStream.Dispose() }
|
||
if ($outputStream) { $outputStream.Dispose() }
|
||
if ($inputStream) { $inputStream.Dispose() }
|
||
}
|
||
}
|
||
|
||
function Get-RegctlPath {
|
||
$toolBase = if ($IsWindows) { $env:LOCALAPPDATA } else { Join-Path $HOME '.cache' }
|
||
$toolRoot = Join-Path $toolBase 'CaiWuXiTong/tools'
|
||
$regctlAsset = 'regctl-windows-amd64.exe'
|
||
$regctlExtension = '.exe'
|
||
$expectedHash = $regctlSha256
|
||
if ($IsMacOS -and [Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) {
|
||
$regctlAsset = 'regctl-darwin-arm64'
|
||
$regctlExtension = ''
|
||
$expectedHash = 'f4d536d64d0c3cc1db7400902175a1c314675991d22e87e15c319501a2676d3f'
|
||
}
|
||
elseif ($IsMacOS) {
|
||
$regctlAsset = 'regctl-darwin-amd64'
|
||
$regctlExtension = ''
|
||
$expectedHash = 'c132fdddda68b9c7584ac19f3b40cd17f71916c2bca8182270ebe65b55198a12'
|
||
}
|
||
elseif ($IsLinux -and [Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) {
|
||
$regctlAsset = 'regctl-linux-arm64'
|
||
$regctlExtension = ''
|
||
$expectedHash = 'c4cf231e74cda685f1599f3d866b02b03c572e54b79ec8b062f32070b0ba4587'
|
||
}
|
||
elseif ($IsLinux) {
|
||
$regctlAsset = 'regctl-linux-amd64'
|
||
$regctlExtension = ''
|
||
}
|
||
$regctlPath = Join-Path $toolRoot "regctl-$regctlVersion$regctlExtension"
|
||
|
||
if (Test-Path -LiteralPath $regctlPath) {
|
||
$actualHash = (Get-FileHash -LiteralPath $regctlPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||
if ($actualHash -eq $expectedHash) {
|
||
if (-not $IsWindows) { chmod +x $regctlPath }
|
||
return $regctlPath
|
||
}
|
||
Remove-Item -LiteralPath $regctlPath -Force
|
||
}
|
||
|
||
New-Item -ItemType Directory -Path $toolRoot -Force | Out-Null
|
||
$downloadPath = "$regctlPath.download"
|
||
try {
|
||
Invoke-WebRequest `
|
||
-Uri "https://github.com/regclient/regclient/releases/download/$regctlVersion/$regctlAsset" `
|
||
-OutFile $downloadPath
|
||
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||
if ($actualHash -ne $expectedHash) {
|
||
throw "regctl 校验失败:预期 $expectedHash,实际 $actualHash。"
|
||
}
|
||
Move-Item -LiteralPath $downloadPath -Destination $regctlPath -Force
|
||
if (-not $IsWindows) { chmod +x $regctlPath }
|
||
}
|
||
finally {
|
||
if (Test-Path -LiteralPath $downloadPath) {
|
||
Remove-Item -LiteralPath $downloadPath -Force
|
||
}
|
||
}
|
||
|
||
return $regctlPath
|
||
}
|
||
|
||
function Get-AvailableLocalPort {
|
||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||
try {
|
||
$listener.Start()
|
||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||
}
|
||
finally {
|
||
$listener.Stop()
|
||
}
|
||
}
|
||
|
||
function Start-RegistryTunnel {
|
||
param([int]$LocalPort)
|
||
|
||
$sshPath = (Get-Command ssh -ErrorAction Stop).Source
|
||
$curlPath = (Get-Command $(if ($IsWindows) { 'curl.exe' } else { 'curl' }) -CommandType Application -ErrorAction Stop).Source
|
||
$startInfo = [Diagnostics.ProcessStartInfo]::new()
|
||
$startInfo.FileName = $sshPath
|
||
$startInfo.UseShellExecute = $false
|
||
$startInfo.CreateNoWindow = $true
|
||
foreach ($argument in ($tunnelSshOptions + @(
|
||
'-N',
|
||
'-o', 'ConnectTimeout=10',
|
||
'-o', 'ExitOnForwardFailure=yes',
|
||
'-o', 'ServerAliveInterval=15',
|
||
'-o', 'ServerAliveCountMax=20',
|
||
'-L', "127.0.0.1:${LocalPort}:127.0.0.1:5000",
|
||
$sshTarget
|
||
))) {
|
||
$startInfo.ArgumentList.Add($argument)
|
||
}
|
||
|
||
$process = [Diagnostics.Process]::Start($startInfo)
|
||
$attempt = 1
|
||
while ($attempt -le 20) {
|
||
if ($process.HasExited) {
|
||
throw 'SSH 增量传输隧道启动失败。'
|
||
}
|
||
& $curlPath `
|
||
--fail `
|
||
--silent `
|
||
--show-error `
|
||
--max-time 1 `
|
||
"http://127.0.0.1:$LocalPort/v2/" 2>$null | Out-Null
|
||
if ($LASTEXITCODE -eq 0) {
|
||
return $process
|
||
}
|
||
|
||
Start-Sleep -Milliseconds 500
|
||
$attempt++
|
||
}
|
||
|
||
$process.Kill()
|
||
$process.Dispose()
|
||
throw 'SSH 隧道已建立,但无法访问服务器本机 Registry。'
|
||
}
|
||
|
||
Push-Location $repoRoot
|
||
try {
|
||
$branch = (git branch --show-current).Trim()
|
||
if (-not $AllowNonMain -and $branch -ne 'main') {
|
||
throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。"
|
||
}
|
||
|
||
$trackedChanges = git status --porcelain --untracked-files=no
|
||
if ($trackedChanges) {
|
||
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||
}
|
||
|
||
$deploySha = (git rev-parse HEAD).Trim()
|
||
$imageTag = $deploySha.Substring(0, 12)
|
||
$fullImage = "${imageName}:$imageTag"
|
||
$buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||
|
||
if (-not $AppUrl) {
|
||
$AppUrl = if ($Server -eq '218.93.190.75') {
|
||
'https://caiwu.jiansuntuzhi.cn:8442'
|
||
}
|
||
else {
|
||
"https://${Server}"
|
||
}
|
||
}
|
||
|
||
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
|
||
|
||
$authSession = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser -KeyPath $KeyPath -Password $Password -UpdatePassword:$UpdatePassword
|
||
$sshOptions = $authSession.Options
|
||
$tunnelSshOptions = @($sshOptions)
|
||
if ($sshControlPath) {
|
||
$sshOptions += @('-o', 'ControlMaster=auto', '-o', 'ControlPersist=600', '-o', "ControlPath=$sshControlPath")
|
||
}
|
||
Invoke-NativeWithRetry { ssh @sshOptions $sshTarget true } '服务器登录失败或已取消,已停止部署。' -Attempts 3
|
||
if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null }
|
||
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
|
||
|
||
# deploy-auth.ps1 is dot-sourced and can clear the automatic script path
|
||
# variable on PowerShell Core. Restore it before invoking the build helper.
|
||
$PSScriptRoot = $scriptRoot
|
||
$pwshPath = (Get-Process -Id $PID).Path
|
||
if ([string]::IsNullOrWhiteSpace($pwshPath)) {
|
||
throw '无法定位当前 PowerShell 可执行文件。'
|
||
}
|
||
& $pwshPath -NoProfile -File (Join-Path $scriptRoot 'build-frontend.ps1')
|
||
|
||
docker buildx inspect $BuilderName 2>$null | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Invoke-Native {
|
||
docker buildx create `
|
||
--name $BuilderName `
|
||
--driver docker-container `
|
||
--driver-opt "image=$BuildkitImage" `
|
||
--buildkitd-config $buildkitConfig `
|
||
--use | Out-Null
|
||
} '创建项目专属 BuildKit 构建器失败。'
|
||
}
|
||
|
||
Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。'
|
||
|
||
Invoke-Native {
|
||
docker buildx build `
|
||
--builder $BuilderName `
|
||
--platform $targetPlatform `
|
||
--load `
|
||
--tag $fullImage `
|
||
--build-context 'frontend=.caiwu-build/public' `
|
||
--build-arg "VCS_REF=$deploySha" `
|
||
--build-arg "BUILD_DATE=$buildDate" `
|
||
--build-arg 'APP_VERSION=3.1.15' `
|
||
--label 'com.caiwu.project=CaiWuXiTong' `
|
||
--file deploy/Dockerfile `
|
||
.
|
||
} '本机镜像构建失败。服务器未发生任何变更。'
|
||
|
||
# 在无网络、无生产配置的临时容器中引导 Laravel,拦截开发包缓存混入镜像。
|
||
Invoke-Native {
|
||
docker run --rm --network none --user www-data --entrypoint php $fullImage artisan --version
|
||
} '发布镜像无法引导 Laravel。服务器未发生任何变更。'
|
||
|
||
# Compare the image's source and compiled assets with this workstation build.
|
||
# This also catches a Docker COPY or stale build-context overwrite before upload.
|
||
foreach ($asset in @(
|
||
@{ Local = 'resources/assets/js/components/business/AccountBills.vue'; Image = 'resources/assets/js/components/business/AccountBills.vue' },
|
||
@{ Local = '.caiwu-build/public/js/business/pages.min.js'; Image = 'public/js/business/pages.min.js' },
|
||
@{ Local = '.caiwu-build/public/mix-manifest.json'; Image = 'public/mix-manifest.json' }
|
||
)) {
|
||
$expectedHash = (Get-FileHash -LiteralPath (Join-Path $repoRoot $asset.Local) -Algorithm SHA256).Hash.ToLowerInvariant()
|
||
$imageHash = @(& docker run --rm --network none --user www-data --entrypoint sha256sum $fullImage $asset.Image)
|
||
if ($LASTEXITCODE -ne 0 -or ($imageHash -join ' ') -notmatch "^$expectedHash\s") {
|
||
throw "发布镜像源码或前端资源不匹配:$($asset.Image)。服务器未发生任何变更。"
|
||
}
|
||
}
|
||
|
||
$previousShaOutput = @(& ssh @sshOptions -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true")
|
||
$previousSha = ($previousShaOutput -join "`n").Trim()
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw '无法连接服务器。'
|
||
}
|
||
|
||
$previousShaExists = $false
|
||
if ($previousSha) {
|
||
git cat-file -e "${previousSha}^{commit}" 2>$null
|
||
$previousShaExists = $LASTEXITCODE -eq 0
|
||
}
|
||
|
||
if ($previousSha -and -not $previousShaExists) {
|
||
# 线上可能是别人刚发的热修复分支,先拉取再判断,避免误判。
|
||
git fetch origin --quiet 2>$null
|
||
git cat-file -e "${previousSha}^{commit}" 2>$null
|
||
$previousShaExists = $LASTEXITCODE -eq 0
|
||
}
|
||
|
||
# 防覆盖:线上当前版本必须包含在本次要发布的版本里,否则发布会把别人已上线的改动撤掉。
|
||
if ($previousSha -and -not $AllowRollback) {
|
||
if (-not $previousShaExists) {
|
||
throw "线上当前版本 $previousSha 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
|
||
}
|
||
git merge-base --is-ancestor $previousSha $deploySha 2>$null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
$previousInfo = (git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' $previousSha) -join ''
|
||
throw "线上当前版本($previousInfo)不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
|
||
}
|
||
}
|
||
|
||
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
|
||
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 -AllowMigrations。"
|
||
}
|
||
|
||
if ($previousSha -and $previousShaExists) {
|
||
$migrationChanges = git diff --diff-filter=ACMRTUXB --name-only "$previousSha..$deploySha" -- `
|
||
database/migrations `
|
||
':(glob)modules/*/Database/Migrations/*.php'
|
||
if ($migrationChanges -and -not $AllowMigrations) {
|
||
throw "检测到数据库迁移:$($migrationChanges -join ', ')。由 Codex 核对全部待执行迁移;本任务镜像迁移完成备份后可直接添加 -AllowMigrations,其他迁移按 AGENTS.md 执行。"
|
||
}
|
||
}
|
||
|
||
New-Item -ItemType Directory -Path $tempRoot | Out-Null
|
||
|
||
if ($BootstrapServer) {
|
||
$bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh"
|
||
Invoke-Native {
|
||
scp @scpOptions @sshOptions deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote"
|
||
} '上传服务器初始化脚本失败。'
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'"
|
||
} '服务器初始化失败。'
|
||
}
|
||
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
|
||
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
|
||
|
||
Ensure-QiniuBusinessStorageRuntime
|
||
|
||
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
|
||
Invoke-Native {
|
||
scp @scpOptions @sshOptions `
|
||
compose.jiangling.yml `
|
||
deploy/activate-release.sh `
|
||
deploy/activate-incremental-release.sh `
|
||
deploy/config-db-init.sh `
|
||
deploy/run-finance-batch.sh `
|
||
deploy/clickhouse-resource-policy.xml `
|
||
deploy/update-runtime-url.sh `
|
||
deploy/configure-dingtalk-callback.sh `
|
||
deploy/host-nginx-8442-dingtalk.conf `
|
||
deploy/setup-incremental-registry.sh `
|
||
deploy/registry-config.yml `
|
||
"${sshTarget}:$RemotePath/"
|
||
} '上传运行配置失败。'
|
||
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
|
||
} '更新服务器访问地址失败。'
|
||
|
||
if ($BootstrapServer) {
|
||
docker image inspect $DatabaseImage 2>$null | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
docker image inspect $DatabaseMirrorImage 2>$null | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Invoke-Native { docker pull $DatabaseMirrorImage } '下载数据库运行镜像失败。'
|
||
}
|
||
Invoke-Native { docker tag $DatabaseMirrorImage $DatabaseImage } '标记数据库运行镜像失败。'
|
||
}
|
||
|
||
Invoke-Native {
|
||
docker save --output $localTar $fullImage $DatabaseImage
|
||
} '导出应用与数据库运行镜像失败。'
|
||
Compress-Gzip -Source $localTar -Destination $localArchive
|
||
Remove-Item -LiteralPath $localTar -Force
|
||
$archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1)
|
||
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
|
||
Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||
} '服务器激活、健康检查或增量仓库初始化失败。'
|
||
}
|
||
else {
|
||
Invoke-NativeWithRetry {
|
||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
|
||
} '服务器增量镜像仓库初始化失败。'
|
||
|
||
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
|
||
# 直接导出 BuildKit 原生 OCI 层,让未变化层的压缩摘要跨版本保持稳定。
|
||
# 注意:360/金山等安全软件会拦截未签名的 regctl(启动即“拒绝访问”),
|
||
# 这类开发机可以用 -SkipRegctl,或者交给下面的自动降级改走 docker 直传。
|
||
Invoke-Native {
|
||
docker buildx build `
|
||
--builder $BuilderName `
|
||
--platform $targetPlatform `
|
||
--output "type=oci,dest=$localTar" `
|
||
--tag $fullImage `
|
||
--build-context 'frontend=.caiwu-build/public' `
|
||
--build-arg "VCS_REF=$deploySha" `
|
||
--build-arg "BUILD_DATE=$buildDate" `
|
||
--build-arg 'APP_VERSION=3.1.15' `
|
||
--label 'com.caiwu.project=CaiWuXiTong' `
|
||
--file deploy/Dockerfile `
|
||
.
|
||
} '导出稳定分层的 OCI 应用镜像失败。'
|
||
$serverRegistryImage = "127.0.0.1:5000/caiwuxitong/app:$imageTag"
|
||
|
||
$beforeBytesOutput = @(& ssh @sshOptions $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
|
||
$beforeBytes = [long](($beforeBytesOutput -join '').Trim())
|
||
$uploadTimer = [Diagnostics.Stopwatch]::StartNew()
|
||
$uploadSucceeded = $false
|
||
if (-not $SkipRegctl) {
|
||
try {
|
||
$regctlPath = Get-RegctlPath
|
||
$localRegistryPort = Get-AvailableLocalPort
|
||
$sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort
|
||
$registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag"
|
||
|
||
for ($uploadAttempt = 1; $uploadAttempt -le 2; $uploadAttempt++) {
|
||
if ($uploadAttempt -gt 1) {
|
||
if ($sshTunnel) {
|
||
if (-not $sshTunnel.HasExited) {
|
||
$sshTunnel.Kill()
|
||
$sshTunnel.WaitForExit()
|
||
}
|
||
$sshTunnel.Dispose()
|
||
}
|
||
|
||
$localRegistryPort = Get-AvailableLocalPort
|
||
$sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort
|
||
$registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag"
|
||
}
|
||
|
||
if ($sshTunnel.HasExited) {
|
||
$uploadExitCode = 255
|
||
}
|
||
else {
|
||
& $regctlPath `
|
||
--host "reg=127.0.0.1:${localRegistryPort},tls=disabled" `
|
||
image import $registryImage $localTar
|
||
$uploadExitCode = $LASTEXITCODE
|
||
}
|
||
|
||
if ($uploadExitCode -eq 0) {
|
||
$uploadSucceeded = $true
|
||
break
|
||
}
|
||
|
||
if ($uploadAttempt -eq 1) {
|
||
Write-Warning '增量镜像隧道在上传期间中断,正在重新建立隧道并重试一次。'
|
||
}
|
||
}
|
||
}
|
||
catch {
|
||
Write-Warning "regctl 无法运行(本机安全软件可能拦截了未签名程序):$($_.Exception.Message)"
|
||
}
|
||
}
|
||
else {
|
||
Write-Host '已指定 -SkipRegctl:跳过 regctl,直接用 docker 直传镜像。'
|
||
}
|
||
|
||
if (-not $uploadSucceeded) {
|
||
if (-not $SkipRegctl) {
|
||
Write-Warning '改用 docker 直传方式上传镜像(与 regctl 增量导入等效,只是改为传输完整镜像包)。'
|
||
}
|
||
|
||
if ($sshTunnel) {
|
||
if (-not $sshTunnel.HasExited) {
|
||
$sshTunnel.Kill()
|
||
$sshTunnel.WaitForExit()
|
||
}
|
||
$sshTunnel.Dispose()
|
||
$sshTunnel = $null
|
||
}
|
||
|
||
if (Test-Path -LiteralPath $localTar) {
|
||
Remove-Item -LiteralPath $localTar -Force
|
||
}
|
||
|
||
Invoke-Native { docker save --output $localTar $fullImage } '导出应用镜像失败。'
|
||
Compress-Gzip -Source $localTar -Destination $localArchive
|
||
$archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1)
|
||
Write-Host "docker 直传镜像包:$archiveSizeMB MiB"
|
||
Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "gunzip -cf '$remoteArchive' | docker load && docker tag '$fullImage' '$serverRegistryImage' && docker push '$serverRegistryImage'"
|
||
} '服务器加载镜像或推入本机 Registry 失败。'
|
||
$uploadSucceeded = $true
|
||
}
|
||
|
||
if (-not $uploadSucceeded) {
|
||
throw '增量镜像层上传失败。'
|
||
}
|
||
$uploadTimer.Stop()
|
||
$afterBytesOutput = @(& ssh @sshOptions $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1")
|
||
$afterBytes = [long](($afterBytesOutput -join '').Trim())
|
||
$incrementalMiB = [math]::Round([math]::Max(0, $afterBytes - $beforeBytes) / 1MB, 2)
|
||
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
|
||
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
|
||
} '服务器增量激活或健康检查失败。'
|
||
}
|
||
|
||
$expectedPageBundleHash = (Get-FileHash -LiteralPath (Join-Path $repoRoot '.caiwu-build/public/js/business/pages.min.js') -Algorithm SHA256).Hash.ToLowerInvariant()
|
||
$deployedPageBundleHashOutput = @(& ssh @sshOptions $sshTarget "docker exec caiwu_app sha256sum /var/www/html/public/js/business/pages.min.js | cut -d ' ' -f1")
|
||
if ($LASTEXITCODE -ne 0 -or ($deployedPageBundleHashOutput -join '').Trim().ToLowerInvariant() -ne $expectedPageBundleHash) {
|
||
throw '生产容器中的业务页签脚本与本次构建不一致。'
|
||
}
|
||
|
||
if ($ConfigureDingTalkCallback) {
|
||
Invoke-Native {
|
||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/configure-dingtalk-callback.sh' && '$RemotePath/configure-dingtalk-callback.sh' '$Server' '$RemotePath'"
|
||
} '钉钉 HTTP 回调 Nginx 配置失败。'
|
||
}
|
||
|
||
Remove-OldProjectImages -CurrentImage $fullImage
|
||
Write-Host "发布完成:$fullImage -> $AppUrl"
|
||
}
|
||
finally {
|
||
try {
|
||
Pop-Location
|
||
|
||
if (Test-Path -LiteralPath $tempRoot) {
|
||
Remove-Item -LiteralPath $tempRoot -Recurse -Force
|
||
}
|
||
|
||
if ($sshTunnel) {
|
||
if (-not $sshTunnel.HasExited) {
|
||
$sshTunnel.Kill()
|
||
}
|
||
$sshTunnel.Dispose()
|
||
}
|
||
|
||
if ($remoteArchive) {
|
||
ssh @sshOptions -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
|
||
}
|
||
|
||
if ($bootstrapRemote) {
|
||
ssh @sshOptions -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null
|
||
}
|
||
|
||
if ($sshControlPath) {
|
||
ssh @sshOptions -O exit $sshTarget 2>$null | Out-Null
|
||
}
|
||
|
||
# Keep the compiled PHP base and Composer vendor layers between releases.
|
||
# The production build cache is normally about 20 GB; pruning it to 3 GB made
|
||
# every deployment rebuild PHP extensions and download all dependencies again.
|
||
$pruneHelp = docker buildx prune --help 2>$null | Out-String
|
||
$buildCacheMaxAge = "${BuildCacheMaxAgeHours}h"
|
||
docker buildx prune --builder $BuilderName --filter "until=$buildCacheMaxAge" --force 2>$null | Out-Null
|
||
if ($pruneHelp -match '--max-used-space') {
|
||
docker buildx prune --builder $BuilderName --max-used-space $BuildCacheMaxUsedSpace --force 2>$null | Out-Null
|
||
}
|
||
elseif ($pruneHelp -match '--keep-storage') {
|
||
docker buildx prune --builder $BuilderName --keep-storage $BuildCacheMaxUsedSpace --force 2>$null | Out-Null
|
||
}
|
||
|
||
docker system df
|
||
} finally {
|
||
Stop-CaiwuDeploymentAuth $authSession
|
||
}
|
||
}
|