From 147c94f403ec44840ae241262a86c16af0b07386 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 21 Sep 2026 18:04:01 +0100 Subject: [PATCH 1/3] feat: capture SQL Server versions and allow database monitors without URLs --- .../HealthMonitoringRegistrationClient.cs | 15 ++-- .../Api/ServiceConfigurationValidatorTests.cs | 18 +++++ .../Monitoring/InitializationStateTests.cs | 50 +++++++++++++ .../Monitoring/SqlServerMonitorClientTests.cs | 3 + .../Monitoring/SqlServerVersionTests.cs | 65 ++++++++++++++++ .../Api/ServiceConfigurationValidator.cs | 10 ++- .../Api/ServiceRegistrationContracts.cs | 4 +- .../Api/ServiceRegistrationEndpoints.cs | 7 +- .../Components/Pages/ServiceDetail.razor | 8 +- .../Domain/MonitoredService.cs | 2 +- .../20260921180000_MakeHealthUrlOptional.cs | 36 +++++++++ .../HealthMonitoringDbContextModelSnapshot.cs | 1 - .../Monitoring/HealthEndpointClient.cs | 5 +- ...thMonitoringServiceCollectionExtensions.cs | 1 + .../Monitoring/InitializationService.cs | 8 +- .../Monitoring/SqlServerMonitorClient.cs | 74 +++++++++++++++++++ .../Persistence/HealthMonitoringDbContext.cs | 2 +- 17 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerVersionTests.cs create mode 100644 HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs diff --git a/HealthMonitoring/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs b/HealthMonitoring/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs index d65f40d..3e5c74b 100644 --- a/HealthMonitoring/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs +++ b/HealthMonitoring/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs @@ -8,7 +8,7 @@ public sealed class ServiceRegistrationOptions { public string ServiceId { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; - public Uri HealthUrl { get; init; } = new("http://localhost/health"); + public Uri? HealthUrl { get; init; } = new("http://localhost/health"); public string MonitorType { get; init; } = "HttpHealthEndpoint"; public string? ConnectionString { get; init; } public bool IgnoreCertificateErrors { get; init; } @@ -42,13 +42,14 @@ public sealed class HealthMonitoringRegistrationClient( public async Task RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(options); - ValidateServiceOptions(options); + var monitorType = ParseMonitorType(options.MonitorType); + ValidateServiceOptions(options, monitorType); var payload = new ServiceRegistrationPayload( options.ServiceId, options.Name, - options.HealthUrl.ToString(), - ParseMonitorType(options.MonitorType), + monitorType == 0 ? options.HealthUrl?.ToString() : null, + monitorType, options.ConnectionString, options.IgnoreCertificateErrors, ToSeconds(options.PollingInterval, nameof(options.PollingInterval)), @@ -146,14 +147,14 @@ private static int ToDays(TimeSpan value, string name) return (int)value.TotalDays; } - private static void ValidateServiceOptions(ServiceRegistrationOptions options) + private static void ValidateServiceOptions(ServiceRegistrationOptions options, int monitorType) { if (string.IsNullOrWhiteSpace(options.ServiceId)) throw new ArgumentException("A service ID is required.", nameof(options)); if (string.IsNullOrWhiteSpace(options.Name)) throw new ArgumentException("A service name is required.", nameof(options)); - if (!options.HealthUrl.IsAbsoluteUri) throw new ArgumentException("The health URL must be absolute.", nameof(options)); + if (monitorType == 0 && (options.HealthUrl is null || !options.HealthUrl.IsAbsoluteUri)) throw new ArgumentException("The health URL must be absolute.", nameof(options)); } - private sealed record ServiceRegistrationPayload(string ServiceId, string Name, string HealthUrl, int MonitorType, string? ConnectionString, bool IgnoreCertificateErrors, int PollingIntervalSeconds, int RequestTimeoutSeconds, int RetentionDays, object? StatusPolicy, string? Group, string? Description, string? Version, string? Host); + private sealed record ServiceRegistrationPayload(string ServiceId, string Name, string? HealthUrl, int MonitorType, string? ConnectionString, bool IgnoreCertificateErrors, int PollingIntervalSeconds, int RequestTimeoutSeconds, int RetentionDays, object? StatusPolicy, string? Group, string? Description, string? Version, string? Host); private sealed record ServiceRegistrationResponsePayload(string ServiceId, string Action, ServiceIdentityPayload Service); private sealed record ServiceIdentityPayload(Guid Id, string ServiceId, string Name); private sealed record DependencyMappingPayload(string DependencyName, Guid TargetMonitoredServiceId); diff --git a/HealthMonitoring/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs index 33e0705..7130b34 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs @@ -124,4 +124,22 @@ public void Sql_server_registration_requires_a_connection_string_instead_of_a_he Assert.DoesNotContain(errors, error => error.Contains("HealthUrl", StringComparison.Ordinal)); Assert.Contains(errors, error => error.Contains("ConnectionString", StringComparison.Ordinal)); } + + [Theory] + [InlineData(MonitorType.SqlServer)] + [InlineData(MonitorType.KurrentDb)] + public void Database_monitor_registration_does_not_store_a_health_url(MonitorType monitorType) + { + var request = new ServiceRegistrationRequest + { + ServiceId = "database", + Name = "Database", + MonitorType = monitorType, + ConnectionString = "configured" + }; + + var result = ServiceConfigurationValidator.ToService(request, "Development"); + + Assert.Null(result.HealthUrl); + } } diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs index bed61c6..0c1cd52 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs @@ -1,5 +1,7 @@ using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; using HealthMonitoring.Monitoring; +using Microsoft.Extensions.Logging.Abstractions; namespace HealthMonitoring.Tests.Monitoring; @@ -39,4 +41,52 @@ public void Each_base_monitor_requirement_is_evaluated_independently() Assert.False(InitializationStateCalculator.IsComplete(services, requireSqlServer: false, requireKurrentDb: true)); Assert.True(InitializationStateCalculator.IsComplete([], requireSqlServer: false, requireKurrentDb: false)); } + + [Fact] + public async Task Initialization_registration_captures_the_sql_server_version() + { + var repository = new InMemoryRepository(); + var sqlServerProbe = new VersionProbe("Microsoft SQL Server 2022 (RTM) - 16.0.1000.6"); + var sqlServerClient = new SqlServerMonitorClient(sqlServerProbe); + var kurrentDbClient = new KurrentDbMonitorClient(new NoOpKurrentDbProbe()); + var resolver = new SqlServerRegistrationVersionResolver(sqlServerProbe, NullLogger.Instance); + var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver); + + await service.InitializeAsync(new InitializationRequest("Server=localhost;Database=Orders;", string.Empty) { TestConnections = false }, "Development", CancellationToken.None); + + Assert.Equal("SQL Server 2022", repository.SavedService?.Version); + } + + private sealed class VersionProbe(string version) : ISqlServerProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult(version); + } + + private sealed class NoOpKurrentDbProbe : IKurrentDbProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class InMemoryRepository : IHealthMonitoringRepository + { + public MonitoredService? SavedService { get; private set; } + + public Task GetServiceAsync(string serviceId, CancellationToken cancellationToken) => Task.FromResult(SavedService); + public Task UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken) { SavedService = service; return Task.FromResult(service); } + public Task> ListServicesAsync(bool includeArchived, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ArchiveServiceAsync(string serviceId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task AddObservationAsync(HealthObservation observation, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> GetObservationsAsync(Guid serviceId, DateTimeOffset fromUtc, DateTimeOffset toUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetSnapshotAsync(Guid serviceId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task UpsertSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetOpenIncidentAsync(Guid serviceId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task AddIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task UpdateIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteObservationsBeforeAsync(Guid serviceId, DateTimeOffset cutoffUtc, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> ListDependencyLinksAsync(Guid serviceId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task UpsertDependencyLinkAsync(ServiceDependencyLink link, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task DeleteDependencyLinkAsync(Guid linkId, CancellationToken cancellationToken) => throw new NotSupportedException(); + } } diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs index 3580c4f..db5ab74 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs @@ -39,5 +39,8 @@ public Task ProbeAsync(MonitoredService service, CancellationToken cancellationT cancellationToken.ThrowIfCancellationRequested(); return exception is null ? Task.CompletedTask : Task.FromException(exception); } + + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => + Task.FromResult(null); } } diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerVersionTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerVersionTests.cs new file mode 100644 index 0000000..7243b45 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/SqlServerVersionTests.cs @@ -0,0 +1,65 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; +using Microsoft.Extensions.Logging.Abstractions; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class SqlServerVersionTests +{ + [Theory] + [InlineData("Microsoft SQL Server 2022 (RTM-CU12) - 16.0.4125.3 (X64)", "SQL Server 2022")] + [InlineData("Microsoft SQL Server 2019 (RTM-CU29) (KB5037331) - 15.0.4390.2 (X64)", "SQL Server 2019")] + [InlineData("Microsoft SQL Server 2017 (RTM-CU31) - 14.0.3456.2 (X64)", "SQL Server 2017")] + [InlineData("16.0.1000.6", "SQL Server 2022")] + [InlineData("15.0.2000.5", "SQL Server 2019")] + public void Formats_representative_sql_server_versions(string rawVersion, string expected) + { + Assert.Equal(expected, SqlServerVersionFormatter.Format(rawVersion)); + } + + [Fact] + public void Returns_null_for_an_unrecognized_sql_server_version() + { + Assert.Null(SqlServerVersionFormatter.Format("Microsoft SQL Server Developer Edition")); + } + + [Fact] + public async Task Registration_version_resolution_preserves_fallback_when_query_fails() + { + var service = new MonitoredService { MonitorType = MonitorType.SqlServer }; + var probe = new FailingVersionProbe(); + + var resolver = new SqlServerRegistrationVersionResolver(probe, NullLogger.Instance); + var version = await resolver.ResolveAsync(service, "reported-by-caller", CancellationToken.None); + + Assert.Equal("reported-by-caller", version); + } + + [Fact] + public async Task Registration_version_resolution_returns_human_readable_sql_server_version() + { + var service = new MonitoredService { MonitorType = MonitorType.SqlServer }; + var probe = new VersionProbe("16.0.1000.6"); + var resolver = new SqlServerRegistrationVersionResolver(probe, NullLogger.Instance); + + var version = await resolver.ResolveAsync(service, "reported-by-caller", CancellationToken.None); + + Assert.Equal("SQL Server 2022", version); + } + + private sealed class FailingVersionProbe : ISqlServerProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => + Task.FromException(new InvalidOperationException("version query failed")); + } + + private sealed class VersionProbe(string version) : ISqlServerProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => + Task.FromResult(version); + } +} diff --git a/HealthMonitoring/HealthMonitoring/Api/ServiceConfigurationValidator.cs b/HealthMonitoring/HealthMonitoring/Api/ServiceConfigurationValidator.cs index e062289..793b144 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServiceConfigurationValidator.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServiceConfigurationValidator.cs @@ -21,7 +21,7 @@ public static MonitoredService ToService(ServiceRegistrationRequest request, str Group = request.Group, Description = request.Description, MonitorType = request.MonitorType, - HealthUrl = ParseHealthUrl(request.HealthUrl), + HealthUrl = ParseHealthUrl(request.MonitorType, request.HealthUrl), ConnectionString = request.ConnectionString, IgnoreCertificateErrors = request.IgnoreCertificateErrors, IsEnabled = existing?.IsEnabled ?? true, @@ -56,11 +56,13 @@ private static IEnumerable ValidateMonitorSettings(ServiceRegistrationRe if (request.RetentionDays < 1) yield return "RetentionDays must be positive."; } - private static bool IsHttpUrl(string value) => + private static bool IsHttpUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; - private static Uri ParseHealthUrl(string value) => - Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : new Uri(DefaultHealthUrl); + private static Uri? ParseHealthUrl(MonitorType monitorType, string? value) => + monitorType == MonitorType.HttpHealthEndpoint + ? Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : new Uri(DefaultHealthUrl) + : null; private static TimeSpan GetPollingInterval(ServiceRegistrationRequest request, MonitoredService? existing, bool preserveManagedSettings) => preserveManagedSettings && existing is not null ? existing.PollingInterval : TimeSpan.FromSeconds(request.PollingIntervalSeconds); diff --git a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationContracts.cs b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationContracts.cs index 3f69c06..9f9b065 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationContracts.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationContracts.cs @@ -8,7 +8,7 @@ public ServiceRegistrationRequest() { } public ServiceRegistrationRequest(string serviceId, string name, string healthUrl) { ServiceId = serviceId; Name = name; HealthUrl = healthUrl; } public string ServiceId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; - public string HealthUrl { get; set; } = string.Empty; + public string? HealthUrl { get; set; } public MonitorType MonitorType { get; set; } = MonitorType.HttpHealthEndpoint; public string? ConnectionString { get; set; } public bool IgnoreCertificateErrors { get; set; } @@ -30,7 +30,7 @@ public sealed record ServiceSummary( string Name, string Environment, MonitorType MonitorType, - string HealthUrl, + string? HealthUrl, bool IsEnabled, HealthStatus Status, DateTimeOffset? LastObservedAtUtc, diff --git a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs index ad7c532..f344cfc 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -1,4 +1,5 @@ using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; using HealthMonitoring.Persistence; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -19,7 +20,7 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi } private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, string dashboardEnvironment) => - endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => + endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, ILoggerFactory loggerFactory, SqlServerRegistrationVersionResolver versionResolver, CancellationToken cancellationToken) => { loggerFactory.CreateLogger("HealthMonitoring.Api.ServiceRegistrationEndpoints").LogInformation( "POST /api/services/register body: {RequestBody}", @@ -30,6 +31,8 @@ private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, str var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: true); + if (request.MonitorType == MonitorType.SqlServer) + service.Version = await versionResolver.ResolveAsync(service, request.Version, cancellationToken); await repository.UpsertServiceAsync(service, cancellationToken); var action = existing is null ? "created" : "acknowledged"; return existing is null @@ -45,7 +48,7 @@ private static void MapListEndpoint(IEndpointRouteBuilder endpoints) => foreach (var service in services) { var snapshot = await repository.GetSnapshotAsync(service.Id, cancellationToken); - results.Add(new ServiceSummary(service.Id, service.ServiceId, service.Name, service.Environment, service.MonitorType, service.HealthUrl.ToString(), service.IsEnabled, snapshot?.Status ?? HealthStatus.Unknown, snapshot?.LastObservedAtUtc, snapshot?.LastResponseDuration, snapshot?.LastError)); + results.Add(new ServiceSummary(service.Id, service.ServiceId, service.Name, service.Environment, service.MonitorType, service.HealthUrl?.ToString(), service.IsEnabled, snapshot?.Status ?? HealthStatus.Unknown, snapshot?.LastObservedAtUtc, snapshot?.LastResponseDuration, snapshot?.LastError)); } return Results.Ok(results); diff --git a/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor b/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor index 04437a1..78213e1 100644 --- a/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor +++ b/HealthMonitoring/HealthMonitoring/Components/Pages/ServiceDetail.razor @@ -8,7 +8,7 @@ } else { -

SERVICE DETAIL · @_model.Service.Environment

@_model.Service.Name

@(_model.Service.MonitorType == MonitorType.KurrentDb ? "KurrentDB monitor" : _model.Service.HealthUrl.ToString())

Version: @(string.IsNullOrWhiteSpace(_model.Service.Version) ? "not reported" : _model.Service.Version)

+

SERVICE DETAIL · @_model.Service.Environment

@_model.Service.Name

@MonitorDescription

Version: @(string.IsNullOrWhiteSpace(_model.Service.Version) ? "not reported" : _model.Service.Version)

Last checked @( _model.Summary.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "never")

Status timeline

@foreach (var observation in _model.Observations.OrderBy(item => item.ObservedAtUtc)) { }
@@ -26,6 +26,12 @@ else ?? []; private string UptimeText => $"{_model?.Summary.UptimePercent:0.00}%"; private string ResponseText => _model?.Summary.ResponseDuration is { } duration ? $"{duration.TotalMilliseconds:0} ms" : "—"; + private string MonitorDescription => _model?.Service.MonitorType switch + { + MonitorType.KurrentDb => "KurrentDB monitor", + MonitorType.SqlServer => "SQL Server monitor", + _ => _model?.Service.HealthUrl?.ToString() ?? "HTTP health monitor" + } ?? "Service monitor"; protected override async Task OnParametersSetAsync() => _model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None); private sealed record ObservedCheck(HealthCheckResult Result, DateTimeOffset ObservedAtUtc); diff --git a/HealthMonitoring/HealthMonitoring/Domain/MonitoredService.cs b/HealthMonitoring/HealthMonitoring/Domain/MonitoredService.cs index 5579994..39d88de 100644 --- a/HealthMonitoring/HealthMonitoring/Domain/MonitoredService.cs +++ b/HealthMonitoring/HealthMonitoring/Domain/MonitoredService.cs @@ -11,7 +11,7 @@ public sealed class MonitoredService public MonitorType MonitorType { get; set; } = MonitorType.HttpHealthEndpoint; public string? Group { get; set; } public string? Description { get; set; } - public Uri HealthUrl { get; set; } = new("https://localhost"); + public Uri? HealthUrl { get; set; } [JsonIgnore] public string? ConnectionString { get; set; } public bool IgnoreCertificateErrors { get; set; } diff --git a/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs b/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs new file mode 100644 index 0000000..f90dff9 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HealthMonitoring.Migrations; + +public partial class MakeHealthUrlOptional : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "HealthUrl", + table: "MonitoredServices", + type: "nvarchar(2048)", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(2048)", + oldMaxLength: 2048); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "HealthUrl", + table: "MonitoredServices", + type: "nvarchar(2048)", + maxLength: 2048, + nullable: false, + defaultValue: "http://localhost/health", + oldClrType: typeof(string), + oldType: "nvarchar(2048)", + oldMaxLength: 2048, + oldNullable: true); + } +} diff --git a/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs b/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs index 07c1a6f..1155c3e 100644 --- a/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs +++ b/HealthMonitoring/HealthMonitoring/Migrations/HealthMonitoringDbContextModelSnapshot.cs @@ -128,7 +128,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)"); b.Property("HealthUrl") - .IsRequired() .HasMaxLength(2048) .HasColumnType("nvarchar(2048)"); diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/HealthEndpointClient.cs b/HealthMonitoring/HealthMonitoring/Monitoring/HealthEndpointClient.cs index 7d44f1e..0a4d6c5 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/HealthEndpointClient.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/HealthEndpointClient.cs @@ -20,19 +20,20 @@ public sealed class HealthEndpointClient(IHttpClientFactory httpClientFactory, I { public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) { + var healthUrl = service.HealthUrl ?? throw new InvalidOperationException("HTTP health endpoint URL is not configured."); var stopwatch = Stopwatch.StartNew(); try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(service.RequestTimeout); - using var customClient = service.IgnoreCertificateErrors && service.HealthUrl.Scheme == Uri.UriSchemeHttps + using var customClient = service.IgnoreCertificateErrors && healthUrl.Scheme == Uri.UriSchemeHttps ? new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }) : null; var httpClient = customClient ?? httpClientFactory.CreateClient(); - using var response = await httpClient.GetAsync(service.HealthUrl, timeout.Token); + using var response = await httpClient.GetAsync(healthUrl, timeout.Token); var raw = await response.Content.ReadAsStringAsync(timeout.Token); stopwatch.Stop(); try diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs b/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs index c3b3f39..6a52660 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddHealthMonitoringServices(this IServiceCollec services.AddSingleton(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddSingleton(); services.AddScoped(); diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs b/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs index a426416..3e9707c 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs @@ -49,7 +49,8 @@ public sealed class InitializationService( IHealthMonitoringRepository repository, InitializationOptions options, SqlServerMonitorClient sqlServerClient, - KurrentDbMonitorClient kurrentDbClient) : IInitializationService + KurrentDbMonitorClient kurrentDbClient, + SqlServerRegistrationVersionResolver versionResolver) : IInitializationService { public async Task GetStatusAsync(CancellationToken cancellationToken) { @@ -150,6 +151,9 @@ private async Task SaveAsync(ServiceRegistrationRequest request, string dashboar var errors = ServiceConfigurationValidator.Validate(request); if (errors.Count > 0) throw new ArgumentException(string.Join(" ", errors)); var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); - await repository.UpsertServiceAsync(ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: false), cancellationToken); + var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: false); + if (request.MonitorType == MonitorType.SqlServer) + service.Version = await versionResolver.ResolveAsync(service, request.Version, cancellationToken); + await repository.UpsertServiceAsync(service, cancellationToken); } } diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs b/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs index baadeb2..c213886 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs @@ -1,7 +1,9 @@ using System.Diagnostics; using System.Text.Json; +using System.Text.RegularExpressions; using HealthMonitoring.Domain; using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace HealthMonitoring.Monitoring; @@ -9,6 +11,63 @@ namespace HealthMonitoring.Monitoring; public interface ISqlServerProbe { Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken); + Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken); +} + +public static partial class SqlServerVersionFormatter +{ + private static readonly IReadOnlyDictionary MajorVersionYears = new Dictionary + { + [16] = 2022, + [15] = 2019, + [14] = 2017, + [13] = 2016, + [12] = 2014, + [11] = 2012, + [10] = 2008, + [9] = 2005, + [8] = 2000 + }; + + public static string? Format(string? rawVersion) + { + if (string.IsNullOrWhiteSpace(rawVersion)) return null; + + var bannerMatch = BannerRegex().Match(rawVersion); + if (bannerMatch.Success) return $"SQL Server {bannerMatch.Groups[1].Value}"; + + var productVersionMatch = ProductVersionRegex().Match(rawVersion.Trim()); + if (!productVersionMatch.Success || !int.TryParse(productVersionMatch.Groups[1].Value, out var majorVersion)) + return null; + + if (!MajorVersionYears.TryGetValue(majorVersion, out var year)) return null; + if (majorVersion == 10 && int.TryParse(productVersionMatch.Groups[2].Value, out var minorVersion) && minorVersion >= 50) + year = 2008; + + return $"SQL Server {year}"; + } + + [GeneratedRegex(@"\b(?:Microsoft\s+)?SQL\s+Server\s+(20\d{2})\b", RegexOptions.IgnoreCase)] + private static partial Regex BannerRegex(); + + [GeneratedRegex(@"^(\d+)(?:\.(\d+))?(?:\.|$)")] + private static partial Regex ProductVersionRegex(); +} + +public sealed class SqlServerRegistrationVersionResolver(ISqlServerProbe probe, ILogger logger) +{ + public async Task ResolveAsync(MonitoredService service, string? fallback, CancellationToken cancellationToken) + { + try + { + return SqlServerVersionFormatter.Format(await probe.GetVersionAsync(service, cancellationToken)) ?? fallback; + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning(exception, "Unable to determine SQL Server version while registering service {ServiceId}.", service.ServiceId); + return fallback; + } + } } public sealed class SqlServerMonitorOptions @@ -69,6 +128,21 @@ public async Task ProbeAsync(MonitoredService service, CancellationToken cancell await command.ExecuteScalarAsync(timeoutSource.Token); } + public async Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(service.ConnectionString)) throw new InvalidOperationException("SQL Server connection string is not configured."); + var connectionString = ValidateConnectionString(service.ConnectionString, options.Value.AllowedDataSources); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(service.RequestTimeout); + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(timeoutSource.Token); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT CAST(@@VERSION AS nvarchar(max))"; + command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(service.RequestTimeout.TotalSeconds)); + return Convert.ToString(await command.ExecuteScalarAsync(timeoutSource.Token)); + } + private static string ValidateConnectionString(string connectionString, IReadOnlyCollection allowedDataSources) { SqlConnectionStringBuilder builder; diff --git a/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs b/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs index 77023a1..6e5792a 100644 --- a/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs +++ b/HealthMonitoring/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs @@ -34,7 +34,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(service => service.Name).HasMaxLength(300).IsRequired(); entity.Property(service => service.Environment).HasMaxLength(100).IsRequired(); entity.Property(service => service.MonitorType).HasConversion().HasMaxLength(50).IsRequired(); - entity.Property(service => service.HealthUrl).HasConversion().HasMaxLength(2048).IsRequired(); + entity.Property(service => service.HealthUrl).HasConversion().HasMaxLength(2048).IsRequired(false); entity.Property(service => service.ConnectionString).HasColumnType(NvarcharMaxColumnType); entity.Property(service => service.StatusPolicyJson).HasColumnType(NvarcharMaxColumnType).IsRequired(); entity.Property(service => service.PollingInterval).HasConversion(TimeSpanTicksConverter).HasColumnType(BigintColumnType); From e13adcd1ba9ad61ecbec01eceaec216eddbeb8ac Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 21 Sep 2026 18:07:36 +0100 Subject: [PATCH 2/3] Disable KurrentDB initialization requirement --- HealthMonitoring/HealthMonitoring/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HealthMonitoring/HealthMonitoring/appsettings.json b/HealthMonitoring/HealthMonitoring/appsettings.json index 8a4c2cd..2f764c1 100644 --- a/HealthMonitoring/HealthMonitoring/appsettings.json +++ b/HealthMonitoring/HealthMonitoring/appsettings.json @@ -11,7 +11,7 @@ }, "Initialization": { "RequireSqlServer": true, - "RequireKurrentDb": true + "RequireKurrentDb": false }, "Monitoring": { "SqlServer": { From a197359dcec624d48306749b7c4d28ddc47a1c21 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 21 Sep 2026 18:15:43 +0100 Subject: [PATCH 3/3] fix: address PR review feedback --- .../Monitoring/InitializationStateTests.cs | 33 ++++++++++++++++++- .../Api/ServiceRegistrationEndpoints.cs | 2 +- .../20260921180000_MakeHealthUrlOptional.cs | 4 +++ .../Monitoring/InitializationService.cs | 2 +- .../Monitoring/SqlServerMonitorClient.cs | 2 +- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs index 0c1cd52..467814d 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs @@ -57,6 +57,29 @@ public async Task Initialization_registration_captures_the_sql_server_version() Assert.Equal("SQL Server 2022", repository.SavedService?.Version); } + [Fact] + public async Task Initialization_registration_preserves_an_existing_sql_server_version_when_query_fails() + { + var repository = new InMemoryRepository + { + SavedService = new MonitoredService + { + ServiceId = "sql-server", + Version = "SQL Server 2019", + MonitorType = MonitorType.SqlServer + } + }; + var sqlServerProbe = new FailingVersionProbe(); + var sqlServerClient = new SqlServerMonitorClient(sqlServerProbe); + var kurrentDbClient = new KurrentDbMonitorClient(new NoOpKurrentDbProbe()); + var resolver = new SqlServerRegistrationVersionResolver(sqlServerProbe, NullLogger.Instance); + var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver); + + await service.InitializeAsync(new InitializationRequest("Server=localhost;Database=Orders;", string.Empty) { TestConnections = false }, "Development", CancellationToken.None); + + Assert.Equal("SQL Server 2019", repository.SavedService?.Version); + } + private sealed class VersionProbe(string version) : ISqlServerProbe { public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; @@ -64,6 +87,14 @@ private sealed class VersionProbe(string version) : ISqlServerProbe public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult(version); } + private sealed class FailingVersionProbe : ISqlServerProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; + + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => + Task.FromException(new InvalidOperationException("version query failed")); + } + private sealed class NoOpKurrentDbProbe : IKurrentDbProbe { public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; @@ -71,7 +102,7 @@ private sealed class NoOpKurrentDbProbe : IKurrentDbProbe private sealed class InMemoryRepository : IHealthMonitoringRepository { - public MonitoredService? SavedService { get; private set; } + public MonitoredService? SavedService { get; set; } public Task GetServiceAsync(string serviceId, CancellationToken cancellationToken) => Task.FromResult(SavedService); public Task UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken) { SavedService = service; return Task.FromResult(service); } diff --git a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs index f344cfc..1d2cffe 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -32,7 +32,7 @@ private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, str var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: true); if (request.MonitorType == MonitorType.SqlServer) - service.Version = await versionResolver.ResolveAsync(service, request.Version, cancellationToken); + service.Version = await versionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken); await repository.UpsertServiceAsync(service, cancellationToken); var action = existing is null ? "created" : "acknowledged"; return existing is null diff --git a/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs b/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs index f90dff9..a61f034 100644 --- a/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs +++ b/HealthMonitoring/HealthMonitoring/Migrations/20260921180000_MakeHealthUrlOptional.cs @@ -1,9 +1,13 @@ using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using HealthMonitoring.Persistence; #nullable disable namespace HealthMonitoring.Migrations; +[DbContext(typeof(HealthMonitoringDbContext))] +[Migration("20260921180000_MakeHealthUrlOptional")] public partial class MakeHealthUrlOptional : Migration { protected override void Up(MigrationBuilder migrationBuilder) diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs b/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs index 3e9707c..9ac7ce4 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs @@ -153,7 +153,7 @@ private async Task SaveAsync(ServiceRegistrationRequest request, string dashboar var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: false); if (request.MonitorType == MonitorType.SqlServer) - service.Version = await versionResolver.ResolveAsync(service, request.Version, cancellationToken); + service.Version = await versionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken); await repository.UpsertServiceAsync(service, cancellationToken); } } diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs b/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs index c213886..a641a05 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs @@ -64,7 +64,7 @@ public sealed class SqlServerRegistrationVersionResolver(ISqlServerProbe probe, } catch (Exception exception) when (!cancellationToken.IsCancellationRequested) { - logger.LogWarning(exception, "Unable to determine SQL Server version while registering service {ServiceId}.", service.ServiceId); + logger.LogWarning(exception, "Unable to determine SQL Server version while registering a service."); return fallback; } }