partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -0,0 +1,417 @@
// <copyright file="LineageStreamController.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using System.Collections.Immutable;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StellaOps.SbomService.Lineage.Services;
namespace StellaOps.SbomService.Controllers;
/// <summary>
/// API endpoints for real-time lineage streaming and optimized graph queries.
/// </summary>
[ApiController]
[Route("api/v1/lineage")]
[Authorize(Policy = "sbom:read")]
public sealed class LineageStreamController : ControllerBase
{
private readonly ILineageStreamService _streamService;
private readonly ILineageGraphOptimizer _optimizer;
private readonly ILineageGraphService _graphService;
private readonly ILogger<LineageStreamController> _logger;
public LineageStreamController(
ILineageStreamService streamService,
ILineageGraphOptimizer optimizer,
ILineageGraphService graphService,
ILogger<LineageStreamController> logger)
{
_streamService = streamService;
_optimizer = optimizer;
_graphService = graphService;
_logger = logger;
}
/// <summary>
/// Subscribe to real-time lineage updates via Server-Sent Events.
/// </summary>
/// <param name="watchDigests">Optional comma-separated list of digests to watch.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>SSE stream of lineage update events.</returns>
[HttpGet("stream")]
[Produces("text/event-stream")]
public async Task StreamUpdates(
[FromQuery] string? watchDigests = null,
CancellationToken ct = default)
{
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
{
Response.StatusCode = 401;
return;
}
Response.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
Response.Headers.Connection = "keep-alive";
var digestList = string.IsNullOrWhiteSpace(watchDigests)
? null
: watchDigests.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
try
{
await foreach (var evt in _streamService.SubscribeAsync(tenantId, digestList, ct))
{
var eventData = System.Text.Json.JsonSerializer.Serialize(new
{
id = evt.EventId,
type = evt.EventType.ToString(),
digest = evt.AffectedDigest,
parentDigest = evt.ParentDigest,
timestamp = evt.Timestamp,
data = evt.Data
});
await Response.WriteAsync($"event: lineage-update\n", ct);
await Response.WriteAsync($"data: {eventData}\n\n", ct);
await Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
_logger.LogDebug("SSE stream cancelled for tenant {TenantId}", tenantId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in SSE stream for tenant {TenantId}", tenantId);
}
}
/// <summary>
/// Get an optimized lineage graph with pagination and depth pruning.
/// </summary>
/// <param name="artifactDigest">Center artifact digest.</param>
/// <param name="maxDepth">Maximum traversal depth (default: 3).</param>
/// <param name="pageSize">Nodes per page (default: 50).</param>
/// <param name="pageNumber">Page number (0-indexed, default: 0).</param>
/// <param name="searchTerm">Optional search filter for node names.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Optimized graph with boundary information.</returns>
[HttpGet("{artifactDigest}/optimized")]
[ProducesResponseType<OptimizedLineageGraphDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOptimizedLineage(
string artifactDigest,
[FromQuery] int maxDepth = 3,
[FromQuery] int pageSize = 50,
[FromQuery] int pageNumber = 0,
[FromQuery] string? searchTerm = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
if (maxDepth < 1 || maxDepth > 20)
return BadRequest(new { error = "INVALID_MAX_DEPTH", message = "maxDepth must be between 1 and 20" });
if (pageSize < 1 || pageSize > 200)
return BadRequest(new { error = "INVALID_PAGE_SIZE", message = "pageSize must be between 1 and 200" });
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
return Unauthorized();
try
{
// Get full graph first
var options = new LineageQueryOptions(
MaxDepth: 50, // Get all to allow optimization
IncludeVerdicts: true,
IncludeBadges: true
);
var fullResult = await _graphService.GetLineageAsync(artifactDigest, tenantId, options, ct);
if (fullResult.Graph.Nodes.Count == 0)
return NotFound(new { error = "LINEAGE_NOT_FOUND", artifactDigest });
// Convert to optimizer format
var allNodes = fullResult.Graph.Nodes
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
.ToImmutableArray();
var allEdges = fullResult.Graph.Edges
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
.ToImmutableArray();
var request = new LineageOptimizationRequest
{
TenantId = tenantId,
CenterDigest = artifactDigest,
AllNodes = allNodes,
AllEdges = allEdges,
MaxDepth = maxDepth,
PageSize = pageSize,
PageNumber = pageNumber,
SearchTerm = searchTerm
};
var optimized = _optimizer.Optimize(request);
return Ok(new OptimizedLineageGraphDto
{
CenterDigest = artifactDigest,
Nodes = optimized.Nodes.Select(n => new LineageNodeDto
{
Digest = n.Digest,
Name = n.Name,
Version = n.Version,
ComponentCount = n.ComponentCount
}).ToList(),
Edges = optimized.Edges.Select(e => new LineageEdgeDto
{
FromDigest = e.FromDigest,
ToDigest = e.ToDigest
}).ToList(),
BoundaryNodes = optimized.BoundaryNodes.Select(b => new BoundaryNodeDto
{
Digest = b.Digest,
HiddenChildrenCount = b.HiddenChildrenCount,
HiddenParentsCount = b.HiddenParentsCount
}).ToList(),
TotalNodes = optimized.TotalNodes,
HasMorePages = optimized.HasMorePages,
PageNumber = pageNumber,
PageSize = pageSize
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get optimized lineage for {Digest}", artifactDigest);
return StatusCode(500, new { error = "INTERNAL_ERROR" });
}
}
/// <summary>
/// Get lineage graph traversed level by level (for progressive rendering).
/// </summary>
/// <param name="artifactDigest">Starting artifact digest.</param>
/// <param name="direction">Traversal direction: Children, Parents, or Center.</param>
/// <param name="maxDepth">Maximum depth to traverse.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>SSE stream of levels.</returns>
[HttpGet("{artifactDigest}/levels")]
[Produces("text/event-stream")]
public async Task StreamLevels(
string artifactDigest,
[FromQuery] string direction = "Children",
[FromQuery] int maxDepth = 5,
CancellationToken ct = default)
{
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
{
Response.StatusCode = 401;
return;
}
if (!Enum.TryParse<TraversalDirection>(direction, ignoreCase: true, out var traversalDir))
{
Response.StatusCode = 400;
await Response.WriteAsync("Invalid direction. Use: Children, Parents, or Center");
return;
}
Response.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
Response.Headers.Connection = "keep-alive";
try
{
// Get full graph
var options = new LineageQueryOptions(MaxDepth: 50, IncludeVerdicts: false, IncludeBadges: false);
var fullResult = await _graphService.GetLineageAsync(artifactDigest, tenantId, options, ct);
if (fullResult.Graph.Nodes.Count == 0)
{
await Response.WriteAsync("event: error\n");
await Response.WriteAsync("data: {\"error\":\"LINEAGE_NOT_FOUND\"}\n\n");
return;
}
var allNodes = fullResult.Graph.Nodes
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
.ToImmutableArray();
var allEdges = fullResult.Graph.Edges
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
.ToImmutableArray();
await foreach (var level in _optimizer.TraverseLevelsAsync(
artifactDigest, allNodes, allEdges, traversalDir, maxDepth, ct))
{
var levelData = System.Text.Json.JsonSerializer.Serialize(new
{
depth = level.Depth,
nodes = level.Nodes.Select(n => new
{
digest = n.Digest,
name = n.Name,
version = n.Version,
componentCount = n.ComponentCount
}),
isComplete = level.IsComplete
});
await Response.WriteAsync($"event: level\n", ct);
await Response.WriteAsync($"data: {levelData}\n\n", ct);
await Response.Body.FlushAsync(ct);
}
await Response.WriteAsync("event: complete\n");
await Response.WriteAsync("data: {\"status\":\"done\"}\n\n");
await Response.Body.FlushAsync(ct);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Level stream cancelled for {Digest}", artifactDigest);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error streaming levels for {Digest}", artifactDigest);
}
}
/// <summary>
/// Get cached metadata about a lineage graph.
/// </summary>
/// <param name="artifactDigest">Center artifact digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Graph metadata including total counts and max depth.</returns>
[HttpGet("{artifactDigest}/metadata")]
[ProducesResponseType<LineageGraphMetadataDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMetadata(
string artifactDigest,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
return Unauthorized();
try
{
// Get full graph to compute metadata
var options = new LineageQueryOptions(MaxDepth: 50, IncludeVerdicts: false, IncludeBadges: false);
var fullResult = await _graphService.GetLineageAsync(artifactDigest, tenantId, options, ct);
if (fullResult.Graph.Nodes.Count == 0)
return NotFound(new { error = "LINEAGE_NOT_FOUND", artifactDigest });
var allNodes = fullResult.Graph.Nodes
.Select(n => new LineageNode(n.Digest, n.Name, n.Version, n.ComponentCount))
.ToImmutableArray();
var allEdges = fullResult.Graph.Edges
.Select(e => new LineageEdge(e.FromDigest, e.ToDigest))
.ToImmutableArray();
var metadata = await _optimizer.GetOrComputeMetadataAsync(
tenantId, artifactDigest, allNodes, allEdges, ct);
return Ok(new LineageGraphMetadataDto
{
CenterDigest = artifactDigest,
TotalNodes = metadata.TotalNodes,
TotalEdges = metadata.TotalEdges,
MaxDepth = metadata.MaxDepth,
ComputedAt = metadata.ComputedAt
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get metadata for {Digest}", artifactDigest);
return StatusCode(500, new { error = "INTERNAL_ERROR" });
}
}
/// <summary>
/// Invalidate cached metadata for an artifact.
/// </summary>
/// <param name="artifactDigest">Center artifact digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Confirmation of cache invalidation.</returns>
[HttpDelete("{artifactDigest}/cache")]
[Authorize(Policy = "lineage:admin")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> InvalidateCache(
string artifactDigest,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(artifactDigest))
return BadRequest(new { error = "ARTIFACT_DIGEST_REQUIRED" });
var tenantId = GetTenantId();
if (tenantId == Guid.Empty)
return Unauthorized();
await _optimizer.InvalidateCacheAsync(tenantId, artifactDigest, ct);
return NoContent();
}
private Guid GetTenantId()
{
// TODO: Extract from claims or headers
return Guid.Parse("00000000-0000-0000-0000-000000000001");
}
}
// DTOs for API responses
public sealed record OptimizedLineageGraphDto
{
public required string CenterDigest { get; init; }
public required List<LineageNodeDto> Nodes { get; init; }
public required List<LineageEdgeDto> Edges { get; init; }
public required List<BoundaryNodeDto> BoundaryNodes { get; init; }
public required int TotalNodes { get; init; }
public required bool HasMorePages { get; init; }
public required int PageNumber { get; init; }
public required int PageSize { get; init; }
}
public sealed record LineageNodeDto
{
public required string Digest { get; init; }
public required string Name { get; init; }
public required string Version { get; init; }
public required int ComponentCount { get; init; }
}
public sealed record LineageEdgeDto
{
public required string FromDigest { get; init; }
public required string ToDigest { get; init; }
}
public sealed record BoundaryNodeDto
{
public required string Digest { get; init; }
public required int HiddenChildrenCount { get; init; }
public required int HiddenParentsCount { get; init; }
}
public sealed record LineageGraphMetadataDto
{
public required string CenterDigest { get; init; }
public required int TotalNodes { get; init; }
public required int TotalEdges { get; init; }
public required int MaxDepth { get; init; }
public required DateTimeOffset ComputedAt { get; init; }
}