param( [string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot), [string]$ProjectSubdir = "furniture-3d-system", [string]$Ref = "origin/main", [string]$RemoteBase = "/opt/furniture-3d-system-prod", [string]$RemoteRepoDir = "", [string]$RemoteGitUrl = "", [string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai", [string]$ExpectedHostname = "mr-dao-Default-string", [int]$SshPort = 60022, [string]$ServerUser = "root", [string]$ServerPassword = "", [string]$IdentityFile = "", [string]$HostKeyFingerprint = "SHA256:JPHcomdjuUZOTM7sNdBYg0/xRkQSXzbXe7FEfNtIFAU", [int]$AppPort = 3001, [string]$Pm2Name = "furniture-3d-system-prod", [int]$KeepReleases = 5, [switch]$ForceArchiveFallback, [switch]$KeepArtifacts ) $ErrorActionPreference = "Stop" $createScript = Join-Path $RepoRoot "scripts\create-yydd-prod-deploy.ps1" $plinkPath = "C:\Program Files\PuTTY\plink.exe" $pscpPath = "C:\Program Files\PuTTY\pscp.exe" $useOpenSshIdentity = -not [string]::IsNullOrWhiteSpace($IdentityFile) $sshPath = "" $scpPath = "" $knownHostsPath = Join-Path $env:USERPROFILE ".ssh\known_hosts" if (-not (Test-Path -LiteralPath $createScript)) { throw "Missing package script: $createScript" } if ($useOpenSshIdentity) { if (-not (Test-Path -LiteralPath $IdentityFile)) { throw "SSH identity file does not exist: $IdentityFile" } if (-not (Test-Path -LiteralPath $knownHostsPath)) { throw "OpenSSH known_hosts file is missing: $knownHostsPath" } if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) { throw "Specify either IdentityFile or ServerPassword, not both." } $sshPath = (Get-Command "ssh.exe" -ErrorAction Stop).Source $scpPath = (Get-Command "scp.exe" -ErrorAction Stop).Source } else { if (-not (Test-Path -LiteralPath $plinkPath)) { throw "Missing plink.exe: $plinkPath" } if (-not (Test-Path -LiteralPath $pscpPath)) { throw "Missing pscp.exe: $pscpPath" } } if ([string]::IsNullOrWhiteSpace($RemoteRepoDir)) { $RemoteRepoDir = ("{0}/repo" -f $RemoteBase) } 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 } function Invoke-Plink { param( [string]$Command ) if ($ExpectedHostname -notmatch '^[a-zA-Z0-9._-]+$' -or $RemoteBase -notmatch '^/[a-zA-Z0-9/_-]+$') { throw "Invalid production identity parameters" } $guard = 'test "$(hostname)" = ''{0}'' && test -d ''{1}'' || exit 91' -f $ExpectedHostname, $RemoteBase $Command = $guard + "`n" + $Command if ($useOpenSshIdentity) { $arguments = @( "-o" "BatchMode=yes" "-o" "StrictHostKeyChecking=yes" "-o" ("UserKnownHostsFile={0}" -f $knownHostsPath) "-o" "ConnectTimeout=20" "-o" "ConnectionAttempts=1" "-o" "ServerAliveInterval=15" "-o" "ServerAliveCountMax=2" "-i" $IdentityFile "-p" [string]$SshPort ("{0}@{1}" -f $ServerUser, $ServerHost) $Command ) return Invoke-NativeCommand -FilePath $sshPath -Arguments $arguments -WorkingDirectory $RepoRoot } $arguments = @( "-ssh" "-batch" "-hostkey" $HostKeyFingerprint "-P" [string]$SshPort ) if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) { $arguments += @("-pw", $ServerPassword) } $arguments += @( ("{0}@{1}" -f $ServerUser, $ServerHost) $Command ) return Invoke-NativeCommand -FilePath $plinkPath -Arguments $arguments -WorkingDirectory $RepoRoot } function Copy-WithPscp { param( [string]$LocalPath, [string]$RemotePath ) if (-not (Test-Path -LiteralPath $LocalPath)) { throw "Upload source does not exist: $LocalPath" } if ($useOpenSshIdentity) { $arguments = @( "-q" "-o" "BatchMode=yes" "-o" "StrictHostKeyChecking=yes" "-o" ("UserKnownHostsFile={0}" -f $knownHostsPath) "-o" "ConnectTimeout=20" "-o" "ConnectionAttempts=1" "-i" $IdentityFile "-P" [string]$SshPort $LocalPath ("{0}@{1}:{2}" -f $ServerUser, $ServerHost, $RemotePath) ) return Invoke-NativeCommand -FilePath $scpPath -Arguments $arguments -WorkingDirectory $RepoRoot } $arguments = @( "-batch" "-hostkey" $HostKeyFingerprint "-P" [string]$SshPort ) if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) { $arguments += @("-pw", $ServerPassword) } $arguments += @( $LocalPath ("{0}@{1}:{2}" -f $ServerUser, $ServerHost, $RemotePath) ) return Invoke-NativeCommand -FilePath $pscpPath -Arguments $arguments -WorkingDirectory $RepoRoot } function New-RemoteGitDeployScript { param( [string]$OutputRoot, [string]$RemoteBase, [string]$RemoteRepoDir, [string]$ProjectSubdir, [string]$ReleaseId, [string]$Commit, [string]$SourceRef, [string]$RemoteGitUrl, [int]$AppPort, [string]$Pm2Name, [int]$KeepReleases ) if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null } $scriptName = "deploy-furniture-prod-$ReleaseId-remote.sh" $scriptPath = Join-Path $OutputRoot $scriptName $shellScript = @' #!/usr/bin/env bash set -euo pipefail BASE="__REMOTE_BASE__" REPO_DIR="__REMOTE_REPO_DIR__" RELEASES="$BASE/releases" SHARED="$BASE/shared" CURRENT="$BASE/current" LEGACY_APP="$BASE/__PROJECT_SUBDIR__" PROJECT_SUBDIR="__PROJECT_SUBDIR__" RELEASE_ID="__RELEASE_ID__" 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__" REMOTE_GIT_URL="__REMOTE_GIT_URL__" 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 } __RELEASE_GUARDS__ 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 } ensure_product_development_todo_secret() { if grep -qE '^PRODUCT_DEVELOPMENT_TODO_CRON_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 product development todo cron secret" >&2 exit 1 fi umask 077 printf '\nPRODUCT_DEVELOPMENT_TODO_CRON_SECRET=%s\n' "$secret" >> "$SHARED/.env" chmod 600 "$SHARED/.env" || true } ensure_image_search_model() { local model_dir="$SHARED/models/dinov2-small" local source_base="${IMAGE_SEARCH_MODEL_SOURCE_BASE_URL:-https://www.modelscope.cn/models/Xenova/dinov2-small/resolve/master}" if [ -s "$model_dir/config.json" ] && \ [ -s "$model_dir/preprocessor_config.json" ] && \ [ -s "$model_dir/onnx/model_quantized.onnx" ]; then return 0 fi mkdir -p "$model_dir/onnx" curl --fail --silent --show-error --location "$source_base/config.json" -o "$model_dir/config.json.tmp" curl --fail --silent --show-error --location "$source_base/preprocessor_config.json" -o "$model_dir/preprocessor_config.json.tmp" curl --fail --silent --show-error --location "$source_base/onnx/model_quantized.onnx" -o "$model_dir/onnx/model_quantized.onnx.tmp" mv "$model_dir/config.json.tmp" "$model_dir/config.json" mv "$model_dir/preprocessor_config.json.tmp" "$model_dir/preprocessor_config.json" mv "$model_dir/onnx/model_quantized.onnx.tmp" "$model_dir/onnx/model_quantized.onnx" } start_pm2_release() { local release_root="$1" local app_root="$release_root/$PROJECT_SUBDIR" local start_command local worker_command local worker_timeout assert_release_ready "$app_root" || return 1 # 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 ensure_image_search_model 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 # The BOM worker calls the same Next process. Bound V8 old-space so a long # sequence of streamed model responses is collected incrementally instead # of letting one case grow the application near the host memory limit. start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec env NODE_OPTIONS='--max-old-space-size=2048' IMAGE_SEARCH_MODEL_PATH='$SHARED/models/dinov2-small' 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 } install_virtual_inventory_push_cron() { local cron_file local cron_line local fixed_cron_line local todo_cron_line local lock_file="/tmp/furniture-3d-system-virtual-inventory-push.lock" local fixed_lock_file="/tmp/furniture-3d-system-fixed-virtual-inventory-push.lock" local todo_lock_file="/tmp/furniture-3d-system-product-development-todos.lock" local endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-virtual-inventory-push" local fixed_endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push" local todo_endpoint="http://127.0.0.1:$APP_PORT/api/internal/product-development-todos/run?mode=send" local log_file="$SHARED/logs/jushuitan-virtual-inventory-push.log" local fixed_log_file="$SHARED/logs/jushuitan-fixed-virtual-inventory-push.log" local todo_log_file="$SHARED/logs/product-development-todos.log" mkdir -p "$SHARED/logs" cron_file="$(mktemp)" (crontab -l 2>/dev/null || true) | sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' | sed '\|furniture-3d-system-virtual-inventory-push|d' | sed '\|/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push|d' | sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' | sed '\|/api/internal/product-development-todos/run|d' | sed '\|furniture-3d-system-product-development-todos|d' | sed '\|^CRON_TZ=Asia/Shanghai$|d' > "$cron_file" cron_line="* * * * * /usr/bin/flock -n \"$lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 55 --request POST \"$endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$log_file\" 2>&1 # furniture-3d-system-virtual-inventory-push" fixed_cron_line="0 20 * * * /usr/bin/flock -n \"$fixed_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 1800 --request POST \"$fixed_endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$fixed_log_file\" 2>&1 # furniture-3d-system-fixed-virtual-inventory-push" todo_cron_line="0 10 * * * /usr/bin/flock -n \"$todo_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${PRODUCT_DEVELOPMENT_TODO_CRON_SECRET:?PRODUCT_DEVELOPMENT_TODO_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 300 --request POST \"$todo_endpoint\" --header \"x-product-development-todo-secret: \${PRODUCT_DEVELOPMENT_TODO_CRON_SECRET}\"' >> \"$todo_log_file\" 2>&1 # furniture-3d-system-product-development-todos" printf '%s\n' "$cron_line" >> "$cron_file" printf '%s\n' "$fixed_cron_line" >> "$cron_file" printf '%s\n' 'CRON_TZ=Asia/Shanghai' >> "$cron_file" printf '%s\n' "$todo_cron_line" >> "$cron_file" crontab "$cron_file" rm -f -- "$cron_file" } 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/dev/null git -C "$REPO_DIR" remote add origin "$REMOTE_GIT_URL" } resolve_fetch_ref() { case "$SOURCE_REF" in origin/*) printf '%s\n' "${SOURCE_REF#origin/}" ;; refs/remotes/origin/*) printf '%s\n' "${SOURCE_REF#refs/remotes/origin/}" ;; refs/heads/*) printf '%s\n' "${SOURCE_REF#refs/heads/}" ;; *) printf '%s\n' "$SOURCE_REF" ;; esac } fetch_target_commit() { local remote_ref="$1" if [ -z "$remote_ref" ]; then echo "Missing remote fetch ref derived from SOURCE_REF=$SOURCE_REF" >&2 exit 1 fi git -C "$REPO_DIR" fetch --prune --depth 1 origin "$remote_ref" if ! git -C "$REPO_DIR" cat-file -e "$COMMIT_SHA^{commit}" 2>/dev/null; then git -C "$REPO_DIR" fetch --prune origin "$remote_ref" fi git -C "$REPO_DIR" cat-file -e "$COMMIT_SHA^{commit}" } 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" } test "$(hostname)" = "__EXPECTED_HOSTNAME__" && test -d "$BASE" || exit 91 acquire_deploy_lock mkdir -p "$RELEASES" "$SHARED" copy_legacy_env_if_needed ensure_bom_costing_worker_secret ensure_product_development_todo_secret ensure_repo fetch_target_commit "$(resolve_fetch_ref)" RELEASE_DIR="$RELEASES/$RELEASE_ID" APP_DIR="$RELEASE_DIR/$PROJECT_SUBDIR" PREVIOUS_RELEASE="" PREVIOUS_RELEASE="$(readlink -f "$CURRENT" 2>/dev/null || true)" mkdir -p "$SHARED/deploy-checks" HEALTHCHECK_FILE="$SHARED/deploy-checks/$RELEASE_ID.log" DEPLOY_SUCCEEDED=0 if [ -n "$PREVIOUS_RELEASE" ]; then assert_release_ready "$PREVIOUS_RELEASE/$PROJECT_SUBDIR" fi cleanup_failed_release() { local status="$1" 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 echo "Failed release retained for inspection: $RELEASE_DIR" >&2 fi exit "$status" } trap 'cleanup_failed_release "$?"' EXIT assert_path_in_base "$RELEASE_DIR" if [ -e "$RELEASE_DIR" ]; then echo "Release already exists: $RELEASE_DIR" >&2 exit 1 fi mkdir -p "$RELEASE_DIR" git -C "$REPO_DIR" archive --format=tar --prefix="$PROJECT_SUBDIR/" "$COMMIT_SHA:$PROJECT_SUBDIR" | tar -xf - -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 set -a source "$SHARED/.env" set +a node scripts/verify-prisma-runtime-schema.js npm run build ensure_shared_non_integrated_assets "$APP_DIR" prepare_standalone_runtime "$APP_DIR" assert_release_ready "$APP_DIR" ln -sfn "$RELEASE_DIR" "$CURRENT" start_pm2_release "$CURRENT" if ! verify_release_http "http://127.0.0.1:$APP_PORT" >"$HEALTHCHECK_FILE" 2>&1 || ! verify_release_http "https://cp.jiansun.vip" >>"$HEALTHCHECK_FILE" 2>&1; then cat "$HEALTHCHECK_FILE" >&2 echo "Release verification failed; restoring previous release." >&2 exit 1 fi sed -n '1,12p' "$HEALTHCHECK_FILE" install_virtual_inventory_push_cron cleanup_old_releases DEPLOY_SUCCEEDED=1 trap - EXIT echo "=== REMOTE GIT 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("__REMOTE_REPO_DIR__", $RemoteRepoDir) $shellScript = $shellScript.Replace("__PROJECT_SUBDIR__", $ProjectSubdir) $shellScript = $shellScript.Replace("__RELEASE_ID__", $ReleaseId) $shellScript = $shellScript.Replace("__APP_PORT__", [string]$AppPort) $shellScript = $shellScript.Replace("__PM2_NAME__", $Pm2Name) $shellScript = $shellScript.Replace("__KEEP_RELEASES__", [string]$KeepReleases) $shellScript = $shellScript.Replace("__RELEASE_GUARDS__", (Get-Content -Raw (Join-Path $PSScriptRoot "yydd-prod-release-guards.sh"))) $shellScript = $shellScript.Replace("__EXPECTED_HOSTNAME__", $ExpectedHostname) $shellScript = $shellScript.Replace("__COMMIT_SHA__", $Commit) $shellScript = $shellScript.Replace("__SOURCE_REF__", $SourceRef) $shellScript = $shellScript.Replace("__REMOTE_GIT_URL__", $RemoteGitUrl) $shellScript = $shellScript -replace "`r`n", "`n" [System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false)) return $scriptPath } function Invoke-ArchiveFallbackDeploy { param( [string]$DeployRef ) $package = & $createScript ` -RepoRoot $RepoRoot ` -Ref $DeployRef ` -RemoteBase $RemoteBase ` -ServerHost $ServerHost ` -ExpectedHostname $ExpectedHostname ` -AppPort $AppPort ` -Pm2Name $Pm2Name ` -KeepReleases $KeepReleases if (-not $package) { throw "Package script returned no result" } $archivePath = [string]$package.Archive $scriptPath = [string]$package.Script $remoteArchivePath = [string]$package.RemoteArchivePath $remoteScriptPath = [string]$package.RemoteScriptPath $remoteIncoming = ("{0}/incoming" -f $RemoteBase) $mkdirIncomingCommand = "mkdir -p '{0}'" -f $remoteIncoming $runDeployCommand = "chmod +x '{0}' && bash '{0}'" -f $remoteScriptPath try { Invoke-Plink $mkdirIncomingCommand | Out-Null Copy-WithPscp -LocalPath $archivePath -RemotePath $remoteArchivePath | Out-Null Copy-WithPscp -LocalPath $scriptPath -RemotePath $remoteScriptPath | Out-Null $deployOutput = Invoke-Plink $runDeployCommand } finally { if (-not $KeepArtifacts) { try { Invoke-Plink ("rm -f '{0}' '{1}'" -f $remoteArchivePath, $remoteScriptPath) | Out-Null } catch { Write-Warning ("Failed to clean remote temp files: {0}" -f $_.Exception.Message) } if (Test-Path -LiteralPath $archivePath) { Remove-Item -LiteralPath $archivePath -Force } if (Test-Path -LiteralPath $scriptPath) { Remove-Item -LiteralPath $scriptPath -Force } } } return [PSCustomObject]@{ Mode = "archive-fallback" Ref = [string]$package.Ref Commit = [string]$package.Commit RemoteBase = $RemoteBase RemoteArchivePath = $remoteArchivePath RemoteScriptPath = $remoteScriptPath ServerHost = $ServerHost SshPort = $SshPort Pm2Name = $Pm2Name Output = ($deployOutput -join [Environment]::NewLine) } } 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 "Unable to resolve deploy ref: $Ref" } if ([string]::IsNullOrWhiteSpace($RemoteGitUrl)) { $RemoteGitUrl = (Invoke-Git remote get-url origin | Select-Object -First 1).Trim() } if ([string]::IsNullOrWhiteSpace($RemoteGitUrl)) { throw "Unable to resolve origin remote URL" } $releaseId = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss"), $commit.Substring(0, 7) $tempOutputRoot = Join-Path $env:TEMP "3danzhuang-deploy-artifacts\prod" $remoteScriptPath = ("{0}/incoming/deploy-furniture-prod-{1}-remote.sh" -f $RemoteBase, $releaseId) $runDeployCommand = "chmod +x '{0}' && bash '{0}'" -f $remoteScriptPath if (-not $ForceArchiveFallback) { $remoteDeployStarted = $false try { Invoke-Plink ("git ls-remote '{0}' HEAD >/dev/null" -f $RemoteGitUrl) | Out-Null $localScriptPath = New-RemoteGitDeployScript ` -OutputRoot $tempOutputRoot ` -RemoteBase $RemoteBase ` -RemoteRepoDir $RemoteRepoDir ` -ProjectSubdir $ProjectSubdir ` -ReleaseId $releaseId ` -Commit $commit ` -SourceRef $Ref ` -RemoteGitUrl $RemoteGitUrl ` -AppPort $AppPort ` -Pm2Name $Pm2Name ` -KeepReleases $KeepReleases try { Invoke-Plink ("mkdir -p '{0}/incoming'" -f $RemoteBase) | Out-Null Copy-WithPscp -LocalPath $localScriptPath -RemotePath $remoteScriptPath | Out-Null $remoteDeployStarted = $true $deployOutput = Invoke-Plink $runDeployCommand } finally { if (-not $KeepArtifacts) { try { Invoke-Plink ("rm -f '{0}'" -f $remoteScriptPath) | Out-Null } catch { Write-Warning ("Failed to clean remote deploy script: {0}" -f $_.Exception.Message) } if (Test-Path -LiteralPath $localScriptPath) { Remove-Item -LiteralPath $localScriptPath -Force } } } return [PSCustomObject]@{ Mode = "remote-git-incremental" Ref = $Ref Commit = $commit RemoteBase = $RemoteBase RemoteRepoDir = $RemoteRepoDir ServerHost = $ServerHost SshPort = $SshPort Pm2Name = $Pm2Name Output = ($deployOutput -join [Environment]::NewLine) } } catch { if ($remoteDeployStarted) { throw } Write-Warning ("Server-side Git deploy failed, falling back to package deploy: {0}" -f $_.Exception.Message) } } Invoke-ArchiveFallbackDeploy -DeployRef $Ref