UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization

Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
This commit is contained in:
master
2025-12-29 19:12:38 +02:00
parent 41552d26ec
commit a4badc275e
286 changed files with 50918 additions and 992 deletions

View File

@@ -0,0 +1,168 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StellaOps.SbomService.Lineage.Domain;
using StellaOps.SbomService.Lineage.Services;
namespace StellaOps.SbomService.Controllers;
/// <summary>
/// API endpoints for SBOM lineage graph operations.
/// </summary>
[ApiController]
[Route("api/v1/lineage")]
[Authorize(Policy = "sbom:read")]
public sealed class LineageController : ControllerBase
{
private readonly ILineageGraphService _lineageService;
private readonly ILogger<LineageController> _logger;
public LineageController(
ILineageGraphService lineageService,
ILogger<LineageController> logger)
{
_lineageService = lineageService;
_logger = logger;
}
/// <summary>
/// Get the lineage graph for an artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest (sha256:...).</param>
/// <param name="maxDepth">Maximum graph traversal depth (default: 10).</param>
/// <param name="includeVerdicts">Include VEX verdict enrichment (default: true).</param>
/// <param name="includeBadges">Include badge metadata (default: true).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Lineage graph with nodes and edges.</returns>
[HttpGet("{artifactDigest}")]
[ProducesResponseType<LineageGraphResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetLineage(
string artifactDigest,
[FromQuery] int maxDepth = 10,
[FromQuery] bool includeVerdicts = true,
[FromQuery] bool includeBadges = true,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
if (maxDepth < 1 || maxDepth > 50)
return BadRequest(new { error = "INVALID_MAX_DEPTH", message = "maxDepth must be between 1 and 50" });
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
return Unauthorized();
var options = new LineageQueryOptions(
MaxDepth: maxDepth,
IncludeVerdicts: includeVerdicts,
IncludeBadges: includeBadges
);
try
{
var result = await _lineageService.GetLineageAsync(artifactDigest, tenantId, options, ct);
if (result.Graph.Nodes.Count == 0)
return NotFound(new { error = "LINEAGE_NOT_FOUND", artifactDigest });
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get lineage for {Digest}", artifactDigest);
return StatusCode(500, new { error = "INTERNAL_ERROR" });
}
}
/// <summary>
/// Get differences between two artifact versions.
/// </summary>
/// <param name="from">Source artifact digest.</param>
/// <param name="to">Target artifact digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Diff containing SBOM, VEX, and reachability changes.</returns>
[HttpGet("diff")]
[ProducesResponseType<LineageDiffResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetDiff(
[FromQuery] string from,
[FromQuery] string to,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
return BadRequest(new { error = "FROM_AND_TO_REQUIRED" });
if (from.Equals(to, StringComparison.Ordinal))
return BadRequest(new { error = "IDENTICAL_DIGESTS", message = "from and to must be different" });
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
return Unauthorized();
try
{
var result = await _lineageService.GetDiffAsync(from, to, tenantId, ct);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to compute diff {From} -> {To}", from, to);
return StatusCode(500, new { error = "INTERNAL_ERROR" });
}
}
/// <summary>
/// Export an evidence pack for an artifact.
/// </summary>
/// <param name="request">Export request parameters.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Download URL and metadata for the evidence pack.</returns>
[HttpPost("export")]
[Authorize(Policy = "lineage:export")]
[ProducesResponseType<ExportResult>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)]
public async Task<IActionResult> Export(
[FromBody] ExportRequest request,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
if (request.MaxDepth < 1 || request.MaxDepth > 10)
return BadRequest(new { error = "INVALID_MAX_DEPTH", message = "maxDepth must be between 1 and 10" });
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
return Unauthorized();
try
{
var result = await _lineageService.ExportEvidencePackAsync(request, tenantId, ct);
// Check size limit (50MB)
const long maxSizeBytes = 50 * 1024 * 1024;
if (result.SizeBytes > maxSizeBytes)
return StatusCode(413, new { error = "EXPORT_TOO_LARGE", maxSizeBytes, actualSize = result.SizeBytes });
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export evidence pack for {Digest}", request.ArtifactDigest);
return StatusCode(500, new { error = "INTERNAL_ERROR" });
}
}
/// <summary>
/// Get tenant ID from HTTP context (placeholder).
/// </summary>
private Guid GetTenantId()
{
// TODO: Extract from claims or headers
// For now, return a placeholder
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
}

View File

@@ -0,0 +1,50 @@
// -----------------------------------------------------------------------------
// LineageExportModels.cs
// Sprint: SPRINT_20251229_005_001_BE_sbom_lineage_api (LIN-010)
// Task: Evidence pack export models
// -----------------------------------------------------------------------------
namespace StellaOps.SbomService.Models;
/// <summary>
/// Request to export an evidence pack for a lineage comparison.
/// </summary>
internal sealed record LineageExportRequest
{
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public required string TenantId { get; init; }
public bool IncludeSbomDiff { get; init; } = true;
public bool IncludeVexDeltas { get; init; } = true;
public bool IncludeReachabilityDiff { get; init; } = false;
public bool IncludeAttestations { get; init; } = true;
public bool SignWithKeyless { get; init; } = false;
}
/// <summary>
/// Response containing evidence pack download URL.
/// </summary>
internal sealed record LineageExportResponse
{
public required string ExportId { get; init; }
public required string DownloadUrl { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required long SizeBytes { get; init; }
public string? SignatureDigest { get; init; }
}
/// <summary>
/// Evidence pack structure (NDJSON format).
/// </summary>
internal sealed record EvidencePack
{
public required string Version { get; init; }
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required string ReplayHash { get; init; }
public SbomDiffSummary? SbomDiff { get; init; }
public IReadOnlyList<VexDeltaSummary>? VexDeltas { get; init; }
public object? ReachabilityDiff { get; init; }
public IReadOnlyList<string>? AttestationDigests { get; init; }
}

View File

@@ -91,6 +91,9 @@ builder.Services.AddSingleton<ISbomLineageGraphService, SbomLineageGraphService>
// LIN-BE-028: Lineage compare service
builder.Services.AddSingleton<ILineageCompareService, LineageCompareService>();
// LIN-010: Lineage export service for evidence packs
builder.Services.AddSingleton<ILineageExportService, LineageExportService>();
// LIN-BE-023: Replay hash service
builder.Services.AddSingleton<IReplayHashService, ReplayHashService>();
@@ -824,6 +827,41 @@ app.MapGet("/api/v1/lineage/{artifactDigest}/parents", async Task<IResult> (
return Results.Ok(new { childDigest = artifactDigest.Trim(), parents });
});
app.MapPost("/api/v1/lineage/export", async Task<IResult> (
[FromServices] ILineageExportService exportService,
[FromBody] LineageExportRequest request,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.FromDigest) || string.IsNullOrWhiteSpace(request.ToDigest))
{
return Results.BadRequest(new { error = "fromDigest and toDigest are required" });
}
if (string.IsNullOrWhiteSpace(request.TenantId))
{
return Results.BadRequest(new { error = "tenantId is required" });
}
using var activity = SbomTracing.Source.StartActivity("lineage.export", ActivityKind.Server);
activity?.SetTag("tenant", request.TenantId);
activity?.SetTag("from_digest", request.FromDigest);
activity?.SetTag("to_digest", request.ToDigest);
var result = await exportService.ExportAsync(request, cancellationToken);
if (result is null)
{
return Results.StatusCode(500);
}
if (result.SizeBytes > 50 * 1024 * 1024)
{
return Results.StatusCode(413); // Payload Too Large
}
return Results.Ok(result);
});
// -----------------------------------------------------------------------------
// Lineage Compare API (LIN-BE-028)
// Sprint: SPRINT_20251228_007_BE_sbom_lineage_graph_ii

View File

@@ -0,0 +1,25 @@
// -----------------------------------------------------------------------------
// ILineageExportService.cs
// Sprint: SPRINT_20251229_005_001_BE_sbom_lineage_api (LIN-010)
// Task: Evidence pack export service interface
// -----------------------------------------------------------------------------
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Service for exporting lineage evidence packs.
/// </summary>
internal interface ILineageExportService
{
/// <summary>
/// Generate and export an evidence pack for a lineage comparison.
/// </summary>
/// <param name="request">Export request with digest range and options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Export response with download URL and metadata.</returns>
Task<LineageExportResponse?> ExportAsync(
LineageExportRequest request,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,131 @@
// -----------------------------------------------------------------------------
// LineageExportService.cs
// Sprint: SPRINT_20251229_005_001_BE_sbom_lineage_api (LIN-010)
// Task: Evidence pack export service implementation
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.SbomService.Models;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Implementation of <see cref="ILineageExportService"/>.
/// Generates signed evidence packs for lineage comparisons.
/// </summary>
internal sealed class LineageExportService : ILineageExportService
{
private readonly ISbomLineageGraphService _lineageService;
private readonly IReplayHashService? _replayHashService;
private readonly ILogger<LineageExportService> _logger;
private const long MaxExportSizeBytes = 50 * 1024 * 1024; // 50MB limit
public LineageExportService(
ISbomLineageGraphService lineageService,
ILogger<LineageExportService> logger,
IReplayHashService? replayHashService = null)
{
_lineageService = lineageService;
_logger = logger;
_replayHashService = replayHashService;
}
public async Task<LineageExportResponse?> ExportAsync(
LineageExportRequest request,
CancellationToken ct = default)
{
// Get lineage diff
var diff = await _lineageService.GetLineageDiffAsync(
request.FromDigest,
request.ToDigest,
request.TenantId,
ct).ConfigureAwait(false);
if (diff is null)
{
_logger.LogWarning(
"Export failed: lineage diff not found for {From} -> {To}",
request.FromDigest,
request.ToDigest);
return null;
}
// Build evidence pack
var evidencePack = new EvidencePack
{
Version = "1.0",
FromDigest = request.FromDigest,
ToDigest = request.ToDigest,
GeneratedAt = DateTimeOffset.UtcNow,
ReplayHash = diff.ReplayHash ?? ComputeFallbackHash(request.FromDigest, request.ToDigest),
SbomDiff = request.IncludeSbomDiff ? diff.SbomDiff?.Summary : null,
VexDeltas = request.IncludeVexDeltas ? diff.VexDiff : null,
ReachabilityDiff = request.IncludeReachabilityDiff ? diff.ReachabilityDiff : null,
AttestationDigests = request.IncludeAttestations ? Array.Empty<string>() : null
};
// Serialize to JSON
var json = JsonSerializer.Serialize(evidencePack, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var sizeBytes = Encoding.UTF8.GetByteCount(json);
// Check size limit
if (sizeBytes > MaxExportSizeBytes)
{
_logger.LogWarning(
"Export size {Size} exceeds limit {Limit} for {From} -> {To}",
sizeBytes,
MaxExportSizeBytes,
request.FromDigest,
request.ToDigest);
return null;
}
// Generate export ID and URL
var exportId = Guid.NewGuid().ToString("N");
var downloadUrl = $"/api/v1/lineage/export/{exportId}/download";
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
// TODO: Store evidence pack for retrieval (file system, blob storage, etc.)
// For now, return metadata only
_logger.LogInformation(
"Evidence pack exported: {ExportId}, size={Size}, from={From}, to={To}",
exportId,
sizeBytes,
request.FromDigest,
request.ToDigest);
return new LineageExportResponse
{
ExportId = exportId,
DownloadUrl = downloadUrl,
ExpiresAt = expiresAt,
SizeBytes = sizeBytes,
SignatureDigest = request.SignWithKeyless
? ComputeSignatureDigest(json)
: null
};
}
private static string ComputeFallbackHash(string fromDigest, string toDigest)
{
var input = $"{fromDigest}:{toDigest}:{DateTimeOffset.UtcNow:O}";
var bytes = Encoding.UTF8.GetBytes(input);
var hashBytes = SHA256.HashData(bytes);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
private static string ComputeSignatureDigest(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hashBytes = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hashBytes).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,257 @@
// -----------------------------------------------------------------------------
// ValkeyLineageCompareCache.cs
// Sprint: SPRINT_20251229_005_001_BE_sbom_lineage_api (LIN-012)
// Task: Implement Valkey compare cache
// Description: Valkey/Redis implementation of lineage compare cache with 10-minute TTL.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Valkey/Redis implementation of <see cref="ILineageCompareCache"/> using IDistributedCache.
/// Provides distributed caching for lineage compare results with TTL-based expiration.
/// </summary>
internal sealed class ValkeyLineageCompareCache : ILineageCompareCache
{
private static readonly ActivitySource ActivitySource = new("StellaOps.SbomService.CompareCache");
private readonly IDistributedCache _cache;
private readonly ILogger<ValkeyLineageCompareCache> _logger;
private readonly CompareCacheOptions _options;
private long _cacheHits;
private long _cacheMisses;
private long _invalidations;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public ValkeyLineageCompareCache(
IDistributedCache cache,
ILogger<ValkeyLineageCompareCache> logger,
IOptions<CompareCacheOptions> options)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new CompareCacheOptions();
_logger.LogInformation(
"Valkey compare cache initialized with TTL {TtlMinutes} minutes",
_options.DefaultTtlMinutes);
}
/// <inheritdoc />
public async Task<LineageCompareResponse?> GetAsync(
string fromDigest,
string toDigest,
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return null;
}
var key = BuildCacheKey(fromDigest, toDigest, tenantId);
using var activity = ActivitySource.StartActivity("CompareCache.Get");
activity?.SetTag("cache_key", key);
activity?.SetTag("backend", "valkey");
try
{
var cached = await _cache.GetStringAsync(key, ct).ConfigureAwait(false);
if (cached != null)
{
Interlocked.Increment(ref _cacheHits);
activity?.SetTag("cache_hit", true);
_logger.LogDebug(
"Cache hit for compare {FromDigest} -> {ToDigest}",
TruncateDigest(fromDigest), TruncateDigest(toDigest));
return JsonSerializer.Deserialize<LineageCompareResponse>(cached, JsonOptions);
}
Interlocked.Increment(ref _cacheMisses);
activity?.SetTag("cache_hit", false);
_logger.LogDebug(
"Cache miss for compare {FromDigest} -> {ToDigest}",
TruncateDigest(fromDigest), TruncateDigest(toDigest));
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get compare result from cache");
Interlocked.Increment(ref _cacheMisses);
return null;
}
}
/// <inheritdoc />
public async Task SetAsync(
string fromDigest,
string toDigest,
string tenantId,
LineageCompareResponse result,
TimeSpan? ttl = null,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return;
}
var key = BuildCacheKey(fromDigest, toDigest, tenantId);
var effectiveTtl = ttl ?? TimeSpan.FromMinutes(_options.DefaultTtlMinutes);
using var activity = ActivitySource.StartActivity("CompareCache.Set");
activity?.SetTag("cache_key", key);
activity?.SetTag("ttl_seconds", effectiveTtl.TotalSeconds);
activity?.SetTag("backend", "valkey");
try
{
var json = JsonSerializer.Serialize(result, JsonOptions);
var cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = effectiveTtl
};
await _cache.SetStringAsync(key, json, cacheOptions, ct).ConfigureAwait(false);
_logger.LogDebug(
"Cached compare result for {FromDigest} -> {ToDigest} with TTL {Ttl}",
TruncateDigest(fromDigest), TruncateDigest(toDigest), effectiveTtl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set compare result in cache");
}
}
/// <inheritdoc />
public async Task<int> InvalidateForArtifactAsync(
string artifactDigest,
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return 0;
}
using var activity = ActivitySource.StartActivity("CompareCache.InvalidateArtifact");
activity?.SetTag("artifact_digest", TruncateDigest(artifactDigest));
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("backend", "valkey");
// Note: Full pattern-based invalidation requires direct Redis/Valkey client access
// with SCAN command. IDistributedCache doesn't support pattern-based deletion.
// For now, we rely on TTL expiration. This can be enhanced when using
// StackExchange.Redis directly.
_logger.LogDebug(
"Artifact invalidation requested for {ArtifactDigest} (relying on TTL with IDistributedCache)",
TruncateDigest(artifactDigest));
Interlocked.Increment(ref _invalidations);
// Return 0 to indicate we're relying on TTL expiration
// In a full implementation with direct Redis access, we would:
// 1. SCAN for keys matching pattern: lineage:compare:{tenantId}:*{artifactDigest}*
// 2. DEL each matching key
// 3. Return count of deleted keys
return 0;
}
/// <inheritdoc />
public async Task<int> InvalidateForTenantAsync(
string tenantId,
CancellationToken ct = default)
{
if (!_options.Enabled)
{
return 0;
}
using var activity = ActivitySource.StartActivity("CompareCache.InvalidateTenant");
activity?.SetTag("tenant_id", tenantId);
activity?.SetTag("backend", "valkey");
// Same limitation as InvalidateForArtifactAsync - pattern deletion requires
// direct Redis client access. Relying on TTL expiration.
_logger.LogDebug(
"Tenant invalidation requested for {TenantId} (relying on TTL with IDistributedCache)",
tenantId);
Interlocked.Increment(ref _invalidations);
return 0;
}
/// <inheritdoc />
public CompareCacheStats GetStats()
{
return new CompareCacheStats
{
TotalEntries = -1, // Unknown with IDistributedCache (would need direct Redis access)
CacheHits = Interlocked.Read(ref _cacheHits),
CacheMisses = Interlocked.Read(ref _cacheMisses),
Invalidations = Interlocked.Read(ref _invalidations),
EstimatedMemoryBytes = -1 // Unknown with IDistributedCache
};
}
private static string BuildCacheKey(string fromDigest, string toDigest, string tenantId)
{
// Normalize: always use smaller digest first for bidirectional lookup
var (first, second) = string.CompareOrdinal(fromDigest, toDigest) <= 0
? (fromDigest, toDigest)
: (toDigest, fromDigest);
// Shorten digests for key efficiency
var firstShort = GetDigestShort(first);
var secondShort = GetDigestShort(second);
// Format: lineage:compare:{tenantId}:{digest1_short}:{digest2_short}
return $"lineage:compare:{tenantId}:{firstShort}:{secondShort}";
}
private static string GetDigestShort(string digest)
{
// Extract first 16 chars after algorithm prefix for shorter key
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 16)
{
return digest[(colonIndex + 1)..(colonIndex + 17)];
}
return digest.Length > 16 ? digest[..16] : digest;
}
private static string TruncateDigest(string digest)
{
if (string.IsNullOrEmpty(digest)) return digest;
var colonIndex = digest.IndexOf(':');
if (colonIndex >= 0 && digest.Length > colonIndex + 12)
{
return $"{digest[..(colonIndex + 13)]}...";
}
return digest.Length > 16 ? $"{digest[..16]}..." : digest;
}
}

View File

@@ -14,6 +14,8 @@
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<!-- LIN-BE-028: Lineage compare service needs VEX delta repository -->
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
<!-- SPRINT_20251229_005_001_BE: Lineage API -->
<ProjectReference Include="../__Libraries/StellaOps.SbomService.Lineage/StellaOps.SbomService.Lineage.csproj" />
</ItemGroup>
<ItemGroup>