furniture-3d-system-Wu/scripts/create-yydd-prod-deploy.ps1
2026-08-05 09:58:43 +08:00

410 lines
12 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__"
BOM_WORKER_NAME="${PM2_NAME}-bom-worker"
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
}
prepare_standalone_runtime() {
local app_root="$1"
local standalone_root="$app_root/.next/standalone"
if [ ! -f "$standalone_root/server.js" ]; then
echo "Missing Next standalone server: $standalone_root/server.js" >&2
exit 1
fi
if [ ! -d "$app_root/.next/static" ]; then
echo "Missing Next static assets: $app_root/.next/static" >&2
exit 1
fi
mkdir -p "$standalone_root/.next"
rm -rf -- "$standalone_root/.next/static" "$standalone_root/public"
cp -a "$app_root/.next/static" "$standalone_root/.next/static"
if [ -d "$app_root/public" ]; then
cp -a "$app_root/public" "$standalone_root/public"
fi
}
ensure_bom_costing_worker_secret() {
if grep -qE '^BOM_COSTING_WORKER_SECRET=.+$' "$SHARED/.env"; then
return 0
fi
local secret
secret="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")"
if [ -z "$secret" ]; then
echo "Unable to generate BOM costing worker secret" >&2
exit 1
fi
umask 077
printf '\nBOM_COSTING_WORKER_SECRET=%s\n' "$secret" >> "$SHARED/.env"
chmod 600 "$SHARED/.env" || true
}
start_pm2_release() {
local release_root="$1"
local app_root="$release_root/__PROJECT_SUBDIR__"
local start_command
local worker_command
local worker_timeout
prepare_standalone_runtime "$app_root"
# Read the shared environment before resolving the curl limit. The worker
# command itself sources this file later, but doing it only there leaves the
# already-interpolated --max-time stuck at the 45-minute default.
set -a
source "$SHARED/.env"
set +a
worker_timeout="${BOM_COSTING_WORKER_REQUEST_TIMEOUT_SECONDS:-2700}"
if ! [[ "$worker_timeout" =~ ^[1-9][0-9]*$ ]]; then
worker_timeout=2700
fi
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node .next/standalone/server.js"
worker_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && : \"\${BOM_COSTING_WORKER_SECRET:?BOM_COSTING_WORKER_SECRET is required}\"; while true; do curl --fail --silent --show-error --max-time $worker_timeout --request POST 'http://127.0.0.1:$APP_PORT/api/internal/bom-costing/worker/run' --header \"x-bom-costing-worker-secret: \$BOM_COSTING_WORKER_SECRET\" >/dev/null || echo '[BOM costing worker] loop request failed' >&2; sleep 10; done"
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
pm2 delete "$BOM_WORKER_NAME" >/dev/null 2>&1 || true
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
pm2 start /bin/bash --name "$BOM_WORKER_NAME" -- -lc "$worker_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-assets"
local legacy_shared_assets="$SHARED/non-integrated-test-assets"
local release_data_assets="$app_root/.data/non-integrated-assets"
local release_public_assets="$app_root/public/non-integrated-test-assets"
assert_path_in_base "$shared_assets"
mkdir -p "$shared_assets"
if [ -n "$PREVIOUS_RELEASE" ]; then
if [ -d "$PREVIOUS_RELEASE/__PROJECT_SUBDIR__/.data/non-integrated-assets" ]; then
cp -a "$PREVIOUS_RELEASE/__PROJECT_SUBDIR__/.data/non-integrated-assets/." "$shared_assets/" 2>/dev/null || true
fi
if [ -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
fi
if [ -d "$legacy_shared_assets" ] && [ ! -L "$legacy_shared_assets" ]; then
cp -a "$legacy_shared_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_data_assets" "$release_public_assets"
mkdir -p "$(dirname "$release_data_assets")" "$(dirname "$release_public_assets")"
ln -sfn "$shared_assets" "$release_data_assets"
}
mkdir -p "$INCOMING" "$RELEASES" "$SHARED"
copy_legacy_env_if_needed
ensure_bom_costing_worker_secret
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"
cd "$APP_DIR"
export PUPPETEER_SKIP_DOWNLOAD=true
export ONNXRUNTIME_NODE_INSTALL_CUDA=skip
npm ci --no-audit --no-fund
npx prisma generate
npx prisma migrate deploy
npm run build
ensure_shared_non_integrated_assets "$APP_DIR"
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'
pm2 describe "$BOM_WORKER_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
}