From a7b127aaa414def870c2ce86ffcc34f66df26c2b Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 21 Sep 2026 18:40:24 +0100 Subject: [PATCH] Add KurrentDB version capture during registration --- .../Monitoring/InitializationStateTests.cs | 32 +++++- .../Monitoring/KurrentDbVersionTests.cs | 100 ++++++++++++++++++ .../Api/ServiceRegistrationEndpoints.cs | 6 +- ...thMonitoringServiceCollectionExtensions.cs | 2 + .../Monitoring/InitializationService.cs | 7 +- .../Monitoring/KurrentDbMonitorClient.cs | 98 +++++++++++++++++ 6 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 HealthMonitoring/HealthMonitoring.Tests/Monitoring/KurrentDbVersionTests.cs diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs index 467814d..cd00354 100644 --- a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs @@ -50,7 +50,8 @@ public async Task Initialization_registration_captures_the_sql_server_version() 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); + var kurrentResolver = new KurrentDbRegistrationVersionResolver(new NoOpKurrentDbVersionProbe(), NullLogger.Instance); + var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver, kurrentResolver); await service.InitializeAsync(new InitializationRequest("Server=localhost;Database=Orders;", string.Empty) { TestConnections = false }, "Development", CancellationToken.None); @@ -73,13 +74,30 @@ public async Task Initialization_registration_preserves_an_existing_sql_server_v 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); + var kurrentResolver = new KurrentDbRegistrationVersionResolver(new NoOpKurrentDbVersionProbe(), NullLogger.Instance); + var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver, kurrentResolver); await service.InitializeAsync(new InitializationRequest("Server=localhost;Database=Orders;", string.Empty) { TestConnections = false }, "Development", CancellationToken.None); Assert.Equal("SQL Server 2019", repository.SavedService?.Version); } + [Fact] + public async Task Initialization_registration_captures_the_kurrentdb_version() + { + var repository = new InMemoryRepository(); + var sqlServerProbe = new VersionProbe("16.0.1000.6"); + var sqlServerClient = new SqlServerMonitorClient(sqlServerProbe); + var kurrentDbClient = new KurrentDbMonitorClient(new NoOpKurrentDbProbe()); + var sqlResolver = new SqlServerRegistrationVersionResolver(sqlServerProbe, NullLogger.Instance); + var kurrentResolver = new KurrentDbRegistrationVersionResolver(new KurrentVersionProbe("25.0.0.1673-build.1"), NullLogger.Instance); + var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = false, RequireKurrentDb = true }, sqlServerClient, kurrentDbClient, sqlResolver, kurrentResolver); + + await service.InitializeAsync(new InitializationRequest(string.Empty, "esdb://admin:password@localhost:2113?tls=false") { TestConnections = false }, "Development", CancellationToken.None); + + Assert.Equal("KurrentDB 25.0.0", repository.SavedService?.Version); + } + private sealed class VersionProbe(string version) : ISqlServerProbe { public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; @@ -100,6 +118,16 @@ private sealed class NoOpKurrentDbProbe : IKurrentDbProbe public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask; } + private sealed class NoOpKurrentDbVersionProbe : IKurrentDbVersionProbe + { + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult(null); + } + + private sealed class KurrentVersionProbe(string version) : IKurrentDbVersionProbe + { + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult(version); + } + private sealed class InMemoryRepository : IHealthMonitoringRepository { public MonitoredService? SavedService { get; set; } diff --git a/HealthMonitoring/HealthMonitoring.Tests/Monitoring/KurrentDbVersionTests.cs b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/KurrentDbVersionTests.cs new file mode 100644 index 0000000..034e8f3 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.Tests/Monitoring/KurrentDbVersionTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http; +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; +using Microsoft.Extensions.Logging.Abstractions; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class KurrentDbVersionTests +{ + [Theory] + [InlineData("25.0.0.1673-build.1", "KurrentDB 25.0.0")] + [InlineData("25.1", "KurrentDB 25.1")] + public void Formats_kurrentdb_versions(string rawVersion, string expected) + { + Assert.Equal(expected, KurrentDbVersionFormatter.Format(rawVersion)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-version")] + public void Returns_null_for_an_unusable_kurrentdb_version(string? rawVersion) + { + Assert.Null(KurrentDbVersionFormatter.Format(rawVersion)); + } + + [Fact] + public async Task Reads_and_formats_the_info_endpoint_version() + { + var handler = new StubHandler(HttpStatusCode.OK, "{\"esVersion\":\"25.0.0.1673-build.1\"}"); + var probe = new KurrentDbVersionProbe(new StubHttpClientFactory(handler)); + var service = new MonitoredService + { + ConnectionString = "esdb://admin:changeit@localhost:2113?tls=false", + RequestTimeout = TimeSpan.FromSeconds(5) + }; + + var version = await probe.GetVersionAsync(service, CancellationToken.None); + + Assert.Equal("25.0.0.1673-build.1", version); + Assert.Equal("http://localhost:2113/info", handler.Request!.RequestUri!.ToString()); + Assert.Equal("Basic YWRtaW46Y2hhbmdlaXQ=", handler.Request.Headers.Authorization!.ToString()); + } + + [Fact] + public async Task Registration_version_resolution_preserves_fallback_when_info_is_unavailable() + { + var service = new MonitoredService { MonitorType = MonitorType.KurrentDb }; + var probe = new FailingVersionProbe(); + var resolver = new KurrentDbRegistrationVersionResolver(probe, NullLogger.Instance); + + var version = await resolver.ResolveAsync(service, "KurrentDB 24.0.0", CancellationToken.None); + + Assert.Equal("KurrentDB 24.0.0", version); + } + + [Fact] + public async Task Registration_version_resolution_returns_a_human_readable_version() + { + var service = new MonitoredService { MonitorType = MonitorType.KurrentDb }; + var resolver = new KurrentDbRegistrationVersionResolver(new VersionProbe("25.0.0.1673-build.1"), NullLogger.Instance); + + var version = await resolver.ResolveAsync(service, "reported-by-caller", CancellationToken.None); + + Assert.Equal("KurrentDB 25.0.0", version); + } + + private sealed class VersionProbe(string version) : IKurrentDbVersionProbe + { + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult(version); + } + + private sealed class FailingVersionProbe : IKurrentDbVersionProbe + { + public Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => + Task.FromException(new HttpRequestException("info endpoint unavailable")); + } + + private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + } + + private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) + { + _ = name; + return new(handler, disposeHandler: false); + } + } +} diff --git a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs index 1d2cffe..b72d5f7 100644 --- a/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs +++ b/HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -20,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, SqlServerRegistrationVersionResolver versionResolver, CancellationToken cancellationToken) => + endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, ILoggerFactory loggerFactory, SqlServerRegistrationVersionResolver sqlServerVersionResolver, KurrentDbRegistrationVersionResolver kurrentDbVersionResolver, CancellationToken cancellationToken) => { loggerFactory.CreateLogger("HealthMonitoring.Api.ServiceRegistrationEndpoints").LogInformation( "POST /api/services/register body: {RequestBody}", @@ -32,7 +32,9 @@ 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 ?? existing?.Version, cancellationToken); + service.Version = await sqlServerVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken); + else if (request.MonitorType == MonitorType.KurrentDb) + service.Version = await kurrentDbVersionResolver.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/Monitoring/HealthMonitoringServiceCollectionExtensions.cs b/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs index 6a52660..7fb7f9a 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs @@ -17,6 +17,8 @@ public static IServiceCollection AddHealthMonitoringServices(this IServiceCollec services.AddScoped(); services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs b/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs index 9ac7ce4..c7e0a84 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs @@ -50,7 +50,8 @@ public sealed class InitializationService( InitializationOptions options, SqlServerMonitorClient sqlServerClient, KurrentDbMonitorClient kurrentDbClient, - SqlServerRegistrationVersionResolver versionResolver) : IInitializationService + SqlServerRegistrationVersionResolver sqlServerVersionResolver, + KurrentDbRegistrationVersionResolver kurrentDbVersionResolver) : IInitializationService { public async Task GetStatusAsync(CancellationToken cancellationToken) { @@ -153,7 +154,9 @@ 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 ?? existing?.Version, cancellationToken); + service.Version = await sqlServerVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken); + else if (request.MonitorType == MonitorType.KurrentDb) + service.Version = await kurrentDbVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken); await repository.UpsertServiceAsync(service, cancellationToken); } } diff --git a/HealthMonitoring/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs b/HealthMonitoring/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs index 430ede7..019eafb 100644 --- a/HealthMonitoring/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs +++ b/HealthMonitoring/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs @@ -1,8 +1,11 @@ using System.Diagnostics; +using System.Net.Http.Headers; using System.Text.Json; +using System.Text.RegularExpressions; using EventStore.Client; using KurrentDB.Client; using HealthMonitoring.Domain; +using Microsoft.Extensions.Logging; namespace HealthMonitoring.Monitoring; @@ -11,6 +14,41 @@ public interface IKurrentDbProbe Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken); } +public interface IKurrentDbVersionProbe +{ + Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken); +} + +public static partial class KurrentDbVersionFormatter +{ + public static string? Format(string? rawVersion) + { + if (string.IsNullOrWhiteSpace(rawVersion)) return null; + + var match = VersionRegex().Match(rawVersion.Trim()); + return match.Success ? $"KurrentDB {match.Value}" : null; + } + + [GeneratedRegex(@"\b\d+(?:\.\d+){1,2}\b")] + private static partial Regex VersionRegex(); +} + +public sealed class KurrentDbRegistrationVersionResolver(IKurrentDbVersionProbe probe, ILogger logger) +{ + public async Task ResolveAsync(MonitoredService service, string? fallback, CancellationToken cancellationToken) + { + try + { + return KurrentDbVersionFormatter.Format(await probe.GetVersionAsync(service, cancellationToken)) ?? fallback; + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + logger.LogWarning(exception, "Unable to determine KurrentDB version while registering a service."); + return fallback; + } + } +} + public sealed class KurrentDbMonitorClient(IKurrentDbProbe probe) : IHealthEndpointClient { public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) @@ -71,3 +109,63 @@ public async Task ProbeAsync(MonitoredService service, CancellationToken cancell } } } + +public sealed class KurrentDbVersionProbe(IHttpClientFactory httpClientFactory) : IKurrentDbVersionProbe +{ + public async Task GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(service.ConnectionString)) + throw new InvalidOperationException("KurrentDB connection string is not configured."); + + var connectionUri = new Uri(service.ConnectionString, UriKind.Absolute); + if (connectionUri.Scheme.EndsWith("+discover", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("KurrentDB discovery connection strings cannot be used to query the HTTP info endpoint."); + + var useTls = !string.Equals(GetQueryValue(connectionUri, "tls"), "false", StringComparison.OrdinalIgnoreCase); + var infoUri = new UriBuilder(connectionUri) + { + Scheme = useTls ? Uri.UriSchemeHttps : Uri.UriSchemeHttp, + Port = connectionUri.IsDefaultPort ? (useTls ? 443 : 80) : connectionUri.Port, + Path = "/info", + Query = string.Empty, + UserName = string.Empty, + Password = string.Empty + }.Uri; + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(service.RequestTimeout); + using var request = new HttpRequestMessage(HttpMethod.Get, infoUri); + if (!string.IsNullOrEmpty(connectionUri.UserInfo)) + { + var separator = connectionUri.UserInfo.IndexOf(':'); + var userName = separator < 0 ? connectionUri.UserInfo : connectionUri.UserInfo[..separator]; + var password = separator < 0 ? string.Empty : connectionUri.UserInfo[(separator + 1)..]; + var credentials = $"{Uri.UnescapeDataString(userName)}:{Uri.UnescapeDataString(password)}"; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials))); + } + + using var response = await httpClientFactory.CreateClient().SendAsync(request, timeoutSource.Token); + response.EnsureSuccessStatusCode(); + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(timeoutSource.Token), cancellationToken: timeoutSource.Token); + + foreach (var propertyName in new[] { "esVersion", "version", "serverVersion" }) + { + if (document.RootElement.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) + return property.GetString(); + } + + return null; + } + + private static string? GetQueryValue(Uri uri, string name) + { + foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var values = part.Split('=', 2); + if (values.Length == 2 && string.Equals(Uri.UnescapeDataString(values[0]), name, StringComparison.OrdinalIgnoreCase)) + return Uri.UnescapeDataString(values[1]); + } + + return null; + } +}