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 0000000..82df68a Binary files /dev/null and b/deploy/windows/installer/shopdb.ico differ diff --git a/deploy/windows/installer/swatch-bad.bmp b/deploy/windows/installer/swatch-bad.bmp new file mode 100644 index 0000000..c3a747a Binary files /dev/null and b/deploy/windows/installer/swatch-bad.bmp differ diff --git a/deploy/windows/installer/swatch-ok.bmp b/deploy/windows/installer/swatch-ok.bmp new file mode 100644 index 0000000..16ac2a4 Binary files /dev/null and b/deploy/windows/installer/swatch-ok.bmp differ diff --git a/deploy/windows/installer/swatch-warn.bmp b/deploy/windows/installer/swatch-warn.bmp new file mode 100644 index 0000000..109b774 Binary files /dev/null and b/deploy/windows/installer/swatch-warn.bmp differ diff --git a/deploy/windows/installer/wizard-image.bmp b/deploy/windows/installer/wizard-image.bmp new file mode 100644 index 0000000..1c8fd44 Binary files /dev/null and b/deploy/windows/installer/wizard-image.bmp differ diff --git a/deploy/windows/installer/wizard-image@125.bmp b/deploy/windows/installer/wizard-image@125.bmp new file mode 100644 index 0000000..a3b9c77 Binary files /dev/null and b/deploy/windows/installer/wizard-image@125.bmp differ diff --git a/deploy/windows/installer/wizard-image@250.bmp b/deploy/windows/installer/wizard-image@250.bmp new file mode 100644 index 0000000..f074efc Binary files /dev/null and b/deploy/windows/installer/wizard-image@250.bmp differ diff --git a/deploy/windows/installer/wizard-small.bmp b/deploy/windows/installer/wizard-small.bmp new file mode 100644 index 0000000..c064afb Binary files /dev/null and b/deploy/windows/installer/wizard-small.bmp differ diff --git a/deploy/windows/installer/wizard-small@125.bmp b/deploy/windows/installer/wizard-small@125.bmp new file mode 100644 index 0000000..9bc0496 Binary files /dev/null and b/deploy/windows/installer/wizard-small@125.bmp differ diff --git a/deploy/windows/installer/wizard-small@250.bmp b/deploy/windows/installer/wizard-small@250.bmp new file mode 100644 index 0000000..a8cd3df Binary files /dev/null and b/deploy/windows/installer/wizard-small@250.bmp differ