Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows
This commit is contained in:
191
scripts/Start-ShopDBDev.ps1
Normal file
191
scripts/Start-ShopDBDev.ps1
Normal file
@@ -0,0 +1,191 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Save-and-run launcher for a ShopDB dev workstation (stable or edge).
|
||||
|
||||
.DESCRIPTION
|
||||
Download this one file and run it. It clones (or updates) the repo at the
|
||||
channel branch into a local path, then hands off to scripts/dev-bootstrap.ps1
|
||||
for the real dependency-check / build / up work.
|
||||
|
||||
ge-aero/shopdb-flask is a PRIVATE repo, but this needs no token pasting: the
|
||||
clone uses a plain https URL and Git Credential Manager supplies your stored
|
||||
GitHub credential automatically - the same way `git clone` works in the wiki
|
||||
DEVELOPMENT-SETUP procedure. If you have ever cloned this repo or signed in to
|
||||
GitHub in Visual Studio / VS Code on this box, GCM already has what it needs.
|
||||
|
||||
This is a downloaded FILE, not a web `iex` one-liner, precisely because a
|
||||
private repo cannot be fetched anonymously from raw.githubusercontent.com. A
|
||||
saved script sidesteps that entirely - only the git operations need auth, and
|
||||
GCM handles those.
|
||||
|
||||
.PARAMETER Channel
|
||||
'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
|
||||
from any repo you already have open.
|
||||
|
||||
.EXAMPLE
|
||||
# Bleeding edge:
|
||||
powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel edge
|
||||
|
||||
.EXAMPLE
|
||||
# Stable:
|
||||
powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel stable
|
||||
|
||||
.NOTES
|
||||
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
|
||||
Needs Git (with Credential Manager, the Git-for-Windows default);
|
||||
dev-bootstrap.ps1 offers to winget-install anything else that is missing.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('stable', 'edge')]
|
||||
[string]$Channel = 'stable',
|
||||
|
||||
[string]$TargetDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Reset leftovers from an interrupted or timed-out prior run so the retry starts
|
||||
# clean. Proxy bottlenecks can kill a build mid-flight, leaving orphaned
|
||||
# containers holding the channel ports, dangling build cache from the partial
|
||||
# build, and sometimes a half-written clone. This is scoped to the channel and
|
||||
# is non-destructive to data: the compose down keeps named volumes (the DB), and
|
||||
# only dangling build cache is pruned - never tagged images or volumes.
|
||||
function Clear-InterruptedRun {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Project,
|
||||
[Parameter(Mandatory)][string]$Target
|
||||
)
|
||||
|
||||
if (Get-Command docker -ErrorAction SilentlyContinue) {
|
||||
# docker/compose write warnings to stderr (e.g. "No resource found to
|
||||
# remove"), which would trip $ErrorActionPreference='Stop' and abort the
|
||||
# whole run. These cleanup steps are best-effort, so soften the preference
|
||||
# and swallow all streams for their duration.
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
# Only try compose down when the engine is actually responding, otherwise
|
||||
# skip quietly - a fresh box may not have Docker started yet.
|
||||
docker info *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "==> Cleaning up any interrupted '$Project' stack" -ForegroundColor Cyan
|
||||
# --remove-orphans clears containers left by a killed build; volumes are
|
||||
# intentionally kept so the DB survives a retry.
|
||||
docker compose -p $Project down --remove-orphans *> $null
|
||||
# Reclaim only dangling build cache from failed/partial builds.
|
||||
docker builder prune -f *> $null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previous
|
||||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
# "detected dubious ownership" guard and every git op fails with exit 128.
|
||||
# That stale clone is effectively unusable for this user, so detect the
|
||||
# condition and remove it here for a clean re-clone rather than surfacing a
|
||||
# misleading auth error later.
|
||||
if (Test-Path (Join-Path $Target '.git')) {
|
||||
$previous = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
|
||||
finally { $ErrorActionPreference = $previous }
|
||||
if ($probe -match 'dubious ownership') {
|
||||
# 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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Plain https URL. No token in the URL: Git Credential Manager injects the
|
||||
# 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'
|
||||
# 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
|
||||
Write-Host " Channel : $Channel ($branch)" -ForegroundColor White
|
||||
Write-Host " Target : $target" -ForegroundColor White
|
||||
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
throw 'git is required. Install Git for Windows (winget install Git.Git) and re-run.'
|
||||
}
|
||||
|
||||
$project = if ($Channel -eq 'edge') { 'shopdb-edge' } else { 'shopdb-stable' }
|
||||
Clear-InterruptedRun -Project $project -Target $target
|
||||
|
||||
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). If this is an auth error, clone the repo once in Visual Studio / VS Code so Credential Manager stores your GitHub login." }
|
||||
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). If this is an auth error, sign in to GitHub once in Visual Studio / VS Code (or run 'git clone $repoUrl' manually) so Credential Manager stores your login, then re-run." }
|
||||
}
|
||||
|
||||
$bootstrap = Join-Path $target 'scripts\dev-bootstrap.ps1'
|
||||
if (-not (Test-Path $bootstrap)) {
|
||||
throw "dev-bootstrap.ps1 not found at $bootstrap. Is the '$branch' branch correct?"
|
||||
}
|
||||
|
||||
Write-Host "==> Handing off to dev-bootstrap.ps1" -ForegroundColor Cyan
|
||||
& $bootstrap -Channel $Channel
|
||||
377
scripts/build-channel-installer.ps1
Normal file
377
scripts/build-channel-installer.ps1
Normal file
@@ -0,0 +1,377 @@
|
||||
<#
|
||||
.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
|
||||
}
|
||||
@@ -21,6 +21,8 @@ VIOLATIONS=0
|
||||
|
||||
EXCLUDES=(
|
||||
--exclude-dir=venv
|
||||
--exclude-dir=env
|
||||
--exclude-dir=.venv
|
||||
--exclude-dir=node_modules
|
||||
--exclude-dir=__pycache__
|
||||
--exclude-dir=dist
|
||||
|
||||
354
scripts/dev-bootstrap.ps1
Normal file
354
scripts/dev-bootstrap.ps1
Normal file
@@ -0,0 +1,354 @@
|
||||
<#
|
||||
.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 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)
|
||||
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' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
|
||||
|
||||
.PARAMETER Ref
|
||||
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.
|
||||
|
||||
.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 = 'main'
|
||||
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.
|
||||
#
|
||||
# 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)"
|
||||
|
||||
$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
|
||||
}
|
||||
335
scripts/sim_policy_monitor_e2e.py
Normal file
335
scripts/sim_policy_monitor_e2e.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Live end-to-end simulation for the policy-driven monitor workflow.
|
||||
|
||||
Drives the REAL HTTP routes through the Flask app against the LIVE database
|
||||
(the same one the container serves), exercising the exact flow a user performs
|
||||
in the browser:
|
||||
|
||||
1. Log in as an admin (mint a real JWT).
|
||||
2. Confirm the monitor catalog lists the four shipped monitors.
|
||||
3. Create a local process monitor (msedge.exe) via POST /api/endpoints/monitors.
|
||||
4. Create a Configuration Policy scoped to a PC type, carrying monitor:<id>.
|
||||
5. Wrap it in an active Business Policy.
|
||||
6. Create a Computer of that PC type.
|
||||
7. GET the services panel and assert the monitor surfaces as an Unknown row.
|
||||
8. Negative + validation probes.
|
||||
9. Clean up everything created (leaves the DB as found).
|
||||
|
||||
Run inside the container:
|
||||
docker compose exec -T api python scripts/sim_policy_monitor_e2e.py
|
||||
|
||||
Exit code 0 = all simulations passed. Non-zero = a failure (printed).
|
||||
"""
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import User, Role, Asset, AssetType, Setting
|
||||
from flask_jwt_extended import create_access_token
|
||||
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
|
||||
|
||||
class Sim:
|
||||
def __init__(self):
|
||||
self.results = []
|
||||
self.created = {
|
||||
"monitor_ids": [],
|
||||
"configpolicy_ids": [],
|
||||
"businesspolicy_ids": [],
|
||||
"asset_ids": [],
|
||||
"computer_ids": [],
|
||||
"computertype_ids": [],
|
||||
"setting_created": False,
|
||||
}
|
||||
|
||||
def check(self, name, condition, detail=""):
|
||||
status = PASS if condition else FAIL
|
||||
self.results.append((status, name, detail))
|
||||
marker = "[ OK ]" if condition else "[FAIL]"
|
||||
line = f"{marker} {name}"
|
||||
if detail:
|
||||
line += f" -- {detail}"
|
||||
print(line, flush=True)
|
||||
return condition
|
||||
|
||||
def summary(self):
|
||||
passed = sum(1 for r in self.results if r[0] == PASS)
|
||||
failed = sum(1 for r in self.results if r[0] == FAIL)
|
||||
print("\n" + "=" * 60, flush=True)
|
||||
print(f"SIMULATION SUMMARY: {passed} passed, {failed} failed", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
return failed == 0
|
||||
|
||||
|
||||
def admin_headers(app):
|
||||
"""Mint a real admin JWT bound to an existing admin user, or a temp one."""
|
||||
with app.app_context():
|
||||
user = (
|
||||
User.query.join(User.roles)
|
||||
.filter(Role.rolename == "admin")
|
||||
.first()
|
||||
)
|
||||
if user is None:
|
||||
role = Role.query.filter_by(rolename="admin").first()
|
||||
if role is None:
|
||||
role = Role(rolename="admin", description="Administrator")
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
user = User(
|
||||
username=f"sim_admin_{uuid.uuid4().hex[:8]}",
|
||||
email=f"sim_{uuid.uuid4().hex[:8]}@test.local",
|
||||
isactive=True,
|
||||
)
|
||||
if hasattr(user, "set_password"):
|
||||
user.set_password("simpass123!")
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
token = create_access_token(
|
||||
identity=str(user.userid),
|
||||
additional_claims={
|
||||
"username": user.username,
|
||||
"roles": [r.rolename for r in user.roles],
|
||||
},
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def jdata(resp):
|
||||
body = resp.get_json()
|
||||
return (body or {}).get("data")
|
||||
|
||||
|
||||
def run():
|
||||
app = create_app()
|
||||
headers = admin_headers(app)
|
||||
sim = Sim()
|
||||
client = app.test_client()
|
||||
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
pctype_name = f"SimPCType_{tag}"
|
||||
|
||||
# --- 1. Monitor catalog lists the shipped four ---------------------------
|
||||
resp = client.get("/api/endpoints/monitors", headers=headers)
|
||||
sim.check("GET /monitors returns 200", resp.status_code == 200,
|
||||
f"status={resp.status_code}")
|
||||
catalog = jdata(resp) or []
|
||||
shipped = [m for m in catalog if m.get("source") == "shipped"]
|
||||
shipped_names = sorted({m["displayname"] for m in shipped})
|
||||
sim.check("Shipped monitors seeded (eDNC, UDC, MTConnect x2)",
|
||||
len(shipped) >= 4,
|
||||
f"shipped={len(shipped)} names={shipped_names}")
|
||||
|
||||
# --- 2. Create a local process monitor (msedge.exe) ----------------------
|
||||
display = f"sim_msedge_{tag}.exe"
|
||||
resp = client.post("/api/endpoints/monitors", headers=headers, json={
|
||||
"kind": "process",
|
||||
"displayname": display,
|
||||
"processname": display,
|
||||
"isactive": True,
|
||||
})
|
||||
sim.check("POST /monitors (process) returns 201",
|
||||
resp.status_code == 201, f"status={resp.status_code}")
|
||||
monitor = jdata(resp) or {}
|
||||
monitor_id = monitor.get("endpointmonitorid")
|
||||
if monitor_id:
|
||||
sim.created["monitor_ids"].append(monitor_id)
|
||||
sim.check("Created monitor has an id", bool(monitor_id),
|
||||
f"id={monitor_id}")
|
||||
|
||||
# --- 2b. Validation probe: missing name is rejected ----------------------
|
||||
resp = client.post("/api/endpoints/monitors", headers=headers, json={
|
||||
"kind": "process", "displayname": "", "processname": "",
|
||||
})
|
||||
sim.check("POST /monitors with blank name rejected (400)",
|
||||
resp.status_code == 400, f"status={resp.status_code}")
|
||||
|
||||
# --- 3. Create a PC type to scope to -------------------------------------
|
||||
resp = client.post("/api/computers/types", headers=headers,
|
||||
json={"computertype": pctype_name})
|
||||
sim.check("POST /computers/types returns 2xx",
|
||||
resp.status_code in (200, 201), f"status={resp.status_code}")
|
||||
pctype = jdata(resp) or {}
|
||||
pctype_id = pctype.get("computertypeid")
|
||||
if pctype_id:
|
||||
sim.created["computertype_ids"].append(pctype_id)
|
||||
sim.check("PC type created with id", bool(pctype_id), f"id={pctype_id}")
|
||||
|
||||
# --- 4. Enable the feature flag ------------------------------------------
|
||||
with app.app_context():
|
||||
flag = Setting.query.filter_by(
|
||||
key="computers_configpolicies_enabled").first()
|
||||
if flag is None:
|
||||
db.session.add(Setting(
|
||||
key="computers_configpolicies_enabled", value="true"))
|
||||
sim.created["setting_created"] = True
|
||||
else:
|
||||
sim._prev_flag = flag.value
|
||||
flag.value = "true"
|
||||
db.session.commit()
|
||||
sim.check("Feature flag computers_configpolicies_enabled=true", True)
|
||||
|
||||
# --- 5. Create a Configuration Policy carrying monitor:<id> ---------------
|
||||
resp = client.post("/api/computers/policies/configuration", headers=headers,
|
||||
json={
|
||||
"name": f"Sim Edge Coverage {tag}",
|
||||
"description": "sim",
|
||||
"scopejson": {"type": "pctype", "id": pctype_id},
|
||||
"actions": [
|
||||
{"action": f"monitor:{monitor_id}",
|
||||
"mode": "gate"},
|
||||
],
|
||||
})
|
||||
sim.check("POST /policies/configuration returns 2xx",
|
||||
resp.status_code in (200, 201), f"status={resp.status_code}")
|
||||
cpolicy = jdata(resp) or {}
|
||||
cpolicy_id = cpolicy.get("configurationpolicyid")
|
||||
if cpolicy_id:
|
||||
sim.created["configpolicy_ids"].append(cpolicy_id)
|
||||
sim.check("Configuration Policy created with id", bool(cpolicy_id),
|
||||
f"id={cpolicy_id}")
|
||||
|
||||
# --- 5b. Round-trip: the monitor action persisted ------------------------
|
||||
resp = client.get(
|
||||
f"/api/computers/policies/configuration/{cpolicy_id}", headers=headers)
|
||||
body = jdata(resp) or {}
|
||||
actions = body.get("actions", [])
|
||||
sim.check("Config policy round-trips the monitor action",
|
||||
any(a.get("action") == f"monitor:{monitor_id}" for a in actions),
|
||||
f"actions={[a.get('action') for a in actions]}")
|
||||
|
||||
# --- 6. Wrap it in an active Business Policy ------------------------------
|
||||
resp = client.post("/api/computers/policies/business", headers=headers,
|
||||
json={
|
||||
"name": f"Sim Process Monitor Test {tag}",
|
||||
"priority": 100,
|
||||
"isactive": True,
|
||||
"items": [
|
||||
{"configurationpolicyid": cpolicy_id,
|
||||
"sortorder": 0},
|
||||
],
|
||||
})
|
||||
sim.check("POST /policies/business returns 2xx",
|
||||
resp.status_code in (200, 201), f"status={resp.status_code}")
|
||||
bpolicy = jdata(resp) or {}
|
||||
bpolicy_id = bpolicy.get("businesspolicyid")
|
||||
if bpolicy_id:
|
||||
sim.created["businesspolicy_ids"].append(bpolicy_id)
|
||||
sim.check("Business Policy created with id", bool(bpolicy_id),
|
||||
f"id={bpolicy_id}")
|
||||
|
||||
# --- 7. Create a Computer of that PC type --------------------------------
|
||||
hostname = f"SIMPC{tag.upper()}"
|
||||
with app.app_context():
|
||||
from plugins.computers.models import Computer
|
||||
atype = AssetType.query.filter_by(assettype="computer").first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype="computer")
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname,
|
||||
computertypeid=pctype_id)
|
||||
db.session.add(comp)
|
||||
db.session.commit()
|
||||
asset_id = asset.assetid
|
||||
computer_id = comp.computerid
|
||||
sim.created["asset_ids"].append(asset_id)
|
||||
sim.created["computer_ids"].append(computer_id)
|
||||
sim.check("Computer created for the scoped PC type", bool(asset_id),
|
||||
f"assetid={asset_id}")
|
||||
|
||||
# --- 8. The monitor surfaces in the services panel -----------------------
|
||||
resp = client.get(f"/api/endpoints/asset/{asset_id}/services",
|
||||
headers=headers)
|
||||
sim.check("GET /asset/<id>/services returns 200",
|
||||
resp.status_code == 200, f"status={resp.status_code}")
|
||||
rows = jdata(resp) or []
|
||||
edge = next((r for r in rows if r.get("name") == display), None)
|
||||
sim.check("Policy-attached monitor surfaces in panel", edge is not None,
|
||||
f"rows={[r.get('name') for r in rows]}")
|
||||
if edge:
|
||||
sim.check("Surfaced row is a process", edge.get("kind") == "process",
|
||||
f"kind={edge.get('kind')}")
|
||||
sim.check("Surfaced row is Unknown (not yet reported)",
|
||||
edge.get("statelabel") == "Unknown",
|
||||
f"statelabel={edge.get('statelabel')}")
|
||||
action = edge.get("action") or {}
|
||||
sim.check("Surfaced row offers a Start action targeting the process",
|
||||
action.get("target") == display
|
||||
and action.get("targetkind") == "process",
|
||||
f"action={action}")
|
||||
|
||||
# --- 9. Negative: flag off hides the row ---------------------------------
|
||||
with app.app_context():
|
||||
flag = Setting.query.filter_by(
|
||||
key="computers_configpolicies_enabled").first()
|
||||
flag.value = "false"
|
||||
db.session.commit()
|
||||
resp = client.get(f"/api/endpoints/asset/{asset_id}/services",
|
||||
headers=headers)
|
||||
rows = jdata(resp) or []
|
||||
sim.check("Flag off hides the policy monitor from the panel",
|
||||
not any(r.get("name") == display for r in rows),
|
||||
f"rows={[r.get('name') for r in rows]}")
|
||||
with app.app_context():
|
||||
flag = Setting.query.filter_by(
|
||||
key="computers_configpolicies_enabled").first()
|
||||
flag.value = "true"
|
||||
db.session.commit()
|
||||
|
||||
# --- Cleanup -------------------------------------------------------------
|
||||
cleanup(app, client, headers, sim)
|
||||
|
||||
return sim.summary()
|
||||
|
||||
|
||||
def cleanup(app, client, headers, sim):
|
||||
print("\n--- cleanup ---", flush=True)
|
||||
for bid in sim.created["businesspolicy_ids"]:
|
||||
client.delete(f"/api/computers/policies/business/{bid}", headers=headers)
|
||||
for cid in sim.created["configpolicy_ids"]:
|
||||
client.delete(f"/api/computers/policies/configuration/{cid}",
|
||||
headers=headers)
|
||||
for mid in sim.created["monitor_ids"]:
|
||||
client.delete(f"/api/endpoints/monitors/{mid}", headers=headers)
|
||||
with app.app_context():
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
for cid in sim.created["computer_ids"]:
|
||||
row = db.session.get(Computer, cid)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
for aid in sim.created["asset_ids"]:
|
||||
row = db.session.get(Asset, aid)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
for tid in sim.created["computertype_ids"]:
|
||||
row = db.session.get(ComputerType, tid)
|
||||
if row:
|
||||
db.session.delete(row)
|
||||
# Restore or remove the feature flag as found.
|
||||
flag = Setting.query.filter_by(
|
||||
key="computers_configpolicies_enabled").first()
|
||||
if flag is not None:
|
||||
if sim.created["setting_created"]:
|
||||
db.session.delete(flag)
|
||||
elif hasattr(sim, "_prev_flag"):
|
||||
flag.value = sim._prev_flag
|
||||
db.session.commit()
|
||||
print("cleanup done", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ok = run()
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -114,6 +114,15 @@ Start-Sleep -Seconds 1
|
||||
Write-Host "[3/6] Removing registry entries ..."
|
||||
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
|
||||
Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
|
||||
|
||||
# Remove the All-Users Start Menu shortcut the site-agent installer created.
|
||||
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
|
||||
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
|
||||
if (Test-Path $shortcutPath) {
|
||||
try { Remove-Item -Path $shortcutPath -Force -ErrorAction Stop; Write-Host " removed Start Menu shortcut $shortcutPath" }
|
||||
catch { Write-Warning " could not remove Start Menu shortcut: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# Drop the parent GE key only if we emptied it (some sites nest siblings under
|
||||
# GE such as SFLD; never tree-delete a key we do not own).
|
||||
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,
|
||||
|
||||
Reference in New Issue
Block a user