CaiWuXiTong/scripts/deploy-from-workstation.ps1
limeng e4d82dab2a chore(部署): 发布前拦截会覆盖线上已发布提交的版本
今天连续发生多次热修复/旧版本发布把别人刚上线的改动冲掉(种菜损耗运费口径被撤回两次)。
发布脚本读取线上 .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
2026-10-19 18:00:07 +08:00

706 lines
29 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

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

[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
}
}