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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -0,0 +1,51 @@
// <auto-generated />
// Compiled model stub for Concelier EF Core.
// This will be regenerated by `dotnet ef dbcontext optimize` when a live DB is available.
// The runtime factory guards against empty stubs using GetEntityTypes().Any().
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Concelier.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Concelier.Persistence.EfCore.CompiledModels
{
[DbContext(typeof(ConcelierDbContext))]
public partial class ConcelierDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static ConcelierDbContextModel()
{
var model = new ConcelierDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (ConcelierDbContextModel)model.FinalizeModel();
}
private static ConcelierDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -1,21 +1,685 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.Persistence.Postgres.Models;
namespace StellaOps.Concelier.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for Concelier module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// EF Core DbContext for the Concelier module.
/// Covers both the vuln and concelier schemas.
/// Scaffolded from SQL migrations 001-005.
/// </summary>
public class ConcelierDbContext : DbContext
public partial class ConcelierDbContext : DbContext
{
public ConcelierDbContext(DbContextOptions<ConcelierDbContext> options)
private readonly string _schemaName;
public ConcelierDbContext(DbContextOptions<ConcelierDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "vuln"
: schemaName.Trim();
}
// ---- vuln schema DbSets ----
public virtual DbSet<SourceEntity> Sources { get; set; }
public virtual DbSet<FeedSnapshotEntity> FeedSnapshots { get; set; }
public virtual DbSet<AdvisorySnapshotEntity> AdvisorySnapshots { get; set; }
public virtual DbSet<AdvisoryEntity> Advisories { get; set; }
public virtual DbSet<AdvisoryAliasEntity> AdvisoryAliases { get; set; }
public virtual DbSet<AdvisoryCvssEntity> AdvisoryCvss { get; set; }
public virtual DbSet<AdvisoryAffectedEntity> AdvisoryAffected { get; set; }
public virtual DbSet<AdvisoryReferenceEntity> AdvisoryReferences { get; set; }
public virtual DbSet<AdvisoryCreditEntity> AdvisoryCredits { get; set; }
public virtual DbSet<AdvisoryWeaknessEntity> AdvisoryWeaknesses { get; set; }
public virtual DbSet<KevFlagEntity> KevFlags { get; set; }
public virtual DbSet<SourceStateEntity> SourceStates { get; set; }
public virtual DbSet<MergeEventEntity> MergeEvents { get; set; }
public virtual DbSet<AdvisoryLinksetCacheEntity> LinksetCache { get; set; }
public virtual DbSet<SyncLedgerEntity> SyncLedger { get; set; }
public virtual DbSet<SitePolicyEntity> SitePolicy { get; set; }
public virtual DbSet<AdvisoryCanonicalEntity> AdvisoryCanonicals { get; set; }
public virtual DbSet<AdvisorySourceEdgeEntity> AdvisorySourceEdges { get; set; }
public virtual DbSet<ProvenanceScopeEntity> ProvenanceScopes { get; set; }
// ---- vuln schema DbSets (additional) ----
public virtual DbSet<InterestScoreEntity> InterestScores { get; set; }
// ---- concelier schema DbSets ----
public virtual DbSet<DocumentRecordEntity> SourceDocuments { get; set; }
public virtual DbSet<DtoRecordEntity> Dtos { get; set; }
public virtual DbSet<ExportStateEntity> ExportStates { get; set; }
public virtual DbSet<PsirtFlagEntity> PsirtFlags { get; set; }
public virtual DbSet<JpFlagEntity> JpFlags { get; set; }
public virtual DbSet<ChangeHistoryEntity> ChangeHistory { get; set; }
public virtual DbSet<SbomDocumentEntity> SbomDocuments { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("vuln");
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
const string concelierSchema = "concelier";
// ================================================================
// vuln.sources
// ================================================================
modelBuilder.Entity<SourceEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("sources_pkey");
entity.ToTable("sources", schemaName);
entity.HasIndex(e => new { e.Enabled, e.Priority }, "idx_sources_enabled")
.IsDescending(false, true);
entity.HasIndex(e => e.Key).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.Key).HasColumnName("key");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.SourceType).HasColumnName("source_type");
entity.Property(e => e.Url).HasColumnName("url");
entity.Property(e => e.Priority).HasColumnName("priority");
entity.Property(e => e.Enabled).HasColumnName("enabled").HasDefaultValue(true);
entity.Property(e => e.Config).HasColumnName("config").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.Metadata).HasColumnName("metadata").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.feed_snapshots
// ================================================================
modelBuilder.Entity<FeedSnapshotEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("feed_snapshots_pkey");
entity.ToTable("feed_snapshots", schemaName);
entity.HasIndex(e => e.SourceId, "idx_feed_snapshots_source");
entity.HasIndex(e => e.CreatedAt, "idx_feed_snapshots_created");
entity.HasIndex(e => new { e.SourceId, e.SnapshotId }).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.SnapshotId).HasColumnName("snapshot_id");
entity.Property(e => e.AdvisoryCount).HasColumnName("advisory_count");
entity.Property(e => e.Checksum).HasColumnName("checksum");
entity.Property(e => e.Metadata).HasColumnName("metadata").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_snapshots
// ================================================================
modelBuilder.Entity<AdvisorySnapshotEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_snapshots_pkey");
entity.ToTable("advisory_snapshots", schemaName);
entity.HasIndex(e => e.FeedSnapshotId, "idx_advisory_snapshots_feed");
entity.HasIndex(e => e.AdvisoryKey, "idx_advisory_snapshots_key");
entity.HasIndex(e => new { e.FeedSnapshotId, e.AdvisoryKey }).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.FeedSnapshotId).HasColumnName("feed_snapshot_id");
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
entity.Property(e => e.ContentHash).HasColumnName("content_hash");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisories
// ================================================================
modelBuilder.Entity<AdvisoryEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisories_pkey");
entity.ToTable("advisories", schemaName);
entity.HasIndex(e => e.AdvisoryKey).IsUnique();
entity.HasIndex(e => e.PrimaryVulnId, "idx_advisories_vuln_id");
entity.HasIndex(e => e.SourceId, "idx_advisories_source");
entity.HasIndex(e => e.Severity, "idx_advisories_severity");
entity.HasIndex(e => e.PublishedAt, "idx_advisories_published");
entity.HasIndex(e => e.ModifiedAt, "idx_advisories_modified");
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
entity.Property(e => e.PrimaryVulnId).HasColumnName("primary_vuln_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.Title).HasColumnName("title");
entity.Property(e => e.Summary).HasColumnName("summary");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.PublishedAt).HasColumnName("published_at");
entity.Property(e => e.ModifiedAt).HasColumnName("modified_at");
entity.Property(e => e.WithdrawnAt).HasColumnName("withdrawn_at");
entity.Property(e => e.Provenance).HasColumnName("provenance").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.RawPayload).HasColumnName("raw_payload").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
// Generated/computed columns and tsvector are not mapped; DB triggers handle them.
});
// ================================================================
// vuln.advisory_aliases
// ================================================================
modelBuilder.Entity<AdvisoryAliasEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_aliases_pkey");
entity.ToTable("advisory_aliases", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_aliases_advisory");
entity.HasIndex(e => new { e.AliasType, e.AliasValue }, "idx_advisory_aliases_value");
entity.HasIndex(e => new { e.AdvisoryId, e.AliasType, e.AliasValue }).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.AliasType).HasColumnName("alias_type");
entity.Property(e => e.AliasValue).HasColumnName("alias_value");
entity.Property(e => e.IsPrimary).HasColumnName("is_primary").HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_cvss
// ================================================================
modelBuilder.Entity<AdvisoryCvssEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_cvss_pkey");
entity.ToTable("advisory_cvss", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_cvss_advisory");
entity.HasIndex(e => e.BaseScore, "idx_advisory_cvss_score").IsDescending();
entity.HasIndex(e => new { e.AdvisoryId, e.CvssVersion, e.Source }).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.CvssVersion).HasColumnName("cvss_version");
entity.Property(e => e.VectorString).HasColumnName("vector_string");
entity.Property(e => e.BaseScore).HasColumnName("base_score").HasColumnType("numeric(3,1)");
entity.Property(e => e.BaseSeverity).HasColumnName("base_severity");
entity.Property(e => e.ExploitabilityScore).HasColumnName("exploitability_score").HasColumnType("numeric(3,1)");
entity.Property(e => e.ImpactScore).HasColumnName("impact_score").HasColumnType("numeric(3,1)");
entity.Property(e => e.Source).HasColumnName("source");
entity.Property(e => e.IsPrimary).HasColumnName("is_primary").HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_affected
// ================================================================
modelBuilder.Entity<AdvisoryAffectedEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_affected_pkey");
entity.ToTable("advisory_affected", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_affected_advisory");
entity.HasIndex(e => new { e.Ecosystem, e.PackageName }, "idx_advisory_affected_ecosystem");
entity.HasIndex(e => e.Purl, "idx_advisory_affected_purl");
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.Ecosystem).HasColumnName("ecosystem");
entity.Property(e => e.PackageName).HasColumnName("package_name");
entity.Property(e => e.Purl).HasColumnName("purl");
entity.Property(e => e.VersionRange).HasColumnName("version_range").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.VersionsAffected).HasColumnName("versions_affected");
entity.Property(e => e.VersionsFixed).HasColumnName("versions_fixed");
entity.Property(e => e.DatabaseSpecific).HasColumnName("database_specific").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
// Generated columns purl_type and purl_name are DB-managed; not mapped.
});
// ================================================================
// vuln.advisory_references
// ================================================================
modelBuilder.Entity<AdvisoryReferenceEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_references_pkey");
entity.ToTable("advisory_references", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_references_advisory");
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.RefType).HasColumnName("ref_type");
entity.Property(e => e.Url).HasColumnName("url");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_credits
// ================================================================
modelBuilder.Entity<AdvisoryCreditEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_credits_pkey");
entity.ToTable("advisory_credits", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_credits_advisory");
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.Name).HasColumnName("name");
entity.Property(e => e.Contact).HasColumnName("contact");
entity.Property(e => e.CreditType).HasColumnName("credit_type");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_weaknesses
// ================================================================
modelBuilder.Entity<AdvisoryWeaknessEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_weaknesses_pkey");
entity.ToTable("advisory_weaknesses", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_advisory_weaknesses_advisory");
entity.HasIndex(e => e.CweId, "idx_advisory_weaknesses_cwe");
entity.HasIndex(e => new { e.AdvisoryId, e.CweId }).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.CweId).HasColumnName("cwe_id");
entity.Property(e => e.Description).HasColumnName("description");
entity.Property(e => e.Source).HasColumnName("source");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.kev_flags
// ================================================================
modelBuilder.Entity<KevFlagEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("kev_flags_pkey");
entity.ToTable("kev_flags", schemaName);
entity.HasIndex(e => e.AdvisoryId, "idx_kev_flags_advisory");
entity.HasIndex(e => e.CveId, "idx_kev_flags_cve");
entity.HasIndex(e => e.DateAdded, "idx_kev_flags_date");
entity.HasIndex(e => new { e.AdvisoryId, e.CveId }).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.VendorProject).HasColumnName("vendor_project");
entity.Property(e => e.Product).HasColumnName("product");
entity.Property(e => e.VulnerabilityName).HasColumnName("vulnerability_name");
entity.Property(e => e.DateAdded).HasColumnName("date_added");
entity.Property(e => e.DueDate).HasColumnName("due_date");
entity.Property(e => e.KnownRansomwareUse).HasColumnName("known_ransomware_use").HasDefaultValue(false);
entity.Property(e => e.Notes).HasColumnName("notes");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.source_states
// ================================================================
modelBuilder.Entity<SourceStateEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("source_states_pkey");
entity.ToTable("source_states", schemaName);
entity.HasIndex(e => e.SourceId, "idx_source_states_source").IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.Cursor).HasColumnName("cursor");
entity.Property(e => e.LastSyncAt).HasColumnName("last_sync_at");
entity.Property(e => e.LastSuccessAt).HasColumnName("last_success_at");
entity.Property(e => e.LastError).HasColumnName("last_error");
entity.Property(e => e.SyncCount).HasColumnName("sync_count");
entity.Property(e => e.ErrorCount).HasColumnName("error_count");
entity.Property(e => e.Metadata).HasColumnName("metadata").HasColumnType("jsonb").HasDefaultValueSql("'{}'::jsonb");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.merge_events (partitioned table - map for query, not insert via EF)
// ================================================================
modelBuilder.Entity<MergeEventEntity>(entity =>
{
entity.HasKey(e => new { e.Id, e.CreatedAt }).HasName("merge_events_pkey");
entity.ToTable("merge_events", schemaName);
entity.HasIndex(e => e.AdvisoryId, "ix_merge_events_part_advisory");
entity.HasIndex(e => e.EventType, "ix_merge_events_part_event_type");
entity.Property(e => e.Id).HasColumnName("id")
.ValueGeneratedOnAdd()
.UseIdentityByDefaultColumn();
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.EventType).HasColumnName("event_type");
entity.Property(e => e.OldValue).HasColumnName("old_value").HasColumnType("jsonb");
entity.Property(e => e.NewValue).HasColumnName("new_value").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.lnm_linkset_cache
// ================================================================
modelBuilder.Entity<AdvisoryLinksetCacheEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("lnm_linkset_cache_pkey");
entity.ToTable("lnm_linkset_cache", schemaName);
entity.HasIndex(e => new { e.TenantId, e.AdvisoryId, e.Source }, "uq_lnm_linkset_cache").IsUnique();
entity.HasIndex(e => new { e.TenantId, e.CreatedAt, e.AdvisoryId, e.Source }, "idx_lnm_linkset_cache_order")
.IsDescending(false, true, false, false);
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.Source).HasColumnName("source");
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.Observations).HasColumnName("observations");
entity.Property(e => e.NormalizedJson).HasColumnName("normalized").HasColumnType("jsonb");
entity.Property(e => e.ConflictsJson).HasColumnName("conflicts").HasColumnType("jsonb");
entity.Property(e => e.ProvenanceJson).HasColumnName("provenance").HasColumnType("jsonb");
entity.Property(e => e.Confidence).HasColumnName("confidence");
entity.Property(e => e.BuiltByJobId).HasColumnName("built_by_job_id");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
});
// ================================================================
// vuln.sync_ledger
// ================================================================
modelBuilder.Entity<SyncLedgerEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("sync_ledger_pkey");
entity.ToTable("sync_ledger", schemaName);
entity.HasIndex(e => e.SiteId, "idx_sync_ledger_site");
entity.HasIndex(e => new { e.SiteId, e.Cursor }, "uq_sync_ledger_site_cursor").IsUnique();
entity.HasIndex(e => e.BundleHash, "uq_sync_ledger_bundle").IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.SiteId).HasColumnName("site_id");
entity.Property(e => e.Cursor).HasColumnName("cursor");
entity.Property(e => e.BundleHash).HasColumnName("bundle_hash");
entity.Property(e => e.ItemsCount).HasColumnName("items_count");
entity.Property(e => e.SignedAt).HasColumnName("signed_at");
entity.Property(e => e.ImportedAt).HasColumnName("imported_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.site_policy
// ================================================================
modelBuilder.Entity<SitePolicyEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("site_policy_pkey");
entity.ToTable("site_policy", schemaName);
entity.HasIndex(e => e.SiteId).IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.SiteId).HasColumnName("site_id");
entity.Property(e => e.DisplayName).HasColumnName("display_name");
entity.Property(e => e.AllowedSources).HasColumnName("allowed_sources");
entity.Property(e => e.DeniedSources).HasColumnName("denied_sources");
entity.Property(e => e.MaxBundleSizeMb).HasColumnName("max_bundle_size_mb").HasDefaultValue(100);
entity.Property(e => e.MaxItemsPerBundle).HasColumnName("max_items_per_bundle").HasDefaultValue(10000);
entity.Property(e => e.RequireSignature).HasColumnName("require_signature").HasDefaultValue(true);
entity.Property(e => e.AllowedSigners).HasColumnName("allowed_signers");
entity.Property(e => e.Enabled).HasColumnName("enabled").HasDefaultValue(true);
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_canonical
// ================================================================
modelBuilder.Entity<AdvisoryCanonicalEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_canonical_pkey");
entity.ToTable("advisory_canonical", schemaName);
entity.HasIndex(e => e.Cve, "idx_advisory_canonical_cve");
entity.HasIndex(e => e.AffectsKey, "idx_advisory_canonical_affects");
entity.HasIndex(e => e.MergeHash, "idx_advisory_canonical_merge_hash").IsUnique();
entity.HasIndex(e => e.UpdatedAt, "idx_advisory_canonical_updated").IsDescending();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.Cve).HasColumnName("cve");
entity.Property(e => e.AffectsKey).HasColumnName("affects_key");
entity.Property(e => e.VersionRange).HasColumnName("version_range").HasColumnType("jsonb");
entity.Property(e => e.Weakness).HasColumnName("weakness");
entity.Property(e => e.MergeHash).HasColumnName("merge_hash");
entity.Property(e => e.Status).HasColumnName("status").HasDefaultValue("active");
entity.Property(e => e.Severity).HasColumnName("severity");
entity.Property(e => e.EpssScore).HasColumnName("epss_score").HasColumnType("numeric(5,4)");
entity.Property(e => e.ExploitKnown).HasColumnName("exploit_known").HasDefaultValue(false);
entity.Property(e => e.Title).HasColumnName("title");
entity.Property(e => e.Summary).HasColumnName("summary");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.advisory_source_edge
// ================================================================
modelBuilder.Entity<AdvisorySourceEdgeEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("advisory_source_edge_pkey");
entity.ToTable("advisory_source_edge", schemaName);
entity.HasIndex(e => e.CanonicalId, "idx_source_edge_canonical");
entity.HasIndex(e => e.SourceId, "idx_source_edge_source");
entity.HasIndex(e => e.SourceAdvisoryId, "idx_source_edge_advisory_id");
entity.HasIndex(e => new { e.CanonicalId, e.SourceId, e.SourceDocHash }, "uq_advisory_source_edge_unique").IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.CanonicalId).HasColumnName("canonical_id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.SourceAdvisoryId).HasColumnName("source_advisory_id");
entity.Property(e => e.SourceDocHash).HasColumnName("source_doc_hash");
entity.Property(e => e.VendorStatus).HasColumnName("vendor_status");
entity.Property(e => e.PrecedenceRank).HasColumnName("precedence_rank").HasDefaultValue(100);
entity.Property(e => e.DsseEnvelope).HasColumnName("dsse_envelope").HasColumnType("jsonb");
entity.Property(e => e.RawPayload).HasColumnName("raw_payload").HasColumnType("jsonb");
entity.Property(e => e.FetchedAt).HasColumnName("fetched_at").HasDefaultValueSql("now()");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.provenance_scope
// ================================================================
modelBuilder.Entity<ProvenanceScopeEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("provenance_scope_pkey");
entity.ToTable("provenance_scope", schemaName);
entity.HasIndex(e => e.CanonicalId, "idx_provenance_scope_canonical");
entity.HasIndex(e => e.DistroRelease, "idx_provenance_scope_distro");
entity.HasIndex(e => new { e.CanonicalId, e.DistroRelease }, "uq_provenance_scope_canonical_distro").IsUnique();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.CanonicalId).HasColumnName("canonical_id");
entity.Property(e => e.DistroRelease).HasColumnName("distro_release");
entity.Property(e => e.BackportSemver).HasColumnName("backport_semver");
entity.Property(e => e.PatchId).HasColumnName("patch_id");
entity.Property(e => e.PatchOrigin).HasColumnName("patch_origin");
entity.Property(e => e.EvidenceRef).HasColumnName("evidence_ref");
entity.Property(e => e.Confidence).HasColumnName("confidence").HasColumnType("numeric(3,2)").HasDefaultValue(0.5m);
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// concelier.source_documents
// ================================================================
modelBuilder.Entity<DocumentRecordEntity>(entity =>
{
entity.HasKey(e => new { e.SourceName, e.Uri }).HasName("pk_source_documents");
entity.ToTable("source_documents", concelierSchema);
entity.HasIndex(e => e.SourceId, "idx_source_documents_source_id");
entity.HasIndex(e => e.Status, "idx_source_documents_status");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.SourceId).HasColumnName("source_id");
entity.Property(e => e.SourceName).HasColumnName("source_name");
entity.Property(e => e.Uri).HasColumnName("uri");
entity.Property(e => e.Sha256).HasColumnName("sha256");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.ContentType).HasColumnName("content_type");
entity.Property(e => e.HeadersJson).HasColumnName("headers_json").HasColumnType("jsonb");
entity.Property(e => e.MetadataJson).HasColumnName("metadata_json").HasColumnType("jsonb");
entity.Property(e => e.Etag).HasColumnName("etag");
entity.Property(e => e.LastModified).HasColumnName("last_modified");
entity.Property(e => e.Payload).HasColumnName("payload");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
entity.Property(e => e.ExpiresAt).HasColumnName("expires_at");
});
// ================================================================
// vuln.interest_score
// ================================================================
modelBuilder.Entity<InterestScoreEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("interest_score_pkey");
entity.ToTable("interest_score", schemaName);
entity.HasIndex(e => e.CanonicalId, "uq_interest_score_canonical").IsUnique();
entity.HasIndex(e => e.Score, "idx_interest_score_score").IsDescending();
entity.HasIndex(e => e.ComputedAt, "idx_interest_score_computed").IsDescending();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.CanonicalId).HasColumnName("canonical_id");
entity.Property(e => e.Score).HasColumnName("score").HasColumnType("numeric(3,2)");
entity.Property(e => e.Reasons).HasColumnName("reasons").HasColumnType("jsonb").HasDefaultValueSql("'[]'::jsonb");
entity.Property(e => e.LastSeenInBuild).HasColumnName("last_seen_in_build");
entity.Property(e => e.ComputedAt).HasColumnName("computed_at").HasDefaultValueSql("now()");
});
// ================================================================
// concelier.dtos
// ================================================================
modelBuilder.Entity<DtoRecordEntity>(entity =>
{
entity.HasKey(e => e.DocumentId).HasName("pk_concelier_dtos");
entity.ToTable("dtos", concelierSchema);
entity.HasIndex(e => new { e.SourceName, e.CreatedAt }, "idx_concelier_dtos_source")
.IsDescending(false, true);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.DocumentId).HasColumnName("document_id");
entity.Property(e => e.SourceName).HasColumnName("source_name");
entity.Property(e => e.Format).HasColumnName("format");
entity.Property(e => e.PayloadJson).HasColumnName("payload_json").HasColumnType("jsonb");
entity.Property(e => e.SchemaVersion).HasColumnName("schema_version").HasDefaultValue("");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.ValidatedAt).HasColumnName("validated_at").HasDefaultValueSql("now()");
});
// ================================================================
// concelier.export_states
// ================================================================
modelBuilder.Entity<ExportStateEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk_concelier_export_states");
entity.ToTable("export_states", concelierSchema);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.ExportCursor).HasColumnName("export_cursor");
entity.Property(e => e.LastFullDigest).HasColumnName("last_full_digest");
entity.Property(e => e.LastDeltaDigest).HasColumnName("last_delta_digest");
entity.Property(e => e.BaseExportId).HasColumnName("base_export_id");
entity.Property(e => e.BaseDigest).HasColumnName("base_digest");
entity.Property(e => e.TargetRepository).HasColumnName("target_repository");
entity.Property(e => e.Files).HasColumnName("files").HasColumnType("jsonb");
entity.Property(e => e.ExporterVersion).HasColumnName("exporter_version");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// concelier.psirt_flags
// ================================================================
modelBuilder.Entity<PsirtFlagEntity>(entity =>
{
entity.HasKey(e => new { e.AdvisoryId, e.Vendor }).HasName("pk_concelier_psirt_flags");
entity.ToTable("psirt_flags", concelierSchema);
entity.HasIndex(e => new { e.SourceName, e.RecordedAt }, "idx_concelier_psirt_source")
.IsDescending(false, true);
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.Vendor).HasColumnName("vendor");
entity.Property(e => e.SourceName).HasColumnName("source_name");
entity.Property(e => e.ExternalId).HasColumnName("external_id");
entity.Property(e => e.RecordedAt).HasColumnName("recorded_at");
});
// ================================================================
// concelier.jp_flags
// ================================================================
modelBuilder.Entity<JpFlagEntity>(entity =>
{
entity.HasKey(e => e.AdvisoryKey).HasName("pk_concelier_jp_flags");
entity.ToTable("jp_flags", concelierSchema);
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
entity.Property(e => e.SourceName).HasColumnName("source_name");
entity.Property(e => e.Category).HasColumnName("category");
entity.Property(e => e.VendorStatus).HasColumnName("vendor_status");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
});
// ================================================================
// concelier.change_history
// ================================================================
modelBuilder.Entity<ChangeHistoryEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk_concelier_change_history");
entity.ToTable("change_history", concelierSchema);
entity.HasIndex(e => new { e.AdvisoryKey, e.CreatedAt }, "idx_concelier_change_history_advisory")
.IsDescending(false, true);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.SourceName).HasColumnName("source_name");
entity.Property(e => e.AdvisoryKey).HasColumnName("advisory_key");
entity.Property(e => e.DocumentId).HasColumnName("document_id");
entity.Property(e => e.DocumentHash).HasColumnName("document_hash");
entity.Property(e => e.SnapshotHash).HasColumnName("snapshot_hash");
entity.Property(e => e.PreviousSnapshotHash).HasColumnName("previous_snapshot_hash");
entity.Property(e => e.Snapshot).HasColumnName("snapshot").HasColumnType("jsonb");
entity.Property(e => e.PreviousSnapshot).HasColumnName("previous_snapshot").HasColumnType("jsonb");
entity.Property(e => e.Changes).HasColumnName("changes").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
});
// ================================================================
// concelier.sbom_documents
// ================================================================
modelBuilder.Entity<SbomDocumentEntity>(entity =>
{
entity.HasKey(e => e.Id).HasName("sbom_documents_pkey");
entity.ToTable("sbom_documents", concelierSchema);
entity.HasIndex(e => e.SerialNumber, "uq_concelier_sbom_serial").IsUnique();
entity.HasIndex(e => e.ArtifactDigest, "uq_concelier_sbom_artifact").IsUnique();
entity.HasIndex(e => e.UpdatedAt, "idx_concelier_sbom_updated").IsDescending();
entity.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.SerialNumber).HasColumnName("serial_number");
entity.Property(e => e.ArtifactDigest).HasColumnName("artifact_digest");
entity.Property(e => e.Format).HasColumnName("format");
entity.Property(e => e.SpecVersion).HasColumnName("spec_version");
entity.Property(e => e.ComponentCount).HasColumnName("component_count").HasDefaultValue(0);
entity.Property(e => e.ServiceCount).HasColumnName("service_count").HasDefaultValue(0);
entity.Property(e => e.VulnerabilityCount).HasColumnName("vulnerability_count").HasDefaultValue(0);
entity.Property(e => e.HasCrypto).HasColumnName("has_crypto").HasDefaultValue(false);
entity.Property(e => e.HasServices).HasColumnName("has_services").HasDefaultValue(false);
entity.Property(e => e.HasVulnerabilities).HasColumnName("has_vulnerabilities").HasDefaultValue(false);
entity.Property(e => e.LicenseIds).HasColumnName("license_ids");
entity.Property(e => e.LicenseExpressions).HasColumnName("license_expressions");
entity.Property(e => e.SbomJson).HasColumnName("sbom_json").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Concelier.Persistence.EfCore.Context;
/// <summary>
/// Design-time factory for dotnet ef CLI tooling.
/// Does NOT use compiled models (reflection-based discovery for scaffold/optimize).
/// </summary>
public sealed class ConcelierDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ConcelierDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vuln,concelier,public";
private const string ConnectionStringEnvironmentVariable =
"STELLAOPS_CONCELIER_EF_CONNECTION";
public ConcelierDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<ConcelierDbContext>()
.UseNpgsql(connectionString)
.Options;
return new ConcelierDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.CompiledModels;
using StellaOps.Concelier.Persistence.EfCore.Context;
namespace StellaOps.Concelier.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating ConcelierDbContext instances.
/// Applies the compiled model for default schema (performance path).
/// Falls back to reflection-based model for non-default schemas (integration tests).
/// </summary>
internal static class ConcelierDbContextFactory
{
public static ConcelierDbContext Create(
NpgsqlConnection connection,
int commandTimeoutSeconds,
string? schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? ConcelierDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<ConcelierDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
// Use static compiled model ONLY for default schema path.
// Guard: only apply if the compiled model has entity types registered
// (empty stub models bypass OnModelCreating and cause DbSet errors).
if (string.Equals(normalizedSchema, ConcelierDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
var compiledModel = ConcelierDbContextModel.Instance;
if (compiledModel.GetEntityTypes().Any())
{
optionsBuilder.UseModel(compiledModel);
}
}
return new ConcelierDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -0,0 +1,20 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for concelier.change_history table.
/// Stores advisory change tracking with before/after snapshots and field-level diffs.
/// </summary>
public sealed class ChangeHistoryEntity
{
public Guid Id { get; set; }
public string SourceName { get; set; } = string.Empty;
public string AdvisoryKey { get; set; } = string.Empty;
public Guid DocumentId { get; set; }
public string DocumentHash { get; set; } = string.Empty;
public string SnapshotHash { get; set; } = string.Empty;
public string? PreviousSnapshotHash { get; set; }
public string Snapshot { get; set; } = string.Empty;
public string? PreviousSnapshot { get; set; }
public string Changes { get; set; } = "[]";
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for concelier.dtos table.
/// Stores parsed DTO documents keyed by document_id.
/// </summary>
public sealed class DtoRecordEntity
{
public Guid Id { get; set; }
public Guid DocumentId { get; set; }
public string SourceName { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public string PayloadJson { get; set; } = string.Empty;
public string SchemaVersion { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime ValidatedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for concelier.export_states table.
/// Tracks state of feed export processes.
/// </summary>
public sealed class ExportStateEntity
{
public string Id { get; set; } = string.Empty;
public string ExportCursor { get; set; } = string.Empty;
public string? LastFullDigest { get; set; }
public string? LastDeltaDigest { get; set; }
public string? BaseExportId { get; set; }
public string? BaseDigest { get; set; }
public string? TargetRepository { get; set; }
public string Files { get; set; } = "[]";
public string ExporterVersion { get; set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for vuln.interest_score table.
/// Stores computed interest scores for advisory canonicals.
/// </summary>
public sealed class InterestScoreEntity
{
public Guid Id { get; set; }
public Guid CanonicalId { get; set; }
public decimal Score { get; set; }
public string Reasons { get; set; } = "[]";
public Guid? LastSeenInBuild { get; set; }
public DateTimeOffset ComputedAt { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for concelier.jp_flags table.
/// Tracks Japan CERT vendor status flags per advisory.
/// </summary>
public sealed class JpFlagEntity
{
public string AdvisoryKey { get; set; } = string.Empty;
public string SourceName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string? VendorStatus { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for concelier.psirt_flags table.
/// Tracks PSIRT (Product Security Incident Response Team) flags per advisory/vendor.
/// </summary>
public sealed class PsirtFlagEntity
{
public string AdvisoryId { get; set; } = string.Empty;
public string Vendor { get; set; } = string.Empty;
public string SourceName { get; set; } = string.Empty;
public string? ExternalId { get; set; }
public DateTimeOffset RecordedAt { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Concelier.Persistence.Postgres.Models;
/// <summary>
/// Entity for concelier.sbom_documents table.
/// Stores enriched SBOM documents with license and component metadata.
/// </summary>
public sealed class SbomDocumentEntity
{
public Guid Id { get; set; }
public string SerialNumber { get; set; } = string.Empty;
public string? ArtifactDigest { get; set; }
public string Format { get; set; } = string.Empty;
public string SpecVersion { get; set; } = string.Empty;
public int ComponentCount { get; set; }
public int ServiceCount { get; set; }
public int VulnerabilityCount { get; set; }
public bool HasCrypto { get; set; }
public bool HasServices { get; set; }
public bool HasVulnerabilities { get; set; }
public string[] LicenseIds { get; set; } = [];
public string[] LicenseExpressions { get; set; } = [];
public string SbomJson { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -1,72 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for advisory snapshots.
/// </summary>
public sealed class AdvisorySnapshotRepository : RepositoryBase<ConcelierDataSource>, IAdvisorySnapshotRepository
public sealed class AdvisorySnapshotRepository : IAdvisorySnapshotRepository
{
private const string SystemTenantId = "_system";
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<AdvisorySnapshotRepository> _logger;
public AdvisorySnapshotRepository(ConcelierDataSource dataSource, ILogger<AdvisorySnapshotRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AdvisorySnapshotEntity> InsertAsync(AdvisorySnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
// ON CONFLICT upsert with RETURNING requires raw SQL.
const string sql = """
INSERT INTO vuln.advisory_snapshots
(id, feed_snapshot_id, advisory_key, content_hash)
VALUES
(@id, @feed_snapshot_id, @advisory_key, @content_hash)
({0}, {1}, {2}, {3})
ON CONFLICT (feed_snapshot_id, advisory_key) DO UPDATE SET
content_hash = EXCLUDED.content_hash
RETURNING id, feed_snapshot_id, advisory_key, content_hash, created_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<AdvisorySnapshotRawResult>(
sql,
cmd =>
{
AddParameter(cmd, "id", snapshot.Id);
AddParameter(cmd, "feed_snapshot_id", snapshot.FeedSnapshotId);
AddParameter(cmd, "advisory_key", snapshot.AdvisoryKey);
AddParameter(cmd, "content_hash", snapshot.ContentHash);
},
MapSnapshot!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Insert returned null");
snapshot.Id,
snapshot.FeedSnapshotId,
snapshot.AdvisoryKey,
snapshot.ContentHash)
.ToListAsync(cancellationToken);
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Insert returned null");
return new AdvisorySnapshotEntity
{
Id = row.id,
FeedSnapshotId = row.feed_snapshot_id,
AdvisoryKey = row.advisory_key,
ContentHash = row.content_hash,
CreatedAt = row.created_at
};
}
public Task<IReadOnlyList<AdvisorySnapshotEntity>> GetByFeedSnapshotAsync(Guid feedSnapshotId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<AdvisorySnapshotEntity>> GetByFeedSnapshotAsync(Guid feedSnapshotId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, feed_snapshot_id, advisory_key, content_hash, created_at
FROM vuln.advisory_snapshots
WHERE feed_snapshot_id = @feed_snapshot_id
ORDER BY advisory_key
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "feed_snapshot_id", feedSnapshotId),
MapSnapshot,
cancellationToken);
return await context.AdvisorySnapshots
.AsNoTracking()
.Where(s => s.FeedSnapshotId == feedSnapshotId)
.OrderBy(s => s.AdvisoryKey)
.ToListAsync(cancellationToken);
}
private static AdvisorySnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
private sealed class AdvisorySnapshotRawResult
{
Id = reader.GetGuid(0),
FeedSnapshotId = reader.GetGuid(1),
AdvisoryKey = reader.GetString(2),
ContentHash = reader.GetString(3),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(4)
};
public Guid id { get; init; }
public Guid feed_snapshot_id { get; init; }
public string advisory_key { get; init; } = string.Empty;
public string content_hash { get; init; } = string.Empty;
public DateTimeOffset created_at { get; init; }
}
}

View File

@@ -1,11 +1,8 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Repositories;
using System.Text.Json;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
@@ -17,97 +14,88 @@ public interface IDocumentRepository
Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken);
}
public sealed class DocumentRepository : RepositoryBase<ConcelierDataSource>, IDocumentRepository
public sealed class DocumentRepository : IDocumentRepository
{
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<DocumentRepository> _logger;
public DocumentRepository(ConcelierDataSource dataSource, ILogger<DocumentRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DocumentRecordEntity?> FindAsync(Guid id, CancellationToken cancellationToken)
{
const string sql = """
SELECT * FROM concelier.source_documents
WHERE id = @Id
LIMIT 1;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await conn.QuerySingleOrDefaultAsync(sql, new { Id = id });
return row is null ? null : Map(row);
return await context.SourceDocuments
.AsNoTracking()
.Where(d => d.Id == id)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<DocumentRecordEntity?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken)
{
const string sql = """
SELECT * FROM concelier.source_documents
WHERE source_name = @SourceName AND uri = @Uri
LIMIT 1;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await conn.QuerySingleOrDefaultAsync(sql, new { SourceName = sourceName, Uri = uri });
return row is null ? null : Map(row);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return await context.SourceDocuments
.AsNoTracking()
.Where(d => d.SourceName == sourceName && d.Uri == uri)
.FirstOrDefaultAsync(cancellationToken);
}
public async Task<DocumentRecordEntity> UpsertAsync(DocumentRecordEntity record, CancellationToken cancellationToken)
{
// ON CONFLICT upsert with RETURNING * and jsonb casts on a composite-key table requires raw SQL.
const string sql = """
INSERT INTO concelier.source_documents (
id, source_id, source_name, uri, sha256, status, content_type,
headers_json, metadata_json, etag, last_modified, payload, created_at, updated_at, expires_at)
VALUES (
@Id, @SourceId, @SourceName, @Uri, @Sha256, @Status, @ContentType,
@HeadersJson::jsonb, @MetadataJson::jsonb, @Etag, @LastModified, @Payload, @CreatedAt, @UpdatedAt, @ExpiresAt)
ON CONFLICT (source_name, uri) DO UPDATE SET
sha256 = EXCLUDED.sha256,
status = EXCLUDED.status,
content_type = EXCLUDED.content_type,
headers_json = EXCLUDED.headers_json,
metadata_json = EXCLUDED.metadata_json,
etag = EXCLUDED.etag,
last_modified = EXCLUDED.last_modified,
payload = EXCLUDED.payload,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at
RETURNING *;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await conn.QuerySingleAsync(sql, new
{
INSERT INTO concelier.source_documents (
id, source_id, source_name, uri, sha256, status, content_type,
headers_json, metadata_json, etag, last_modified, payload, created_at, updated_at, expires_at)
VALUES (
{0}, {1}, {2}, {3}, {4}, {5}, {6},
{7}::jsonb, {8}::jsonb, {9}, {10}, {11}, {12}, {13}, {14})
ON CONFLICT (source_name, uri) DO UPDATE SET
sha256 = EXCLUDED.sha256,
status = EXCLUDED.status,
content_type = EXCLUDED.content_type,
headers_json = EXCLUDED.headers_json,
metadata_json = EXCLUDED.metadata_json,
etag = EXCLUDED.etag,
last_modified = EXCLUDED.last_modified,
payload = EXCLUDED.payload,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at
RETURNING id, source_id, source_name, uri, sha256, status, content_type,
headers_json::text, metadata_json::text, etag, last_modified, payload,
created_at, updated_at, expires_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<DocumentRawResult>(
sql,
record.Id,
record.SourceId,
record.SourceName,
record.Uri,
record.Sha256,
record.Status,
record.ContentType,
record.HeadersJson,
record.MetadataJson,
record.Etag,
record.LastModified,
record.ContentType ?? (object)DBNull.Value,
record.HeadersJson ?? (object)DBNull.Value,
record.MetadataJson ?? (object)DBNull.Value,
record.Etag ?? (object)DBNull.Value,
record.LastModified ?? (object)DBNull.Value,
record.Payload,
record.CreatedAt,
record.UpdatedAt,
record.ExpiresAt
});
return Map(row);
}
record.ExpiresAt ?? (object)DBNull.Value)
.ToListAsync(cancellationToken);
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
{
const string sql = """
UPDATE concelier.source_documents
SET status = @Status, updated_at = NOW()
WHERE id = @Id;
""";
await using var conn = await DataSource.OpenSystemConnectionAsync(cancellationToken);
await conn.ExecuteAsync(sql, new { Id = id, Status = status });
}
private DocumentRecordEntity Map(dynamic row)
{
var row = rows.Single();
return new DocumentRecordEntity(
row.id,
row.source_id,
@@ -115,14 +103,44 @@ WHERE id = @Id;
row.uri,
row.sha256,
row.status,
(string?)row.content_type,
(string?)row.headers_json,
(string?)row.metadata_json,
(string?)row.etag,
(DateTimeOffset?)row.last_modified,
(byte[])row.payload,
DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc),
DateTime.SpecifyKind(row.updated_at, DateTimeKind.Utc),
row.expires_at is null ? null : DateTime.SpecifyKind(row.expires_at, DateTimeKind.Utc));
row.content_type,
row.headers_json,
row.metadata_json,
row.etag,
row.last_modified,
row.payload,
row.created_at,
row.updated_at,
row.expires_at);
}
public async Task UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
await context.Database.ExecuteSqlRawAsync(
"UPDATE concelier.source_documents SET status = {0}, updated_at = NOW() WHERE id = {1}",
[status, id],
cancellationToken);
}
private sealed class DocumentRawResult
{
public Guid id { get; init; }
public Guid source_id { get; init; }
public string source_name { get; init; } = string.Empty;
public string uri { get; init; } = string.Empty;
public string sha256 { get; init; } = string.Empty;
public string status { get; init; } = string.Empty;
public string? content_type { get; init; }
public string? headers_json { get; init; }
public string? metadata_json { get; init; }
public string? etag { get; init; }
public DateTimeOffset? last_modified { get; init; }
public byte[] payload { get; init; } = [];
public DateTime created_at { get; init; }
public DateTime updated_at { get; init; }
public DateTime? expires_at { get; init; }
}
}

View File

@@ -1,78 +1,87 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for feed snapshots.
/// </summary>
public sealed class FeedSnapshotRepository : RepositoryBase<ConcelierDataSource>, IFeedSnapshotRepository
public sealed class FeedSnapshotRepository : IFeedSnapshotRepository
{
private const string SystemTenantId = "_system";
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<FeedSnapshotRepository> _logger;
public FeedSnapshotRepository(ConcelierDataSource dataSource, ILogger<FeedSnapshotRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FeedSnapshotEntity> InsertAsync(FeedSnapshotEntity snapshot, CancellationToken cancellationToken = default)
{
// ON CONFLICT DO NOTHING with RETURNING requires raw SQL.
const string sql = """
INSERT INTO vuln.feed_snapshots
(id, source_id, snapshot_id, advisory_count, checksum, metadata)
VALUES
(@id, @source_id, @snapshot_id, @advisory_count, @checksum, @metadata::jsonb)
({0}, {1}, {2}, {3}, {4}, {5}::jsonb)
ON CONFLICT (source_id, snapshot_id) DO NOTHING
RETURNING id, source_id, snapshot_id, advisory_count, checksum, metadata::text, created_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<FeedSnapshotRawResult>(
sql,
cmd =>
{
AddParameter(cmd, "id", snapshot.Id);
AddParameter(cmd, "source_id", snapshot.SourceId);
AddParameter(cmd, "snapshot_id", snapshot.SnapshotId);
AddParameter(cmd, "advisory_count", snapshot.AdvisoryCount);
AddParameter(cmd, "checksum", snapshot.Checksum);
AddJsonbParameter(cmd, "metadata", snapshot.Metadata);
},
MapSnapshot!,
cancellationToken).ConfigureAwait(false) ?? snapshot;
snapshot.Id,
snapshot.SourceId,
snapshot.SnapshotId,
snapshot.AdvisoryCount,
snapshot.Checksum ?? (object)DBNull.Value,
snapshot.Metadata)
.ToListAsync(cancellationToken);
if (rows.Count == 0)
{
return snapshot; // ON CONFLICT DO NOTHING -> return original
}
var row = rows.Single();
return new FeedSnapshotEntity
{
Id = row.id,
SourceId = row.source_id,
SnapshotId = row.snapshot_id,
AdvisoryCount = row.advisory_count,
Checksum = row.checksum,
Metadata = row.metadata ?? "{}",
CreatedAt = row.created_at
};
}
public Task<FeedSnapshotEntity?> GetBySourceAndIdAsync(Guid sourceId, string snapshotId, CancellationToken cancellationToken = default)
public async Task<FeedSnapshotEntity?> GetBySourceAndIdAsync(Guid sourceId, string snapshotId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, source_id, snapshot_id, advisory_count, checksum, metadata::text, created_at
FROM vuln.feed_snapshots
WHERE source_id = @source_id AND snapshot_id = @snapshot_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "source_id", sourceId);
AddParameter(cmd, "snapshot_id", snapshotId);
},
MapSnapshot,
cancellationToken);
return await context.FeedSnapshots
.AsNoTracking()
.Where(f => f.SourceId == sourceId && f.SnapshotId == snapshotId)
.FirstOrDefaultAsync(cancellationToken);
}
private static FeedSnapshotEntity MapSnapshot(NpgsqlDataReader reader) => new()
private sealed class FeedSnapshotRawResult
{
Id = reader.GetGuid(0),
SourceId = reader.GetGuid(1),
SnapshotId = reader.GetString(2),
AdvisoryCount = reader.GetInt32(3),
Checksum = GetNullableString(reader, 4),
Metadata = reader.GetString(5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
};
public Guid id { get; init; }
public Guid source_id { get; init; }
public string snapshot_id { get; init; } = string.Empty;
public int advisory_count { get; init; }
public string? checksum { get; init; }
public string? metadata { get; init; }
public DateTimeOffset created_at { get; init; }
}
}

View File

@@ -1,114 +1,70 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for KEV flags.
/// </summary>
public sealed class KevFlagRepository : RepositoryBase<ConcelierDataSource>, IKevFlagRepository
public sealed class KevFlagRepository : IKevFlagRepository
{
private const string SystemTenantId = "_system";
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<KevFlagRepository> _logger;
public KevFlagRepository(ConcelierDataSource dataSource, ILogger<KevFlagRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task ReplaceAsync(Guid advisoryId, IEnumerable<KevFlagEntity> flags, CancellationToken cancellationToken = default)
{
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
const string deleteSql = "DELETE FROM vuln.kev_flags WHERE advisory_id = @advisory_id";
await using (var deleteCmd = CreateCommand(deleteSql, connection))
{
deleteCmd.Transaction = transaction;
AddParameter(deleteCmd, "advisory_id", advisoryId);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);
const string insertSql = """
INSERT INTO vuln.kev_flags
(id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes)
VALUES
(@id, @advisory_id, @cve_id, @vendor_project, @product, @vulnerability_name,
@date_added, @due_date, @known_ransomware_use, @notes)
""";
// Delete existing flags for this advisory
await context.KevFlags
.Where(k => k.AdvisoryId == advisoryId)
.ExecuteDeleteAsync(cancellationToken);
// Insert new flags (caller must have set AdvisoryId on each flag)
foreach (var flag in flags)
{
await using var insertCmd = CreateCommand(insertSql, connection);
insertCmd.Transaction = transaction;
AddParameter(insertCmd, "id", flag.Id);
AddParameter(insertCmd, "advisory_id", advisoryId);
AddParameter(insertCmd, "cve_id", flag.CveId);
AddParameter(insertCmd, "vendor_project", flag.VendorProject);
AddParameter(insertCmd, "product", flag.Product);
AddParameter(insertCmd, "vulnerability_name", flag.VulnerabilityName);
AddParameter(insertCmd, "date_added", flag.DateAdded);
AddParameter(insertCmd, "due_date", flag.DueDate);
AddParameter(insertCmd, "known_ransomware_use", flag.KnownRansomwareUse);
AddParameter(insertCmd, "notes", flag.Notes);
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
context.KevFlags.Add(flag);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public Task<IReadOnlyList<KevFlagEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<KevFlagEntity>> GetByAdvisoryAsync(Guid advisoryId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes, created_at
FROM vuln.kev_flags
WHERE advisory_id = @advisory_id
ORDER BY date_added DESC, cve_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "advisory_id", advisoryId),
MapKev,
cancellationToken);
return await context.KevFlags
.AsNoTracking()
.Where(k => k.AdvisoryId == advisoryId)
.OrderByDescending(k => k.DateAdded)
.ThenBy(k => k.CveId)
.ToListAsync(cancellationToken);
}
public Task<IReadOnlyList<KevFlagEntity>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<KevFlagEntity>> GetByCveAsync(string cveId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, cve_id, vendor_project, product, vulnerability_name,
date_added, due_date, known_ransomware_use, notes, created_at
FROM vuln.kev_flags
WHERE cve_id = @cve_id
ORDER BY date_added DESC, advisory_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "cve_id", cveId),
MapKev,
cancellationToken);
return await context.KevFlags
.AsNoTracking()
.Where(k => k.CveId == cveId)
.OrderByDescending(k => k.DateAdded)
.ThenBy(k => k.AdvisoryId)
.ToListAsync(cancellationToken);
}
private static KevFlagEntity MapKev(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
AdvisoryId = reader.GetGuid(1),
CveId = reader.GetString(2),
VendorProject = GetNullableString(reader, 3),
Product = GetNullableString(reader, 4),
VulnerabilityName = GetNullableString(reader, 5),
DateAdded = DateOnly.FromDateTime(reader.GetDateTime(6)),
DueDate = reader.IsDBNull(7) ? null : DateOnly.FromDateTime(reader.GetDateTime(7)),
KnownRansomwareUse = reader.GetBoolean(8),
Notes = GetNullableString(reader, 9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -1,79 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for merge event audit records.
/// </summary>
public sealed class MergeEventRepository : RepositoryBase<ConcelierDataSource>, IMergeEventRepository
public sealed class MergeEventRepository : IMergeEventRepository
{
private const string SystemTenantId = "_system";
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<MergeEventRepository> _logger;
public MergeEventRepository(ConcelierDataSource dataSource, ILogger<MergeEventRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<MergeEventEntity> InsertAsync(MergeEventEntity evt, CancellationToken cancellationToken = default)
{
// Insert with RETURNING and jsonb casts requires raw SQL.
// Partitioned table (merge_events) - inserts must go through SQL, not EF Add.
const string sql = """
INSERT INTO vuln.merge_events
(advisory_id, source_id, event_type, old_value, new_value)
VALUES
(@advisory_id, @source_id, @event_type, @old_value::jsonb, @new_value::jsonb)
({0}, {1}, {2}, {3}::jsonb, {4}::jsonb)
RETURNING id, advisory_id, source_id, event_type, old_value::text, new_value::text, created_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<MergeEventRawResult>(
sql,
cmd =>
{
AddParameter(cmd, "advisory_id", evt.AdvisoryId);
AddParameter(cmd, "source_id", evt.SourceId);
AddParameter(cmd, "event_type", evt.EventType);
AddJsonbParameter(cmd, "old_value", evt.OldValue);
AddJsonbParameter(cmd, "new_value", evt.NewValue);
},
MapEvent!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Insert returned null");
evt.AdvisoryId,
evt.SourceId ?? (object)DBNull.Value,
evt.EventType,
evt.OldValue ?? (object)DBNull.Value,
evt.NewValue ?? (object)DBNull.Value)
.ToListAsync(cancellationToken);
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Insert returned null");
return new MergeEventEntity
{
Id = row.id,
AdvisoryId = row.advisory_id,
SourceId = row.source_id,
EventType = row.event_type,
OldValue = row.old_value,
NewValue = row.new_value,
CreatedAt = row.created_at
};
}
public Task<IReadOnlyList<MergeEventEntity>> GetByAdvisoryAsync(Guid advisoryId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<MergeEventEntity>> GetByAdvisoryAsync(Guid advisoryId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, advisory_id, source_id, event_type, old_value::text, new_value::text, created_at
FROM vuln.merge_events
WHERE advisory_id = @advisory_id
ORDER BY created_at DESC, id DESC
LIMIT @limit OFFSET @offset
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QueryAsync(
SystemTenantId,
sql,
cmd =>
{
AddParameter(cmd, "advisory_id", advisoryId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapEvent,
cancellationToken);
return await context.MergeEvents
.AsNoTracking()
.Where(e => e.AdvisoryId == advisoryId)
.OrderByDescending(e => e.CreatedAt)
.ThenByDescending(e => e.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken);
}
private static MergeEventEntity MapEvent(NpgsqlDataReader reader) => new()
private sealed class MergeEventRawResult
{
Id = reader.GetInt64(0),
AdvisoryId = reader.GetGuid(1),
SourceId = GetNullableGuid(reader, 2),
EventType = reader.GetString(3),
OldValue = GetNullableString(reader, 4),
NewValue = GetNullableString(reader, 5),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(6)
};
public long id { get; init; }
public Guid advisory_id { get; init; }
public Guid? source_id { get; init; }
public string event_type { get; init; } = string.Empty;
public string? old_value { get; init; }
public string? new_value { get; init; }
public DateTimeOffset created_at { get; init; }
}
}

View File

@@ -1,5 +1,7 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Concelier.Storage.ChangeHistory;
using System.Text.Json;
@@ -20,80 +22,66 @@ internal sealed class PostgresChangeHistoryStore : IChangeHistoryStore
public async Task AddAsync(ChangeHistoryRecord record, CancellationToken cancellationToken)
{
// ON CONFLICT (id) DO NOTHING requires raw SQL.
const string sql = """
INSERT INTO concelier.change_history
(id, source_name, advisory_key, document_id, document_hash, snapshot_hash, previous_snapshot_hash, snapshot, previous_snapshot, changes, created_at)
VALUES (@Id, @SourceName, @AdvisoryKey, @DocumentId, @DocumentHash, @SnapshotHash, @PreviousSnapshotHash, @Snapshot::jsonb, @PreviousSnapshot::jsonb, @Changes::jsonb, @CreatedAt)
ON CONFLICT (id) DO NOTHING;
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}::jsonb, {8}::jsonb, {9}::jsonb, {10})
ON CONFLICT (id) DO NOTHING
""";
var changesJson = JsonSerializer.Serialize(record.Changes, _jsonOptions);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
record.Id,
record.SourceName,
record.AdvisoryKey,
record.DocumentId,
record.DocumentHash,
record.SnapshotHash,
record.PreviousSnapshotHash,
Snapshot = record.Snapshot,
PreviousSnapshot = record.PreviousSnapshot,
Changes = JsonSerializer.Serialize(record.Changes, _jsonOptions),
record.CreatedAt
}, cancellationToken: cancellationToken));
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
await context.Database.ExecuteSqlRawAsync(
sql,
[
record.Id,
record.SourceName,
record.AdvisoryKey,
record.DocumentId,
record.DocumentHash,
record.SnapshotHash,
record.PreviousSnapshotHash ?? (object)DBNull.Value,
record.Snapshot,
record.PreviousSnapshot ?? (object)DBNull.Value,
changesJson,
record.CreatedAt
],
cancellationToken);
}
public async Task<IReadOnlyList<ChangeHistoryRecord>> GetRecentAsync(string sourceName, string advisoryKey, int limit, CancellationToken cancellationToken)
{
const string sql = """
SELECT id, source_name, advisory_key, document_id, document_hash, snapshot_hash, previous_snapshot_hash, snapshot, previous_snapshot, changes, created_at
FROM concelier.change_history
WHERE source_name = @SourceName AND advisory_key = @AdvisoryKey
ORDER BY created_at DESC
LIMIT @Limit;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<ChangeHistoryRow>(new CommandDefinition(sql, new
{
SourceName = sourceName,
AdvisoryKey = advisoryKey,
Limit = limit
}, cancellationToken: cancellationToken));
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return rows.Select(ToRecord).ToArray();
var entities = await context.ChangeHistory
.AsNoTracking()
.Where(c => c.SourceName == sourceName && c.AdvisoryKey == advisoryKey)
.OrderByDescending(c => c.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(ToRecord).ToArray();
}
private ChangeHistoryRecord ToRecord(ChangeHistoryRow row)
private ChangeHistoryRecord ToRecord(ChangeHistoryEntity entity)
{
var changes = JsonSerializer.Deserialize<IReadOnlyList<ChangeHistoryFieldChange>>(row.Changes, _jsonOptions) ?? Array.Empty<ChangeHistoryFieldChange>();
var changes = JsonSerializer.Deserialize<IReadOnlyList<ChangeHistoryFieldChange>>(entity.Changes, _jsonOptions) ?? Array.Empty<ChangeHistoryFieldChange>();
return new ChangeHistoryRecord(
row.Id,
row.SourceName,
row.AdvisoryKey,
row.DocumentId,
row.DocumentHash,
row.SnapshotHash,
row.PreviousSnapshotHash ?? string.Empty,
row.Snapshot,
row.PreviousSnapshot ?? string.Empty,
entity.Id,
entity.SourceName,
entity.AdvisoryKey,
entity.DocumentId,
entity.DocumentHash,
entity.SnapshotHash,
entity.PreviousSnapshotHash ?? string.Empty,
entity.Snapshot,
entity.PreviousSnapshot ?? string.Empty,
changes,
row.CreatedAt);
}
private sealed class ChangeHistoryRow
{
public Guid Id { get; init; }
public string SourceName { get; init; } = string.Empty;
public string AdvisoryKey { get; init; } = string.Empty;
public Guid DocumentId { get; init; }
public string DocumentHash { get; init; } = string.Empty;
public string SnapshotHash { get; init; } = string.Empty;
public string? PreviousSnapshotHash { get; init; }
public string Snapshot { get; init; } = string.Empty;
public string? PreviousSnapshot { get; init; }
public string Changes { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
entity.CreatedAt);
}
}

View File

@@ -1,7 +1,8 @@
using Contracts = StellaOps.Concelier.Storage.Contracts;
using Dapper;
using StellaOps.Concelier.Persistence.Postgres;
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Concelier.Storage;
using System.Linq;
using System.Text.Json;
@@ -11,10 +12,6 @@ namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
{
private readonly ConcelierDataSource _dataSource;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.General)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public PostgresDtoStore(ConcelierDataSource dataSource)
{
@@ -23,9 +20,10 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
public async Task<DtoRecord> UpsertAsync(DtoRecord record, CancellationToken cancellationToken)
{
// ON CONFLICT upsert with RETURNING requires raw SQL.
const string sql = """
INSERT INTO concelier.dtos (id, document_id, source_name, format, payload_json, schema_version, created_at, validated_at)
VALUES (@Id, @DocumentId, @SourceName, @Format, @PayloadJson::jsonb, @SchemaVersion, @CreatedAt, @ValidatedAt)
VALUES ({0}, {1}, {2}, {3}, {4}::jsonb, {5}, {6}, {7})
ON CONFLICT (document_id) DO UPDATE
SET payload_json = EXCLUDED.payload_json,
schema_version = EXCLUDED.schema_version,
@@ -33,92 +31,87 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
format = EXCLUDED.format,
validated_at = EXCLUDED.validated_at
RETURNING
id AS "Id",
document_id AS "DocumentId",
source_name AS "SourceName",
format AS "Format",
payload_json::text AS "PayloadJson",
schema_version AS "SchemaVersion",
created_at AS "CreatedAt",
validated_at AS "ValidatedAt";
id, document_id, source_name, format, payload_json::text, schema_version, created_at, validated_at
""";
var payloadJson = record.Payload.ToJson();
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QuerySingleAsync<DtoRow>(new CommandDefinition(sql, new
{
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<DtoRawResult>(
sql,
record.Id,
record.DocumentId,
record.SourceName,
record.Format,
PayloadJson = payloadJson,
payloadJson,
record.SchemaVersion,
record.CreatedAt,
record.ValidatedAt
}, cancellationToken: cancellationToken));
record.ValidatedAt)
.ToListAsync(cancellationToken);
var row = rows.Single();
return ToRecord(row);
}
public async Task<DtoRecord?> FindByDocumentIdAsync(Guid documentId, CancellationToken cancellationToken)
{
const string sql = """
SELECT
id AS "Id",
document_id AS "DocumentId",
source_name AS "SourceName",
format AS "Format",
payload_json::text AS "PayloadJson",
schema_version AS "SchemaVersion",
created_at AS "CreatedAt",
validated_at AS "ValidatedAt"
FROM concelier.dtos
WHERE document_id = @DocumentId
LIMIT 1;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<DtoRow>(new CommandDefinition(sql, new { DocumentId = documentId }, cancellationToken: cancellationToken));
return row is null ? null : ToRecord(row);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var entity = await context.Dtos
.AsNoTracking()
.Where(d => d.DocumentId == documentId)
.FirstOrDefaultAsync(cancellationToken);
return entity is null ? null : ToRecord(entity);
}
public async Task<IReadOnlyList<DtoRecord>> GetBySourceAsync(string sourceName, int limit, CancellationToken cancellationToken)
{
const string sql = """
SELECT
id AS "Id",
document_id AS "DocumentId",
source_name AS "SourceName",
format AS "Format",
payload_json::text AS "PayloadJson",
schema_version AS "SchemaVersion",
created_at AS "CreatedAt",
validated_at AS "ValidatedAt"
FROM concelier.dtos
WHERE source_name = @SourceName
ORDER BY created_at DESC
LIMIT @Limit;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<DtoRow>(new CommandDefinition(sql, new { SourceName = sourceName, Limit = limit }, cancellationToken: cancellationToken));
return rows.Select(ToRecord).ToArray();
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var entities = await context.Dtos
.AsNoTracking()
.Where(d => d.SourceName == sourceName)
.OrderByDescending(d => d.CreatedAt)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(ToRecord).ToArray();
}
private DtoRecord ToRecord(DtoRow row)
private static DtoRecord ToRecord(DtoRecordEntity entity)
{
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(row.PayloadJson);
var createdAtUtc = DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc);
var validatedAtUtc = DateTime.SpecifyKind(row.ValidatedAt, DateTimeKind.Utc);
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(entity.PayloadJson);
var createdAtUtc = DateTime.SpecifyKind(entity.CreatedAt, DateTimeKind.Utc);
var validatedAtUtc = DateTime.SpecifyKind(entity.ValidatedAt, DateTimeKind.Utc);
return new DtoRecord(
row.Id,
row.DocumentId,
row.SourceName,
row.Format,
entity.Id,
entity.DocumentId,
entity.SourceName,
entity.Format,
payload,
new DateTimeOffset(createdAtUtc),
row.SchemaVersion,
entity.SchemaVersion,
new DateTimeOffset(validatedAtUtc));
}
private static DtoRecord ToRecord(DtoRawResult row)
{
var payload = StellaOps.Concelier.Documents.DocumentObject.Parse(row.payload_json);
var createdAtUtc = DateTime.SpecifyKind(row.created_at, DateTimeKind.Utc);
var validatedAtUtc = DateTime.SpecifyKind(row.validated_at, DateTimeKind.Utc);
return new DtoRecord(
row.id,
row.document_id,
row.source_name,
row.format,
payload,
new DateTimeOffset(createdAtUtc),
row.schema_version,
new DateTimeOffset(validatedAtUtc));
}
@@ -133,15 +126,19 @@ internal sealed class PostgresDtoStore : IDtoStore, Contracts.IStorageDtoStore
.Select(dto => dto.ToStorageDto())
.ToArray();
private sealed class DtoRow
/// <summary>
/// Raw result type for SqlQueryRaw RETURNING clause.
/// Property names must match column names exactly (lowercase).
/// </summary>
private sealed class DtoRawResult
{
public Guid Id { get; init; }
public Guid DocumentId { get; init; }
public string SourceName { get; init; } = string.Empty;
public string Format { get; init; } = string.Empty;
public string PayloadJson { get; init; } = string.Empty;
public string SchemaVersion { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public DateTime ValidatedAt { get; init; }
public Guid id { get; init; }
public Guid document_id { get; init; }
public string source_name { get; init; } = string.Empty;
public string format { get; init; } = string.Empty;
public string payload_json { get; init; } = string.Empty;
public string schema_version { get; init; } = string.Empty;
public DateTime created_at { get; init; }
public DateTime validated_at { get; init; }
}
}

View File

@@ -1,5 +1,7 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Concelier.Storage.Exporting;
using System.Text.Json;
@@ -20,33 +22,24 @@ internal sealed class PostgresExportStateStore : IExportStateStore
public async Task<ExportStateRecord?> FindAsync(string id, CancellationToken cancellationToken)
{
const string sql = """
SELECT id,
export_cursor,
last_full_digest,
last_delta_digest,
base_export_id,
base_digest,
target_repository,
files,
exporter_version,
updated_at
FROM concelier.export_states
WHERE id = @Id
LIMIT 1;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<ExportStateRow>(new CommandDefinition(sql, new { Id = id }, cancellationToken: cancellationToken));
return row is null ? null : ToRecord(row);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var entity = await context.ExportStates
.AsNoTracking()
.Where(e => e.Id == id)
.FirstOrDefaultAsync(cancellationToken);
return entity is null ? null : ToRecord(entity);
}
public async Task<ExportStateRecord> UpsertAsync(ExportStateRecord record, CancellationToken cancellationToken)
{
// ON CONFLICT upsert with RETURNING requires raw SQL.
const string sql = """
INSERT INTO concelier.export_states
(id, export_cursor, last_full_digest, last_delta_digest, base_export_id, base_digest, target_repository, files, exporter_version, updated_at)
VALUES (@Id, @ExportCursor, @LastFullDigest, @LastDeltaDigest, @BaseExportId, @BaseDigest, @TargetRepository, @Files, @ExporterVersion, @UpdatedAt)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}::jsonb, {8}, {9})
ON CONFLICT (id) DO UPDATE
SET export_cursor = EXCLUDED.export_cursor,
last_full_digest = EXCLUDED.last_full_digest,
@@ -57,64 +50,80 @@ internal sealed class PostgresExportStateStore : IExportStateStore
files = EXCLUDED.files,
exporter_version = EXCLUDED.exporter_version,
updated_at = EXCLUDED.updated_at
RETURNING id,
export_cursor,
last_full_digest,
last_delta_digest,
base_export_id,
base_digest,
target_repository,
files,
exporter_version,
updated_at;
RETURNING id, export_cursor, last_full_digest, last_delta_digest, base_export_id, base_digest, target_repository, files::text, exporter_version, updated_at
""";
var filesJson = JsonSerializer.Serialize(record.Files, _jsonOptions);
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QuerySingleAsync<ExportStateRow>(new CommandDefinition(sql, new
{
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<ExportStateRawResult>(
sql,
record.Id,
record.ExportCursor,
record.LastFullDigest,
record.LastDeltaDigest,
record.BaseExportId,
record.BaseDigest,
record.TargetRepository,
Files = filesJson,
record.LastFullDigest ?? (object)DBNull.Value,
record.LastDeltaDigest ?? (object)DBNull.Value,
record.BaseExportId ?? (object)DBNull.Value,
record.BaseDigest ?? (object)DBNull.Value,
record.TargetRepository ?? (object)DBNull.Value,
filesJson,
record.ExporterVersion,
record.UpdatedAt
}, cancellationToken: cancellationToken));
record.UpdatedAt)
.ToListAsync(cancellationToken);
var row = rows.Single();
return ToRecord(row);
}
private ExportStateRecord ToRecord(ExportStateRow row)
private ExportStateRecord ToRecord(ExportStateEntity entity)
{
var files = JsonSerializer.Deserialize<IReadOnlyList<ExportFileRecord>>(row.Files, _jsonOptions) ?? Array.Empty<ExportFileRecord>();
var files = JsonSerializer.Deserialize<IReadOnlyList<ExportFileRecord>>(entity.Files, _jsonOptions) ?? Array.Empty<ExportFileRecord>();
return new ExportStateRecord(
row.Id,
row.ExportCursor,
row.LastFullDigest,
row.LastDeltaDigest,
row.BaseExportId,
row.BaseDigest,
row.TargetRepository,
entity.Id,
entity.ExportCursor,
entity.LastFullDigest,
entity.LastDeltaDigest,
entity.BaseExportId,
entity.BaseDigest,
entity.TargetRepository,
files,
row.ExporterVersion,
row.UpdatedAt);
entity.ExporterVersion,
entity.UpdatedAt);
}
private sealed record ExportStateRow(
string Id,
string ExportCursor,
string? LastFullDigest,
string? LastDeltaDigest,
string? BaseExportId,
string? BaseDigest,
string? TargetRepository,
string Files,
string ExporterVersion,
DateTimeOffset UpdatedAt);
private ExportStateRecord ToRecord(ExportStateRawResult row)
{
var files = JsonSerializer.Deserialize<IReadOnlyList<ExportFileRecord>>(row.files, _jsonOptions) ?? Array.Empty<ExportFileRecord>();
return new ExportStateRecord(
row.id,
row.export_cursor,
row.last_full_digest,
row.last_delta_digest,
row.base_export_id,
row.base_digest,
row.target_repository,
files,
row.exporter_version,
row.updated_at);
}
/// <summary>
/// Raw result type for SqlQueryRaw RETURNING clause.
/// </summary>
private sealed class ExportStateRawResult
{
public string id { get; init; } = string.Empty;
public string export_cursor { get; init; } = string.Empty;
public string? last_full_digest { get; init; }
public string? last_delta_digest { get; init; }
public string? base_export_id { get; init; }
public string? base_digest { get; init; }
public string? target_repository { get; init; }
public string files { get; init; } = "[]";
public string exporter_version { get; init; } = string.Empty;
public DateTimeOffset updated_at { get; init; }
}
}

View File

@@ -1,4 +1,6 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Concelier.Storage.JpFlags;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
@@ -14,62 +16,46 @@ internal sealed class PostgresJpFlagStore : IJpFlagStore
public async Task UpsertAsync(JpFlagRecord record, CancellationToken cancellationToken)
{
// ON CONFLICT upsert requires raw SQL.
const string sql = """
INSERT INTO concelier.jp_flags (advisory_key, source_name, category, vendor_status, created_at)
VALUES (@AdvisoryKey, @SourceName, @Category, @VendorStatus, @CreatedAt)
VALUES ({0}, {1}, {2}, {3}, {4})
ON CONFLICT (advisory_key) DO UPDATE
SET source_name = EXCLUDED.source_name,
category = EXCLUDED.category,
vendor_status = EXCLUDED.vendor_status,
created_at = EXCLUDED.created_at;
created_at = EXCLUDED.created_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
record.AdvisoryKey,
record.SourceName,
record.Category,
record.VendorStatus,
record.CreatedAt
}, cancellationToken: cancellationToken));
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
await context.Database.ExecuteSqlRawAsync(
sql,
[record.AdvisoryKey, record.SourceName, record.Category, record.VendorStatus ?? (object)DBNull.Value, record.CreatedAt],
cancellationToken);
}
public async Task<JpFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
{
const string sql = """
SELECT advisory_key AS "AdvisoryKey",
source_name AS "SourceName",
category AS "Category",
vendor_status AS "VendorStatus",
created_at AS "CreatedAt"
FROM concelier.jp_flags
WHERE advisory_key = @AdvisoryKey
LIMIT 1;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<JpFlagRow>(new CommandDefinition(sql, new { AdvisoryKey = advisoryKey }, cancellationToken: cancellationToken));
if (row is null)
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var entity = await context.JpFlags
.AsNoTracking()
.Where(j => j.AdvisoryKey == advisoryKey)
.FirstOrDefaultAsync(cancellationToken);
if (entity is null)
{
return null;
}
var createdAt = DateTime.SpecifyKind(row.CreatedAt, DateTimeKind.Utc);
return new JpFlagRecord(
row.AdvisoryKey,
row.SourceName,
row.Category,
row.VendorStatus,
new DateTimeOffset(createdAt));
}
private sealed class JpFlagRow
{
public string AdvisoryKey { get; set; } = string.Empty;
public string SourceName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string? VendorStatus { get; set; }
public DateTime CreatedAt { get; set; }
entity.AdvisoryKey,
entity.SourceName,
entity.Category,
entity.VendorStatus,
entity.CreatedAt);
}
}

View File

@@ -1,4 +1,6 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Concelier.Storage.PsirtFlags;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
@@ -14,75 +16,54 @@ internal sealed class PostgresPsirtFlagStore : IPsirtFlagStore
public async Task UpsertAsync(PsirtFlagRecord flag, CancellationToken cancellationToken)
{
// ON CONFLICT upsert requires raw SQL.
const string sql = """
INSERT INTO concelier.psirt_flags (advisory_id, vendor, source_name, external_id, recorded_at)
VALUES (@AdvisoryId, @Vendor, @SourceName, @ExternalId, @RecordedAt)
VALUES ({0}, {1}, {2}, {3}, {4})
ON CONFLICT (advisory_id, vendor) DO UPDATE
SET source_name = EXCLUDED.source_name,
external_id = EXCLUDED.external_id,
recorded_at = EXCLUDED.recorded_at;
recorded_at = EXCLUDED.recorded_at
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(sql, new
{
flag.AdvisoryId,
flag.Vendor,
flag.SourceName,
flag.ExternalId,
flag.RecordedAt
}, cancellationToken: cancellationToken));
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
await context.Database.ExecuteSqlRawAsync(
sql,
[flag.AdvisoryId, flag.Vendor, flag.SourceName, flag.ExternalId ?? (object)DBNull.Value, flag.RecordedAt],
cancellationToken);
}
public async Task<IReadOnlyList<PsirtFlagRecord>> GetRecentAsync(string advisoryKey, int limit, CancellationToken cancellationToken)
{
const string sql = """
SELECT
advisory_id AS AdvisoryId,
vendor AS Vendor,
source_name AS SourceName,
external_id AS ExternalId,
recorded_at AS RecordedAt
FROM concelier.psirt_flags
WHERE advisory_id = @AdvisoryId
ORDER BY recorded_at DESC
LIMIT @Limit;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<PsirtFlagRow>(new CommandDefinition(sql, new { AdvisoryId = advisoryKey, Limit = limit }, cancellationToken: cancellationToken));
return rows.Select(ToRecord).ToArray();
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var entities = await context.PsirtFlags
.AsNoTracking()
.Where(p => p.AdvisoryId == advisoryKey)
.OrderByDescending(p => p.RecordedAt)
.Take(limit)
.ToListAsync(cancellationToken);
return entities.Select(ToRecord).ToArray();
}
public async Task<PsirtFlagRecord?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
{
const string sql = """
SELECT
advisory_id AS AdvisoryId,
vendor AS Vendor,
source_name AS SourceName,
external_id AS ExternalId,
recorded_at AS RecordedAt
FROM concelier.psirt_flags
WHERE advisory_id = @AdvisoryId
ORDER BY recorded_at DESC
LIMIT 1;
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
var row = await connection.QuerySingleOrDefaultAsync<PsirtFlagRow>(new CommandDefinition(sql, new { AdvisoryId = advisoryKey }, cancellationToken: cancellationToken));
return row is null ? null : ToRecord(row);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var entity = await context.PsirtFlags
.AsNoTracking()
.Where(p => p.AdvisoryId == advisoryKey)
.OrderByDescending(p => p.RecordedAt)
.FirstOrDefaultAsync(cancellationToken);
return entity is null ? null : ToRecord(entity);
}
private static PsirtFlagRecord ToRecord(PsirtFlagRow row) =>
new(row.AdvisoryId, row.Vendor, row.SourceName, row.ExternalId, row.RecordedAt);
private sealed class PsirtFlagRow
{
public string AdvisoryId { get; init; } = string.Empty;
public string Vendor { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public string? ExternalId { get; init; }
public DateTimeOffset RecordedAt { get; init; }
}
private static PsirtFlagRecord ToRecord(PsirtFlagEntity entity) =>
new(entity.AdvisoryId, entity.Vendor, entity.SourceName, entity.ExternalId, entity.RecordedAt);
}

View File

@@ -1,29 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for feed sources.
/// </summary>
public sealed class SourceRepository : RepositoryBase<ConcelierDataSource>, ISourceRepository
public sealed class SourceRepository : ISourceRepository
{
private const string SystemTenantId = "_system";
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<SourceRepository> _logger;
public SourceRepository(ConcelierDataSource dataSource, ILogger<SourceRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default)
{
// ON CONFLICT upsert with RETURNING and jsonb casts requires raw SQL.
const string sql = """
INSERT INTO vuln.sources
(id, key, name, source_type, url, priority, enabled, config, metadata)
VALUES
(@id, @key, @name, @source_type, @url, @priority, @enabled, @config::jsonb, @metadata::jsonb)
({0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}::jsonb, {8}::jsonb)
ON CONFLICT (key) DO UPDATE SET
name = EXCLUDED.name,
source_type = EXCLUDED.source_type,
@@ -37,100 +40,95 @@ public sealed class SourceRepository : RepositoryBase<ConcelierDataSource>, ISou
config::text, metadata::text, created_at, updated_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<SourceRawResult>(
sql,
cmd =>
{
AddParameter(cmd, "id", source.Id);
AddParameter(cmd, "key", source.Key);
AddParameter(cmd, "name", source.Name);
AddParameter(cmd, "source_type", source.SourceType);
AddParameter(cmd, "url", source.Url);
AddParameter(cmd, "priority", source.Priority);
AddParameter(cmd, "enabled", source.Enabled);
AddJsonbParameter(cmd, "config", source.Config);
AddJsonbParameter(cmd, "metadata", source.Metadata);
},
MapSource!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Upsert returned null");
source.Id,
source.Key,
source.Name,
source.SourceType,
source.Url ?? (object)DBNull.Value,
source.Priority,
source.Enabled,
source.Config,
source.Metadata)
.ToListAsync(cancellationToken);
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Upsert returned null");
return new SourceEntity
{
Id = row.id,
Key = row.key,
Name = row.name,
SourceType = row.source_type,
Url = row.url,
Priority = row.priority,
Enabled = row.enabled,
Config = row.config ?? "{}",
Metadata = row.metadata ?? "{}",
CreatedAt = row.created_at,
UpdatedAt = row.updated_at
};
}
public Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
public async Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
FROM vuln.sources
WHERE id = @id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "id", id),
MapSource,
cancellationToken);
return await context.Sources
.AsNoTracking()
.Where(s => s.Id == id)
.FirstOrDefaultAsync(cancellationToken);
}
public Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
public async Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
FROM vuln.sources
WHERE key = @key
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "key", key),
MapSource,
cancellationToken);
return await context.Sources
.AsNoTracking()
.Where(s => s.Key == key)
.FirstOrDefaultAsync(cancellationToken);
}
public Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, key, name, source_type, url, priority, enabled,
config::text, metadata::text, created_at, updated_at
FROM vuln.sources
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
IQueryable<SourceEntity> query = context.Sources.AsNoTracking();
if (enabled.HasValue)
{
sql += " WHERE enabled = @enabled";
query = query.Where(s => s.Enabled == enabled.Value);
}
sql += " ORDER BY priority DESC, key";
return QueryAsync(
SystemTenantId,
sql,
cmd =>
{
if (enabled.HasValue)
{
AddParameter(cmd, "enabled", enabled.Value);
}
},
MapSource,
cancellationToken);
return await query
.OrderByDescending(s => s.Priority)
.ThenBy(s => s.Key)
.ToListAsync(cancellationToken);
}
private static SourceEntity MapSource(Npgsql.NpgsqlDataReader reader) => new()
/// <summary>
/// Raw result type for SqlQueryRaw RETURNING clause.
/// </summary>
private sealed class SourceRawResult
{
Id = reader.GetGuid(0),
Key = reader.GetString(1),
Name = reader.GetString(2),
SourceType = reader.GetString(3),
Url = GetNullableString(reader, 4),
Priority = reader.GetInt32(5),
Enabled = reader.GetBoolean(6),
Config = reader.GetString(7),
Metadata = reader.GetString(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
public Guid id { get; init; }
public string key { get; init; } = string.Empty;
public string name { get; init; } = string.Empty;
public string source_type { get; init; } = string.Empty;
public string? url { get; init; }
public int priority { get; init; }
public bool enabled { get; init; }
public string? config { get; init; }
public string? metadata { get; init; }
public DateTimeOffset created_at { get; init; }
public DateTimeOffset updated_at { get; init; }
}
}

View File

@@ -1,31 +1,34 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Concelier.Persistence.EfCore.Context;
using StellaOps.Concelier.Persistence.Postgres.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for source ingestion state.
/// </summary>
public sealed class SourceStateRepository : RepositoryBase<ConcelierDataSource>, ISourceStateRepository
public sealed class SourceStateRepository : ISourceStateRepository
{
private const string SystemTenantId = "_system";
private readonly ConcelierDataSource _dataSource;
private readonly ILogger<SourceStateRepository> _logger;
public SourceStateRepository(ConcelierDataSource dataSource, ILogger<SourceStateRepository> logger)
: base(dataSource, logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<SourceStateEntity> UpsertAsync(SourceStateEntity state, CancellationToken cancellationToken = default)
{
// ON CONFLICT upsert with RETURNING and jsonb requires raw SQL.
const string sql = """
INSERT INTO vuln.source_states
(id, source_id, cursor, last_sync_at, last_success_at, last_error,
sync_count, error_count, metadata)
VALUES
(@id, @source_id, @cursor, @last_sync_at, @last_success_at, @last_error,
@sync_count, @error_count, @metadata::jsonb)
({0}, {1}, {2}, {3}, {4}, {5},
{6}, {7}, {8}::jsonb)
ON CONFLICT (source_id) DO UPDATE SET
cursor = EXCLUDED.cursor,
last_sync_at = EXCLUDED.last_sync_at,
@@ -39,54 +42,65 @@ public sealed class SourceStateRepository : RepositoryBase<ConcelierDataSource>,
sync_count, error_count, metadata::text, updated_at
""";
return await QuerySingleOrDefaultAsync(
SystemTenantId,
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
var rows = await context.Database.SqlQueryRaw<SourceStateRawResult>(
sql,
cmd =>
{
AddParameter(cmd, "id", state.Id);
AddParameter(cmd, "source_id", state.SourceId);
AddParameter(cmd, "cursor", state.Cursor);
AddParameter(cmd, "last_sync_at", state.LastSyncAt);
AddParameter(cmd, "last_success_at", state.LastSuccessAt);
AddParameter(cmd, "last_error", state.LastError);
AddParameter(cmd, "sync_count", state.SyncCount);
AddParameter(cmd, "error_count", state.ErrorCount);
AddJsonbParameter(cmd, "metadata", state.Metadata);
},
MapState!,
cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Upsert returned null");
state.Id,
state.SourceId,
state.Cursor ?? (object)DBNull.Value,
state.LastSyncAt ?? (object)DBNull.Value,
state.LastSuccessAt ?? (object)DBNull.Value,
state.LastError ?? (object)DBNull.Value,
state.SyncCount,
state.ErrorCount,
state.Metadata)
.ToListAsync(cancellationToken);
var row = rows.SingleOrDefault() ?? throw new InvalidOperationException("Upsert returned null");
return new SourceStateEntity
{
Id = row.id,
SourceId = row.source_id,
Cursor = row.cursor,
LastSyncAt = row.last_sync_at,
LastSuccessAt = row.last_success_at,
LastError = row.last_error,
SyncCount = row.sync_count,
ErrorCount = row.error_count,
Metadata = row.metadata ?? "{}",
UpdatedAt = row.updated_at
};
}
public Task<SourceStateEntity?> GetBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default)
public async Task<SourceStateEntity?> GetBySourceIdAsync(Guid sourceId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, source_id, cursor, last_sync_at, last_success_at, last_error,
sync_count, error_count, metadata::text, updated_at
FROM vuln.source_states
WHERE source_id = @source_id
""";
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var context = ConcelierDbContextFactory.Create(connection, 30, _dataSource.SchemaName);
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "source_id", sourceId),
MapState,
cancellationToken);
return await context.SourceStates
.AsNoTracking()
.Where(s => s.SourceId == sourceId)
.FirstOrDefaultAsync(cancellationToken);
}
private static SourceStateEntity MapState(NpgsqlDataReader reader) => new()
/// <summary>
/// Raw result type for SqlQueryRaw RETURNING clause.
/// </summary>
private sealed class SourceStateRawResult
{
Id = reader.GetGuid(0),
SourceId = reader.GetGuid(1),
Cursor = GetNullableString(reader, 2),
LastSyncAt = GetNullableDateTimeOffset(reader, 3),
LastSuccessAt = GetNullableDateTimeOffset(reader, 4),
LastError = GetNullableString(reader, 5),
SyncCount = reader.GetInt64(6),
ErrorCount = reader.GetInt32(7),
Metadata = reader.GetString(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
public Guid id { get; init; }
public Guid source_id { get; init; }
public string? cursor { get; init; }
public DateTimeOffset? last_sync_at { get; init; }
public DateTimeOffset? last_success_at { get; init; }
public string? last_error { get; init; }
public long sync_count { get; init; }
public int error_count { get; init; }
public string? metadata { get; init; }
public DateTimeOffset updated_at { get; init; }
}
}

View File

@@ -28,6 +28,11 @@
<EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\ConcelierDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />

View File

@@ -0,0 +1,165 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Context;
/// <summary>
/// EF Core DbContext for the Concelier ProofService module.
/// Maps proof evidence tables across vuln and feedser schemas (read-heavy, cross-schema).
/// Scaffolded from migration 20251223000001_AddProofEvidenceTables.sql.
/// </summary>
public partial class ProofServiceDbContext : DbContext
{
private readonly string _vulnSchema;
private readonly string _feedserSchema;
public ProofServiceDbContext(
DbContextOptions<ProofServiceDbContext> options,
string? vulnSchema = null,
string? feedserSchema = null)
: base(options)
{
_vulnSchema = string.IsNullOrWhiteSpace(vulnSchema) ? "vuln" : vulnSchema.Trim();
_feedserSchema = string.IsNullOrWhiteSpace(feedserSchema) ? "feedser" : feedserSchema.Trim();
}
// ---- vuln schema DbSets ----
public virtual DbSet<DistroAdvisoryEntity> DistroAdvisories { get; set; }
public virtual DbSet<ChangelogEvidenceEntity> ChangelogEvidence { get; set; }
public virtual DbSet<PatchEvidenceEntity> PatchEvidence { get; set; }
public virtual DbSet<PatchSignatureEntity> PatchSignatures { get; set; }
// ---- feedser schema DbSets ----
public virtual DbSet<BinaryFingerprintEntity> BinaryFingerprints { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var vulnSchema = _vulnSchema;
var feedserSchema = _feedserSchema;
// ================================================================
// vuln.distro_advisories
// ================================================================
modelBuilder.Entity<DistroAdvisoryEntity>(entity =>
{
entity.HasKey(e => e.AdvisoryId);
entity.ToTable("distro_advisories", vulnSchema);
entity.HasIndex(e => new { e.CveId, e.PackagePurl }, "idx_distro_advisories_cve_pkg");
entity.HasIndex(e => new { e.DistroName, e.PublishedAt }, "idx_distro_advisories_distro")
.IsDescending(false, true);
entity.HasIndex(e => e.PublishedAt, "idx_distro_advisories_published").IsDescending();
entity.Property(e => e.AdvisoryId).HasColumnName("advisory_id");
entity.Property(e => e.DistroName).HasColumnName("distro_name");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.PackagePurl).HasColumnName("package_purl");
entity.Property(e => e.FixedVersion).HasColumnName("fixed_version");
entity.Property(e => e.PublishedAt).HasColumnName("published_at");
entity.Property(e => e.Status).HasColumnName("status");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.changelog_evidence
// ================================================================
modelBuilder.Entity<ChangelogEvidenceEntity>(entity =>
{
entity.HasKey(e => e.ChangelogId);
entity.ToTable("changelog_evidence", vulnSchema);
entity.HasIndex(e => new { e.PackagePurl, e.Date }, "idx_changelog_evidence_pkg_date")
.IsDescending(false, true);
entity.Property(e => e.ChangelogId).HasColumnName("changelog_id");
entity.Property(e => e.PackagePurl).HasColumnName("package_purl");
entity.Property(e => e.Format).HasColumnName("format");
entity.Property(e => e.Version).HasColumnName("version");
entity.Property(e => e.Date).HasColumnName("date");
entity.Property(e => e.CveIds).HasColumnName("cve_ids");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.patch_evidence
// ================================================================
modelBuilder.Entity<PatchEvidenceEntity>(entity =>
{
entity.HasKey(e => e.PatchId);
entity.ToTable("patch_evidence", vulnSchema);
entity.HasIndex(e => new { e.Origin, e.ParsedAt }, "idx_patch_evidence_origin")
.IsDescending(false, true);
entity.Property(e => e.PatchId).HasColumnName("patch_id");
entity.Property(e => e.PatchFilePath).HasColumnName("patch_file_path");
entity.Property(e => e.Origin).HasColumnName("origin");
entity.Property(e => e.CveIds).HasColumnName("cve_ids");
entity.Property(e => e.ParsedAt).HasColumnName("parsed_at");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// vuln.patch_signatures
// ================================================================
modelBuilder.Entity<PatchSignatureEntity>(entity =>
{
entity.HasKey(e => e.SignatureId);
entity.ToTable("patch_signatures", vulnSchema);
entity.HasIndex(e => e.CveId, "idx_patch_signatures_cve");
entity.HasIndex(e => e.HunkHash, "idx_patch_signatures_hunk");
entity.HasIndex(e => new { e.UpstreamRepo, e.ExtractedAt }, "idx_patch_signatures_repo")
.IsDescending(false, true);
entity.Property(e => e.SignatureId).HasColumnName("signature_id");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.CommitSha).HasColumnName("commit_sha");
entity.Property(e => e.UpstreamRepo).HasColumnName("upstream_repo");
entity.Property(e => e.HunkHash).HasColumnName("hunk_hash");
entity.Property(e => e.ExtractedAt).HasColumnName("extracted_at");
entity.Property(e => e.Payload).HasColumnName("payload").HasColumnType("jsonb");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
// ================================================================
// feedser.binary_fingerprints
// ================================================================
modelBuilder.Entity<BinaryFingerprintEntity>(entity =>
{
entity.HasKey(e => e.FingerprintId);
entity.ToTable("binary_fingerprints", feedserSchema);
entity.HasIndex(e => new { e.CveId, e.Method }, "idx_binary_fingerprints_cve");
entity.HasIndex(e => new { e.Method, e.ExtractedAt }, "idx_binary_fingerprints_method")
.IsDescending(false, true);
entity.HasIndex(e => new { e.TargetBinary, e.TargetFunction }, "idx_binary_fingerprints_target");
entity.HasIndex(e => new { e.Architecture, e.Format }, "idx_binary_fingerprints_arch");
entity.Property(e => e.FingerprintId).HasColumnName("fingerprint_id");
entity.Property(e => e.CveId).HasColumnName("cve_id");
entity.Property(e => e.Method).HasColumnName("method");
entity.Property(e => e.FingerprintValue).HasColumnName("fingerprint_value");
entity.Property(e => e.TargetBinary).HasColumnName("target_binary");
entity.Property(e => e.TargetFunction).HasColumnName("target_function");
entity.Property(e => e.Architecture).HasColumnName("architecture");
entity.Property(e => e.Format).HasColumnName("format");
entity.Property(e => e.Compiler).HasColumnName("compiler");
entity.Property(e => e.OptimizationLevel).HasColumnName("optimization_level");
entity.Property(e => e.HasDebugSymbols).HasColumnName("has_debug_symbols");
entity.Property(e => e.FileOffset).HasColumnName("file_offset");
entity.Property(e => e.RegionSize).HasColumnName("region_size");
entity.Property(e => e.ExtractedAt).HasColumnName("extracted_at");
entity.Property(e => e.ExtractorVersion).HasColumnName("extractor_version");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Context;
/// <summary>
/// Design-time factory for dotnet ef CLI tooling.
/// Does NOT use compiled models (reflection-based discovery for scaffold/optimize).
/// </summary>
public sealed class ProofServiceDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ProofServiceDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vuln,feedser,public";
private const string ConnectionStringEnvironmentVariable =
"STELLAOPS_PROOFSERVICE_EF_CONNECTION";
public ProofServiceDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<ProofServiceDbContext>()
.UseNpgsql(connectionString)
.Options;
return new ProofServiceDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
/// <summary>
/// Entity for feedser.binary_fingerprints table.
/// Tier 4 evidence: Binary fingerprints for fuzzy matching of patched code.
/// </summary>
public sealed class BinaryFingerprintEntity
{
public string FingerprintId { get; set; } = string.Empty;
public string CveId { get; set; } = string.Empty;
public string Method { get; set; } = string.Empty;
public string FingerprintValue { get; set; } = string.Empty;
public string TargetBinary { get; set; } = string.Empty;
public string? TargetFunction { get; set; }
public string Architecture { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public string? Compiler { get; set; }
public string? OptimizationLevel { get; set; }
public bool HasDebugSymbols { get; set; }
public long? FileOffset { get; set; }
public long? RegionSize { get; set; }
public DateTimeOffset ExtractedAt { get; set; }
public string ExtractorVersion { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
/// <summary>
/// Entity for vuln.changelog_evidence table.
/// Tier 2 evidence: CVE mentions in debian/changelog, RPM changelog, Alpine commit messages.
/// </summary>
public sealed class ChangelogEvidenceEntity
{
public string ChangelogId { get; set; } = string.Empty;
public string PackagePurl { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public DateTimeOffset Date { get; set; }
public string[] CveIds { get; set; } = [];
public string Payload { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
/// <summary>
/// Entity for vuln.distro_advisories table.
/// Tier 1 evidence: Distro security advisories (DSA, RHSA, USN, etc.)
/// </summary>
public sealed class DistroAdvisoryEntity
{
public string AdvisoryId { get; set; } = string.Empty;
public string DistroName { get; set; } = string.Empty;
public string CveId { get; set; } = string.Empty;
public string PackagePurl { get; set; } = string.Empty;
public string? FixedVersion { get; set; }
public DateTimeOffset PublishedAt { get; set; }
public string Status { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
/// <summary>
/// Entity for vuln.patch_evidence table.
/// Tier 3 evidence: Patch headers from Git commit messages and patch files.
/// </summary>
public sealed class PatchEvidenceEntity
{
public string PatchId { get; set; } = string.Empty;
public string PatchFilePath { get; set; } = string.Empty;
public string? Origin { get; set; }
public string[] CveIds { get; set; } = [];
public DateTimeOffset ParsedAt { get; set; }
public string Payload { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Concelier.ProofService.Postgres.EfCore.Models;
/// <summary>
/// Entity for vuln.patch_signatures table.
/// Tier 3 evidence: HunkSig fuzzy patch signature matches.
/// </summary>
public sealed class PatchSignatureEntity
{
public string SignatureId { get; set; } = string.Empty;
public string CveId { get; set; } = string.Empty;
public string CommitSha { get; set; } = string.Empty;
public string UpstreamRepo { get; set; } = string.Empty;
public string HunkHash { get; set; } = string.Empty;
public DateTimeOffset ExtractedAt { get; set; }
public string Payload { get; set; } = "{}";
public DateTimeOffset CreatedAt { get; set; }
}

View File

@@ -10,9 +10,16 @@
<ItemGroup>
<PackageReference Include="Npgsql" />
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj" />
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />