Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user