Release 0.8.0: Windows installer, twelve fixes from a real Server 2019 install, operator docs
This commit is contained in:
@@ -53,7 +53,9 @@ bundle/wheels/ pip download -r requirements.txt --only-binary=:all
|
||||
bundle/python/ python-3.14.x-amd64.exe
|
||||
bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
|
||||
bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below)
|
||||
bundle/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)
|
||||
bundle/vcredist/ VC_redist.x64.exe (MySQL will not install without it)
|
||||
bundle/mysql/ mysql-8.4.x-winx64.msi (bundled-database option only)
|
||||
bundle/mysqlclient/ mysql.exe, mysqldump.exe + libcrypto/libssl (backups)
|
||||
```
|
||||
|
||||
Then compile on Windows:
|
||||
@@ -235,7 +237,7 @@ 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
|
||||
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.4 LTS and
|
||||
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
|
||||
rollback, uninstall, and both deployment methods including switching between
|
||||
them.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
;
|
||||
; 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
|
||||
; on Windows Server 2025 against both a bundled MySQL 8.4 LTS 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.
|
||||
@@ -181,6 +181,7 @@ Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
|
||||
var
|
||||
DbChoicePage: TInputOptionWizardPage;
|
||||
DbDetailsPage: TInputQueryWizardPage;
|
||||
DbCredsPage: TInputQueryWizardPage;
|
||||
SitePage: TInputQueryWizardPage;
|
||||
PreflightPage: TWizardPage;
|
||||
BannerPanel: TPanel;
|
||||
@@ -402,33 +403,40 @@ begin
|
||||
'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('Install the bundled MySQL 8.4 LTS (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. If they need the exact statements:' + #13#10 +
|
||||
' CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' + #13#10 +
|
||||
' CREATE USER ''shopdb''@''%'' IDENTIFIED BY ''<password>'';' + #13#10 +
|
||||
' GRANT ALL PRIVILEGES ON shopdb_flask.* TO ''shopdb''@''%'';' + #13#10 +
|
||||
'The utf8mb4 charset matters - the default on older servers is latin1.');
|
||||
'The database and application user must already exist - the installer does ' +
|
||||
'not create them, so it never needs administrative rights on your database ' +
|
||||
'server. The exact SQL to give your DBA is in the INSTALL-WINDOWS guide.');
|
||||
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';
|
||||
|
||||
// SPLIT ACROSS TWO PAGES on purpose. CreateInputQueryPage stacks its fields
|
||||
// below the description and does not scroll or shrink: with five fields the
|
||||
// last one is drawn past the bottom of the surface and simply does not appear.
|
||||
// Trimming the description bought one field back and lost the next, which is
|
||||
// guessing at a pixel budget that varies with DPI and font. Three fields and
|
||||
// two fields both fit under any reasonable description, at any scaling.
|
||||
DbCredsPage := CreateInputQueryPage(DbDetailsPage.ID,
|
||||
'Existing database', 'Sign-in',
|
||||
'The account the application uses to reach that database. It needs full ' +
|
||||
'rights on it, and nothing outside it.');
|
||||
DbCredsPage.Add('Username:', False);
|
||||
DbCredsPage.Add('Password:', True);
|
||||
DbCredsPage.Values[0] := '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,
|
||||
DeployPage := CreateInputOptionPage(DbCredsPage.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.',
|
||||
@@ -647,7 +655,7 @@ end;
|
||||
// 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;
|
||||
I, Y, Shown, Fails, Warns, Passes, RowHeight, MaxRows, Hidden: Integer;
|
||||
Parts: TArrayOfString;
|
||||
Check, Detail, Fix: String;
|
||||
begin
|
||||
@@ -655,6 +663,16 @@ begin
|
||||
Y := ScaleY(4);
|
||||
Shown := 0;
|
||||
|
||||
// How many rows actually FIT, measured, not guessed. The old cap of 6 bore no
|
||||
// relation to the panel, which holds roughly three: at four or five notes the
|
||||
// rows below were drawn past the bottom edge and silently vanished - taking
|
||||
// with them the warning that the bundled MySQL collides on port 3306, which is
|
||||
// exactly what the operator needs before the very next page.
|
||||
RowHeight := ScaleY(46) + ScaleY(ROW_GAP);
|
||||
if RowHeight < 1 then RowHeight := 1;
|
||||
MaxRows := DetailPanel.Height div RowHeight;
|
||||
if MaxRows < 1 then MaxRows := 1;
|
||||
|
||||
// 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
|
||||
@@ -668,6 +686,9 @@ begin
|
||||
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
// Blockers are capped too. Without this the FAIL loop drew every failure,
|
||||
// however many, straight off the bottom of the panel.
|
||||
if Shown >= MaxRows then Break;
|
||||
Parts := StringSplit(Lines[I], ['|'], stAll);
|
||||
if GetArrayLength(Parts) < 4 then Continue;
|
||||
if Parts[0] <> 'FAIL' then Continue;
|
||||
@@ -682,7 +703,7 @@ begin
|
||||
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
if Shown >= 6 then Break;
|
||||
if Shown >= MaxRows then Break;
|
||||
Parts := StringSplit(Lines[I], ['|'], stAll);
|
||||
if GetArrayLength(Parts) < 4 then Continue;
|
||||
if Parts[0] <> 'WARN' then Continue;
|
||||
@@ -719,8 +740,13 @@ begin
|
||||
+ '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.'
|
||||
// Count what was actually left out rather than inferring it from the old cap,
|
||||
// which under-reported: with 2 failures and 5 warnings it claimed everything
|
||||
// was shown while four rows had been dropped.
|
||||
Hidden := (Fails + Warns) - Shown;
|
||||
if Hidden > 0 then
|
||||
FooterText.Caption := IntToStr(Hidden) + ' further item(s) are not shown here. '
|
||||
+ 'All of them are in the install log.'
|
||||
else
|
||||
FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.';
|
||||
end;
|
||||
@@ -829,10 +855,76 @@ begin
|
||||
Log('[shopdb] pre-fill base directory: ' + Result);
|
||||
end;
|
||||
|
||||
// Everything typed in the wizard ends up on a command line that
|
||||
// CommandLineToArgvW parses, and reaches PowerShell parameters typed [int] or
|
||||
// [string]. A blank, a space, or a trailing backslash therefore has to be caught
|
||||
// HERE - by the time the script sees it, the arguments have already shifted.
|
||||
|
||||
function IsValidPort(const S: String): Boolean;
|
||||
var
|
||||
I, N: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
if (S = '') or (Length(S) > 5) then Exit;
|
||||
for I := 1 to Length(S) do
|
||||
if (S[I] < '0') or (S[I] > '9') then Exit; // also rejects spaces and signs
|
||||
N := StrToIntDef(S, -1);
|
||||
Result := (N >= 1) and (N <= 65535);
|
||||
end;
|
||||
|
||||
function IsAsciiOnly(const S: String): Boolean;
|
||||
var
|
||||
I: Integer;
|
||||
begin
|
||||
Result := True;
|
||||
for I := 1 to Length(S) do
|
||||
if Ord(S[I]) > 126 then
|
||||
begin
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
|
||||
// A path that ends in a backslash - a drive root such as D:\ - closes the
|
||||
// argument with \" , which CommandLineToArgvW reads as an ESCAPED quote. The
|
||||
// argument never terminates and every argument after it shifts one place.
|
||||
// Doubling the backslash inside the quotes is the documented way out.
|
||||
function QuotePathArg(const P: String): String;
|
||||
begin
|
||||
if (P <> '') and (P[Length(P)] = '\') then
|
||||
Result := '"' + P + '\' + '"'
|
||||
else
|
||||
Result := '"' + P + '"';
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
|
||||
// A drive root is refused outright rather than quoted around. Uninstall
|
||||
// removes the application directory recursively, so accepting D:\ here would
|
||||
// mean uninstalling ShopDB-Flask wipes the whole drive.
|
||||
if CurPageID = wpSelectDir then
|
||||
begin
|
||||
if Length(WizardDirValue) <= 3 then
|
||||
begin
|
||||
MsgBox('Choose a folder rather than a whole drive.' + #13#10#13#10
|
||||
+ 'Removing ShopDB-Flask deletes the folder it was installed into, '
|
||||
+ 'so installing to a drive root would delete everything on that '
|
||||
+ 'drive when it is uninstalled.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurPageID = SitePage.ID then
|
||||
begin
|
||||
if not IsValidPort(SitePage.Values[1]) then
|
||||
begin
|
||||
MsgBox('Enter the port as a number between 1 and 65535.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurPageID = wpWelcome then
|
||||
RunPreflight;
|
||||
|
||||
@@ -865,8 +957,34 @@ begin
|
||||
MsgBox('Enter the database host.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end
|
||||
else if (DbDetailsPage.Values[4] = '') and
|
||||
(not FileExists(InstalledDir + '.env')) then
|
||||
else if not IsValidPort(DbDetailsPage.Values[1]) then
|
||||
begin
|
||||
MsgBox('Enter the database port as a number between 1 and 65535.'
|
||||
+ #13#10#13#10 + 'MySQL uses 3306 unless it was changed.',
|
||||
mbError, MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
if CurPageID = DbCredsPage.ID then
|
||||
begin
|
||||
if not UseBundledDb then
|
||||
begin
|
||||
if DbCredsPage.Values[0] = '' then
|
||||
begin
|
||||
MsgBox('Enter the username the application connects with.', mbError, MB_OK);
|
||||
Result := False;
|
||||
end
|
||||
// Blank is allowed when the password is already ON DISK in a form the
|
||||
// installer can read: .env from a previous install, or the ACL'd .dbpass
|
||||
// handoff stage 0 leaves when it created the database itself. Stage 2
|
||||
// consumes .dbpass on its own, so demanding the password here blocked the
|
||||
// operator on something the installer already had - and sent them looking
|
||||
// for a generated secret they were never meant to handle.
|
||||
else if (DbCredsPage.Values[1] = '') and
|
||||
(not FileExists(InstalledDir + '.env')) and
|
||||
(not FileExists(InstalledDir + '.dbpass')) 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.
|
||||
@@ -913,6 +1031,13 @@ begin
|
||||
if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then
|
||||
begin
|
||||
DbPageReady := True;
|
||||
// A stage-0 handoff means the database was created by a previous run of this
|
||||
// installer and the password is already on disk. Nobody should be asked to
|
||||
// find or retype a secret the installer generated.
|
||||
if FileExists(InstalledDir + '.dbpass') then
|
||||
DbCredsPage.SubCaptionLabel.Caption :=
|
||||
'This server already has a database created by this installer. Leave the '
|
||||
+ 'password blank and it will be used automatically.';
|
||||
if LoadStringsFromFile(InstalledDir + '.env', Lines) then
|
||||
for I := 0 to GetArrayLength(Lines) - 1 do
|
||||
if Pos('DATABASE_URL=', Lines[I]) = 1 then
|
||||
@@ -924,7 +1049,7 @@ 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);
|
||||
DbCredsPage.Values[0] := Copy(Creds, 1, Pos(':', Creds) - 1);
|
||||
if Pos(':', Rest) > 0 then
|
||||
begin
|
||||
DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1);
|
||||
@@ -940,10 +1065,13 @@ begin
|
||||
end;
|
||||
// Password intentionally left blank: blank means "keep the current
|
||||
// one", so an upgrade never needs the operator to know it.
|
||||
DbDetailsPage.Values[4] := '';
|
||||
DbCredsPage.Values[1] := '';
|
||||
DbDetailsPage.SubCaptionLabel.Caption :=
|
||||
'These are the settings this server is using now. Leave the password '
|
||||
+ 'blank to keep the current one.';
|
||||
'These are the settings this server is using now.';
|
||||
// The hint belongs where the password field actually is - it moved to
|
||||
// the next page when this one was split.
|
||||
DbCredsPage.SubCaptionLabel.Caption :=
|
||||
'Leave the password blank to keep the one this server already uses.';
|
||||
end;
|
||||
end;
|
||||
|
||||
@@ -994,7 +1122,7 @@ begin
|
||||
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;
|
||||
Result := ((PageID = DbDetailsPage.ID) or (PageID = DbCredsPage.ID)) and UseBundledDb;
|
||||
end;
|
||||
|
||||
// Runs from ssPostInstall, NOT PrepareToInstall.
|
||||
@@ -1019,22 +1147,31 @@ var
|
||||
Cmd: String;
|
||||
begin
|
||||
if not FileExists(LnkPath) then Exit;
|
||||
// THREE quotes each side, not four. Four is an escaped quote twice over, which
|
||||
// keeps the whole expression inside one Pascal literal: LnkPath was never
|
||||
// interpolated, PowerShell got the bare word "LnkPath", failed to parse, and
|
||||
// no shortcut ever received the elevation flag. Self-elevation hid it - the
|
||||
// only symptom was a second window after an extra prompt.
|
||||
Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "'
|
||||
+ '$p='''' + LnkPath + ''''; '
|
||||
+ '$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);
|
||||
if ResultCode <> 0 then
|
||||
Log('[shopdb] could not mark ' + LnkPath + ' run-as-administrator (exit '
|
||||
+ IntToStr(ResultCode) + '); the console will self-elevate instead');
|
||||
end;
|
||||
|
||||
function RunInstallStages: String;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
PwFile, Args, Common: String;
|
||||
PwLines: TArrayOfString;
|
||||
begin
|
||||
Result := '';
|
||||
Common := '-BundleRoot "' + ExpandConstant('{tmp}\shopdb-bundle') + '"' +
|
||||
' -AppRoot "' + ExpandConstant('{app}') + '"' +
|
||||
Common := '-BundleRoot ' + QuotePathArg(ExpandConstant('{tmp}\shopdb-bundle')) +
|
||||
' -AppRoot ' + QuotePathArg(ExpandConstant('{app}')) +
|
||||
' -SiteHost "' + SitePage.Values[0] + '"' +
|
||||
' -SitePort ' + SitePage.Values[1] +
|
||||
' -OnFailure never' +
|
||||
@@ -1070,10 +1207,16 @@ begin
|
||||
// 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
|
||||
if DbCredsPage.Values[1] <> '' then
|
||||
begin
|
||||
PwFile := ExpandConstant('{tmp}\dbpw.txt');
|
||||
SaveStringToFile(PwFile, DbDetailsPage.Values[4] + #13#10, False);
|
||||
// UTF-8, no BOM. SaveStringToFile writes an AnsiString, but the installer
|
||||
// reads this back with -Encoding UTF8 - so any non-ASCII character in the
|
||||
// password came out mangled and the database rejected a password that was
|
||||
// typed correctly, reported as "wrong username or password".
|
||||
SetArrayLength(PwLines, 1);
|
||||
PwLines[0] := DbCredsPage.Values[1];
|
||||
SaveStringsToUTF8FileWithoutBOM(PwFile, PwLines, False);
|
||||
end
|
||||
else
|
||||
PwFile := '';
|
||||
@@ -1081,7 +1224,7 @@ begin
|
||||
' -DbHost "' + DbDetailsPage.Values[0] + '"' +
|
||||
' -DbPort ' + DbDetailsPage.Values[1] +
|
||||
' -DbName "' + DbDetailsPage.Values[2] + '"' +
|
||||
' -DbUser "' + DbDetailsPage.Values[3] + '"';
|
||||
' -DbUser "' + DbCredsPage.Values[0] + '"';
|
||||
if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"';
|
||||
end;
|
||||
|
||||
@@ -1169,6 +1312,12 @@ end;
|
||||
; 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"; \
|
||||
;
|
||||
; Sysnative, for the same reason as the [Run] entry above: the uninstaller is a
|
||||
; 32-bit process, so a bare "powershell.exe" is resolved through WOW64 to the
|
||||
; 32-bit PowerShell, which cannot see the IIS provider at all. Removal then
|
||||
; skipped the site, application pool and application - leaving them pointing at
|
||||
; a directory it HAD deleted - while Windows reported a clean uninstall.
|
||||
Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
|
||||
Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \
|
||||
RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden
|
||||
|
||||
@@ -286,7 +286,7 @@ refresh-bundle-lock.ps1, read the diff, and commit the new lock).
|
||||
# --- summary ----------------------------------------------------------------
|
||||
Write-Host ''
|
||||
Say " Bundle staged at: $BundleRoot" 'White'
|
||||
foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'mysql')) {
|
||||
foreach ($d in @('app', 'wheels', 'python', 'httpplatformhandler', 'urlrewrite', 'vcredist', 'mysqlclient', 'mysql')) {
|
||||
$p = Join-Path $BundleRoot $d
|
||||
if (Test-Path $p) {
|
||||
$mb = ((Get-ChildItem $p -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
|
||||
|
||||
@@ -82,7 +82,7 @@ done
|
||||
|
||||
echo ""
|
||||
echo "Bundle staged at: $BUNDLE"
|
||||
for d in app wheels python httpplatformhandler urlrewrite mysql; do
|
||||
for d in app wheels python httpplatformhandler urlrewrite vcredist mysqlclient mysql; do
|
||||
if [ -d "$BUNDLE/$d" ]; then
|
||||
printf ' %-20s %s\n' "$d" "$(du -sh "$BUNDLE/$d" | cut -f1)"
|
||||
else
|
||||
@@ -125,7 +125,8 @@ else
|
||||
echo " python/ python-3.14.x-amd64.exe"
|
||||
echo " httpplatformhandler/ httpPlatformHandler_amd64.msi"
|
||||
echo " urlrewrite/ rewrite_amd64.msi (client-IP rule; see README)"
|
||||
echo " mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)"
|
||||
echo " vcredist/ VC_redist.x64.exe (MySQL requires it)"
|
||||
echo " mysql/ mysql-8.4.x-winx64.msi (bundled-database option only)"
|
||||
echo ""
|
||||
echo " If the payload changed ON PURPOSE, regenerate and COMMIT the lock:"
|
||||
echo " pwsh ./refresh-bundle-lock.ps1 # review the diff"
|
||||
|
||||
@@ -1,197 +1,240 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"generated": "2026-08-03T15:45:11Z",
|
||||
"generated": "2026-08-04T23:11:22Z",
|
||||
"pythontag": "cp314",
|
||||
"platform": "win_amd64",
|
||||
"note": "Exact third-party payload of the installer bundle. Regenerate with refresh-bundle-lock.ps1 and COMMIT the change as a reviewed dependency bump.",
|
||||
"payloads": {
|
||||
"wheels": {
|
||||
"required": true,
|
||||
"files": {
|
||||
"jinja2-3.1.6-py3-none-any.whl": {
|
||||
"size": 134899,
|
||||
"sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"
|
||||
},
|
||||
"itsdangerous-2.2.0-py3-none-any.whl": {
|
||||
"size": 16234,
|
||||
"sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"
|
||||
},
|
||||
"cffi-2.1.0-cp314-cp314-win_amd64.whl": {
|
||||
"size": 187937,
|
||||
"sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"
|
||||
},
|
||||
"email_validator-2.3.0-py3-none-any.whl": {
|
||||
"size": 35604,
|
||||
"sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"
|
||||
},
|
||||
"urllib3-2.7.0-py3-none-any.whl": {
|
||||
"size": 131087,
|
||||
"sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
|
||||
},
|
||||
"flask_jwt_extended-4.7.3-py2.py3-none-any.whl": {
|
||||
"size": 22698,
|
||||
"sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb"
|
||||
},
|
||||
"flask_sqlalchemy-3.1.1-py3-none-any.whl": {
|
||||
"size": 25125,
|
||||
"sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0"
|
||||
},
|
||||
"pycparser-3.0-py3-none-any.whl": {
|
||||
"size": 48172,
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
|
||||
},
|
||||
"requests-2.33.1-py3-none-any.whl": {
|
||||
"size": 64947,
|
||||
"sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
|
||||
},
|
||||
"blinker-1.9.0-py3-none-any.whl": {
|
||||
"size": 8458,
|
||||
"sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"
|
||||
},
|
||||
"greenlet-3.5.0-cp314-cp314-win_amd64.whl": {
|
||||
"size": 239835,
|
||||
"sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033"
|
||||
},
|
||||
"marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": {
|
||||
"size": 16582,
|
||||
"sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258"
|
||||
},
|
||||
"alembic-1.18.4-py3-none-any.whl": {
|
||||
"size": 263893,
|
||||
"sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"
|
||||
},
|
||||
"pyjwt-2.12.1-py3-none-any.whl": {
|
||||
"size": 29726,
|
||||
"sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"
|
||||
},
|
||||
"mako-1.3.12-py3-none-any.whl": {
|
||||
"size": 78521,
|
||||
"sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"
|
||||
},
|
||||
"waitress-3.0.2-py3-none-any.whl": {
|
||||
"size": 56232,
|
||||
"sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e"
|
||||
},
|
||||
"colorama-0.4.6-py2.py3-none-any.whl": {
|
||||
"size": 25335,
|
||||
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"
|
||||
},
|
||||
"certifi-2026.4.22-py3-none-any.whl": {
|
||||
"size": 135707,
|
||||
"sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"
|
||||
},
|
||||
"cachelib-0.13.0-py3-none-any.whl": {
|
||||
"size": 20914,
|
||||
"sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516"
|
||||
},
|
||||
"pymysql-1.1.3-py3-none-any.whl": {
|
||||
"size": 45356,
|
||||
"sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289"
|
||||
},
|
||||
"werkzeug-3.1.8-py3-none-any.whl": {
|
||||
"size": 226459,
|
||||
"sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"
|
||||
},
|
||||
"idna-3.13-py3-none-any.whl": {
|
||||
"size": 68629,
|
||||
"sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
|
||||
},
|
||||
"python_dotenv-1.2.2-py3-none-any.whl": {
|
||||
"size": 22101,
|
||||
"sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"
|
||||
},
|
||||
"flask_marshmallow-1.5.0-py3-none-any.whl": {
|
||||
"size": 12161,
|
||||
"sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6"
|
||||
},
|
||||
"markupsafe-3.0.3-cp314-cp314-win_amd64.whl": {
|
||||
"size": 15341,
|
||||
"sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"
|
||||
},
|
||||
"flask_cors-6.0.2-py3-none-any.whl": {
|
||||
"size": 13257,
|
||||
"sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a"
|
||||
},
|
||||
"flask_caching-2.4.0-py3-none-any.whl": {
|
||||
"size": 28727,
|
||||
"sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486"
|
||||
},
|
||||
"tzdata-2026.3-py2.py3-none-any.whl": {
|
||||
"size": 348168,
|
||||
"sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"
|
||||
},
|
||||
"charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": {
|
||||
"size": 159634,
|
||||
"sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"
|
||||
},
|
||||
"sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": {
|
||||
"size": 2144204,
|
||||
"sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"
|
||||
},
|
||||
"marshmallow-4.3.0-py3-none-any.whl": {
|
||||
"size": 49148,
|
||||
"sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46"
|
||||
},
|
||||
"dnspython-2.8.0-py3-none-any.whl": {
|
||||
"size": 331094,
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"
|
||||
},
|
||||
"typing_extensions-4.15.0-py3-none-any.whl": {
|
||||
"size": 44614,
|
||||
"sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
},
|
||||
"flask-3.1.3-py3-none-any.whl": {
|
||||
"size": 103424,
|
||||
"sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"
|
||||
},
|
||||
"tabulate-0.10.0-py3-none-any.whl": {
|
||||
"size": 39814,
|
||||
"sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"
|
||||
},
|
||||
"mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": {
|
||||
"size": 18197576,
|
||||
"sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49"
|
||||
},
|
||||
"Flask_Migrate-4.1.0-py3-none-any.whl": {
|
||||
"size": 21237,
|
||||
"sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d"
|
||||
},
|
||||
"cryptography-50.0.0-cp311-abi3-win_amd64.whl": {
|
||||
"size": 3840395,
|
||||
"sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"
|
||||
},
|
||||
"click-8.3.3-py3-none-any.whl": {
|
||||
"size": 110502,
|
||||
"sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
|
||||
}
|
||||
}
|
||||
},
|
||||
"python": {
|
||||
"required": true,
|
||||
"files": {
|
||||
"python-3.14.6-amd64.exe": {
|
||||
"size": 30774112,
|
||||
"sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd"
|
||||
}
|
||||
}
|
||||
},
|
||||
"urlrewrite": {
|
||||
"required": false,
|
||||
"files": {
|
||||
"rewrite_amd64_en-US.msi": {
|
||||
"size": 6078464,
|
||||
"sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpplatformhandler": {
|
||||
"required": true,
|
||||
"files": {
|
||||
"httpPlatformHandler_amd64.msi": {
|
||||
"size": 557056,
|
||||
"sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef"
|
||||
"sha256": "90f8d4905a0ab4f2c95223b3c79e2807a0b74507747d240e43c4302e8db4b5ef",
|
||||
"size": 557056
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"python": {
|
||||
"files": {
|
||||
"python-3.14.6-amd64.exe": {
|
||||
"sha256": "14b3e9a710a3fcf0bd9b55ab6b60412bd91227563f813fc49040cabc0209e0bd",
|
||||
"size": 30774112
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"vcredist": {
|
||||
"files": {
|
||||
"VC_redist.x64.exe": {
|
||||
"sha256": "cc0ff0eb1dc3f5188ae6300faef32bf5beeba4bdd6e8e445a9184072096b713b",
|
||||
"size": 25635768
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"mysql": {
|
||||
"files": {
|
||||
"mysql-8.4.6-winx64.msi": {
|
||||
"sha256": "868885b2e221409f0170729bb30b2b2ce83440a1a4c735d19b3c225b51045762",
|
||||
"size": 135081984
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"wheels": {
|
||||
"files": {
|
||||
"email_validator-2.3.0-py3-none-any.whl": {
|
||||
"sha256": "80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4",
|
||||
"size": 35604
|
||||
},
|
||||
"cachelib-0.13.0-py3-none-any.whl": {
|
||||
"sha256": "8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516",
|
||||
"size": 20914
|
||||
},
|
||||
"flask_caching-2.4.0-py3-none-any.whl": {
|
||||
"sha256": "d15b8135f055c4f28f6f7dbcf8d36a3de4af1224def975ae6e0b43cbfa684486",
|
||||
"size": 28727
|
||||
},
|
||||
"typing_extensions-4.15.0-py3-none-any.whl": {
|
||||
"sha256": "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548",
|
||||
"size": 44614
|
||||
},
|
||||
"greenlet-3.5.0-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
|
||||
"size": 239835
|
||||
},
|
||||
"charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f",
|
||||
"size": 159634
|
||||
},
|
||||
"colorama-0.4.6-py2.py3-none-any.whl": {
|
||||
"sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
|
||||
"size": 25335
|
||||
},
|
||||
"Flask_Migrate-4.1.0-py3-none-any.whl": {
|
||||
"sha256": "24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d",
|
||||
"size": 21237
|
||||
},
|
||||
"markupsafe-3.0.3-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581",
|
||||
"size": 15341
|
||||
},
|
||||
"flask_marshmallow-1.5.0-py3-none-any.whl": {
|
||||
"sha256": "99951c77e5654111ed733811c6dc9310bfb4c3688c78a9e76f80b5ae0b2279a6",
|
||||
"size": 12161
|
||||
},
|
||||
"tzdata-2026.3-py2.py3-none-any.whl": {
|
||||
"sha256": "dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931",
|
||||
"size": 348168
|
||||
},
|
||||
"python_dotenv-1.2.2-py3-none-any.whl": {
|
||||
"sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
|
||||
"size": 22101
|
||||
},
|
||||
"dnspython-2.8.0-py3-none-any.whl": {
|
||||
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
|
||||
"size": 331094
|
||||
},
|
||||
"pymysql-1.1.3-py3-none-any.whl": {
|
||||
"sha256": "8164ba62c552f6105f3b11753352d0f16b90d1703ba67d81923d5a8a5d1c5289",
|
||||
"size": 45356
|
||||
},
|
||||
"packaging-26.3-py3-none-any.whl": {
|
||||
"sha256": "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c",
|
||||
"size": 129956
|
||||
},
|
||||
"flask_jwt_extended-4.7.3-py2.py3-none-any.whl": {
|
||||
"sha256": "905ac807b52b5409bc9244dbcca434968c13ca6f9d91bffe7d4cb71e0e6231cb",
|
||||
"size": 22698
|
||||
},
|
||||
"urllib3-2.7.0-py3-none-any.whl": {
|
||||
"sha256": "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897",
|
||||
"size": 131087
|
||||
},
|
||||
"marshmallow-4.3.0-py3-none-any.whl": {
|
||||
"sha256": "46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46",
|
||||
"size": 49148
|
||||
},
|
||||
"waitress-3.0.2-py3-none-any.whl": {
|
||||
"sha256": "c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e",
|
||||
"size": 56232
|
||||
},
|
||||
"idna-3.13-py3-none-any.whl": {
|
||||
"sha256": "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3",
|
||||
"size": 68629
|
||||
},
|
||||
"flask-3.1.3-py3-none-any.whl": {
|
||||
"sha256": "f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c",
|
||||
"size": 103424
|
||||
},
|
||||
"werkzeug-3.1.8-py3-none-any.whl": {
|
||||
"sha256": "63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50",
|
||||
"size": 226459
|
||||
},
|
||||
"flask_sqlalchemy-3.1.1-py3-none-any.whl": {
|
||||
"sha256": "4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0",
|
||||
"size": 25125
|
||||
},
|
||||
"marshmallow_sqlalchemy-1.5.0-py3-none-any.whl": {
|
||||
"sha256": "3865232672f3dd38c4d5e4e85fdedce76904200742c3594948a2d11d0af93258",
|
||||
"size": 16582
|
||||
},
|
||||
"alembic-1.18.4-py3-none-any.whl": {
|
||||
"sha256": "a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a",
|
||||
"size": 263893
|
||||
},
|
||||
"tabulate-0.10.0-py3-none-any.whl": {
|
||||
"sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3",
|
||||
"size": 39814
|
||||
},
|
||||
"cffi-2.1.0-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb",
|
||||
"size": 187937
|
||||
},
|
||||
"jinja2-3.1.6-py3-none-any.whl": {
|
||||
"sha256": "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67",
|
||||
"size": 134899
|
||||
},
|
||||
"cryptography-50.0.0-cp311-abi3-win_amd64.whl": {
|
||||
"sha256": "bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30",
|
||||
"size": 3840395
|
||||
},
|
||||
"certifi-2026.4.22-py3-none-any.whl": {
|
||||
"sha256": "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a",
|
||||
"size": 135707
|
||||
},
|
||||
"sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5",
|
||||
"size": 2144204
|
||||
},
|
||||
"blinker-1.9.0-py3-none-any.whl": {
|
||||
"sha256": "ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc",
|
||||
"size": 8458
|
||||
},
|
||||
"pycparser-3.0-py3-none-any.whl": {
|
||||
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992",
|
||||
"size": 48172
|
||||
},
|
||||
"flask_cors-6.0.2-py3-none-any.whl": {
|
||||
"sha256": "e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a",
|
||||
"size": 13257
|
||||
},
|
||||
"requests-2.33.1-py3-none-any.whl": {
|
||||
"sha256": "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a",
|
||||
"size": 64947
|
||||
},
|
||||
"pyjwt-2.12.1-py3-none-any.whl": {
|
||||
"sha256": "28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
||||
"size": 29726
|
||||
},
|
||||
"click-8.3.3-py3-none-any.whl": {
|
||||
"sha256": "a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613",
|
||||
"size": 110502
|
||||
},
|
||||
"mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl": {
|
||||
"sha256": "5a5abbc152bc28cb2e64a04605ecd9941eff6b0dc5f9528cb84adb873e9a1e49",
|
||||
"size": 18197576
|
||||
},
|
||||
"mako-1.3.12-py3-none-any.whl": {
|
||||
"sha256": "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9",
|
||||
"size": 78521
|
||||
},
|
||||
"itsdangerous-2.2.0-py3-none-any.whl": {
|
||||
"sha256": "c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef",
|
||||
"size": 16234
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"urlrewrite": {
|
||||
"files": {
|
||||
"rewrite_amd64_en-US.msi": {
|
||||
"sha256": "37342ff2f585f263f34f48e9de59eb1051d61015a8e967dbde4075716230a32a",
|
||||
"size": 6078464
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
"mysqlclient": {
|
||||
"files": {
|
||||
"libcrypto-3-x64.dll": {
|
||||
"sha256": "e84ec61fc1b07a3c899ae39c1dc3f38591cdd310c043a388e7ab40a6828aa297",
|
||||
"size": 5187216
|
||||
},
|
||||
"mysqldump.exe": {
|
||||
"sha256": "329410b7a8ae3d68d38e6f5b781610770854d3f11b975060e231dae62c20dc46",
|
||||
"size": 7142528
|
||||
},
|
||||
"mysql.exe": {
|
||||
"sha256": "bd6880253c1853fc17b1e19129d6e5d9f9c86a2dccf2c7c9e6b878a10baa9770",
|
||||
"size": 7177336
|
||||
},
|
||||
"libssl-3-x64.dll": {
|
||||
"sha256": "e24fcd6c8ec31a14c97f520b5b960ed360c55ddf0935040eff297e511697633e",
|
||||
"size": 788616
|
||||
}
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ $script:BundlePayloads = @(
|
||||
@{ Name = 'httpplatformhandler'; Required = $true; What = 'the IIS module that launches waitress' }
|
||||
@{ Name = 'urlrewrite'; Required = $false; What = 'IIS URL Rewrite, for the client-IP rule' }
|
||||
@{ Name = 'mysqlclient'; Required = $false; What = 'mysql/mysqldump, for backups against a remote database' }
|
||||
@{ Name = 'vcredist'; Required = $false; What = 'the Visual C++ runtime MySQL requires' }
|
||||
@{ Name = 'mysql'; Required = $false; What = 'MySQL, for the bundled-database option' }
|
||||
)
|
||||
|
||||
@@ -75,14 +76,35 @@ function Get-FileDigest {
|
||||
}
|
||||
|
||||
function Get-PayloadFiles {
|
||||
# Every file in the directory, keyed by its path RELATIVE to that directory
|
||||
# with forward slashes, so a lock generated on Windows reads the same from
|
||||
# the Bash builder.
|
||||
<#
|
||||
Every file in the directory, keyed by its path RELATIVE to that directory
|
||||
with forward slashes, so a lock generated on Windows reads the same from
|
||||
the Bash builder.
|
||||
|
||||
The root comes from Get-Item, NOT Resolve-Path, and the prefix is checked
|
||||
before it is trimmed. Both matter, and a real install proved it:
|
||||
|
||||
Inno extracts the bundle under C:\Users\ADMINI~1\AppData\Local\Temp\... -
|
||||
an 8.3 SHORT path. Resolve-Path kept that short form while Get-ChildItem
|
||||
returned the long one (Administrator), so the root was five characters
|
||||
shorter than the prefix it was slicing off. Every relative path came out
|
||||
mangled - 'wheels/heels/flask.whl' - and the verifier reported all 96 files
|
||||
as simultaneously missing and unexpected. The payload was fine; the
|
||||
comparison was not.
|
||||
|
||||
Get-Item and Get-ChildItem go through the same provider, so their path
|
||||
forms agree. The StartsWith guard means that if they ever disagree again
|
||||
this fails loudly instead of inventing paths.
|
||||
#>
|
||||
param([string] $Dir)
|
||||
$out = @{}
|
||||
if (-not (Test-Path $Dir)) { return $out }
|
||||
$root = (Resolve-Path $Dir).Path.TrimEnd('\', '/')
|
||||
foreach ($f in (Get-ChildItem $Dir -Recurse -File)) {
|
||||
$rootItem = Get-Item -LiteralPath $Dir
|
||||
$root = $rootItem.FullName.TrimEnd('\', '/')
|
||||
foreach ($f in (Get-ChildItem -LiteralPath $rootItem.FullName -Recurse -File)) {
|
||||
if (-not $f.FullName.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw ("cannot place '{0}' beneath '{1}' - path forms disagree (8.3 short name?)" -f $f.FullName, $root)
|
||||
}
|
||||
$rel = $f.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
|
||||
$out[$rel] = @{ sha256 = (Get-FileDigest $f.FullName); size = $f.Length }
|
||||
}
|
||||
@@ -137,7 +159,9 @@ function Test-BundleLock {
|
||||
)
|
||||
$problems = @()
|
||||
$payloads = Get-JsonProperty $Lock 'payloads'
|
||||
if ($null -eq $payloads) { return @('bundle-lock.json has no "payloads" section') }
|
||||
# ,@(...) for the same reason as the final return: a bare one-element array
|
||||
# unrolls to a string, and the caller's .Count then measures the wrong thing.
|
||||
if ($null -eq $payloads) { return ,@('bundle-lock.json has no "payloads" section') }
|
||||
|
||||
foreach ($p in $script:BundlePayloads) {
|
||||
$name = $p.Name
|
||||
@@ -184,7 +208,11 @@ function Test-BundleLock {
|
||||
}
|
||||
}
|
||||
$problems += Test-WheelhouseCoversRequirements -BundleRoot $BundleRoot
|
||||
return $problems
|
||||
# The comma keeps this an ARRAY through the return. Without it PowerShell
|
||||
# unrolls an empty result to $null and a single result to a bare string, and
|
||||
# every caller that asks for .Count then behaves differently depending on how
|
||||
# many problems there happen to be.
|
||||
return ,$problems
|
||||
}
|
||||
|
||||
function Test-WheelhouseCoversRequirements {
|
||||
|
||||
@@ -195,6 +195,8 @@ function Get-DbParts {
|
||||
|
||||
function Find-MysqlClient {
|
||||
$candidates = @(
|
||||
'C:\MySQL84\bin\mysql.exe',
|
||||
'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe',
|
||||
'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 |
|
||||
@@ -312,10 +314,38 @@ function Stop-App {
|
||||
|
||||
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
|
||||
# A recycle CANNOT start something that is stopped - it is a no-op on a
|
||||
# stopped pool. Restarting after a failed upgrade, which is exactly when the
|
||||
# pool is stopped, therefore did nothing and then reported "did not respond"
|
||||
# in red, as though the application were broken. Start it first when needed.
|
||||
Import-Module WebAdministration -ErrorAction SilentlyContinue
|
||||
$started = $true
|
||||
try {
|
||||
if (Test-Path "IIS:\AppPools\$AppPool") {
|
||||
$started = ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')
|
||||
}
|
||||
} catch { }
|
||||
if (-not $started) {
|
||||
Say ' the application pool is stopped; starting it' 'Yellow'
|
||||
Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
} else {
|
||||
# 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 " $_" }
|
||||
if ($LASTEXITCODE -ne 0) { Say " recycle reported exit $LASTEXITCODE" 'Yellow' }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# A stopped SITE answers nothing however healthy the pool is.
|
||||
try {
|
||||
$siteNow = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||
if ($siteNow -and $siteNow.State -ne 'Started') {
|
||||
Say ' the site is stopped; starting it' 'Yellow'
|
||||
Start-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
} catch { }
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
|
||||
Say (" back up (HTTP {0})" -f $r.StatusCode) 'Green'
|
||||
@@ -370,9 +400,28 @@ function Backup-Db {
|
||||
Head 'Database backup'
|
||||
$db = Get-DbParts
|
||||
if (-not $db) { Say ' no .env found - cannot determine the database' 'Red'; return }
|
||||
$usingDefault = -not $Dest
|
||||
if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
|
||||
if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }
|
||||
|
||||
# A dump holds every row, including the users table and its password hashes.
|
||||
# A directory created under ProgramData INHERITS Users:RX, so those hashes
|
||||
# were readable by every authenticated user on the server whenever this
|
||||
# command created the directory rather than the installer.
|
||||
#
|
||||
# Re-applied on every backup, not only on creation, because this may be
|
||||
# repairing a directory made by an earlier version.
|
||||
#
|
||||
# Only for the default location. A path the operator named is theirs, and
|
||||
# silently rewriting its ACL is not this command's business - say so instead.
|
||||
if ($usingDefault) {
|
||||
& icacls.exe $Dest '/inheritance:r' `
|
||||
'/grant' 'BUILTIN\Administrators:(OI)(CI)(F)' `
|
||||
'/grant' 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' 2>&1 | Out-Null
|
||||
} else {
|
||||
Say ' note: this dump contains password hashes - check who can read that directory' 'Yellow'
|
||||
}
|
||||
|
||||
$mysql = Find-MysqlClient
|
||||
if (-not $mysql) { Say ' mysql client not found' 'Red'; return }
|
||||
$dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -221,8 +221,12 @@ Invoke-Check 'Network' 'Site port' {
|
||||
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."
|
||||
# WARN, not FAIL. This check runs before the operator has reached the
|
||||
# Address page, so it is testing the DEFAULT port, not necessarily
|
||||
# the one they intend to use. Blocking here would refuse an install
|
||||
# over a conflict the very next page lets them resolve.
|
||||
Add-Result 'Network' 'Site port' 'WARN' "TCP $SitePort is in use$owner" `
|
||||
"Pick a different port on the Address page later in this wizard, or stop whatever is holding it."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,8 +270,13 @@ Invoke-Check 'IIS' 'HttpPlatformHandler' {
|
||||
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.'
|
||||
# NOT a blocker: the MSI is in the bundle and stage 4 installs it. This
|
||||
# was a FAIL, which - once the preflight page started blocking on any
|
||||
# failure - stopped the wizard dead over something the installer was
|
||||
# about to do by itself, with no way forward but to go and install it by
|
||||
# hand. Nothing the installer SUPPLIES may be a blocker.
|
||||
Add-Result 'IIS' 'HttpPlatformHandler' 'INFO' 'not installed yet' `
|
||||
'The installer installs it from the bundle. No action needed.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +359,7 @@ 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.'
|
||||
'Bundled MySQL 8.4 LTS is the appropriate choice on this box.'
|
||||
return
|
||||
}
|
||||
foreach ($s in $svcs) {
|
||||
@@ -378,7 +387,7 @@ Invoke-Check 'MySQL' 'Backup client' {
|
||||
$found = ''
|
||||
foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
|
||||
(Join-Path $AppRoot 'mysqlclient'),
|
||||
'C:\Program Files\MySQL', 'C:\mysql56\bin',
|
||||
'C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin',
|
||||
'C:\Program Files (x86)\MySQL')) {
|
||||
if (-not (Test-Path $root)) { continue }
|
||||
$hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |
|
||||
@@ -389,7 +398,7 @@ Invoke-Check 'MySQL' 'Backup client' {
|
||||
Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)"
|
||||
} else {
|
||||
Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' `
|
||||
'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. Add mysqlclient\ to the installer bundle, or install the MySQL client on this server.'
|
||||
'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. It is not on this server yet - the installer stages its own copy, so this normally resolves itself during installation.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +439,13 @@ Invoke-Check 'MySQL' 'Version and config' {
|
||||
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 ', ')) `
|
||||
# WARN, not FAIL. This inspects the LOCAL MySQL, which may not be the
|
||||
# database the operator is about to install against - a bundled 8.4,
|
||||
# or a remote server. Blocking the wizard here refused an install
|
||||
# over a server that had nothing to do with it. Stage 3 runs
|
||||
# 'flask db-utils preflight' against the database actually chosen,
|
||||
# which is the check that can genuinely block.
|
||||
Add-Result 'MySQL' '5.6 index flags' 'WARN' ("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."
|
||||
}
|
||||
}
|
||||
|
||||
130
deploy/windows/installer/tests/test-install-state.ps1
Normal file
130
deploy/windows/installer/tests/test-install-state.ps1
Normal file
@@ -0,0 +1,130 @@
|
||||
# Tests the installer's durable install record.
|
||||
#
|
||||
# The record answers one question: "is this a re-run of MY install?". Getting it
|
||||
# wrong is destructive in one direction - answering "first provisioning" for a
|
||||
# server holding real data lets prune-schema --force loose on it - and a dead
|
||||
# end in the other, which is what it was built to fix.
|
||||
#
|
||||
# Run directly: pwsh -File test-install-state.ps1
|
||||
# pytest runs it through tests/test_installer_state.py wherever pwsh exists.
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Installer = Join-Path (Split-Path -Parent $Here) 'shopdb-install.ps1'
|
||||
if (-not (Test-Path $Installer)) { throw "installer not found at $Installer" }
|
||||
|
||||
$Sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ('shopdb-state-' + [System.Guid]::NewGuid().ToString('N'))
|
||||
|
||||
# Load ONLY the state block, so the rest of the installer does not run. Bounded
|
||||
# by the same markers the installer uses, and it fails loudly if either moves
|
||||
# rather than silently testing nothing.
|
||||
$lines = Get-Content $Installer
|
||||
$start = ($lines | Select-String -Pattern '^\$script:StateFile ' | Select-Object -First 1)
|
||||
$end = ($lines | Select-String -Pattern '^function Invoke-Native' | Select-Object -First 1)
|
||||
if (-not $start -or -not $end) { throw 'could not locate the install-record block in shopdb-install.ps1' }
|
||||
$block = $lines[($start.LineNumber - 1)..($end.LineNumber - 2)] -join "`n"
|
||||
if ($block -notmatch 'function Test-FirstProvisioning') { throw 'extracted block does not contain the state functions' }
|
||||
|
||||
# Stand-ins for the surrounding script.
|
||||
$script:Created = New-Object System.Collections.ArrayList
|
||||
function Write-Log { param($m, $l = 'INFO') }
|
||||
function Protect-File { param([string] $Path, [switch] $Directory) }
|
||||
function Test-SamePath {
|
||||
param([string] $A, [string] $B)
|
||||
if (-not $A) { return -not $B }
|
||||
return ($A.TrimEnd('\', '/').ToLowerInvariant() -eq $B.TrimEnd('\', '/').ToLowerInvariant())
|
||||
}
|
||||
if (-not $env:ProgramData) { $env:ProgramData = [System.IO.Path]::GetTempPath() }
|
||||
|
||||
Invoke-Expression $block
|
||||
|
||||
$script:Failures = 0
|
||||
function Check {
|
||||
param([string] $Name, $Expected, $Actual)
|
||||
if ($Expected -eq $Actual) {
|
||||
Write-Host (" PASS " + $Name)
|
||||
} else {
|
||||
Write-Host (" FAIL {0}: expected [{1}], got [{2}]" -f $Name, $Expected, $Actual)
|
||||
$script:Failures++
|
||||
}
|
||||
}
|
||||
|
||||
function Reset-Case {
|
||||
param([switch] $Stamped)
|
||||
if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $Sandbox -Force | Out-Null
|
||||
$script:AppRoot = $Sandbox
|
||||
$script:StateFile = Join-Path $Sandbox 'install-state.json'
|
||||
$script:InstallState = $null
|
||||
if ($Stamped) { Set-Content (Join-Path $Sandbox '.installed-version') '0.7.0' }
|
||||
}
|
||||
|
||||
Write-Host 'brand new server'
|
||||
Reset-Case
|
||||
Initialize-InstallState
|
||||
Check 'first provisioning' $true (Test-FirstProvisioning)
|
||||
Check 'record written' $true (Test-Path $script:StateFile)
|
||||
|
||||
Write-Host 'retry of a failed first install'
|
||||
$script:InstallState = $null
|
||||
Initialize-InstallState
|
||||
Check 'still first provisioning' $true (Test-FirstProvisioning)
|
||||
|
||||
Write-Host 'genuine upgrade after a completed install'
|
||||
Complete-InstallState
|
||||
$script:InstallState = $null
|
||||
Initialize-InstallState
|
||||
Check 'no longer first provisioning' $false (Test-FirstProvisioning)
|
||||
|
||||
Write-Host 'install predating the record - must take the safe side'
|
||||
Reset-Case -Stamped
|
||||
Initialize-InstallState
|
||||
Check 'treated as established' $false (Test-FirstProvisioning)
|
||||
|
||||
Write-Host 'record naming a different directory'
|
||||
Reset-Case
|
||||
Initialize-InstallState
|
||||
$other = Get-Content $script:StateFile -Raw | ConvertFrom-Json
|
||||
$other.approot = 'C:\somewhere-else'
|
||||
($other | ConvertTo-Json -Depth 6) | Set-Content $script:StateFile
|
||||
$script:InstallState = $null
|
||||
Check 'foreign record rejected' $null (Get-InstallState)
|
||||
|
||||
Write-Host 'corrupt record'
|
||||
Reset-Case
|
||||
Set-Content $script:StateFile '{ this is not json'
|
||||
$script:InstallState = $null
|
||||
Check 'corrupt record rejected' $null (Get-InstallState)
|
||||
Reset-Case
|
||||
Set-Content $script:StateFile '{ this is not json'
|
||||
Initialize-InstallState
|
||||
Check 'recovers and rewrites' $true ($null -ne $script:InstallState)
|
||||
|
||||
Write-Host 'created items survive a crash'
|
||||
Reset-Case
|
||||
Initialize-InstallState
|
||||
Track 'site' 'shopdb-flask'
|
||||
Track 'apppool' 'shopdbflask'
|
||||
Track 'site' 'shopdb-flask'
|
||||
$script:InstallState = $null
|
||||
Initialize-InstallState
|
||||
Check 'site remembered' 'shopdb-flask' ((Get-CreatedItems 'site') -join ',')
|
||||
Check 'apppool remembered' 'shopdbflask' ((Get-CreatedItems 'apppool') -join ',')
|
||||
# A zero-length array returned from a function unrolls to $null, and $null.Count
|
||||
# is fatal under StrictMode. This is the check that catches losing the comma.
|
||||
Check 'unknown kind is an empty array' 0 ((Get-CreatedItems 'firewall')).Count
|
||||
|
||||
Write-Host 'no record at all'
|
||||
$script:InstallState = $null
|
||||
Check 'empty list, not null' 0 ((Get-CreatedItems 'site')).Count
|
||||
Check 'not first provisioning' $false (Test-FirstProvisioning)
|
||||
|
||||
if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force }
|
||||
if ($script:Failures -gt 0) {
|
||||
Write-Host ("{0} check(s) failed" -f $script:Failures)
|
||||
exit 1
|
||||
}
|
||||
Write-Host 'all checks passed'
|
||||
exit 0
|
||||
@@ -32,6 +32,7 @@ PAYLOADS = [
|
||||
('httpplatformhandler', True, 'the IIS module that launches waitress'),
|
||||
('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'),
|
||||
('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'),
|
||||
('vcredist', False, 'the Visual C++ runtime MySQL requires'),
|
||||
('mysql', False, 'MySQL, for the bundled-database option'),
|
||||
]
|
||||
|
||||
|
||||
422
deploy/windows/shopdb-diagnose.py
Normal file
422
deploy/windows/shopdb-diagnose.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Collect everything needed to diagnose a ShopDB-Flask stage 5 failure.
|
||||
|
||||
Stage 5 is the smoke test: the installer asks IIS for the site and expects 200.
|
||||
When it does not get one, the cause is always in one of four places, and this
|
||||
script reads all four in one pass so the answer arrives in a single round trip:
|
||||
|
||||
1. What IIS actually answers, and with which status code and error page.
|
||||
2. Whether the config sections httpPlatformHandler needs are unlocked.
|
||||
3. Whether the app-pool identity can read the app and run its venv.
|
||||
4. Whether the app itself imports and starts.
|
||||
|
||||
Run it ON THE SERVER, as Administrator:
|
||||
|
||||
C:\\Python314\\python.exe shopdb-diagnose.py
|
||||
|
||||
It writes shopdb-diagnose-<timestamp>.txt next to itself and prints the path.
|
||||
Send that file back.
|
||||
|
||||
SECRETS: the report never contains them. Values from .env (database password,
|
||||
SECRET_KEY, JWT_SECRET_KEY) are read first, then scrubbed out of every section
|
||||
of the report before it is written, including command output and tracebacks
|
||||
that might quote them.
|
||||
|
||||
Standard library only, so it runs on the bundled runtime or any system Python.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
APP_ROOT = os.environ.get('SHOPDB_APPROOT', r'C:\shopdb-flask')
|
||||
ALIAS = 'shopdb'
|
||||
TIMEOUT = 25
|
||||
|
||||
# Filled from .env, then scrubbed from the whole report.
|
||||
SECRETS = []
|
||||
|
||||
WINDIR = os.environ.get('WINDIR', r'C:\Windows')
|
||||
# Sysnative gives a 32-bit process the real 64-bit System32. Harmless on 64-bit.
|
||||
APPCMD_CANDIDATES = [
|
||||
os.path.join(WINDIR, 'Sysnative', 'inetsrv', 'appcmd.exe'),
|
||||
os.path.join(WINDIR, 'System32', 'inetsrv', 'appcmd.exe'),
|
||||
]
|
||||
|
||||
|
||||
def find_appcmd():
|
||||
for path in APPCMD_CANDIDATES:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
class Report(object):
|
||||
def __init__(self):
|
||||
self.chunks = []
|
||||
|
||||
def head(self, title):
|
||||
self.chunks.append('\n' + '=' * 72 + '\n' + title + '\n' + '=' * 72)
|
||||
|
||||
def line(self, text=''):
|
||||
self.chunks.append(str(text))
|
||||
|
||||
def block(self, title, body):
|
||||
self.chunks.append('\n--- %s ---' % title)
|
||||
if body is None or str(body).strip() == '':
|
||||
self.chunks.append('(no output)')
|
||||
else:
|
||||
self.chunks.append(str(body).rstrip())
|
||||
|
||||
def text(self):
|
||||
return '\n'.join(self.chunks) + '\n'
|
||||
|
||||
|
||||
def run(cmd, timeout=60):
|
||||
"""Run a command, return combined output. Never raises."""
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, shell=False)
|
||||
out, _ = proc.communicate(timeout=timeout)
|
||||
text = out.decode('utf-8', 'replace') if out else ''
|
||||
return '[exit %s]\n%s' % (proc.returncode, text)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return '[TIMED OUT after %ss]' % timeout
|
||||
except Exception as exc:
|
||||
return '[could not run: %s]' % exc
|
||||
|
||||
|
||||
def powershell(script, timeout=90):
|
||||
exe = os.path.join(WINDIR, 'Sysnative', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
if not os.path.isfile(exe):
|
||||
exe = 'powershell.exe'
|
||||
return run([exe, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
||||
timeout=timeout)
|
||||
|
||||
|
||||
def load_secrets():
|
||||
"""Read .env so its secret VALUES can be scrubbed from the report."""
|
||||
env_path = os.path.join(APP_ROOT, '.env')
|
||||
found = {}
|
||||
if not os.path.isfile(env_path):
|
||||
return found, None
|
||||
try:
|
||||
with open(env_path, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
raw = handle.read()
|
||||
except Exception as exc:
|
||||
return found, '[could not read .env: %s]' % exc
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
key, value = line.split('=', 1)
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
found[key] = value
|
||||
if not value:
|
||||
continue
|
||||
upper = key.upper()
|
||||
if 'SECRET' in upper or 'PASSWORD' in upper or 'TOKEN' in upper or 'KEY' in upper:
|
||||
SECRETS.append(value)
|
||||
if upper == 'DATABASE_URL':
|
||||
# mysql+pymysql://user:PASSWORD@host/db -- the password only.
|
||||
match = re.match(r'^[^:]+://([^:@/]+):([^@]+)@', value)
|
||||
if match:
|
||||
SECRETS.append(match.group(2))
|
||||
return found, raw
|
||||
|
||||
|
||||
def scrub(text):
|
||||
"""Remove every known secret value from the report."""
|
||||
for secret in SECRETS:
|
||||
if secret and len(secret) >= 4:
|
||||
text = text.replace(secret, '<REDACTED>')
|
||||
# Catch a password inside any URL that did not come from .env.
|
||||
text = re.sub(r'(://[^:@/\s]+:)[^@\s]+(@)', r'\1<REDACTED>\2', text)
|
||||
return text
|
||||
|
||||
|
||||
def http_probe(url):
|
||||
"""Fetch a URL, returning status, headers and body even for an error page."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
started = time.time()
|
||||
try:
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'shopdb-diagnose'})
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
||||
body = response.read(4000).decode('utf-8', 'replace')
|
||||
return {'status': response.status, 'reason': response.reason,
|
||||
'headers': dict(response.headers), 'body': body,
|
||||
'seconds': time.time() - started}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = ''
|
||||
try:
|
||||
body = exc.read(4000).decode('utf-8', 'replace')
|
||||
except Exception:
|
||||
pass
|
||||
return {'status': exc.code, 'reason': exc.reason,
|
||||
'headers': dict(exc.headers or {}), 'body': body,
|
||||
'seconds': time.time() - started}
|
||||
except Exception as exc:
|
||||
return {'status': None, 'reason': '%s: %s' % (type(exc).__name__, exc),
|
||||
'headers': {}, 'body': '', 'seconds': time.time() - started}
|
||||
|
||||
|
||||
def summarise_iis_error(body):
|
||||
"""Pull the meaningful bits out of an IIS error page."""
|
||||
if not body:
|
||||
return None
|
||||
import html
|
||||
|
||||
# style and script blocks first: their contents survive plain tag stripping
|
||||
# and drag CSS into the summary.
|
||||
flat = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', body)
|
||||
flat = re.sub(r'(?s)<!--.*?-->', ' ', flat)
|
||||
flat = re.sub(r'(?s)<[^>]*>', ' ', flat)
|
||||
# Any unterminated tag left by the 4000-byte body truncation.
|
||||
flat = re.sub(r'(?s)<[^>]*$', ' ', flat)
|
||||
flat = html.unescape(flat)
|
||||
flat = re.sub(r'\s+', ' ', flat).strip()
|
||||
|
||||
hints = []
|
||||
for pattern in (r'\b\d{3}\.\d+\b', r'0x[0-9a-fA-F]{8}',
|
||||
r'Error Code[^.]{0,80}', r'Config (?:Error|File)[^.]{0,120}',
|
||||
r'Requested URL[^.]{0,120}', r'Physical Path[^.]{0,120}'):
|
||||
for match in re.findall(pattern, flat):
|
||||
cleaned = re.sub(r'\s+', ' ', match).strip(' :-')
|
||||
if cleaned and cleaned not in hints:
|
||||
hints.append(cleaned)
|
||||
return {'flat': flat[:1200], 'hints': hints}
|
||||
|
||||
|
||||
def main():
|
||||
report = Report()
|
||||
stamp = datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||
|
||||
env_values, env_raw = load_secrets()
|
||||
|
||||
report.line('ShopDB-Flask stage 5 diagnostic')
|
||||
report.line('generated %s' % datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
|
||||
report.line('host %s' % socket.gethostname())
|
||||
report.line('app root %s' % APP_ROOT)
|
||||
report.line('python %s' % sys.version.replace('\n', ' '))
|
||||
report.line('process is %d-bit' % (64 if sys.maxsize > 2 ** 32 else 32))
|
||||
|
||||
# ---------------------------------------------------------------- 1. HTTP
|
||||
report.head('1. WHAT IIS ANSWERS')
|
||||
report.line('This is the single most important section. The status code names')
|
||||
report.line('the fault: 500.19 = config locked, 502.3/503 = the app did not')
|
||||
report.line('start, 404 = application or handler mapping missing.')
|
||||
|
||||
hostname = socket.gethostname()
|
||||
targets = [
|
||||
'http://localhost/%s/' % ALIAS,
|
||||
'http://127.0.0.1/%s/' % ALIAS,
|
||||
'http://[::1]/%s/' % ALIAS,
|
||||
'http://%s/%s/' % (hostname, ALIAS),
|
||||
'http://localhost/',
|
||||
]
|
||||
for url in targets:
|
||||
result = http_probe(url)
|
||||
report.line('\n%s' % url)
|
||||
report.line(' status : %s %s' % (result['status'], result['reason']))
|
||||
report.line(' time : %.1fs' % result['seconds'])
|
||||
server = result['headers'].get('Server')
|
||||
if server:
|
||||
report.line(' server : %s' % server)
|
||||
summary = summarise_iis_error(result['body'])
|
||||
if summary and summary['hints']:
|
||||
report.line(' hints : %s' % ' | '.join(summary['hints'][:8]))
|
||||
if summary and summary['flat']:
|
||||
report.line(' body : %s' % summary['flat'][:600])
|
||||
|
||||
# localhost resolving to ::1 first has bitten this install before.
|
||||
report.block('name resolution for localhost', run(
|
||||
['nslookup', 'localhost'], timeout=20))
|
||||
|
||||
# ------------------------------------------------------------- 2. IIS state
|
||||
report.head('2. IIS STATE')
|
||||
appcmd = find_appcmd()
|
||||
if not appcmd:
|
||||
report.line('appcmd.exe NOT FOUND - is the IIS role installed?')
|
||||
else:
|
||||
report.line('appcmd: %s' % appcmd)
|
||||
report.block('sites', run([appcmd, 'list', 'sites']))
|
||||
report.block('applications', run([appcmd, 'list', 'apps']))
|
||||
report.block('app pools', run([appcmd, 'list', 'apppools']))
|
||||
report.block('worker processes (empty means nothing is running)',
|
||||
run([appcmd, 'list', 'wp']))
|
||||
report.block('modules: httpPlatformHandler present?',
|
||||
run([appcmd, 'list', 'modules']))
|
||||
# overrideMode tells us whether the unlock actually took effect.
|
||||
#
|
||||
# allowedServerVariables is in this list because it caused a 500.52 that
|
||||
# the first two sections could not explain: it is Deny by default, so an
|
||||
# <allowedServerVariables> block in the app's web.config is rejected
|
||||
# before httpPlatformHandler runs. Checking only the sections we unlock
|
||||
# would have missed the one we do not.
|
||||
for section in ('system.webServer/handlers',
|
||||
'system.webServer/httpPlatform',
|
||||
'system.webServer/rewrite/allowedServerVariables',
|
||||
'system.webServer/rewrite/rules'):
|
||||
report.block('lock state of %s' % section,
|
||||
run([appcmd, 'list', 'config', '/section:%s' % section,
|
||||
'/text:*']))
|
||||
|
||||
report.block('W3SVC / WAS services', powershell(
|
||||
"Get-Service W3SVC,WAS | Format-Table Name,Status,StartType -AutoSize | Out-String"))
|
||||
report.block('listeners on port 80', powershell(
|
||||
"Get-NetTCPConnection -LocalPort 80 -State Listen -EA SilentlyContinue | "
|
||||
"Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize | Out-String"))
|
||||
report.block('app pool detail', powershell(
|
||||
"Import-Module WebAdministration -EA SilentlyContinue; "
|
||||
"Get-Item IIS:\\AppPools\\shopdbflask -EA SilentlyContinue | "
|
||||
"Select-Object name,state,managedRuntimeVersion,enable32BitAppOnWin64,"
|
||||
"@{n='identity';e={$_.processModel.identityType}} | Format-List | Out-String"))
|
||||
|
||||
# --------------------------------------------------------- 3. app + config
|
||||
report.head('3. APPLICATION AND CONFIG')
|
||||
|
||||
web_config = os.path.join(APP_ROOT, 'web.config')
|
||||
if os.path.isfile(web_config):
|
||||
try:
|
||||
with open(web_config, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
report.block('web.config', handle.read())
|
||||
except Exception as exc:
|
||||
report.block('web.config', '[could not read: %s]' % exc)
|
||||
else:
|
||||
report.block('web.config', 'MISSING at %s' % web_config)
|
||||
|
||||
if env_raw is None:
|
||||
report.block('.env', 'MISSING at %s' % os.path.join(APP_ROOT, '.env'))
|
||||
else:
|
||||
# Keys and non-secret values only. Secret values are scrubbed anyway.
|
||||
lines = []
|
||||
for key in sorted(env_values):
|
||||
upper = key.upper()
|
||||
secretish = ('SECRET' in upper or 'PASSWORD' in upper
|
||||
or 'TOKEN' in upper or 'KEY' in upper
|
||||
or upper == 'DATABASE_URL')
|
||||
if secretish:
|
||||
lines.append('%s = <set, %d chars>' % (key, len(env_values[key])))
|
||||
else:
|
||||
lines.append('%s = %s' % (key, env_values[key]))
|
||||
report.block('.env (secret values withheld)', '\n'.join(lines))
|
||||
|
||||
venv_python = os.path.join(APP_ROOT, 'venv', 'Scripts', 'python.exe')
|
||||
report.line('\nvenv python exists: %s' % os.path.isfile(venv_python))
|
||||
if os.path.isfile(venv_python):
|
||||
# The exact failure stage 3 used to hit. Proves the app imports.
|
||||
report.block('venv: import shopdb', run(
|
||||
[venv_python, '-c',
|
||||
'import shopdb; print("import OK"); '
|
||||
'app = shopdb.create_app(); print("create_app OK")'], timeout=120))
|
||||
report.block('venv: waitress present', run(
|
||||
[venv_python, '-c', 'import waitress; print(waitress.__version__)'],
|
||||
timeout=60))
|
||||
|
||||
# What httpPlatformHandler is told to launch, and whether it exists.
|
||||
if os.path.isfile(web_config):
|
||||
try:
|
||||
with open(web_config, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
raw = handle.read()
|
||||
match = re.search(r'processPath\s*=\s*"([^"]+)"', raw)
|
||||
args = re.search(r'arguments\s*=\s*"([^"]*)"', raw)
|
||||
if match:
|
||||
path = os.path.expandvars(match.group(1))
|
||||
report.line('\nhttpPlatform processPath : %s' % match.group(1))
|
||||
report.line(' resolved : %s' % path)
|
||||
report.line(' exists : %s' % os.path.isfile(path))
|
||||
if args:
|
||||
report.line('httpPlatform arguments : %s' % args.group(1))
|
||||
except Exception as exc:
|
||||
report.line('[could not parse web.config: %s]' % exc)
|
||||
|
||||
# ------------------------------------------------------------- 4. app logs
|
||||
report.head('4. APPLICATION LOGS')
|
||||
log_dir = os.path.join(APP_ROOT, 'logs')
|
||||
if not os.path.isdir(log_dir):
|
||||
report.line('MISSING: %s' % log_dir)
|
||||
else:
|
||||
entries = []
|
||||
for name in sorted(os.listdir(log_dir)):
|
||||
full = os.path.join(log_dir, name)
|
||||
try:
|
||||
entries.append((os.path.getmtime(full), full, name,
|
||||
os.path.getsize(full)))
|
||||
except OSError:
|
||||
pass
|
||||
if not entries:
|
||||
report.line('%s is EMPTY.' % log_dir)
|
||||
report.line('No stdout log at all means httpPlatformHandler never')
|
||||
report.line('launched python - look at the pool identity and ACLs.')
|
||||
for _, full, name, size in sorted(entries, reverse=True)[:5]:
|
||||
if size == 0:
|
||||
report.block('%s (0 bytes)' % name,
|
||||
'EMPTY - python was launched but wrote nothing.')
|
||||
continue
|
||||
try:
|
||||
with open(full, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
tail = handle.readlines()[-60:]
|
||||
report.block('%s (%d bytes, last 60 lines)' % (name, size),
|
||||
''.join(tail))
|
||||
except Exception as exc:
|
||||
report.block(name, '[could not read: %s]' % exc)
|
||||
|
||||
# ---------------------------------------------------------------- 5. ACLs
|
||||
report.head('5. PERMISSIONS')
|
||||
report.line('The pool runs as "IIS AppPool\\shopdbflask". It needs RX on the')
|
||||
report.line('tree, Modify on logs and instance, and Read on .env.')
|
||||
for target in (APP_ROOT, log_dir, os.path.join(APP_ROOT, '.env'),
|
||||
os.path.join(APP_ROOT, 'venv', 'Scripts')):
|
||||
if os.path.exists(target):
|
||||
report.block('icacls %s' % target, run(['icacls', target], timeout=40))
|
||||
|
||||
# ------------------------------------------------------------ 6. event log
|
||||
report.head('6. EVENT LOG')
|
||||
report.block('recent application errors', powershell(
|
||||
"Get-WinEvent -FilterHashtable @{LogName='Application';"
|
||||
"StartTime=(Get-Date).AddHours(-6)} -EA SilentlyContinue | "
|
||||
"Where-Object { $_.ProviderName -match 'HttpPlatform|IIS|W3SVC|WAS|\\.NET' "
|
||||
"-or $_.LevelDisplayName -eq 'Error' } | Select-Object -First 25 "
|
||||
"TimeCreated,ProviderName,LevelDisplayName,Message | Format-List | Out-String",
|
||||
timeout=180))
|
||||
report.block('system log: WAS / W3SVC', powershell(
|
||||
"Get-WinEvent -FilterHashtable @{LogName='System';"
|
||||
"StartTime=(Get-Date).AddHours(-6)} -EA SilentlyContinue | "
|
||||
"Where-Object { $_.ProviderName -match 'WAS|W3SVC|HTTP' } | "
|
||||
"Select-Object -First 20 TimeCreated,ProviderName,LevelDisplayName,Message | "
|
||||
"Format-List | Out-String", timeout=180))
|
||||
|
||||
# ------------------------------------------------------------------ write
|
||||
body = scrub(report.text())
|
||||
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'shopdb-diagnose-%s.txt' % stamp)
|
||||
try:
|
||||
with open(out_path, 'w', encoding='utf-8') as handle:
|
||||
handle.write(body)
|
||||
except Exception:
|
||||
out_path = os.path.join(os.environ.get('TEMP', r'C:\Windows\Temp'),
|
||||
'shopdb-diagnose-%s.txt' % stamp)
|
||||
with open(out_path, 'w', encoding='utf-8') as handle:
|
||||
handle.write(body)
|
||||
|
||||
print('')
|
||||
print('Report written to:')
|
||||
print(' %s' % out_path)
|
||||
print('')
|
||||
print('%d secret value(s) were scrubbed from it.' % len(SECRETS))
|
||||
print('Send that file back.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -67,15 +67,28 @@
|
||||
It ships DISABLED because it needs the URL Rewrite module; enabled without
|
||||
it, IIS returns HTTP 500.19 ("configuration section not well-formed").
|
||||
|
||||
There is deliberately NO <allowedServerVariables> block below. Setting a
|
||||
server variable requires that variable to be allowed, but the section
|
||||
system.webServer/rewrite/allowedServerVariables ships with
|
||||
overrideModeDefault="Deny", so declaring it in an application's own
|
||||
web.config is refused outright: IIS answered 500.52 with error 0x80070021,
|
||||
"this configuration section cannot be used at this path", BEFORE it ever
|
||||
reached httpPlatformHandler - so python was never launched and the stdout
|
||||
log stayed empty, which looks like an application fault and is not one.
|
||||
|
||||
The installer instead allows the single variable at server level, which
|
||||
grants exactly HTTP_X_FORWARDED_FOR rather than unlocking the section and
|
||||
letting every site on the machine declare arbitrary server variables.
|
||||
|
||||
The installer handles both: -ClientIpSource direct installs URL Rewrite
|
||||
from the bundle and enables this; -ClientIpSource proxy leaves it alone.
|
||||
By hand: install URL Rewrite, then delete the two marker lines below.
|
||||
from the bundle, allows the variable, and enables this; -ClientIpSource
|
||||
proxy leaves it alone. By hand: install URL Rewrite, run
|
||||
appcmd set config /section:system.webServer/rewrite/allowedServerVariables ^
|
||||
/+"[name='HTTP_X_FORWARDED_FOR']" /commit:apphost
|
||||
then delete the two marker lines below.
|
||||
-->
|
||||
<!-- SHOPDB-CLIENTIP-BEGIN
|
||||
<rewrite>
|
||||
<allowedServerVariables>
|
||||
<add name="HTTP_X_FORWARDED_FOR" />
|
||||
</allowedServerVariables>
|
||||
<rules>
|
||||
<rule name="Set X-Forwarded-For" stopProcessing="false">
|
||||
<match url=".*" />
|
||||
|
||||
Reference in New Issue
Block a user