consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -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);
}

View File

@@ -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;
}