diff --git a/src/Particular.LicensingComponent.Contracts/EndpointThroughputSummary.cs b/src/Particular.LicensingComponent.Contracts/EndpointThroughputSummary.cs index 7860ae8eec..f42271d3c1 100644 --- a/src/Particular.LicensingComponent.Contracts/EndpointThroughputSummary.cs +++ b/src/Particular.LicensingComponent.Contracts/EndpointThroughputSummary.cs @@ -4,11 +4,12 @@ public class EndpointThroughputSummary { public string Name { get; set; } + public string NameHash { get; set; } public bool IsKnownEndpoint { get; set; } public string UserIndicator { get; set; } public long MaxDailyThroughput { get; set; } public MonthlyThroughput[] MonthlyThroughput { get; set; } - public long MaxMonthlyThroughput { get; set; } + public long AverageMonthlyThroughput { get; set; } } public class UpdateUserIndicator diff --git a/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs b/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs new file mode 100644 index 0000000000..7d518fc60e --- /dev/null +++ b/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs @@ -0,0 +1,33 @@ +namespace Particular.LicensingComponent.Contracts +{ + public class LicensedEndpointDetails + { + public string? LicenseId { get; set; } + public LicensedEndpoint[] Endpoints { get; set; } = []; + public QueueIdentity[] InfrastructureQueues { get; set; } = []; + public QueueIdentity[] ExcludedQueues { get; set; } = []; + public string? ServiceEndDate { get; set; } + public Product[] Products { get; set; } = []; + public bool ValidId { get; set; } + } + + public class Product + { + public string? ProductCode { get; set; } + public int? MonthlyThroughput { get; set; } + } + + public class QueueIdentity + { + public string? NameHash { get; set; } + public string? Scope { get; set; } + } + + public class LicensedEndpoint + { + public string? Name { get; set; } + public int? Classification { get; set; } + public string? EndpointSize { get; set; } + public QueueIdentity[] Queues { get; set; } = []; + } +} diff --git a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs index 816dd605b2..46639e6305 100644 --- a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs @@ -15,6 +15,7 @@ public class InMemoryLicensingDataStore : ILicensingDataStore BrokerMetadata brokerMetadata = new(null, []); AuditServiceMetadata auditServiceMetadata = new([], []); List reportMasks = []; + LicensedEndpointDetails? endpointDetails = null; public Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken) { @@ -170,6 +171,14 @@ public Task SaveReportMasks(List reportMasks, CancellationToken cancella return Task.CompletedTask; } + public Task GetLicensedEndpointDetails(CancellationToken cancellationToken) => Task.FromResult(endpointDetails); + + public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken) + { + endpointDetails = result; + return Task.CompletedTask; + } + class EndpointCollection : KeyedCollection { protected override EndpointIdentifier GetKeyForItem(Endpoint item) => item.Id; diff --git a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs index 9a1b340aa3..3a73a50891 100644 --- a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs @@ -36,4 +36,7 @@ Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSo Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken); Task> GetReportMasks(CancellationToken cancellationToken); Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken); + + Task GetLicensedEndpointDetails(CancellationToken cancellationToken); + Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_ThroughputSummary_Tests.cs b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_ThroughputSummary_Tests.cs index 9f7f44b0a9..b3007fe41f 100644 --- a/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_ThroughputSummary_Tests.cs +++ b/src/Particular.LicensingComponent.UnitTests/ThroughputCollector/ThroughputCollector_ThroughputSummary_Tests.cs @@ -171,8 +171,8 @@ await DataStore.CreateBuilder() using (Assert.EnterMultipleScope()) { - Assert.That(summary.First(w => w.Name == "Endpoint1").MaxMonthlyThroughput, Is.EqualTo(250), $"Incorrect MaxDailyThroughput recorded for Endpoint1"); - Assert.That(summary.First(w => w.Name == "Endpoint2").MaxMonthlyThroughput, Is.EqualTo(165), $"Incorrect MaxDailyThroughput recorded for Endpoint2"); + Assert.That(summary.First(w => w.Name == "Endpoint1").AverageMonthlyThroughput, Is.EqualTo(2694), $"Incorrect AverageMonthlyThroughput recorded for Endpoint1"); + Assert.That(summary.First(w => w.Name == "Endpoint2").AverageMonthlyThroughput, Is.EqualTo(2585), $"Incorrect AverageMonthlyThroughput recorded for Endpoint2"); } } diff --git a/src/Particular.LicensingComponent/ThroughputCollector.cs b/src/Particular.LicensingComponent/ThroughputCollector.cs index 925cc203c0..71f4012eee 100644 --- a/src/Particular.LicensingComponent/ThroughputCollector.cs +++ b/src/Particular.LicensingComponent/ThroughputCollector.cs @@ -70,11 +70,12 @@ public async Task> GetThroughputSummary(Cancella var endpointSummary = new EndpointThroughputSummary { Name = endpointData.Name, + NameHash = OneWayHasher.CalculateOneWayHash(endpointData.Name), UserIndicator = endpointData.UserIndicator ?? (endpointData.IsKnownEndpoint ? Contracts.UserIndicator.NServiceBusEndpoint.ToString() : string.Empty), IsKnownEndpoint = endpointData.IsKnownEndpoint, MaxDailyThroughput = endpointData.ThroughputData.MaxDailyThroughput(), MonthlyThroughput = endpointData.ThroughputData.MonthlyThroughput(), - MaxMonthlyThroughput = endpointData.ThroughputData.MaxMonthlyThroughput() + AverageMonthlyThroughput = endpointData.ThroughputData.AverageMonthlyThroughput() }; endpointSummaries.Add(endpointSummary); diff --git a/src/Particular.LicensingComponent/ThroughputDataExtensions.cs b/src/Particular.LicensingComponent/ThroughputDataExtensions.cs index 6d54edb6c9..ce19589692 100644 --- a/src/Particular.LicensingComponent/ThroughputDataExtensions.cs +++ b/src/Particular.LicensingComponent/ThroughputDataExtensions.cs @@ -25,23 +25,29 @@ public static long MaxDailyThroughput(this List throughputs) public static MonthlyThroughput[] MonthlyThroughput(this List throughputs) => [.. throughputs .SelectMany(data => data) - .GroupBy(kvp => $"{kvp.Key:yyyy-MM}") - .Select(group => new MonthlyThroughput(group.Key, group.Sum(kvp => kvp.Value)))]; - - public static long MaxMonthlyThroughput(this List throughputs) + // Older SQL Reports could return a negative value for daily throughput. These are not valid. See https://github.com/Particular/ServiceControl/pull/5404 + .Where(x => x.Value >= 0) + .GroupBy(x => x.Key, x => x.Value) + .ToLookup(x => x.Key, x => x.Max()) + .GroupBy(kvp => $"{kvp.Key:yyyy-MM}", x => x.Sum()) + .Select(group => new MonthlyThroughput(group.Key, group.Sum()))]; + + public static long AverageMonthlyThroughput(this List throughputs) { - var monthlySums = throughputs - .SelectMany(data => data) - .GroupBy(kvp => $"{kvp.Key.Year}-{kvp.Key.Month}") - .Select(group => group.Sum(kvp => kvp.Value)) - .ToArray(); - - if (monthlySums.Any()) + if (!throughputs.Any(x => x.Any())) { - return monthlySums.Max(); + return 0; } - return 0; + // keep this in sync with the internal licensing calculation + var maxDailyThroughput = throughputs + .SelectMany(x => x) + .Where(x => x.Value >= 0) + .GroupBy(x => x.Key, x => x.Value) + .ToLookup(x => x.Key, x => x.Max()) + .ToDictionary(x => x.Key, x => x.Sum()); + + return (long)Math.Truncate(maxDailyThroughput.Sum(x => x.Value) / (decimal)maxDailyThroughput.Count * 365 / 12); } public static bool HasDataFromSource(this IDictionary> throughputPerQueue, ThroughputSource source) => diff --git a/src/ServiceControl.LicenseManagement/LicenseDetails.cs b/src/ServiceControl.LicenseManagement/LicenseDetails.cs index 970b65e1cb..72f9ca429f 100644 --- a/src/ServiceControl.LicenseManagement/LicenseDetails.cs +++ b/src/ServiceControl.LicenseManagement/LicenseDetails.cs @@ -7,6 +7,7 @@ public class LicenseDetails { + public string Id { get; private init; } public DateTime? ExpirationDate { get; private init; } public DateTime? UpgradeProtectionExpiration { get; private init; } @@ -67,6 +68,7 @@ internal static LicenseDetails FromLicense(License license) var details = new LicenseDetails { + Id = license.LicenseId, UpgradeProtectionExpiration = license.UpgradeProtectionExpiration, //If expiration date is greater that 50 years treat is as no expiration date ExpirationDate = license.ExpirationDate.HasValue diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index 03a0c1e6fe..acf8fe9102 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -9,6 +9,7 @@ class LicensingDataStore : ILicensingDataStore public Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task>> GetEndpointThroughputByQueueName(IList queueNames, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task GetLicensedEndpointDetails(CancellationToken cancellationToken) => throw new NotImplementedException(); public Task> GetReportMasks(CancellationToken cancellationToken) => throw new NotImplementedException(); public Task IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken) => throw new NotImplementedException(); @@ -16,6 +17,7 @@ class LicensingDataStore : ILicensingDataStore public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SaveBrokerMetadata(BrokerMetadata brokerMetadata, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SaveEndpoint(Particular.LicensingComponent.Contracts.Endpoint endpoint, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken) => throw new NotImplementedException(); public Task UpdateUserIndicatorOnEndpoints(List userIndicatorUpdates, CancellationToken cancellationToken) => throw new NotImplementedException(); } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs index 287ffb5a56..26b666cdcb 100644 --- a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs @@ -23,6 +23,7 @@ class LicensingDataStore( const string AuditServiceMetadataDocumentId = "AuditServiceMetadata"; const string BrokerMetadataDocumentId = "BrokerMetadata"; const string ReportMasksDocumentId = "ReportMasks"; + const string LicencedEndpointDetailsDocumentId = "LicensedEndpointDetails"; static readonly AuditServiceMetadata DefaultAuditServiceMetadata = new([], []); static readonly BrokerMetadata DefaultBrokerMetadata = new(null, []); @@ -316,4 +317,21 @@ public async Task SaveReportMasks(List reportMasks, CancellationToken ca await session.StoreAsync(new ReportConfigurationDocument { MaskedStrings = reportMasks }, ReportMasksDocumentId, cancellationToken); await session.SaveChangesAsync(cancellationToken); } + + public async Task GetLicensedEndpointDetails(CancellationToken cancellationToken) + { + var store = await storeProvider.GetDocumentStore(cancellationToken); + using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); + + return await session.LoadAsync(LicencedEndpointDetailsDocumentId, cancellationToken); + } + + public async Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken) + { + var store = await storeProvider.GetDocumentStore(cancellationToken); + using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); + + await session.StoreAsync(result, LicencedEndpointDetailsDocumentId, cancellationToken); + await session.SaveChangesAsync(cancellationToken); + } } \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 5a0176d2f5..8dbb69897d 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -42,6 +42,8 @@ GET /eventlogitems => ServiceControl.EventLog.EventLogApiController:Items(Paging GET /heartbeats/stats => ServiceControl.Monitoring.EndpointsMonitoringController:HeartbeatStats() GET /instance-info => ServiceControl.Infrastructure.WebApi.RootController:Config() GET /license => ServiceControl.Licensing.LicenseController:License(Boolean refresh, String clientName, CancellationToken cancellationToken) +GET /license/details => ServiceControl.Licensing.LicenseController:LicenseDetails(CancellationToken cancellationToken) +POST /license/detailsUpload => ServiceControl.Licensing.LicenseController:UploadLicenseDetails(IFormFile file, CancellationToken cancellationToken) GET /messages => ServiceControl.CompositeViews.Messages.GetMessagesController:Messages(PagingInfo pagingInfo, SortInfo sortInfo, Boolean includeSystemMessages) GET /messages/{id}/body => ServiceControl.CompositeViews.Messages.GetMessagesController:Get(String id, String instanceId) GET /messages/search => ServiceControl.CompositeViews.Messages.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q) diff --git a/src/ServiceControl/Licensing/LicenseController.cs b/src/ServiceControl/Licensing/LicenseController.cs index 9dc7abe437..561ad09178 100644 --- a/src/ServiceControl/Licensing/LicenseController.cs +++ b/src/ServiceControl/Licensing/LicenseController.cs @@ -1,18 +1,26 @@ -namespace ServiceControl.Licensing +#nullable enable +namespace ServiceControl.Licensing { + using System; + using System.IO; + using System.IO.Compression; + using System.Text; + using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Infrastructure.Auth; using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Monitoring.HeartbeatMonitoring; + using Particular.LicensingComponent.Contracts; + using Particular.LicensingComponent.Persistence; using Particular.ServiceControl.Licensing; using ServiceBus.Management.Infrastructure.Settings; - using ServiceControl.LicenseManagement; [ApiController] [Route("api")] - public class LicenseController(ActiveLicense activeLicense, Settings settings, MassTransitConnectorHeartbeatStatus connectorHeartbeatStatus) : ControllerBase + public class LicenseController(ActiveLicense activeLicense, Settings settings, MassTransitConnectorHeartbeatStatus connectorHeartbeatStatus, ILicensingDataStore dataStore) : ControllerBase { [Authorize(Policy = Permissions.ErrorLicensingView)] [HttpGet] @@ -44,29 +52,40 @@ public async Task> License(bool refresh, string client return licenseInfo; } - public class LicenseInfo + [Authorize(Policy = Permissions.ErrorLicensingView)] + [HttpGet] + [Route("license/details")] + public async Task> LicenseDetails(CancellationToken cancellationToken) { - public bool TrialLicense { get; set; } - - public string Edition { get; set; } - - public string RegisteredTo { get; set; } - - public string UpgradeProtectionExpiration { get; set; } - - public string ExpirationDate { get; set; } - - public string Status { get; set; } - - public LicensedProduct[] Products { get; set; } - - public string LicenseType { get; set; } + var licenseDetails = await dataStore.GetLicensedEndpointDetails(cancellationToken); + if (licenseDetails is null) + { + return (LicensedEndpointDetails?)null; + } - public string InstanceName { get; set; } + licenseDetails.ValidId = licenseDetails.LicenseId == activeLicense.Details.Id; + return licenseDetails; + } - public string LicenseStatus { get; set; } + [Authorize(Policy = Permissions.ErrorLicensingManage)] + [HttpPost] + [Route("license/detailsUpload")] + public async Task UploadLicenseDetails([FromForm] IFormFile file, CancellationToken cancellationToken) + { + //perform date and license id checks + using var fileStream = file.OpenReadStream(); + using var fileStreamReader = new StreamReader(fileStream); + var compressed = await fileStreamReader.ReadToEndAsync(cancellationToken); + var compressedData = Convert.FromBase64String(compressed); + using var memoryStream = new MemoryStream(compressedData); + using var brotliStream = new BrotliStream(memoryStream, CompressionMode.Decompress); + using var reader = new StreamReader(brotliStream, Encoding.UTF8); - public string LicenseExtensionUrl { get; set; } + var fileContents = reader.ReadToEnd(); + var result = JsonSerializer.Deserialize(fileContents, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidDataException("File contents cannot be deserialized"); + //persist + await dataStore.SaveLicensedEndpointDetails(result, cancellationToken); } } } \ No newline at end of file diff --git a/src/ServiceControl/Licensing/LicenseControllerTypes.cs b/src/ServiceControl/Licensing/LicenseControllerTypes.cs new file mode 100644 index 0000000000..5a4a7f9d79 --- /dev/null +++ b/src/ServiceControl/Licensing/LicenseControllerTypes.cs @@ -0,0 +1,29 @@ +namespace ServiceControl.Licensing +{ + using ServiceControl.LicenseManagement; + + public class LicenseInfo + { + public bool TrialLicense { get; set; } + + public string Edition { get; set; } + + public string RegisteredTo { get; set; } + + public string UpgradeProtectionExpiration { get; set; } + + public string ExpirationDate { get; set; } + + public string Status { get; set; } + + public LicensedProduct[] Products { get; set; } + + public string LicenseType { get; set; } + + public string InstanceName { get; set; } + + public string LicenseStatus { get; set; } + + public string LicenseExtensionUrl { get; set; } + } +}