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
345 lines
12 KiB
PowerShell
345 lines
12 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Turnkey ShopDB developer-workstation bootstrap for Windows.
|
|
|
|
.DESCRIPTION
|
|
Stands up a ShopDB stack from a git branch on a fresh dev box with as few
|
|
manual steps as possible:
|
|
|
|
1. Checks the tools a dev needs (git, Docker) and offers to install any
|
|
that are missing via winget (with a prompt - nothing is installed
|
|
silently). Python is optional and only checked when -CheckPython is
|
|
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.
|
|
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)
|
|
if one does not already exist, then PULLS the prebuilt channel image
|
|
from GHCR and brings up the stack. Nothing is built on the host: the
|
|
image is produced by CI, so a pilot needs only Docker (no Node/npm and
|
|
no source compile behind the corporate proxy).
|
|
|
|
Windows-only by design: pre-release audience is Windows. Not a production
|
|
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
|
|
|
|
.PARAMETER Channel
|
|
'stable' (main) or 'edge' (dev-be). Defaults to 'stable'.
|
|
|
|
.PARAMETER Ref
|
|
Override the git ref to build. Defaults to 'main' for stable, 'dev-be' 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.
|
|
|
|
.PARAMETER CheckPython
|
|
Also verify a local Python 3.11+ (only needed if you run tests/tooling on
|
|
the host rather than in the container).
|
|
|
|
.PARAMETER SkipDependencyInstall
|
|
Report missing dependencies and stop instead of offering winget installs.
|
|
Handy for CI-style dry runs.
|
|
|
|
.EXAMPLE
|
|
./scripts/dev-bootstrap.ps1 -Channel edge
|
|
|
|
.NOTES
|
|
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[ValidateSet('stable', 'edge')]
|
|
[string]$Channel = 'stable',
|
|
|
|
[string]$Ref,
|
|
|
|
[switch]$CheckPython,
|
|
|
|
[switch]$SkipDependencyInstall
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Channel -> (branch, compose project, host ports, badge label). Kept in one
|
|
# table so adding a channel later is a single edit. Ports are offset for edge so
|
|
# both stacks bind distinct host ports; the container-internal ports never move.
|
|
$channels = @{
|
|
stable = @{
|
|
Ref = 'main'
|
|
Project = 'shopdb-stable'
|
|
ApiPort = 5001
|
|
MysqlPort = 3306
|
|
EnvLabel = ''
|
|
ImageTag = 'stable'
|
|
}
|
|
edge = @{
|
|
Ref = 'dev-be'
|
|
Project = 'shopdb-edge'
|
|
ApiPort = 5011
|
|
MysqlPort = 3316
|
|
EnvLabel = 'DEV'
|
|
ImageTag = 'edge'
|
|
}
|
|
}
|
|
|
|
# GHCR repository that CI (.github/workflows/publish-images.yml) publishes the
|
|
# per-channel images to. Pilots PULL this instead of building from source,
|
|
# because building behind an inspecting proxy is unreliable.
|
|
$imageRepo = 'ghcr.io/ge-aero/shopdb-flask'
|
|
|
|
function Write-Step {
|
|
param([string]$Message)
|
|
Write-Host ''
|
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
|
}
|
|
|
|
function Write-Ok {
|
|
param([string]$Message)
|
|
Write-Host " OK: $Message" -ForegroundColor Green
|
|
}
|
|
|
|
function Write-Warn {
|
|
param([string]$Message)
|
|
Write-Host " WARN: $Message" -ForegroundColor Yellow
|
|
}
|
|
|
|
function Test-Command {
|
|
param([string]$Name)
|
|
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
|
|
}
|
|
|
|
# Detect a corporate TLS-inspection root CA (e.g. Zscaler) by inspecting the
|
|
# certificate chain the host uses to reach the npm registry, then stage it as
|
|
# corp-root-ca.pem in the build context so the Docker frontend build can trust
|
|
# it. On a clean network this simply stages a public root, which is harmless.
|
|
# Writes nothing and returns quietly when detection is not possible.
|
|
function Set-CorpRootCa {
|
|
param(
|
|
[Parameter(Mandatory)][string]$RepoRoot
|
|
)
|
|
|
|
$certPath = Join-Path $RepoRoot 'corp-root-ca.pem'
|
|
|
|
try {
|
|
$request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/')
|
|
$request.Method = 'HEAD'
|
|
$request.Timeout = 10000
|
|
try { $response = $request.GetResponse(); $response.Close() } catch { }
|
|
|
|
$cert = $request.ServicePoint.Certificate
|
|
if (-not $cert) {
|
|
Write-Warn 'Could not read the TLS certificate chain; skipping corp CA staging.'
|
|
return
|
|
}
|
|
|
|
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
|
|
$chain.ChainPolicy.RevocationMode = 'NoCheck'
|
|
$cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
|
|
[void]$chain.Build($cert2)
|
|
|
|
$root = $chain.ChainElements[$chain.ChainElements.Count - 1].Certificate
|
|
$issuer = $root.Subject
|
|
|
|
$builder = New-Object System.Text.StringBuilder
|
|
[void]$builder.AppendLine('-----BEGIN CERTIFICATE-----')
|
|
[void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks'))
|
|
[void]$builder.AppendLine('-----END CERTIFICATE-----')
|
|
Set-Content -Path $certPath -Value $builder.ToString() -Encoding ASCII
|
|
|
|
Write-Ok "Staged corp-root-ca.pem from root CA: $issuer"
|
|
}
|
|
catch {
|
|
Write-Warn "Corp CA detection failed ($($_.Exception.Message)); continuing without it."
|
|
}
|
|
}
|
|
|
|
# Confirm a dependency, offering a winget install when it is missing. Returns
|
|
# $true when the dependency is present (or was just installed), $false when the
|
|
# dev declined or install failed.
|
|
function Confirm-Dependency {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Command,
|
|
[Parameter(Mandatory)][string]$DisplayName,
|
|
[Parameter(Mandatory)][string]$WingetId,
|
|
[string]$PostInstallNote
|
|
)
|
|
|
|
if (Test-Command $Command) {
|
|
Write-Ok "$DisplayName found."
|
|
return $true
|
|
}
|
|
|
|
Write-Warn "$DisplayName not found (command '$Command')."
|
|
|
|
if ($SkipDependencyInstall) {
|
|
Write-Warn "Skipping install (-SkipDependencyInstall). Install '$WingetId' manually."
|
|
return $false
|
|
}
|
|
|
|
if (-not (Test-Command 'winget')) {
|
|
Write-Warn "winget is unavailable, so $DisplayName cannot be auto-installed."
|
|
Write-Warn "Install it from the Microsoft Store / App Installer, then re-run."
|
|
return $false
|
|
}
|
|
|
|
$answer = Read-Host " Install $DisplayName now via winget ($WingetId)? [y/N]"
|
|
if ($answer -notmatch '^(y|yes)$') {
|
|
Write-Warn "Skipped $DisplayName. It is required; re-run once it is installed."
|
|
return $false
|
|
}
|
|
|
|
Write-Host " Running: winget install --id $WingetId" -ForegroundColor DarkGray
|
|
winget install --id $WingetId --exact --accept-source-agreements --accept-package-agreements
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Warn "winget exited $LASTEXITCODE installing $DisplayName."
|
|
return $false
|
|
}
|
|
|
|
if ($PostInstallNote) { Write-Warn $PostInstallNote }
|
|
# winget updates the machine PATH, but this session will not see it until
|
|
# relaunch, so re-probe rather than claim success outright.
|
|
return (Test-Command $Command)
|
|
}
|
|
|
|
# Docker being installed is not the same as the engine running. A stopped
|
|
# Docker Desktop passes the command check but fails every compose call.
|
|
function Test-DockerEngine {
|
|
try {
|
|
docker info --format '{{.ServerVersion}}' 2>$null | Out-Null
|
|
return ($LASTEXITCODE -eq 0)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
# --- Resolve paths and channel -------------------------------------------------
|
|
|
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
$config = $channels[$Channel]
|
|
$branch = if ($Ref) { $Ref } else { $config.Ref }
|
|
|
|
Write-Host "ShopDB dev bootstrap" -ForegroundColor White
|
|
Write-Host " Channel : $Channel" -ForegroundColor White
|
|
Write-Host " Branch : $branch" -ForegroundColor White
|
|
Write-Host " Project : $($config.Project)" -ForegroundColor White
|
|
Write-Host " Repo : $repoRoot" -ForegroundColor White
|
|
|
|
# --- 1. Dependencies -----------------------------------------------------------
|
|
|
|
Write-Step 'Checking developer dependencies'
|
|
|
|
$allPresent = $true
|
|
$allPresent = (Confirm-Dependency -Command 'git' -DisplayName 'Git' -WingetId 'Git.Git') -and $allPresent
|
|
$allPresent = (Confirm-Dependency -Command 'docker' -DisplayName 'Docker Desktop' -WingetId 'Docker.DockerDesktop' `
|
|
-PostInstallNote 'Docker Desktop needs a sign-out/restart and must be started once before use.') -and $allPresent
|
|
|
|
if ($CheckPython) {
|
|
$allPresent = (Confirm-Dependency -Command 'python' -DisplayName 'Python 3.11+' -WingetId 'Python.Python.3.11') -and $allPresent
|
|
}
|
|
|
|
if (-not $allPresent) {
|
|
throw 'One or more required dependencies are missing. Resolve the warnings above and re-run.'
|
|
}
|
|
|
|
Write-Step 'Checking the Docker engine is running'
|
|
if (-not (Test-DockerEngine)) {
|
|
throw 'Docker is installed but the engine is not responding. Start Docker Desktop and re-run.'
|
|
}
|
|
Write-Ok 'Docker engine is responding.'
|
|
|
|
# --- 2. Check out the channel branch ------------------------------------------
|
|
|
|
Write-Step "Preparing the '$branch' branch"
|
|
Push-Location $repoRoot
|
|
try {
|
|
$dirty = git status --porcelain
|
|
if ($dirty) {
|
|
Write-Warn 'Working tree has uncommitted changes; leaving the current branch as-is.'
|
|
Write-Warn "Building from the checked-out tree instead of switching to '$branch'."
|
|
} else {
|
|
git fetch origin $branch
|
|
git checkout $branch
|
|
git pull --ff-only origin $branch
|
|
Write-Ok "On '$branch', up to date with origin."
|
|
}
|
|
|
|
# --- 3. Channel .env ------------------------------------------------------
|
|
|
|
Write-Step 'Ensuring a channel .env exists'
|
|
$envPath = Join-Path $repoRoot ".env.$($config.Project)"
|
|
if (Test-Path $envPath) {
|
|
Write-Ok "Reusing existing $([System.IO.Path]::GetFileName($envPath))."
|
|
# A .env written before the pull-based flow existed will not carry a
|
|
# SHOPDB_IMAGE line, and the pull compose file requires it. Top it up in
|
|
# place rather than force the pilot to delete and re-key the file.
|
|
$image = "$imageRepo`:$($config.ImageTag)"
|
|
$envText = Get-Content -Path $envPath
|
|
if (-not ($envText | Where-Object { $_ -match '^SHOPDB_IMAGE=' })) {
|
|
Add-Content -Path $envPath -Value "SHOPDB_IMAGE=$image" -Encoding ASCII
|
|
Write-Ok "Added SHOPDB_IMAGE=$image to the existing .env."
|
|
}
|
|
} 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'
|
|
$apiPort = $config.ApiPort
|
|
$mysqlPort = $config.MysqlPort
|
|
$image = "$imageRepo`:$($config.ImageTag)"
|
|
|
|
$lines = @(
|
|
"# Auto-generated by scripts/dev-bootstrap.ps1 for the $Channel channel.",
|
|
"# DEV-GRADE ONLY. Do not reuse these secrets for a production site.",
|
|
"FLASK_APP=wsgi.py",
|
|
"FLASK_ENV=production",
|
|
"SECRET_KEY=$secret",
|
|
"JWT_SECRET_KEY=$jwt",
|
|
"DATABASE_URL=mysql+pymysql://shopdb:$dbPass@db:3306/shopdb_flask?charset=utf8mb4",
|
|
"CORS_ORIGINS=https://localhost:$apiPort",
|
|
"LOG_LEVEL=INFO",
|
|
"MYSQL_ROOT_PASSWORD=$rootPass",
|
|
"MYSQL_PASSWORD=$dbPass",
|
|
"MYSQL_PORT=$mysqlPort",
|
|
"API_PORT=$apiPort",
|
|
"SHOPDB_IMAGE=$image",
|
|
"SHOPDB_ENV_LABEL=$($config.EnvLabel)"
|
|
)
|
|
Set-Content -Path $envPath -Value $lines -Encoding ASCII
|
|
Write-Ok "Wrote $([System.IO.Path]::GetFileName($envPath)) (API on $apiPort, MySQL on $mysqlPort)."
|
|
}
|
|
|
|
# --- 4. Pull and start ----------------------------------------------------
|
|
|
|
# No local build: the image is produced by CI and published to GHCR (see
|
|
# .github/workflows/publish-images.yml). Pilots may have only Docker
|
|
# installed, and building the frontend from source behind an inspecting
|
|
# proxy is unreliable, so we pull the finished channel image instead.
|
|
Write-Step "Pulling and starting the '$($config.Project)' stack"
|
|
$composeArgs = @(
|
|
'compose',
|
|
'-p', $config.Project,
|
|
'--env-file', $envPath,
|
|
'-f', 'docker-compose.pull.yml'
|
|
)
|
|
|
|
& docker @composeArgs pull
|
|
if ($LASTEXITCODE -ne 0) { throw "docker compose pull failed ($LASTEXITCODE). If this is an auth error, run: docker login ghcr.io" }
|
|
|
|
& docker @composeArgs up -d
|
|
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)." }
|
|
|
|
Write-Step 'Done'
|
|
Write-Ok "The $Channel stack is up."
|
|
Write-Host " UI/API : https://localhost:$($config.ApiPort)" -ForegroundColor White
|
|
if ($config.EnvLabel) {
|
|
Write-Host " Badge : sidebar shows '$($config.EnvLabel)' (edge channel)." -ForegroundColor White
|
|
}
|
|
Write-Host " Logs : docker compose -p $($config.Project) logs -f api" -ForegroundColor White
|
|
Write-Host " Stop : docker compose -p $($config.Project) down" -ForegroundColor White
|
|
} finally {
|
|
Pop-Location
|
|
}
|