From 3bb0c36baccb3b3eb5de0e849945dc6547b7f14c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:54:36 -0400 Subject: [PATCH 1/4] Add Installer.Core shared library and retarget InstallerGui + Tests (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 extraction of shared installation logic into Installer.Core class library. InstallerGui and Installer.Tests now consume the shared library instead of duplicating code. CLI Installer refactor is next. New files: - Installer.Core/InstallationService.cs — all static install/upgrade methods - Installer.Core/DependencyInstaller.cs — community dependency downloads - Installer.Core/ScriptProvider.cs — filesystem + embedded resource abstraction - Installer.Core/Patterns.cs — shared regex patterns ([GeneratedRegex]) - Installer.Core/Models/ — InstallationProgress, ServerInfo, InstallationResult, UpgradeInfo, InstallationResultCode (enum mapping CLI exit codes 0-8) Key changes: - SQL scripts embedded as assembly resources for future Dashboard integration - ScriptProvider.FromDirectory() for CLI/GUI, FromEmbeddedResources() for Dashboard - AutoDiscover() searches filesystem then falls back to embedded - Comprehensive [DEBUG] logging throughout all methods for GUI diagnostics - upgrade.txt missing warning (was silently skipped, now logged) - GenerateSummaryReport gains optional outputDirectory parameter Retargeted: - InstallerGui references Installer.Core, old InstallationService.cs deleted - Installer.Tests targets net8.0 (was net8.0-windows), no WPF dependency - Tests use ScriptProvider.FromDirectory() instead of raw file paths Co-Authored-By: Claude Opus 4.6 (1M context) --- Installer.Core/DependencyInstaller.cs | 196 +++ Installer.Core/InstallationService.cs | 1160 ++++++++++++ Installer.Core/Installer.Core.csproj | 31 + Installer.Core/Models/InstallationProgress.cs | 13 + Installer.Core/Models/InstallationResult.cs | 16 + .../Models/InstallationResultCode.cs | 18 + Installer.Core/Models/ServerInfo.cs | 38 + Installer.Core/Models/UpgradeInfo.cs | 12 + Installer.Core/Patterns.cs | 60 + Installer.Core/ScriptProvider.cs | 405 +++++ Installer.Tests/AdversarialTests.cs | 27 +- Installer.Tests/Installer.Tests.csproj | 5 +- Installer.Tests/UpgradeOrderingTests.cs | 23 +- InstallerGui/InstallerGui.csproj | 4 + InstallerGui/MainWindow.xaml.cs | 46 +- InstallerGui/Services/InstallationService.cs | 1552 ----------------- PerformanceMonitor.sln | 6 + 17 files changed, 2008 insertions(+), 1604 deletions(-) create mode 100644 Installer.Core/DependencyInstaller.cs create mode 100644 Installer.Core/InstallationService.cs create mode 100644 Installer.Core/Installer.Core.csproj create mode 100644 Installer.Core/Models/InstallationProgress.cs create mode 100644 Installer.Core/Models/InstallationResult.cs create mode 100644 Installer.Core/Models/InstallationResultCode.cs create mode 100644 Installer.Core/Models/ServerInfo.cs create mode 100644 Installer.Core/Models/UpgradeInfo.cs create mode 100644 Installer.Core/Patterns.cs create mode 100644 Installer.Core/ScriptProvider.cs delete mode 100644 InstallerGui/Services/InstallationService.cs diff --git a/Installer.Core/DependencyInstaller.cs b/Installer.Core/DependencyInstaller.cs new file mode 100644 index 0000000..13aad3c --- /dev/null +++ b/Installer.Core/DependencyInstaller.cs @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Diagnostics; +using Installer.Core.Models; +using Microsoft.Data.SqlClient; + +namespace Installer.Core; + +/// +/// Installs community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit) +/// from GitHub. Requires an HttpClient — create one instance and dispose when done. +/// +public sealed class DependencyInstaller : IDisposable +{ + private readonly HttpClient _httpClient; + private bool _disposed; + + public DependencyInstaller() + { + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// Install community dependencies from GitHub into the PerformanceMonitor database. + /// Returns the number of successfully installed dependencies. + /// + public async Task InstallDependenciesAsync( + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var dependencies = new List<(string Name, string Url, string Description)> + { + ( + "sp_WhoIsActive", + "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql", + "Query activity monitoring by Adam Machanic (GPLv3)" + ), + ( + "DarlingData", + "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql", + "sp_HealthParser, sp_HumanEventsBlockViewer by Erik Darling (MIT)" + ), + ( + "First Responder Kit", + "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql", + "sp_BlitzLock and diagnostic tools by Brent Ozar Unlimited (MIT)" + ) + }; + + progress?.Report(new InstallationProgress + { + Message = "Installing community dependencies...", + Status = "Info" + }); + + int successCount = 0; + + foreach (var (name, url, description) in dependencies) + { + cancellationToken.ThrowIfCancellationRequested(); + + progress?.Report(new InstallationProgress + { + Message = $"Installing {name}...", + Status = "Info" + }); + + try + { + var depSw = Stopwatch.StartNew(); + progress?.Report(new InstallationProgress { Message = $"[DEBUG] Downloading {name} from {url}", Status = "Debug" }); + string sql = await DownloadWithRetryAsync(url, progress, cancellationToken: cancellationToken).ConfigureAwait(false); + progress?.Report(new InstallationProgress { Message = $"[DEBUG] {name}: downloaded {sql.Length} chars in {depSw.ElapsedMilliseconds}ms", Status = "Debug" }); + + if (string.IsNullOrWhiteSpace(sql)) + { + progress?.Report(new InstallationProgress + { + Message = $"{name} - FAILED (empty response)", + Status = "Error" + }); + continue; + } + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + using (var useDbCommand = new SqlCommand("USE PerformanceMonitor;", connection)) + { + await useDbCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + string[] batches = Patterns.GoBatchSplitter.Split(sql); + int nonEmpty = batches.Count(b => !string.IsNullOrWhiteSpace(b)); + progress?.Report(new InstallationProgress { Message = $"[DEBUG] {name}: executing {nonEmpty} batches", Status = "Debug" }); + + foreach (string batch in batches) + { + string trimmedBatch = batch.Trim(); + if (string.IsNullOrWhiteSpace(trimmedBatch)) + continue; + + using var command = new SqlCommand(trimmedBatch, connection); + command.CommandTimeout = InstallationService.DependencyTimeoutSeconds; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + progress?.Report(new InstallationProgress + { + Message = $"{name} - Success ({description})", + Status = "Success" + }); + + successCount++; + } + catch (HttpRequestException ex) + { + progress?.Report(new InstallationProgress + { + Message = $"{name} - Download failed: {ex.Message}", + Status = "Error" + }); + } + catch (SqlException ex) + { + progress?.Report(new InstallationProgress + { + Message = $"{name} - SQL execution failed: {ex.Message}", + Status = "Error" + }); + } + catch (Exception ex) + { + progress?.Report(new InstallationProgress + { + Message = $"{name} - Failed: {ex.Message}", + Status = "Error" + }); + } + } + + progress?.Report(new InstallationProgress + { + Message = $"Dependencies installed: {successCount}/{dependencies.Count}", + Status = successCount == dependencies.Count ? "Success" : "Warning" + }); + + return successCount; + } + + private async Task DownloadWithRetryAsync( + string url, + IProgress? progress = null, + int maxRetries = 3, + CancellationToken cancellationToken = default) + { + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + return await _httpClient.GetStringAsync(url, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException) when (attempt < maxRetries) + { + int delaySeconds = (int)Math.Pow(2, attempt); + progress?.Report(new InstallationProgress + { + Message = $"Network error, retrying in {delaySeconds}s ({attempt}/{maxRetries})...", + Status = "Warning" + }); + await Task.Delay(delaySeconds * 1000, cancellationToken).ConfigureAwait(false); + } + } + return await _httpClient.GetStringAsync(url, cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + if (!_disposed) + { + _httpClient?.Dispose(); + _disposed = true; + } + GC.SuppressFinalize(this); + } +} diff --git a/Installer.Core/InstallationService.cs b/Installer.Core/InstallationService.cs new file mode 100644 index 0000000..e26f99f --- /dev/null +++ b/Installer.Core/InstallationService.cs @@ -0,0 +1,1160 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Data; +using System.Diagnostics; +using System.Text; +using Installer.Core.Models; +using Microsoft.Data.SqlClient; + +namespace Installer.Core; + +/// +/// Core installation service for the Performance Monitor database. +/// All methods are static — no instance state needed. +/// +public static class InstallationService +{ + private static readonly char[] NewLineChars = ['\r', '\n']; + + /// + /// Logs a diagnostic message through the progress reporter. + /// Uses "Debug" status so consumers can filter verbose output. + /// + private static void LogDebug(IProgress? progress, string message) + { + progress?.Report(new InstallationProgress { Message = $"[DEBUG] {message}", Status = "Debug" }); + } + + /// + /// Timeout for standard SQL file execution (5 minutes). + /// + public const int StandardTimeoutSeconds = 300; + + /// + /// Timeout for upgrade migrations on large tables (1 hour). + /// + public const int UpgradeTimeoutSeconds = 3600; + + /// + /// Timeout for short operations like cleanup (1 minute). + /// + public const int ShortTimeoutSeconds = 60; + + /// + /// Timeout for dependency installation (2 minutes). + /// + public const int DependencyTimeoutSeconds = 120; + + /// + /// Build a connection string from the provided parameters. + /// + public static string BuildConnectionString( + string server, + bool useWindowsAuth, + string? username = null, + string? password = null, + string encryption = "Mandatory", + bool trustCertificate = false, + bool useEntraAuth = false) + { + var builder = new SqlConnectionStringBuilder + { + DataSource = server, + InitialCatalog = "master", + TrustServerCertificate = trustCertificate + }; + + builder.Encrypt = encryption switch + { + "Optional" => SqlConnectionEncryptOption.Optional, + "Mandatory" => SqlConnectionEncryptOption.Mandatory, + "Strict" => SqlConnectionEncryptOption.Strict, + _ => SqlConnectionEncryptOption.Mandatory + }; + + if (useEntraAuth) + { + builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; + builder.UserID = username; + } + else if (useWindowsAuth) + { + builder.IntegratedSecurity = true; + } + else + { + builder.UserID = username; + builder.Password = password; + } + + return builder.ConnectionString; + } + + /// + /// Test connection to SQL Server and get server information. + /// + public static async Task TestConnectionAsync( + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var info = new ServerInfo(); + LogDebug(progress, $"TestConnectionAsync: opening connection"); + var sw = Stopwatch.StartNew(); + + try + { + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + LogDebug(progress, $"TestConnectionAsync: connected in {sw.ElapsedMilliseconds}ms"); + + info.IsConnected = true; + + using var command = new SqlCommand(@" + SELECT + @@VERSION, + SERVERPROPERTY('Edition'), + @@SERVERNAME, + CONVERT(int, SERVERPROPERTY('EngineEdition')), + SERVERPROPERTY('ProductMajorVersion');", connection); + using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + info.SqlServerVersion = reader.GetString(0); + info.SqlServerEdition = reader.GetString(1); + info.ServerName = reader.GetString(2); + info.EngineEdition = reader.IsDBNull(3) ? 0 : reader.GetInt32(3); + info.ProductMajorVersion = reader.IsDBNull(4) ? 0 : int.TryParse(reader.GetValue(4).ToString(), out var v) ? v : 0; + } + + LogDebug(progress, $"TestConnectionAsync: server={info.ServerName}, edition={info.SqlServerEdition}, " + + $"engineEdition={info.EngineEdition}, majorVersion={info.ProductMajorVersion}, " + + $"supported={info.IsSupportedVersion}, elapsed={sw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + info.IsConnected = false; + info.ErrorMessage = ex.Message; + if (ex.InnerException != null) + { + info.ErrorMessage += $"\n{ex.InnerException.Message}"; + } + LogDebug(progress, $"TestConnectionAsync: FAILED after {sw.ElapsedMilliseconds}ms — " + + $"{ex.GetType().Name}: {ex.Message}" + + (ex.InnerException != null ? $" → {ex.InnerException.GetType().Name}: {ex.InnerException.Message}" : "")); + } + + return info; + } + + /// + /// Perform clean install (drop existing database and jobs). + /// + public static async Task CleanInstallAsync( + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + LogDebug(progress, "CleanInstallAsync: starting — will drop database, jobs, XE sessions"); + var sw = Stopwatch.StartNew(); + progress?.Report(new InstallationProgress + { + Message = "Performing clean install...", + Status = "Info" + }); + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + /*Stop any existing traces before dropping database*/ + try + { + using var traceCmd = new SqlCommand( + "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", + connection); + traceCmd.CommandTimeout = ShortTimeoutSeconds; + await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + progress?.Report(new InstallationProgress + { + Message = "Stopped existing traces", + Status = "Success" + }); + } + catch (SqlException) + { + /*Database or procedure doesn't exist - no traces to clean*/ + } + + /*Remove Agent jobs, XE sessions, and database*/ + string cleanupSql = @" +USE msdb; + +IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection') +BEGIN + EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1; +END; + +IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention') +BEGIN + EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1; +END; + +IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor') +BEGIN + EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1; +END; + +USE master; + +IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') +BEGIN + IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') + ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP; + DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER; +END; + +IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock') +BEGIN + IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock') + ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP; + DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER; +END; + +IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') +BEGIN + ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE PerformanceMonitor; +END;"; + + using var command = new SqlCommand(cleanupSql, connection); + command.CommandTimeout = ShortTimeoutSeconds; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + progress?.Report(new InstallationProgress + { + Message = "Clean install completed (jobs, XE sessions, and database removed)", + Status = "Success" + }); + } + + /// + /// Perform complete uninstall (remove database, jobs, XE sessions, and traces). + /// + public static async Task ExecuteUninstallAsync( + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + progress?.Report(new InstallationProgress + { + Message = "Uninstalling Performance Monitor...", + Status = "Info" + }); + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + /*Stop existing traces before dropping database*/ + try + { + using var traceCmd = new SqlCommand( + "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", + connection); + traceCmd.CommandTimeout = ShortTimeoutSeconds; + await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + progress?.Report(new InstallationProgress + { + Message = "Stopped server-side traces", + Status = "Success" + }); + } + catch (SqlException) + { + progress?.Report(new InstallationProgress + { + Message = "No traces to stop (database or procedure not found)", + Status = "Info" + }); + } + + /*Remove Agent jobs, XE sessions, and database*/ + await CleanInstallAsync(connectionString, progress, cancellationToken) + .ConfigureAwait(false); + + progress?.Report(new InstallationProgress + { + Message = "Uninstall completed successfully", + Status = "Success", + ProgressPercent = 100 + }); + + return true; + } + + /// + /// Execute SQL installation files from the given ScriptProvider. + /// + public static async Task ExecuteInstallationAsync( + string connectionString, + ScriptProvider provider, + bool cleanInstall, + bool resetSchedule = false, + IProgress? progress = null, + Func? preValidationAction = null, + CancellationToken cancellationToken = default) + { + var scriptFiles = provider.GetInstallFiles(); + ArgumentNullException.ThrowIfNull(scriptFiles); + + LogDebug(progress, $"ExecuteInstallationAsync: cleanInstall={cleanInstall}, resetSchedule={resetSchedule}, " + + $"scriptCount={scriptFiles.Count}, providerType={provider.GetType().Name}"); + LogDebug(progress, $"ExecuteInstallationAsync: scripts=[{string.Join(", ", scriptFiles.Select(f => f.Name))}]"); + + var result = new InstallationResult + { + StartTime = DateTime.Now + }; + + /*Perform clean install if requested*/ + if (cleanInstall) + { + try + { + await CleanInstallAsync(connectionString, progress, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + progress?.Report(new InstallationProgress + { + Message = $"CLEAN INSTALL FAILED: {ex.Message}", + Status = "Error" + }); + progress?.Report(new InstallationProgress + { + Message = "Installation aborted - clean install was requested but failed.", + Status = "Error" + }); + result.EndTime = DateTime.Now; + result.Success = false; + result.FilesFailed = 1; + result.Errors.Add(("Clean Install", ex.Message)); + return result; + } + } + + /* + Execute SQL files. + Files execute without transaction wrapping because many contain DDL. + If installation fails mid-way, use clean install to reset and retry. + */ + progress?.Report(new InstallationProgress + { + Message = "Starting installation...", + Status = "Info", + CurrentStep = 0, + TotalSteps = scriptFiles.Count, + ProgressPercent = 0 + }); + + bool preValidationActionRan = false; + + for (int i = 0; i < scriptFiles.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var scriptFile = scriptFiles[i]; + string fileName = scriptFile.Name; + + /*Install community dependencies before validation runs. + Collectors in 98_validate need sp_WhoIsActive, sp_HealthParser, etc.*/ + if (!preValidationActionRan && + preValidationAction != null && + fileName.StartsWith("98_", StringComparison.Ordinal)) + { + preValidationActionRan = true; + await preValidationAction().ConfigureAwait(false); + } + + progress?.Report(new InstallationProgress + { + Message = $"Executing {fileName}...", + Status = "Info", + CurrentStep = i + 1, + TotalSteps = scriptFiles.Count, + ProgressPercent = (int)(((i + 1) / (double)scriptFiles.Count) * 100) + }); + + try + { + var fileSw = Stopwatch.StartNew(); + string sqlContent = await provider.ReadScriptAsync(scriptFile, cancellationToken).ConfigureAwait(false); + LogDebug(progress, $" {fileName}: read {sqlContent.Length} chars"); + + /*Reset schedule to defaults if requested*/ + if (resetSchedule && fileName.StartsWith("04_", StringComparison.Ordinal)) + { + sqlContent = "TRUNCATE TABLE [PerformanceMonitor].[config].[collection_schedule];\nGO\n" + sqlContent; + progress?.Report(new InstallationProgress + { + Message = "Resetting schedule to recommended defaults...", + Status = "Info" + }); + } + + /*Remove SQLCMD directives*/ + sqlContent = Patterns.SqlCmdDirectivePattern.Replace(sqlContent, ""); + + /*Execute the SQL batch*/ + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + /*Split by GO statements*/ + string[] batches = Patterns.GoBatchSplitter.Split(sqlContent); + int nonEmptyBatches = batches.Count(b => !string.IsNullOrWhiteSpace(b)); + LogDebug(progress, $" {fileName}: {nonEmptyBatches} batches to execute"); + + int batchNumber = 0; + foreach (string batch in batches) + { + string trimmedBatch = batch.Trim(); + if (string.IsNullOrWhiteSpace(trimmedBatch)) + continue; + + batchNumber++; + + using var command = new SqlCommand(trimmedBatch, connection); + command.CommandTimeout = StandardTimeoutSeconds; + + try + { + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) + { + string batchPreview = trimmedBatch.Length > 500 + ? trimmedBatch[..500] + $"... [truncated, total length: {trimmedBatch.Length}]" + : trimmedBatch; + throw new InvalidOperationException( + $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex); + } + } + + LogDebug(progress, $" {fileName}: completed in {fileSw.ElapsedMilliseconds}ms ({batchNumber} batches)"); + progress?.Report(new InstallationProgress + { + Message = $"{fileName} - Success", + Status = "Success", + CurrentStep = i + 1, + TotalSteps = scriptFiles.Count, + ProgressPercent = (int)(((i + 1) / (double)scriptFiles.Count) * 100) + }); + + result.FilesSucceeded++; + } + catch (Exception ex) + { + LogDebug(progress, $" {fileName}: FAILED — {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + LogDebug(progress, $" {fileName}: InnerException — {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + + progress?.Report(new InstallationProgress + { + Message = $"{fileName} - FAILED: {ex.Message}", + Status = "Error", + CurrentStep = i + 1, + TotalSteps = scriptFiles.Count + }); + + result.FilesFailed++; + result.Errors.Add((fileName, ex.Message)); + + /*Critical files abort installation*/ + if (Patterns.IsCriticalFile(fileName)) + { + progress?.Report(new InstallationProgress + { + Message = "Critical installation file failed. Aborting installation.", + Status = "Error" + }); + break; + } + } + } + + result.EndTime = DateTime.Now; + result.Success = result.FilesFailed == 0; + + var totalDuration = result.EndTime - result.StartTime; + LogDebug(progress, $"ExecuteInstallationAsync: finished — success={result.Success}, " + + $"succeeded={result.FilesSucceeded}, failed={result.FilesFailed}, " + + $"duration={totalDuration.TotalSeconds:F1}s"); + + return result; + } + + /// + /// Run validation (master collector) after installation. + /// + public static async Task<(int CollectorsSucceeded, int CollectorsFailed)> RunValidationAsync( + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + progress?.Report(new InstallationProgress + { + Message = "Running initial collection to validate installation...", + Status = "Info" + }); + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + /*Capture timestamp before running so we only check errors from this run. + Use SYSDATETIME() (local) because collection_time is stored in server local time.*/ + DateTime validationStart; + using (var command = new SqlCommand("SELECT SYSDATETIME();", connection)) + { + validationStart = (DateTime)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; + } + + /*Run master collector with @force_run_all*/ + progress?.Report(new InstallationProgress + { + Message = "Executing master collector...", + Status = "Info" + }); + + using (var command = new SqlCommand( + "EXECUTE PerformanceMonitor.collect.scheduled_master_collector @force_run_all = 1, @debug = 0;", + connection)) + { + command.CommandTimeout = StandardTimeoutSeconds; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + progress?.Report(new InstallationProgress + { + Message = "Master collector completed", + Status = "Success" + }); + + /*Check results - only from this validation run, not historical errors*/ + int successCount = 0; + int errorCount = 0; + + using (var command = new SqlCommand(@" + SELECT + success_count = COUNT_BIG(DISTINCT CASE WHEN collection_status = 'SUCCESS' THEN collector_name END), + error_count = SUM(CASE WHEN collection_status = 'ERROR' THEN 1 ELSE 0 END) + FROM PerformanceMonitor.config.collection_log + WHERE collection_time >= @validation_start;", connection)) + { + command.Parameters.AddWithValue("@validation_start", validationStart); + using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + successCount = reader.IsDBNull(0) ? 0 : (int)reader.GetInt64(0); + errorCount = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); + } + } + + progress?.Report(new InstallationProgress + { + Message = $"Validation complete: {successCount} collectors succeeded, {errorCount} failed", + Status = errorCount == 0 ? "Success" : "Warning" + }); + + /*Show failed collectors if any*/ + if (errorCount > 0) + { + using var command = new SqlCommand(@" + SELECT collector_name, error_message + FROM PerformanceMonitor.config.collection_log + WHERE collection_status = 'ERROR' + AND collection_time >= @validation_start + ORDER BY collection_time DESC;", connection); + command.Parameters.AddWithValue("@validation_start", validationStart); + + using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + string name = reader["collector_name"]?.ToString() ?? ""; + string error = reader["error_message"] == DBNull.Value + ? "(no error message)" + : reader["error_message"]?.ToString() ?? ""; + + progress?.Report(new InstallationProgress + { + Message = $" {name}: {error}", + Status = "Error" + }); + } + } + + return (successCount, errorCount); + } + + /// + /// Run installation verification diagnostics using 99_installer_troubleshooting.sql. + /// + public static async Task RunTroubleshootingAsync( + string connectionString, + ScriptProvider provider, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + bool hasErrors = false; + + try + { + string? scriptContent = provider.ReadTroubleshootingScript(); + + if (scriptContent == null) + { + progress?.Report(new InstallationProgress + { + Message = "Troubleshooting script not found: 99_installer_troubleshooting.sql", + Status = "Error" + }); + return false; + } + + progress?.Report(new InstallationProgress + { + Message = "Running installation diagnostics...", + Status = "Info" + }); + + /*Remove SQLCMD directives*/ + scriptContent = Patterns.SqlCmdDirectivePattern.Replace(scriptContent, string.Empty); + + /*Split into batches*/ + var batches = Patterns.GoBatchSplitter.Split(scriptContent) + .Where(b => !string.IsNullOrWhiteSpace(b)) + .ToList(); + + /*Connect to master first (script will USE PerformanceMonitor)*/ + using var connection = new SqlConnection(connectionString); + + /*Capture PRINT messages and determine status*/ + connection.InfoMessage += (sender, e) => + { + string message = e.Message; + + string status = "Info"; + if (message.Contains("[OK]", StringComparison.OrdinalIgnoreCase)) + status = "Success"; + else if (message.Contains("[WARN]", StringComparison.OrdinalIgnoreCase)) + { + status = "Warning"; + } + else if (message.Contains("[ERROR]", StringComparison.OrdinalIgnoreCase)) + { + status = "Error"; + hasErrors = true; + } + + progress?.Report(new InstallationProgress + { + Message = message, + Status = status + }); + }; + + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + foreach (var batch in batches) + { + if (string.IsNullOrWhiteSpace(batch)) + continue; + + cancellationToken.ThrowIfCancellationRequested(); + + using var cmd = new SqlCommand(batch, connection) + { + CommandTimeout = DependencyTimeoutSeconds + }; + + try + { + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) + { + progress?.Report(new InstallationProgress + { + Message = $"SQL Error: {ex.Message}", + Status = "Error" + }); + hasErrors = true; + } + + /*Small delay to allow UI to process messages*/ + await Task.Delay(25, cancellationToken).ConfigureAwait(false); + } + + return !hasErrors; + } + catch (Exception ex) + { + progress?.Report(new InstallationProgress + { + Message = $"Diagnostics failed: {ex.Message}", + Status = "Error" + }); + return false; + } + } + + /// + /// Generate installation summary report file. + /// + /// Directory to write the report. Null defaults to user profile. + public static string GenerateSummaryReport( + string serverName, + string sqlServerVersion, + string sqlServerEdition, + string installerVersion, + InstallationResult result, + string? outputDirectory = null) + { + ArgumentNullException.ThrowIfNull(serverName); + ArgumentNullException.ThrowIfNull(result); + + var duration = result.EndTime - result.StartTime; + + string timestamp = result.StartTime.ToString("yyyyMMdd_HHmmss"); + string fileName = $"PerformanceMonitor_Install_{serverName.Replace("\\", "_", StringComparison.Ordinal)}_{timestamp}.txt"; + string reportDir = outputDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string reportPath = Path.Combine(reportDir, fileName); + + var sb = new StringBuilder(); + + sb.AppendLine("================================================================================"); + sb.AppendLine("Performance Monitor Installation Report"); + sb.AppendLine("================================================================================"); + sb.AppendLine(); + + sb.AppendLine("INSTALLATION SUMMARY"); + sb.AppendLine("--------------------------------------------------------------------------------"); + sb.AppendLine($"Status: {(result.Success ? "SUCCESS" : "FAILED")}"); + sb.AppendLine($"Start Time: {result.StartTime:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"End Time: {result.EndTime:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Duration: {duration.TotalSeconds:F1} seconds"); + sb.AppendLine($"Files Executed: {result.FilesSucceeded}"); + sb.AppendLine($"Files Failed: {result.FilesFailed}"); + sb.AppendLine(); + + sb.AppendLine("SERVER INFORMATION"); + sb.AppendLine("--------------------------------------------------------------------------------"); + sb.AppendLine($"Server Name: {serverName}"); + sb.AppendLine($"SQL Server Edition: {sqlServerEdition}"); + sb.AppendLine(); + + if (!string.IsNullOrEmpty(sqlServerVersion)) + { + string[] versionLines = sqlServerVersion.Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries); + if (versionLines.Length > 0) + { + sb.AppendLine("SQL Server Version:"); + foreach (var line in versionLines) + { + sb.AppendLine($" {line.Trim()}"); + } + } + } + sb.AppendLine(); + + sb.AppendLine("INSTALLER INFORMATION"); + sb.AppendLine("--------------------------------------------------------------------------------"); + sb.AppendLine($"Installer Version: {installerVersion}"); + sb.AppendLine($"Working Directory: {Directory.GetCurrentDirectory()}"); + sb.AppendLine($"Machine Name: {Environment.MachineName}"); + sb.AppendLine($"User Name: {Environment.UserName}"); + sb.AppendLine(); + + if (result.Errors.Count > 0) + { + sb.AppendLine("ERRORS"); + sb.AppendLine("--------------------------------------------------------------------------------"); + foreach (var (file, error) in result.Errors) + { + sb.AppendLine($"File: {file}"); + string errorMsg = error.Length > 500 ? error[..500] + "..." : error; + sb.AppendLine($"Error: {errorMsg}"); + sb.AppendLine(); + } + } + + if (result.LogMessages.Count > 0) + { + sb.AppendLine("DETAILED INSTALLATION LOG"); + sb.AppendLine("--------------------------------------------------------------------------------"); + foreach (var (message, status) in result.LogMessages) + { + string prefix = status switch + { + "Success" => "[OK] ", + "Error" => "[ERROR] ", + "Warning" => "[WARN] ", + _ => "" + }; + sb.AppendLine($"{prefix}{message}"); + } + sb.AppendLine(); + } + + sb.AppendLine("================================================================================"); + sb.AppendLine("Generated by Performance Monitor Installer"); + sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC"); + sb.AppendLine("================================================================================"); + + File.WriteAllText(reportPath, sb.ToString()); + + return reportPath; + } + + /// + /// Get the currently installed version from the database. + /// Returns null if database doesn't exist or no successful installation found. + /// + public static async Task GetInstalledVersionAsync( + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + LogDebug(progress, "GetInstalledVersionAsync: checking for existing installation"); + try + { + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + /*Check if PerformanceMonitor database exists*/ + using var dbCheckCmd = new SqlCommand(@" + SELECT database_id + FROM sys.databases + WHERE name = N'PerformanceMonitor';", connection); + + var dbExists = await dbCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (dbExists == null || dbExists == DBNull.Value) + { + LogDebug(progress, "GetInstalledVersionAsync: database does not exist → clean install"); + return null; + } + LogDebug(progress, "GetInstalledVersionAsync: database exists, checking installation_history table"); + + /*Check if installation_history table exists*/ + using var tableCheckCmd = new SqlCommand(@" + USE PerformanceMonitor; + SELECT OBJECT_ID(N'config.installation_history', N'U');", connection); + + var tableExists = await tableCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (tableExists == null || tableExists == DBNull.Value) + { + LogDebug(progress, "GetInstalledVersionAsync: installation_history table does not exist → old or corrupted install"); + return null; + } + + /*Get most recent successful installation version*/ + using var versionCmd = new SqlCommand(@" + SELECT TOP 1 installer_version + FROM PerformanceMonitor.config.installation_history + WHERE installation_status = 'SUCCESS' + ORDER BY installation_date DESC;", connection); + + var version = await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (version != null && version != DBNull.Value) + { + LogDebug(progress, $"GetInstalledVersionAsync: found installed version {version}"); + return version.ToString(); + } + + /* + Fallback: database and history table exist but no SUCCESS rows. + This can happen if a prior install didn't write history (#538/#539). + Return "1.0.0" so all idempotent upgrade scripts are attempted + rather than treating this as a fresh install (which would drop the database). + */ + LogDebug(progress, "GetInstalledVersionAsync: no SUCCESS rows — fallback to 1.0.0 (#538 guard)"); + return "1.0.0"; + } + catch (SqlException ex) + { + LogDebug(progress, $"GetInstalledVersionAsync: SqlException — {ex.Number}: {ex.Message}"); + return null; + } + catch (Exception ex) + { + LogDebug(progress, $"GetInstalledVersionAsync: {ex.GetType().Name} — {ex.Message}"); + return null; + } + } + + /// + /// Execute an upgrade's SQL scripts using the ScriptProvider. + /// Returns (successCount, failureCount). + /// + public static async Task<(int successCount, int failureCount)> ExecuteUpgradeAsync( + ScriptProvider provider, + UpgradeInfo upgrade, + string connectionString, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + int successCount = 0; + int failureCount = 0; + + LogDebug(progress, $"ExecuteUpgradeAsync: {upgrade.FolderName} ({upgrade.FromVersion} → {upgrade.ToVersion})"); + var upgradeSw = Stopwatch.StartNew(); + + progress?.Report(new InstallationProgress + { + Message = $"Applying upgrade: {upgrade.FolderName}", + Status = "Info" + }); + + var sqlFileNames = provider.GetUpgradeManifest(upgrade); + LogDebug(progress, $"ExecuteUpgradeAsync: manifest has {sqlFileNames.Count} scripts: [{string.Join(", ", sqlFileNames)}]"); + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + + foreach (var fileName in sqlFileNames) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!provider.UpgradeScriptExists(upgrade, fileName)) + { + progress?.Report(new InstallationProgress + { + Message = $" {fileName} - WARNING: File not found", + Status = "Warning" + }); + failureCount++; + continue; + } + + try + { + string sql = await provider.ReadUpgradeScriptAsync(upgrade, fileName, cancellationToken).ConfigureAwait(false); + + /*Remove SQLCMD directives*/ + sql = Patterns.SqlCmdDirectivePattern.Replace(sql, ""); + + /*Split by GO statements*/ + string[] batches = Patterns.GoBatchSplitter.Split(sql); + + int batchNumber = 0; + foreach (var batch in batches) + { + batchNumber++; + string trimmedBatch = batch.Trim(); + + if (string.IsNullOrWhiteSpace(trimmedBatch)) + continue; + + using var cmd = new SqlCommand(trimmedBatch, connection); + cmd.CommandTimeout = UpgradeTimeoutSeconds; + + try + { + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) + { + string batchPreview = trimmedBatch.Length > 500 + ? trimmedBatch[..500] + $"... [truncated, total length: {trimmedBatch.Length}]" + : trimmedBatch; + throw new InvalidOperationException( + $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex); + } + } + + progress?.Report(new InstallationProgress + { + Message = $" {fileName} - Success", + Status = "Success" + }); + successCount++; + } + catch (Exception ex) + { + progress?.Report(new InstallationProgress + { + Message = $" {fileName} - FAILED: {ex.Message}", + Status = "Error" + }); + failureCount++; + } + } + + progress?.Report(new InstallationProgress + { + Message = $"Upgrade {upgrade.FolderName}: {successCount} succeeded, {failureCount} failed", + Status = failureCount == 0 ? "Success" : "Warning" + }); + + return (successCount, failureCount); + } + + /// + /// Execute all applicable upgrades in order using the ScriptProvider. + /// + public static async Task<(int totalSuccessCount, int totalFailureCount, int upgradeCount)> ExecuteAllUpgradesAsync( + ScriptProvider provider, + string connectionString, + string? currentVersion, + string targetVersion, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + int totalSuccessCount = 0; + int totalFailureCount = 0; + + var upgrades = provider.GetApplicableUpgrades(currentVersion, targetVersion, + warning => progress?.Report(new InstallationProgress { Message = warning, Status = "Warning" })); + + if (upgrades.Count == 0) + { + return (0, 0, 0); + } + + progress?.Report(new InstallationProgress + { + Message = $"Found {upgrades.Count} upgrade(s) to apply", + Status = "Info" + }); + + foreach (var upgrade in upgrades) + { + cancellationToken.ThrowIfCancellationRequested(); + + var (success, failure) = await ExecuteUpgradeAsync( + provider, + upgrade, + connectionString, + progress, + cancellationToken).ConfigureAwait(false); + + totalSuccessCount += success; + totalFailureCount += failure; + } + + return (totalSuccessCount, totalFailureCount, upgrades.Count); + } + + /// + /// Log installation history to config.installation_history. + /// + public static async Task LogInstallationHistoryAsync( + string connectionString, + string assemblyVersion, + string infoVersion, + DateTime startTime, + int filesExecuted, + int filesFailed, + bool isSuccess, + IProgress? progress = null) + { + LogDebug(progress, $"LogInstallationHistoryAsync: version={assemblyVersion}, filesExecuted={filesExecuted}, " + + $"filesFailed={filesFailed}, isSuccess={isSuccess}"); + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync().ConfigureAwait(false); + + /*Check if this is an upgrade by checking for existing installation*/ + string? previousVersion = null; + string installationType = "INSTALL"; + + try + { + using var checkCmd = new SqlCommand(@" + SELECT TOP 1 installer_version + FROM PerformanceMonitor.config.installation_history + WHERE installation_status = 'SUCCESS' + ORDER BY installation_date DESC;", connection); + + var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false); + if (result != null && result != DBNull.Value) + { + previousVersion = result.ToString(); + bool isSameVersion = Version.TryParse(previousVersion, out var prevVer) + && Version.TryParse(assemblyVersion, out var currVer) + && prevVer == currVer; + installationType = isSameVersion ? "REINSTALL" : "UPGRADE"; + } + } + catch (SqlException) + { + /*Table might not exist yet on first install*/ + } + + /*Get SQL Server version info*/ + string sqlVersion = ""; + string sqlEdition = ""; + + using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection)) + using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false)) + { + if (await reader.ReadAsync().ConfigureAwait(false)) + { + sqlVersion = reader.GetString(0); + sqlEdition = reader.GetString(1); + } + } + + long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds; + string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED"); + + var insertSql = @" + INSERT INTO PerformanceMonitor.config.installation_history + ( + installer_version, + installer_info_version, + sql_server_version, + sql_server_edition, + installation_type, + previous_version, + installation_status, + files_executed, + files_failed, + installation_duration_ms + ) + VALUES + ( + @installer_version, + @installer_info_version, + @sql_server_version, + @sql_server_edition, + @installation_type, + @previous_version, + @installation_status, + @files_executed, + @files_failed, + @installation_duration_ms + );"; + + using var insertCmd = new SqlCommand(insertSql, connection); + insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion }); + insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value }); + insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion }); + insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition }); + insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType }); + insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value }); + insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status }); + insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted }); + insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed }); + insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs }); + + await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false); + LogDebug(progress, $"LogInstallationHistoryAsync: wrote {installationType} record (status={status}, previousVersion={previousVersion ?? "null"})"); + } +} diff --git a/Installer.Core/Installer.Core.csproj b/Installer.Core/Installer.Core.csproj new file mode 100644 index 0000000..d203bfe --- /dev/null +++ b/Installer.Core/Installer.Core.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + Installer.Core + Installer.Core + SQL Server Performance Monitor Installer Core + 2.4.1 + 2.4.1.0 + 2.4.1.0 + 2.4.1 + Darling Data, LLC + Copyright (c) 2026 Darling Data, LLC + true + latest-recommended + CA1305;CA1845;CA1861;CA2100 + + + + + + + + + + + + + diff --git a/Installer.Core/Models/InstallationProgress.cs b/Installer.Core/Models/InstallationProgress.cs new file mode 100644 index 0000000..df97902 --- /dev/null +++ b/Installer.Core/Models/InstallationProgress.cs @@ -0,0 +1,13 @@ +namespace Installer.Core.Models; + +/// +/// Progress information for installation steps. +/// +public class InstallationProgress +{ + public string Message { get; set; } = string.Empty; + public string Status { get; set; } = "Info"; // Info, Success, Error, Warning + public int? CurrentStep { get; set; } + public int? TotalSteps { get; set; } + public int? ProgressPercent { get; set; } +} diff --git a/Installer.Core/Models/InstallationResult.cs b/Installer.Core/Models/InstallationResult.cs new file mode 100644 index 0000000..ee154e6 --- /dev/null +++ b/Installer.Core/Models/InstallationResult.cs @@ -0,0 +1,16 @@ +namespace Installer.Core.Models; + +/// +/// Installation result summary. +/// +public class InstallationResult +{ + public bool Success { get; set; } + public int FilesSucceeded { get; set; } + public int FilesFailed { get; set; } + public List<(string FileName, string ErrorMessage)> Errors { get; } = new(); + public List<(string Message, string Status)> LogMessages { get; } = new(); + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public string? ReportPath { get; set; } +} diff --git a/Installer.Core/Models/InstallationResultCode.cs b/Installer.Core/Models/InstallationResultCode.cs new file mode 100644 index 0000000..ad1b982 --- /dev/null +++ b/Installer.Core/Models/InstallationResultCode.cs @@ -0,0 +1,18 @@ +namespace Installer.Core.Models; + +/// +/// Result codes for installation operations. +/// Maps to CLI exit codes for backward compatibility. +/// +public enum InstallationResultCode +{ + Success = 0, + InvalidArguments = 1, + ConnectionFailed = 2, + CriticalScriptFailed = 3, + PartialInstallation = 4, + VersionCheckFailed = 5, + SqlFilesNotFound = 6, + UninstallFailed = 7, + UpgradesFailed = 8 +} diff --git a/Installer.Core/Models/ServerInfo.cs b/Installer.Core/Models/ServerInfo.cs new file mode 100644 index 0000000..740a31c --- /dev/null +++ b/Installer.Core/Models/ServerInfo.cs @@ -0,0 +1,38 @@ +namespace Installer.Core.Models; + +/// +/// Server information returned from connection test. +/// +public class ServerInfo +{ + public string ServerName { get; set; } = string.Empty; + public string SqlServerVersion { get; set; } = string.Empty; + public string SqlServerEdition { get; set; } = string.Empty; + public bool IsConnected { get; set; } + public string? ErrorMessage { get; set; } + public int EngineEdition { get; set; } + public int ProductMajorVersion { get; set; } + + /// + /// Returns true if the SQL Server version is supported (2016+). + /// Only checked for on-prem Standard (2) and Enterprise (3). + /// Azure MI (8) is always current and skips the check. + /// + public bool IsSupportedVersion => + EngineEdition is 8 || ProductMajorVersion >= 13; + + /// + /// Human-readable version name for error messages. + /// + public string ProductMajorVersionName => ProductMajorVersion switch + { + 11 => "SQL Server 2012", + 12 => "SQL Server 2014", + 13 => "SQL Server 2016", + 14 => "SQL Server 2017", + 15 => "SQL Server 2019", + 16 => "SQL Server 2022", + 17 => "SQL Server 2025", + _ => $"SQL Server (version {ProductMajorVersion})" + }; +} diff --git a/Installer.Core/Models/UpgradeInfo.cs b/Installer.Core/Models/UpgradeInfo.cs new file mode 100644 index 0000000..ff35ae3 --- /dev/null +++ b/Installer.Core/Models/UpgradeInfo.cs @@ -0,0 +1,12 @@ +namespace Installer.Core.Models; + +/// +/// Information about an applicable upgrade. +/// +public class UpgradeInfo +{ + public string Path { get; set; } = string.Empty; + public string FolderName { get; set; } = string.Empty; + public Version? FromVersion { get; set; } + public Version? ToVersion { get; set; } +} diff --git a/Installer.Core/Patterns.cs b/Installer.Core/Patterns.cs new file mode 100644 index 0000000..4e5c7fe --- /dev/null +++ b/Installer.Core/Patterns.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; + +namespace Installer.Core; + +/// +/// Shared compiled regex patterns for SQL file processing. +/// +public static partial class Patterns +{ + /// + /// Matches numbered SQL installation files (e.g., "01_install_database.sql", "41a_extra.sql"). + /// + [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$")] + public static partial Regex SqlFilePattern(); + + /// + /// Matches SQLCMD :r include directives for removal before execution. + /// + public static readonly Regex SqlCmdDirectivePattern = new( + @"^:r\s+.*$", + RegexOptions.Compiled | RegexOptions.Multiline); + + /// + /// Splits SQL content on GO batch separators (case-insensitive, with optional trailing comments). + /// + public static readonly Regex GoBatchSplitter = new( + @"^\s*GO\s*(?:--[^\r\n]*)?\s*$", + RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase); + + /// + /// Prefixes that indicate excluded scripts (uninstall, test, troubleshooting). + /// + public static readonly string[] ExcludedPrefixes = ["00_", "97_", "99_"]; + + /// + /// Prefixes that indicate critical installation scripts (abort on failure). + /// + public static readonly string[] CriticalPrefixes = ["01_", "02_", "03_"]; + + /// + /// Filters and sorts SQL installation files using the standard rules: + /// include files matching SqlFilePattern, exclude 00_/97_/99_ prefixes, sort alphabetically. + /// + public static List FilterInstallFiles(IEnumerable fileNames) + { + return fileNames + .Where(f => SqlFilePattern().IsMatch(f)) + .Where(f => !ExcludedPrefixes.Any(p => f.StartsWith(p, StringComparison.Ordinal))) + .OrderBy(f => f, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Returns true if the given file name represents a critical installation script. + /// + public static bool IsCriticalFile(string fileName) + { + return CriticalPrefixes.Any(p => fileName.StartsWith(p, StringComparison.Ordinal)); + } +} diff --git a/Installer.Core/ScriptProvider.cs b/Installer.Core/ScriptProvider.cs new file mode 100644 index 0000000..d07219d --- /dev/null +++ b/Installer.Core/ScriptProvider.cs @@ -0,0 +1,405 @@ +using System.Reflection; +using System.Text; +using Installer.Core.Models; + +namespace Installer.Core; + +/// +/// Identifies an SQL installation script. +/// +public record ScriptFile(string Name, string Identifier); + +/// +/// Abstracts the source of SQL installation and upgrade scripts. +/// FileSystem mode reads from install/ and upgrades/ directories (CLI, GUI). +/// Embedded mode reads from assembly resources (Dashboard). +/// +public abstract class ScriptProvider +{ + /// + /// Create a provider that reads scripts from the filesystem. + /// + public static ScriptProvider FromDirectory(string monitorRootDirectory) + => new FileSystemScriptProvider(monitorRootDirectory); + + /// + /// Create a provider that reads scripts from embedded assembly resources. + /// + public static ScriptProvider FromEmbeddedResources(Assembly? assembly = null) + => new EmbeddedResourceScriptProvider(assembly ?? typeof(ScriptProvider).Assembly); + + /// + /// Auto-discover: search filesystem starting from CWD and executable directory, + /// walking up to 5 parent directories. Falls back to embedded resources. + /// + /// Optional logging callback for diagnostics. + public static ScriptProvider AutoDiscover(Action? log = null) + { + var startDirs = new[] { Directory.GetCurrentDirectory(), AppDomain.CurrentDomain.BaseDirectory } + .Distinct() + .ToList(); + + log?.Invoke($"AutoDiscover: searching from [{string.Join(", ", startDirs)}]"); + + foreach (string startDir in startDirs) + { + DirectoryInfo? searchDir = new DirectoryInfo(startDir); + for (int i = 0; i < 6 && searchDir != null; i++) + { + string installFolder = Path.Combine(searchDir.FullName, "install"); + if (Directory.Exists(installFolder)) + { + var sqlFiles = Directory.GetFiles(installFolder, "*.sql") + .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f))) + .ToList(); + if (sqlFiles.Count > 0) + { + log?.Invoke($"AutoDiscover: found {sqlFiles.Count} scripts in {installFolder}"); + return new FileSystemScriptProvider(searchDir.FullName); + } + } + + var rootFiles = Directory.GetFiles(searchDir.FullName, "*.sql") + .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f))) + .ToList(); + if (rootFiles.Count > 0) + { + log?.Invoke($"AutoDiscover: found {rootFiles.Count} scripts in {searchDir.FullName}"); + return new FileSystemScriptProvider(searchDir.FullName); + } + + log?.Invoke($"AutoDiscover: no scripts in {searchDir.FullName}, trying parent"); + searchDir = searchDir.Parent; + } + } + + log?.Invoke("AutoDiscover: no filesystem scripts found, falling back to embedded resources"); + return FromEmbeddedResources(); + } + + /// + /// Returns the filtered, sorted list of install scripts (excludes 00_/97_/99_). + /// + public abstract List GetInstallFiles(); + + /// + /// Reads the content of an install script. + /// + public abstract string ReadScript(ScriptFile file); + + /// + /// Reads the content of an install script asynchronously. + /// + public abstract Task ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default); + + /// + /// Finds applicable upgrades from currentVersion to targetVersion. + /// Returns empty list if currentVersion is null (clean install) or no upgrades apply. + /// + /// Currently installed version, or null for clean install. + /// Target version to upgrade to. + /// Optional callback for warnings (e.g., missing upgrade.txt). + public abstract List GetApplicableUpgrades( + string? currentVersion, + string targetVersion, + Action? onWarning = null); + + /// + /// Reads the upgrade manifest (upgrade.txt) for a given upgrade. + /// Returns script names in execution order, skipping comments and blank lines. + /// + public abstract List GetUpgradeManifest(UpgradeInfo upgrade); + + /// + /// Reads an upgrade script's content. + /// + public abstract string ReadUpgradeScript(UpgradeInfo upgrade, string scriptName); + + /// + /// Reads an upgrade script's content asynchronously. + /// + public abstract Task ReadUpgradeScriptAsync( + UpgradeInfo upgrade, string scriptName, CancellationToken cancellationToken = default); + + /// + /// Returns true if the given upgrade script file exists. + /// + public abstract bool UpgradeScriptExists(UpgradeInfo upgrade, string scriptName); + + /// + /// Returns the content of the troubleshooting script (99_installer_troubleshooting.sql), or null if not found. + /// + public abstract string? ReadTroubleshootingScript(); + + /// + /// Core upgrade-discovery logic shared by both providers. + /// + protected static List FilterUpgrades( + IEnumerable candidates, + string? currentVersion, + string targetVersion) + { + if (currentVersion == null) + return []; + + if (!Version.TryParse(currentVersion, out var currentRaw)) + return []; + var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build); + + if (!Version.TryParse(targetVersion, out var targetRaw)) + return []; + var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build); + + return candidates + .Where(x => x.FromVersion != null && x.ToVersion != null) + .Where(x => x.FromVersion >= current) + .Where(x => x.ToVersion <= target) + .OrderBy(x => x.FromVersion) + .ToList(); + } + + /// + /// Parses an upgrade folder name like "1.2.0-to-1.3.0" into an UpgradeInfo. + /// Returns null if the name doesn't match the expected pattern. + /// + protected static UpgradeInfo? ParseUpgradeFolderName(string folderName, string path) + { + if (!folderName.Contains("-to-", StringComparison.Ordinal)) + return null; + + var parts = folderName.Split("-to-"); + var from = Version.TryParse(parts[0], out var f) ? f : null; + var to = parts.Length > 1 && Version.TryParse(parts[1], out var t) ? t : null; + + if (from == null || to == null) + return null; + + return new UpgradeInfo + { + Path = path, + FolderName = folderName, + FromVersion = from, + ToVersion = to + }; + } + + /// + /// Parses upgrade.txt content into a list of script names. + /// + protected static List ParseUpgradeManifest(IEnumerable lines) + { + return lines + .Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#')) + .Select(line => line.Trim()) + .ToList(); + } +} + +/// +/// Reads scripts from the filesystem (install/ and upgrades/ directories). +/// +internal sealed class FileSystemScriptProvider : ScriptProvider +{ + private readonly string _rootDirectory; + private readonly string _sqlDirectory; + + public FileSystemScriptProvider(string monitorRootDirectory) + { + _rootDirectory = monitorRootDirectory; + + string installFolder = Path.Combine(monitorRootDirectory, "install"); + _sqlDirectory = Directory.Exists(installFolder) ? installFolder : monitorRootDirectory; + } + + public override List GetInstallFiles() + { + if (!Directory.Exists(_sqlDirectory)) + return []; + + return Directory.GetFiles(_sqlDirectory, "*.sql") + .Select(f => new ScriptFile(Path.GetFileName(f), f)) + .Where(f => Patterns.SqlFilePattern().IsMatch(f.Name)) + .Where(f => !Patterns.ExcludedPrefixes.Any(p => f.Name.StartsWith(p, StringComparison.Ordinal))) + .OrderBy(f => f.Name, StringComparer.Ordinal) + .ToList(); + } + + public override string ReadScript(ScriptFile file) => + File.ReadAllText(file.Identifier); + + public override Task ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default) => + File.ReadAllTextAsync(file.Identifier, cancellationToken); + + public override List GetApplicableUpgrades( + string? currentVersion, + string targetVersion, + Action? onWarning = null) + { + string upgradesDir = Path.Combine(_rootDirectory, "upgrades"); + if (!Directory.Exists(upgradesDir)) + return []; + + var allFolders = Directory.GetDirectories(upgradesDir) + .Select(d => ParseUpgradeFolderName(Path.GetFileName(d), d)) + .Where(x => x != null) + .Cast() + .ToList(); + + var filtered = FilterUpgrades(allFolders, currentVersion, targetVersion); + + var result = new List(); + foreach (var upgrade in filtered) + { + string manifestPath = Path.Combine(upgrade.Path, "upgrade.txt"); + if (File.Exists(manifestPath)) + { + result.Add(upgrade); + } + else + { + onWarning?.Invoke($"Upgrade folder '{upgrade.FolderName}' has no upgrade.txt — skipped"); + } + } + return result; + } + + public override List GetUpgradeManifest(UpgradeInfo upgrade) + { + string manifestPath = Path.Combine(upgrade.Path, "upgrade.txt"); + return ParseUpgradeManifest(File.ReadAllLines(manifestPath)); + } + + public override string ReadUpgradeScript(UpgradeInfo upgrade, string scriptName) => + File.ReadAllText(Path.Combine(upgrade.Path, scriptName)); + + public override Task ReadUpgradeScriptAsync( + UpgradeInfo upgrade, string scriptName, CancellationToken cancellationToken = default) => + File.ReadAllTextAsync(Path.Combine(upgrade.Path, scriptName), cancellationToken); + + public override bool UpgradeScriptExists(UpgradeInfo upgrade, string scriptName) => + File.Exists(Path.Combine(upgrade.Path, scriptName)); + + public override string? ReadTroubleshootingScript() + { + string path = Path.Combine(_sqlDirectory, "99_installer_troubleshooting.sql"); + return File.Exists(path) ? File.ReadAllText(path) : null; + } +} + +/// +/// Reads scripts from embedded assembly resources. +/// Resource names follow: {AssemblyName}.Resources.install.{filename} +/// and {AssemblyName}.Resources.upgrades.{from}-to-{to}.{filename} +/// +internal sealed class EmbeddedResourceScriptProvider : ScriptProvider +{ + private readonly Assembly _assembly; + private readonly string _resourcePrefix; + + public EmbeddedResourceScriptProvider(Assembly assembly) + { + _assembly = assembly; + _resourcePrefix = assembly.GetName().Name ?? "Installer.Core"; + } + + public override List GetInstallFiles() + { + string installPrefix = $"{_resourcePrefix}.Resources.install."; + + return _assembly.GetManifestResourceNames() + .Where(r => r.StartsWith(installPrefix, StringComparison.Ordinal)) + .Select(r => new ScriptFile( + Name: r[installPrefix.Length..], + Identifier: r)) + .Where(f => Patterns.SqlFilePattern().IsMatch(f.Name)) + .Where(f => !Patterns.ExcludedPrefixes.Any(p => f.Name.StartsWith(p, StringComparison.Ordinal))) + .OrderBy(f => f.Name, StringComparer.Ordinal) + .ToList(); + } + + public override string ReadScript(ScriptFile file) => + ReadResource(file.Identifier); + + public override Task ReadScriptAsync(ScriptFile file, CancellationToken cancellationToken = default) => + Task.FromResult(ReadResource(file.Identifier)); + + public override List GetApplicableUpgrades( + string? currentVersion, + string targetVersion, + Action? onWarning = null) + { + string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades."; + + var folderNames = _assembly.GetManifestResourceNames() + .Where(r => r.StartsWith(upgradesPrefix, StringComparison.Ordinal)) + .Select(r => r[upgradesPrefix.Length..]) + .Select(r => r.Split('.')[0]) + .Distinct() + .ToList(); + + var allUpgrades = folderNames + .Select(f => ParseUpgradeFolderName(f, f)) + .Where(x => x != null) + .Cast() + .ToList(); + + var filtered = FilterUpgrades(allUpgrades, currentVersion, targetVersion); + + var result = new List(); + foreach (var upgrade in filtered) + { + string manifestResource = $"{upgradesPrefix}{upgrade.FolderName}.upgrade.txt"; + if (_assembly.GetManifestResourceNames().Contains(manifestResource)) + { + result.Add(upgrade); + } + else + { + onWarning?.Invoke($"Upgrade folder '{upgrade.FolderName}' has no upgrade.txt — skipped"); + } + } + return result; + } + + public override List GetUpgradeManifest(UpgradeInfo upgrade) + { + string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades."; + string manifestResource = $"{upgradesPrefix}{upgrade.FolderName}.upgrade.txt"; + string content = ReadResource(manifestResource); + return ParseUpgradeManifest(content.Split('\n')); + } + + public override string ReadUpgradeScript(UpgradeInfo upgrade, string scriptName) + { + string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades."; + string resource = $"{upgradesPrefix}{upgrade.FolderName}.{scriptName}"; + return ReadResource(resource); + } + + public override Task ReadUpgradeScriptAsync( + UpgradeInfo upgrade, string scriptName, CancellationToken cancellationToken = default) => + Task.FromResult(ReadUpgradeScript(upgrade, scriptName)); + + public override bool UpgradeScriptExists(UpgradeInfo upgrade, string scriptName) + { + string upgradesPrefix = $"{_resourcePrefix}.Resources.upgrades."; + string resource = $"{upgradesPrefix}{upgrade.FolderName}.{scriptName}"; + return _assembly.GetManifestResourceNames().Contains(resource); + } + + public override string? ReadTroubleshootingScript() + { + string resource = $"{_resourcePrefix}.Resources.install.99_installer_troubleshooting.sql"; + return _assembly.GetManifestResourceNames().Contains(resource) + ? ReadResource(resource) + : null; + } + + private string ReadResource(string resourceName) + { + using var stream = _assembly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}"); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } +} diff --git a/Installer.Tests/AdversarialTests.cs b/Installer.Tests/AdversarialTests.cs index 2908b6b..9565c46 100644 --- a/Installer.Tests/AdversarialTests.cs +++ b/Installer.Tests/AdversarialTests.cs @@ -1,6 +1,7 @@ +using Installer.Core; +using Installer.Core.Models; using Installer.Tests.Helpers; using Microsoft.Data.SqlClient; -using PerformanceMonitorInstallerGui.Services; namespace Installer.Tests; @@ -55,7 +56,7 @@ public async Task UpgradeFailure_DoesNotDropDatabase() // Run upgrades — should fail var (_, failureCount, _) = await InstallationService.ExecuteAllUpgradesAsync( - dir.RootPath, + ScriptProvider.FromDirectory(dir.RootPath), TestDatabaseHelper.GetTestDbConnectionString(), "2.0.0", "2.1.0", @@ -188,10 +189,10 @@ public async Task CriticalFileFailure_AbortsInstallation() File.WriteAllText(Path.Combine(dir.InstallPath, "05_procs.sql"), "CREATE TABLE dbo.definitely_should_not_exist (id int);"); - var files = dir.GetFilteredInstallFiles(); + var provider = ScriptProvider.FromDirectory(dir.RootPath); var result = await InstallationService.ExecuteInstallationAsync( TestDatabaseHelper.GetTestDbConnectionString(), - files, + provider, cleanInstall: false, cancellationToken: TestContext.Current.CancellationToken ); @@ -234,7 +235,7 @@ public async Task CancellationMidUpgrade_VersionUnchanged() try { await InstallationService.ExecuteAllUpgradesAsync( - dir.RootPath, + ScriptProvider.FromDirectory(dir.RootPath), TestDatabaseHelper.GetTestDbConnectionString(), "2.0.0", "2.1.0", @@ -278,10 +279,10 @@ public async Task NonCriticalFileFailure_ContinuesInstallation() File.WriteAllText(Path.Combine(dir.InstallPath, "05_should_still_run.sql"), "CREATE TABLE dbo.proof_it_continued (id int);"); - var files = dir.GetFilteredInstallFiles(); + var provider = ScriptProvider.FromDirectory(dir.RootPath); var result = await InstallationService.ExecuteInstallationAsync( TestDatabaseHelper.GetTestDbConnectionString(), - files, + provider, cleanInstall: false, cancellationToken: TestContext.Current.CancellationToken ); @@ -315,10 +316,10 @@ public async Task CorruptSqlContent_FailsGracefully() File.WriteAllText(Path.Combine(dir.InstallPath, "04_corrupt.sql"), "THIS IS NOT SQL AT ALL 🔥 §±∞ DROP TABLE BOBBY;; EXEC((("); - var files = dir.GetFilteredInstallFiles(); + var provider = ScriptProvider.FromDirectory(dir.RootPath); var result = await InstallationService.ExecuteInstallationAsync( TestDatabaseHelper.GetTestDbConnectionString(), - files, + provider, cleanInstall: false, cancellationToken: TestContext.Current.CancellationToken ); @@ -341,10 +342,10 @@ public async Task EmptySqlFile_DoesNotCrash() File.WriteAllText(Path.Combine(dir.InstallPath, "01_empty.sql"), ""); - var files = dir.GetFilteredInstallFiles(); + var provider = ScriptProvider.FromDirectory(dir.RootPath); var result = await InstallationService.ExecuteInstallationAsync( TestDatabaseHelper.GetTestDbConnectionString(), - files, + provider, cleanInstall: false, cancellationToken: TestContext.Current.CancellationToken ); @@ -463,10 +464,10 @@ IF DB_ID(N'PerformanceMonitor_RestrictedTest') IS NULL File.WriteAllText(Path.Combine(dir.InstallPath, "02_create_tables.sql"), "CREATE TABLE dbo.should_not_exist (id int);"); - var files = dir.GetFilteredInstallFiles(); + var provider = ScriptProvider.FromDirectory(dir.RootPath); var result = await InstallationService.ExecuteInstallationAsync( restrictedConnStr, - files, + provider, cleanInstall: false, cancellationToken: TestContext.Current.CancellationToken ); diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj index c930745..18a446f 100644 --- a/Installer.Tests/Installer.Tests.csproj +++ b/Installer.Tests/Installer.Tests.csproj @@ -1,8 +1,7 @@ - net8.0-windows + net8.0 enable - true false enable true @@ -21,6 +20,6 @@ - + diff --git a/Installer.Tests/UpgradeOrderingTests.cs b/Installer.Tests/UpgradeOrderingTests.cs index ee911df..eace56b 100644 --- a/Installer.Tests/UpgradeOrderingTests.cs +++ b/Installer.Tests/UpgradeOrderingTests.cs @@ -1,5 +1,6 @@ +using Installer.Core; +using Installer.Core.Models; using Installer.Tests.Helpers; -using PerformanceMonitorInstallerGui.Services; namespace Installer.Tests; @@ -17,7 +18,7 @@ public void ReturnsCorrectUpgradesForVersionRange() .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql") .WithUpgrade("2.1.0", "2.2.0", "01_compress.sql"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "1.3.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("1.3.0", "2.2.0"); Assert.Equal(3, upgrades.Count); Assert.Equal("1.3.0-to-2.0.0", upgrades[0].FolderName); @@ -33,7 +34,7 @@ public void SkipsAlreadyAppliedUpgrades() .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql") .WithUpgrade("2.1.0", "2.2.0", "01_compress.sql"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0"); Assert.Equal(2, upgrades.Count); Assert.Equal("2.0.0-to-2.1.0", upgrades[0].FolderName); @@ -47,7 +48,7 @@ public void AlreadyAtTargetVersion_ReturnsEmpty() .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql") .WithUpgrade("2.1.0", "2.2.0", "01_compress.sql"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.2.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.2.0", "2.2.0"); Assert.Empty(upgrades); } @@ -59,7 +60,7 @@ public void FourPartVersion_NormalizedToThreePart() using var dir = new TempDirectoryBuilder() .WithUpgrade("2.1.0", "2.2.0", "01_compress.sql"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.1.0.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.1.0.0", "2.2.0"); Assert.Single(upgrades); Assert.Equal("2.1.0-to-2.2.0", upgrades[0].FolderName); @@ -73,7 +74,7 @@ public void MalformedFolderNames_Skipped() .WithMalformedUpgradeFolder("not-a-version") .WithMalformedUpgradeFolder("foo-to-bar"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0"); Assert.Single(upgrades); Assert.Equal("2.0.0-to-2.1.0", upgrades[0].FolderName); @@ -86,7 +87,7 @@ public void MissingUpgradeTxt_FolderSkipped() .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql") .WithUpgradeNoManifest("2.1.0", "2.2.0"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0"); Assert.Single(upgrades); Assert.Equal("2.0.0-to-2.1.0", upgrades[0].FolderName); @@ -98,7 +99,7 @@ public void NoUpgradesFolder_ReturnsEmpty() using var dir = new TempDirectoryBuilder(); // Don't create any upgrade folders - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0"); Assert.Empty(upgrades); } @@ -109,7 +110,7 @@ public void NullCurrentVersion_ReturnsEmpty() using var dir = new TempDirectoryBuilder() .WithUpgrade("2.0.0", "2.1.0", "01_columns.sql"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, null, "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades(null, "2.2.0"); Assert.Empty(upgrades); } @@ -123,7 +124,7 @@ public void OrderedByFromVersion() .WithUpgrade("1.3.0", "2.0.0", "01_a.sql") .WithUpgrade("2.0.0", "2.1.0", "01_b.sql"); - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "1.3.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("1.3.0", "2.2.0"); Assert.Equal(3, upgrades.Count); Assert.Equal(new Version(1, 3, 0), upgrades[0].FromVersion); @@ -140,7 +141,7 @@ public void DoesNotIncludeFutureUpgrades() .WithUpgrade("2.2.0", "2.3.0", "01_c.sql"); // Target is 2.2.0, so 2.2.0-to-2.3.0 should NOT be included - var upgrades = InstallationService.GetApplicableUpgrades(dir.RootPath, "2.0.0", "2.2.0"); + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.0.0", "2.2.0"); Assert.Equal(2, upgrades.Count); Assert.DoesNotContain(upgrades, u => u.FolderName == "2.2.0-to-2.3.0"); diff --git a/InstallerGui/InstallerGui.csproj b/InstallerGui/InstallerGui.csproj index c063f11..61d0342 100644 --- a/InstallerGui/InstallerGui.csproj +++ b/InstallerGui/InstallerGui.csproj @@ -30,6 +30,10 @@ + + + + PreserveNewest diff --git a/InstallerGui/MainWindow.xaml.cs b/InstallerGui/MainWindow.xaml.cs index c7d80b7..6103e94 100644 --- a/InstallerGui/MainWindow.xaml.cs +++ b/InstallerGui/MainWindow.xaml.cs @@ -15,19 +15,18 @@ using System.Windows; using System.Windows.Documents; using System.Windows.Media; -using PerformanceMonitorInstallerGui.Services; +using Installer.Core; +using Installer.Core.Models; using PerformanceMonitorInstallerGui.Utilities; namespace PerformanceMonitorInstallerGui { public partial class MainWindow : Window { - private readonly InstallationService _installationService; + private readonly DependencyInstaller _dependencyInstaller; private CancellationTokenSource? _cancellationTokenSource; private string? _connectionString; - private string? _sqlDirectory; - private string? _monitorRootDirectory; - private List? _sqlFiles; + private ScriptProvider? _scriptProvider; private ServerInfo? _serverInfo; private InstallationResult? _installationResult; private string? _installedVersion; @@ -61,7 +60,7 @@ public MainWindow() try { InitializeComponent(); - _installationService = new InstallationService(); + _dependencyInstaller = new DependencyInstaller(); /*Set window title with version*/ Title = $"Performance Monitor Installer v{AppVersion}"; @@ -108,20 +107,18 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) /// private void FindInstallationFiles() { - var (sqlDirectory, monitorRootDirectory, sqlFiles) = InstallationService.FindInstallationFiles(); + _scriptProvider = ScriptProvider.AutoDiscover(); + var scriptFiles = _scriptProvider.GetInstallFiles(); - _sqlDirectory = sqlDirectory; - _monitorRootDirectory = monitorRootDirectory; - _sqlFiles = sqlFiles; - - if (sqlDirectory != null) + if (scriptFiles.Count > 0) { - LogMessage($"Found {sqlFiles.Count} SQL files in: {sqlDirectory}", "Info"); + LogMessage($"Found {scriptFiles.Count} SQL installation files", "Info"); } else { LogMessage("WARNING: No SQL installation files found.", "Warning"); LogMessage("Make sure the installer is in the Monitor directory or a subdirectory.", "Warning"); + _scriptProvider = null; InstallButton.IsEnabled = false; MessageBox.Show(this, @@ -290,10 +287,9 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e) LogMessage($"Installed version: {_installedVersion}", "Info"); /*Check for applicable upgrades*/ - if (_monitorRootDirectory != null) + if (_scriptProvider != null) { - var upgrades = InstallationService.GetApplicableUpgrades( - _monitorRootDirectory, + var upgrades = _scriptProvider.GetApplicableUpgrades( _installedVersion, AppAssemblyVersion); if (upgrades.Count > 0) @@ -311,7 +307,7 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e) LogMessage("No existing installation detected (clean install)", "Info"); } - InstallButton.IsEnabled = _sqlFiles != null && _sqlFiles.Count > 0; + InstallButton.IsEnabled = _scriptProvider != null; UninstallButton.IsEnabled = _installedVersion != null; /*Show confirmation MessageBox*/ @@ -353,7 +349,7 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e) /// private async void Install_Click(object sender, RoutedEventArgs e) { - if (_connectionString == null || _sqlFiles == null || _sqlDirectory == null) + if (_connectionString == null || _scriptProvider == null) { MessageBox.Show(this, "Please test the connection first.", "Validation Error", MessageBoxButton.OK, MessageBoxImage.Warning); @@ -407,10 +403,10 @@ private async void Install_Click(object sender, RoutedEventArgs e) Execute upgrades if applicable (only when not doing clean install) */ bool isCleanInstall = CleanInstallCheckBox.IsChecked == true; - if (!isCleanInstall && _installedVersion != null && _monitorRootDirectory != null) + if (!isCleanInstall && _installedVersion != null && _scriptProvider != null) { var (upgradeSuccess, upgradeFailure, upgradeCount) = await InstallationService.ExecuteAllUpgradesAsync( - _monitorRootDirectory, + _scriptProvider, _connectionString, _installedVersion, AppAssemblyVersion, @@ -443,13 +439,13 @@ Community dependencies install automatically before validation (98_validate) bool resetSchedule = ResetScheduleCheckBox.IsChecked == true; _installationResult = await InstallationService.ExecuteInstallationAsync( _connectionString, - _sqlFiles, + _scriptProvider, isCleanInstall, resetSchedule, progress, preValidationAction: async () => { - await _installationService.InstallDependenciesAsync( + await _dependencyInstaller.InstallDependenciesAsync( _connectionString, progress, cancellationToken); @@ -685,7 +681,7 @@ private async void Uninstall_Click(object sender, RoutedEventArgs e) /// private async void Troubleshoot_Click(object sender, RoutedEventArgs e) { - if (_connectionString == null || _sqlDirectory == null) + if (_connectionString == null || _scriptProvider == null) { return; } @@ -708,7 +704,7 @@ private async void Troubleshoot_Click(object sender, RoutedEventArgs e) { bool success = await InstallationService.RunTroubleshootingAsync( _connectionString, - _sqlDirectory, + _scriptProvider, progress, cancellationToken); @@ -773,7 +769,7 @@ private void Close_Click(object sender, RoutedEventArgs e) protected override void OnClosed(EventArgs e) { _cancellationTokenSource?.Dispose(); - _installationService?.Dispose(); + _dependencyInstaller?.Dispose(); base.OnClosed(e); } diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs deleted file mode 100644 index 0f0c91a..0000000 --- a/InstallerGui/Services/InstallationService.cs +++ /dev/null @@ -1,1552 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Collections.Generic; -using System.Data; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Data.SqlClient; - -namespace PerformanceMonitorInstallerGui.Services -{ - /// - /// Progress information for installation steps - /// - public class InstallationProgress - { - public string Message { get; set; } = string.Empty; - public string Status { get; set; } = "Info"; // Info, Success, Error, Warning - public int? CurrentStep { get; set; } - public int? TotalSteps { get; set; } - public int? ProgressPercent { get; set; } - } - - /// - /// Server information returned from connection test - /// - public class ServerInfo - { - public string ServerName { get; set; } = string.Empty; - public string SqlServerVersion { get; set; } = string.Empty; - public string SqlServerEdition { get; set; } = string.Empty; - public bool IsConnected { get; set; } - public string? ErrorMessage { get; set; } - public int EngineEdition { get; set; } - public int ProductMajorVersion { get; set; } - - /// - /// Returns true if the SQL Server version is supported (2016+). - /// Only checked for on-prem Standard (2) and Enterprise (3). - /// Azure MI (8) is always current and skips the check. - /// - public bool IsSupportedVersion => - EngineEdition is 8 || ProductMajorVersion >= 13; - - /// - /// Human-readable version name for error messages. - /// - public string ProductMajorVersionName => ProductMajorVersion switch - { - 11 => "SQL Server 2012", - 12 => "SQL Server 2014", - 13 => "SQL Server 2016", - 14 => "SQL Server 2017", - 15 => "SQL Server 2019", - 16 => "SQL Server 2022", - 17 => "SQL Server 2025", - _ => $"SQL Server (version {ProductMajorVersion})" - }; - } - - /// - /// Installation result summary - /// - public class InstallationResult - { - public bool Success { get; set; } - public int FilesSucceeded { get; set; } - public int FilesFailed { get; set; } - public List<(string FileName, string ErrorMessage)> Errors { get; } = new(); - public List<(string Message, string Status)> LogMessages { get; } = new(); - public DateTime StartTime { get; set; } - public DateTime EndTime { get; set; } - public string? ReportPath { get; set; } - } - - /// - /// Service for installing the Performance Monitor database - /// - public partial class InstallationService : IDisposable - { - private readonly HttpClient _httpClient; - private bool _disposed; - - /* - Compiled regex patterns for better performance - */ - private static readonly Regex SqlFilePattern = SqlFileRegExp(); - - private static readonly Regex SqlCmdDirectivePattern = new( - @"^:r\s+.*$", - RegexOptions.Compiled | RegexOptions.Multiline); - - private static readonly Regex GoBatchSplitter = new( - @"^\s*GO\s*(?:--[^\r\n]*)?\s*$", - RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase); - - private static readonly char[] NewLineChars = { '\r', '\n' }; - - public InstallationService() - { - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(30) - }; - } - - /// - /// Build a connection string from the provided parameters - /// - public static string BuildConnectionString( - string server, - bool useWindowsAuth, - string? username = null, - string? password = null, - string encryption = "Mandatory", - bool trustCertificate = false, - bool useEntraAuth = false) - { - var builder = new SqlConnectionStringBuilder - { - DataSource = server, - InitialCatalog = "master", - TrustServerCertificate = trustCertificate - }; - - /*Set encryption mode: Optional, Mandatory, or Strict*/ - builder.Encrypt = encryption switch - { - "Optional" => SqlConnectionEncryptOption.Optional, - "Mandatory" => SqlConnectionEncryptOption.Mandatory, - "Strict" => SqlConnectionEncryptOption.Strict, - _ => SqlConnectionEncryptOption.Mandatory - }; - - if (useEntraAuth) - { - builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; - builder.UserID = username; - } - else if (useWindowsAuth) - { - builder.IntegratedSecurity = true; - } - else - { - builder.UserID = username; - builder.Password = password; - } - - return builder.ConnectionString; - } - - /// - /// Test connection to SQL Server and get server information - /// - public static async Task TestConnectionAsync(string connectionString, CancellationToken cancellationToken = default) - { - var info = new ServerInfo(); - - try - { - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - info.IsConnected = true; - - using var command = new SqlCommand(@" - SELECT - @@VERSION, - SERVERPROPERTY('Edition'), - @@SERVERNAME, - CONVERT(int, SERVERPROPERTY('EngineEdition')), - SERVERPROPERTY('ProductMajorVersion');", connection); - using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - - if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - info.SqlServerVersion = reader.GetString(0); - info.SqlServerEdition = reader.GetString(1); - info.ServerName = reader.GetString(2); - info.EngineEdition = reader.IsDBNull(3) ? 0 : reader.GetInt32(3); - info.ProductMajorVersion = reader.IsDBNull(4) ? 0 : int.TryParse(reader.GetValue(4).ToString(), out var v) ? v : 0; - } - } - catch (Exception ex) - { - info.IsConnected = false; - info.ErrorMessage = ex.Message; - if (ex.InnerException != null) - { - info.ErrorMessage += $"\n{ex.InnerException.Message}"; - } - } - - return info; - } - - /// - /// Find SQL installation files - /// - public static (string? SqlDirectory, string? MonitorRootDirectory, List SqlFiles) FindInstallationFiles() - { - string? sqlDirectory = null; - string? monitorRootDirectory = null; - var sqlFiles = new List(); - - /*Try multiple starting locations: current directory and executable location*/ - var startingDirectories = new List - { - Directory.GetCurrentDirectory(), - AppDomain.CurrentDomain.BaseDirectory - }; - - foreach (string startDir in startingDirectories.Distinct()) - { - if (sqlDirectory != null) - break; - - DirectoryInfo? searchDir = new DirectoryInfo(startDir); - - for (int i = 0; i < 6 && searchDir != null; i++) - { - /*Check for install/ subfolder first (new structure)*/ - string installFolder = Path.Combine(searchDir.FullName, "install"); - if (Directory.Exists(installFolder)) - { - var installFiles = Directory.GetFiles(installFolder, "*.sql") - .Where(f => SqlFilePattern.IsMatch(Path.GetFileName(f))) - .ToList(); - - if (installFiles.Count > 0) - { - sqlDirectory = installFolder; - monitorRootDirectory = searchDir.FullName; - break; - } - } - - /*Fall back to old structure (SQL files in root)*/ - var files = Directory.GetFiles(searchDir.FullName, "*.sql") - .Where(f => SqlFilePattern.IsMatch(Path.GetFileName(f))) - .ToList(); - - if (files.Count > 0) - { - sqlDirectory = searchDir.FullName; - monitorRootDirectory = searchDir.FullName; - break; - } - - searchDir = searchDir.Parent; - } - } - - if (sqlDirectory != null) - { - sqlFiles = Directory.GetFiles(sqlDirectory, "*.sql") - .Where(f => - { - string fileName = Path.GetFileName(f); - /*Match numbered SQL files but exclude 97 (tests) and 99 (troubleshooting)*/ - if (!SqlFilePattern.IsMatch(fileName)) - return false; - /*Exclude uninstall, test, and troubleshooting scripts from main install*/ - if (fileName.StartsWith("00_", StringComparison.Ordinal) || - fileName.StartsWith("97_", StringComparison.Ordinal) || - fileName.StartsWith("99_", StringComparison.Ordinal)) - return false; - return true; - }) - .OrderBy(f => Path.GetFileName(f)) - .ToList(); - } - - return (sqlDirectory, monitorRootDirectory, sqlFiles); - } - - /// - /// Perform clean install (drop existing database and jobs) - /// - public static async Task CleanInstallAsync( - string connectionString, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - progress?.Report(new InstallationProgress - { - Message = "Performing clean install...", - Status = "Info" - }); - - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - /* - Stop any existing traces before dropping database - */ - try - { - using var traceCmd = new SqlCommand( - "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", - connection); - traceCmd.CommandTimeout = 60; - await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - progress?.Report(new InstallationProgress - { - Message = "Stopped existing traces", - Status = "Success" - }); - } - catch (SqlException) - { - /*Database or procedure doesn't exist - no traces to clean*/ - } - - /* - Remove Agent jobs, XE sessions, and database - */ - string cleanupSql = @" -USE msdb; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1; -END; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1; -END; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1; -END; - -USE master; - -IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') -BEGIN - IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') - ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP; - DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER; -END; - -IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock') -BEGIN - IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock') - ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP; - DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER; -END; - -IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') -BEGIN - ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - DROP DATABASE PerformanceMonitor; -END;"; - - using var command = new SqlCommand(cleanupSql, connection); - command.CommandTimeout = 60; - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - progress?.Report(new InstallationProgress - { - Message = "Clean install completed (jobs, XE sessions, and database removed)", - Status = "Success" - }); - } - - /// - /// Perform complete uninstall (remove database, jobs, XE sessions, and traces) - /// - public static async Task ExecuteUninstallAsync( - string connectionString, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - progress?.Report(new InstallationProgress - { - Message = "Uninstalling Performance Monitor...", - Status = "Info" - }); - - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - /* - Stop existing traces before dropping database - */ - try - { - using var traceCmd = new SqlCommand( - "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", - connection); - traceCmd.CommandTimeout = 60; - await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - - progress?.Report(new InstallationProgress - { - Message = "Stopped server-side traces", - Status = "Success" - }); - } - catch (SqlException) - { - progress?.Report(new InstallationProgress - { - Message = "No traces to stop (database or procedure not found)", - Status = "Info" - }); - } - - /* - Remove Agent jobs, XE sessions, and database - */ - await CleanInstallAsync(connectionString, progress, cancellationToken) - .ConfigureAwait(false); - - progress?.Report(new InstallationProgress - { - Message = "Uninstall completed successfully", - Status = "Success", - ProgressPercent = 100 - }); - - return true; - } - - /// - /// Execute SQL installation files - /// - public static async Task ExecuteInstallationAsync( - string connectionString, - List sqlFiles, - bool cleanInstall, - bool resetSchedule = false, - IProgress? progress = null, - Func? preValidationAction = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(sqlFiles); - - var result = new InstallationResult - { - StartTime = DateTime.Now - }; - - /* - Perform clean install if requested - */ - if (cleanInstall) - { - try - { - await CleanInstallAsync(connectionString, progress, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - progress?.Report(new InstallationProgress - { - Message = $"CLEAN INSTALL FAILED: {ex.Message}", - Status = "Error" - }); - progress?.Report(new InstallationProgress - { - Message = "Installation aborted - clean install was requested but failed.", - Status = "Error" - }); - result.EndTime = DateTime.Now; - result.Success = false; - result.FilesFailed = 1; - result.Errors.Add(("Clean Install", ex.Message)); - return result; - } - } - - /* - Execute SQL files - Note: Files execute without transaction wrapping because many contain DDL. - If installation fails mid-way, use clean install to reset and retry. - */ - progress?.Report(new InstallationProgress - { - Message = "Starting installation...", - Status = "Info", - CurrentStep = 0, - TotalSteps = sqlFiles.Count, - ProgressPercent = 0 - }); - - bool preValidationActionRan = false; - - for (int i = 0; i < sqlFiles.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - - string sqlFile = sqlFiles[i]; - string fileName = Path.GetFileName(sqlFile); - - /*Install community dependencies before validation runs - Collectors in 98_validate need sp_WhoIsActive, sp_HealthParser, etc.*/ - if (!preValidationActionRan && - preValidationAction != null && - fileName.StartsWith("98_", StringComparison.Ordinal)) - { - preValidationActionRan = true; - await preValidationAction().ConfigureAwait(false); - } - - progress?.Report(new InstallationProgress - { - Message = $"Executing {fileName}...", - Status = "Info", - CurrentStep = i + 1, - TotalSteps = sqlFiles.Count, - ProgressPercent = (int)(((i + 1) / (double)sqlFiles.Count) * 100) - }); - - try - { - string sqlContent = await File.ReadAllTextAsync(sqlFile, cancellationToken).ConfigureAwait(false); - - /*Reset schedule to defaults if requested*/ - if (resetSchedule && fileName.StartsWith("04_", StringComparison.Ordinal)) - { - sqlContent = "TRUNCATE TABLE [PerformanceMonitor].[config].[collection_schedule];\nGO\n" + sqlContent; - progress?.Report(new InstallationProgress - { - Message = "Resetting schedule to recommended defaults...", - Status = "Info" - }); - } - - /*Remove SQLCMD directives*/ - sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, ""); - - /*Execute the SQL batch*/ - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - /*Split by GO statements*/ - string[] batches = GoBatchSplitter.Split(sqlContent); - - int batchNumber = 0; - foreach (string batch in batches) - { - string trimmedBatch = batch.Trim(); - if (string.IsNullOrWhiteSpace(trimmedBatch)) - continue; - - batchNumber++; - - using var command = new SqlCommand(trimmedBatch, connection); - command.CommandTimeout = 300; - - try - { - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - catch (SqlException ex) - { - string batchPreview = trimmedBatch.Length > 500 - ? trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]" - : trimmedBatch; - throw new InvalidOperationException( - $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex); - } - } - - progress?.Report(new InstallationProgress - { - Message = $"{fileName} - Success", - Status = "Success", - CurrentStep = i + 1, - TotalSteps = sqlFiles.Count, - ProgressPercent = (int)(((i + 1) / (double)sqlFiles.Count) * 100) - }); - - result.FilesSucceeded++; - } - catch (Exception ex) - { - progress?.Report(new InstallationProgress - { - Message = $"{fileName} - FAILED: {ex.Message}", - Status = "Error", - CurrentStep = i + 1, - TotalSteps = sqlFiles.Count - }); - - result.FilesFailed++; - result.Errors.Add((fileName, ex.Message)); - - /*Critical files abort installation*/ - if (fileName.StartsWith("01_", StringComparison.Ordinal) || - fileName.StartsWith("02_", StringComparison.Ordinal) || - fileName.StartsWith("03_", StringComparison.Ordinal)) - { - progress?.Report(new InstallationProgress - { - Message = "Critical installation file failed. Aborting installation.", - Status = "Error" - }); - break; - } - } - } - - result.EndTime = DateTime.Now; - - result.Success = result.FilesFailed == 0; - - return result; - } - - /// - /// Install community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit) - /// - public async Task InstallDependenciesAsync( - string connectionString, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - var dependencies = new List<(string Name, string Url, string Description)> - { - ( - "sp_WhoIsActive", - "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql", - "Query activity monitoring by Adam Machanic (GPLv3)" - ), - ( - "DarlingData", - "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql", - "sp_HealthParser, sp_HumanEventsBlockViewer by Erik Darling (MIT)" - ), - ( - "First Responder Kit", - "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql", - "sp_BlitzLock and diagnostic tools by Brent Ozar Unlimited (MIT)" - ) - }; - - progress?.Report(new InstallationProgress - { - Message = "Installing community dependencies...", - Status = "Info" - }); - - int successCount = 0; - - foreach (var (name, url, description) in dependencies) - { - cancellationToken.ThrowIfCancellationRequested(); - - progress?.Report(new InstallationProgress - { - Message = $"Installing {name}...", - Status = "Info" - }); - - try - { - string sql = await DownloadWithRetryAsync(_httpClient, url, progress, cancellationToken: cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(sql)) - { - progress?.Report(new InstallationProgress - { - Message = $"{name} - FAILED (empty response)", - Status = "Error" - }); - continue; - } - - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - using (var useDbCommand = new SqlCommand("USE PerformanceMonitor;", connection)) - { - await useDbCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - string[] batches = GoBatchSplitter.Split(sql); - - foreach (string batch in batches) - { - string trimmedBatch = batch.Trim(); - if (string.IsNullOrWhiteSpace(trimmedBatch)) - continue; - - using var command = new SqlCommand(trimmedBatch, connection); - command.CommandTimeout = 120; - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - progress?.Report(new InstallationProgress - { - Message = $"{name} - Success ({description})", - Status = "Success" - }); - - successCount++; - } - catch (HttpRequestException ex) - { - progress?.Report(new InstallationProgress - { - Message = $"{name} - Download failed: {ex.Message}", - Status = "Error" - }); - } - catch (SqlException ex) - { - progress?.Report(new InstallationProgress - { - Message = $"{name} - SQL execution failed: {ex.Message}", - Status = "Error" - }); - } - catch (Exception ex) - { - progress?.Report(new InstallationProgress - { - Message = $"{name} - Failed: {ex.Message}", - Status = "Error" - }); - } - } - - progress?.Report(new InstallationProgress - { - Message = $"Dependencies installed: {successCount}/{dependencies.Count}", - Status = successCount == dependencies.Count ? "Success" : "Warning" - }); - - return successCount; - } - - /// - /// Run validation (master collector) after installation - /// - public static async Task<(int CollectorsSucceeded, int CollectorsFailed)> RunValidationAsync( - string connectionString, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - progress?.Report(new InstallationProgress - { - Message = "Running initial collection to validate installation...", - Status = "Info" - }); - - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - /*Capture timestamp before running so we only check errors from this run. - Use SYSDATETIME() (local) because collection_time is stored in server local time.*/ - DateTime validationStart; - using (var command = new SqlCommand("SELECT SYSDATETIME();", connection)) - { - validationStart = (DateTime)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!; - } - - /*Run master collector with @force_run_all*/ - progress?.Report(new InstallationProgress - { - Message = "Executing master collector...", - Status = "Info" - }); - - using (var command = new SqlCommand( - "EXECUTE PerformanceMonitor.collect.scheduled_master_collector @force_run_all = 1, @debug = 0;", - connection)) - { - command.CommandTimeout = 300; - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - - progress?.Report(new InstallationProgress - { - Message = "Master collector completed", - Status = "Success" - }); - - /*Check results — only from this validation run, not historical errors*/ - int successCount = 0; - int errorCount = 0; - - using (var command = new SqlCommand(@" - SELECT - success_count = COUNT_BIG(DISTINCT CASE WHEN collection_status = 'SUCCESS' THEN collector_name END), - error_count = SUM(CASE WHEN collection_status = 'ERROR' THEN 1 ELSE 0 END) - FROM PerformanceMonitor.config.collection_log - WHERE collection_time >= @validation_start;", connection)) - { - command.Parameters.AddWithValue("@validation_start", validationStart); - using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - successCount = reader.IsDBNull(0) ? 0 : (int)reader.GetInt64(0); - errorCount = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); - } - } - - progress?.Report(new InstallationProgress - { - Message = $"Validation complete: {successCount} collectors succeeded, {errorCount} failed", - Status = errorCount == 0 ? "Success" : "Warning" - }); - - /*Show failed collectors if any*/ - if (errorCount > 0) - { - using var command = new SqlCommand(@" - SELECT collector_name, error_message - FROM PerformanceMonitor.config.collection_log - WHERE collection_status = 'ERROR' - AND collection_time >= @validation_start - ORDER BY collection_time DESC;", connection); - command.Parameters.AddWithValue("@validation_start", validationStart); - - using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); - while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) - { - string name = reader["collector_name"]?.ToString() ?? ""; - string error = reader["error_message"] == DBNull.Value - ? "(no error message)" - : reader["error_message"]?.ToString() ?? ""; - - progress?.Report(new InstallationProgress - { - Message = $" {name}: {error}", - Status = "Error" - }); - } - } - - return (successCount, errorCount); - } - - /// - /// Run installation verification diagnostics using 99_installer_troubleshooting.sql - /// - public static async Task RunTroubleshootingAsync( - string connectionString, - string sqlDirectory, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - bool hasErrors = false; - - try - { - /*Find the troubleshooting script*/ - string scriptPath = Path.Combine(sqlDirectory, "99_installer_troubleshooting.sql"); - if (!File.Exists(scriptPath)) - { - /*Try parent directory (install folder might be one level up)*/ - string? parentDir = Directory.GetParent(sqlDirectory)?.FullName; - if (parentDir != null) - { - string altPath = Path.Combine(parentDir, "install", "99_installer_troubleshooting.sql"); - if (File.Exists(altPath)) - scriptPath = altPath; - } - } - - if (!File.Exists(scriptPath)) - { - progress?.Report(new InstallationProgress - { - Message = $"Troubleshooting script not found: 99_installer_troubleshooting.sql", - Status = "Error" - }); - return false; - } - - progress?.Report(new InstallationProgress - { - Message = "Running installation diagnostics...", - Status = "Info" - }); - - /*Read and prepare the script*/ - string scriptContent = await File.ReadAllTextAsync(scriptPath, cancellationToken).ConfigureAwait(false); - - /*Remove SQLCMD directives*/ - scriptContent = SqlCmdDirectivePattern.Replace(scriptContent, string.Empty); - - /*Split into batches*/ - var batches = GoBatchSplitter.Split(scriptContent) - .Where(b => !string.IsNullOrWhiteSpace(b)) - .ToList(); - - /*Connect to master first (script will USE PerformanceMonitor)*/ - using var connection = new SqlConnection(connectionString); - - /*Capture PRINT messages and determine status*/ - connection.InfoMessage += (sender, e) => - { - string message = e.Message; - - /*Determine status based on message content*/ - string status = "Info"; - if (message.Contains("[OK]", StringComparison.OrdinalIgnoreCase)) - status = "Success"; - else if (message.Contains("[WARN]", StringComparison.OrdinalIgnoreCase)) - { - status = "Warning"; - } - else if (message.Contains("[ERROR]", StringComparison.OrdinalIgnoreCase)) - { - status = "Error"; - hasErrors = true; - } - - progress?.Report(new InstallationProgress - { - Message = message, - Status = status - }); - }; - - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - /*Execute each batch*/ - foreach (var batch in batches) - { - if (string.IsNullOrWhiteSpace(batch)) - continue; - - cancellationToken.ThrowIfCancellationRequested(); - - using var cmd = new SqlCommand(batch, connection) - { - CommandTimeout = 120 - }; - - try - { - await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - catch (SqlException ex) - { - /*Report SQL errors but continue with remaining batches*/ - progress?.Report(new InstallationProgress - { - Message = $"SQL Error: {ex.Message}", - Status = "Error" - }); - hasErrors = true; - } - - /*Small delay to allow UI to process messages*/ - await Task.Delay(25, cancellationToken).ConfigureAwait(false); - } - - return !hasErrors; - } - catch (Exception ex) - { - progress?.Report(new InstallationProgress - { - Message = $"Diagnostics failed: {ex.Message}", - Status = "Error" - }); - return false; - } - } - - /// - /// Generate installation summary report file - /// - public static string GenerateSummaryReport( - string serverName, - string sqlServerVersion, - string sqlServerEdition, - string installerVersion, - InstallationResult result) - { - ArgumentNullException.ThrowIfNull(serverName); - ArgumentNullException.ThrowIfNull(result); - - var duration = result.EndTime - result.StartTime; - - string timestamp = result.StartTime.ToString("yyyyMMdd_HHmmss"); - string fileName = $"PerformanceMonitor_Install_{serverName.Replace("\\", "_", StringComparison.Ordinal)}_{timestamp}.txt"; - string reportPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), fileName); - - var sb = new StringBuilder(); - - sb.AppendLine("================================================================================"); - sb.AppendLine("Performance Monitor Installation Report"); - sb.AppendLine("================================================================================"); - sb.AppendLine(); - - sb.AppendLine("INSTALLATION SUMMARY"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Status: {(result.Success ? "SUCCESS" : "FAILED")}"); - sb.AppendLine($"Start Time: {result.StartTime:yyyy-MM-dd HH:mm:ss}"); - sb.AppendLine($"End Time: {result.EndTime:yyyy-MM-dd HH:mm:ss}"); - sb.AppendLine($"Duration: {duration.TotalSeconds:F1} seconds"); - sb.AppendLine($"Files Executed: {result.FilesSucceeded}"); - sb.AppendLine($"Files Failed: {result.FilesFailed}"); - sb.AppendLine(); - - sb.AppendLine("SERVER INFORMATION"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Server Name: {serverName}"); - sb.AppendLine($"SQL Server Edition: {sqlServerEdition}"); - sb.AppendLine(); - - if (!string.IsNullOrEmpty(sqlServerVersion)) - { - string[] versionLines = sqlServerVersion.Split(NewLineChars, StringSplitOptions.RemoveEmptyEntries); - if (versionLines.Length > 0) - { - sb.AppendLine($"SQL Server Version:"); - foreach (var line in versionLines) - { - sb.AppendLine($" {line.Trim()}"); - } - } - } - sb.AppendLine(); - - sb.AppendLine("INSTALLER INFORMATION"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Installer Version: {installerVersion}"); - sb.AppendLine($"Working Directory: {Directory.GetCurrentDirectory()}"); - sb.AppendLine($"Machine Name: {Environment.MachineName}"); - sb.AppendLine($"User Name: {Environment.UserName}"); - sb.AppendLine(); - - if (result.Errors.Count > 0) - { - sb.AppendLine("ERRORS"); - sb.AppendLine("--------------------------------------------------------------------------------"); - foreach (var (file, error) in result.Errors) - { - sb.AppendLine($"File: {file}"); - string errorMsg = error.Length > 500 ? error.Substring(0, 500) + "..." : error; - sb.AppendLine($"Error: {errorMsg}"); - sb.AppendLine(); - } - } - - if (result.LogMessages.Count > 0) - { - sb.AppendLine("DETAILED INSTALLATION LOG"); - sb.AppendLine("--------------------------------------------------------------------------------"); - foreach (var (message, status) in result.LogMessages) - { - string prefix = status switch - { - "Success" => "[OK] ", - "Error" => "[ERROR] ", - "Warning" => "[WARN] ", - _ => "" - }; - sb.AppendLine($"{prefix}{message}"); - } - sb.AppendLine(); - } - - sb.AppendLine("================================================================================"); - sb.AppendLine("Generated by Performance Monitor Installer GUI"); - sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC"); - sb.AppendLine("================================================================================"); - - File.WriteAllText(reportPath, sb.ToString()); - - return reportPath; - } - - /// - /// Information about an applicable upgrade - /// - public class UpgradeInfo - { - public string Path { get; set; } = string.Empty; - public string FolderName { get; set; } = string.Empty; - public Version? FromVersion { get; set; } - public Version? ToVersion { get; set; } - } - - /// - /// Get the currently installed version from the database - /// Returns null if database doesn't exist or no successful installation found - /// - public static async Task GetInstalledVersionAsync( - string connectionString, - CancellationToken cancellationToken = default) - { - try - { - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - /*Check if PerformanceMonitor database exists*/ - using var dbCheckCmd = new SqlCommand(@" - SELECT database_id - FROM sys.databases - WHERE name = N'PerformanceMonitor';", connection); - - var dbExists = await dbCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (dbExists == null || dbExists == DBNull.Value) - { - return null; /*Database doesn't exist - clean install needed*/ - } - - /*Check if installation_history table exists*/ - using var tableCheckCmd = new SqlCommand(@" - USE PerformanceMonitor; - SELECT OBJECT_ID(N'config.installation_history', N'U');", connection); - - var tableExists = await tableCheckCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (tableExists == null || tableExists == DBNull.Value) - { - return null; /*Table doesn't exist - old version or corrupted install*/ - } - - /*Get most recent successful installation version*/ - using var versionCmd = new SqlCommand(@" - SELECT TOP 1 installer_version - FROM PerformanceMonitor.config.installation_history - WHERE installation_status = 'SUCCESS' - ORDER BY installation_date DESC;", connection); - - var version = await versionCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); - if (version != null && version != DBNull.Value) - { - return version.ToString(); - } - - /* - Fallback: database and history table exist but no SUCCESS rows. - This can happen if a prior GUI install didn't write history (#538/#539). - Return "1.0.0" so all idempotent upgrade scripts are attempted - rather than treating this as a fresh install (which would drop the database). - */ - return "1.0.0"; - } - catch (SqlException) - { - /*Connection or query failed - treat as no version installed*/ - return null; - } - catch (Exception) - { - /*Any other error - treat as no version installed*/ - return null; - } - } - - /// - /// Find upgrade folders that need to be applied - /// Returns list of upgrade info in order of application - /// Filters by version: only applies upgrades where FromVersion >= currentVersion and ToVersion <= targetVersion - /// - public static List GetApplicableUpgrades( - string monitorRootDirectory, - string? currentVersion, - string targetVersion) - { - var upgrades = new List(); - string upgradesDirectory = Path.Combine(monitorRootDirectory, "upgrades"); - - if (!Directory.Exists(upgradesDirectory)) - { - return upgrades; /*No upgrades folder - return empty list*/ - } - - /*If there's no current version, it's a clean install - no upgrades needed*/ - if (currentVersion == null) - { - return upgrades; - } - - /*Parse current version - if invalid, skip upgrades - Normalize to 3-part (Major.Minor.Build) to avoid Revision mismatch: - folder names use 3-part "1.3.0" but DB stores 4-part "1.3.0.0" - Version(1,3,0).Revision=-1 which breaks >= comparison with Version(1,3,0,0)*/ - if (!Version.TryParse(currentVersion, out var currentRaw)) - { - return upgrades; - } - var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build); - - /*Parse target version - if invalid, skip upgrades*/ - if (!Version.TryParse(targetVersion, out var targetRaw)) - { - return upgrades; - } - var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build); - - /* - Find all upgrade folders matching pattern: {from}-to-{to} - Parse versions and filter to only applicable upgrades - */ - var applicableUpgrades = Directory.GetDirectories(upgradesDirectory) - .Select(d => new UpgradeInfo - { - Path = d, - FolderName = Path.GetFileName(d) - }) - .Where(x => x.FolderName.Contains("-to-", StringComparison.Ordinal)) - .Select(x => - { - var parts = x.FolderName.Split("-to-"); - x.FromVersion = Version.TryParse(parts[0], out var from) ? from : null; - x.ToVersion = parts.Length > 1 && Version.TryParse(parts[1], out var to) ? to : null; - return x; - }) - .Where(x => x.FromVersion != null && x.ToVersion != null) - .Where(x => x.FromVersion >= current) /*Don't re-apply old upgrades*/ - .Where(x => x.ToVersion <= target) /*Don't apply future upgrades*/ - .OrderBy(x => x.FromVersion) - .ToList(); - - foreach (var upgrade in applicableUpgrades) - { - string upgradeFile = Path.Combine(upgrade.Path, "upgrade.txt"); - if (File.Exists(upgradeFile)) - { - upgrades.Add(upgrade); - } - } - - return upgrades; - } - - /// - /// Execute an upgrade folder's SQL scripts - /// Returns (successCount, failureCount) - /// - public static async Task<(int successCount, int failureCount)> ExecuteUpgradeAsync( - string upgradeFolder, - string connectionString, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - int successCount = 0; - int failureCount = 0; - - string upgradeName = Path.GetFileName(upgradeFolder); - string upgradeFile = Path.Combine(upgradeFolder, "upgrade.txt"); - - progress?.Report(new InstallationProgress - { - Message = $"Applying upgrade: {upgradeName}", - Status = "Info" - }); - - /*Read the upgrade.txt file to get ordered list of SQL files*/ - var sqlFileNames = (await File.ReadAllLinesAsync(upgradeFile, cancellationToken).ConfigureAwait(false)) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#')) - .Select(line => line.Trim()) - .ToList(); - - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - - foreach (var fileName in sqlFileNames) - { - cancellationToken.ThrowIfCancellationRequested(); - - string filePath = Path.Combine(upgradeFolder, fileName); - - if (!File.Exists(filePath)) - { - progress?.Report(new InstallationProgress - { - Message = $" {fileName} - WARNING: File not found", - Status = "Warning" - }); - failureCount++; - continue; - } - - try - { - string sql = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false); - - /*Remove SQLCMD directives*/ - sql = SqlCmdDirectivePattern.Replace(sql, ""); - - /*Split by GO statements*/ - string[] batches = GoBatchSplitter.Split(sql); - - int batchNumber = 0; - foreach (var batch in batches) - { - batchNumber++; - string trimmedBatch = batch.Trim(); - - if (string.IsNullOrWhiteSpace(trimmedBatch)) - continue; - - using var cmd = new SqlCommand(trimmedBatch, connection); - cmd.CommandTimeout = 3600; /*1 hour — upgrade migrations on large tables need extended time*/ - - try - { - await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } - catch (SqlException ex) - { - /*Add batch info to error message*/ - string batchPreview = trimmedBatch.Length > 500 - ? trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]" - : trimmedBatch; - throw new InvalidOperationException( - $"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex); - } - } - - progress?.Report(new InstallationProgress - { - Message = $" {fileName} - Success", - Status = "Success" - }); - successCount++; - } - catch (Exception ex) - { - progress?.Report(new InstallationProgress - { - Message = $" {fileName} - FAILED: {ex.Message}", - Status = "Error" - }); - failureCount++; - } - } - - progress?.Report(new InstallationProgress - { - Message = $"Upgrade {upgradeName}: {successCount} succeeded, {failureCount} failed", - Status = failureCount == 0 ? "Success" : "Warning" - }); - - return (successCount, failureCount); - } - - /// - /// Execute all applicable upgrades in order - /// - public static async Task<(int totalSuccessCount, int totalFailureCount, int upgradeCount)> ExecuteAllUpgradesAsync( - string monitorRootDirectory, - string connectionString, - string? currentVersion, - string targetVersion, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - int totalSuccessCount = 0; - int totalFailureCount = 0; - - var upgrades = GetApplicableUpgrades(monitorRootDirectory, currentVersion, targetVersion); - - if (upgrades.Count == 0) - { - return (0, 0, 0); - } - - progress?.Report(new InstallationProgress - { - Message = $"Found {upgrades.Count} upgrade(s) to apply", - Status = "Info" - }); - - foreach (var upgrade in upgrades) - { - cancellationToken.ThrowIfCancellationRequested(); - - var (success, failure) = await ExecuteUpgradeAsync( - upgrade.Path, - connectionString, - progress, - cancellationToken).ConfigureAwait(false); - - totalSuccessCount += success; - totalFailureCount += failure; - } - - return (totalSuccessCount, totalFailureCount, upgrades.Count); - } - - /* - Download content from URL with retry logic for transient failures - Uses exponential backoff: 2s, 4s, 8s between retries - */ - private static async Task DownloadWithRetryAsync( - HttpClient client, - string url, - IProgress? progress = null, - int maxRetries = 3, - CancellationToken cancellationToken = default) - { - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - return await client.GetStringAsync(url, cancellationToken).ConfigureAwait(false); - } - catch (HttpRequestException) when (attempt < maxRetries) - { - int delaySeconds = (int)Math.Pow(2, attempt); /*2s, 4s, 8s*/ - progress?.Report(new InstallationProgress - { - Message = $"Network error, retrying in {delaySeconds}s ({attempt}/{maxRetries})...", - Status = "Warning" - }); - await Task.Delay(delaySeconds * 1000, cancellationToken).ConfigureAwait(false); - } - } - /*Final attempt - let exception propagate if it fails*/ - return await client.GetStringAsync(url, cancellationToken).ConfigureAwait(false); - } - - /// - /// Dispose of managed resources - /// - public void Dispose() - { - if (!_disposed) - { - _httpClient?.Dispose(); - _disposed = true; - } - GC.SuppressFinalize(this); - } - - /// - /// Log installation history to config.installation_history - /// Mirrors CLI installer's LogInstallationHistory method - /// - public static async Task LogInstallationHistoryAsync( - string connectionString, - string assemblyVersion, - string infoVersion, - DateTime startTime, - int filesExecuted, - int filesFailed, - bool isSuccess) - { - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync().ConfigureAwait(false); - - /*Check if this is an upgrade by checking for existing installation*/ - string? previousVersion = null; - string installationType = "INSTALL"; - - try - { - using var checkCmd = new SqlCommand(@" - SELECT TOP 1 installer_version - FROM PerformanceMonitor.config.installation_history - WHERE installation_status = 'SUCCESS' - ORDER BY installation_date DESC;", connection); - - var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false); - if (result != null && result != DBNull.Value) - { - previousVersion = result.ToString(); - bool isSameVersion = Version.TryParse(previousVersion, out var prevVer) - && Version.TryParse(assemblyVersion, out var currVer) - && prevVer == currVer; - installationType = isSameVersion ? "REINSTALL" : "UPGRADE"; - } - } - catch (SqlException) - { - /*Table might not exist yet on first install*/ - } - - /*Get SQL Server version info*/ - string sqlVersion = ""; - string sqlEdition = ""; - - using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection)) - using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false)) - { - if (await reader.ReadAsync().ConfigureAwait(false)) - { - sqlVersion = reader.GetString(0); - sqlEdition = reader.GetString(1); - } - } - - long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds; - string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED"); - - var insertSql = @" - INSERT INTO PerformanceMonitor.config.installation_history - ( - installer_version, - installer_info_version, - sql_server_version, - sql_server_edition, - installation_type, - previous_version, - installation_status, - files_executed, - files_failed, - installation_duration_ms - ) - VALUES - ( - @installer_version, - @installer_info_version, - @sql_server_version, - @sql_server_edition, - @installation_type, - @previous_version, - @installation_status, - @files_executed, - @files_failed, - @installation_duration_ms - );"; - - using var insertCmd = new SqlCommand(insertSql, connection); - insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion }); - insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value }); - insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion }); - insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition }); - insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType }); - insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value }); - insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status }); - insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted }); - insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed }); - insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs }); - - await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$")] - private static partial Regex SqlFileRegExp(); - } -} diff --git a/PerformanceMonitor.sln b/PerformanceMonitor.sln index 3128202..f253ff9 100644 --- a/PerformanceMonitor.sln +++ b/PerformanceMonitor.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitorInstaller EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Tests", "Installer.Tests\Installer.Tests.csproj", "{9B2800D2-8F32-450E-A169-86B381EA5560}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Core", "Installer.Core\Installer.Core.csproj", "{AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +47,10 @@ Global {9B2800D2-8F32-450E-A169-86B381EA5560}.Debug|Any CPU.Build.0 = Debug|Any CPU {9B2800D2-8F32-450E-A169-86B381EA5560}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B2800D2-8F32-450E-A169-86B381EA5560}.Release|Any CPU.Build.0 = Release|Any CPU + {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 272e3753460090e83c358226647f40340274c7cc Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 29 Mar 2026 11:27:45 -0400 Subject: [PATCH 2/4] Refactor CLI Installer to thin wrapper over Installer.Core (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Program.cs rewritten from 2,122 lines to 1,172 lines (45% reduction). All duplicated logic removed — CLI now delegates to Installer.Core for: - Connection string building (InstallationService.BuildConnectionString) - Connection testing (InstallationService.TestConnectionAsync) - Script discovery (ScriptProvider.FromDirectory) - SQL file execution (InstallationService.ExecuteInstallationAsync) - Upgrade detection and execution (ExecuteAllUpgradesAsync) - Uninstall (InstallationService.ExecuteUninstallAsync) - Version detection (InstallationService.GetInstalledVersionAsync) - Community dependencies (DependencyInstaller) - Validation (InstallationService.RunValidationAsync) - Installation history (InstallationService.LogInstallationHistoryAsync) - Summary reports (InstallationService.GenerateSummaryReport) What stays in Program.cs (console-specific): - Argument parsing, interactive prompts, retry loop - Console output helpers (WriteSuccess/WriteError/WriteWarning) - ReadPassword, WaitForExit, CheckForInstallerUpdateAsync - Error log generation (WriteErrorLog) Fixes during review: - Added missing exit code 8 (UpgradesFailed) to --help text - Fixed encryption default label in error message (was "optional", is "mandatory") Co-Authored-By: Claude Opus 4.6 (1M context) --- Installer/PerformanceMonitorInstaller.csproj | 4 + Installer/Program.cs | 3294 +++++++----------- 2 files changed, 1176 insertions(+), 2122 deletions(-) diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj index 2624bb4..2986f97 100644 --- a/Installer/PerformanceMonitorInstaller.csproj +++ b/Installer/PerformanceMonitorInstaller.csproj @@ -34,6 +34,10 @@ + + + + PreserveNewest diff --git a/Installer/Program.cs b/Installer/Program.cs index 4307c1e..daa4cb9 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -1,2122 +1,1172 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Collections.Generic; -using System.Data; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System.Reflection; -using Microsoft.Data.SqlClient; - -namespace PerformanceMonitorInstaller -{ - partial class Program - { - /// - /// Complete uninstall SQL: stops traces, deletes all 3 Agent jobs, - /// drops both XE sessions, and drops the database. - /// - private const string UninstallSql = @" -/* -Remove SQL Agent jobs -*/ -USE msdb; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1; - PRINT 'Deleted job: PerformanceMonitor - Collection'; -END; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1; - PRINT 'Deleted job: PerformanceMonitor - Data Retention'; -END; - -IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor') -BEGIN - EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1; - PRINT 'Deleted job: PerformanceMonitor - Hung Job Monitor'; -END; - -/* -Drop Extended Events sessions -*/ -USE master; - -IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') -BEGIN - IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess') - ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP; - DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER; - PRINT 'Dropped XE session: PerformanceMonitor_BlockedProcess'; -END; - -IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock') -BEGIN - IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock') - ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP; - DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER; - PRINT 'Dropped XE session: PerformanceMonitor_Deadlock'; -END; - -/* -Drop the database -*/ -IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor') -BEGIN - ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - DROP DATABASE PerformanceMonitor; - PRINT 'PerformanceMonitor database dropped'; -END -ELSE -BEGIN - PRINT 'PerformanceMonitor database does not exist'; -END;"; - - /* - Pre-compiled regex patterns for performance - */ - private static readonly Regex GoBatchPattern = GoBatchRegExp(); - - private static readonly Regex SqlFileNamePattern = new Regex( - @"^\d{2}[a-z]?_.*\.sql$", - RegexOptions.Compiled); - - private static readonly Regex SqlCmdDirectivePattern = new Regex( - @"^:r\s+.*$", - RegexOptions.Compiled | RegexOptions.Multiline); - - /* - SQL command timeout constants (in seconds) - */ - private const int ShortTimeoutSeconds = 60; // Quick operations (cleanup, queries) - private const int MediumTimeoutSeconds = 120; // Dependency installation - private const int LongTimeoutSeconds = 300; // SQL file execution (5 minutes) - private const int UpgradeTimeoutSeconds = 3600; // Upgrade data migrations (1 hour, large tables) - - /* - Exit codes for granular error reporting - */ - private static class ExitCodes - { - public const int Success = 0; - public const int InvalidArguments = 1; - public const int ConnectionFailed = 2; - public const int CriticalFileFailed = 3; - public const int PartialInstallation = 4; - public const int VersionCheckFailed = 5; - public const int SqlFilesNotFound = 6; - public const int UninstallFailed = 7; - public const int UpgradesFailed = 8; - } - - static async Task Main(string[] args) - { - var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"; - var infoVersion = Assembly.GetExecutingAssembly() - .GetCustomAttribute()?.InformationalVersion ?? version; - - Console.WriteLine("================================================================================"); - Console.WriteLine($"Performance Monitor Installation Utility v{infoVersion}"); - Console.WriteLine("Copyright © 2026 Darling Data, LLC"); - Console.WriteLine("Licensed under the MIT License"); - Console.WriteLine("https://github.com/erikdarlingdata/PerformanceMonitor"); - Console.WriteLine("================================================================================"); - - await CheckForInstallerUpdateAsync(version); - - - /* - Determine if running in automated mode (command-line arguments provided) - Usage: PerformanceMonitorInstaller.exe [server] [username] [password] [options] - If server is provided alone, uses Windows Authentication - If server, username, and password are provided, uses SQL Authentication - - Options: - --reinstall Drop existing database and perform clean install - --encrypt=X Connection encryption: mandatory (default), optional, strict - --trust-cert Trust server certificate without validation (default: require valid cert) - */ - if (args.Any(a => a.Equals("--help", StringComparison.OrdinalIgnoreCase) - || a.Equals("-h", StringComparison.OrdinalIgnoreCase))) - { - Console.WriteLine("Usage:"); - Console.WriteLine(" PerformanceMonitorInstaller.exe Interactive mode"); - Console.WriteLine(" PerformanceMonitorInstaller.exe [options] Windows Auth"); - Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth"); - Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth (password via env var)"); - Console.WriteLine(" PerformanceMonitorInstaller.exe --entra Entra ID (MFA)"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" -h, --help Show this help message"); - Console.WriteLine(" --reinstall Drop existing database and perform clean install"); - Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions"); - Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); - Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); - Console.WriteLine(" --trust-cert Trust server certificate without validation"); - Console.WriteLine(" --entra Use Microsoft Entra ID interactive authentication (MFA)"); - Console.WriteLine(); - Console.WriteLine("Environment Variables:"); - Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)"); - Console.WriteLine(); - Console.WriteLine("Exit Codes:"); - Console.WriteLine(" 0 Success"); - Console.WriteLine(" 1 Invalid arguments"); - Console.WriteLine(" 2 Connection failed"); - Console.WriteLine(" 3 Critical file failed"); - Console.WriteLine(" 4 Partial installation (non-critical failures)"); - Console.WriteLine(" 5 Version check failed"); - Console.WriteLine(" 6 SQL files not found"); - Console.WriteLine(" 7 Uninstall failed"); - return 0; - } - - bool automatedMode = args.Length > 0; - bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)); - bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)); - bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)); - bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)); - bool entraMode = args.Any(a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase)); - - /*Parse --entra email (the argument following --entra)*/ - string? entraEmail = null; - if (entraMode) - { - int entraIndex = Array.FindIndex(args, a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase)); - if (entraIndex >= 0 && entraIndex + 1 < args.Length && !args[entraIndex + 1].StartsWith("--", StringComparison.Ordinal)) - { - entraEmail = args[entraIndex + 1]; - } - } - - /*Parse encryption option (default: Mandatory)*/ - var encryptArg = args.FirstOrDefault(a => a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase)); - SqlConnectionEncryptOption encryptOption = SqlConnectionEncryptOption.Mandatory; - if (encryptArg != null) - { - string encryptValue = encryptArg.Substring("--encrypt=".Length).ToLowerInvariant(); - encryptOption = encryptValue switch - { - "optional" => SqlConnectionEncryptOption.Optional, - "strict" => SqlConnectionEncryptOption.Strict, - _ => SqlConnectionEncryptOption.Mandatory - }; - } - - /*Filter out option flags to get positional arguments*/ - /*Filter out option flags and --entra to get positional arguments*/ - var filteredArgsList = args - .Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)) - .Where(a => !a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)) - .Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)) - .Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)) - .Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase)) - .Where(a => !a.Equals("--entra", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - /*Remove the entra email from positional args if present*/ - if (entraEmail != null) - { - filteredArgsList.Remove(entraEmail); - } - - var filteredArgs = filteredArgsList.ToArray(); - string? serverName; - string? username = null; - string? password = null; - bool useWindowsAuth; - bool useEntraAuth = false; - - if (automatedMode) - { - /* - Automated mode with command-line arguments - */ - serverName = filteredArgs.Length > 0 ? filteredArgs[0] : null; - - if (entraMode) - { - /*Microsoft Entra ID interactive authentication*/ - useWindowsAuth = false; - useEntraAuth = true; - username = entraEmail; - - if (string.IsNullOrWhiteSpace(username)) - { - Console.WriteLine("Error: Email address is required for Entra ID authentication."); - Console.WriteLine("Usage: PerformanceMonitorInstaller.exe --entra "); - return ExitCodes.InvalidArguments; - } - - Console.WriteLine($"Server: {serverName}"); - Console.WriteLine($"Authentication: Microsoft Entra ID ({username})"); - Console.WriteLine("A browser window will open for interactive authentication..."); - } - else if (filteredArgs.Length >= 2) - { - /*SQL Authentication - password from env var or command-line*/ - useWindowsAuth = false; - username = filteredArgs[1]; - - string? envPassword = Environment.GetEnvironmentVariable("PM_SQL_PASSWORD"); - if (filteredArgs.Length >= 3) - { - password = filteredArgs[2]; - if (envPassword == null) - { - Console.WriteLine("Note: Password provided via command-line is visible in process listings."); - Console.WriteLine(" Consider using PM_SQL_PASSWORD environment variable instead."); - Console.WriteLine(); - } - } - else if (envPassword != null) - { - password = envPassword; - } - else - { - Console.WriteLine("Error: Password is required for SQL Server Authentication."); - Console.WriteLine("Provide password as third argument or set PM_SQL_PASSWORD environment variable."); - return ExitCodes.InvalidArguments; - } - - Console.WriteLine($"Server: {serverName}"); - Console.WriteLine($"Authentication: SQL Server ({username})"); - } - else if (filteredArgs.Length == 1) - { - /*Windows Authentication*/ - useWindowsAuth = true; - Console.WriteLine($"Server: {serverName}"); - Console.WriteLine($"Authentication: Windows"); - } - else - { - Console.WriteLine("Error: Invalid arguments."); - Console.WriteLine("Usage:"); - Console.WriteLine(" Windows Auth: PerformanceMonitorInstaller.exe [options]"); - Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]"); - Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]"); - Console.WriteLine(" (with PM_SQL_PASSWORD environment variable set)"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --reinstall Drop existing database and perform clean install"); - Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); - Console.WriteLine(" --encrypt= Connection encryption: optional (default), mandatory, strict"); - Console.WriteLine(" --trust-cert Trust server certificate without validation (default: require valid cert)"); - return ExitCodes.InvalidArguments; - } - } - else - { - /* - Interactive mode - prompt for connection information - */ - Console.Write("SQL Server instance (e.g., localhost, SQL2022): "); - serverName = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(serverName)) - { - Console.WriteLine("Error: Server name is required."); - WaitForExit(); - return ExitCodes.InvalidArguments; - } - - Console.WriteLine("Authentication type:"); - Console.WriteLine(" [W] Windows Authentication (default)"); - Console.WriteLine(" [S] SQL Server Authentication"); - Console.WriteLine(" [E] Microsoft Entra ID (interactive MFA)"); - Console.Write("Choice (W/S/E, default W): "); - string? authResponse = Console.ReadLine()?.Trim(); - - if (string.IsNullOrWhiteSpace(authResponse) || authResponse.Equals("W", StringComparison.OrdinalIgnoreCase)) - { - useWindowsAuth = true; - } - else if (authResponse.Equals("E", StringComparison.OrdinalIgnoreCase)) - { - useWindowsAuth = false; - useEntraAuth = true; - - Console.Write("Email address (UPN): "); - username = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(username)) - { - Console.WriteLine("Error: Email address is required for Entra ID authentication."); - WaitForExit(); - return ExitCodes.InvalidArguments; - } - - Console.WriteLine("A browser window will open for interactive authentication..."); - } - else - { - useWindowsAuth = false; - - Console.Write("SQL Server login: "); - username = Console.ReadLine(); - if (string.IsNullOrWhiteSpace(username)) - { - Console.WriteLine("Error: Login is required for SQL Server Authentication."); - WaitForExit(); - return ExitCodes.InvalidArguments; - } - - Console.Write("Password: "); - password = ReadPassword(); - Console.WriteLine(); - - if (string.IsNullOrWhiteSpace(password)) - { - Console.WriteLine("Error: Password is required for SQL Server Authentication."); - WaitForExit(); - return ExitCodes.InvalidArguments; - } - } - } - - /* - Build connection string - */ - var builder = new SqlConnectionStringBuilder - { - DataSource = serverName, - InitialCatalog = "master", - Encrypt = encryptOption, - TrustServerCertificate = trustCert - }; - - if (useEntraAuth) - { - builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive; - builder.UserID = username; - } - else if (useWindowsAuth) - { - builder.IntegratedSecurity = true; - } - else - { - builder.UserID = username; - builder.Password = password; - } - - /* - Test connection and get SQL Server version - */ - string sqlServerVersion = ""; - string sqlServerEdition = ""; - - Console.WriteLine(); - Console.WriteLine("Testing connection..."); - try - { - using (var connection = new SqlConnection(builder.ConnectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - WriteSuccess("Connection successful!"); - - /*Capture SQL Server version for summary report*/ - using (var versionCmd = new SqlCommand(@" - SELECT - @@VERSION, - SERVERPROPERTY('Edition'), - CONVERT(int, SERVERPROPERTY('EngineEdition')), - SERVERPROPERTY('ProductMajorVersion');", connection)) - { - using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false)) - { - if (await reader.ReadAsync().ConfigureAwait(false)) - { - sqlServerVersion = reader.GetString(0); - sqlServerEdition = reader.GetString(1); - - var engineEdition = reader.IsDBNull(2) ? 0 : reader.GetInt32(2); - var majorVersion = reader.IsDBNull(3) ? 0 : int.TryParse(reader.GetValue(3).ToString(), out var v) ? v : 0; - - /*Check minimum SQL Server version — 2016+ required for on-prem (Standard/Enterprise). - Azure MI (EngineEdition 8) is always current, skip the check.*/ - if (engineEdition is not 8 && majorVersion > 0 && majorVersion < 13) - { - string versionName = majorVersion switch - { - 11 => "SQL Server 2012", - 12 => "SQL Server 2014", - _ => $"SQL Server (version {majorVersion})" - }; - Console.WriteLine(); - Console.WriteLine($"ERROR: {versionName} is not supported."); - Console.WriteLine("Performance Monitor requires SQL Server 2016 (13.x) or later."); - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.VersionCheckFailed; - } - } - } - } - } - } - catch (Exception ex) - { - WriteError($"Connection failed: {ex.Message}"); - Console.WriteLine($"Exception type: {ex.GetType().Name}"); - if (ex.InnerException != null) - { - Console.WriteLine($"Inner exception: {ex.InnerException.Message}"); - } - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.ConnectionFailed; - } - - /* - Handle --uninstall mode (no SQL files needed) - */ - if (uninstallMode) - { - return await PerformUninstallAsync(builder.ConnectionString, automatedMode); - } - - /* - Find SQL files to execute (do this once before the installation loop) - Search current directory and up to 5 parent directories - Prefer install/ subfolder if it exists (new structure) - */ - string? sqlDirectory = null; - string? monitorRootDirectory = null; - string currentDirectory = Directory.GetCurrentDirectory(); - DirectoryInfo? searchDir = new DirectoryInfo(currentDirectory); - - for (int i = 0; i < 6 && searchDir != null; i++) - { - /*Check for install/ subfolder first (new structure)*/ - string installFolder = Path.Combine(searchDir.FullName, "install"); - if (Directory.Exists(installFolder)) - { - var installFiles = Directory.GetFiles(installFolder, "*.sql") - .Where(f => SqlFileNamePattern.IsMatch(Path.GetFileName(f))) - .ToList(); - - if (installFiles.Count > 0) - { - sqlDirectory = installFolder; - monitorRootDirectory = searchDir.FullName; - break; - } - } - - /*Fall back to old structure (SQL files in root)*/ - var files = Directory.GetFiles(searchDir.FullName, "*.sql") - .Where(f => SqlFileNamePattern.IsMatch(Path.GetFileName(f))) - .ToList(); - - if (files.Count > 0) - { - sqlDirectory = searchDir.FullName; - monitorRootDirectory = searchDir.FullName; - break; - } - - searchDir = searchDir.Parent; - } - - if (sqlDirectory == null) - { - Console.WriteLine($"Error: No SQL installation files found."); - Console.WriteLine($"Searched in: {currentDirectory}"); - Console.WriteLine("Expected files in install/ folder or root directory:"); - Console.WriteLine(" install/01_install_database.sql, install/02_create_tables.sql, etc."); - Console.WriteLine(); - Console.WriteLine("Make sure the installer is in the Monitor directory or a subdirectory."); - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.SqlFilesNotFound; - } - - var sqlFiles = Directory.GetFiles(sqlDirectory, "*.sql") - .Where(f => - { - string fileName = Path.GetFileName(f); - if (!SqlFileNamePattern.IsMatch(fileName)) - return false; - /*Exclude uninstall, test, and troubleshooting scripts from main install*/ - if (fileName.StartsWith("00_", StringComparison.Ordinal) || - fileName.StartsWith("97_", StringComparison.Ordinal) || - fileName.StartsWith("99_", StringComparison.Ordinal)) - return false; - return true; - }) - .OrderBy(f => Path.GetFileName(f)) - .ToList(); - - Console.WriteLine(); - Console.WriteLine($"Found {sqlFiles.Count} SQL files in: {sqlDirectory}"); - if (monitorRootDirectory != sqlDirectory) - { - Console.WriteLine($"Using new folder structure (install/ subfolder)"); - } - - /* - Main installation loop - allows retry on failure - */ - int upgradeSuccessCount = 0; - int upgradeFailureCount = 0; - int installSuccessCount = 0; - int installFailureCount = 0; - int totalSuccessCount = 0; - int totalFailureCount = 0; - var installationErrors = new List<(string FileName, string ErrorMessage)>(); - bool installationSuccessful = false; - bool retry; - DateTime installationStartTime = DateTime.Now; - do - { - retry = false; - upgradeSuccessCount = 0; - upgradeFailureCount = 0; - installSuccessCount = 0; - installFailureCount = 0; - installationErrors.Clear(); - installationSuccessful = false; - installationStartTime = DateTime.Now; - - /* - Ask about clean install (automated mode preserves database unless --reinstall flag is used) - */ - bool dropExisting; - if (automatedMode) - { - dropExisting = reinstallMode; - Console.WriteLine(); - if (reinstallMode) - { - Console.WriteLine("Automated mode: Performing clean reinstall (dropping existing database)..."); - } - else - { - Console.WriteLine("Automated mode: Performing upgrade (preserving existing database)..."); - } - } - else - { - Console.WriteLine(); - Console.Write("Drop existing PerformanceMonitor database if it exists? (Y/N, default N): "); - string? cleanInstall = Console.ReadLine(); - dropExisting = cleanInstall?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; - } - - if (dropExisting) - { - Console.WriteLine(); - Console.WriteLine("Performing clean install..."); - try - { - using (var connection = new SqlConnection(builder.ConnectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - - /* - Stop any existing traces before dropping database - Traces are server-level and persist after database drops - Use existing procedure if database exists - */ - try - { - using (var command = new SqlCommand("EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", connection)) - { - command.CommandTimeout = ShortTimeoutSeconds; - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - Console.WriteLine("✓ Stopped existing traces"); - } - } - catch (SqlException) - { - /*Database or procedure doesn't exist - no traces to clean*/ - } - - using (var command = new SqlCommand(UninstallSql, connection)) - { - command.CommandTimeout = ShortTimeoutSeconds; - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - } - } - - WriteSuccess("Clean install completed (jobs and database removed)"); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Could not complete cleanup: {ex.Message}"); - Console.WriteLine("Continuing with installation..."); - } - } - else - { - /* - Upgrade mode - check for existing installation and apply upgrades - */ - string? currentVersion = null; - try - { - currentVersion = await GetInstalledVersion(builder.ConnectionString); - } - catch (InvalidOperationException ex) - { - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("ERROR: Failed to check for existing installation"); - Console.WriteLine("================================================================================"); - Console.WriteLine(ex.Message); - if (ex.InnerException != null) - { - Console.WriteLine($"Details: {ex.InnerException.Message}"); - } - Console.WriteLine(); - Console.WriteLine("This may indicate a permissions issue or database corruption."); - Console.WriteLine("Please review the error log and report this issue if it persists."); - Console.WriteLine(); - - /*Write error log for bug reporting*/ - string errorLogPath = WriteErrorLog(ex, serverName!, infoVersion); - Console.WriteLine($"Error log written to: {errorLogPath}"); - - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.VersionCheckFailed; - } - - if (currentVersion != null && monitorRootDirectory != null) - { - Console.WriteLine(); - Console.WriteLine($"Existing installation detected: v{currentVersion}"); - Console.WriteLine("Checking for applicable upgrades..."); - - var upgrades = GetApplicableUpgrades(monitorRootDirectory, currentVersion, version); - - if (upgrades.Count > 0) - { - Console.WriteLine($"Found {upgrades.Count} upgrade(s) to apply."); - Console.WriteLine(); - Console.WriteLine("================================================================================" ); - Console.WriteLine("Applying upgrades..."); - Console.WriteLine("================================================================================"); - - using (var connection = new SqlConnection(builder.ConnectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - - foreach (var upgradeFolder in upgrades) - { - var (upgradeSuccess, upgradeFail) = await ExecuteUpgrade(upgradeFolder, connection); - upgradeSuccessCount += upgradeSuccess; - upgradeFailureCount += upgradeFail; - } - } - - Console.WriteLine(); - Console.WriteLine($"Upgrades complete: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed"); - - /*Abort if any upgrade scripts failed — proceeding would reinstall over a partially-upgraded database*/ - if (upgradeFailureCount > 0) - { - Console.WriteLine(); - Console.WriteLine("================================================================================"); - WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed."); - Console.WriteLine("Fix the errors above and re-run the installer."); - Console.WriteLine("================================================================================"); - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.UpgradesFailed; - } - } - else - { - Console.WriteLine("No pending upgrades found."); - } - } - else - { - Console.WriteLine(); - Console.WriteLine("No existing installation detected, proceeding with fresh install..."); - } - } - - /* - Execute SQL files in order - */ - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("Starting installation..."); - Console.WriteLine("================================================================================"); - Console.WriteLine(); - - /* - Open a single connection for all SQL file execution - Connection pooling handles the underlying socket reuse - */ - bool communityDepsInstalled = false; - - using (var connection = new SqlConnection(builder.ConnectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - - foreach (var sqlFile in sqlFiles) - { - string fileName = Path.GetFileName(sqlFile); - - /*Install community dependencies before validation runs - Collectors in 98_validate need sp_WhoIsActive, sp_HealthParser, etc.*/ - if (!communityDepsInstalled && - fileName.StartsWith("98_", StringComparison.Ordinal)) - { - communityDepsInstalled = true; - try - { - await InstallDependenciesAsync(builder.ConnectionString); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}"); - Console.WriteLine("Continuing with installation..."); - } - } - - Console.Write($"Executing {fileName}... "); - - try - { - string sqlContent = await File.ReadAllTextAsync(sqlFile); - - /* - Reset schedule to defaults if requested — truncate before the - INSERT...WHERE NOT EXISTS re-populates with current recommended values - */ - if (resetSchedule && fileName.StartsWith("04_", StringComparison.Ordinal)) - { - sqlContent = "TRUNCATE TABLE [PerformanceMonitor].[config].[collection_schedule];\nGO\n" + sqlContent; - Console.Write("(resetting schedule) "); - } - - /* - Remove SQLCMD directives (:r includes) as we're executing files directly - */ - sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, ""); - - /* - Split by GO statements using pre-compiled regex - Match GO only when it's a whole word on its own line - */ - string[] batches = GoBatchPattern.Split(sqlContent); - - int batchNumber = 0; - foreach (string batch in batches) - { - string trimmedBatch = batch.Trim(); - - /*Skip empty batches*/ - if (string.IsNullOrWhiteSpace(trimmedBatch)) - continue; - - batchNumber++; - - using (var command = new SqlCommand(trimmedBatch, connection)) - { - command.CommandTimeout = LongTimeoutSeconds; - try - { - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - } - catch (SqlException ex) - { - /*Add batch info to error message*/ - string batchPreview = trimmedBatch.Length > 500 ? - trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]" : - trimmedBatch; - throw new InvalidOperationException($"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex); - } - } - } - - WriteSuccess("Success"); - installSuccessCount++; - } - catch (Exception ex) - { - WriteError("FAILED"); - Console.WriteLine($" Error: {ex.Message}"); - installFailureCount++; - installationErrors.Add((fileName, ex.Message)); - - if (fileName.StartsWith("01_", StringComparison.Ordinal) || fileName.StartsWith("02_", StringComparison.Ordinal) || fileName.StartsWith("03_", StringComparison.Ordinal)) - { - Console.WriteLine(); - Console.WriteLine("Critical installation file failed. Aborting installation."); - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.CriticalFileFailed; - } - } - } - } - - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("File Execution Summary"); - Console.WriteLine("================================================================================"); - if (upgradeSuccessCount > 0 || upgradeFailureCount > 0) - { - Console.WriteLine($"Upgrades: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed"); - } - Console.WriteLine($"Installation: {installSuccessCount} succeeded, {installFailureCount} failed"); - Console.WriteLine(); - - /* - Install community dependencies if not already done (no 98_ files in batch) - */ - if (!communityDepsInstalled && installFailureCount <= 1) - { - try - { - await InstallDependenciesAsync(builder.ConnectionString); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}"); - Console.WriteLine("Continuing with validation..."); - } - } - - /* - Run initial collection and retry failed views - This validates the installation and creates dynamically-generated tables - */ - if (installFailureCount <= 1 && automatedMode) /* Allow 1 failure for query_snapshots view */ - { - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("Running initial collection to validate installation..."); - Console.WriteLine("================================================================================"); - Console.WriteLine(); - - try - { - using (var connection = new SqlConnection(builder.ConnectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - - /*Capture timestamp before running so we only check errors from this run. - Use SYSDATETIME() (local) because collection_time is stored in server local time.*/ - DateTime validationStart; - using (var command = new SqlCommand("SELECT SYSDATETIME();", connection)) - { - validationStart = (DateTime)(await command.ExecuteScalarAsync().ConfigureAwait(false))!; - } - - /*Run master collector once with @force_run_all to collect everything immediately*/ - Console.Write("Executing master collector... "); - using (var command = new SqlCommand("EXECUTE PerformanceMonitor.collect.scheduled_master_collector @force_run_all = 1, @debug = 0;", connection)) - { - command.CommandTimeout = LongTimeoutSeconds; - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - } - WriteSuccess("Success"); - - /* - Verify data was collected — only from this validation run, not historical errors - */ - Console.WriteLine(); - Console.Write("Verifying data collection... "); - - /* Check successful collections from this run */ - int collectedCount = 0; - using (var command = new SqlCommand(@" - SELECT - COUNT(DISTINCT collector_name) - FROM PerformanceMonitor.config.collection_log - WHERE collection_status = 'SUCCESS' - AND collection_time >= @validation_start;", connection)) - { - command.Parameters.AddWithValue("@validation_start", validationStart); - collectedCount = (int)(await command.ExecuteScalarAsync().ConfigureAwait(false) ?? 0); - } - - /* Total log entries from this run */ - int totalLogEntries = 0; - using (var command = new SqlCommand(@" - SELECT COUNT(*) - FROM PerformanceMonitor.config.collection_log - WHERE collection_time >= @validation_start;", connection)) - { - command.Parameters.AddWithValue("@validation_start", validationStart); - totalLogEntries = (int)(await command.ExecuteScalarAsync().ConfigureAwait(false) ?? 0); - } - - Console.WriteLine($"✓ {collectedCount} collectors ran successfully (total log entries: {totalLogEntries})"); - - /* Show failed collectors from this run */ - int errorCount = 0; - using (var command = new SqlCommand(@" - SELECT COUNT(*) - FROM PerformanceMonitor.config.collection_log - WHERE collection_status = 'ERROR' - AND collection_time >= @validation_start;", connection)) - { - command.Parameters.AddWithValue("@validation_start", validationStart); - errorCount = (int)(await command.ExecuteScalarAsync().ConfigureAwait(false) ?? 0); - } - - if (errorCount > 0) - { - Console.WriteLine(); - Console.WriteLine($"⚠ {errorCount} collector(s) encountered errors:"); - using (var command = new SqlCommand(@" - SELECT - collector_name, - error_message - FROM PerformanceMonitor.config.collection_log - WHERE collection_status = 'ERROR' - AND collection_time >= @validation_start - ORDER BY collection_time DESC;", connection)) - { - command.Parameters.AddWithValue("@validation_start", validationStart); - using (var reader = await command.ExecuteReaderAsync().ConfigureAwait(false)) - { - while (await reader.ReadAsync().ConfigureAwait(false)) - { - string name = reader["collector_name"]?.ToString() ?? ""; - string error = reader["error_message"] == DBNull.Value ? "(no error message)" : reader["error_message"]?.ToString() ?? ""; - Console.WriteLine($" ✗ {name}"); - Console.WriteLine($" {error}"); - } - } - } - } - - /* Show recent log entries for debugging */ - if (totalLogEntries > 0 && errorCount == 0) - { - Console.WriteLine(); - Console.WriteLine("Sample collection log entries:"); - using (var command = new SqlCommand(@" - SELECT TOP 5 - collector_name, - collection_status, - rows_collected, - error_message - FROM PerformanceMonitor.config.collection_log - WHERE collection_status = 'SUCCESS' - AND collection_time >= @validation_start - ORDER BY collection_time DESC;", connection)) - { - command.Parameters.AddWithValue("@validation_start", validationStart); - using (var reader = await command.ExecuteReaderAsync().ConfigureAwait(false)) - { - while (await reader.ReadAsync().ConfigureAwait(false)) - { - string status = reader["collection_status"]?.ToString() ?? ""; - string name = reader["collector_name"]?.ToString() ?? ""; - int rows = (int)reader["rows_collected"]; - string error = reader["error_message"] == DBNull.Value ? "" : $" - {reader["error_message"]}"; - Console.WriteLine($" {status,10}: {name,-35} ({rows,4} rows){error}"); - } - } - } - } - - /* - Check if sp_WhoIsActive created query_snapshots table - The collector creates daily tables like query_snapshots_20260102 - */ - if (installFailureCount > 0) - { - Console.WriteLine(); - Console.Write("Checking for query_snapshots table... "); - - bool tableExists = false; - using (var command = new SqlCommand(@" - SELECT TOP (1) 1 - FROM sys.tables AS t - WHERE t.name LIKE 'query_snapshots_%' - AND t.schema_id = SCHEMA_ID('collect');", connection)) - { - var result = await command.ExecuteScalarAsync().ConfigureAwait(false); - tableExists = result != null && result != DBNull.Value; - } - - if (tableExists) - { - Console.WriteLine("✓ Found"); - Console.Write("Retrying query plan views... "); - - try - { - string viewFile = Path.Combine(sqlDirectory, "46_create_query_plan_views.sql"); - if (File.Exists(viewFile)) - { - string sqlContent = await File.ReadAllTextAsync(viewFile); - sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, ""); - - string[] batches = GoBatchPattern.Split(sqlContent); - - foreach (string batch in batches) - { - string trimmedBatch = batch.Trim(); - if (string.IsNullOrWhiteSpace(trimmedBatch)) continue; - - using (var command = new SqlCommand(trimmedBatch, connection)) - { - command.CommandTimeout = ShortTimeoutSeconds; - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - } - } - - WriteSuccess("Success"); - installFailureCount = 0; /* Reset failure count */ - } - } - catch (SqlException) - { - Console.WriteLine("✗ Skipped (sp_WhoIsActive not installed or incompatible schema)"); - /*This is expected if sp_WhoIsActive isn't installed - keep installFailureCount = 1 but don't error*/ - } - catch (IOException) - { - Console.WriteLine("✗ Skipped (could not read view file)"); - } - } - else - { - Console.WriteLine("✗ Not created (sp_WhoIsActive installation may have failed)"); - Console.WriteLine(); - Console.WriteLine("NOTE: The query_snapshots table creation depends on sp_WhoIsActive"); - Console.WriteLine(" The view will be created automatically on next collection if available"); - } - } - } - } - catch (Exception ex) - { - Console.WriteLine($"✗ Failed"); - Console.WriteLine($"Error: {ex.Message}"); - Console.WriteLine(); - Console.WriteLine("Installation completed but initial collection failed."); - Console.WriteLine("Check PerformanceMonitor.config.collection_log for details."); - } - } - - /* - Installation summary - Calculate totals and determine success - Treat query_snapshots view failure as a warning, not an error - */ - totalSuccessCount = upgradeSuccessCount + installSuccessCount; - totalFailureCount = upgradeFailureCount + installFailureCount; - installationSuccessful = totalFailureCount == 0; - - /* - Log installation history to database - */ - try - { - await LogInstallationHistory( - builder.ConnectionString, - version, - infoVersion, - installationStartTime, - totalSuccessCount, - totalFailureCount, - installationSuccessful - ); - } - catch (Exception ex) - { - Console.WriteLine($"Warning: Could not log installation history: {ex.Message}"); - } - - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("Installation Summary"); - Console.WriteLine("================================================================================"); - - if (installationSuccessful) - { - WriteSuccess("Installation completed successfully!"); - Console.WriteLine(); - Console.WriteLine("WHAT WAS INSTALLED:"); - Console.WriteLine("✓ PerformanceMonitor database and all collection tables"); - Console.WriteLine("✓ All collector stored procedures"); - Console.WriteLine("✓ Community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)"); - Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Collection (runs every 1 minute)"); - Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Data Retention (runs daily at 2:00 AM)"); - Console.WriteLine("✓ Initial collection completed successfully"); - - Console.WriteLine(); - Console.WriteLine("NEXT STEPS:"); - Console.WriteLine("1. Ensure SQL Server Agent service is running"); - Console.WriteLine("2. Verify installation: SELECT * FROM PerformanceMonitor.report.collection_health;"); - Console.WriteLine("3. Monitor job history in SQL Server Agent"); - Console.WriteLine(); - Console.WriteLine("See README.md for detailed information."); - } - else - { - WriteWarning($"Installation completed with {totalFailureCount} error(s)."); - Console.WriteLine("Review errors above and check PerformanceMonitor.config.collection_log for details."); - } - - /* - Ask if user wants to retry or exit (skip in automated mode) - */ - if (totalFailureCount > 0 && !automatedMode) - { - retry = PromptRetryOrExit(); - } - - } while (retry); - - /* - Generate installation summary report file - */ - try - { - string reportPath = GenerateSummaryReport( - serverName!, - sqlServerVersion, - sqlServerEdition, - infoVersion, - installationStartTime, - totalSuccessCount, - totalFailureCount, - installationSuccessful, - installationErrors - ); - Console.WriteLine(); - Console.WriteLine($"Installation report saved to: {reportPath}"); - } - catch (Exception ex) - { - Console.WriteLine(); - Console.WriteLine($"Warning: Could not generate summary report: {ex.Message}"); - } - - /* - Exit message for successful completion or user chose not to retry - */ - if (!automatedMode) - { - Console.WriteLine(); - Console.Write("Press any key to exit..."); - Console.ReadKey(true); - Console.WriteLine(); - } - - return installationSuccessful ? ExitCodes.Success : ExitCodes.PartialInstallation; - } - - /* - Ask user if they want to retry or exit - Returns true to retry, false to exit - */ - private static bool PromptRetryOrExit() - { - Console.WriteLine(); - Console.Write("Y to retry installation, N to exit: "); - string? response = Console.ReadLine(); - return response?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; - } - - /* - Log installation history to database - Tracks version, duration, success/failure, and upgrade detection - */ - - /// - /// Performs a complete uninstall: stops traces, removes jobs, XE sessions, and database. - /// - private static async Task PerformUninstallAsync(string connectionString, bool automatedMode) - { - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("UNINSTALL MODE"); - Console.WriteLine("================================================================================"); - Console.WriteLine(); - - if (!automatedMode) - { - Console.WriteLine("This will remove:"); - Console.WriteLine(" - SQL Agent jobs (Collection, Data Retention, Hung Job Monitor)"); - Console.WriteLine(" - Extended Events sessions (BlockedProcess, Deadlock)"); - Console.WriteLine(" - Server-side traces"); - Console.WriteLine(" - PerformanceMonitor database and ALL collected data"); - Console.WriteLine(); - Console.Write("Are you sure you want to continue? (Y/N, default N): "); - string? confirm = Console.ReadLine(); - if (!confirm?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? true) - { - Console.WriteLine("Uninstall cancelled."); - WaitForExit(); - return ExitCodes.Success; - } - } - - Console.WriteLine(); - Console.WriteLine("Uninstalling Performance Monitor..."); - - try - { - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync().ConfigureAwait(false); - - /*Stop traces first (procedure lives in the database)*/ - try - { - using var traceCmd = new SqlCommand( - "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';", - connection); - traceCmd.CommandTimeout = ShortTimeoutSeconds; - await traceCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - Console.WriteLine("✓ Stopped server-side traces"); - } - catch (SqlException) - { - Console.WriteLine(" No traces to stop (database or procedure not found)"); - } - - /*Remove jobs, XE sessions, and database*/ - using var command = new SqlCommand(UninstallSql, connection); - command.CommandTimeout = ShortTimeoutSeconds; - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - - Console.WriteLine(); - WriteSuccess("Uninstall completed successfully"); - Console.WriteLine(); - Console.WriteLine("Note: blocked process threshold (s) was NOT reset."); - } - catch (Exception ex) - { - Console.WriteLine(); - Console.WriteLine($"Uninstall failed: {ex.Message}"); - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.UninstallFailed; - } - - if (!automatedMode) - { - WaitForExit(); - } - return ExitCodes.Success; - } - - /* - Get currently installed version from database - Returns null if not installed (database or table doesn't exist) - Throws exception for unexpected errors (permissions, network, etc.) - */ - private static async Task GetInstalledVersion(string connectionString) - { - try - { - using (var connection = new SqlConnection(connectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - - /*Check if PerformanceMonitor database exists*/ - using var dbCheckCmd = new SqlCommand(@" - SELECT database_id - FROM sys.databases - WHERE name = N'PerformanceMonitor';", connection); - - var dbExists = await dbCheckCmd.ExecuteScalarAsync().ConfigureAwait(false); - if (dbExists == null || dbExists == DBNull.Value) - { - return null; /*Database doesn't exist - clean install needed*/ - } - - /*Check if installation_history table exists*/ - using var tableCheckCmd = new SqlCommand(@" - USE PerformanceMonitor; - SELECT OBJECT_ID(N'config.installation_history', N'U');", connection); - - var tableExists = await tableCheckCmd.ExecuteScalarAsync().ConfigureAwait(false); - if (tableExists == null || tableExists == DBNull.Value) - { - return null; /*Table doesn't exist - old version or corrupted install*/ - } - - /*Get most recent successful installation version*/ - using var versionCmd = new SqlCommand(@" - SELECT TOP 1 installer_version - FROM PerformanceMonitor.config.installation_history - WHERE installation_status = 'SUCCESS' - ORDER BY installation_date DESC;", connection); - - var version = await versionCmd.ExecuteScalarAsync().ConfigureAwait(false); - if (version != null && version != DBNull.Value) - { - return version.ToString(); - } - - /* - Fallback: database and history table exist but no SUCCESS rows. - This can happen if a prior GUI install didn't write history (#538/#539). - Return "1.0.0" so all idempotent upgrade scripts are attempted - rather than treating this as a fresh install (which would drop the database). - */ - Console.WriteLine("Warning: PerformanceMonitor database exists but installation_history has no records."); - Console.WriteLine("Treating as v1.0.0 to apply all available upgrades."); - return "1.0.0"; - } - } - catch (SqlException ex) - { - throw new InvalidOperationException( - $"Failed to check installed version. SQL Error {ex.Number}: {ex.Message}", ex); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Failed to check installed version: {ex.Message}", ex); - } - } - - /* - Find upgrade folders that need to be applied - Returns list of upgrade folder paths in order - Filters by version: only applies upgrades where FromVersion >= currentVersion and ToVersion <= targetVersion - */ - private static List GetApplicableUpgrades( - string monitorRootDirectory, - string? currentVersion, - string targetVersion) - { - var upgradeFolders = new List(); - string upgradesDirectory = Path.Combine(monitorRootDirectory, "upgrades"); - - if (!Directory.Exists(upgradesDirectory)) - { - return upgradeFolders; /*No upgrades folder - return empty list*/ - } - - /*If there's no current version, it's a clean install - no upgrades needed*/ - if (currentVersion == null) - { - return upgradeFolders; - } - - /*Parse current version - if invalid, skip upgrades - Normalize to 3-part (Major.Minor.Build) to avoid Revision mismatch: - folder names use 3-part "1.3.0" but DB stores 4-part "1.3.0.0" - Version(1,3,0).Revision=-1 which breaks >= comparison with Version(1,3,0,0)*/ - if (!Version.TryParse(currentVersion, out var currentRaw)) - { - return upgradeFolders; - } - var current = new Version(currentRaw.Major, currentRaw.Minor, currentRaw.Build); - - /*Parse target version - if invalid, skip upgrades*/ - if (!Version.TryParse(targetVersion, out var targetRaw)) - { - return upgradeFolders; - } - var target = new Version(targetRaw.Major, targetRaw.Minor, targetRaw.Build); - - /* - Find all upgrade folders matching pattern: {from}-to-{to} - Parse versions and filter to only applicable upgrades - */ - var applicableUpgrades = Directory.GetDirectories(upgradesDirectory) - .Select(d => new - { - Path = d, - FolderName = Path.GetFileName(d) - }) - .Where(x => x.FolderName.Contains("-to-", StringComparison.Ordinal)) - .Select(x => - { - var parts = x.FolderName.Split("-to-"); - return new - { - x.Path, - FromVersion = Version.TryParse(parts[0], out var from) ? from : null, - ToVersion = parts.Length > 1 && Version.TryParse(parts[1], out var to) ? to : null - }; - }) - .Where(x => x.FromVersion != null && x.ToVersion != null) - .Where(x => x.FromVersion >= current) /*Don't re-apply old upgrades*/ - .Where(x => x.ToVersion <= target) /*Don't apply future upgrades*/ - .OrderBy(x => x.FromVersion) - .ToList(); - - foreach (var upgrade in applicableUpgrades) - { - string upgradeFile = Path.Combine(upgrade.Path, "upgrade.txt"); - if (File.Exists(upgradeFile)) - { - upgradeFolders.Add(upgrade.Path); - } - } - - return upgradeFolders; - } - - /* - Execute an upgrade folder - Returns (successCount, failureCount) - */ - private static async Task<(int successCount, int failureCount)> ExecuteUpgrade( - string upgradeFolder, - SqlConnection connection) - { - int successCount = 0; - int failureCount = 0; - - string upgradeName = Path.GetFileName(upgradeFolder); - string upgradeFile = Path.Combine(upgradeFolder, "upgrade.txt"); - - Console.WriteLine(); - Console.WriteLine($"Applying upgrade: {upgradeName}"); - Console.WriteLine("--------------------------------------------------------------------------------"); - - /*Read the upgrade.txt file to get ordered list of SQL files*/ - var sqlFileNames = (await File.ReadAllLinesAsync(upgradeFile).ConfigureAwait(false)) - .Where(line => !string.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#')) - .Select(line => line.Trim()) - .ToList(); - - foreach (var fileName in sqlFileNames) - { - string filePath = Path.Combine(upgradeFolder, fileName); - - if (!File.Exists(filePath)) - { - Console.WriteLine($" {fileName}... ? WARNING: File not found"); - failureCount++; - continue; - } - - Console.Write($" {fileName}... "); - - try - { - string sql = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - string[] batches = GoBatchPattern.Split(sql); - - int batchNumber = 0; - foreach (var batch in batches) - { - batchNumber++; - string trimmedBatch = batch.Trim(); - - if (string.IsNullOrWhiteSpace(trimmedBatch)) - continue; - - using (var cmd = new SqlCommand(trimmedBatch, connection)) - { - cmd.CommandTimeout = UpgradeTimeoutSeconds; - try - { - await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - catch (SqlException ex) - { - /*Add batch info to error message*/ - string batchPreview = trimmedBatch.Length > 500 ? - trimmedBatch.Substring(0, 500) + $"... [truncated, total length: {trimmedBatch.Length}]" : - trimmedBatch; - throw new InvalidOperationException($"Batch {batchNumber} failed:\n{batchPreview}\n\nOriginal error: {ex.Message}", ex); - } - } - } - - WriteSuccess("Success"); - successCount++; - } - catch (Exception ex) - { - WriteError("FAILED"); - Console.WriteLine($" Error: {ex.Message}"); - failureCount++; - } - } - - return (successCount, failureCount); - } - - private static async Task LogInstallationHistory( - string connectionString, - string assemblyVersion, - string infoVersion, - DateTime startTime, - int filesExecuted, - int filesFailed, - bool isSuccess) - { - try - { - using (var connection = new SqlConnection(connectionString)) - { - await connection.OpenAsync().ConfigureAwait(false); - - /*Check if this is an upgrade by checking for existing installation*/ - string? previousVersion = null; - string installationType = "INSTALL"; - - try - { - using (var checkCmd = new SqlCommand(@" - SELECT TOP 1 installer_version - FROM PerformanceMonitor.config.installation_history - WHERE installation_status = 'SUCCESS' - ORDER BY installation_date DESC;", connection)) - { - var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false); - if (result != null && result != DBNull.Value) - { - previousVersion = result.ToString(); - bool isSameVersion = Version.TryParse(previousVersion, out var prevVer) - && Version.TryParse(assemblyVersion, out var currVer) - && prevVer == currVer; - installationType = isSameVersion ? "REINSTALL" : "UPGRADE"; - } - } - } - catch (SqlException) - { - /*Table might not exist yet on first install - that's ok*/ - } - - /*Get SQL Server version info*/ - string sqlVersion = ""; - string sqlEdition = ""; - - using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection)) - { - using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false)) - { - if (await reader.ReadAsync().ConfigureAwait(false)) - { - sqlVersion = reader.GetString(0); - sqlEdition = reader.GetString(1); - } - } - } - - /*Calculate duration*/ - long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds; - - /*Determine installation status*/ - string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED"); - - /*Insert installation record*/ - var insertSql = @" - INSERT INTO PerformanceMonitor.config.installation_history - ( - installer_version, - installer_info_version, - sql_server_version, - sql_server_edition, - installation_type, - previous_version, - installation_status, - files_executed, - files_failed, - installation_duration_ms - ) - VALUES - ( - @installer_version, - @installer_info_version, - @sql_server_version, - @sql_server_edition, - @installation_type, - @previous_version, - @installation_status, - @files_executed, - @files_failed, - @installation_duration_ms - );"; - - using (var insertCmd = new SqlCommand(insertSql, connection)) - { - insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion }); - insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value }); - insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion }); - insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition }); - insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType }); - insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value }); - insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status }); - insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted }); - insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed }); - insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs }); - - await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - /*Don't fail installation if logging fails*/ - Console.WriteLine($"Warning: Failed to log installation history: {ex.Message}"); - } - } - - /* - Write error log file for bug reporting - Returns the path to the log file - */ - private static string WriteErrorLog(Exception ex, string serverName, string installerVersion) - { - string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - string sanitizedServer = SanitizeFilename(serverName); - string fileName = $"PerformanceMonitor_Error_{sanitizedServer}_{timestamp}.log"; - string logPath = Path.Combine(Directory.GetCurrentDirectory(), fileName); - - var sb = new System.Text.StringBuilder(); - - sb.AppendLine("================================================================================"); - sb.AppendLine("Performance Monitor Installer - Error Log"); - sb.AppendLine("================================================================================"); - sb.AppendLine(); - sb.AppendLine($"Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); - sb.AppendLine($"Installer Version: {installerVersion}"); - sb.AppendLine($"Server: {serverName}"); - sb.AppendLine($"Machine: {Environment.MachineName}"); - sb.AppendLine($"User: {Environment.UserName}"); - sb.AppendLine($"OS: {Environment.OSVersion}"); - sb.AppendLine($".NET Version: {Environment.Version}"); - sb.AppendLine(); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine("ERROR DETAILS"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Type: {ex.GetType().FullName}"); - sb.AppendLine($"Message: {ex.Message}"); - sb.AppendLine(); - - if (ex.InnerException != null) - { - sb.AppendLine("Inner Exception:"); - sb.AppendLine($" Type: {ex.InnerException.GetType().FullName}"); - sb.AppendLine($" Message: {ex.InnerException.Message}"); - sb.AppendLine(); - } - - sb.AppendLine("Stack Trace:"); - sb.AppendLine(ex.StackTrace ?? "(not available)"); - sb.AppendLine(); - - if (ex.InnerException?.StackTrace != null) - { - sb.AppendLine("Inner Exception Stack Trace:"); - sb.AppendLine(ex.InnerException.StackTrace); - sb.AppendLine(); - } - - sb.AppendLine("================================================================================"); - sb.AppendLine("Please include this file when reporting issues at:"); - sb.AppendLine("https://github.com/erikdarlingdata/PerformanceMonitor/issues"); - sb.AppendLine("================================================================================"); - - File.WriteAllText(logPath, sb.ToString()); - - return logPath; - } - - /* - Sanitize a string for use in a filename - Replaces invalid characters with underscores - */ - private static string SanitizeFilename(string input) - { - var invalid = Path.GetInvalidFileNameChars(); - return string.Concat(input.Select(c => invalid.Contains(c) ? '_' : c)); - } - - /* - Download content from URL with retry logic for transient failures - Uses exponential backoff: 2s, 4s, 8s between retries - */ - private static async Task DownloadWithRetryAsync( - HttpClient client, - string url, - int maxRetries = 3) - { - for (int attempt = 1; attempt <= maxRetries; attempt++) - { - try - { - return await client.GetStringAsync(url).ConfigureAwait(false); - } - catch (HttpRequestException) when (attempt < maxRetries) - { - int delaySeconds = (int)Math.Pow(2, attempt); /*2s, 4s, 8s*/ - Console.WriteLine($"network error, retrying in {delaySeconds}s ({attempt}/{maxRetries})..."); - Console.Write($"Installing ... "); - await Task.Delay(delaySeconds * 1000).ConfigureAwait(false); - } - } - /*Final attempt - let exception propagate if it fails*/ - return await client.GetStringAsync(url).ConfigureAwait(false); - } - - /* - Wait for user input before exiting (prevents window from closing) - Used for fatal errors where retry doesn't make sense - */ - private static void WaitForExit() - { - Console.WriteLine(); - Console.Write("Press any key to exit..."); - Console.ReadKey(true); - Console.WriteLine(); - } - - /* - Read password from console, displaying asterisks - */ - private static string ReadPassword() - { - string password = string.Empty; - ConsoleKeyInfo key; - - do - { - key = Console.ReadKey(true); - - if (key.Key == ConsoleKey.Backspace && password.Length > 0) - { - password = password.Substring(0, password.Length - 1); - Console.Write("\b \b"); - } - else if (key.Key != ConsoleKey.Enter && !char.IsControl(key.KeyChar)) - { - password += key.KeyChar; - Console.Write("*"); - } - } while (key.Key != ConsoleKey.Enter); - - return password; - } - - /* - Install community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit) - Downloads and installs latest versions in PerformanceMonitor database - */ - private static async Task InstallDependenciesAsync(string connectionString) - { - Console.WriteLine(); - Console.WriteLine("================================================================================"); - Console.WriteLine("Installing community dependencies..."); - Console.WriteLine("================================================================================"); - Console.WriteLine(); - - var dependencies = new List<(string Name, string Url, string Description)> - { - ( - "sp_WhoIsActive", - "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql", - "Query activity monitoring by Adam Machanic (GPLv3)" - ), - ( - "DarlingData", - "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql", - "sp_HealthParser, sp_HumanEventsBlockViewer, and others by Erik Darling (MIT)" - ), - ( - "First Responder Kit", - "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql", - "sp_BlitzLock and other diagnostic tools by Brent Ozar Unlimited (MIT)" - ) - }; - - using var httpClient = new HttpClient(); - httpClient.Timeout = TimeSpan.FromSeconds(30); - - int successCount = 0; - int failureCount = 0; - - foreach (var (name, url, description) in dependencies) - { - Console.Write($"Installing {name}... "); - - try - { - /*Download the script with retry for transient failures*/ - string sql = await DownloadWithRetryAsync(httpClient, url).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(sql)) - { - Console.WriteLine("✗ FAILED (empty response)"); - failureCount++; - continue; - } - - /*Execute in PerformanceMonitor database*/ - using var connection = new SqlConnection(connectionString); - await connection.OpenAsync().ConfigureAwait(false); - - /*Switch to PerformanceMonitor database*/ - using (var useDbCommand = new SqlCommand("USE PerformanceMonitor;", connection)) - { - await useDbCommand.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - /* - Split by GO statements using pre-compiled regex - */ - string[] batches = GoBatchPattern.Split(sql); - - foreach (string batch in batches) - { - string trimmedBatch = batch.Trim(); - - if (string.IsNullOrWhiteSpace(trimmedBatch)) - continue; - - using var command = new SqlCommand(trimmedBatch, connection); - command.CommandTimeout = MediumTimeoutSeconds; - await command.ExecuteNonQueryAsync().ConfigureAwait(false); - } - - WriteSuccess("Success"); - Console.WriteLine($" {description}"); - successCount++; - } - catch (HttpRequestException ex) - { - Console.WriteLine($"✗ Download failed: {ex.Message}"); - failureCount++; - } - catch (SqlException ex) - { - Console.WriteLine($"✗ SQL execution failed: {ex.Message}"); - failureCount++; - } - catch (Exception ex) - { - Console.WriteLine($"✗ Failed: {ex.Message}"); - failureCount++; - } - } - - Console.WriteLine(); - Console.WriteLine($"Dependencies installed: {successCount}/{dependencies.Count}"); - - if (failureCount > 0) - { - Console.WriteLine($"Note: {failureCount} dependencies failed to install. The system will work but some"); - Console.WriteLine(" collectors may not function optimally. Check network connectivity and try again."); - } - } - - /* - Generate installation summary report file - Creates a text file with installation details for documentation and troubleshooting - */ - private static string GenerateSummaryReport( - string serverName, - string sqlServerVersion, - string sqlServerEdition, - string installerVersion, - DateTime startTime, - int filesSucceeded, - int filesFailed, - bool overallSuccess, - List<(string FileName, string ErrorMessage)> errors) - { - var endTime = DateTime.Now; - var duration = endTime - startTime; - - /* - Generate unique filename with timestamp - */ - string timestamp = startTime.ToString("yyyyMMdd_HHmmss"); - string fileName = $"PerformanceMonitor_Install_{SanitizeFilename(serverName)}_{timestamp}.txt"; - string reportPath = Path.Combine(Directory.GetCurrentDirectory(), fileName); - - var sb = new System.Text.StringBuilder(); - - /* - Header - */ - sb.AppendLine("================================================================================"); - sb.AppendLine("Performance Monitor Installation Report"); - sb.AppendLine("================================================================================"); - sb.AppendLine(); - - /* - Installation summary - */ - sb.AppendLine("INSTALLATION SUMMARY"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Status: {(overallSuccess ? "SUCCESS" : "FAILED")}"); - sb.AppendLine($"Start Time: {startTime:yyyy-MM-dd HH:mm:ss}"); - sb.AppendLine($"End Time: {endTime:yyyy-MM-dd HH:mm:ss}"); - sb.AppendLine($"Duration: {duration.TotalSeconds:F1} seconds"); - sb.AppendLine($"Files Executed: {filesSucceeded}"); - sb.AppendLine($"Files Failed: {filesFailed}"); - sb.AppendLine(); - - /* - Server information - */ - sb.AppendLine("SERVER INFORMATION"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Server Name: {serverName}"); - sb.AppendLine($"SQL Server Edition: {sqlServerEdition}"); - sb.AppendLine(); - - /* - Extract version info from @@VERSION (first line only) - */ - if (!string.IsNullOrEmpty(sqlServerVersion)) - { - string[] versionLines = sqlServerVersion.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); - if (versionLines.Length > 0) - { - sb.AppendLine($"SQL Server Version:"); - foreach (var line in versionLines) - { - sb.AppendLine($" {line.Trim()}"); - } - } - } - sb.AppendLine(); - - /* - Installer information - */ - sb.AppendLine("INSTALLER INFORMATION"); - sb.AppendLine("--------------------------------------------------------------------------------"); - sb.AppendLine($"Installer Version: {installerVersion}"); - sb.AppendLine($"Working Directory: {Directory.GetCurrentDirectory()}"); - sb.AppendLine($"Machine Name: {Environment.MachineName}"); - sb.AppendLine($"User Name: {Environment.UserName}"); - sb.AppendLine(); - - /* - Errors section (if any) - */ - if (errors.Count > 0) - { - sb.AppendLine("ERRORS"); - sb.AppendLine("--------------------------------------------------------------------------------"); - foreach (var (file, error) in errors) - { - sb.AppendLine($"File: {file}"); - /* - Truncate very long error messages - */ - string errorMsg = error.Length > 500 ? error.Substring(0, 500) + "..." : error; - sb.AppendLine($"Error: {errorMsg}"); - sb.AppendLine(); - } - } - - /* - Footer - */ - sb.AppendLine("================================================================================"); - sb.AppendLine("Generated by Performance Monitor Installer"); - sb.AppendLine($"Copyright (c) {DateTime.Now.Year} Darling Data, LLC"); - sb.AppendLine("================================================================================"); - - /* - Write file - */ - File.WriteAllText(reportPath, sb.ToString()); - - return reportPath; - } - - private static void WriteSuccess(string message) - { - Console.ForegroundColor = ConsoleColor.Green; - Console.Write("√ "); - Console.ResetColor(); - Console.WriteLine(message); - } - - private static void WriteError(string message) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.Write("✗ "); - Console.ResetColor(); - Console.WriteLine(message); - } - - private static void WriteWarning(string message) - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("! "); - Console.ResetColor(); - Console.WriteLine(message); - } - - private static async Task CheckForInstallerUpdateAsync(string currentVersion) - { - try - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor"); - client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); - - var response = await client.GetAsync( - "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest") - .ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) return; - - var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - using var doc = System.Text.Json.JsonDocument.Parse(json); - var tagName = doc.RootElement.GetProperty("tag_name").GetString() ?? ""; - var versionString = tagName.TrimStart('v', 'V'); - - if (!Version.TryParse(versionString, out var latest)) return; - if (!Version.TryParse(currentVersion, out var current)) return; - - if (latest > current) - { - Console.WriteLine(); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗"); - Console.WriteLine($"║ A newer version ({tagName}) is available! "); - Console.WriteLine("║ https://github.com/erikdarlingdata/PerformanceMonitor/releases "); - Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝"); - Console.ResetColor(); - Console.WriteLine(); - } - } - catch - { - /* Best effort — don't block installation if GitHub is unreachable */ - } - } - - [GeneratedRegex(@"^\s*GO\s*(?:--[^\r\n]*)?\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline)] - private static partial Regex GoBatchRegExp(); - } -} +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using System.Reflection; +using Installer.Core; +using Installer.Core.Models; + +namespace PerformanceMonitorInstaller +{ + class Program + { + static async Task Main(string[] args) + { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"; + var infoVersion = Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.InformationalVersion ?? version; + + Console.WriteLine("================================================================================"); + Console.WriteLine($"Performance Monitor Installation Utility v{infoVersion}"); + Console.WriteLine("Copyright © 2026 Darling Data, LLC"); + Console.WriteLine("Licensed under the MIT License"); + Console.WriteLine("https://github.com/erikdarlingdata/PerformanceMonitor"); + Console.WriteLine("================================================================================"); + + await CheckForInstallerUpdateAsync(version); + + + /* + Determine if running in automated mode (command-line arguments provided) + Usage: PerformanceMonitorInstaller.exe [server] [username] [password] [options] + If server is provided alone, uses Windows Authentication + If server, username, and password are provided, uses SQL Authentication + + Options: + --reinstall Drop existing database and perform clean install + --encrypt=X Connection encryption: mandatory (default), optional, strict + --trust-cert Trust server certificate without validation (default: require valid cert) + */ + if (args.Any(a => a.Equals("--help", StringComparison.OrdinalIgnoreCase) + || a.Equals("-h", StringComparison.OrdinalIgnoreCase))) + { + Console.WriteLine("Usage:"); + Console.WriteLine(" PerformanceMonitorInstaller.exe Interactive mode"); + Console.WriteLine(" PerformanceMonitorInstaller.exe [options] Windows Auth"); + Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth"); + Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth (password via env var)"); + Console.WriteLine(" PerformanceMonitorInstaller.exe --entra Entra ID (MFA)"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" -h, --help Show this help message"); + Console.WriteLine(" --reinstall Drop existing database and perform clean install"); + Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions"); + Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); + Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); + Console.WriteLine(" --trust-cert Trust server certificate without validation"); + Console.WriteLine(" --entra Use Microsoft Entra ID interactive authentication (MFA)"); + Console.WriteLine(); + Console.WriteLine("Environment Variables:"); + Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)"); + Console.WriteLine(); + Console.WriteLine("Exit Codes:"); + Console.WriteLine(" 0 Success"); + Console.WriteLine(" 1 Invalid arguments"); + Console.WriteLine(" 2 Connection failed"); + Console.WriteLine(" 3 Critical file failed"); + Console.WriteLine(" 4 Partial installation (non-critical failures)"); + Console.WriteLine(" 5 Version check failed"); + Console.WriteLine(" 6 SQL files not found"); + Console.WriteLine(" 7 Uninstall failed"); + Console.WriteLine(" 8 Upgrade failed"); + return 0; + } + + bool automatedMode = args.Length > 0; + bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)); + bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)); + bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)); + bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)); + bool entraMode = args.Any(a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase)); + + /*Parse --entra email (the argument following --entra)*/ + string? entraEmail = null; + if (entraMode) + { + int entraIndex = Array.FindIndex(args, a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase)); + if (entraIndex >= 0 && entraIndex + 1 < args.Length && !args[entraIndex + 1].StartsWith("--", StringComparison.Ordinal)) + { + entraEmail = args[entraIndex + 1]; + } + } + + /*Parse encryption option (default: Mandatory)*/ + var encryptArg = args.FirstOrDefault(a => a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase)); + string encryptionLevel = "Mandatory"; + if (encryptArg != null) + { + string encryptValue = encryptArg.Substring("--encrypt=".Length).ToLowerInvariant(); + encryptionLevel = encryptValue switch + { + "optional" => "Optional", + "strict" => "Strict", + _ => "Mandatory" + }; + } + + /*Filter out option flags and --entra to get positional arguments*/ + var filteredArgsList = args + .Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.Equals("--entra", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + /*Remove the entra email from positional args if present*/ + if (entraEmail != null) + { + filteredArgsList.Remove(entraEmail); + } + + var filteredArgs = filteredArgsList.ToArray(); + string? serverName; + string? username = null; + string? password = null; + bool useWindowsAuth; + bool useEntraAuth = false; + + if (automatedMode) + { + /* + Automated mode with command-line arguments + */ + serverName = filteredArgs.Length > 0 ? filteredArgs[0] : null; + + if (entraMode) + { + /*Microsoft Entra ID interactive authentication*/ + useWindowsAuth = false; + useEntraAuth = true; + username = entraEmail; + + if (string.IsNullOrWhiteSpace(username)) + { + Console.WriteLine("Error: Email address is required for Entra ID authentication."); + Console.WriteLine("Usage: PerformanceMonitorInstaller.exe --entra "); + return (int)InstallationResultCode.InvalidArguments; + } + + Console.WriteLine($"Server: {serverName}"); + Console.WriteLine($"Authentication: Microsoft Entra ID ({username})"); + Console.WriteLine("A browser window will open for interactive authentication..."); + } + else if (filteredArgs.Length >= 2) + { + /*SQL Authentication - password from env var or command-line*/ + useWindowsAuth = false; + username = filteredArgs[1]; + + string? envPassword = Environment.GetEnvironmentVariable("PM_SQL_PASSWORD"); + if (filteredArgs.Length >= 3) + { + password = filteredArgs[2]; + if (envPassword == null) + { + Console.WriteLine("Note: Password provided via command-line is visible in process listings."); + Console.WriteLine(" Consider using PM_SQL_PASSWORD environment variable instead."); + Console.WriteLine(); + } + } + else if (envPassword != null) + { + password = envPassword; + } + else + { + Console.WriteLine("Error: Password is required for SQL Server Authentication."); + Console.WriteLine("Provide password as third argument or set PM_SQL_PASSWORD environment variable."); + return (int)InstallationResultCode.InvalidArguments; + } + + Console.WriteLine($"Server: {serverName}"); + Console.WriteLine($"Authentication: SQL Server ({username})"); + } + else if (filteredArgs.Length == 1) + { + /*Windows Authentication*/ + useWindowsAuth = true; + Console.WriteLine($"Server: {serverName}"); + Console.WriteLine($"Authentication: Windows"); + } + else + { + Console.WriteLine("Error: Invalid arguments."); + Console.WriteLine("Usage:"); + Console.WriteLine(" Windows Auth: PerformanceMonitorInstaller.exe [options]"); + Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]"); + Console.WriteLine(" SQL Auth: PerformanceMonitorInstaller.exe [options]"); + Console.WriteLine(" (with PM_SQL_PASSWORD environment variable set)"); + Console.WriteLine(); + Console.WriteLine("Options:"); + Console.WriteLine(" --reinstall Drop existing database and perform clean install"); + Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); + Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); + Console.WriteLine(" --trust-cert Trust server certificate without validation (default: require valid cert)"); + return (int)InstallationResultCode.InvalidArguments; + } + } + else + { + /* + Interactive mode - prompt for connection information + */ + Console.Write("SQL Server instance (e.g., localhost, SQL2022): "); + serverName = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(serverName)) + { + Console.WriteLine("Error: Server name is required."); + WaitForExit(); + return (int)InstallationResultCode.InvalidArguments; + } + + Console.WriteLine("Authentication type:"); + Console.WriteLine(" [W] Windows Authentication (default)"); + Console.WriteLine(" [S] SQL Server Authentication"); + Console.WriteLine(" [E] Microsoft Entra ID (interactive MFA)"); + Console.Write("Choice (W/S/E, default W): "); + string? authResponse = Console.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(authResponse) || authResponse.Equals("W", StringComparison.OrdinalIgnoreCase)) + { + useWindowsAuth = true; + } + else if (authResponse.Equals("E", StringComparison.OrdinalIgnoreCase)) + { + useWindowsAuth = false; + useEntraAuth = true; + + Console.Write("Email address (UPN): "); + username = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(username)) + { + Console.WriteLine("Error: Email address is required for Entra ID authentication."); + WaitForExit(); + return (int)InstallationResultCode.InvalidArguments; + } + + Console.WriteLine("A browser window will open for interactive authentication..."); + } + else + { + useWindowsAuth = false; + + Console.Write("SQL Server login: "); + username = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(username)) + { + Console.WriteLine("Error: Login is required for SQL Server Authentication."); + WaitForExit(); + return (int)InstallationResultCode.InvalidArguments; + } + + Console.Write("Password: "); + password = ReadPassword(); + Console.WriteLine(); + + if (string.IsNullOrWhiteSpace(password)) + { + Console.WriteLine("Error: Password is required for SQL Server Authentication."); + WaitForExit(); + return (int)InstallationResultCode.InvalidArguments; + } + } + } + + /* + Build connection string using Installer.Core + */ + string connectionString = InstallationService.BuildConnectionString( + serverName!, + useWindowsAuth, + username, + password, + encryptionLevel, + trustCert, + useEntraAuth); + + /* + Test connection and get SQL Server version + */ + string sqlServerVersion = ""; + string sqlServerEdition = ""; + + Console.WriteLine(); + Console.WriteLine("Testing connection..."); + + var serverInfo = await InstallationService.TestConnectionAsync(connectionString).ConfigureAwait(false); + + if (!serverInfo.IsConnected) + { + WriteError($"Connection failed: {serverInfo.ErrorMessage}"); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.ConnectionFailed; + } + + WriteSuccess("Connection successful!"); + sqlServerVersion = serverInfo.SqlServerVersion; + sqlServerEdition = serverInfo.SqlServerEdition; + + /*Check minimum SQL Server version -- 2016+ required for on-prem (Standard/Enterprise). + Azure MI (EngineEdition 8) is always current, skip the check.*/ + if (serverInfo.ProductMajorVersion > 0 && !serverInfo.IsSupportedVersion) + { + Console.WriteLine(); + Console.WriteLine($"ERROR: {serverInfo.ProductMajorVersionName} is not supported."); + Console.WriteLine("Performance Monitor requires SQL Server 2016 (13.x) or later."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + } + + /* + Handle --uninstall mode (no SQL files needed) + */ + if (uninstallMode) + { + return await PerformUninstallAsync(connectionString, automatedMode); + } + + /* + Find SQL files using ScriptProvider.FromDirectory() + Search current directory and up to 5 parent directories + Prefer install/ subfolder if it exists (new structure) + */ + ScriptProvider? scriptProvider = null; + string? sqlDirectory = null; + string? monitorRootDirectory = null; + string currentDirectory = Directory.GetCurrentDirectory(); + DirectoryInfo? searchDir = new DirectoryInfo(currentDirectory); + + for (int i = 0; i < 6 && searchDir != null; i++) + { + /*Check for install/ subfolder first (new structure)*/ + string installFolder = Path.Combine(searchDir.FullName, "install"); + if (Directory.Exists(installFolder)) + { + var installFiles = Directory.GetFiles(installFolder, "*.sql") + .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f))) + .ToList(); + + if (installFiles.Count > 0) + { + sqlDirectory = installFolder; + monitorRootDirectory = searchDir.FullName; + break; + } + } + + /*Fall back to old structure (SQL files in root)*/ + var files = Directory.GetFiles(searchDir.FullName, "*.sql") + .Where(f => Patterns.SqlFilePattern().IsMatch(Path.GetFileName(f))) + .ToList(); + + if (files.Count > 0) + { + sqlDirectory = searchDir.FullName; + monitorRootDirectory = searchDir.FullName; + break; + } + + searchDir = searchDir.Parent; + } + + if (sqlDirectory == null || monitorRootDirectory == null) + { + Console.WriteLine($"Error: No SQL installation files found."); + Console.WriteLine($"Searched in: {currentDirectory}"); + Console.WriteLine("Expected files in install/ folder or root directory:"); + Console.WriteLine(" install/01_install_database.sql, install/02_create_tables.sql, etc."); + Console.WriteLine(); + Console.WriteLine("Make sure the installer is in the Monitor directory or a subdirectory."); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.SqlFilesNotFound; + } + + scriptProvider = ScriptProvider.FromDirectory(monitorRootDirectory); + var sqlFiles = scriptProvider.GetInstallFiles(); + + Console.WriteLine(); + Console.WriteLine($"Found {sqlFiles.Count} SQL files in: {sqlDirectory}"); + if (monitorRootDirectory != sqlDirectory) + { + Console.WriteLine($"Using new folder structure (install/ subfolder)"); + } + + /* + Create progress reporter that routes to console helpers + */ + var progress = new Progress(p => + { + switch (p.Status) + { + case "Success": + WriteSuccess(p.Message); + break; + case "Error": + WriteError(p.Message); + break; + case "Warning": + WriteWarning(p.Message); + break; + case "Debug": + /*Suppress debug messages in CLI output*/ + break; + default: + Console.WriteLine(p.Message); + break; + } + }); + + /* + Main installation loop - allows retry on failure + */ + int upgradeSuccessCount = 0; + int upgradeFailureCount = 0; + int installSuccessCount = 0; + int installFailureCount = 0; + int totalSuccessCount = 0; + int totalFailureCount = 0; + var installationErrors = new List<(string FileName, string ErrorMessage)>(); + bool installationSuccessful = false; + bool retry; + DateTime installationStartTime = DateTime.Now; + do + { + retry = false; + upgradeSuccessCount = 0; + upgradeFailureCount = 0; + installSuccessCount = 0; + installFailureCount = 0; + installationErrors.Clear(); + installationSuccessful = false; + installationStartTime = DateTime.Now; + + /* + Ask about clean install (automated mode preserves database unless --reinstall flag is used) + */ + bool dropExisting; + if (automatedMode) + { + dropExisting = reinstallMode; + Console.WriteLine(); + if (reinstallMode) + { + Console.WriteLine("Automated mode: Performing clean reinstall (dropping existing database)..."); + } + else + { + Console.WriteLine("Automated mode: Performing upgrade (preserving existing database)..."); + } + } + else + { + Console.WriteLine(); + Console.Write("Drop existing PerformanceMonitor database if it exists? (Y/N, default N): "); + string? cleanInstall = Console.ReadLine(); + dropExisting = cleanInstall?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; + } + + if (dropExisting) + { + Console.WriteLine(); + Console.WriteLine("Performing clean install..."); + try + { + await InstallationService.CleanInstallAsync(connectionString).ConfigureAwait(false); + WriteSuccess("Clean install completed (jobs and database removed)"); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not complete cleanup: {ex.Message}"); + Console.WriteLine("Continuing with installation..."); + } + } + else + { + /* + Upgrade mode - check for existing installation and apply upgrades + */ + string? currentVersion = null; + try + { + currentVersion = await InstallationService.GetInstalledVersionAsync(connectionString).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("ERROR: Failed to check for existing installation"); + Console.WriteLine("================================================================================"); + Console.WriteLine(ex.Message); + if (ex.InnerException != null) + { + Console.WriteLine($"Details: {ex.InnerException.Message}"); + } + Console.WriteLine(); + Console.WriteLine("This may indicate a permissions issue or database corruption."); + Console.WriteLine("Please review the error log and report this issue if it persists."); + Console.WriteLine(); + + /*Write error log for bug reporting*/ + string errorLogPath = WriteErrorLog(ex, serverName!, infoVersion); + Console.WriteLine($"Error log written to: {errorLogPath}"); + + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.VersionCheckFailed; + } + + if (currentVersion != null) + { + Console.WriteLine(); + Console.WriteLine($"Existing installation detected: v{currentVersion}"); + Console.WriteLine("Checking for applicable upgrades..."); + + var (upgSuccessCount, upgFailureCount, upgradeCount) = + await InstallationService.ExecuteAllUpgradesAsync( + scriptProvider, + connectionString, + currentVersion, + version, + progress).ConfigureAwait(false); + + upgradeSuccessCount = upgSuccessCount; + upgradeFailureCount = upgFailureCount; + + if (upgradeCount > 0) + { + Console.WriteLine(); + Console.WriteLine($"Upgrades complete: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed"); + + /*Abort if any upgrade scripts failed -- proceeding would reinstall over a partially-upgraded database*/ + if (upgradeFailureCount > 0) + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed."); + Console.WriteLine("Fix the errors above and re-run the installer."); + Console.WriteLine("================================================================================"); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.UpgradesFailed; + } + } + else + { + Console.WriteLine("No pending upgrades found."); + } + } + else + { + Console.WriteLine(); + Console.WriteLine("No existing installation detected, proceeding with fresh install..."); + } + } + + /* + Execute SQL files in order + */ + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("Starting installation..."); + Console.WriteLine("================================================================================"); + Console.WriteLine(); + + /* + Execute installation using Installer.Core + Use DependencyInstaller for community dependencies before validation + */ + using var dependencyInstaller = new DependencyInstaller(); + + var installResult = await InstallationService.ExecuteInstallationAsync( + connectionString, + scriptProvider, + cleanInstall: false, /* Clean install was already handled above if requested */ + resetSchedule: resetSchedule, + progress: new Progress(p => + { + switch (p.Status) + { + case "Success": + if (p.Message.EndsWith(" - Success", StringComparison.Ordinal)) + { + /*File success: replicate the original "Executing ... Success" format*/ + string fileName = p.Message.Replace(" - Success", "", StringComparison.Ordinal); + /*The "Executing..." was already printed by the Info message*/ + WriteSuccess("Success"); + } + else + { + WriteSuccess(p.Message); + } + break; + case "Error": + if (p.Message.Contains(" - FAILED:", StringComparison.Ordinal)) + { + WriteError("FAILED"); + string errorMsg = p.Message.Substring(p.Message.IndexOf(" - FAILED: ", StringComparison.Ordinal) + 11); + Console.WriteLine($" Error: {errorMsg}"); + } + else if (p.Message == "Critical installation file failed. Aborting installation.") + { + Console.WriteLine(); + Console.WriteLine(p.Message); + } + else + { + WriteError(p.Message); + } + break; + case "Warning": + WriteWarning(p.Message); + break; + case "Info": + if (p.Message.StartsWith("Executing ", StringComparison.Ordinal) && p.Message.EndsWith("...", StringComparison.Ordinal)) + { + /*Replicate "Executing ... " format (no newline yet)*/ + Console.Write(p.Message.Replace("Executing ", "Executing ", StringComparison.Ordinal) + " "); + } + else if (p.Message == "Resetting schedule to recommended defaults...") + { + Console.Write("(resetting schedule) "); + } + else if (p.Message != "Starting installation...") + { + Console.WriteLine(p.Message); + } + break; + case "Debug": + /*Suppress debug messages in CLI output*/ + break; + default: + Console.WriteLine(p.Message); + break; + } + }), + preValidationAction: async () => + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("Installing community dependencies..."); + Console.WriteLine("================================================================================"); + Console.WriteLine(); + + try + { + await dependencyInstaller.InstallDependenciesAsync( + connectionString, + new Progress(dp => + { + switch (dp.Status) + { + case "Success": + WriteSuccess(dp.Message); + break; + case "Error": + WriteError(dp.Message); + break; + case "Warning": + WriteWarning(dp.Message); + break; + case "Debug": + break; + default: + Console.WriteLine(dp.Message); + break; + } + })).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Dependency installation encountered errors: {ex.Message}"); + Console.WriteLine("Continuing with installation..."); + } + }).ConfigureAwait(false); + + installSuccessCount = installResult.FilesSucceeded; + installFailureCount = installResult.FilesFailed; + installationErrors.AddRange(installResult.Errors); + + /*Check for critical file failure*/ + if (installResult.FilesFailed > 0 && installResult.Errors.Any(e => Patterns.IsCriticalFile(e.FileName))) + { + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.CriticalScriptFailed; + } + + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("File Execution Summary"); + Console.WriteLine("================================================================================"); + if (upgradeSuccessCount > 0 || upgradeFailureCount > 0) + { + Console.WriteLine($"Upgrades: {upgradeSuccessCount} succeeded, {upgradeFailureCount} failed"); + } + Console.WriteLine($"Installation: {installSuccessCount} succeeded, {installFailureCount} failed"); + Console.WriteLine(); + + /* + Run initial collection and retry failed views + This validates the installation and creates dynamically-generated tables + */ + if (installFailureCount <= 1 && automatedMode) /* Allow 1 failure for query_snapshots view */ + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("Running initial collection to validate installation..."); + Console.WriteLine("================================================================================"); + Console.WriteLine(); + + try + { + Console.Write("Executing master collector... "); + var (collectorsSucceeded, collectorsFailed) = await InstallationService.RunValidationAsync( + connectionString, + new Progress(vp => + { + /*Suppress most messages; the method writes detailed results*/ + if (vp.Status == "Error" && !vp.Message.StartsWith(" ", StringComparison.Ordinal)) + { + WriteError(vp.Message); + } + })).ConfigureAwait(false); + + WriteSuccess("Success"); + Console.WriteLine(); + Console.Write("Verifying data collection... "); + Console.WriteLine($"✓ {collectorsSucceeded} collectors ran successfully"); + + if (collectorsFailed > 0) + { + Console.WriteLine(); + Console.WriteLine($"⚠ {collectorsFailed} collector(s) encountered errors"); + } + } + catch (Exception ex) + { + Console.WriteLine($"✗ Failed"); + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine("Installation completed but initial collection failed."); + Console.WriteLine("Check PerformanceMonitor.config.collection_log for details."); + } + } + + /* + Installation summary + Calculate totals and determine success + Treat query_snapshots view failure as a warning, not an error + */ + totalSuccessCount = upgradeSuccessCount + installSuccessCount; + totalFailureCount = upgradeFailureCount + installFailureCount; + installationSuccessful = totalFailureCount == 0; + + /* + Log installation history to database + */ + try + { + await InstallationService.LogInstallationHistoryAsync( + connectionString, + version, + infoVersion, + installationStartTime, + totalSuccessCount, + totalFailureCount, + installationSuccessful + ).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not log installation history: {ex.Message}"); + } + + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("Installation Summary"); + Console.WriteLine("================================================================================"); + + if (installationSuccessful) + { + WriteSuccess("Installation completed successfully!"); + Console.WriteLine(); + Console.WriteLine("WHAT WAS INSTALLED:"); + Console.WriteLine("✓ PerformanceMonitor database and all collection tables"); + Console.WriteLine("✓ All collector stored procedures"); + Console.WriteLine("✓ Community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit)"); + Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Collection (runs every 1 minute)"); + Console.WriteLine("✓ SQL Agent Job: PerformanceMonitor - Data Retention (runs daily at 2:00 AM)"); + Console.WriteLine("✓ Initial collection completed successfully"); + + Console.WriteLine(); + Console.WriteLine("NEXT STEPS:"); + Console.WriteLine("1. Ensure SQL Server Agent service is running"); + Console.WriteLine("2. Verify installation: SELECT * FROM PerformanceMonitor.report.collection_health;"); + Console.WriteLine("3. Monitor job history in SQL Server Agent"); + Console.WriteLine(); + Console.WriteLine("See README.md for detailed information."); + } + else + { + WriteWarning($"Installation completed with {totalFailureCount} error(s)."); + Console.WriteLine("Review errors above and check PerformanceMonitor.config.collection_log for details."); + } + + /* + Ask if user wants to retry or exit (skip in automated mode) + */ + if (totalFailureCount > 0 && !automatedMode) + { + retry = PromptRetryOrExit(); + } + + } while (retry); + + /* + Generate installation summary report file + */ + try + { + var summaryResult = new InstallationResult + { + Success = installationSuccessful, + FilesSucceeded = totalSuccessCount, + FilesFailed = totalFailureCount, + StartTime = installationStartTime, + EndTime = DateTime.Now + }; + foreach (var (fileName, errorMessage) in installationErrors) + { + summaryResult.Errors.Add((fileName, errorMessage)); + } + + string reportPath = InstallationService.GenerateSummaryReport( + serverName!, + sqlServerVersion, + sqlServerEdition, + infoVersion, + summaryResult, + outputDirectory: Directory.GetCurrentDirectory()); + + Console.WriteLine(); + Console.WriteLine($"Installation report saved to: {reportPath}"); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.WriteLine($"Warning: Could not generate summary report: {ex.Message}"); + } + + /* + Exit message for successful completion or user chose not to retry + */ + if (!automatedMode) + { + Console.WriteLine(); + Console.Write("Press any key to exit..."); + Console.ReadKey(true); + Console.WriteLine(); + } + + return installationSuccessful + ? (int)InstallationResultCode.Success + : (int)InstallationResultCode.PartialInstallation; + } + + /* + Ask user if they want to retry or exit + Returns true to retry, false to exit + */ + private static bool PromptRetryOrExit() + { + Console.WriteLine(); + Console.Write("Y to retry installation, N to exit: "); + string? response = Console.ReadLine(); + return response?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; + } + + /// + /// Performs a complete uninstall: stops traces, removes jobs, XE sessions, and database. + /// + private static async Task PerformUninstallAsync(string connectionString, bool automatedMode) + { + Console.WriteLine(); + Console.WriteLine("================================================================================"); + Console.WriteLine("UNINSTALL MODE"); + Console.WriteLine("================================================================================"); + Console.WriteLine(); + + if (!automatedMode) + { + Console.WriteLine("This will remove:"); + Console.WriteLine(" - SQL Agent jobs (Collection, Data Retention, Hung Job Monitor)"); + Console.WriteLine(" - Extended Events sessions (BlockedProcess, Deadlock)"); + Console.WriteLine(" - Server-side traces"); + Console.WriteLine(" - PerformanceMonitor database and ALL collected data"); + Console.WriteLine(); + Console.Write("Are you sure you want to continue? (Y/N, default N): "); + string? confirm = Console.ReadLine(); + if (!confirm?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? true) + { + Console.WriteLine("Uninstall cancelled."); + WaitForExit(); + return (int)InstallationResultCode.Success; + } + } + + Console.WriteLine(); + Console.WriteLine("Uninstalling Performance Monitor..."); + + try + { + await InstallationService.ExecuteUninstallAsync( + connectionString, + new Progress(p => + { + switch (p.Status) + { + case "Success": + WriteSuccess(p.Message); + break; + case "Error": + WriteError(p.Message); + break; + case "Warning": + WriteWarning(p.Message); + break; + case "Info": + Console.WriteLine(p.Message); + break; + case "Debug": + break; + default: + Console.WriteLine(p.Message); + break; + } + })).ConfigureAwait(false); + + Console.WriteLine(); + WriteSuccess("Uninstall completed successfully"); + Console.WriteLine(); + Console.WriteLine("Note: blocked process threshold (s) was NOT reset."); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.WriteLine($"Uninstall failed: {ex.Message}"); + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.UninstallFailed; + } + + if (!automatedMode) + { + WaitForExit(); + } + return (int)InstallationResultCode.Success; + } + + /* + Write error log file for bug reporting + Returns the path to the log file + */ + private static string WriteErrorLog(Exception ex, string serverName, string installerVersion) + { + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string sanitizedServer = SanitizeFilename(serverName); + string fileName = $"PerformanceMonitor_Error_{sanitizedServer}_{timestamp}.log"; + string logPath = Path.Combine(Directory.GetCurrentDirectory(), fileName); + + var sb = new System.Text.StringBuilder(); + + sb.AppendLine("================================================================================"); + sb.AppendLine("Performance Monitor Installer - Error Log"); + sb.AppendLine("================================================================================"); + sb.AppendLine(); + sb.AppendLine($"Timestamp: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Installer Version: {installerVersion}"); + sb.AppendLine($"Server: {serverName}"); + sb.AppendLine($"Machine: {Environment.MachineName}"); + sb.AppendLine($"User: {Environment.UserName}"); + sb.AppendLine($"OS: {Environment.OSVersion}"); + sb.AppendLine($".NET Version: {Environment.Version}"); + sb.AppendLine(); + sb.AppendLine("--------------------------------------------------------------------------------"); + sb.AppendLine("ERROR DETAILS"); + sb.AppendLine("--------------------------------------------------------------------------------"); + sb.AppendLine($"Type: {ex.GetType().FullName}"); + sb.AppendLine($"Message: {ex.Message}"); + sb.AppendLine(); + + if (ex.InnerException != null) + { + sb.AppendLine("Inner Exception:"); + sb.AppendLine($" Type: {ex.InnerException.GetType().FullName}"); + sb.AppendLine($" Message: {ex.InnerException.Message}"); + sb.AppendLine(); + } + + sb.AppendLine("Stack Trace:"); + sb.AppendLine(ex.StackTrace ?? "(not available)"); + sb.AppendLine(); + + if (ex.InnerException?.StackTrace != null) + { + sb.AppendLine("Inner Exception Stack Trace:"); + sb.AppendLine(ex.InnerException.StackTrace); + sb.AppendLine(); + } + + sb.AppendLine("================================================================================"); + sb.AppendLine("Please include this file when reporting issues at:"); + sb.AppendLine("https://github.com/erikdarlingdata/PerformanceMonitor/issues"); + sb.AppendLine("================================================================================"); + + File.WriteAllText(logPath, sb.ToString()); + + return logPath; + } + + /* + Sanitize a string for use in a filename + Replaces invalid characters with underscores + */ + private static string SanitizeFilename(string input) + { + var invalid = Path.GetInvalidFileNameChars(); + return string.Concat(input.Select(c => invalid.Contains(c) ? '_' : c)); + } + + /* + Wait for user input before exiting (prevents window from closing) + Used for fatal errors where retry doesn't make sense + */ + private static void WaitForExit() + { + Console.WriteLine(); + Console.Write("Press any key to exit..."); + Console.ReadKey(true); + Console.WriteLine(); + } + + /* + Read password from console, displaying asterisks + */ + private static string ReadPassword() + { + string password = string.Empty; + ConsoleKeyInfo key; + + do + { + key = Console.ReadKey(true); + + if (key.Key == ConsoleKey.Backspace && password.Length > 0) + { + password = password.Substring(0, password.Length - 1); + Console.Write("\b \b"); + } + else if (key.Key != ConsoleKey.Enter && !char.IsControl(key.KeyChar)) + { + password += key.KeyChar; + Console.Write("*"); + } + } while (key.Key != ConsoleKey.Enter); + + return password; + } + + private static void WriteSuccess(string message) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("√ "); + Console.ResetColor(); + Console.WriteLine(message); + } + + private static void WriteError(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Write("✗ "); + Console.ResetColor(); + Console.WriteLine(message); + } + + private static void WriteWarning(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("! "); + Console.ResetColor(); + Console.WriteLine(message); + } + + private static async Task CheckForInstallerUpdateAsync(string currentVersion) + { + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + + var response = await client.GetAsync( + "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest") + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) return; + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + using var doc = System.Text.Json.JsonDocument.Parse(json); + var tagName = doc.RootElement.GetProperty("tag_name").GetString() ?? ""; + var versionString = tagName.TrimStart('v', 'V'); + + if (!Version.TryParse(versionString, out var latest)) return; + if (!Version.TryParse(currentVersion, out var current)) return; + + if (latest > current) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗"); + Console.WriteLine($"║ A newer version ({tagName}) is available! "); + Console.WriteLine("║ https://github.com/erikdarlingdata/PerformanceMonitor/releases "); + Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝"); + Console.ResetColor(); + Console.WriteLine(); + } + } + catch + { + /* Best effort — don't block installation if GitHub is unreachable */ + } + } + } +} From efabf91fb50edf99f17c185de7444f2a2c883182 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:17:20 -0400 Subject: [PATCH 3/4] Fix doomed transaction error handling in delta framework and ensure_collection_table (#756) When calculate_deltas was called inside a collector's transaction and failed, the CATCH block tried to INSERT into collection_log while the transaction was doomed (XACT_STATE = -1), swallowing the real error with "The current transaction cannot be committed." Same pattern in ensure_collection_table where INSERT happened before ROLLBACK. Co-Authored-By: Claude Opus 4.6 (1M context) --- install/05_delta_framework.sql | 47 ++++++++++++++------------ install/06_ensure_collection_table.sql | 11 +++--- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/install/05_delta_framework.sql b/install/05_delta_framework.sql index b0e87b2..688e87c 100644 --- a/install/05_delta_framework.sql +++ b/install/05_delta_framework.sql @@ -1202,38 +1202,43 @@ BEGIN END TRY BEGIN CATCH + DECLARE + @error_message nvarchar(4000) = ERROR_MESSAGE(); + /* Only rollback if we started the transaction Otherwise let the caller handle it */ - IF @trancount_at_entry = 0 + IF @trancount_at_entry = 0 AND @@TRANCOUNT > 0 BEGIN ROLLBACK TRANSACTION; END; - DECLARE - @error_message nvarchar(4000) = ERROR_MESSAGE(); - /* - Log the error + Log the error only if the transaction is not doomed + When called inside a caller's transaction that is doomed (XACT_STATE = -1), + we cannot write to the log — the caller must rollback first */ - INSERT INTO - config.collection_log - ( - collector_name, - collection_status, - duration_ms, - error_message - ) - VALUES - ( - N'calculate_deltas_' + @table_name, - N'ERROR', - DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()), - @error_message - ); - + IF XACT_STATE() <> -1 + BEGIN + INSERT INTO + config.collection_log + ( + collector_name, + collection_status, + duration_ms, + error_message + ) + VALUES + ( + N'calculate_deltas_' + @table_name, + N'ERROR', + DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()), + @error_message + ); + END; + RAISERROR(N'Error calculating deltas for %s: %s', 16, 1, @table_name, @error_message); END CATCH; END; diff --git a/install/06_ensure_collection_table.sql b/install/06_ensure_collection_table.sql index c47e67e..414d5b1 100644 --- a/install/06_ensure_collection_table.sql +++ b/install/06_ensure_collection_table.sql @@ -1206,8 +1206,14 @@ BEGIN BEGIN CATCH SET @error_message = ERROR_MESSAGE(); + IF @@TRANCOUNT > 0 + BEGIN + ROLLBACK; + END; + /* Log errors to collection log + Must happen after rollback to avoid doomed transaction writes */ INSERT INTO config.collection_log @@ -1229,11 +1235,6 @@ BEGIN @error_message ); - IF @@TRANCOUNT > 0 - BEGIN - ROLLBACK; - END; - THROW; END CATCH; END; From 7642511d321168dc90b2c7798db1a607cb172383 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:43:42 -0400 Subject: [PATCH 4/4] Add XACT_STATE check after third-party proc calls in XML processors (#695) sp_BlitzLock and sp_HumanEventsBlockViewer can fail internally and doom the caller's transaction. Without checking XACT_STATE after the call, the next write attempt produces "cannot be committed" which swallows the real error. Now we detect the doomed state, rollback, and surface a meaningful error message. Co-Authored-By: Claude Opus 4.6 (1M context) --- install/23_process_blocked_process_xml.sql | 11 +++++++++++ install/25_process_deadlock_xml.sql | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/install/23_process_blocked_process_xml.sql b/install/23_process_blocked_process_xml.sql index 6def84c..e8c3b2c 100644 --- a/install/23_process_blocked_process_xml.sql +++ b/install/23_process_blocked_process_xml.sql @@ -220,6 +220,17 @@ BEGIN @end_date = @end_date_local, @debug = @debug; + /* + If sp_HumanEventsBlockViewer failed internally it may have doomed our transaction + Check XACT_STATE and surface the real error before it gets swallowed + */ + IF XACT_STATE() = -1 + BEGIN + ROLLBACK TRANSACTION; + RAISERROR(N'sp_HumanEventsBlockViewer failed and doomed the transaction - check procedure version and compatibility', 16, 1); + RETURN; + END; + /* Verify sp_HumanEventsBlockViewer produced parsed results before marking rows as processed If no results were inserted, leave rows unprocessed so they are retried next run diff --git a/install/25_process_deadlock_xml.sql b/install/25_process_deadlock_xml.sql index 74067fd..48f4bba 100644 --- a/install/25_process_deadlock_xml.sql +++ b/install/25_process_deadlock_xml.sql @@ -209,6 +209,17 @@ BEGIN @end_date_local = @end_date_local, @debug = @debug; + /* + If sp_BlitzLock failed internally it may have doomed our transaction + Check XACT_STATE and surface the real error before it gets swallowed + */ + IF XACT_STATE() = -1 + BEGIN + ROLLBACK TRANSACTION; + RAISERROR(N'sp_BlitzLock failed and doomed the transaction - check sp_BlitzLock version and compatibility', 16, 1); + RETURN; + END; + /* Verify sp_BlitzLock produced parsed results before marking rows as processed If no results were inserted, leave rows unprocessed so they are retried next run