627 lines
22 KiB
PowerShell
627 lines
22 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.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)
|
||
|
||
$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"
|
||
|
||
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"
|
||
-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 [ -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.'
|