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
29 changes: 29 additions & 0 deletions deploy/Install-NodePilot.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,35 @@ Assert-SafeInstallRoot -Path $InstallPath
if (Test-Path $InstallPath) {
# Empty install path but do NOT touch DataPath.
Get-ChildItem $InstallPath -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force

# The GUI setup keeps its uninstaller in this directory, so emptying it invalidates whatever
# Add/Remove Programs entry pointed at that uninstaller. Both the setup and this script are
# documented ways to install the same product and an operator may well mix them - and an entry
# whose uninstaller no longer exists is one Windows can neither run nor clear, so it sits there
# until somebody edits the registry.
#
# Sparing the uninstaller instead is not an option: Assert-NodePilotExtractedFiles a few steps
# below requires this directory to hold exactly the signed artifact and nothing else, which is
# what stops a binary being swapped before the service executes it.
#
# Deliberately narrow - an entry is removed only when its uninstaller lived in the directory
# just emptied AND is really gone. One that still works belongs to somebody else.
$deadEntryPrefix = $InstallPath.TrimEnd('\') + '\'
foreach ($uninstallRoot in @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) {
foreach ($entry in @(Get-ChildItem -LiteralPath $uninstallRoot -ErrorAction SilentlyContinue)) {
$uninstallString = [string]$entry.GetValue('UninstallString')
if ([string]::IsNullOrWhiteSpace($uninstallString)) { continue }
# Strip the quoting an uninstaller path normally carries, plus any trailing switches.
$uninstallTarget = ($uninstallString -replace '^\s*"([^"]+)".*$', '$1').Trim()
if (-not $uninstallTarget.StartsWith($deadEntryPrefix, [StringComparison]::OrdinalIgnoreCase)) { continue }
if (Test-Path -LiteralPath $uninstallTarget) { continue }
$deadEntryName = [string]$entry.GetValue('DisplayName')
Remove-Item -LiteralPath $entry.PSPath -Recurse -Force -ErrorAction SilentlyContinue
Write-Info " Removed the stale uninstall entry '$deadEntryName' - its uninstaller was in $InstallPath."
}
}
} else {
New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null
}
Expand Down
35 changes: 35 additions & 0 deletions deploy/Test-DeploymentTemplates.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,20 @@ Assert-TextMatches -Name 'installer renders the trusted-proxy placeholder' `
-Text $installer -Pattern "Replace\('\{\{KNOWN_PROXIES_JSON\}\}'"
Assert-TextMatches -Name 'installer validates trusted proxy addresses' `
-Text $installer -Pattern 'IPAddress\]::TryParse\(\$proxyIp'
# Emptying the install directory kills the GUI setup's uninstaller, which lives in it. Sparing that
# file is not an option - Assert-NodePilotExtractedFiles requires the directory to hold exactly the
# signed artifact - so the Add/Remove Programs entry it leaves behind is removed instead, or Windows
# keeps an entry it can neither run nor clear.
Assert-TextMatches -Name 'a ZIP install clears the uninstall entry it just invalidated' `
-Text $installer `
-Pattern '(?s)CurrentVersion\\Uninstall[\s\S]{0,1400}Remove-Item -LiteralPath \$entry\.PSPath'
# Both halves matter: an entry pointing somewhere else, or one whose uninstaller still exists, is
# somebody else's. Without either test this silently becomes "delete every uninstall entry".
Assert-TextMatches -Name 'and only one whose uninstaller lived in the emptied directory' `
-Text $installer -Pattern '\$uninstallTarget\.StartsWith\(\$deadEntryPrefix'
Assert-TextMatches -Name 'and only one whose uninstaller is really gone' `
-Text $installer -Pattern '(?s)if \(Test-Path -LiteralPath \$uninstallTarget\) \{ continue \}'

Assert-TextMatches -Name 'installer keeps the Postgres secret in the service-scoped environment' `
-Text $installer -Pattern 'ConnectionStrings__Postgres=\$postgresServiceConnStr'
Assert-TextMatches -Name 'installer protects the service registry key before writing the Postgres secret' `
Expand Down Expand Up @@ -506,6 +520,17 @@ $successCode = ($successPath -split "`n" | Where-Object { $_.TrimStart() -notmat
Assert-TextDoesNotMatch -Name 'a successful update must not stop the service again' `
-Text $successCode -Pattern 'Stop-ServiceAndVerify'

# Only the installer used to write the marker, so its Version kept naming the last INSTALL and a
# script-driven update was invisible in it - including on the setup wizard's own mode page, which
# reads exactly this value to tell the operator what is already installed.
Assert-TextMatches -Name 'a successful update refreshes the installation marker' `
-Text $successCode `
-Pattern "(?s)SOFTWARE\\NodePilot\\Server[\s\S]{0,700}New-ItemProperty[^\r\n]*-Name 'Version'"
# The marker is one machine-wide key, so on a host with a second instance it may well describe a
# different installation. Stamping this update's version onto that one is worse than leaving it stale.
Assert-TextMatches -Name 'and only when the marker describes this installation' `
-Text $successCode -Pattern '\$markerInstallPath\.TrimEnd\([^)]*\) -eq \$InstallPath\.TrimEnd\('

# The rollback path is the one place the prior state still governs.
$rollbackPath = $updateScript.Substring($catchStart)
Assert-TextMatches -Name 'a failed update still restores the pre-update state' `
Expand Down Expand Up @@ -1072,6 +1097,16 @@ Assert-TextDoesNotMatch -Name 'the uninstall must not pass the wizard default as
Assert-TextDoesNotMatch -Name 'the uninstall must not pass {app} as the install path' `
-Text $serverIssCode -Pattern '-InstallPath[\s\S]{0,24}ExpandConstant'

# Apps & Features must name the directory NodePilot is in, not the one the uninstaller landed in.
# With /ANSWERFILE the directory page never runs, so {app} keeps DefaultDirName while the adapter
# installs to the answer file's installPath - and Inno fills InstallLocation with {app}.
Assert-TextMatches -Name 'the ARP entry is corrected to the real install path' `
-Text $serverIssCode `
-Pattern '(?s)ssPostInstall[\s\S]{0,900}RegWriteStringValue\(HKLM64,[\s\S]{0,200}InstallLocation'
Assert-TextMatches -Name 'and it takes that path from the installer marker' `
-Text $serverIssCode `
-Pattern '(?s)ssPostInstall[\s\S]{0,600}RegQueryStringValue\(HKLM64,[^)]*InstallPath'

# /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,
Expand Down
29 changes: 29 additions & 0 deletions deploy/Update-NodePilot.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,35 @@ public class TrustAllCertsUpdate : ICertificatePolicy {
catch {
Write-Host "[update] Update succeeded, but old backup pruning failed: $($_.Exception.Message)" -ForegroundColor Yellow
}

# Only Install-NodePilot.ps1 used to write this, so the marker kept the version of the last
# INSTALL and every script-driven update was invisible in it. That value is what the setup
# wizard puts on its mode page ("NodePilot <version> is already installed in <path>") and
# the obvious thing for an inventory to read - measured in the lab: binaries updated from
# 1.2.6-rc1 to the 1.2.5 artifact, marker still claiming 1.2.6-rc1.
#
# Version only: path, service name, provider and port are not changed by an update and are
# already correct. Guarded on InstallPath because the marker is a single machine-wide key -
# on a host running more than one instance it describes whichever was installed last, and
# stamping this update's version onto another instance's marker is worse than leaving it
# stale. A failure here is a warning: it costs discoverability, not a working installation.
try {
$markerPath = 'HKLM:\SOFTWARE\NodePilot\Server'
$marker = Get-ItemProperty -LiteralPath $markerPath -ErrorAction Stop
$markerInstallPath = [string]$marker.InstallPath
if ($markerInstallPath -and
$markerInstallPath.TrimEnd('\') -eq $InstallPath.TrimEnd('\')) {
New-ItemProperty -LiteralPath $markerPath -Name 'Version' `
-Value ([string]$verifiedArtifact.Version) -PropertyType String -Force | Out-Null
Write-Info "Installation marker updated to version $($verifiedArtifact.Version)."
}
else {
Write-Info 'Installation marker describes another installation; left untouched.'
}
} catch {
Write-Warn "Could not update the installation marker: $($_.Exception.Message)"
}

Write-Ok 'Update complete.'
}
catch {
Expand Down
24 changes: 24 additions & 0 deletions deploy/server/NodePilotServer.iss
Original file line number Diff line number Diff line change
Expand Up @@ -1833,6 +1833,30 @@ begin
UninstallPurgeData := Response = IDNO;
end;

procedure CurStepChanged(CurStep: TSetupStep);
var
InstalledInstallPath: String;
begin
if CurStep = ssPostInstall then
begin
// Apps & Features shows InstallLocation, and Inno fills it with {app} - which is where the
// uninstaller ended up, not where NodePilot was installed. /ANSWERFILE skips the directory
// page, so {app} keeps DefaultDirName while the adapter installs to the answer file's
// installPath; the entry then points an operator at a directory holding the uninstaller and
// nothing else. Corrected from the marker the installer has just written, which is the same
// source the uninstaller reads.
//
// Best-effort on purpose. An exception in ssPostInstall does NOT change the exit code (see the
// note in [Files]), so nothing load-bearing may live here - a failed write leaves Inno's own
// value in place, which is exactly today's behaviour.
if RegQueryStringValue(HKLM64, 'SOFTWARE\NodePilot\Server', 'InstallPath', InstalledInstallPath) and
(InstalledInstallPath <> '') then
RegWriteStringValue(HKLM64,
'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{03EAD540-1472-4A1B-9F06-9CB3D358E202}_is1',
'InstallLocation', AddBackslash(InstalledInstallPath));
end;
end;

procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
ResultCode: Integer;
Expand Down
Loading