341 lines
9.3 KiB
PowerShell
341 lines
9.3 KiB
PowerShell
param(
|
|
[string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot),
|
|
[string]$ProjectSubdir = "furniture-3d-system",
|
|
[string]$OutputRoot = "",
|
|
[string]$Ref = "origin/main",
|
|
[string]$RemoteBase = "/opt/furniture-3d-system-prod",
|
|
[string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai",
|
|
[int]$AppPort = 3001,
|
|
[string]$Pm2Name = "furniture-3d-system-prod",
|
|
[int]$KeepReleases = 5
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
|
|
$OutputRoot = Join-Path $env:TEMP '3danzhuang-deploy-artifacts\prod'
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $RepoRoot)) {
|
|
throw "仓库目录不存在: $RepoRoot"
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $OutputRoot)) {
|
|
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
|
|
}
|
|
|
|
$projectRoot = Join-Path $RepoRoot $ProjectSubdir
|
|
if (-not (Test-Path -LiteralPath $projectRoot)) {
|
|
throw "项目目录不存在: $projectRoot"
|
|
}
|
|
|
|
function Invoke-NativeCommand {
|
|
param(
|
|
[string]$FilePath,
|
|
[string[]]$Arguments,
|
|
[string]$WorkingDirectory = $RepoRoot
|
|
)
|
|
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
|
|
try {
|
|
$ErrorActionPreference = 'Continue'
|
|
Push-Location -LiteralPath $WorkingDirectory
|
|
try {
|
|
$output = & $FilePath @Arguments 2>&1
|
|
$exitCode = $LASTEXITCODE
|
|
}
|
|
finally {
|
|
Pop-Location
|
|
}
|
|
|
|
$lines = @(
|
|
$output |
|
|
ForEach-Object {
|
|
if ($_ -is [System.Management.Automation.ErrorRecord]) {
|
|
$_.ToString()
|
|
}
|
|
else {
|
|
[string]$_
|
|
}
|
|
} |
|
|
Where-Object { $_.Length -gt 0 }
|
|
)
|
|
|
|
if ($exitCode -ne 0) {
|
|
throw (($lines -join [Environment]::NewLine).Trim())
|
|
}
|
|
|
|
return $lines
|
|
}
|
|
finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
}
|
|
|
|
function Invoke-Git {
|
|
param(
|
|
[Parameter(ValueFromRemainingArguments = $true)]
|
|
[string[]]$Arguments
|
|
)
|
|
|
|
return Invoke-NativeCommand -FilePath 'git' -Arguments (@('-C', $RepoRoot) + $Arguments) -WorkingDirectory $RepoRoot
|
|
}
|
|
|
|
if ($Ref -match '^(origin/|refs/remotes/origin/)') {
|
|
Invoke-Git fetch origin | Out-Null
|
|
}
|
|
|
|
$commit = (Invoke-Git rev-parse $Ref | Select-Object -First 1).Trim()
|
|
if ([string]::IsNullOrWhiteSpace($commit)) {
|
|
throw "无法解析部署引用: $Ref"
|
|
}
|
|
|
|
$shortCommit = $commit.Substring(0, 7)
|
|
$releaseId = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $shortCommit
|
|
$archiveName = "deploy-furniture-prod-$releaseId.tar.gz"
|
|
$scriptName = "deploy-furniture-prod-$releaseId.sh"
|
|
$archivePath = Join-Path $OutputRoot $archiveName
|
|
$scriptPath = Join-Path $OutputRoot $scriptName
|
|
$remoteIncoming = "$RemoteBase/incoming"
|
|
$remoteArchivePath = "$remoteIncoming/$archiveName"
|
|
$remoteScriptPath = "$remoteIncoming/$scriptName"
|
|
|
|
if (Test-Path -LiteralPath $archivePath) {
|
|
Remove-Item -LiteralPath $archivePath -Force
|
|
}
|
|
if (Test-Path -LiteralPath $scriptPath) {
|
|
Remove-Item -LiteralPath $scriptPath -Force
|
|
}
|
|
|
|
Invoke-Git archive --format=tar.gz "--output=$archivePath" "--prefix=$ProjectSubdir/" "$commit`:$ProjectSubdir" | Out-Null
|
|
|
|
$shellScript = @'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
BASE="__REMOTE_BASE__"
|
|
INCOMING="$BASE/incoming"
|
|
RELEASES="$BASE/releases"
|
|
SHARED="$BASE/shared"
|
|
CURRENT="$BASE/current"
|
|
LEGACY_APP="$BASE/__PROJECT_SUBDIR__"
|
|
|
|
RELEASE_ID="__RELEASE_ID__"
|
|
ARCHIVE="$INCOMING/__ARCHIVE_NAME__"
|
|
APP_PORT="__APP_PORT__"
|
|
PM2_NAME="__PM2_NAME__"
|
|
KEEP_RELEASES="__KEEP_RELEASES__"
|
|
COMMIT_SHA="__COMMIT_SHA__"
|
|
SOURCE_REF="__SOURCE_REF__"
|
|
|
|
assert_path_in_base() {
|
|
local target="$1"
|
|
case "$target" in
|
|
"$BASE"/*) ;;
|
|
*)
|
|
echo "Refuse to touch path outside deployment base: $target" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
copy_legacy_env_if_needed() {
|
|
if [ -f "$SHARED/.env" ]; then
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$SHARED"
|
|
|
|
if [ -f "$BASE/.env" ]; then
|
|
cp "$BASE/.env" "$SHARED/.env"
|
|
return 0
|
|
fi
|
|
|
|
if [ -f "$LEGACY_APP/.env" ]; then
|
|
cp "$LEGACY_APP/.env" "$SHARED/.env"
|
|
return 0
|
|
fi
|
|
|
|
echo "Missing shared env file. Expected one of:" >&2
|
|
echo " $SHARED/.env" >&2
|
|
echo " $BASE/.env" >&2
|
|
echo " $LEGACY_APP/.env" >&2
|
|
exit 1
|
|
}
|
|
|
|
start_pm2_release() {
|
|
local release_root="$1"
|
|
local app_root="$release_root/__PROJECT_SUBDIR__"
|
|
local start_command
|
|
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT"
|
|
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
|
|
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
|
|
pm2 save >/dev/null
|
|
}
|
|
|
|
cleanup_old_releases() {
|
|
if [ "$KEEP_RELEASES" -le 0 ]; then
|
|
return 0
|
|
fi
|
|
|
|
mapfile -t release_dirs < <(find "$RELEASES" -mindepth 1 -maxdepth 1 -type d | sort)
|
|
local total="${#release_dirs[@]}"
|
|
if [ "$total" -le "$KEEP_RELEASES" ]; then
|
|
return 0
|
|
fi
|
|
|
|
local current_real=""
|
|
current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
local remove_count=$((total - KEEP_RELEASES))
|
|
|
|
for ((i=0; i<remove_count; i++)); do
|
|
local candidate="${release_dirs[$i]}"
|
|
if [ -n "$current_real" ] && [ "$candidate" = "$current_real" ]; then
|
|
continue
|
|
fi
|
|
assert_path_in_base "$candidate"
|
|
rm -rf -- "$candidate"
|
|
done
|
|
}
|
|
|
|
ensure_shared_non_integrated_assets() {
|
|
local app_root="$1"
|
|
local shared_assets="$SHARED/non-integrated-test-assets"
|
|
local release_assets="$app_root/public/non-integrated-test-assets"
|
|
|
|
assert_path_in_base "$shared_assets"
|
|
mkdir -p "$shared_assets"
|
|
|
|
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE/__PROJECT_SUBDIR__/public/non-integrated-test-assets" ]; then
|
|
cp -a "$PREVIOUS_RELEASE/__PROJECT_SUBDIR__/public/non-integrated-test-assets/." "$shared_assets/" 2>/dev/null || true
|
|
fi
|
|
|
|
if [ -d "$LEGACY_APP/public/non-integrated-test-assets" ] && [ ! -L "$LEGACY_APP/public/non-integrated-test-assets" ]; then
|
|
cp -a "$LEGACY_APP/public/non-integrated-test-assets/." "$shared_assets/" 2>/dev/null || true
|
|
fi
|
|
|
|
rm -rf -- "$release_assets"
|
|
mkdir -p "$(dirname "$release_assets")"
|
|
ln -sfn "$shared_assets" "$release_assets"
|
|
}
|
|
|
|
mkdir -p "$INCOMING" "$RELEASES" "$SHARED"
|
|
copy_legacy_env_if_needed
|
|
|
|
RELEASE_DIR="$RELEASES/$RELEASE_ID"
|
|
APP_DIR="$RELEASE_DIR/__PROJECT_SUBDIR__"
|
|
PREVIOUS_RELEASE=""
|
|
PREVIOUS_RELEASE="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
HEALTHCHECK_FILE="/tmp/furniture-3d-system-prod-healthcheck-$RELEASE_ID.txt"
|
|
DEPLOY_SUCCEEDED=0
|
|
|
|
cleanup_failed_release() {
|
|
local status="$1"
|
|
|
|
rm -f -- "${HEALTHCHECK_FILE:-}" >/dev/null 2>&1 || true
|
|
|
|
if [ "$DEPLOY_SUCCEEDED" -eq 1 ]; then
|
|
exit "$status"
|
|
fi
|
|
|
|
if [ -n "${RELEASE_DIR:-}" ] && [ -d "$RELEASE_DIR" ]; then
|
|
local current_real=""
|
|
current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)"
|
|
|
|
if [ -n "$current_real" ] && [ "$current_real" = "$RELEASE_DIR" ]; then
|
|
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then
|
|
ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"
|
|
if ! start_pm2_release "$CURRENT"; then
|
|
echo "Rollback start failed for previous release: $PREVIOUS_RELEASE" >&2
|
|
status=1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
assert_path_in_base "$RELEASE_DIR"
|
|
rm -rf -- "$RELEASE_DIR"
|
|
fi
|
|
|
|
exit "$status"
|
|
}
|
|
|
|
trap 'cleanup_failed_release "$?"' EXIT
|
|
|
|
assert_path_in_base "$RELEASE_DIR"
|
|
rm -rf -- "$RELEASE_DIR"
|
|
mkdir -p "$RELEASE_DIR"
|
|
|
|
tar -xzf "$ARCHIVE" -C "$RELEASE_DIR"
|
|
|
|
if [ ! -d "$APP_DIR" ]; then
|
|
echo "Archive extracted but app directory missing: $APP_DIR" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf '%s\n' "$COMMIT_SHA" > "$RELEASE_DIR/REVISION"
|
|
printf '%s\n' "$SOURCE_REF" > "$RELEASE_DIR/SOURCE_REF"
|
|
|
|
ln -sfn "$SHARED/.env" "$APP_DIR/.env"
|
|
ensure_shared_non_integrated_assets "$APP_DIR"
|
|
|
|
cd "$APP_DIR"
|
|
export PUPPETEER_SKIP_DOWNLOAD=true
|
|
npm ci --no-audit --no-fund
|
|
npx prisma generate
|
|
npx prisma migrate deploy
|
|
npm run build
|
|
|
|
ln -sfn "$RELEASE_DIR" "$CURRENT"
|
|
start_pm2_release "$CURRENT"
|
|
|
|
sleep 5
|
|
if ! curl -fsS -I "http://127.0.0.1:$APP_PORT" >"$HEALTHCHECK_FILE"; then
|
|
echo "New release health check failed. Rolling back..." >&2
|
|
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then
|
|
ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"
|
|
start_pm2_release "$CURRENT"
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
sed -n '1,12p' "$HEALTHCHECK_FILE"
|
|
cleanup_old_releases
|
|
rm -f -- "$ARCHIVE"
|
|
rm -f -- "$HEALTHCHECK_FILE"
|
|
DEPLOY_SUCCEEDED=1
|
|
trap - EXIT
|
|
echo "=== SAFE RELEASE DEPLOY DONE ==="
|
|
pm2 describe "$PM2_NAME" | sed -n '1,35p'
|
|
'@
|
|
|
|
$shellScript = $shellScript.Replace('__REMOTE_BASE__', $RemoteBase)
|
|
$shellScript = $shellScript.Replace('__PROJECT_SUBDIR__', $ProjectSubdir)
|
|
$shellScript = $shellScript.Replace('__RELEASE_ID__', $releaseId)
|
|
$shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName)
|
|
$shellScript = $shellScript.Replace('__APP_PORT__', [string]$AppPort)
|
|
$shellScript = $shellScript.Replace('__PM2_NAME__', $Pm2Name)
|
|
$shellScript = $shellScript.Replace('__KEEP_RELEASES__', [string]$KeepReleases)
|
|
$shellScript = $shellScript.Replace('__COMMIT_SHA__', $commit)
|
|
$shellScript = $shellScript.Replace('__SOURCE_REF__', $Ref)
|
|
$shellScript = $shellScript -replace "`r`n", "`n"
|
|
|
|
[System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false))
|
|
|
|
$archive = Get-Item -LiteralPath $archivePath
|
|
[PSCustomObject]@{
|
|
Mode = 'safe-git-release'
|
|
Ref = $Ref
|
|
Commit = $commit
|
|
Archive = $archive.FullName
|
|
ArchiveMB = [math]::Round($archive.Length / 1MB, 2)
|
|
Script = $scriptPath
|
|
RemoteArchivePath = $remoteArchivePath
|
|
RemoteScriptPath = $remoteScriptPath
|
|
RemoteBase = $RemoteBase
|
|
ServerHost = $ServerHost
|
|
AppPort = $AppPort
|
|
Pm2Name = $Pm2Name
|
|
KeepReleases = $KeepReleases
|
|
}
|