feat(deploy): add the air-gapped Windows installer
Roughly 2500 lines of tested installer had been living in ~/Downloads and an untracked folder - nothing was under version control. It goes here rather than in a repo of its own because it depends on application internals: the `flask plugin` verbs, site-profile.json, MOUNT_PATH, and the plugin registry. Versioned separately it would drift out of step with the thing it installs. Contents: the read-only preflight, the staged installer (bundled MySQL, runtime, schema, IIS, verify, uninstall), the operator console, the Inno Setup wizard, the bundle builder and the artwork generator. bundle/ and Output/ are ignored - regenerable, and ~220MB. plugins.iss is ignored because build-installer.sh generates it from the staged payload. The artwork IS committed so a Windows build box does not need Python and cairosvg. Verified end to end on Windows Server 2025 against a bundled MySQL 8.0 and an existing MySQL 5.6: fresh install, upgrade with backup and rollback, re-run idempotency, uninstall, and both deployment methods including switching between them. Not yet verified: a hypervisor-level air-gapped run, and any load from a real browser (every HTTP check so far used curl, which sends no Origin header).
5
deploy/windows/installer/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Build output and staged payload - regenerable, and ~220MB.
|
||||||
|
bundle/
|
||||||
|
Output/
|
||||||
|
# Generated by build-installer.sh from the staged bundle.
|
||||||
|
plugins.iss
|
||||||
83
deploy/windows/installer/README.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Windows installer
|
||||||
|
|
||||||
|
Builds a single self-contained `.exe` that installs ShopDB-Flask on an
|
||||||
|
**air-gapped** Windows Server. Nothing here ever touches the network at install
|
||||||
|
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.
|
||||||
|
|
||||||
|
Lives with the application on purpose. The installer depends on app internals -
|
||||||
|
`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry -
|
||||||
|
so a separate repo would drift out of step with the thing it installs.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. |
|
||||||
|
| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. |
|
||||||
|
| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. |
|
||||||
|
| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
|
||||||
|
| `build-installer.sh` | Stages the bundle from a site profile. |
|
||||||
|
| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. |
|
||||||
|
| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. |
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stage the bundle for a site's plugin set.
|
||||||
|
./build-installer.sh ../../site-profile.example.json
|
||||||
|
|
||||||
|
# 2. Add the pieces that cannot be built on Linux:
|
||||||
|
# bundle/wheels/ ~47 cp314 win_amd64 wheels, built ON Windows:
|
||||||
|
# pip download -r requirements.txt -d wheels --only-binary=:all:
|
||||||
|
# bundle/python/ python-3.14.x-amd64.exe
|
||||||
|
# bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
|
||||||
|
# bundle/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)
|
||||||
|
|
||||||
|
# 3. Compile on Windows.
|
||||||
|
iscc ShopDBFlask.iss
|
||||||
|
```
|
||||||
|
|
||||||
|
The wheelhouse is **cp314-locked**. A different Python minor version means a
|
||||||
|
different wheelhouse; the installer will not use a Python it did not install.
|
||||||
|
|
||||||
|
## Deployment methods
|
||||||
|
|
||||||
|
Chosen in the wizard, and the bundle carries a SPA build for each because Vite
|
||||||
|
compiles the base path in - it cannot be switched at install time.
|
||||||
|
|
||||||
|
- **Its own site** on a port (default 8090).
|
||||||
|
- **Subpath** under an existing site, e.g. `http://<server-fqdn>/shopdb/`. Needs
|
||||||
|
no new DNS record. Three things must agree - the IIS application alias,
|
||||||
|
`MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed
|
||||||
|
per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the
|
||||||
|
bundle's build does not match what was asked for.
|
||||||
|
|
||||||
|
Switching between methods removes the other one's IIS artifact and reconciles
|
||||||
|
`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both.
|
||||||
|
|
||||||
|
## Upgrades
|
||||||
|
|
||||||
|
Run a newer installer over an existing install. It:
|
||||||
|
|
||||||
|
- backs the database up first, **verifies** the dump is complete, and refuses to
|
||||||
|
migrate if it cannot;
|
||||||
|
- restores from that backup if migrations fail, and reports honestly that DDL
|
||||||
|
the failed migration committed cannot be undone;
|
||||||
|
- refuses to run a bundle older than what is installed;
|
||||||
|
- keeps `.env` unless new credentials are supplied, and copies it aside first;
|
||||||
|
- stops the app pool before replacing files, then starts it again.
|
||||||
|
|
||||||
|
Whether a run is an upgrade is decided by probing the **target database**, not by
|
||||||
|
whether the app directory exists - a rebuilt server pointed at an existing
|
||||||
|
database is an upgrade, and treating it as fresh would drop tables.
|
||||||
|
|
||||||
|
## Testing notes
|
||||||
|
|
||||||
|
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.0 and
|
||||||
|
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
|
||||||
|
rollback, uninstall, and both deployment methods including switching between
|
||||||
|
them.
|
||||||
|
|
||||||
|
**Not yet verified:** a fully air-gapped run with the network disabled at the
|
||||||
|
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
|
||||||
|
which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger).
|
||||||
1002
deploy/windows/installer/ShopDBFlask.iss
Normal file
84
deploy/windows/installer/build-installer.sh
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Stage a lean per-site bundle next to ShopDBFlask.iss, ready for Inno Setup.
|
||||||
|
#
|
||||||
|
# The bundle is built FOR ONE SITE from its plugin profile (ADR-013): plugins the
|
||||||
|
# site did not choose are absent from the payload entirely. Build one installer
|
||||||
|
# per site, not one universal installer.
|
||||||
|
#
|
||||||
|
# Usage: build-installer.sh <site-profile.json> [repo-path]
|
||||||
|
#
|
||||||
|
# The wheelhouse cannot be built here. Wheels are cp314 win_amd64 and must be
|
||||||
|
# produced ON Windows with the matching Python:
|
||||||
|
# pip download -r requirements.txt -d wheels --only-binary=:all:
|
||||||
|
# Copy that wheels\ directory in before compiling.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROFILE="${1:?usage: build-installer.sh <site-profile.json> [repo-path]}"
|
||||||
|
REPO="${2:-$HOME/projects/shopdb-flask}"
|
||||||
|
BUNDLE="$HERE/bundle"
|
||||||
|
|
||||||
|
[ -f "$PROFILE" ] || { echo "profile not found: $PROFILE"; exit 1; }
|
||||||
|
[ -d "$REPO" ] || { echo "repo not found: $REPO"; exit 1; }
|
||||||
|
|
||||||
|
echo "==> Staging lean app tree from $PROFILE"
|
||||||
|
rm -rf "$BUNDLE/app"
|
||||||
|
bash "$REPO/scripts/build-site.sh" "$PROFILE" "$BUNDLE/app"
|
||||||
|
|
||||||
|
# build-site.sh emits the SPA as frontend-dist; the installer's web.config and
|
||||||
|
# static route expect frontend\dist.
|
||||||
|
if [ -d "$BUNDLE/app/frontend-dist" ]; then
|
||||||
|
mkdir -p "$BUNDLE/app/frontend"
|
||||||
|
rm -rf "$BUNDLE/app/frontend/dist"
|
||||||
|
mv "$BUNDLE/app/frontend-dist" "$BUNDLE/app/frontend/dist"
|
||||||
|
fi
|
||||||
|
# The /shopdb-based build, used when the operator picks the subpath deployment.
|
||||||
|
if [ -d "$BUNDLE/app/frontend-dist-subpath" ]; then
|
||||||
|
rm -rf "$BUNDLE/app/frontend/dist-subpath"
|
||||||
|
mv "$BUNDLE/app/frontend-dist-subpath" "$BUNDLE/app/frontend/dist-subpath"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tell the .iss which plugins this bundle actually carries, so the wizard's
|
||||||
|
# plugin page always matches the payload instead of a hand-maintained list.
|
||||||
|
echo "==> Writing plugins.iss"
|
||||||
|
PLUGINS=$(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
||||||
|
# The subpath SPA is built with its base path compiled in, so whether the wizard
|
||||||
|
# can OFFER a subpath install is a property of the bundle, not a runtime choice.
|
||||||
|
SUBPATH_ALIAS_BUILT=""
|
||||||
|
if [ -f "$BUNDLE/app/frontend/dist-subpath/.alias" ]; then
|
||||||
|
SUBPATH_ALIAS_BUILT="$(cat "$BUNDLE/app/frontend/dist-subpath/.alias")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$HERE/plugins.iss" <<EOF
|
||||||
|
; GENERATED by build-installer.sh - do not edit.
|
||||||
|
; The plugins present in bundle\\app\\plugins at build time.
|
||||||
|
#define AvailablePlugins "$PLUGINS"
|
||||||
|
; The alias the subpath SPA was built for, or empty if this bundle has no
|
||||||
|
; subpath build - in which case the wizard must not offer that option.
|
||||||
|
#define SubpathAlias "$SUBPATH_ALIAS_BUILT"
|
||||||
|
EOF
|
||||||
|
echo " $PLUGINS"
|
||||||
|
|
||||||
|
echo "==> Copying installer scripts"
|
||||||
|
mkdir -p "$BUNDLE"
|
||||||
|
cp "$HOME/Downloads/shopdb-install.ps1" "$HOME/Downloads/shopdb-preflight.ps1" "$BUNDLE/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Bundle staged at: $BUNDLE"
|
||||||
|
for d in app wheels python httpplatformhandler mysql; do
|
||||||
|
if [ -d "$BUNDLE/$d" ]; then
|
||||||
|
printf ' %-20s %s\n' "$d" "$(du -sh "$BUNDLE/$d" | cut -f1)"
|
||||||
|
else
|
||||||
|
printf ' %-20s MISSING\n' "$d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo " plugins shipped: $(ls "$BUNDLE/app/plugins" 2>/dev/null | tr '\n' ' ')"
|
||||||
|
echo ""
|
||||||
|
echo "Missing pieces must be added by hand before compiling:"
|
||||||
|
echo " wheels\\ built ON Windows (cp314 win_amd64), ~47 wheels"
|
||||||
|
echo " python\\ python-3.14.6-amd64.exe"
|
||||||
|
echo " httpplatformhandler\\ httpPlatformHandler_amd64.msi"
|
||||||
|
echo " mysql\\ mysql-8.0.46-winx64.msi (bundled-database option only)"
|
||||||
|
echo ""
|
||||||
|
echo "Then compile on Windows: iscc ShopDBFlask.iss"
|
||||||
159
deploy/windows/installer/make-branding.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate Inno Setup wizard artwork from the app's own brand assets.
|
||||||
|
|
||||||
|
Everything here is derived from frontend/public/*.svg so the installer and the
|
||||||
|
running application are visibly the same product. Nothing is redrawn by hand.
|
||||||
|
|
||||||
|
Inno stretches artwork to fit and does not resample well, so render at the exact
|
||||||
|
sizes it asks for and supply the 125%/250% variants for high-DPI displays.
|
||||||
|
|
||||||
|
WizardImageFile 164x314, 192x386, 384x772
|
||||||
|
WizardSmallImageFile 55x55, 64x64, 138x138
|
||||||
|
SetupIconFile .ico with 16/24/32/48/64/128/256
|
||||||
|
|
||||||
|
Usage: python3 make-branding.py [output-dir]
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cairosvg
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ASSETS = Path.home() / "projects/shopdb-flask/frontend/public"
|
||||||
|
OUT = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent)
|
||||||
|
|
||||||
|
# Sampled from the application's own palette so the installer does not look like
|
||||||
|
# a different product wearing the same badge.
|
||||||
|
NAVY = (10, 34, 74) # deep base
|
||||||
|
BLUE = (16, 74, 150) # GE blue
|
||||||
|
CYAN = (0, 158, 224) # accent
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
MUTED = (176, 197, 226)
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg(name, width=None, height=None):
|
||||||
|
png = cairosvg.svg2png(url=str(ASSETS / name), output_width=width, output_height=height)
|
||||||
|
return Image.open(io.BytesIO(png)).convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def recolour(img, colour):
|
||||||
|
"""Replace RGB while keeping the alpha mask. Source marks are dark-on-light;
|
||||||
|
on a dark panel they must be inverted or they disappear."""
|
||||||
|
solid = Image.new("RGBA", img.size, colour + (255,))
|
||||||
|
solid.putalpha(img.getchannel("A"))
|
||||||
|
return solid
|
||||||
|
|
||||||
|
|
||||||
|
def font(size, bold=False):
|
||||||
|
for path in (
|
||||||
|
f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
|
||||||
|
f"/usr/share/fonts/truetype/liberation/LiberationSans{'-Bold' if bold else '-Regular'}.ttf",
|
||||||
|
):
|
||||||
|
if Path(path).exists():
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def vertical_gradient(size, top, bottom):
|
||||||
|
w, h = size
|
||||||
|
img = Image.new("RGB", size)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
for y in range(h):
|
||||||
|
t = y / max(1, h - 1)
|
||||||
|
# Ease the ramp so the middle does not look flat.
|
||||||
|
t = t * t * (3 - 2 * t)
|
||||||
|
draw.line(
|
||||||
|
[(0, y), (w, y)],
|
||||||
|
fill=tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)),
|
||||||
|
)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def banner(w, h):
|
||||||
|
img = vertical_gradient((w, h), BLUE, NAVY)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
k = w / 164.0 # scale factor from the 100% design
|
||||||
|
|
||||||
|
# Faint diagonal wash: stops the flat area under the text reading as empty.
|
||||||
|
glow = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||||
|
gd = ImageDraw.Draw(glow)
|
||||||
|
gd.polygon([(0, int(h * 0.52)), (w, int(h * 0.30)), (w, h), (0, h)],
|
||||||
|
fill=(255, 255, 255, 10))
|
||||||
|
img = Image.alpha_composite(img.convert("RGBA"), glow).convert("RGB")
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
margin = int(22 * k)
|
||||||
|
|
||||||
|
# GE Aerospace wordmark at the top, above the product name - the corporate
|
||||||
|
# mark leads, the product sits under it. (Previously the bare monogram was
|
||||||
|
# here and the wordmark was stranded at the bottom.)
|
||||||
|
mark_w = w - (margin * 2)
|
||||||
|
mark = render_svg("ge-aerospace-logo.svg", mark_w, int(mark_w * 32 / 138))
|
||||||
|
mark = recolour(mark, WHITE)
|
||||||
|
img.paste(mark, (margin, int(34 * k)), mark)
|
||||||
|
|
||||||
|
# Product name, directly beneath it.
|
||||||
|
y = int(34 * k) + mark.height + int(30 * k)
|
||||||
|
draw.text((margin, y), "ShopDB", font=font(int(26 * k), bold=True), fill=WHITE)
|
||||||
|
y += int(31 * k)
|
||||||
|
|
||||||
|
# Hairline rule, then the descriptor. Cheap way to look considered.
|
||||||
|
draw.rectangle([margin, y, margin + int(30 * k), y + max(1, int(2 * k))], fill=CYAN)
|
||||||
|
y += int(14 * k)
|
||||||
|
for line in ("Asset management", "for the shop floor"):
|
||||||
|
draw.text((margin, y), line, font=font(int(10.5 * k)), fill=MUTED)
|
||||||
|
y += int(15 * k)
|
||||||
|
|
||||||
|
# Accent bar flush to the bottom edge.
|
||||||
|
bar = max(2, int(4 * k))
|
||||||
|
draw.rectangle([0, h - bar, w, h], fill=CYAN)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def small(size):
|
||||||
|
"""Header mark on every page after the welcome page. White plate so it sits
|
||||||
|
correctly on the wizard's own header, in light or dark mode."""
|
||||||
|
img = Image.new("RGB", (size, size), WHITE)
|
||||||
|
m = int(size * 0.80)
|
||||||
|
mono = recolour(render_svg("ge-monogram.svg", m, m), BLUE)
|
||||||
|
off = (size - m) // 2
|
||||||
|
img.paste(mono, (off, off), mono)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def icon(path):
|
||||||
|
"""Installer icon. Rounded navy tile with the monogram, so it reads at 16px
|
||||||
|
instead of turning into mush."""
|
||||||
|
base = 256
|
||||||
|
img = Image.new("RGBA", (base, base), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.rounded_rectangle([0, 0, base - 1, base - 1], radius=int(base * 0.22), fill=BLUE + (255,))
|
||||||
|
d.rounded_rectangle([0, 0, base - 1, int(base * 0.5)], radius=int(base * 0.22),
|
||||||
|
fill=(30, 96, 175, 255))
|
||||||
|
d.rounded_rectangle([0, int(base * 0.3), base - 1, base - 1], radius=int(base * 0.22),
|
||||||
|
fill=BLUE + (255,))
|
||||||
|
m = int(base * 0.62)
|
||||||
|
mono = recolour(render_svg("ge-monogram.svg", m, m), WHITE)
|
||||||
|
img.paste(mono, ((base - m) // 2, (base - m) // 2), mono)
|
||||||
|
img.save(path, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64),
|
||||||
|
(128, 128), (256, 256)])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
made = []
|
||||||
|
for w, h, name in [(164, 314, "wizard-image.bmp"),
|
||||||
|
(192, 386, "wizard-image@125.bmp"),
|
||||||
|
(384, 772, "wizard-image@250.bmp")]:
|
||||||
|
banner(w, h).save(OUT / name, "BMP"); made.append((name, f"{w}x{h}"))
|
||||||
|
for s, name in [(55, "wizard-small.bmp"), (64, "wizard-small@125.bmp"),
|
||||||
|
(138, "wizard-small@250.bmp")]:
|
||||||
|
small(s).save(OUT / name, "BMP"); made.append((name, f"{s}x{s}"))
|
||||||
|
icon(OUT / "shopdb.ico"); made.append(("shopdb.ico", "multi-res"))
|
||||||
|
for name, dims in made:
|
||||||
|
print(f" {name:<26} {dims:<10} {(OUT / name).stat().st_size // 1024} KB")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
529
deploy/windows/installer/shopdb-admin.ps1
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Day-to-day control of a ShopDB-Flask installation, on the server itself.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Installed alongside the application so an operator never has to open IIS
|
||||||
|
Manager, hunt for a log, or remember an appcmd incantation.
|
||||||
|
|
||||||
|
Run it with no arguments for a menu, or pass a command directly:
|
||||||
|
|
||||||
|
.\shopdb-admin.ps1 status
|
||||||
|
.\shopdb-admin.ps1 restart
|
||||||
|
.\shopdb-admin.ps1 backup D:\backups
|
||||||
|
|
||||||
|
Everything here is safe to run at any time EXCEPT backup/restore, which are
|
||||||
|
called out explicitly.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Written for stock Windows PowerShell 5.1. No modules to install.
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
|
||||||
|
'check','sessions','plugins','add-plugin','uninstall')]
|
||||||
|
[string] $Command = 'menu',
|
||||||
|
[string] $Path = '',
|
||||||
|
[string] $AppRoot = 'C:\shopdb-flask',
|
||||||
|
[string] $SiteName = 'shopdb-flask',
|
||||||
|
[string] $AppPool = 'shopdbflask',
|
||||||
|
[int] $SitePort = 8090
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe'
|
||||||
|
|
||||||
|
# --- bitness ---------------------------------------------------------------
|
||||||
|
# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell,
|
||||||
|
# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with
|
||||||
|
# Retrieving the COM class factory ... REGDB_E_CLASSNOTREG
|
||||||
|
# which this script caught and reported as "cannot read IIS" - indistinguishable
|
||||||
|
# from IIS being absent.
|
||||||
|
#
|
||||||
|
# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so
|
||||||
|
# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather
|
||||||
|
# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is
|
||||||
|
# the alias that lets a 32-bit process reach the real System32, and it exists
|
||||||
|
# ONLY for 32-bit processes - hence the guard.
|
||||||
|
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
|
||||||
|
$native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
|
||||||
|
if (Test-Path $native) {
|
||||||
|
$relaunch = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
|
||||||
|
'-File', ('"' + $PSCommandPath + '"'), $Command)
|
||||||
|
if ($Path) { $relaunch += @('-Path', ('"' + $Path + '"')) }
|
||||||
|
Start-Process -FilePath $native -ArgumentList $relaunch -Wait -NoNewWindow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- elevation -------------------------------------------------------------
|
||||||
|
# Reading IIS state needs Administrator: the WebAdministration module and the
|
||||||
|
# IIS: drive both fail without it. Unelevated, this tool used to report
|
||||||
|
# "IIS not available / application pool: not installed", which reads as "your
|
||||||
|
# install is broken" when it actually means "I cannot see it". Relaunch elevated
|
||||||
|
# instead of reporting a false state.
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' Administrator rights are needed to read IIS state.' -ForegroundColor Yellow
|
||||||
|
Write-Host ' Re-launching elevated - approve the prompt.' -ForegroundColor Yellow
|
||||||
|
$argList = @('-NoExit', '-NoProfile', '-ExecutionPolicy', 'Bypass',
|
||||||
|
'-File', ('"' + $PSCommandPath + '"'), $Command)
|
||||||
|
if ($Path) { $argList += @('-Path', ('"' + $Path + '"')) }
|
||||||
|
try {
|
||||||
|
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' Elevation was declined.' -ForegroundColor Red
|
||||||
|
Write-Host ' Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
|
||||||
|
Read-Host ' Press Enter to close' | Out-Null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- brand header ----------------------------------------------------------
|
||||||
|
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
|
||||||
|
# linework; rendered as block characters at console resolution it reads as noise,
|
||||||
|
# which looks worse than no mark at all. A clean rule and correct wordmark says
|
||||||
|
# "considered"; mushy art says the opposite.
|
||||||
|
#
|
||||||
|
# Box-drawing characters used here are all in code page 437 (the Windows console
|
||||||
|
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
|
||||||
|
# correctly rather than assuming the ANSI code page.
|
||||||
|
function Show-Banner {
|
||||||
|
$rule = ([string][char]0x2500) * 58
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' GE AEROSPACE' -ForegroundColor Blue
|
||||||
|
Write-Host (' ' + $rule) -ForegroundColor DarkGray
|
||||||
|
Write-Host ' ShopDB-Flask' -ForegroundColor White
|
||||||
|
Write-Host ' Asset management for the shop floor' -ForegroundColor DarkGray
|
||||||
|
Write-Host ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Say { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
|
||||||
|
function Head {
|
||||||
|
param($m)
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host (' ' + $m) -ForegroundColor Cyan
|
||||||
|
Write-Host (' ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-EnvValue {
|
||||||
|
param([string] $Key)
|
||||||
|
$envFile = Join-Path $AppRoot '.env'
|
||||||
|
if (-not (Test-Path $envFile)) { return '' }
|
||||||
|
$line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
|
||||||
|
if ($line) { return $line.Substring($Key.Length + 1) }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Deployment {
|
||||||
|
<#
|
||||||
|
Which way was this installed?
|
||||||
|
|
||||||
|
Method A: its own IIS site on $SitePort.
|
||||||
|
Method B: an IIS Application under an existing site, at /<alias>, reached
|
||||||
|
on that site's port. MOUNT_PATH in .env is what distinguishes
|
||||||
|
them - it is the same value wsgi.py uses to mount the app.
|
||||||
|
|
||||||
|
Without this the console looked for a SITE that method B never creates and
|
||||||
|
reported "web site: not installed" on a perfectly healthy server, then
|
||||||
|
probed the wrong port and said it was not responding.
|
||||||
|
#>
|
||||||
|
$mount = (Get-EnvValue 'MOUNT_PATH').Trim()
|
||||||
|
if (-not $mount) {
|
||||||
|
return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
|
||||||
|
}
|
||||||
|
$alias = $mount.Trim('/')
|
||||||
|
$port = 80
|
||||||
|
$parent = 'Default Web Site'
|
||||||
|
try {
|
||||||
|
Import-Module WebAdministration -ErrorAction Stop
|
||||||
|
foreach ($site in (Get-Website)) {
|
||||||
|
$app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
|
||||||
|
if ($app) {
|
||||||
|
$parent = $site.Name
|
||||||
|
$b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
|
||||||
|
if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
$base = "http://localhost:$port/$alias"
|
||||||
|
if ($port -eq 80) { $base = "http://localhost/$alias" }
|
||||||
|
return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DbParts {
|
||||||
|
# Pull host/port/name/user out of DATABASE_URL without printing the password.
|
||||||
|
$url = Get-EnvValue 'DATABASE_URL'
|
||||||
|
if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
|
||||||
|
# The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
|
||||||
|
# [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
|
||||||
|
# this, every command here fails to authenticate whenever the password
|
||||||
|
# contains a space, @, %, ! or /, and the console reports the database as
|
||||||
|
# unreachable on a server where the application is running perfectly.
|
||||||
|
return @{ User = [uri]::UnescapeDataString($Matches[1])
|
||||||
|
Pass = [uri]::UnescapeDataString($Matches[2])
|
||||||
|
Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-MysqlClient {
|
||||||
|
$candidates = @(
|
||||||
|
'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
|
||||||
|
'C:\mysql56\bin\mysql.exe'
|
||||||
|
) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
|
||||||
|
Select-Object -ExpandProperty FullName)
|
||||||
|
foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
function Show-Status {
|
||||||
|
Head 'Status'
|
||||||
|
|
||||||
|
$deploy = Get-Deployment
|
||||||
|
$siteState = 'not installed'
|
||||||
|
$poolState = 'not installed'
|
||||||
|
try {
|
||||||
|
Import-Module WebAdministration -ErrorAction Stop
|
||||||
|
if ($deploy.Subpath) {
|
||||||
|
# Method B: there is no site of our own - we are an Application.
|
||||||
|
$app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
|
||||||
|
if ($app) {
|
||||||
|
$parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
|
||||||
|
$siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
|
||||||
|
$(if ($parentSite) { $parentSite.State } else { 'unknown' }))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||||
|
if ($site) { $siteState = $site.State }
|
||||||
|
}
|
||||||
|
if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
|
||||||
|
} catch {
|
||||||
|
# Should be unreachable now that the script self-elevates, but if IIS is
|
||||||
|
# genuinely absent say THAT, rather than implying ShopDB is missing.
|
||||||
|
# Name the real cause instead of implying ShopDB is missing.
|
||||||
|
if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
|
||||||
|
$siteState = 'IIS is not installed on this server'
|
||||||
|
} else {
|
||||||
|
$siteState = 'could not read IIS - ' + $_.Exception.Message
|
||||||
|
}
|
||||||
|
$poolState = $siteState
|
||||||
|
}
|
||||||
|
|
||||||
|
$colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
|
||||||
|
Say (" published as : {0}" -f $siteState) $colour
|
||||||
|
Say (" application pool: {0}" -f $poolState) $colour
|
||||||
|
Say (" installed at : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))
|
||||||
|
|
||||||
|
# Does it actually answer? A "Started" pool proves nothing. Under method B
|
||||||
|
# this must include the mount path, or it tests a URL that never existed.
|
||||||
|
$url = $deploy.BaseUrl + '/'
|
||||||
|
try {
|
||||||
|
$r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
|
||||||
|
Say (" responding : yes (HTTP {0})" -f $r.StatusCode) 'Green'
|
||||||
|
} catch {
|
||||||
|
Say ' responding : NO' 'Red'
|
||||||
|
Say (" {0}" -f $_.Exception.Message) 'DarkGray'
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Get-DbParts
|
||||||
|
if ($db) {
|
||||||
|
Say (" database : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
|
||||||
|
$mysql = Find-MysqlClient
|
||||||
|
if ($mysql) {
|
||||||
|
$q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
|
||||||
|
$n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) { Say (" tables : {0}" -f $n) 'Green' }
|
||||||
|
else { Say ' tables : could not connect' 'Red' }
|
||||||
|
}
|
||||||
|
} else { Say ' database : no .env found' 'Yellow' }
|
||||||
|
|
||||||
|
# Is anyone set up yet?
|
||||||
|
try {
|
||||||
|
$na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
|
||||||
|
if ($na.Content -match '"needsadmin"\s*:\s*true') {
|
||||||
|
Say ' first run : NOT SET UP - open the site to create the first administrator' 'Yellow'
|
||||||
|
} else { Say ' first run : complete' 'Green' }
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
Say ''
|
||||||
|
if ($deploy.Subpath) {
|
||||||
|
$shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
|
||||||
|
if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
||||||
|
} else {
|
||||||
|
$shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
||||||
|
}
|
||||||
|
Say (" open with : {0}" -f $shown) 'White'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-App {
|
||||||
|
Head 'Starting'
|
||||||
|
$deploy = Get-Deployment
|
||||||
|
& $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
||||||
|
if (-not $deploy.Subpath) {
|
||||||
|
& $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
||||||
|
} else {
|
||||||
|
# Method B shares the parent site - starting or stopping THAT would take
|
||||||
|
# every other application on this server with it.
|
||||||
|
Say (" (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
|
||||||
|
}
|
||||||
|
Say ' started' 'Green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-App {
|
||||||
|
Head 'Stopping'
|
||||||
|
$deploy = Get-Deployment
|
||||||
|
Say ' ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
|
||||||
|
if (-not $deploy.Subpath) {
|
||||||
|
& $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say " $_" }
|
||||||
|
} else {
|
||||||
|
Say (" (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
|
||||||
|
}
|
||||||
|
& $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
||||||
|
Say ' stopped' 'Yellow'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Restart-App {
|
||||||
|
Head 'Restarting'
|
||||||
|
# Recycle rather than stop/start: it drains existing requests instead of
|
||||||
|
# cutting them off, and it is what a config change actually needs.
|
||||||
|
& $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say " $_" }
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
try {
|
||||||
|
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
|
||||||
|
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
|
||||||
|
} catch { Say ' did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Logs {
|
||||||
|
Head 'Recent logs'
|
||||||
|
$appLog = Join-Path $AppRoot 'logs'
|
||||||
|
if (Test-Path $appLog) {
|
||||||
|
$newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
if ($newest) {
|
||||||
|
Say (" application: {0}" -f $newest.FullName) 'White'
|
||||||
|
Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say " $_" }
|
||||||
|
} else { Say ' no application log yet' }
|
||||||
|
}
|
||||||
|
$inst = 'C:\ProgramData\ShopDB-Flask\logs'
|
||||||
|
if (Test-Path $inst) {
|
||||||
|
$newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
if ($newest) {
|
||||||
|
Say ''
|
||||||
|
Say (" install: {0}" -f $newest.FullName) 'White'
|
||||||
|
Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say " $_" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Open-Site {
|
||||||
|
$deploy = Get-Deployment
|
||||||
|
if ($deploy.Subpath) {
|
||||||
|
if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias }
|
||||||
|
else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
|
||||||
|
} else {
|
||||||
|
$u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
|
||||||
|
}
|
||||||
|
Start-Process $u
|
||||||
|
Say ' opened in your browser' 'Green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Backup-Db {
|
||||||
|
param([string] $Dest)
|
||||||
|
Head 'Database backup'
|
||||||
|
$db = Get-DbParts
|
||||||
|
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
|
||||||
|
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
|
||||||
|
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
|
||||||
|
|
||||||
|
$mysql = Find-MysqlClient
|
||||||
|
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
|
||||||
|
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
|
||||||
|
if (-not (Test-Path $dump)) { Say ' mysqldump not found' 'Red'; return }
|
||||||
|
|
||||||
|
$file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||||
|
Say (" writing {0}" -f $file)
|
||||||
|
# Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
|
||||||
|
# Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
|
||||||
|
# PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
|
||||||
|
# console code page, producing a dump MySQL refuses to load and mangling any
|
||||||
|
# non-ASCII data - discovered only when the backup is finally needed.
|
||||||
|
# Redirect the process's stdout straight to the file instead.
|
||||||
|
$args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
|
||||||
|
'--single-transaction', '--routines', '--triggers', $db.Name)
|
||||||
|
$quoted = $args | ForEach-Object {
|
||||||
|
if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
|
||||||
|
}
|
||||||
|
$err = [System.IO.Path]::GetTempFileName()
|
||||||
|
try {
|
||||||
|
$proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
|
||||||
|
-RedirectStandardOutput $file -RedirectStandardError $err
|
||||||
|
if ($proc.ExitCode -ne 0) {
|
||||||
|
Say (" mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
|
||||||
|
Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say (" " + $_) 'DarkGray' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
# Verify it is complete rather than merely present.
|
||||||
|
$tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
|
||||||
|
if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
|
||||||
|
Say (" done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'
|
||||||
|
Say ' Store this off the server. It contains all of your asset data.' 'Yellow'
|
||||||
|
} else {
|
||||||
|
Say ' backup is empty or truncated - do NOT rely on it' 'Red'
|
||||||
|
Remove-Item $file -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Check {
|
||||||
|
Head 'Health check'
|
||||||
|
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
||||||
|
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return }
|
||||||
|
Push-Location $AppRoot
|
||||||
|
$env:FLASK_APP = 'shopdb'
|
||||||
|
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
|
||||||
|
finally { Pop-Location }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Sessions {
|
||||||
|
Head 'Worker processes'
|
||||||
|
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
|
||||||
|
if (-not $w) { Say ' no IIS worker running (the site starts one on first request)' }
|
||||||
|
else {
|
||||||
|
foreach ($p in $w) {
|
||||||
|
Say (" pid {0} {1:N0} MB started {2}" -f $p.ProcessId,
|
||||||
|
($p.WorkingSetSize/1MB), $p.CreationDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
|
||||||
|
Where-Object { $_.CommandLine -like "*$AppRoot*" }
|
||||||
|
if ($py) { foreach ($p in $py) { Say (" python pid {0} {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Invoke-Flask {
|
||||||
|
param([string[]] $Arguments)
|
||||||
|
$flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
|
||||||
|
if (-not (Test-Path $flask)) { Say ' application not installed' 'Red'; return $null }
|
||||||
|
Push-Location $AppRoot
|
||||||
|
$env:FLASK_APP = 'shopdb'
|
||||||
|
# The app logs plugin startup to STDERR even on success; with EAP=Stop that
|
||||||
|
# becomes a terminating error and a healthy command looks like a failure.
|
||||||
|
$prev = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try { & $flask @Arguments 2>&1 }
|
||||||
|
finally { $ErrorActionPreference = $prev; Pop-Location }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Plugins {
|
||||||
|
Head 'Plugins'
|
||||||
|
Invoke-Flask @('plugin','list') | ForEach-Object { Say " $_" }
|
||||||
|
|
||||||
|
# What is on disk but NOT installed can still be added here. What is absent
|
||||||
|
# from disk cannot - see the note below.
|
||||||
|
$dir = Join-Path $AppRoot 'plugins'
|
||||||
|
if (Test-Path $dir) {
|
||||||
|
$onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
|
||||||
|
Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
|
||||||
|
Select-Object -ExpandProperty Name) -join ', '
|
||||||
|
Say ''
|
||||||
|
Say (" shipped in this build : {0}" -f $onDisk) 'White'
|
||||||
|
}
|
||||||
|
Say ''
|
||||||
|
Say ' Add one that is shipped : shopdb-admin.ps1 add-plugin -Path <name>' 'White'
|
||||||
|
Say ''
|
||||||
|
Say ' A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
|
||||||
|
Say ' made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
|
||||||
|
Say ' Adding one means a new installer built from an updated site profile.' 'DarkGray'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-Plugin {
|
||||||
|
param([string] $Name)
|
||||||
|
Head 'Add a plugin'
|
||||||
|
if (-not $Name) { Say ' usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }
|
||||||
|
|
||||||
|
$dir = Join-Path $AppRoot ('plugins\' + $Name)
|
||||||
|
if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
|
||||||
|
Say (" '{0}' is not present on this server." -f $Name) 'Red'
|
||||||
|
Say ''
|
||||||
|
Say ' This build ships only the plugins your site chose. Adding a new one' 'Yellow'
|
||||||
|
Say ' requires a new installer built from an updated site profile - the' 'Yellow'
|
||||||
|
Say ' code is not here to install.' 'Yellow'
|
||||||
|
Say ''
|
||||||
|
Say ' Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Say (" installing {0}..." -f $Name)
|
||||||
|
Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say " $_" }
|
||||||
|
Say ' applying its database migrations...'
|
||||||
|
Invoke-Flask @('plugin','upgrade-all') | ForEach-Object { Say " $_" }
|
||||||
|
Say ' restarting so its routes register...'
|
||||||
|
Restart-App
|
||||||
|
Say (" {0} added" -f $Name) 'Green'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Uninstall {
|
||||||
|
Head 'Uninstall'
|
||||||
|
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
|
||||||
|
Say ''
|
||||||
|
Say ' That removes the web site, application pool, firewall rule and files.'
|
||||||
|
Say ' It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow'
|
||||||
|
Say ' Take a backup first: shopdb-admin.ps1 backup' 'Yellow'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Menu {
|
||||||
|
$first = $true
|
||||||
|
while ($true) {
|
||||||
|
if ($first) { Show-Banner; $first = $false }
|
||||||
|
Show-Status
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host ' 1 Restart the application 6 Back up the database' -ForegroundColor White
|
||||||
|
Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White
|
||||||
|
Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White
|
||||||
|
Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White
|
||||||
|
Write-Host ' 5 Health check 0 Exit' -ForegroundColor White
|
||||||
|
Write-Host ''
|
||||||
|
$c = Read-Host ' Choose'
|
||||||
|
switch ($c) {
|
||||||
|
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
|
||||||
|
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
|
||||||
|
'7' { Show-Sessions } '8' { Open-Site }
|
||||||
|
'9' { Show-Plugins
|
||||||
|
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
|
||||||
|
if ($add) { Add-Plugin $add } }
|
||||||
|
'0' { return }
|
||||||
|
default { Say ' not a choice' 'Yellow' }
|
||||||
|
}
|
||||||
|
Write-Host ''
|
||||||
|
Read-Host ' Press Enter to continue' | Out-Null
|
||||||
|
Clear-Host
|
||||||
|
Show-Banner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Command) {
|
||||||
|
'status' { Show-Banner; Show-Status }
|
||||||
|
'start' { Start-App }
|
||||||
|
'stop' { Stop-App }
|
||||||
|
'restart' { Restart-App }
|
||||||
|
'logs' { Show-Logs }
|
||||||
|
'open' { Open-Site }
|
||||||
|
'backup' { Backup-Db $Path }
|
||||||
|
'check' { Invoke-Check }
|
||||||
|
'sessions' { Show-Sessions }
|
||||||
|
'plugins' { Show-Plugins }
|
||||||
|
'add-plugin'{ Add-Plugin $Path }
|
||||||
|
'uninstall' { Show-Uninstall }
|
||||||
|
default { Show-Menu }
|
||||||
|
}
|
||||||
1636
deploy/windows/installer/shopdb-install.ps1
Normal file
517
deploy/windows/installer/shopdb-preflight.ps1
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
ShopDB-Flask installer - Stage 1: read-only preflight.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Discovers everything the installer needs to know about this box and reports
|
||||||
|
it. Makes NO changes: no installs, no config edits, no service restarts.
|
||||||
|
Safe to run on a production server.
|
||||||
|
|
||||||
|
Written for stock Windows PowerShell 5.1 (Windows Server ships it). No
|
||||||
|
pwsh-only syntax, no external modules, no network access.
|
||||||
|
|
||||||
|
.PARAMETER SitePort
|
||||||
|
The port the ShopDB site will listen on. Default 8090 (the runbook's example;
|
||||||
|
the classic ASP site keeps 8080).
|
||||||
|
|
||||||
|
.PARAMETER AppRoot
|
||||||
|
Intended install directory. Default C:\shopdb-flask.
|
||||||
|
|
||||||
|
.PARAMETER Json
|
||||||
|
Emit machine-readable JSON instead of the human report. Later installer
|
||||||
|
stages consume this.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Exit 0 = no blocking problems. Exit 1 = at least one FAIL.
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[int] $SitePort = 8090,
|
||||||
|
[string] $AppRoot = 'C:\shopdb-flask',
|
||||||
|
# Needed so the port check can tell OUR site apart from a stranger's.
|
||||||
|
[string] $SiteName = 'shopdb-flask',
|
||||||
|
[switch] $Json,
|
||||||
|
# Machine-readable output for a GUI caller: one record per line,
|
||||||
|
# STATUS|AREA|CHECK|DETAIL|FIX
|
||||||
|
# The console rendering below aligns columns with padding spaces, which only
|
||||||
|
# works in a fixed-width font at console width. A GUI must do its own layout,
|
||||||
|
# so give it DATA and let it decide - do not make it parse a formatted table.
|
||||||
|
# (-Json exists too, but Inno's Pascal Script has no JSON parser.)
|
||||||
|
[switch] $Delimited
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version 2.0
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# --- result collection -------------------------------------------------------
|
||||||
|
# Every check appends one record. Status is PASS / WARN / FAIL / INFO.
|
||||||
|
$script:Results = New-Object System.Collections.ArrayList
|
||||||
|
$script:IisPresent = $false
|
||||||
|
|
||||||
|
function Add-Result {
|
||||||
|
param(
|
||||||
|
[string] $Area,
|
||||||
|
[string] $Check,
|
||||||
|
[ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status,
|
||||||
|
[string] $Detail,
|
||||||
|
[string] $Fix = ''
|
||||||
|
)
|
||||||
|
$null = $script:Results.Add([PSCustomObject]@{
|
||||||
|
Area = $Area
|
||||||
|
Check = $Check
|
||||||
|
Status = $Status
|
||||||
|
Detail = $Detail
|
||||||
|
Fix = $Fix
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box
|
||||||
|
# an unexpected exception is itself a finding, not a crash.
|
||||||
|
function Invoke-Check {
|
||||||
|
param([string] $Area, [string] $Check, [scriptblock] $Body)
|
||||||
|
try { & $Body }
|
||||||
|
catch {
|
||||||
|
Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" `
|
||||||
|
'Report this output; the installer needs to handle this box shape.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. Operator context
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
Invoke-Check 'System' 'Elevation' {
|
||||||
|
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
|
||||||
|
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
|
if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' }
|
||||||
|
else {
|
||||||
|
Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' `
|
||||||
|
'Re-run PowerShell as Administrator. IIS and service changes require it.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'System' 'Windows version' {
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
$name = $os.Caption
|
||||||
|
$ver = $os.Version
|
||||||
|
# ProductType: 1 = workstation, 2 = domain controller, 3 = server
|
||||||
|
$isServer = ($os.ProductType -ne 1)
|
||||||
|
$detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})"
|
||||||
|
|
||||||
|
$supported = $false
|
||||||
|
if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true } # 2019+
|
||||||
|
if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+
|
||||||
|
|
||||||
|
if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail }
|
||||||
|
else {
|
||||||
|
Add-Result 'System' 'Windows version' 'FAIL' $detail `
|
||||||
|
'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Client SKUs must be Pro/Enterprise/Education for IIS.
|
||||||
|
if (-not $isServer -and $name -match 'Home') {
|
||||||
|
Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' `
|
||||||
|
'IIS is not available on Home editions. Pro or higher is required.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'System' 'Architecture' {
|
||||||
|
if ([Environment]::Is64BitOperatingSystem) {
|
||||||
|
Add-Result 'System' 'Architecture' 'PASS' '64-bit'
|
||||||
|
} else {
|
||||||
|
Add-Result 'System' 'Architecture' 'FAIL' '32-bit' `
|
||||||
|
'The bundled Python and wheels are 64-bit (win_amd64) only.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'System' 'PowerShell version' {
|
||||||
|
$v = $PSVersionTable.PSVersion
|
||||||
|
Add-Result 'System' 'PowerShell version' 'INFO' "$v"
|
||||||
|
if ($v.Major -lt 5) {
|
||||||
|
Add-Result 'System' 'PowerShell version' 'FAIL' "$v" `
|
||||||
|
'PowerShell 5.1 or later is required.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. Disk and ports
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
Invoke-Check 'Disk' 'Free space' {
|
||||||
|
$drive = (Split-Path -Qualifier $AppRoot)
|
||||||
|
$d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'"
|
||||||
|
if ($null -eq $d) {
|
||||||
|
Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" `
|
||||||
|
"Choose an -AppRoot on an existing volume."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$freeGB = [math]::Round($d.FreeSpace / 1GB, 1)
|
||||||
|
$detail = "$freeGB GB free on $drive"
|
||||||
|
if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail }
|
||||||
|
else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'Disk' 'AppRoot' {
|
||||||
|
if (Test-Path $AppRoot) {
|
||||||
|
$existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue)
|
||||||
|
if ($existing.Count -gt 0) {
|
||||||
|
$hasEnv = Test-Path (Join-Path $AppRoot '.env')
|
||||||
|
if ($hasEnv) {
|
||||||
|
# An existing install is the NORMAL state for an upgrade. Reporting
|
||||||
|
# it as a warning makes a routine update look like a problem.
|
||||||
|
$ver = ''
|
||||||
|
$vf = Join-Path $AppRoot '.installed-version'
|
||||||
|
if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() }
|
||||||
|
Add-Result 'Disk' 'AppRoot' 'INFO' `
|
||||||
|
"existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept"
|
||||||
|
} else {
|
||||||
|
Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" `
|
||||||
|
'Confirm this directory is safe to install into.'
|
||||||
|
}
|
||||||
|
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" }
|
||||||
|
} else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PortFree {
|
||||||
|
param([int] $Port)
|
||||||
|
# Get-NetTCPConnection is the reliable listener check on Server 2012R2+.
|
||||||
|
try {
|
||||||
|
$listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
|
||||||
|
return ($listening.Count -eq 0)
|
||||||
|
} catch {
|
||||||
|
# Fall back to a bind attempt if the cmdlet is unavailable.
|
||||||
|
try {
|
||||||
|
$l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port)
|
||||||
|
$l.Start(); $l.Stop(); return $true
|
||||||
|
} catch { return $false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'Network' 'Site port' {
|
||||||
|
if (Test-PortFree $SitePort) {
|
||||||
|
Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free"
|
||||||
|
} else {
|
||||||
|
$owner = ''
|
||||||
|
try {
|
||||||
|
$c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" }
|
||||||
|
} catch { }
|
||||||
|
# Is the listener OUR OWN site? On a reinstall or upgrade the port is held
|
||||||
|
# by the very application being upgraded, and blocking on that makes the
|
||||||
|
# installer refuse to update anything it previously installed.
|
||||||
|
$ours = $false
|
||||||
|
try {
|
||||||
|
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||||
|
$site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||||
|
if ($site) {
|
||||||
|
foreach ($b in $site.bindings.Collection) {
|
||||||
|
# ${} is required: "$SitePort:" parses as a DRIVE-qualified variable.
|
||||||
|
if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
if ($ours) {
|
||||||
|
Add-Result 'Network' 'Site port' 'INFO' `
|
||||||
|
"TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place"
|
||||||
|
} else {
|
||||||
|
Add-Result 'Network' 'Site port' 'FAIL' "TCP $SitePort is in use$owner" `
|
||||||
|
"Choose a different port with -SitePort, or stop the listener."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. IIS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
Invoke-Check 'IIS' 'Installed' {
|
||||||
|
$svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue
|
||||||
|
$script:IisPresent = ($null -ne $svc)
|
||||||
|
if ($null -eq $svc) {
|
||||||
|
Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' `
|
||||||
|
'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)"
|
||||||
|
if ($svc.Status -ne 'Running') {
|
||||||
|
Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'IIS' 'WebAdministration module' {
|
||||||
|
$m = Get-Module -ListAvailable -Name WebAdministration
|
||||||
|
if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' }
|
||||||
|
else {
|
||||||
|
Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' `
|
||||||
|
'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'IIS' 'HttpPlatformHandler' {
|
||||||
|
# The handler registers itself as a global module. Check the module list.
|
||||||
|
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||||
|
if (-not (Test-Path $appcmd)) {
|
||||||
|
Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' `
|
||||||
|
'Re-run this preflight after installing IIS.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$modules = & $appcmd list module 2>$null
|
||||||
|
if ($modules -match 'httpPlatformHandler') {
|
||||||
|
Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed'
|
||||||
|
} else {
|
||||||
|
Add-Result 'IIS' 'HttpPlatformHandler' 'FAIL' 'not installed' `
|
||||||
|
'Install httpPlatformHandler_amd64.msi from the bundle. IIS cannot launch waitress without it.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'IIS' 'Locked config sections' {
|
||||||
|
# The authoritative source is applicationHost.config. `appcmd list config
|
||||||
|
# /section:X` prints the section CONTENTS, not its lock state, so grepping
|
||||||
|
# that output silently reports every section as unlocked.
|
||||||
|
$cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config'
|
||||||
|
if (-not (Test-Path $cfg)) {
|
||||||
|
Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foreach ($name in @('handlers','httpPlatform')) {
|
||||||
|
$line = Select-String -Path $cfg -Pattern ('<section name="' + $name + '"') |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($null -eq $line) {
|
||||||
|
# httpPlatform is registered by the HttpPlatformHandler MSI. Before
|
||||||
|
# that, `appcmd unlock config /section:system.webServer/httpPlatform`
|
||||||
|
# fails with "Unknown config section".
|
||||||
|
Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
|
||||||
|
'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
|
||||||
|
} elseif ($line.Line -match 'overrideModeDefault="Deny"') {
|
||||||
|
Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
|
||||||
|
"Installer must run: appcmd unlock config /section:system.webServer/$name (else IIS 500.19)"
|
||||||
|
} else {
|
||||||
|
Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'IIS' 'URL Rewrite module' {
|
||||||
|
$appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
|
||||||
|
if (-not (Test-Path $appcmd)) {
|
||||||
|
Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$modules = & $appcmd list module 2>$null
|
||||||
|
if ($modules -match 'RewriteModule') {
|
||||||
|
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (X-Forwarded-For rule can be enabled)'
|
||||||
|
} else {
|
||||||
|
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
||||||
|
'Optional. Leave the <rewrite> block in web.config commented out, or IIS returns 500.19.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'IIS' 'Existing sites' {
|
||||||
|
if (-not $script:IisPresent) {
|
||||||
|
Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Import-Module WebAdministration -ErrorAction Stop
|
||||||
|
$sites = @(Get-Website)
|
||||||
|
if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
|
||||||
|
$desc = ($sites | ForEach-Object {
|
||||||
|
$b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
|
||||||
|
"$($_.Name) [$($_.State)] $b"
|
||||||
|
}) -join '; '
|
||||||
|
Add-Result 'IIS' 'Existing sites' 'INFO' $desc
|
||||||
|
# Adoption sites typically run the classic ASP shopdb here already.
|
||||||
|
if ($desc -match '8080') {
|
||||||
|
Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
|
||||||
|
'Install ShopDB as a separate site on its own port; do not disturb this one.'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. MySQL (detect BEFORE offering bundled vs existing)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
Invoke-Check 'MySQL' 'Service' {
|
||||||
|
$svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
|
||||||
|
if ($svcs.Count -eq 0) {
|
||||||
|
Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
|
||||||
|
'Bundled MySQL 8.0 is the appropriate choice on this box.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foreach ($s in $svcs) {
|
||||||
|
Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" `
|
||||||
|
'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'MySQL' 'Port 3306' {
|
||||||
|
if (Test-PortFree 3306) {
|
||||||
|
Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306'
|
||||||
|
} else {
|
||||||
|
Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' `
|
||||||
|
'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'MySQL' 'Version and config' {
|
||||||
|
# Find mysqld.exe via the service binary path; read the version and locate my.ini.
|
||||||
|
$svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1
|
||||||
|
if ($null -eq $svc) { return }
|
||||||
|
|
||||||
|
$path = $svc.PathName
|
||||||
|
$exe = ''
|
||||||
|
if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] }
|
||||||
|
elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] }
|
||||||
|
|
||||||
|
$ver = ''
|
||||||
|
if ($exe -and (Test-Path $exe)) {
|
||||||
|
try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { }
|
||||||
|
}
|
||||||
|
if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver }
|
||||||
|
|
||||||
|
# my.ini path is passed as --defaults-file in the service command line.
|
||||||
|
$ini = ''
|
||||||
|
if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] }
|
||||||
|
if ($ini -and (Test-Path $ini)) {
|
||||||
|
Add-Result 'MySQL' 'Config file' 'INFO' $ini
|
||||||
|
|
||||||
|
# MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071.
|
||||||
|
$is56 = ($ver -match '\b5\.6\.')
|
||||||
|
if ($is56) {
|
||||||
|
$content = Get-Content $ini -Raw
|
||||||
|
$need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix')
|
||||||
|
$missing = @()
|
||||||
|
foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } }
|
||||||
|
if ($missing.Count -eq 0) {
|
||||||
|
# Present in the FILE is not the same as ACTIVE. MySQL must be
|
||||||
|
# restarted for them to take effect, and the app's own
|
||||||
|
# `flask db-utils preflight` queries the live server - trust that.
|
||||||
|
Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' `
|
||||||
|
'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.'
|
||||||
|
} else {
|
||||||
|
Add-Result 'MySQL' '5.6 index flags' 'FAIL' ("missing: " + ($missing -join ', ')) `
|
||||||
|
"Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. Python (detect, but the installer uses its OWN bundled interpreter)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
Invoke-Check 'Python' 'On PATH' {
|
||||||
|
$cmd = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $cmd) {
|
||||||
|
Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' `
|
||||||
|
'Expected. The installer supplies its own interpreter.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$v = ''
|
||||||
|
try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { }
|
||||||
|
$detail = "$v at $($cmd.Source)"
|
||||||
|
|
||||||
|
# A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool
|
||||||
|
# identity. That produces a 500 with an empty HttpPlatform log.
|
||||||
|
if ($cmd.Source -like "$env:LOCALAPPDATA*") {
|
||||||
|
Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" `
|
||||||
|
'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.'
|
||||||
|
} elseif ($cmd.Source -like '*WindowsApps*') {
|
||||||
|
Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" `
|
||||||
|
'Store Python misbehaves under service identities. The installer will not use it.'
|
||||||
|
} else {
|
||||||
|
Add-Result 'Python' 'On PATH' 'INFO' $detail `
|
||||||
|
'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Check 'Python' 'Registered installs' {
|
||||||
|
$found = @()
|
||||||
|
foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) {
|
||||||
|
if (Test-Path $hive) {
|
||||||
|
foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) {
|
||||||
|
$ip = Join-Path $k.PSPath 'InstallPath'
|
||||||
|
if (Test-Path $ip) {
|
||||||
|
$loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)'
|
||||||
|
$scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' }
|
||||||
|
$found += "$($k.PSChildName) ($scope) $loc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' }
|
||||||
|
else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Report
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' })
|
||||||
|
$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' })
|
||||||
|
$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' })
|
||||||
|
|
||||||
|
if ($Delimited) {
|
||||||
|
# Data only. No padding, no colour, no alignment - the caller lays it out.
|
||||||
|
# Pipes are stripped from field values so the record can be split naively.
|
||||||
|
foreach ($r in $script:Results) {
|
||||||
|
$fix = ''
|
||||||
|
if ($r.Fix) { $fix = $r.Fix }
|
||||||
|
$fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object {
|
||||||
|
([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' '
|
||||||
|
}
|
||||||
|
Write-Output ($fields -join '|')
|
||||||
|
}
|
||||||
|
} elseif ($Json) {
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Timestamp = (Get-Date).ToString('s')
|
||||||
|
Computer = $env:COMPUTERNAME
|
||||||
|
SitePort = $SitePort
|
||||||
|
AppRoot = $AppRoot
|
||||||
|
Failures = $fails.Count
|
||||||
|
Warnings = $warns.Count
|
||||||
|
Skipped = $skips.Count
|
||||||
|
Results = $script:Results
|
||||||
|
} | ConvertTo-Json -Depth 5
|
||||||
|
} else {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan
|
||||||
|
Write-Host (" host {0} site port {1} approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot)
|
||||||
|
Write-Host ''
|
||||||
|
$area = ''
|
||||||
|
foreach ($r in $script:Results) {
|
||||||
|
if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White }
|
||||||
|
$colour = 'Gray'
|
||||||
|
if ($r.Status -eq 'PASS') { $colour = 'Green' }
|
||||||
|
if ($r.Status -eq 'WARN') { $colour = 'Yellow' }
|
||||||
|
if ($r.Status -eq 'FAIL') { $colour = 'Red' }
|
||||||
|
if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' }
|
||||||
|
Write-Host (" {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour
|
||||||
|
if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') {
|
||||||
|
Write-Host (" -> {0}" -f $r.Fix) -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ''
|
||||||
|
if ($fails.Count -eq 0) {
|
||||||
|
Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
if ($skips.Count -gt 0) {
|
||||||
|
Write-Host " Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
Write-Host ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fails.Count -gt 0) { exit 1 } else { exit 0 }
|
||||||
BIN
deploy/windows/installer/shopdb.ico
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
deploy/windows/installer/swatch-bad.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-ok.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/swatch-warn.bmp
Normal file
|
After Width: | Height: | Size: 822 B |
BIN
deploy/windows/installer/wizard-image.bmp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
deploy/windows/installer/wizard-image@125.bmp
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
deploy/windows/installer/wizard-image@250.bmp
Normal file
|
After Width: | Height: | Size: 869 KiB |
BIN
deploy/windows/installer/wizard-small.bmp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
deploy/windows/installer/wizard-small@125.bmp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
deploy/windows/installer/wizard-small@250.bmp
Normal file
|
After Width: | Height: | Size: 56 KiB |