From bb12ec511feda162949743f2ede443cbdbb4e73c Mon Sep 17 00:00:00 2001 From: John Simons Date: Wed, 26 Aug 2026 15:04:11 +1000 Subject: [PATCH 1/2] Report deployment and storage environment data in the usage report --- .../IEnvironmentDataProvider.cs | 2 +- ...AdditionalEnvironmentDataProvider_Tests.cs | 6 +- ...utCollector_SanitizedNameGrouping_Tests.cs | 9 +- .../ThroughputCollector.cs | 21 ++- .../When_reporting_the_environment.cs | 140 ++++++++++++++++ .../PostgreSqlDatabaseHostingProbe.cs | 95 +++++++++++ .../PostgreSqlPersistence.cs | 1 + .../SqlServerDatabaseHostingProbe.cs | 85 ++++++++++ .../SqlServerPersistence.cs | 1 + .../Abstractions/BasePersistence.cs | 2 + .../EFEnvironmentDataProvider.cs | 45 ++++++ .../Infrastructure/IDatabaseHostingProbe.cs | 28 ++++ .../RavenEnvironmentDataProvider.cs | 56 +++++++ .../RavenPersistence.cs | 2 + .../EFCore/EnvironmentDataTests.cs | 152 ++++++++++++++++++ .../DatabaseHostClassifier.cs | 53 ++++++ .../HostEnvironmentDataProviderTests.cs | 62 +++++++ .../HostApplicationBuilderExtensions.cs | 1 + .../HostEnvironmentDataProvider.cs | 66 ++++++++ ...rolErrorInstanceEnvironmentDataProvider.cs | 46 +++++- 20 files changed, 858 insertions(+), 15 deletions(-) create mode 100644 src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs create mode 100644 src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs create mode 100644 src/ServiceControl.Persistence/DatabaseHostClassifier.cs create mode 100644 src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs create mode 100644 src/ServiceControl/HostEnvironmentDataProvider.cs diff --git a/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs b/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs index e7bb79f9c7..6edecab74d 100644 --- a/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs +++ b/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs @@ -5,5 +5,5 @@ /// public interface IEnvironmentDataProvider { - IEnumerable<(string key, string value)> GetData(); + Task> GetData(CancellationToken cancellationToken = default); } diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs index 732a6f8122..11f9845478 100644 --- a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs +++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs @@ -35,9 +35,7 @@ public async Task Should_include_additional_environment_data_in_throughput_repor class TestAdditionalEnvironmentDataProvider : IEnvironmentDataProvider { - public IEnumerable<(string key, string value)> GetData() - { - yield return ("TestKey", "TestValue"); - } + public Task> GetData(CancellationToken cancellationToken = default) => + Task.FromResult>([("TestKey", "TestValue")]); } } diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs index 24cc1783b0..aa92362f64 100644 --- a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs +++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_SanitizedNameGrouping_Tests.cs @@ -5,6 +5,7 @@ using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.UnitTests.Infrastructure; @@ -35,7 +36,7 @@ await DataStore.CreateBuilder() .WithThroughput(data: [60]) .Build(); - var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse()); + var throughputCollector = new ThroughputCollector(NullLogger.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse()); // Act var summary = await throughputCollector.GetThroughputSummary(); @@ -61,7 +62,7 @@ await DataStore.CreateBuilder() .WithThroughput(data: [60]) .Build(); - var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse()); + var throughputCollector = new ThroughputCollector(NullLogger.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithLowerCaseSanitizedNameCleanse()); // Act var report = await throughputCollector.GenerateThroughputReport(null, null); @@ -88,7 +89,7 @@ await DataStore.CreateBuilder() .WithThroughput(data: [60]) .Build(); - var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse()); + var throughputCollector = new ThroughputCollector(NullLogger.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse()); // Act var summary = await throughputCollector.GetThroughputSummary(); @@ -114,7 +115,7 @@ await DataStore.CreateBuilder() .WithThroughput(data: [60]) .Build(); - var throughputCollector = new ThroughputCollector(DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse()); + var throughputCollector = new ThroughputCollector(NullLogger.Instance, DataStore, configuration.ThroughputSettings, configuration.AuditQuery, configuration.MonitoringService, [], new BrokerThroughputQuery_WithNoSanitizedNameCleanse()); // Act var report = await throughputCollector.GenerateThroughputReport(null, null); diff --git a/src/Particular.LicensingComponent/ThroughputCollector.cs b/src/Particular.LicensingComponent/ThroughputCollector.cs index 6d9567d8ee..2edb55d9c2 100644 --- a/src/Particular.LicensingComponent/ThroughputCollector.cs +++ b/src/Particular.LicensingComponent/ThroughputCollector.cs @@ -5,6 +5,7 @@ using System.Threading; using AuditThroughput; using Contracts; +using Microsoft.Extensions.Logging; using MonitoringThroughput; using Particular.LicensingComponent.Report.Utility; using Persistence; @@ -13,7 +14,7 @@ using Shared; using QueueThroughput = Report.QueueThroughput; -public class ThroughputCollector(ILicensingDataStore dataStore, ThroughputSettings throughputSettings, IAuditQuery auditQuery, MonitoringService monitoringService, IEnumerable environmentDataProviders, IBrokerThroughputQuery? throughputQuery = null) +public class ThroughputCollector(ILogger logger, ILicensingDataStore dataStore, ThroughputSettings throughputSettings, IAuditQuery auditQuery, MonitoringService monitoringService, IEnumerable environmentDataProviders, IBrokerThroughputQuery? throughputQuery = null) : IThroughputCollector { public async Task GetThroughputConnectionSettingsInformation(CancellationToken cancellationToken = default) @@ -188,7 +189,23 @@ public async Task GenerateThroughputReport(string spVersion, DateT foreach (var environmentDataProvider in environmentDataProviders) { - foreach (var (key, value) in environmentDataProvider.GetData()) + IEnumerable<(string key, string value)> environmentData; + + try + { + environmentData = await environmentDataProvider.GetData(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + logger.LogWarning(e, "Environment data provider {EnvironmentDataProvider} failed, its data is omitted from the report", environmentDataProvider.GetType().Name); + continue; + } + + foreach (var (key, value) in environmentData) { report.EnvironmentInformation.EnvironmentData[key] = value; } diff --git a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs new file mode 100644 index 0000000000..ad2d57dd55 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs @@ -0,0 +1,140 @@ +namespace ServiceControl.AcceptanceTests.Licensing +{ + using System; + using System.IO; + using System.IO.Compression; + using System.Linq; + using System.Text.Json; + using System.Threading.Tasks; + using AcceptanceTesting; + using AcceptanceTesting.EndpointTemplates; + using NServiceBus; + using NServiceBus.AcceptanceTesting; + using NServiceBus.Routing; + using NServiceBus.Transport; + using NUnit.Framework; + using Particular.LicensingComponent.Contracts; + using Particular.LicensingComponent.MonitoringThroughput; + using Particular.LicensingComponent.Shared; + + class When_reporting_the_environment : AcceptanceTest + { + [Test] + public async Task Should_describe_how_the_instance_is_deployed() + { + JsonDocument report = null; + + await Define() + .WithEndpoint() + .Do("Wait for the throughput data to be recorded", async _ => + { + var available = await this.TryGet( + "/api/licensing/report/available", state => state.ReportCanBeGenerated); + + return available.HasResult; + }) + .Do("Download the report", async _ => + { + var archive = await this.DownloadData("/api/licensing/report/file?spVersion=1.2.3"); + + report = ReadReport(archive); + + return true; + }) + .Done(_ => true) + .Run(); + + var data = report.RootElement + .GetProperty("ReportData") + .GetProperty("EnvironmentInformation") + .GetProperty("EnvironmentData") + .EnumerateObject() + .ToDictionary(entry => entry.Name, entry => entry.Value.GetString()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data.Keys, Is.SupersetOf(ExpectedKeys)); + + Assert.That(data["Host.Model"], Is.AnyOf("Container", "WindowsService", "Console")); + Assert.That(data["Persistence.Type"], Is.Not.Empty); + Assert.That(data["Persistence.BodyStorage.Type"], Is.Not.Empty); + Assert.That(data["Persistence.BodyStorage.Auth"], Is.AnyOf("ManagedIdentity", "SharedKeyOrSas", "IamRole", "StaticCredentials", "NotApplicable")); + Assert.That(data["Security.Authentication"], Is.AnyOf("Enabled", "Disabled")); + Assert.That(data["Features.EmailNotifications"], Is.AnyOf("Enabled", "Disabled", "NotConfigured")); + Assert.That(int.Parse(data["Retention.ErrorHours"]), Is.GreaterThan(0)); + + Assert.That(data.Values, Has.None.Contains(Environment.MachineName), + "The report must not carry anything that identifies the customer's machine"); + } + } + + static readonly string[] ExpectedKeys = + [ + "Host.Model", + "Host.Orchestrator", + "Host.OSPlatform", + "Host.OSVersion", + "Host.Architecture", + "Host.RuntimeVersion", + "Host.ProcessorCount", + "Host.AvailableMemoryGB", + "Persistence.Type", + "Persistence.Hosting", + "Persistence.ServerVersion", + "Persistence.FullTextSearch", + "Persistence.BodyStorage.Type", + "Persistence.BodyStorage.Auth", + "Security.Authentication", + "Security.RoleBasedAuthorization", + "Security.Https", + "Features.IntegratedServicePulse", + "Features.MessageEditing", + "Features.ExternalIntegrationsPublishing", + "Features.ForwardErrorMessages", + "Features.EmailNotifications", + "Retention.ErrorHours", + "Retention.AuditHours", + "Retention.EventsHours" + ]; + + static JsonDocument ReadReport(byte[] archive) + { + using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read); + using var entry = zip.Entries.Single().Open(); + + return JsonDocument.Parse(entry); + } + + const string SalesEndpoint = "Particular.Sales"; + + class Context : ScenarioContext, ISequenceContext + { + public int Step { get; set; } + } + + class MonitoringInstance : EndpointConfigurationBuilder + { + public MonitoringInstance() => + EndpointSetup(c => c.EnableFeature()); + + class ReportThroughput : DispatchRawMessages + { + protected override TransportOperations CreateMessage(Context context) + { + var recorded = new RecordEndpointThroughputData + { + StartDateTime = DateTime.UtcNow.AddDays(-1).AddHours(-1), + EndDateTime = DateTime.UtcNow.AddDays(-1), + EndpointThroughputData = [new EndpointThroughputData { Name = SalesEndpoint, Throughput = 42 }] + }; + + var body = JsonSerializer.SerializeToUtf8Bytes(recorded); + var message = new OutgoingMessage(Guid.NewGuid().ToString(), [], body); + + return new TransportOperations( + new TransportOperation(message, new UnicastAddressTag(ServiceControlSettings.ServiceControlThroughputDataQueue))); + } + } + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs new file mode 100644 index 0000000000..c922976f58 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs @@ -0,0 +1,95 @@ +namespace ServiceControl.Persistence.EFCore.PostgreSql; + +using System.Data.Common; +using System.Globalization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using ServiceControl.Persistence; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Infrastructure; + +class PostgreSqlDatabaseHostingProbe(PostgreSqlPersisterSettings settings, IServiceScopeFactory scopeFactory, ILogger logger) : IDatabaseHostingProbe +{ + public string StorageName => "PostgreSQL"; + + public async Task Probe(CancellationToken cancellationToken = default) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await using var command = dbContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = ProbeSql; + command.CommandTimeout = ProbeTimeoutSeconds; + + await dbContext.Database.OpenConnectionAsync(cancellationToken); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + + if (!await reader.ReadAsync(cancellationToken)) + { + return HostingFromConnectionString(); + } + + return new DatabaseHosting(HostingFromRoles(reader), MajorVersion(reader)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not ask PostgreSQL how it is hosted, falling back to the connection string"); + + return HostingFromConnectionString(); + } + } + + // PostgreSQL has no equivalent of SQL Server's EngineEdition, but every managed offering creates + // a distinctive administrative role that a self-hosted server does not have. + string HostingFromRoles(DbDataReader reader) + { + if (reader.GetBoolean(1)) + { + return "AzurePostgres"; + } + + if (reader.GetBoolean(2)) + { + return "AwsRds"; + } + + return reader.GetBoolean(3) ? "GoogleCloudSql" : HostingFromConnectionString().Hosting; + } + + static string MajorVersion(DbDataReader reader) => + reader.IsDBNull(0) ? DatabaseHostClassifier.Unknown : (reader.GetInt32(0) / 10000).ToString(CultureInfo.InvariantCulture); + + DatabaseHosting HostingFromConnectionString() + { + try + { + var host = new NpgsqlConnectionStringBuilder(settings.ConnectionString).Host; + + return new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown); + } + catch (Exception e) + { + logger.LogDebug(e, "Could not classify the configured PostgreSQL host"); + + return DatabaseHosting.Unavailable; + } + } + + const string ProbeSql = """ + SELECT current_setting('server_version_num')::int, + EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'azure_pg_admin'), + EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rds_superuser'), + EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cloudsqlsuperuser') + """; + + const int ProbeTimeoutSeconds = 5; +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs index f5d8c2ac6f..803151fce8 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlPersistence.cs @@ -17,6 +17,7 @@ public void AddPersistence(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public void AddInstaller(IServiceCollection services) diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs new file mode 100644 index 0000000000..4ac6d5a6b6 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs @@ -0,0 +1,85 @@ +namespace ServiceControl.Persistence.EFCore.SqlServer; + +using System.Data.Common; +using System.Globalization; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ServiceControl.Persistence; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Infrastructure; + +class SqlServerDatabaseHostingProbe(SqlServerPersisterSettings settings, IServiceScopeFactory scopeFactory, ILogger logger) : IDatabaseHostingProbe +{ + public string StorageName => "SQLServer"; + + public async Task Probe(CancellationToken cancellationToken = default) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await using var command = dbContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductMajorVersion')"; + command.CommandTimeout = ProbeTimeoutSeconds; + + await dbContext.Database.OpenConnectionAsync(cancellationToken); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + + if (!await reader.ReadAsync(cancellationToken)) + { + return HostingFromConnectionString(); + } + + return new DatabaseHosting(HostingFromEngineEdition(reader), MajorVersion(reader)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not ask SQL Server how it is hosted, falling back to the connection string"); + + return HostingFromConnectionString(); + } + } + + // EngineEdition is the authoritative answer: the server itself reports which Azure service it is, + // where a host name suffix is only a guess. Anything on-premises falls back to the suffix. + string HostingFromEngineEdition(DbDataReader reader) => + reader.IsDBNull(0) ? HostingFromConnectionString().Hosting : Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture) switch + { + AzureSqlDatabaseEdition => "AzureSql", + AzureSqlManagedInstanceEdition => "AzureSqlManagedInstance", + AzureSynapseEdition => "AzureSynapse", + _ => HostingFromConnectionString().Hosting + }; + + static string MajorVersion(DbDataReader reader) => + reader.IsDBNull(1) ? DatabaseHostClassifier.Unknown : Convert.ToString(reader.GetValue(1), CultureInfo.InvariantCulture) ?? DatabaseHostClassifier.Unknown; + + DatabaseHosting HostingFromConnectionString() + { + try + { + var host = new SqlConnectionStringBuilder(settings.ConnectionString).DataSource; + + return new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown); + } + catch (Exception e) + { + logger.LogDebug(e, "Could not classify the configured SQL Server host"); + + return DatabaseHosting.Unavailable; + } + } + + const int ProbeTimeoutSeconds = 5; + const int AzureSqlDatabaseEdition = 5; + const int AzureSqlManagedInstanceEdition = 8; + const int AzureSynapseEdition = 11; +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs index 2a1e1d6dbf..6c67df2065 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerPersistence.cs @@ -17,6 +17,7 @@ public void AddPersistence(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); } public void AddInstaller(IServiceCollection services) diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs index 632bb0cbbb..a1ced0ed8e 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; +using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.Persistence; using ServiceControl.CustomChecks; using ServiceControl.Operations.BodyStorage; @@ -28,6 +29,7 @@ protected static void RegisterDataStores(IServiceCollection services, EFPersiste services.AddUnitOfWorkFactory(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(p => p.GetRequiredService()); diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs new file mode 100644 index 0000000000..9533fcfe7d --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs @@ -0,0 +1,45 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using System.Globalization; +using Abstractions; +using Infrastructure; +using Particular.LicensingComponent.Contracts; + +class EFEnvironmentDataProvider(EFPersisterSettings settings, IDatabaseHostingProbe hostingProbe) : IEnvironmentDataProvider +{ + public async Task> GetData(CancellationToken cancellationToken = default) + { + var hosting = await hostingProbe.Probe(cancellationToken); + + return + [ + ("Persistence.Type", hostingProbe.StorageName), + ("Persistence.Hosting", hosting.Hosting), + ("Persistence.ServerVersion", hosting.ServerVersion), + ("Persistence.FullTextSearch", settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), + ("Persistence.BodyStorage.Type", BodyStorageType(settings.BodyStorage)), + ("Persistence.BodyStorage.Auth", BodyStorageAuth(settings.BodyStorage)), + ("Limits.MaxBodySizeToStore", settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)) + ]; + } + + static string BodyStorageType(BodyStorageSettings bodyStorage) => bodyStorage switch + { + FileSystemBodyStorageSettings => nameof(Abstractions.BodyStorageType.FileSystem), + AzureBlobBodyStorageSettings => nameof(Abstractions.BodyStorageType.AzureBlob), + S3BodyStorageSettings => nameof(Abstractions.BodyStorageType.S3), + _ => "Unknown" + }; + + static string BodyStorageAuth(BodyStorageSettings bodyStorage) => bodyStorage switch + { + AzureBlobBodyStorageSettings azureBlob => azureBlob.Authentication switch + { + AzureBlobManagedIdentityAuthentication => "ManagedIdentity", + AzureBlobSharedKeyAuthentication => "SharedKeyOrSas", + _ => "Unknown" + }, + S3BodyStorageSettings s3 => s3.Credentials is null ? "IamRole" : "StaticCredentials", + _ => "NotApplicable" + }; +} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs new file mode 100644 index 0000000000..3a9e69fd5b --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs @@ -0,0 +1,28 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using ServiceControl.Persistence; + +/// +/// How the database this instance stores its data in is hosted. Reported in usage telemetry, so +/// every value is a fixed classification and never carries a host name, database name or credential. +/// +public interface IDatabaseHostingProbe +{ + /// + /// The persistence name as it appears in the persistence manifest, for example SQLServer. + /// + string StorageName { get; } + + /// + /// Classifies the database host, asking the server itself where it can. Never throws; a server + /// that cannot be reached or does not answer is reported as unknown. + /// + Task Probe(CancellationToken cancellationToken = default); +} + +/// One of AzureSql, AzureSqlManagedInstance, AzureSynapse, AzurePostgres, AwsRds, GoogleCloudSql, SelfHosted or Unknown. +/// The engine major version, or Unknown. +public record DatabaseHosting(string Hosting, string ServerVersion) +{ + public static readonly DatabaseHosting Unavailable = new(DatabaseHostClassifier.Unknown, DatabaseHostClassifier.Unknown); +} diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs new file mode 100644 index 0000000000..bd812d1b89 --- /dev/null +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -0,0 +1,56 @@ +namespace ServiceControl.Persistence.RavenDB; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Particular.LicensingComponent.Contracts; +using Raven.Client.ServerWide.Operations; + +class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider, ILogger logger) : IEnvironmentDataProvider +{ + public async Task> GetData(CancellationToken cancellationToken = default) => + [ + ("Persistence.Type", "RavenDB"), + ("Persistence.RavenServer", settings.UseEmbeddedServer ? "Embedded" : "External"), + ("Persistence.Hosting", Hosting()), + ("Persistence.ServerVersion", await ServerVersion(cancellationToken)), + ("Persistence.FullTextSearch", settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), + ("Persistence.BodyStorage.Type", "RavenAttachments"), + ("Persistence.BodyStorage.Auth", "NotApplicable") + ]; + + string Hosting() + { + if (settings.UseEmbeddedServer) + { + return DatabaseHostClassifier.SelfHosted; + } + + return Uri.TryCreate(settings.ConnectionString, UriKind.Absolute, out var url) + ? DatabaseHostClassifier.Classify(url.Host) + : DatabaseHostClassifier.Unknown; + } + + async Task ServerVersion(CancellationToken cancellationToken) + { + try + { + var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken); + var buildNumber = await documentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); + + return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not read the RavenDB server version"); + + return DatabaseHostClassifier.Unknown; + } + } +} diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs index 8e82edd6a9..09863a002a 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenPersistence.cs @@ -7,6 +7,7 @@ namespace ServiceControl.Persistence.RavenDB; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; using Operations.BodyStorage; using Operations.BodyStorage.RavenAttachments; +using Particular.LicensingComponent.Contracts; using Persistence.MessageRedirects; using Persistence.Recoverability; using Recoverability; @@ -36,6 +37,7 @@ public void AddPersistence(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(p => p.GetRequiredService()); diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs new file mode 100644 index 0000000000..d882cf63fc --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs @@ -0,0 +1,152 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.Implementation; +using ServiceControl.Persistence.EFCore.Infrastructure; + +[TestFixture] +class DatabaseHostClassifierTests +{ + [TestCase("sc.database.windows.net", "AzureSql")] + [TestCase("tcp:sc.database.windows.net,1433", "AzureSql")] + [TestCase("SC.DATABASE.WINDOWS.NET", "AzureSql")] + [TestCase("sc.postgres.database.azure.com", "AzurePostgres")] + [TestCase("sc.abcdef.eu-west-1.rds.amazonaws.com", "AwsRds")] + [TestCase("/cloudsql/my-project:europe-west1:sc", "GoogleCloudSql")] + [TestCase("a.b.c.ravendb.cloud", "RavenCloud")] + [TestCase("localhost", "SelfHosted")] + [TestCase("sqlserver.internal.contoso.com", "SelfHosted")] + public void Should_classify_host(string host, string expected) => + Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo(expected)); + + [TestCase("")] + [TestCase(" ")] + [TestCase(null)] + public void Should_report_unknown_when_there_is_no_host(string host) => + Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo("Unknown")); +} + +[TestFixture] +class EFEnvironmentDataProviderTests +{ + [Test] + public async Task Should_report_managed_identity_for_azure_blob_service_uri() + { + var data = await GetData(new AzureBlobBodyStorageSettings + { + Authentication = new AzureBlobManagedIdentityAuthentication { ServiceUri = new Uri("https://account.blob.core.windows.net") } + }); + + Assert.Multiple(() => + { + Assert.That(data["Persistence.BodyStorage.Type"], Is.EqualTo("AzureBlob")); + Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("ManagedIdentity")); + }); + } + + [Test] + public async Task Should_report_shared_key_for_azure_blob_connection_string() + { + var data = await GetData(new AzureBlobBodyStorageSettings + { + Authentication = new AzureBlobSharedKeyAuthentication { ConnectionString = "UseDevelopmentStorage=true" } + }); + + Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("SharedKeyOrSas")); + } + + [Test] + public async Task Should_report_iam_role_when_s3_has_no_static_credentials() + { + var data = await GetData(new S3BodyStorageSettings { BucketName = "bodies" }); + + Assert.Multiple(() => + { + Assert.That(data["Persistence.BodyStorage.Type"], Is.EqualTo("S3")); + Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("IamRole")); + }); + } + + [Test] + public async Task Should_report_static_credentials_when_s3_has_an_access_key() + { + var data = await GetData(new S3BodyStorageSettings + { + BucketName = "bodies", + Credentials = new S3StaticCredentials { AccessKeyId = "key", SecretAccessKey = "secret" } + }); + + Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("StaticCredentials")); + } + + [Test] + public async Task Should_report_file_system_body_storage_as_not_applicable_for_auth() + { + var data = await GetData(new FileSystemBodyStorageSettings { StoragePath = "/var/lib/servicecontrol" }); + + Assert.Multiple(() => + { + Assert.That(data["Persistence.BodyStorage.Type"], Is.EqualTo("FileSystem")); + Assert.That(data["Persistence.BodyStorage.Auth"], Is.EqualTo("NotApplicable")); + }); + } + + [Test] + public async Task Should_not_report_any_body_storage_secret_or_location() + { + var data = await GetData(new S3BodyStorageSettings + { + BucketName = "customer-bucket-name", + Credentials = new S3StaticCredentials { AccessKeyId = "AKIAEXAMPLE", SecretAccessKey = "topsecret" } + }); + + foreach (var value in data.Values) + { + Assert.That(value, Does.Not.Contain("customer-bucket-name").And.Not.Contain("AKIAEXAMPLE").And.Not.Contain("topsecret")); + } + } + + [Test] + public async Task Should_fall_back_to_unknown_when_the_hosting_probe_fails() + { + var data = await GetData(new FileSystemBodyStorageSettings { StoragePath = "/var/lib/servicecontrol" }, new FailingHostingProbe()); + + Assert.Multiple(() => + { + Assert.That(data["Persistence.Hosting"], Is.EqualTo("Unknown")); + Assert.That(data["Persistence.ServerVersion"], Is.EqualTo("Unknown")); + }); + } + + static async Task> GetData(BodyStorageSettings bodyStorage, IDatabaseHostingProbe hostingProbe = null) + { + var settings = new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = bodyStorage }; + var provider = new EFEnvironmentDataProvider(settings, hostingProbe ?? new TestHostingProbe()); + + return (await provider.GetData()).ToDictionary(entry => entry.key, entry => entry.value); + } + + class TestPersisterSettings : EFPersisterSettings; + + class TestHostingProbe : IDatabaseHostingProbe + { + public string StorageName => "PostgreSQL"; + + public Task Probe(CancellationToken cancellationToken = default) => + Task.FromResult(new DatabaseHosting("SelfHosted", "17")); + } + + class FailingHostingProbe : IDatabaseHostingProbe + { + public string StorageName => "PostgreSQL"; + + public Task Probe(CancellationToken cancellationToken = default) => + Task.FromResult(DatabaseHosting.Unavailable); + } +} diff --git a/src/ServiceControl.Persistence/DatabaseHostClassifier.cs b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs new file mode 100644 index 0000000000..585e665a1b --- /dev/null +++ b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs @@ -0,0 +1,53 @@ +namespace ServiceControl.Persistence; + +using System; + +/// +/// Classifies a database host name into a managed-service category. Only the category is ever +/// reported; the host name itself never leaves the process. +/// +/// +/// This is the fallback for servers that cannot identify their own hosting. Where the engine can be +/// asked directly, as SQL Server can through EngineEdition, that answer wins over this one. +/// +public static class DatabaseHostClassifier +{ + public const string Unknown = "Unknown"; + public const string SelfHosted = "SelfHosted"; + + public static string Classify(string? host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return Unknown; + } + + var normalized = host.Trim().ToLowerInvariant(); + + foreach (var (suffix, hosting) in ManagedHostSuffixes) + { + if (normalized.EndsWith(suffix, StringComparison.Ordinal) || normalized.Contains($"{suffix},", StringComparison.Ordinal)) + { + return hosting; + } + } + + // Cloud SQL is reached either through a host name or through a unix socket directory named + // after the instance, so neither end of the value is a reliable place to look. + return normalized.Contains("cloudsql", StringComparison.Ordinal) ? "GoogleCloudSql" : SelfHosted; + } + + // Ordered longest-suffix-first so that the more specific Azure services win over the shared + // .database.azure.com suffix. + static readonly (string Suffix, string Hosting)[] ManagedHostSuffixes = + [ + (".postgres.database.azure.com", "AzurePostgres"), + (".mysql.database.azure.com", "AzureMySql"), + (".database.windows.net", "AzureSql"), + (".database.azure.com", "AzureSql"), + (".rds.amazonaws.com", "AwsRds"), + (".ravendb.cloud", "RavenCloud"), + (".development.run", "RavenCloud"), + (".gcp.cloud", "GoogleCloudSql") + ]; +} diff --git a/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs new file mode 100644 index 0000000000..91532cefc3 --- /dev/null +++ b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs @@ -0,0 +1,62 @@ +namespace ServiceControl.UnitTests.Licensing; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.ServiceControl; + +[TestFixture] +class HostEnvironmentDataProviderTests +{ + [SetUp] + public async Task SetUp() => data = (await new HostEnvironmentDataProvider().GetData()).ToDictionary(entry => entry.key, entry => entry.value); + + [Test] + public void Should_report_a_known_hosting_model() => + Assert.That(data["Host.Model"], Is.AnyOf("Container", "WindowsService", "Console")); + + [Test] + public void Should_report_whether_kubernetes_is_orchestrating() => + Assert.That(data["Host.Orchestrator"], Is.AnyOf("Kubernetes", "None")); + + [Test] + public void Should_report_a_known_os_platform() => + Assert.That(data["Host.OSPlatform"], Is.AnyOf("Windows", "Linux", "macOS", "Unknown")); + + [Test] + public void Should_report_os_version_as_major_and_minor_only() => + Assert.That(data["Host.OSVersion"], Does.Match(@"^\d+\.\d+$")); + + [Test] + public void Should_report_runtime_version_without_build_metadata() => + Assert.That(data["Host.RuntimeVersion"], Does.Match(@"^\d+\.\d+\.\d+$")); + + [Test] + public void Should_report_a_positive_processor_count() => + Assert.That(int.Parse(data["Host.ProcessorCount"]), Is.GreaterThan(0)); + + [Test] + public void Should_report_available_memory_in_whole_gigabytes() => + Assert.That(data["Host.AvailableMemoryGB"], Does.Match(@"^\d+$").Or.EqualTo("Unknown")); + + [Test] + public void Should_not_report_any_value_that_could_identify_the_machine() + { + var machineIdentifiers = new[] + { + Environment.MachineName, + Environment.UserName, + RuntimeInformation.OSDescription + }; + + foreach (var value in data.Values) + { + Assert.That(machineIdentifiers, Has.None.EqualTo(value), $"Value '{value}' identifies the machine"); + } + } + + Dictionary data; +} diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs index 6f132742bc..109e37b8a7 100644 --- a/src/ServiceControl/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs @@ -78,6 +78,7 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S services.AddSingleton(settings); services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); services.AddHttpLogging(options => { diff --git a/src/ServiceControl/HostEnvironmentDataProvider.cs b/src/ServiceControl/HostEnvironmentDataProvider.cs new file mode 100644 index 0000000000..b9e4ca604d --- /dev/null +++ b/src/ServiceControl/HostEnvironmentDataProvider.cs @@ -0,0 +1,66 @@ +namespace Particular.ServiceControl; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting.WindowsServices; +using Particular.LicensingComponent.Contracts; +using global::ServiceControl.Configuration; + +class HostEnvironmentDataProvider : IEnvironmentDataProvider +{ + public Task> GetData(CancellationToken cancellationToken = default) => + Task.FromResult>( + [ + ("Host.Model", HostModel()), + ("Host.Orchestrator", Environment.GetEnvironmentVariable(KubernetesServiceHostVariable) is not null ? "Kubernetes" : "None"), + ("Host.OSPlatform", OSPlatformName()), + ("Host.OSVersion", $"{Environment.OSVersion.Version.Major}.{Environment.OSVersion.Version.Minor}"), + ("Host.Architecture", RuntimeInformation.ProcessArchitecture.ToString()), + ("Host.RuntimeVersion", Environment.Version.ToString(3)), + ("Host.ProcessorCount", Environment.ProcessorCount.ToString(CultureInfo.InvariantCulture)), + ("Host.AvailableMemoryGB", AvailableMemoryGB()) + ]); + + static string HostModel() + { + if (AppEnvironment.RunningInContainer) + { + return "Container"; + } + + return WindowsServiceHelpers.IsWindowsService() ? "WindowsService" : "Console"; + } + + static string OSPlatformName() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Windows"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "Linux"; + } + + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "macOS" : "Unknown"; + } + + // TotalAvailableMemoryBytes honours the cgroup limit, so a container reports the memory it is + // limited to rather than the physical memory of the machine hosting it. + static string AvailableMemoryGB() + { + var totalAvailableMemoryBytes = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + + return totalAvailableMemoryBytes <= 0 + ? "Unknown" + : Math.Round(totalAvailableMemoryBytes / (double)BytesPerGigabyte, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + } + + const string KubernetesServiceHostVariable = "KUBERNETES_SERVICE_HOST"; + const long BytesPerGigabyte = 1024L * 1024 * 1024; +} diff --git a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs index b3828e8b43..3f72e58be6 100644 --- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs +++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs @@ -1,13 +1,51 @@ namespace Particular.ServiceControl; +using System; using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using global::ServiceControl.Notifications; +using global::ServiceControl.Persistence; using Particular.LicensingComponent.Contracts; using ServiceBus.Management.Infrastructure.Settings; -class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings) : IEnvironmentDataProvider +class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore) : IEnvironmentDataProvider { - public IEnumerable<(string key, string value)> GetData() + public async Task> GetData(CancellationToken cancellationToken = default) { - yield return ("Features.IntegratedServicePulse", settings.EnableIntegratedServicePulse ? "Enabled" : "Disabled"); + var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken); + + return + [ + ("Security.Authentication", Toggle(settings.OpenIdConnectSettings.Enabled)), + ("Security.RoleBasedAuthorization", Toggle(settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled)), + ("Security.Https", Toggle(settings.HttpsSettings.Enabled)), + ("Features.IntegratedServicePulse", Toggle(settings.EnableIntegratedServicePulse)), + ("Features.MessageEditing", Toggle(settings.AllowMessageEditing)), + ("Features.ExternalIntegrationsPublishing", Toggle(!settings.DisableExternalIntegrationsPublishing)), + ("Features.ForwardErrorMessages", Toggle(settings.ForwardErrorMessages)), + ("Features.EmailNotifications", EmailNotifications(notificationsSettings)), + ("Retention.ErrorHours", Hours(settings.ErrorRetentionPeriod)), + ("Retention.AuditHours", Hours(settings.AuditRetentionPeriod)), + ("Retention.EventsHours", Hours(settings.EventsRetentionPeriod)) + ]; + } + + static string Toggle(bool enabled) => enabled ? "Enabled" : "Disabled"; + + static string Hours(TimeSpan? retentionPeriod) => + retentionPeriod is { } period + ? Math.Round(period.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture) + : "NotSet"; + + static string EmailNotifications(NotificationsSettings notificationsSettings) + { + if (notificationsSettings.Email.Enabled) + { + return "Enabled"; + } + + return string.IsNullOrWhiteSpace(notificationsSettings.Email.SmtpServer) ? "NotConfigured" : "Disabled"; } -} \ No newline at end of file +} From 9ffb74f7f399d00f5efe9ab1fe36eeb62e107d6e Mon Sep 17 00:00:00 2001 From: John Simons Date: Fri, 28 Aug 2026 09:25:54 +1000 Subject: [PATCH 2/2] Make environment data collection more resilient and improve database hosting classification Introduces deferred reading for environment data so that a failure to fetch a specific datum doesn't discard the rest of a provider's output. Refines database hosting probes to better distinguish between confirmed self-hosted instances and unknown private hostnames, and adds a source field to track whether the classification was determined by a probe, configuration, or connection string. --- .../EnvironmentDatum.cs | 32 ++++++ .../IEnvironmentDataProvider.cs | 2 +- ...AdditionalEnvironmentDataProvider_Tests.cs | 5 +- ...tCollector_EnvironmentDataFailure_Tests.cs | 66 +++++++++++++ .../ThroughputCollector.cs | 26 +++-- .../When_reporting_the_environment.cs | 5 +- .../PostgreSqlDatabaseHostingProbe.cs | 53 +++++++--- .../SqlServerDatabaseHostingProbe.cs | 98 ++++++++++++++----- .../EFEnvironmentDataProvider.cs | 23 +++-- .../Infrastructure/IDatabaseHostingProbe.cs | 8 +- .../RavenEnvironmentDataProvider.cs | 51 ++++------ .../PostgreSqlDatabaseHostingProbeTests.cs | 32 ++++++ .../SqlServerDatabaseHostingProbeTests.cs | 47 +++++++++ .../EFCore/EnvironmentDataTests.cs | 24 ++++- .../DatabaseHostClassifier.cs | 22 ++++- .../DatabaseHostingSource.cs | 20 ++++ .../HostEnvironmentDataProviderTests.cs | 11 ++- .../HostEnvironmentDataProvider.cs | 28 +++--- ...rolErrorInstanceEnvironmentDataProvider.cs | 44 ++++----- 19 files changed, 454 insertions(+), 143 deletions(-) create mode 100644 src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs create mode 100644 src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs create mode 100644 src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs create mode 100644 src/ServiceControl.Persistence/DatabaseHostingSource.cs diff --git a/src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs b/src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs new file mode 100644 index 0000000000..ce1f5ac6ce --- /dev/null +++ b/src/Particular.LicensingComponent.Contracts/EnvironmentDatum.cs @@ -0,0 +1,32 @@ +namespace Particular.LicensingComponent.Contracts; + +/// +/// One key in a usage report's environment data, together with how to read its value. +/// +/// +/// The value is deferred rather than supplied so that reading it can be isolated. A datum that +/// cannot be read costs only its own key, never a sibling's, and providers therefore carry no +/// error handling of their own. +/// +public sealed record EnvironmentDatum(string Key, Func> ReadValue) +{ + /// + /// Reported in place of a value whose read threw. Deliberately not a word that could pass for a + /// state the instance is legitimately in: it always means the read failed, never that the thing + /// being described is absent or switched off. + /// + public const string ReadFailed = "ReadFailed"; + + /// + /// A value that is already at hand, such as one read from configuration. Still deferred, so + /// that nothing is evaluated while a provider is listing what it offers. + /// + public static EnvironmentDatum Value(string key, Func readValue) => + new(key, _ => new ValueTask(readValue())); + + /// + /// A value that has to be fetched, such as one read from storage or from the database itself. + /// + public static EnvironmentDatum Deferred(string key, Func> readValue) => + new(key, readValue); +} diff --git a/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs b/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs index 6edecab74d..1c4e760711 100644 --- a/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs +++ b/src/Particular.LicensingComponent.Contracts/IEnvironmentDataProvider.cs @@ -5,5 +5,5 @@ /// public interface IEnvironmentDataProvider { - Task> GetData(CancellationToken cancellationToken = default); + IEnumerable GetData(); } diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs index 11f9845478..9b19884b99 100644 --- a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs +++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_AdditionalEnvironmentDataProvider_Tests.cs @@ -1,7 +1,6 @@ namespace Particular.LicensingComponent.UnitTests; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; @@ -35,7 +34,7 @@ public async Task Should_include_additional_environment_data_in_throughput_repor class TestAdditionalEnvironmentDataProvider : IEnvironmentDataProvider { - public Task> GetData(CancellationToken cancellationToken = default) => - Task.FromResult>([("TestKey", "TestValue")]); + public IEnumerable GetData() => + [EnvironmentDatum.Value("TestKey", () => "TestValue")]; } } diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs new file mode 100644 index 0000000000..a8325ac7df --- /dev/null +++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_EnvironmentDataFailure_Tests.cs @@ -0,0 +1,66 @@ +namespace Particular.LicensingComponent.UnitTests; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Particular.LicensingComponent.Contracts; +using Particular.LicensingComponent.UnitTests.Infrastructure; + +[TestFixture] +class ThroughputCollector_EnvironmentDataFailure_Tests : ThroughputCollectorTestFixture +{ + public override Task Setup() + { + SetExtraDependencies = services => + { + services.AddSingleton(); + services.AddSingleton(); + }; + + return base.Setup(); + } + + [Test] + public async Task Should_keep_the_siblings_of_a_datum_that_cannot_be_read() + { + var report = await ThroughputCollector.GenerateThroughputReport(null, null); + + var environmentData = report.ReportData.EnvironmentInformation.EnvironmentData; + + Assert.Multiple(() => + { + Assert.That(environmentData["Readable.Before"], Is.EqualTo("value"), + "A datum listed before the failing one has already been read"); + Assert.That(environmentData["Readable.After"], Is.EqualTo("value"), + "A datum listed after the failing one must still be read, unlike an iterator that has faulted"); + Assert.That(environmentData["Unreadable"], Is.EqualTo("ReadFailed"), + "The failure is recorded rather than leaving the key absent"); + }); + } + + [Test] + public async Task Should_still_report_when_a_provider_cannot_list_its_data() + { + var report = await ThroughputCollector.GenerateThroughputReport(null, null); + + Assert.That(report.ReportData.EnvironmentInformation.EnvironmentData, Does.ContainKey("Readable.Before"), + "One broken provider must not cost another provider its data"); + } + + class ProviderWithOneUnreadableDatum : IEnvironmentDataProvider + { + public IEnumerable GetData() => + [ + EnvironmentDatum.Value("Readable.Before", () => "value"), + EnvironmentDatum.Deferred("Unreadable", _ => throw new InvalidOperationException("the storage read failed")), + EnvironmentDatum.Value("Readable.After", () => "value") + ]; + } + + class ProviderThatCannotListItsData : IEnvironmentDataProvider + { + public IEnumerable GetData() => throw new InvalidOperationException("the provider is broken"); + } +} diff --git a/src/Particular.LicensingComponent/ThroughputCollector.cs b/src/Particular.LicensingComponent/ThroughputCollector.cs index 2edb55d9c2..f36de2ffbb 100644 --- a/src/Particular.LicensingComponent/ThroughputCollector.cs +++ b/src/Particular.LicensingComponent/ThroughputCollector.cs @@ -189,25 +189,33 @@ public async Task GenerateThroughputReport(string spVersion, DateT foreach (var environmentDataProvider in environmentDataProviders) { - IEnumerable<(string key, string value)> environmentData; + EnvironmentDatum[] environmentData; try { - environmentData = await environmentDataProvider.GetData(cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; + environmentData = [.. environmentDataProvider.GetData()]; } catch (Exception e) { - logger.LogWarning(e, "Environment data provider {EnvironmentDataProvider} failed, its data is omitted from the report", environmentDataProvider.GetType().Name); + logger.LogWarning(e, "Environment data provider {EnvironmentDataProvider} could not list what it offers, so none of its data is in the report", environmentDataProvider.GetType().Name); continue; } - foreach (var (key, value) in environmentData) + foreach (var datum in environmentData) { - report.EnvironmentInformation.EnvironmentData[key] = value; + try + { + report.EnvironmentInformation.EnvironmentData[datum.Key] = await datum.ReadValue(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + logger.LogWarning(e, "Environment datum {EnvironmentDatum} could not be read", datum.Key); + report.EnvironmentInformation.EnvironmentData[datum.Key] = EnvironmentDatum.ReadFailed; + } } } diff --git a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs index ad2d57dd55..4d8d75b109 100644 --- a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs +++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs @@ -59,8 +59,9 @@ await Define() Assert.That(data["Persistence.Type"], Is.Not.Empty); Assert.That(data["Persistence.BodyStorage.Type"], Is.Not.Empty); Assert.That(data["Persistence.BodyStorage.Auth"], Is.AnyOf("ManagedIdentity", "SharedKeyOrSas", "IamRole", "StaticCredentials", "NotApplicable")); + Assert.That(data["Persistence.HostingSource"], Is.AnyOf("Probe", "Configuration", "ConnectionString", "None")); Assert.That(data["Security.Authentication"], Is.AnyOf("Enabled", "Disabled")); - Assert.That(data["Features.EmailNotifications"], Is.AnyOf("Enabled", "Disabled", "NotConfigured")); + Assert.That(data["Features.EmailNotifications"], Is.AnyOf("Enabled", "Disabled", "NotConfigured", "ReadFailed")); Assert.That(int.Parse(data["Retention.ErrorHours"]), Is.GreaterThan(0)); Assert.That(data.Values, Has.None.Contains(Environment.MachineName), @@ -81,6 +82,7 @@ await Define() "Persistence.Type", "Persistence.Hosting", "Persistence.ServerVersion", + "Persistence.HostingSource", "Persistence.FullTextSearch", "Persistence.BodyStorage.Type", "Persistence.BodyStorage.Auth", @@ -93,7 +95,6 @@ await Define() "Features.ForwardErrorMessages", "Features.EmailNotifications", "Retention.ErrorHours", - "Retention.AuditHours", "Retention.EventsHours" ]; diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs index c922976f58..e46dd85cae 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs @@ -34,7 +34,9 @@ public async Task Probe(CancellationToken cancellationToken = d return HostingFromConnectionString(); } - return new DatabaseHosting(HostingFromRoles(reader), MajorVersion(reader)); + var hosting = HostingFor(reader.GetBoolean(1), reader.GetBoolean(2), reader.GetBoolean(3), ConfiguredHost); + + return new DatabaseHosting(hosting, MajorVersion(reader), DatabaseHostingSource.Probe); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -48,21 +50,32 @@ public async Task Probe(CancellationToken cancellationToken = d } } - // PostgreSQL has no equivalent of SQL Server's EngineEdition, but every managed offering creates - // a distinctive administrative role that a self-hosted server does not have. - string HostingFromRoles(DbDataReader reader) + /// + /// PostgreSQL has no equivalent of SQL Server's EngineEdition, but every managed offering + /// creates a distinctive administrative role that a self-hosted server does not have. A server + /// that answered and has none of them is self-hosted, unless its host name names a managed + /// service that does not announce itself this way. + /// + internal static string HostingFor(bool azure, bool rds, bool cloudSql, string? host) { - if (reader.GetBoolean(1)) + if (azure) { return "AzurePostgres"; } - if (reader.GetBoolean(2)) + if (rds) { return "AwsRds"; } - return reader.GetBoolean(3) ? "GoogleCloudSql" : HostingFromConnectionString().Hosting; + if (cloudSql) + { + return "GoogleCloudSql"; + } + + var classified = DatabaseHostClassifier.Classify(host); + + return classified == DatabaseHostClassifier.Unknown ? DatabaseHostClassifier.SelfHosted : classified; } static string MajorVersion(DbDataReader reader) => @@ -70,17 +83,27 @@ static string MajorVersion(DbDataReader reader) => DatabaseHosting HostingFromConnectionString() { - try - { - var host = new NpgsqlConnectionStringBuilder(settings.ConnectionString).Host; + var host = ConfiguredHost; - return new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown); - } - catch (Exception e) + return host is null + ? DatabaseHosting.Unclassified + : new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown, DatabaseHostingSource.ConnectionString); + } + + string? ConfiguredHost + { + get { - logger.LogDebug(e, "Could not classify the configured PostgreSQL host"); + try + { + return new NpgsqlConnectionStringBuilder(settings.ConnectionString).Host; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not read the configured PostgreSQL host"); - return DatabaseHosting.Unavailable; + return null; + } } } diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs index 4ac6d5a6b6..7b686d72c7 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs @@ -22,19 +22,23 @@ public async Task Probe(CancellationToken cancellationToken = d var dbContext = scope.ServiceProvider.GetRequiredService(); await using var command = dbContext.Database.GetDbConnection().CreateCommand(); - command.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductMajorVersion')"; + // ProductVersion rather than ProductMajorVersion: the latter is documented as SQL Server + // only and comes back null on Azure SQL Database, Managed Instance and Synapse. + command.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion')"; command.CommandTimeout = ProbeTimeoutSeconds; await dbContext.Database.OpenConnectionAsync(cancellationToken); await using var reader = await command.ExecuteReaderAsync(cancellationToken); - if (!await reader.ReadAsync(cancellationToken)) + if (!await reader.ReadAsync(cancellationToken) || reader.IsDBNull(0)) { return HostingFromConnectionString(); } - return new DatabaseHosting(HostingFromEngineEdition(reader), MajorVersion(reader)); + var engineEdition = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture); + + return new DatabaseHosting(HostingFor(engineEdition, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -48,38 +52,82 @@ public async Task Probe(CancellationToken cancellationToken = d } } - // EngineEdition is the authoritative answer: the server itself reports which Azure service it is, - // where a host name suffix is only a guess. Anything on-premises falls back to the suffix. - string HostingFromEngineEdition(DbDataReader reader) => - reader.IsDBNull(0) ? HostingFromConnectionString().Hosting : Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture) switch - { - AzureSqlDatabaseEdition => "AzureSql", - AzureSqlManagedInstanceEdition => "AzureSqlManagedInstance", - AzureSynapseEdition => "AzureSynapse", - _ => HostingFromConnectionString().Hosting - }; + /// + /// Maps an EngineEdition to a hosting classification. The Azure editions identify their service + /// outright. The ordinary editions say only that this is a regular SQL Server, which still + /// leaves RDS and Cloud SQL in play, so the host name decides before self-hosted is concluded. + /// Synapse and Fabric are not mapped: ServiceControl does not run on them, and an edition we do + /// not recognise is not evidence of an ordinary SQL Server, so it falls to the host name. + /// + internal static string HostingFor(int engineEdition, string? host) => engineEdition switch + { + AzureSqlDatabase => "AzureSql", + AzureSqlManagedInstance => "AzureSqlManagedInstance", + AzureSqlEdge => "AzureSqlEdge", + PersonalOrDesktop or Standard or Enterprise or Express => ManagedOrSelfHosted(host), + _ => DatabaseHostClassifier.Classify(host) + }; - static string MajorVersion(DbDataReader reader) => - reader.IsDBNull(1) ? DatabaseHostClassifier.Unknown : Convert.ToString(reader.GetValue(1), CultureInfo.InvariantCulture) ?? DatabaseHostClassifier.Unknown; + static string ManagedOrSelfHosted(string? host) + { + var classified = DatabaseHostClassifier.Classify(host); - DatabaseHosting HostingFromConnectionString() + return classified == DatabaseHostClassifier.Unknown ? DatabaseHostClassifier.SelfHosted : classified; + } + + static string MajorVersion(DbDataReader reader) { - try + if (reader.IsDBNull(1)) { - var host = new SqlConnectionStringBuilder(settings.ConnectionString).DataSource; + return DatabaseHostClassifier.Unknown; + } - return new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown); + var productVersion = Convert.ToString(reader.GetValue(1), CultureInfo.InvariantCulture); + + if (string.IsNullOrWhiteSpace(productVersion)) + { + return DatabaseHostClassifier.Unknown; } - catch (Exception e) + + var major = productVersion.Split('.')[0]; + + return string.IsNullOrWhiteSpace(major) ? DatabaseHostClassifier.Unknown : major; + } + + DatabaseHosting HostingFromConnectionString() + { + var host = ConfiguredHost; + + return host is null + ? DatabaseHosting.Unclassified + : new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown, DatabaseHostingSource.ConnectionString); + } + + string? ConfiguredHost + { + get { - logger.LogDebug(e, "Could not classify the configured SQL Server host"); + try + { + return new SqlConnectionStringBuilder(settings.ConnectionString).DataSource; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not read the configured SQL Server host"); - return DatabaseHosting.Unavailable; + return null; + } } } const int ProbeTimeoutSeconds = 5; - const int AzureSqlDatabaseEdition = 5; - const int AzureSqlManagedInstanceEdition = 8; - const int AzureSynapseEdition = 11; + + // https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql + const int PersonalOrDesktop = 1; + const int Standard = 2; + const int Enterprise = 3; + const int Express = 4; + const int AzureSqlDatabase = 5; + const int AzureSqlManagedInstance = 8; + const int AzureSqlEdge = 9; } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs index 9533fcfe7d..1ddb54ae6c 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs @@ -4,22 +4,27 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Abstractions; using Infrastructure; using Particular.LicensingComponent.Contracts; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class EFEnvironmentDataProvider(EFPersisterSettings settings, IDatabaseHostingProbe hostingProbe) : IEnvironmentDataProvider { - public async Task> GetData(CancellationToken cancellationToken = default) + public IEnumerable GetData() { - var hosting = await hostingProbe.Probe(cancellationToken); + // The three hosting keys share one probe, so the database is asked once per report and they + // stand or fall together, which is right because they have a single cause. + Task? probe = null; + Task Hosting(CancellationToken cancellationToken) => probe ??= hostingProbe.Probe(cancellationToken); return [ - ("Persistence.Type", hostingProbe.StorageName), - ("Persistence.Hosting", hosting.Hosting), - ("Persistence.ServerVersion", hosting.ServerVersion), - ("Persistence.FullTextSearch", settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), - ("Persistence.BodyStorage.Type", BodyStorageType(settings.BodyStorage)), - ("Persistence.BodyStorage.Auth", BodyStorageAuth(settings.BodyStorage)), - ("Limits.MaxBodySizeToStore", settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)) + Value("Persistence.Type", () => hostingProbe.StorageName), + Deferred("Persistence.Hosting", async cancellationToken => (await Hosting(cancellationToken)).Hosting), + Deferred("Persistence.ServerVersion", async cancellationToken => (await Hosting(cancellationToken)).ServerVersion), + Deferred("Persistence.HostingSource", async cancellationToken => (await Hosting(cancellationToken)).Source), + Value("Persistence.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), + Value("Persistence.BodyStorage.Type", () => BodyStorageType(settings.BodyStorage)), + Value("Persistence.BodyStorage.Auth", () => BodyStorageAuth(settings.BodyStorage)), + Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)) ]; } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs index 3a9e69fd5b..73c3b12051 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs @@ -20,9 +20,11 @@ public interface IDatabaseHostingProbe Task Probe(CancellationToken cancellationToken = default); } -/// One of AzureSql, AzureSqlManagedInstance, AzureSynapse, AzurePostgres, AwsRds, GoogleCloudSql, SelfHosted or Unknown. +/// One of AzureSql, AzureSqlManagedInstance, AzureSqlEdge, AzurePostgres, AzureMySql, AwsRds, GoogleCloudSql, RavenCloud, SelfHosted or Unknown. /// The engine major version, or Unknown. -public record DatabaseHosting(string Hosting, string ServerVersion) +/// A value. +public record DatabaseHosting(string Hosting, string ServerVersion, string Source) { - public static readonly DatabaseHosting Unavailable = new(DatabaseHostClassifier.Unknown, DatabaseHostClassifier.Unknown); + /// Nothing was available to classify the host with. A determination, not a failure. + public static readonly DatabaseHosting Unclassified = new(DatabaseHostClassifier.Unknown, DatabaseHostClassifier.Unknown, DatabaseHostingSource.None); } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs index bd812d1b89..5070d0bebb 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -4,53 +4,42 @@ namespace ServiceControl.Persistence.RavenDB; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Particular.LicensingComponent.Contracts; using Raven.Client.ServerWide.Operations; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; -class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider, ILogger logger) : IEnvironmentDataProvider +class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider) : IEnvironmentDataProvider { - public async Task> GetData(CancellationToken cancellationToken = default) => + public IEnumerable GetData() => [ - ("Persistence.Type", "RavenDB"), - ("Persistence.RavenServer", settings.UseEmbeddedServer ? "Embedded" : "External"), - ("Persistence.Hosting", Hosting()), - ("Persistence.ServerVersion", await ServerVersion(cancellationToken)), - ("Persistence.FullTextSearch", settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), - ("Persistence.BodyStorage.Type", "RavenAttachments"), - ("Persistence.BodyStorage.Auth", "NotApplicable") + Value("Persistence.Type", () => "RavenDB"), + Value("Persistence.RavenServer", () => settings.UseEmbeddedServer ? "Embedded" : "External"), + Value("Persistence.Hosting", () => Hosting().Hosting), + Deferred("Persistence.ServerVersion", ServerVersion), + Value("Persistence.HostingSource", () => Hosting().Source), + Value("Persistence.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), + Value("Persistence.BodyStorage.Type", () => "RavenAttachments"), + Value("Persistence.BodyStorage.Auth", () => "NotApplicable") ]; - string Hosting() + (string Hosting, string Source) Hosting() { + // An embedded server runs in this process, so there is nothing to infer. if (settings.UseEmbeddedServer) { - return DatabaseHostClassifier.SelfHosted; + return (DatabaseHostClassifier.SelfHosted, DatabaseHostingSource.Configuration); } return Uri.TryCreate(settings.ConnectionString, UriKind.Absolute, out var url) - ? DatabaseHostClassifier.Classify(url.Host) - : DatabaseHostClassifier.Unknown; + ? (DatabaseHostClassifier.Classify(url.Host), DatabaseHostingSource.ConnectionString) + : (DatabaseHostClassifier.Unknown, DatabaseHostingSource.None); } - async Task ServerVersion(CancellationToken cancellationToken) + async ValueTask ServerVersion(CancellationToken cancellationToken) { - try - { - var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken); - var buildNumber = await documentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); + var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken); + var buildNumber = await documentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); - return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception e) - { - logger.LogDebug(e, "Could not read the RavenDB server version"); - - return DatabaseHostClassifier.Unknown; - } + return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown; } } diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs new file mode 100644 index 0000000000..3c424ac471 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlDatabaseHostingProbeTests.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Tests.PostgreSql; + +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.PostgreSql; + +[TestFixture] +class PostgreSqlDatabaseHostingProbeTests +{ + [Test] + public void Should_report_azure_when_the_azure_admin_role_exists() => + Assert.That(Hosting(azure: true), Is.EqualTo("AzurePostgres")); + + [Test] + public void Should_report_rds_when_the_rds_superuser_role_exists() => + Assert.That(Hosting(rds: true), Is.EqualTo("AwsRds")); + + [Test] + public void Should_report_cloud_sql_when_the_cloud_sql_superuser_role_exists() => + Assert.That(Hosting(cloudSql: true), Is.EqualTo("GoogleCloudSql")); + + [Test] + public void Should_report_self_hosted_when_no_managed_role_exists() => + Assert.That(PostgreSqlDatabaseHostingProbe.HostingFor(false, false, false, "db01.corp.example"), Is.EqualTo("SelfHosted"), + "The server answered and has no managed fingerprint, which is evidence rather than absence of it"); + + [Test] + public void Should_prefer_the_host_when_a_managed_service_creates_no_role() => + Assert.That(PostgreSqlDatabaseHostingProbe.HostingFor(false, false, false, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds")); + + static string Hosting(bool azure = false, bool rds = false, bool cloudSql = false) => + PostgreSqlDatabaseHostingProbe.HostingFor(azure, rds, cloudSql, "anything.example.com"); +} diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs new file mode 100644 index 0000000000..cd2c5830b1 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs @@ -0,0 +1,47 @@ +namespace ServiceControl.Persistence.Tests.SqlServer; + +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.SqlServer; + +// EngineEdition values per +// https://learn.microsoft.com/en-us/sql/t-sql/functions/serverproperty-transact-sql +[TestFixture] +class SqlServerDatabaseHostingProbeTests +{ + [TestCase(5, "AzureSql")] + [TestCase(8, "AzureSqlManagedInstance")] + [TestCase(9, "AzureSqlEdge")] + public void Should_take_the_azure_service_from_the_engine_edition(int engineEdition, string expected) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "anything.example.com"), Is.EqualTo(expected), + "The engine names its own service, so the host name must not get a say"); + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + public void Should_report_an_ordinary_edition_on_an_unrecognised_host_as_self_hosted(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "db01.corp.example"), Is.EqualTo("SelfHosted"), + "The server answered and is not an Azure service, which is evidence rather than absence of it"); + + [TestCase(2)] + [TestCase(3)] + public void Should_still_recognise_rds_running_an_ordinary_edition(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds"), + "RDS for SQL Server reports Standard or Enterprise, so the host name is the only thing that gives it away"); + + // Synapse (6, 11) and Fabric (12) are deliberately unmapped, so they take this path. + [TestCase(6)] + [TestCase(11)] + [TestCase(12)] + [TestCase(99)] + public void Should_fall_back_to_the_host_for_an_unmapped_edition(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "sc.database.windows.net"), Is.EqualTo("AzureSql")); + + [TestCase(6)] + [TestCase(11)] + [TestCase(12)] + [TestCase(99)] + public void Should_report_unknown_for_an_unmapped_edition_on_an_unrecognised_host(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, "db01.corp.example"), Is.EqualTo("Unknown"), + "An edition we do not map is not evidence of an ordinary SQL Server"); +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs index d882cf63fc..bf603ff58d 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs @@ -21,10 +21,19 @@ class DatabaseHostClassifierTests [TestCase("/cloudsql/my-project:europe-west1:sc", "GoogleCloudSql")] [TestCase("a.b.c.ravendb.cloud", "RavenCloud")] [TestCase("localhost", "SelfHosted")] - [TestCase("sqlserver.internal.contoso.com", "SelfHosted")] + [TestCase("127.0.0.1", "SelfHosted")] + [TestCase("(local)", "SelfHosted")] + [TestCase("(localdb)\\MSSQLLocalDB", "SelfHosted")] public void Should_classify_host(string host, string expected) => Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo(expected)); + [TestCase("sqlserver.internal.contoso.com")] + [TestCase("db01.corp.example")] + [TestCase("10.0.4.12")] + public void Should_not_call_a_private_name_self_hosted(string host) => + Assert.That(DatabaseHostClassifier.Classify(host), Is.EqualTo("Unknown"), + "A private DNS name in front of a managed database must not be counted as self-hosting"); + [TestCase("")] [TestCase(" ")] [TestCase(null)] @@ -121,6 +130,7 @@ public async Task Should_fall_back_to_unknown_when_the_hosting_probe_fails() { Assert.That(data["Persistence.Hosting"], Is.EqualTo("Unknown")); Assert.That(data["Persistence.ServerVersion"], Is.EqualTo("Unknown")); + Assert.That(data["Persistence.HostingSource"], Is.EqualTo("None")); }); } @@ -128,8 +138,14 @@ static async Task> GetData(BodyStorageSettings bodySt { var settings = new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = bodyStorage }; var provider = new EFEnvironmentDataProvider(settings, hostingProbe ?? new TestHostingProbe()); + var data = new Dictionary(); + + foreach (var datum in provider.GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } - return (await provider.GetData()).ToDictionary(entry => entry.key, entry => entry.value); + return data; } class TestPersisterSettings : EFPersisterSettings; @@ -139,7 +155,7 @@ class TestHostingProbe : IDatabaseHostingProbe public string StorageName => "PostgreSQL"; public Task Probe(CancellationToken cancellationToken = default) => - Task.FromResult(new DatabaseHosting("SelfHosted", "17")); + Task.FromResult(new DatabaseHosting("SelfHosted", "17", DatabaseHostingSource.Probe)); } class FailingHostingProbe : IDatabaseHostingProbe @@ -147,6 +163,6 @@ class FailingHostingProbe : IDatabaseHostingProbe public string StorageName => "PostgreSQL"; public Task Probe(CancellationToken cancellationToken = default) => - Task.FromResult(DatabaseHosting.Unavailable); + Task.FromResult(DatabaseHosting.Unclassified); } } diff --git a/src/ServiceControl.Persistence/DatabaseHostClassifier.cs b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs index 585e665a1b..560bd972f8 100644 --- a/src/ServiceControl.Persistence/DatabaseHostClassifier.cs +++ b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Persistence; using System; +using System.Linq; /// /// Classifies a database host name into a managed-service category. Only the category is ever @@ -15,6 +16,11 @@ public static class DatabaseHostClassifier public const string Unknown = "Unknown"; public const string SelfHosted = "SelfHosted"; + /// + /// A host name is only ever evidence of a managed service or of a server on this machine. + /// Anything else, a private DNS name above all, is unknown rather than self-hosted: a customer + /// who fronts a managed database with their own DNS must not be counted as self-hosting it. + /// public static string Classify(string? host) { if (string.IsNullOrWhiteSpace(host)) @@ -34,9 +40,23 @@ public static string Classify(string? host) // Cloud SQL is reached either through a host name or through a unix socket directory named // after the instance, so neither end of the value is a reliable place to look. - return normalized.Contains("cloudsql", StringComparison.Ordinal) ? "GoogleCloudSql" : SelfHosted; + if (normalized.Contains("cloudsql", StringComparison.Ordinal)) + { + return "GoogleCloudSql"; + } + + return IsThisMachine(normalized) ? SelfHosted : Unknown; } + static bool IsThisMachine(string normalized) => + LocalHosts.Contains(normalized) || + normalized.StartsWith("(localdb)", StringComparison.Ordinal) || + normalized.StartsWith("np:", StringComparison.Ordinal) || + normalized.StartsWith("lpc:", StringComparison.Ordinal) || + normalized.StartsWith('/'); // a unix socket directory, which only a server on this machine listens on + + static readonly string[] LocalHosts = ["localhost", "127.0.0.1", "::1", "[::1]", ".", "(local)"]; + // Ordered longest-suffix-first so that the more specific Azure services win over the shared // .database.azure.com suffix. static readonly (string Suffix, string Hosting)[] ManagedHostSuffixes = diff --git a/src/ServiceControl.Persistence/DatabaseHostingSource.cs b/src/ServiceControl.Persistence/DatabaseHostingSource.cs new file mode 100644 index 0000000000..7696c1eac5 --- /dev/null +++ b/src/ServiceControl.Persistence/DatabaseHostingSource.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Persistence; + +/// +/// Where a reported database hosting classification came from, so that a guess can be told apart +/// from an answer when the reports are analysed. +/// +public static class DatabaseHostingSource +{ + /// The server itself said so, and is therefore authoritative. + public const string Probe = "Probe"; + + /// The configuration says so outright, with nothing left to infer. + public const string Configuration = "Configuration"; + + /// Inferred from the configured host name because the server could not be asked. + public const string ConnectionString = "ConnectionString"; + + /// Nothing was available to classify. + public const string None = "None"; +} diff --git a/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs index 91532cefc3..42cecbfa0c 100644 --- a/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs +++ b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs @@ -4,6 +4,7 @@ namespace ServiceControl.UnitTests.Licensing; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Particular.ServiceControl; @@ -12,7 +13,15 @@ namespace ServiceControl.UnitTests.Licensing; class HostEnvironmentDataProviderTests { [SetUp] - public async Task SetUp() => data = (await new HostEnvironmentDataProvider().GetData()).ToDictionary(entry => entry.key, entry => entry.value); + public async Task SetUp() + { + data = []; + + foreach (var datum in new HostEnvironmentDataProvider().GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + } [Test] public void Should_report_a_known_hosting_model() => diff --git a/src/ServiceControl/HostEnvironmentDataProvider.cs b/src/ServiceControl/HostEnvironmentDataProvider.cs index b9e4ca604d..344cfff0b8 100644 --- a/src/ServiceControl/HostEnvironmentDataProvider.cs +++ b/src/ServiceControl/HostEnvironmentDataProvider.cs @@ -4,26 +4,26 @@ namespace Particular.ServiceControl; using System.Collections.Generic; using System.Globalization; using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Hosting.WindowsServices; using Particular.LicensingComponent.Contracts; using global::ServiceControl.Configuration; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class HostEnvironmentDataProvider : IEnvironmentDataProvider { - public Task> GetData(CancellationToken cancellationToken = default) => - Task.FromResult>( - [ - ("Host.Model", HostModel()), - ("Host.Orchestrator", Environment.GetEnvironmentVariable(KubernetesServiceHostVariable) is not null ? "Kubernetes" : "None"), - ("Host.OSPlatform", OSPlatformName()), - ("Host.OSVersion", $"{Environment.OSVersion.Version.Major}.{Environment.OSVersion.Version.Minor}"), - ("Host.Architecture", RuntimeInformation.ProcessArchitecture.ToString()), - ("Host.RuntimeVersion", Environment.Version.ToString(3)), - ("Host.ProcessorCount", Environment.ProcessorCount.ToString(CultureInfo.InvariantCulture)), - ("Host.AvailableMemoryGB", AvailableMemoryGB()) - ]); + public IEnumerable GetData() => + [ + Value("Host.Model", HostModel), + Value("Host.Orchestrator", Orchestrator), + Value("Host.OSPlatform", OSPlatformName), + Value("Host.OSVersion", () => $"{Environment.OSVersion.Version.Major}.{Environment.OSVersion.Version.Minor}"), + Value("Host.Architecture", () => RuntimeInformation.ProcessArchitecture.ToString()), + Value("Host.RuntimeVersion", () => Environment.Version.ToString(3)), + Value("Host.ProcessorCount", () => Environment.ProcessorCount.ToString(CultureInfo.InvariantCulture)), + Value("Host.AvailableMemoryGB", AvailableMemoryGB) + ]; + + static string Orchestrator() => Environment.GetEnvironmentVariable(KubernetesServiceHostVariable) is not null ? "Kubernetes" : "None"; static string HostModel() { diff --git a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs index 3f72e58be6..4e73a2a1f0 100644 --- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs +++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs @@ -5,42 +5,36 @@ namespace Particular.ServiceControl; using System.Globalization; using System.Threading; using System.Threading.Tasks; -using global::ServiceControl.Notifications; using global::ServiceControl.Persistence; using Particular.LicensingComponent.Contracts; using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore) : IEnvironmentDataProvider { - public async Task> GetData(CancellationToken cancellationToken = default) - { - var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken); - - return - [ - ("Security.Authentication", Toggle(settings.OpenIdConnectSettings.Enabled)), - ("Security.RoleBasedAuthorization", Toggle(settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled)), - ("Security.Https", Toggle(settings.HttpsSettings.Enabled)), - ("Features.IntegratedServicePulse", Toggle(settings.EnableIntegratedServicePulse)), - ("Features.MessageEditing", Toggle(settings.AllowMessageEditing)), - ("Features.ExternalIntegrationsPublishing", Toggle(!settings.DisableExternalIntegrationsPublishing)), - ("Features.ForwardErrorMessages", Toggle(settings.ForwardErrorMessages)), - ("Features.EmailNotifications", EmailNotifications(notificationsSettings)), - ("Retention.ErrorHours", Hours(settings.ErrorRetentionPeriod)), - ("Retention.AuditHours", Hours(settings.AuditRetentionPeriod)), - ("Retention.EventsHours", Hours(settings.EventsRetentionPeriod)) - ]; - } + public IEnumerable GetData() => + [ + Value("Security.Authentication", () => Toggle(settings.OpenIdConnectSettings.Enabled)), + Value("Security.RoleBasedAuthorization", () => Toggle(settings.OpenIdConnectSettings.RoleBasedAuthorizationEnabled)), + Value("Security.Https", () => Toggle(settings.HttpsSettings.Enabled)), + Value("Features.IntegratedServicePulse", () => Toggle(settings.EnableIntegratedServicePulse)), + Value("Features.MessageEditing", () => Toggle(settings.AllowMessageEditing)), + Value("Features.ExternalIntegrationsPublishing", () => Toggle(!settings.DisableExternalIntegrationsPublishing)), + Value("Features.ForwardErrorMessages", () => Toggle(settings.ForwardErrorMessages)), + Deferred("Features.EmailNotifications", EmailNotifications), + Value("Retention.ErrorHours", () => Hours(settings.ErrorRetentionPeriod)), + Value("Retention.EventsHours", () => Hours(settings.EventsRetentionPeriod)) + ]; static string Toggle(bool enabled) => enabled ? "Enabled" : "Disabled"; - static string Hours(TimeSpan? retentionPeriod) => - retentionPeriod is { } period - ? Math.Round(period.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture) - : "NotSet"; + static string Hours(TimeSpan retentionPeriod) => + Math.Round(retentionPeriod.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); - static string EmailNotifications(NotificationsSettings notificationsSettings) + async ValueTask EmailNotifications(CancellationToken cancellationToken) { + var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken); + if (notificationsSettings.Email.Enabled) { return "Enabled";