CaiWuXiTong/scripts/deploy-from-workstation.ps1

558 lines
21 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(
[Parameter(Mandatory = $true)]
[string]$Server,
[Parameter(Mandatory = $true)]
[string]$KeyPath,
[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]$ConfigureHttps,
[switch]$AllowNonMain,
[switch]$AllowMigrations
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$keyFullPath = (Resolve-Path $KeyPath).Path
$sshTarget = "${SshUser}@${Server}"
$imageName = 'caiwu_akaunting'
$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 -i $keyFullPath -o BatchMode=yes $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 `
-i $keyFullPath `
-o BatchMode=yes `
$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 {
$toolRoot = Join-Path $env:LOCALAPPDATA 'CaiWuXiTong\tools'
$regctlPath = Join-Path $toolRoot "regctl-$regctlVersion.exe"
if (Test-Path -LiteralPath $regctlPath) {
$actualHash = (Get-FileHash -LiteralPath $regctlPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -eq $regctlSha256) {
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/regctl-windows-amd64.exe" `
-OutFile $downloadPath
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $regctlSha256) {
throw "regctl 校验失败:预期 $regctlSha256,实际 $actualHash。"
}
Move-Item -LiteralPath $downloadPath -Destination $regctlPath -Force
}
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 curl.exe -ErrorAction Stop).Source
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $sshPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
foreach ($argument in @(
'-N',
'-i', $keyFullPath,
'-o', 'BatchMode=yes',
'-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"
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
$buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
if (-not $AppUrl) {
$AppUrl = if ($Server -eq '218.93.190.75') {
"https://${Server}:8442"
}
else {
"https://${Server}"
}
}
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
& (Join-Path $PSScriptRoot '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 `
--load `
--tag $fullImage `
--build-context 'frontend=.caiwu-build/public' `
--build-arg "VCS_REF=$deploySha" `
--build-arg "BUILD_DATE=$buildDate" `
--label 'com.caiwu.project=CaiWuXiTong' `
--file deploy/Dockerfile `
.
} '本机镜像构建失败。服务器未发生任何变更。'
$previousShaOutput = @(& ssh -i $keyFullPath -o BatchMode=yes -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,或在负责人授权后使用 -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 ', ')。需负责人确认后添加 -AllowMigrations。"
}
}
New-Item -ItemType Directory -Path $tempRoot | Out-Null
if ($BootstrapServer) {
$bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh"
Invoke-Native {
scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote"
} '上传服务器初始化脚本失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'"
} '服务器初始化失败。'
}
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $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 -i $keyFullPath -o BatchMode=yes `
compose.jiangling.yml `
deploy/activate-release.sh `
deploy/activate-incremental-release.sh `
deploy/config-db-init.sh `
deploy/update-runtime-url.sh `
deploy/setup-https.sh `
deploy/host-nginx-http.conf `
deploy/host-nginx.conf `
deploy/setup-incremental-registry.sh `
deploy/registry-config.yml `
"${sshTarget}:$RemotePath/"
} '上传运行配置失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $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 -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $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 -i $keyFullPath -o BatchMode=yes -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 `
--output "type=oci,dest=$localTar" `
--tag $fullImage `
--build-context 'frontend=.caiwu-build/public' `
--build-arg "VCS_REF=$deploySha" `
--build-arg "BUILD_DATE=$buildDate" `
--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 -i $keyFullPath -o BatchMode=yes $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 -i $keyFullPath -o BatchMode=yes $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 -i $keyFullPath -o BatchMode=yes $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
} '服务器增量激活或健康检查失败。'
}
if ($ConfigureHttps) {
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/setup-https.sh' && '$RemotePath/setup-https.sh' '$Server' '$RemotePath'"
} '服务器 HTTPS 配置或证书申请失败。'
}
Remove-OldProjectImages -CurrentImage $fullImage
Write-Host "发布完成:$fullImage -> $AppUrl"
}
finally {
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 -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
}
if ($bootstrapRemote) {
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 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
}