diff --git a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs index f3b4403d00..d5f23ea1e8 100644 --- a/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs +++ b/src/ServiceControl.AcceptanceTests/Licensing/When_reporting_the_environment.cs @@ -16,6 +16,11 @@ namespace ServiceControl.AcceptanceTests.Licensing using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.MonitoringThroughput; using Particular.LicensingComponent.Shared; + using Recoverability.MessageRedirects; + using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Monitoring; + using ServiceControl.Persistence; + using Conventions = NServiceBus.AcceptanceTesting.Customization.Conventions; class When_reporting_the_environment : AcceptanceTest { @@ -58,9 +63,7 @@ await Define() Assert.That(data["Host.Model"], Is.AnyOf("Container", "WindowsService", "Console")); Assert.That(data["Storage.Type"], Is.Not.Empty); Assert.That(data["Storage.BodyStorage.Type"], Is.Not.Empty); - Assert.That(data["Storage.BodyStorage.Auth"], Is.AnyOf("ManagedIdentity", "SharedKeyOrSas", "IamRole", "StaticCredentials", "NotApplicable")); Assert.That(data["Storage.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)); @@ -85,19 +88,125 @@ await Define() "Storage.HostingSource", "Storage.FullTextSearch", "Storage.BodyStorage.Type", - "Storage.BodyStorage.Auth", - "Security.Authentication", - "Security.RoleBasedAuthorization", - "Security.Https", + "Storage.QueryTimeoutSeconds", + "Storage.FreeSpaceThresholdPercent", + "Transport.Type", "Features.IntegratedServicePulse", "Features.MessageEditing", "Features.ExternalIntegrationsPublishing", "Features.ForwardErrorMessages", + "Features.ErrorIngestion", + "Features.ConfigurationValidation", "Features.EmailNotifications", + "ServicePulse.MonitoringUrl", + "ServicePulse.DefaultRoute", + "ServicePulse.ShowPendingRetry", + "Host.VirtualDirectory", + "Host.ShutdownTimeoutSeconds", + "Logging.Providers", + "Logging.Level", + "Telemetry.OtlpMetrics", + "Limits.ExternalIntegrationsBatchSize", + "Ingestion.Error.MaxConcurrency", + "Ingestion.Error.BatchSize", + "Ingestion.Error.MaxParallelWriters", + "Ingestion.Error.BatchTimeoutMs", + "Ingestion.Error.RestartAfterFailureSeconds", + "Heartbeats.TrackInstancesDefault", + "Heartbeats.TrackInstancesOverrides", + "Heartbeats.KnownInstances", + "Heartbeats.MonitoredInstances", + "Heartbeats.GracePeriodSeconds", + "Recoverability.Redirects", + "Recoverability.RetryHistoryDepth", + "Licensing.ReportMasks", "Retention.ErrorHours", "Retention.EventsHours" ]; + [Test] + public async Task Should_count_the_choices_made_in_servicepulse_without_revealing_them() + { + JsonDocument report = null; + + await Define() + .WithEndpoint() + .WithEndpoint() + .Do("Wait for the first heartbeat of the endpoint that scales out", async _ => + { + var endpoints = await this.TryGetMany("/api/endpoints", endpoint => endpoint.Name == ScalingOutEndpoint && endpoint.Monitored); + + return endpoints.HasResult; + }) + .Do("Stop tracking instances by default", async _ => + { + await this.Patch("/api/endpointssettings", new { track_instances = false }); + + var settings = await this.TryGetMany("/api/endpointssettings", + setting => setting.Name == string.Empty && !setting.TrackInstances); + + return settings.HasResult; + }) + .Do("Keep tracking the endpoint that scales out", async _ => + { + await this.Patch($"/api/endpointssettings/{ScalingOutEndpoint}", new { track_instances = true }); + + var settings = await this.TryGetMany("/api/endpointssettings", + setting => setting.Name == ScalingOutEndpoint && setting.TrackInstances); + + return settings.HasResult; + }) + .Do("Redirect a retired queue", async _ => + { + await this.Post("/api/redirects", new RedirectRequest { fromphysicaladdress = RetiredQueue, tophysicaladdress = ReplacementQueue }); + + var redirects = await this.TryGetMany("/api/redirects"); + + return redirects.HasResult; + }) + .Do("Mask the customer's name in the report", async _ => + { + await this.Post("/api/licensing/settings/masks/update", new[] { MaskedWord }); + + return true; + }) + .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["Heartbeats.TrackInstancesDefault"], Is.EqualTo("Disabled")); + Assert.That(data["Heartbeats.TrackInstancesOverrides"], Is.EqualTo("1")); + Assert.That(int.Parse(data["Heartbeats.MonitoredInstances"]), Is.GreaterThanOrEqualTo(1)); + Assert.That(int.Parse(data["Heartbeats.KnownInstances"]), Is.GreaterThanOrEqualTo(int.Parse(data["Heartbeats.MonitoredInstances"]))); + Assert.That(data["Recoverability.Redirects"], Is.EqualTo("1")); + Assert.That(data["Licensing.ReportMasks"], Is.EqualTo("1")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain(RetiredQueue).And.Not.Contain(ReplacementQueue).And.Not.Contain(MaskedWord).And.Not.Contain(ScalingOutEndpoint)); + } + } + static JsonDocument ReadReport(byte[] archive) { using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read); @@ -107,12 +216,23 @@ static JsonDocument ReadReport(byte[] archive) } const string SalesEndpoint = "Particular.Sales"; + const string RetiredQueue = "Contoso.Billing.Legacy@machine1"; + const string ReplacementQueue = "Contoso.Billing@machine2"; + const string MaskedWord = "Contoso"; class Context : ScenarioContext, ISequenceContext { public int Step { get; set; } } + static string ScalingOutEndpoint => Conventions.EndpointNamingConvention(typeof(ScalingOut)); + + class ScalingOut : EndpointConfigurationBuilder + { + public ScalingOut() => + EndpointSetup(c => c.SendHeartbeatTo(Settings.DEFAULT_INSTANCE_NAME)); + } + class MonitoringInstance : EndpointConfigurationBuilder { public MonitoringInstance() => diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs index 7b686d72c7..5ca4450e2e 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerDatabaseHostingProbe.cs @@ -24,7 +24,7 @@ public async Task Probe(CancellationToken cancellationToken = d 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.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion'), DB_ID('rdsadmin')"; command.CommandTimeout = ProbeTimeoutSeconds; await dbContext.Database.OpenConnectionAsync(cancellationToken); @@ -38,7 +38,9 @@ public async Task Probe(CancellationToken cancellationToken = d var engineEdition = Convert.ToInt32(reader.GetValue(0), CultureInfo.InvariantCulture); - return new DatabaseHosting(HostingFor(engineEdition, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe); + var rds = !reader.IsDBNull(2); + + return new DatabaseHosting(HostingFor(engineEdition, rds, ConfiguredHost), MajorVersion(reader), DatabaseHostingSource.Probe); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -59,12 +61,12 @@ public async Task Probe(CancellationToken cancellationToken = d /// 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 + internal static string HostingFor(int engineEdition, bool rds, string? host) => engineEdition switch { AzureSqlDatabase => "AzureSql", AzureSqlManagedInstance => "AzureSqlManagedInstance", AzureSqlEdge => "AzureSqlEdge", - PersonalOrDesktop or Standard or Enterprise or Express => ManagedOrSelfHosted(host), + PersonalOrDesktop or Standard or Enterprise or Express => rds ? "AwsRds" : ManagedOrSelfHosted(host), _ => DatabaseHostClassifier.Classify(host) }; diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs index 727aef4b98..c708103e7c 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs @@ -7,11 +7,11 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; public abstract class EFPersistenceConfigurationBase : PersistenceConfiguration, IPersistenceConfiguration { const string ConnectionStringKey = "Database/ConnectionString"; - const string SchemaKey = "Database/Schema"; - const string CommandTimeoutKey = "Database/CommandTimeout"; + internal const string SchemaKey = "Database/Schema"; + internal const string CommandTimeoutKey = "Database/CommandTimeout"; const string BodyStorageTypeKey = "MessageBody/StorageType"; const string FileSystemStoragePathKey = "MessageBody/FileSystem/StoragePath"; - const string FileSystemDataSpaceRemainingThresholdKey = "MessageBody/FileSystem/DataSpaceRemainingThreshold"; + internal const string FileSystemDataSpaceRemainingThresholdKey = "MessageBody/FileSystem/DataSpaceRemainingThreshold"; const string AzureConnectionStringKey = "MessageBody/Azure/ConnectionString"; const string AzureServiceUriKey = "MessageBody/Azure/ServiceUri"; const string AzureManagedIdentityClientIdKey = "MessageBody/Azure/ManagedIdentityClientId"; @@ -23,11 +23,11 @@ public abstract class EFPersistenceConfigurationBase : PersistenceConfiguration, const string S3ServiceUrlKey = "MessageBody/S3/ServiceUrl"; const string S3AccessKeyIdKey = "MessageBody/S3/AccessKeyId"; const string S3SecretAccessKeyKey = "MessageBody/S3/SecretAccessKey"; - const string MinBodySizeForCompressionKey = "MessageBody/MinCompressionSize"; + internal const string MinBodySizeForCompressionKey = "MessageBody/MinCompressionSize"; const string MaxBodySizeToStoreKey = "MaxBodySizeToStore"; const string ErrorRetentionPeriodKey = "ErrorRetentionPeriod"; const string EventsRetentionPeriodKey = "EventsRetentionPeriod"; - const string SubscriptionCacheDurationKey = "SubscriptionCacheDuration"; + internal const string SubscriptionCacheDurationKey = "SubscriptionCacheDuration"; const string ExternalIntegrationsDispatchingBatchSizeKey = "ExternalIntegrationsDispatchingBatchSize"; public bool SupportsMaintenanceMode => false; diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs index d95f90646c..f5ccfaf98a 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/EFEnvironmentDataProvider.cs @@ -4,6 +4,8 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Abstractions; using Infrastructure; using Particular.LicensingComponent.Contracts; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class EFEnvironmentDataProvider(EFPersisterSettings settings, IDatabaseHostingProbe hostingProbe) : IEnvironmentDataProvider @@ -23,11 +25,28 @@ public IEnumerable GetData() Deferred("Storage.HostingSource", async cancellationToken => (await Hosting(cancellationToken)).Source), Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => BodyStorageType(settings.BodyStorage)), - Value("Storage.BodyStorage.Auth", () => BodyStorageAuth(settings.BodyStorage)), - Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)) + Value("Limits.MaxBodySizeToStore", () => settings.BodyStorage.MaxBodySizeToStore.ToString(CultureInfo.InvariantCulture)), + Value("Storage.Schema", () => settings.Schema is null ? "Default" : "Custom"), + Value("Storage.CommandTimeoutSeconds", () => WhenConfigured(EFPersistenceConfigurationBase.CommandTimeoutKey, () => Number(settings.CommandTimeout))), + Value("Storage.QueryTimeoutSeconds", () => WhenConfigured(QueryTimeLimit.SettingName, () => Seconds(settings.QueryTimeout))), + Value("Storage.SubscriptionCacheSeconds", () => WhenConfigured(EFPersistenceConfigurationBase.SubscriptionCacheDurationKey, () => Seconds(settings.SubscriptionCacheDuration))), + Value("Storage.BodyStorage.MinCompressionBytes", () => WhenConfigured(EFPersistenceConfigurationBase.MinBodySizeForCompressionKey, () => Number(settings.BodyStorage.MinCompressionSize))), + Value("Storage.FreeSpaceThresholdPercent", FreeSpaceThreshold) ]; } + string FreeSpaceThreshold() => settings.BodyStorage is FileSystemBodyStorageSettings fileSystem + ? WhenConfigured(EFPersistenceConfigurationBase.FileSystemDataSpaceRemainingThresholdKey, () => Number(fileSystem.DataSpaceRemainingThreshold)) + : "NotApplicable"; + + static string WhenConfigured(string key, Func readValue) => + SettingsReader.TryRead(SettingsNamespace, key, out _) ? readValue() : "Default"; + + static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + + static string Seconds(TimeSpan value) => + Math.Round(value.TotalSeconds, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + static string BodyStorageType(BodyStorageSettings bodyStorage) => bodyStorage switch { FileSystemBodyStorageSettings => nameof(Abstractions.BodyStorageType.FileSystem), @@ -36,15 +55,5 @@ public IEnumerable GetData() _ => "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" - }; + static readonly SettingsRootNamespace SettingsNamespace = new("ServiceControl"); } diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs index d1f596f1d2..61c709b6d7 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -2,10 +2,13 @@ namespace ServiceControl.Persistence.RavenDB; using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using Particular.LicensingComponent.Contracts; using Raven.Client.ServerWide.Operations; +using ServiceControl.Configuration; +using ServiceControl.Infrastructure; using static Particular.LicensingComponent.Contracts.EnvironmentDatum; class RavenEnvironmentDataProvider(RavenPersisterSettings settings, IRavenDocumentStoreProvider documentStoreProvider) : IEnvironmentDataProvider @@ -19,7 +22,11 @@ public IEnumerable GetData() => Value("Storage.HostingSource", () => Hosting().Source), Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => "RavenAttachments"), - Value("Storage.BodyStorage.Auth", () => "NotApplicable") + Value("Storage.LogLevel", () => settings.LogsMode), + Value("Storage.QueryTimeoutSeconds", () => WhenConfigured(QueryTimeLimit.SettingName, () => Number((int)Math.Round(settings.QueryTimeout.TotalSeconds, MidpointRounding.AwayFromZero)))), + Value("Storage.FreeSpaceThresholdPercent", () => WhenConfigured(RavenPersistenceConfiguration.DataSpaceRemainingThresholdKey, () => Number(settings.DataSpaceRemainingThreshold))), + Value("Storage.MinimumFreeSpaceForIngestionPercent", () => WhenConfigured(RavenBootstrapper.MinimumStorageLeftRequiredForIngestionKey, () => Number(settings.MinimumStorageLeftRequiredForIngestion))), + Value("Storage.ExpirationIntervalSeconds", () => WhenConfigured(RavenBootstrapper.ExpirationProcessTimerInSecondsKey, () => Number(settings.ExpirationProcessTimerInSeconds))) ]; (string Hosting, string Source) Hosting() @@ -42,4 +49,11 @@ async ValueTask ServerVersion(CancellationToken cancellationToken) return buildNumber.ProductVersion ?? DatabaseHostClassifier.Unknown; } + + static string WhenConfigured(string key, Func readValue) => + SettingsReader.TryRead(SettingsNamespace, key, out _) ? readValue() : "Default"; + + static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + + static readonly SettingsRootNamespace SettingsNamespace = new("ServiceControl"); } diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs new file mode 100644 index 0000000000..cd7c23e4dc --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs @@ -0,0 +1,83 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.RavenDB; + +[TestFixture] +[NonParallelizable] +class RavenEnvironmentDataProviderConfigurationTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_defaults_when_nothing_is_configured() + { + var data = await GetData(new RavenPersisterSettings()); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.LogLevel"], Is.EqualTo("Operations")); + Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("Default")); + Assert.That(data["Storage.MinimumFreeSpaceForIngestionPercent"], Is.EqualTo("Default")); + Assert.That(data["Storage.ExpirationIntervalSeconds"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_configured_tuning_values() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_DATASPACEREMAININGTHRESHOLD", "30"); + Environment.SetEnvironmentVariable("SERVICECONTROL_EXPIRATIONPROCESSTIMERINSECONDS", "300"); + + var data = await GetData(new RavenPersisterSettings { DataSpaceRemainingThreshold = 30, ExpirationProcessTimerInSeconds = 300 }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("30")); + Assert.That(data["Storage.ExpirationIntervalSeconds"], Is.EqualTo("300")); + Assert.That(data["Storage.MinimumFreeSpaceForIngestionPercent"], Is.EqualTo("Default")); + } + } + + static async Task> GetData(RavenPersisterSettings settings) + { + var data = new Dictionary(); + + foreach (var datum in new RavenEnvironmentDataProvider(settings, documentStoreProvider: null).GetData()) + { + if (ConfigurationKeys.Contains(datum.Key)) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + } + + return data; + } + + static readonly HashSet ConfigurationKeys = + [ + "Storage.LogLevel", + "Storage.QueryTimeoutSeconds", + "Storage.FreeSpaceThresholdPercent", + "Storage.MinimumFreeSpaceForIngestionPercent", + "Storage.ExpirationIntervalSeconds" + ]; + + static readonly string[] Variables = + [ + "SERVICECONTROL_DATASPACEREMAININGTHRESHOLD", + "SERVICECONTROL_EXPIRATIONPROCESSTIMERINSECONDS" + ]; +} diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs index cd2c5830b1..31d19aea3d 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerDatabaseHostingProbeTests.cs @@ -12,7 +12,7 @@ class SqlServerDatabaseHostingProbeTests [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), + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "anything.example.com"), Is.EqualTo(expected), "The engine names its own service, so the host name must not get a say"); [TestCase(1)] @@ -20,14 +20,20 @@ public void Should_take_the_azure_service_from_the_engine_edition(int engineEdit [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"), + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "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"); + [TestCase(4)] + public void Should_recognise_rds_by_its_rdsadmin_database_whatever_the_host_name(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, true, "db01.corp.example"), Is.EqualTo("AwsRds"), + "RDS for SQL Server reports an ordinary edition, and a customer's own DNS name must not make it look self-hosted"); + + [TestCase(2)] + [TestCase(3)] + public void Should_still_recognise_rds_by_its_host_name_when_rdsadmin_is_not_visible(int engineEdition) => + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "sc.abcdef.eu-west-1.rds.amazonaws.com"), Is.EqualTo("AwsRds")); // Synapse (6, 11) and Fabric (12) are deliberately unmapped, so they take this path. [TestCase(6)] @@ -35,13 +41,13 @@ public void Should_still_recognise_rds_running_an_ordinary_edition(int engineEdi [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")); + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "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"), + Assert.That(SqlServerDatabaseHostingProbe.HostingFor(engineEdition, false, "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/EnvironmentDataConfigurationTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataConfigurationTests.cs new file mode 100644 index 0000000000..daab19d03a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataConfigurationTests.cs @@ -0,0 +1,113 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +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] +[NonParallelizable] +class EFEnvironmentDataProviderConfigurationTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_defaults_when_nothing_is_configured() + { + var data = await GetData(new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" } }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.Schema"], Is.EqualTo("Default")); + Assert.That(data["Storage.CommandTimeoutSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.SubscriptionCacheSeconds"], Is.EqualTo("Default")); + Assert.That(data["Storage.BodyStorage.MinCompressionBytes"], Is.EqualTo("Default")); + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_configured_values_in_the_unit_their_keys_name() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_DATABASE_COMMANDTIMEOUT", "60"); + Environment.SetEnvironmentVariable("SERVICECONTROL_QUERYTIMEOUTINSECONDS", "120"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD", "15"); + + var data = await GetData(new TestPersisterSettings + { + ConnectionString = "Host=localhost", + BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies", DataSpaceRemainingThreshold = 15 }, + CommandTimeout = 60, + QueryTimeout = TimeSpan.FromSeconds(120) + }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.CommandTimeoutSeconds"], Is.EqualTo("60")); + Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("120")); + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("15")); + Assert.That(data["Storage.SubscriptionCacheSeconds"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_a_custom_schema_without_naming_it() + { + var data = await GetData(new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = new FileSystemBodyStorageSettings { StoragePath = "/var/bodies" }, Schema = "contoso" }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Storage.Schema"], Is.EqualTo("Custom")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso")); + } + } + + [Test] + public async Task Should_report_the_free_space_threshold_as_not_applicable_for_cloud_body_storage() + { + var data = await GetData(new TestPersisterSettings { ConnectionString = "Host=localhost", BodyStorage = new S3BodyStorageSettings { BucketName = "bodies" } }); + + Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("NotApplicable")); + } + + static async Task> GetData(EFPersisterSettings settings) + { + var data = new Dictionary(); + + foreach (var datum in new EFEnvironmentDataProvider(settings, new StubHostingProbe()).GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + static readonly string[] Variables = + [ + "SERVICECONTROL_DATABASE_COMMANDTIMEOUT", + "SERVICECONTROL_QUERYTIMEOUTINSECONDS", + "SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD" + ]; + + sealed class TestPersisterSettings : EFPersisterSettings; + + sealed class StubHostingProbe : IDatabaseHostingProbe + { + public string StorageName => "PostgreSQL"; + + public Task Probe(CancellationToken cancellationToken = default) => + Task.FromResult(DatabaseHosting.Unclassified); + } +} diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs index 1f279e2469..08c8e3e5a0 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/EnvironmentDataTests.cs @@ -45,65 +45,30 @@ public void Should_report_unknown_when_there_is_no_host(string host) => class EFEnvironmentDataProviderTests { [Test] - public async Task Should_report_managed_identity_for_azure_blob_service_uri() + public async Task Should_report_azure_blob_body_storage() { var data = await GetData(new AzureBlobBodyStorageSettings { Authentication = new AzureBlobManagedIdentityAuthentication { ServiceUri = new Uri("https://account.blob.core.windows.net") } }); - Assert.Multiple(() => - { - Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("AzureBlob")); - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("ManagedIdentity")); - }); + Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("AzureBlob")); } [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["Storage.BodyStorage.Auth"], Is.EqualTo("SharedKeyOrSas")); - } - - [Test] - public async Task Should_report_iam_role_when_s3_has_no_static_credentials() + public async Task Should_report_s3_body_storage() { var data = await GetData(new S3BodyStorageSettings { BucketName = "bodies" }); - Assert.Multiple(() => - { - Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("S3")); - Assert.That(data["Storage.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["Storage.BodyStorage.Auth"], Is.EqualTo("StaticCredentials")); + Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("S3")); } [Test] - public async Task Should_report_file_system_body_storage_as_not_applicable_for_auth() + public async Task Should_report_file_system_body_storage() { var data = await GetData(new FileSystemBodyStorageSettings { StoragePath = "/var/lib/servicecontrol" }); - Assert.Multiple(() => - { - Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("FileSystem")); - Assert.That(data["Storage.BodyStorage.Auth"], Is.EqualTo("NotApplicable")); - }); + Assert.That(data["Storage.BodyStorage.Type"], Is.EqualTo("FileSystem")); } [Test] diff --git a/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..af225d310f --- /dev/null +++ b/src/ServiceControl.Transports.ASBS.Tests/EnvironmentDataTests.cs @@ -0,0 +1,62 @@ +namespace ServiceControl.Transports.UnitTests.ASBS +{ + using System; + using System.Collections.Generic; + using System.Linq; + using NUnit.Framework; + using ServiceControl.Transports.ASBS; + + [TestFixture] + [NonParallelizable] + class EnvironmentDataTests + { + [TearDown] + public void TearDown() => Environment.SetEnvironmentVariable(TopologyVariable, null); + + [Test] + public void Should_report_defaults_when_the_connection_string_selects_no_options() + { + var data = Read("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("TopicPerEvent")); + Assert.That(data["Transport.AzureServiceBus.Partitioning"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.AzureServiceBus.WebSockets"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.AzureServiceBus.HierarchyNamespace"], Is.EqualTo("None")); + } + } + + [Test] + public void Should_report_selected_options_without_their_values() + { + var data = Read("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0;TopicName=contoso-bundle;EnablePartitioning=true;TransportType=AmqpWebSockets;HierarchyNamespace=contoso-prod"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("Migration")); + Assert.That(data["Transport.AzureServiceBus.Partitioning"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.AzureServiceBus.WebSockets"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.AzureServiceBus.HierarchyNamespace"], Is.EqualTo("Configured")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase.And.Not.Contain("c2VjcmV0")); + } + } + + [Test] + public void Should_report_a_custom_topology() + { + Environment.SetEnvironmentVariable(TopologyVariable, "{}"); + + var data = Read("Endpoint=sb://contoso.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=c2VjcmV0"); + + Assert.That(data["Transport.AzureServiceBus.Topology"], Is.EqualTo("Custom")); + } + + static Dictionary Read(string connectionString) => + new ASBSTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); + + const string TopologyVariable = "SERVICECONTROL_TRANSPORT_ASBS_TOPOLOGY"; + } +} diff --git a/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs b/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs index 778dec09d5..c7411779ce 100644 --- a/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs +++ b/src/ServiceControl.Transports.ASBS/ASBSTransportCustomization.cs @@ -17,6 +17,9 @@ public class ASBSTransportCustomization : TransportCustomization { + static readonly SettingsRootNamespace TopologySettingsNamespace = new("ServiceControl.Transport.ASBS"); + const string TopologySettingName = "Topology"; + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, AzureServiceBusTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.SendsAtomicWithReceive; protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, AzureServiceBusTransport transportDefinition, TransportSettings transportSettings) => transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; @@ -24,6 +27,29 @@ public class ASBSTransportCustomization : TransportCustomization transportDefinition.TransportTransactionMode = TransportTransactionMode.ReceiveOnly; + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) + { + ConnectionSettings Parse() => ConnectionStringParser.Parse(transportSettings.ConnectionString); + + return + [ + new("Transport.AzureServiceBus.Topology", () => Topology(Parse())), + new("Transport.AzureServiceBus.Partitioning", () => Parse().EnablePartitioning ? "Enabled" : "Disabled"), + new("Transport.AzureServiceBus.WebSockets", () => Parse().UseWebSockets ? "Enabled" : "Disabled"), + new("Transport.AzureServiceBus.HierarchyNamespace", () => string.IsNullOrEmpty(Parse().HierarchyNamespace) ? "None" : "Configured") + ]; + } + + static string Topology(ConnectionSettings connectionSettings) + { + if (connectionSettings.TopicName != null) + { + return "Migration"; + } + + return SettingsReader.TryRead(TopologySettingsNamespace, TopologySettingName, out _) ? "Custom" : "TopicPerEvent"; + } + protected override AzureServiceBusTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionSettings = ConnectionStringParser.Parse(transportSettings.ConnectionString); @@ -66,7 +92,6 @@ protected override void AddTransportForPrimaryCore(IServiceCollection services, var connectionSettings = ConnectionStringParser.Parse(transportSettings.ConnectionString); TopicTopology selectedTopology; - var serviceBusRootNamespace = new SettingsRootNamespace("ServiceControl.Transport.ASBS"); if (connectionSettings.TopicName != null) { //Bundle name provided -> use migration topology @@ -80,7 +105,7 @@ protected override void AddTransportForPrimaryCore(IServiceCollection services, EventsToMigrateMap = [.. transportSettings.EventTypesPublished.Select(t => t.FullName)] }); } - else if (SettingsReader.TryRead(serviceBusRootNamespace, "Topology", out var topologyJson)) + else if (SettingsReader.TryRead(TopologySettingsNamespace, TopologySettingName, out var topologyJson)) { //Load topology from json selectedTopology = TopicTopology.FromOptions(JsonSerializer.Deserialize(topologyJson, TopologyOptionsSerializationContext.Default.TopologyOptions)); diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..cb6c369e97 --- /dev/null +++ b/src/ServiceControl.Transports.PostgreSql.Tests/EnvironmentDataTests.cs @@ -0,0 +1,41 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.PostgreSql; + +[TestFixture] +class EnvironmentDataTests +{ + [Test] + public void Should_report_default_schema_and_subscriptions_table() + { + var data = Read("Host=localhost;Username=nsb;Password=contoso-secret"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.PostgreSQL.QueueSchema"], Is.EqualTo("Default")); + Assert.That(data["Transport.PostgreSQL.SubscriptionsTable"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_a_custom_schema_and_subscriptions_table_without_naming_them() + { + var data = Read("Host=localhost;Username=nsb;Password=contoso-secret;Queue Schema=contoso;Subscriptions Table=contoso.subscriptions"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.PostgreSQL.QueueSchema"], Is.EqualTo("Custom")); + Assert.That(data["Transport.PostgreSQL.SubscriptionsTable"], Is.EqualTo("Custom")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + + static Dictionary Read(string connectionString) => + new PostgreSqlTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs b/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs index cbe89f5b78..fbcb493344 100644 --- a/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs +++ b/src/ServiceControl.Transports.PostgreSql.Tests/PostgreSqlQueryTests.cs @@ -93,6 +93,7 @@ public async Task RunScenario() IBrokerQueue queue = queueNames.Find(name => ((BrokerQueueTable)name).SanitizedName == transportSettings.EndpointName); Assert.That(queue, Is.Not.Null); + Assert.That(query.Data["SqlVersion"], Does.Match(@"^\d+$")); long total = 0L; diff --git a/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs b/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs index e90ed6959b..cffabb03cd 100644 --- a/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs +++ b/src/ServiceControl.Transports.PostgreSql/DatabaseDetails.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using Npgsql; @@ -69,9 +70,11 @@ async Task GetPostgreSqlVersion(CancellationToken cancellationToken) { await using var conn = await OpenConnectionAsync(cancellationToken); await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT version()"; + cmd.CommandText = "SELECT current_setting('server_version_num')::int"; - return (string)await cmd.ExecuteScalarAsync(cancellationToken); + var versionNumber = (int)await cmd.ExecuteScalarAsync(cancellationToken); + + return (versionNumber / 10000).ToString(CultureInfo.InvariantCulture); } public async Task> GetTables(CancellationToken cancellationToken = default) diff --git a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs index 4523792f18..063c32b3e4 100644 --- a/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs +++ b/src/ServiceControl.Transports.PostgreSql/PostgreSqlTransportCustomization.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Transports.PostgreSql; +using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using BrokerThroughput; @@ -42,6 +43,20 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => + [ + new("Transport.PostgreSQL.QueueSchema", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var schema, out _); + return schema is null ? "Default" : "Custom"; + }), + new("Transport.PostgreSQL.SubscriptionsTable", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out var subscriptionsTable); + return subscriptionsTable is null ? "Default" : "Custom"; + }) + ]; + protected override PostgreSqlTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionString = transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var customSchema, out var subscriptionsTableSetting); diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs index 0a096c5222..5b41d12582 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQConventionalRoutingTransportCustomization.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Transports.RabbitMQ { using System; + using System.Collections.Generic; using System.Linq; using BrokerThroughput; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,9 @@ ManagementClient Get() } } + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => + RabbitMQTransportExtensions.GetEnvironmentData(transportSettings.ConnectionString); + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs index 2d497877d0..82b710791a 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQDirectRoutingTransportCustomization.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Transports.RabbitMQ { using System; + using System.Collections.Generic; using System.Linq; using BrokerThroughput; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,9 @@ ManagementClient Get() } } + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => + RabbitMQTransportExtensions.GetEnvironmentData(transportSettings.ConnectionString); + protected override void CustomizeTransportForPrimaryEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; protected override void CustomizeTransportForAuditEndpoint(EndpointConfiguration endpointConfiguration, RabbitMQTransport transportDefinition, TransportSettings transportSettings) => transport = transportDefinition; diff --git a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs index 1f5b2ed033..71b7d5c811 100644 --- a/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs +++ b/src/ServiceControl.Transports.RabbitMQ/RabbitMQTransportExtensions.cs @@ -11,14 +11,7 @@ static class RabbitMQTransportExtensions { public static void ApplySettingsFromConnectionString(this RabbitMQTransport transport, string connectionString) { - if (connectionString.StartsWith("amqp", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var dictionary = new DbConnectionStringBuilder { ConnectionString = connectionString } - .OfType>() - .ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase); + var dictionary = ReadConnectionStringOptions(connectionString); if (dictionary.TryGetValue("ValidateDeliveryLimits", out var validateDeliveryLimitsString)) { @@ -43,4 +36,27 @@ public static void ApplySettingsFromConnectionString(this RabbitMQTransport tran transport.AuthMechanisms = [new ExternalMechanismFactory()]; } } + + public static IEnumerable GetEnvironmentData(string connectionString) => + [ + new("Transport.RabbitMQ.DeliveryLimitValidation", () => + { + var options = ReadConnectionStringOptions(connectionString); + var validates = !options.TryGetValue("ValidateDeliveryLimits", out var value) || (bool.TryParse(value, out var parsed) && parsed); + return validates ? "Enabled" : "Disabled"; + }), + new("Transport.RabbitMQ.ManagementApi", () => ReadConnectionStringOptions(connectionString).ContainsKey("ManagementApiUrl") ? "Configured" : "Default") + ]; + + static Dictionary ReadConnectionStringOptions(string connectionString) + { + if (connectionString.StartsWith("amqp", StringComparison.OrdinalIgnoreCase)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + return new DbConnectionStringBuilder { ConnectionString = connectionString } + .OfType>() + .ToDictionary(pair => pair.Key, pair => pair.Value.ToString(), StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..89c7f96045 --- /dev/null +++ b/src/ServiceControl.Transports.RabbitMQClassicConventionalRouting.Tests/EnvironmentDataTests.cs @@ -0,0 +1,47 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.RabbitMQ; + +[TestFixture] +class EnvironmentDataTests +{ + [TestCase("host=localhost")] + [TestCase("amqp://guest:guest@localhost:5672")] + public void Should_report_defaults_when_the_connection_string_selects_no_options(string connectionString) + { + var data = Read(new RabbitMQClassicConventionalRoutingTransportCustomization(), connectionString); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.RabbitMQ.DeliveryLimitValidation"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.RabbitMQ.ManagementApi"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_selected_options_on_either_routing_topology_without_their_values() + { + const string connectionString = "host=rabbit.contoso.local;ValidateDeliveryLimits=false;ManagementApiUrl=https://rabbit.contoso.local:15671"; + + foreach (var customization in new ITransportCustomization[] { new RabbitMQClassicConventionalRoutingTransportCustomization(), new RabbitMQQuorumDirectRoutingTransportCustomization() }) + { + var data = Read(customization, connectionString); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.RabbitMQ.DeliveryLimitValidation"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.RabbitMQ.ManagementApi"], Is.EqualTo("Configured")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + } + + static Dictionary Read(ITransportCustomization customization, string connectionString) => + customization + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..5ec8b228a9 --- /dev/null +++ b/src/ServiceControl.Transports.SQS.Tests/EnvironmentDataTests.cs @@ -0,0 +1,50 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.SQS; + +[TestFixture] +class EnvironmentDataTests +{ + [Test] + public void Should_report_defaults_when_the_connection_string_carries_only_a_region() + { + var data = Read("Region=eu-west-1"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo("None")); + Assert.That(data["Transport.AmazonSQS.LargeMessageBucket"], Is.EqualTo("None")); + Assert.That(data["Transport.AmazonSQS.MessageWrapping"], Is.EqualTo("Enabled")); + Assert.That(data["Transport.AmazonSQS.ReservedBytesInMessageSize"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_selected_options_without_their_values() + { + var data = Read("AccessKeyId=AKIACONTOSO;SecretAccessKey=contoso-secret;Region=eu-west-1;QueueNamePrefix=contoso-;S3BucketForLargeMessages=contoso-bodies;DoNotWrapOutgoingMessages=true;ReservedBytesInMessageSize=1024"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo("Queue")); + Assert.That(data["Transport.AmazonSQS.LargeMessageBucket"], Is.EqualTo("Configured")); + Assert.That(data["Transport.AmazonSQS.MessageWrapping"], Is.EqualTo("Disabled")); + Assert.That(data["Transport.AmazonSQS.ReservedBytesInMessageSize"], Is.EqualTo("1024")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [TestCase("QueueNamePrefix=a-;TopicNamePrefix=b-", "QueueAndTopic")] + [TestCase("TopicNamePrefix=b-", "Topic")] + public void Should_report_which_name_prefixes_are_set(string prefixes, string expected) => + Assert.That(Read($"Region=eu-west-1;{prefixes}")["Transport.AmazonSQS.NamePrefixes"], Is.EqualTo(expected)); + + static Dictionary Read(string connectionString) => + new SQSTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs b/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs index c1a3e2a678..71bce5cc03 100644 --- a/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs +++ b/src/ServiceControl.Transports.SQS/SQSTransportConnectionString.cs @@ -53,12 +53,14 @@ public SQSTransportConnectionString(string connectionString) } - if (builder.TryGetValue("ReservedBytesInMessageSize", out object reservedBytes)) + if (builder.TryGetValue(ReservedBytesInMessageSizeKey, out object reservedBytes)) { ReservedBytesInMessageSize = Convert.ToInt32(reservedBytes); } } + public const string ReservedBytesInMessageSizeKey = "ReservedBytesInMessageSize"; + public string AccessKey { get; } public string SecretKey { get; } public string Region { get; } diff --git a/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs b/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs index cf844e1961..4252e7e0ff 100644 --- a/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs +++ b/src/ServiceControl.Transports.SQS/SQSTransportCustomization.cs @@ -1,6 +1,9 @@ namespace ServiceControl.Transports.SQS { using System; + using System.Collections.Generic; + using System.Data.Common; + using System.Globalization; using System.Linq; using Amazon; using Amazon.Runtime; @@ -41,6 +44,30 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) + { + SQSTransportConnectionString Parse() => new(transportSettings.ConnectionString); + + return + [ + new("Transport.AmazonSQS.NamePrefixes", () => NamePrefixes(Parse())), + new("Transport.AmazonSQS.LargeMessageBucket", () => string.IsNullOrEmpty(Parse().S3BucketForLargeMessages) ? "None" : "Configured"), + new("Transport.AmazonSQS.MessageWrapping", () => Parse().DoNotWrapOutgoingMessages ? "Disabled" : "Enabled"), + new("Transport.AmazonSQS.ReservedBytesInMessageSize", () => new DbConnectionStringBuilder { ConnectionString = transportSettings.ConnectionString }.ContainsKey(SQSTransportConnectionString.ReservedBytesInMessageSizeKey) + ? Parse().ReservedBytesInMessageSize.ToString(CultureInfo.InvariantCulture) + : "Default") + ]; + } + + static string NamePrefixes(SQSTransportConnectionString connectionString) => + (string.IsNullOrEmpty(connectionString.QueueNamePrefix), string.IsNullOrEmpty(connectionString.TopicNamePrefix)) switch + { + (false, false) => "QueueAndTopic", + (false, true) => "Queue", + (true, false) => "Topic", + _ => "None" + }; + protected override SqsTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var builder = new SQSTransportConnectionString(transportSettings.ConnectionString); diff --git a/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs b/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs new file mode 100644 index 0000000000..19dca3576b --- /dev/null +++ b/src/ServiceControl.Transports.SqlServer.Tests/EnvironmentDataTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Transport.Tests; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Transports; +using Transports.SqlServer; + +[TestFixture] +class EnvironmentDataTests +{ + [Test] + public void Should_report_default_schema_and_subscriptions_table() + { + var data = Read("Server=.;Database=nsb;Integrated Security=true"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.SQLServer.QueueSchema"], Is.EqualTo("Default")); + Assert.That(data["Transport.SQLServer.SubscriptionsTable"], Is.EqualTo("Default")); + } + } + + [Test] + public void Should_report_a_custom_schema_and_subscriptions_table_without_naming_them() + { + var data = Read("Server=.;Database=nsb;User Id=sa;Password=contoso-secret;Queue Schema=contoso;Subscriptions Table=contoso.subscriptions"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Transport.SQLServer.QueueSchema"], Is.EqualTo("Custom")); + Assert.That(data["Transport.SQLServer.SubscriptionsTable"], Is.EqualTo("Custom")); + Assert.That(string.Join("|", data.Values), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [TestCase(3, "16.0.4225.2")] + [TestCase(8, "16.0.4165.4")] + public void Should_report_only_the_major_sql_version(int engineEdition, string productVersion) => + Assert.That(DatabaseDetails.VersionFor(engineEdition, productVersion), Is.EqualTo("16")); + + [Test] + public void Should_report_azure_sql_database_by_name_rather_than_by_version() => + Assert.That(DatabaseDetails.VersionFor(5, "12.0.2000.8"), Is.EqualTo("AzureSql"), + "Azure SQL Database always reports 12, which would read as SQL Server 2014"); + + static Dictionary Read(string connectionString) => + new SqlServerTransportCustomization() + .GetEnvironmentData(new TransportSettings { ConnectionString = connectionString }) + .ToDictionary(datum => datum.Key, datum => datum.ReadValue()); +} diff --git a/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs b/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs index 5784435401..0f9cdfed49 100644 --- a/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs +++ b/src/ServiceControl.Transports.SqlServer.Tests/SqlServerQueryTests.cs @@ -113,6 +113,7 @@ public async Task RunScenario() IBrokerQueue queue = queueNames.Find(name => ((BrokerQueueTable)name).Name == transportSettings.EndpointName); Assert.That(queue, Is.Not.Null); + Assert.That(query.Data["SqlVersion"], Does.Match(@"^(\d+|AzureSql)$")); long total = 0L; diff --git a/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs b/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs index 0ff57251e7..f8b072ce0a 100644 --- a/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs +++ b/src/ServiceControl.Transports.SqlServer/DatabaseDetails.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using System.Globalization; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.SqlClient; @@ -90,11 +91,27 @@ async Task GetSqlVersion(CancellationToken cancellationToken) { await using var conn = await OpenConnectionAsync(cancellationToken); await using var cmd = conn.CreateCommand(); - cmd.CommandText = "select @@VERSION"; + cmd.CommandText = "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('ProductVersion')"; - return (string)await cmd.ExecuteScalarAsync(cancellationToken); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + + return await reader.ReadAsync(cancellationToken) ? VersionFor(reader.GetValue(0), reader.GetValue(1)) : "Unknown"; + } + + internal static string VersionFor(object engineEdition, object productVersion) + { + if (engineEdition is AzureSqlDatabaseEngineEdition) + { + return "AzureSql"; + } + + var major = Convert.ToString(productVersion, CultureInfo.InvariantCulture)?.Split('.')[0]; + + return string.IsNullOrWhiteSpace(major) ? "Unknown" : major; } + const int AzureSqlDatabaseEngineEdition = 5; + public async Task> GetTables(CancellationToken cancellationToken = default) { List tables = []; diff --git a/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs b/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs index 4bed774adb..35be6b3a11 100644 --- a/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs +++ b/src/ServiceControl.Transports.SqlServer/SqlServerTransportCustomization.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Transports.SqlServer { + using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using BrokerThroughput; @@ -50,6 +51,20 @@ protected override void AddTransportForMonitoringCore(IServiceCollection service services.AddHostedService(provider => provider.GetRequiredService()); } + public override IEnumerable GetEnvironmentData(TransportSettings transportSettings) => + [ + new("Transport.SQLServer.QueueSchema", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var schema, out _); + return schema is null ? "Default" : "Custom"; + }), + new("Transport.SQLServer.SubscriptionsTable", () => + { + transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out _, out var subscriptionsTable); + return subscriptionsTable is null ? "Default" : "Custom"; + }) + ]; + protected override SqlServerTransport CreateTransport(TransportSettings transportSettings, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly) { var connectionString = transportSettings.ConnectionString.RemoveCustomConnectionStringParts(out var customSchema, out var subscriptionsTableSetting); diff --git a/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt b/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt index 7963f24901..df5a7bc609 100644 --- a/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt +++ b/src/ServiceControl.Transports.Tests/ApprovalFiles/APIApprovals.ServiceControlTransport.approved.txt @@ -31,6 +31,7 @@ namespace ServiceControl.Transports void CustomizeAuditEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, ServiceControl.Transports.TransportSettings transportSettings); void CustomizeMonitoringEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, ServiceControl.Transports.TransportSettings transportSettings); void CustomizePrimaryEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, ServiceControl.Transports.TransportSettings transportSettings); + System.Collections.Generic.IEnumerable GetEnvironmentData(ServiceControl.Transports.TransportSettings transportSettings); System.Threading.Tasks.Task ProvisionQueues(ServiceControl.Transports.TransportSettings transportSettings, System.Collections.Generic.IEnumerable additionalQueues, System.Threading.CancellationToken cancellationToken = default); string ToTransportQualifiedQueueName(string queueName); } @@ -59,10 +60,17 @@ namespace ServiceControl.Transports protected abstract void CustomizeTransportForAuditEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); protected abstract void CustomizeTransportForMonitoringEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); protected abstract void CustomizeTransportForPrimaryEndpoint(NServiceBus.EndpointConfiguration endpointConfiguration, TTransport transportDefinition, ServiceControl.Transports.TransportSettings transportSettings); + public virtual System.Collections.Generic.IEnumerable GetEnvironmentData(ServiceControl.Transports.TransportSettings transportSettings) { } public virtual System.Threading.Tasks.Task ProvisionQueues(ServiceControl.Transports.TransportSettings transportSettings, System.Collections.Generic.IEnumerable additionalQueues, System.Threading.CancellationToken cancellationToken = default) { } public string ToTransportQualifiedQueueName(string queueName) { } protected virtual string ToTransportQualifiedQueueNameCore(string queueName) { } } + public sealed class TransportEnvironmentDatum : System.IEquatable + { + public TransportEnvironmentDatum(string Key, System.Func ReadValue) { } + public string Key { get; init; } + public System.Func ReadValue { get; init; } + } public static class TransportFactory { public static ServiceControl.Transports.ITransportCustomization Create(ServiceControl.Transports.TransportSettings settings) { } diff --git a/src/ServiceControl.Transports/TransportCustomization.cs b/src/ServiceControl.Transports/TransportCustomization.cs index a03fcb2ee1..f7d4142eba 100644 --- a/src/ServiceControl.Transports/TransportCustomization.cs +++ b/src/ServiceControl.Transports/TransportCustomization.cs @@ -26,6 +26,8 @@ public interface ITransportCustomization string ToTransportQualifiedQueueName(string queueName); Task CreateTransportInfrastructure(string name, TransportSettings transportSettings, OnMessage onMessage = null, OnError onError = null, Func onCriticalError = null, TransportTransactionMode preferredTransactionMode = TransportTransactionMode.ReceiveOnly, CancellationToken cancellationToken = default); + + IEnumerable GetEnvironmentData(TransportSettings transportSettings) => []; } public abstract class TransportCustomization : ITransportCustomization where TTransport : TransportDefinition @@ -48,6 +50,8 @@ protected virtual void AddTransportForPrimaryCore(IServiceCollection services, T { } + public virtual IEnumerable GetEnvironmentData(TransportSettings transportSettings) => []; + public void AddTransportForAudit(IServiceCollection services, TransportSettings transportSettings) { services.AddSingleton(this); diff --git a/src/ServiceControl.Transports/TransportEnvironmentDatum.cs b/src/ServiceControl.Transports/TransportEnvironmentDatum.cs new file mode 100644 index 0000000000..52d7508af9 --- /dev/null +++ b/src/ServiceControl.Transports/TransportEnvironmentDatum.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Transports +{ + using System; + + public sealed record TransportEnvironmentDatum(string Key, Func ReadValue); +} diff --git a/src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs new file mode 100644 index 0000000000..7341595e2f --- /dev/null +++ b/src/ServiceControl.UnitTests/Licensing/ErrorInstanceTuningEnvironmentDataProviderTests.cs @@ -0,0 +1,84 @@ +namespace ServiceControl.UnitTests.Licensing; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.ServiceControl; +using ServiceBus.Management.Infrastructure.Settings; + +[TestFixture] +[NonParallelizable] +class ErrorInstanceTuningEnvironmentDataProviderTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_default_for_every_setting_left_unset() + { + var data = await GetData(); + + Assert.That(data.Values, Has.All.EqualTo("Default")); + } + + [Test] + public async Task Should_report_the_configured_value_in_the_unit_its_key_names() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_HEARTBEATGRACEPERIOD", "00:01:30"); + Environment.SetEnvironmentVariable("SERVICECONTROL_ERRORINGESTIONBATCHTIMEOUT", "00:00:00.250"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MAXIMUMCONCURRENCYLEVEL", "64"); + Environment.SetEnvironmentVariable("SERVICECONTROL_RETRYHISTORYDEPTH", "20"); + Environment.SetEnvironmentVariable("SERVICECONTROL_SHUTDOWNTIMEOUT", "00:02:00"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Heartbeats.GracePeriodSeconds"], Is.EqualTo("90")); + Assert.That(data["Ingestion.Error.BatchTimeoutMs"], Is.EqualTo("250")); + Assert.That(data["Ingestion.Error.MaxConcurrency"], Is.EqualTo("64")); + Assert.That(data["Recoverability.RetryHistoryDepth"], Is.EqualTo("20")); + Assert.That(data["Host.ShutdownTimeoutSeconds"], Is.EqualTo("120")); + Assert.That(data["Ingestion.Error.BatchSize"], Is.EqualTo("Default")); + } + } + + [Test] + public async Task Should_report_a_value_set_to_the_default_as_configured() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_RETRYHISTORYDEPTH", "10"); + + var data = await GetData(); + + Assert.That(data["Recoverability.RetryHistoryDepth"], Is.EqualTo("10")); + } + + static async Task> GetData() + { + var data = new Dictionary(); + + foreach (var datum in new ErrorInstanceTuningEnvironmentDataProvider(new Settings()).GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + static readonly string[] Variables = + [ + "SERVICECONTROL_HEARTBEATGRACEPERIOD", + "SERVICECONTROL_ERRORINGESTIONBATCHTIMEOUT", + "SERVICECONTROL_MAXIMUMCONCURRENCYLEVEL", + "SERVICECONTROL_RETRYHISTORYDEPTH", + "SERVICECONTROL_SHUTDOWNTIMEOUT" + ]; +} diff --git a/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs b/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs new file mode 100644 index 0000000000..dd52a9fd60 --- /dev/null +++ b/src/ServiceControl.UnitTests/Licensing/ServiceControlErrorInstanceEnvironmentDataProviderTests.cs @@ -0,0 +1,163 @@ +namespace ServiceControl.UnitTests.Licensing; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; +using Particular.ServiceControl; +using ServiceBus.Management.Infrastructure.Settings; +using ServiceControl.Notifications; +using ServiceControl.Persistence; + +[TestFixture] +[NonParallelizable] +class ServiceControlErrorInstanceEnvironmentDataProviderTests +{ + [TearDown] + public void TearDown() + { + foreach (var variable in Variables) + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + [Test] + public async Task Should_report_configuration_validation_disabled() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_VALIDATECONFIG", "false"); + + var data = await GetData(); + + Assert.That(data["Features.ConfigurationValidation"], Is.EqualTo("Disabled")); + } + + [Test] + public async Task Should_report_servicepulse_choices_as_not_applicable_when_it_is_not_integrated() + { + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["ServicePulse.MonitoringUrl"], Is.EqualTo("NotApplicable")); + Assert.That(data["ServicePulse.DefaultRoute"], Is.EqualTo("NotApplicable")); + Assert.That(data["ServicePulse.ShowPendingRetry"], Is.EqualTo("NotApplicable")); + } + } + + [Test] + public async Task Should_report_integrated_servicepulse_choices_without_their_values() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", "true"); + Environment.SetEnvironmentVariable("MONITORING_URL", "!"); + Environment.SetEnvironmentVariable("DEFAULT_ROUTE", "/contoso-failed-messages"); + Environment.SetEnvironmentVariable("SHOW_PENDING_RETRY", "true"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["ServicePulse.MonitoringUrl"], Is.EqualTo("Disabled")); + Assert.That(data["ServicePulse.DefaultRoute"], Is.EqualTo("Custom")); + Assert.That(data["ServicePulse.ShowPendingRetry"], Is.EqualTo("Enabled")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_a_custom_monitoring_url_for_integrated_servicepulse() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", "true"); + Environment.SetEnvironmentVariable("MONITORING_URL", "http://monitoring.contoso.local:33633/"); + + var data = await GetData(); + + Assert.That(data["ServicePulse.MonitoringUrl"], Is.EqualTo("Custom")); + } + + [Test] + public async Task Should_report_feature_switches_from_configuration() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_INGESTERRORMESSAGES", "false"); + Environment.SetEnvironmentVariable("SERVICECONTROL_VIRTUALDIRECTORY", "contoso"); + + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Features.ErrorIngestion"], Is.EqualTo("Disabled")); + Assert.That(data["Host.VirtualDirectory"], Is.EqualTo("Configured")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_otlp_metrics_export_without_the_endpoint() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://collector.contoso.local:4317" }) + .Build(); + + var data = await GetData(configuration); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Telemetry.OtlpMetrics"], Is.EqualTo("Enabled")); + Assert.That(Reported(data), Does.Not.Contain("contoso").IgnoreCase); + } + } + + [Test] + public async Task Should_report_logging_as_fixed_names() + { + var data = await GetData(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(data["Logging.Level"], Is.AnyOf("Trace", "Debug", "Information", "Warning", "Error", "Critical", "None")); + Assert.That(data["Logging.Providers"].Split(','), Is.SubsetOf(new[] { "NLog", "Seq", "Otlp", "None" })); + Assert.That(data["Telemetry.OtlpMetrics"], Is.EqualTo("Disabled")); + } + } + + static async Task> GetData(IConfiguration configuration = null) + { + var provider = new ServiceControlErrorInstanceEnvironmentDataProvider( + new Settings(), + new StoredNotifications(), + configuration ?? new ConfigurationBuilder().Build()); + + var data = new Dictionary(); + + foreach (var datum in provider.GetData()) + { + data[datum.Key] = await datum.ReadValue(CancellationToken.None); + } + + return data; + } + + static string Reported(Dictionary data) => string.Join("|", data.Values); + + class StoredNotifications : INotificationsDataStore + { + public Task LoadSettings(CancellationToken cancellationToken = default) => + Task.FromResult(new NotificationsSettings()); + + public Task SaveSettings(NotificationsSettings settings, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + static readonly string[] Variables = + [ + "SERVICECONTROL_VALIDATECONFIG", + "SERVICECONTROL_ENABLEINTEGRATEDSERVICEPULSE", + "MONITORING_URL", + "DEFAULT_ROUTE", + "SHOW_PENDING_RETRY", + "SERVICECONTROL_INGESTERRORMESSAGES", + "SERVICECONTROL_VIRTUALDIRECTORY" + ]; +} diff --git a/src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs b/src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs new file mode 100644 index 0000000000..0c1f7fbbb8 --- /dev/null +++ b/src/ServiceControl/ErrorInstanceStoredChoicesEnvironmentDataProvider.cs @@ -0,0 +1,57 @@ +namespace Particular.ServiceControl; + +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using global::ServiceControl.Persistence; +using global::ServiceControl.Persistence.MessageRedirects; +using Particular.LicensingComponent.Contracts; +using Particular.LicensingComponent.Persistence; +using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class ErrorInstanceStoredChoicesEnvironmentDataProvider( + Settings settings, + IEndpointSettingsStore endpointSettingsStore, + IEndpointInstanceMonitoring endpointInstanceMonitoring, + IMessageRedirectsDataStore messageRedirectsDataStore, + ILicensingDataStore licensingDataStore) : IEnvironmentDataProvider +{ + public IEnumerable GetData() + { + Task tracking = null; + Task Tracking(CancellationToken cancellationToken) => tracking ??= ReadInstanceTracking(cancellationToken); + + return + [ + Deferred("Heartbeats.TrackInstancesDefault", async cancellationToken => (await Tracking(cancellationToken)).TrackByDefault ? "Enabled" : "Disabled"), + Deferred("Heartbeats.TrackInstancesOverrides", async cancellationToken => Count((await Tracking(cancellationToken)).Overrides)), + Value("Heartbeats.KnownInstances", () => Count(endpointInstanceMonitoring.GetEndpoints().Length)), + Value("Heartbeats.MonitoredInstances", () => Count(endpointInstanceMonitoring.GetEndpoints().Count(endpoint => endpoint.Monitored))), + Deferred("Recoverability.Redirects", async cancellationToken => Count((await messageRedirectsDataStore.GetRedirects(cancellationToken)).Count)), + Deferred("Licensing.ReportMasks", async cancellationToken => Count((await licensingDataStore.GetReportMasks(cancellationToken)).Count)) + ]; + } + + async Task ReadInstanceTracking(CancellationToken cancellationToken) + { + var endpointSettings = new List(); + + await foreach (var endpoint in endpointSettingsStore.GetAllEndpointSettings(cancellationToken)) + { + endpointSettings.Add(endpoint); + } + + var defaultRow = endpointSettings.FirstOrDefault(endpoint => endpoint.Name == string.Empty); + var trackByDefault = defaultRow?.TrackInstances ?? settings.TrackInstancesInitialValue; + var overrides = endpointSettings.Count(endpoint => endpoint.Name != string.Empty && endpoint.TrackInstances != trackByDefault); + + return new InstanceTracking(trackByDefault, overrides); + } + + static string Count(int count) => count.ToString(CultureInfo.InvariantCulture); + + record InstanceTracking(bool TrackByDefault, int Overrides); +} diff --git a/src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs b/src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs new file mode 100644 index 0000000000..03136ad692 --- /dev/null +++ b/src/ServiceControl/ErrorInstanceTuningEnvironmentDataProvider.cs @@ -0,0 +1,36 @@ +namespace Particular.ServiceControl; + +using System; +using System.Collections.Generic; +using System.Globalization; +using global::ServiceControl.Configuration; +using Particular.LicensingComponent.Contracts; +using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class ErrorInstanceTuningEnvironmentDataProvider(Settings settings) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + Value("Host.ShutdownTimeoutSeconds", () => WhenConfigured(Settings.ShutdownTimeoutKey, () => Seconds(settings.ShutdownTimeout))), + Value("Limits.ExternalIntegrationsBatchSize", () => WhenConfigured(Settings.ExternalIntegrationsDispatchingBatchSizeKey, () => Number(settings.ExternalIntegrationsDispatchingBatchSize))), + Value("Ingestion.Error.MaxConcurrency", () => WhenConfigured(Settings.MaximumConcurrencyLevelKey, () => Number(settings.MaximumConcurrencyLevel))), + Value("Ingestion.Error.BatchSize", () => WhenConfigured(nameof(Settings.ErrorIngestionBatchSize), () => Number(settings.ErrorIngestionBatchSize))), + Value("Ingestion.Error.MaxParallelWriters", () => WhenConfigured(nameof(Settings.ErrorIngestionMaxParallelWriters), () => Number(settings.ErrorIngestionMaxParallelWriters))), + Value("Ingestion.Error.BatchTimeoutMs", () => WhenConfigured(nameof(Settings.ErrorIngestionBatchTimeout), () => Milliseconds(settings.ErrorIngestionBatchTimeout))), + Value("Ingestion.Error.RestartAfterFailureSeconds", () => WhenConfigured(Settings.TimeToRestartErrorIngestionAfterFailureKey, () => Seconds(settings.TimeToRestartErrorIngestionAfterFailure))), + Value("Heartbeats.GracePeriodSeconds", () => WhenConfigured(Settings.HeartbeatGracePeriodKey, () => Seconds(settings.HeartbeatGracePeriod))), + Value("Recoverability.RetryHistoryDepth", () => WhenConfigured(Settings.RetryHistoryDepthKey, () => Number(settings.RetryHistoryDepth))) + ]; + + static string WhenConfigured(string key, Func readValue) => + SettingsReader.TryRead(Settings.SettingsRootNamespace, key, out _) ? readValue() : "Default"; + + static string Number(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? "Unknown"; + + static string Seconds(TimeSpan value) => + Math.Round(value.TotalSeconds, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); + + static string Milliseconds(TimeSpan value) => + Math.Round(value.TotalMilliseconds, MidpointRounding.AwayFromZero).ToString("F0", CultureInfo.InvariantCulture); +} diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs index cc43874a16..6b3ac7c6e0 100644 --- a/src/ServiceControl/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs @@ -84,6 +84,9 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S services.AddSingleton(settings); services.AddEnvironmentDataProvider(); services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); + services.AddEnvironmentDataProvider(); services.AddHttpLogging(options => { diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index af52f4bf88..d4da55d4de 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -65,8 +65,8 @@ public Settings( } ProcessRetryBatchesFrequency = TimeSpan.FromSeconds(30); - MaximumConcurrencyLevel = SettingsReader.Read(SettingsRootNamespace, "MaximumConcurrencyLevel"); - RetryHistoryDepth = SettingsReader.Read(SettingsRootNamespace, "RetryHistoryDepth", 10); + MaximumConcurrencyLevel = SettingsReader.Read(SettingsRootNamespace, MaximumConcurrencyLevelKey); + RetryHistoryDepth = SettingsReader.Read(SettingsRootNamespace, RetryHistoryDepthKey, 10); AllowMessageEditing = SettingsReader.Read(SettingsRootNamespace, "AllowMessageEditing"); EnableIntegratedServicePulse = SettingsReader.Read(SettingsRootNamespace, "EnableIntegratedServicePulse", false); if (EnableIntegratedServicePulse) @@ -86,7 +86,7 @@ public Settings( ErrorIngestionBatchTimeout = IngestionSettingsReader.ReadBatchTimeout(SettingsRootNamespace, nameof(ErrorIngestionBatchTimeout), ValidateConfiguration); DisableExternalIntegrationsPublishing = SettingsReader.Read(SettingsRootNamespace, "DisableExternalIntegrationsPublishing", false); TrackInstancesInitialValue = SettingsReader.Read(SettingsRootNamespace, "TrackInstancesInitialValue", true); - ShutdownTimeout = SettingsReader.Read(SettingsRootNamespace, "ShutdownTimeout", ShutdownTimeout); + ShutdownTimeout = SettingsReader.Read(SettingsRootNamespace, ShutdownTimeoutKey, ShutdownTimeout); AssemblyLoadContextResolver = static assemblyPath => new PluginAssemblyLoadContext(assemblyPath); } @@ -130,7 +130,7 @@ public Settings( public bool ValidateConfiguration => SettingsReader.Read(SettingsRootNamespace, "ValidateConfig", true); - public int ExternalIntegrationsDispatchingBatchSize => SettingsReader.Read(SettingsRootNamespace, "ExternalIntegrationsDispatchingBatchSize", 100); + public int ExternalIntegrationsDispatchingBatchSize => SettingsReader.Read(SettingsRootNamespace, ExternalIntegrationsDispatchingBatchSizeKey, 100); public bool DisableExternalIntegrationsPublishing { get; set; } @@ -381,7 +381,7 @@ TimeSpan GetHeartbeatGracePeriod() { try { - return TimeSpan.Parse(SettingsReader.Read(SettingsRootNamespace, "HeartbeatGracePeriod", "00:00:40")); + return TimeSpan.Parse(SettingsReader.Read(SettingsRootNamespace, HeartbeatGracePeriodKey, "00:00:40")); } catch (Exception ex) { @@ -393,7 +393,7 @@ TimeSpan GetHeartbeatGracePeriod() TimeSpan GetTimeToRestartErrorIngestionAfterFailure() { string message; - var valueRead = SettingsReader.Read(SettingsRootNamespace, "TimeToRestartErrorIngestionAfterFailure"); + var valueRead = SettingsReader.Read(SettingsRootNamespace, TimeToRestartErrorIngestionAfterFailureKey); if (valueRead == null) { return TimeSpan.FromSeconds(60); @@ -483,6 +483,12 @@ void LoadErrorIngestionSettings() readonly ILogger logger = LoggerUtil.CreateStaticLogger(); public const string DEFAULT_INSTANCE_NAME = "Particular.ServiceControl"; + public const string MaximumConcurrencyLevelKey = "MaximumConcurrencyLevel"; + public const string RetryHistoryDepthKey = "RetryHistoryDepth"; + public const string ShutdownTimeoutKey = "ShutdownTimeout"; + public const string ExternalIntegrationsDispatchingBatchSizeKey = "ExternalIntegrationsDispatchingBatchSize"; + public const string HeartbeatGracePeriodKey = "HeartbeatGracePeriod"; + public const string TimeToRestartErrorIngestionAfterFailureKey = "TimeToRestartErrorIngestionAfterFailure"; public static readonly SettingsRootNamespace SettingsRootNamespace = new("ServiceControl"); } } \ No newline at end of file diff --git a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs index 4e73a2a1f0..6fa63c0606 100644 --- a/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs +++ b/src/ServiceControl/ServiceControlErrorInstanceEnvironmentDataProvider.cs @@ -3,25 +3,34 @@ namespace Particular.ServiceControl; using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using global::ServiceControl.Infrastructure; using global::ServiceControl.Persistence; +using Microsoft.Extensions.Configuration; using Particular.LicensingComponent.Contracts; using ServiceBus.Management.Infrastructure.Settings; using static Particular.LicensingComponent.Contracts.EnvironmentDatum; -class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore) : IEnvironmentDataProvider +class ServiceControlErrorInstanceEnvironmentDataProvider(Settings settings, INotificationsDataStore notificationsDataStore, IConfiguration configuration) : IEnvironmentDataProvider { 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)), + Value("Features.ErrorIngestion", () => Toggle(settings.IngestErrorMessages)), + Value("Features.ConfigurationValidation", () => Toggle(settings.ValidateConfiguration)), Deferred("Features.EmailNotifications", EmailNotifications), + Value("ServicePulse.MonitoringUrl", ServicePulseMonitoringUrl), + Value("ServicePulse.DefaultRoute", () => WhenServicePulseIntegrated(() => EnvironmentVariableSet(ServicePulseDefaultRouteVariable) ? "Custom" : "Default")), + Value("ServicePulse.ShowPendingRetry", () => WhenServicePulseIntegrated(() => Toggle(settings.ServicePulseSettings.ShowPendingRetry))), + Value("Host.VirtualDirectory", () => string.IsNullOrEmpty(settings.VirtualDirectory) ? "None" : "Configured"), + Value("Logging.Providers", LoggingProviders), + Value("Logging.Level", () => settings.LoggingSettings.LogLevel.ToString()), + Value("Telemetry.OtlpMetrics", () => Toggle(OtlpEndpoint.Read(configuration) is not null)), Value("Retention.ErrorHours", () => Hours(settings.ErrorRetentionPeriod)), Value("Retention.EventsHours", () => Hours(settings.EventsRetentionPeriod)) ]; @@ -42,4 +51,29 @@ async ValueTask EmailNotifications(CancellationToken cancellationToken) return string.IsNullOrWhiteSpace(notificationsSettings.Email.SmtpServer) ? "NotConfigured" : "Disabled"; } + + string ServicePulseMonitoringUrl() => WhenServicePulseIntegrated(() => + { + if (settings.ServicePulseSettings.MonitoringUrl is null) + { + return "Disabled"; + } + + return EnvironmentVariableSet(ServicePulseMonitoringUrlVariable) || EnvironmentVariableSet(ServicePulseLegacyMonitoringUrlsVariable) ? "Custom" : "Default"; + }); + + string WhenServicePulseIntegrated(Func readValue) => settings.EnableIntegratedServicePulse ? readValue() : NotApplicable; + + static bool EnvironmentVariableSet(string name) => Environment.GetEnvironmentVariable(name) is not null; + + static string LoggingProviders() + { + var providers = new[] { Loggers.NLog, Loggers.Seq, Loggers.Otlp }.Where(LoggerUtil.IsLoggingTo).ToArray(); + return providers.Length == 0 ? "None" : string.Join(",", providers); + } + + const string NotApplicable = "NotApplicable"; + const string ServicePulseMonitoringUrlVariable = "MONITORING_URL"; + const string ServicePulseLegacyMonitoringUrlsVariable = "MONITORING_URLS"; + const string ServicePulseDefaultRouteVariable = "DEFAULT_ROUTE"; } diff --git a/src/ServiceControl/TransportEnvironmentDataProvider.cs b/src/ServiceControl/TransportEnvironmentDataProvider.cs new file mode 100644 index 0000000000..c2606ae6b0 --- /dev/null +++ b/src/ServiceControl/TransportEnvironmentDataProvider.cs @@ -0,0 +1,17 @@ +namespace Particular.ServiceControl; + +using System.Collections.Generic; +using System.Linq; +using global::ServiceControl.Transports; +using Particular.LicensingComponent.Contracts; +using ServiceBus.Management.Infrastructure.Settings; +using static Particular.LicensingComponent.Contracts.EnvironmentDatum; + +class TransportEnvironmentDataProvider(Settings settings, ITransportCustomization transportCustomization, TransportSettings transportSettings) : IEnvironmentDataProvider +{ + public IEnumerable GetData() => + [ + Value("Transport.Type", () => TransportManifestLibrary.Find(settings.TransportType)?.Name ?? "Unknown"), + .. transportCustomization.GetEnvironmentData(transportSettings).Select(datum => Value(datum.Key, datum.ReadValue)) + ]; +}