Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
@@ -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.';
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>();
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user