Gaps fill up, fixes, ui restructuring

This commit is contained in:
master
2026-02-19 22:10:54 +02:00
parent b5829dce5c
commit 04cacdca8a
331 changed files with 42859 additions and 2174 deletions

View File

@@ -40,6 +40,7 @@ public static class ConcelierPersistenceExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
@@ -87,6 +88,7 @@ public static class ConcelierPersistenceExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();

View File

@@ -0,0 +1,31 @@
-- Concelier migration 004: advisory source freshness projection support
-- Sprint: SPRINT_20260219_008 (BE8-04)
CREATE TABLE IF NOT EXISTS vuln.source_freshness_sla (
source_id UUID PRIMARY KEY REFERENCES vuln.sources(id) ON DELETE CASCADE,
sla_seconds INT NOT NULL DEFAULT 21600 CHECK (sla_seconds > 0),
warning_ratio NUMERIC(4,2) NOT NULL DEFAULT 0.80 CHECK (warning_ratio > 0 AND warning_ratio < 1),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by TEXT NOT NULL DEFAULT 'system'
);
COMMENT ON TABLE vuln.source_freshness_sla IS
'Freshness SLA thresholds per advisory source for advisory-sources UI contracts.';
INSERT INTO vuln.source_freshness_sla (source_id)
SELECT s.id
FROM vuln.sources s
ON CONFLICT (source_id) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_source_states_last_success_at
ON vuln.source_states (last_success_at DESC)
WHERE last_success_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_source_states_last_sync_at
ON vuln.source_states (last_sync_at DESC)
WHERE last_sync_at IS NOT NULL;
DROP TRIGGER IF EXISTS trg_source_freshness_sla_updated_at ON vuln.source_freshness_sla;
CREATE TRIGGER trg_source_freshness_sla_updated_at
BEFORE UPDATE ON vuln.source_freshness_sla
FOR EACH ROW EXECUTE FUNCTION vuln.update_updated_at();

View File

@@ -0,0 +1,73 @@
-- Concelier migration 005: advisory-source signature projection support
-- Sprint: SPRINT_20260219_008 (BE8-07)
CREATE INDEX IF NOT EXISTS idx_advisories_source_key
ON vuln.advisories (source_id, advisory_key)
WHERE source_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_source_edge_source_advisory
ON vuln.advisory_source_edge (source_id, source_advisory_id);
CREATE OR REPLACE VIEW vuln.advisory_source_signature_projection AS
WITH advisory_totals AS (
SELECT
a.source_id,
COUNT(*)::BIGINT AS total_advisories
FROM vuln.advisories a
WHERE a.source_id IS NOT NULL
GROUP BY a.source_id
),
signed_totals AS (
SELECT
a.source_id,
COUNT(*)::BIGINT AS signed_advisories
FROM vuln.advisories a
WHERE a.source_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM vuln.advisory_source_edge e
WHERE e.source_id = a.source_id
AND e.source_advisory_id = a.advisory_key
AND e.dsse_envelope IS NOT NULL
AND CASE
WHEN jsonb_typeof(e.dsse_envelope->'signatures') = 'array'
THEN jsonb_array_length(e.dsse_envelope->'signatures') > 0
ELSE FALSE
END
)
GROUP BY a.source_id
),
failure_totals AS (
SELECT
ss.source_id,
CASE
WHEN ss.metadata ? 'signature_failure_count'
AND (ss.metadata->>'signature_failure_count') ~ '^[0-9]+$'
THEN (ss.metadata->>'signature_failure_count')::BIGINT
ELSE 0::BIGINT
END AS signature_failure_count
FROM vuln.source_states ss
)
SELECT
s.id AS source_id,
COALESCE(t.total_advisories, 0)::BIGINT AS total_advisories,
LEAST(
COALESCE(t.total_advisories, 0)::BIGINT,
COALESCE(st.signed_advisories, 0)::BIGINT
) AS signed_advisories,
GREATEST(
COALESCE(t.total_advisories, 0)::BIGINT
- LEAST(
COALESCE(t.total_advisories, 0)::BIGINT,
COALESCE(st.signed_advisories, 0)::BIGINT
),
0::BIGINT
) AS unsigned_advisories,
COALESCE(f.signature_failure_count, 0)::BIGINT AS signature_failure_count
FROM vuln.sources s
LEFT JOIN advisory_totals t ON t.source_id = s.id
LEFT JOIN signed_totals st ON st.source_id = s.id
LEFT JOIN failure_totals f ON f.source_id = s.id;
COMMENT ON VIEW vuln.advisory_source_signature_projection IS
'Per-source advisory totals and signature rollups for advisory-source detail diagnostics.';

View File

@@ -0,0 +1,193 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed read model for advisory source freshness contracts.
/// </summary>
public sealed class AdvisorySourceReadRepository : RepositoryBase<ConcelierDataSource>, IAdvisorySourceReadRepository
{
private const string SystemTenantId = "_system";
public AdvisorySourceReadRepository(
ConcelierDataSource dataSource,
ILogger<AdvisorySourceReadRepository> logger)
: base(dataSource, logger)
{
}
public Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
bool includeDisabled = false,
CancellationToken cancellationToken = default)
{
const string sql = """
WITH source_projection AS (
SELECT
s.id,
s.key,
s.name,
s.source_type,
s.url,
s.priority,
s.enabled,
st.last_sync_at,
st.last_success_at,
st.last_error,
COALESCE(st.sync_count, 0) AS sync_count,
COALESCE(st.error_count, 0) AS error_count,
COALESCE(sla.sla_seconds, 21600) AS freshness_sla_seconds,
COALESCE(sla.warning_ratio, 0.80) AS warning_ratio,
COALESCE(s.metadata->>'signature_status', 'unsigned') AS signature_status,
COALESCE(sig.total_advisories, 0) AS total_advisories,
COALESCE(sig.signed_advisories, 0) AS signed_advisories,
COALESCE(sig.unsigned_advisories, 0) AS unsigned_advisories,
COALESCE(sig.signature_failure_count, 0) AS signature_failure_count
FROM vuln.sources s
LEFT JOIN vuln.source_states st ON st.source_id = s.id
LEFT JOIN vuln.source_freshness_sla sla ON sla.source_id = s.id
LEFT JOIN vuln.advisory_source_signature_projection sig ON sig.source_id = s.id
WHERE (@include_disabled OR s.enabled = TRUE)
)
SELECT
id,
key,
name,
source_type,
url,
priority,
enabled,
last_sync_at,
last_success_at,
last_error,
sync_count,
error_count,
freshness_sla_seconds,
warning_ratio,
CAST(
EXTRACT(EPOCH FROM (
NOW() - COALESCE(last_success_at, last_sync_at, NOW())
)) AS BIGINT) AS freshness_age_seconds,
CASE
WHEN last_success_at IS NULL THEN 'unavailable'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > freshness_sla_seconds THEN 'stale'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > (freshness_sla_seconds * warning_ratio) THEN 'warning'
ELSE 'healthy'
END AS freshness_status,
signature_status,
total_advisories,
signed_advisories,
unsigned_advisories,
signature_failure_count
FROM source_projection
ORDER BY enabled DESC, priority DESC, key
""";
return QueryAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "include_disabled", includeDisabled),
MapRecord,
cancellationToken);
}
public Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
Guid sourceId,
CancellationToken cancellationToken = default)
{
const string sql = """
WITH source_projection AS (
SELECT
s.id,
s.key,
s.name,
s.source_type,
s.url,
s.priority,
s.enabled,
st.last_sync_at,
st.last_success_at,
st.last_error,
COALESCE(st.sync_count, 0) AS sync_count,
COALESCE(st.error_count, 0) AS error_count,
COALESCE(sla.sla_seconds, 21600) AS freshness_sla_seconds,
COALESCE(sla.warning_ratio, 0.80) AS warning_ratio,
COALESCE(s.metadata->>'signature_status', 'unsigned') AS signature_status,
COALESCE(sig.total_advisories, 0) AS total_advisories,
COALESCE(sig.signed_advisories, 0) AS signed_advisories,
COALESCE(sig.unsigned_advisories, 0) AS unsigned_advisories,
COALESCE(sig.signature_failure_count, 0) AS signature_failure_count
FROM vuln.sources s
LEFT JOIN vuln.source_states st ON st.source_id = s.id
LEFT JOIN vuln.source_freshness_sla sla ON sla.source_id = s.id
LEFT JOIN vuln.advisory_source_signature_projection sig ON sig.source_id = s.id
WHERE s.id = @source_id
)
SELECT
id,
key,
name,
source_type,
url,
priority,
enabled,
last_sync_at,
last_success_at,
last_error,
sync_count,
error_count,
freshness_sla_seconds,
warning_ratio,
CAST(
EXTRACT(EPOCH FROM (
NOW() - COALESCE(last_success_at, last_sync_at, NOW())
)) AS BIGINT) AS freshness_age_seconds,
CASE
WHEN last_success_at IS NULL THEN 'unavailable'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > freshness_sla_seconds THEN 'stale'
WHEN EXTRACT(EPOCH FROM (NOW() - last_success_at)) > (freshness_sla_seconds * warning_ratio) THEN 'warning'
ELSE 'healthy'
END AS freshness_status,
signature_status,
total_advisories,
signed_advisories,
unsigned_advisories,
signature_failure_count
FROM source_projection
""";
return QuerySingleOrDefaultAsync(
SystemTenantId,
sql,
cmd => AddParameter(cmd, "source_id", sourceId),
MapRecord,
cancellationToken);
}
private static AdvisorySourceFreshnessRecord MapRecord(NpgsqlDataReader reader)
{
return new AdvisorySourceFreshnessRecord(
SourceId: reader.GetGuid(0),
SourceKey: reader.GetString(1),
SourceName: reader.GetString(2),
SourceFamily: reader.GetString(3),
SourceUrl: GetNullableString(reader, 4),
Priority: reader.GetInt32(5),
Enabled: reader.GetBoolean(6),
LastSyncAt: GetNullableDateTimeOffset(reader, 7),
LastSuccessAt: GetNullableDateTimeOffset(reader, 8),
LastError: GetNullableString(reader, 9),
SyncCount: reader.GetInt64(10),
ErrorCount: reader.GetInt32(11),
FreshnessSlaSeconds: reader.GetInt32(12),
WarningRatio: reader.GetDecimal(13),
FreshnessAgeSeconds: reader.GetInt64(14),
FreshnessStatus: reader.GetString(15),
SignatureStatus: reader.GetString(16),
TotalAdvisories: reader.GetInt64(17),
SignedAdvisories: reader.GetInt64(18),
UnsignedAdvisories: reader.GetInt64(19),
SignatureFailureCount: reader.GetInt64(20));
}
}

View File

@@ -0,0 +1,38 @@
namespace StellaOps.Concelier.Persistence.Postgres.Repositories;
/// <summary>
/// Read-model repository for advisory source freshness surfaces.
/// </summary>
public interface IAdvisorySourceReadRepository
{
Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
bool includeDisabled = false,
CancellationToken cancellationToken = default);
Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
Guid sourceId,
CancellationToken cancellationToken = default);
}
public sealed record AdvisorySourceFreshnessRecord(
Guid SourceId,
string SourceKey,
string SourceName,
string SourceFamily,
string? SourceUrl,
int Priority,
bool Enabled,
DateTimeOffset? LastSyncAt,
DateTimeOffset? LastSuccessAt,
string? LastError,
long SyncCount,
int ErrorCount,
int FreshnessSlaSeconds,
decimal WarningRatio,
long FreshnessAgeSeconds,
string FreshnessStatus,
string SignatureStatus,
long TotalAdvisories,
long SignedAdvisories,
long UnsignedAdvisories,
long SignatureFailureCount);

View File

@@ -41,6 +41,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();
@@ -90,6 +91,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IAdvisoryRepository, AdvisoryRepository>();
services.AddScoped<IPostgresAdvisoryStore, PostgresAdvisoryStore>();
services.AddScoped<ISourceRepository, SourceRepository>();
services.AddScoped<IAdvisorySourceReadRepository, AdvisorySourceReadRepository>();
services.AddScoped<IAdvisoryAliasRepository, AdvisoryAliasRepository>();
services.AddScoped<IAdvisoryCvssRepository, AdvisoryCvssRepository>();
services.AddScoped<IAdvisoryAffectedRepository, AdvisoryAffectedRepository>();

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| CICD-VAL-SMOKE-001 | DONE | Smoke validation: restore reference summaries from raw payload. |
| TASK-015-011 | DONE | Added enriched SBOM storage table + Postgres repository. |
| TASK-015-007d | DONE | Added license indexes and repository queries for license inventory. |
| BE8-07 | DONE | Added migration `005_add_advisory_source_signature_projection.sql` and advisory-source signature stats projection fields for UI detail diagnostics. |