CaiWuXiTong/scripts/deploy-from-workstation.ps1
2026-09-28 10:53:05 +08:00

640 lines
25 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
)
$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 -and -not $AllowMigrations) {
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 -AllowMigrations。"
}
if ($previousSha -and $previousShaExists) {
$migrationChanges = git diff --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/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 层,让未变化层的压缩摘要跨版本保持稳定。
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 应用镜像失败。'
$regctlPath = Get-RegctlPath
$localRegistryPort = Get-AvailableLocalPort
$sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort
$registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag"
$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
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 '增量镜像隧道在上传期间中断,正在重新建立隧道并重试一次。'
}
}
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
}
}