gongxiangp2/scripts/git-incremental-deploy.ps1
2026-07-30 18:16:14 +08:00

1064 lines
36 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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' </dev/urandom | head -c 64 > "$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" <<EOF
[Unit]
Description=Sync public traffic nginx logs from tunnel server
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT}
TimeoutStartSec=180
EOF
cat > "/etc/systemd/system/public-traffic-log-sync.timer" <<EOF
[Unit]
Description=Run public traffic log sync every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true
[Install]
WantedBy=timers.target
EOF
cat > "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" <<EOF
#!/usr/bin/env bash
set -euo pipefail
current_file='${CURRENT_FILE}'
container="\$(cat "\$current_file" 2>/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}" <<EOF
[Unit]
Description=Sync public traffic statistics rollups
After=network-online.target ${PROXY_SERVICE}
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${PUBLIC_TRAFFIC_ROLLUP_SCRIPT}
TimeoutStartSec=360
EOF
cat > "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_TIMER}" <<EOF
[Unit]
Description=Run public traffic statistics rollup sync
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable public-traffic-log-sync.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" <<EOF
worker_processes 1;
pid ${PROXY_PID};
events {
worker_connections 2048;
}
http {
access_log off;
error_log ${PROXY_ROOT}/error.log warn;
client_max_body_size 1000m;
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
map \$http_x_forwarded_proto \$forwarded_proto {
default \$http_x_forwarded_proto;
'' \$scheme;
}
upstream app_backend {
server ${upstream} max_fails=3 fail_timeout=5s;
keepalive 64;
}
server {
listen 0.0.0.0:${PORT};
server_name _;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Host \$http_host;
# Preserve the incoming LAN host and port for same-origin auth handoff.
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$forwarded_proto;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
proxy_buffering off;
}
}
}
EOF
nginx -p "${PROXY_ROOT}/" -c "$PROXY_CONF" -t
}
ensure_proxy_service() {
mkdir -p "$PROXY_ROOT"
cat > "/etc/systemd/system/${PROXY_SERVICE}" <<EOF
[Unit]
Description=Youyidian material local entry proxy (${CONTAINER_NAME})
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
ExecStartPre=/usr/sbin/nginx -p ${PROXY_ROOT}/ -c ${PROXY_CONF} -t
ExecStart=/usr/sbin/nginx -p ${PROXY_ROOT}/ -c ${PROXY_CONF} -g 'daemon off;'
ExecReload=/usr/sbin/nginx -p ${PROXY_ROOT}/ -c ${PROXY_CONF} -s reload
ExecStop=/usr/sbin/nginx -p ${PROXY_ROOT}/ -c ${PROXY_CONF} -s quit
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$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}" <<EOF
[Unit]
Description=Reconcile ${CONTAINER_NAME} local proxy upstream
After=docker.service ${PROXY_SERVICE}
Requires=docker.service
[Service]
Type=oneshot
Environment=CONTAINER_NAME=${CONTAINER_NAME}
Environment=PORT=${PORT}
Environment=PROXY_ROOT=${PROXY_ROOT}
Environment=PROXY_SERVICE=${PROXY_SERVICE}
ExecStart=${PROXY_RECONCILE_SCRIPT}
TimeoutStartSec=30
EOF
cat > "/etc/systemd/system/${PROXY_RECONCILE_TIMER}" <<EOF
[Unit]
Description=Check ${CONTAINER_NAME} local proxy upstream
[Timer]
OnBootSec=20s
OnUnitActiveSec=15s
AccuracySec=1s
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable "$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.'