up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MicrosoftOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(SbomServicePostgresCollection.Name)]
|
||||
public sealed class PostgresEntrypointRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SbomServicePostgresFixture _fixture;
|
||||
private readonly PostgresEntrypointRepository _repository;
|
||||
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public PostgresEntrypointRepositoryTests(SbomServicePostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new SbomServiceDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SbomServiceDataSource>.Instance);
|
||||
_repository = new PostgresEntrypointRepository(dataSource, NullLogger<PostgresEntrypointRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAndList_RoundTripsEntrypoint()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoint = new Entrypoint(
|
||||
Artifact: "ghcr.io/test/api",
|
||||
Service: "web",
|
||||
Path: "/api",
|
||||
Scope: "runtime",
|
||||
RuntimeFlag: true);
|
||||
|
||||
// Act
|
||||
await _repository.UpsertAsync(_tenantId, entrypoint, CancellationToken.None);
|
||||
var fetched = await _repository.ListAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().HaveCount(1);
|
||||
fetched[0].Artifact.Should().Be("ghcr.io/test/api");
|
||||
fetched[0].Service.Should().Be("web");
|
||||
fetched[0].Path.Should().Be("/api");
|
||||
fetched[0].RuntimeFlag.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertAsync_UpdatesExistingEntrypoint()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoint1 = new Entrypoint("ghcr.io/test/api", "web", "/old", "runtime", false);
|
||||
var entrypoint2 = new Entrypoint("ghcr.io/test/api", "web", "/new", "build", true);
|
||||
|
||||
// Act
|
||||
await _repository.UpsertAsync(_tenantId, entrypoint1, CancellationToken.None);
|
||||
await _repository.UpsertAsync(_tenantId, entrypoint2, CancellationToken.None);
|
||||
var fetched = await _repository.ListAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().HaveCount(1);
|
||||
fetched[0].Path.Should().Be("/new");
|
||||
fetched[0].Scope.Should().Be("build");
|
||||
fetched[0].RuntimeFlag.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsOrderedByArtifactServicePath()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.UpsertAsync(_tenantId, new Entrypoint("z-api", "web", "/z", "runtime", true), CancellationToken.None);
|
||||
await _repository.UpsertAsync(_tenantId, new Entrypoint("a-api", "web", "/a", "runtime", true), CancellationToken.None);
|
||||
await _repository.UpsertAsync(_tenantId, new Entrypoint("a-api", "worker", "/b", "runtime", true), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var fetched = await _repository.ListAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().HaveCount(3);
|
||||
fetched[0].Artifact.Should().Be("a-api");
|
||||
fetched[0].Service.Should().Be("web");
|
||||
fetched[1].Artifact.Should().Be("a-api");
|
||||
fetched[1].Service.Should().Be("worker");
|
||||
fetched[2].Artifact.Should().Be("z-api");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsEmptyForUnknownTenant()
|
||||
{
|
||||
// Act
|
||||
var fetched = await _repository.ListAsync("unknown-tenant", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MicrosoftOptions = Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Services;
|
||||
using StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Tests;
|
||||
|
||||
[Collection(SbomServicePostgresCollection.Name)]
|
||||
public sealed class PostgresOrchestratorControlRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly SbomServicePostgresFixture _fixture;
|
||||
private readonly PostgresOrchestratorControlRepository _repository;
|
||||
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public PostgresOrchestratorControlRepositoryTests(SbomServicePostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
|
||||
var options = fixture.Fixture.CreateOptions();
|
||||
options.SchemaName = fixture.SchemaName;
|
||||
var dataSource = new SbomServiceDataSource(MicrosoftOptions.Options.Create(options), NullLogger<SbomServiceDataSource>.Instance);
|
||||
_repository = new PostgresOrchestratorControlRepository(dataSource, NullLogger<PostgresOrchestratorControlRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsDefaultStateForNewTenant()
|
||||
{
|
||||
// Act
|
||||
var state = await _repository.GetAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
state.Should().NotBeNull();
|
||||
state.TenantId.Should().Be(_tenantId);
|
||||
state.Paused.Should().BeFalse();
|
||||
state.ThrottlePercent.Should().Be(0);
|
||||
state.Backpressure.Should().Be("normal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_PersistsControlState()
|
||||
{
|
||||
// Arrange
|
||||
var state = new OrchestratorControlState(
|
||||
TenantId: _tenantId,
|
||||
Paused: true,
|
||||
ThrottlePercent: 50,
|
||||
Backpressure: "high",
|
||||
UpdatedAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
await _repository.SetAsync(state, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Paused.Should().BeTrue();
|
||||
fetched.ThrottlePercent.Should().Be(50);
|
||||
fetched.Backpressure.Should().Be("high");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_UpdatesExistingState()
|
||||
{
|
||||
// Arrange
|
||||
var state1 = new OrchestratorControlState(_tenantId, false, 10, "low", DateTimeOffset.UtcNow);
|
||||
var state2 = new OrchestratorControlState(_tenantId, true, 90, "critical", DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
await _repository.SetAsync(state1, CancellationToken.None);
|
||||
await _repository.SetAsync(state2, CancellationToken.None);
|
||||
var fetched = await _repository.GetAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Paused.Should().BeTrue();
|
||||
fetched.ThrottlePercent.Should().Be(90);
|
||||
fetched.Backpressure.Should().Be("critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsAllStates()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1 = "tenant-a-" + Guid.NewGuid().ToString("N")[..4];
|
||||
var tenant2 = "tenant-b-" + Guid.NewGuid().ToString("N")[..4];
|
||||
await _repository.SetAsync(new OrchestratorControlState(tenant1, false, 0, "normal", DateTimeOffset.UtcNow), CancellationToken.None);
|
||||
await _repository.SetAsync(new OrchestratorControlState(tenant2, true, 50, "high", DateTimeOffset.UtcNow), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var states = await _repository.ListAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
states.Should().HaveCountGreaterOrEqualTo(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the SbomService module.
|
||||
/// </summary>
|
||||
public sealed class SbomServicePostgresFixture : PostgresIntegrationFixture, ICollectionFixture<SbomServicePostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(SbomServiceDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "SbomService";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for SbomService PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class SbomServicePostgresCollection : ICollectionFixture<SbomServicePostgresFixture>
|
||||
{
|
||||
public const string Name = "SbomServicePostgres";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.SbomService.Storage.Postgres\StellaOps.SbomService.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,181 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ICatalogRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresCatalogRepository : RepositoryBase<SbomServiceDataSource>, ICatalogRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresCatalogRepository(SbomServiceDataSource dataSource, ILogger<PostgresCatalogRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CatalogRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT artifact, sbom_version, digest, license, scope, asset_tags, created_at, projection_hash, evaluation_metadata
|
||||
FROM sbom.catalog
|
||||
ORDER BY created_at DESC, artifact";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<CatalogRecord>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapCatalogRecord(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<CatalogRecord> Items, int Total)> QueryAsync(SbomCatalogQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conditions = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
conditions.Add("artifact ILIKE @artifact");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.License))
|
||||
{
|
||||
conditions.Add("LOWER(license) = LOWER(@license)");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.Scope))
|
||||
{
|
||||
conditions.Add("LOWER(scope) = LOWER(@scope)");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.AssetTag))
|
||||
{
|
||||
conditions.Add("asset_tags ? @asset_tag");
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : "";
|
||||
|
||||
var countSql = $"SELECT COUNT(*) FROM sbom.catalog {whereClause}";
|
||||
var dataSql = $@"
|
||||
SELECT artifact, sbom_version, digest, license, scope, asset_tags, created_at, projection_hash, evaluation_metadata
|
||||
FROM sbom.catalog
|
||||
{whereClause}
|
||||
ORDER BY created_at DESC, artifact
|
||||
LIMIT @limit OFFSET @offset";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get count
|
||||
await using var countCommand = CreateCommand(countSql, connection);
|
||||
AddQueryParameters(countCommand, query);
|
||||
var total = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
// Get data
|
||||
await using var dataCommand = CreateCommand(dataSql, connection);
|
||||
AddQueryParameters(dataCommand, query);
|
||||
AddParameter(dataCommand, "@limit", query.Limit);
|
||||
AddParameter(dataCommand, "@offset", query.Offset);
|
||||
|
||||
await using var reader = await dataCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<CatalogRecord>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapCatalogRecord(reader));
|
||||
}
|
||||
|
||||
return (results, total);
|
||||
}
|
||||
|
||||
private void AddQueryParameters(NpgsqlCommand command, SbomCatalogQuery query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
AddParameter(command, "@artifact", $"%{query.Artifact}%");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.License))
|
||||
{
|
||||
AddParameter(command, "@license", query.License);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.Scope))
|
||||
{
|
||||
AddParameter(command, "@scope", query.Scope);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.AssetTag))
|
||||
{
|
||||
AddParameter(command, "@asset_tag", query.AssetTag);
|
||||
}
|
||||
}
|
||||
|
||||
private static CatalogRecord MapCatalogRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var assetTagsJson = reader.IsDBNull(5) ? null : reader.GetString(5);
|
||||
var assetTags = string.IsNullOrWhiteSpace(assetTagsJson)
|
||||
? new Dictionary<string, string>()
|
||||
: JsonSerializer.Deserialize<Dictionary<string, string>>(assetTagsJson, JsonOptions) ?? new Dictionary<string, string>();
|
||||
|
||||
return new CatalogRecord(
|
||||
Artifact: reader.GetString(0),
|
||||
SbomVersion: reader.GetString(1),
|
||||
Digest: reader.GetString(2),
|
||||
License: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Scope: reader.GetString(4),
|
||||
AssetTags: assetTags,
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(6),
|
||||
ProjectionHash: reader.GetString(7),
|
||||
EvaluationMetadata: reader.GetString(8));
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = @"
|
||||
CREATE SCHEMA IF NOT EXISTS sbom;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.catalog (
|
||||
id TEXT PRIMARY KEY,
|
||||
artifact TEXT NOT NULL,
|
||||
sbom_version TEXT NOT NULL,
|
||||
digest TEXT NOT NULL,
|
||||
license TEXT,
|
||||
scope TEXT NOT NULL,
|
||||
asset_tags JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
projection_hash TEXT NOT NULL,
|
||||
evaluation_metadata TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_artifact ON sbom.catalog (artifact);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_license ON sbom.catalog (license);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_scope ON sbom.catalog (scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_created_at ON sbom.catalog (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_asset_tags ON sbom.catalog USING GIN (asset_tags);";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IComponentLookupRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresComponentLookupRepository : RepositoryBase<SbomServiceDataSource>, IComponentLookupRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresComponentLookupRepository(SbomServiceDataSource dataSource, ILogger<PostgresComponentLookupRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<ComponentLookupRecord> Items, int Total)> QueryAsync(ComponentLookupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conditions = new List<string> { "LOWER(purl) = LOWER(@purl)" };
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
conditions.Add("LOWER(artifact) = LOWER(@artifact)");
|
||||
}
|
||||
|
||||
var whereClause = "WHERE " + string.Join(" AND ", conditions);
|
||||
|
||||
var countSql = $"SELECT COUNT(*) FROM sbom.component_lookups {whereClause}";
|
||||
var dataSql = $@"
|
||||
SELECT artifact, purl, neighbor_purl, relationship, license, scope, runtime_flag
|
||||
FROM sbom.component_lookups
|
||||
{whereClause}
|
||||
ORDER BY artifact, purl
|
||||
LIMIT @limit OFFSET @offset";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get count
|
||||
await using var countCommand = CreateCommand(countSql, connection);
|
||||
AddParameter(countCommand, "@purl", query.Purl);
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
AddParameter(countCommand, "@artifact", query.Artifact);
|
||||
}
|
||||
var total = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
// Get data
|
||||
await using var dataCommand = CreateCommand(dataSql, connection);
|
||||
AddParameter(dataCommand, "@purl", query.Purl);
|
||||
if (!string.IsNullOrWhiteSpace(query.Artifact))
|
||||
{
|
||||
AddParameter(dataCommand, "@artifact", query.Artifact);
|
||||
}
|
||||
AddParameter(dataCommand, "@limit", query.Limit);
|
||||
AddParameter(dataCommand, "@offset", query.Offset);
|
||||
|
||||
await using var reader = await dataCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<ComponentLookupRecord>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapComponentLookupRecord(reader));
|
||||
}
|
||||
|
||||
return (results, total);
|
||||
}
|
||||
|
||||
private static ComponentLookupRecord MapComponentLookupRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ComponentLookupRecord(
|
||||
Artifact: reader.GetString(0),
|
||||
Purl: reader.GetString(1),
|
||||
NeighborPurl: reader.GetString(2),
|
||||
Relationship: reader.GetString(3),
|
||||
License: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Scope: reader.GetString(5),
|
||||
RuntimeFlag: reader.GetBoolean(6));
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = @"
|
||||
CREATE SCHEMA IF NOT EXISTS sbom;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.component_lookups (
|
||||
id TEXT PRIMARY KEY,
|
||||
artifact TEXT NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
neighbor_purl TEXT NOT NULL,
|
||||
relationship TEXT NOT NULL,
|
||||
license TEXT,
|
||||
scope TEXT NOT NULL,
|
||||
runtime_flag BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_component_lookups_purl ON sbom.component_lookups (LOWER(purl));
|
||||
CREATE INDEX IF NOT EXISTS idx_component_lookups_artifact ON sbom.component_lookups (LOWER(artifact));
|
||||
CREATE INDEX IF NOT EXISTS idx_component_lookups_purl_artifact ON sbom.component_lookups (LOWER(purl), LOWER(artifact));";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEntrypointRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresEntrypointRepository : RepositoryBase<SbomServiceDataSource>, IEntrypointRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresEntrypointRepository(SbomServiceDataSource dataSource, ILogger<PostgresEntrypointRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Entrypoint>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT artifact, service, path, scope, runtime_flag
|
||||
FROM sbom.entrypoints
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY artifact, service, path";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@tenant_id", tenantId.Trim().ToLowerInvariant());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<Entrypoint>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapEntrypoint(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(string tenantId, Entrypoint entrypoint, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(entrypoint);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
INSERT INTO sbom.entrypoints (tenant_id, artifact, service, path, scope, runtime_flag)
|
||||
VALUES (@tenant_id, @artifact, @service, @path, @scope, @runtime_flag)
|
||||
ON CONFLICT (tenant_id, artifact, service) DO UPDATE SET
|
||||
path = EXCLUDED.path,
|
||||
scope = EXCLUDED.scope,
|
||||
runtime_flag = EXCLUDED.runtime_flag";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@tenant_id", tenantId.Trim().ToLowerInvariant());
|
||||
AddParameter(command, "@artifact", entrypoint.Artifact);
|
||||
AddParameter(command, "@service", entrypoint.Service);
|
||||
AddParameter(command, "@path", entrypoint.Path);
|
||||
AddParameter(command, "@scope", entrypoint.Scope);
|
||||
AddParameter(command, "@runtime_flag", entrypoint.RuntimeFlag);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Entrypoint MapEntrypoint(NpgsqlDataReader reader)
|
||||
{
|
||||
return new Entrypoint(
|
||||
Artifact: reader.GetString(0),
|
||||
Service: reader.GetString(1),
|
||||
Path: reader.GetString(2),
|
||||
Scope: reader.GetString(3),
|
||||
RuntimeFlag: reader.GetBoolean(4));
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = @"
|
||||
CREATE SCHEMA IF NOT EXISTS sbom;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.entrypoints (
|
||||
tenant_id TEXT NOT NULL,
|
||||
artifact TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
runtime_flag BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (tenant_id, artifact, service)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entrypoints_tenant_id ON sbom.entrypoints (tenant_id);";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IOrchestratorControlRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresOrchestratorControlRepository : RepositoryBase<SbomServiceDataSource>, IOrchestratorControlRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresOrchestratorControlRepository(SbomServiceDataSource dataSource, ILogger<PostgresOrchestratorControlRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT tenant_id, paused, throttle_percent, backpressure, updated_at
|
||||
FROM sbom.orchestrator_control
|
||||
WHERE tenant_id = @tenant_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@tenant_id", tenantId.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapOrchestratorControlState(reader);
|
||||
}
|
||||
|
||||
// Return default state and persist it
|
||||
var defaultState = OrchestratorControlState.Default(tenantId);
|
||||
await reader.CloseAsync().ConfigureAwait(false);
|
||||
await SetAsync(defaultState, cancellationToken).ConfigureAwait(false);
|
||||
return defaultState;
|
||||
}
|
||||
|
||||
public async Task<OrchestratorControlState> SetAsync(OrchestratorControlState state, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
INSERT INTO sbom.orchestrator_control (tenant_id, paused, throttle_percent, backpressure, updated_at)
|
||||
VALUES (@tenant_id, @paused, @throttle_percent, @backpressure, @updated_at)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
paused = EXCLUDED.paused,
|
||||
throttle_percent = EXCLUDED.throttle_percent,
|
||||
backpressure = EXCLUDED.backpressure,
|
||||
updated_at = EXCLUDED.updated_at";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@tenant_id", state.TenantId.Trim());
|
||||
AddParameter(command, "@paused", state.Paused);
|
||||
AddParameter(command, "@throttle_percent", state.ThrottlePercent);
|
||||
AddParameter(command, "@backpressure", state.Backpressure);
|
||||
AddParameter(command, "@updated_at", state.UpdatedAtUtc);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OrchestratorControlState>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT tenant_id, paused, throttle_percent, backpressure, updated_at
|
||||
FROM sbom.orchestrator_control
|
||||
ORDER BY tenant_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<OrchestratorControlState>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapOrchestratorControlState(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static OrchestratorControlState MapOrchestratorControlState(NpgsqlDataReader reader)
|
||||
{
|
||||
return new OrchestratorControlState(
|
||||
TenantId: reader.GetString(0),
|
||||
Paused: reader.GetBoolean(1),
|
||||
ThrottlePercent: reader.GetInt32(2),
|
||||
Backpressure: reader.GetString(3),
|
||||
UpdatedAtUtc: reader.GetFieldValue<DateTimeOffset>(4));
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = @"
|
||||
CREATE SCHEMA IF NOT EXISTS sbom;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.orchestrator_control (
|
||||
tenant_id TEXT PRIMARY KEY,
|
||||
paused BOOLEAN NOT NULL DEFAULT false,
|
||||
throttle_percent INTEGER NOT NULL DEFAULT 0,
|
||||
backpressure TEXT NOT NULL DEFAULT 'normal',
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IOrchestratorRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresOrchestratorRepository : RepositoryBase<SbomServiceDataSource>, IOrchestratorRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresOrchestratorRepository(SbomServiceDataSource dataSource, ILogger<PostgresOrchestratorRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OrchestratorSource>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT tenant_id, source_id, artifact_digest, source_type, created_at, metadata
|
||||
FROM sbom.orchestrator_sources
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY artifact_digest, source_type, source_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@tenant_id", tenantId.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<OrchestratorSource>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapOrchestratorSource(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<OrchestratorSource> RegisterAsync(RegisterOrchestratorSourceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check for existing record (idempotent on tenant, artifactDigest, sourceType)
|
||||
const string checkSql = @"
|
||||
SELECT tenant_id, source_id, artifact_digest, source_type, created_at, metadata
|
||||
FROM sbom.orchestrator_sources
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND artifact_digest = @artifact_digest
|
||||
AND source_type = @source_type";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var checkCommand = CreateCommand(checkSql, connection);
|
||||
AddParameter(checkCommand, "@tenant_id", request.TenantId.Trim());
|
||||
AddParameter(checkCommand, "@artifact_digest", request.ArtifactDigest.Trim());
|
||||
AddParameter(checkCommand, "@source_type", request.SourceType.Trim());
|
||||
|
||||
await using var checkReader = await checkCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await checkReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MapOrchestratorSource(checkReader);
|
||||
}
|
||||
await checkReader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
// Generate new source ID
|
||||
const string countSql = "SELECT COUNT(*) FROM sbom.orchestrator_sources WHERE tenant_id = @tenant_id";
|
||||
await using var countCommand = CreateCommand(countSql, connection);
|
||||
AddParameter(countCommand, "@tenant_id", request.TenantId.Trim());
|
||||
var count = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
var sourceId = $"src-{count + 1:D3}";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
const string insertSql = @"
|
||||
INSERT INTO sbom.orchestrator_sources (tenant_id, source_id, artifact_digest, source_type, created_at, metadata)
|
||||
VALUES (@tenant_id, @source_id, @artifact_digest, @source_type, @created_at, @metadata)";
|
||||
|
||||
await using var insertCommand = CreateCommand(insertSql, connection);
|
||||
AddParameter(insertCommand, "@tenant_id", request.TenantId.Trim());
|
||||
AddParameter(insertCommand, "@source_id", sourceId);
|
||||
AddParameter(insertCommand, "@artifact_digest", request.ArtifactDigest.Trim());
|
||||
AddParameter(insertCommand, "@source_type", request.SourceType.Trim());
|
||||
AddParameter(insertCommand, "@created_at", now);
|
||||
AddParameter(insertCommand, "@metadata", request.Metadata.Trim());
|
||||
|
||||
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OrchestratorSource(
|
||||
request.TenantId.Trim(),
|
||||
sourceId,
|
||||
request.ArtifactDigest.Trim(),
|
||||
request.SourceType.Trim(),
|
||||
now,
|
||||
request.Metadata.Trim());
|
||||
}
|
||||
|
||||
private static OrchestratorSource MapOrchestratorSource(NpgsqlDataReader reader)
|
||||
{
|
||||
return new OrchestratorSource(
|
||||
TenantId: reader.GetString(0),
|
||||
SourceId: reader.GetString(1),
|
||||
ArtifactDigest: reader.GetString(2),
|
||||
SourceType: reader.GetString(3),
|
||||
CreatedAtUtc: reader.GetFieldValue<DateTimeOffset>(4),
|
||||
Metadata: reader.GetString(5));
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = @"
|
||||
CREATE SCHEMA IF NOT EXISTS sbom;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.orchestrator_sources (
|
||||
tenant_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
artifact_digest TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, source_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_orchestrator_sources_unique
|
||||
ON sbom.orchestrator_sources (tenant_id, artifact_digest, source_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_orchestrator_sources_tenant_id
|
||||
ON sbom.orchestrator_sources (tenant_id);";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IProjectionRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresProjectionRepository : RepositoryBase<SbomServiceDataSource>, IProjectionRepository
|
||||
{
|
||||
private bool _tableInitialized;
|
||||
|
||||
public PostgresProjectionRepository(SbomServiceDataSource dataSource, ILogger<PostgresProjectionRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<SbomProjectionResult?> GetAsync(string snapshotId, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(snapshotId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT snapshot_id, tenant_id, projection_json, projection_hash, schema_version
|
||||
FROM sbom.projections
|
||||
WHERE snapshot_id = @snapshot_id AND tenant_id = @tenant_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "@snapshot_id", snapshotId.Trim());
|
||||
AddParameter(command, "@tenant_id", tenantId.Trim());
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapSbomProjectionResult(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SbomProjectionResult>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string sql = @"
|
||||
SELECT snapshot_id, tenant_id, projection_json, projection_hash, schema_version
|
||||
FROM sbom.projections
|
||||
ORDER BY snapshot_id, tenant_id";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<SbomProjectionResult>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapSbomProjectionResult(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SbomProjectionResult MapSbomProjectionResult(NpgsqlDataReader reader)
|
||||
{
|
||||
var projectionJson = reader.GetString(2);
|
||||
using var doc = JsonDocument.Parse(projectionJson);
|
||||
var projection = doc.RootElement.Clone();
|
||||
|
||||
return new SbomProjectionResult(
|
||||
SnapshotId: reader.GetString(0),
|
||||
TenantId: reader.GetString(1),
|
||||
Projection: projection,
|
||||
ProjectionHash: reader.GetString(3),
|
||||
SchemaVersion: reader.GetString(4));
|
||||
}
|
||||
|
||||
private async Task EnsureTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_tableInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string ddl = @"
|
||||
CREATE SCHEMA IF NOT EXISTS sbom;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sbom.projections (
|
||||
snapshot_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
projection_json JSONB NOT NULL,
|
||||
projection_hash TEXT NOT NULL,
|
||||
schema_version TEXT NOT NULL,
|
||||
PRIMARY KEY (snapshot_id, tenant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projections_tenant_id ON sbom.projections (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projections_schema_version ON sbom.projections (schema_version);";
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(ddl, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_tableInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for SbomService module.
|
||||
/// </summary>
|
||||
public sealed class SbomServiceDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for SbomService tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "sbom";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SbomService data source.
|
||||
/// </summary>
|
||||
public SbomServiceDataSource(IOptions<PostgresOptions> options, ILogger<SbomServiceDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "SbomService";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring SbomService PostgreSQL storage services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds SbomService PostgreSQL storage services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration root.</param>
|
||||
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSbomServicePostgresStorage(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionName = "Postgres:SbomService")
|
||||
{
|
||||
services.Configure<PostgresOptions>(configuration.GetSection(sectionName));
|
||||
services.AddSingleton<SbomServiceDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddSingleton<ICatalogRepository, PostgresCatalogRepository>();
|
||||
services.AddSingleton<IComponentLookupRepository, PostgresComponentLookupRepository>();
|
||||
services.AddSingleton<IEntrypointRepository, PostgresEntrypointRepository>();
|
||||
services.AddSingleton<IOrchestratorRepository, PostgresOrchestratorRepository>();
|
||||
services.AddSingleton<IOrchestratorControlRepository, PostgresOrchestratorControlRepository>();
|
||||
services.AddSingleton<IProjectionRepository, PostgresProjectionRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds SbomService PostgreSQL storage services with explicit options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>Service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSbomServicePostgresStorage(
|
||||
this IServiceCollection services,
|
||||
Action<PostgresOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<SbomServiceDataSource>();
|
||||
|
||||
// Register repositories
|
||||
services.AddSingleton<ICatalogRepository, PostgresCatalogRepository>();
|
||||
services.AddSingleton<IComponentLookupRepository, PostgresComponentLookupRepository>();
|
||||
services.AddSingleton<IEntrypointRepository, PostgresEntrypointRepository>();
|
||||
services.AddSingleton<IOrchestratorRepository, PostgresOrchestratorRepository>();
|
||||
services.AddSingleton<IOrchestratorControlRepository, PostgresOrchestratorControlRepository>();
|
||||
services.AddSingleton<IProjectionRepository, PostgresProjectionRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.SbomService.Storage.Postgres</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.SbomService/StellaOps.SbomService.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user