Files
shopdb-flask/scripts/build-channel-installer.ps1
cproudlock 27b2817fd7 Import the wilm endpoint-monitor and channel-distribution work
Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.

Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.

NOT READY FOR main. Open items:
  - 10 new routes are absent from docs/api-inventory.json, so
    test_api_inventory_parity fails. That gate SKIPS on the published mirror
    (the test ships, its data file is scrubbed), which is why wilm never saw it.
  - dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
  - plugins/endpoints/plugin.py carries a whitespace-only re-indent.

Scope: product
2026-09-02 10:05:44 -04:00

347 lines
13 KiB
PowerShell

<#
.SYNOPSIS
Build a channel-aware ShopDB distributable installer (stable or edge).
.DESCRIPTION
Produces ONE self-contained installer package for the requested channel:
stable -> built from 'main', no DEV badge (SHOPDB_ENV_LABEL empty).
edge -> built from 'dev-be', red DEV badge (SHOPDB_ENV_LABEL=DEV).
This is the distributable counterpart to scripts/dev-bootstrap.ps1 (which
stands a dev box up FROM the repo). Here the operator has no repo: the script
builds the app image from the channel branch on a connected build box, saves
it plus MySQL into a loadable tarball (reusing the exact bundle mechanics of
build-offline-bundle.ps1), and drops a runnable package:
dist/shopdb-<channel>-<version>/
shopdb-stack-<version>.tar.gz # docker load input
shopdb-stack-<version>.tar.gz.sha256
docker-compose.airgap.yml # image-only run template
docker-compose.channel.yml # SHOPDB_ENV_LABEL overlay
.env # channel-filled, secrets as CHANGE_ME_* sentinels
Install-ShopDB.ps1 # site-side one-command installer
README.txt
Keeping stable and edge as ONE parameterized builder (not two scripts) is the
same anti-drift decision as the config-driven badge: the channels differ only
by branch, image tag, and label - all data, not code.
.PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Required.
.PARAMETER Version
Image tag / bundle version. Defaults to a channel-prefixed date stamp so two
channels built the same day never collide (e.g. 'stable-2024.06.01').
.PARAMETER Ref
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for
edge. Lets a release engineer cut an installer from a tag or fix branch.
.PARAMETER MysqlImage
MySQL image the stack runs (default mysql:8.4). Must match db.image in
docker-compose.airgap.yml.
.PARAMETER OutDir
Root for the generated package (default ./dist).
.PARAMETER SkipBuild
Reuse an already-built shopdb-flask:<Version> image; only save + package.
.EXAMPLE
pwsh scripts/build-channel-installer.ps1 -Channel stable
.EXAMPLE
pwsh scripts/build-channel-installer.ps1 -Channel edge -Version edge-preview3
.NOTES
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
Requires Docker Desktop and (unless -SkipBuild) clean access to build the
image. Secrets ship as CHANGE_ME_* placeholders and the generated installer
refuses to start until they are rotated, so a package can never go live on
shipped credentials.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('stable', 'edge')]
[string]$Channel,
[string]$Version,
[string]$Ref,
[string]$MysqlImage = 'mysql:8.4',
[string]$OutDir = 'dist',
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
# Channel -> (branch, badge label). The single source of channel truth, mirrored
# from dev-bootstrap.ps1 so the two entry points never disagree about what a
# channel means.
$channels = @{
stable = @{ Ref = 'main'; EnvLabel = '' }
edge = @{ Ref = 'dev-be'; EnvLabel = 'DEV' }
}
function Assert-LastExit([string]$What) {
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'docker not found on PATH. Install Docker Desktop and retry.'
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git not found on PATH. Install Git and retry.'
}
$config = $channels[$Channel]
$branch = if ($Ref) { $Ref } else { $config.Ref }
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
$appImage = "shopdb-flask:$Version"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
Write-Host "ShopDB channel installer builder" -ForegroundColor White
Write-Host " Channel : $Channel" -ForegroundColor White
Write-Host " Branch : $branch" -ForegroundColor White
Write-Host " Version : $Version" -ForegroundColor White
Write-Host " Label : $(if ($config.EnvLabel) { $config.EnvLabel } else { '(none)' })" -ForegroundColor White
Push-Location $repoRoot
try {
# --- 1. Build the image from the channel branch --------------------------
if (-not $SkipBuild) {
# Build from the channel branch WITHOUT switching the operator's working
# tree: create a throwaway worktree at the ref, build there, remove it.
# This keeps a release build reproducible and side-effect-free even when
# the operator has local edits checked out.
$worktree = Join-Path ([System.IO.Path]::GetTempPath()) "shopdb-build-$Version"
Write-Host "==> Fetching origin/$branch" -ForegroundColor Cyan
git fetch origin $branch
Assert-LastExit 'git fetch'
if (Test-Path $worktree) {
git worktree remove --force $worktree 2>$null
Remove-Item $worktree -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host "==> Adding build worktree at $worktree" -ForegroundColor Cyan
git worktree add --detach $worktree "origin/$branch"
Assert-LastExit 'git worktree add'
try {
Write-Host "==> Building $appImage from $branch" -ForegroundColor Cyan
docker build -t $appImage $worktree
Assert-LastExit 'docker build'
} finally {
Write-Host "==> Removing build worktree" -ForegroundColor Cyan
git worktree remove --force $worktree 2>$null
Remove-Item $worktree -Recurse -Force -ErrorAction SilentlyContinue
}
} else {
Write-Host "==> Skipping build; reusing $appImage" -ForegroundColor Yellow
}
Write-Host "==> Pulling $MysqlImage" -ForegroundColor Cyan
docker pull $MysqlImage
Assert-LastExit 'docker pull'
# --- 2. Save image bundle into the package dir ---------------------------
$pkgName = "shopdb-$Channel-$Version"
$pkgDir = Join-Path $OutDir $pkgName
if (Test-Path $pkgDir) { Remove-Item $pkgDir -Recurse -Force }
New-Item -ItemType Directory -Path $pkgDir -Force | Out-Null
$stem = "shopdb-stack-$Version"
$tar = Join-Path $pkgDir "$stem.tar"
$gz = Join-Path $pkgDir "$stem.tar.gz"
Write-Host "==> Saving $appImage + $MysqlImage" -ForegroundColor Cyan
docker save -o $tar $appImage $MysqlImage
Assert-LastExit 'docker save'
Write-Host "==> Compressing to $gz" -ForegroundColor Cyan
$inStream = [System.IO.File]::OpenRead($tar)
$outStream = [System.IO.File]::Create($gz)
$gzip = New-Object System.IO.Compression.GzipStream($outStream, [System.IO.Compression.CompressionMode]::Compress)
try {
$inStream.CopyTo($gzip)
} finally {
$gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose()
}
Remove-Item $tar
$hash = (Get-FileHash $gz -Algorithm SHA256).Hash.ToLower()
"$hash $stem.tar.gz" | Set-Content -Path "$gz.sha256" -NoNewline
# --- 3. Copy the run templates into the package -------------------------
Copy-Item (Join-Path $repoRoot 'docker-compose.airgap.yml') $pkgDir
Copy-Item (Join-Path $repoRoot 'docker-compose.channel.yml') $pkgDir
# --- 4. Channel-filled .env ---------------------------------------------
# Secrets ship as explicit CHANGE_ME_* sentinels, NOT generated values. The
# site-side installer refuses to start until every sentinel is replaced, so a
# distributable can never silently go live on shipped credentials. Ports,
# image tag, and the channel label are real because they are not secrets.
$apiPort = if ($Channel -eq 'edge') { 5011 } else { 5001 }
$mysqlPort = if ($Channel -eq 'edge') { 3316 } else { 3306 }
$envLines = @(
"# ShopDB $Channel installer .env (built $(Get-Date -Format s)).",
"# REQUIRED: replace every CHANGE_ME_* value below before installing.",
"# Generate strong secrets, e.g.:",
"# python -c ""import secrets; print(secrets.token_urlsafe(64))""",
"FLASK_APP=wsgi.py",
"FLASK_ENV=production",
"SECRET_KEY=CHANGE_ME_SECRET_KEY",
"JWT_SECRET_KEY=CHANGE_ME_JWT_SECRET_KEY",
"DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME_DB_PASSWORD@db:3306/shopdb_flask?charset=utf8mb4",
"CORS_ORIGINS=https://localhost:$apiPort",
"LOG_LEVEL=INFO",
"MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD",
"MYSQL_PASSWORD=CHANGE_ME_DB_PASSWORD",
"MYSQL_PORT=$mysqlPort",
"API_PORT=$apiPort",
"IMAGE_TAG=$Version",
"SHOPDB_ENV_LABEL=$($config.EnvLabel)"
)
Set-Content -Path (Join-Path $pkgDir '.env') -Value $envLines -Encoding ASCII
# --- 5. Site-side installer script --------------------------------------
# One command at the site: verify checksum, load the image, bring the stack
# up with the airgap template + channel overlay. Mirrors the run steps in
# docs/DEPLOY-AIRGAP.md but wraps them so an operator runs a single file.
$installer = @'
<#
.SYNOPSIS
Install / start this ShopDB stack on the target host (one command).
.DESCRIPTION
Verifies the image-bundle checksum, docker-loads it, and brings the stack up
from the bundled compose templates and .env. Re-runnable: on later runs it
skips the load if the image is already present and just applies the compose.
#>
[CmdletBinding()]
param([switch]$Recreate)
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'docker not found. Install Docker Desktop / Engine and retry.'
}
$bundle = Get-ChildItem -Filter 'shopdb-stack-*.tar.gz' | Select-Object -First 1
if (-not $bundle) { throw 'No shopdb-stack-*.tar.gz found next to this script.' }
# Refuse to start on shipped placeholder secrets. A distributable must never go
# live on credentials that ship in the box; the operator has to rotate every
# CHANGE_ME_* in .env first. This is a hard gate, not a warning.
$sentinels = Select-String -Path '.env' -Pattern 'CHANGE_ME_' -SimpleMatch
if ($sentinels) {
Write-Host 'Refusing to start: .env still has placeholder secrets.' -ForegroundColor Red
Write-Host 'Replace every CHANGE_ME_* value with a real secret, then re-run:'
foreach ($line in $sentinels) { Write-Host " $($line.Line)" -ForegroundColor Yellow }
Write-Host ''
Write-Host 'Generate strong values, e.g.:'
Write-Host ' python -c "import secrets; print(secrets.token_urlsafe(64))"'
throw 'Placeholder secrets present in .env.'
}
$sumFile = "$($bundle.Name).sha256"
if (Test-Path $sumFile) {
Write-Host "==> Verifying $($bundle.Name) checksum" -ForegroundColor Cyan
$expected = ((Get-Content $sumFile -Raw).Trim() -split '\s+')[0].ToLower()
$actual = (Get-FileHash $bundle.FullName -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) { throw "Checksum mismatch. Expected $expected, got $actual." }
Write-Host " OK" -ForegroundColor Green
} else {
Write-Host "==> No checksum file; skipping verification" -ForegroundColor Yellow
}
Write-Host "==> Loading image bundle" -ForegroundColor Cyan
docker load -i $bundle.FullName
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
$composeArgs = @(
'compose', '--env-file', '.env',
'-f', 'docker-compose.airgap.yml',
'-f', 'docker-compose.channel.yml'
)
if ($Recreate) { $composeArgs += @('up', '-d', '--force-recreate') }
else { $composeArgs += @('up', '-d') }
Write-Host "==> Starting the stack" -ForegroundColor Cyan
& docker @composeArgs
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)." }
$apiPort = (Select-String -Path '.env' -Pattern '^API_PORT=(.+)$').Matches.Groups[1].Value
Write-Host ''
Write-Host "ShopDB is up: https://localhost:$apiPort" -ForegroundColor Green
Write-Host "Seed the first admin (once):"
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
'@
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
# --- 6. README ----------------------------------------------------------
$badgeLine = if ($config.EnvLabel) {
"This is the EDGE (bleeding-edge) build from '$branch'. The sidebar shows a red '$($config.EnvLabel)' badge."
} else {
"This is the STABLE build from '$branch'. No DEV badge."
}
$readme = @(
"ShopDB $Channel installer ($Version)",
"==================================================",
"",
$badgeLine,
"",
"Contents:",
" shopdb-stack-$Version.tar.gz Docker image bundle (app + MySQL)",
" shopdb-stack-$Version.tar.gz.sha256 Checksum for the bundle",
" docker-compose.airgap.yml Image-only run template",
" docker-compose.channel.yml Channel label overlay",
" .env Pre-filled config (ROTATE SECRETS)",
" Install-ShopDB.ps1 One-command installer",
"",
"Install (Windows, from this folder):",
" 1. Edit .env: replace every CHANGE_ME_* value with a real secret.",
" 2. powershell -ExecutionPolicy Bypass -File .\Install-ShopDB.ps1",
"",
"The installer REFUSES to start while any CHANGE_ME_* placeholder remains,",
"so the stack can never come up on shipped credentials.",
"",
"The API listens on https://localhost:$apiPort (self-signed cert on first",
"run). Adjust the port or CORS origins in .env if needed. Both channels can",
"run on one host at once because stable and edge use different ports and",
"compose project state.",
"",
"Generate strong secrets with:",
" python -c ""import secrets; print(secrets.token_urlsafe(64))"""
)
Set-Content -Path (Join-Path $pkgDir 'README.txt') -Value $readme -Encoding ASCII
# --- 7. Summary ---------------------------------------------------------
$sizeMb = [math]::Round((Get-Item $gz).Length / 1MB, 1)
Write-Host ''
Write-Host "Installer package ready:" -ForegroundColor Green
Write-Host " $pkgDir"
Write-Host " bundle : $stem.tar.gz ($sizeMb MB)"
Write-Host " sha256 : $hash"
Write-Host " label : $(if ($config.EnvLabel) { $config.EnvLabel } else { '(none, stable)' })"
Write-Host ''
Write-Host "Ship the folder; the operator runs Install-ShopDB.ps1."
}
finally {
Pop-Location
}