Release runbook now describes default migrateProdSchema, auto-migrate safety guard, and troubleshooting for updated_at restore failures; i18n docs cover partial entity_translations index. Co-authored-by: Cursor <cursoragent@cursor.com>
623 lines
21 KiB
PowerShell
623 lines
21 KiB
PowerShell
# Dev to prod release orchestrator.
|
|
# Usage (from repo root):
|
|
# npm run devtoprod:release
|
|
# npm run devtoprod:release -- -DryRun
|
|
# npm run devtoprod:release -- -Config infra/deploy/devtoprod.config.json
|
|
#
|
|
# Requires infra/deploy/devtoprod.config.json (copy from devtoprod.config.example.json).
|
|
|
|
param(
|
|
[string]$Config = (Join-Path $PSScriptRoot "..\deploy\devtoprod.config.json"),
|
|
[switch]$DryRun
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
. (Join-Path $PSScriptRoot "lib\Deploy-CliResult.ps1")
|
|
|
|
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
|
Set-Location $RepoRoot
|
|
|
|
$BannerWidth = 64
|
|
$CompletedSteps = [System.Collections.Generic.List[string]]::new()
|
|
$FailedStep = $null
|
|
$script:ProdSchemaMigrated = $false
|
|
|
|
function Write-ReleaseBanner {
|
|
param([bool]$Success, [string]$FailedAt = '')
|
|
|
|
Write-Host ''
|
|
Write-Host ('=' * $BannerWidth)
|
|
if ($Success) {
|
|
Write-Host ' RELEASE SUCCEEDED - production promoted'
|
|
Write-Host (' Steps: ' + ($CompletedSteps -join ', '))
|
|
Write-Host ' Next: open https://gallery.mysuperlab.netcraze.pro/'
|
|
} else {
|
|
Write-Host " RELEASE FAILED at step: $FailedAt"
|
|
if ($CompletedSteps.Count -gt 0) {
|
|
Write-Host (' Completed: ' + ($CompletedSteps -join ', '))
|
|
}
|
|
Write-Host " Failed: $FailedAt"
|
|
}
|
|
Write-Host ('=' * $BannerWidth)
|
|
}
|
|
|
|
function Get-StepEnabled {
|
|
param([hashtable]$Steps, [string]$Name)
|
|
if ($Steps.ContainsKey($Name)) {
|
|
return [bool]$Steps[$Name]
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Invoke-Npm {
|
|
param([string]$Script, [string[]]$ExtraArgs = @())
|
|
$npmArgs = @('run', $Script)
|
|
if ($ExtraArgs.Count -gt 0) {
|
|
$npmArgs += '--'
|
|
$npmArgs += $ExtraArgs
|
|
}
|
|
Write-Host ("Running npm: npm " + ($npmArgs -join ' ')) -ForegroundColor DarkGray
|
|
return Invoke-ExternalCommand -Name 'npm' -CommandArgs $npmArgs
|
|
}
|
|
|
|
function Invoke-Git {
|
|
param([string[]]$GitArgs)
|
|
Write-Host ("Running git: git " + ($GitArgs -join ' ')) -ForegroundColor DarkGray
|
|
return Invoke-ExternalCommand -Name 'git' -CommandArgs $GitArgs
|
|
}
|
|
|
|
function Get-GitPorcelainStatus {
|
|
$prevEap = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
$output = & git status --porcelain 2>&1
|
|
$ErrorActionPreference = $prevEap
|
|
return ($output | Out-String).Trim()
|
|
}
|
|
|
|
function Invoke-ExternalCommand {
|
|
param(
|
|
[string]$Name,
|
|
[string[]]$CommandArgs
|
|
)
|
|
|
|
# Capture stdout/stderr so callers assigning the return value only get exit code.
|
|
$prevEap = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
$output = & $Name @CommandArgs 2>&1
|
|
$exit = $LASTEXITCODE
|
|
$ErrorActionPreference = $prevEap
|
|
|
|
foreach ($line in $output) {
|
|
Write-Host $line
|
|
}
|
|
return $exit
|
|
}
|
|
|
|
function Invoke-Step {
|
|
param(
|
|
[string]$Name,
|
|
[scriptblock]$Action
|
|
)
|
|
|
|
if ($DryRun) {
|
|
Write-Host "[dry-run] Would run: $Name"
|
|
$script:CompletedSteps.Add($Name)
|
|
return $true
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host "--- Step: $Name ---" -ForegroundColor Cyan
|
|
try {
|
|
$code = & $Action
|
|
if ($null -eq $code) {
|
|
$code = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 }
|
|
}
|
|
if ($code -ne 0) {
|
|
Write-Host ("Step failed with exit code: " + $code) -ForegroundColor Red
|
|
$script:FailedStep = $Name
|
|
return $false
|
|
}
|
|
$script:CompletedSteps.Add($Name)
|
|
return $true
|
|
} catch {
|
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
|
$script:FailedStep = $Name
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-ProfileSteps {
|
|
param([string]$Profile)
|
|
|
|
$base = @{
|
|
validateBuild = $false
|
|
gitCommitPush = $false
|
|
thumbnails = $false
|
|
backupDev = $false
|
|
backupProd = $false
|
|
migrateProdSchema = $false
|
|
restoreProd = $false
|
|
syncImages = $false
|
|
dockerPublish = $false
|
|
truenasRestartPause = $false
|
|
verify = $false
|
|
}
|
|
|
|
switch ($Profile) {
|
|
'code' {
|
|
$base.validateBuild = $true
|
|
$base.gitCommitPush = $true
|
|
$base.dockerPublish = $true
|
|
$base.truenasRestartPause = $true
|
|
$base.verify = $true
|
|
}
|
|
'data' {
|
|
$base.validateBuild = $true
|
|
$base.thumbnails = $true
|
|
$base.backupDev = $true
|
|
$base.backupProd = $true
|
|
$base.restoreProd = $true
|
|
$base.syncImages = $true
|
|
$base.verify = $true
|
|
}
|
|
default {
|
|
$base.validateBuild = $true
|
|
$base.gitCommitPush = $true
|
|
$base.thumbnails = $true
|
|
$base.backupDev = $true
|
|
$base.backupProd = $true
|
|
$base.migrateProdSchema = $true
|
|
$base.restoreProd = $true
|
|
$base.syncImages = $true
|
|
$base.dockerPublish = $true
|
|
$base.truenasRestartPause = $true
|
|
$base.verify = $true
|
|
}
|
|
}
|
|
|
|
return $base
|
|
}
|
|
|
|
function Merge-Steps {
|
|
param([hashtable]$ProfileSteps, [object]$ConfigSteps)
|
|
|
|
if ($null -eq $ConfigSteps) { return $ProfileSteps }
|
|
foreach ($key in $ConfigSteps.PSObject.Properties.Name) {
|
|
$ProfileSteps[$key] = [bool]$ConfigSteps.$key
|
|
}
|
|
return $ProfileSteps
|
|
}
|
|
|
|
function Get-LatestDevBackup {
|
|
$backupDir = Join-Path $RepoRoot 'db\DataBackup'
|
|
if (-not (Test-Path $backupDir)) {
|
|
throw "Backup directory not found: $backupDir"
|
|
}
|
|
$latest = Get-ChildItem -Path $backupDir -Filter 'gallery_dev_data_*.txt' |
|
|
Sort-Object LastWriteTime -Descending |
|
|
Select-Object -First 1
|
|
if (-not $latest) {
|
|
throw 'No gallery_dev_data_*.txt backup found in db/DataBackup/'
|
|
}
|
|
return $latest.FullName
|
|
}
|
|
|
|
function Assert-ProdDbTarget {
|
|
$prodEnvPath = Join-Path $RepoRoot 'infra\docker\.env.prod'
|
|
if (-not (Test-Path $prodEnvPath)) {
|
|
throw "Missing production env file: $prodEnvPath"
|
|
}
|
|
|
|
$prodDbName = $null
|
|
foreach ($line in Get-Content -LiteralPath $prodEnvPath) {
|
|
if ($line -match '^\s*DB_NAME\s*=\s*(.+?)\s*$') {
|
|
$prodDbName = $Matches[1].Trim().Trim('"', "'")
|
|
break
|
|
}
|
|
}
|
|
|
|
if (-not $prodDbName) {
|
|
throw "DB_NAME is not set in $prodEnvPath"
|
|
}
|
|
|
|
if ($prodDbName -ne 'gallery_prod') {
|
|
throw "Prod DB target is '$prodDbName' in .env.prod. Set DB_NAME=gallery_prod before dev -> prod migration."
|
|
}
|
|
|
|
Write-Host "Prod DB target confirmed: $prodDbName" -ForegroundColor DarkGray
|
|
}
|
|
|
|
function Get-DevApiPort {
|
|
$envFile = Join-Path $RepoRoot '.env'
|
|
$port = '3451'
|
|
if (Test-Path $envFile) {
|
|
foreach ($line in Get-Content -LiteralPath $envFile) {
|
|
if ($line -match '^\s*PORT\s*=\s*(\d+)\s*$') {
|
|
$port = $Matches[1]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return $port
|
|
}
|
|
|
|
function Test-TcpPortListening {
|
|
param([int]$Port)
|
|
|
|
$matches = netstat -ano | Select-String ":$Port\s"
|
|
foreach ($line in $matches) {
|
|
if ($line -match 'LISTENING') {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Start-DevNpmProcess {
|
|
param([string]$ScriptName)
|
|
|
|
$logPath = Join-Path $RepoRoot "infra\deploy\dev-autostart-$ScriptName.log"
|
|
Write-Host "Starting background process: npm run $ScriptName (log: $logPath)" -ForegroundColor Yellow
|
|
Start-Process -FilePath 'cmd.exe' -ArgumentList @(
|
|
'/c',
|
|
"npm run $ScriptName > `"$logPath`" 2>&1"
|
|
) -WorkingDirectory $RepoRoot -WindowStyle Hidden | Out-Null
|
|
}
|
|
|
|
function Ensure-DevApiReady {
|
|
param(
|
|
[int]$ApiPort,
|
|
[bool]$AutoStart = $true,
|
|
[int]$WaitSeconds = 90
|
|
)
|
|
|
|
$apiUrl = "http://127.0.0.1:$ApiPort/api/bounds"
|
|
$detail = ''
|
|
if (Test-BoundsJson -Url $apiUrl -MaxTimeSeconds 5 -Retries 1 -Detail ([ref]$detail)) {
|
|
Write-Host "Dev API already running: $apiUrl"
|
|
return $true
|
|
}
|
|
|
|
if (-not $AutoStart) {
|
|
return $false
|
|
}
|
|
|
|
$viteUp = Test-TcpPortListening -Port 5173
|
|
$apiUp = Test-TcpPortListening -Port $ApiPort
|
|
|
|
if ($viteUp -and -not $apiUp) {
|
|
Write-Host "Vite is running on :5173 but API is down on :$ApiPort." -ForegroundColor Yellow
|
|
Start-DevNpmProcess -ScriptName 'dev:server'
|
|
} elseif (-not $viteUp -and -not $apiUp) {
|
|
Write-Host 'Dev stack is not running.' -ForegroundColor Yellow
|
|
Start-DevNpmProcess -ScriptName 'dev:web'
|
|
} else {
|
|
Write-Host "API port :$ApiPort is listening but /api/bounds is not ready yet. Waiting..." -ForegroundColor Yellow
|
|
}
|
|
|
|
for ($elapsed = 3; $elapsed -le $WaitSeconds; $elapsed += 3) {
|
|
Start-Sleep -Seconds 3
|
|
if (Test-BoundsJson -Url $apiUrl -MaxTimeSeconds 5 -Retries 1 -Detail ([ref]$detail)) {
|
|
Write-Host "Dev API ready: $apiUrl"
|
|
return $true
|
|
}
|
|
Write-Host "Waiting for dev API... (${elapsed}s, $detail)" -ForegroundColor DarkGray
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
function Test-BoundsJson {
|
|
param(
|
|
[string]$Url,
|
|
[switch]$Insecure,
|
|
[int]$MaxTimeSeconds = 10,
|
|
[int]$Retries = 3,
|
|
[ref]$Detail
|
|
)
|
|
|
|
$lastDetail = 'no response'
|
|
for ($attempt = 1; $attempt -le $Retries; $attempt++) {
|
|
$curlArgs = @('-s', '-w', "`nHTTP_CODE:%{http_code}", '--max-time', $MaxTimeSeconds)
|
|
if ($Insecure) { $curlArgs += '-k' }
|
|
$curlArgs += $Url
|
|
|
|
$raw = (& curl.exe @curlArgs 2>&1 | Out-String).TrimEnd()
|
|
$httpCode = $null
|
|
$body = $raw
|
|
if ($raw -match '(?s)^(.*)HTTP_CODE:(\d+)\s*$') {
|
|
$body = $Matches[1].TrimEnd()
|
|
$httpCode = $Matches[2]
|
|
}
|
|
|
|
if ($httpCode -eq '200' -and $body) {
|
|
try {
|
|
$json = $body | ConvertFrom-Json
|
|
if ($null -ne $json.min_year -and $null -ne $json.max_year) {
|
|
return $true
|
|
}
|
|
$lastDetail = 'HTTP 200 but JSON missing min_year/max_year'
|
|
} catch {
|
|
$lastDetail = 'HTTP 200 but response is not valid JSON'
|
|
}
|
|
} elseif ($httpCode) {
|
|
$lastDetail = "HTTP $httpCode"
|
|
} else {
|
|
$lastDetail = "curl exit $LASTEXITCODE"
|
|
}
|
|
|
|
if ($attempt -lt $Retries) {
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
}
|
|
|
|
if ($Detail) { $Detail.Value = $lastDetail }
|
|
return $false
|
|
}
|
|
|
|
# --- Load config ---
|
|
if (-not (Test-Path $Config)) {
|
|
Write-Host "Config not found: $Config" -ForegroundColor Red
|
|
Write-Host 'Copy infra/deploy/devtoprod.config.example.json to infra/deploy/devtoprod.config.json and edit it.'
|
|
Write-ReleaseBanner -Success $false -FailedAt 'loadConfig'
|
|
exit 1
|
|
}
|
|
|
|
$raw = Get-Content -LiteralPath $Config -Raw -Encoding UTF8
|
|
$cfg = $raw | ConvertFrom-Json
|
|
|
|
$profile = if ($cfg.profile) { $cfg.profile } else { 'full' }
|
|
$steps = Merge-Steps (Get-ProfileSteps -Profile $profile) $cfg.steps
|
|
|
|
if ($cfg.autoConfirm) {
|
|
$env:CONFIRM_PROD = '1'
|
|
}
|
|
|
|
if ($cfg.smb.user) { $env:SMB_USER = $cfg.smb.user }
|
|
if ($cfg.smb.password) { $env:SMB_PASSWORD = $cfg.smb.password }
|
|
|
|
$smbHost = if ($cfg.smb.host) { $cfg.smb.host } else { '192.168.10.122' }
|
|
$smbShare = if ($cfg.smb.share) { $cfg.smb.share } else { 'Gallery' }
|
|
$imageDest = "\\$smbHost\$smbShare\data\images"
|
|
|
|
$script:backupFile = ''
|
|
if ($cfg.backupFile -and $cfg.backupFile.Trim()) {
|
|
$script:backupFile = (Resolve-Path (Join-Path $RepoRoot $cfg.backupFile) -ErrorAction Stop).Path
|
|
}
|
|
|
|
Write-Host "Gallery dev -> prod release"
|
|
Write-Host "Config: $Config"
|
|
Write-Host "Profile: $profile"
|
|
if ($DryRun) { Write-Host 'Mode: DRY RUN (no changes)' -ForegroundColor Yellow }
|
|
|
|
$enabled = @($steps.Keys | Where-Object { $steps[$_] } | Sort-Object)
|
|
Write-Host ('Steps: ' + ($enabled -join ', '))
|
|
|
|
# --- Steps ---
|
|
if (Get-StepEnabled $steps 'validateBuild') {
|
|
$ok = Invoke-Step 'validateBuild' {
|
|
$code = Invoke-Npm 'prod:build'
|
|
if ($code -ne 0) { return $code }
|
|
|
|
# Dev-only endpoints. Do not use verify.lanUrl here (that is prod on TrueNAS).
|
|
$devApiPort = Get-DevApiPort
|
|
$autoStartDev = if ($null -ne $cfg.autoStartDevStack) { [bool]$cfg.autoStartDevStack } else { $true }
|
|
if (-not (Ensure-DevApiReady -ApiPort $devApiPort -AutoStart:$autoStartDev)) {
|
|
Write-Host "Dev API check failed on all URLs." -ForegroundColor Red
|
|
Write-Host "Start the full dev stack before release: npm run dev:web" -ForegroundColor Yellow
|
|
Write-Host "Vite on :5173 alone is not enough - the API must listen on :$devApiPort (PORT in .env)." -ForegroundColor Yellow
|
|
return 1
|
|
}
|
|
|
|
$devCandidates = @(
|
|
"http://127.0.0.1:$devApiPort/api/bounds"
|
|
'http://localhost:5173/api/bounds'
|
|
'http://192.168.10.70:5173/api/bounds'
|
|
)
|
|
if ($cfg.verify.devApiUrl) { $devCandidates += $cfg.verify.devApiUrl }
|
|
if ($cfg.verify.devLanUrl) { $devCandidates += $cfg.verify.devLanUrl }
|
|
if ($cfg.verify.devUrl) { $devCandidates += $cfg.verify.devUrl }
|
|
$devCandidates = $devCandidates | Select-Object -Unique
|
|
|
|
$devOk = $false
|
|
foreach ($u in $devCandidates) {
|
|
Write-Host "Checking dev /api/bounds: $u"
|
|
$useInsecure = $u.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)
|
|
$detail = ''
|
|
if (Test-BoundsJson -Url $u -Insecure:$useInsecure -MaxTimeSeconds 10 -Retries 3 -Detail ([ref]$detail)) {
|
|
Write-Host "Dev API OK: $u"
|
|
$devOk = $true
|
|
break
|
|
}
|
|
Write-Host "Dev API check failed for: $u ($detail)" -ForegroundColor DarkYellow
|
|
}
|
|
|
|
if (-not $devOk) {
|
|
Write-Host "Dev API check failed on all URLs." -ForegroundColor Red
|
|
Write-Host "Start the full dev stack before release: npm run dev:web" -ForegroundColor Yellow
|
|
Write-Host "Vite on :5173 alone is not enough - the API must listen on :$devApiPort (PORT in .env)." -ForegroundColor Yellow
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'gitCommitPush') {
|
|
$ok = Invoke-Step 'gitCommitPush' {
|
|
$branch = if ($cfg.git.branch) { $cfg.git.branch } else { 'main' }
|
|
$message = if ($cfg.git.message) { $cfg.git.message } else { 'Release: deploy' }
|
|
$stageAll = if ($null -ne $cfg.git.stageAll) { [bool]$cfg.git.stageAll } else { $true }
|
|
|
|
if ($stageAll) {
|
|
$code = Invoke-Git @('add', '-A')
|
|
if ($code -ne 0) { return $code }
|
|
}
|
|
|
|
$status = Get-GitPorcelainStatus
|
|
if (-not $status) {
|
|
Write-Host 'Git: working tree clean, skipping commit.'
|
|
} else {
|
|
$code = Invoke-Git @('commit', '-m', $message)
|
|
if ($code -ne 0) { return $code }
|
|
}
|
|
|
|
$pushAttempts = 2
|
|
for ($attempt = 1; $attempt -le $pushAttempts; $attempt++) {
|
|
$code = Invoke-Git @('push', 'origin', $branch)
|
|
if ($code -eq 0) { break }
|
|
if ($attempt -lt $pushAttempts) {
|
|
Write-Host "Git push failed (exit $code). Retrying once in 3s..." -ForegroundColor Yellow
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
}
|
|
if ($code -ne 0) {
|
|
Write-Host 'Git push authentication failed for Gitea.' -ForegroundColor Red
|
|
Write-Host 'Fix: create a Gitea token with repo write access, then run:' -ForegroundColor Yellow
|
|
Write-Host ' git push origin main' -ForegroundColor Yellow
|
|
Write-Host 'If prompted credentials fail, clear cached token:' -ForegroundColor Yellow
|
|
Write-Host ' "protocol=https`nhost=gitea.mysuperlab.netcraze.pro`n" | git credential reject' -ForegroundColor Yellow
|
|
Write-Host 'Then push again and resume release with gitCommitPush disabled.' -ForegroundColor Yellow
|
|
return $code
|
|
}
|
|
return 0
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'thumbnails') {
|
|
$ok = Invoke-Step 'thumbnails' {
|
|
$code = Invoke-Npm 'devtoprod:thumbnails'
|
|
return $code
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'backupDev') {
|
|
$ok = Invoke-Step 'backupDev' {
|
|
$code = Invoke-Npm 'dev:db:backup'
|
|
if ($code -ne 0) { return $code }
|
|
if (-not $script:backupFile) {
|
|
$script:backupFile = Get-LatestDevBackup
|
|
Write-Host "Using latest dev backup: $($script:backupFile)"
|
|
}
|
|
return 0
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'backupProd') {
|
|
$ok = Invoke-Step 'backupProd' {
|
|
Assert-ProdDbTarget
|
|
$code = Invoke-Npm 'prod:db:backup'
|
|
return $code
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
function Invoke-MigrateProdSchema {
|
|
Assert-ProdDbTarget
|
|
$env:DB_NAME = 'gallery_prod'
|
|
try {
|
|
$code = Invoke-Npm 'dev:migrate'
|
|
if ($code -eq 0) {
|
|
$script:ProdSchemaMigrated = $true
|
|
}
|
|
return $code
|
|
} finally {
|
|
Remove-Item Env:\DB_NAME -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'migrateProdSchema') {
|
|
$ok = Invoke-Step 'migrateProdSchema' {
|
|
return (Invoke-MigrateProdSchema)
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'restoreProd') {
|
|
if (-not $script:ProdSchemaMigrated) {
|
|
Write-Host 'Auto-running migrateProdSchema before restoreProd (prod schema must match dev backup).' -ForegroundColor Yellow
|
|
$ok = Invoke-Step 'migrateProdSchema' {
|
|
return (Invoke-MigrateProdSchema)
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
$ok = Invoke-Step 'restoreProd' {
|
|
Assert-ProdDbTarget
|
|
if (-not $script:backupFile) {
|
|
if ($cfg.backupFile -and $cfg.backupFile.Trim()) {
|
|
$script:backupFile = (Resolve-Path (Join-Path $RepoRoot $cfg.backupFile)).Path
|
|
} else {
|
|
$script:backupFile = Get-LatestDevBackup
|
|
}
|
|
}
|
|
$rel = $script:backupFile.Replace($RepoRoot, '').TrimStart('\', '/').Replace('\', '/')
|
|
Write-Host "Restore file: $rel"
|
|
$code = Invoke-Npm 'devtoprod:db:restore' @('--file', $rel)
|
|
return $code
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'syncImages') {
|
|
$ok = Invoke-Step 'syncImages' {
|
|
$syncScript = Join-Path $PSScriptRoot 'sync-images-to-prod.ps1'
|
|
if ($cfg.autoConfirm) {
|
|
& $syncScript -Dest $imageDest -SkipConfirm
|
|
} else {
|
|
& $syncScript -Dest $imageDest
|
|
}
|
|
return $LASTEXITCODE
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'dockerPublish') {
|
|
$ok = Invoke-Step 'dockerPublish' {
|
|
$code = Invoke-Npm 'prod:docker:publish'
|
|
return $code
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'truenasRestartPause') {
|
|
if ($DryRun) {
|
|
Write-Host '[dry-run] Would pause for TrueNAS gallery-web restart'
|
|
$CompletedSteps.Add('truenasRestartPause')
|
|
} else {
|
|
Write-Host ''
|
|
Write-Host '--- Manual: restart gallery-web on TrueNAS ---' -ForegroundColor Yellow
|
|
Write-Host '1. TrueNAS Web UI -> Apps -> gallery-web -> Restart'
|
|
Write-Host '2. Wait until status is Running'
|
|
Read-Host 'Press Enter after restarting gallery-web on TrueNAS'
|
|
$CompletedSteps.Add('truenasRestartPause')
|
|
}
|
|
}
|
|
|
|
if (Get-StepEnabled $steps 'verify') {
|
|
$ok = Invoke-Step 'verify' {
|
|
$lanUrl = if ($cfg.verify.lanUrl) { $cfg.verify.lanUrl } else { 'http://192.168.10.122:5173/api/bounds' }
|
|
$publicUrl = if ($cfg.verify.publicUrl) { $cfg.verify.publicUrl } else { 'https://gallery.mysuperlab.netcraze.pro/api/bounds' }
|
|
|
|
if (-not (Test-BoundsJson -Url $lanUrl)) {
|
|
Write-Host "LAN verify failed: $lanUrl" -ForegroundColor Red
|
|
return 1
|
|
}
|
|
Write-Host "LAN API OK: $lanUrl"
|
|
|
|
if (-not (Test-BoundsJson -Url $publicUrl -Insecure)) {
|
|
Write-Host "Public verify failed: $publicUrl" -ForegroundColor Red
|
|
return 1
|
|
}
|
|
Write-Host "Public API OK: $publicUrl"
|
|
return 0
|
|
}
|
|
if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 }
|
|
}
|
|
|
|
Write-ReleaseBanner -Success $true
|
|
exit 0
|