CaiWuXiTong/scripts/deploy-from-workstation.ps1

382 lines
14 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,
[switch]$BootstrapServer,
[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 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
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $sshPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
foreach ($argument in @(
'-N',
'-i', $keyFullPath,
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=15',
'-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 增量传输隧道启动失败。'
}
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$LocalPort/v2/" -TimeoutSec 1 | Out-Null
return $process
}
catch {
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 = "http://${Server}:18088"
}
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
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。'
$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/update-runtime-url.sh `
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 && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
} '服务器激活、健康检查或增量仓库初始化失败。'
}
else {
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
} '服务器增量镜像仓库初始化失败。'
Invoke-Native { docker save --output $localTar $fullImage } '导出本次应用镜像失败。'
$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()
Invoke-Native {
& $regctlPath `
--host "reg=127.0.0.1:${localRegistryPort},tls=disabled" `
image import $registryImage $localTar
} '增量镜像层上传失败。'
$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'"
} '服务器增量激活或健康检查失败。'
}
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
}
$pruneHelp = docker buildx prune --help 2>$null | Out-String
docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null
if ($pruneHelp -match '--max-used-space') {
docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null
}
elseif ($pruneHelp -match '--keep-storage') {
docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null
}
docker system df
}