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 @@ -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; }
Expand Down Expand Up @@ -42,13 +42,14 @@ public sealed class HealthMonitoringRegistrationClient(
public async Task<ServiceRegistrationResult> 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)),
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using HealthMonitoring.Domain;
using HealthMonitoring.Persistence;
using HealthMonitoring.Monitoring;
using Microsoft.Extensions.Logging.Abstractions;

namespace HealthMonitoring.Tests.Monitoring;

Expand Down Expand Up @@ -39,4 +41,83 @@ 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<SqlServerRegistrationVersionResolver>.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);
}

[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<SqlServerRegistrationVersionResolver>.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;

public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) => Task.FromResult<string?>(version);
}

private sealed class FailingVersionProbe : ISqlServerProbe
{
public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask;

public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) =>
Task.FromException<string?>(new InvalidOperationException("version query failed"));
}

private sealed class NoOpKurrentDbProbe : IKurrentDbProbe
{
public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask;
}

private sealed class InMemoryRepository : IHealthMonitoringRepository
{
public MonitoredService? SavedService { get; set; }

public Task<MonitoredService?> GetServiceAsync(string serviceId, CancellationToken cancellationToken) => Task.FromResult(SavedService);
public Task<MonitoredService> UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken) { SavedService = service; return Task.FromResult(service); }
public Task<IReadOnlyList<MonitoredService>> 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<IReadOnlyList<HealthObservation>> GetObservationsAsync(Guid serviceId, DateTimeOffset fromUtc, DateTimeOffset toUtc, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<ServiceStatusSnapshot?> GetSnapshotAsync(Guid serviceId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task UpsertSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<ServiceIncident?> 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<int> DeleteObservationsBeforeAsync(Guid serviceId, DateTimeOffset cutoffUtc, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<ServiceDependencyLink>> ListDependencyLinksAsync(Guid serviceId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<ServiceDependencyLink> UpsertDependencyLinkAsync(ServiceDependencyLink link, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task DeleteDependencyLinkAsync(Guid linkId, CancellationToken cancellationToken) => throw new NotSupportedException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ public Task ProbeAsync(MonitoredService service, CancellationToken cancellationT
cancellationToken.ThrowIfCancellationRequested();
return exception is null ? Task.CompletedTask : Task.FromException(exception);
}

public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) =>
Task.FromResult<string?>(null);
}
}
Original file line number Diff line number Diff line change
@@ -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<SqlServerRegistrationVersionResolver>.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<SqlServerRegistrationVersionResolver>.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<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) =>
Task.FromException<string?>(new InvalidOperationException("version query failed"));
}

private sealed class VersionProbe(string version) : ISqlServerProbe
{
public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => Task.CompletedTask;

public Task<string?> GetVersionAsync(MonitoredService service, CancellationToken cancellationToken) =>
Task.FromResult<string?>(version);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,11 +56,13 @@ private static IEnumerable<string> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -30,7 +30,7 @@ public sealed record ServiceSummary(
string Name,
string Environment,
MonitorType MonitorType,
string HealthUrl,
string? HealthUrl,
bool IsEnabled,
HealthStatus Status,
DateTimeOffset? LastObservedAtUtc,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using HealthMonitoring.Domain;
using HealthMonitoring.Monitoring;
using HealthMonitoring.Persistence;
using System.Text.Json;
using Microsoft.Extensions.Logging;
Expand All @@ -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}",
Expand All @@ -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 ?? existing?.Version, cancellationToken);
await repository.UpsertServiceAsync(service, cancellationToken);
var action = existing is null ? "created" : "acknowledged";
return existing is null
Expand All @@ -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);
Expand Down
Loading
Loading