379 lines
15 KiB
PowerShell
379 lines
15 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 '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
|
|
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' (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 the newest v* tag for stable, 'main' 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 -> (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 = $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) {
|
|
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]
|
|
# 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"
|
|
$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)." }
|
|
|
|
# -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'
|
|
)
|
|
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>"
|
|
'@
|
|
# 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 ----------------------------------------------------------
|
|
|
|
$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
|
|
}
|