consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
public interface IMarketplaceSourceRepository
|
||||
{
|
||||
Task<IReadOnlyList<MarketplaceSource>> ListAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<MarketplaceSource?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<MarketplaceSource> UpsertAsync(
|
||||
string tenantId,
|
||||
MarketplaceSource source,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Remediation.Core.Models;
|
||||
using StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
using StellaOps.Remediation.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core-backed PostgreSQL implementation of <see cref="IMarketplaceSourceRepository"/>.
|
||||
/// Operates against remediation.marketplace_sources with tenant-scoped key composition.
|
||||
/// When constructed without a data source, operates in deterministic in-memory mode.
|
||||
/// </summary>
|
||||
public sealed class PostgresMarketplaceSourceRepository : IMarketplaceSourceRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private const string TenantKeyDelimiter = "::";
|
||||
private const string DefaultTenant = "default";
|
||||
|
||||
private readonly RemediationDataSource? _dataSource;
|
||||
private readonly Dictionary<string, MarketplaceSource>? _inMemoryStore;
|
||||
private readonly object _inMemoryLock = new();
|
||||
|
||||
public PostgresMarketplaceSourceRepository(RemediationDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
public PostgresMarketplaceSourceRepository()
|
||||
{
|
||||
_inMemoryStore = new Dictionary<string, MarketplaceSource>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MarketplaceSource>> ListAsync(
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var normalizedTenantId = NormalizeTenantId(tenantId);
|
||||
var tenantPrefix = $"{normalizedTenantId}{TenantKeyDelimiter}";
|
||||
|
||||
if (_dataSource is null)
|
||||
{
|
||||
lock (_inMemoryLock)
|
||||
{
|
||||
return _inMemoryStore!
|
||||
.Where(entry => entry.Key.StartsWith(tenantPrefix, StringComparison.Ordinal))
|
||||
.Select(static entry => entry.Value)
|
||||
.OrderBy(static source => source.Key, StringComparer.Ordinal)
|
||||
.ThenBy(static source => source.CreatedAt)
|
||||
.ThenBy(static source => source.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.MarketplaceSources
|
||||
.AsNoTracking()
|
||||
.Where(entity => EF.Functions.Like(entity.Key, $"{tenantPrefix}%"))
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities
|
||||
.Select(entity => ToModel(entity, normalizedTenantId))
|
||||
.OrderBy(static source => source.Key, StringComparer.Ordinal)
|
||||
.ThenBy(static source => source.CreatedAt)
|
||||
.ThenBy(static source => source.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<MarketplaceSource?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var normalizedTenantId = NormalizeTenantId(tenantId);
|
||||
var normalizedKey = NormalizeSourceKey(key);
|
||||
var tenantScopedKey = BuildTenantScopedKey(normalizedTenantId, normalizedKey);
|
||||
|
||||
if (_dataSource is null)
|
||||
{
|
||||
lock (_inMemoryLock)
|
||||
{
|
||||
return _inMemoryStore!.TryGetValue(tenantScopedKey, out var source)
|
||||
? source
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.MarketplaceSources
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(source => source.Key == tenantScopedKey, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity, normalizedTenantId);
|
||||
}
|
||||
|
||||
public async Task<MarketplaceSource> UpsertAsync(
|
||||
string tenantId,
|
||||
MarketplaceSource source,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var normalizedTenantId = NormalizeTenantId(tenantId);
|
||||
var normalizedKey = NormalizeSourceKey(source.Key);
|
||||
var tenantScopedKey = BuildTenantScopedKey(normalizedTenantId, normalizedKey);
|
||||
|
||||
if (_dataSource is null)
|
||||
{
|
||||
lock (_inMemoryLock)
|
||||
{
|
||||
if (_inMemoryStore!.TryGetValue(tenantScopedKey, out var existing))
|
||||
{
|
||||
var updated = existing with
|
||||
{
|
||||
Name = NormalizeName(source.Name),
|
||||
Url = NormalizeUrl(source.Url),
|
||||
SourceType = NormalizeSourceType(source.SourceType),
|
||||
Enabled = source.Enabled,
|
||||
TrustScore = source.TrustScore,
|
||||
LastSyncAt = source.LastSyncAt
|
||||
};
|
||||
|
||||
_inMemoryStore[tenantScopedKey] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
var created = source with
|
||||
{
|
||||
Id = source.Id == Guid.Empty ? Guid.NewGuid() : source.Id,
|
||||
Key = normalizedKey,
|
||||
Name = NormalizeName(source.Name),
|
||||
Url = NormalizeUrl(source.Url),
|
||||
SourceType = NormalizeSourceType(source.SourceType),
|
||||
CreatedAt = source.CreatedAt == default ? DateTimeOffset.UtcNow : source.CreatedAt
|
||||
};
|
||||
|
||||
_inMemoryStore[tenantScopedKey] = created;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var existingEntity = await dbContext.MarketplaceSources
|
||||
.FirstOrDefaultAsync(entity => entity.Key == tenantScopedKey, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existingEntity is null)
|
||||
{
|
||||
var entity = new MarketplaceSourceEntity
|
||||
{
|
||||
Id = source.Id == Guid.Empty ? Guid.NewGuid() : source.Id,
|
||||
Key = tenantScopedKey,
|
||||
Name = NormalizeName(source.Name),
|
||||
Url = NormalizeUrl(source.Url),
|
||||
SourceType = NormalizeSourceType(source.SourceType),
|
||||
Enabled = source.Enabled,
|
||||
TrustScore = source.TrustScore,
|
||||
CreatedAt = source.CreatedAt == default ? DateTime.UtcNow : source.CreatedAt.UtcDateTime,
|
||||
LastSyncAt = source.LastSyncAt?.UtcDateTime
|
||||
};
|
||||
|
||||
dbContext.MarketplaceSources.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
return ToModel(entity, normalizedTenantId);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
existingEntity = await dbContext.MarketplaceSources
|
||||
.FirstOrDefaultAsync(item => item.Key == tenantScopedKey, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (existingEntity is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingEntity.Name = NormalizeName(source.Name);
|
||||
existingEntity.Url = NormalizeUrl(source.Url);
|
||||
existingEntity.SourceType = NormalizeSourceType(source.SourceType);
|
||||
existingEntity.Enabled = source.Enabled;
|
||||
existingEntity.TrustScore = source.TrustScore;
|
||||
existingEntity.LastSyncAt = source.LastSyncAt?.UtcDateTime;
|
||||
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
return ToModel(existingEntity, normalizedTenantId);
|
||||
}
|
||||
|
||||
private static string NormalizeTenantId(string tenantId)
|
||||
{
|
||||
var normalized = tenantId?.Trim().ToLowerInvariant();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? DefaultTenant : normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeSourceKey(string key)
|
||||
{
|
||||
return key.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name)
|
||||
{
|
||||
return name.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeSourceType(string sourceType)
|
||||
{
|
||||
return sourceType.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? NormalizeUrl(string? url)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(url) ? null : url.Trim();
|
||||
}
|
||||
|
||||
private static string BuildTenantScopedKey(string normalizedTenantId, string normalizedSourceKey)
|
||||
{
|
||||
return $"{normalizedTenantId}{TenantKeyDelimiter}{normalizedSourceKey}";
|
||||
}
|
||||
|
||||
private static MarketplaceSource ToModel(MarketplaceSourceEntity entity, string normalizedTenantId)
|
||||
{
|
||||
var expectedPrefix = $"{normalizedTenantId}{TenantKeyDelimiter}";
|
||||
var sourceKey = entity.Key.StartsWith(expectedPrefix, StringComparison.Ordinal)
|
||||
? entity.Key[expectedPrefix.Length..]
|
||||
: entity.Key;
|
||||
|
||||
return new MarketplaceSource
|
||||
{
|
||||
Id = entity.Id,
|
||||
Key = sourceKey,
|
||||
Name = entity.Name,
|
||||
Url = entity.Url,
|
||||
SourceType = entity.SourceType,
|
||||
Enabled = entity.Enabled,
|
||||
TrustScore = entity.TrustScore,
|
||||
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
|
||||
LastSyncAt = entity.LastSyncAt.HasValue
|
||||
? new DateTimeOffset(entity.LastSyncAt.Value, TimeSpan.Zero)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is Npgsql.PostgresException { SqlState: "23505" })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string GetSchemaName() => RemediationDataSource.DefaultSchemaName;
|
||||
}
|
||||
Reference in New Issue
Block a user