412 lines
12 KiB
PowerShell
412 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 an unsafe long default.
|
|
set -a
|
|
source "$SHARED/.env"
|
|
set +a
|
|
worker_timeout="${BOM_COSTING_WORKER_REQUEST_TIMEOUT_SECONDS:-1200}"
|
|
# One worker turn contains its own per-request limits. Never let a stale
|
|
# environment value turn a failed model call into hours of silent waiting.
|
|
if ! [[ "$worker_timeout" =~ ^[1-9][0-9]*$ ]] || [ "$worker_timeout" -lt 60 ] || [ "$worker_timeout" -gt 1200 ]; then
|
|
worker_timeout=1200
|
|
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
|
|
}
|