Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<SqlServerRegistrationVersionResolver>.Instance);
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver);
var kurrentResolver = new KurrentDbRegistrationVersionResolver(new NoOpKurrentDbVersionProbe(), NullLogger<KurrentDbRegistrationVersionResolver>.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);

Expand All @@ -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<SqlServerRegistrationVersionResolver>.Instance);
var service = new InitializationService(repository, new InitializationOptions { RequireSqlServer = true, RequireKurrentDb = false }, sqlServerClient, kurrentDbClient, resolver);
var kurrentResolver = new KurrentDbRegistrationVersionResolver(new NoOpKurrentDbVersionProbe(), NullLogger<KurrentDbRegistrationVersionResolver>.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<SqlServerRegistrationVersionResolver>.Instance);
var kurrentResolver = new KurrentDbRegistrationVersionResolver(new KurrentVersionProbe("25.0.0.1673-build.1"), NullLogger<KurrentDbRegistrationVersionResolver>.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;
Expand All @@ -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<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
}

private sealed class KurrentVersionProbe(string version) : IKurrentDbVersionProbe
{
public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(version);
}

private sealed class InMemoryRepository : IHealthMonitoringRepository
{
public MonitoredService? SavedService { get; set; }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KurrentDbRegistrationVersionResolver>.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<KurrentDbRegistrationVersionResolver>.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<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(version);
}

private sealed class FailingVersionProbe : IKurrentDbVersionProbe
{
public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) =>
Task.FromException<string?>(new HttpRequestException("info endpoint unavailable"));
}

private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler
{
public HttpRequestMessage? Request { get; private set; }

protected override Task<HttpResponseMessage> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public static IServiceCollection AddHealthMonitoringServices(this IServiceCollec
services.AddScoped<IHealthEndpointClient, HealthEndpointClient>();
services.AddSingleton<IKurrentDbProbe, KurrentDbProbe>();
services.AddScoped<KurrentDbMonitorClient>();
services.AddSingleton<IKurrentDbVersionProbe, KurrentDbVersionProbe>();
services.AddSingleton<KurrentDbRegistrationVersionResolver>();
services.AddSingleton<ISqlServerProbe, SqlServerProbe>();
services.AddSingleton<SqlServerRegistrationVersionResolver>();
services.AddScoped<SqlServerMonitorClient>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InitializationStatus> GetStatusAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

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

public interface IKurrentDbVersionProbe
{
Task<string?> 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<KurrentDbRegistrationVersionResolver> logger)
{
public async Task<string?> 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<HealthEndpointResult> CheckAsync(MonitoredService service, CancellationToken cancellationToken)
Expand Down Expand Up @@ -71,3 +109,63 @@ public async Task ProbeAsync(MonitoredService service, CancellationToken cancell
}
}
}

public sealed class KurrentDbVersionProbe(IHttpClientFactory httpClientFactory) : IKurrentDbVersionProbe
{
public async Task<string?> 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor disabled certificate verification in the version probe

When a TLS KurrentDB connection relies on tlsVerifyCert=false in its connection string or IgnoreCertificateErrors to accept a self-signed/internal certificate, the KurrentDB client can connect successfully, but this default HttpClient still performs normal certificate validation. The /info request therefore fails and the resolver falls back, leaving a newly registered service without an automatically captured version despite its connection being valid; configure the HTTP handler consistently with the service's certificate-verification settings.

Useful? React with 👍 / 👎.

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;
}
}
Loading