partly or unimplemented features - now implemented
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user