From 68c33ee896fbefb2b98c42e1e41a36bae20723fb Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Wed, 2 Sep 2026 05:41:25 -0400 Subject: [PATCH] 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. --- docker-compose.airgap.yml | 4 + docs/DEV-BUILD-TEST.md | 26 +++ scripts/build-channel-installer.ps1 | 346 ++++++++++++++++++++++++++++ scripts/get.ps1 | 67 ++++++ 4 files changed, 443 insertions(+) create mode 100644 docs/DEV-BUILD-TEST.md create mode 100644 scripts/build-channel-installer.ps1 create mode 100644 scripts/get.ps1 diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index 7354ccb9..edaba728 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -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: diff --git a/docs/DEV-BUILD-TEST.md b/docs/DEV-BUILD-TEST.md new file mode 100644 index 00000000..b2269f3b --- /dev/null +++ b/docs/DEV-BUILD-TEST.md @@ -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`). diff --git a/scripts/build-channel-installer.ps1 b/scripts/build-channel-installer.ps1 new file mode 100644 index 00000000..6d85f1be --- /dev/null +++ b/scripts/build-channel-installer.ps1 @@ -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--/ + shopdb-stack-.tar.gz # docker load input + shopdb-stack-.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: 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 " +'@ + 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 +} diff --git a/scripts/get.ps1 b/scripts/get.ps1 new file mode 100644 index 00000000..398b877d --- /dev/null +++ b/scripts/get.ps1 @@ -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