#Requires -Version 5.1 <# .SYNOPSIS Git-based incremental deployment for gongxiangp2. .DESCRIPTION Source of truth is the Git repository. The build server fetches only Git deltas, builds a Docker image with cache, pushes commit-tagged images to the internal registry, and the production server pulls the selected image. This script intentionally does not upload a source tarball from the local workstation and does not build on the production server. #> param( [ValidateSet('production', 'shadow')] [string]$Target = 'production', [string]$RepoUrl = 'http://git.1080p.hk/lunanting/gongxiangp2.git', [string]$Branch = 'main', [string]$Commit = '', [string]$GitUsername = $env:GIT_DEPLOY_USERNAME, [string]$GitPassword = $env:GIT_DEPLOY_PASSWORD, [string]$GitAuthHeader = $env:GIT_AUTH_HEADER, [string]$BuildServerIP = '192.168.40.206', [string]$BuildServerUser = 'root', [string]$BuildServerPassword = $env:BUILD_SERVER_PASSWORD, [string]$RemoteBuildDir = '/root/youyidian-3d-git-build', [string]$ProdServerIP = '192.168.58.20', [string]$ProdServerUser = 'root', [int]$RegistryPort = 5000, [string]$ImageName = 'youyidian-3d-material', [string]$DownloadTokenSecret = $env:DOWNLOAD_TOKEN_SECRET, [switch]$DryRun, [switch]$NoCache, [switch]$SkipBuild, [switch]$SkipPush, [switch]$SkipDeploy, [switch]$KeepOldImages, [switch]$AggressiveBuildCacheCleanup, [switch]$NoRollback ) $ErrorActionPreference = 'Stop' $targetConfig = @{ production = @{ Port = 4320 ContainerName = 'youyidian-3d-material' EnvFile = '.env' PublicAppUrl = 'https://sc.jiansun.vip' CorsAllowedOrigins = 'https://sc.jiansun.vip,http://192.168.58.20:4320' DingtalkCallbackOrigin = 'https://sc.jiansun.vip' DingtalkInternalReturnOrigins = 'http://192.168.58.20:4320' S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan' } shadow = @{ Port = 5320 ContainerName = 'youyidian-3d-shadow' EnvFile = '.env.shadow' PublicAppUrl = 'http://192.168.58.20:5320' CorsAllowedOrigins = 'http://192.168.58.20:5320' DingtalkCallbackOrigin = 'http://192.168.58.20:5320' DingtalkInternalReturnOrigins = 'http://192.168.58.20:5320' S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan' } } function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } function Write-Error-Custom { param([string]$Message) Write-Host "[ERR] $Message" -ForegroundColor Red } function Write-Step { param([string]$Message) Write-Host "" Write-Host "==================================================" -ForegroundColor Cyan Write-Host $Message -ForegroundColor Magenta Write-Host "==================================================" -ForegroundColor Cyan } function ConvertTo-ShellSingleQuoted { param([AllowNull()][string]$Value) if ($null -eq $Value) { return "''" } return "'" + $Value.Replace("'", "'\''") + "'" } function Get-PlinkPath { $candidates = @( 'C:\Program Files\PuTTY\plink.exe', 'C:\Program Files (x86)\PuTTY\plink.exe' ) foreach ($candidate in $candidates) { if (Test-Path $candidate) { return $candidate } } $fromPath = Get-Command plink.exe -ErrorAction SilentlyContinue if ($fromPath) { return $fromPath.Source } throw 'Cannot find plink.exe. Please install PuTTY or add plink.exe to PATH.' } function Get-PscpPath { $candidates = @( 'C:\Program Files\PuTTY\pscp.exe', 'C:\Program Files (x86)\PuTTY\pscp.exe' ) foreach ($candidate in $candidates) { if (Test-Path $candidate) { return $candidate } } $fromPath = Get-Command pscp.exe -ErrorAction SilentlyContinue if ($fromPath) { return $fromPath.Source } throw 'Cannot find pscp.exe. Please install PuTTY or add pscp.exe to PATH.' } function Protect-LogLine { param([string]$Line) $masked = $Line foreach ($secret in @($GitPassword, $BuildServerPassword, $DownloadTokenSecret, $GitAuthHeader)) { if (-not [string]::IsNullOrEmpty($secret)) { $masked = $masked.Replace($secret, '***') } } return $masked } function Invoke-BuildServerScript { param([string]$ScriptText) $usePasswordAuth = -not [string]::IsNullOrWhiteSpace($BuildServerPassword) $plinkPath = $null $pscpPath = $null $sshPath = $null $scpPath = $null if ($usePasswordAuth) { $plinkPath = Get-PlinkPath $pscpPath = Get-PscpPath } else { $sshCommand = Get-Command ssh.exe -ErrorAction SilentlyContinue $scpCommand = Get-Command scp.exe -ErrorAction SilentlyContinue if (-not $sshCommand -or -not $scpCommand) { throw 'Build server password is empty, and OpenSSH ssh.exe/scp.exe were not found for key-based deployment.' } $sshPath = $sshCommand.Source $scpPath = $scpCommand.Source } $scriptId = [guid]::NewGuid().ToString('N') $localTemp = Join-Path ([System.IO.Path]::GetTempPath()) "git-incremental-deploy-$scriptId.sh" $remoteTemp = "/tmp/git-incremental-deploy-$scriptId.sh" # PowerShell here-string 在 Windows 下使用 CRLF;远端 Bash 会把行尾的 # `\r` 当作参数内容,导致 `set -euo pipefail` 在脚本第一行就失败。 # 上传前统一为 LF,保证脚本在 Linux 构建机上可直接执行。 $normalizedScriptText = $ScriptText.Replace("`r`n", "`n").Replace("`r", "`n") [System.IO.File]::WriteAllText($localTemp, $normalizedScriptText, [System.Text.UTF8Encoding]::new($false)) $authLabel = if ($usePasswordAuth) { 'password auth' } else { 'SSH key auth' } Write-Info "Connecting to build server $BuildServerUser@$BuildServerIP ($authLabel)" # 原生程序(plink/pscp)会把 git 等子进程的提示信息写到 stderr。 # 在 ErrorActionPreference=Stop 下,PowerShell 会把这些非致命的 stderr # 当成 NativeCommandError 抛出,导致部署在 Git Sync 等阶段误中断。 # 这里在调用原生程序的局部作用域内改为 Continue,让 stderr 通过 2>&1 # 合并进管道正常显示;真实失败仍由 $LASTEXITCODE 捕获,不会掩盖错误。 $previousErrorAction = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $remoteSpec = "${BuildServerUser}@${BuildServerIP}:$remoteTemp" $copyOutput = $null if ($usePasswordAuth) { $copyOutput = & $pscpPath -batch -pw $BuildServerPassword $localTemp $remoteSpec 2>&1 } else { $copyOutput = & $scpPath -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10 $localTemp $remoteSpec 2>&1 } $copyOutput | ForEach-Object { $line = Protect-LogLine -Line ($_.ToString()) if ($line.Trim()) { Write-Host $line -ForegroundColor Gray } } if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } $command = "bash '$remoteTemp'; rc=`$?; rm -f '$remoteTemp'; exit `$rc" $runOutput = $null if ($usePasswordAuth) { $runOutput = & $plinkPath -batch -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $command 2>&1 } else { $runOutput = & $sshPath -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$BuildServerUser@$BuildServerIP" $command 2>&1 } $runOutput | ForEach-Object { $line = Protect-LogLine -Line ($_.ToString()) if ($line -match '^\[OK\]') { Write-Host $line -ForegroundColor Green } elseif ($line -match '^\[WARN\]') { Write-Host $line -ForegroundColor Yellow } elseif ($line -match '^\[ERR\]|error|failed|fatal' ) { Write-Host $line -ForegroundColor Red } else { Write-Host $line -ForegroundColor Gray } } return $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorAction Remove-Item $localTemp -Force -ErrorAction SilentlyContinue } } if ([string]::IsNullOrWhiteSpace($GitAuthHeader) -and -not [string]::IsNullOrWhiteSpace($GitUsername) -and -not [string]::IsNullOrWhiteSpace($GitPassword)) { $pair = "${GitUsername}:${GitPassword}" $basic = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) $GitAuthHeader = "Authorization: Basic $basic" } if ($RepoUrl -match '^https?://' -and [string]::IsNullOrWhiteSpace($GitAuthHeader)) { Write-Warning-Custom 'HTTP Git repository has no auth header. This is OK only if the repository is public or credentials are already configured on the build server.' } $cfg = $targetConfig[$Target] $rollbackFlag = if ($NoRollback) { '0' } else { '1' } $tokens = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl '__BRANCH__' = ConvertTo-ShellSingleQuoted $Branch '__COMMIT__' = ConvertTo-ShellSingleQuoted $Commit '__GIT_AUTH_HEADER__' = ConvertTo-ShellSingleQuoted $GitAuthHeader '__REMOTE_BUILD_DIR__' = ConvertTo-ShellSingleQuoted $RemoteBuildDir '__TARGET__' = ConvertTo-ShellSingleQuoted $Target '__BUILD_SERVER_IP__' = ConvertTo-ShellSingleQuoted $BuildServerIP '__PROD_SERVER_IP__' = ConvertTo-ShellSingleQuoted $ProdServerIP '__PROD_SERVER_USER__' = ConvertTo-ShellSingleQuoted $ProdServerUser '__REGISTRY_PORT__' = ConvertTo-ShellSingleQuoted ([string]$RegistryPort) '__IMAGE_NAME__' = ConvertTo-ShellSingleQuoted $ImageName '__CONTAINER_NAME__' = ConvertTo-ShellSingleQuoted $cfg.ContainerName '__PORT__' = ConvertTo-ShellSingleQuoted ([string]$cfg.Port) '__ENV_FILE__' = ConvertTo-ShellSingleQuoted $cfg.EnvFile '__PUBLIC_APP_URL__' = ConvertTo-ShellSingleQuoted $cfg.PublicAppUrl '__CORS_ALLOWED_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.CorsAllowedOrigins '__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin '__DINGTALK_INTERNAL_RETURN_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkInternalReturnOrigins '__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain '__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret '__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' })) '__NO_CACHE__' = ConvertTo-ShellSingleQuoted ($(if ($NoCache) { '1' } else { '0' })) '__SKIP_BUILD__' = ConvertTo-ShellSingleQuoted ($(if ($SkipBuild) { '1' } else { '0' })) '__SKIP_PUSH__' = ConvertTo-ShellSingleQuoted ($(if ($SkipPush) { '1' } else { '0' })) '__SKIP_DEPLOY__' = ConvertTo-ShellSingleQuoted ($(if ($SkipDeploy) { '1' } else { '0' })) '__KEEP_OLD_IMAGES__' = ConvertTo-ShellSingleQuoted ($(if ($KeepOldImages) { '1' } else { '0' })) '__AGGRESSIVE_CLEANUP__' = ConvertTo-ShellSingleQuoted ($(if ($AggressiveBuildCacheCleanup) { '1' } else { '0' })) '__ROLLBACK_ON_FAILURE__' = ConvertTo-ShellSingleQuoted $rollbackFlag } $remoteTemplate = @' set -Eeuo pipefail REPO_URL=__REPO_URL__ BRANCH=__BRANCH__ COMMIT_INPUT=__COMMIT__ GIT_AUTH_HEADER=__GIT_AUTH_HEADER__ REMOTE_BUILD_DIR=__REMOTE_BUILD_DIR__ TARGET=__TARGET__ BUILD_SERVER_IP=__BUILD_SERVER_IP__ PROD_SERVER_IP=__PROD_SERVER_IP__ PROD_SERVER_USER=__PROD_SERVER_USER__ REGISTRY_PORT=__REGISTRY_PORT__ IMAGE_NAME=__IMAGE_NAME__ CONTAINER_NAME=__CONTAINER_NAME__ PORT=__PORT__ ENV_FILE=__ENV_FILE__ PUBLIC_APP_URL=__PUBLIC_APP_URL__ CORS_ALLOWED_ORIGINS=__CORS_ALLOWED_ORIGINS__ DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__ DINGTALK_INTERNAL_RETURN_ORIGINS=__DINGTALK_INTERNAL_RETURN_ORIGINS__ S3_PUBLIC_DOMAIN=__S3_PUBLIC_DOMAIN__ DOWNLOAD_TOKEN_SECRET=__DOWNLOAD_TOKEN_SECRET__ DRY_RUN=__DRY_RUN__ NO_CACHE=__NO_CACHE__ SKIP_BUILD=__SKIP_BUILD__ SKIP_PUSH=__SKIP_PUSH__ SKIP_DEPLOY=__SKIP_DEPLOY__ KEEP_OLD_IMAGES=__KEEP_OLD_IMAGES__ AGGRESSIVE_CLEANUP=__AGGRESSIVE_CLEANUP__ ROLLBACK_ON_FAILURE=__ROLLBACK_ON_FAILURE__ log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*"; } fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; } git_cmd() { if [ -n "$GIT_AUTH_HEADER" ]; then git -c "http.extraHeader=$GIT_AUTH_HEADER" "$@" else git "$@" fi } require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "Missing command on build server: $1" } start_remote_step() { printf '\n==================================================\n' printf '%s\n' "$1" printf '==================================================\n' } start_remote_step "Preflight" require_cmd git require_cmd docker require_cmd curl require_cmd ssh ok "Build server tools are available" ssh_prod_command() { local log_file="$1" shift ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=8 "$PROD_SERVER_USER@$PROD_SERVER_IP" "$@" >"$log_file" 2>&1 } retry_prod_ssh() { local log_file="$1" shift local max_attempts=5 local attempt for attempt in $(seq 1 "$max_attempts"); do if ssh_prod_command "$log_file" "$@"; then return 0 fi warn "Production SSH check failed, retrying (${attempt}/${max_attempts})" cat "$log_file" >&2 || true sleep $((attempt * 2)) done return 1 } REMOTE_HEAD="$(git_cmd ls-remote --heads "$REPO_URL" "$BRANCH" | awk '{print $1}' | head -n 1 || true)" if [ -z "$REMOTE_HEAD" ]; then fail "Cannot read Git branch $BRANCH from $REPO_URL" fi ok "Git branch $BRANCH is reachable: ${REMOTE_HEAD:0:12}" retry_prod_ssh /tmp/git-deploy-prod-check.log "echo OK" || { cat /tmp/git-deploy-prod-check.log >&2 || true fail "Cannot reach production server from build server" } ok "Production server is reachable from build server" curl -fsS "http://localhost:${REGISTRY_PORT}/v2/" >/dev/null 2>&1 || { fail "Docker registry is not reachable on build server port ${REGISTRY_PORT}" } ok "Docker registry is reachable" if [ "$DRY_RUN" = "1" ]; then log "Dry run completed. No code, image, or container was changed." exit 0 fi start_remote_step "Git Sync" if [ ! -d "$REMOTE_BUILD_DIR/.git" ]; then warn "Git worktree not found. Creating a fresh clone at $REMOTE_BUILD_DIR" rm -rf "$REMOTE_BUILD_DIR" mkdir -p "$(dirname "$REMOTE_BUILD_DIR")" git_cmd clone "$REPO_URL" "$REMOTE_BUILD_DIR" else log "Using existing Git worktree: $REMOTE_BUILD_DIR" fi cd "$REMOTE_BUILD_DIR" git remote set-url origin "$REPO_URL" git_cmd fetch --prune origin "$BRANCH" TARGET_REF="origin/$BRANCH" if [ -n "$COMMIT_INPUT" ]; then TARGET_REF="$COMMIT_INPUT" fi git checkout -B "$BRANCH" "origin/$BRANCH" >/dev/null 2>&1 || git checkout "$BRANCH" git reset --hard "$TARGET_REF" git clean -fdx COMMIT_SHA="$(git rev-parse HEAD)" SHORT_SHA="$(git rev-parse --short=12 HEAD)" COMMIT_TAG="${TARGET}-${SHORT_SHA}" MOVING_TAG="${TARGET}-latest" REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${COMMIT_TAG}" REGISTRY_MOVING="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${MOVING_TAG}" LOCAL_REGISTRY_COMMIT="localhost:${REGISTRY_PORT}/${IMAGE_NAME}:${COMMIT_TAG}" LOCAL_REGISTRY_MOVING="localhost:${REGISTRY_PORT}/${IMAGE_NAME}:${MOVING_TAG}" test -f Dockerfile || fail "Dockerfile missing after Git sync" test -f package.json || fail "package.json missing after Git sync" test -f "app/api/uploads/[...path]/route.ts" || fail "app/api/uploads fallback route missing" test -f "app/uploads/[...path]/route.ts" || fail "app/uploads fallback route missing" test -f "scripts/local-proxy-reconcile.sh" || fail "local proxy reconciler missing" PROXY_RECONCILE_SCRIPT_B64="$(base64 -w 0 scripts/local-proxy-reconcile.sh)" ok "Synced to commit $COMMIT_SHA" log "Image tag: $COMMIT_TAG" if [ "$SKIP_BUILD" != "1" ]; then start_remote_step "Docker Build" docker pull "$LOCAL_REGISTRY_MOVING" >/dev/null 2>&1 || true build_args=() if [ "$NO_CACHE" = "1" ]; then build_args+=(--no-cache) elif docker image inspect "$LOCAL_REGISTRY_MOVING" >/dev/null 2>&1; then build_args+=(--cache-from "$LOCAL_REGISTRY_MOVING") fi DOCKER_BUILDKIT=1 docker build "${build_args[@]}" \ -t "$IMAGE_NAME:$COMMIT_TAG" \ -t "$IMAGE_NAME:$MOVING_TAG" \ -t "$LOCAL_REGISTRY_COMMIT" \ -t "$LOCAL_REGISTRY_MOVING" \ -t "$REGISTRY_COMMIT" \ -t "$REGISTRY_MOVING" \ . ok "Docker image built: $REGISTRY_COMMIT" else warn "Skipping Docker build by request" fi if [ "$SKIP_PUSH" != "1" ]; then start_remote_step "Push Image" docker push "$LOCAL_REGISTRY_COMMIT" docker push "$LOCAL_REGISTRY_MOVING" ok "Pushed image tags: $COMMIT_TAG, $MOVING_TAG" else warn "Skipping image push by request" fi if [ "$SKIP_DEPLOY" != "1" ]; then start_remote_step "Deploy on Production Server" log "Ensuring production server can access image $REGISTRY_COMMIT" if ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" "docker pull '$REGISTRY_COMMIT'" >/tmp/git-deploy-prod-pull.log 2>&1; then ok "Production server pulled image from registry" else warn "Production server could not pull image directly; streaming image from build server" cat /tmp/git-deploy-prod-pull.log >&2 || true docker save "$REGISTRY_COMMIT" | ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" "docker load" ok "Image loaded on production server via SSH stream" fi PROD_PAYLOAD="$(cat <<'PROD_SCRIPT' set -Eeuo pipefail log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*"; } fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; } start_container() { local name="$1" local image="$2" local publish_host_port="${3:-0}" local deploy_version deploy_version="$(date +%s)" run_args=( docker run -d --name "$name" --network "$NETWORK_NAME" -v "${UPLOADS_MOUNT}:/app/uploads" -v "/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs:/var/log/nginx:ro" --env-file "$ENV_FILE_PATH" -e "USE_REDIS=true" -e "REDIS_URL=redis://gongxiangpan-redis:6379" -e "NEXTAUTH_URL=$PUBLIC_APP_URL" -e "APP_PUBLIC_URL=$PUBLIC_APP_URL" -e "CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS" -e "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN" -e "DINGTALK_INTERNAL_RETURN_ORIGINS=$DINGTALK_INTERNAL_RETURN_ORIGINS" -e "S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN" -e "DISABLE_CACHE=true" -e "DEPLOYMENT_VERSION=$deploy_version" -e "DEPLOYMENT_COMMIT=$COMMIT_SHA" --memory=6g --memory-reservation=512m --restart=unless-stopped ) if [ "$publish_host_port" = "1" ]; then run_args+=(-p "${PORT}:3000") fi if [ -n "$DOWNLOAD_TOKEN_SECRET" ]; then run_args+=(-e "DOWNLOAD_TOKEN_SECRET=$DOWNLOAD_TOKEN_SECRET") fi if [ -n "${PUBLIC_TRAFFIC_ROLLUP_TOKEN:-}" ]; then run_args+=(-e "PUBLIC_TRAFFIC_ROLLUP_TOKEN=$PUBLIC_TRAFFIC_ROLLUP_TOKEN") fi "${run_args[@]}" "$image" } require_file() { [ -f "$1" ] || fail "Missing required file: $1" } command -v docker >/dev/null 2>&1 || fail "Missing docker on production server" command -v curl >/dev/null 2>&1 || warn "curl not found; health check will use docker logs only" require_file "$ENV_FILE_PATH" [ -d "$UPLOADS_MOUNT" ] || fail "Uploads mount does not exist: $UPLOADS_MOUNT" docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 || fail "Docker network missing: $NETWORK_NAME" command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" log "Checking image $REGISTRY_COMMIT" if docker image inspect "$REGISTRY_COMMIT" >/dev/null 2>&1; then ok "Image already available on production server" else docker pull "$REGISTRY_COMMIT" fi PROXY_ROOT="/mnt/PoolOne/apps/youyidian-3d/local-proxy/${CONTAINER_NAME}" PROXY_CONF="${PROXY_ROOT}/nginx.conf" PROXY_PID="${PROXY_ROOT}/nginx.pid" PROXY_SERVICE="${CONTAINER_NAME}-local-proxy.service" CURRENT_FILE="${PROXY_ROOT}/current-container" PROXY_RECONCILE_SCRIPT="${PROXY_ROOT}/reconcile-upstream.sh" PROXY_RECONCILE_SERVICE="${CONTAINER_NAME}-local-proxy-reconcile.service" PROXY_RECONCILE_TIMER="${CONTAINER_NAME}-local-proxy-reconcile.timer" BLUE_CONTAINER="${CONTAINER_NAME}-blue" GREEN_CONTAINER="${CONTAINER_NAME}-green" PUBLIC_TRAFFIC_SYNC_ROOT="/mnt/PoolOne/apps/youyidian-3d/public-traffic-sync" PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE="${PUBLIC_TRAFFIC_SYNC_ROOT}/rollup.token" PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT="${PUBLIC_TRAFFIC_SYNC_ROOT}/public-traffic-log-sync.sh" PUBLIC_TRAFFIC_ROLLUP_SCRIPT="${PUBLIC_TRAFFIC_SYNC_ROOT}/public-traffic-rollup-sync.sh" PUBLIC_TRAFFIC_ROLLUP_SERVICE="public-traffic-rollup-sync.service" PUBLIC_TRAFFIC_ROLLUP_TIMER="public-traffic-rollup-sync.timer" PUBLIC_TRAFFIC_ROLLUP_TOKEN="" ensure_public_traffic_rollup_token() { if [ "$TARGET" != "production" ]; then return 0 fi mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT" chmod 700 "$PUBLIC_TRAFFIC_SYNC_ROOT" if [ ! -s "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" ]; then if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 > "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" else tr -dc 'A-Za-z0-9' "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" fi fi chmod 600 "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" PUBLIC_TRAFFIC_ROLLUP_TOKEN="$(tr -d '\r\n' < "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE")" } install_public_traffic_rollup_timers() { if [ "$TARGET" != "production" ]; then return 0 fi mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT" cat > "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" <<'EOF' #!/usr/bin/env bash set -euo pipefail remote='trafficlog@218.93.9.38' key='/root/.ssh/public_traffic_log_sync_ed25519' dest='/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs' ssh_opts="ssh -i ${key} -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10" domains=( sc.jiansun.vip s3.jiansun.vip cp.jiansun.vip ) mkdir -p "$dest" chmod 755 "$dest" status=0 for domain in "${domains[@]}"; do if ! rsync -rtz --timeout=45 --chmod=F644,D755 -e "$ssh_opts" "${remote}:/var/log/nginx/${domain}.access.log*" "$dest/"; then echo "WARN: failed to sync ${domain}.access.log*" >&2 status=1 fi done find "$dest" -maxdepth 1 -type f -name '*.access.log*' -exec chmod 0644 {} + exit "$status" EOF chmod 700 "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" cat > "/etc/systemd/system/public-traffic-log-sync.service" < "/etc/systemd/system/public-traffic-log-sync.timer" < "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" </dev/null || true)" if [ -z "\$container" ] || ! docker inspect "\$container" >/dev/null 2>&1; then echo "No active material container found" >&2 exit 1 fi systemctl start public-traffic-log-sync.service >/dev/null 2>&1 || true docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity 5m yesterday="\$(TZ=Asia/Shanghai date -d 'yesterday' +%F)" docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "\$yesterday" --end-date "\$yesterday" EOF chmod 700 "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" cat > "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_SERVICE}" < "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_TIMER}" </dev/null 2>&1 || true systemctl enable "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true systemctl restart public-traffic-log-sync.timer >/dev/null 2>&1 || true systemctl restart "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true } is_proxy_running() { systemctl is-active --quiet "$PROXY_SERVICE" 2>/dev/null || { [ -f "$PROXY_PID" ] && kill -0 "$(cat "$PROXY_PID")" >/dev/null 2>&1 } } container_ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1" 2>/dev/null || true } write_proxy_config() { local upstream="$1" mkdir -p "$PROXY_ROOT" cat > "$PROXY_CONF" < "/etc/systemd/system/${PROXY_SERVICE}" </dev/null 2>&1 || true } install_proxy_reconciler() { mkdir -p "$PROXY_ROOT" printf '%s' "$PROXY_RECONCILE_SCRIPT_B64" | base64 -d > "$PROXY_RECONCILE_SCRIPT" chmod 700 "$PROXY_RECONCILE_SCRIPT" cat > "/etc/systemd/system/${PROXY_RECONCILE_SERVICE}" < "/etc/systemd/system/${PROXY_RECONCILE_TIMER}" </dev/null 2>&1 || true systemctl restart "$PROXY_RECONCILE_TIMER" } switch_proxy() { ensure_proxy_service if is_proxy_running; then systemctl reload "$PROXY_SERVICE" else systemctl start "$PROXY_SERVICE" fi } stop_proxy() { systemctl stop "$PROXY_SERVICE" >/dev/null 2>&1 || true } wait_for_container_health() { local name="$1" local ip="" for i in $(seq 1 24); do running="$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)" ip="$(container_ip "$name")" if [ "$running" = "true" ] && [ -n "$ip" ]; then if curl -fsS --max-time 8 "http://${ip}:3000/api/health" >/tmp/git-deploy-health.json 2>/tmp/git-deploy-health.err; then printf '%s\n' "$ip" return 0 fi fi sleep 5 done return 1 } CURRENT_CONTAINER="" if [ -f "$CURRENT_FILE" ]; then CURRENT_CONTAINER="$(cat "$CURRENT_FILE" 2>/dev/null || true)" fi if [ "$CURRENT_CONTAINER" = "$BLUE_CONTAINER" ]; then NEXT_CONTAINER="$GREEN_CONTAINER" else NEXT_CONTAINER="$BLUE_CONTAINER" fi LEGACY_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || true)" if [ -n "$LEGACY_IMAGE" ]; then log "Legacy direct container detected: $LEGACY_IMAGE" fi PROXY_WAS_RUNNING=0 if is_proxy_running; then PROXY_WAS_RUNNING=1 fi ensure_public_traffic_rollup_token log "Starting candidate container $NEXT_CONTAINER" docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true start_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT" 0 >/tmp/git-deploy-new-container-id.txt cat /tmp/git-deploy-new-container-id.txt if ! NEXT_IP="$(wait_for_container_health "$NEXT_CONTAINER")"; then warn "Candidate container failed health check" docker logs --tail 120 "$NEXT_CONTAINER" 2>&1 || true docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true fail "Deployment failed before traffic switch; existing entry was kept" fi ok "Candidate container is healthy at ${NEXT_IP}:3000" write_proxy_config "${NEXT_IP}:3000" if [ "$PROXY_WAS_RUNNING" != "1" ]; then warn "Stable local proxy is not running; migrating public entry from direct container to proxy" if [ -n "$LEGACY_IMAGE" ]; then docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true fi fi if ! switch_proxy; then warn "Local proxy switch failed" if [ "$PROXY_WAS_RUNNING" != "1" ] && [ "$ROLLBACK_ON_FAILURE" = "1" ] && [ -n "$LEGACY_IMAGE" ]; then warn "Rolling back to direct container $LEGACY_IMAGE" stop_proxy start_container "$CONTAINER_NAME" "$LEGACY_IMAGE" 1 >/tmp/git-deploy-rollback-container-id.txt cat /tmp/git-deploy-rollback-container-id.txt fi docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true fail "Deployment failed during local proxy switch" fi if ! curl -fsS --max-time 8 "http://127.0.0.1:${PORT}/api/health" >/tmp/git-deploy-entry-health.json 2>/tmp/git-deploy-entry-health.err; then warn "Stable local entry health check failed after switch" cat /tmp/git-deploy-entry-health.err >&2 || true if [ "$PROXY_WAS_RUNNING" != "1" ] && [ "$ROLLBACK_ON_FAILURE" = "1" ] && [ -n "$LEGACY_IMAGE" ]; then warn "Rolling back to direct container $LEGACY_IMAGE" stop_proxy start_container "$CONTAINER_NAME" "$LEGACY_IMAGE" 1 >/tmp/git-deploy-rollback-container-id.txt cat /tmp/git-deploy-rollback-container-id.txt elif [ "$PROXY_WAS_RUNNING" = "1" ] && [ -n "$CURRENT_CONTAINER" ]; then CURRENT_IP="$(container_ip "$CURRENT_CONTAINER")" if [ -n "$CURRENT_IP" ]; then warn "Reloading proxy back to previous container $CURRENT_CONTAINER" write_proxy_config "${CURRENT_IP}:3000" >/dev/null 2>&1 || true switch_proxy >/dev/null 2>&1 || true fi fi docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true fail "Deployment failed after local proxy switch" fi printf '%s\n' "$NEXT_CONTAINER" > "$CURRENT_FILE" install_proxy_reconciler ok "Stable local entry is healthy on port $PORT" docker logs --tail 20 "$NEXT_CONTAINER" 2>&1 || true if install_public_traffic_rollup_timers; then if [ "$TARGET" = "production" ]; then if systemctl start "$PUBLIC_TRAFFIC_ROLLUP_SERVICE" >/tmp/public-traffic-rollup-sync.log 2>/tmp/public-traffic-rollup-sync.err; then ok "Public traffic statistics rollups synced" else warn "Public traffic statistics rollup sync failed; page remains available, check /tmp/public-traffic-rollup-sync.err" cat /tmp/public-traffic-rollup-sync.err >&2 || true fi backfill_start="$(TZ=Asia/Shanghai date -d '7 days ago' +%F)" backfill_end="$(TZ=Asia/Shanghai date -d 'yesterday' +%F)" if docker exec "$NEXT_CONTAINER" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "$backfill_start" --end-date "$backfill_end" >/tmp/public-traffic-rollup-backfill.log 2>/tmp/public-traffic-rollup-backfill.err; then ok "Public traffic historical day rollups backfilled from ${backfill_start} to ${backfill_end}" else warn "Public traffic historical backfill failed; check /tmp/public-traffic-rollup-backfill.err" cat /tmp/public-traffic-rollup-backfill.err >&2 || true fi fi else warn "Public traffic statistics rollup timers were not installed" fi if [ -n "$CURRENT_CONTAINER" ] && [ "$CURRENT_CONTAINER" != "$NEXT_CONTAINER" ]; then docker rm -f "$CURRENT_CONTAINER" >/dev/null 2>&1 || true fi if [ -n "$LEGACY_IMAGE" ]; then docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true fi PROD_SCRIPT )" export_script="$(printf '%s' "$PROD_PAYLOAD" | base64 -w 0)" ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" \ "TARGET='$TARGET' CONTAINER_NAME='$CONTAINER_NAME' PORT='$PORT' REGISTRY_COMMIT='$REGISTRY_COMMIT' COMMIT_SHA='$COMMIT_SHA' ENV_FILE_PATH='/mnt/PoolOne/apps/youyidian-3d/$ENV_FILE' PUBLIC_APP_URL='$PUBLIC_APP_URL' CORS_ALLOWED_ORIGINS='$CORS_ALLOWED_ORIGINS' DINGTALK_CALLBACK_ORIGIN='$DINGTALK_CALLBACK_ORIGIN' DINGTALK_INTERNAL_RETURN_ORIGINS='$DINGTALK_INTERNAL_RETURN_ORIGINS' S3_PUBLIC_DOMAIN='$S3_PUBLIC_DOMAIN' DOWNLOAD_TOKEN_SECRET='$DOWNLOAD_TOKEN_SECRET' PROXY_RECONCILE_SCRIPT_B64='$PROXY_RECONCILE_SCRIPT_B64' NETWORK_NAME='youyidian-3d_app-network' UPLOADS_MOUNT='/mnt/PoolOne/apps/youyidian-3d/uploads' ROLLBACK_ON_FAILURE='$ROLLBACK_ON_FAILURE' bash -lc \"printf '%s' '$export_script' | base64 -d | bash\"" ok "Production deployment command completed" if [ "$TARGET" = "production" ]; then log "Checking public FRP endpoints after deployment" curl -fsS --max-time 12 "$PUBLIC_APP_URL/api/health" >/tmp/git-deploy-public-health.json ok "Public application endpoint is healthy" if curl -fsS --max-time 12 "https://s3.jiansun.vip/minio/health/live" >/tmp/git-deploy-public-s3-health.txt; then ok "Public S3 endpoint is healthy" else warn "Public S3 endpoint is unhealthy; application deployment succeeded, check the storage tunnel separately" fi fi else warn "Skipping production deployment by request" fi start_remote_step "Cleanup" if [ "$KEEP_OLD_IMAGES" = "1" ]; then warn "Skipping cleanup by request" else docker image prune -f >/dev/null 2>&1 || true if [ "$AGGRESSIVE_CLEANUP" = "1" ]; then docker builder prune -af --filter until=168h >/dev/null 2>&1 || true ok "Build server cleanup completed, including old build cache" else ok "Build server image cleanup completed; build cache preserved" fi ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" \ "docker image prune -af --filter until=6h >/dev/null 2>&1 || true; docker container prune -f --filter until=6h >/dev/null 2>&1 || true" ok "Production server cleanup completed" fi start_remote_step "Deployment Summary" ok "Target: $TARGET" ok "Commit: $COMMIT_SHA" ok "Image: $REGISTRY_COMMIT" ok "Container: $CONTAINER_NAME" '@ $remoteScript = $remoteTemplate foreach ($key in $tokens.Keys) { $remoteScript = $remoteScript.Replace($key, $tokens[$key]) } Write-Step "Git Incremental Deploy" Write-Info "Target: $Target" Write-Info "Repository: $RepoUrl" Write-Info "Branch: $Branch" if ($Commit) { Write-Info "Commit override: $Commit" } if ($DryRun) { Write-Warning-Custom 'DryRun mode: no code, image, or production container will be changed.' } $exitCode = Invoke-BuildServerScript -ScriptText $remoteScript if ($exitCode -ne 0) { Write-Error-Custom "Git incremental deployment failed with exit code $exitCode" exit $exitCode } Write-Success 'Git incremental deployment finished.'