stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -82,54 +82,3 @@ public interface IReachGraphRepository
|
||||
ReplayLogEntry entry,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of storing a graph.
|
||||
/// </summary>
|
||||
public sealed record StoreResult
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required bool Created { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required int NodeCount { get; init; }
|
||||
public required int EdgeCount { get; init; }
|
||||
public required DateTimeOffset StoredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a stored graph (without full blob).
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSummary
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required int NodeCount { get; init; }
|
||||
public required int EdgeCount { get; init; }
|
||||
public required int BlobSizeBytes { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required ReachGraphScope Scope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the replay verification log.
|
||||
/// </summary>
|
||||
public sealed record ReplayLogEntry
|
||||
{
|
||||
public required string SubgraphDigest { get; init; }
|
||||
public required ReachGraphInputs InputDigests { get; init; }
|
||||
public required string ComputedDigest { get; init; }
|
||||
public required bool Matches { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required int DurationMs { get; init; }
|
||||
public ReplayDivergence? Divergence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes divergence when replay doesn't match.
|
||||
/// </summary>
|
||||
public sealed record ReplayDivergence
|
||||
{
|
||||
public required int NodesAdded { get; init; }
|
||||
public required int NodesRemoved { get; init; }
|
||||
public required int EdgesChanged { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(
|
||||
string digest,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
AND tenant_id = @TenantId
|
||||
RETURNING digest
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { Digest = digest, TenantId = tenantId },
|
||||
cancellationToken: cancellationToken);
|
||||
var deleted = await connection.QuerySingleOrDefaultAsync<string>(command).ConfigureAwait(false);
|
||||
|
||||
if (deleted is not null)
|
||||
{
|
||||
_logger.LogInformation("Deleted reachability graph {Digest}", digest);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphMinimal?> GetByDigestAsync(
|
||||
string digest,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT blob
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
AND tenant_id = @TenantId
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { Digest = digest, TenantId = tenantId },
|
||||
cancellationToken: cancellationToken);
|
||||
var blob = await connection.QuerySingleOrDefaultAsync<byte[]>(command).ConfigureAwait(false);
|
||||
|
||||
if (blob is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var decompressed = ReachGraphPersistenceCodec.DecompressGzip(blob);
|
||||
return _serializer.Deserialize(decompressed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
var effectiveLimit = ClampLimit(limit);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE artifact_digest = @ArtifactDigest
|
||||
AND tenant_id = @TenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { ArtifactDigest = artifactDigest, TenantId = tenantId, Limit = effectiveLimit },
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ReachGraphSummary>> FindByCveAsync(
|
||||
string cveId,
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(cveId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
var effectiveLimit = ClampLimit(limit);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE scope->'cves' @> @CveJson::jsonb
|
||||
AND tenant_id = @TenantId
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
var cveJson = JsonSerializer.Serialize(new[] { cveId });
|
||||
var command = new CommandDefinition(
|
||||
sql,
|
||||
new { CveJson = cveJson, TenantId = tenantId, Limit = effectiveLimit },
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<dynamic>(command).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ReachGraphPersistenceCodec.ParseScope((string)row.scope)
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task RecordReplayAsync(
|
||||
ReplayLogEntry entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, entry.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var inputsJson = ReachGraphPersistenceCodec.SerializeInputs(entry.InputDigests);
|
||||
var divergenceJson = ReachGraphPersistenceCodec.SerializeDivergence(entry.Divergence);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO reachgraph.replay_log (
|
||||
subgraph_digest, input_digests, computed_digest, matches,
|
||||
divergence, tenant_id, duration_ms
|
||||
)
|
||||
VALUES (
|
||||
@SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches,
|
||||
@Divergence::jsonb, @TenantId, @DurationMs
|
||||
)
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(sql, new
|
||||
{
|
||||
entry.SubgraphDigest,
|
||||
InputDigests = inputsJson,
|
||||
entry.ComputedDigest,
|
||||
entry.Matches,
|
||||
Divergence = divergenceJson,
|
||||
entry.TenantId,
|
||||
entry.DurationMs
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
await connection.ExecuteAsync(command).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)",
|
||||
entry.Matches ? "MATCH" : "MISMATCH",
|
||||
entry.SubgraphDigest,
|
||||
entry.ComputedDigest,
|
||||
entry.DurationMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreResult> StoreAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
var digest = _digestComputer.ComputeDigest(graph);
|
||||
var canonicalBytes = _serializer.SerializeMinimal(graph);
|
||||
var compressedBlob = ReachGraphPersistenceCodec.CompressGzip(canonicalBytes);
|
||||
|
||||
var scopeJson = ReachGraphPersistenceCodec.SerializeScope(graph.Scope);
|
||||
var provenanceJson = ReachGraphPersistenceCodec.SerializeProvenance(graph.Provenance);
|
||||
|
||||
await using var connection = await _dataSource
|
||||
.OpenConnectionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SetTenantContextAsync(connection, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO reachgraph.subgraphs (
|
||||
digest, artifact_digest, tenant_id, scope, node_count, edge_count,
|
||||
blob, blob_size_bytes, provenance
|
||||
)
|
||||
VALUES (
|
||||
@Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount,
|
||||
@Blob, @BlobSizeBytes, @Provenance::jsonb
|
||||
)
|
||||
ON CONFLICT (digest) DO NOTHING
|
||||
RETURNING created_at
|
||||
""";
|
||||
|
||||
var command = new CommandDefinition(sql, new
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = graph.Artifact.Digest,
|
||||
TenantId = tenantId,
|
||||
Scope = scopeJson,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
Blob = compressedBlob,
|
||||
BlobSizeBytes = compressedBlob.Length,
|
||||
Provenance = provenanceJson
|
||||
}, cancellationToken: cancellationToken);
|
||||
|
||||
var result = await connection
|
||||
.QuerySingleOrDefaultAsync<DateTime?>(command)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var created = result.HasValue;
|
||||
var storedAt = result.HasValue
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(result.Value, DateTimeKind.Utc))
|
||||
: _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"{Action} reachability graph {Digest} for artifact {Artifact}",
|
||||
created ? "Stored" : "Found existing",
|
||||
digest,
|
||||
graph.Artifact.Digest);
|
||||
|
||||
return new StoreResult
|
||||
{
|
||||
Digest = digest,
|
||||
Created = created,
|
||||
ArtifactDigest = graph.Artifact.Digest,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
StoredAt = storedAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
public sealed partial class PostgresReachGraphRepository
|
||||
{
|
||||
private static async Task SetTenantContextAsync(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT set_config('app.tenant_id', @TenantId, false);";
|
||||
command.Parameters.AddWithValue("TenantId", tenantId);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,23 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the ReachGraph repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresReachGraphRepository : IReachGraphRepository
|
||||
public sealed partial class PostgresReachGraphRepository : IReachGraphRepository
|
||||
{
|
||||
private const int MaxLimit = 100;
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
private readonly ILogger<PostgresReachGraphRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public PostgresReachGraphRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
CanonicalReachGraphSerializer serializer,
|
||||
@@ -43,307 +32,13 @@ public sealed class PostgresReachGraphRepository : IReachGraphRepository
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StoreResult> StoreAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
internal static int ClampLimit(int limit)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
var digest = _digestComputer.ComputeDigest(graph);
|
||||
var canonicalBytes = _serializer.SerializeMinimal(graph);
|
||||
var compressedBlob = CompressGzip(canonicalBytes);
|
||||
|
||||
var scopeJson = JsonSerializer.Serialize(new
|
||||
if (limit <= 0)
|
||||
{
|
||||
entrypoints = graph.Scope.Entrypoints,
|
||||
selectors = graph.Scope.Selectors,
|
||||
cves = graph.Scope.Cves
|
||||
}, JsonOptions);
|
||||
|
||||
var provenanceJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
intoto = graph.Provenance.Intoto,
|
||||
inputs = graph.Provenance.Inputs,
|
||||
computedAt = graph.Provenance.ComputedAt,
|
||||
analyzer = graph.Provenance.Analyzer
|
||||
}, JsonOptions);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContext(connection, tenantId, cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO reachgraph.subgraphs (
|
||||
digest, artifact_digest, tenant_id, scope, node_count, edge_count,
|
||||
blob, blob_size_bytes, provenance
|
||||
)
|
||||
VALUES (
|
||||
@Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount,
|
||||
@Blob, @BlobSizeBytes, @Provenance::jsonb
|
||||
)
|
||||
ON CONFLICT (digest) DO NOTHING
|
||||
RETURNING created_at
|
||||
""";
|
||||
|
||||
var result = await connection.QuerySingleOrDefaultAsync<DateTimeOffset?>(sql, new
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = graph.Artifact.Digest,
|
||||
TenantId = tenantId,
|
||||
Scope = scopeJson,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
Blob = compressedBlob,
|
||||
BlobSizeBytes = compressedBlob.Length,
|
||||
Provenance = provenanceJson
|
||||
});
|
||||
|
||||
var created = result.HasValue;
|
||||
var storedAt = result ?? _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"{Action} reachability graph {Digest} for artifact {Artifact}",
|
||||
created ? "Stored" : "Found existing",
|
||||
digest,
|
||||
graph.Artifact.Digest);
|
||||
|
||||
return new StoreResult
|
||||
{
|
||||
Digest = digest,
|
||||
Created = created,
|
||||
ArtifactDigest = graph.Artifact.Digest,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
StoredAt = storedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphMinimal?> GetByDigestAsync(
|
||||
string digest,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContext(connection, tenantId, cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT blob
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
""";
|
||||
|
||||
var blob = await connection.QuerySingleOrDefaultAsync<byte[]>(sql, new { Digest = digest });
|
||||
|
||||
if (blob is null)
|
||||
{
|
||||
return null;
|
||||
throw new ArgumentOutOfRangeException(nameof(limit), "Limit must be greater than zero.");
|
||||
}
|
||||
|
||||
var decompressed = DecompressGzip(blob);
|
||||
return _serializer.Deserialize(decompressed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ReachGraphSummary>> ListByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContext(connection, tenantId, cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE artifact_digest = @ArtifactDigest
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
var rows = await connection.QueryAsync<dynamic>(sql, new { ArtifactDigest = artifactDigest, Limit = limit });
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ParseScope((string)row.scope)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ReachGraphSummary>> FindByCveAsync(
|
||||
string cveId,
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(cveId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContext(connection, tenantId, cancellationToken);
|
||||
|
||||
const string sql = """
|
||||
SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope
|
||||
FROM reachgraph.subgraphs
|
||||
WHERE scope->'cves' @> @CveJson::jsonb
|
||||
ORDER BY created_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
var cveJson = JsonSerializer.Serialize(new[] { cveId });
|
||||
var rows = await connection.QueryAsync<dynamic>(sql, new { CveJson = cveJson, Limit = limit });
|
||||
|
||||
return rows.Select(row => new ReachGraphSummary
|
||||
{
|
||||
Digest = row.digest,
|
||||
ArtifactDigest = row.artifact_digest,
|
||||
NodeCount = row.node_count,
|
||||
EdgeCount = row.edge_count,
|
||||
BlobSizeBytes = row.blob_size_bytes,
|
||||
CreatedAt = row.created_at,
|
||||
Scope = ParseScope((string)row.scope)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(
|
||||
string digest,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(digest);
|
||||
ArgumentException.ThrowIfNullOrEmpty(tenantId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContext(connection, tenantId, cancellationToken);
|
||||
|
||||
// Using DELETE for now; could be soft-delete with deleted_at column
|
||||
const string sql = """
|
||||
DELETE FROM reachgraph.subgraphs
|
||||
WHERE digest = @Digest
|
||||
RETURNING digest
|
||||
""";
|
||||
|
||||
var deleted = await connection.QuerySingleOrDefaultAsync<string>(sql, new { Digest = digest });
|
||||
|
||||
if (deleted is not null)
|
||||
{
|
||||
_logger.LogInformation("Deleted reachability graph {Digest}", digest);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RecordReplayAsync(
|
||||
ReplayLogEntry entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
|
||||
await SetTenantContext(connection, entry.TenantId, cancellationToken);
|
||||
|
||||
var inputsJson = JsonSerializer.Serialize(entry.InputDigests, JsonOptions);
|
||||
var divergenceJson = entry.Divergence is not null
|
||||
? JsonSerializer.Serialize(entry.Divergence, JsonOptions)
|
||||
: null;
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO reachgraph.replay_log (
|
||||
subgraph_digest, input_digests, computed_digest, matches,
|
||||
divergence, tenant_id, duration_ms
|
||||
)
|
||||
VALUES (
|
||||
@SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches,
|
||||
@Divergence::jsonb, @TenantId, @DurationMs
|
||||
)
|
||||
""";
|
||||
|
||||
await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
entry.SubgraphDigest,
|
||||
InputDigests = inputsJson,
|
||||
entry.ComputedDigest,
|
||||
entry.Matches,
|
||||
Divergence = divergenceJson,
|
||||
entry.TenantId,
|
||||
entry.DurationMs
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)",
|
||||
entry.Matches ? "MATCH" : "MISMATCH",
|
||||
entry.SubgraphDigest,
|
||||
entry.ComputedDigest,
|
||||
entry.DurationMs);
|
||||
}
|
||||
|
||||
private static async Task SetTenantContext(
|
||||
NpgsqlConnection connection,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SET LOCAL app.tenant_id = @TenantId";
|
||||
cmd.Parameters.AddWithValue("TenantId", tenantId);
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static byte[] CompressGzip(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
{
|
||||
gzip.Write(data);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] DecompressGzip(byte[] compressed)
|
||||
{
|
||||
using var input = new MemoryStream(compressed);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static ReachGraphScope ParseScope(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var entrypoints = root.TryGetProperty("entrypoints", out var ep)
|
||||
? ep.EnumerateArray().Select(e => e.GetString()!).ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
var selectors = root.TryGetProperty("selectors", out var sel)
|
||||
? sel.EnumerateArray().Select(s => s.GetString()!).ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
ImmutableArray<string>? cves = null;
|
||||
if (root.TryGetProperty("cves", out var cvesElem) && cvesElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
cves = cvesElem.EnumerateArray().Select(c => c.GetString()!).ToImmutableArray();
|
||||
}
|
||||
|
||||
return new ReachGraphScope(entrypoints, selectors, cves);
|
||||
return Math.Min(limit, MaxLimit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
internal static class ReachGraphPersistenceCodec
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
internal static string SerializeScope(ReachGraphScope scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
entrypoints = scope.Entrypoints,
|
||||
selectors = scope.Selectors,
|
||||
cves = scope.Cves
|
||||
}, _jsonOptions);
|
||||
}
|
||||
|
||||
internal static string SerializeProvenance(ReachGraphProvenance provenance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provenance);
|
||||
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
intoto = provenance.Intoto,
|
||||
inputs = provenance.Inputs,
|
||||
computedAt = provenance.ComputedAt,
|
||||
analyzer = provenance.Analyzer
|
||||
}, _jsonOptions);
|
||||
}
|
||||
|
||||
internal static string SerializeInputs(ReachGraphInputs inputs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputs);
|
||||
return JsonSerializer.Serialize(inputs, _jsonOptions);
|
||||
}
|
||||
|
||||
internal static string? SerializeDivergence(ReplayDivergence? divergence)
|
||||
=> divergence is null ? null : JsonSerializer.Serialize(divergence, _jsonOptions);
|
||||
|
||||
internal static byte[] CompressGzip(byte[] data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
{
|
||||
gzip.Write(data);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
internal static byte[] DecompressGzip(byte[] compressed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(compressed);
|
||||
|
||||
using var input = new MemoryStream(compressed);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
internal static ReachGraphScope ParseScope(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(json);
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var entrypoints = root.TryGetProperty("entrypoints", out var ep)
|
||||
? ep.EnumerateArray().Select(e => e.GetString()!).ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
var selectors = root.TryGetProperty("selectors", out var sel)
|
||||
? sel.EnumerateArray().Select(s => s.GetString()!).ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
ImmutableArray<string>? cves = null;
|
||||
if (root.TryGetProperty("cves", out var cvesElem) && cvesElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
cves = cvesElem.EnumerateArray().Select(c => c.GetString()!).ToImmutableArray();
|
||||
}
|
||||
|
||||
return new ReachGraphScope(entrypoints, selectors, cves);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a stored graph (without full blob).
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSummary
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required int NodeCount { get; init; }
|
||||
public required int EdgeCount { get; init; }
|
||||
public required int BlobSizeBytes { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required ReachGraphScope Scope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Describes divergence when replay doesn't match.
|
||||
/// </summary>
|
||||
public sealed record ReplayDivergence
|
||||
{
|
||||
public required int NodesAdded { get; init; }
|
||||
public required int NodesRemoved { get; init; }
|
||||
public required int EdgesChanged { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the replay verification log.
|
||||
/// </summary>
|
||||
public sealed record ReplayLogEntry
|
||||
{
|
||||
public required string SubgraphDigest { get; init; }
|
||||
public required ReachGraphInputs InputDigests { get; init; }
|
||||
public required string ComputedDigest { get; init; }
|
||||
public required bool Matches { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required int DurationMs { get; init; }
|
||||
public ReplayDivergence? Divergence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
namespace StellaOps.ReachGraph.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Result of storing a graph.
|
||||
/// </summary>
|
||||
public sealed record StoreResult
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required bool Created { get; init; }
|
||||
public required string ArtifactDigest { get; init; }
|
||||
public required int NodeCount { get; init; }
|
||||
public required int EdgeCount { get; init; }
|
||||
public required DateTimeOffset StoredAt { get; init; }
|
||||
}
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0104-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph.Persistence. |
|
||||
| AUDIT-0104-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Enforced tenant filters in list/get/delete queries, added Intent traits for tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Persistence.Tests/StellaOps.ReachGraph.Persistence.Tests.csproj` passed 2026-02-03. |
|
||||
|
||||
Reference in New Issue
Block a user