Merge origin/main into align-wilm-main (align wilm with Cameron's latest)

Bring main's endpoint hardening, operator-report validation, launch-path
policy, and theme-variable CSS into wilm. Channel model reconciled to this
repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's
remote-BuildKit publish step and GHCR pull-based launcher.
This commit is contained in:
benjamin.baker
2026-09-02 17:41:03 -04:00
33 changed files with 1539 additions and 117 deletions

View File

@@ -19,7 +19,7 @@
GCM handles those.
.PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
.PARAMETER TargetDir
Where to clone. Defaults to %USERPROFILE%\shopdb-dev, a separate working copy
@@ -85,12 +85,28 @@ function Clear-InterruptedRun {
}
}
# A directory that exists but has no valid .git is a broken/partial clone;
# remove it so the clone below can start fresh instead of failing.
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
# A directory that exists but has no valid .git is a broken/partial clone -
# USUALLY. It is also what -TargetDir pointed at somebody's Documents folder
# looks like, and this ran Remove-Item -Recurse -Force on it without asking.
# For a script distributed as "save it anywhere and run it", a mistyped
# -TargetDir must not be able to delete a user's files.
#
# Only reclaim a directory this script can show it owns: empty, or carrying
# the marker written after a successful clone. Anything else stops.
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
$ours = Test-Path (Join-Path $Target $script:CloneMarker)
$empty = -not (Get-ChildItem -LiteralPath $Target -Force |
Select-Object -First 1)
if ($ours -or $empty) {
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force
}
else {
throw ("$Target already exists, is not empty, and was not created " +
"by this launcher. Refusing to delete it. Pass -TargetDir " +
'pointing somewhere else, or remove it yourself if you are sure.')
}
}
# A clone created by an elevated (Administrator) run is owned by
# BUILTIN\Administrators; a later non-elevated run then trips git's
@@ -104,8 +120,18 @@ function Clear-InterruptedRun {
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
finally { $ErrorActionPreference = $previous }
if ($probe -match 'dubious ownership') {
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force
# Same rule as above: a .git directory proves this is a clone, but
# not that it is OUR clone, and re-cloning discards whatever is in
# it. Only reclaim one this launcher created.
if (Test-Path (Join-Path $Target $script:CloneMarker)) {
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force
}
else {
throw ("The git clone at $Target has mismatched ownership and " +
'was not created by this launcher, so it is not being ' +
'deleted. Remove it yourself, or pass -TargetDir elsewhere.')
}
}
}
}
@@ -114,7 +140,21 @@ function Clear-InterruptedRun {
# stored GitHub credential for github.com, so clone/fetch authenticate against
# the private repo without anything sensitive touching this script or disk.
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
$branch = if ($Channel -eq 'edge') { 'dev-be' } else { 'main' }
# Written into a clone this launcher made, so a later run can tell its own
# working directory apart from one that happens to sit at the same path.
$script:CloneMarker = '.shopdb-launcher'
# Channels follow how this repo ships: a release is a TAG, the trunk is main.
# Upstream this read 'dev-be' for edge, which is that repo's trunk branch and
# does not exist here - the launcher would have cloned a missing branch.
$branch = 'main'
if ($Channel -ne 'edge') {
# Newest release tag, so 'stable' means a thing that was released rather
# than whatever the trunk happens to be today.
$tag = (git ls-remote --tags --refs --sort='-v:refname' $repoUrl 'v*' |
Select-Object -First 1) -replace '.*refs/tags/', ''
if ($tag) { $branch = $tag }
else { Write-Warning "No v* tag found; falling back to main for 'stable'." }
}
$target = if ($TargetDir) { $TargetDir } else { Join-Path $env:USERPROFILE 'shopdb-dev' }
Write-Host "ShopDB dev launcher" -ForegroundColor White

View File

@@ -6,7 +6,7 @@
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).
edge -> built from 'main' (the trunk), 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
@@ -28,14 +28,14 @@
by branch, image tag, and label - all data, not code.
.PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Required.
'stable' (the newest release tag) or 'edge' (main, the trunk). 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
Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
edge. Lets a release engineer cut an installer from a tag or fix branch.
.PARAMETER MysqlImage
@@ -80,12 +80,28 @@ param(
$ErrorActionPreference = 'Stop'
# Channel -> (branch, badge label). The single source of channel truth, mirrored
# Channel -> (ref, 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.
#
# stable has NO fixed ref: a release here is a tag, not a branch, so it is
# resolved below from the newest v* tag. Writing 'main' here would build the
# trunk and hand it to a site under a word that promises a release - the same
# mislabel this repo's channel mapping was corrected for, missed in this file
# the first time round.
$channels = @{
stable = @{ Ref = 'main'; EnvLabel = '' }
edge = @{ Ref = 'dev-be'; EnvLabel = 'DEV' }
stable = @{ Ref = $null; EnvLabel = '' }
edge = @{ Ref = 'main'; EnvLabel = 'DEV' }
}
function Resolve-StableRef {
# Newest release tag by version sort. Falls back to main with a warning
# rather than silently building something and calling it stable.
$tag = (git tag --list 'v*' --sort='-v:refname' | Select-Object -First 1)
if ($tag) { return $tag }
Write-Warning 'No v* tag in this repo; building stable from main.'
return 'main'
}
}
function Assert-LastExit([string]$What) {
@@ -100,7 +116,11 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
}
$config = $channels[$Channel]
$branch = if ($Ref) { $Ref } else { $config.Ref }
# An explicit -Ref always wins. Otherwise the channel decides: edge is the
# trunk, stable resolves the newest release tag.
$branch = if ($Ref) { $Ref }
elseif ($config.Ref) { $config.Ref }
else { Resolve-StableRef }
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
$appImage = "shopdb-flask:$Version"
@@ -271,8 +291,15 @@ Write-Host "==> Loading image bundle" -ForegroundColor Cyan
docker load -i $bundle.FullName
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
# -p is NOT optional here. Without it compose names the project after the
# containing folder, which is shopdb-<channel>-<version> - so installing the
# next version creates a NEW project with new empty named volumes, and the site
# comes up looking wiped. The old data is still there under the old project's
# volumes, but nothing carries it across. Pin the project to the channel so an
# upgrade lands on the same volumes.
$composeArgs = @(
'compose', '--env-file', '.env',
'-p', 'shopdb-__CHANNEL__',
'-f', 'docker-compose.airgap.yml',
'-f', 'docker-compose.channel.yml'
)
@@ -289,6 +316,11 @@ 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>"
'@
# The here-string above is single-quoted so nothing in it interpolates - that
# is deliberate, the installer is full of $ that must survive verbatim. The
# compose project name is the one value that has to be baked per channel, so
# substitute it here rather than switching the whole block to interpolation.
$installer = $installer.Replace('__CHANNEL__', $Channel)
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
# --- 6. README ----------------------------------------------------------

View File

@@ -12,7 +12,7 @@
passed, because the app itself runs entirely in the container.
2. Picks a channel:
stable -> pulls the 'stable' image (built from main), no DEV badge.
edge -> pulls the 'edge' image (built from dev-be), red DEV badge.
edge -> pulls the 'edge' image (built from main), red DEV badge.
The two channels use different compose project names, host ports, and
named volumes, so they run side by side without colliding.
3. Generates a .env for the channel (dev-grade secrets, NOT for production)
@@ -25,10 +25,10 @@
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
.PARAMETER Channel
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
.PARAMETER Ref
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' for
Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
edge. Retained for compatibility; the pull flow selects the image by channel
tag, so this now only affects which branch the working tree is prepared on.
@@ -74,7 +74,7 @@ $channels = @{
ImageTag = 'stable'
}
edge = @{
Ref = 'dev-be'
Ref = 'main'
Project = 'shopdb-edge'
ApiPort = 5011
MysqlPort = 3316
@@ -282,10 +282,20 @@ try {
} else {
# Dev-grade secrets: generated locally, never for production. A real
# deploy fills .env from the wiki runbook, not from this script.
$secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
$jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 }))
$dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
$rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x'
#
# Get-Random is System.Random: a 32-bit seed, and all four values came
# off one stream, so recovering the seed from any one of them recovers
# the other three. These sign JWTs for a stack a pilot may point at a
# real imported dataset, so they come from the CSPRNG instead. "Dev
# grade" is about rotation and provenance, not about being guessable.
$secret = [Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
$jwt = [Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
$dbPass = ([Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
$rootPass = ([Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
$apiPort = $config.ApiPort
$mysqlPort = $config.MysqlPort
$image = "$imageRepo`:$($config.ImageTag)"