1002 lines
34 KiB
PowerShell
1002 lines
34 KiB
PowerShell
#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'
|
||
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)
|
||
|
||
$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"
|
||
[System.IO.File]::WriteAllText($localTemp, $ScriptText, [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
|
||
'__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"
|
||
|
||
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"
|
||
|
||
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 "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 [ "$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"
|
||
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 \$host;
|
||
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
|
||
}
|
||
|
||
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"
|
||
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' 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"
|
||
|
||
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.'
|