Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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; } = [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class InMemoryLicensingDataStore : ILicensingDataStore
BrokerMetadata brokerMetadata = new(null, []);
AuditServiceMetadata auditServiceMetadata = new([], []);
List<string> reportMasks = [];
LicensedEndpointDetails? endpointDetails = null;

public Task<IEnumerable<Endpoint>> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -170,6 +171,14 @@ public Task SaveReportMasks(List<string> reportMasks, CancellationToken cancella
return Task.CompletedTask;
}

public Task<LicensedEndpointDetails?> GetLicensedEndpointDetails(CancellationToken cancellationToken) => Task.FromResult(endpointDetails);

public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken)
{
endpointDetails = result;
return Task.CompletedTask;
}

class EndpointCollection : KeyedCollection<EndpointIdentifier, Endpoint>
{
protected override EndpointIdentifier GetKeyForItem(Endpoint item) => item.Id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSo
Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken);
Task<List<string>> GetReportMasks(CancellationToken cancellationToken);
Task SaveReportMasks(List<string> reportMasks, CancellationToken cancellationToken);

Task<LicensedEndpointDetails?> GetLicensedEndpointDetails(CancellationToken cancellationToken);
Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/Particular.LicensingComponent/ThroughputCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,12 @@ public async Task<List<EndpointThroughputSummary>> 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);
Expand Down
32 changes: 19 additions & 13 deletions src/Particular.LicensingComponent/ThroughputDataExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,29 @@ public static long MaxDailyThroughput(this List<ThroughputData> throughputs)

public static MonthlyThroughput[] MonthlyThroughput(this List<ThroughputData> 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<ThroughputData> 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<ThroughputData> 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<string, IEnumerable<ThroughputData>> throughputPerQueue, ThroughputSource source) =>
Expand Down
2 changes: 2 additions & 0 deletions src/ServiceControl.LicenseManagement/LicenseDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ class LicensingDataStore : ILicensingDataStore
public Task<Particular.LicensingComponent.Contracts.Endpoint?> GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<IEnumerable<(EndpointIdentifier Id, Particular.LicensingComponent.Contracts.Endpoint? Endpoint)>> GetEndpoints(IList<EndpointIdentifier> endpointIds, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<IDictionary<string, IEnumerable<ThroughputData>>> GetEndpointThroughputByQueueName(IList<string> queueNames, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<LicensedEndpointDetails?> GetLicensedEndpointDetails(CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<List<string>> GetReportMasks(CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<bool> IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<bool> IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSource, IList<EndpointDailyThroughput> throughput, CancellationToken cancellationToken) => throw new NotImplementedException();
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<string> reportMasks, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task UpdateUserIndicatorOnEndpoints(List<UpdateUserIndicator> userIndicatorUpdates, CancellationToken cancellationToken) => throw new NotImplementedException();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
Expand Down Expand Up @@ -316,4 +317,21 @@ public async Task SaveReportMasks(List<string> reportMasks, CancellationToken ca
await session.StoreAsync(new ReportConfigurationDocument { MaskedStrings = reportMasks }, ReportMasksDocumentId, cancellationToken);
await session.SaveChangesAsync(cancellationToken);
}

public async Task<LicensedEndpointDetails?> GetLicensedEndpointDetails(CancellationToken cancellationToken)
{
var store = await storeProvider.GetDocumentStore(cancellationToken);
using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name);

return await session.LoadAsync<LicensedEndpointDetails>(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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 41 additions & 22 deletions src/ServiceControl/Licensing/LicenseController.cs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -44,29 +52,40 @@ public async Task<ActionResult<LicenseInfo>> License(bool refresh, string client
return licenseInfo;
}

public class LicenseInfo
[Authorize(Policy = Permissions.ErrorLicensingView)]
[HttpGet]
[Route("license/details")]
public async Task<ActionResult<LicensedEndpointDetails?>> 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need the Base64 conversion when we aren't embedding details in the license file.

That means we could hook the BrotliStream directly to the fileStream.

It may be worth trying to pass that into the deserializer as well. That way we avoid creating big in-memory string objects.

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<LicensedEndpointDetails>(fileContents, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidDataException("File contents cannot be deserialized");
//persist
await dataStore.SaveLicensedEndpointDetails(result, cancellationToken);
}
}
}
29 changes: 29 additions & 0 deletions src/ServiceControl/Licensing/LicenseControllerTypes.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
Loading