Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5c35117
Abort the install when an upgrade migration fails (#1497)
erikdarlingdata Jul 12, 2026
6306cd6
Add CHANGELOG entry for #1498
erikdarlingdata Jul 12, 2026
33df6ac
Make the CLI abort on discovery failure; normalize SemVer suffixes
erikdarlingdata Jul 12, 2026
a7705ac
Update CHANGELOG for the CLI abort and SemVer normalization
erikdarlingdata Jul 12, 2026
dd3f912
Add a non-destructive repair path (Dashboard checkbox + CLI --repair)
erikdarlingdata Jul 12, 2026
183dbf2
CHANGELOG: the non-destructive repair path
erikdarlingdata Jul 12, 2026
0f1def5
Merge remote-tracking branch 'origin/dev' into feature/1497-addserver…
erikdarlingdata Jul 12, 2026
875576d
Block the install when version discovery fails; fix the repair-loop trap
erikdarlingdata Jul 12, 2026
07ff5fd
CHANGELOG: the discovery-failure guard
erikdarlingdata Jul 12, 2026
c3f36a0
Round 1 review: close the downgrade path my Connected_Current change …
erikdarlingdata Jul 12, 2026
d9d7e27
Round 2 review: re-run the safety verdict, and stop awaiting in front…
erikdarlingdata Jul 12, 2026
2431b9d
Round 3 review: repair writes no history row; close the last two inst…
erikdarlingdata Jul 12, 2026
b8fb23d
Round 4 review: close the in-flight-detection door; stop the CLI lyin…
erikdarlingdata Jul 12, 2026
fc588bc
Round 5 review: stop the repair handoff swapping the user's credentia…
erikdarlingdata Jul 12, 2026
1113fa6
Round 6: close the last stranding door, and the clean-install-behind-…
erikdarlingdata Jul 12, 2026
d65e6c4
Round 7: the abort left the form dead, and my tests never ran in CI
erikdarlingdata Jul 12, 2026
a237031
Round 8: the drop-the-database bug reopened itself, and one flag answ…
erikdarlingdata Jul 12, 2026
933ecd5
Round 9: restore the state the FACTS imply, not the one we came from
erikdarlingdata Jul 12, 2026
d1943a5
Round 9b: pin the fresh-install-vs-migrate decision in CI; move the s…
erikdarlingdata Jul 12, 2026
e1c1d1e
Round 10: make staleness unrenderable, instead of patching each consu…
erikdarlingdata Jul 12, 2026
2d71170
Round 11: the structural guard was comparing the box to itself
erikdarlingdata Jul 12, 2026
4f8f46b
Round 12: an install must permanently supersede a detection in flight
erikdarlingdata Jul 12, 2026
de76076
Merge remote-tracking branch 'origin/dev' into feature/1497-addserver…
erikdarlingdata Jul 12, 2026
36dd781
Round 13: fix the regression Round 12 introduced, and its two siblings
erikdarlingdata Jul 12, 2026
9659b48
Round 13b: the handoff text outlived the run that produced it
erikdarlingdata Jul 12, 2026
b9979c1
Round 14: stop clearing the destructive ticks at each exit; clear the…
erikdarlingdata Jul 12, 2026
2264945
Round 15: the guard's own landing pad was the state it could not see
erikdarlingdata Jul 12, 2026
23219fa
Round 17: a half-replaced fact does not merely evade the guard, it ma…
erikdarlingdata Jul 12, 2026
889214e
Round 18: the fix for the blank dialog was the same bug as the blank …
erikdarlingdata Jul 12, 2026
8237ea3
Round 19: Round 18 stopped the throw and not the write, and the throw…
erikdarlingdata Jul 12, 2026
7bbdc82
Round 20: hiding the install log on the one path that drops the database
erikdarlingdata Jul 12, 2026
2cc803e
CHANGELOG: the cancelled-edit rename and the hidden clean-install log
erikdarlingdata Jul 12, 2026
638dd49
Round 21: "await is the only place this yields" was false, and I had …
erikdarlingdata Jul 12, 2026
4adbf45
Round 21b: the not-connected report is a status line, not a block
erikdarlingdata Jul 12, 2026
73b8bf7
Round 22: cancelling a clean install came back looking like a complet…
erikdarlingdata Jul 12, 2026
931537c
CHANGELOG: the swallowed clean-install cancel, and the Save-on-no-dat…
erikdarlingdata Jul 12, 2026
316c86f
Round 23: sanitize the report filename, and close the --repair/--unin…
erikdarlingdata Jul 12, 2026
513b71c
Round 23b: the file loop swallowed a cancel too, one method deeper th…
erikdarlingdata Jul 12, 2026
b740a53
Follow-ups: clean-install teardown MI-safe + un-brick, and the --pass…
erikdarlingdata Jul 13, 2026
e3fe934
Extract the add-server state machine to Installer.Core so CI can pin it
erikdarlingdata Jul 13, 2026
b88a5e8
Merge remote-tracking branch 'origin/dev' into feature/1497-addserver…
erikdarlingdata Jul 13, 2026
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
16 changes: 16 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Dashboard/AddServerDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<TextBlock Text="Server Name/Address:" FontWeight="Bold" Margin="0,0,0,4"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBox x:Name="ServerNameTextBox" Margin="0,0,0,12"
TextChanged="ServerNameTextBox_TextChanged"
ToolTip="e.g., localhost, server.domain.com, 192.168.1.100, server\instance"/>

<!-- Display Name -->
Expand Down Expand Up @@ -218,6 +219,12 @@
<StackPanel Margin="16,8,0,0">
<CheckBox x:Name="CleanInstallCheckBox"
Content="Perform clean install (drops existing database)"
Checked="CleanInstallCheckBox_Checked"
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/>
<CheckBox x:Name="RepairCheckBox"
Content="Repair: reinstall objects, skip upgrade scripts"
Checked="RepairCheckBox_Checked"
ToolTip="Use when an upgrade fails because a table or object is missing or damaged. Reinstalls the schema objects without running migrations and without dropping the database. The pending upgrade still needs to run afterwards."
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/>
<CheckBox x:Name="ResetScheduleCheckBox"
Content="Reset collection schedule to defaults"
Expand Down
1,871 changes: 1,743 additions & 128 deletions Dashboard/AddServerDialog.xaml.cs

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions Dashboard/Services/ServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,35 @@ IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hu
jobCmd.CommandTimeout = 30;
await jobCmd.ExecuteNonQueryAsync();

// Close active connections before dropping
// Close active connections before dropping.
// EngineEdition 8 = Azure SQL Managed Instance, where SINGLE_USER is non-modifiable (the batch
// would die on that line); gate it to box SQL Server and let MI DROP directly. Mirrors
// InstallationService.CleanInstallAsync.
using var killCmd = new SqlCommand(@"
IF DB_ID('PerformanceMonitor') IS NOT NULL
BEGIN
ALTER DATABASE [PerformanceMonitor] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
IF SERVERPROPERTY('EngineEdition') <> 8
ALTER DATABASE [PerformanceMonitor] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [PerformanceMonitor];
END", connection);
killCmd.CommandTimeout = 30;
await killCmd.ExecuteNonQueryAsync();
try
{
await killCmd.ExecuteNonQueryAsync();
}
catch
{
// A DROP that fails or times out after SINGLE_USER committed strands the database
// semi-bricked. Un-brick best-effort on a fresh connection, then re-raise the original error.
connection.Close();
bool restored = await InstallationService.TryRestoreDatabaseAccessAsync(builder.ConnectionString);
if (!restored)
{
Logger.Warning($"Drop failed on '{server.DisplayName}' and the database may be left in " +
"SINGLE_USER mode; re-run the clean install to recover, or ALTER DATABASE ... SET MULTI_USER manually.");
}
throw;
}

Logger.Info($"Dropped PerformanceMonitor database and Agent jobs on '{server.DisplayName}'");
}
Expand Down
74 changes: 74 additions & 0 deletions Installer.Core/InstallGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
namespace Installer.Core;

/// <summary>Why installing against a server would be unsafe.</summary>
public enum InstallBlock
{
/// <summary>Safe to install.</summary>
None,

/// <summary>
/// This binary's own version will not parse. It is what a FRESH install writes to
/// <c>installation_history.installer_version</c>, so letting it through poisons a brand-new server's
/// ledger at birth — after which every surface refuses to touch that server again.
/// </summary>
UnreadableBuildVersion,

/// <summary>
/// The version recorded on the server will not parse, so we cannot tell which migrations still apply.
/// </summary>
UnreadableInstalledVersion,

/// <summary>
/// The server is on a NEWER build than this binary. Installing would run our older scripts over it —
/// reverting every <c>CREATE OR ALTER</c> procedure and view to their older definitions — and then
/// record the LOWER version as SUCCESS. A silent downgrade.
/// </summary>
InstalledIsNewerThanBuild,
}

/// <summary>
/// The decision behind both installers' pre-install blocks.
///
/// Two of these cases are invisible to every other guard: they produce ZERO upgrade hops AND ZERO
/// failures, so the migration-failure abort never fires and nothing downstream notices. That is what
/// makes them dangerous, and why the decision lives here — shared and pinned — rather than hand-copied
/// into a WPF code-behind and a CLI Main.
/// </summary>
public static class InstallGuard
{
/// <param name="installedVersion">The version recorded on the server; null when nothing is installed.</param>
/// <param name="buildVersion">The version of the binary about to run.</param>
public static InstallBlock Check(string? installedVersion, string? buildVersion)
{
var build = ScriptProvider.TryParseVersionCore(buildVersion);

/*
Checked first, and independently of whether anything is installed: a fresh install WRITES this
value, so an unreadable one is fatal even on an empty server.
*/
if (build == null)
{
return InstallBlock.UnreadableBuildVersion;
}

if (installedVersion == null)
{
/* Nothing installed: a fresh install is safe. */
return InstallBlock.None;
}

var installed = ScriptProvider.TryParseVersionCore(installedVersion);

if (installed == null)
{
return InstallBlock.UnreadableInstalledVersion;
}

if (installed > build)
{
return InstallBlock.InstalledIsNewerThanBuild;
}

return InstallBlock.None;
}
}
Loading
Loading