229 lines
8.4 KiB
PowerShell
229 lines
8.4 KiB
PowerShell
[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',
|
||
[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
|
||
$buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
|
||
|
||
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() }
|
||
}
|
||
}
|
||
|
||
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"
|
||
}
|
||
|
||
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。'
|
||
|
||
Invoke-Native { docker save --output $localTar $fullImage } '导出镜像失败。'
|
||
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 { scp -i $keyFullPath -o BatchMode=yes compose.jiangling.yml deploy/activate-release.sh "${sshTarget}:$RemotePath/" } '上传运行配置失败。'
|
||
|
||
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
|
||
Invoke-Native {
|
||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/activate-release.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$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 ($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
|
||
}
|