wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for RemediationDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.RemediationDbContext))]
|
||||
public partial class RemediationDbContextModel : RuntimeModel
|
||||
{
|
||||
private static RemediationDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new RemediationDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Context;
|
||||
|
||||
public partial class RemediationDbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Relationship overlays and navigation property configuration.
|
||||
/// </summary>
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// pr_submissions.fix_template_id -> fix_templates.id foreign key
|
||||
modelBuilder.Entity<PrSubmissionEntity>(entity =>
|
||||
{
|
||||
entity.HasOne<FixTemplateEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.FixTemplateId)
|
||||
.HasConstraintName("pr_submissions_fix_template_id_fkey")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Remediation module.
|
||||
/// Maps to the remediation PostgreSQL schema: fix_templates, pr_submissions,
|
||||
/// contributors, and marketplace_sources tables.
|
||||
/// </summary>
|
||||
public partial class RemediationDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public RemediationDbContext(DbContextOptions<RemediationDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "remediation"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<FixTemplateEntity> FixTemplates { get; set; }
|
||||
public virtual DbSet<PrSubmissionEntity> PrSubmissions { get; set; }
|
||||
public virtual DbSet<ContributorEntity> Contributors { get; set; }
|
||||
public virtual DbSet<MarketplaceSourceEntity> MarketplaceSources { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- fix_templates -----------------------------------------------
|
||||
modelBuilder.Entity<FixTemplateEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("fix_templates_pkey");
|
||||
entity.ToTable("fix_templates", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.CveId, "idx_fix_templates_cve");
|
||||
entity.HasIndex(e => e.Purl, "idx_fix_templates_purl");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.Purl).HasColumnName("purl");
|
||||
entity.Property(e => e.VersionRange).HasColumnName("version_range");
|
||||
entity.Property(e => e.PatchContent).HasColumnName("patch_content");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.ContributorId).HasColumnName("contributor_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.Status)
|
||||
.HasDefaultValueSql("'pending'")
|
||||
.HasColumnName("status");
|
||||
entity.Property(e => e.TrustScore)
|
||||
.HasDefaultValue(0.0)
|
||||
.HasColumnName("trust_score");
|
||||
entity.Property(e => e.DsseDigest).HasColumnName("dsse_digest");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.VerifiedAt).HasColumnName("verified_at");
|
||||
});
|
||||
|
||||
// -- pr_submissions ----------------------------------------------
|
||||
modelBuilder.Entity<PrSubmissionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("pr_submissions_pkey");
|
||||
entity.ToTable("pr_submissions", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.CveId, "idx_pr_submissions_cve");
|
||||
entity.HasIndex(e => e.Status, "idx_pr_submissions_status");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.FixTemplateId).HasColumnName("fix_template_id");
|
||||
entity.Property(e => e.PrUrl).HasColumnName("pr_url");
|
||||
entity.Property(e => e.RepositoryUrl).HasColumnName("repository_url");
|
||||
entity.Property(e => e.SourceBranch).HasColumnName("source_branch");
|
||||
entity.Property(e => e.TargetBranch).HasColumnName("target_branch");
|
||||
entity.Property(e => e.CveId).HasColumnName("cve_id");
|
||||
entity.Property(e => e.Status)
|
||||
.HasDefaultValueSql("'opened'")
|
||||
.HasColumnName("status");
|
||||
entity.Property(e => e.PreScanDigest).HasColumnName("pre_scan_digest");
|
||||
entity.Property(e => e.PostScanDigest).HasColumnName("post_scan_digest");
|
||||
entity.Property(e => e.ReachabilityDeltaDigest).HasColumnName("reachability_delta_digest");
|
||||
entity.Property(e => e.FixChainDsseDigest).HasColumnName("fix_chain_dsse_digest");
|
||||
entity.Property(e => e.Verdict).HasColumnName("verdict");
|
||||
entity.Property(e => e.ContributorId).HasColumnName("contributor_id");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.MergedAt).HasColumnName("merged_at");
|
||||
entity.Property(e => e.VerifiedAt).HasColumnName("verified_at");
|
||||
});
|
||||
|
||||
// -- contributors ------------------------------------------------
|
||||
modelBuilder.Entity<ContributorEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("contributors_pkey");
|
||||
entity.ToTable("contributors", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.Username).HasName("contributors_username_key");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.Username).HasColumnName("username");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.VerifiedFixes)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("verified_fixes");
|
||||
entity.Property(e => e.TotalSubmissions)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("total_submissions");
|
||||
entity.Property(e => e.RejectedSubmissions)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("rejected_submissions");
|
||||
entity.Property(e => e.TrustScore)
|
||||
.HasDefaultValue(0.0)
|
||||
.HasColumnName("trust_score");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.LastActiveAt).HasColumnName("last_active_at");
|
||||
});
|
||||
|
||||
// -- marketplace_sources -----------------------------------------
|
||||
modelBuilder.Entity<MarketplaceSourceEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("marketplace_sources_pkey");
|
||||
entity.ToTable("marketplace_sources", schemaName);
|
||||
|
||||
entity.HasAlternateKey(e => e.Key).HasName("marketplace_sources_key_key");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.Key).HasColumnName("key");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.Url).HasColumnName("url");
|
||||
entity.Property(e => e.SourceType)
|
||||
.HasDefaultValueSql("'community'")
|
||||
.HasColumnName("source_type");
|
||||
entity.Property(e => e.Enabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
entity.Property(e => e.TrustScore)
|
||||
.HasDefaultValue(0.0)
|
||||
.HasColumnName("trust_score");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.LastSyncAt).HasColumnName("last_sync_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for <see cref="RemediationDbContext"/>.
|
||||
/// Used by <c>dotnet ef</c> CLI tooling for scaffold and optimize commands.
|
||||
/// </summary>
|
||||
public sealed class RemediationDesignTimeDbContextFactory : IDesignTimeDbContextFactory<RemediationDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=remediation,public";
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_REMEDIATION_EF_CONNECTION";
|
||||
|
||||
public RemediationDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<RemediationDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new RemediationDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the remediation.contributors table.
|
||||
/// </summary>
|
||||
public partial class ContributorEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Username { get; set; } = null!;
|
||||
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
public int VerifiedFixes { get; set; }
|
||||
|
||||
public int TotalSubmissions { get; set; }
|
||||
|
||||
public int RejectedSubmissions { get; set; }
|
||||
|
||||
public double TrustScore { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? LastActiveAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the remediation.fix_templates table.
|
||||
/// </summary>
|
||||
public partial class FixTemplateEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string CveId { get; set; } = null!;
|
||||
|
||||
public string Purl { get; set; } = null!;
|
||||
|
||||
public string VersionRange { get; set; } = null!;
|
||||
|
||||
public string PatchContent { get; set; } = null!;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public Guid? ContributorId { get; set; }
|
||||
|
||||
public Guid? SourceId { get; set; }
|
||||
|
||||
public string Status { get; set; } = null!;
|
||||
|
||||
public double TrustScore { get; set; }
|
||||
|
||||
public string? DsseDigest { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the remediation.marketplace_sources table.
|
||||
/// </summary>
|
||||
public partial class MarketplaceSourceEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string Key { get; set; } = null!;
|
||||
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public string? Url { get; set; }
|
||||
|
||||
public string SourceType { get; set; } = null!;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public double TrustScore { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace StellaOps.Remediation.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for the remediation.pr_submissions table.
|
||||
/// </summary>
|
||||
public partial class PrSubmissionEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid? FixTemplateId { get; set; }
|
||||
|
||||
public string PrUrl { get; set; } = null!;
|
||||
|
||||
public string RepositoryUrl { get; set; } = null!;
|
||||
|
||||
public string SourceBranch { get; set; } = null!;
|
||||
|
||||
public string TargetBranch { get; set; } = null!;
|
||||
|
||||
public string CveId { get; set; } = null!;
|
||||
|
||||
public string Status { get; set; } = null!;
|
||||
|
||||
public string? PreScanDigest { get; set; }
|
||||
|
||||
public string? PostScanDigest { get; set; }
|
||||
|
||||
public string? ReachabilityDeltaDigest { get; set; }
|
||||
|
||||
public string? FixChainDsseDigest { get; set; }
|
||||
|
||||
public string? Verdict { get; set; }
|
||||
|
||||
public Guid? ContributorId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? MergedAt { get; set; }
|
||||
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for the Remediation module.
|
||||
/// Manages connections for fix templates, PR submissions, contributors, and marketplace sources.
|
||||
/// </summary>
|
||||
public sealed class RemediationDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for Remediation tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "remediation";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Remediation data source.
|
||||
/// </summary>
|
||||
public RemediationDataSource(IOptions<PostgresOptions> options, ILogger<RemediationDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "Remediation";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Remediation.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Remediation.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Remediation.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="RemediationDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class RemediationDbContextFactory
|
||||
{
|
||||
public static RemediationDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? RemediationDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<RemediationDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, RemediationDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(RemediationDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new RemediationDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,252 @@
|
||||
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="IFixTemplateRepository"/>.
|
||||
/// Operates against the remediation.fix_templates table.
|
||||
/// When constructed without a data source, operates in in-memory stub mode for backward compatibility.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixTemplateRepository : IFixTemplateRepository
|
||||
{
|
||||
// Stub: real implementation uses Npgsql/Dapper against remediation.fix_templates
|
||||
private readonly List<FixTemplate> _store = new();
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
public Task<IReadOnlyList<FixTemplate>> ListAsync(string? cveId = null, string? purl = null, int limit = 50, int offset = 0, CancellationToken ct = default)
|
||||
private readonly RemediationDataSource? _dataSource;
|
||||
private readonly List<FixTemplate>? _inMemoryStore;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a repository backed by PostgreSQL via EF Core.
|
||||
/// </summary>
|
||||
public PostgresFixTemplateRepository(RemediationDataSource dataSource)
|
||||
{
|
||||
var query = _store.AsEnumerable();
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameterless constructor for backward compatibility with in-memory usage.
|
||||
/// When no data source is provided, the repository operates in stub/in-memory mode.
|
||||
/// </summary>
|
||||
public PostgresFixTemplateRepository()
|
||||
{
|
||||
_inMemoryStore = new List<FixTemplate>();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FixTemplate>> ListAsync(
|
||||
string? cveId = null,
|
||||
string? purl = null,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return ListInMemory(cveId, purl, limit, offset);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<FixTemplateEntity> query = dbContext.FixTemplates.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
query = query.Where(t => t.CveId == cveId);
|
||||
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
query = query.Where(t => t.Purl == purl);
|
||||
|
||||
query = query
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
|
||||
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return _inMemoryStore!.FirstOrDefault(t => t.Id == id);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.FixTemplates
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == id, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return InsertInMemory(template);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(template);
|
||||
if (entity.Id == Guid.Empty)
|
||||
entity.Id = Guid.NewGuid();
|
||||
|
||||
entity.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
dbContext.FixTemplates.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotent: return the template as-is if it already exists
|
||||
}
|
||||
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(
|
||||
string cveId,
|
||||
string? purl = null,
|
||||
string? version = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return FindMatchesInMemory(cveId, purl, version);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<FixTemplateEntity> query = dbContext.FixTemplates
|
||||
.AsNoTracking()
|
||||
.Where(t => t.CveId == cveId && t.Status == "verified");
|
||||
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
query = query.Where(t => t.Purl == purl);
|
||||
|
||||
// Version range matching requires application-level logic (cannot be expressed in LINQ),
|
||||
// so we load the filtered set and apply version range matching in memory.
|
||||
var entities = await query
|
||||
.OrderByDescending(t => t.TrustScore)
|
||||
.ThenByDescending(t => t.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IEnumerable<FixTemplateEntity> filtered = entities;
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
filtered = entities.Where(t => VersionRangeMatches(t.VersionRange, version));
|
||||
|
||||
return filtered.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
#region In-memory stub operations (backward compatibility)
|
||||
|
||||
private IReadOnlyList<FixTemplate> ListInMemory(string? cveId, string? purl, int limit, int offset)
|
||||
{
|
||||
var query = _inMemoryStore!.AsEnumerable();
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
query = query.Where(t => t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
query = query.Where(t => t.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
IReadOnlyList<FixTemplate> result = query.Skip(offset).Take(limit).ToList();
|
||||
return Task.FromResult(result);
|
||||
return query.Skip(offset).Take(limit).ToList();
|
||||
}
|
||||
|
||||
public Task<FixTemplate?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
private FixTemplate InsertInMemory(FixTemplate template)
|
||||
{
|
||||
var template = _store.FirstOrDefault(t => t.Id == id);
|
||||
return Task.FromResult(template);
|
||||
var created = template with
|
||||
{
|
||||
Id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_inMemoryStore!.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
public Task<FixTemplate> InsertAsync(FixTemplate template, CancellationToken ct = default)
|
||||
private IReadOnlyList<FixTemplate> FindMatchesInMemory(string cveId, string? purl, string? version)
|
||||
{
|
||||
var created = template with { Id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_store.Add(created);
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
var query = _inMemoryStore!.Where(t =>
|
||||
t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) && t.Status == "verified");
|
||||
|
||||
public Task<IReadOnlyList<FixTemplate>> FindMatchesAsync(string cveId, string? purl = null, string? version = null, CancellationToken ct = default)
|
||||
{
|
||||
var query = _store.Where(t => t.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) && t.Status == "verified");
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
query = query.Where(t => t.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
query = query.Where(t => VersionRangeMatches(t.VersionRange, version));
|
||||
|
||||
IReadOnlyList<FixTemplate> result = query
|
||||
return query
|
||||
.OrderByDescending(t => t.TrustScore)
|
||||
.ThenByDescending(t => t.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entity/Model mapping
|
||||
|
||||
private static FixTemplateEntity ToEntity(FixTemplate model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
CveId = model.CveId,
|
||||
Purl = model.Purl,
|
||||
VersionRange = model.VersionRange,
|
||||
PatchContent = model.PatchContent,
|
||||
Description = model.Description,
|
||||
ContributorId = model.ContributorId,
|
||||
SourceId = model.SourceId,
|
||||
Status = model.Status,
|
||||
TrustScore = model.TrustScore,
|
||||
DsseDigest = model.DsseDigest,
|
||||
CreatedAt = model.CreatedAt.UtcDateTime,
|
||||
VerifiedAt = model.VerifiedAt?.UtcDateTime
|
||||
};
|
||||
|
||||
private static FixTemplate ToModel(FixTemplateEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CveId = entity.CveId,
|
||||
Purl = entity.Purl,
|
||||
VersionRange = entity.VersionRange,
|
||||
PatchContent = entity.PatchContent,
|
||||
Description = entity.Description,
|
||||
ContributorId = entity.ContributorId,
|
||||
SourceId = entity.SourceId,
|
||||
Status = entity.Status,
|
||||
TrustScore = entity.TrustScore,
|
||||
DsseDigest = entity.DsseDigest,
|
||||
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
|
||||
VerifiedAt = entity.VerifiedAt.HasValue
|
||||
? new DateTimeOffset(entity.VerifiedAt.Value, TimeSpan.Zero)
|
||||
: null
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
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 static bool VersionRangeMatches(string? versionRange, string targetVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetVersion))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(versionRange))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalizedRange = versionRange.Trim();
|
||||
var normalizedTarget = targetVersion.Trim();
|
||||
@@ -82,4 +270,6 @@ public sealed class PostgresFixTemplateRepository : IFixTemplateRepository
|
||||
// Fallback: substring match supports lightweight expressions like ">=1.2.0 <2.0.0".
|
||||
return normalizedRange.Contains(normalizedTarget, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string GetSchemaName() => RemediationDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,233 @@
|
||||
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="IPrSubmissionRepository"/>.
|
||||
/// Operates against the remediation.pr_submissions table.
|
||||
/// When constructed without a data source, operates in in-memory stub mode for backward compatibility.
|
||||
/// </summary>
|
||||
public sealed class PostgresPrSubmissionRepository : IPrSubmissionRepository
|
||||
{
|
||||
// Stub: real implementation uses Npgsql/Dapper against remediation.pr_submissions
|
||||
private readonly List<PrSubmission> _store = new();
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
public Task<IReadOnlyList<PrSubmission>> ListAsync(string? cveId = null, string? status = null, int limit = 50, int offset = 0, CancellationToken ct = default)
|
||||
private readonly RemediationDataSource? _dataSource;
|
||||
private readonly List<PrSubmission>? _inMemoryStore;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a repository backed by PostgreSQL via EF Core.
|
||||
/// </summary>
|
||||
public PostgresPrSubmissionRepository(RemediationDataSource dataSource)
|
||||
{
|
||||
var query = _store.AsEnumerable();
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameterless constructor for backward compatibility with in-memory usage.
|
||||
/// When no data source is provided, the repository operates in stub/in-memory mode.
|
||||
/// </summary>
|
||||
public PostgresPrSubmissionRepository()
|
||||
{
|
||||
_inMemoryStore = new List<PrSubmission>();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PrSubmission>> ListAsync(
|
||||
string? cveId = null,
|
||||
string? status = null,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return ListInMemory(cveId, status, limit, offset);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<PrSubmissionEntity> query = dbContext.PrSubmissions.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
query = query.Where(s => s.CveId == cveId);
|
||||
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
query = query.Where(s => s.Status == status);
|
||||
|
||||
query = query
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ThenBy(s => s.Id)
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
|
||||
var entities = await query.ToListAsync(ct).ConfigureAwait(false);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async Task<PrSubmission?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return _inMemoryStore!.FirstOrDefault(s => s.Id == id);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.PrSubmissions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == id, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task<PrSubmission> InsertAsync(PrSubmission submission, CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
return InsertInMemory(submission);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(submission);
|
||||
if (entity.Id == Guid.Empty)
|
||||
entity.Id = Guid.NewGuid();
|
||||
|
||||
entity.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
dbContext.PrSubmissions.Add(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotent: return the submission as-is if it already exists
|
||||
}
|
||||
|
||||
return ToModel(entity);
|
||||
}
|
||||
|
||||
public async Task UpdateStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default)
|
||||
{
|
||||
if (_dataSource is null)
|
||||
{
|
||||
UpdateStatusInMemory(id, status, verdict);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.PrSubmissions
|
||||
.FirstOrDefaultAsync(s => s.Id == id, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is not null)
|
||||
{
|
||||
entity.Status = status;
|
||||
entity.Verdict = verdict;
|
||||
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
#region In-memory stub operations (backward compatibility)
|
||||
|
||||
private IReadOnlyList<PrSubmission> ListInMemory(string? cveId, string? status, int limit, int offset)
|
||||
{
|
||||
var query = _inMemoryStore!.AsEnumerable();
|
||||
if (!string.IsNullOrEmpty(cveId))
|
||||
query = query.Where(s => s.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
query = query.Where(s => s.Status.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
IReadOnlyList<PrSubmission> result = query.Skip(offset).Take(limit).ToList();
|
||||
return Task.FromResult(result);
|
||||
return query.Skip(offset).Take(limit).ToList();
|
||||
}
|
||||
|
||||
public Task<PrSubmission?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
private PrSubmission InsertInMemory(PrSubmission submission)
|
||||
{
|
||||
var submission = _store.FirstOrDefault(s => s.Id == id);
|
||||
return Task.FromResult(submission);
|
||||
var created = submission with
|
||||
{
|
||||
Id = submission.Id == Guid.Empty ? Guid.NewGuid() : submission.Id,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_inMemoryStore!.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
public Task<PrSubmission> InsertAsync(PrSubmission submission, CancellationToken ct = default)
|
||||
private void UpdateStatusInMemory(Guid id, string status, string? verdict)
|
||||
{
|
||||
var created = submission with { Id = submission.Id == Guid.Empty ? Guid.NewGuid() : submission.Id, CreatedAt = DateTimeOffset.UtcNow };
|
||||
_store.Add(created);
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task UpdateStatusAsync(Guid id, string status, string? verdict = null, CancellationToken ct = default)
|
||||
{
|
||||
var index = _store.FindIndex(s => s.Id == id);
|
||||
var index = _inMemoryStore!.FindIndex(s => s.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
_store[index] = _store[index] with { Status = status, Verdict = verdict };
|
||||
_inMemoryStore[index] = _inMemoryStore[index] with { Status = status, Verdict = verdict };
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entity/Model mapping
|
||||
|
||||
private static PrSubmissionEntity ToEntity(PrSubmission model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
FixTemplateId = model.FixTemplateId,
|
||||
PrUrl = model.PrUrl,
|
||||
RepositoryUrl = model.RepositoryUrl,
|
||||
SourceBranch = model.SourceBranch,
|
||||
TargetBranch = model.TargetBranch,
|
||||
CveId = model.CveId,
|
||||
Status = model.Status,
|
||||
PreScanDigest = model.PreScanDigest,
|
||||
PostScanDigest = model.PostScanDigest,
|
||||
ReachabilityDeltaDigest = model.ReachabilityDeltaDigest,
|
||||
FixChainDsseDigest = model.FixChainDsseDigest,
|
||||
Verdict = model.Verdict,
|
||||
ContributorId = model.ContributorId,
|
||||
CreatedAt = model.CreatedAt.UtcDateTime,
|
||||
MergedAt = model.MergedAt?.UtcDateTime,
|
||||
VerifiedAt = model.VerifiedAt?.UtcDateTime
|
||||
};
|
||||
|
||||
private static PrSubmission ToModel(PrSubmissionEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
FixTemplateId = entity.FixTemplateId,
|
||||
PrUrl = entity.PrUrl,
|
||||
RepositoryUrl = entity.RepositoryUrl,
|
||||
SourceBranch = entity.SourceBranch,
|
||||
TargetBranch = entity.TargetBranch,
|
||||
CveId = entity.CveId,
|
||||
Status = entity.Status,
|
||||
PreScanDigest = entity.PreScanDigest,
|
||||
PostScanDigest = entity.PostScanDigest,
|
||||
ReachabilityDeltaDigest = entity.ReachabilityDeltaDigest,
|
||||
FixChainDsseDigest = entity.FixChainDsseDigest,
|
||||
Verdict = entity.Verdict,
|
||||
ContributorId = entity.ContributorId,
|
||||
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
|
||||
MergedAt = entity.MergedAt.HasValue
|
||||
? new DateTimeOffset(entity.MergedAt.Value, TimeSpan.Zero)
|
||||
: null,
|
||||
VerifiedAt = entity.VerifiedAt.HasValue
|
||||
? new DateTimeOffset(entity.VerifiedAt.Value, TimeSpan.Zero)
|
||||
: null
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,33 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Remediation.Persistence</RootNamespace>
|
||||
<AssemblyName>StellaOps.Remediation.Persistence</AssemblyName>
|
||||
<Description>Consolidated persistence layer for StellaOps Remediation module</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\RemediationDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Remediation.Core\StellaOps.Remediation.Core.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user