From acbfaeddbef8494d2aad67c48a78eec3dee489f3 Mon Sep 17 00:00:00 2001 From: Phil Bastian Date: Tue, 13 Oct 2026 14:53:54 +0800 Subject: [PATCH 1/6] storage and application of licencedetails amendment data to support management of license metadata in ServicePulse --- .../LicensedEndpointDetails.cs | 27 ++++++++++- .../InMemoryLicensingDataStore.cs | 10 +++- .../ILicensingDataStore.cs | 1 + .../LicenseMetadata.cs | 46 +++++++++++++++++++ .../MonitoringService_Tests.cs | 1 + .../Implementation/LicensingDataStore.cs | 21 +++++++-- .../Infrastructure/SettingKeys.cs | 1 + .../Throughput/LicensingDataStore.cs | 22 ++++++++- .../Licensing/LicenseController.cs | 24 ++++++++++ 9 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs diff --git a/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs b/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs index c54c9611e7..7ecd1b3490 100644 --- a/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs +++ b/src/Particular.LicensingComponent.Contracts/LicensedEndpointDetails.cs @@ -6,28 +6,51 @@ public class LicensedEndpointDetails public LicensedEndpoint[] Endpoints { get; set; } = []; public QueueIdentity[] InfrastructureQueues { get; set; } = []; public QueueIdentity[] ExcludedQueues { get; set; } = []; + public QueueIdentity[] UnassignedQueues { get; set; } = []; public string? ServiceEndDate { get; set; } public Product[] Products { get; set; } = []; public bool ValidId { get; set; } } + public class LicensedEndpointDetailsAmendment + { + public LicensedEndpointAmendment[] Endpoints { get; set; } = []; + public string[] RemovedEndpointIds { get; set; } = []; + public QueueIdentity[] InfrastructureQueues { get; set; } = []; + public QueueIdentity[] ExcludedQueues { get; set; } = []; + public QueueIdentity[] UnassignedQueues { get; set; } = []; + public QueueIdentity[] UnmappedQueues { get; set; } = []; + + } + public class Product { public string? ProductCode { get; set; } public int? MonthlyThroughput { get; set; } } - public class QueueIdentity + public record QueueIdentity { public string? NameHash { get; set; } public string? ScopeHash { get; set; } + public string? OriginalLocation { get; set; } } - public class LicensedEndpoint + public record LicensedEndpoint { + public string? ClientId { get; set; } public string? Name { get; set; } public int? Classification { get; set; } public string? EndpointSize { get; set; } + public QueueIdentity[] Queues { get; set; } = []; } + + public class LicensedEndpointAmendment + { + public string? ClientId { get; set; } + public string? Name { get; set; } + public int? Classification { get; set; } + public QueueIdentity[] AmendedQueues { get; set; } = []; + } } diff --git a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs index 257eef677a..250b0028bf 100644 --- a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs @@ -16,6 +16,7 @@ public class InMemoryLicensingDataStore : ILicensingDataStore AuditServiceMetadata auditServiceMetadata = new([], []); List reportMasks = []; LicensedEndpointDetails? endpointDetails = null; + LicensedEndpointDetailsAmendment? endpointDetailsAmendment = null; public Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken = default) { @@ -175,11 +176,18 @@ public Task SaveReportMasks(List reportMasks, CancellationToken cancella return Task.CompletedTask; } - public Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default) => Task.FromResult(endpointDetails); + public Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default) => Task.FromResult(endpointDetails?.Amend(endpointDetailsAmendment)); public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) { endpointDetails = result; + endpointDetailsAmendment = new LicensedEndpointDetailsAmendment(); + return Task.CompletedTask; + } + + public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) + { + endpointDetailsAmendment = result; return Task.CompletedTask; } diff --git a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs index 9dc65e1caf..dab80b5fc4 100644 --- a/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence/ILicensingDataStore.cs @@ -41,4 +41,5 @@ Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSo Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default); Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default); + Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs b/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs new file mode 100644 index 0000000000..572f2a1710 --- /dev/null +++ b/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs @@ -0,0 +1,46 @@ +namespace Particular.LicensingComponent.Persistence +{ + using System; + using Particular.LicensingComponent.Contracts; + + public static class LicenseMetadata + { + extension(LicensedEndpointDetails baseDetails) + { + public LicensedEndpointDetails Amend(LicensedEndpointDetailsAmendment? amendments) + { + if (amendments is null) + { + return baseDetails; + } + + //Note: baseDetails.UnassignedQueues will always be empty before amendments + baseDetails.UnassignedQueues = amendments.UnassignedQueues; + baseDetails.ExcludedQueues = [.. baseDetails.ExcludedQueues.Except([.. amendments.UnassignedQueues, .. amendments.UnmappedQueues, .. amendments.InfrastructureQueues, .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), .. amendments.ExcludedQueues]; + baseDetails.InfrastructureQueues = [.. baseDetails.InfrastructureQueues.Except([.. amendments.UnassignedQueues, .. amendments.UnmappedQueues, .. amendments.ExcludedQueues, .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), .. amendments.InfrastructureQueues]; + baseDetails.Endpoints = [ + .. baseDetails.Endpoints.Where(endpoint => !amendments.RemovedEndpointIds.Contains(endpoint.ClientId)) + .Select(endpoint => + { + var amendment = amendments.Endpoints.FirstOrDefault(amendment => amendment.ClientId == endpoint.ClientId); + return endpoint with + { + Name = amendment?.Name ?? endpoint.Name, + Classification = amendment?.Classification ?? endpoint.Classification, + Queues = [.. endpoint.Queues.Except([.. amendments.UnassignedQueues, .. amendments.UnmappedQueues, .. amendments.ExcludedQueues, .. amendments.InfrastructureQueues, .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), .. amendment?.AmendedQueues ?? []] + }; + }), + .. amendments.Endpoints.Where(endpoint => !baseDetails.Endpoints.Any(baseEndpoint => baseEndpoint.ClientId == endpoint.ClientId)) + .Select(endpoint => new LicensedEndpoint + { + ClientId = endpoint.ClientId, + Name = endpoint.Name, + Classification = endpoint.Classification, + Queues = endpoint.AmendedQueues + })]; + + return baseDetails; + } + } + } +} diff --git a/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs b/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs index a4d39e1b1c..1b4104f5be 100644 --- a/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs +++ b/src/Particular.LicensingComponent.UnitTests/MonitoringService_Tests.cs @@ -264,6 +264,7 @@ public Task GetLicensedEndpointDetails(CancellationToke public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task RemoveEndpoints(EndpointIdentifier[] endpointIds, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) => throw new NotImplementedException(); } class BrokerThroughputQuery_WithSanitization : IBrokerThroughputQuery diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index 2683c5069c..463a838d82 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -4,7 +4,6 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Microsoft.Extensions.DependencyInjection; using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.Persistence; -using ServiceControl.Persistence.EFCore.DbContexts; using ServiceControl.Persistence.EFCore.Entities; using ServiceControl.Persistence.EFCore.Infrastructure; @@ -311,10 +310,26 @@ public Task SaveReportMasks(List reportMasks, CancellationToken cancella ExecuteWithDbContext((context, token) => context.StoreSetting(SettingKeys.ReportMasks, reportMasks, token), cancellationToken); public Task GetLicensedEndpointDetails(CancellationToken cancellationToken = default) => - ExecuteWithDbContext((context, token) => context.GetSetting(SettingKeys.LicensedEndpointDetails, token), cancellationToken); + ExecuteWithDbContext(async (context, token) => + { + var baseDetails = await context.GetSetting(SettingKeys.LicensedEndpointDetails, token); + if (baseDetails is null) + { + return null; + } + var amendments = await context.GetSetting(SettingKeys.LicensedEndpointDetailsAmendment, token); + return baseDetails.Amend(amendments); + }, cancellationToken); public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) => - ExecuteWithDbContext((context, token) => context.StoreSetting(SettingKeys.LicensedEndpointDetails, result, token), cancellationToken); + ExecuteWithDbContext(async (context, token) => + { + await context.StoreSetting(SettingKeys.LicensedEndpointDetails, result, token); + await context.StoreSetting(SettingKeys.LicensedEndpointDetailsAmendment, new LicensedEndpointDetailsAmendment(), token); + }, cancellationToken); + + public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) => + ExecuteWithDbContext((context, token) => context.StoreSetting(SettingKeys.LicensedEndpointDetailsAmendment, result, token), cancellationToken); static Endpoint ToEndpoint(LicensingEndpointEntity row) => ToEndpoint(row, lastCollectedDate: null); diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs index 7a8e403170..a97f3e100b 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs @@ -8,5 +8,6 @@ static class SettingKeys public const string AuditServiceMetadata = "AuditServiceMetadata"; public const string ReportMasks = "ReportMasks"; public const string LicensedEndpointDetails = "LicensedEndpointDetails"; + public const string LicensedEndpointDetailsAmendment = "LicensedEndpointDetailsAmendment"; public const string NotificationEmails = "NotificationEmails"; } diff --git a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs index 6e14a20845..a809d30fd2 100644 --- a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs @@ -25,6 +25,7 @@ class LicensingDataStore( const string BrokerMetadataDocumentId = "BrokerMetadata"; const string ReportMasksDocumentId = "ReportMasks"; const string LicencedEndpointDetailsDocumentId = "LicensedEndpointDetails"; + const string LicencedEndpointDetailsAmendmentDocumentId = "LicensedEndpointDetailsAmendment"; static readonly AuditServiceMetadata DefaultAuditServiceMetadata = new([], []); static readonly BrokerMetadata DefaultBrokerMetadata = new(null, []); @@ -162,7 +163,8 @@ public async Task>> GetEndpointT { var endpointDailyThroughputs = timeSeries .Where(entry => !throughputMaxDate.HasValue || entry.Timestamp < maxDateTime) - .Select(entry => new EndpointDailyThroughput(DateOnly.FromDateTime(entry.Timestamp), (long)entry.Value)); + .Select(entry => new EndpointDailyThroughput(DateOnly.FromDateTime(entry.Timestamp), (long)entry.Value)) + .ToList(); var throughputData = new ThroughputData(endpointDailyThroughputs) { ThroughputSource = document.EndpointId.ThroughputSource @@ -342,7 +344,13 @@ public async Task SaveReportMasks(List reportMasks, CancellationToken ca var store = await storeProvider.GetDocumentStore(cancellationToken); using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); - return await session.LoadAsync(LicencedEndpointDetailsDocumentId, cancellationToken); + var baseDetails = await session.LoadAsync(LicencedEndpointDetailsDocumentId, cancellationToken); + if (baseDetails is null) + { + return null; + } + var amendments = await session.LoadAsync(LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); + return baseDetails.Amend(amendments); } public async Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken = default) @@ -351,6 +359,16 @@ public async Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, Ca using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); await session.StoreAsync(result, LicencedEndpointDetailsDocumentId, cancellationToken); + await session.StoreAsync(new LicensedEndpointDetailsAmendment(), LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); + await session.SaveChangesAsync(cancellationToken); + } + + public async Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) + { + var store = await storeProvider.GetDocumentStore(cancellationToken); + using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); + + await session.StoreAsync(result, LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); await session.SaveChangesAsync(cancellationToken); } } \ No newline at end of file diff --git a/src/ServiceControl/Licensing/LicenseController.cs b/src/ServiceControl/Licensing/LicenseController.cs index a2c734b261..fd2ff4b142 100644 --- a/src/ServiceControl/Licensing/LicenseController.cs +++ b/src/ServiceControl/Licensing/LicenseController.cs @@ -1,6 +1,7 @@ #nullable enable namespace ServiceControl.Licensing { + using System; using System.IO; using System.IO.Compression; using System.Text.Json; @@ -81,8 +82,31 @@ public async Task UploadLicenseDetails([FromForm] IFormFile file, CancellationTo var result = JsonSerializer.Deserialize(brotliStream, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidDataException("File contents cannot be deserialized"); + //assign unique identifiers to entities for amendment tracking + foreach (var endpoint in result.Endpoints) + { + endpoint.ClientId = Guid.NewGuid().ToString(); + foreach (var queue in endpoint.Queues) + { + queue.OriginalLocation = endpoint.ClientId; + } + } + foreach (var queue in result.InfrastructureQueues) + { + queue.OriginalLocation = "infrastructure"; + } + foreach (var queue in result.ExcludedQueues) + { + queue.OriginalLocation = "excluded"; + } //persist await dataStore.SaveLicensedEndpointDetails(result, cancellationToken); } + + [Authorize(Policy = Permissions.ErrorThroughputManage)] + [HttpPost] + [Route("license/amendDetails")] + public async Task AmendLicenseDetails([FromBody] LicensedEndpointDetailsAmendment amendment, CancellationToken cancellationToken = default) => + await dataStore.SaveLicensedEndpointDetailsAmendment(amendment, cancellationToken); } } \ No newline at end of file From 10eee16b9ae5e217c79faf8632786559c7df8f8b Mon Sep 17 00:00:00 2001 From: Phil Bastian Date: Wed, 14 Oct 2026 08:53:37 +0800 Subject: [PATCH 2/6] tests for loading license details with amendments --- .../Throughput/LicenseMetadataTests.cs | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs diff --git a/src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs b/src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs new file mode 100644 index 0000000000..756e66c250 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Throughput/LicenseMetadataTests.cs @@ -0,0 +1,203 @@ +namespace ServiceControl.Persistence.Tests.Throughput; + +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.LicensingComponent.Contracts; + +[TestFixture] +class LicensedEndpointDetailsTests : PersistenceTestBase +{ + [Test] + public async Task Should_return_null_when_endpoint_details_have_not_been_saved() + { + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Null); + } + + [Test] + public async Task Should_return_null_when_only_an_amendment_has_been_saved() + { + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["endpoint-id"] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Null); + } + + [Test] + public async Task Should_apply_endpoint_amendments() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-id", + Name = "Original name", + Classification = 1, + Queues = + [ + new QueueIdentity { NameHash = "original-queue" } + ] + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + Endpoints = + [ + new LicensedEndpointAmendment + { + ClientId = "endpoint-id", + Name = "Amended name", + Classification = 2, + AmendedQueues = + [ + new QueueIdentity { NameHash = "additional-queue" } + ] + } + ] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + var endpoint = retrievedDetails.Endpoints.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.ClientId, Is.EqualTo("endpoint-id")); + Assert.That(endpoint.Name, Is.EqualTo("Amended name")); + Assert.That(endpoint.Classification, Is.EqualTo(2)); + Assert.That(endpoint.Queues.Select(q => q.NameHash), Is.EqualTo(["original-queue", "additional-queue"])); + } + } + + [Test] + public async Task Should_remove_endpoints_listed_in_the_amendment() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-to-keep", + Name = "Endpoint to keep" + }, + new LicensedEndpoint + { + ClientId = "endpoint-to-remove", + Name = "Endpoint to remove" + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["endpoint-to-remove"] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + var endpoint = retrievedDetails.Endpoints.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.ClientId, Is.EqualTo("endpoint-to-keep")); + Assert.That(endpoint.Name, Is.EqualTo("Endpoint to keep")); + } + } + + [Test] + public async Task Should_replace_an_existing_amendment() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "first-endpoint", + Name = "First endpoint" + }, + new LicensedEndpoint + { + ClientId = "second-endpoint", + Name = "Second endpoint" + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["first-endpoint"] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["second-endpoint"] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + Assert.That( + retrievedDetails.Endpoints.Select(endpoint => endpoint.ClientId), + Is.EqualTo(["first-endpoint"])); + } + + [Test] + public async Task Saving_endpoint_details_should_clear_the_existing_amendment() + { + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-id", + Name = "Original name" + } + ] + }); + + await LicensingDataStore.SaveLicensedEndpointDetailsAmendment( + new LicensedEndpointDetailsAmendment + { + RemovedEndpointIds = ["endpoint-id"] + }); + + await LicensingDataStore.SaveLicensedEndpointDetails(new LicensedEndpointDetails + { + Endpoints = + [ + new LicensedEndpoint + { + ClientId = "endpoint-id", + Name = "Replacement name" + } + ] + }); + + var retrievedDetails = await LicensingDataStore.GetLicensedEndpointDetails(); + + Assert.That(retrievedDetails, Is.Not.Null); + Assert.That( + retrievedDetails.Endpoints.Single().Name, + Is.EqualTo("Replacement name")); + } +} \ No newline at end of file From 37a9159dc17cb922b6cd31db19e64bccb18af715 Mon Sep 17 00:00:00 2001 From: Phil Bastian Date: Wed, 14 Oct 2026 09:42:46 +0800 Subject: [PATCH 3/6] API approval file --- .../ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt index 69498d4d04..d9f20d7486 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.HttpApiRoutes.approved.txt @@ -41,6 +41,7 @@ GET /eventlogitems => ServiceControl.EventLog.EventLogApiController:Items(Paging GET /heartbeats/stats => ServiceControl.Monitoring.EndpointsMonitoringController:HeartbeatStats() GET /instance-info => ServiceControl.Infrastructure.WebApi.RootController:Config(CancellationToken cancellationToken) GET /license => ServiceControl.Licensing.LicenseController:License(Boolean refresh, String clientName, CancellationToken cancellationToken) +POST /license/amendDetails => ServiceControl.Licensing.LicenseController:AmendLicenseDetails(LicensedEndpointDetailsAmendment amendment, 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, CancellationToken cancellationToken) From 21afd08bdaa432e1d1323df5a61523e6c822f600 Mon Sep 17 00:00:00 2001 From: Phil Bastian Date: Thu, 15 Oct 2026 12:28:33 +0800 Subject: [PATCH 4/6] ensure unmapped queues are retained --- .../InMemoryLicensingDataStore.cs | 1 + .../Implementation/LicensingDataStore.cs | 12 +++++++++++- .../Throughput/LicensingDataStore.cs | 8 ++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs index 250b0028bf..8ed4679bbe 100644 --- a/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs +++ b/src/Particular.LicensingComponent.Persistence.InMemory/InMemoryLicensingDataStore.cs @@ -187,6 +187,7 @@ public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, Cancella public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) { + result.UnmappedQueues = [.. endpointDetailsAmendment?.UnmappedQueues ?? [], .. result.UnmappedQueues]; endpointDetailsAmendment = result; return Task.CompletedTask; } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index 463a838d82..05f4e0167c 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -6,6 +6,7 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Particular.LicensingComponent.Persistence; using ServiceControl.Persistence.EFCore.Entities; using ServiceControl.Persistence.EFCore.Infrastructure; +using static System.Collections.Specialized.BitVector32; class LicensingDataStore(IServiceScopeFactory scopeFactory, TimeProvider timeProvider, IEndpointThroughputDialect throughputDialect) : DataStoreBase(scopeFactory), ILicensingDataStore { @@ -329,7 +330,16 @@ public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, Cancella }, cancellationToken); public Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAmendment result, CancellationToken cancellationToken = default) => - ExecuteWithDbContext((context, token) => context.StoreSetting(SettingKeys.LicensedEndpointDetailsAmendment, result, token), cancellationToken); + ExecuteWithDbContext(async (context, token) => + { + var existing = await context.GetSetting(SettingKeys.LicensedEndpointDetailsAmendment, token); + if (existing is not null) + { + // these do not round-trip + result.UnmappedQueues = [.. existing.UnmappedQueues, .. result.UnmappedQueues]; + } + await context.StoreSetting(SettingKeys.LicensedEndpointDetailsAmendment, result, token); + }, cancellationToken); static Endpoint ToEndpoint(LicensingEndpointEntity row) => ToEndpoint(row, lastCollectedDate: null); diff --git a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs index a809d30fd2..775fd13a94 100644 --- a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs @@ -368,6 +368,14 @@ public async Task SaveLicensedEndpointDetailsAmendment(LicensedEndpointDetailsAm var store = await storeProvider.GetDocumentStore(cancellationToken); using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); + var existing = await session.LoadAsync(LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); + if (existing is not null) + { + // these do not round-trip + result.UnmappedQueues = [.. existing.UnmappedQueues, .. result.UnmappedQueues]; + session.Advanced.Evict(existing); + } + await session.StoreAsync(result, LicencedEndpointDetailsAmendmentDocumentId, cancellationToken); await session.SaveChangesAsync(cancellationToken); } From 9257686386eb7fddae417ea40d5545f2f57ba8cc Mon Sep 17 00:00:00 2001 From: Phil Bastian Date: Fri, 16 Oct 2026 07:50:49 +0800 Subject: [PATCH 5/6] remove unneeded include --- .../Implementation/LicensingDataStore.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index 3a44a8c08c..f6fd359362 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -6,7 +6,6 @@ namespace ServiceControl.Persistence.EFCore.Implementation; using Particular.LicensingComponent.Persistence; using ServiceControl.Persistence.EFCore.Entities; using ServiceControl.Persistence.EFCore.Infrastructure; -using static System.Collections.Specialized.BitVector32; class LicensingDataStore(IServiceScopeFactory scopeFactory, TimeProvider timeProvider, IEndpointThroughputDialect throughputDialect) : DataStoreBase(scopeFactory), ILicensingDataStore { From c1f9763e8988f4407ef34f141427d2fc409426cb Mon Sep 17 00:00:00 2001 From: Phil Bastian Date: Fri, 16 Oct 2026 08:57:56 +0800 Subject: [PATCH 6/6] format exclude arrays for better clarity --- .../LicenseMetadata.cs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs b/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs index 572f2a1710..60079668bb 100644 --- a/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs +++ b/src/Particular.LicensingComponent.Persistence/LicenseMetadata.cs @@ -16,8 +16,22 @@ public LicensedEndpointDetails Amend(LicensedEndpointDetailsAmendment? amendment //Note: baseDetails.UnassignedQueues will always be empty before amendments baseDetails.UnassignedQueues = amendments.UnassignedQueues; - baseDetails.ExcludedQueues = [.. baseDetails.ExcludedQueues.Except([.. amendments.UnassignedQueues, .. amendments.UnmappedQueues, .. amendments.InfrastructureQueues, .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), .. amendments.ExcludedQueues]; - baseDetails.InfrastructureQueues = [.. baseDetails.InfrastructureQueues.Except([.. amendments.UnassignedQueues, .. amendments.UnmappedQueues, .. amendments.ExcludedQueues, .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), .. amendments.InfrastructureQueues]; + baseDetails.ExcludedQueues = [.. baseDetails.ExcludedQueues.Except( + [ + .. amendments.UnassignedQueues, + .. amendments.UnmappedQueues, + .. amendments.InfrastructureQueues, + .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), + .. amendments.ExcludedQueues + ]; + baseDetails.InfrastructureQueues = [.. baseDetails.InfrastructureQueues.Except( + [ + .. amendments.UnassignedQueues, + .. amendments.UnmappedQueues, + .. amendments.ExcludedQueues, + .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), + .. amendments.InfrastructureQueues + ]; baseDetails.Endpoints = [ .. baseDetails.Endpoints.Where(endpoint => !amendments.RemovedEndpointIds.Contains(endpoint.ClientId)) .Select(endpoint => @@ -27,7 +41,15 @@ .. baseDetails.Endpoints.Where(endpoint => !amendments.RemovedEndpointIds.Contai { Name = amendment?.Name ?? endpoint.Name, Classification = amendment?.Classification ?? endpoint.Classification, - Queues = [.. endpoint.Queues.Except([.. amendments.UnassignedQueues, .. amendments.UnmappedQueues, .. amendments.ExcludedQueues, .. amendments.InfrastructureQueues, .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), .. amendment?.AmendedQueues ?? []] + Queues = [.. endpoint.Queues.Except( + [ + .. amendments.UnassignedQueues, + .. amendments.UnmappedQueues, + .. amendments.ExcludedQueues, + .. amendments.InfrastructureQueues, + .. amendments.Endpoints.SelectMany(queue => queue.AmendedQueues)]), + .. amendment?.AmendedQueues ?? [] + ] }; }), .. amendments.Endpoints.Where(endpoint => !baseDetails.Endpoints.Any(baseEndpoint => baseEndpoint.ClientId == endpoint.ClientId))