#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/WuYongJie/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]$BuildServerProxyCommand = $env:BUILD_SERVER_PROXY_COMMAND, [string]$BuildServerHostKey = $env:BUILD_SERVER_HOST_KEY, [string]$BuildServerKnownHostsFile = $env:BUILD_SERVER_KNOWN_HOSTS_FILE, [string]$RemoteBuildDir = '/root/youyidian-3d-git-build', [string]$ProdServerIP = '192.168.58.20', [string]$ProdServerUser = 'root', [string]$ProdServerHostKey = $env:PROD_SERVER_HOST_KEY, [string]$ProdServerKnownHostsFile = $env:PROD_SERVER_KNOWN_HOSTS_FILE, [int]$RegistryPort = 5000, [string]$ImageName = 'youyidian-3d-material', [string]$UploadsMount = $env:GONGXIANGP2_UPLOADS_MOUNT, [string]$MigrationAuditFile = $env:GONGXIANGP2_MIGRATION_AUDIT_FILE, [string]$MigrationAuditSha256 = $env:GONGXIANGP2_MIGRATION_AUDIT_SHA256, [string]$MigrationAuditSourceCommit = $env:GONGXIANGP2_MIGRATION_AUDIT_SOURCE_COMMIT, # Normal application releases do not run the database migration workflow. # This explicit switch is only for a migration batch that already has an # independently created and verified audit receipt. [switch]$RequireMigrationAudit, [string]$DownloadTokenSecret = $env:DOWNLOAD_TOKEN_SECRET, [switch]$DryRun, [switch]$NoCache, [switch]$SkipBuild, [switch]$SkipPush, # Deploy a previously built, immutable exact-SHA image only after the # build server and registry both prove its identity. This is intended for # a script-only rollout or a retry; it never falls back to an image tag # that has not been verified for the requested commit. [switch]$ReuseVerifiedImage, [switch]$SkipDeploy, [switch]$KeepOldImages, [switch]$AggressiveBuildCacheCleanup, [switch]$NoRollback, # Production returns to the original single-slot replacement workflow: # stop the active slot, start the same slot with the new exact image, and # roll it back to the previous image if health fails. Keep the old # blue/green implementation below only as historical code while existing # runtime artefacts are gradually retired; it is not a release path. [ValidateSet('direct')] [string]$DeploymentStrategy = 'direct' ) $ErrorActionPreference = 'Stop' # Shadow still shares production uploads, transcode runtime markers, and Redis, # while its separate database lease cannot serialize FFmpeg across environments. # Keep this entry fail-closed until a dedicated shadow topology is verified. if ($Target -eq 'shadow') { throw 'Target=shadow is disabled fail-closed: use the dedicated shadow deployment entry after uploads/runtime/Redis isolation is configured and verified.' } if ($ReuseVerifiedImage -and ($SkipBuild -or $SkipPush -or $SkipDeploy -or $NoCache)) { throw '-ReuseVerifiedImage cannot be combined with -SkipBuild, -SkipPush, -SkipDeploy, or -NoCache.' } if (-not $SkipDeploy -and ($SkipBuild -or $SkipPush) -and -not $ReuseVerifiedImage) { throw 'Production deployment requires a freshly built and pushed exact-SHA image; -SkipBuild and -SkipPush are only allowed together with -SkipDeploy.' } if (-not $DryRun -and $Commit -cnotmatch '^[0-9a-f]{40}$') { throw 'HTTP Git transport requires -Commit with the exact 40-character lowercase SHA selected on the trusted workstation.' } if ($RequireMigrationAudit -and [string]::IsNullOrWhiteSpace($MigrationAuditSourceCommit)) { $MigrationAuditSourceCommit = $Commit } if ($RequireMigrationAudit -and -not $DryRun -and -not $SkipDeploy) { if ($MigrationAuditFile -cnotmatch '^/root/gongxiangp2-migration-audit/[A-Za-z0-9._-]+/applied-[0-9]{8}T[0-9]{6}Z\.json$') { throw 'Production deployment requires the immutable apply audit file under /root/gongxiangp2-migration-audit//applied-.json.' } if ($MigrationAuditSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'Production deployment requires the exact lowercase SHA-256 of the immutable apply audit file.' } if ($MigrationAuditSourceCommit -cnotmatch '^[0-9a-f]{40}$') { throw 'Production deployment requires the exact lowercase source commit recorded by the approved migration audit.' } } $trustedRemoteBuildDir = '/root/youyidian-3d-git-build' if ($RemoteBuildDir -cne $trustedRemoteBuildDir) { throw "RemoteBuildDir is fixed to the dedicated build worktree $trustedRemoteBuildDir; arbitrary cleanup targets are forbidden." } if ( -not [string]::IsNullOrWhiteSpace($GitUsername) -or -not [string]::IsNullOrWhiteSpace($GitPassword) -or -not [string]::IsNullOrWhiteSpace($GitAuthHeader) ) { throw 'Inline Git credentials are forbidden. Configure a read-only credential helper on the workstation and build server instead.' } $trustedRepoUrl = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' $localDryRunRepository = $false if ($DryRun -and [System.IO.Path]::IsPathRooted($RepoUrl)) { $localDryRunRepository = Test-Path -LiteralPath $RepoUrl -PathType Container } if (-not $localDryRunRepository -and $RepoUrl -cne $trustedRepoUrl) { throw "Production Git repository must be the exact pinned HTTP repository $trustedRepoUrl. URL credentials and arbitrary repositories are forbidden." } # Production evidence from 2026-08-02 identifies the dedicated ZFS dataset as # the current source of truth. The older application-directory path remains an # explicit, controlled compatibility choice only; never infer a path from an # existing container because that would preserve a bad mount automatically. $defaultUploadsMount = '/mnt/PoolOne/youyidian-uploads' $trustedUploadsMounts = @( $defaultUploadsMount, '/mnt/PoolOne/apps/youyidian-3d/uploads' ) if ([string]::IsNullOrWhiteSpace($UploadsMount)) { $UploadsMount = $defaultUploadsMount } if ($trustedUploadsMounts -cnotcontains $UploadsMount) { throw "UploadsMount must be one of the explicitly trusted production paths: $($trustedUploadsMounts -join ', ')" } $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 Invoke-PreflightGit { param( [Parameter(Mandatory = $true)][string]$GitExecutable, [Parameter(Mandatory = $true)][string[]]$Arguments, [switch]$CaptureOutput ) $previousErrorAction = $ErrorActionPreference $previousGitPrompt = $env:GIT_TERMINAL_PROMPT $ErrorActionPreference = 'Continue' $env:GIT_TERMINAL_PROMPT = '0' try { $output = & $GitExecutable ` -c 'http.lowSpeedLimit=1' ` -c 'http.lowSpeedTime=30' ` @Arguments 2>&1 $exitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorAction $env:GIT_TERMINAL_PROMPT = $previousGitPrompt } if ($exitCode -ne 0) { $detail = Protect-LogLine -Line ((@($output) | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine) if ([string]::IsNullOrWhiteSpace($detail)) { $detail = 'no diagnostic output' } throw "Git target preflight failed with exit code ${exitCode}: $detail" } if ($CaptureOutput) { return ((@($output) | ForEach-Object { $_.ToString() }) -join "`n").Trim() } } function Invoke-LegacyStorageBoundaryPreflight { param( [Parameter(Mandatory = $true)][string]$RepositoryUrl, [Parameter(Mandatory = $true)][string]$TargetBranch, [AllowEmptyString()][string]$RequestedCommit ) $scannerPath = Join-Path $PSScriptRoot 'check-legacy-storage-boundaries.mjs' if (-not (Test-Path -LiteralPath $scannerPath -PathType Leaf)) { throw "Legacy storage boundary scanner is missing: $scannerPath" } $nodeCommand = Get-Command node -ErrorAction SilentlyContinue if (-not $nodeCommand) { throw 'Node.js is required for the legacy storage boundary preflight.' } $gitCommand = Get-Command git -ErrorAction SilentlyContinue if (-not $gitCommand) { throw 'Git is required to resolve and inspect the exact deployment target.' } if ([string]::IsNullOrWhiteSpace($RepositoryUrl) -or $RepositoryUrl.StartsWith('-')) { throw 'Repository URL is invalid for exact-target preflight.' } $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([char[]]@('\', '/')) $tempRoot = Join-Path $tempBase ("gongxiangp2-deploy-preflight-{0}" -f [Guid]::NewGuid().ToString('N')) $bareRepository = Join-Path $tempRoot 'target.git' $targetCheckout = Join-Path $tempRoot 'target-tree' try { New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( 'check-ref-format', '--branch', $TargetBranch ) $remoteHeadOutput = Invoke-PreflightGit -GitExecutable $gitCommand.Source -CaptureOutput -Arguments @( 'ls-remote', '--heads', $RepositoryUrl, "refs/heads/$TargetBranch" ) $remoteHeadLines = @($remoteHeadOutput -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($remoteHeadLines.Count -ne 1) { throw "Remote branch $TargetBranch did not resolve to exactly one ref." } $remoteHeadParts = @($remoteHeadLines[0].Trim() -split '\s+') if ( $remoteHeadParts.Count -ne 2 -or $remoteHeadParts[0] -cnotmatch '^[0-9a-f]{40}$' -or $remoteHeadParts[1] -cne "refs/heads/$TargetBranch" ) { throw 'Remote branch did not resolve to one exact commit SHA.' } $branchCommit = $remoteHeadParts[0] $targetCommit = $branchCommit if (-not [string]::IsNullOrWhiteSpace($RequestedCommit)) { if ($RequestedCommit -cnotmatch '^[0-9a-f]{40}$') { throw 'Commit override must be the exact 40-character lowercase Git commit SHA.' } $targetCommit = $RequestedCommit } $localRepository = $null try { $localRepositoryCandidate = Invoke-PreflightGit -GitExecutable $gitCommand.Source -CaptureOutput -Arguments @( '-C', $PSScriptRoot, 'rev-parse', '--show-toplevel' ) if (Test-Path -LiteralPath $localRepositoryCandidate -PathType Container) { $localRepository = [System.IO.Path]::GetFullPath($localRepositoryCandidate) } } catch { $localRepository = $null } $useLocalObjects = $false if ($localRepository) { try { $resolvedBranchCommit = Invoke-PreflightGit -GitExecutable $gitCommand.Source -CaptureOutput -Arguments @( '-C', $localRepository, 'rev-parse', '--verify', "${branchCommit}^{commit}" ) $resolvedTargetCommit = Invoke-PreflightGit -GitExecutable $gitCommand.Source -CaptureOutput -Arguments @( '-C', $localRepository, 'rev-parse', '--verify', "${targetCommit}^{commit}" ) if ($resolvedBranchCommit -cne $branchCommit -or $resolvedTargetCommit -cne $targetCommit) { throw 'Local Git objects did not resolve to the exact remote commits.' } Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( '-C', $localRepository, 'merge-base', '--is-ancestor', $targetCommit, $branchCommit ) $useLocalObjects = $true } catch { $useLocalObjects = $false } } if ($useLocalObjects) { Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( 'clone', '--no-checkout', '--shared', $localRepository, $targetCheckout ) Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( '-C', $targetCheckout, 'checkout', '--detach', '--force', $targetCommit ) } else { Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @('init', '--bare', $bareRepository) Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( "--git-dir=$bareRepository", 'fetch', '--no-tags', '--force', $RepositoryUrl, "+refs/heads/${TargetBranch}:refs/preflight/branch" ) $fetchedBranchCommit = Invoke-PreflightGit -GitExecutable $gitCommand.Source -CaptureOutput -Arguments @( "--git-dir=$bareRepository", 'rev-parse', '--verify', 'refs/preflight/branch^{commit}' ) if ($fetchedBranchCommit -cne $branchCommit) { throw 'Fetched branch changed after the exact remote ref was resolved; retry the deployment.' } $resolvedTargetCommit = Invoke-PreflightGit -GitExecutable $gitCommand.Source -CaptureOutput -Arguments @( "--git-dir=$bareRepository", 'rev-parse', '--verify', "${targetCommit}^{commit}" ) if ($resolvedTargetCommit -cne $targetCommit) { throw 'Commit override did not resolve to one exact commit SHA.' } try { Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( "--git-dir=$bareRepository", 'merge-base', '--is-ancestor', $targetCommit, $branchCommit ) } catch { throw "Commit override $targetCommit is not reachable from fetched branch $TargetBranch." } Invoke-PreflightGit -GitExecutable $gitCommand.Source -Arguments @( "--git-dir=$bareRepository", 'worktree', 'add', '--detach', $targetCheckout, $targetCommit ) } Push-Location $targetCheckout try { $previousErrorAction = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $scannerOutput = & $nodeCommand.Source $scannerPath 2>&1 $scannerExitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorAction } } finally { Pop-Location } foreach ($line in @($scannerOutput)) { Write-Host (Protect-LogLine -Line $line.ToString()) } if ($scannerExitCode -ne 0) { throw "Legacy storage boundary preflight failed for target $targetCommit with exit code $scannerExitCode" } return $targetCommit } finally { $resolvedTempRoot = [System.IO.Path]::GetFullPath($tempRoot) $expectedPrefix = "$tempBase$([System.IO.Path]::DirectorySeparatorChar)gongxiangp2-deploy-preflight-" if ($resolvedTempRoot.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) { Remove-Item -LiteralPath $resolvedTempRoot -Recurse -Force -ErrorAction SilentlyContinue } else { Write-Warning-Custom "Skipped unexpected preflight cleanup path: $resolvedTempRoot" } } } 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 Assert-Sha256HostFingerprint { param( [Parameter(Mandatory = $true)][string]$Fingerprint, [Parameter(Mandatory = $true)][string]$Label ) if ($Fingerprint -notmatch '^SHA256:[A-Za-z0-9+/]{20,}={0,2}$') { throw "$Label must be an explicit SHA256 host fingerprint." } } function Assert-LocalKnownHostFingerprint { param( [Parameter(Mandatory = $true)][string]$KnownHostsFile, [Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][string]$ExpectedFingerprint ) if ([string]::IsNullOrWhiteSpace($KnownHostsFile) -or -not (Test-Path -LiteralPath $KnownHostsFile -PathType Leaf)) { throw 'BuildServerKnownHostsFile must be a pre-provisioned regular file.' } $knownHostsItem = Get-Item -LiteralPath $KnownHostsFile -Force if (($knownHostsItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'BuildServerKnownHostsFile must not be a symbolic link or reparse point.' } $sshKeygen = Get-Command ssh-keygen.exe -ErrorAction SilentlyContinue if (-not $sshKeygen) { throw 'ssh-keygen.exe is required to verify the pinned build-server host fingerprint.' } $lookup = & $sshKeygen.Source -F $HostName -f $KnownHostsFile 2>$null if ($LASTEXITCODE -ne 0) { throw "Build server $HostName is absent from the pinned known_hosts file." } $fingerprints = @() foreach ($line in @($lookup)) { $keyLine = $line.ToString().Trim() if (-not $keyLine -or $keyLine.StartsWith('#')) { continue } $fingerprintOutput = $keyLine | & $sshKeygen.Source -l -f - -E sha256 2>$null if ($LASTEXITCODE -ne 0) { throw 'Could not calculate the build-server key fingerprint from known_hosts.' } foreach ($fingerprintLine in @($fingerprintOutput)) { if ($fingerprintLine.ToString() -match '(SHA256:[A-Za-z0-9+/]{20,}={0,2})') { $fingerprints += $Matches[1] } } } $uniqueFingerprints = @($fingerprints | Sort-Object -Unique) if ($uniqueFingerprints.Count -ne 1 -or $uniqueFingerprints[0] -cne $ExpectedFingerprint) { throw 'Build-server known_hosts fingerprint does not match BuildServerHostKey exactly.' } } 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 $sshCommonArgs = @() $scpCommonArgs = @() Assert-Sha256HostFingerprint -Fingerprint $BuildServerHostKey -Label 'BuildServerHostKey' Assert-Sha256HostFingerprint -Fingerprint $ProdServerHostKey -Label 'ProdServerHostKey' if ([string]::IsNullOrWhiteSpace($ProdServerKnownHostsFile)) { throw 'ProdServerKnownHostsFile is required on the build server for the production SSH hop.' } if ($usePasswordAuth) { if (-not [string]::IsNullOrWhiteSpace($BuildServerProxyCommand)) { throw 'BuildServerProxyCommand is supported only with OpenSSH key authentication.' } $plinkPath = Get-PlinkPath $pscpPath = Get-PscpPath } else { Assert-LocalKnownHostFingerprint ` -KnownHostsFile $BuildServerKnownHostsFile ` -HostName $BuildServerIP ` -ExpectedFingerprint $BuildServerHostKey $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 $sshCommonArgs = @('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', "UserKnownHostsFile=$BuildServerKnownHostsFile", '-o', 'ConnectTimeout=10') $scpCommonArgs = @('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', "UserKnownHostsFile=$BuildServerKnownHostsFile", '-o', 'ConnectTimeout=10') if (-not [string]::IsNullOrWhiteSpace($BuildServerProxyCommand)) { $sshCommonArgs += @('-o', "ProxyCommand=$BuildServerProxyCommand") $scpCommonArgs += @('-o', "ProxyCommand=$BuildServerProxyCommand") } } $scriptId = [guid]::NewGuid().ToString('N') $localTemp = Join-Path ([System.IO.Path]::GetTempPath()) "git-incremental-deploy-$scriptId.sh" $remoteTempDir = "/tmp/gongxiangp2-deploy-$scriptId" $remoteTemp = "$remoteTempDir/payload.sh" $remotePrepared = $false # 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' } elseif (-not [string]::IsNullOrWhiteSpace($BuildServerProxyCommand)) { 'SSH key auth via proxy command' } 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 { $prepareCommand = "set -eu; umask 077; test ! -e '$remoteTempDir'; mkdir -m 700 '$remoteTempDir'; test -d '$remoteTempDir'; test ! -L '$remoteTempDir'; test -O '$remoteTempDir'; test `"`$(stat -c %a '$remoteTempDir')`" = '700'" if ($usePasswordAuth) { $prepareOutput = & $plinkPath -batch -hostkey $BuildServerHostKey -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $prepareCommand 2>&1 } else { $prepareOutput = & $sshPath @sshCommonArgs "$BuildServerUser@$BuildServerIP" $prepareCommand 2>&1 } $prepareExitCode = $LASTEXITCODE $prepareOutput | ForEach-Object { $line = Protect-LogLine -Line ($_.ToString()) if ($line.Trim()) { Write-Host $line -ForegroundColor Gray } } if ($prepareExitCode -ne 0) { return $prepareExitCode } $remotePrepared = $true $remoteSpec = "${BuildServerUser}@${BuildServerIP}:$remoteTemp" $copyOutput = $null if ($usePasswordAuth) { $copyOutput = & $pscpPath -batch -hostkey $BuildServerHostKey -pw $BuildServerPassword $localTemp $remoteSpec 2>&1 } else { $copyOutput = & $scpPath @scpCommonArgs $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 = "set -eu; umask 077; test -f '$remoteTemp'; test ! -L '$remoteTemp'; test -O '$remoteTemp'; chmod 600 '$remoteTemp'; test `"`$(stat -c %a '$remoteTemp')`" = '600'; set +e; bash '$remoteTemp'; rc=`$?; set -e; rm -f '$remoteTemp'; rmdir '$remoteTempDir'; exit `$rc" $runOutput = $null if ($usePasswordAuth) { $runOutput = & $plinkPath -batch -hostkey $BuildServerHostKey -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $command 2>&1 } else { $runOutput = & $sshPath @sshCommonArgs "$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 { if ($remotePrepared) { $cleanupCommand = "rm -f '$remoteTemp'; rmdir '$remoteTempDir' >/dev/null 2>&1 || true" if ($usePasswordAuth) { $null = & $plinkPath -batch -hostkey $BuildServerHostKey -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $cleanupCommand 2>$null } elseif ($sshPath) { $null = & $sshPath @sshCommonArgs "$BuildServerUser@$BuildServerIP" $cleanupCommand 2>$null } } $ErrorActionPreference = $previousErrorAction Remove-Item $localTemp -Force -ErrorAction SilentlyContinue } } $cfg = $targetConfig[$Target] $rollbackFlag = if ($NoRollback) { '0' } else { '1' } $proxyReconcilerPath = Join-Path $PSScriptRoot 'local-proxy-reconcile.sh' if (-not (Test-Path -LiteralPath $proxyReconcilerPath)) { throw "Local proxy reconciler is missing: $proxyReconcilerPath" } $proxyReconcilerText = [System.IO.File]::ReadAllText($proxyReconcilerPath) $proxyReconcilerText = $proxyReconcilerText.Replace("`r`n", "`n").Replace("`r", "`n") $proxyReconcilerBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($proxyReconcilerText) $proxyReconcilerBase64 = [Convert]::ToBase64String($proxyReconcilerBytes) $tokens = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl '__BRANCH__' = ConvertTo-ShellSingleQuoted $Branch '__COMMIT__' = ConvertTo-ShellSingleQuoted '' '__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 '__PROD_SERVER_HOST_KEY__' = ConvertTo-ShellSingleQuoted $ProdServerHostKey '__PROD_KNOWN_HOSTS_FILE__' = ConvertTo-ShellSingleQuoted $ProdServerKnownHostsFile '__REGISTRY_PORT__' = ConvertTo-ShellSingleQuoted ([string]$RegistryPort) '__IMAGE_NAME__' = ConvertTo-ShellSingleQuoted $ImageName '__UPLOADS_MOUNT__' = ConvertTo-ShellSingleQuoted $UploadsMount '__MIGRATION_AUDIT_FILE__' = ConvertTo-ShellSingleQuoted $MigrationAuditFile '__MIGRATION_AUDIT_SHA256__' = ConvertTo-ShellSingleQuoted $MigrationAuditSha256 '__MIGRATION_AUDIT_SOURCE_COMMIT__' = ConvertTo-ShellSingleQuoted $MigrationAuditSourceCommit '__REQUIRE_MIGRATION_AUDIT__' = ConvertTo-ShellSingleQuoted ($(if ($RequireMigrationAudit) { '1' } else { '0' })) '__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 '__PROXY_RECONCILE_SCRIPT_B64__' = ConvertTo-ShellSingleQuoted $proxyReconcilerBase64 '__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' })) '__REUSE_VERIFIED_IMAGE__' = ConvertTo-ShellSingleQuoted ($(if ($ReuseVerifiedImage) { '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 '__DEPLOYMENT_STRATEGY__' = ConvertTo-ShellSingleQuoted $DeploymentStrategy } $remoteTemplate = @' set -Eeuo pipefail REPO_URL=__REPO_URL__ BRANCH=__BRANCH__ COMMIT_INPUT=__COMMIT__ 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__ PROD_SERVER_HOST_KEY=__PROD_SERVER_HOST_KEY__ PROD_KNOWN_HOSTS_FILE=__PROD_KNOWN_HOSTS_FILE__ REGISTRY_PORT=__REGISTRY_PORT__ IMAGE_NAME=__IMAGE_NAME__ UPLOADS_MOUNT=__UPLOADS_MOUNT__ MIGRATION_AUDIT_FILE=__MIGRATION_AUDIT_FILE__ MIGRATION_AUDIT_SHA256=__MIGRATION_AUDIT_SHA256__ MIGRATION_AUDIT_SOURCE_COMMIT=__MIGRATION_AUDIT_SOURCE_COMMIT__ REQUIRE_MIGRATION_AUDIT=__REQUIRE_MIGRATION_AUDIT__ 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__ PROXY_RECONCILE_SCRIPT_B64=__PROXY_RECONCILE_SCRIPT_B64__ DRY_RUN=__DRY_RUN__ NO_CACHE=__NO_CACHE__ SKIP_BUILD=__SKIP_BUILD__ SKIP_PUSH=__SKIP_PUSH__ REUSE_VERIFIED_IMAGE=__REUSE_VERIFIED_IMAGE__ SKIP_DEPLOY=__SKIP_DEPLOY__ KEEP_OLD_IMAGES=__KEEP_OLD_IMAGES__ AGGRESSIVE_CLEANUP=__AGGRESSIVE_CLEANUP__ ROLLBACK_ON_FAILURE=__ROLLBACK_ON_FAILURE__ DEPLOYMENT_STRATEGY=__DEPLOYMENT_STRATEGY__ 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() { GIT_TERMINAL_PROMPT=0 git \ -c http.lowSpeedLimit=1 \ -c http.lowSpeedTime=30 \ "$@" } 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 node require_cmd ssh require_cmd ssh-keygen [ "$REPO_URL" = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git' ] || fail "Build server received an untrusted Git repository URL" [ "$REMOTE_BUILD_DIR" = '/root/youyidian-3d-git-build' ] || fail "Build directory is not the pinned dedicated worktree" test -f "$PROD_KNOWN_HOSTS_FILE" || fail "Pinned production known_hosts file is missing on build server" test ! -L "$PROD_KNOWN_HOSTS_FILE" || fail "Pinned production known_hosts file must not be a symlink" ssh-keygen -F "$PROD_SERVER_IP" -f "$PROD_KNOWN_HOSTS_FILE" >/dev/null 2>&1 || fail "Pinned production host is absent from known_hosts" actual_prod_host_key="$(ssh-keygen -F "$PROD_SERVER_IP" -f "$PROD_KNOWN_HOSTS_FILE" 2>/dev/null | awk '!/^#/ { print $2 " " $3 }' | ssh-keygen -lf - -E sha256 2>/dev/null | awk '{print $2}' | sort -u)" [ "$(printf '%s\n' "$actual_prod_host_key" | sed '/^$/d' | wc -l | tr -d ' ')" = "1" ] || fail "Production known_hosts must resolve to exactly one SHA256 fingerprint" [ "$actual_prod_host_key" = "$PROD_SERVER_HOST_KEY" ] || fail "Production host key fingerprint does not match the pinned value" ok "Build server tools are available" prod_ssh() { ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$PROD_KNOWN_HOSTS_FILE" \ -o ConnectTimeout=8 -o ServerAliveInterval=3 -o ServerAliveCountMax=10 -o TCPKeepAlive=yes \ "$PROD_SERVER_USER@$PROD_SERVER_IP" "$@" } prod_ssh_script() { local remote_command='bash -s --' local argument='' local quoted_argument='' for argument in "$@"; do printf -v quoted_argument '%q' "$argument" remote_command+=" $quoted_argument" done prod_ssh "$remote_command" } ssh_prod_command() { local log_file="$1" shift prod_ssh "$@" >"$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}" [ -n "$COMMIT_INPUT" ] || fail "Exact preflighted deployment commit is missing" 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" test ! -L "$REMOTE_BUILD_DIR" || fail "Pinned build directory must not be a symbolic link" if [ -e "$REMOTE_BUILD_DIR" ]; then test -d "$REMOTE_BUILD_DIR" || fail "Pinned build path exists but is not a directory; refusing destructive cleanup" test -d "$REMOTE_BUILD_DIR/.git" || fail "Pinned build directory is not the expected Git worktree; refusing destructive cleanup" test ! -L "$REMOTE_BUILD_DIR/.git" || fail "Pinned build worktree metadata must not be a symbolic link" [ "$(realpath -e "$REMOTE_BUILD_DIR")" = "$REMOTE_BUILD_DIR" ] || fail "Pinned build directory resolves to an unexpected path" existing_origin="$(git -C "$REMOTE_BUILD_DIR" remote get-url origin 2>/dev/null || true)" [ "$existing_origin" = "$REPO_URL" ] || fail "Pinned build worktree origin does not match the trusted repository" log "Using verified dedicated Git worktree: $REMOTE_BUILD_DIR" else [ "$(dirname "$REMOTE_BUILD_DIR")" = '/root' ] || fail "Pinned build directory parent is not trusted" test -d /root && test ! -L /root || fail "Pinned build directory parent is not a trusted physical directory" log "Creating a fresh trusted clone at $REMOTE_BUILD_DIR" git_cmd clone --origin origin "$REPO_URL" "$REMOTE_BUILD_DIR" fi cd "$REMOTE_BUILD_DIR" git_cmd fetch --prune origin "$BRANCH" TARGET_REF="$COMMIT_INPUT" 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)" [ "$COMMIT_SHA" = "$COMMIT_INPUT" ] || fail "Git sync did not land on the exact preflighted commit" if [ "$REQUIRE_MIGRATION_AUDIT" = "1" ] && [ "$SKIP_DEPLOY" != "1" ]; then git cat-file -e "${MIGRATION_AUDIT_SOURCE_COMMIT}^{commit}" 2>/dev/null || \ fail "Migration audit source commit is unavailable in the trusted build worktree" git merge-base --is-ancestor "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA" || \ fail "Migration audit source commit is not an ancestor of the deployment commit" if [ "$MIGRATION_AUDIT_SOURCE_COMMIT" != "$COMMIT_SHA" ]; then git diff --quiet "$MIGRATION_AUDIT_SOURCE_COMMIT" "$COMMIT_SHA" -- prisma/schema.prisma prisma/migrations || \ fail "Database schema or migrations changed after the approved audit; create a fresh verified migration audit" ok "Reusing verified migration audit because Prisma schema and migrations are unchanged" fi fi SHORT_SHA="$(git rev-parse --short=12 HEAD)" COMMIT_TAG="${TARGET}-${SHORT_SHA}" MOVING_TAG="${TARGET}-latest" REGISTRY_TAG="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${COMMIT_TAG}" REGISTRY_COMMIT="$REGISTRY_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 [ "$REUSE_VERIFIED_IMAGE" = "1" ]; then start_remote_step "Verify Existing Image" docker pull "$LOCAL_REGISTRY_COMMIT" [ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$LOCAL_REGISTRY_COMMIT" 2>/dev/null || true)" = "$COMMIT_SHA" ] || \ fail "Existing exact-SHA image does not contain the requested Git revision" docker tag "$LOCAL_REGISTRY_COMMIT" "$REGISTRY_TAG" ok "Reusing verified exact-SHA image without rebuilding: $LOCAL_REGISTRY_COMMIT" elif [ "$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[@]}" \ --build-arg "VCS_REF=$COMMIT_SHA" \ -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 [ "$REUSE_VERIFIED_IMAGE" = "1" ]; then ok "Reusing immutable registry image; no image push is required" elif [ "$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 resolve_published_image_identity() { EXPECTED_MANIFEST_DIGEST="$(curl -fsSI \ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ "http://localhost:${REGISTRY_PORT}/v2/${IMAGE_NAME}/manifests/${COMMIT_TAG}" \ | tr -d '\r' \ | awk 'tolower($1) == "docker-content-digest:" { print $2 }' \ | tail -n 1)" [[ "$EXPECTED_MANIFEST_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Registry did not return one immutable manifest digest for the exact commit tag" REGISTRY_MANIFEST_JSON="$(curl -fsS \ -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ "http://localhost:${REGISTRY_PORT}/v2/${IMAGE_NAME}/manifests/${COMMIT_TAG}")" EXPECTED_PLATFORM_MANIFEST_DIGEST="$(printf '%s' "$REGISTRY_MANIFEST_JSON" | node -e ' const document = JSON.parse(require("node:fs").readFileSync(0, "utf8")) if (!Array.isArray(document.manifests)) { if (!document.config || typeof document.config.digest !== "string") process.exit(2) process.stdout.write("self") process.exit(0) } const matches = document.manifests.filter((entry) => entry && entry.platform && entry.platform.os === "linux" && entry.platform.architecture === "amd64" ) if (matches.length !== 1 || typeof matches[0].digest !== "string") process.exit(3) process.stdout.write(matches[0].digest) ')" if [ "$EXPECTED_PLATFORM_MANIFEST_DIGEST" = "self" ]; then REGISTRY_PLATFORM_MANIFEST_JSON="$REGISTRY_MANIFEST_JSON" else [[ "$EXPECTED_PLATFORM_MANIFEST_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Registry index did not contain exactly one linux/amd64 manifest" REGISTRY_PLATFORM_MANIFEST_JSON="$(curl -fsS \ -H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ "http://localhost:${REGISTRY_PORT}/v2/${IMAGE_NAME}/manifests/${EXPECTED_PLATFORM_MANIFEST_DIGEST}")" fi EXPECTED_IMAGE_ID="$(printf '%s' "$REGISTRY_PLATFORM_MANIFEST_JSON" | node -e ' const document = JSON.parse(require("node:fs").readFileSync(0, "utf8")) const digest = document && document.config && document.config.digest if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) process.exit(2) process.stdout.write(digest) ')" [[ "$EXPECTED_IMAGE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Registry platform manifest did not contain one exact config digest" REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}@${EXPECTED_MANIFEST_DIGEST}" } if [ "$SKIP_DEPLOY" != "1" ]; then resolve_published_image_identity ok "Pinned production image: $REGISTRY_COMMIT ($EXPECTED_IMAGE_ID)" start_remote_step "Deploy on Production Server" log "Ensuring production server can access image $REGISTRY_COMMIT" if prod_ssh "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_TAG" | prod_ssh "docker load" REGISTRY_COMMIT="$REGISTRY_TAG" ok "Image loaded on production server via SSH stream" fi ACTUAL_PROD_IMAGE_ID="$(prod_ssh "docker image inspect --format '{{.Id}}' '$REGISTRY_COMMIT' 2>/dev/null" | tr -d '\r\n' || true)" if [ "$ACTUAL_PROD_IMAGE_ID" != "$EXPECTED_IMAGE_ID" ]; then fail "Production image ID differs from the image built on the pinned build server" fi ok "Production image ID matches the trusted build result: $EXPECTED_IMAGE_ID" if ! prod_ssh \ "test \"\$(docker image inspect --format '{{ index .Config.Labels \"org.opencontainers.image.revision\" }}' '$REGISTRY_COMMIT' 2>/dev/null)\" = '$COMMIT_SHA'"; then fail "Production image revision label does not match the exact preflighted Git commit" fi ok "Production image revision label matches $COMMIT_SHA" IFS= read -r -d '' PROD_PAYLOAD <<'PROD_SCRIPT' || true set -Eeuo pipefail TARGET="$1" CONTAINER_NAME="$2" PORT="$3" REGISTRY_COMMIT="$4" EXPECTED_IMAGE_ID="$5" COMMIT_SHA="$6" ENV_FILE_PATH="$7" PUBLIC_APP_URL="$8" CORS_ALLOWED_ORIGINS="$9" shift 9 DINGTALK_CALLBACK_ORIGIN="$1" DINGTALK_INTERNAL_RETURN_ORIGINS="$2" S3_PUBLIC_DOMAIN="$3" DOWNLOAD_TOKEN_SECRET="$4" PROXY_RECONCILE_SCRIPT_B64="$5" NETWORK_NAME="$6" UPLOADS_MOUNT="$7" ROLLBACK_ON_FAILURE="$8" MIGRATION_AUDIT_FILE="$9" shift 9 MIGRATION_AUDIT_SHA256="$1" MIGRATION_AUDIT_SOURCE_COMMIT="$2" REQUIRE_MIGRATION_AUDIT="$3" DEPLOYMENT_STRATEGY="${4:-direct}" log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*"; } fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; } create_container() { local name="$1" local image="$2" local publish_host_port="${3:-0}" local deploy_version local transcode_instance_id local sanitized_env_file local create_status deploy_version="$(date +%s)" transcode_instance_id="${name}-${COMMIT_SHA}-${deploy_version}-$$" sanitized_env_file="$(mktemp "/run/${CONTAINER_NAME}.env.XXXXXX")" || return 1 chmod 600 "$sanitized_env_file" if ! awk '!/^UPLOAD_DIR=/' "$ENV_FILE_PATH" >"$sanitized_env_file"; then rm -f "$sanitized_env_file" return 1 fi run_args=( docker create --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 "$sanitized_env_file" -e "UPLOAD_DIR=/app/uploads" -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 "VIDEO_TRANSCODE_WORKER_ID=$name" -e "VIDEO_TRANSCODE_INSTANCE_ID=$transcode_instance_id" -e "VIDEO_TRANSCODE_RUNTIME_DIR=/app/uploads/.video-transcode-runtime" -e "TASK_DETAIL_UPLOAD_WORKER_ID=$name" -e "TASK_DETAIL_UPLOAD_INSTANCE_ID=$transcode_instance_id" -e "TASK_DETAIL_UPLOAD_RUNTIME_DIR=/app/uploads/.task-detail-upload-runtime" --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 if "${run_args[@]}" "$image"; then rm -f "$sanitized_env_file" return 0 fi create_status=$? rm -f "$sanitized_env_file" return "$create_status" } verify_image_revision_label() { local image="$1" local expected_commit="$2" local revision="" local image_id="" revision="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image" 2>/dev/null || true)" image_id="$(docker image inspect --format '{{.Id}}' "$image" 2>/dev/null || true)" [ "$revision" = "$expected_commit" ] && [ "$image_id" = "$EXPECTED_IMAGE_ID" ] } verify_container_baked_revision() { local name="$1" local expected_commit="$2" local revision="" local baked="" local image_id="" revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$name" 2>/dev/null || true)" image_id="$(docker inspect --format '{{.Image}}' "$name" 2>/dev/null || true)" baked="$(docker exec "$name" sh -c 'cat /app/DEPLOYMENT_COMMIT 2>/dev/null' 2>/dev/null | tr -d '\r\n' || true)" [ "$revision" = "$expected_commit" ] && [ "$baked" = "$expected_commit" ] && [ "$image_id" = "$EXPECTED_IMAGE_ID" ] } 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 || fail "Missing curl on production server" command -v flock >/dev/null 2>&1 || fail "Missing flock on production server" command -v ss >/dev/null 2>&1 || fail "Missing ss on production server; safe connection draining cannot be verified" command -v base64 >/dev/null 2>&1 || fail "Missing base64 on production server" command -v findmnt >/dev/null 2>&1 || fail "Missing findmnt on production server; uploads storage identity cannot be verified" command -v stat >/dev/null 2>&1 || fail "Missing stat on production server; uploads storage identity cannot be verified" command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" command -v systemctl >/dev/null 2>&1 || fail "Missing systemctl on production server" command -v sha256sum >/dev/null 2>&1 || fail "Missing sha256sum on production server" command -v timeout >/dev/null 2>&1 || fail "Missing timeout on production server" require_file "$ENV_FILE_PATH" uploads_findmnt_metadata_is_trusted() { local mount_path="$1" local mount_line="$2" local root_line="$3" local mount_target="" local mount_source="" local mount_fstype="" local mount_extra="" local root_target="" local root_source="" local root_fstype="" local root_extra="" [ -n "$mount_line" ] || return 1 [ -n "$root_line" ] || return 1 case "$mount_line" in *$'\n'*|*$'\r'*) return 1 ;; esac case "$root_line" in *$'\n'*|*$'\r'*) return 1 ;; esac read -r mount_target mount_source mount_fstype mount_extra <<< "$mount_line" read -r root_target root_source root_fstype root_extra <<< "$root_line" [ -n "$mount_target" ] && [ -n "$mount_source" ] && [ -n "$mount_fstype" ] && [ -z "$mount_extra" ] || return 1 [ "$root_target" = "/" ] && [ -n "$root_source" ] && [ -n "$root_fstype" ] && [ -z "$root_extra" ] || return 1 case "${mount_path}|${mount_target}|${mount_source}|${mount_fstype}" in '/mnt/PoolOne/youyidian-uploads|/mnt/PoolOne/youyidian-uploads|PoolOne/youyidian-uploads|zfs') ;; '/mnt/PoolOne/apps/youyidian-3d/uploads|/mnt/PoolOne|PoolOne|zfs') ;; *) return 1 ;; esac [ "$mount_target" != "$root_target" ] || return 1 [ "$mount_source" != "$root_source" ] || return 1 UPLOADS_MOUNT_TARGET="$mount_target" UPLOADS_MOUNT_SOURCE="$mount_source" UPLOADS_MOUNT_FSTYPE="$mount_fstype" } host_statfs_signature_is_trusted() { local uploads_signature="$1" local root_signature="$2" case "$uploads_signature" in ''|*$'\n'*|*$'\r'*|*[!0-9a-fA-F:]*) return 1 ;; esac case "$root_signature" in ''|*$'\n'*|*$'\r'*|*[!0-9a-fA-F:]*) return 1 ;; esac [ "$uploads_signature" != "$root_signature" ] } statfs_signatures_match() { local host_signature="$1" local container_signature="$2" case "$host_signature" in ''|*$'\n'*|*$'\r'*|*[!0-9a-fA-F:]*) return 1 ;; esac case "$container_signature" in ''|*$'\n'*|*$'\r'*|*[!0-9a-fA-F:]*) return 1 ;; esac [ "$host_signature" = "$container_signature" ] } uploads_container_mount_metadata_is_trusted() { local expected_source="$1" local metadata="$2" local allow_resolved_source_alias="${3:-0}" local matching_metadata="" local matching_count="" local destination="" local mount_type="" local source="" local read_write="" local extra="" local resolved_source="" local resolved_expected_source="" [ -n "$metadata" ] || return 1 case "$metadata" in *$'\r'*) return 1 ;; esac matching_metadata="$(printf '%s\n' "$metadata" | awk -F '\t' '$1 == "/app/uploads" { print }')" matching_count="$(printf '%s\n' "$matching_metadata" | awk 'NF { count++ } END { print count + 0 }')" [ "$matching_count" = "1" ] || return 1 IFS=$'\t' read -r destination mount_type source read_write extra <<< "$matching_metadata" [ "$destination" = "/app/uploads" ] || return 1 [ "$mount_type" = "bind" ] || return 1 if [ "$source" != "$expected_source" ]; then # Old production containers were created before UPLOADS_MOUNT was # canonicalised. They may retain the former path spelling in Docker # metadata, while resolving to exactly the same verified ZFS dataset. # This compatibility is for draining an existing container only; newly # created candidates always use the canonical string above. [ "$allow_resolved_source_alias" = "1" ] || return 1 resolved_source="$(readlink -f -- "$source")" || return 1 resolved_expected_source="$(readlink -f -- "$expected_source")" || return 1 [ "$resolved_source" = "$resolved_expected_source" ] || return 1 fi [ "$read_write" = "true" ] || return 1 [ -z "$extra" ] } verify_host_uploads_mount() { local mount_line="" local root_line="" local uploads_signature="" local root_signature="" [ -d "$UPLOADS_MOUNT" ] || { warn "Uploads mount path does not exist: $UPLOADS_MOUNT" return 1 } if ! mount_line="$(findmnt -rn -o TARGET,SOURCE,FSTYPE -T "$UPLOADS_MOUNT")"; then warn "findmnt failed for the configured uploads path" return 1 fi if ! root_line="$(findmnt -rn -o TARGET,SOURCE,FSTYPE -T /)"; then warn "findmnt failed for the host root filesystem" return 1 fi if ! uploads_findmnt_metadata_is_trusted "$UPLOADS_MOUNT" "$mount_line" "$root_line"; then warn "Uploads findmnt identity is not an explicitly trusted production tuple" return 1 fi if ! uploads_signature="$(stat -f -c '%i:%t' -- "$UPLOADS_MOUNT")"; then warn "Cannot read uploads statfs identity" return 1 fi if ! root_signature="$(stat -f -c '%i:%t' -- /)"; then warn "Cannot read root statfs identity" return 1 fi if ! host_statfs_signature_is_trusted "$uploads_signature" "$root_signature"; then warn "Uploads path resolves to the root filesystem or has an invalid statfs identity" return 1 fi HOST_UPLOADS_STATFS_SIGNATURE="$uploads_signature" ok "Uploads mount verified: TARGET=${UPLOADS_MOUNT_TARGET} SOURCE=${UPLOADS_MOUNT_SOURCE} FSTYPE=${UPLOADS_MOUNT_FSTYPE}" } container_uploads_mount_metadata() { local name="$1" docker inspect --format '{{range .Mounts}}{{printf "%s\t%s\t%s\t%t\n" .Destination .Type .Source .RW}}{{end}}' "$name" } verify_container_uploads_mount_metadata() { local name="$1" local allow_resolved_source_alias="${2:-0}" local metadata="" if ! metadata="$(container_uploads_mount_metadata "$name")"; then warn "Cannot inspect uploads mount metadata for container ${name}" return 1 fi if ! uploads_container_mount_metadata_is_trusted "$UPLOADS_MOUNT" "$metadata" "$allow_resolved_source_alias"; then warn "Container ${name} does not have exactly one trusted RW bind mount at /app/uploads" return 1 fi } verify_container_upload_dir_config() { local name="$1" local require_exact="${2:-0}" local env_lines="" local count="0" local value="" if ! env_lines="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$name")"; then warn "Cannot inspect UPLOAD_DIR for container ${name}" return 1 fi count="$(printf '%s\n' "$env_lines" | awk '/^UPLOAD_DIR=/{count++} END{print count+0}')" value="$(printf '%s\n' "$env_lines" | awk -F= '/^UPLOAD_DIR=/{sub(/^UPLOAD_DIR=/, ""); print}')" if [ "$require_exact" = "1" ]; then if [ "$count" != "1" ] || [ "$value" != "/app/uploads" ]; then warn "Container ${name} must declare exactly UPLOAD_DIR=/app/uploads" return 1 fi return 0 fi if [ "$count" = "0" ]; then return 0 fi if [ "$count" = "1" ] && { [ "$value" = "/app/uploads" ] || [ "$value" = "./uploads" ]; }; then return 0 fi warn "Existing container ${name} has an unsafe UPLOAD_DIR override" return 1 } verify_container_upload_dir_runtime() { local name="$1" local require_exact="${2:-0}" verify_container_upload_dir_config "$name" "$require_exact" || return 1 docker exec "$name" node -e ' const path = require("path"); const configured = String(process.env.UPLOAD_DIR || "").trim(); const resolved = configured ? path.resolve(configured) : path.join(process.cwd(), "uploads"); if (resolved !== "/app/uploads") process.exit(1); ' >/dev/null 2>&1 || { warn "Container ${name} application upload root does not resolve to /app/uploads" return 1 } } verify_container_uploads_statfs() { local name="$1" local container_signature="" if ! container_signature="$(docker exec "$name" stat -f -c '%i:%t' -- /app/uploads)"; then warn "Cannot read /app/uploads statfs identity from container ${name}" return 1 fi if ! statfs_signatures_match "$HOST_UPLOADS_STATFS_SIGNATURE" "$container_signature"; then warn "Container ${name} /app/uploads statfs identity differs from the verified host data disk" return 1 fi } verify_container_uploads_storage() { local name="$1" local require_exact="${2:-0}" local allow_resolved_source_alias="0" [ "$require_exact" = "1" ] || allow_resolved_source_alias="1" verify_container_uploads_mount_metadata "$name" "$allow_resolved_source_alias" || return 1 verify_container_uploads_statfs "$name" || return 1 verify_container_upload_dir_runtime "$name" "$require_exact" } # This gate is intentionally before every container discovery, drain, start, # stop, or proxy mutation below. A directory shell on the root disk must never # be allowed to seed a second uploads/runtime universe. if ! verify_host_uploads_mount; then fail "Production uploads data disk could not be proven; no container or traffic was changed" fi 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 [[ "$EXPECTED_IMAGE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Trusted build image ID is missing or malformed" ACTUAL_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$REGISTRY_COMMIT" 2>/dev/null || true)" [ "$ACTUAL_IMAGE_ID" = "$EXPECTED_IMAGE_ID" ] || fail "Production image differs from the exact artifact built on the pinned build server" ok "Production image identity is pinned to $EXPECTED_IMAGE_ID" 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" PROXY_RECONCILE_LOCK="/run/${CONTAINER_NAME}-local-proxy-reconcile.lock" DEPLOY_LOCK="/run/${CONTAINER_NAME}-production-deploy.lock" BOOTSTRAP_ROOT="${PROXY_ROOT}/legacy-bootstrap" PROXY_CONFIG_REPLACED=0 PROXY_CONFIG_BACKUP="" 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="" READY_BOOTSTRAP_HANDOFF="" exec 9>"$DEPLOY_LOCK" flock -w 30 9 || fail "Another production deployment or legacy bootstrap transition is already running" verify_migration_audit_evidence() { if [ "$REQUIRE_MIGRATION_AUDIT" != "1" ]; then ok "Migration audit is not required for this normal application release" return 0 fi [ "$TARGET" = "production" ] || return 0 case "$MIGRATION_AUDIT_FILE" in /root/gongxiangp2-migration-audit/*/applied-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z.json) ;; *) fail "Migration apply audit path is outside the immutable production audit tree" ;; esac [ -f "$MIGRATION_AUDIT_FILE" ] && [ ! -L "$MIGRATION_AUDIT_FILE" ] || fail "Migration apply audit file is missing or not a regular file" [ "$(stat -c '%a' "$MIGRATION_AUDIT_FILE")" = "600" ] || fail "Migration apply audit file permissions must be 0600" [ "$(sha256sum "$MIGRATION_AUDIT_FILE" | awk '{print $1}')" = "$MIGRATION_AUDIT_SHA256" ] || fail "Migration apply audit SHA-256 does not match approved evidence" docker run --rm -i --network none --read-only --user 0:0 --cap-drop ALL --security-opt no-new-privileges --cap-add DAC_READ_SEARCH \ -e MIGRATION_AUDIT_SOURCE_COMMIT="$MIGRATION_AUDIT_SOURCE_COMMIT" \ -v "$MIGRATION_AUDIT_FILE:/approved-migration-audit.json:ro" \ -v "$ENV_FILE_PATH:/approved-production.env:ro" \ --entrypoint node "$REGISTRY_COMMIT" <<'NODE' const fs = require('fs'); const audit = JSON.parse(fs.readFileSync('/approved-migration-audit.json', 'utf8')); if (audit.mode !== 'apply' || audit.verified !== true || audit.sourceCommit !== process.env.MIGRATION_AUDIT_SOURCE_COMMIT) process.exit(1); const env = Object.fromEntries(fs.readFileSync('/approved-production.env', 'utf8').split(/\r?\n/) .filter((line) => line && !line.trimStart().startsWith('#') && line.includes('=')) .map((line) => { const index = line.indexOf('='); return [line.slice(0, index), line.slice(index + 1)]; })); const url = new URL(env.DATABASE_URL || ''); const database = audit.database || {}; const expected = { configuredHost: url.hostname, configuredPort: url.port || '5432', configuredDatabase: decodeURIComponent(url.pathname.replace(/^\//, '')), configuredUser: decodeURIComponent(url.username), }; if (Object.entries(expected).some(([key, value]) => String(database[key] || '') !== value)) process.exit(1); const migrations = Array.isArray(audit.targetMigrations) ? audit.targetMigrations : []; const expectedMigrationNames = new Set([ '20260811_add_video_transcode_lease', '20260811_update_brand_logo_defaults', '20260812_add_product_package_upload_job_lease', '20260812_align_material_upload_idempotency_index', '20260813_add_material_custody_department', '20260813_add_organization_units_and_employment_state', '20260813_add_user_employee_binding', '20260817_add_material_is_incomplete_shell', '20260820_add_ding_workflow_snapshots', '20260824_expand_ding_workflow_insights', '20260826_add_ding_workflow_demand_type', '20260827_add_ding_workflow_detail_fields', '20260826_add_pack_video_early_access', '20260828_add_pack_item_can_edit_assets', '20260828_add_task_detail_change_approvals', '20260829_add_recoverable_api_key_secret', '20260831_add_private_task_detail', '20260902_add_employee_roster_history', '20260903_add_work_review_brand', '20260908_add_brand_knowledge', '20260909_expand_brand_knowledge_upload', '20260914_add_brand_knowledge_engagement', '20260914_expand_brand_knowledge_download_count', '20260914_expand_brand_knowledge_preview_slices', '20260915_add_brand_knowledge_soft_delete', '20260918_add_performance_conversion_sync', ]); if (migrations.length !== expectedMigrationNames.size || migrations.some((item) => !expectedMigrationNames.has(item && item.name) || item.completed !== true || item.sourceChecksum !== item.databaseChecksum) || new Set(migrations.map((item) => item.name)).size !== expectedMigrationNames.size) process.exit(1); if (!database.actualDatabase || !database.actualUser || !database.serverAddress || !database.serverPort) process.exit(1); NODE } verify_migration_audit_evidence verify_candidate_database_identity() { [ "$REQUIRE_MIGRATION_AUDIT" = "1" ] || return 0 local name="$1" docker exec -i "$name" node -e ' const { PrismaClient } = require("@prisma/client"); (async () => { const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const input = Buffer.concat(chunks).toString("utf8"); const audit = JSON.parse(input); const expected = audit.database || {}; const url = new URL(process.env.DATABASE_URL || ""); const configured = { configuredHost: url.hostname, configuredPort: url.port || "5432", configuredDatabase: decodeURIComponent(url.pathname.replace(/^\//, "")), configuredUser: decodeURIComponent(url.username), }; const prisma = new PrismaClient(); try { const rows = await prisma.$queryRawUnsafe(`SELECT current_database() AS database, current_user AS user, inet_server_addr()::text AS server_address, inet_server_port()::text AS server_port`); const actual = rows[0] || {}; const ok = Object.entries(configured).every(([key, value]) => String(expected[key] || "") === value) && String(actual.database || "") === String(expected.actualDatabase || "") && String(actual.user || "") === String(expected.actualUser || "") && String(actual.server_address || "") === String(expected.serverAddress || "") && String(actual.server_port || "") === String(expected.serverPort || ""); if (!ok) process.exitCode = 1; } finally { await prisma.$disconnect(); } })().catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }); ' < "$MIGRATION_AUDIT_FILE" } verify_candidate_authenticated_s3() { local name="$1" docker exec "$name" node -e ' const { S3Client, HeadBucketCommand } = require("@aws-sdk/client-s3"); const required = ["S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"]; if (required.some((name) => !String(process.env[name] || "").trim())) process.exit(1); const endpoint = String(process.env.S3_ENDPOINT).split("#")[0].trim(); const client = new S3Client({ endpoint, region: process.env.S3_REGION || "us-east-1", credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY }, forcePathStyle: true, maxAttempts: 2, }); (async () => { try { await client.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET }), { abortSignal: AbortSignal.timeout(12000) }); } finally { client.destroy(); } })().catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }); ' } decode_image_stream_with_candidate() { local name="$1" docker exec -i "$name" node -e ' const sharp = require("sharp"); (async () => { const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const input = Buffer.concat(chunks); if (!input.length) throw new Error("image input is empty"); await sharp(input, { failOn: "error", limitInputPixels: 25000000, animated: false }).rotate().toBuffer(); })().catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }); ' } verify_candidate_baked_brand_assets() { local name="$1" docker exec "$name" node -e ' const sharp = require("sharp"); Promise.all(["header-logo.png", "login-logo.png", "favicon.png"].map((name) => sharp(`/app/public/brand/${name}`, { failOn: "error", limitInputPixels: 25000000, animated: false }).rotate().toBuffer() )).catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }); ' } verify_candidate_configured_brand_assets() { local name="$1" local candidate_ip="$2" local prefix="/tmp/git-deploy-candidate-brand" local logo_index=0 local logo_url="" local resolved_logo_url="" local logo_content_type="" curl -fsS --max-time 12 "http://${candidate_ip}:3000/api/system-settings" > "${prefix}-settings.json" || return 1 docker exec -i -e PUBLIC_APP_URL="$PUBLIC_APP_URL" "$name" node -e ' (async () => { const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const settings = JSON.parse(Buffer.concat(chunks).toString("utf8")); const origin = new URL(process.env.PUBLIC_APP_URL); const values = [settings.headerLogoUrl, settings.loginLogoUrl]; if (!values.every((value) => typeof value === "string" && value.length > 0)) process.exit(1); const normalized = values.map((value) => { const parsed = new URL(value, origin); const local = parsed.origin === origin.origin && /^\/brand\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(parsed.pathname) && !parsed.search && !parsed.hash; const s3 = parsed.protocol === "https:" && parsed.hostname === "s3.jiansun.vip" && parsed.port === "" && parsed.pathname.startsWith("/gongxiangpan/") && !parsed.search && !parsed.hash; if (!local && !s3) process.exit(1); return local ? parsed.pathname : parsed.href; }); process.stdout.write(normalized.join("\n") + "\n"); })().catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }); ' < "${prefix}-settings.json" > "${prefix}-urls.txt" || return 1 while IFS= read -r logo_url; do [ -n "$logo_url" ] || return 1 logo_index=$((logo_index + 1)) case "$logo_url" in /brand/*) resolved_logo_url="http://${candidate_ip}:3000${logo_url}" ;; https://s3.jiansun.vip/gongxiangpan/*) resolved_logo_url="$logo_url" ;; *) return 1 ;; esac curl -fsS --max-time 12 -D "${prefix}-${logo_index}.headers" \ -o "${prefix}-${logo_index}.body" "$resolved_logo_url" || return 1 logo_content_type="$(tr -d '\r' <"${prefix}-${logo_index}.headers" | sed -nE 's/^Content-Type:[[:space:]]*([^;[:space:]]+).*$/\1/Ip' | tail -n 1 | tr 'A-Z' 'a-z')" case "$logo_content_type" in image/*) ;; *) return 1 ;; esac decode_image_stream_with_candidate "$name" < "${prefix}-${logo_index}.body" || return 1 done < "${prefix}-urls.txt" [ "$logo_index" -eq 2 ] } verify_candidate_brand_and_storage_dependencies() { local name="$1" local candidate_ip="$2" verify_candidate_authenticated_s3 "$name" || return 1 verify_candidate_baked_brand_assets "$name" || return 1 verify_candidate_configured_brand_assets "$name" "$candidate_ip" || return 1 } verify_ready_bootstrap_handoff_evidence() { local handoff="$1" local evidence_name="" local legacy_container_id="" local legacy_image_id="" local legacy_image_repo_digest="" local legacy_upstream="" local legacy_ip="" local uploads_signature="" local preserved_container="" local expected_preserved_container="" local preserved_identity="" local expected_identity="" local repo_digests="" local mount_metadata="" local published_endpoints="" local octet="" local -a legacy_octets=() [ -d "$handoff" ] && [ ! -L "$handoff" ] || fail "Bootstrap handoff evidence is not a physical directory" [ "$(stat -c '%a' "$handoff")" = "700" ] || fail "Bootstrap handoff directory permissions must be 0700" for evidence_name in target-commit legacy-container-id legacy-image-id legacy-image-repo-digest legacy-upstream uploads-statfs-signature preserved-container handoff.ready; do [ -f "$handoff/$evidence_name" ] && [ ! -L "$handoff/$evidence_name" ] || fail "Bootstrap handoff evidence is missing or not a regular file: $evidence_name" [ "$(stat -c '%a' "$handoff/$evidence_name")" = "600" ] || fail "Bootstrap handoff evidence permissions must be 0600: $evidence_name" done [ "$(tr -d '\r\n' < "$handoff/target-commit")" = "$COMMIT_SHA" ] || fail "Bootstrap handoff targets a different Git commit" [ "$(tr -d '\r\n' < "$handoff/handoff.ready")" = "ready" ] || fail "Bootstrap handoff ready marker is invalid" legacy_container_id="$(tr -d '\r\n' < "$handoff/legacy-container-id")" legacy_image_id="$(tr -d '\r\n' < "$handoff/legacy-image-id")" legacy_image_repo_digest="$(tr -d '\r\n' < "$handoff/legacy-image-repo-digest")" legacy_upstream="$(tr -d '\r\n' < "$handoff/legacy-upstream")" uploads_signature="$(tr 'A-F' 'a-f' < "$handoff/uploads-statfs-signature" | tr -d '\r\n')" preserved_container="$(tr -d '\r\n' < "$handoff/preserved-container")" [[ "$legacy_container_id" =~ ^[0-9a-f]{64}$ ]] || fail "Bootstrap handoff legacy container ID is malformed" [[ "$legacy_image_id" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Bootstrap handoff legacy image ID is malformed" [[ "$legacy_image_repo_digest" =~ ^[A-Za-z0-9._:/-]+@sha256:[0-9a-f]{64}$ ]] || fail "Bootstrap handoff legacy RepoDigest is malformed" [[ "$legacy_upstream" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}:3000$ ]] || fail "Bootstrap handoff legacy upstream is malformed" legacy_ip="${legacy_upstream%:3000}" IFS='.' read -r -a legacy_octets <<< "$legacy_ip" [ "${#legacy_octets[@]}" = "4" ] || fail "Bootstrap handoff legacy IPv4 address is malformed" for octet in "${legacy_octets[@]}"; do (( 10#$octet <= 255 )) || fail "Bootstrap handoff legacy IPv4 address is out of range" done [ "$uploads_signature" = "$(printf '%s' "$HOST_UPLOADS_STATFS_SIGNATURE" | tr 'A-F' 'a-f')" ] || fail "Bootstrap handoff uploads statfs identity changed" expected_preserved_container="${CONTAINER_NAME}-legacy-${COMMIT_SHA:0:12}-${legacy_container_id:0:12}" [ "$preserved_container" = "$expected_preserved_container" ] || fail "Bootstrap handoff preserved container name is not deterministic" if ! preserved_identity="$(docker inspect --format '{{printf "%s\t%s\t%s\t%t\t%t" .Id .Image .State.Status .State.Running .State.Paused}}' "$preserved_container")"; then fail "Preserved legacy container is missing" fi expected_identity="${legacy_container_id}"$'\t'"${legacy_image_id}"$'\t'exited$'\t'false$'\t'false [ "$preserved_identity" = "$expected_identity" ] || fail "Preserved legacy container is not the exact stopped handoff identity" if ! repo_digests="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$legacy_image_id")"; then fail "Preserved legacy image is missing" fi [ "$(printf '%s\n' "$repo_digests" | grep -Fxc -- "$legacy_image_repo_digest")" = "1" ] || fail "Preserved legacy image RepoDigest changed" if ! mount_metadata="$(container_uploads_mount_metadata "$preserved_container")"; then fail "Preserved legacy uploads mount metadata is unavailable" fi uploads_container_mount_metadata_is_trusted "$UPLOADS_MOUNT" "$mount_metadata" || fail "Preserved legacy uploads bind changed" if ! published_endpoints="$(docker port "$preserved_container" 3000/tcp 2>/dev/null | tr -d '\r' | sort -u | paste -sd, -)"; then fail "Preserved legacy published endpoints are unavailable" fi [ "$published_endpoints" = "0.0.0.0:${PORT},[::]:${PORT}" ] || fail "Preserved legacy published endpoints changed" } verify_bootstrap_handoff_if_present() { [ "$TARGET" = "production" ] || return 0 local handoff="" local ready_count=0 local runtime_target="" while IFS= read -r handoff; do [ -n "$handoff" ] || continue [ ! -e "$handoff/consumed" ] || continue ready_count=$((ready_count + 1)) READY_BOOTSTRAP_HANDOFF="$handoff" done < <(find "$BOOTSTRAP_ROOT" -mindepth 2 -maxdepth 2 -type f -name handoff.ready -printf '%h\n' 2>/dev/null) [ "$ready_count" -le 1 ] || fail "Multiple ready bootstrap handoffs are ambiguous" runtime_target="$(runtime_proxy_container)" if [ -n "$runtime_target" ]; then [ "$ready_count" -eq 0 ] || fail "A normal blue-green entry cannot consume a stale bootstrap handoff" elif is_proxy_running; then [ "$ready_count" -eq 1 ] || fail "The maintenance entry requires exactly one unconsumed bootstrap handoff for this Git commit" verify_ready_bootstrap_handoff_evidence "$READY_BOOTSTRAP_HANDOFF" maintenance_entry_is_ready_after_restore || fail "Bootstrap maintenance entry is not an exact dual-stack 503 handoff" elif [ "$ready_count" -ne 0 ]; then fail "Bootstrap handoff evidence exists but its maintenance proxy is not running" fi } ensure_public_traffic_rollup_token() { if [ "$TARGET" != "production" ]; then return 0 fi mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT" chmod 700 "$PUBLIC_TRAFFIC_SYNC_ROOT" if [ ! -s "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" ]; then if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 > "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" else tr -dc 'A-Za-z0-9' "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" fi fi chmod 600 "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" PUBLIC_TRAFFIC_ROLLUP_TOKEN="$(tr -d '\r\n' < "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE")" } install_public_traffic_rollup_timers() { if [ "$TARGET" != "production" ]; then return 0 fi mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT" cat > "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" <<'EOF' #!/usr/bin/env bash set -euo pipefail remote='trafficlog@218.93.9.38' key='/root/.ssh/public_traffic_log_sync_ed25519' dest='/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs' ssh_opts="ssh -i ${key} -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10" domains=( sc.jiansun.vip s3.jiansun.vip cp.jiansun.vip ) mkdir -p "$dest" chmod 755 "$dest" status=0 for domain in "${domains[@]}"; do if ! rsync -rtz --timeout=45 --chmod=F644,D755 -e "$ssh_opts" "${remote}:/var/log/nginx/${domain}.access.log*" "$dest/"; then echo "WARN: failed to sync ${domain}.access.log*" >&2 status=1 fi done find "$dest" -maxdepth 1 -type f -name '*.access.log*' -exec chmod 0644 {} + exit "$status" EOF chmod 700 "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" cat > "/etc/systemd/system/public-traffic-log-sync.service" < "/etc/systemd/system/public-traffic-log-sync.timer" < "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" </dev/null || true)" if [ -z "\$container" ] || ! docker inspect "\$container" >/dev/null 2>&1; then echo "No active material container found" >&2 exit 1 fi systemctl start public-traffic-log-sync.service >/dev/null 2>&1 || true docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity 5m yesterday="\$(TZ=Asia/Shanghai date -d 'yesterday' +%F)" docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "\$yesterday" --end-date "\$yesterday" EOF chmod 700 "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" cat > "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_SERVICE}" < "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_TIMER}" </dev/null 2>&1 || true systemctl enable "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true systemctl restart public-traffic-log-sync.timer >/dev/null 2>&1 || true systemctl restart "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true } is_proxy_running() { systemctl is-active --quiet "$PROXY_SERVICE" 2>/dev/null || { [ -f "$PROXY_PID" ] && kill -0 "$(cat "$PROXY_PID")" >/dev/null 2>&1 } } container_ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1" 2>/dev/null || true } write_proxy_config() { local upstream="$1" local target_container="$2" local temp_conf="" local backup_conf="" PROXY_CONFIG_REPLACED=0 PROXY_CONFIG_BACKUP="" case "$target_container" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") ;; *) warn "Refusing to write proxy config for invalid container: ${target_container}" return 1 ;; esac mkdir -p "$PROXY_ROOT" if ! temp_conf="$(mktemp "${PROXY_CONF}.tmp.XXXXXX")"; then return 1 fi cat > "$temp_conf" < "$service_temp" </dev/null 2>&1 || return 1 } switch_proxy() { ensure_proxy_service || return 1 if is_proxy_running; then systemctl reload "$PROXY_SERVICE" else systemctl start "$PROXY_SERVICE" fi } runtime_proxy_container_at_url() { local url="$1" local target="" target="$( curl --noproxy '*' -g -sS --max-time 5 -D - -o /dev/null "$url" 2>/dev/null | tr -d '\r' | sed -nE 's/^X-Material-Proxy-Target:[[:space:]]*(.*)$/\1/Ip' | tail -n 1 || true )" case "$target" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") printf '%s\n' "$target" ;; *) printf '\n' ;; esac } runtime_proxy_container() { runtime_proxy_container_at_url "http://127.0.0.1:${PORT}/api/health" } verify_proxy_target() { local expected_container="$1" local expected_commit="${2:-}" local observed_container="" local observed_ipv6_container="" for _ in $(seq 1 5); do observed_container="$(runtime_proxy_container)" observed_ipv6_container="$(runtime_proxy_container_at_url "http://[::1]:${PORT}/api/health")" if [ "$observed_container" = "$expected_container" ] && \ [ "$observed_ipv6_container" = "$expected_container" ] && \ 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 && \ health_payload_is_ready /tmp/git-deploy-entry-health.json "$expected_commit" && \ curl --noproxy '*' -g -fsS --max-time 8 "http://[::1]:${PORT}/api/health" >/tmp/git-deploy-entry-health-ipv6.json 2>/tmp/git-deploy-entry-health-ipv6.err && \ health_payload_is_ready /tmp/git-deploy-entry-health-ipv6.json "$expected_commit"; then return 0 fi sleep 1 done warn "Proxy target verification failed: expected=${expected_container}, observed_ipv4=${observed_container:-missing}, observed_ipv6=${observed_ipv6_container:-missing}" return 1 } install_proxy_reconciler() { local script_temp="" local service_file="/etc/systemd/system/${PROXY_RECONCILE_SERVICE}" local service_temp="" local timer_file="/etc/systemd/system/${PROXY_RECONCILE_TIMER}" local timer_temp="" mkdir -p "$PROXY_ROOT" || return 1 script_temp="$(mktemp "${PROXY_RECONCILE_SCRIPT}.tmp.XXXXXX")" printf '%s' "$PROXY_RECONCILE_SCRIPT_B64" | base64 -d > "$script_temp" bash -n "$script_temp" chmod 700 "$script_temp" mv "$script_temp" "$PROXY_RECONCILE_SCRIPT" service_temp="$(mktemp "${service_file}.tmp.XXXXXX")" cat > "$service_temp" < "$timer_temp" </dev/null 2>&1 || return 1 systemctl restart "$PROXY_RECONCILE_TIMER" } container_running() { [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" = "true" ] } read_recorded_container() { local name="" if [ -s "$CURRENT_FILE" ]; then name="$(tr -d '\r\n' < "$CURRENT_FILE")" fi case "$name" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") printf '%s\n' "$name" ;; *) printf '\n' ;; esac } write_current_container() { local name="$1" local temp_file="" mkdir -p "$PROXY_ROOT" temp_file="$(mktemp "${CURRENT_FILE}.tmp.XXXXXX")" printf '%s\n' "$name" > "$temp_file" chmod 0644 "$temp_file" mv "$temp_file" "$CURRENT_FILE" } discover_current_container() { local recorded_container="" local runtime_container="" local running_containers=() local name="" recorded_container="$(read_recorded_container)" runtime_container="$(runtime_proxy_container)" if [ -n "$runtime_container" ] && container_running "$runtime_container"; then printf '%s\n' "$runtime_container" return 0 fi for name in "$BLUE_CONTAINER" "$GREEN_CONTAINER"; do if container_running "$name"; then running_containers+=("$name") fi done if [ "${#running_containers[@]}" -eq 1 ]; then printf '%s\n' "${running_containers[0]}" return 0 fi if [ "${#running_containers[@]}" -gt 1 ]; then warn "Multiple running material containers found without a verified runtime proxy target (recorded=${recorded_container:-missing})" return 1 fi printf '\n' } health_payload_is_ready() { local payload_file="$1" local expected_commit="${2:-}" grep -Fq -- '"status":"healthy"' "$payload_file" || return 1 grep -Fq -- '"database":{"status":"ok"' "$payload_file" || return 1 grep -Fq -- '"redis":{"status":"ok"' "$payload_file" || return 1 if [ -n "$expected_commit" ]; then grep -Fq -- "\"deploymentCommit\":\"${expected_commit}\"" "$payload_file" || return 1 fi } wait_for_container_health() { local name="$1" local expected_commit="${2:-}" 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 && health_payload_is_ready /tmp/git-deploy-health.json "$expected_commit"; then printf '%s\n' "$ip" return 0 fi fi sleep 5 done return 1 } container_healthy() { local name="$1" local expected_commit="${2:-}" local ip="" container_running "$name" || return 1 ip="$(container_ip "$name")" [ -n "$ip" ] || return 1 curl -fsS --max-time 8 "http://${ip}:3000/api/health" >/tmp/git-deploy-container-health.json 2>/dev/null && health_payload_is_ready /tmp/git-deploy-container-health.json "$expected_commit" } # This is intentionally a one-slot replacement. It never starts a second # application worker beside the running one: the active slot is stopped before # the new image is created with that same name. It therefore matches the # former short deployment model and avoids the video/transcode drain protocol # required only by the retired blue/green hand-off path. restore_direct_previous_container() { local name="$1" local previous_image="$2" local previous_ip="" docker rm -f "$name" >/dev/null 2>&1 || return 1 create_container "$name" "$previous_image" 0 || return 1 verify_container_uploads_mount_metadata "$name" || return 1 verify_container_upload_dir_config "$name" 1 || return 1 docker start "$name" >/dev/null || return 1 verify_container_uploads_storage "$name" 1 || return 1 previous_ip="$(wait_for_container_health "$name")" || return 1 write_proxy_config "${previous_ip}:3000" "$name" || return 1 switch_proxy || return 1 verify_proxy_target "$name" || return 1 write_current_container "$name" || return 1 } fail_direct_replace_with_restore() { local reason="$1" local name="$2" local previous_image="$3" warn "$reason" if restore_direct_previous_container "$name" "$previous_image"; then fail "$reason; previous container was restored" fi fail "$reason; previous container could not be restored automatically" } retire_inactive_direct_slot() { local active_name="$1" local inactive_name="" case "$active_name" in "$BLUE_CONTAINER") inactive_name="$GREEN_CONTAINER" ;; "$GREEN_CONTAINER") inactive_name="$BLUE_CONTAINER" ;; *) return 1 ;; esac docker inspect "$inactive_name" >/dev/null 2>&1 || return 0 if container_running "$inactive_name"; then log "Retiring inactive historical container: $inactive_name" docker stop --time 10 "$inactive_name" >/dev/null || return 1 fi docker rm "$inactive_name" >/dev/null || return 1 } direct_replace_active_container() { local name="$1" local previous_image="" local replacement_ip="" case "$name" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") ;; *) fail "Direct replacement requires one active material slot" ;; esac previous_image="$(docker inspect -f '{{.Config.Image}}' "$name" 2>/dev/null || true)" [ -n "$previous_image" ] || fail "Active container image could not be determined: $name" verify_container_uploads_storage "$name" || fail "Active container uploads storage could not be verified before direct replacement" verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA" || fail "Direct replacement image label does not match the exact Git commit" prepare_worker_runtime_directories || fail "Worker runtime directories could not be prepared" log "Replacing active container directly: $name" if ! docker stop --time 10 "$name" >/dev/null; then fail "Active container could not be stopped for direct replacement" fi if ! docker rm "$name" >/dev/null; then docker start "$name" >/dev/null 2>&1 || true fail "Stopped container could not be removed for direct replacement" fi if ! create_container "$name" "$REGISTRY_COMMIT" 0; then fail_direct_replace_with_restore "New direct container could not be created" "$name" "$previous_image" fi if ! verify_container_uploads_mount_metadata "$name" || ! verify_container_upload_dir_config "$name" 1; then fail_direct_replace_with_restore "New direct container uploads configuration is invalid" "$name" "$previous_image" fi if ! docker start "$name" >/dev/null; then fail_direct_replace_with_restore "New direct container could not be started" "$name" "$previous_image" fi if ! verify_container_uploads_storage "$name" 1 || ! verify_container_baked_revision "$name" "$COMMIT_SHA"; then fail_direct_replace_with_restore "New direct container identity or uploads storage verification failed" "$name" "$previous_image" fi if ! replacement_ip="$(wait_for_container_health "$name" "$COMMIT_SHA")"; then fail_direct_replace_with_restore "New direct container failed its health check" "$name" "$previous_image" fi if ! verify_transcode_lease_schema "$name"; then fail_direct_replace_with_restore "New direct container cannot verify required video transcode lease columns" "$name" "$previous_image" fi if ! verify_task_detail_upload_lease_schema "$name"; then fail_direct_replace_with_restore "New direct container cannot verify task-detail upload job lease schema" "$name" "$previous_image" fi if ! verify_brand_logo_defaults "$name"; then fail_direct_replace_with_restore "New direct container cannot verify safe brand logo database defaults" "$name" "$previous_image" fi if ! verify_material_upload_idempotency_schema "$name"; then fail_direct_replace_with_restore "New direct container cannot verify material upload idempotency schema" "$name" "$previous_image" fi if ! verify_candidate_database_identity "$name"; then fail_direct_replace_with_restore "New direct container database identity differs from the approved migration audit" "$name" "$previous_image" fi if ! verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"; then fail_direct_replace_with_restore "New direct container cannot authenticate to S3 or decode configured brand images" "$name" "$previous_image" fi if ! verify_video_transcode_capability "$name"; then fail_direct_replace_with_restore "New direct container did not publish the required video transcode capability" "$name" "$previous_image" fi if ! verify_task_detail_upload_capability "$name"; then fail_direct_replace_with_restore "New direct container did not publish the required task-detail upload capability" "$name" "$previous_image" fi if ! verify_container_uploads_storage "$name" 1; then fail_direct_replace_with_restore "New direct container uploads storage identity changed before traffic switch" "$name" "$previous_image" fi if ! write_proxy_config "${replacement_ip}:3000" "$name" || ! switch_proxy || ! verify_proxy_target "$name" "$COMMIT_SHA"; then fail_direct_replace_with_restore "New direct container could not become the verified proxy target" "$name" "$previous_image" fi write_current_container "$name" || fail "Direct replacement traffic is healthy but the current-slot record could not be written" retire_inactive_direct_slot "$name" || warn "The inactive historical slot could not be removed; the active direct container remains healthy" rm -f -- "$(transcode_drain_file "$name")" "$(task_detail_drain_file "$name")" || true ok "Direct replacement completed: $name" } container_upstream_connection_count() { local name="$1" local ip="" command -v ss >/dev/null 2>&1 || return 2 container_running "$name" || { printf '0\n' return 0 } ip="$(container_ip "$name")" [ -n "$ip" ] || return 2 # Let ss filter the peer endpoint itself; column positions differ between # iproute2 versions when the State column is omitted by -H. ss -Htan state established dst "${ip}:3000" 2>/dev/null | awk 'END { print NR + 0 }' } wait_for_container_drain() { local name="$1" local max_checks="${2:-15}" local count="" local zero_samples=0 for _ in $(seq 1 "$max_checks"); do if ! count="$(container_upstream_connection_count "$name")"; then warn "Cannot prove that upstream connections drained for ${name}; leaving it running" return 1 fi if [ "$count" = "0" ]; then zero_samples=$((zero_samples + 1)) if [ "$zero_samples" -ge 2 ]; then return 0 fi else zero_samples=0 log "Waiting for ${count} active upstream connection(s) to drain from ${name}" fi sleep 2 done warn "Active upstream connections did not drain from ${name}; leaving it running" return 1 } TRANSCODE_RUNTIME_ROOT="${UPLOADS_MOUNT}/.video-transcode-runtime" TRANSCODE_DRAIN_TARGET="" TRANSCODE_DRAIN_TARGETS="" transcode_capability_file() { printf '%s/capabilities/%s.v1\n' "$TRANSCODE_RUNTIME_ROOT" "$1" } transcode_drain_file() { printf '%s/drain/%s\n' "$TRANSCODE_RUNTIME_ROOT" "$1" } transcode_active_file() { printf '%s/active/%s.json\n' "$TRANSCODE_RUNTIME_ROOT" "$1" } container_transcode_instance_id() { local name="$1" docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null | while IFS='=' read -r key value; do if [ "$key" = "VIDEO_TRANSCODE_INSTANCE_ID" ]; then printf '%s\n' "$value" break fi done } verify_video_transcode_capability() { local name="$1" local capability local instance_id capability="$(transcode_capability_file "$name")" instance_id="$(container_transcode_instance_id "$name")" [ -n "$instance_id" ] || return 1 [ -s "$capability" ] || return 1 grep -Fqx -- 'VIDEO_TRANSCODE_DRAIN_PROTOCOL_V1' "$capability" || return 1 grep -Fqx -- "workerId=$name" "$capability" || return 1 grep -Fqx -- "instanceId=$instance_id" "$capability" } resume_video_transcode() { local name="$1" local marker marker="$(transcode_drain_file "$name")" rm -f -- "$marker" || return 1 log "Video transcode drain resumed for ${name}" } wait_for_video_transcode_drain() { local name="$1" local max_checks="${2:-90}" local active local zero_samples=0 active="$(transcode_active_file "$name")" for _ in $(seq 1 "$max_checks"); do if [ -e "$active" ]; then zero_samples=0 log "Waiting for active video transcode lease in ${name}" else zero_samples=$((zero_samples + 1)) if [ "$zero_samples" -ge 2 ]; then return 0 fi fi sleep 2 done warn "Cannot prove video transcode worker ${name} is inactive; container will not be stopped" return 1 } request_video_transcode_drain() { local name="$1" local marker marker="$(transcode_drain_file "$name")" if ! verify_video_transcode_capability "$name"; then warn "Container ${name} does not prove current-instance VIDEO_TRANSCODE_DRAIN_PROTOCOL_V1 support" return 2 fi mkdir -p "$(dirname "$marker")" : > "$marker" chmod 600 "$marker" TRANSCODE_DRAIN_TARGET="$name" case " $TRANSCODE_DRAIN_TARGETS " in *" $name "*) ;; *) TRANSCODE_DRAIN_TARGETS="${TRANSCODE_DRAIN_TARGETS} ${name}" ;; esac wait_for_video_transcode_drain "$name" } TASK_DETAIL_RUNTIME_ROOT="${UPLOADS_MOUNT}/.task-detail-upload-runtime" TASK_DETAIL_DRAIN_TARGETS="" prepare_worker_runtime_directories() { local app_uid='' local app_gid='' local runtime_root='' app_uid="$(docker run --rm --network none --entrypoint id "$REGISTRY_COMMIT" -u nextjs 2>/dev/null || true)" app_gid="$(docker run --rm --network none --entrypoint id "$REGISTRY_COMMIT" -g nextjs 2>/dev/null || true)" [[ "$app_uid" =~ ^[1-9][0-9]*$ ]] || return 1 [[ "$app_gid" =~ ^[1-9][0-9]*$ ]] || return 1 for runtime_root in "$TRANSCODE_RUNTIME_ROOT" "$TASK_DETAIL_RUNTIME_ROOT"; do if [ -e "$runtime_root" ] && { [ ! -d "$runtime_root" ] || [ -L "$runtime_root" ]; }; then return 1 fi install -d -m 0750 -o "$app_uid" -g "$app_gid" "$runtime_root" || return 1 install -d -m 0750 -o "$app_uid" -g "$app_gid" "$runtime_root/capabilities" "$runtime_root/active" || return 1 if [ -e "$runtime_root/drain" ] && { [ ! -d "$runtime_root/drain" ] || [ -L "$runtime_root/drain" ]; }; then return 1 fi install -d -m 0755 -o 0 -g 0 "$runtime_root/drain" || return 1 done } task_detail_capability_file() { printf '%s/capabilities/%s.v1\n' "$TASK_DETAIL_RUNTIME_ROOT" "$1" } task_detail_drain_file() { printf '%s/drain/%s\n' "$TASK_DETAIL_RUNTIME_ROOT" "$1" } task_detail_active_file() { printf '%s/active/%s.json\n' "$TASK_DETAIL_RUNTIME_ROOT" "$1" } container_task_detail_instance_id() { local name="$1" docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$name" 2>/dev/null | while IFS='=' read -r key value; do if [ "$key" = "TASK_DETAIL_UPLOAD_INSTANCE_ID" ]; then printf '%s\n' "$value" break fi done } verify_task_detail_upload_capability() { local name="$1" local capability local instance_id capability="$(task_detail_capability_file "$name")" instance_id="$(container_task_detail_instance_id "$name")" [ -n "$instance_id" ] || return 1 [ -s "$capability" ] || return 1 grep -Fqx -- 'TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL_V1' "$capability" || return 1 grep -Fqx -- "workerId=$name" "$capability" || return 1 grep -Fqx -- "instanceId=$instance_id" "$capability" } resume_task_detail_upload() { local name="$1" rm -f -- "$(task_detail_drain_file "$name")" || return 1 log "Task-detail upload drain resumed for ${name}" } wait_for_task_detail_upload_drain() { local name="$1" local max_checks="${2:-900}" local active local zero_samples=0 active="$(task_detail_active_file "$name")" for _ in $(seq 1 "$max_checks"); do if [ -e "$active" ]; then zero_samples=0 log "Waiting for active task-detail upload job in ${name}" else zero_samples=$((zero_samples + 1)) if [ "$zero_samples" -ge 2 ]; then return 0 fi fi sleep 2 done warn "Cannot prove task-detail upload worker ${name} is inactive; container will not be stopped" return 1 } request_task_detail_upload_drain() { local name="$1" local marker marker="$(task_detail_drain_file "$name")" if ! verify_task_detail_upload_capability "$name"; then warn "Container ${name} does not prove current-instance TASK_DETAIL_UPLOAD_DRAIN_PROTOCOL_V1 support" return 2 fi mkdir -p "$(dirname "$marker")" : > "$marker" chmod 600 "$marker" case " $TASK_DETAIL_DRAIN_TARGETS " in *" $name "*) ;; *) TASK_DETAIL_DRAIN_TARGETS="${TASK_DETAIL_DRAIN_TARGETS} ${name}" ;; esac wait_for_task_detail_upload_drain "$name" } forget_task_detail_drain_target() { local forgotten="$1" local retained="" local target for target in $TASK_DETAIL_DRAIN_TARGETS; do if [ "$target" != "$forgotten" ]; then retained="${retained} ${target}" fi done TASK_DETAIL_DRAIN_TARGETS="$retained" } verify_task_detail_upload_lease_schema() { local name="$1" local result result="$(docker exec "$name" node -e ' const { createHash } = require("crypto"); const { readFileSync } = require("fs"); const { PrismaClient } = require("@prisma/client"); const p = new PrismaClient(); const migrationName = "20260812_add_product_package_upload_job_lease"; const migrationPath = `/app/prisma/migrations/${migrationName}/migration.sql`; let sourceChecksum = ""; try { sourceChecksum = createHash("sha256") .update(readFileSync(migrationPath, "utf8").replace(/\r\n/g, "\n"), "utf8") .digest("hex"); } catch (error) { process.stderr.write(String(error && error.message || error)); process.exit(1); } Promise.all([ p.$queryRawUnsafe("SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name IN ($2, $3, $4)", "product_package_upload_jobs", "worker_id", "lease_token", "lease_expires_at"), p.$queryRawUnsafe("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2", "product_package_upload_jobs", "product_package_upload_jobs_lease_idx"), p.$queryRawUnsafe("SELECT checksum FROM \"_prisma_migrations\" WHERE migration_name = $1 AND finished_at IS NOT NULL AND rolled_back_at IS NULL", migrationName) ]).then(([columns, indexes, migrations]) => { const byName = new Map(columns.map((row) => [String(row.column_name), row])); const textColumnOk = (column) => byName.has(column) && byName.get(column).data_type === "text" && byName.get(column).is_nullable === "YES"; const timestamp = byName.get("lease_expires_at"); const columnsOk = textColumnOk("worker_id") && textColumnOk("lease_token") && timestamp && timestamp.data_type === "timestamp without time zone" && timestamp.is_nullable === "YES"; const indexDefinition = String(indexes[0] && indexes[0].indexdef || ""); const normalizedIndexDefinition = indexDefinition.replace(/"/g, ""); const indexOk = indexes.length === 1 && /\(\s*status\s*,\s*lease_expires_at\s*\)/i.test(normalizedIndexDefinition); const migrationOk = migrations.length === 1 && String(migrations[0].checksum || "") === sourceChecksum; if (!columnsOk || !indexOk || !migrationOk) { process.stderr.write(JSON.stringify({ columnsOk, indexOk, migrationOk, indexDefinition, sourceChecksum })); process.exitCode = 1; return; } process.stdout.write("OK"); }).catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }).finally(() => p.$disconnect()); ' 2>/dev/null || true)" [ "$result" = "OK" ] } verify_transcode_lease_schema() { local name="$1" local result result="$(docker exec "$name" node -e ' const { createHash } = require("crypto"); const { readFileSync } = require("fs"); const { PrismaClient } = require("@prisma/client"); const p = new PrismaClient(); const migrationName = "20260811_add_video_transcode_lease"; const migrationPath = `/app/prisma/migrations/${migrationName}/migration.sql`; let sourceChecksum = ""; try { sourceChecksum = createHash("sha256") .update(readFileSync(migrationPath, "utf8").replace(/\r\n/g, "\n"), "utf8") .digest("hex"); } catch (error) { process.stderr.write(String(error && error.message || error)); process.exit(1); } Promise.all([ p.$queryRawUnsafe("SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name IN ($2, $3)", "materials", "transcode_lease_token", "transcode_lease_expires_at"), p.$queryRawUnsafe("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2", "materials", "materials_transcode_lease_idx"), p.$queryRawUnsafe("SELECT checksum FROM \"_prisma_migrations\" WHERE migration_name = $1 AND finished_at IS NOT NULL AND rolled_back_at IS NULL", migrationName) ]).then(([columns, indexes, migrations]) => { const byName = new Map(columns.map((row) => [String(row.column_name), row])); const token = byName.get("transcode_lease_token"); const expires = byName.get("transcode_lease_expires_at"); const columnsOk = columns.length === 2 && token && token.data_type === "text" && token.is_nullable === "YES" && expires && expires.data_type === "timestamp without time zone" && expires.is_nullable === "YES"; const indexDefinition = String(indexes[0] && indexes[0].indexdef || ""); const indexOk = indexes.length === 1 && /^CREATE INDEX\s+\S+\s+ON\s+\S+\s+USING\s+btree\s*\(\s*transcode_lease_expires_at\s*,\s*transcode_lease_token\s*\)\s*$/i.test(indexDefinition.replace(/"/g, "")) && !/\bUNIQUE\b|\bWHERE\b/i.test(indexDefinition); const migrationOk = migrations.length === 1 && String(migrations[0].checksum || "") === sourceChecksum; if (!columnsOk || !indexOk || !migrationOk) { process.stderr.write(JSON.stringify({ columnsOk, indexOk, migrationOk, indexDefinition, sourceChecksum })); process.exitCode = 1; return; } process.stdout.write("OK"); }).catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }).finally(() => p.$disconnect()); ' 2>/dev/null || true)" [ "$result" = "OK" ] } verify_brand_logo_defaults() { local name="$1" local result result="$(docker exec "$name" node -e ' const { PrismaClient } = require("@prisma/client"); const p = new PrismaClient(); Promise.all([ p.$queryRawUnsafe("SELECT column_name, column_default FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name IN ($2, $3, $4)", "system_settings", "headerLogoUrl", "loginLogoUrl", "logoUrl"), p.$queryRawUnsafe("SELECT COUNT(*)::int AS count FROM \"_prisma_migrations\" WHERE migration_name = $1 AND finished_at IS NOT NULL AND rolled_back_at IS NULL", "20260811_update_brand_logo_defaults"), p.$queryRawUnsafe("SELECT id, \"headerLogoUrl\", \"loginLogoUrl\" FROM \"system_settings\" ORDER BY id") ]).then(([columns, migrations, brandRows]) => { const defaults = new Map(columns.map((row) => [String(row.column_name), row.column_default])); const quote = String.fromCharCode(39); const normalize = (value) => { let normalized = String(value == null ? "" : value).trim().replace(/::(?:text|character varying)$/i, ""); if (normalized.startsWith(quote) && normalized.endsWith(quote)) normalized = normalized.slice(1, -1); return normalized; }; const headerOk = defaults.has("headerLogoUrl") && normalize(defaults.get("headerLogoUrl")) === "/brand/header-logo.png"; const loginOk = defaults.has("loginLogoUrl") && normalize(defaults.get("loginLogoUrl")) === "/brand/login-logo.png"; const legacyOk = !defaults.has("logoUrl") || normalize(defaults.get("logoUrl")) === "/brand/header-logo.png"; const migrationOk = Number(migrations[0] && migrations[0].count || 0) === 1; const isTrustedBrandValue = (value, localDefault) => { const normalized = String(value == null ? "" : value).trim(); if (normalized === localDefault) return true; try { const parsed = new URL(normalized); return parsed.protocol === "https:" && parsed.hostname === "s3.jiansun.vip" && parsed.port === "" && parsed.username === "" && parsed.password === "" && parsed.pathname.startsWith("/gongxiangpan/") && parsed.search === "" && parsed.hash === ""; } catch { return false; } }; const rowsOk = brandRows.every((row) => isTrustedBrandValue(row.headerLogoUrl, "/brand/header-logo.png") && isTrustedBrandValue(row.loginLogoUrl, "/brand/login-logo.png") ); if (!headerOk || !loginOk || !legacyOk || !migrationOk || !rowsOk) { process.stderr.write(JSON.stringify({ headerOk, loginOk, legacyOk, migrationOk, rowsOk })); process.exitCode = 1; return; } process.stdout.write("OK"); }).catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }).finally(() => p.$disconnect()); ' 2>/dev/null || true)" [ "$result" = "OK" ] } verify_material_upload_idempotency_schema() { local name="$1" local result result="$(docker exec "$name" node -e ' const { createHash } = require("crypto"); const { readFileSync } = require("fs"); const { PrismaClient } = require("@prisma/client"); const p = new PrismaClient(); const historicalMigrationName = "20251118000000_add_upload_id_to_materials"; const normalizationMigrationName = "20260812_align_material_upload_idempotency_index"; const migrationChecksum = (name) => createHash("sha256") .update(readFileSync(`/app/prisma/migrations/${name}/migration.sql`, "utf8").replace(/\r\n/g, "\n"), "utf8") .digest("hex"); let historicalSourceChecksum = ""; let normalizationSourceChecksum = ""; try { historicalSourceChecksum = migrationChecksum(historicalMigrationName); normalizationSourceChecksum = migrationChecksum(normalizationMigrationName); } catch (error) { process.stderr.write(String(error && error.message || error)); process.exit(1); } Promise.all([ p.$queryRawUnsafe("SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2", "materials", "uploadId"), p.$queryRawUnsafe(` SELECT i.indisunique, i.indisprimary, i.indisvalid, i.indisready, am.amname AS access_method, i.indnkeyatts, i.indnatts, pg_get_expr(i.indpred, i.indrelid) AS predicate, pg_get_indexdef(i.indexrelid) AS indexdef, ARRAY( SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum WHERE k.ordinality <= i.indnkeyatts ORDER BY k.ordinality ) AS key_columns FROM pg_class t JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_index i ON i.indrelid = t.oid JOIN pg_class idx ON idx.oid = i.indexrelid JOIN pg_am am ON am.oid = idx.relam WHERE n.nspname = current_schema() AND t.relname = $1 AND idx.relname = $2 `, "materials", "materials_uploadId_key"), p.$queryRawUnsafe("SELECT migration_name, checksum FROM \"_prisma_migrations\" WHERE migration_name IN ($1, $2) AND finished_at IS NOT NULL AND rolled_back_at IS NULL ORDER BY migration_name", historicalMigrationName, normalizationMigrationName) ]).then(([columns, indexes, migrations]) => { const column = columns[0]; const columnOk = columns.length === 1 && column && column.column_name === "uploadId" && column.data_type === "text" && column.is_nullable === "YES"; const index = indexes[0]; const indexDefinition = String(index && index.indexdef || ""); const predicate = String(index && index.predicate || ""); const normalizedPredicate = predicate.replace(/[\"()\s]/g, "").toLowerCase(); const keyColumns = Array.isArray(index && index.key_columns) ? index.key_columns.map(String) : []; const indexOk = indexes.length === 1 && index && index.indisunique === true && index.indisprimary === false && index.indisvalid === true && index.indisready === true && index.access_method === "btree" && Number(index.indnkeyatts) === 1 && Number(index.indnatts) === 1 && keyColumns.length === 1 && keyColumns[0] === "uploadId" && (normalizedPredicate === "uploadidisnotnullanddeletedatisnull" || normalizedPredicate === "deletedatisnullanduploadidisnotnull"); const migrationByName = new Map(migrations.map((row) => [String(row.migration_name), String(row.checksum || "")])); const migrationOk = migrations.length === 2 && migrationByName.get(historicalMigrationName) === historicalSourceChecksum && migrationByName.get(normalizationMigrationName) === normalizationSourceChecksum; if (!columnOk || !indexOk || !migrationOk) { process.stderr.write(JSON.stringify({ columnOk, indexOk, migrationOk, indexDefinition, predicate, keyColumns, historicalSourceChecksum, normalizationSourceChecksum })); process.exitCode = 1; return; } process.stdout.write("OK"); }).catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }).finally(() => p.$disconnect()); ' 2>/dev/null || true)" [ "$result" = "OK" ] } BACKGROUND_WORKERS_COMMITTED=0 resume_transcode_after_failed_deploy() { local status=$? trap - EXIT if [ "$status" -ne 0 ] && [ "$BACKGROUND_WORKERS_COMMITTED" != "1" ]; then for target in $TRANSCODE_DRAIN_TARGETS; do resume_video_transcode "$target" || \ warn "Deployment failed and video worker drain marker could not be removed: $target" done for target in $TASK_DETAIL_DRAIN_TARGETS; do resume_task_detail_upload "$target" || \ warn "Deployment failed and task-detail worker drain marker could not be removed: $target" done elif [ "$status" -ne 0 ]; then warn "Deployment failed after worker commit; previous background-worker drain markers remain enabled" fi exit "$status" } trap resume_transcode_after_failed_deploy EXIT forget_transcode_drain_target() { local forgotten="$1" local retained="" local target for target in $TRANSCODE_DRAIN_TARGETS; do if [ "$target" != "$forgotten" ]; then retained="${retained} ${target}" fi done TRANSCODE_DRAIN_TARGETS="$retained" } remove_video_container_safely() { local name="$1" if docker inspect "$name" >/dev/null 2>&1; then if container_running "$name"; then if ! wait_for_video_transcode_drain "$name" 2; then warn "Container $name still has active video transcode work and will be preserved" return 1 fi if ! wait_for_task_detail_upload_drain "$name" 2; then warn "Container $name still has active task-detail upload work and will be preserved" return 1 fi if ! docker stop --time 30 "$name" >/dev/null 2>&1; then warn "Container $name could not be stopped and will be preserved" return 1 fi fi if [ -e "$(transcode_active_file "$name")" ]; then warn "Container $name left an active marker and will not be removed" return 1 fi if [ -e "$(task_detail_active_file "$name")" ]; then warn "Container $name left a task-detail active marker and will not be removed" return 1 fi docker rm "$name" >/dev/null 2>&1 || return 1 fi rm -f -- \ "$(transcode_drain_file "$name")" \ "$(transcode_capability_file "$name")" \ "$(task_detail_drain_file "$name")" \ "$(task_detail_capability_file "$name")" } restore_unreloaded_proxy_config() { [ "$PROXY_CONFIG_REPLACED" = "1" ] || return 0 if [ -n "$PROXY_CONFIG_BACKUP" ] && [ -f "$PROXY_CONFIG_BACKUP" ]; then cp -a "$PROXY_CONFIG_BACKUP" "$PROXY_CONF" || return 1 else rm -f -- "$PROXY_CONF" || return 1 fi PROXY_CONFIG_REPLACED=0 PROXY_CONFIG_BACKUP="" } fail_candidate_before_traffic() { local reason="$1" local proxy_config_restored=1 local candidate_removed=1 if ! restore_unreloaded_proxy_config; then proxy_config_restored=0 warn "Previous proxy configuration could not be restored after candidate rejection" fi if ! remove_video_container_safely "$NEXT_CONTAINER"; then candidate_removed=0 warn "Rejected candidate could not be removed safely: $NEXT_CONTAINER" fi if [ "$proxy_config_restored" = "1" ] && [ "$candidate_removed" = "1" ]; then fail "${reason}; candidate was removed and existing entry was kept" fi fail "${reason}; existing traffic was not switched, but rejection cleanup needs operator attention" } maintenance_entry_is_ready_after_restore() { local suffix="" local url="" local http_code="" local target_count="" local target="" for suffix in ipv4 ipv6; do if [ "$suffix" = "ipv4" ]; then url="http://127.0.0.1:${PORT}/api/health" else url="http://[::1]:${PORT}/api/health" fi http_code="$(curl --noproxy '*' -g -sS --max-time 8 \ -D "/tmp/git-deploy-maintenance-${suffix}.headers" \ -o "/tmp/git-deploy-maintenance-${suffix}.body" \ -w '%{http_code}' "$url")" || return 1 [ "$http_code" = "503" ] || return 1 target_count="$(tr -d '\r' <"/tmp/git-deploy-maintenance-${suffix}.headers" | grep -Fic 'X-Material-Proxy-Target:' || true)" target="$(tr -d '\r' <"/tmp/git-deploy-maintenance-${suffix}.headers" | sed -nE 's/^X-Material-Proxy-Target:[[:space:]]*(.*)$/\1/Ip' | tail -n 1 || true)" [ "$target_count" = "1" ] && [ "$target" = "legacy-bootstrap-maintenance" ] || return 1 done } restore_bootstrap_maintenance_entry() { local temp_conf="" [ -n "$READY_BOOTSTRAP_HANDOFF" ] || return 1 [ -n "$PROXY_CONFIG_BACKUP" ] && [ -f "$PROXY_CONFIG_BACKUP" ] && [ ! -L "$PROXY_CONFIG_BACKUP" ] || return 1 temp_conf="$(mktemp "${PROXY_CONF}.restore.XXXXXX")" || return 1 cp -a -- "$PROXY_CONFIG_BACKUP" "$temp_conf" || { rm -f -- "$temp_conf"; return 1; } nginx -p "${PROXY_ROOT}/" -c "$temp_conf" -t || { rm -f -- "$temp_conf"; return 1; } mv -- "$temp_conf" "$PROXY_CONF" || { rm -f -- "$temp_conf"; return 1; } systemctl reload "$PROXY_SERVICE" || return 1 maintenance_entry_is_ready_after_restore || return 1 rm -f -- "$CURRENT_FILE" || return 1 PROXY_CONFIG_REPLACED=0 PROXY_CONFIG_BACKUP="" } rollback_to_previous_entry() { local previous_ip="" [ "$ROLLBACK_ON_FAILURE" = "1" ] || return 1 if [ "$PROXY_WAS_RUNNING" = "1" ] && [ -n "$CURRENT_CONTAINER" ]; then if ! container_running "$CURRENT_CONTAINER"; then if ! verify_container_uploads_mount_metadata "$CURRENT_CONTAINER"; then warn "Previous container uploads mount metadata is invalid: $CURRENT_CONTAINER" return 1 fi if ! verify_container_upload_dir_config "$CURRENT_CONTAINER" 0; then warn "Previous container upload root configuration is invalid: $CURRENT_CONTAINER" return 1 fi if ! docker start "$CURRENT_CONTAINER" >/dev/null; then warn "Failed to restart previous container $CURRENT_CONTAINER" return 1 fi if ! verify_container_uploads_statfs "$CURRENT_CONTAINER"; then warn "Previous container uploads statfs identity is invalid: $CURRENT_CONTAINER" return 1 fi if ! verify_container_upload_dir_runtime "$CURRENT_CONTAINER" 0; then warn "Previous container application upload root is invalid: $CURRENT_CONTAINER" return 1 fi if ! wait_for_container_health "$CURRENT_CONTAINER" >/dev/null; then warn "Previous container did not become healthy: $CURRENT_CONTAINER" return 1 fi else if ! verify_container_uploads_storage "$CURRENT_CONTAINER"; then warn "Previous container uploads storage identity is invalid: $CURRENT_CONTAINER" return 1 fi if ! container_healthy "$CURRENT_CONTAINER"; then warn "Previous container is not healthy: $CURRENT_CONTAINER" return 1 fi fi previous_ip="$(container_ip "$CURRENT_CONTAINER")" [ -n "$previous_ip" ] || return 1 if ! write_proxy_config "${previous_ip}:3000" "$CURRENT_CONTAINER"; then return 1 fi if ! switch_proxy; then return 1 fi if ! verify_proxy_target "$CURRENT_CONTAINER"; then return 1 fi if ! write_current_container "$CURRENT_CONTAINER"; then return 1 fi resume_video_transcode "$CURRENT_CONTAINER" || { warn "Previous container traffic was restored but video transcode drain could not be resumed" return 1 } resume_task_detail_upload "$CURRENT_CONTAINER" || { warn "Previous container traffic was restored but task-detail upload drain could not be resumed" return 1 } return 0 fi return 1 } abort_after_uncertain_switch() { local reason="$1" warn "$reason" # Traffic may already have reached the candidate. From this point the EXIT # trap must never blindly resume either old or new workers; rollback handles # the chosen worker explicitly and otherwise leaves both sides drained. BACKGROUND_WORKERS_COMMITTED=1 if [ "${PROXY_LOCK_HELD:-0}" != "1" ]; then if ! flock -w 30 8; then fail "$reason; proxy reconciliation lock could not be reacquired, so both containers remain drained" fi PROXY_LOCK_HELD=1 fi if ! request_video_transcode_drain "$NEXT_CONTAINER"; then warn "Candidate video transcode worker could not be safely drained; rollback was not attempted" fail "$reason; both containers were preserved" fi if ! request_task_detail_upload_drain "$NEXT_CONTAINER"; then warn "Candidate task-detail upload worker could not be safely drained; rollback was not attempted" fail "$reason; both containers were preserved" fi if rollback_to_previous_entry; then ok "Previous entry was restored and verified" if wait_for_container_drain "$NEXT_CONTAINER" 15; then remove_video_container_safely "$NEXT_CONTAINER" || \ warn "Rolled-back candidate was retained because video activity or stop state is uncertain" else warn "Rolled-back candidate was retained because an in-flight request is still using it" fi fail "$reason; deployment was rolled back" fi if restore_bootstrap_maintenance_entry; then ok "Bootstrap maintenance entry was restored and verified" if wait_for_container_drain "$NEXT_CONTAINER" 15; then remove_video_container_safely "$NEXT_CONTAINER" || \ warn "Candidate was retained after maintenance rollback because its stop state is uncertain" else warn "Candidate was retained after maintenance rollback because an in-flight request is still using it" fi fail "$reason; deployment returned to the verified bootstrap maintenance entry" fi warn "Rollback could not be verified; candidate container is being kept for recovery" fail "$reason; both containers were preserved" } # The historical shadow topology shared production uploads/runtime while using # a separate database lease. A running shadow app therefore makes global FFmpeg # exclusivity unknowable; inspect both known names and the Compose service label. verify_bootstrap_handoff_if_present if [ "$TARGET" = "production" ]; then if ! RUNNING_CONTAINER_METADATA="$(docker ps --format '{{.Names}}\t{{.Label "com.docker.compose.service"}}')"; then fail "Cannot inspect running containers for shadow topology; production deployment is blocked" fi RUNNING_SHADOW_CONTAINERS="$( printf '%s\n' "$RUNNING_CONTAINER_METADATA" | awk -F '\t' '$1 == "youyidian-3d-material-shadow" || $1 ~ /^youyidian-3d-shadow($|[-_])/ || $2 == "app-shadow" { print $1 }' | sort -u )" if [ -n "$RUNNING_SHADOW_CONTAINERS" ]; then fail "Running shadow container(s) detected; production deployment is blocked until they are stopped and verified: $RUNNING_SHADOW_CONTAINERS" fi fi # This general deployment entry must never perform the one-time direct-to-proxy # topology migration. Detect the legacy direct container before starting a # candidate, changing traffic, draining workers, or stopping any container. LEGACY_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || true)" PROXY_WAS_RUNNING=0 if is_proxy_running; then PROXY_WAS_RUNNING=1 elif [ -n "$LEGACY_IMAGE" ]; then fail "Stable local proxy is not running while legacy direct container $CONTAINER_NAME exists; no container or traffic was changed. Use the controlled one-time direct-to-proxy migration SOP before rerunning this deployment." fi if [ -n "$LEGACY_IMAGE" ]; then log "Legacy direct container detected behind an already-running stable proxy: $LEGACY_IMAGE" fi mkdir -p "$PROXY_ROOT" exec 8>"$PROXY_RECONCILE_LOCK" flock -w 30 8 || fail "Another proxy switch or reconciliation is already running" PROXY_LOCK_HELD=1 install_proxy_reconciler RECORDED_CONTAINER="$(read_recorded_container)" if ! CURRENT_CONTAINER="$(discover_current_container)"; then fail "Cannot safely determine the active material container" fi if [ -n "$CURRENT_CONTAINER" ] && [ "$CURRENT_CONTAINER" != "$RECORDED_CONTAINER" ]; then warn "Repairing stale current-container state: ${RECORDED_CONTAINER:-missing} -> ${CURRENT_CONTAINER}" write_current_container "$CURRENT_CONTAINER" fi if [ "$DEPLOYMENT_STRATEGY" = "direct" ]; then [ -n "$CURRENT_CONTAINER" ] || fail "Direct replacement requires an active material container" direct_replace_active_container "$CURRENT_CONTAINER" exit 0 fi if [ "$CURRENT_CONTAINER" = "$BLUE_CONTAINER" ]; then NEXT_CONTAINER="$GREEN_CONTAINER" else NEXT_CONTAINER="$BLUE_CONTAINER" fi ensure_public_traffic_rollup_token TRANSCODE_DRAIN_SOURCES="" if [ -n "$CURRENT_CONTAINER" ]; then TRANSCODE_DRAIN_SOURCES="$CURRENT_CONTAINER" fi if [ -n "$LEGACY_IMAGE" ] && container_running "$CONTAINER_NAME"; then case " $TRANSCODE_DRAIN_SOURCES " in *" $CONTAINER_NAME "*) ;; *) TRANSCODE_DRAIN_SOURCES="${TRANSCODE_DRAIN_SOURCES} ${CONTAINER_NAME}" ;; esac fi # 蓝绿入口和意外残留的直连容器都可能运行 worker;必须逐个证明当前协议、 # 设置 drain 并等待 active 消失。任一状态不明都保留原容器并停止发布。 for TRANSCODE_DRAIN_SOURCE in $TRANSCODE_DRAIN_SOURCES; do if ! verify_container_uploads_storage "$TRANSCODE_DRAIN_SOURCE"; then fail "Existing container uploads mount could not be verified before drain; candidate was not created" fi if ! verify_transcode_lease_schema "$TRANSCODE_DRAIN_SOURCE"; then fail "Video transcode lease columns are missing or could not be verified; apply the additive Prisma migration before deployment" fi if ! verify_task_detail_upload_lease_schema "$TRANSCODE_DRAIN_SOURCE"; then fail "Task-detail upload job lease schema or migration checksum is missing; apply and audit the additive Prisma migration before deployment" fi if ! verify_material_upload_idempotency_schema "$TRANSCODE_DRAIN_SOURCE"; then fail "Existing container cannot verify the exact material uploadId column, unique index, and migration checksum; candidate was not created" fi if ! request_video_transcode_drain "$TRANSCODE_DRAIN_SOURCE"; then fail "Existing container cannot be safely drained for video transcode; candidate was not started (first rollout fails closed)" fi if ! request_task_detail_upload_drain "$TRANSCODE_DRAIN_SOURCE"; then fail "Existing container cannot be safely drained for task-detail uploads; candidate was not started (first rollout fails closed)" fi ok "Existing video transcode worker is drained: $TRANSCODE_DRAIN_SOURCE" ok "Existing task-detail upload worker is drained: $TRANSCODE_DRAIN_SOURCE" done log "Starting candidate container $NEXT_CONTAINER" if ! verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA"; then fail "Candidate image label does not match the exact preflighted Git commit; no container or traffic was changed" fi if container_running "$NEXT_CONTAINER"; then if ! verify_container_uploads_storage "$NEXT_CONTAINER"; then fail "Inactive deployment slot $NEXT_CONTAINER has an untrusted uploads mount and cannot be safely drained" fi if ! request_video_transcode_drain "$NEXT_CONTAINER"; then fail "Inactive deployment slot $NEXT_CONTAINER cannot prove video transcode drain support" fi if ! request_task_detail_upload_drain "$NEXT_CONTAINER"; then fail "Inactive deployment slot $NEXT_CONTAINER cannot prove task-detail upload drain support" fi if ! wait_for_container_drain "$NEXT_CONTAINER" 15; then fail "Inactive deployment slot $NEXT_CONTAINER is still serving requests; retry after those requests finish" fi if ! wait_for_video_transcode_drain "$NEXT_CONTAINER" 2; then fail "Inactive deployment slot $NEXT_CONTAINER has unknown video activity and will not be removed" fi if ! wait_for_task_detail_upload_drain "$NEXT_CONTAINER" 2; then fail "Inactive deployment slot $NEXT_CONTAINER has unknown task-detail upload activity and will not be removed" fi fi if ! remove_video_container_safely "$NEXT_CONTAINER"; then fail "Inactive deployment slot $NEXT_CONTAINER could not be removed safely" fi forget_transcode_drain_target "$NEXT_CONTAINER" forget_task_detail_drain_target "$NEXT_CONTAINER" if ! prepare_worker_runtime_directories; then fail "Worker runtime directories could not be safely assigned to the verified application UID/GID" fi mkdir -p "$(dirname "$(transcode_drain_file "$NEXT_CONTAINER")")" : > "$(transcode_drain_file "$NEXT_CONTAINER")" chmod 600 "$(transcode_drain_file "$NEXT_CONTAINER")" mkdir -p "$(dirname "$(task_detail_drain_file "$NEXT_CONTAINER")")" : > "$(task_detail_drain_file "$NEXT_CONTAINER")" chmod 600 "$(task_detail_drain_file "$NEXT_CONTAINER")" if ! create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT" 0 >/tmp/git-deploy-new-container-id.txt; then fail_candidate_before_traffic "Candidate container could not be created" fi cat /tmp/git-deploy-new-container-id.txt if ! verify_container_uploads_mount_metadata "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate uploads bind metadata failed before docker start" fi if ! verify_container_upload_dir_config "$NEXT_CONTAINER" 1; then fail_candidate_before_traffic "Candidate UPLOAD_DIR is not pinned to /app/uploads before docker start" fi if ! docker start "$NEXT_CONTAINER" >/dev/null; then fail_candidate_before_traffic "Candidate container could not be started" fi if ! verify_container_uploads_statfs "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate host/container statfs identity differs before health checks" fi if ! verify_container_upload_dir_runtime "$NEXT_CONTAINER" 1; then fail_candidate_before_traffic "Candidate application upload root is not exactly /app/uploads" fi if ! verify_container_baked_revision "$NEXT_CONTAINER" "$COMMIT_SHA"; then fail_candidate_before_traffic "Candidate image label or baked commit file does not match the exact Git commit" fi if ! NEXT_IP="$(wait_for_container_health "$NEXT_CONTAINER" "$COMMIT_SHA")"; then warn "Candidate container failed health check" if ! docker logs --tail 120 "$NEXT_CONTAINER" 2>&1; then warn "Candidate logs could not be read" fi fail_candidate_before_traffic "Deployment failed before traffic switch" fi ok "Candidate container is healthy at ${NEXT_IP}:3000" if ! verify_transcode_lease_schema "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate cannot verify required video transcode lease columns; traffic was not switched" fi if ! verify_task_detail_upload_lease_schema "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate cannot verify task-detail upload job lease columns, index, and migration checksum; traffic was not switched" fi if ! verify_brand_logo_defaults "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate cannot verify safe brand logo database defaults and completed migration; traffic was not switched" fi if ! verify_material_upload_idempotency_schema "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate cannot verify material upload idempotency column, unique index, and completed migration; traffic was not switched" fi if ! verify_candidate_database_identity "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate database identity differs from the approved migration audit; traffic was not switched" fi if ! verify_candidate_brand_and_storage_dependencies "$NEXT_CONTAINER" "$NEXT_IP"; then fail_candidate_before_traffic "Candidate cannot authenticate to S3 or fully decode its configured and baked brand images; traffic was not switched" fi if ! verify_video_transcode_capability "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate did not publish the required video transcode drain capability" fi if ! verify_task_detail_upload_capability "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate did not publish the required task-detail upload drain capability" fi if ! wait_for_video_transcode_drain "$NEXT_CONTAINER" 2; then fail_candidate_before_traffic "Candidate started despite its pre-set drain marker; traffic was not switched" fi if ! wait_for_task_detail_upload_drain "$NEXT_CONTAINER" 2; then fail_candidate_before_traffic "Candidate started despite its pre-set task-detail drain marker; traffic was not switched" fi if ! write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"; then fail_candidate_before_traffic "Candidate proxy configuration could not be prepared" fi if ! verify_container_uploads_storage "$NEXT_CONTAINER" 1; then fail_candidate_before_traffic "Candidate uploads storage identity changed immediately before traffic switch" fi SWITCH_REPORTED_FAILURE=0 # nginx reload can complete before the shell receives its status. From this # instruction onward traffic may have reached the candidate, so the generic # EXIT trap must never resume the old workers on its own. BACKGROUND_WORKERS_COMMITTED=1 if ! switch_proxy; then SWITCH_REPORTED_FAILURE=1 fi if ! verify_proxy_target "$NEXT_CONTAINER"; then cat /tmp/git-deploy-entry-health.err >&2 2>/dev/null || true abort_after_uncertain_switch "Stable local entry did not verify the candidate target" fi if [ "$SWITCH_REPORTED_FAILURE" = "1" ]; then warn "Proxy switch command reported failure, but the runtime candidate target was independently verified" fi if ! write_current_container "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate traffic was verified but the committed target record could not be written" fi sleep 3 if ! verify_proxy_target "$NEXT_CONTAINER" "$COMMIT_SHA"; then abort_after_uncertain_switch "Candidate target did not remain healthy during the post-switch check" fi if ! resume_video_transcode "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate traffic is stable but its video transcode drain could not be resumed" fi if ! resume_task_detail_upload "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate traffic is stable but its task-detail upload drain could not be resumed" fi BACKGROUND_WORKERS_COMMITTED=1 ok "Candidate is the committed background worker: $NEXT_CONTAINER" if [ -n "$CURRENT_CONTAINER" ] && [ "$CURRENT_CONTAINER" != "$NEXT_CONTAINER" ]; then if [ "$(runtime_proxy_container)" != "$NEXT_CONTAINER" ] || \ [ "$(read_recorded_container)" != "$NEXT_CONTAINER" ] || \ ! container_healthy "$NEXT_CONTAINER" "$COMMIT_SHA" || \ ! verify_container_uploads_storage "$CURRENT_CONTAINER"; then abort_after_uncertain_switch "Proxy state changed before the previous container could be drained" fi if wait_for_container_drain "$CURRENT_CONTAINER" 15; then if wait_for_video_transcode_drain "$CURRENT_CONTAINER" 2 && \ wait_for_task_detail_upload_drain "$CURRENT_CONTAINER" 2 && \ docker stop --time 30 "$CURRENT_CONTAINER" >/dev/null 2>&1; then ok "Previous container stopped and retained for rollback: $CURRENT_CONTAINER" else warn "Previous container background-worker activity is not proven idle; it was retained in drain mode: $CURRENT_CONTAINER" fi else warn "Previous container is still serving an in-flight request and was retained: $CURRENT_CONTAINER" fi fi if ! verify_proxy_target "$NEXT_CONTAINER" "$COMMIT_SHA"; then abort_after_uncertain_switch "Candidate target failed after the previous container drain" fi if ! flock -u 8; then PROXY_LOCK_HELD=0 abort_after_uncertain_switch "Proxy reconciliation lock could not be released for post-deployment verification" fi PROXY_LOCK_HELD=0 if ! systemctl start "$PROXY_RECONCILE_SERVICE"; then abort_after_uncertain_switch "Proxy reconciler failed its post-deployment verification" fi 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 "$LEGACY_IMAGE" ]; then if ! remove_video_container_safely "$CONTAINER_NAME"; then abort_after_uncertain_switch "Legacy direct container could not be proven inactive and was preserved" fi fi if [ "$TARGET" = "production" ]; then log "Checking public FRP, authenticated S3, and Logo decode acceptance under the deployment lock" COMMITTED_PUBLIC_TARGET="$(tr -d '\r\n' < "$CURRENT_FILE" 2>/dev/null || true)" [ "$COMMITTED_PUBLIC_TARGET" = "$NEXT_CONTAINER" ] || abort_after_uncertain_switch "Production did not report the committed candidate target after cutover" curl -fsS --max-time 12 -D /tmp/git-deploy-public-health.headers \ "$PUBLIC_APP_URL/api/health" >/tmp/git-deploy-public-health.json || \ abort_after_uncertain_switch "Public application endpoint is unreachable after cutover" health_payload_is_ready /tmp/git-deploy-public-health.json "$COMMIT_SHA" || \ abort_after_uncertain_switch "Public application endpoint does not prove the expected commit and healthy database/Redis after cutover" public_target_count="$(tr -d '\r' /tmp/git-deploy-public-s3-health.txt || \ abort_after_uncertain_switch "Public S3 endpoint is unhealthy after cutover" curl -fsS --max-time 12 "$PUBLIC_APP_URL/api/system-settings" >/tmp/git-deploy-public-settings.json || \ abort_after_uncertain_switch "Public system settings are unavailable after cutover" PUBLIC_APP_URL="$PUBLIC_APP_URL" docker exec -i -e PUBLIC_APP_URL="$PUBLIC_APP_URL" "$NEXT_CONTAINER" node -e ' const fs = require("fs"); (async () => { const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const input = Buffer.concat(chunks).toString("utf8"); const settings = JSON.parse(input); const origin = new URL(process.env.PUBLIC_APP_URL); const values = [settings.headerLogoUrl, settings.loginLogoUrl]; if (!values.every((value) => typeof value === "string" && value.length > 0)) process.exit(1); for (const value of values) { const parsed = new URL(value, origin); const local = parsed.origin === origin.origin && /^\/brand\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(parsed.pathname) && !parsed.search && !parsed.hash; const s3 = parsed.protocol === "https:" && parsed.hostname === "s3.jiansun.vip" && parsed.port === "" && parsed.pathname.startsWith("/gongxiangpan/") && !parsed.search && !parsed.hash; if (!local && !s3) process.exit(1); } process.stdout.write(values.join("\n") + "\n"); })().catch((error) => { process.stderr.write(String(error && error.message || error)); process.exitCode = 1; }); ' /tmp/git-deploy-public-logo-urls.txt || \ abort_after_uncertain_switch "Public system settings did not expose two trusted Logo URLs" logo_index=0 while IFS= read -r logo_url; do logo_index=$((logo_index + 1)) case "$logo_url" in /*) resolved_logo_url="${PUBLIC_APP_URL%/}${logo_url}" ;; *) resolved_logo_url="$logo_url" ;; esac curl -fsS --max-time 12 -D "/tmp/git-deploy-logo-${logo_index}.headers" \ -o "/tmp/git-deploy-logo-${logo_index}.body" "$resolved_logo_url" || \ abort_after_uncertain_switch "Configured public Logo ${logo_index} is unavailable after cutover" logo_content_type="$(tr -d '\r' <"/tmp/git-deploy-logo-${logo_index}.headers" | sed -nE 's/^Content-Type:[[:space:]]*([^;[:space:]]+).*$/\1/Ip' | tail -n 1 | tr 'A-Z' 'a-z')" case "$logo_content_type" in image/*) ;; *) abort_after_uncertain_switch "Configured public Logo ${logo_index} did not return an image Content-Type" ;; esac decode_image_stream_with_candidate "$NEXT_CONTAINER" < "/tmp/git-deploy-logo-${logo_index}.body" || abort_after_uncertain_switch "Configured public Logo ${logo_index} cannot be fully decoded" done /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 prod_ssh \ "docker image prune -af --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" '@ 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.' } Write-Info 'Resolving and scanning the exact Git tree that will be deployed...' $resolvedTargetCommit = Invoke-LegacyStorageBoundaryPreflight ` -RepositoryUrl $RepoUrl ` -TargetBranch $Branch ` -RequestedCommit $Commit $tokens['__COMMIT__'] = ConvertTo-ShellSingleQuoted $resolvedTargetCommit Write-Success "Exact deployment target passed legacy storage boundary preflight: $resolvedTargetCommit" $remoteScript = $remoteTemplate foreach ($key in $tokens.Keys) { $remoteScript = $remoteScript.Replace($key, $tokens[$key]) } $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.'