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,15 @@
|
||||
# VexLens Compiled Models
|
||||
|
||||
This directory contains compiled model stubs for the VexLens EF Core DbContext.
|
||||
|
||||
To regenerate after schema or model changes, run:
|
||||
|
||||
```bash
|
||||
dotnet ef dbcontext optimize \
|
||||
--project src/VexLens/StellaOps.VexLens.Persistence/ \
|
||||
--output-dir EfCore/CompiledModels \
|
||||
--namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels
|
||||
```
|
||||
|
||||
After regeneration, ensure that `VexLensDbContextAssemblyAttributes.cs` remains
|
||||
excluded from compilation via the `.csproj` `<Compile Remove>` directive.
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for VexLensDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.VexLensDbContext))]
|
||||
public partial class VexLensDbContextModel : RuntimeModel
|
||||
{
|
||||
private static VexLensDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new VexLensDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for VexLensDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class VexLensDbContextModel
|
||||
{
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Context;
|
||||
|
||||
public partial class VexLensDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// -- FK: consensus_projections.previous_projection_id -> consensus_projections.id --
|
||||
modelBuilder.Entity<ConsensusProjectionEntity>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.PreviousProjection)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.PreviousProjectionId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// -- FK: consensus_inputs.projection_id -> consensus_projections.id (ON DELETE CASCADE) --
|
||||
modelBuilder.Entity<ConsensusInputEntity>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Projection)
|
||||
.WithMany(p => p.Inputs)
|
||||
.HasForeignKey(e => e.ProjectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// -- FK: consensus_conflicts.projection_id -> consensus_projections.id (ON DELETE CASCADE) --
|
||||
modelBuilder.Entity<ConsensusConflictEntity>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Projection)
|
||||
.WithMany(p => p.Conflicts)
|
||||
.HasForeignKey(e => e.ProjectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the VexLens module.
|
||||
/// Maps to the vexlens PostgreSQL schema: consensus_projections,
|
||||
/// consensus_inputs, and consensus_conflicts tables.
|
||||
/// </summary>
|
||||
public partial class VexLensDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public VexLensDbContext(DbContextOptions<VexLensDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "vexlens"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<ConsensusProjectionEntity> ConsensusProjections { get; set; }
|
||||
public virtual DbSet<ConsensusInputEntity> ConsensusInputs { get; set; }
|
||||
public virtual DbSet<ConsensusConflictEntity> ConsensusConflicts { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// -- consensus_projections ------------------------------------------------
|
||||
modelBuilder.Entity<ConsensusProjectionEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("consensus_projections_pkey");
|
||||
entity.ToTable("consensus_projections", schemaName);
|
||||
|
||||
// Indexes matching SQL migration
|
||||
entity.HasIndex(e => e.VulnerabilityId, "idx_consensus_projections_vuln_id");
|
||||
entity.HasIndex(e => e.ProductKey, "idx_consensus_projections_product_key");
|
||||
entity.HasIndex(e => e.TenantId, "idx_consensus_projections_tenant_id")
|
||||
.HasFilter("(tenant_id IS NOT NULL)");
|
||||
entity.HasIndex(e => e.Status, "idx_consensus_projections_status");
|
||||
entity.HasIndex(e => e.Outcome, "idx_consensus_projections_outcome");
|
||||
entity.HasIndex(e => e.ComputedAt, "idx_consensus_projections_computed_at")
|
||||
.IsDescending(true);
|
||||
entity.HasIndex(e => e.StoredAt, "idx_consensus_projections_stored_at")
|
||||
.IsDescending(true);
|
||||
entity.HasIndex(e => e.ConfidenceScore, "idx_consensus_projections_confidence")
|
||||
.IsDescending(true);
|
||||
entity.HasIndex(e => e.StatusChanged, "idx_consensus_projections_status_changed")
|
||||
.HasFilter("(status_changed = true)");
|
||||
entity.HasIndex(e => new { e.VulnerabilityId, e.ProductKey, e.TenantId, e.ComputedAt },
|
||||
"idx_consensus_projections_history")
|
||||
.IsDescending(false, false, false, true);
|
||||
|
||||
// Column mappings
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
|
||||
entity.Property(e => e.ProductKey).HasColumnName("product_key");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Justification).HasColumnName("justification");
|
||||
entity.Property(e => e.ConfidenceScore).HasColumnName("confidence_score");
|
||||
entity.Property(e => e.Outcome).HasColumnName("outcome");
|
||||
entity.Property(e => e.StatementCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("statement_count");
|
||||
entity.Property(e => e.ConflictCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("conflict_count");
|
||||
entity.Property(e => e.RationaleSummary).HasColumnName("rationale_summary");
|
||||
entity.Property(e => e.MergeTrace)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("merge_trace");
|
||||
entity.Property(e => e.ComputedAt).HasColumnName("computed_at");
|
||||
entity.Property(e => e.StoredAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("stored_at");
|
||||
entity.Property(e => e.PreviousProjectionId).HasColumnName("previous_projection_id");
|
||||
entity.Property(e => e.StatusChanged)
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("status_changed");
|
||||
entity.Property(e => e.AttestationDigest).HasColumnName("attestation_digest");
|
||||
entity.Property(e => e.InputHash).HasColumnName("input_hash");
|
||||
});
|
||||
|
||||
// -- consensus_inputs ----------------------------------------------------
|
||||
modelBuilder.Entity<ConsensusInputEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.ProjectionId, e.StatementId })
|
||||
.HasName("consensus_inputs_pkey");
|
||||
entity.ToTable("consensus_inputs", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ProjectionId, "idx_consensus_inputs_projection");
|
||||
|
||||
entity.Property(e => e.ProjectionId).HasColumnName("projection_id");
|
||||
entity.Property(e => e.StatementId).HasColumnName("statement_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Confidence).HasColumnName("confidence");
|
||||
entity.Property(e => e.Weight).HasColumnName("weight");
|
||||
});
|
||||
|
||||
// -- consensus_conflicts -------------------------------------------------
|
||||
modelBuilder.Entity<ConsensusConflictEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("consensus_conflicts_pkey");
|
||||
entity.ToTable("consensus_conflicts", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ProjectionId, "idx_consensus_conflicts_projection");
|
||||
entity.HasIndex(e => e.Severity, "idx_consensus_conflicts_severity");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.ProjectionId).HasColumnName("projection_id");
|
||||
entity.Property(e => e.Issuer1).HasColumnName("issuer1");
|
||||
entity.Property(e => e.Issuer2).HasColumnName("issuer2");
|
||||
entity.Property(e => e.Status1).HasColumnName("status1");
|
||||
entity.Property(e => e.Status2).HasColumnName("status2");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.DetectedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("detected_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Context;
|
||||
|
||||
public sealed class VexLensDesignTimeDbContextFactory : IDesignTimeDbContextFactory<VexLensDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vexlens,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_VEXLENS_EF_CONNECTION";
|
||||
|
||||
public VexLensDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<VexLensDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new VexLensDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
public partial class ConsensusConflictEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation property: the projection this conflict belongs to.
|
||||
/// </summary>
|
||||
public virtual ConsensusProjectionEntity Projection { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexlens.consensus_conflicts table.
|
||||
/// Detailed conflict records from consensus computation.
|
||||
/// </summary>
|
||||
public partial class ConsensusConflictEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ProjectionId { get; set; }
|
||||
public string Issuer1 { get; set; } = null!;
|
||||
public string Issuer2 { get; set; } = null!;
|
||||
public string Status1 { get; set; } = null!;
|
||||
public string Status2 { get; set; } = null!;
|
||||
public string Severity { get; set; } = null!;
|
||||
public DateTime DetectedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
public partial class ConsensusInputEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation property: the projection this input belongs to.
|
||||
/// </summary>
|
||||
public virtual ConsensusProjectionEntity Projection { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexlens.consensus_inputs table.
|
||||
/// Tracks which VEX statements contributed to a consensus projection.
|
||||
/// </summary>
|
||||
public partial class ConsensusInputEntity
|
||||
{
|
||||
public Guid ProjectionId { get; set; }
|
||||
public string StatementId { get; set; } = null!;
|
||||
public string SourceId { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public double? Confidence { get; set; }
|
||||
public double? Weight { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
public partial class ConsensusProjectionEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation property: self-referential link to previous projection.
|
||||
/// </summary>
|
||||
public virtual ConsensusProjectionEntity? PreviousProjection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation property: input statements that contributed to this projection.
|
||||
/// </summary>
|
||||
public virtual ICollection<ConsensusInputEntity> Inputs { get; set; } = new List<ConsensusInputEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigation property: conflicts detected during this projection.
|
||||
/// </summary>
|
||||
public virtual ICollection<ConsensusConflictEntity> Conflicts { get; set; } = new List<ConsensusConflictEntity>();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexlens.consensus_projections table.
|
||||
/// </summary>
|
||||
public partial class ConsensusProjectionEntity
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string VulnerabilityId { get; set; } = null!;
|
||||
public string ProductKey { get; set; } = null!;
|
||||
public string? TenantId { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public string? Justification { get; set; }
|
||||
public double ConfidenceScore { get; set; }
|
||||
public string Outcome { get; set; } = null!;
|
||||
public int StatementCount { get; set; }
|
||||
public int ConflictCount { get; set; }
|
||||
public string? RationaleSummary { get; set; }
|
||||
public string? MergeTrace { get; set; }
|
||||
public DateTime ComputedAt { get; set; }
|
||||
public DateTime StoredAt { get; set; }
|
||||
public Guid? PreviousProjectionId { get; set; }
|
||||
public bool StatusChanged { get; set; }
|
||||
public string? AttestationDigest { get; set; }
|
||||
public string? InputHash { get; set; }
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// © StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
// (c) StellaOps Contributors. See LICENSE and NOTICE.md in the repository root.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IConsensusProjectionStore"/>.
|
||||
/// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii (LIN-BE-021)
|
||||
/// PostgreSQL (EF Core) implementation of <see cref="IConsensusProjectionStore"/>.
|
||||
/// Sprint: SPRINT_20260222_071_VexLens_dal_to_efcore (VEXLENS-EF-03)
|
||||
/// </summary>
|
||||
public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.VexLens.Persistence.PostgresConsensusProjectionStore");
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
@@ -76,65 +79,58 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
!string.Equals(previous.Status.ToString(), result.ConsensusStatus.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
try
|
||||
var entity = new ConsensusProjectionEntity
|
||||
{
|
||||
// Insert the projection
|
||||
await using var cmd = new NpgsqlCommand(InsertProjectionSql, connection, transaction);
|
||||
cmd.Parameters.AddWithValue("id", projectionId);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", result.VulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", result.ProductKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", options.TenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status", MapStatus(result.ConsensusStatus));
|
||||
cmd.Parameters.AddWithValue("justification", result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("confidence_score", result.ConfidenceScore);
|
||||
cmd.Parameters.AddWithValue("outcome", MapOutcome(result.Outcome));
|
||||
cmd.Parameters.AddWithValue("statement_count", result.Contributions.Count);
|
||||
cmd.Parameters.AddWithValue("conflict_count", result.Conflicts?.Count ?? 0);
|
||||
cmd.Parameters.AddWithValue("rationale_summary", result.Rationale.Summary ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("computed_at", result.ComputedAt);
|
||||
cmd.Parameters.AddWithValue("stored_at", now);
|
||||
cmd.Parameters.AddWithValue("previous_projection_id", previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("status_changed", statusChanged);
|
||||
Id = projectionId,
|
||||
VulnerabilityId = result.VulnerabilityId,
|
||||
ProductKey = result.ProductKey,
|
||||
TenantId = options.TenantId,
|
||||
Status = MapStatus(result.ConsensusStatus),
|
||||
Justification = result.ConsensusJustification.HasValue ? MapJustification(result.ConsensusJustification.Value) : null,
|
||||
ConfidenceScore = result.ConfidenceScore,
|
||||
Outcome = MapOutcome(result.Outcome),
|
||||
StatementCount = result.Contributions.Count,
|
||||
ConflictCount = result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary = result.Rationale.Summary,
|
||||
ComputedAt = result.ComputedAt.UtcDateTime,
|
||||
StoredAt = now.UtcDateTime,
|
||||
PreviousProjectionId = previous?.ProjectionId is not null ? Guid.Parse(previous.ProjectionId) : null,
|
||||
StatusChanged = statusChanged
|
||||
};
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
dbContext.ConsensusProjections.Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: projectionId.ToString(),
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
StatementCount: result.Contributions.Count,
|
||||
ConflictCount: result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary: result.Rationale.Summary ?? string.Empty,
|
||||
ComputedAt: result.ComputedAt,
|
||||
StoredAt: now,
|
||||
PreviousProjectionId: previous?.ProjectionId,
|
||||
StatusChanged: statusChanged);
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: projectionId.ToString(),
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
Status: result.ConsensusStatus,
|
||||
Justification: result.ConsensusJustification,
|
||||
ConfidenceScore: result.ConfidenceScore,
|
||||
Outcome: result.Outcome,
|
||||
StatementCount: result.Contributions.Count,
|
||||
ConflictCount: result.Conflicts?.Count ?? 0,
|
||||
RationaleSummary: result.Rationale.Summary ?? string.Empty,
|
||||
ComputedAt: result.ComputedAt,
|
||||
StoredAt: now,
|
||||
PreviousProjectionId: previous?.ProjectionId,
|
||||
StatusChanged: statusChanged);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
|
||||
projectionId, result.VulnerabilityId, result.ProductKey);
|
||||
_logger.LogDebug(
|
||||
"Stored consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}",
|
||||
projectionId, result.VulnerabilityId, result.ProductKey);
|
||||
|
||||
// Emit events if configured
|
||||
if (options.EmitEvent && _eventEmitter is not null)
|
||||
{
|
||||
await EmitEventsAsync(projection, previous, cancellationToken);
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
catch
|
||||
// Emit events if configured
|
||||
if (options.EmitEvent && _eventEmitter is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
await EmitEventsAsync(projection, previous, cancellationToken);
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -153,16 +149,13 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
activity?.SetTag("projectionId", projectionId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectByIdSql, connection);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
var entity = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
|
||||
|
||||
return null;
|
||||
return entity is not null ? MapToProjection(entity) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -180,18 +173,17 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectLatestSql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
if (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return MapProjection(reader);
|
||||
}
|
||||
var entity = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.VulnerabilityId == vulnerabilityId
|
||||
&& e.ProductKey == productKey
|
||||
&& (tenantId == null ? e.TenantId == null : e.TenantId == tenantId))
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return null;
|
||||
return entity is not null ? MapToProjection(entity) : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -203,32 +195,81 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
|
||||
using var activity = ActivitySource.StartActivity("ListAsync");
|
||||
|
||||
var (sql, countSql, parameters) = BuildListQuery(query);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
IQueryable<ConsensusProjectionEntity> dbQuery = dbContext.ConsensusProjections.AsNoTracking();
|
||||
|
||||
// Apply filters
|
||||
if (query.TenantId is not null)
|
||||
dbQuery = dbQuery.Where(e => e.TenantId == query.TenantId);
|
||||
|
||||
if (query.VulnerabilityId is not null)
|
||||
dbQuery = dbQuery.Where(e => e.VulnerabilityId == query.VulnerabilityId);
|
||||
|
||||
if (query.ProductKey is not null)
|
||||
dbQuery = dbQuery.Where(e => e.ProductKey == query.ProductKey);
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
var statusStr = MapStatus(query.Status.Value);
|
||||
dbQuery = dbQuery.Where(e => e.Status == statusStr);
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
var outcomeStr = MapOutcome(query.Outcome.Value);
|
||||
dbQuery = dbQuery.Where(e => e.Outcome == outcomeStr);
|
||||
}
|
||||
|
||||
if (query.MinimumConfidence.HasValue)
|
||||
dbQuery = dbQuery.Where(e => e.ConfidenceScore >= query.MinimumConfidence.Value);
|
||||
|
||||
if (query.ComputedAfter.HasValue)
|
||||
{
|
||||
var afterUtc = query.ComputedAfter.Value.UtcDateTime;
|
||||
dbQuery = dbQuery.Where(e => e.ComputedAt >= afterUtc);
|
||||
}
|
||||
|
||||
if (query.ComputedBefore.HasValue)
|
||||
{
|
||||
var beforeUtc = query.ComputedBefore.Value.UtcDateTime;
|
||||
dbQuery = dbQuery.Where(e => e.ComputedAt <= beforeUtc);
|
||||
}
|
||||
|
||||
if (query.StatusChanged.HasValue)
|
||||
dbQuery = dbQuery.Where(e => e.StatusChanged == query.StatusChanged.Value);
|
||||
|
||||
// Get total count
|
||||
await using var countCmd = new NpgsqlCommand(countSql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
countCmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
var totalCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
|
||||
var totalCount = await dbQuery.CountAsync(cancellationToken);
|
||||
|
||||
// Get projections
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var cmd = new NpgsqlCommand(sql, connection);
|
||||
foreach (var (name, value) in parameters)
|
||||
// Apply sorting
|
||||
dbQuery = query.SortBy switch
|
||||
{
|
||||
cmd.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
ProjectionSortField.StoredAt => query.SortDescending
|
||||
? dbQuery.OrderByDescending(e => e.StoredAt)
|
||||
: dbQuery.OrderBy(e => e.StoredAt),
|
||||
ProjectionSortField.VulnerabilityId => query.SortDescending
|
||||
? dbQuery.OrderByDescending(e => e.VulnerabilityId)
|
||||
: dbQuery.OrderBy(e => e.VulnerabilityId),
|
||||
ProjectionSortField.ProductKey => query.SortDescending
|
||||
? dbQuery.OrderByDescending(e => e.ProductKey)
|
||||
: dbQuery.OrderBy(e => e.ProductKey),
|
||||
ProjectionSortField.ConfidenceScore => query.SortDescending
|
||||
? dbQuery.OrderByDescending(e => e.ConfidenceScore)
|
||||
: dbQuery.OrderBy(e => e.ConfidenceScore),
|
||||
_ => query.SortDescending
|
||||
? dbQuery.OrderByDescending(e => e.ComputedAt)
|
||||
: dbQuery.OrderBy(e => e.ComputedAt)
|
||||
};
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
// Apply pagination
|
||||
var entities = await dbQuery
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var projections = entities.Select(MapToProjection).ToList();
|
||||
return new ProjectionListResult(projections, totalCount, query.Offset, query.Limit);
|
||||
}
|
||||
|
||||
@@ -248,20 +289,18 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
activity?.SetTag("productKey", productKey);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(SelectHistorySql, connection);
|
||||
cmd.Parameters.AddWithValue("vulnerability_id", vulnerabilityId);
|
||||
cmd.Parameters.AddWithValue("product_key", productKey);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId ?? (object)DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("limit", limit ?? 100);
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
var projections = new List<ConsensusProjection>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
projections.Add(MapProjection(reader));
|
||||
}
|
||||
var entities = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.VulnerabilityId == vulnerabilityId
|
||||
&& e.ProductKey == productKey
|
||||
&& (tenantId == null ? e.TenantId == null : e.TenantId == tenantId))
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.Take(limit ?? 100)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return projections;
|
||||
return entities.Select(MapToProjection).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -274,98 +313,46 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
activity?.SetTag("olderThan", olderThan.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
tenantId is null ? PurgeSql : PurgeByTenantSql,
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("older_than", olderThan);
|
||||
if (tenantId is not null)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
}
|
||||
await using var dbContext = CreateDbContext(connection);
|
||||
|
||||
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
var olderThanUtc = olderThan.UtcDateTime;
|
||||
|
||||
IQueryable<ConsensusProjectionEntity> query = dbContext.ConsensusProjections
|
||||
.Where(e => e.ComputedAt < olderThanUtc);
|
||||
|
||||
if (tenantId is not null)
|
||||
query = query.Where(e => e.TenantId == tenantId);
|
||||
|
||||
var deleted = await query.ExecuteDeleteAsync(cancellationToken);
|
||||
_logger.LogInformation("Purged {Count} consensus projections older than {OlderThan}", deleted, olderThan);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
#region SQL Queries
|
||||
|
||||
private const string InsertProjectionSql = """
|
||||
INSERT INTO vexlens.consensus_projections (
|
||||
id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
) VALUES (
|
||||
@id, @vulnerability_id, @product_key, @tenant_id, @status, @justification,
|
||||
@confidence_score, @outcome, @statement_count, @conflict_count,
|
||||
@rationale_summary, @computed_at, @stored_at, @previous_projection_id, @status_changed
|
||||
)
|
||||
""";
|
||||
|
||||
private const string SelectByIdSql = """
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
private const string SelectLatestSql = """
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
private const string SelectHistorySql = """
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
WHERE vulnerability_id = @vulnerability_id
|
||||
AND product_key = @product_key
|
||||
AND (tenant_id = @tenant_id OR (@tenant_id IS NULL AND tenant_id IS NULL))
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
|
||||
private const string PurgeSql = """
|
||||
DELETE FROM vexlens.consensus_projections
|
||||
WHERE computed_at < @older_than
|
||||
""";
|
||||
|
||||
private const string PurgeByTenantSql = """
|
||||
DELETE FROM vexlens.consensus_projections
|
||||
WHERE computed_at < @older_than AND tenant_id = @tenant_id
|
||||
""";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static ConsensusProjection MapProjection(NpgsqlDataReader reader)
|
||||
private EfCore.Context.VexLensDbContext CreateDbContext(NpgsqlConnection connection)
|
||||
{
|
||||
return VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, VexLensDataSource.DefaultSchemaName);
|
||||
}
|
||||
|
||||
private static ConsensusProjection MapToProjection(ConsensusProjectionEntity entity)
|
||||
{
|
||||
return new ConsensusProjection(
|
||||
ProjectionId: reader.GetGuid(0).ToString(),
|
||||
VulnerabilityId: reader.GetString(1),
|
||||
ProductKey: reader.GetString(2),
|
||||
TenantId: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Status: ParseStatus(reader.GetString(4)),
|
||||
Justification: reader.IsDBNull(5) ? null : ParseJustification(reader.GetString(5)),
|
||||
ConfidenceScore: reader.GetDouble(6),
|
||||
Outcome: ParseOutcome(reader.GetString(7)),
|
||||
StatementCount: reader.GetInt32(8),
|
||||
ConflictCount: reader.GetInt32(9),
|
||||
RationaleSummary: reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
|
||||
ComputedAt: reader.GetFieldValue<DateTimeOffset>(11),
|
||||
StoredAt: reader.GetFieldValue<DateTimeOffset>(12),
|
||||
PreviousProjectionId: reader.IsDBNull(13) ? null : reader.GetGuid(13).ToString(),
|
||||
StatusChanged: reader.GetBoolean(14));
|
||||
ProjectionId: entity.Id.ToString(),
|
||||
VulnerabilityId: entity.VulnerabilityId,
|
||||
ProductKey: entity.ProductKey,
|
||||
TenantId: entity.TenantId,
|
||||
Status: ParseStatus(entity.Status),
|
||||
Justification: entity.Justification is not null ? ParseJustification(entity.Justification) : null,
|
||||
ConfidenceScore: entity.ConfidenceScore,
|
||||
Outcome: ParseOutcome(entity.Outcome),
|
||||
StatementCount: entity.StatementCount,
|
||||
ConflictCount: entity.ConflictCount,
|
||||
RationaleSummary: entity.RationaleSummary ?? string.Empty,
|
||||
ComputedAt: new DateTimeOffset(entity.ComputedAt, TimeSpan.Zero),
|
||||
StoredAt: new DateTimeOffset(entity.StoredAt, TimeSpan.Zero),
|
||||
PreviousProjectionId: entity.PreviousProjectionId?.ToString(),
|
||||
StatusChanged: entity.StatusChanged);
|
||||
}
|
||||
|
||||
private static string MapStatus(VexStatus status) => status switch
|
||||
@@ -428,96 +415,6 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(outcome))
|
||||
};
|
||||
|
||||
private static (string sql, string countSql, List<(string name, object value)> parameters) BuildListQuery(ProjectionQuery query)
|
||||
{
|
||||
var conditions = new List<string>();
|
||||
var parameters = new List<(string name, object value)>();
|
||||
|
||||
if (query.TenantId is not null)
|
||||
{
|
||||
conditions.Add("tenant_id = @tenant_id");
|
||||
parameters.Add(("tenant_id", query.TenantId));
|
||||
}
|
||||
|
||||
if (query.VulnerabilityId is not null)
|
||||
{
|
||||
conditions.Add("vulnerability_id = @vulnerability_id");
|
||||
parameters.Add(("vulnerability_id", query.VulnerabilityId));
|
||||
}
|
||||
|
||||
if (query.ProductKey is not null)
|
||||
{
|
||||
conditions.Add("product_key = @product_key");
|
||||
parameters.Add(("product_key", query.ProductKey));
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
conditions.Add("status = @status");
|
||||
parameters.Add(("status", MapStatus(query.Status.Value)));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
conditions.Add("outcome = @outcome");
|
||||
parameters.Add(("outcome", MapOutcome(query.Outcome.Value)));
|
||||
}
|
||||
|
||||
if (query.MinimumConfidence.HasValue)
|
||||
{
|
||||
conditions.Add("confidence_score >= @min_confidence");
|
||||
parameters.Add(("min_confidence", query.MinimumConfidence.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedAfter.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at >= @computed_after");
|
||||
parameters.Add(("computed_after", query.ComputedAfter.Value));
|
||||
}
|
||||
|
||||
if (query.ComputedBefore.HasValue)
|
||||
{
|
||||
conditions.Add("computed_at <= @computed_before");
|
||||
parameters.Add(("computed_before", query.ComputedBefore.Value));
|
||||
}
|
||||
|
||||
if (query.StatusChanged.HasValue)
|
||||
{
|
||||
conditions.Add("status_changed = @status_changed");
|
||||
parameters.Add(("status_changed", query.StatusChanged.Value));
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0
|
||||
? $"WHERE {string.Join(" AND ", conditions)}"
|
||||
: string.Empty;
|
||||
|
||||
var sortColumn = query.SortBy switch
|
||||
{
|
||||
ProjectionSortField.ComputedAt => "computed_at",
|
||||
ProjectionSortField.StoredAt => "stored_at",
|
||||
ProjectionSortField.VulnerabilityId => "vulnerability_id",
|
||||
ProjectionSortField.ProductKey => "product_key",
|
||||
ProjectionSortField.ConfidenceScore => "confidence_score",
|
||||
_ => "computed_at"
|
||||
};
|
||||
|
||||
var sortDirection = query.SortDescending ? "DESC" : "ASC";
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, vulnerability_id, product_key, tenant_id, status, justification,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
rationale_summary, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM vexlens.consensus_projections
|
||||
{whereClause}
|
||||
ORDER BY {sortColumn} {sortDirection}
|
||||
LIMIT {query.Limit} OFFSET {query.Offset}
|
||||
""";
|
||||
|
||||
var countSql = $"SELECT COUNT(*) FROM vexlens.consensus_projections {whereClause}";
|
||||
|
||||
return (sql, countSql, parameters);
|
||||
}
|
||||
|
||||
private async Task EmitEventsAsync(
|
||||
ConsensusProjection projection,
|
||||
ConsensusProjection? previous,
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class VexLensDataSource : DataSourceBase
|
||||
/// <summary>
|
||||
/// Default schema name for VexLens tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "vex";
|
||||
public const string DefaultSchemaName = "vexlens";
|
||||
|
||||
public VexLensDataSource(
|
||||
IOptions<PostgresOptions> options,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.VexLens.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.VexLens.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="VexLensDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class VexLensDbContextFactory
|
||||
{
|
||||
public static VexLensDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? VexLensDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<VexLensDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, VexLensDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(VexLensDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new VexLensDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +1,67 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ConsensusProjectionRepository.cs
|
||||
// Sprint: SPRINT_20251229_001_002_BE_vex_delta (VEX-006)
|
||||
// Task: Implement IConsensusProjectionRepository
|
||||
// Sprint: SPRINT_20260222_071_VexLens_dal_to_efcore (VEXLENS-EF-03)
|
||||
// Task: Convert DAL repository from Npgsql to EF Core
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using Npgsql;
|
||||
using StellaOps.VexLens.Persistence.EfCore.Models;
|
||||
using StellaOps.VexLens.Persistence.Postgres;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.VexLens.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of consensus projection repository.
|
||||
/// EF Core implementation of consensus projection repository.
|
||||
/// </summary>
|
||||
public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSource>, IConsensusProjectionRepository
|
||||
public sealed class ConsensusProjectionRepository : IConsensusProjectionRepository
|
||||
{
|
||||
private const string Schema = "vex";
|
||||
private const string Table = "consensus_projections";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly VexLensDataSource _dataSource;
|
||||
private readonly ILogger<ConsensusProjectionRepository> _logger;
|
||||
|
||||
public ConsensusProjectionRepository(
|
||||
VexLensDataSource dataSource,
|
||||
ILogger<ConsensusProjectionRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async ValueTask<ConsensusProjection> AddAsync(
|
||||
ConsensusProjection projection,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
INSERT INTO {FullTable} (
|
||||
id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, previous_projection_id, status_changed
|
||||
)
|
||||
VALUES (
|
||||
@id, @tenantId, @vulnId, @productKey, @status,
|
||||
@confidence, @outcome, @stmtCount, @conflictCount,
|
||||
@mergeTrace::jsonb, @computedAt, @previousId, @statusChanged
|
||||
)
|
||||
RETURNING id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
""";
|
||||
await using var connection = await OpenConnectionAsync(projection.TenantId.ToString(), ct);
|
||||
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var result = await QuerySingleOrDefaultAsync(
|
||||
projection.TenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "id", projection.Id);
|
||||
AddParameter(cmd, "tenantId", projection.TenantId);
|
||||
AddParameter(cmd, "vulnId", projection.VulnerabilityId);
|
||||
AddParameter(cmd, "productKey", projection.ProductKey);
|
||||
AddParameter(cmd, "status", projection.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "confidence", projection.ConfidenceScore);
|
||||
AddParameter(cmd, "outcome", projection.Outcome);
|
||||
AddParameter(cmd, "stmtCount", projection.StatementCount);
|
||||
AddParameter(cmd, "conflictCount", projection.ConflictCount);
|
||||
AddParameter(cmd, "mergeTrace", SerializeTrace(projection.Trace));
|
||||
AddParameter(cmd, "computedAt", projection.ComputedAt);
|
||||
AddParameter(cmd, "previousId", (object?)projection.PreviousProjectionId ?? DBNull.Value);
|
||||
AddParameter(cmd, "statusChanged", projection.StatusChanged);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
var entity = ToEntity(projection);
|
||||
dbContext.ConsensusProjections.Add(entity);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Failed to add consensus projection");
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Duplicate consensus projection {ProjectionId} for {VulnerabilityId}/{ProductKey}; idempotent skip.",
|
||||
projection.Id, projection.VulnerabilityId, projection.ProductKey);
|
||||
}
|
||||
|
||||
// Re-read to get DB-generated stored_at
|
||||
await using var readConnection = await OpenConnectionAsync(projection.TenantId.ToString(), ct);
|
||||
await using var readContext = VexLensDbContextFactory.Create(readConnection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var stored = await readContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.Id == projection.Id, ct);
|
||||
|
||||
return stored is not null ? ToModel(stored) : projection;
|
||||
}
|
||||
|
||||
public async ValueTask<ConsensusProjection?> GetLatestAsync(
|
||||
@@ -81,29 +70,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE vulnerability_id = @vulnId
|
||||
AND product_key = @productKey
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
|
||||
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vulnId", vulnerabilityId);
|
||||
AddParameter(cmd, "productKey", productKey);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
var entity = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.VulnerabilityId == vulnerabilityId
|
||||
&& e.ProductKey == productKey
|
||||
&& e.TenantId == tenantId.ToString())
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return entity is not null ? ToModel(entity) : null;
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByVulnerabilityAsync(
|
||||
@@ -112,28 +90,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE vulnerability_id = @vulnId
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
|
||||
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vulnId", vulnerabilityId);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
var entities = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.VulnerabilityId == vulnerabilityId
|
||||
&& e.TenantId == tenantId.ToString())
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetByProductAsync(
|
||||
@@ -142,28 +110,18 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE product_key = @productKey
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
|
||||
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "productKey", productKey);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
var entities = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ProductKey == productKey
|
||||
&& e.TenantId == tenantId.ToString())
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetStatusChangesAsync(
|
||||
@@ -172,29 +130,21 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
int limit = 100,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE tenant_id = @tenantId
|
||||
AND status_changed = TRUE
|
||||
AND computed_at >= @since
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
|
||||
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "since", since);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
var sinceUtc = since.UtcDateTime;
|
||||
|
||||
var entities = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId.ToString()
|
||||
&& e.StatusChanged == true
|
||||
&& e.ComputedAt >= sinceUtc)
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask<IReadOnlyList<ConsensusProjection>> GetHistoryAsync(
|
||||
@@ -204,36 +154,52 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
int limit = 50,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, tenant_id, vulnerability_id, product_key, status,
|
||||
confidence_score, outcome, statement_count, conflict_count,
|
||||
merge_trace, computed_at, stored_at, previous_projection_id, status_changed
|
||||
FROM {FullTable}
|
||||
WHERE vulnerability_id = @vulnId
|
||||
AND product_key = @productKey
|
||||
AND tenant_id = @tenantId
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT @limit
|
||||
""";
|
||||
await using var connection = await OpenConnectionAsync(tenantId.ToString(), ct);
|
||||
await using var dbContext = VexLensDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId.ToString(),
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "vulnId", vulnerabilityId);
|
||||
AddParameter(cmd, "productKey", productKey);
|
||||
AddParameter(cmd, "tenantId", tenantId);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapProjection,
|
||||
ct);
|
||||
var entities = await dbContext.ConsensusProjections
|
||||
.AsNoTracking()
|
||||
.Where(e => e.VulnerabilityId == vulnerabilityId
|
||||
&& e.ProductKey == productKey
|
||||
&& e.TenantId == tenantId.ToString())
|
||||
.OrderByDescending(e => e.ComputedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
private static ConsensusProjection MapProjection(System.Data.Common.DbDataReader reader)
|
||||
#region Mapping
|
||||
|
||||
private static ConsensusProjectionEntity ToEntity(ConsensusProjection model) => new()
|
||||
{
|
||||
var statusStr = reader.GetString(reader.GetOrdinal("status"));
|
||||
var status = statusStr.ToLowerInvariant() switch
|
||||
Id = model.Id,
|
||||
TenantId = model.TenantId.ToString(),
|
||||
VulnerabilityId = model.VulnerabilityId,
|
||||
ProductKey = model.ProductKey,
|
||||
Status = model.Status.ToString().ToLowerInvariant() switch
|
||||
{
|
||||
"unknown" => "unknown",
|
||||
"underinvestigation" => "under_investigation",
|
||||
"notaffected" => "not_affected",
|
||||
"affected" => "affected",
|
||||
"fixed" => "fixed",
|
||||
_ => model.Status.ToString().ToLowerInvariant()
|
||||
},
|
||||
ConfidenceScore = (double)model.ConfidenceScore,
|
||||
Outcome = model.Outcome,
|
||||
StatementCount = model.StatementCount,
|
||||
ConflictCount = model.ConflictCount,
|
||||
MergeTrace = SerializeTrace(model.Trace),
|
||||
ComputedAt = model.ComputedAt.UtcDateTime,
|
||||
PreviousProjectionId = model.PreviousProjectionId,
|
||||
StatusChanged = model.StatusChanged
|
||||
};
|
||||
|
||||
private static ConsensusProjection ToModel(ConsensusProjectionEntity entity)
|
||||
{
|
||||
var statusStr = entity.Status.ToLowerInvariant();
|
||||
var status = statusStr switch
|
||||
{
|
||||
"unknown" => VexConsensusStatus.Unknown,
|
||||
"under_investigation" => VexConsensusStatus.UnderInvestigation,
|
||||
@@ -243,28 +209,21 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
_ => throw new InvalidOperationException($"Unknown status: {statusStr}")
|
||||
};
|
||||
|
||||
var traceJson = reader.IsDBNull(reader.GetOrdinal("merge_trace"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("merge_trace"));
|
||||
|
||||
return new ConsensusProjection(
|
||||
Id: reader.GetGuid(reader.GetOrdinal("id")),
|
||||
TenantId: reader.GetGuid(reader.GetOrdinal("tenant_id")),
|
||||
VulnerabilityId: reader.GetString(reader.GetOrdinal("vulnerability_id")),
|
||||
ProductKey: reader.GetString(reader.GetOrdinal("product_key")),
|
||||
Id: entity.Id,
|
||||
TenantId: Guid.TryParse(entity.TenantId, out var tenantId) ? tenantId : Guid.Empty,
|
||||
VulnerabilityId: entity.VulnerabilityId,
|
||||
ProductKey: entity.ProductKey,
|
||||
Status: status,
|
||||
ConfidenceScore: reader.GetDecimal(reader.GetOrdinal("confidence_score")),
|
||||
Outcome: reader.GetString(reader.GetOrdinal("outcome")),
|
||||
StatementCount: reader.GetInt32(reader.GetOrdinal("statement_count")),
|
||||
ConflictCount: reader.GetInt32(reader.GetOrdinal("conflict_count")),
|
||||
Trace: DeserializeTrace(traceJson),
|
||||
ComputedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("computed_at")),
|
||||
StoredAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("stored_at")),
|
||||
PreviousProjectionId: reader.IsDBNull(reader.GetOrdinal("previous_projection_id"))
|
||||
? null
|
||||
: reader.GetGuid(reader.GetOrdinal("previous_projection_id")),
|
||||
StatusChanged: reader.GetBoolean(reader.GetOrdinal("status_changed"))
|
||||
);
|
||||
ConfidenceScore: (decimal)entity.ConfidenceScore,
|
||||
Outcome: entity.Outcome,
|
||||
StatementCount: entity.StatementCount,
|
||||
ConflictCount: entity.ConflictCount,
|
||||
Trace: DeserializeTrace(entity.MergeTrace),
|
||||
ComputedAt: new DateTimeOffset(entity.ComputedAt, TimeSpan.Zero),
|
||||
StoredAt: new DateTimeOffset(entity.StoredAt, TimeSpan.Zero),
|
||||
PreviousProjectionId: entity.PreviousProjectionId,
|
||||
StatusChanged: entity.StatusChanged);
|
||||
}
|
||||
|
||||
private static string SerializeTrace(MergeTrace? trace)
|
||||
@@ -282,4 +241,29 @@ public sealed class ConsensusProjectionRepository : RepositoryBase<VexLensDataSo
|
||||
|
||||
return JsonSerializer.Deserialize<MergeTrace>(json, SerializerOptions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private async Task<NpgsqlConnection> OpenConnectionAsync(string tenantId, CancellationToken ct)
|
||||
{
|
||||
return await _dataSource.OpenConnectionAsync(tenantId, "reader", ct);
|
||||
}
|
||||
|
||||
private static string GetSchemaName() => VexLensDataSource.DefaultSchemaName;
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
@@ -18,11 +21,17 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\VexLensDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user