diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7e49d6d805..f0987c5c92 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -19,6 +19,10 @@ + + + + @@ -50,6 +54,7 @@ + @@ -62,6 +67,7 @@ + @@ -78,6 +84,9 @@ + + + diff --git a/src/ServiceControl.Persistence.Sql.Core/.editorconfig b/src/ServiceControl.Persistence.Sql.Core/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs new file mode 100644 index 0000000000..39de35cfe4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/IDatabaseMigrator.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Persistence.Sql.Core.Abstractions; + +public interface IDatabaseMigrator +{ + Task ApplyMigrations(CancellationToken cancellationToken = default); +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs new file mode 100644 index 0000000000..25de65b710 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Abstractions/SqlPersisterSettings.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Abstractions; + +using ServiceControl.Persistence; + +public abstract class SqlPersisterSettings : PersistenceSettings +{ + public required string ConnectionString { get; set; } + public int CommandTimeout { get; set; } = 30; + public bool EnableSensitiveDataLogging { get; set; } = false; +} diff --git a/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs new file mode 100644 index 0000000000..179b88e829 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/DbContexts/ServiceControlDbContextBase.cs @@ -0,0 +1,33 @@ +namespace ServiceControl.Persistence.Sql.Core.DbContexts; + +using Entities; +using EntityConfigurations; +using Microsoft.EntityFrameworkCore; + +public abstract class ServiceControlDbContextBase : DbContext +{ + protected ServiceControlDbContextBase(DbContextOptions options) : base(options) + { + } + + public DbSet TrialLicenses { get; set; } + public DbSet LicensingMetadata { get; set; } + public DbSet Endpoints { get; set; } + public DbSet Throughput { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfiguration(new TrialLicenseConfiguration()); + modelBuilder.ApplyConfiguration(new LicensingMetadataEntityConfiguration()); + modelBuilder.ApplyConfiguration(new ThroughputEndpointConfiguration()); + modelBuilder.ApplyConfiguration(new DailyThroughputConfiguration()); + + OnModelCreatingProvider(modelBuilder); + } + + protected virtual void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs new file mode 100644 index 0000000000..41f3052a29 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/DailyThroughputEntity.cs @@ -0,0 +1,10 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class DailyThroughputEntity +{ + public int Id { get; set; } + public required string EndpointName { get; set; } + public required string ThroughputSource { get; set; } + public required DateOnly Date { get; set; } + public required long MessageCount { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs new file mode 100644 index 0000000000..4666dd85bf --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/LicensingMetadataEntity.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class LicensingMetadataEntity +{ + public int Id { get; set; } + public required string Key { get; set; } + public required string Data { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs new file mode 100644 index 0000000000..44f1b2ddcf --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/ThroughputEndpointEntity.cs @@ -0,0 +1,13 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class ThroughputEndpointEntity +{ + public int Id { get; set; } + public required string EndpointName { get; set; } + public required string ThroughputSource { get; set; } + public string? SanitizedEndpointName { get; set; } + public string? EndpointIndicators { get; set; } + public string? UserIndicator { get; set; } + public string? Scope { get; set; } + public DateOnly LastCollectedData { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs b/src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs new file mode 100644 index 0000000000..36659760c0 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Entities/TrialLicenseEntity.cs @@ -0,0 +1,7 @@ +namespace ServiceControl.Persistence.Sql.Core.Entities; + +public class TrialLicenseEntity +{ + public int Id { get; set; } + public DateOnly TrialEndDate { get; set; } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs new file mode 100644 index 0000000000..dd5394f438 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/DailyThroughputConfiguration.cs @@ -0,0 +1,31 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class DailyThroughputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DailyThroughput") + .HasIndex(e => new + { + e.EndpointName, + e.ThroughputSource, + e.Date + }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + builder.HasKey(e => e.Id); + builder.Property(e => e.EndpointName) + .IsRequired() + .HasMaxLength(200); + builder.Property(e => e.ThroughputSource) + .IsRequired() + .HasMaxLength(50); + builder.Property(e => e.Date) + .IsRequired(); + builder.Property(e => e.MessageCount) + .IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs new file mode 100644 index 0000000000..06e2318cbd --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/LicensingMetadataEntityConfiguration.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class LicensingMetadataEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("LicensingMetadata") + .HasIndex(e => e.Key) + .IsUnique(); + builder.HasKey(e => e.Id); + builder.Property(e => e.Key) + .IsRequired() + .HasMaxLength(200); + builder.Property(e => e.Data) + .IsRequired() + .HasMaxLength(2000); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs new file mode 100644 index 0000000000..dbd1e630ee --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/ThroughputEndpointConfiguration.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class ThroughputEndpointConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ThroughputEndpoint") + .HasIndex(e => new + { + e.EndpointName, + e.ThroughputSource + }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + builder.HasKey(e => e.Id); + builder.Property(e => e.EndpointName) + .IsRequired() + .HasMaxLength(200); + builder.Property(e => e.ThroughputSource) + .IsRequired() + .HasMaxLength(50); + + builder.Property(e => e.SanitizedEndpointName); + builder.Property(e => e.EndpointIndicators); + builder.Property(e => e.UserIndicator); + builder.Property(e => e.Scope); + builder.Property(e => e.LastCollectedData); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs new file mode 100644 index 0000000000..a00d3277c0 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/EntityConfigurations/TrialLicenseConfiguration.cs @@ -0,0 +1,23 @@ +namespace ServiceControl.Persistence.Sql.Core.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class TrialLicenseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TrialLicense"); + + builder.HasKey(e => e.Id); + + // Ensure only one row exists by using a fixed primary key + builder.Property(e => e.Id) + .HasDefaultValue(1) + .ValueGeneratedNever(); + + builder.Property(e => e.TrialEndDate) + .IsRequired(); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs new file mode 100644 index 0000000000..89ff1bbeae --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/LicensingDataStore.cs @@ -0,0 +1,321 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Particular.LicensingComponent.Contracts; +using Particular.LicensingComponent.Persistence; +using ServiceControl.Persistence.Sql.Core.DbContexts; +using ServiceControl.Persistence.Sql.Core.Entities; + +public class LicensingDataStore(IServiceProvider serviceProvider) : ILicensingDataStore +{ + #region Throughput + static DateOnly DefaultCutOff() + => DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-400)); + + public async Task>> GetEndpointThroughputByQueueName(IList queueNames, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var cutOff = DefaultCutOff(); + + var data = await dbContext.Throughput + .AsNoTracking() + .Where(x => queueNames.Contains(x.EndpointName) && x.Date >= cutOff) + .ToListAsync(cancellationToken); + + var lookup = data.ToLookup(x => x.EndpointName); + + Dictionary> result = []; + + foreach (var queueName in queueNames) + { + result[queueName] = [.. lookup[queueName].GroupBy(x => x.ThroughputSource) + .Select(x => new ThroughputData([.. from t in x select new EndpointDailyThroughput(t.Date, t.MessageCount)]) + { + ThroughputSource = Enum.Parse(x.Key) + })]; + } + + return result; + } + + public async Task IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var cutoffDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days + 1)); + return await dbContext.Throughput.AnyAsync(t => t.Date >= cutoffDate, cancellationToken); + } + + public async Task IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var cutoffDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days + 1)); + var endDate = DateOnly.FromDateTime(includeToday ? DateTime.UtcNow : DateTime.UtcNow.AddDays(-1)); + var source = Enum.GetName(throughputSource)!; + return await dbContext.Throughput.AnyAsync(t => t.Date >= cutoffDate && t.Date <= endDate && t.ThroughputSource == source, cancellationToken); + } + + public async Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSource, IList throughput, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var source = Enum.GetName(throughputSource)!; + var cutOff = DefaultCutOff(); + var existing = await dbContext.Throughput.Where(t => t.EndpointName == endpointName && t.ThroughputSource == source && t.Date >= cutOff) + .ToListAsync(cancellationToken); + + var lookup = existing.ToLookup(t => t.Date); + + foreach (var t in throughput) + { + var existingEntry = lookup[t.DateUTC].FirstOrDefault(); + if (existingEntry is not null) + { + existingEntry.MessageCount = t.MessageCount; + } + else + { + var newEntry = new DailyThroughputEntity + { + EndpointName = endpointName, + ThroughputSource = source, + Date = t.DateUTC, + MessageCount = t.MessageCount, + }; + _ = await dbContext.Throughput.AddAsync(newEntry, cancellationToken); + } + } + + _ = await dbContext.SaveChangesAsync(cancellationToken); + } + #endregion + + #region Endpoints + public async Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var fromDatabase = await dbContext.Endpoints.AsNoTracking() + .Where(e => endpointIds.Any(id => id.Name == e.EndpointName && Enum.GetName(id.ThroughputSource) == e.ThroughputSource)) + .ToListAsync(cancellationToken); + + var lookup = fromDatabase.Select(MapEndpointEntityToContract).ToLookup(e => e.Id); + + return endpointIds.Select(id => (id, lookup[id].FirstOrDefault())); + } + + public async Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var fromDatabase = await dbContext.Endpoints.AsNoTracking().SingleOrDefaultAsync(e => e.EndpointName == id.Name && e.ThroughputSource == Enum.GetName(id.ThroughputSource), cancellationToken); + if (fromDatabase is null) + { + return null; + } + + return MapEndpointEntityToContract(fromDatabase); + } + + public async Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var endpoints = dbContext.Endpoints.AsNoTracking(); + if (!includePlatformEndpoints) + { + endpoints = endpoints.Where(x => x.EndpointIndicators == null || !x.EndpointIndicators.Contains(Enum.GetName(EndpointIndicator.PlatformEndpoint)!)); + } + + var fromDatabase = await endpoints.ToListAsync(cancellationToken); + + return fromDatabase.Select(MapEndpointEntityToContract); + } + + public async Task SaveEndpoint(Endpoint endpoint, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + var existing = await dbContext.Endpoints.SingleOrDefaultAsync(e => e.EndpointName == endpoint.Id.Name && e.ThroughputSource == Enum.GetName(endpoint.Id.ThroughputSource), cancellationToken); + if (existing is null) + { + var entity = MapEndpointContractToEntity(endpoint); + _ = await dbContext.Endpoints.AddAsync(entity, cancellationToken); + } + else + { + existing.SanitizedEndpointName = endpoint.SanitizedName; + existing.EndpointIndicators = endpoint.EndpointIndicators is null ? null : string.Join("|", endpoint.EndpointIndicators); + existing.UserIndicator = endpoint.UserIndicator; + existing.Scope = endpoint.Scope; + existing.LastCollectedData = endpoint.LastCollectedDate; + _ = dbContext.Endpoints.Update(existing); + } + + _ = await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateUserIndicatorOnEndpoints(List userIndicatorUpdates, CancellationToken cancellationToken) + { + var updates = userIndicatorUpdates.ToDictionary(u => u.Name, u => u.UserIndicator); + using var scope = serviceProvider.CreateScope(); + using var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Get all relevant sanitized names from endpoints matched by name + var sanitizedNames = await dbContext.Endpoints + .Where(e => updates.Keys.Contains(e.EndpointName) && e.SanitizedEndpointName != null) + .Select(e => e.SanitizedEndpointName) + .Distinct() + .ToListAsync(cancellationToken); + + // Get all endpoints that match either by name or sanitized name in a single query + var endpoints = await dbContext.Endpoints + .Where(e => updates.Keys.Contains(e.EndpointName) + || (e.SanitizedEndpointName != null && updates.Keys.Contains(e.SanitizedEndpointName)) + || (e.SanitizedEndpointName != null && sanitizedNames.Contains(e.SanitizedEndpointName))) + .ToListAsync(cancellationToken) ?? []; + + foreach (var endpoint in endpoints) + { + if (endpoint.SanitizedEndpointName is not null && updates.TryGetValue(endpoint.SanitizedEndpointName, out var newValueFromSanitizedName)) + { + // Direct match by sanitized name + endpoint.UserIndicator = newValueFromSanitizedName; + } + else if (updates.TryGetValue(endpoint.EndpointName, out var newValueFromEndpoint)) + { + // Direct match by endpoint name - this should also update all endpoints with the same sanitized name + endpoint.UserIndicator = newValueFromEndpoint; + } + else if (endpoint.SanitizedEndpointName != null && sanitizedNames.Contains(endpoint.SanitizedEndpointName)) + { + // This endpoint shares a sanitized name with an endpoint that was matched by name + // Find the update value from the endpoint that has this sanitized name + var matchingEndpoint = endpoints.FirstOrDefault(e => + e.SanitizedEndpointName == endpoint.SanitizedEndpointName && + updates.ContainsKey(e.EndpointName)); + + if (matchingEndpoint != null && updates.TryGetValue(matchingEndpoint.EndpointName, out var cascadedValue)) + { + endpoint.UserIndicator = cascadedValue; + } + } + _ = dbContext.Endpoints.Update(endpoint); + } + + _ = await dbContext.SaveChangesAsync(cancellationToken); + } + + + static Endpoint MapEndpointEntityToContract(ThroughputEndpointEntity entity) + => new(entity.EndpointName, Enum.Parse(entity.ThroughputSource)) + { +#pragma warning disable CS8601 // Possible null reference assignment. + SanitizedName = entity.SanitizedEndpointName, + EndpointIndicators = entity.EndpointIndicators?.Split("|"), + UserIndicator = entity.UserIndicator, + Scope = entity.Scope, + LastCollectedDate = entity.LastCollectedData +#pragma warning restore CS8601 // Possible null reference assignment. + }; + + static ThroughputEndpointEntity MapEndpointContractToEntity(Endpoint endpoint) + => new() + { + EndpointName = endpoint.Id.Name, + ThroughputSource = Enum.GetName(endpoint.Id.ThroughputSource)!, + SanitizedEndpointName = endpoint.SanitizedName, + EndpointIndicators = endpoint.EndpointIndicators is null ? null : string.Join("|", endpoint.EndpointIndicators), + UserIndicator = endpoint.UserIndicator, + Scope = endpoint.Scope, + LastCollectedData = endpoint.LastCollectedDate + }; + + + #endregion + + #region AuditServiceMetadata + + static readonly AuditServiceMetadata EmptyAuditServiceMetadata = new([], []); + public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken) + => SaveMetadata("AuditServiceMetadata", auditServiceMetadata, cancellationToken); + public async Task GetAuditServiceMetadata(CancellationToken cancellationToken = default) + => await LoadMetadata("AuditServiceMetadata", cancellationToken) + ?? EmptyAuditServiceMetadata; + + #endregion + + #region ReportMasks + static readonly List EmptyReportMasks = []; + public Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken) + => SaveMetadata("ReportMasks", reportMasks, cancellationToken); + public async Task> GetReportMasks(CancellationToken cancellationToken) + => await LoadMetadata>("ReportMasks", cancellationToken) ?? EmptyReportMasks; + + #endregion + + #region Broker Metadata + static readonly BrokerMetadata EmptyBrokerMetada = new(ScopeType: null, []); + + public Task SaveBrokerMetadata(BrokerMetadata brokerMetadata, CancellationToken cancellationToken) + => SaveMetadata("BrokerMetadata", brokerMetadata, cancellationToken); + + public async Task GetBrokerMetadata(CancellationToken cancellationToken) + => await LoadMetadata("BrokerMetadata", cancellationToken) ?? EmptyBrokerMetada; + #endregion + + #region Metadata + async Task LoadMetadata(string key, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + var existing = await dbContext.LicensingMetadata + .AsNoTracking() + .SingleOrDefaultAsync(m => m.Key == key, cancellationToken); + if (existing is null) + { + return default; + } + return JsonSerializer.Deserialize(existing.Data); + + } + + async Task SaveMetadata(string key, T data, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + + var existing = await dbContext.LicensingMetadata.SingleOrDefaultAsync(m => m.Key == key, cancellationToken); + + var serialized = JsonSerializer.Serialize(data); + + if (existing is null) + { + LicensingMetadataEntity newMetadata = new() + { + Key = key, + Data = serialized + }; + _ = await dbContext.LicensingMetadata.AddAsync(newMetadata, cancellationToken); + } + else + { + existing.Data = serialized; + _ = dbContext.LicensingMetadata.Update(existing); + } + + _ = await dbContext.SaveChangesAsync(cancellationToken); + } + #endregion +} diff --git a/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs new file mode 100644 index 0000000000..80cdce3eae --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/Implementation/TrialLicenseDataProvider.cs @@ -0,0 +1,56 @@ +namespace ServiceControl.Persistence.Sql.Core.Implementation; + +using DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.Persistence; + +public class TrialLicenseDataProvider : ITrialLicenseDataProvider +{ + readonly IServiceProvider serviceProvider; + const int SingletonId = 1; + + public TrialLicenseDataProvider(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public async Task GetTrialEndDate(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var entity = await dbContext.TrialLicenses + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); + + return entity?.TrialEndDate; + } + + public async Task StoreTrialEndDate(DateOnly trialEndDate, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var existingEntity = await dbContext.TrialLicenses + .FirstOrDefaultAsync(t => t.Id == SingletonId, cancellationToken); + + if (existingEntity != null) + { + // Update existing + existingEntity.TrialEndDate = trialEndDate; + } + else + { + // Insert new + var newEntity = new Entities.TrialLicenseEntity + { + Id = SingletonId, + TrialEndDate = trialEndDate + }; + await dbContext.TrialLicenses.AddAsync(newEntity, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj new file mode 100644 index 0000000000..e839730594 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.Core/ServiceControl.Persistence.Sql.Core.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.Designer.cs new file mode 100644 index 0000000000..e1129e6700 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.Designer.cs @@ -0,0 +1,143 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.Sql.MySQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + [DbContext(typeof(MySqlDbContext))] + [Migration("20251210040740_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("longtext"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("longtext"); + + b.Property("Scope") + .HasColumnType("longtext"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.cs new file mode 100644 index 0000000000..c1d7bfa3fc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/20251210040740_InitialCreate.cs @@ -0,0 +1,128 @@ +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + using System; + using Microsoft.EntityFrameworkCore.Metadata; + using Microsoft.EntityFrameworkCore.Migrations; + + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "DailyThroughput", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + EndpointName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ThroughputSource = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Date = table.Column(type: "date", nullable: false), + MessageCount = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailyThroughput", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "LicensingMetadata", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Key = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Data = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_LicensingMetadata", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ThroughputEndpoint", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + EndpointName = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + ThroughputSource = table.Column(type: "varchar(50)", maxLength: 50, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + SanitizedEndpointName = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + EndpointIndicators = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + UserIndicator = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + Scope = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + LastCollectedData = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ThroughputEndpoint", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + Id = table.Column(type: "int", nullable: false, defaultValue: 1), + TrialEndDate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrialLicense", x => x.Id); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", + table: "DailyThroughput", + columns: new[] { "EndpointName", "ThroughputSource", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LicensingMetadata_Key", + table: "LicensingMetadata", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "EndpointName", "ThroughputSource" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DailyThroughput"); + + migrationBuilder.DropTable( + name: "LicensingMetadata"); + + migrationBuilder.DropTable( + name: "ThroughputEndpoint"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs new file mode 100644 index 0000000000..df1713a0f5 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/Migrations/MySqlDbContextModelSnapshot.cs @@ -0,0 +1,140 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.Sql.MySQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.MySQL.Migrations +{ + [DbContext(typeof(MySqlDbContext))] + partial class MySqlDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("longtext"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("longtext"); + + b.Property("Scope") + .HasColumnType("longtext"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs new file mode 100644 index 0000000000..9a7ab109e8 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDatabaseMigrator.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +class MySqlDatabaseMigrator : IDatabaseMigrator +{ + readonly IServiceProvider serviceProvider; + readonly ILogger logger; + + public MySqlDatabaseMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + public async Task ApplyMigrations(CancellationToken cancellationToken) + { + logger.LogInformation("Initializing MySQL database"); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Testing database connectivity"); + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + throw new Exception("Cannot connect to MySQL database. Check connection string and ensure database server is accessible."); + } + + logger.LogInformation("Applying pending migrations"); + await dbContext.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("MySQL database initialized successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize MySQL database"); + throw; + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs new file mode 100644 index 0000000000..db430f16b8 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContext.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class MySqlDbContext : ServiceControlDbContextBase +{ + public MySqlDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + // MySQL-specific configurations if needed + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs new file mode 100644 index 0000000000..2cccacee55 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlDbContextFactory.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +// Design-time factory for EF Core tools (migrations, etc.) +class MySqlDbContextFactory : IDesignTimeDbContextFactory +{ + public MySqlDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use a dummy connection string for design-time operations + var connectionString = "Server=localhost;Database=servicecontrol;User=root;Password=mysql"; + // Use MySQL 8.0 as the server version for migrations + optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0))); + + return new MySqlDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs new file mode 100644 index 0000000000..c4207dff30 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistence.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; +using Core.DbContexts; +using Core.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Particular.LicensingComponent.Persistence; +using ServiceControl.Persistence; + +class MySqlPersistence : IPersistence +{ + readonly MySqlPersisterSettings settings; + + public MySqlPersistence(MySqlPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + + if (settings.MaintenanceMode) + { + return; + } + + services.AddSingleton(); + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + ConfigureDbContext(services); + + services.AddSingleton(); + } + + void ConfigureDbContext(IServiceCollection services) + { + services.AddSingleton(settings); + services.AddSingleton(settings); + + services.AddDbContext((serviceProvider, options) => + { + options.UseMySql(settings.ConnectionString, ServerVersion.AutoDetect(settings.ConnectionString), mySqlOptions => + { + mySqlOptions.CommandTimeout(settings.CommandTimeout); + if (settings.EnableRetryOnFailure) + { + mySqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + } + }); + + if (settings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + }, ServiceLifetime.Scoped); + + services.AddScoped(sp => sp.GetRequiredService()); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs new file mode 100644 index 0000000000..7cd3303d3f --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersistenceConfiguration.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Configuration; +using ServiceControl.Persistence; + +public class MySqlPersistenceConfiguration : IPersistenceConfiguration +{ + const string DatabaseConnectionStringKey = "Database/ConnectionString"; + const string CommandTimeoutKey = "Database/CommandTimeout"; + + public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) + { + var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception($"Setting {DatabaseConnectionStringKey} is required for MySQL persistence. " + + $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); + } + + var settings = new MySqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + }; + + return settings; + } + + public IPersistence Create(PersistenceSettings settings) + { + var specificSettings = (MySqlPersisterSettings)settings; + return new MySqlPersistence(specificSettings); + } +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs new file mode 100644 index 0000000000..b69f72d2e4 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/MySqlPersisterSettings.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.MySQL; + +using Core.Abstractions; + +public class MySqlPersisterSettings : SqlPersisterSettings +{ + public bool EnableRetryOnFailure { get; set; } = true; +} diff --git a/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj new file mode 100644 index 0000000000..d5ea500c83 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/ServiceControl.Persistence.Sql.MySQL.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest b/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest new file mode 100644 index 0000000000..db462a730c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.MySQL/persistence.manifest @@ -0,0 +1,13 @@ +{ + "Name": "MySQL", + "DisplayName": "MySQL", + "Description": "MySQL ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql.MySQL", + "TypeName": "ServiceControl.Persistence.Sql.MySQL.MySqlPersistenceConfiguration, ServiceControl.Persistence.Sql.MySQL", + "Settings": [ + { + "Name": "ServiceControl/Database/ConnectionString", + "Mandatory": true + } + ] +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.Designer.cs new file mode 100644 index 0000000000..5bfef70c40 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.Designer.cs @@ -0,0 +1,165 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ServiceControl.Persistence.Sql.PostgreSQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + [DbContext(typeof(PostgreSqlDbContext))] + [Migration("20251210071033_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("MessageCount") + .HasColumnType("bigint") + .HasColumnName("message_count"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.HasKey("Id") + .HasName("p_k_throughput"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("data"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.HasKey("Id") + .HasName("p_k_licensing_metadata"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("text") + .HasColumnName("endpoint_indicators"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("LastCollectedData") + .HasColumnType("date") + .HasColumnName("last_collected_data"); + + b.Property("SanitizedEndpointName") + .HasColumnType("text") + .HasColumnName("sanitized_endpoint_name"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.Property("UserIndicator") + .HasColumnType("text") + .HasColumnName("user_indicator"); + + b.HasKey("Id") + .HasName("p_k_endpoints"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("id"); + + b.Property("TrialEndDate") + .HasColumnType("date") + .HasColumnName("trial_end_date"); + + b.HasKey("Id") + .HasName("p_k_trial_licenses"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.cs new file mode 100644 index 0000000000..dd5271dda5 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/20251210071033_InitialCreate.cs @@ -0,0 +1,111 @@ +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + using System; + using Microsoft.EntityFrameworkCore.Migrations; + using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DailyThroughput", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + endpoint_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + throughput_source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + date = table.Column(type: "date", nullable: false), + message_count = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_throughput", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "LicensingMetadata", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + data = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_licensing_metadata", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "ThroughputEndpoint", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + endpoint_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + throughput_source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + sanitized_endpoint_name = table.Column(type: "text", nullable: true), + endpoint_indicators = table.Column(type: "text", nullable: true), + user_indicator = table.Column(type: "text", nullable: true), + scope = table.Column(type: "text", nullable: true), + last_collected_data = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_endpoints", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + id = table.Column(type: "integer", nullable: false, defaultValue: 1), + trial_end_date = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("p_k_trial_licenses", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", + table: "DailyThroughput", + columns: new[] { "endpoint_name", "throughput_source", "date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LicensingMetadata_key", + table: "LicensingMetadata", + column: "key", + unique: true); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "endpoint_name", "throughput_source" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DailyThroughput"); + + migrationBuilder.DropTable( + name: "LicensingMetadata"); + + migrationBuilder.DropTable( + name: "ThroughputEndpoint"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs new file mode 100644 index 0000000000..ff49b46e96 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -0,0 +1,162 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ServiceControl.Persistence.Sql.PostgreSQL; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.PostgreSQL.Migrations +{ + [DbContext(typeof(PostgreSqlDbContext))] + partial class PostgreSqlDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("MessageCount") + .HasColumnType("bigint") + .HasColumnName("message_count"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.HasKey("Id") + .HasName("p_k_throughput"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("data"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.HasKey("Id") + .HasName("p_k_licensing_metadata"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("text") + .HasColumnName("endpoint_indicators"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("endpoint_name"); + + b.Property("LastCollectedData") + .HasColumnType("date") + .HasColumnName("last_collected_data"); + + b.Property("SanitizedEndpointName") + .HasColumnType("text") + .HasColumnName("sanitized_endpoint_name"); + + b.Property("Scope") + .HasColumnType("text") + .HasColumnName("scope"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("throughput_source"); + + b.Property("UserIndicator") + .HasColumnType("text") + .HasColumnName("user_indicator"); + + b.HasKey("Id") + .HasName("p_k_endpoints"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("id"); + + b.Property("TrialEndDate") + .HasColumnType("date") + .HasColumnName("trial_end_date"); + + b.HasKey("Id") + .HasName("p_k_trial_licenses"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs new file mode 100644 index 0000000000..68d8ac14eb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDatabaseMigrator.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +class PostgreSqlDatabaseMigrator : IDatabaseMigrator +{ + readonly IServiceProvider serviceProvider; + readonly ILogger logger; + + public PostgreSqlDatabaseMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + public async Task ApplyMigrations(CancellationToken cancellationToken) + { + logger.LogInformation("Initializing PostgreSQL database"); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Testing database connectivity"); + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + throw new Exception("Cannot connect to PostgreSQL database. Check connection string and ensure database server is accessible."); + } + + logger.LogInformation("Applying pending migrations"); + await dbContext.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("PostgreSQL database initialized successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize PostgreSQL database"); + throw; + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs new file mode 100644 index 0000000000..3d435d487b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContext.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class PostgreSqlDbContext : ServiceControlDbContextBase +{ + public PostgreSqlDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Apply snake_case naming convention for PostgreSQL + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + entity.SetTableName(ToSnakeCase(entity.GetTableName())); + + foreach (var property in entity.GetProperties()) + { + property.SetColumnName(ToSnakeCase(property.GetColumnName())); + } + + foreach (var key in entity.GetKeys()) + { + key.SetName(ToSnakeCase(key.GetName())); + } + + foreach (var foreignKey in entity.GetForeignKeys()) + { + foreignKey.SetConstraintName(ToSnakeCase(foreignKey.GetConstraintName())); + } + + foreach (var index in entity.GetIndexes()) + { + index.SetDatabaseName(ToSnakeCase(index.GetDatabaseName())); + } + } + + base.OnModelCreating(modelBuilder); + } + + static string? ToSnakeCase(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new System.Text.StringBuilder(); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c) && i > 0) + { + builder.Append('_'); + } + builder.Append(char.ToLowerInvariant(c)); + } + return builder.ToString(); + } + + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + // PostgreSQL-specific configurations if needed + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs new file mode 100644 index 0000000000..6fd19a453b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlDbContextFactory.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +// Design-time factory for EF Core tools (migrations, etc.) +class PostgreSqlDbContextFactory : IDesignTimeDbContextFactory +{ + public PostgreSqlDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use a dummy connection string for design-time operations + optionsBuilder.UseNpgsql("Host=localhost;Database=servicecontrol;Username=postgres;Password=postgres"); + + return new PostgreSqlDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs new file mode 100644 index 0000000000..72fa7d731e --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistence.cs @@ -0,0 +1,67 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; +using Core.DbContexts; +using Core.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Particular.LicensingComponent.Persistence; +using ServiceControl.Persistence; + +class PostgreSqlPersistence : IPersistence +{ + readonly PostgreSqlPersisterSettings settings; + + public PostgreSqlPersistence(PostgreSqlPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + + if (settings.MaintenanceMode) + { + return; + } + + services.AddSingleton(); + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + ConfigureDbContext(services); + + services.AddSingleton(); + } + + void ConfigureDbContext(IServiceCollection services) + { + services.AddSingleton(settings); + services.AddSingleton(settings); + + services.AddDbContext((serviceProvider, options) => + { + options.UseNpgsql(settings.ConnectionString, npgsqlOptions => + { + npgsqlOptions.CommandTimeout(settings.CommandTimeout); + if (settings.EnableRetryOnFailure) + { + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + } + }); + + if (settings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + }, ServiceLifetime.Scoped); + + services.AddScoped(sp => sp.GetRequiredService()); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs new file mode 100644 index 0000000000..99b81c0406 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersistenceConfiguration.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Configuration; +using ServiceControl.Persistence; + +public class PostgreSqlPersistenceConfiguration : IPersistenceConfiguration +{ + const string DatabaseConnectionStringKey = "Database/ConnectionString"; + const string CommandTimeoutKey = "Database/CommandTimeout"; + + public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) + { + var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception($"Setting {DatabaseConnectionStringKey} is required for PostgreSQL persistence. " + + $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); + } + + var settings = new PostgreSqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + }; + + return settings; + } + + public IPersistence Create(PersistenceSettings settings) + { + var specificSettings = (PostgreSqlPersisterSettings)settings; + return new PostgreSqlPersistence(specificSettings); + } +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs new file mode 100644 index 0000000000..c0352984c6 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/PostgreSqlPersisterSettings.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.PostgreSQL; + +using Core.Abstractions; + +public class PostgreSqlPersisterSettings : SqlPersisterSettings +{ + public bool EnableRetryOnFailure { get; set; } = true; +} diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj new file mode 100644 index 0000000000..275e2387eb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/ServiceControl.Persistence.Sql.PostgreSQL.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest b/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest new file mode 100644 index 0000000000..e04bb32751 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.PostgreSQL/persistence.manifest @@ -0,0 +1,13 @@ +{ + "Name": "PostgreSQL", + "DisplayName": "PostgreSQL", + "Description": "PostgreSQL ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql.PostgreSQL", + "TypeName": "ServiceControl.Persistence.Sql.PostgreSQL.PostgreSqlPersistenceConfiguration, ServiceControl.Persistence.Sql.PostgreSQL", + "Settings": [ + { + "Name": "ServiceControl/Database/ConnectionString", + "Mandatory": true + } + ] +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.Designer.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.Designer.cs new file mode 100644 index 0000000000..92dd16614b --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.Designer.cs @@ -0,0 +1,143 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.Sql.SqlServer; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + [DbContext(typeof(SqlServerDbContext))] + [Migration("20251210040736_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("nvarchar(max)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasColumnType("nvarchar(max)"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.cs new file mode 100644 index 0000000000..b32f1f2962 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/20251210040736_InitialCreate.cs @@ -0,0 +1,110 @@ +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + using System; + using Microsoft.EntityFrameworkCore.Migrations; + + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DailyThroughput", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EndpointName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ThroughputSource = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + Date = table.Column(type: "date", nullable: false), + MessageCount = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailyThroughput", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "LicensingMetadata", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Key = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Data = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LicensingMetadata", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ThroughputEndpoint", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EndpointName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + ThroughputSource = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + SanitizedEndpointName = table.Column(type: "nvarchar(max)", nullable: true), + EndpointIndicators = table.Column(type: "nvarchar(max)", nullable: true), + UserIndicator = table.Column(type: "nvarchar(max)", nullable: true), + Scope = table.Column(type: "nvarchar(max)", nullable: true), + LastCollectedData = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ThroughputEndpoint", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TrialLicense", + columns: table => new + { + Id = table.Column(type: "int", nullable: false, defaultValue: 1), + TrialEndDate = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TrialLicense", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "UC_DailyThroughput_EndpointName_ThroughputSource_Date", + table: "DailyThroughput", + columns: new[] { "EndpointName", "ThroughputSource", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_LicensingMetadata_Key", + table: "LicensingMetadata", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "UC_ThroughputEndpoint_EndpointName_ThroughputSource", + table: "ThroughputEndpoint", + columns: new[] { "EndpointName", "ThroughputSource" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DailyThroughput"); + + migrationBuilder.DropTable( + name: "LicensingMetadata"); + + migrationBuilder.DropTable( + name: "ThroughputEndpoint"); + + migrationBuilder.DropTable( + name: "TrialLicense"); + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs new file mode 100644 index 0000000000..576cf28f6d --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs @@ -0,0 +1,140 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.Sql.SqlServer; + +#nullable disable + +namespace ServiceControl.Persistence.Sql.SqlServer.Migrations +{ + [DbContext(typeof(SqlServerDbContext))] + partial class SqlServerDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.DailyThroughputEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource", "Date" }, "UC_DailyThroughput_EndpointName_ThroughputSource_Date") + .IsUnique(); + + b.ToTable("DailyThroughput", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.LicensingMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("LicensingMetadata", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.ThroughputEndpointEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EndpointIndicators") + .HasColumnType("nvarchar(max)"); + + b.Property("EndpointName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("LastCollectedData") + .HasColumnType("date"); + + b.Property("SanitizedEndpointName") + .HasColumnType("nvarchar(max)"); + + b.Property("Scope") + .HasColumnType("nvarchar(max)"); + + b.Property("ThroughputSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("UserIndicator") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "EndpointName", "ThroughputSource" }, "UC_ThroughputEndpoint_EndpointName_ThroughputSource") + .IsUnique(); + + b.ToTable("ThroughputEndpoint", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.Sql.Core.Entities.TrialLicenseEntity", b => + { + b.Property("Id") + .HasColumnType("int") + .HasDefaultValue(1); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialLicense", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj b/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj new file mode 100644 index 0000000000..71cad0fb82 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/ServiceControl.Persistence.Sql.SqlServer.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs new file mode 100644 index 0000000000..2cd8812506 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDatabaseMigrator.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +class SqlServerDatabaseMigrator : IDatabaseMigrator +{ + readonly IServiceProvider serviceProvider; + readonly ILogger logger; + + public SqlServerDatabaseMigrator( + IServiceProvider serviceProvider, + ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + public async Task ApplyMigrations(CancellationToken cancellationToken) + { + logger.LogInformation("Initializing SQL Server database"); + + try + { + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Testing database connectivity"); + var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); + if (!canConnect) + { + throw new Exception("Cannot connect to SQL Server database. Check connection string and ensure database server is accessible."); + } + + logger.LogInformation("Applying pending migrations"); + await dbContext.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("SQL Server database initialized successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize SQL Server database"); + throw; + } + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs new file mode 100644 index 0000000000..aa53ad4c67 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContext.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.DbContexts; +using Microsoft.EntityFrameworkCore; + +class SqlServerDbContext : ServiceControlDbContextBase +{ + public SqlServerDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreatingProvider(ModelBuilder modelBuilder) + { + // SQL Server-specific configurations if needed + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs new file mode 100644 index 0000000000..6bbe125407 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerDbContextFactory.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +// Design-time factory for EF Core tools (migrations, etc.) +class SqlServerDbContextFactory : IDesignTimeDbContextFactory +{ + public SqlServerDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // Use a dummy connection string for design-time operations + optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ServiceControl;Trusted_Connection=True;"); + + return new SqlServerDbContext(optionsBuilder.Options); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs new file mode 100644 index 0000000000..1cc86f1449 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistence.cs @@ -0,0 +1,69 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; +using Core.DbContexts; +using Core.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Particular.LicensingComponent.Persistence; +using ServiceControl.Persistence; + +class SqlServerPersistence : IPersistence +{ + readonly SqlServerPersisterSettings settings; + + public SqlServerPersistence(SqlServerPersisterSettings settings) + { + this.settings = settings; + } + + public void AddPersistence(IServiceCollection services) + { + ConfigureDbContext(services); + + if (settings.MaintenanceMode) + { + return; + } + + services.AddSingleton(); + services.AddSingleton(); + } + + public void AddInstaller(IServiceCollection services) + { + ConfigureDbContext(services); + + // Register the database migrator - this runs during installation/setup + services.AddSingleton(); + } + + void ConfigureDbContext(IServiceCollection services) + { + services.AddSingleton(settings); + services.AddSingleton(settings); + + services.AddDbContext((serviceProvider, options) => + { + options.UseSqlServer(settings.ConnectionString, sqlOptions => + { + sqlOptions.CommandTimeout(settings.CommandTimeout); + if (settings.EnableRetryOnFailure) + { + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + } + }); + + if (settings.EnableSensitiveDataLogging) + { + options.EnableSensitiveDataLogging(); + } + }, ServiceLifetime.Scoped); + + // Register as base type for TrialLicenseDataProvider + services.AddScoped(sp => sp.GetRequiredService()); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs new file mode 100644 index 0000000000..7972c2a801 --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersistenceConfiguration.cs @@ -0,0 +1,35 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Configuration; +using ServiceControl.Persistence; + +public class SqlServerPersistenceConfiguration : IPersistenceConfiguration +{ + const string DatabaseConnectionStringKey = "Database/ConnectionString"; + const string CommandTimeoutKey = "Database/CommandTimeout"; + + public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) + { + var connectionString = SettingsReader.Read(settingsRootNamespace, DatabaseConnectionStringKey); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new Exception($"Setting {DatabaseConnectionStringKey} is required for SQL Server persistence. " + + $"Set environment variable: SERVICECONTROL_DATABASE_CONNECTIONSTRING"); + } + + var settings = new SqlServerPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, 30), + }; + + return settings; + } + + public IPersistence Create(PersistenceSettings settings) + { + var specificSettings = (SqlServerPersisterSettings)settings; + return new SqlServerPersistence(specificSettings); + } +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs new file mode 100644 index 0000000000..144c853ecc --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/SqlServerPersisterSettings.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.Sql.SqlServer; + +using Core.Abstractions; + +public class SqlServerPersisterSettings : SqlPersisterSettings +{ + public bool EnableRetryOnFailure { get; set; } = true; +} diff --git a/src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest b/src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest new file mode 100644 index 0000000000..11392f992c --- /dev/null +++ b/src/ServiceControl.Persistence.Sql.SqlServer/persistence.manifest @@ -0,0 +1,13 @@ +{ + "Name": "SqlServer", + "DisplayName": "SQL Server", + "Description": "SQL Server ServiceControl persister", + "AssemblyName": "ServiceControl.Persistence.Sql.SqlServer", + "TypeName": "ServiceControl.Persistence.Sql.SqlServer.SqlServerPersistenceConfiguration, ServiceControl.Persistence.Sql.SqlServer", + "Settings": [ + { + "Name": "ServiceControl/Database/ConnectionString", + "Mandatory": true + } + ] +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig b/src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs new file mode 100644 index 0000000000..ea197fa88b --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/PersistenceTestsContext.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Sql.Core.Abstractions; +using ServiceControl.Persistence.Sql.MySQL; +using Testcontainers.MySql; + +public class PersistenceTestsContext : IPersistenceTestsContext +{ + MySqlContainer mySqlContainer; + + public async Task Setup(IHostApplicationBuilder hostBuilder) + { + mySqlContainer = new MySqlBuilder() + .WithImage("mysql:8.0") + .WithDatabase("servicecontrol") + .WithUsername("root") + .WithPassword("mysql") + .Build(); + + await mySqlContainer.StartAsync(); + + var connectionString = mySqlContainer.GetConnectionString(); + + PersistenceSettings = new MySqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = 30, + MaintenanceMode = false + }; + + var persistence = new MySqlPersistenceConfiguration().Create(PersistenceSettings); + persistence.AddPersistence(hostBuilder.Services); + persistence.AddInstaller(hostBuilder.Services); + } + + public async Task PostSetup(IHost host) + { + // Apply migrations + var migrator = host.Services.GetRequiredService(); + await migrator.ApplyMigrations(); + } + + public async Task TearDown() + { + if (mySqlContainer != null) + { + await mySqlContainer.StopAsync(); + await mySqlContainer.DisposeAsync(); + } + } + + public PersistenceSettings PersistenceSettings { get; private set; } + + public void CompleteDatabaseOperation() + { + // No-op for SQL (no async indexing like RavenDB) + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj b/src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj new file mode 100644 index 0000000000..974855eebe --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/ServiceControl.Persistence.Tests.Sql.MySQL.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs new file mode 100644 index 0000000000..38ccad0235 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.MySQL/TrialLicenseDataProviderTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using ServiceControl.Persistence; + +[TestFixture] +class TrialLicenseDataProviderTests +{ + [Test] + public async Task Should_store_and_retrieve_trial_end_date() + { + var context = new PersistenceTestsContext(); + var hostBuilder = Host.CreateApplicationBuilder(); + + await context.Setup(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(); + + await context.PostSetup(host); + + var provider = host.Services.GetRequiredService(); + + // Initially should be null + var initialValue = await provider.GetTrialEndDate(default); + Assert.That(initialValue, Is.Null); + + // Store a trial end date + var expectedDate = new DateOnly(2025, 12, 31); + await provider.StoreTrialEndDate(expectedDate, default); + + // Retrieve and verify + var retrievedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedDate, Is.EqualTo(expectedDate)); + + // Update the trial end date + var updatedDate = new DateOnly(2026, 6, 30); + await provider.StoreTrialEndDate(updatedDate, default); + + // Retrieve and verify update + var retrievedUpdatedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedUpdatedDate, Is.EqualTo(updatedDate)); + + await host.StopAsync(); + await context.TearDown(); + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs new file mode 100644 index 0000000000..bebc1fa27a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/PersistenceTestsContext.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Sql.Core.Abstractions; +using ServiceControl.Persistence.Sql.PostgreSQL; +using Testcontainers.PostgreSql; + +public class PersistenceTestsContext : IPersistenceTestsContext +{ + PostgreSqlContainer postgreSqlContainer; + + public async Task Setup(IHostApplicationBuilder hostBuilder) + { + postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("servicecontrol") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + await postgreSqlContainer.StartAsync(); + + var connectionString = postgreSqlContainer.GetConnectionString(); + + PersistenceSettings = new PostgreSqlPersisterSettings + { + ConnectionString = connectionString, + CommandTimeout = 30, + MaintenanceMode = false + }; + + var persistence = new PostgreSqlPersistenceConfiguration().Create(PersistenceSettings); + persistence.AddPersistence(hostBuilder.Services); + persistence.AddInstaller(hostBuilder.Services); + } + + public async Task PostSetup(IHost host) + { + // Apply migrations + var migrator = host.Services.GetRequiredService(); + await migrator.ApplyMigrations(); + } + + public async Task TearDown() + { + if (postgreSqlContainer != null) + { + await postgreSqlContainer.StopAsync(); + await postgreSqlContainer.DisposeAsync(); + } + } + + public PersistenceSettings PersistenceSettings { get; private set; } + + public void CompleteDatabaseOperation() + { + // No-op for SQL (no async indexing like RavenDB) + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj new file mode 100644 index 0000000000..7c294e994b --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs new file mode 100644 index 0000000000..38ccad0235 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.PostgreSQL/TrialLicenseDataProviderTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using ServiceControl.Persistence; + +[TestFixture] +class TrialLicenseDataProviderTests +{ + [Test] + public async Task Should_store_and_retrieve_trial_end_date() + { + var context = new PersistenceTestsContext(); + var hostBuilder = Host.CreateApplicationBuilder(); + + await context.Setup(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(); + + await context.PostSetup(host); + + var provider = host.Services.GetRequiredService(); + + // Initially should be null + var initialValue = await provider.GetTrialEndDate(default); + Assert.That(initialValue, Is.Null); + + // Store a trial end date + var expectedDate = new DateOnly(2025, 12, 31); + await provider.StoreTrialEndDate(expectedDate, default); + + // Retrieve and verify + var retrievedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedDate, Is.EqualTo(expectedDate)); + + // Update the trial end date + var updatedDate = new DateOnly(2026, 6, 30); + await provider.StoreTrialEndDate(updatedDate, default); + + // Retrieve and verify update + var retrievedUpdatedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedUpdatedDate, Is.EqualTo(updatedDate)); + + await host.StopAsync(); + await context.TearDown(); + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig new file mode 100644 index 0000000000..ff993b49bb --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# Justification: ServiceControl app has no synchronization context +dotnet_diagnostic.CA2007.severity = none diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ManageDatabaseServer.cs b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ManageDatabaseServer.cs new file mode 100644 index 0000000000..401124bd03 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ManageDatabaseServer.cs @@ -0,0 +1,37 @@ +namespace ServiceControl.Persistence.Tests; + +using System.Threading.Tasks; +using NUnit.Framework; +using Testcontainers.MsSql; + +[SetUpFixture] +static class ManageDatabaseServer +{ + static MsSqlContainer sqlServerContainer; + + public static string ConnectionString { get; private set; } + + [OneTimeSetUp] + public static async Task EnsureServerStarted() + { + sqlServerContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .WithPassword("YourStrong@Passw0rd") + .WithPortBinding(11433, 1433) + .Build(); + + await sqlServerContainer.StartAsync(); + + ConnectionString = sqlServerContainer.GetConnectionString(); + } + + [OneTimeTearDown] + public static async Task EnsureServerStopped() + { + if (sqlServerContainer != null) + { + await sqlServerContainer.StopAsync(); + await sqlServerContainer.DisposeAsync(); + } + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs new file mode 100644 index 0000000000..c4f18a4e90 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/PersistenceTestsContext.cs @@ -0,0 +1,69 @@ +namespace ServiceControl.Persistence.Tests; + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ServiceControl.Persistence; +using ServiceControl.Persistence.Sql.Core.Abstractions; +using ServiceControl.Persistence.Sql.Core.DbContexts; +using ServiceControl.Persistence.Sql.SqlServer; + +public class PersistenceTestsContext : IPersistenceTestsContext +{ + IHost host; + + public Task Setup(IHostApplicationBuilder hostBuilder) + { + PersistenceSettings = new SqlServerPersisterSettings + { + ConnectionString = ManageDatabaseServer.ConnectionString, + CommandTimeout = 30, + MaintenanceMode = false + }; + + var persistence = new SqlServerPersistenceConfiguration().Create(PersistenceSettings); + persistence.AddPersistence(hostBuilder.Services); + persistence.AddInstaller(hostBuilder.Services); + + return Task.CompletedTask; + } + + public async Task PostSetup(IHost host) + { + // Apply migrations + var migrator = host.Services.GetRequiredService(); + await migrator.ApplyMigrations(); + this.host = host; + } + + public async Task TearDown() + { + if (host != null) + { + using var scope = host.Services.CreateScope(); + using var context = scope.ServiceProvider.GetService(); + + var tableNames = context.Model.GetEntityTypes() + .Select(t => t.GetTableName()) + .Distinct() + .ToList(); + + foreach (var tableName in tableNames) + { +#pragma warning disable EF1002 // Risk of vulnerability to SQL injection. + _ = await context.Database.ExecuteSqlRawAsync($"TRUNCATE TABLE [{tableName}];", CancellationToken.None); +#pragma warning restore EF1002 // Risk of vulnerability to SQL injection. + } + } + } + + public PersistenceSettings PersistenceSettings { get; private set; } + + public void CompleteDatabaseOperation() + { + // No-op for SQL (no async indexing like RavenDB) + } +} diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj new file mode 100644 index 0000000000..042f8fd9ee --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/ServiceControl.Persistence.Tests.Sql.SqlServer.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs new file mode 100644 index 0000000000..38ccad0235 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.Sql.SqlServer/TrialLicenseDataProviderTests.cs @@ -0,0 +1,51 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using ServiceControl.Persistence; + +[TestFixture] +class TrialLicenseDataProviderTests +{ + [Test] + public async Task Should_store_and_retrieve_trial_end_date() + { + var context = new PersistenceTestsContext(); + var hostBuilder = Host.CreateApplicationBuilder(); + + await context.Setup(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(); + + await context.PostSetup(host); + + var provider = host.Services.GetRequiredService(); + + // Initially should be null + var initialValue = await provider.GetTrialEndDate(default); + Assert.That(initialValue, Is.Null); + + // Store a trial end date + var expectedDate = new DateOnly(2025, 12, 31); + await provider.StoreTrialEndDate(expectedDate, default); + + // Retrieve and verify + var retrievedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedDate, Is.EqualTo(expectedDate)); + + // Update the trial end date + var updatedDate = new DateOnly(2026, 6, 30); + await provider.StoreTrialEndDate(updatedDate, default); + + // Retrieve and verify update + var retrievedUpdatedDate = await provider.GetTrialEndDate(default); + Assert.That(retrievedUpdatedDate, Is.EqualTo(updatedDate)); + + await host.StopAsync(); + await context.TearDown(); + } +} diff --git a/src/ServiceControl.sln b/src/ServiceControl.sln index b8a8f676c0..c6063c8dff 100644 --- a/src/ServiceControl.sln +++ b/src/ServiceControl.sln @@ -190,6 +190,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SetupProcessFake", "SetupPr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql", "ServiceControl.Persistence.Sql\ServiceControl.Persistence.Sql.csproj", "{07D7A850-3164-4C27-BE22-FD8A97C06EF3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.Core", "ServiceControl.Persistence.Sql.Core\ServiceControl.Persistence.Sql.Core.csproj", "{7C7239A8-E56B-4A89-9028-80B2A416E989}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.SqlServer", "ServiceControl.Persistence.Sql.SqlServer\ServiceControl.Persistence.Sql.SqlServer.csproj", "{B1177EF2-9022-49D8-B282-DDF494B79CFF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.PostgreSQL", "ServiceControl.Persistence.Sql.PostgreSQL\ServiceControl.Persistence.Sql.PostgreSQL.csproj", "{7A42C8BE-01C9-42F3-B15B-9365940D3FC3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Sql.MySQL", "ServiceControl.Persistence.Sql.MySQL\ServiceControl.Persistence.Sql.MySQL.csproj", "{13F6F4DA-D447-4968-82E2-D4B0897B605E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Tests.Sql.SqlServer", "ServiceControl.Persistence.Tests.Sql.SqlServer\ServiceControl.Persistence.Tests.Sql.SqlServer.csproj", "{DA015F58-BD32-48AF-848D-74DEA5E6B905}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Tests.Sql.PostgreSQL", "ServiceControl.Persistence.Tests.Sql.PostgreSQL\ServiceControl.Persistence.Tests.Sql.PostgreSQL.csproj", "{B1726E92-FD6E-4628-BD20-148095281E1D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceControl.Persistence.Tests.Sql.MySQL", "ServiceControl.Persistence.Tests.Sql.MySQL\ServiceControl.Persistence.Tests.Sql.MySQL.csproj", "{00984992-0ED5-40F1-8821-0B6367D05968}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1040,6 +1054,90 @@ Global {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x64.Build.0 = Release|Any CPU {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.ActiveCfg = Release|Any CPU {07D7A850-3164-4C27-BE22-FD8A97C06EF3}.Release|x86.Build.0 = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x64.Build.0 = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Debug|x86.Build.0 = Debug|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|Any CPU.Build.0 = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x64.ActiveCfg = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x64.Build.0 = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x86.ActiveCfg = Release|Any CPU + {7C7239A8-E56B-4A89-9028-80B2A416E989}.Release|x86.Build.0 = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x64.Build.0 = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Debug|x86.Build.0 = Debug|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|Any CPU.Build.0 = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x64.ActiveCfg = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x64.Build.0 = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x86.ActiveCfg = Release|Any CPU + {B1177EF2-9022-49D8-B282-DDF494B79CFF}.Release|x86.Build.0 = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x64.Build.0 = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Debug|x86.Build.0 = Debug|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|Any CPU.Build.0 = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x64.ActiveCfg = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x64.Build.0 = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x86.ActiveCfg = Release|Any CPU + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3}.Release|x86.Build.0 = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x64.ActiveCfg = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x64.Build.0 = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x86.ActiveCfg = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Debug|x86.Build.0 = Debug|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|Any CPU.Build.0 = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x64.ActiveCfg = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x64.Build.0 = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x86.ActiveCfg = Release|Any CPU + {13F6F4DA-D447-4968-82E2-D4B0897B605E}.Release|x86.Build.0 = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x64.Build.0 = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Debug|x86.Build.0 = Debug|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|Any CPU.Build.0 = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x64.ActiveCfg = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x64.Build.0 = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x86.ActiveCfg = Release|Any CPU + {DA015F58-BD32-48AF-848D-74DEA5E6B905}.Release|x86.Build.0 = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x64.Build.0 = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Debug|x86.Build.0 = Debug|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|Any CPU.Build.0 = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x64.ActiveCfg = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x64.Build.0 = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x86.ActiveCfg = Release|Any CPU + {B1726E92-FD6E-4628-BD20-148095281E1D}.Release|x86.Build.0 = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x64.ActiveCfg = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x64.Build.0 = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x86.ActiveCfg = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Debug|x86.Build.0 = Debug|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|Any CPU.Build.0 = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x64.ActiveCfg = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x64.Build.0 = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x86.ActiveCfg = Release|Any CPU + {00984992-0ED5-40F1-8821-0B6367D05968}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1126,6 +1224,13 @@ Global {481032A1-1106-4C6C-B75E-512F2FB08882} = {9AF9D3C7-E859-451B-BA4D-B954D289213A} {5837F789-69B9-44BE-B114-3A2880F06CAB} = {927A078A-E271-4878-A153-86D71AE510E2} {07D7A850-3164-4C27-BE22-FD8A97C06EF3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {7C7239A8-E56B-4A89-9028-80B2A416E989} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {B1177EF2-9022-49D8-B282-DDF494B79CFF} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {7A42C8BE-01C9-42F3-B15B-9365940D3FC3} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {13F6F4DA-D447-4968-82E2-D4B0897B605E} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {DA015F58-BD32-48AF-848D-74DEA5E6B905} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {B1726E92-FD6E-4628-BD20-148095281E1D} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} + {00984992-0ED5-40F1-8821-0B6367D05968} = {9B52418E-BF18-4D25-BE17-4B56D3FB1154} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3B9E5B72-F580-465A-A22C-2D2148AF4EB4}