#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.42.134', [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' DingtalkCallbackOrigin = 'https://sc.jiansun.vip' S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan' } shadow = @{ Port = 5320 ContainerName = 'youyidian-3d-shadow' EnvFile = '.env.shadow' PublicAppUrl = 'http://192.168.58.20:5320' DingtalkCallbackOrigin = '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) $plinkPath = Get-PlinkPath $pscpPath = Get-PscpPath $scriptId = [guid]::NewGuid().ToString('N') $localTemp = Join-Path ([System.IO.Path]::GetTempPath()) "git-incremental-deploy-$scriptId.sh" $remoteTemp = "/tmp/git-incremental-deploy-$scriptId.sh" [System.IO.File]::WriteAllText($localTemp, $ScriptText, [System.Text.UTF8Encoding]::new($false)) Write-Info "Connecting to build server $BuildServerUser@$BuildServerIP" try { $remoteSpec = "${BuildServerUser}@${BuildServerIP}:$remoteTemp" & $pscpPath -batch -pw $BuildServerPassword $localTemp $remoteSpec 2>&1 | 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" & $plinkPath -batch -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $command 2>&1 | 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 { Remove-Item $localTemp -Force -ErrorAction SilentlyContinue } } if ([string]::IsNullOrWhiteSpace($BuildServerPassword)) { throw 'Build server password is required. Pass -BuildServerPassword or set BUILD_SERVER_PASSWORD.' } 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 '__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin '__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__ DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__ 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" 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}" ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=8 "$PROD_SERVER_USER@$PROD_SERVER_IP" "echo OK" >/tmp/git-deploy-prod-check.log 2>&1 || { 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}" 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" ok "Synced to commit $COMMIT_SHA" log "Image tag: $COMMIT_TAG" if [ "$SKIP_BUILD" != "1" ]; then start_remote_step "Docker Build" docker pull "$REGISTRY_MOVING" >/dev/null 2>&1 || true build_args=() if [ "$NO_CACHE" = "1" ]; then build_args+=(--no-cache) elif docker image inspect "$REGISTRY_MOVING" >/dev/null 2>&1; then build_args+=(--cache-from "$REGISTRY_MOVING") fi DOCKER_BUILDKIT=1 docker build "${build_args[@]}" \ -t "$IMAGE_NAME:$COMMIT_TAG" \ -t "$IMAGE_NAME:$MOVING_TAG" \ -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 "$REGISTRY_COMMIT" docker push "$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 image="$1" local deploy_version deploy_version="$(date +%s)" run_args=( docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" -p "${PORT}:3000" -v "${UPLOADS_MOUNT}:/app/uploads" --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 "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN" -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 [ -n "$DOWNLOAD_TOKEN_SECRET" ]; then run_args+=(-e "DOWNLOAD_TOKEN_SECRET=$DOWNLOAD_TOKEN_SECRET") 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" 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 OLD_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || true)" if [ -n "$OLD_IMAGE" ]; then log "Current container image: $OLD_IMAGE" else warn "No existing container found" fi docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true log "Starting new container $CONTAINER_NAME" start_container "$REGISTRY_COMMIT" >/tmp/git-deploy-new-container-id.txt cat /tmp/git-deploy-new-container-id.txt healthy=0 for i in $(seq 1 12); do running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" if [ "$running" != "true" ]; then sleep 5 continue fi if command -v curl >/dev/null 2>&1; then if curl -fsS --max-time 8 "http://127.0.0.1:${PORT}/api/health" >/tmp/git-deploy-health.json 2>/tmp/git-deploy-health.err; then healthy=1 break fi else healthy=1 break fi sleep 5 done if [ "$healthy" != "1" ]; then warn "New container failed health check" docker logs --tail 120 "$CONTAINER_NAME" 2>&1 || true if [ "$ROLLBACK_ON_FAILURE" = "1" ] && [ -n "$OLD_IMAGE" ]; then warn "Rolling back to $OLD_IMAGE" docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true start_container "$OLD_IMAGE" >/tmp/git-deploy-rollback-container-id.txt cat /tmp/git-deploy-rollback-container-id.txt fi fail "Deployment failed" fi ok "Container is healthy on port $PORT" docker logs --tail 20 "$CONTAINER_NAME" 2>&1 || true 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' DINGTALK_CALLBACK_ORIGIN='$DINGTALK_CALLBACK_ORIGIN' S3_PUBLIC_DOMAIN='$S3_PUBLIC_DOMAIN' DOWNLOAD_TOKEN_SECRET='$DOWNLOAD_TOKEN_SECRET' 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" 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.'