From 0fa5f1e910f09af6a84d60427b39dc80f7f76e56 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 3 Aug 2026 01:47:34 -0400 Subject: [PATCH] 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). --- deploy/windows/installer/.gitignore | 5 + deploy/windows/installer/README.md | 83 + deploy/windows/installer/ShopDBFlask.iss | 1002 ++++++++++ deploy/windows/installer/build-installer.sh | 84 + deploy/windows/installer/make-branding.py | 159 ++ deploy/windows/installer/shopdb-admin.ps1 | 529 ++++++ deploy/windows/installer/shopdb-install.ps1 | 1636 +++++++++++++++++ deploy/windows/installer/shopdb-preflight.ps1 | 517 ++++++ deploy/windows/installer/shopdb.ico | Bin 0 -> 52695 bytes deploy/windows/installer/swatch-bad.bmp | Bin 0 -> 822 bytes deploy/windows/installer/swatch-ok.bmp | Bin 0 -> 822 bytes deploy/windows/installer/swatch-warn.bmp | Bin 0 -> 822 bytes deploy/windows/installer/wizard-image.bmp | Bin 0 -> 154542 bytes deploy/windows/installer/wizard-image@125.bmp | Bin 0 -> 222390 bytes deploy/windows/installer/wizard-image@250.bmp | Bin 0 -> 889398 bytes deploy/windows/installer/wizard-small.bmp | Bin 0 -> 9294 bytes deploy/windows/installer/wizard-small@125.bmp | Bin 0 -> 12342 bytes deploy/windows/installer/wizard-small@250.bmp | Bin 0 -> 57462 bytes 18 files changed, 4015 insertions(+) create mode 100644 deploy/windows/installer/.gitignore create mode 100644 deploy/windows/installer/README.md create mode 100644 deploy/windows/installer/ShopDBFlask.iss create mode 100755 deploy/windows/installer/build-installer.sh create mode 100644 deploy/windows/installer/make-branding.py create mode 100644 deploy/windows/installer/shopdb-admin.ps1 create mode 100644 deploy/windows/installer/shopdb-install.ps1 create mode 100644 deploy/windows/installer/shopdb-preflight.ps1 create mode 100644 deploy/windows/installer/shopdb.ico create mode 100644 deploy/windows/installer/swatch-bad.bmp create mode 100644 deploy/windows/installer/swatch-ok.bmp create mode 100644 deploy/windows/installer/swatch-warn.bmp create mode 100644 deploy/windows/installer/wizard-image.bmp create mode 100644 deploy/windows/installer/wizard-image@125.bmp create mode 100644 deploy/windows/installer/wizard-image@250.bmp create mode 100644 deploy/windows/installer/wizard-small.bmp create mode 100644 deploy/windows/installer/wizard-small@125.bmp create mode 100644 deploy/windows/installer/wizard-small@250.bmp diff --git a/deploy/windows/installer/.gitignore b/deploy/windows/installer/.gitignore new file mode 100644 index 0000000..1b43d65 --- /dev/null +++ b/deploy/windows/installer/.gitignore @@ -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 diff --git a/deploy/windows/installer/README.md b/deploy/windows/installer/README.md new file mode 100644 index 0000000..7debc62 --- /dev/null +++ b/deploy/windows/installer/README.md @@ -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:///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). diff --git a/deploy/windows/installer/ShopDBFlask.iss b/deploy/windows/installer/ShopDBFlask.iss new file mode 100644 index 0000000..1b5490a --- /dev/null +++ b/deploy/windows/installer/ShopDBFlask.iss @@ -0,0 +1,1002 @@ +; ShopDB-Flask air-gapped Windows installer +; Wraps the tested PowerShell stages in an operator-facing wizard. +; Version 1.0 +; +; DESIGN NOTE - why this is a thin wrapper and not a reimplementation: +; every install action lives in shopdb-install.ps1, which is verified end to end +; on Windows Server 2025 against both a bundled MySQL 8.0 and an existing MySQL +; 5.6. Reimplementing any of it in Pascal Script would create a second code path +; that nobody tests. This file only collects operator input, runs the stages in +; order, and reports which one failed. + +#define AppName "ShopDB-Flask" +; Pre-release. This has not shipped, so it is 0.x by definition. It becomes +; 1.0.0 when a real site installs from it successfully - not before. +; Do NOT bump this to work around a locked output file: the BUILD STAMP below +; makes every compile a unique filename, which is what that problem needed. +#define AppVersion "0.9.0" +#define AppPublisher "GE Aerospace" +#define BundleDir "bundle" +; Single source for the install directory - used by DefaultDirName and by the +; wizard's pre-fill fallback, so the two cannot disagree. +#define DefaultDir "C:\shopdb-flask" +; Generated by build-installer.sh from what is actually in the bundle, so the +; plugin page can never offer something the payload does not contain. +#include "plugins.iss" + +[Setup] +AppId={{7C4E1A93-2F86-4D5B-9E31-8A0C6B5D4F27} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +DefaultDirName={#DefaultDir} +DisableDirPage=no +CreateAppDir=yes +PrivilegesRequired=admin +OutputDir=.\Output +; Version identifies the RELEASE; the stamp identifies the BUILD. Separating them +; means recompiling never collides with an exe you happen to have open, and the +; version only moves when something meaningful changed. +#define BuildStamp GetDateTimeString('yyyymmdd-hhnn', '', '') +OutputBaseFilename=ShopDBFlask_Installer_{#AppVersion}_{#BuildStamp} +; The payload is ~200MB of wheels, Python and an MSI, all already compressed. +; lzma2/max on top of that costs minutes and saves almost nothing. +Compression=lzma2/normal +SolidCompression=yes + +; --- Appearance ------------------------------------------------------------- +; 'modern' larger layout, Segoe UI, no 1990s bevels +; 'windows11' built-in custom style (6.6.0+) - rounded controls, current palette +; 'dynamic' follows the machine's light/dark setting automatically +; 'hidebevels' removes the remaining sunken separator lines +; Custom styles switch themselves off under a high-contrast theme or /NOSTYLE, +; so accessibility is not broken by any of this. +WizardStyle=modern windows11 dynamic hidebevels +; 15% larger than default without scaling the font up - the preflight page is +; dense and benefits from the extra room. +WizardSizePercent=115 +DisableWelcomePage=no +; Name the Start Menu folder and skip the "choose a folder" page - a server tool +; does not need the operator to invent a location for its shortcuts. +DefaultGroupName=ShopDB-Flask +DisableProgramGroupPage=yes +; Always write a setup log. Inno writes one only when asked, and every streamed +; line from the install stages goes into it, so this is the difference between a +; diagnosable failure and "exit 1" with nothing to look at. +SetupLogging=yes + +; Artwork is generated by make-branding.py from the app's OWN brand assets, so +; the installer and the running site look like the same product. The @125/@250 +; variants let Inno pick a crisp image on high-DPI displays rather than upscaling. +WizardImageFile=wizard-image.bmp,wizard-image@125.bmp,wizard-image@250.bmp +WizardSmallImageFile=wizard-small.bmp,wizard-small@125.bmp,wizard-small@250.bmp + +; Installer executable icon, and the icon shown in Apps & Features. Without this +; Windows falls back to a generic setup icon and, for uninstall, to whatever exe +; is named - which was python.exe, i.e. a Python logo for a ShopDB entry. +SetupIconFile=shopdb.ico +UninstallDisplayIcon={app}\shopdb.ico +UninstallDisplayName=ShopDB-Flask + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Messages] +WelcomeLabel1=Set up ShopDB-Flask on this server +; Short, and says the two things an operator actually wants up front: it will not +; need the internet, and it will tell them before it changes anything. +WelcomeLabel2=Everything needed is included in this installer. No internet connection is used at any point.%n%nYou will be asked four short questions, and nothing on this server is changed until you reach the final confirmation.%n%nThe next page checks that this server is ready. It only reads - it changes nothing. +ClickNext=Click Next to check this server. +FinishedHeadingLabel=ShopDB-Flask is ready +SetupAppTitle=ShopDB-Flask Setup +SetupWindowTitle=ShopDB-Flask Setup + +[Files] +; The whole verified bundle, staged next to this script by build-installer.sh. +; Extracted during the install step, so it is available from ssPostInstall onward +; but NOT during the wizard pages. +Source: "{#BundleDir}\*"; DestDir: "{tmp}\shopdb-bundle"; \ + Flags: ignoreversion recursesubdirs createallsubdirs deleteafterinstall +; The preflight has to run on a WIZARD PAGE, which happens long before the [Files] +; section is processed. 'dontcopy' plus ExtractTemporaryFile is the only way to get +; a file on disk that early. Listed twice on purpose - once for each phase. +Source: "{#BundleDir}\shopdb-preflight.ps1"; Flags: dontcopy +; The operator's day-to-day tool. Installed into the app directory and given +; Start Menu shortcuts, so nobody has to open IIS Manager to restart the site. +; [UninstallRun] executes this from {app}. It was only ever staged into {tmp} +; with deleteafterinstall, so uninstall ran powershell against a path that no +; longer existed, exited non-zero unnoticed (runhidden, no result check), and +; Windows reported success while the site, app pool, firewall rule and .env with +; its plaintext password were all left in place. +Source: "{#BundleDir}\shopdb-install.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#BundleDir}\shopdb-preflight.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "shopdb-admin.ps1"; DestDir: "{app}"; Flags: ignoreversion +; Kept on disk so the Start Menu shortcuts and the uninstall entry have an icon. +Source: "shopdb.ico"; DestDir: "{app}"; Flags: ignoreversion +; Status colours. These are BITMAPS, not control colours: a custom VCL style +; (WizardStyle=... windows11) repaints styled controls and ignores both +; Font.Color and TPanel.Color, so a bitmap is the only thing guaranteed to show +; the colour the operator is meant to see. +Source: "swatch-ok.bmp"; Flags: dontcopy +Source: "swatch-warn.bmp"; Flags: dontcopy +Source: "swatch-bad.bmp"; Flags: dontcopy + +[Icons] +; A folder rather than loose icons: this is a server tool, not a desktop app. +Name: "{group}\ShopDB-Flask Console"; Filename: "powershell.exe"; \ + Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"""; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Status, restart, logs and backups" +Name: "{group}\Open ShopDB-Flask"; Filename: "http://localhost:8090/login"; \ + IconFilename: "{app}\shopdb.ico"; Comment: "Open the application in a browser" +Name: "{group}\Restart ShopDB-Flask"; Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" restart"; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Recycle the application pool" +Name: "{group}\Back up the database"; Filename: "powershell.exe"; \ + Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" backup"; \ + WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \ + Comment: "Write a .sql dump to ProgramData" + +[Run] +; Offer the console at the end, unticked - finishing the wizard should not +; surprise anyone with a shell. +; Setup is a 32-bit process, so a bare "powershell.exe" here resolves through +; WOW64 to the 32-bit build, whose Get-Website fails with REGDB_E_CLASSNOTREG +; and makes the console report "cannot read IIS". Sysnative reaches the real +; System32 from a 32-bit process. The script also self-corrects, but launching +; it correctly avoids a visible relaunch. +Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \ + Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"""; \ + Description: "Open the ShopDB-Flask console"; \ + Flags: postinstall skipifsilent unchecked + +[Code] +var + DbChoicePage: TInputOptionWizardPage; + DbDetailsPage: TInputQueryWizardPage; + SitePage: TInputQueryWizardPage; + PreflightPage: TWizardPage; + BannerPanel: TPanel; + BannerBar: TBitmapImage; + BannerText: TNewStaticText; + BannerSub: TNewStaticText; + DetailPanel: TPanel; + FooterText: TNewStaticText; + PreflightDone: Boolean; + // Shown while the preflight runs. Without it, clicking Next appears to hang: + // the check takes a few seconds, runs hidden, and gives no sign of life. + CheckingPage: TOutputProgressWizardPage; + PluginPage: TInputOptionWizardPage; + PluginNames: TArrayOfString; + PluginPageReady: Boolean; + DbPageReady: Boolean; + DeployPage: TInputOptionWizardPage; + DeployPageReady: Boolean; + // Set from the streamed stage output so a failure can name its cause. Without + // this the wizard could only report "exit 1", which points at nothing. + FailCause: String; + FailDetail: String; + UseBundledDb: Boolean; + LogPath: String; + +const + DB_BUNDLED = 0; + DB_EXISTING = 1; + // Row geometry, in the page's own coordinate space. ScaleY/ScaleX keep these + // correct at 125%/150% DPI - hardcoded pixels would overlap on a 4K display. + ROW_GAP = 4; + + +// Plugin directory names are developer-facing. Show operators what the feature +// actually is; anything unlisted falls back to its raw name so a new plugin +// still appears rather than vanishing. +function PluginLabel(const Name: String): String; +begin + if Name = 'computers' then Result := 'Computers and workstations' + else if Name = 'machines' then Result := 'Machines (CNC, CMM, lathes)' + else if Name = 'printers' then Result := 'Printers and supplies' + else if Name = 'network' then Result := 'Network devices' + else if Name = 'measuringtools' then Result := 'Measuring tools and gauges' + else if Name = 'printedparts' then Result := '3D printed parts' + else if Name = 'knowledgebase' then Result := 'Knowledge base articles' + else if Name = 'slides' then Result := 'Shopfloor display slides' + else if Name = 'warranty' then Result := 'Warranty tracking' + else if Name = 'notifications' then Result := 'Notifications and announcements' + else if Name = 'usb' then Result := 'USB device checkout' + else if Name = 'employees' then Result := 'Employee directory' + else if Name = 'geenforce' then Result := 'GE-Enforce manifests' + else Result := Name; +end; + +// True when a fresh install should tick this by default. The five omitted here +// are specialised; a site that wants them can tick them. +function PluginDefault(const Name: String): Boolean; +begin + Result := (Name <> 'usb') and (Name <> 'employees') and (Name <> 'geenforce'); +end; + +// Comma-separated list of what the operator ticked. +function SelectedPlugins: String; +var + I, Count: Integer; +begin + Result := ''; + if PluginPage = nil then Exit; + // Never index past what was actually ADDED to the page. PluginNames comes from + // a build-time define and CheckListBox.Items from the Add() calls; if those + // ever disagree, reading Values[] past the end is a runtime error rather than + // a graceful miss. + Count := GetArrayLength(PluginNames); + if PluginPage.CheckListBox.Items.Count < Count then + Count := PluginPage.CheckListBox.Items.Count; + for I := 0 to Count - 1 do + if PluginPage.Values[I] then + begin + if Result <> '' then Result := Result + ','; + Result := Result + PluginNames[I]; + end; +end; + +procedure SetSwatch(Img: TBitmapImage; const Swatch: String); +begin + ExtractTemporaryFile(Swatch); + Img.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch); +end; + +// A solid colour block, drawn as a stretched bitmap so no VCL style can repaint +// it. Used for the row markers and the banner's accent bar. +function AddSwatch(Parent: TWinControl; L, T, W, H: Integer; const Swatch: String): TBitmapImage; +begin + Result := TBitmapImage.Create(WizardForm); + Result.Parent := Parent; + Result.Left := L; + Result.Top := T; + Result.Width := W; + Result.Height := H; + Result.Stretch := True; + ExtractTemporaryFile(Swatch); + Result.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch); +end; + +procedure InitializeWizard; +var + I: Integer; + Domain: String; +begin + LogPath := ExpandConstant('{tmp}\shopdb-installer-wizard.log'); + + // A custom page, not a memo. Status belongs in controls with colour, not in a + // wall of monospace text that has to be read line by line. + PreflightPage := CreateCustomPage(wpWelcome, + 'Server check', 'Confirming this server is ready'); + + // Verdict banner. Colour is the whole point: an operator should know within a + // second whether they can continue, without reading anything. + BannerPanel := TPanel.Create(WizardForm); + BannerPanel.Parent := PreflightPage.Surface; + BannerPanel.Left := 0; + BannerPanel.Top := 0; + BannerPanel.Width := PreflightPage.SurfaceWidth; + BannerPanel.Height := ScaleY(58); + BannerPanel.BevelOuter := bvNone; + // No Color here: the style would repaint it. The verdict colour is carried by + // BannerBar, a stretched bitmap down the left edge, which the style cannot touch. + + BannerBar := AddSwatch(BannerPanel, 0, 0, ScaleX(6), ScaleY(58), 'swatch-ok.bmp'); + + BannerText := TNewStaticText.Create(WizardForm); + BannerText.Parent := BannerPanel; + BannerText.Left := ScaleX(18); + BannerText.Top := ScaleY(10); + BannerText.Font.Size := 12; + BannerText.Font.Style := [fsBold]; + BannerText.Caption := 'Checking...'; + + BannerSub := TNewStaticText.Create(WizardForm); + BannerSub.Parent := BannerPanel; + BannerSub.Left := ScaleX(18); + BannerSub.Top := ScaleY(33); + BannerSub.Width := PreflightPage.SurfaceWidth - ScaleX(28); + BannerSub.AutoSize := False; + BannerSub.Caption := ''; + + // Rows are added here at run time, once the results are known. + DetailPanel := TPanel.Create(WizardForm); + DetailPanel.Parent := PreflightPage.Surface; + DetailPanel.Left := 0; + DetailPanel.Top := BannerPanel.Height + ScaleY(12); + DetailPanel.Width := PreflightPage.SurfaceWidth; + DetailPanel.Height := PreflightPage.SurfaceHeight - BannerPanel.Height - ScaleY(34); + DetailPanel.BevelOuter := bvNone; + DetailPanel.Color := clWindow; + + FooterText := TNewStaticText.Create(WizardForm); + FooterText.Parent := PreflightPage.Surface; + FooterText.Left := 0; + FooterText.Top := PreflightPage.SurfaceHeight - ScaleY(16); + FooterText.Width := PreflightPage.SurfaceWidth; + FooterText.AutoSize := False; + FooterText.Caption := ''; + + CheckingPage := CreateOutputProgressPage('Checking this server', + 'Reading the current configuration. Nothing is being changed.'); + + // Which features this site uses. Multi-select, and pre-ticked from the + // EXISTING install when there is one - so an upgrade shows what you already + // have and ticking a new box adds it. + PluginPage := CreateInputOptionPage(PreflightPage.ID, + 'Features', 'Which parts of ShopDB-Flask does this site use?', + 'Everything is included in this installer. Unticked features are simply not ' + + 'set up, and their database tables are not created.', + False, True); + PluginNames := StringSplit('{#AvailablePlugins}', [','], stAll); + for I := 0 to GetArrayLength(PluginNames) - 1 do + if Trim(PluginNames[I]) <> '' then + PluginPage.Add(PluginLabel(Trim(PluginNames[I]))); + + DbChoicePage := CreateInputOptionPage(PluginPage.ID, + 'Database', 'Where should ShopDB-Flask store its data?', + 'Most sites already run MySQL for the existing shopdb application. If so, ' + + 'choose the second option - installing a second server would collide on ' + + 'port 3306.', + True, False); + DbChoicePage.Add('Install the bundled MySQL 8.0 (new servers only)'); + DbChoicePage.Add('Use a MySQL server this site already runs'); + DbChoicePage.SelectedValueIndex := DB_EXISTING; + + DbDetailsPage := CreateInputQueryPage(DbChoicePage.ID, + 'Existing database', 'Connection details', + 'The database and application user must already exist. Your DBA creates ' + + 'them; the installer does not, so that it never needs administrative ' + + 'rights on your database server.'); + DbDetailsPage.Add('Host:', False); + DbDetailsPage.Add('Port:', False); + DbDetailsPage.Add('Database:', False); + DbDetailsPage.Add('Username:', False); + DbDetailsPage.Add('Password:', True); + DbDetailsPage.Values[0] := '127.0.0.1'; + DbDetailsPage.Values[1] := '3306'; + DbDetailsPage.Values[2] := 'shopdb_flask'; + DbDetailsPage.Values[3] := 'shopdb'; + + // How the application is published. Offered only when the bundle actually + // carries a subpath SPA build - Vite compiles the base path in, so this can + // never be a pure runtime switch. + DeployPage := CreateInputOptionPage(DbDetailsPage.ID, + 'Address', 'How should people reach ShopDB-Flask?', + 'Both options serve the same application. The second needs no new DNS name ' + + 'and no port number, because it rides this server''s existing address.', + True, False); + DeployPage.Add('Its own address, on a port - http://:8090/'); + DeployPage.Add('Under this server''s existing address - http:///{#SubpathAlias}/'); + DeployPage.SelectedValueIndex := 0; + + SitePage := CreateInputQueryPage(DeployPage.ID, + 'Web site', 'How the site is published', + 'The host name is used for CORS. It must be the name operators actually ' + + 'type in the browser, or the page will load but its data requests will fail.'); + SitePage.Add('Host name:', False); + SitePage.Add('Port:', False); + // FQDN, not the NetBIOS name: this value becomes CORS_ORIGINS, and a browser + // arriving at the fully qualified address would be refused by a bare hostname. + Domain := ''; + RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters', 'Domain', Domain); + if Domain <> '' then + SitePage.Values[0] := GetComputerNameString + '.' + Domain + else + SitePage.Values[0] := GetComputerNameString; + SitePage.Values[1] := '8090'; +end; + +// Full path to the 64-bit PowerShell. +// +// Setup is a 32-bit process, so 'powershell.exe' resolves through WOW64 to the +// 32-bit SysWOW64 build, which CANNOT instantiate IIS's 64-bit COM objects: +// "Retrieving the COM class factory for component with CLSID ... 80040144" +// from Get-Website, while the identical script run from a normal shell is fine. +// 'Sysnative' is the alias that lets a 32-bit process reach the real System32; +// it exists ONLY for 32-bit processes, hence the IsWin64 guard. +// +// Inno has ExecAndCaptureOutputWithNativeSysDir for this, but there is no +// ExecAndLogOutput equivalent, so resolving the path ourselves keeps both call +// sites on one mechanism instead of two that can drift apart. +function PowerShellPath: String; +begin + if IsWin64 then + Result := ExpandConstant('{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe') + else + Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'); +end; + +// Streamed line by line from ExecAndLogOutput, so the operator sees the install +// happening instead of a frozen wizard. Stage 2 alone installs 47 wheels and can +// run for minutes with nothing on screen. +procedure OnStageLog(const S: String; const Error, FirstLine: Boolean); +var + Line: String; +begin + // Everything goes to the setup log verbatim - that is the diagnostic record. + Log('[shopdb] ' + S); + if Error then Exit; + Line := Trim(S); + if Line = '' then Exit; + + // Recognise the failures an operator can actually act on, and translate them + // into plain language. Matching on the SYMPTOM text (not an exit code) is what + // lets the wizard say "wrong password" instead of "exit 1". + if Pos('Access denied for user', Line) > 0 then + begin + FailCause := 'The database rejected the username or password.'; + FailDetail := 'Go back to the Database page and re-enter them. Check for ' + + 'capital letters - the password is case-sensitive.'; + end + else if Pos('Unknown database', Line) > 0 then + begin + FailCause := 'That database does not exist on the server.'; + FailDetail := 'Your DBA must create it before installing, along with the ' + + 'application user that owns it.'; + end + else if (Pos('Can''t connect to MySQL server', Line) > 0) or (Pos('timed out', Line) > 0) then + begin + FailCause := 'The database server did not answer.'; + FailDetail := 'Check the host name and port on the Database page, and that ' + + 'MySQL is running and reachable from this server.'; + end + else if Pos('index flags', Line) > 0 then + begin + FailCause := 'This MySQL 5.6 server is missing three required settings.'; + FailDetail := 'innodb_file_per_table, innodb_file_format=Barracuda and ' + + 'innodb_large_prefix must be set, then MySQL restarted.'; + end + else if Pos('does not represent a valid object', Line) > 0 then + begin + FailCause := 'IIS refused to create the site or application pool.'; + FailDetail := 'This usually means a previous ShopDB site or pool is in a ' + + 'half-removed state. Run IISRESET and try again.'; + end; + + // The status caption is a DIFFERENT job: it tells an operator what is happening + // right now. Raw log lines are useless for that - "exec python.exe (7 args)" + // says nothing, and the argument COUNT is only there because argument VALUES + // must never be logged (they can carry a password). + // So: translate the few lines that mark real progress, and ignore the rest. + if Pos('exec ', Line) > 0 then Exit; + + if Pos('STAGE 0', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing the bundled MySQL database...' + else if Pos('STAGE 2', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing Python and the application...' + else if Pos('wheelhouse', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing dependencies (offline)...' + else if Pos('STAGE 3', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Preparing the database...' + else if Pos('db upgrade', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Creating the database schema...' + else if Pos('seed ', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Adding reference data...' + else if Pos('apply-profile', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Installing the plugins this site uses...' + else if Pos('upgrade-all', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Applying plugin migrations...' + else if Pos('prune-schema', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Removing unused plugin tables...' + else if Pos('STAGE 4', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Configuring IIS...' + else if Pos('STAGE 5', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Checking the site responds...' + else if Pos('[FAIL]', Line) > 0 then + WizardForm.StatusLabel.Caption := 'Failed - see the message that follows'; +end; + +function RunPowerShell(const ScriptArgs: String; var ResultCode: Integer): Boolean; +var + Cmd: String; +begin + Cmd := '-NoProfile -ExecutionPolicy Bypass -File "' + + ExpandConstant('{tmp}\shopdb-bundle\shopdb-install.ps1') + '" ' + ScriptArgs; + // ExecAndLogOutput streams output through OnStageLog as it is produced, and + // also writes it into the setup log - so a failed install leaves a full trace + // without the operator having to find the PowerShell log in TEMP. + // 64-bit PowerShell: stage 4 configures IIS, which the 32-bit build cannot do. + Result := ExecAndLogOutput(PowerShellPath, Cmd, '', SW_HIDE, + ewWaitUntilTerminated, ResultCode, @OnStageLog); +end; + +// One result row: coloured dot, bold title, wrapped detail. Returns the Y for +// the next row so the caller does not have to guess heights. +// +// Status marker. Four approaches failed before this one, all recorded so nobody +// re-treads them: +// - PNG into TBitmapImage.Bitmap -> "Bitmap image is not valid" (BMP only). +// - Chr(9679) for U+25CF -> renders 'I'-diaeresis; Chr takes a BYTE. +// - Wingdings glyph + Font.Color -> draws, but BLACK: a custom VCL style owns +// text painting and ignores Font.Color. +// - TPanel + Color -> also repainted by the style; no colour. +// A stretched BMP is painted verbatim, so the colour always shows. +function AddRow(Y: Integer; const Swatch, Title, Detail: String): Integer; +var + T, D: TNewStaticText; +begin + AddSwatch(DetailPanel, ScaleX(3), Y + ScaleY(4), ScaleX(10), ScaleY(10), Swatch); + + T := TNewStaticText.Create(WizardForm); + T.Parent := DetailPanel; + T.Left := ScaleX(22); + T.Top := Y; + T.Width := DetailPanel.Width - ScaleX(24); + T.Font.Style := [fsBold]; + T.Caption := Title; + + D := TNewStaticText.Create(WizardForm); + D.Parent := DetailPanel; + D.Left := ScaleX(22); + D.Top := Y + ScaleY(15); + D.Width := DetailPanel.Width - ScaleX(24); + D.AutoSize := False; + D.WordWrap := True; + D.Height := ScaleY(28); + D.Font.Color := clGrayText; + D.Caption := Detail; + + Result := Y + ScaleY(46) + ScaleY(ROW_GAP); +end; + +// Turn the delimited records into a verdict plus rows. Passing checks are NEVER +// listed: 10 green lines bury the one amber line that actually needs reading. +// If nothing is wrong the page collapses to a single reassuring statement. +procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean); +var + I, Y, Shown, Fails, Warns, Passes: Integer; + Parts: TArrayOfString; + Check, Detail, Fix: String; +begin + Fails := 0; Warns := 0; Passes := 0; + Y := ScaleY(4); + Shown := 0; + + // Blockers first, then warnings - a second pass rather than one, so severity + // ordering does not depend on the order the checks happen to run in. + for I := 0 to GetArrayLength(Lines) - 1 do + begin + Parts := StringSplit(Lines[I], ['|'], stAll); + if GetArrayLength(Parts) < 4 then Continue; + if Parts[0] = 'FAIL' then Fails := Fails + 1 + else if Parts[0] = 'WARN' then Warns := Warns + 1 + else if Parts[0] = 'PASS' then Passes := Passes + 1; + end; + + for I := 0 to GetArrayLength(Lines) - 1 do + begin + Parts := StringSplit(Lines[I], ['|'], stAll); + if GetArrayLength(Parts) < 4 then Continue; + if Parts[0] <> 'FAIL' then Continue; + // Qualify the check with its area: "Service" and "Port 3306" on their own + // say nothing about WHAT service or whose port. + Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3]; + if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := ''; + if Fix <> '' then Detail := Detail + ' - ' + Fix; + Y := AddRow(Y, 'swatch-bad.bmp', Check, Detail); + Shown := Shown + 1; + end; + + for I := 0 to GetArrayLength(Lines) - 1 do + begin + if Shown >= 6 then Break; + Parts := StringSplit(Lines[I], ['|'], stAll); + if GetArrayLength(Parts) < 4 then Continue; + if Parts[0] <> 'WARN' then Continue; + Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3]; + if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := ''; + if Fix <> '' then Detail := Detail + ' - ' + Fix; + Y := AddRow(Y, 'swatch-warn.bmp', Check, Detail); + Shown := Shown + 1; + end; + + HasBlockers := Fails > 0; + + if Fails > 0 then + begin + SetSwatch(BannerBar, 'swatch-bad.bmp'); + BannerText.Caption := 'This server is not ready'; + BannerSub.Caption := IntToStr(Fails) + ' problem(s) must be fixed before installing.'; + end + else if Warns > 0 then + begin + SetSwatch(BannerBar, 'swatch-warn.bmp'); + BannerText.Caption := 'Ready, with notes'; + BannerSub.Caption := 'Installation can continue. ' + IntToStr(Warns) + + ' item(s) below are worth reading first.'; + end + else + begin + SetSwatch(BannerBar, 'swatch-ok.bmp'); + BannerText.Caption := 'This server is ready'; + BannerSub.Caption := 'Everything needed for ShopDB-Flask is in place.'; + // Nothing to list, so say so rather than leaving an empty white box. + AddRow(ScaleY(4), 'swatch-ok.bmp', 'All checks passed', + 'IIS, HttpPlatformHandler, disk space, ports and the database were all ' + + 'verified. Nothing needs your attention.'); + end; + + if (Shown >= 6) and (Warns > 6 - Fails) then + FooterText.Caption := 'Some notes are not shown. The full check is in the install log.' + else + FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.'; +end; + +// Where an EXISTING install would be, for the pre-fill reads. +// +// Not WizardDirValue() alone: these pages are inserted after wpWelcome and so +// run BEFORE the directory page, where that value can still be empty - and an +// empty base silently turned every pre-fill into "no existing install", which +// is why the Address page kept defaulting to 8090 on a /shopdb server. +// Falls back to the compile-time default, which is also the only directory the +// operator could have meant at that point in the wizard. +function InstalledDir: String; +begin + Result := WizardDirValue; + if Trim(Result) = '' then Result := ExpandConstant('{#DefaultDir}'); + Result := AddBackslash(Result); + Log('[shopdb] pre-fill base directory: ' + Result); +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +var + ResultCode, I: Integer; + Output: TExecOutput; + PreflightScript, Detail: String; + HasBlockers: Boolean; +begin + Result := True; + + if CurPageID = wpWelcome then + begin + // Stage 1 is read-only, so running it before the operator commits to + // anything is free and catches the common blockers (IIS absent, locked + // config sections, port in use, MySQL 5.6 missing its index flags). + // The bundle is NOT extracted yet at wizard time - [Files] is processed during + // the install step. Pull just this one script out of the compressed payload. + ExtractTemporaryFile('shopdb-preflight.ps1'); + PreflightScript := ExpandConstant('{tmp}\shopdb-preflight.ps1'); + + // Visible feedback for the few seconds the check takes. SW_HIDE means the + // operator sees nothing at all otherwise, and a frozen wizard reads as a + // crash. ProgressBar.Style := npbstMarquee because the duration is unknown - + // a bar that sits at 0% is worse than no bar. + CheckingPage.SetText('Looking at IIS, disk, ports and the database...', ''); + CheckingPage.SetProgress(0, 0); + CheckingPage.ProgressBar.Style := npbstMarquee; + CheckingPage.Show; + try + + // ExecAndCaptureOutput (6.4.0+) hands back stdout and stderr as string arrays, + // so output never touches disk. That deletes BOTH bugs this page shipped with: + // - Exec() has no shell, so "> file" went to PowerShell as a literal + // argument and no file was ever written (page came up empty); + // - PowerShell 5.1 writes UTF-16LE, and LoadStringFromFile reads bytes as + // ANSI, so the page rendered as garbage with a null between characters. + // Neither failure mode can recur now: there is no file and no encoding step. + // 64-bit PowerShell - see PowerShellPath. The IIS checks fail without it. + ExecAndCaptureOutput(PowerShellPath, + '-NoProfile -ExecutionPolicy Bypass -File "' + PreflightScript + '" -Delimited', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Output); + // Build the rows once. Going Back and Next again must not stack a second set + // of controls on top of the first. + if not PreflightDone then + begin + PreflightDone := True; + if (not Output.Error) and (GetArrayLength(Output.StdOut) > 0) then + begin + RenderPreflight(Output.StdOut, HasBlockers); + if HasBlockers then + MsgBox('This server is not ready. The problems are listed on the next ' + + 'page, each with what to do about it.', mbError, MB_OK); + end + else + begin + // stderr is captured SEPARATELY, so a failure can report what actually + // went wrong instead of only an exit code. + Detail := ''; + for I := 0 to GetArrayLength(Output.StdErr) - 1 do + Detail := Detail + Output.StdErr[I] + ' '; + if Trim(Detail) = '' then Detail := 'No error output was produced.'; + SetSwatch(BannerBar, 'swatch-warn.bmp'); + BannerText.Caption := 'Could not check this server'; + BannerSub.Caption := 'Installation can continue, but nothing has been verified.'; + AddRow(ScaleY(4), 'swatch-warn.bmp', + 'Check did not run (exit ' + IntToStr(ResultCode) + ')', Detail); + FooterText.Caption := 'Nothing has been changed on this server.'; + end; + end; + finally + CheckingPage.Hide; + end; + end; + + if CurPageID = DbChoicePage.ID then + UseBundledDb := (DbChoicePage.SelectedValueIndex = DB_BUNDLED); + + if CurPageID = DbDetailsPage.ID then + begin + if not UseBundledDb then + begin + if DbDetailsPage.Values[0] = '' then + begin + MsgBox('Enter the database host.', mbError, MB_OK); + Result := False; + end + else if (DbDetailsPage.Values[4] = '') and + (not FileExists(InstalledDir + '.env')) then + begin + // Only required on a FRESH install. On an upgrade, blank means "keep the + // password already in .env", so the operator never has to know it. + MsgBox('Enter the password for the application database user.', mbError, MB_OK); + Result := False; + end; + end; + end; +end; + +// Pre-tick the boxes the first time the page appears. On an upgrade the existing +// site-profile.json is already on disk, so the page opens showing exactly what +// this server has today - and ticking another box adds it. +procedure CurPageChanged(CurPageID: Integer); +var + I, Count: Integer; + Existing, Creds, Rest: String; + Lines: TArrayOfString; +begin + // Pre-fill the database page from the EXISTING .env on an upgrade. Its + // defaults are 127.0.0.1 / shopdb_flask, so a site whose database lives on + // another server would otherwise have its real connection string overwritten + // by defaults the operator never looked at - and .env was the only record. + // Pre-select the method this server already uses. Without this, an upgrade of + // a /shopdb install would sit on the default and create a SECOND deployment - + // a new site on 8090 beside the existing Application, same directory, wrong + // SPA build, MOUNT_PATH still set. Read it from .env, which is the same value + // wsgi.py mounts on. + if (CurPageID = DeployPage.ID) and (not DeployPageReady) then + begin + DeployPageReady := True; + if LoadStringsFromFile(InstalledDir + '.env', Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do + if Pos('MOUNT_PATH=', Lines[I]) = 1 then + begin + Log('[shopdb] existing install is a subpath deployment: ' + Lines[I]); + DeployPage.SelectedValueIndex := 1; + DeployPage.SubCaptionLabel.Caption := + 'This server is currently published under its existing address. ' + + 'Changing this will move where people reach ShopDB-Flask.'; + end; + end; + + if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then + begin + DbPageReady := True; + if LoadStringsFromFile(InstalledDir + '.env', Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do + if Pos('DATABASE_URL=', Lines[I]) = 1 then + begin + // mysql+pymysql://USER:PASS@HOST:PORT/NAME?charset=... + Existing := Copy(Lines[I], Pos('//', Lines[I]) + 2, Length(Lines[I])); + if Pos('@', Existing) > 0 then + begin + Creds := Copy(Existing, 1, Pos('@', Existing) - 1); + Rest := Copy(Existing, Pos('@', Existing) + 1, Length(Existing)); + if Pos(':', Creds) > 0 then + DbDetailsPage.Values[3] := Copy(Creds, 1, Pos(':', Creds) - 1); + if Pos(':', Rest) > 0 then + begin + DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1); + Rest := Copy(Rest, Pos(':', Rest) + 1, Length(Rest)); + if Pos('/', Rest) > 0 then + begin + DbDetailsPage.Values[1] := Copy(Rest, 1, Pos('/', Rest) - 1); + Rest := Copy(Rest, Pos('/', Rest) + 1, Length(Rest)); + if Pos('?', Rest) > 0 then Rest := Copy(Rest, 1, Pos('?', Rest) - 1); + DbDetailsPage.Values[2] := Rest; + end; + end; + end; + // Password intentionally left blank: blank means "keep the current + // one", so an upgrade never needs the operator to know it. + DbDetailsPage.Values[4] := ''; + DbDetailsPage.SubCaptionLabel.Caption := + 'These are the settings this server is using now. Leave the password ' + + 'blank to keep the current one.'; + end; + end; + + if (CurPageID = PluginPage.ID) and (not PluginPageReady) then + begin + PluginPageReady := True; + Existing := ''; + // Read instance\plugins.json - the PLUGIN REGISTRY, which is what is actually + // installed. site-profile.json only records the last SELECTION, and the two + // drift: unticking a plugin shrinks the profile but does NOT uninstall it + // (apply-profile never removes), so the profile would show a plugin as absent + // while its tables and data are still on the server. + // + // NOT ExpandConstant('{app}') here: this page runs BEFORE the directory page + // and {app} is not initialised yet - expanding it raises "attempt was made to + // expand the app constant before it was initialized". WizardDirValue() is the + // chosen directory and is safe at any point. + if LoadStringsFromFile(InstalledDir + 'instance\plugins.json', Lines) then + for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I] + else if LoadStringsFromFile(InstalledDir + 'site-profile.json', Lines) then + // Fallback for an install predating the registry, or a partial install. + for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I]; + + Count := GetArrayLength(PluginNames); + if PluginPage.CheckListBox.Items.Count < Count then + Count := PluginPage.CheckListBox.Items.Count; + for I := 0 to Count - 1 do + if Existing <> '' then + // Crude but sufficient: the profile lists plugins as quoted strings. + PluginPage.Values[I] := (Pos('"' + PluginNames[I] + '"', Existing) > 0) + else + PluginPage.Values[I] := PluginDefault(PluginNames[I]); + end; +end; + +function UsingSubpath: Boolean; +begin + Result := ('{#SubpathAlias}' <> '') and (DeployPage.SelectedValueIndex = 1); +end; + +function ShouldSkipPage(PageID: Integer): Boolean; +begin + // A bundle without a subpath build cannot offer the choice at all. + if (PageID = DeployPage.ID) and ('{#SubpathAlias}' = '') then + begin + Result := True; + Exit; + end; + // The bundled path generates its own credentials, so asking for them would be + // meaningless - and any value typed here would be silently ignored. + Result := (PageID = DbDetailsPage.ID) and UseBundledDb; +end; + +// Runs from ssPostInstall, NOT PrepareToInstall. +// +// PrepareToInstall fires BEFORE the [Files] section is processed, so +// {tmp}\shopdb-bundle does not exist yet and powershell.exe fails on a missing +// -File path (exit -196608 / 0xFFFD0000). Everything here depends on the +// extracted bundle, so it has to run after the install step. + +// Set the "Run as administrator" bit on a .lnk (byte 21, flag 0x20). +// +// The console needs Administrator to read IIS state. The script self-elevates, +// but that spawns a SECOND window after a UAC prompt, which is easy to miss and +// looks broken. Marking the shortcut makes Windows elevate up front: one window, +// one prompt. +// +// Inno cannot set this flag, and its file helpers are string-based rather than +// binary safe, so use PowerShell - already a dependency here. +procedure MarkShortcutRunAs(const LnkPath: String); +var + ResultCode: Integer; + Cmd: String; +begin + if not FileExists(LnkPath) then Exit; + Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "' + + '$p='''' + LnkPath + ''''; ' + + '$b=[IO.File]::ReadAllBytes($p); ' + + '$b[21]=$b[21] -bor 0x20; ' + + '[IO.File]::WriteAllBytes($p,$b)"'; + Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +function RunInstallStages: String; +var + ResultCode: Integer; + PwFile, Args, Common: String; +begin + Result := ''; + Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' + + ' -AppRoot "' + ExpandConstant('{app}') + '"' + + ' -SiteHost "' + SitePage.Values[0] + '"' + + ' -SitePort ' + SitePage.Values[1] + + ' -OnFailure never' + + ' -SitePlugins "' + SelectedPlugins + '"'; + + // Subpath deployment: an IIS Application under the existing site instead of a + // site of its own. Appended here because Pascal has no conditional expression. + if UsingSubpath then + Common := Common + ' -MountAlias "{#SubpathAlias}"'; + + if UseBundledDb then + begin + if not RunPowerShell('-Stage 0 ' + Common, ResultCode) or (ResultCode <> 0) then + begin + if FailCause <> '' then + Result := FailCause + #13#10#13#10 + FailDetail + else + Result := 'The bundled MySQL database could not be installed (exit ' + + IntToStr(ResultCode) + ').'; + Result := Result + #13#10#13#10 + 'Full details are in:' + #13#10 + + ExpandConstant('{log}'); + Exit; + end; + // Stage 0 leaves an ACL'd handoff file that stage 2 picks up, so no password + // is passed here or anywhere else. + Args := Common; + end + else + begin + // The password goes via an ACL'd FILE, never on the command line: command + // lines are readable by any user through Win32_Process and are captured in + // PowerShell transcripts. The installer shreds the file after reading it. + // No password typed on an upgrade means "leave .env alone" - so send no + // password file, and the installer keeps the existing DATABASE_URL. + if DbDetailsPage.Values[4] <> '' then + begin + PwFile := ExpandConstant('{tmp}\dbpw.txt'); + SaveStringToFile(PwFile, DbDetailsPage.Values[4] + #13#10, False); + end + else + PwFile := ''; + Args := Common + + ' -DbHost "' + DbDetailsPage.Values[0] + '"' + + ' -DbPort ' + DbDetailsPage.Values[1] + + ' -DbName "' + DbDetailsPage.Values[2] + '"' + + ' -DbUser "' + DbDetailsPage.Values[3] + '"'; + if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"'; + end; + + if not RunPowerShell('-Stage all ' + Args, ResultCode) or (ResultCode <> 0) then + begin + if FailCause <> '' then + Result := FailCause + #13#10#13#10 + FailDetail + else + Result := 'The installation could not be completed (exit ' + + IntToStr(ResultCode) + ').'; + Result := Result + #13#10#13#10 + + 'Nothing was left running. Full details are in:' + #13#10 + + ExpandConstant('{log}'); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + Failure, FinalUrl: String; +begin + if CurStep = ssPostInstall then + begin + if UsingSubpath then + FinalUrl := 'http://' + SitePage.Values[0] + '/{#SubpathAlias}/login' + else + FinalUrl := 'http://' + SitePage.Values[0] + ':' + SitePage.Values[1] + '/login'; + + Failure := RunInstallStages; + + // After the stages: the shortcuts exist by now, and this is cosmetic enough + // that it must never be able to fail the install. + MarkShortcutRunAs(ExpandConstant('{group}\ShopDB-Flask Console.lnk')); + MarkShortcutRunAs(ExpandConstant('{group}\Restart ShopDB-Flask.lnk')); + MarkShortcutRunAs(ExpandConstant('{group}\Back up the database.lnk')); + // NOTE: never start a line with #13#10. The Inno PREPROCESSOR treats any line + // whose first non-blank character is '#' as a directive and fails with + // "Unknown preprocessor directive" before Pascal parsing happens. Keep the + // concatenation operator at the start of continuation lines instead. + if Failure <> '' then + MsgBox(Failure, mbCriticalError, MB_OK) + else + MsgBox('ShopDB-Flask is installed.' + #13#10#13#10 + + 'Open ' + FinalUrl + + #13#10#13#10 + + 'With no user in the database that page offers to create the first ' + + 'administrator and then runs the setup wizard.', + mbInformation, MB_OK); + end; +end; + +[UninstallRun] +; Delegate to the same tested script rather than duplicating removal logic here. +; It removes the site, app pool, firewall rule and application directory, and +; deliberately does NOT drop the database or uninstall MySQL. +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \ + RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden diff --git a/deploy/windows/installer/build-installer.sh b/deploy/windows/installer/build-installer.sh new file mode 100755 index 0000000..115b09e --- /dev/null +++ b/deploy/windows/installer/build-installer.sh @@ -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 [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 [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" < 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" diff --git a/deploy/windows/installer/make-branding.py b/deploy/windows/installer/make-branding.py new file mode 100644 index 0000000..20caf30 --- /dev/null +++ b/deploy/windows/installer/make-branding.py @@ -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() diff --git a/deploy/windows/installer/shopdb-admin.ps1 b/deploy/windows/installer/shopdb-admin.ps1 new file mode 100644 index 0000000..622e892 --- /dev/null +++ b/deploy/windows/installer/shopdb-admin.ps1 @@ -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 /, 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 ' '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 ' '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 } +} diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 new file mode 100644 index 0000000..bf3e06c --- /dev/null +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -0,0 +1,1636 @@ +<# +.SYNOPSIS + ShopDB-Flask offline installer for Windows (IIS + HttpPlatformHandler + waitress). + +.DESCRIPTION + Automates docs/INSTALL-WINDOWS-IIS.md. Fully offline: every prerequisite comes + from the bundle directory. Never contacts the network. + + Stages (each can be run alone with -Stage): + 1 preflight - read-only discovery (shopdb-preflight.ps1) + 2 runtime - Python, venv, wheels, .env + 3 data - schema, seeds, plugins + 4 iis - app pool, ACLs, site, firewall + 5 verify - smoke test + handoff + all - 2,3,4,5 in order (default) + + Written for stock Windows PowerShell 5.1. No pwsh-only syntax, no modules, + no network. + +.PARAMETER BundleRoot + Directory holding the offline bundle. Expected layout: + python\python-3.14.x-amd64.exe + wheels\*.whl + app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...) + httpplatformhandler\httpPlatformHandler_amd64.msi + mysql\ (optional, bundled-database option) + +.PARAMETER DbHost + Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER - + it is prompted for as a SecureString, because Windows command lines are + readable by any user (Win32_Process) and are captured in PowerShell + transcripts and history. If a valid .env already exists it is reused and + nothing is prompted. + +.EXAMPLE + .\shopdb-install.ps1 -BundleRoot D:\shopdb-bundle -SiteHost shopdb.plant.local + .\shopdb-install.ps1 -Stage 4 -BundleRoot D:\shopdb-bundle + +.NOTES + Idempotent. Re-running preserves an existing .env (regenerating JWT_SECRET_KEY + would invalidate every issued session). +#> + +[CmdletBinding()] +param( + # Stage 0 installs the bundled MySQL 8.0 (greenfield sites only). It is NOT in + # 'all': a site with an existing MySQL must never have a second server dropped + # on top of it, and that is the common case (docs/DEPLOY-WINDOWS-IIS.md: the + # target box already runs the classic ASP shopdb against MySQL 5.6). Run it + # explicitly, and only when preflight reports no MySQL on 3306. + [ValidateSet('0','1','2','3','4','5','all','uninstall')] [string] $Stage = 'all', + # Bundled-MySQL (stage 0) settings. The root and app passwords are GENERATED, + # never supplied: see New-Secret. Root is shown once and not persisted. + [string] $MysqlRoot = 'C:\Program Files\MySQL\MySQL Server 8.0', + [string] $MysqlDataDir = 'C:\ProgramData\MySQL\data', + [string] $MysqlIni = 'C:\ProgramData\MySQL\my.ini', + [string] $MysqlService = 'MySQL80', + [Parameter(Mandatory=$true)] [string] $BundleRoot, + [string] $AppRoot = 'C:\shopdb-flask', + [int] $SitePort = 8090, + [string] $SiteName = 'shopdb-flask', + [string] $AppPool = 'shopdbflask', + [string] $SiteHost = '', # for CORS_ORIGINS; defaults to this machine + # Deliberately NOT a password parameter. Command lines are readable by any + # user via Win32_Process and are captured in PowerShell transcripts. + # Supply host/user/db here; the password is prompted for. + [string] $DbHost = '', + [int] $DbPort = 3306, + [string] $DbName = 'shopdb_flask', + [string] $DbUser = 'shopdb', + # Unattended installs only. Path to a file whose FIRST LINE is the DB password. + # The installer reads it, overwrites it and deletes it. ACL the file to + # SYSTEM + Administrators before writing it. Omit this for an interactive run + # and the password is prompted for instead. It is never a parameter value: + # command lines are world-readable via Win32_Process and are captured in + # PowerShell transcripts and ConsoleHost_history.txt. + [string] $DbPasswordFile = '', + # The site's plugin set is DECLARED in a profile (ADR-013), not listed here. + # The bundle is built lean for that same profile by scripts/build-site.sh, so + # a plugin the site did not choose is absent from the backend tree entirely. + # That absence is what makes core's `try: from plugins.X.models import ... + # except ImportError` guards correct: the import genuinely fails, no + # relationship backref is created, and no query joins the missing table. + # + # Do NOT go back to installing a fixed list against a full tree. That was the + # cause of the 500s on /api/assets and /api/dashboard/summary + # ("Table 'shopdb_flask.measuringtools' doesn't exist"): the plugin's CODE + # shipped regardless, so the import succeeded, SQLAlchemy created the + # Asset.measuringtool backref and LEFT OUTER JOINed a table that only exists + # if the plugin was installed. + [string] $SiteProfile = '', + # Comma-separated plugin names chosen at INSTALL time (the wizard's plugin + # page). When supplied this REWRITES site-profile.json before apply-profile + # runs, so the operator's choice wins over whatever the bundle was built with. + # Omit it and the profile shipped in the bundle is used unchanged. + [string] $SitePlugins = '', + # Subpath deployment (docs/INSTALL-WINDOWS-IIS.md method B). Empty = the app + # gets its own IIS site on -SitePort (method A, the default). Set to an alias + # such as 'shopdb' and it becomes an IIS Application under -ParentSite, i.e. + # http:///shopdb/ - no new DNS record, no port in the URL. + # The alias MUST match the one the bundle's subpath SPA was built with; the + # installer checks that and refuses if they disagree. + [string] $MountAlias = '', + [string] $ParentSite = 'Default Web Site', + [switch] $WhatIfOnly, + # Unattended runs cannot answer a prompt. Choose the failure behaviour up front. + [ValidateSet('ask','always','never')] [string] $OnFailure = 'ask' +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +# NOT $env:TEMP. When this runs under the Inno wrapper, TEMP points at Setup's +# own extraction directory, which is deleted when Setup exits - so the log the +# operator is told to send to support disappears with it, and a failed install +# leaves nothing to diagnose. ProgramData persists and is admin-writable. +$script:LogDir = Join-Path $env:ProgramData 'ShopDB-Flask\logs' +try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } } +catch { $script:LogDir = $env:TEMP } +$script:LogPath = Join-Path $script:LogDir ("shopdb-install-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss')) +$script:Created = New-Object System.Collections.ArrayList # for rollback + +function Write-Log { + param([string] $Message, [string] $Level = 'INFO') + $line = "{0} [{1}] {2}" -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message + Add-Content -Path $script:LogPath -Value $line + $colour = 'Gray' + if ($Level -eq 'OK') { $colour = 'Green' } + if ($Level -eq 'WARN') { $colour = 'Yellow' } + if ($Level -eq 'FAIL') { $colour = 'Red' } + if ($Level -eq 'STEP') { $colour = 'Cyan' } + Write-Host $line -ForegroundColor $colour +} + +function Fail { + param([string] $Message, [string] $Fix = '') + Write-Log $Message 'FAIL' + if ($Fix) { Write-Log " fix: $Fix" 'FAIL' } + Write-Log "log: $script:LogPath" + throw $Message +} + +function Track { param([string] $Kind, [string] $Id) $null = $script:Created.Add(@{Kind=$Kind; Id=$Id}) } + +function Invoke-Native { + # Runs an external command and fails loudly. + # + # Three PowerShell 5.1 traps are handled here, each found the hard way on a + # real Server 2025 box: + # 1. `& $exe @array` can collapse the array into ONE argument. Start-Process + # -ArgumentList binds arrays correctly. + # 2. Native tools write progress to stderr on SUCCESS, and + # $ErrorActionPreference='Stop' turns that into a terminating error. + # 3. Argument VALUES must never be logged: a DATABASE_URL or password would + # land in the log we invite operators to send to support. + param([string] $Exe, [string[]] $Arguments, [string] $What, [string] $StdinFile = '', + [int] $TimeoutSec = 0) + Write-Log (" exec {0} ({1} args)" -f (Split-Path $Exe -Leaf), $Arguments.Count) + # Start-Process -ArgumentList joins the array with spaces WITHOUT quoting, so + # any argument containing whitespace gets split into several. That is how + # "IIS AppPool\name:(OI)(CI)RX" became the parameter "IIS" (icacls exit 87), + # and how TargetDir=C:\Program Files\... installed Python into C:\Program\. + $quoted = $Arguments | ForEach-Object { + if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } + } + $so = [System.IO.Path]::GetTempFileName() + $se = [System.IO.Path]::GetTempFileName() + try { + if ($StdinFile) { + # Feeding SQL as a file on stdin, not as -e "source ": the mysql + # client reads that path as a DATABASE NAME and fails with + # ERROR 1049 Unknown database ''. + $p = Start-Process -FilePath $Exe -ArgumentList $quoted -Wait -PassThru ` + -NoNewWindow -RedirectStandardInput $StdinFile ` + -RedirectStandardOutput $so -RedirectStandardError $se + } else { + $p = Start-Process -FilePath $Exe -ArgumentList $quoted -Wait -PassThru ` + -NoNewWindow -RedirectStandardOutput $so -RedirectStandardError $se + } + # A hung child must not hang the install. msiexec in particular deadlocks + # when a second Windows Installer transaction holds the global mutex: two + # msiexec processes sat there for 40 minutes with no output and no error, + # and -Wait alone gives you no way out. + if ($TimeoutSec -gt 0 -and -not $p.HasExited) { + if (-not $p.WaitForExit($TimeoutSec * 1000)) { + try { $p.Kill() } catch { } + Fail "$What timed out after $TimeoutSec seconds" @' +The command was killed. For an MSI this usually means another Windows Installer +transaction held the global mutex: check for stray msiexec processes +(Get-Process msiexec), end them, then re-run this stage. +'@ + } + } + $out = @() + if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue } + if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue } + if ($p.ExitCode -ne 0) { + $out | Select-Object -Last 8 | ForEach-Object { Write-Log " $_" 'FAIL' } + Fail "$What failed (exit $($p.ExitCode))" + } + return $out + } + finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } +} + +function Protect-File { + # Owner-only ACL: Administrators + SYSTEM, inheritance broken. + param([string] $Path) + Invoke-Native 'icacls.exe' @($Path,'/inheritance:r', + '/grant','BUILTIN\Administrators:(F)','/grant','NT AUTHORITY\SYSTEM:(F)') 'ACL' +} + +function New-Secret { + param([int] $Bytes = 48) + $b = New-Object byte[] $Bytes + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b) + # URL-safe, no padding: avoids characters that need escaping in .env or URLs. + return [Convert]::ToBase64String($b).Replace('+','-').Replace('/','_').TrimEnd('=') +} + +# Paths derived once. +$Py = Join-Path $AppRoot 'venv\Scripts\python.exe' +$Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' +$Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' +$EnvFile = Join-Path $AppRoot '.env' +$WheelDir = Join-Path $BundleRoot 'wheels' +$AppSource = Join-Path $BundleRoot 'app' + + +# ============================================================================= +# Version awareness and database safety (upgrades) +# ============================================================================= +# An upgrade is just this installer run against an existing install. That is +# only safe if three things are true, and none of them were before: +# - it knows whether it is going forwards or backwards; +# - the database is backed up BEFORE migrations touch it; +# - a failed migration puts the backup back rather than leaving a half-state. + +function Get-BundleVersion { + # The version being installed, read from the payload itself so it can never + # disagree with the code that is about to be copied. + $init = Join-Path $AppSource 'shopdb\__init__.py' + if (-not (Test-Path $init)) { return '' } + $m = Select-String -Path $init -Pattern "^__version__\s*=\s*'([^']+)'" | Select-Object -First 1 + if ($m) { return $m.Matches[0].Groups[1].Value } + return '' +} + +function Get-InstalledVersion { + $f = Join-Path $AppRoot '.installed-version' + if (Test-Path $f) { return (Get-Content $f -TotalCount 1).Trim() } + # An install predating version stamping still has an app tree. + if (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) { return 'unknown' } + return '' +} + +function Compare-Version { + # -1 / 0 / 1, comparing dotted numeric versions. 'unknown' sorts as older so + # a stamped bundle over an unstamped install is treated as an upgrade. + param([string] $A, [string] $B) + if ($A -eq $B) { return 0 } + if ($A -eq 'unknown' -or $A -eq '') { return -1 } + if ($B -eq 'unknown' -or $B -eq '') { return 1 } + $x = @($A -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) + $y = @($B -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') }) + for ($i = 0; $i -lt [Math]::Max($x.Count, $y.Count); $i++) { + $xi = if ($i -lt $x.Count) { $x[$i] } else { 0 } + $yi = if ($i -lt $y.Count) { $y[$i] } else { 0 } + if ($xi -gt $yi) { return 1 } + if ($xi -lt $yi) { return -1 } + } + return 0 +} + +function Find-MysqlTool { + param([string] $Name) # mysql.exe or mysqldump.exe + $roots = @('C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL') + foreach ($r in $roots) { + if (Test-Path $r) { + $hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($hit) { return $hit.FullName } + } + } + return '' +} + +function Get-DbFromEnv { + $line = Get-Content $EnvFile -ErrorAction SilentlyContinue | + Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1 + if ($line -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') { + return @{ User = $Matches[1]; Pass = [uri]::UnescapeDataString($Matches[2]) + Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] } + } + return $null +} + + +function Test-DatabaseEmpty { + <# + Is the TARGET DATABASE empty right now? + + This - not the presence of C:\shopdb-flask - is what decides whether it is + safe to force-drop tables. A rebuilt server pointed at a site's existing, + populated database has no app directory, so filesystem-based detection + called it a "fresh install" and ran prune-schema --yes --force, destroying + the rows of every unselected plugin with no backup taken. + + Returns $true ONLY when we positively confirm zero tables. Anything we + cannot determine returns $false, because the failure mode of guessing + "empty" is irreversible data loss and the failure mode of guessing + "populated" is merely a redundant backup. + #> + $probe = Join-Path $env:TEMP ("shopdb-dbprobe-{0}.py" -f (Get-Date -Format 'HHmmssfff')) + @' +import os, sys +try: + from sqlalchemy import create_engine, inspect + url = None + for line in open(".env", encoding="utf-8", errors="replace"): + if line.startswith("DATABASE_URL="): + url = line.split("=", 1)[1].strip() + if not url: + print("UNKNOWN"); sys.exit(0) + print("EMPTY" if len(inspect(create_engine(url)).get_table_names()) == 0 else "POPULATED") +except Exception: + print("UNKNOWN") +'@ | Set-Content -Path $probe -Encoding ASCII + try { + Push-Location $AppRoot + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { $out = & $Py $probe 2>&1 } finally { $ErrorActionPreference = $prev; Pop-Location } + $verdict = ($out | Where-Object { $_ -match '^(EMPTY|POPULATED|UNKNOWN)$' } | Select-Object -Last 1) + Write-Log "database probe: $verdict" + return ($verdict -eq 'EMPTY') + } + finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } +} + +function Backup-Database { + # Returns the backup path, or '' if it could not be taken. + param([string] $Reason = 'pre-upgrade') + $db = Get-DbFromEnv + if (-not $db) { Write-Log 'cannot read DATABASE_URL; skipping backup' 'WARN'; return '' } + $dump = Find-MysqlTool 'mysqldump.exe' + if (-not $dump) { Write-Log 'mysqldump not found; skipping backup' 'WARN'; return '' } + + $dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups' + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss')) + + Write-Log "backing up $($db.Name) before migrating" + # --single-transaction keeps it consistent without locking the whole server. + # Password goes in the argument array, never interpolated into a log line. + $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", + '--single-transaction', '--routines', '--triggers', $db.Name) + $so = [System.IO.Path]::GetTempFileName() + $se = [System.IO.Path]::GetTempFileName() + try { + # Start-Process -ArgumentList does NOT quote, so a password containing a + # space splits into several arguments and the dump fails. Quote anything + # with whitespace, exactly as Invoke-Native does. + $quoted = $args | ForEach-Object { + if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } + } + Write-Log (" exec mysqldump.exe ({0} args)" -f $args.Count) + $p = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow ` + -RedirectStandardOutput $so -RedirectStandardError $se + if ($p.ExitCode -ne 0) { + Write-Log "mysqldump exited $($p.ExitCode); no usable backup" 'WARN' + return '' + } + + # VALIDATE before anything relies on this file. mysqldump can exit 0 and + # still leave a truncated dump - a full disk, or a table it could not + # read part-way through. A backup that is trusted but unusable is worse + # than no backup at all, because migrations then proceed on its strength. + if (-not (Test-Path $so)) { Write-Log 'mysqldump produced no file' 'WARN'; return '' } + $len = (Get-Item $so).Length + if ($len -lt 512) { + Write-Log "backup is only $len bytes; refusing to treat it as valid" 'WARN'; return '' + } + $tail = @(Get-Content $so -Tail 5 -ErrorAction SilentlyContinue) + if (-not ($tail -match 'Dump completed')) { + Write-Log 'backup lacks the mysqldump completion marker - it is truncated' 'WARN' + return '' + } + + Move-Item $so $file -Force + $mb = [math]::Round((Get-Item $file).Length / 1MB, 1) + Write-Log "backup written and verified complete: $file ($mb MB)" 'OK' + return $file + } + finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue } +} + +function Restore-Database { + param([string] $BackupFile) + if (-not $BackupFile -or -not (Test-Path $BackupFile)) { + Write-Log 'no backup available to restore from' 'FAIL'; return $false + } + $db = Get-DbFromEnv + $mysql = Find-MysqlTool 'mysql.exe' + if (-not $db -or -not $mysql) { Write-Log 'cannot restore: client or .env missing' 'FAIL'; return $false } + Write-Log "restoring $($db.Name) from $BackupFile" 'WARN' + $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", $db.Name) + try { + # mysqldump emits DROP TABLE IF EXISTS before each CREATE, so replaying + # genuinely restores every table the dump contains. + Invoke-Native $mysql $args 'database restore' -StdinFile $BackupFile | Out-Null + } catch { Write-Log "restore failed: $($_.Exception.Message)" 'FAIL'; return $false } + + # What the replay CANNOT undo: tables the failed migration created that the + # dump knows nothing about. MySQL DDL is not transactional, so those CREATEs + # are already committed and no dump-replay removes them. + # + # We report them rather than dropping them. Dropping would mean deciding, on + # a machine holding a site's only copy of its data, that a table is safe to + # destroy - and the installer deliberately does not claim that authority. + # (Restoring into a side database instead is not available either: the app + # user has rights on this database only, not CREATE DATABASE.) + $dumped = @() + foreach ($line in (Get-Content $BackupFile -ErrorAction SilentlyContinue)) { + if ($line -match '^CREATE TABLE `([^`]+)`') { $dumped += $Matches[1] } + } + + $probe = Join-Path $env:TEMP ("shopdb-tables-{0}.py" -f (Get-Date -Format 'HHmmssfff')) + @' +import sys +try: + from sqlalchemy import create_engine, inspect + url = None + for line in open(".env", encoding="utf-8", errors="replace"): + if line.startswith("DATABASE_URL="): + url = line.split("=", 1)[1].strip() + for t in sorted(inspect(create_engine(url)).get_table_names()): + print("TABLE " + t) +except Exception: + pass +'@ | Set-Content -Path $probe -Encoding ASCII + $current = @() + try { + Push-Location $AppRoot + $prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { $current = @(& $Py $probe 2>&1 | Where-Object { $_ -match '^TABLE ' } | + ForEach-Object { $_.Substring(6).Trim() }) } + finally { $ErrorActionPreference = $prev; Pop-Location } + } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue } + + $residue = @($current | Where-Object { $dumped -notcontains $_ }) + + if ($residue.Count -eq 0) { + Write-Log 'data restored from backup; no leftover tables from the failed migration' 'OK' + } else { + # Say exactly what is true. The previous wording claimed the database was + # "restored to its previous state", which is a false assurance when the + # schema still carries changes the migration committed before it failed. + Write-Log 'data restored from backup, but the schema is NOT identical to before' 'WARN' + Write-Log (" {0} table(s) created by the failed migration remain:" -f $residue.Count) 'WARN' + $residue | ForEach-Object { Write-Log " $_" 'WARN' } + Write-Log ' they hold no data and are harmless to the running application,' 'WARN' + Write-Log ' but a DBA should drop them before the next upgrade attempt.' 'WARN' + } + Write-Log " backup used: $BackupFile" + return $true +} + +# ============================================================================= +# STAGE 0 - bundled MySQL 8.0 (greenfield sites only) +# ============================================================================= +# The MSI installs BINARIES ONLY: no service, no data directory, no config. The +# full sequence is msiexec -> write my.ini -> --initialize-insecure -> --install +# -> Start-Service -> bootstrap SQL. Every step below has bitten us once. +function Invoke-Stage0 { + Write-Log 'STAGE 0: bundled MySQL 8.0' 'STEP' + + $msi = Get-ChildItem (Join-Path $BundleRoot 'mysql') -Filter '*.msi' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $msi) { + Fail "no MySQL MSI in $BundleRoot\mysql" @' +Stage 0 installs the BUNDLED database. Add mysql\mysql-8.0.x-winx64.msi to the +bundle, or skip stage 0 and use the site's existing server (the common case). +'@ + } + + # Refuse to stack a second server on an existing one. Two servers fighting + # over 3306, or a silent takeover of the classic ASP app's database, is far + # worse than stopping here. + $existing = Get-Service -Name 'MySQL*' -ErrorAction SilentlyContinue + if ($existing) { + Fail ("MySQL service already present: {0}" -f (($existing | ForEach-Object Name) -join ', ')) @' +This box already runs MySQL. Do NOT install the bundled server on top of it: +it would collide on port 3306 and could disrupt the existing application. +Skip stage 0 and point the installer at the existing server with -DbHost. +'@ + } + $inUse = Get-NetTCPConnection -LocalPort $DbPort -State Listen -ErrorAction SilentlyContinue + if ($inUse) { Fail "port $DbPort is already in use" 'Free the port, or use the existing server.' } + + if ($WhatIfOnly) { Write-Log 'WhatIf: skipping MySQL install' 'WARN'; return } + + # --- binaries ----------------------------------------------------------- + $mysqld = Join-Path $MysqlRoot 'bin\mysqld.exe' + if (Test-Path $mysqld) { + # Re-running stage 0 after a partial failure must not re-run the MSI. + # Reinstalling an already-registered product is a no-op that STILL needs + # the Windows Installer service, and if msiserver is stopped the client + # blocks forever at 0% CPU with no transaction ever opening and no error. + # The binaries are what this step exists to produce, so if they are here, + # skip straight to configuration. Also makes the stage idempotent (T-005). + Write-Log "MySQL binaries already present at $MysqlRoot; skipping MSI" 'OK' + } + else { + # The Windows Installer service is Manual-start and is supposed to start + # on demand. When it does not, msiexec waits on it indefinitely - so start + # it explicitly rather than trusting on-demand activation. + $msiSvc = Get-Service msiserver -ErrorAction SilentlyContinue + if ($msiSvc -and $msiSvc.Status -ne 'Running') { + Write-Log 'starting Windows Installer service (msiserver)' + try { Start-Service msiserver -ErrorAction Stop } catch { + Write-Log "could not start msiserver: $($_.Exception.Message)" 'WARN' + } + } + # Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it. + Write-Log "installing $($msi.Name) (125MB, takes a minute)" + Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart', + "INSTALLDIR=$MysqlRoot") 'MySQL MSI' -TimeoutSec 900 + } + # An installer exit code of 0 does NOT mean it did what you asked - the Python + # bootstrapper returned 0 while installing to the wrong directory. Verify. + if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } + Track 'mysql-files' $MysqlRoot + Write-Log 'MySQL binaries installed' 'OK' + + # --- config ------------------------------------------------------------- + # Space-free paths on purpose: every quoting bug in this script came from a + # path containing a space being split into two arguments. + New-Item -ItemType Directory -Force -Path (Split-Path $MysqlIni -Parent) | Out-Null + $iniText = @" +[mysqld] +basedir=$MysqlRoot +datadir=$MysqlDataDir +port=$DbPort +bind-address=127.0.0.1 +character-set-server=utf8mb4 +collation-server=utf8mb4_unicode_ci +default-storage-engine=INNODB +innodb_file_per_table=1 +max_connections=200 +log-error=$MysqlDataDir\mysql-error.log +"@ + Set-Content -Path $MysqlIni -Value $iniText -Encoding ASCII + Write-Log "wrote $MysqlIni" + Track 'mysql-files' $MysqlIni + + # --- data directory ----------------------------------------------------- + # --initialize-insecure REFUSES a non-empty datadir, so a previous failed + # attempt must be cleared before retrying. + if (Test-Path $MysqlDataDir) { + $items = Get-ChildItem $MysqlDataDir -Force -ErrorAction SilentlyContinue + if ($items) { + Fail "$MysqlDataDir already exists and is not empty" @' +mysqld --initialize-insecure refuses a non-empty data directory. If this is a +failed previous run, remove the directory and re-run stage 0. If it holds real +data, STOP: you are about to destroy a database. +'@ + } + } + Write-Log 'initializing data directory' + Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' + Track 'mysql-datadir' $MysqlDataDir + + # --- service ------------------------------------------------------------ + # Argument ORDER MATTERS: --install BEFORE --defaults-file. Verified + # empirically - reversed, mysqld exits 1 and the service is not registered. + Write-Log "registering service $MysqlService" + Invoke-Native $mysqld @('--install', $MysqlService, "--defaults-file=$MysqlIni") 'MySQL service register' + Track 'service' $MysqlService + Start-Service -Name $MysqlService + $svc = Get-Service -Name $MysqlService + if ($svc.Status -ne 'Running') { Fail "service $MysqlService did not start" "Check $MysqlDataDir\mysql-error.log" } + Set-Service -Name $MysqlService -StartupType Automatic + Write-Log "service $MysqlService running" 'OK' + + # --- bootstrap ---------------------------------------------------------- + # --initialize-insecure leaves root with an EMPTY password, so this must run + # immediately. Generated, never supplied; root is shown once and not stored. + $rootPass = New-Secret -Bytes 24 + $appPass = New-Secret -Bytes 24 + # Written OUTSIDE %TEMP% and ACL'd BEFORE the secrets go in - a file created + # with default ACLs and populated afterwards is readable in the gap. + $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' + New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null + New-Item -ItemType File -Force -Path $sqlPath | Out-Null + Protect-File $sqlPath + $sql = @" +CREATE DATABASE IF NOT EXISTS $DbName CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER IF NOT EXISTS '$DbUser'@'localhost' IDENTIFIED BY '$appPass'; +CREATE USER IF NOT EXISTS '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass'; +GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost'; +GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1'; +ALTER USER 'root'@'localhost' IDENTIFIED BY '$rootPass'; +FLUSH PRIVILEGES; +"@ + Set-Content -Path $sqlPath -Value $sql -Encoding ASCII + try { + $mysqlExe = Join-Path $MysqlRoot 'bin\mysql.exe' + # -e "source " fails with ERROR 1049 Unknown database '': the + # client reads the path as a database name. Redirect stdin instead. That + # also stops PowerShell eating the backticks MySQL uses to quote identifiers. + Invoke-Native $mysqlExe @('-uroot', '--skip-password', '--protocol=TCP', + "--port=$DbPort") 'MySQL bootstrap' -StdinFile $sqlPath + } + finally { + # Unconditional: this file holds both passwords. + Set-Content -Path $sqlPath -Value ('0' * 512) -Encoding ASCII -ErrorAction SilentlyContinue + Remove-Item $sqlPath -Force -ErrorAction SilentlyContinue + } + + # Hand the app password to stage 2 through an ACL'd FILE, never stdout - + # stdout lands in the log that FR-161 invites operators to send to support. + $handoff = Join-Path $AppRoot '.dbpass' + New-Item -ItemType File -Force -Path $handoff | Out-Null + Protect-File $handoff + Set-Content -Path $handoff -Value $appPass -Encoding UTF8 + Write-Log "database $DbName and user $DbUser created" 'OK' + Write-Log "app password written to $handoff (stage 2 consumes and deletes it)" + + Write-Host '' + Write-Host ' MySQL root password (shown ONCE, not stored anywhere):' -ForegroundColor Yellow + Write-Host (" {0}" -f $rootPass) -ForegroundColor Yellow + Write-Host ' Write it down now. It cannot be recovered.' -ForegroundColor Yellow + Write-Host '' + Write-Log 'stage 0 complete' 'OK' +} + +# ============================================================================= +# STAGE 2 - runtime: Python, venv, wheels, .env +# ============================================================================= +function Invoke-Stage2 { + Write-Log 'STAGE 2: runtime and configuration' 'STEP' + + if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" } + if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Build it on Windows with the matching Python. See build-offline-bundle-native.ps1.' } + if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" } + + # --- Python, all users -------------------------------------------------- + # A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity + # cannot read. That produces a 500 with an empty HttpPlatform log. + $pyInstaller = Get-ChildItem (Join-Path $BundleRoot 'python') -Filter '*.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 + # SPACE-FREE deliberately. `TargetDir=C:\Program Files\Python314` splits at + # the space when passed to the bootstrapper and installs into C:\Program\. + # Same class of failure as MySQL's --defaults-file. Never put a space in a + # path this installer controls. + $pyTarget = 'C:\Python314' + if (Test-Path (Join-Path $pyTarget 'python.exe')) { + Write-Log "Python already present at $pyTarget" 'OK' + } elseif ($null -eq $pyInstaller) { + Fail "no Python installer in $BundleRoot\python" + } else { + Write-Log "installing Python (all users) from $($pyInstaller.Name)" + if (-not $WhatIfOnly) { + Invoke-Native $pyInstaller.FullName @( + '/quiet','InstallAllUsers=1','PrependPath=0','Include_launcher=1', + 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" + ) 'Python install' + Track 'python' $pyTarget + } + } + $sysPy = Join-Path $pyTarget 'python.exe' + if (-not $WhatIfOnly -and -not (Test-Path $sysPy)) { Fail "Python not at $sysPy after install" } + + # --- application payload ------------------------------------------------ + if (-not (Test-Path $AppRoot)) { + New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null + Track 'dir' $AppRoot + } + # --- upgrade detection --------------------------------------------------- + $script:BundleVersion = Get-BundleVersion + $script:InstalledVersion = Get-InstalledVersion + $script:IsUpgrade = ($script:InstalledVersion -ne '') + $script:DbWasEmpty = $false # established for real in stage 3 + + if ($script:IsUpgrade) { + Write-Log ("upgrading: installed {0} -> bundle {1}" -f $script:InstalledVersion, $script:BundleVersion) 'STEP' + $cmp = Compare-Version $script:BundleVersion $script:InstalledVersion + if ($cmp -lt 0) { + # Migrations only go forwards. Running older code against a newer + # schema fails in ways that are hard to unpick, so refuse outright. + Fail ("this bundle ({0}) is OLDER than what is installed ({1})" -f $script:BundleVersion, $script:InstalledVersion) @' +Downgrading is not supported: the database schema has already been migrated +forwards, and older code cannot read it. Install a build at least as new as +what is on this server, or restore a backup taken before the upgrade. +'@ + } + if ($cmp -eq 0) { + Write-Log 'same version already installed; re-running is harmless (repair)' 'WARN' + } + } else { + Write-Log ("installing version {0}" -f $script:BundleVersion) + } + + # UPGRADE PATH: on an existing install the app pool holds python.exe and the + # site files open, so copying over them fails with "file in use" partway + # through - leaving a half-replaced application. Stop the pool first and note + # that we did, so stage 4 knows to start it again. + $script:PoolWasStopped = $false + if (-not $WhatIfOnly) { + try { + Import-Module WebAdministration -ErrorAction Stop + if ((Test-Path "IIS:\AppPools\$AppPool") -and + ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')) { + Write-Log "stopping app pool $AppPool so its files can be replaced" 'WARN' + Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + # The worker does not exit instantly; copying too soon still fails. + for ($i = 0; $i -lt 15; $i++) { + Start-Sleep -Seconds 1 + if (-not (Get-Process w3wp -ErrorAction SilentlyContinue)) { break } + } + $script:PoolWasStopped = $true + } + } catch { } + } + + Write-Log "copying application payload to $AppRoot" + if (-not $WhatIfOnly) { + Copy-Item (Join-Path $AppSource '*') -Destination $AppRoot -Recurse -Force + } + # Pick the SPA build that matches the deployment method. Vite compiles the + # base path in, so the wrong one loads a page that then fetches its assets + # from the wrong prefix and renders nothing. + if (-not $WhatIfOnly) { + $rootDist = Join-Path $AppRoot 'frontend\dist' + $subDist = Join-Path $AppRoot 'frontend\dist-subpath' + if ($MountAlias) { + if (-not (Test-Path $subDist)) { + Fail 'this bundle has no subpath frontend build' @' +The bundle was built without the / SPA, so a subpath install would serve +a page that cannot load its own assets. Rebuild with scripts/build-site.sh +(which produces both), or install without -MountAlias. +'@ + } + # The alias is fixed at BUILD time; refuse rather than mis-serve. + $built = '' + $aliasFile = Join-Path $subDist '.alias' + if (Test-Path $aliasFile) { $built = (Get-Content $aliasFile -TotalCount 1).Trim() } + if ($built -and ($built -ne $MountAlias.Trim('/'))) { + Fail ("this bundle's subpath build is for '/{0}', not '/{1}'" -f $built, $MountAlias.Trim('/')) ` + 'Rebuild the bundle with SUBPATH_ALIAS set to the alias you want.' + } + Remove-Item $rootDist -Recurse -Force -ErrorAction SilentlyContinue + Move-Item $subDist $rootDist -Force + Write-Log ("using the /{0} frontend build" -f $MountAlias.Trim('/')) 'OK' + } else { + # Not needed for a root install; leave nothing confusing behind. + Remove-Item $subDist -Recurse -Force -ErrorAction SilentlyContinue + } + } + + if (-not $WhatIfOnly -and $script:BundleVersion) { + # Stamped AFTER the copy: if the copy dies, the stamp still reflects what + # is actually on disk rather than what we hoped to put there. + Set-Content -Path (Join-Path $AppRoot '.installed-version') ` + -Value $script:BundleVersion -Encoding ASCII + } + foreach ($sub in @('logs','instance')) { + $p = Join-Path $AppRoot $sub + if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null } + } + + # --- venv --------------------------------------------------------------- + if (Test-Path $Py) { + Write-Log 'venv already exists' 'OK' + } else { + Write-Log 'creating venv' + if (-not $WhatIfOnly) { + Invoke-Native $sysPy @('-m','venv',(Join-Path $AppRoot 'venv')) 'venv creation' + Track 'dir' (Join-Path $AppRoot 'venv') + } + } + + # --- offline dependency install ---------------------------------------- + # PIP_NO_INDEX makes a network attempt impossible rather than merely + # unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts. + Write-Log 'installing dependencies from the wheelhouse (offline)' + if (-not $WhatIfOnly) { + $env:PIP_NO_INDEX = '1' + $env:PIP_FIND_LINKS = $WheelDir + $env:PIP_DISABLE_PIP_VERSION_CHECK = '1' + try { + Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir", + '-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install' + } finally { + Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue + } + # waitress and tzdata are load-bearing on Windows; verify rather than assume. + foreach ($mod in @('waitress','tzdata')) { + & $Py -c "import $mod" 2>$null + if ($LASTEXITCODE -ne 0) { + Fail "$mod did not install" "The wheelhouse is incomplete. $mod is required (waitress is the WSGI server; tzdata provides the IANA timezone database Windows lacks)." + } + } + Write-Log 'waitress and tzdata present' 'OK' + } + + # --- .env --------------------------------------------------------------- + if (Test-Path $EnvFile) { + # Preserving .env is right for a plain re-run: regenerating JWT_SECRET_KEY + # would invalidate every issued session. + # + # But if the caller EXPLICITLY supplied connection details - a wizard where + # someone just typed a password, or -DbHost/-DbPasswordFile on the command + # line - then silently keeping the old DATABASE_URL ignores what they asked + # for and fails later with a bare "Access denied", pointing at the database + # instead of at the stale file that actually caused it. + # So: keep the secrets, refresh the connection string. + # Rewrite the connection string ONLY when a password was actually + # supplied. Previously any -DbHost (which the wizard always passes, from + # a page whose defaults were 127.0.0.1/shopdb_flask) rewrote DATABASE_URL + # - so a site whose database lives on another server had its real + # connection string silently replaced with defaults during an upgrade, + # and .env was the only record of it. + if ($DbPasswordFile) { + Write-Log '.env exists and new database details were supplied; updating DATABASE_URL' 'WARN' + $dbPass = '' + if ($DbPasswordFile) { + if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } + $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) + if ($dbPass) { $dbPass = $dbPass.Trim() } + if ($DbPasswordFile -ne (Join-Path $AppRoot '.dbpass')) { + Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force -ErrorAction SilentlyContinue + Remove-Item $DbPasswordFile -Force -ErrorAction SilentlyContinue + } + } + if (-not $dbPass) { Fail 'no database password supplied to update .env' } + if (-not $DbHost) { $DbHost = '127.0.0.1' } + $escUser = [uri]::EscapeDataString($DbUser) + $escPass = [uri]::EscapeDataString($dbPass) + $newUrl = "DATABASE_URL=mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" + # Keep a copy before touching it. .env holds the DB password in + # plaintext by design, so the backup gets the same owner-only ACL and + # is the operator's way back if the new details turn out wrong. + $envBak = "$EnvFile.bak-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss') + Copy-Item $EnvFile $envBak -Force -ErrorAction SilentlyContinue + if (Test-Path $envBak) { + Protect-File $envBak + Write-Log "previous .env kept at $envBak" + } + $kept = Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*DATABASE_URL=' } + Set-Content -Path $EnvFile -Value ($kept + $newUrl) -Encoding ASCII + Protect-File $EnvFile + Write-Log 'DATABASE_URL updated; SECRET_KEY and JWT_SECRET_KEY kept' 'OK' + } else { + Write-Log '.env exists and no new password was supplied; keeping it unchanged' 'OK' + Write-Log ' (its database settings and secret keys are left exactly as they are)' + } + + # MOUNT_PATH must ALWAYS track the chosen deployment method, whether or + # not the connection string changed. Switching from a subpath install + # back to its own site otherwise leaves MOUNT_PATH=/shopdb behind, and + # wsgi.py keeps mounting the app at /shopdb - so the new site answers + # 404 on every path and the smoke test fails on a correct install. + # CORS_ORIGINS is tied to the deployment method too: switching between + # methods changes the port people arrive on, and a stale value fails every + # browser request while curl (which does not send Origin) still works. + $wantOrigin = '' + if ($MountAlias) { + $parentPort = 80 + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } + } catch { } + if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME } + if ($parentPort -eq 80) { $wantOrigin = "http://{0}" -f $SiteHost } + else { $wantOrigin = "http://{0}:{1}" -f $SiteHost, $parentPort } + } elseif ($SiteHost) { + $wantOrigin = "http://{0}:{1}" -f $SiteHost, $SitePort + } + + $envLines = @(Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*MOUNT_PATH=' }) + if ($wantOrigin) { + $envLines = @($envLines | Where-Object { $_ -notmatch '^\s*CORS_ORIGINS=' }) + $envLines += ("CORS_ORIGINS={0}" -f $wantOrigin) + Write-Log "CORS_ORIGINS set to $wantOrigin" 'OK' + } + if ($MountAlias) { + $envLines += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) + Write-Log ("MOUNT_PATH set to /{0}" -f $MountAlias.Trim('/')) 'OK' + } else { + Write-Log 'MOUNT_PATH removed - the app serves from the site root' 'OK' + } + Set-Content -Path $EnvFile -Value ($envLines -join "`r`n") -Encoding ASCII + Protect-File $EnvFile + } else { + if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME } + + # Stage 0 leaves an ACL'd handoff file when it created the database itself. + # Prefer it: a greenfield install then needs no password from anyone, and + # the generated password never passes through a human or a command line. + # This MUST come before the -DbHost check below - stage 0 always installs + # to the local box, so the handoff implies the host as well as the password. + $handoff = Join-Path $AppRoot '.dbpass' + if (-not $DbPasswordFile -and (Test-Path $handoff)) { + $DbPasswordFile = $handoff + if (-not $DbHost) { $DbHost = '127.0.0.1' } + Write-Log 'using the bundled-MySQL password handoff from stage 0 (host 127.0.0.1)' + } + + if (-not $DbHost) { + Fail 'no database host supplied and no existing .env' @' +Pass -DbHost . The password is prompted for, never passed on the +command line. For a bundled-database install run stage 0 first: it creates the +database and leaves an ACL'd handoff file, after which no password is needed. +'@ + } + # Two ways in, both keeping the password off the command line (which any + # user can read via Win32_Process, and which lands in transcripts and + # ConsoleHost_history.txt). + if ($DbPasswordFile) { + # Unattended path. The caller writes an ACL'd file; we consume and + # shred it so it does not outlive the install. + if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" } + $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8) + if ($dbPass) { $dbPass = $dbPass.Trim() } + Write-Log "read DB password from $DbPasswordFile" + if ($DbPasswordFile -eq $handoff) { + # Do NOT shred the stage-0 handoff here. It holds the ONLY copy of a + # GENERATED password: .env is the sole other copy, and rollback + # deletes .env. Shredding both on a failed install leaves an + # unrecoverable box - the database exists, the app user exists, and + # nobody alive knows the password. Stage 5 shreds it once the + # install is proven working. + Write-Log 'keeping the stage-0 handoff until stage 5 confirms the install' + } else { + try { + # Operator-supplied file: they know the password, so consume it + # immediately. Overwrite before delete - a plain Remove-Item + # leaves the bytes on disk. + Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force + Remove-Item $DbPasswordFile -Force + Write-Log 'password file overwritten and removed' + } catch { Write-Log "could not remove $DbPasswordFile - delete it manually" 'WARN' } + } + } + else { + # Interactive path. Read-Host -AsSecureString reads the CONSOLE, not + # stdin: piping into it does not work, it just blocks forever with no + # error and no timeout. So refuse up front rather than hang a remote or + # scheduled run. + if (-not [Environment]::UserInteractive) { + Fail 'no interactive console for the password prompt' @' +This session has no console, so the password prompt would hang indefinitely +(Read-Host -AsSecureString reads the console directly and ignores piped stdin). +Re-run with -DbPasswordFile pointing at a file whose first line is the +password. The installer reads it, overwrites it and deletes it. +'@ + } + $sec = Read-Host -Prompt ("Password for MySQL user '{0}' on {1}" -f $DbUser, $DbHost) -AsSecureString + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) + try { $dbPass = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + } + if (-not $dbPass) { Fail 'no password entered' } + # Percent-encode: passwords routinely contain characters that break a URL. + $escUser = [uri]::EscapeDataString($DbUser) + $escPass = [uri]::EscapeDataString($dbPass) + $DatabaseUrl = "mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4" + # CORS_ORIGINS must be the ORIGIN PEOPLE ACTUALLY USE, and an explicit one - + # a wildcard makes the app refuse to boot. Under a subpath install the app + # is reached on the PARENT site's port (usually 80), so pinning the + # stand-alone port here would make every browser request fail CORS on a + # correctly installed server. Origin is scheme+host+port only - never the path. + if ($MountAlias) { + $parentPort = 80 + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } + } catch { } + if ($parentPort -eq 80) { $origin = "http://{0}" -f $SiteHost } + else { $origin = "http://{0}:{1}" -f $SiteHost, $parentPort } + } else { + $origin = "http://{0}:{1}" -f $SiteHost, $SitePort + } + $content = @( + 'FLASK_ENV=production' + ("SECRET_KEY={0}" -f (New-Secret)) + ("JWT_SECRET_KEY={0}" -f (New-Secret)) + ("DATABASE_URL={0}" -f $DatabaseUrl) + ("CORS_ORIGINS={0}" -f $origin) + 'LOG_LEVEL=INFO' + ) + if ($MountAlias) { + # wsgi.py reads MOUNT_PATH and moves the prefix from PATH_INFO to + # SCRIPT_NAME, so Flask both routes and GENERATES urls under it. + # Added to the ARRAY, before the join - appending to the joined string + # glues it onto the previous line ("LOG_LEVEL=INFOMOUNT_PATH=/shopdb"). + $content += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/')) + } + $content = $content -join "`r`n" + Write-Log "writing .env (CORS_ORIGINS=$origin)" + if (-not $WhatIfOnly) { + Set-Content -Path $EnvFile -Value $content -Encoding ASCII + Track 'file' $EnvFile + # .env holds the database password in plaintext BY DESIGN - the app + # reads it at boot. Filesystem permissions are the only control. + # Administrators and SYSTEM only; the app-pool identity gets Read in stage 4. + Protect-File $EnvFile + # Clear the plaintext copy from this session's memory. + $dbPass = $null; $content = $null + [GC]::Collect() + } + } + Write-Log 'stage 2 complete' 'OK' +} + +# ============================================================================= +# STAGE 3 - data: schema, seeds, plugins +# ============================================================================= +function Invoke-Stage3 { + Write-Log 'STAGE 3: schema, seed data, plugins' 'STEP' + if (-not (Test-Path $Flask)) { Fail 'venv not found; run stage 2 first' } + + # Resolve the site profile. An explicit -SiteProfile wins; otherwise use the + # one the bundle was built from, which scripts/build-site.sh stages alongside + # the app tree so the profile and the lean tree can never drift apart. + $ProfilePath = $SiteProfile + if (-not $ProfilePath) { $ProfilePath = Join-Path $AppRoot 'site-profile.json' } + if (-not (Test-Path $ProfilePath)) { + Fail "site profile not found: $ProfilePath" @' +The installer no longer carries a hardcoded plugin list - the site's set is +declared in a profile and the bundle is built lean for it. +Either pass -SiteProfile , or rebuild the bundle with +scripts/build-site.sh , which stages the profile into the app tree. +See deploy/site-profile.example.json for the format. +'@ + } + # Report the chosen set up front: it decides both what gets installed and what + # prune-schema drops, so it belongs in the log FR-161 sends to support. + try { + $declared = (Get-Content $ProfilePath -Raw | ConvertFrom-Json).plugins -join ', ' + Write-Log "site profile: $ProfilePath" + Write-Log "declared plugins: $declared" + } catch { Fail "site profile is not valid JSON: $ProfilePath" } + + $env:FLASK_APP = 'shopdb' + Push-Location $AppRoot + try { + # The app ships its own preflight. Use it rather than reimplementing: + # it checks Python, required env, DB connectivity and the MySQL 5.6 flags. + Write-Log 'running flask db-utils preflight' + # TRAP (documented in Invoke-Native, and hit again here): the app logs + # plugin initialisation to STDERR even on success. With 2>&1 and + # $ErrorActionPreference='Stop' PowerShell turns that into a terminating + # NativeCommandError, so a healthy preflight aborts the install with a + # plugin INFO line as the "error". Judge by the OUTPUT, not by stderr. + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { $pf = & $Flask db-utils preflight 2>&1 } + finally { $ErrorActionPreference = $prevEAP } + $pf | ForEach-Object { Write-Log " $_" } + # Match the preflight's own status column (" FAIL "), not any line + # that merely contains the word - plugin log lines can too. + $pfFailed = @($pf | Where-Object { $_ -match '^\s*FAIL\s' }).Count -gt 0 + if ($LASTEXITCODE -ne 0 -or $pfFailed) { + Fail 'application preflight reported failures' 'Fix the FAIL items above, then re-run stage 3.' + } + + if ($WhatIfOnly) { Write-Log 'WhatIf: skipping migrations and seeds' 'WARN'; return } + + # Decide fresh-vs-existing from the DATABASE, and do it HERE rather than + # relying on a variable set in stage 2 - stage 3 is documented as + # separately runnable ("re-run stage 3"), and under Set-StrictMode + # reading an unset $script:IsUpgrade is a terminating error. + $script:DbWasEmpty = Test-DatabaseEmpty + if (-not $script:DbWasEmpty) { + $script:IsUpgrade = $true + Write-Log 'existing data found in the target database - treating this as an upgrade' 'WARN' + } + + # Back up BEFORE Alembic touches anything. Skipped only when the database + # is provably empty; on anything else it is the only thing standing + # between a failed migration and a half-migrated database. + $script:PreUpgradeBackup = '' + if (-not $script:DbWasEmpty) { + $script:PreUpgradeBackup = Backup-Database 'pre-upgrade' + if (-not $script:PreUpgradeBackup) { + Fail 'could not back up the database before upgrading' @' +Refusing to migrate without a backup. Fix mysqldump access (or take a backup +manually with shopdb-admin.ps1 backup) and run this again. +'@ + } + } + + # Alembic owns the schema. Nothing else may create tables. + Write-Log 'flask db upgrade' + try { + Invoke-Native $Flask @('db','upgrade') 'schema upgrade' + } catch { + if ($script:PreUpgradeBackup) { + Write-Log 'migration failed - putting the database back' 'FAIL' + if (Restore-Database $script:PreUpgradeBackup) { + Fail 'the upgrade failed; your data was restored from the pre-upgrade backup' @' +Your DATA is intact. The schema may still carry tables the failed migration +created before it stopped - the log lists any of them, and they are empty. +The application files on disk are the NEW version, so re-install the PREVIOUS +build to get a matching pair, then send the install log to support. +'@ + } + } + throw + } + + foreach ($seed in @('reference-data','permissions','settings')) { + Write-Log "flask seed $seed" + Invoke-Native $Flask @('seed',$seed) "seed $seed" + } + + # Plugin registry starts empty on a fresh box. apply-profile resolves the + # declared set's hard-dependency closure and installs + enables it in + # dependency order, idempotently (ADR-013). It fails loudly if the profile + # names a plugin that is not on disk - which on a lean bundle correctly + # catches a profile/bundle mismatch. + # An install-time selection overrides the bundled profile. Written here, + # after the payload copy, so it survives stage 2 replacing the app tree. + if ($SitePlugins) { + $chosen = @($SitePlugins -split ',' | ForEach-Object { $_.Trim() } | + Where-Object { $_ }) + $onDisk = @(Get-ChildItem (Join-Path $AppRoot 'plugins') -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } | + Select-Object -ExpandProperty Name) + $missing = @($chosen | Where-Object { $onDisk -notcontains $_ }) + if ($missing.Count -gt 0) { + Fail ("these plugins are not in this bundle: {0}" -f ($missing -join ', ')) ` + 'Rebuild the installer from a profile that includes them.' + } + $profileJson = @{ site = $SiteHost; plugins = $chosen; locked = @() } | + ConvertTo-Json -Depth 3 + Set-Content -Path $ProfilePath -Value $profileJson -Encoding ASCII + Write-Log ("site profile rewritten from the installer selection: {0}" -f ($chosen -join ', ')) 'OK' + } + + Write-Log "flask plugin apply-profile $ProfilePath" + Invoke-Native $Flask @('plugin','apply-profile',$ProfilePath) 'apply site profile' + + # Per-plugin Alembic chains (ADR-008) run after the plugins are registered. + Write-Log 'flask plugin upgrade-all' + Invoke-Native $Flask @('plugin','upgrade-all') 'plugin migrations' + + # ADR-014 Phase 2. The shared core Alembic baseline creates EVERY plugin's + # tables regardless of this site's choice, so a lean site still carries the + # omitted plugins' (empty) tables. Drop them, leaving core + chosen. + # + # Both flags are required and they mean different things: --yes executes + # (the command is a dry-run preview otherwise), --force permits dropping a + # table that holds rows. --force is needed even on a fresh install because + # core migrations seed a few plugin reference tables (e.g. 7d05 inserts + # default access protocols). Safe here and ONLY here: this runs during + # initial provisioning, before any site data exists. + # NOTE: ADR-014's prose says lean provisioning "uses --force"; that alone + # would silently do nothing but print a preview. It needs both. + # --force drops tables that CONTAIN ROWS. That is required on a fresh + # install, because core migrations seed a few plugin reference tables + # (7d05 inserts default access protocols) and prune would otherwise + # refuse. On an UPGRADE the same flag would silently destroy a site's + # data for any plugin that is not installed - so never force there. + # Without --force, prune-schema refuses non-empty tables and says so, + # which is the behaviour we want when data exists. + if (-not $script:DbWasEmpty) { + Write-Log 'flask plugin prune-schema --yes (no --force: refuses to drop tables holding data)' + $pruneOut = Invoke-Native $Flask @('plugin','prune-schema','--yes') 'prune not-installed plugin tables' + $skipped = @($pruneOut | Where-Object { $_ -match 'refus|not empty|rows' }) + if ($skipped.Count -gt 0) { + Write-Log 'some plugin tables were kept because they hold data:' 'WARN' + $skipped | ForEach-Object { Write-Log " $_" 'WARN' } + Write-Log ' drop them by hand only if you are certain that data is not needed' 'WARN' + } + } else { + # Provably-empty database only: core migrations seed a few plugin + # reference tables, so prune would otherwise refuse. + Write-Log 'flask plugin prune-schema --yes --force (database was empty at start)' + Invoke-Native $Flask @('plugin','prune-schema','--yes','--force') 'prune not-installed plugin tables' + } + } + finally { Pop-Location } + Write-Log 'stage 3 complete' 'OK' +} + +# ============================================================================= +# STAGE 4 - IIS +# ============================================================================= +function Invoke-Stage4 { + Write-Log 'STAGE 4: IIS site' 'STEP' + Import-Module WebAdministration -ErrorAction Stop + $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe' + + # HttpPlatformHandler is what actually launches waitress; IIS cannot serve + # this application without it. The MSI ships in the bundle but nothing ever + # installed it, so a clean server ran all the way to the stage 5 smoke test - + # Python, venv, schema, plugins and IIS all mutated - and only then failed. + # It must also come BEFORE the section unlock below: system.webServer/ + # httpPlatform does not exist until the module is registered. + $hphDll = Join-Path $env:windir 'system32\inetsrv\httpplatformhandler.dll' + if (Test-Path $hphDll) { + Write-Log 'HttpPlatformHandler already installed' 'OK' + } else { + $hphMsi = Get-ChildItem (Join-Path $BundleRoot 'httpplatformhandler') -Filter '*.msi' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $hphMsi) { + Fail 'HttpPlatformHandler is not installed and no MSI is in the bundle' @' +IIS cannot run this application without the HttpPlatformHandler module. +Add httpplatformhandler\httpPlatformHandler_amd64.msi to the bundle, or install +the module by hand, then re-run stage 4. +'@ + } + Write-Log "installing $($hphMsi.Name)" + if (-not $WhatIfOnly) { + Invoke-Native 'msiexec.exe' @('/i', $hphMsi.FullName, '/quiet', '/norestart') ` + 'HttpPlatformHandler MSI' -TimeoutSec 600 + if (-not (Test-Path $hphDll)) { + Fail 'the HttpPlatformHandler MSI reported success but the module is missing' ` + 'An installer exit code of 0 does not prove it did what was asked.' + } + Write-Log 'HttpPlatformHandler installed' 'OK' + } + } + + # web.config ships in the repo; only its paths need correcting. + $srcCfg = Join-Path $AppRoot 'deploy\windows\web.config' + $dstCfg = Join-Path $AppRoot 'web.config' + if (Test-Path $srcCfg) { + Write-Log 'installing web.config' + if (-not $WhatIfOnly) { + $cfg = Get-Content $srcCfg -Raw + $cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot) + Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8 + Track 'file' $dstCfg + } + } elseif (-not (Test-Path $dstCfg)) { + Fail "web.config not found at $srcCfg or $dstCfg" + } + + # App pool: No Managed Code. This is a Python app; the CLR must not load. + if (Test-Path "IIS:\AppPools\$AppPool") { + Write-Log "app pool $AppPool already exists" 'OK' + } else { + Write-Log "creating app pool $AppPool" + if (-not $WhatIfOnly) { + New-WebAppPool -Name $AppPool | Out-Null + Set-ItemProperty "IIS:\AppPools\$AppPool" -Name managedRuntimeVersion -Value '' + Track 'apppool' $AppPool + } + } + + # ACLs. RX on the tree; Modify on logs\ and instance\ or plugin toggles and + # uploads fail with "internal error". Read on .env for the pool identity. + if (-not $WhatIfOnly) { + $ident = "IIS AppPool\$AppPool" + Write-Log "granting $ident access" + Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' + foreach ($sub in @('logs','instance')) { + Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" + } + Invoke-Native 'icacls.exe' @($EnvFile,'/grant',"${ident}:(R)") 'ACL on .env' + } + + # Handler sections are locked server-wide by default. Without unlocking, + # IIS returns 500.19 the moment it reads the app's web.config. + foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) { + Write-Log "unlocking $section" + if (-not $WhatIfOnly) { + & $appcmd unlock config /section:$section 2>&1 | ForEach-Object { Write-Log " $_" } + } + } + + # Switching method must not leave BOTH deployments in place: two entry points + # to one directory, one of them serving an SPA built for the wrong base path. + # Remove whichever artifact belongs to the method we are NOT using. + if (-not $WhatIfOnly) { + if ($MountAlias) { + if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + Write-Log "removing the previous stand-alone site '$SiteName' (now published as a subpath)" 'WARN' + Remove-Website -Name $SiteName -ErrorAction SilentlyContinue + $oldRule = "$SiteName $SitePort" + Get-NetFirewallRule -DisplayName $oldRule -ErrorAction SilentlyContinue | + Remove-NetFirewallRule -ErrorAction SilentlyContinue + } + } else { + foreach ($site in (Get-Website)) { + foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) { + if ($app.PhysicalPath -eq $AppRoot) { + $name = $app.Path.Trim('/') + Write-Log "removing the previous subpath application '/$name' (now its own site)" 'WARN' + Remove-WebApplication -Site $site.Name -Name $name -ErrorAction SilentlyContinue + } + } + } + } + } + + if ($MountAlias) { + # METHOD B: an IIS Application under an existing site, reached at + # http://// - no new DNS name and no port in the URL. + # The app's own handler mappings apply only inside the Application, so the + # parent site's handlers (classic ASP, static files) are untouched. + $alias = $MountAlias.Trim('/') + if (-not (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue)) { + Fail "parent site '$ParentSite' does not exist" ` + 'Pass -ParentSite with the name of an existing IIS site, or install without -MountAlias.' + } + $existing = Get-WebApplication -Site $ParentSite -Name $alias -ErrorAction SilentlyContinue + if ($existing) { + Write-Log "application /$alias already exists under '$ParentSite'" 'OK' + } else { + Write-Log "creating application /$alias under '$ParentSite'" + if (-not $WhatIfOnly) { + New-WebApplication -Site $ParentSite -Name $alias -PhysicalPath $AppRoot ` + -ApplicationPool $AppPool | Out-Null + Track 'webapp' ("{0}/{1}" -f $ParentSite, $alias) + } + } + # No firewall rule: the parent site's port is already reachable, which is + # the whole point of this method. + Write-Log "no firewall change needed - served on '$ParentSite' existing bindings" 'OK' + } + else { + # METHOD A: its own site on $SitePort. + if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + Write-Log "site $SiteName already exists" 'OK' + } else { + Write-Log "creating site $SiteName on port $SitePort" + if (-not $WhatIfOnly) { + New-Website -Name $SiteName -Port $SitePort -PhysicalPath $AppRoot -ApplicationPool $AppPool | Out-Null + Track 'site' $SiteName + } + } + + $ruleName = "$SiteName $SitePort" + if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) { + Write-Log 'firewall rule already exists' 'OK' + } else { + Write-Log "adding firewall rule for TCP $SitePort" + if (-not $WhatIfOnly) { + New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP ` + -LocalPort $SitePort -Action Allow | Out-Null + Track 'firewall' $ruleName + } + } + + if (-not $WhatIfOnly) { Start-Website -Name $SiteName -ErrorAction SilentlyContinue } + } + # Stage 2 stops the pool on an upgrade so its files can be replaced. Nothing + # else starts it again, so the smoke test would fail against a stopped site + # and report it as a broken install. + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + if (Test-Path "IIS:\AppPools\$AppPool") { + if ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started') { + Write-Log "starting app pool $AppPool" + Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + } + $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue + if ($site -and $site.State -ne 'Started') { + Write-Log "starting site $SiteName" + Start-Website -Name $SiteName -ErrorAction SilentlyContinue + } + } catch { Write-Log "could not start the site: $($_.Exception.Message)" 'WARN' } + + Write-Log 'stage 4 complete' 'OK' +} + +# ============================================================================= +# STAGE 5 - verify and hand off +# ============================================================================= +function Invoke-Stage5 { + Write-Log 'STAGE 5: smoke test' 'STEP' + if ($WhatIfOnly) { Write-Log 'WhatIf: skipping smoke test' 'WARN'; return } + + # Stages can be run one at a time, so do not assume stage 4 just ran. + try { + Import-Module WebAdministration -ErrorAction SilentlyContinue + if ((Test-Path "IIS:\AppPools\$AppPool") -and + ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started')) { + Write-Log "app pool was stopped; starting it before the smoke test" 'WARN' + Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + } + } catch { } + + # FR-150: test the host operators will actually use, not just the loopback. + # localhost alone passes even when the app is unreachable by name and when + # CORS_ORIGINS names a host that does not resolve - so the SPA would fail its + # first XHR on a site the installer just called healthy. + $hostName = if ($SiteHost) { $SiteHost } else { $env:COMPUTERNAME } + # Under method B the app answers on the PARENT site's port, at the alias - + # requesting http://localhost:8090/ would test a site that does not exist. + if ($MountAlias) { + $alias = $MountAlias.Trim('/') + $parentPort = 80 + try { + $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection | + Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1 + if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] } + } catch { } + $targets = @("http://localhost:$parentPort/$alias/") + if ($hostName -and $hostName -ne 'localhost') { + $targets += "http://{0}:{1}/{2}/" -f $hostName, $parentPort, $alias + } + } else { + $targets = @("http://localhost:$SitePort/") + if ($hostName -and $hostName -ne 'localhost') { + $targets += "http://{0}:{1}/" -f $hostName, $SitePort + } + } + + # First request boots the app and connects to MySQL: the runbook records ~15s. + Write-Log "requesting $($targets[0]) (first request takes ~15s while the app boots)" + $ok = $false + for ($i = 1; $i -le 12; $i++) { + try { + $r = Invoke-WebRequest -Uri $targets[0] -UseBasicParsing -TimeoutSec 20 + if ($r.StatusCode -eq 200) { $ok = $true; break } + } catch { + Start-Sleep -Seconds 5 + } + } + if (-not $ok) { + Fail "site did not return 200 at $($targets[0])" ` + "Check $AppRoot\logs. Empty HttpPlatform log usually means the app-pool identity cannot read $AppRoot or run the venv, or .env is missing/invalid." + } + Write-Log 'site responded 200 on localhost' 'OK' + + # The app is up by now, so the configured-host check needs no boot retry. A + # failure here is a name-resolution or firewall problem, not a broken install, + # so warn rather than Fail: rolling back a working install would be worse. + foreach ($t in $targets[1..($targets.Count - 1)]) { + try { + $r = Invoke-WebRequest -Uri $t -UseBasicParsing -TimeoutSec 20 + if ($r.StatusCode -eq 200) { Write-Log "site responded 200 on $t" 'OK' } + else { Write-Log "unexpected status $($r.StatusCode) at $t" 'WARN' } + } catch { + Write-Log "site did NOT respond at $t - CORS_ORIGINS names this host, so the SPA will fail its first request from other machines" 'WARN' + Write-Log " check DNS/hosts for '$hostName' and the firewall rule on TCP $SitePort" 'WARN' + } + } + + # The install is proven working, so the stage-0 handoff is no longer the only + # copy of the generated password (.env has it and the app is running on it). + # Safe to shred now, and only now - see the note in stage 2. + $handoff = Join-Path $AppRoot '.dbpass' + if (Test-Path $handoff) { + try { + Set-Content -Path $handoff -Value ('0' * 256) -Encoding UTF8 -Force + Remove-Item $handoff -Force + Write-Log 'stage-0 password handoff shredded (install verified)' 'OK' + } catch { Write-Log "could not remove $handoff - delete it manually" 'WARN' } + } + + Write-Host '' + Write-Host ' ShopDB-Flask is installed.' -ForegroundColor Green + Write-Host '' + # FR-151: send the operator to /login, not to /. The site root renders the + # anonymous dashboard; the first-run gate lives on the login route, so an + # operator landing on / sees an empty dashboard and no way to discover setup. + if ($MountAlias) { + Write-Host (" Open: http://{0}/{1}/login" -f $hostName, $MountAlias.Trim('/')) + } else { + Write-Host (" Open: http://{0}:{1}/login" -f $hostName, $SitePort) + } + Write-Host '' + Write-Host ' With no user in the database that page offers to create the first' + Write-Host ' administrator, then runs the setup wizard (site details, features,' + Write-Host ' floor map, common vendors).' + Write-Host '' + Write-Host ' Headless alternative:' + Write-Host (" cd {0}" -f $AppRoot) + Write-Host ' venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example' + Write-Host ' (the password is generated and printed once)' + Write-Host '' + Write-Host (" Install log: {0}" -f $script:LogPath) + Write-Host '' +} + +# ============================================================================= +# Rollback +# ============================================================================= +function Invoke-Rollback { + Write-Log 'rolling back what this run created' 'WARN' + # Reverse order. Never touches an external database, and never removes a + # data directory without explicit confirmation. + for ($i = $script:Created.Count - 1; $i -ge 0; $i--) { + $item = $script:Created[$i] + try { + switch ($item.Kind) { + 'site' { Remove-Website -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed site $($item.Id)" } + 'webapp' { + $parts = $item.Id -split '/', 2 + Remove-WebApplication -Site $parts[0] -Name $parts[1] -ErrorAction SilentlyContinue + Write-Log " removed application /$($parts[1]) under '$($parts[0])'" + } + 'apppool' { Remove-WebAppPool -Name $item.Id -ErrorAction SilentlyContinue; Write-Log " removed app pool $($item.Id)" } + 'firewall' { Remove-NetFirewallRule -DisplayName $item.Id -ErrorAction SilentlyContinue; Write-Log " removed firewall rule" } + 'file' { Remove-Item $item.Id -Force -ErrorAction SilentlyContinue; Write-Log " removed $($item.Id)" } + 'dir' { Write-Log " left directory $($item.Id) in place (remove manually if wanted)" 'WARN' } + 'python' { Write-Log " left Python install in place at $($item.Id)" 'WARN' } + 'service' { + # Stop and deregister a service THIS run created. Safe because + # stage 0 refuses to run when any MySQL service already exists. + Stop-Service -Name $item.Id -Force -ErrorAction SilentlyContinue + & sc.exe delete $item.Id | Out-Null + Write-Log " removed service $($item.Id)" + } + 'mysql-files' { Write-Log " left MySQL binaries/config at $($item.Id) (remove manually if wanted)" 'WARN' } + # FR-162a: never delete a database directory automatically. Even on + # a failed run the operator may have put data in it, and a wrong + # guess here is unrecoverable. + 'mysql-datadir' { + Write-Log " LEFT MySQL data directory $($item.Id) IN PLACE" 'WARN' + Write-Log ' delete it manually only if you are certain it holds no data' 'WARN' + } + } + } catch { Write-Log " rollback of $($item.Kind) $($item.Id) failed: $($_.Exception.Message)" 'WARN' } + } +} + +# ============================================================================= +# Uninstall +# ============================================================================= +# Deliberately conservative. It removes what the installer creates and NEVER +# touches data without a typed confirmation: no dropped databases, no deleted +# data directory, no uninstalled MySQL that might serve another application. +function Invoke-Uninstall { + Write-Log 'UNINSTALL' 'STEP' + Write-Host '' + Write-Host ' This removes the ShopDB-Flask IIS site, app pool, firewall rule and' -ForegroundColor Yellow + Write-Host (" the application directory {0}." -f $AppRoot) -ForegroundColor Yellow + Write-Host ' It does NOT drop any database and does NOT uninstall MySQL.' -ForegroundColor Yellow + Write-Host '' + if ($OnFailure -ne 'never') { + $answer = Read-Host 'Type UNINSTALL to proceed' + if ($answer -ne 'UNINSTALL') { Write-Log 'aborted by operator'; return } + } + + Import-Module WebAdministration -ErrorAction SilentlyContinue + + if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) { + Remove-Website -Name $SiteName -ErrorAction SilentlyContinue + Write-Log "removed site $SiteName" 'OK' + } else { Write-Log "site $SiteName not present" } + + if (Test-Path "IIS:\AppPools\$AppPool") { + # Stop first: a running worker holds the app directory open and the + # subsequent Remove-Item then fails with "file in use". + Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + Remove-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue + Write-Log "removed app pool $AppPool" 'OK' + } else { Write-Log "app pool $AppPool not present" } + + $rule = "ShopDB-Flask $SitePort" + if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) { + Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue + Write-Log "removed firewall rule '$rule'" 'OK' + } else { Write-Log "firewall rule '$rule' not present" } + + if (Test-Path $AppRoot) { + # .env holds the database password in plaintext by design (the app reads it + # at boot), so shred it rather than just unlinking. + $envPath = Join-Path $AppRoot '.env' + if (Test-Path $envPath) { + Set-Content -Path $envPath -Value ('0' * 1024) -ErrorAction SilentlyContinue + Write-Log 'overwrote .env before deletion (it holds the DB password)' + } + try { + Remove-Item $AppRoot -Recurse -Force -ErrorAction Stop + Write-Log "removed $AppRoot" 'OK' + } catch { + Write-Log "could not fully remove $AppRoot : $($_.Exception.Message)" 'WARN' + Write-Log ' a running worker process usually holds it; try again after iisreset' 'WARN' + } + } else { Write-Log "$AppRoot not present" } + + Write-Host '' + Write-Host ' Uninstalled.' -ForegroundColor Green + Write-Host '' + Write-Host (" The database '{0}' was NOT dropped." -f $DbName) + Write-Host ' MySQL was NOT uninstalled or modified.' + Write-Host ' Remove either manually if this box is being decommissioned.' + Write-Host '' +} + +# ============================================================================= +# Main +# ============================================================================= +Write-Log "ShopDB-Flask installer, stage=$Stage, approot=$AppRoot, bundle=$BundleRoot" 'STEP' +if ($WhatIfOnly) { Write-Log 'WhatIfOnly: no changes will be made' 'WARN' } + +try { + switch ($Stage) { + '0' { Invoke-Stage0 } + '1' { & (Join-Path $PSScriptRoot 'shopdb-preflight.ps1') -SitePort $SitePort -AppRoot $AppRoot } + '2' { Invoke-Stage2 } + '3' { Invoke-Stage3 } + '4' { Invoke-Stage4 } + '5' { Invoke-Stage5 } + # Stage 0 is NOT in 'all' on purpose: most sites already run MySQL, and + # installing a second server on top of an existing one is destructive. + 'all' { Invoke-Stage2; Invoke-Stage3; Invoke-Stage4; Invoke-Stage5 } + 'uninstall' { Invoke-Uninstall } + } + Write-Log "done. log: $script:LogPath" 'OK' + exit 0 +} +catch { + Write-Log $_.Exception.Message 'FAIL' + if ($script:Created.Count -gt 0) { + $doRollback = $false + if ($OnFailure -eq 'always') { $doRollback = $true } + elseif ($OnFailure -eq 'ask') { + Write-Host '' + $answer = Read-Host 'Roll back what this run created? (yes/no)' + $doRollback = ($answer -eq 'yes') + } + if ($doRollback) { Invoke-Rollback } + else { Write-Log 'rollback declined; partial install left in place' 'WARN' } + } + Write-Log "log: $script:LogPath" 'FAIL' + exit 1 +} diff --git a/deploy/windows/installer/shopdb-preflight.ps1 b/deploy/windows/installer/shopdb-preflight.ps1 new file mode 100644 index 0000000..f5856a3 --- /dev/null +++ b/deploy/windows/installer/shopdb-preflight.ps1 @@ -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 ('
$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 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 } diff --git a/deploy/windows/installer/shopdb.ico b/deploy/windows/installer/shopdb.ico new file mode 100644 index 0000000000000000000000000000000000000000..82df68ac009028ea201167bb43fba957d5d0d059 GIT binary patch literal 52695 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 literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/swatch-bad.bmp b/deploy/windows/installer/swatch-bad.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c3a747a8aefd4ba845dcffad3bd5be9d581168eb GIT binary patch literal 822 ucmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyVkFg4U5yG#UaUHUt1?PZ1UX literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/swatch-ok.bmp b/deploy/windows/installer/swatch-ok.bmp new file mode 100644 index 0000000000000000000000000000000000000000..16ac2a4be866bcf4526bc838438830cc6d6c6b16 GIT binary patch literal 822 ucmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyVkFcfHgo8V!LF8v+0$t>*^- literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/swatch-warn.bmp b/deploy/windows/installer/swatch-warn.bmp new file mode 100644 index 0000000000000000000000000000000000000000..109b7746305c542f3a2d7eff03bf7de2eeba854a GIT binary patch literal 822 tcmZ?rHDhJ~12Z700mK4O%*Y@C7H0s;AK`;whyakBcWo4nhQNpo0RVAEcNPEu literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-image.bmp b/deploy/windows/installer/wizard-image.bmp new file mode 100644 index 0000000000000000000000000000000000000000..1c8fd44ea50bc17dfb35c040849b9d8d170c7a01 GIT binary patch literal 154542 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 C_HPmZ literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-image@125.bmp b/deploy/windows/installer/wizard-image@125.bmp new file mode 100644 index 0000000000000000000000000000000000000000..a3b9c771ca66df25f330eaea10ea5af088f8d7f0 GIT binary patch literal 222390 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 G!2bt;>4TL3 literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-image@250.bmp b/deploy/windows/installer/wizard-image@250.bmp new file mode 100644 index 0000000000000000000000000000000000000000..f074efc667352587d1b9b0767a7dc0a4347d78f4 GIT binary patch literal 889398 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 literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-small.bmp b/deploy/windows/installer/wizard-small.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c064afbcb3715c71fa9446d41c6fd8def8ab25cd GIT binary patch literal 9294 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 Mp98AMBL?*OH~X!G7XSbN literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-small@125.bmp b/deploy/windows/installer/wizard-small@125.bmp new file mode 100644 index 0000000000000000000000000000000000000000..9bc049646bfa036b2561842391c0f15d7e2b2399 GIT binary patch literal 12342 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 HID!8Hz@b#y literal 0 HcmV?d00001 diff --git a/deploy/windows/installer/wizard-small@250.bmp b/deploy/windows/installer/wizard-small@250.bmp new file mode 100644 index 0000000000000000000000000000000000000000..a8cd3df6e390ce1a82ecbe8f367e7e01e1b89624 GIT binary patch literal 57462 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