Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,251 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using StellaOps.ReachGraph.WebService.Models;
using StellaOps.ReachGraph.WebService.Services;
namespace StellaOps.ReachGraph.WebService.Controllers;
/// <summary>
/// ReachGraph Store API for storing, querying, and verifying reachability subgraphs.
/// </summary>
[ApiController]
[Route("v1/reachgraphs")]
[Produces("application/json")]
public class ReachGraphController : ControllerBase
{
private readonly IReachGraphStoreService _storeService;
private readonly IReachGraphSliceService _sliceService;
private readonly IReachGraphReplayService _replayService;
private readonly ILogger<ReachGraphController> _logger;
public ReachGraphController(
IReachGraphStoreService storeService,
IReachGraphSliceService sliceService,
IReachGraphReplayService replayService,
ILogger<ReachGraphController> logger)
{
_storeService = storeService;
_sliceService = sliceService;
_replayService = replayService;
_logger = logger;
}
/// <summary>
/// Upsert a reachability graph. Idempotent by digest.
/// </summary>
/// <param name="request">The graph to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Store result with digest and creation status.</returns>
[HttpPost]
[EnableRateLimiting("reachgraph-write")]
[ProducesResponseType(typeof(UpsertReachGraphResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(UpsertReachGraphResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> UpsertAsync(
[FromBody] UpsertReachGraphRequest request,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var result = await _storeService.UpsertAsync(request.Graph, tenantId, cancellationToken);
var response = new UpsertReachGraphResponse
{
Digest = result.Digest,
Created = result.Created,
ArtifactDigest = result.ArtifactDigest,
NodeCount = result.NodeCount,
EdgeCount = result.EdgeCount,
StoredAt = result.StoredAt
};
return result.Created
? CreatedAtAction(nameof(GetByDigestAsync), new { digest = result.Digest }, response)
: Ok(response);
}
/// <summary>
/// Retrieve a full subgraph by digest.
/// </summary>
/// <param name="digest">The BLAKE3 digest of the graph.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The full reachability graph.</returns>
[HttpGet("{digest}")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(Schema.ReachGraphMinimal), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ResponseCache(Duration = 86400, VaryByHeader = "Authorization")]
public async Task<IActionResult> GetByDigestAsync(
[FromRoute] string digest,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var graph = await _storeService.GetByDigestAsync(digest, tenantId, cancellationToken);
if (graph is null)
{
return NotFound(new { error = "Subgraph not found", digest });
}
// Set ETag for caching
Response.Headers.ETag = $"\"{digest}\"";
return Ok(graph);
}
/// <summary>
/// Query a slice of the subgraph by package PURL.
/// </summary>
/// <param name="digest">The parent graph digest.</param>
/// <param name="q">Package PURL pattern (supports wildcards).</param>
/// <param name="depth">Max hops from package node (default: 3).</param>
/// <param name="direction">upstream, downstream, or both (default: both).</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpGet("{digest}/slice")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(SliceQueryResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSliceAsync(
[FromRoute] string digest,
[FromQuery] string? q = null,
[FromQuery] string? cve = null,
[FromQuery] string? entrypoint = null,
[FromQuery] string? file = null,
[FromQuery] int depth = 3,
[FromQuery] string direction = "both",
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
// Determine slice type based on query parameters
SliceQueryResponse? result;
if (!string.IsNullOrEmpty(cve))
{
result = await _sliceService.SliceByCveAsync(digest, cve, tenantId, depth, cancellationToken);
}
else if (!string.IsNullOrEmpty(q))
{
result = await _sliceService.SliceByPackageAsync(digest, q, tenantId, depth, direction, cancellationToken);
}
else if (!string.IsNullOrEmpty(entrypoint))
{
result = await _sliceService.SliceByEntrypointAsync(digest, entrypoint, tenantId, depth, cancellationToken);
}
else if (!string.IsNullOrEmpty(file))
{
result = await _sliceService.SliceByFileAsync(digest, file, tenantId, depth, cancellationToken);
}
else
{
return BadRequest(new { error = "At least one query parameter (q, cve, entrypoint, file) is required" });
}
if (result is null)
{
return NotFound(new { error = "Subgraph not found", digest });
}
return Ok(result);
}
/// <summary>
/// Verify determinism by replaying graph computation from inputs.
/// </summary>
/// <param name="request">Replay verification request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpPost("replay")]
[EnableRateLimiting("reachgraph-write")]
[ProducesResponseType(typeof(ReplayResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ReplayAsync(
[FromBody] ReplayRequest request,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId();
var result = await _replayService.ReplayAsync(request, tenantId, cancellationToken);
return Ok(result);
}
/// <summary>
/// List subgraphs for a specific artifact.
/// </summary>
/// <param name="artifactDigest">The artifact digest to search for.</param>
/// <param name="limit">Maximum results (default: 50).</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpGet("by-artifact/{artifactDigest}")]
[EnableRateLimiting("reachgraph-read")]
[ProducesResponseType(typeof(ListByArtifactResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> ListByArtifactAsync(
[FromRoute] string artifactDigest,
[FromQuery] int limit = 50,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
var summaries = await _storeService.ListByArtifactAsync(artifactDigest, tenantId, limit, cancellationToken);
var response = new ListByArtifactResponse
{
Subgraphs = summaries.Select(s => new ReachGraphSummaryDto
{
Digest = s.Digest,
ArtifactDigest = s.ArtifactDigest,
NodeCount = s.NodeCount,
EdgeCount = s.EdgeCount,
BlobSizeBytes = s.BlobSizeBytes,
CreatedAt = s.CreatedAt
}).ToList(),
TotalCount = summaries.Count
};
return Ok(response);
}
/// <summary>
/// Delete a subgraph (admin only).
/// </summary>
/// <param name="digest">The digest of the graph to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
[HttpDelete("{digest}")]
[EnableRateLimiting("reachgraph-write")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> DeleteAsync(
[FromRoute] string digest,
CancellationToken cancellationToken)
{
// TODO: Add admin authorization check
var tenantId = GetTenantId();
var deleted = await _storeService.DeleteAsync(digest, tenantId, cancellationToken);
if (!deleted)
{
return NotFound(new { error = "Subgraph not found", digest });
}
return NoContent();
}
private string GetTenantId()
{
// Extract tenant from header or claims
if (Request.Headers.TryGetValue("X-Tenant-ID", out var tenantHeader))
{
return tenantHeader.ToString();
}
// Fallback to claim if using JWT
var tenantClaim = User.FindFirst("tenant")?.Value;
if (!string.IsNullOrEmpty(tenantClaim))
{
return tenantClaim;
}
throw new InvalidOperationException("Tenant ID not found in request");
}
}