Release 0.8.0: Windows installer, twelve fixes from a real Server 2019 install, operator docs
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user