Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion deploy/Invoke-NodePilotSetup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -559,10 +559,21 @@ function Invoke-NodePilotSetupMode {
"$env:USERDOMAIN\$env:COMPUTERNAME`$"
}
else { [string]$answers['identity.account'] }
# The certificate host name travels with the server name or the connection is
# rejected before any DDL runs. The runtime connection string carries it as
# HostNameInCertificate and Invoke-NodePilotPreflight is handed it too; leaving
# it off here meant provisioning derived it from -Server instead, so the very
# normal 'localhost' against a server whose TLS certificate names the FQDN died
# with "The target principal name is incorrect" and no database was created.
# An absent key is fine - the script derives the same fallback itself.
$certificateHostName = if ($answers.Contains('database.sqlCertificateHostName')) {
[string]$answers['database.sqlCertificateHostName']
} else { '' }
$outcome = & (Join-Path $scriptDirectory 'Provision-NodePilotDatabase.ps1') `
-Server ([string]$answers['database.sqlServer']) `
-Database ([string]$answers['database.sqlDatabase']) `
-Principal $principal
-Principal $principal `
-CertificateHostName $certificateHostName
}
Set-NodePilotResult -Buffer $result -Section 'provision.database' -Name 'status' -Value $outcome.Status
Set-NodePilotResult -Buffer $result -Section 'provision.database' -Name 'detail' -Value $outcome.Detail
Expand Down
71 changes: 67 additions & 4 deletions deploy/Test-DeploymentTemplates.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,52 @@ Assert-TextMatches -Name 'uninstalling runs the deployment uninstaller from code
-Text $serverIss -Pattern '(?s)usUninstall.*Uninstall-NodePilot\.ps1'
Assert-TextMatches -Name 'the purge switch is built at uninstall time' `
-Text $serverIss -Pattern '(?s)usUninstall.*UninstallPurgeData then Switches'

# Everything below reads the Pascal with its comments removed. The rules these contracts pin are
# each stated, in words, in a comment a few lines above the code that implements them - and a
# contract that matches its own explanation measures nothing. Same trap Remove-CommentLines was
# written for; Pascal just spells the prefix '//'.
$serverIssCode = Remove-CommentLines -Text $serverIss -CommentPrefix '//'

# The uninstaller must remove the INSTALLATION, not merely the bookkeeping that says it exists.
# It ran with GetServiceName('') - which resolves to the literal 'NodePilot' in the uninstaller
# process, because ExistingServiceName is only ever set by DetectExistingInstallation() in Setup -
# and with {app}, which is where Inno put the uninstaller and NOT installPath when /ANSWERFILE
# supplied one. An install with a non-default service name or path was therefore "uninstalled"
# with exit 0 while the service, its firewall rule and every program file stayed put. -DataPath
# was not passed at all, so -PurgeData wiped the default directory or nothing.
# Patterns avoid quote characters entirely - the Pascal they match is full of them, and escaping
# both layers is how a contract ends up matching nothing.
Assert-TextMatches -Name 'the uninstall reads the installed service name from the marker' `
-Text $serverIssCode `
-Pattern '(?s)usUninstall[\s\S]*RegQueryStringValue\(HKLM64,[^)]*ServiceName'
Assert-TextMatches -Name 'the uninstall reads the installed path from the marker' `
-Text $serverIssCode `
-Pattern '(?s)usUninstall[\s\S]*RegQueryStringValue\(HKLM64,[^)]*InstallPath'
Assert-TextMatches -Name 'the uninstall passes the installed data path through' `
-Text $serverIssCode -Pattern '(?s)usUninstall[\s\S]*-DataPath[\s\S]{0,40}InstalledDataPath'
Assert-TextDoesNotMatch -Name 'the uninstall must not pass the wizard default as the service name' `
-Text $serverIssCode -Pattern '-ServiceName[\s\S]{0,24}GetServiceName'
Assert-TextDoesNotMatch -Name 'the uninstall must not pass {app} as the install path' `
-Text $serverIssCode -Pattern '-InstallPath[\s\S]{0,24}ExpandConstant'

# /ANSWERFILE skips the mode page, so IsUpdateSelected() reads ModePage's hard default of 0
# ('update') and AnswerMode contradicts a file that says "mode": "install". Gating the silent
# provisioning run on AnswerMode alone therefore dropped every provisioning key - database, login,
# generated certificate, runtime - on any host that already carried a NodePilot installation.
Assert-TextMatches -Name 'an answer file reaches provisioning regardless of the unshown mode page' `
-Text $serverIssCode `
-Pattern '(?s)WizardSilent\(\) and \(\(AnswerFileOverride <> ..\)[\s\S]{0,40}AnswerMode = .install.'
Assert-TextDoesNotMatch -Name 'the silent provisioning gate must not rest on AnswerMode alone' `
-Text $serverIssCode -Pattern 'WizardSilent\(\) and \(AnswerMode'

# A failed database provisioning exits 0 and reports itself inside provision.ini. The readiness
# page has always read that value and stopped; the silent path walked on to Apply and died in the
# SQL pre-flight instead, telling the operator to have a DBA create a login that was never the
# problem.
Assert-TextMatches -Name 'the silent path reads the provisioning verdict, not just the exit code' `
-Text $serverIssCode `
-Pattern '(?s)-Mode Provision[\s\S]{0,900}?GetIniString\([^)]*provision\.database[^)]*status'
# The data directory is ours; the database is not. There is no option to remove it and there must
# not be one: this installer never created it.
Assert-TextDoesNotMatch -Name 'the setup must not offer to drop the database' `
Expand Down Expand Up @@ -1620,7 +1666,7 @@ Assert-TextMatches -Name 'the readiness probe extracts the Postgres client first
Assert-TextMatches -Name 'the auto-fix run extracts it too' `
-Text $serverIss -Pattern '(?s)if WantsFix then[\s\S]{0,200}EnsurePgClient\(\)'
Assert-TextMatches -Name 'and so does the unattended path, which never sees a page' `
-Text $serverIss -Pattern "(?s)WizardSilent\(\) and \(AnswerMode = 'install'\)[\s\S]{0,300}EnsurePgClient\(\)"
-Text $serverIss -Pattern '(?s)WizardSilent\(\) and \(\(AnswerFileOverride[\s\S]{0,400}EnsurePgClient\(\)'

# The runtime fix is offered on the readiness page, before PrepareToInstall has extracted the
# dontcopy payload. Checking only that the runtime is extracted somewhere misses that ordering bug:
Expand All @@ -1643,7 +1689,8 @@ if ($interactiveRuntimeIndex -lt 0 -or $interactiveRunIndex -lt 0 -or
'bundled runtime before launching provisioning.')
}

$silentProvisionStart = $serverIss.IndexOf("if WizardSilent() and (AnswerMode = 'install') then")
$silentProvisionStart = $serverIss.IndexOf(
"if WizardSilent() and ((AnswerFileOverride <> '') or (AnswerMode = 'install')) then")
$silentProvisionEnd = $serverIss.IndexOf("Arguments := '-Mode Apply'", $silentProvisionStart)
if ($silentProvisionStart -lt 0 -or $silentProvisionEnd -lt 0) {
throw 'Deployment template check failed: could not locate the silent provisioning block.'
Expand All @@ -1663,10 +1710,10 @@ if ($silentRuntimeIndex -lt 0 -or $silentRunIndex -lt 0 -or $silentRuntimeIndex
# file - accepted, validated, then ignored - which is how a fleet rollout ends up with a service
# that starts and answers 503 because the computer account was never granted db_owner.
Assert-TextMatches -Name 'a silent install runs the provisioning its answer file asks for' `
-Text $serverIss -Pattern "(?s)WizardSilent\(\) and \(AnswerMode = 'install'\)[\s\S]{0,600}-Mode Provision"
-Text $serverIss -Pattern '(?s)WizardSilent\(\) and \(\(AnswerFileOverride[\s\S]{0,700}-Mode Provision'
# Before the install, not after it: everything provisioning does - the runtime, the certificate,
# the database grant - is a precondition of the install rather than a follow-up to it.
$silentProvisionIndex = $serverIss.IndexOf("WizardSilent() and (AnswerMode = 'install')")
$silentProvisionIndex = $serverIss.IndexOf("WizardSilent() and ((AnswerFileOverride <> '')")
$applyIndex = $serverIss.IndexOf("Arguments := '-Mode Apply'")
if ($silentProvisionIndex -lt 0 -or $applyIndex -lt 0 -or $silentProvisionIndex -gt $applyIndex) {
throw ('Deployment template check failed: the silent provisioning step does not run before ' +
Expand Down Expand Up @@ -1826,6 +1873,22 @@ $updateBranch = $setupAdapter.Substring($updateBranchStart, $updateInvokeIndex -
Assert-TextDoesNotMatch -Name 'the adapter must not pass a HTTPS port to the updater' `
-Text $updateBranch -Pattern '\bHttpsPort\b'

# The provisioning connection is subject to the same TLS name check as the runtime one, so the
# certificate host name has to travel with the server name. Without it the provisioner derived the
# name from -Server, and the entirely normal 'localhost' against a SQL Server whose certificate
# names the FQDN failed with "The target principal name is incorrect" - leaving no database, and
# an install that then died in the SQL pre-flight blaming a missing login.
$provisionDbIndex = $setupAdapter.IndexOf("'Provision-NodePilotDatabase.ps1'")
if ($provisionDbIndex -lt 0) {
throw 'Deployment template check failed: could not locate the database provisioning call in the setup adapter.'
}
# Bounded to the invocation itself: the adapter reads database.sqlCertificateHostName elsewhere
# (the preflight splat), and a file-wide match would pass on that alone.
$provisionDbCall = $setupAdapter.Substring($provisionDbIndex,
[Math]::Min(400, $setupAdapter.Length - $provisionDbIndex))
Assert-TextMatches -Name 'database provisioning is given the certificate host name' `
-Text $provisionDbCall -Pattern '-CertificateHostName'

# powershell.exe -File returns 0 for a script that merely wrote errors, so an implicit
# fall-through would report a failed installation as success.
Assert-TextMatches -Name 'the adapter exits explicitly' `
Expand Down
53 changes: 49 additions & 4 deletions deploy/server/NodePilotServer.iss
Original file line number Diff line number Diff line change
Expand Up @@ -1686,7 +1686,7 @@ end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
AnswerMode, Arguments, ResultIni, ProvisionIni, Extra: String;
AnswerMode, Arguments, ResultIni, ProvisionIni, DbStatus, Extra: String;
begin
Result := EnsureSession();
if Result <> '' then Exit;
Expand All @@ -1709,7 +1709,16 @@ begin
// Run unconditionally rather than after parsing the file for a "does it ask for anything"
// flag: Pascal Script has no JSON reader, the adapter already has one, and a run with nothing
// requested performs no action and exits 0.
if WizardSilent() and (AnswerMode = 'install') then
//
// /ANSWERFILE is deliberately NOT filtered through AnswerMode. That variable comes from
// IsUpdateSelected(), which reads ModePage.SelectedValueIndex - and /ANSWERFILE skips the mode
// page, so the index keeps its hard default of 0 ('update'). Any host that already carried a
// NodePilot installation therefore turned an answer file saying "mode": "install" into
// AnswerMode = 'update' and silently dropped every provisioning key: no database, no login, no
// generated certificate, no runtime. The file decides on this path, by the same reasoning as
// above - the adapter validates it, update mode accepts no provisioning keys at all, so a
// Provision run for an update answer file performs no action and exits 0.
if WizardSilent() and ((AnswerFileOverride <> '') or (AnswerMode = 'install')) then
begin
// Unattended runs never reached the readiness page, so this is the first and only chance to
// put lazy dontcopy payloads where the adapter looks for them.
Expand All @@ -1723,6 +1732,18 @@ begin
IntToStr(ResultCode) + '). Log: ' + ExpandConstant('{%TEMP}') + '\nodepilot-server-setup.log';
Exit;
end;

// A failed database provisioning exits 0 and reports itself INSIDE provision.ini, exactly as
// it does for the readiness page - which reads this same value and stops. Without the check
// the unattended path walked on to Apply and died in the SQL pre-flight instead, telling the
// operator to have a DBA create a login that was never the problem.
DbStatus := GetIniString('provision.database', 'status', '', ProvisionIni);
if (DbStatus <> '') and (DbStatus <> 'Pass') then
begin
Result := 'The database could not be prepared: ' +
ExpandNewlines(GetIniString('provision.database', 'detail', '', ProvisionIni));
Exit;
end;
end;

// -Mode Apply, not Install or Update: the answer file already declares which it is, and a
Expand Down Expand Up @@ -1816,6 +1837,7 @@ procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
ResultCode: Integer;
ScriptPath, Arguments, Switches: String;
InstalledServiceName, InstalledInstallPath, InstalledDataPath: String;
begin
if CurUninstallStep = usUninstall then
begin
Expand All @@ -1834,9 +1856,32 @@ begin
Switches := '';
if UninstallPurgeData then Switches := Switches + ' -PurgeData';

// Read back what was actually installed, from the marker Install-NodePilot.ps1 writes.
// GetServiceName('') resolves to the literal 'NodePilot' in the uninstaller process -
// ExistingServiceName is only ever populated by DetectExistingInstallation(), which runs in
// Setup - and {app} is merely where Inno put the uninstaller, which is NOT installPath when
// /ANSWERFILE supplied one (the dir page never ran). Passing those guesses meant an install
// with a non-default service name or path was "uninstalled" with exit 0 while the service,
// its firewall rule and every program file stayed exactly where they were, and only the
// bookkeeping that said NodePilot existed was removed. -DataPath was not passed at all, so
// -PurgeData wiped the default directory or nothing.
if (not RegQueryStringValue(HKLM64, 'SOFTWARE\NodePilot\Server', 'ServiceName', InstalledServiceName)) or
(InstalledServiceName = '') then
InstalledServiceName := GetServiceName('');
if (not RegQueryStringValue(HKLM64, 'SOFTWARE\NodePilot\Server', 'InstallPath', InstalledInstallPath)) or
(InstalledInstallPath = '') then
InstalledInstallPath := ExpandConstant('{app}');
if not RegQueryStringValue(HKLM64, 'SOFTWARE\NodePilot\Server', 'DataPath', InstalledDataPath) then
InstalledDataPath := '';

Arguments := '-NoProfile -ExecutionPolicy Bypass -File "' + ScriptPath + '"' +
' -ServiceName "' + GetServiceName('') + '"' +
' -InstallPath "' + ExpandConstant('{app}') + '"' + Switches;
' -ServiceName "' + InstalledServiceName + '"' +
' -InstallPath "' + InstalledInstallPath + '"';
// Only when known: Uninstall-NodePilot.ps1's own default is the right fallback, and an empty
// -DataPath "" would point it at the current directory.
if InstalledDataPath <> '' then
Arguments := Arguments + ' -DataPath "' + InstalledDataPath + '"';
Arguments := Arguments + Switches;

if not Exec('powershell.exe', Arguments, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then
SuppressibleMsgBox('Could not start PowerShell to remove the NodePilot service.',
Expand Down
8 changes: 8 additions & 0 deletions deploy/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,14 @@ JWT-Signaturschlüssel, Data-Protection-Keyring). Default ist **behalten**, übe
"C:\Program Files\NodePilot\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /PURGEDATA=1 # Daten löschen
```

**Was entfernt wird, liest der Uninstaller aus dem Installations-Marker**
(`HKLM\SOFTWARE\NodePilot\Server`: `ServiceName`, `InstallPath`, `DataPath`) — nicht aus den
Wizard-Defaults. Das ist der einzige Weg, der bei einer Installation mit abweichendem Dienstnamen
oder abweichenden Pfaden funktioniert: die Modus- und Verzeichnis-Seiten laufen unter
`/ANSWERFILE` nie, `{app}` ist dann lediglich der Ort des Uninstallers, und der Uninstaller-Prozess
kennt den Dienstnamen des Setups nicht mehr. Wer den Marker von Hand löscht, nimmt dem Uninstaller
damit seine einzige Quelle; er fällt dann auf `NodePilot` und `{app}` zurück.

**Die Datenbank wird nie entfernt, und es gibt dafür keine Option.** Dieses Setup legt sie nicht an
— sie wurde separat bereitgestellt, hat oft ein eigenes Backup-, Replikations- und
Aufbewahrungsregime, und in einem Active/Passive-Cluster teilen sich **beide Knoten dieselbe
Expand Down
10 changes: 10 additions & 0 deletions src/NodePilot.Engine/PowerShell/IPowerShellExecutionEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ public sealed class PowerShellExecutionResult

public interface IPowerShellExecutionEngine
{
/// <summary>
/// The message carried by the <see cref="OperationCanceledException"/> every engine throws
/// when the caller's token is signalled. Caller cancellation is not a script failure — it is
/// how a waitAny/waitNofM junction stands down its losing branches — so the exception has to
/// reach StepRunner, which records the step as Cancelled. An engine that swallowed it into a
/// failed result marked those branches Failed, and a single Failed row fails the whole run.
/// A timeout is a different matter and still comes back as a failed result with TimedOut set.
/// </summary>
public const string CancelledMessage = "Script execution cancelled";

string EngineType { get; }
bool IsAvailable { get; }
Task<PowerShellExecutionResult> ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct);
Expand Down
Loading
Loading