add channel-aware distributable installers and web launcher

Follows the config-driven-badge decision through to packaging: a single channel-parameterized builder emits two distinct installers (stable from main, edge from dev-be) rather than two drifting scripts. Each package ships the image bundle, the airgap+channel compose templates, a pre-filled .env, and a one-command site installer.

Secrets ship as CHANGE_ME_* sentinels and Install-ShopDB.ps1 refuses to start until every one is rotated, so a distributable can never come up on shipped credentials. docker-compose.airgap.yml now threads SHOPDB_ENV_LABEL through the shared app-env anchor, otherwise a distributed edge stack would run the badge-capable image but never light the badge.

get.ps1 is the web launcher that makes the iex one-liners work: dev-bootstrap.ps1 operates on a local clone, so get.ps1 clones/updates the channel branch and hands off. docs/DEV-BUILD-TEST.md is the short reviewer handout with the two one-liners for Cameron to try.
This commit is contained in:
benjamin.baker
2026-09-02 05:41:25 -04:00
parent 0524ce7751
commit 68c33ee896
4 changed files with 443 additions and 0 deletions

View File

@@ -32,6 +32,10 @@ x-app-env: &app-env
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ZABBIX_URL: ${ZABBIX_URL:-}
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
# Channel label for a distributable installer. Empty on the stable (main)
# bundle, 'DEV' on the edge (dev-be) bundle, which lights up the sidebar
# badge. Sourced from .env, which build-channel-installer.ps1 pre-fills.
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-}
services:
db:

26
docs/DEV-BUILD-TEST.md Normal file
View File

@@ -0,0 +1,26 @@
# ShopDB dev build - quick test
Two dev builds you can stand up on a Windows box with Docker Desktop. Each is a
single PowerShell line: it clones the repo, checks your dependencies (offers to
winget-install anything missing), then builds and starts the stack in Docker.
## Stable (main)
$env:SHOPDB_CHANNEL='stable'; iex (irm https://raw.githubusercontent.com/ge-aero/shopdb-flask/wilm/scripts/get.ps1)
Comes up at https://localhost:5001. No DEV badge.
## Bleeding edge (dev-be)
$env:SHOPDB_CHANNEL='edge'; iex (irm https://raw.githubusercontent.com/ge-aero/shopdb-flask/wilm/scripts/get.ps1)
Comes up at https://localhost:5011. Red DEV badge in the sidebar.
## Notes
- You need Git and Docker Desktop. If Git is missing, install it first
(`winget install Git.Git`); the script handles the rest with a prompt.
- Both builds can run at the same time - they use different ports and separate
Docker volumes, so they do not collide and each keeps its own database.
- First page load shows a self-signed cert warning (expected for a dev box).
- Stop a stack: `docker compose -p shopdb-stable down` (or `shopdb-edge`).

View File

@@ -0,0 +1,346 @@
<#
.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
}

67
scripts/get.ps1 Normal file
View File

@@ -0,0 +1,67 @@
<#
.SYNOPSIS
One-line web launcher for a ShopDB dev workstation.
.DESCRIPTION
The piece that makes `iex (irm .../get.ps1)` work. dev-bootstrap.ps1 operates
on a local clone, so it cannot be run straight from the web. This launcher
fills that gap: it clones (or updates) the repo at the channel branch into a
stable local path, then hands off to scripts/dev-bootstrap.ps1 for the real
dependency-check / build / up work.
Channel is chosen by an environment variable so a pure `iex` pipe (which
cannot pass parameters) still selects stable vs edge:
# Stable (main):
$env:SHOPDB_CHANNEL='stable'; iex (irm https://raw.githubusercontent.com/ge-aero/shopdb-flask/wilm/scripts/get.ps1)
# Bleeding edge (dev-be):
$env:SHOPDB_CHANNEL='edge'; iex (irm https://raw.githubusercontent.com/ge-aero/shopdb-flask/wilm/scripts/get.ps1)
SHOPDB_DIR overrides the clone location (default %USERPROFILE%\shopdb-dev).
.NOTES
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
Runs git-checkout of the channel branch, so it needs Git; dev-bootstrap.ps1
offers to winget-install anything else that is missing.
#>
$ErrorActionPreference = 'Stop'
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
$channel = if ($env:SHOPDB_CHANNEL) { $env:SHOPDB_CHANNEL } else { 'stable' }
if ($channel -notin @('stable', 'edge')) {
throw "SHOPDB_CHANNEL must be 'stable' or 'edge' (got '$channel')."
}
$branch = if ($channel -eq 'edge') { 'dev-be' } else { 'main' }
$target = if ($env:SHOPDB_DIR) { $env:SHOPDB_DIR } else { Join-Path $env:USERPROFILE 'shopdb-dev' }
Write-Host "ShopDB web launcher" -ForegroundColor White
Write-Host " Channel : $channel ($branch)" -ForegroundColor White
Write-Host " Target : $target" -ForegroundColor White
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git is required to clone the repo. Install Git (winget install Git.Git) and re-run.'
}
if (Test-Path (Join-Path $target '.git')) {
Write-Host "==> Updating existing clone" -ForegroundColor Cyan
git -C $target fetch origin $branch
if ($LASTEXITCODE -ne 0) { throw "git fetch failed ($LASTEXITCODE)." }
git -C $target checkout $branch
if ($LASTEXITCODE -ne 0) { throw "git checkout failed ($LASTEXITCODE)." }
git -C $target pull --ff-only origin $branch
if ($LASTEXITCODE -ne 0) { throw "git pull failed ($LASTEXITCODE)." }
} else {
Write-Host "==> Cloning $branch" -ForegroundColor Cyan
git clone --branch $branch $repoUrl $target
if ($LASTEXITCODE -ne 0) { throw "git clone failed ($LASTEXITCODE)." }
}
$bootstrap = Join-Path $target 'scripts\dev-bootstrap.ps1'
if (-not (Test-Path $bootstrap)) {
throw "dev-bootstrap.ps1 not found at $bootstrap. Is the branch correct?"
}
Write-Host "==> Handing off to dev-bootstrap.ps1" -ForegroundColor Cyan
& $bootstrap -Channel $channel