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 e7bb79f9c7..1c4e760711 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(); + 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 732a6f8122..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,9 +34,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 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.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..f36de2ffbb 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,9 +189,33 @@ public async Task GenerateThroughputReport(string spVersion, DateT foreach (var environmentDataProvider in environmentDataProviders) { - foreach (var (key, value) in environmentDataProvider.GetData()) + EnvironmentDatum[] environmentData; + + try + { + environmentData = [.. environmentDataProvider.GetData()]; + } + catch (Exception e) + { + 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 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 new file mode 100644 index 0000000000..4d8d75b109 --- /dev/null +++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs @@ -0,0 +1,141 @@ +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["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", "ReadFailed")); + 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.HostingSource", + "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.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..e46dd85cae --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlDatabaseHostingProbe.cs @@ -0,0 +1,118 @@ +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(); + } + + 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) + { + 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. 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 (azure) + { + return "AzurePostgres"; + } + + if (rds) + { + return "AwsRds"; + } + + if (cloudSql) + { + return "GoogleCloudSql"; + } + + var classified = DatabaseHostClassifier.Classify(host); + + return classified == DatabaseHostClassifier.Unknown ? DatabaseHostClassifier.SelfHosted : classified; + } + + static string MajorVersion(DbDataReader reader) => + reader.IsDBNull(0) ? DatabaseHostClassifier.Unknown : (reader.GetInt32(0) / 10000).ToString(CultureInfo.InvariantCulture); + + DatabaseHosting HostingFromConnectionString() + { + var host = ConfiguredHost; + + return host is null + ? DatabaseHosting.Unclassified + : new DatabaseHosting(DatabaseHostClassifier.Classify(host), DatabaseHostClassifier.Unknown, DatabaseHostingSource.ConnectionString); + } + + string? ConfiguredHost + { + get + { + try + { + return new NpgsqlConnectionStringBuilder(settings.ConnectionString).Host; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not read the configured PostgreSQL host"); + + return null; + } + } + } + + 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..7b686d72c7 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs @@ -0,0 +1,133 @@ +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(); + // 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) || reader.IsDBNull(0)) + { + return HostingFromConnectionString(); + } + + var engineEdition = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture); + + return new DatabaseHosting(HostingFor(engineEdition, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe); + } + 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(); + } + } + + /// + /// 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 ManagedOrSelfHosted(string? host) + { + var classified = DatabaseHostClassifier.Classify(host); + + return classified == DatabaseHostClassifier.Unknown ? DatabaseHostClassifier.SelfHosted : classified; + } + + static string MajorVersion(DbDataReader reader) + { + if (reader.IsDBNull(1)) + { + return DatabaseHostClassifier.Unknown; + } + + var productVersion = Convert.ToString(reader.GetValue(1), CultureInfo.InvariantCulture); + + if (string.IsNullOrWhiteSpace(productVersion)) + { + return DatabaseHostClassifier.Unknown; + } + + 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 + { + try + { + return new SqlConnectionStringBuilder(settings.ConnectionString).DataSource; + } + catch (Exception e) + { + logger.LogDebug(e, "Could not read the configured SQL Server host"); + + return null; + } + } + } + + const int ProbeTimeoutSeconds = 5; + + // 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.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..1ddb54ae6c --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs @@ -0,0 +1,50 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using System.Globalization; +using Abstractions; +using Infrastructure; +using Particular.LicensingComponent.Contracts; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class EFEnvironmentDataProvider(EFPersisterSettings settings, IDatabaseHostingProbe hostingProbe) : IEnvironmentDataProvider +{ + public IEnumerable GetData() + { + // 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 + [ + 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)) + ]; + } + + 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..73c3b12051 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/IDatabaseHostingProbe.cs @@ -0,0 +1,30 @@ +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, AzureSqlEdge, AzurePostgres, AzureMySql, AwsRds, GoogleCloudSql, RavenCloud, SelfHosted or Unknown. +/// The engine major version, or Unknown. +/// A value. +public record DatabaseHosting(string Hosting, string ServerVersion, string Source) +{ + /// 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 new file mode 100644 index 0000000000..5070d0bebb --- /dev/null +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -0,0 +1,45 @@ +namespace ServiceControl.Persistence.RavenDB; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Particular.LicensingComponent.Contracts; +using Raven.Client.ServerWide.Operations; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + 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 Source) Hosting() + { + // An embedded server runs in this process, so there is nothing to infer. + if (settings.UseEmbeddedServer) + { + return (DatabaseHostClassifier.SelfHosted, DatabaseHostingSource.Configuration); + } + + return Uri.TryCreate(settings.ConnectionString, UriKind.Absolute, out var url) + ? (DatabaseHostClassifier.Classify(url.Host), DatabaseHostingSource.ConnectionString) + : (DatabaseHostClassifier.Unknown, DatabaseHostingSource.None); + } + + async ValueTask ServerVersion(CancellationToken cancellationToken) + { + var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken); + var buildNumber = await documentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); + + return buildNumber.ProductVersion ?? 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.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 new file mode 100644 index 0000000000..bf603ff58d --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs @@ -0,0 +1,168 @@ +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("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)] + 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")); + Assert.That(data["Persistence.HostingSource"], Is.EqualTo("None")); + }); + } + + 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()); + var data = new Dictionary(); + + foreach (var datum in provider.GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + class TestPersisterSettings : EFPersisterSettings; + + class TestHostingProbe : IDatabaseHostingProbe + { + public string StorageName => "PostgreSQL"; + + public Task Probe(CancellationToken cancellationToken = default) => + Task.FromResult(new DatabaseHosting("SelfHosted", "17", DatabaseHostingSource.Probe)); + } + + class FailingHostingProbe : IDatabaseHostingProbe + { + public string StorageName => "PostgreSQL"; + + public Task Probe(CancellationToken cancellationToken = default) => + Task.FromResult(DatabaseHosting.Unclassified); + } +} diff --git a/src/ServiceControl.Persistence/DatabaseHostClassifier.cs b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs new file mode 100644 index 0000000000..560bd972f8 --- /dev/null +++ b/src/ServiceControl.Persistence/DatabaseHostClassifier.cs @@ -0,0 +1,73 @@ +namespace ServiceControl.Persistence; + +using System; +using System.Linq; + +/// +/// 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"; + + /// + /// 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)) + { + 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. + 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 = + [ + (".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.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 new file mode 100644 index 0000000000..42cecbfa0c --- /dev/null +++ b/src/ServiceControl.UnitTests/Licensing/HostEnvironmentDataProviderTests.cs @@ -0,0 +1,71 @@ +namespace ServiceControl.UnitTests.Licensing; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.ServiceControl; + +[TestFixture] +class HostEnvironmentDataProviderTests +{ + [SetUp] + 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() => + 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..344cfff0b8 --- /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 Microsoft.Extensions.Hosting.WindowsServices; +using Particular.LicensingComponent.Contracts; +using global::ServiceControl.Configuration; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class HostEnvironmentDataProvider : IEnvironmentDataProvider +{ + 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() + { + 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..4e73a2a1f0 100644 --- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs +++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs @@ -1,13 +1,45 @@ namespace Particular.ServiceControl; +using System; using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using global::ServiceControl.Persistence; using Particular.LicensingComponent.Contracts; using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; -class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings) : IEnvironmentDataProvider +class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore) : IEnvironmentDataProvider { - public IEnumerable<(string key, string value)> GetData() + 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) => + Math.Round(retentionPeriod.TotalHours, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + + async ValueTask EmailNotifications(CancellationToken cancellationToken) { - yield return ("Features.IntegratedServicePulse", settings.EnableIntegratedServicePulse ? "Enabled" : "Disabled"); + var notificationsSettings = await notificationsDataStore.LoadSettings(cancellationToken); + + if (notificationsSettings.Email.Enabled) + { + return "Enabled"; + } + + return string.IsNullOrWhiteSpace(notificationsSettings.Email.SmtpServer) ? "NotConfigured" : "Disabled"; } -} \ No newline at end of file +}