wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user