Skip to content

Commit a7b127a

Browse files
Add KurrentDB version capture during registration
1 parent e1b3940 commit a7b127a

6 files changed

Lines changed: 239 additions & 6 deletions

File tree

‎HealthMonitoring/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ public async Task Initialization_registration_captures_the_sql_server_version()
5050
var sqlServerClient = new SqlServerMonitorClient(sqlServerProbe);
5151
var kurrentDbClient = new KurrentDbMonitorClient(new NoOpKurrentDbProbe());
5252
var resolver = new SqlServerRegistrationVersionResolver(sqlServerProbe, NullLogger<SqlServerRegistrationVersionResolver>.Instance);
53-
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver);
53+
var kurrentResolver = new KurrentDbRegistrationVersionResolver(new NoOpKurrentDbVersionProbe(), NullLogger<KurrentDbRegistrationVersionResolver>.Instance);
54+
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver, kurrentResolver);
5455

5556
await service.InitializeAsync(new InitializationRequest("Server=localhost;Database=Orders;", string.Empty) { TestConnections = false }, "Development", CancellationToken.None);
5657

@@ -73,13 +74,30 @@ public async Task Initialization_registration_preserves_an_existing_sql_server_v
7374
var sqlServerClient = new SqlServerMonitorClient(sqlServerProbe);
7475
var kurrentDbClient = new KurrentDbMonitorClient(new NoOpKurrentDbProbe());
7576
var resolver = new SqlServerRegistrationVersionResolver(sqlServerProbe, NullLogger<SqlServerRegistrationVersionResolver>.Instance);
76-
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver);
77+
var kurrentResolver = new KurrentDbRegistrationVersionResolver(new NoOpKurrentDbVersionProbe(), NullLogger<KurrentDbRegistrationVersionResolver>.Instance);
78+
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver, kurrentResolver);
7779

7880
await service.InitializeAsync(new InitializationRequest("Server=localhost;Database=Orders;", string.Empty) { TestConnections = false }, "Development", CancellationToken.None);
7981

8082
Assert.Equal("SQL Server 2019", repository.SavedService?.Version);
8183
}
8284

85+
[Fact]
86+
public async Task Initialization_registration_captures_the_kurrentdb_version()
87+
{
88+
var repository = new InMemoryRepository();
89+
var sqlServerProbe = new VersionProbe("16.0.1000.6");
90+
var sqlServerClient = new SqlServerMonitorClient(sqlServerProbe);
91+
var kurrentDbClient = new KurrentDbMonitorClient(new NoOpKurrentDbProbe());
92+
var sqlResolver = new SqlServerRegistrationVersionResolver(sqlServerProbe, NullLogger<SqlServerRegistrationVersionResolver>.Instance);
93+
var kurrentResolver = new KurrentDbRegistrationVersionResolver(new KurrentVersionProbe("25.0.0.1673-build.1"), NullLogger<KurrentDbRegistrationVersionResolver>.Instance);
94+
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = false, RequireKurrentDb = true }, sqlServerClient, kurrentDbClient, sqlResolver, kurrentResolver);
95+
96+
await service.InitializeAsync(new InitializationRequest(string.Empty, "esdb://admin:password@localhost:2113?tls=false") { TestConnections = false }, "Development", CancellationToken.None);
97+
98+
Assert.Equal("KurrentDB 25.0.0", repository.SavedService?.Version);
99+
}
100+
83101
private sealed class VersionProbe(string version) : ISqlServerProbe
84102
{
85103
public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask;
@@ -100,6 +118,16 @@ private sealed class NoOpKurrentDbProbe : IKurrentDbProbe
100118
public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask;
101119
}
102120

121+
private sealed class NoOpKurrentDbVersionProbe : IKurrentDbVersionProbe
122+
{
123+
public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
124+
}
125+
126+
private sealed class KurrentVersionProbe(string version) : IKurrentDbVersionProbe
127+
{
128+
public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(version);
129+
}
130+
103131
private sealed class InMemoryRepository : IHealthMonitoringRepository
104132
{
105133
public MonitoredService? SavedService { get; set; }
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using System.Net;
2+
using System.Net.Http;
3+
using HealthMonitoring.Domain;
4+
using HealthMonitoring.Monitoring;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
7+
namespace HealthMonitoring.Tests.Monitoring;
8+
9+
public sealed class KurrentDbVersionTests
10+
{
11+
[Theory]
12+
[InlineData("25.0.0.1673-build.1", "KurrentDB 25.0.0")]
13+
[InlineData("25.1", "KurrentDB 25.1")]
14+
public void Formats_kurrentdb_versions(string rawVersion, string expected)
15+
{
16+
Assert.Equal(expected, KurrentDbVersionFormatter.Format(rawVersion));
17+
}
18+
19+
[Theory]
20+
[InlineData(null)]
21+
[InlineData("")]
22+
[InlineData("not-a-version")]
23+
public void Returns_null_for_an_unusable_kurrentdb_version(string? rawVersion)
24+
{
25+
Assert.Null(KurrentDbVersionFormatter.Format(rawVersion));
26+
}
27+
28+
[Fact]
29+
public async Task Reads_and_formats_the_info_endpoint_version()
30+
{
31+
var handler = new StubHandler(HttpStatusCode.OK, "{\"esVersion\":\"25.0.0.1673-build.1\"}");
32+
var probe = new KurrentDbVersionProbe(new StubHttpClientFactory(handler));
33+
var service = new MonitoredService
34+
{
35+
ConnectionString = "esdb://admin:changeit@localhost:2113?tls=false",
36+
RequestTimeout = TimeSpan.FromSeconds(5)
37+
};
38+
39+
var version = await probe.GetVersionAsync(service, CancellationToken.None);
40+
41+
Assert.Equal("25.0.0.1673-build.1", version);
42+
Assert.Equal("http://localhost:2113/info", handler.Request!.RequestUri!.ToString());
43+
Assert.Equal("Basic YWRtaW46Y2hhbmdlaXQ=", handler.Request.Headers.Authorization!.ToString());
44+
}
45+
46+
[Fact]
47+
public async Task Registration_version_resolution_preserves_fallback_when_info_is_unavailable()
48+
{
49+
var service = new MonitoredService { MonitorType = MonitorType.KurrentDb };
50+
var probe = new FailingVersionProbe();
51+
var resolver = new KurrentDbRegistrationVersionResolver(probe, NullLogger<KurrentDbRegistrationVersionResolver>.Instance);
52+
53+
var version = await resolver.ResolveAsync(service, "KurrentDB 24.0.0", CancellationToken.None);
54+
55+
Assert.Equal("KurrentDB 24.0.0", version);
56+
}
57+
58+
[Fact]
59+
public async Task Registration_version_resolution_returns_a_human_readable_version()
60+
{
61+
var service = new MonitoredService { MonitorType = MonitorType.KurrentDb };
62+
var resolver = new KurrentDbRegistrationVersionResolver(new VersionProbe("25.0.0.1673-build.1"), NullLogger<KurrentDbRegistrationVersionResolver>.Instance);
63+
64+
var version = await resolver.ResolveAsync(service, "reported-by-caller", CancellationToken.None);
65+
66+
Assert.Equal("KurrentDB 25.0.0", version);
67+
}
68+
69+
private sealed class VersionProbe(string version) : IKurrentDbVersionProbe
70+
{
71+
public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(version);
72+
}
73+
74+
private sealed class FailingVersionProbe : IKurrentDbVersionProbe
75+
{
76+
public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) =>
77+
Task.FromException<string?>(new HttpRequestException("info endpoint unavailable"));
78+
}
79+
80+
private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler
81+
{
82+
public HttpRequestMessage? Request { get; private set; }
83+
84+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
85+
{
86+
Request = request;
87+
cancellationToken.ThrowIfCancellationRequested();
88+
return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) });
89+
}
90+
}
91+
92+
private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
93+
{
94+
public HttpClient CreateClient(string name)
95+
{
96+
_ = name;
97+
return new(handler, disposeHandler: false);
98+
}
99+
}
100+
}

‎HealthMonitoring/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi
2020
}
2121

2222
private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, string dashboardEnvironment) =>
23-
endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, ILoggerFactory loggerFactory, SqlServerRegistrationVersionResolver versionResolver, CancellationToken cancellationToken) =>
23+
endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, ILoggerFactory loggerFactory, SqlServerRegistrationVersionResolver sqlServerVersionResolver, KurrentDbRegistrationVersionResolver kurrentDbVersionResolver, CancellationToken cancellationToken) =>
2424
{
2525
loggerFactory.CreateLogger("HealthMonitoring.Api.ServiceRegistrationEndpoints").LogInformation(
2626
"POST /api/services/register body: {RequestBody}",
@@ -32,7 +32,9 @@ private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, str
3232
var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken);
3333
var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: true);
3434
if (request.MonitorType == MonitorType.SqlServer)
35-
service.Version = await versionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken);
35+
service.Version = await sqlServerVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken);
36+
else if (request.MonitorType == MonitorType.KurrentDb)
37+
service.Version = await kurrentDbVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken);
3638
await repository.UpsertServiceAsync(service, cancellationToken);
3739
var action = existing is null ? "created" : "acknowledged";
3840
return existing is null

‎HealthMonitoring/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public static IServiceCollection AddHealthMonitoringServices(this IServiceCollec
1717
services.AddScoped<IHealthEndpointClient, HealthEndpointClient>();
1818
services.AddSingleton<IKurrentDbProbe, KurrentDbProbe>();
1919
services.AddScoped<KurrentDbMonitorClient>();
20+
services.AddSingleton<IKurrentDbVersionProbe, KurrentDbVersionProbe>();
21+
services.AddSingleton<KurrentDbRegistrationVersionResolver>();
2022
services.AddSingleton<ISqlServerProbe, SqlServerProbe>();
2123
services.AddSingleton<SqlServerRegistrationVersionResolver>();
2224
services.AddScoped<SqlServerMonitorClient>();

‎HealthMonitoring/HealthMonitoring/Monitoring/InitializationService.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ public sealed class InitializationService(
5050
InitializationOptions options,
5151
SqlServerMonitorClient sqlServerClient,
5252
KurrentDbMonitorClient kurrentDbClient,
53-
SqlServerRegistrationVersionResolver versionResolver) : IInitializationService
53+
SqlServerRegistrationVersionResolver sqlServerVersionResolver,
54+
KurrentDbRegistrationVersionResolver kurrentDbVersionResolver) : IInitializationService
5455
{
5556
public async Task<InitializationStatus> GetStatusAsync(CancellationToken cancellationToken)
5657
{
@@ -153,7 +154,9 @@ private async Task SaveAsync(ServiceRegistrationRequest request, string dashboar
153154
var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken);
154155
var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: false);
155156
if (request.MonitorType == MonitorType.SqlServer)
156-
service.Version = await versionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken);
157+
service.Version = await sqlServerVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken);
158+
else if (request.MonitorType == MonitorType.KurrentDb)
159+
service.Version = await kurrentDbVersionResolver.ResolveAsync(service, request.Version ?? existing?.Version, cancellationToken);
157160
await repository.UpsertServiceAsync(service, cancellationToken);
158161
}
159162
}

‎HealthMonitoring/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
using System.Diagnostics;
2+
using System.Net.Http.Headers;
23
using System.Text.Json;
4+
using System.Text.RegularExpressions;
35
using EventStore.Client;
46
using KurrentDB.Client;
57
using HealthMonitoring.Domain;
8+
using Microsoft.Extensions.Logging;
69

710
namespace HealthMonitoring.Monitoring;
811

@@ -11,6 +14,41 @@ public interface IKurrentDbProbe
1114
Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken);
1215
}
1316

17+
public interface IKurrentDbVersionProbe
18+
{
19+
Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken);
20+
}
21+
22+
public static partial class KurrentDbVersionFormatter
23+
{
24+
public static string? Format(string? rawVersion)
25+
{
26+
if (string.IsNullOrWhiteSpace(rawVersion)) return null;
27+
28+
var match = VersionRegex().Match(rawVersion.Trim());
29+
return match.Success ? $"KurrentDB {match.Value}" : null;
30+
}
31+
32+
[GeneratedRegex(@"\b\d+(?:\.\d+){1,2}\b")]
33+
private static partial Regex VersionRegex();
34+
}
35+
36+
public sealed class KurrentDbRegistrationVersionResolver(IKurrentDbVersionProbe probe, ILogger<KurrentDbRegistrationVersionResolver> logger)
37+
{
38+
public async Task<string?> ResolveAsync(MonitoredService service, string? fallback, CancellationToken cancellationToken)
39+
{
40+
try
41+
{
42+
return KurrentDbVersionFormatter.Format(await probe.GetVersionAsync(service, cancellationToken)) ?? fallback;
43+
}
44+
catch (Exception exception) when (!cancellationToken.IsCancellationRequested)
45+
{
46+
logger.LogWarning(exception, "Unable to determine KurrentDB version while registering a service.");
47+
return fallback;
48+
}
49+
}
50+
}
51+
1452
public sealed class KurrentDbMonitorClient(IKurrentDbProbe probe) : IHealthEndpointClient
1553
{
1654
public async Task<HealthEndpointResult> CheckAsync(MonitoredService service, CancellationToken cancellationToken)
@@ -71,3 +109,63 @@ public async Task ProbeAsync(MonitoredService service, CancellationToken cancell
71109
}
72110
}
73111
}
112+
113+
public sealed class KurrentDbVersionProbe(IHttpClientFactory httpClientFactory) : IKurrentDbVersionProbe
114+
{
115+
public async Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken)
116+
{
117+
if (string.IsNullOrWhiteSpace(service.ConnectionString))
118+
throw new InvalidOperationException("KurrentDB connection string is not configured.");
119+
120+
var connectionUri = new Uri(service.ConnectionString, UriKind.Absolute);
121+
if (connectionUri.Scheme.EndsWith("+discover", StringComparison.OrdinalIgnoreCase))
122+
throw new InvalidOperationException("KurrentDB discovery connection strings cannot be used to query the HTTP info endpoint.");
123+
124+
var useTls = !string.Equals(GetQueryValue(connectionUri, "tls"), "false", StringComparison.OrdinalIgnoreCase);
125+
var infoUri = new UriBuilder(connectionUri)
126+
{
127+
Scheme = useTls ? Uri.UriSchemeHttps : Uri.UriSchemeHttp,
128+
Port = connectionUri.IsDefaultPort ? (useTls ? 443 : 80) : connectionUri.Port,
129+
Path = "/info",
130+
Query = string.Empty,
131+
UserName = string.Empty,
132+
Password = string.Empty
133+
}.Uri;
134+
135+
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
136+
timeoutSource.CancelAfter(service.RequestTimeout);
137+
using var request = new HttpRequestMessage(HttpMethod.Get, infoUri);
138+
if (!string.IsNullOrEmpty(connectionUri.UserInfo))
139+
{
140+
var separator = connectionUri.UserInfo.IndexOf(':');
141+
var userName = separator < 0 ? connectionUri.UserInfo : connectionUri.UserInfo[..separator];
142+
var password = separator < 0 ? string.Empty : connectionUri.UserInfo[(separator + 1)..];
143+
var credentials = $"{Uri.UnescapeDataString(userName)}:{Uri.UnescapeDataString(password)}";
144+
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(credentials)));
145+
}
146+
147+
using var response = await httpClientFactory.CreateClient().SendAsync(request, timeoutSource.Token);
148+
response.EnsureSuccessStatusCode();
149+
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(timeoutSource.Token), cancellationToken: timeoutSource.Token);
150+
151+
foreach (var propertyName in new[] { "esVersion", "version", "serverVersion" })
152+
{
153+
if (document.RootElement.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
154+
return property.GetString();
155+
}
156+
157+
return null;
158+
}
159+
160+
private static string? GetQueryValue(Uri uri, string name)
161+
{
162+
foreach (var part in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
163+
{
164+
var values = part.Split('=', 2);
165+
if (values.Length == 2 && string.Equals(Uri.UnescapeDataString(values[0]), name, StringComparison.OrdinalIgnoreCase))
166+
return Uri.UnescapeDataString(values[1]);
167+
}
168+
169+
return null;
170+
}
171+
}

0 commit comments

Comments
 (0)