feat: Add Bun language analyzer and related functionality
- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.ConsoleExport;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
|
||||
/// </summary>
|
||||
public static class ConsoleExportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConsoleExportJobs(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/export");
|
||||
|
||||
// Job management
|
||||
group.MapPost("/jobs", CreateJobAsync)
|
||||
.WithName("Export.CreateJob")
|
||||
.WithDescription("Create a new export job");
|
||||
|
||||
group.MapGet("/jobs", ListJobsAsync)
|
||||
.WithName("Export.ListJobs")
|
||||
.WithDescription("List export jobs");
|
||||
|
||||
group.MapGet("/jobs/{jobId}", GetJobAsync)
|
||||
.WithName("Export.GetJob")
|
||||
.WithDescription("Get an export job by ID");
|
||||
|
||||
group.MapPut("/jobs/{jobId}", UpdateJobAsync)
|
||||
.WithName("Export.UpdateJob")
|
||||
.WithDescription("Update an export job");
|
||||
|
||||
group.MapDelete("/jobs/{jobId}", DeleteJobAsync)
|
||||
.WithName("Export.DeleteJob")
|
||||
.WithDescription("Delete an export job");
|
||||
|
||||
// Job execution
|
||||
group.MapPost("/jobs/{jobId}/run", TriggerJobAsync)
|
||||
.WithName("Export.TriggerJob")
|
||||
.WithDescription("Trigger a job execution");
|
||||
|
||||
group.MapGet("/jobs/{jobId}/executions/{executionId}", GetExecutionAsync)
|
||||
.WithName("Export.GetExecution")
|
||||
.WithDescription("Get execution status");
|
||||
|
||||
// Bundle retrieval
|
||||
group.MapGet("/bundles/{bundleId}", GetBundleAsync)
|
||||
.WithName("Export.GetBundle")
|
||||
.WithDescription("Get bundle manifest");
|
||||
|
||||
group.MapGet("/bundles/{bundleId}/download", DownloadBundleAsync)
|
||||
.WithName("Export.DownloadBundle")
|
||||
.WithDescription("Download bundle content");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateJobAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] CreateExportJobRequest request,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant ID required",
|
||||
detail: "X-Tenant-Id header is required",
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var job = await service.CreateJobAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/export/jobs/{job.JobId}", job);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
var code = ex.Message.Contains("schedule", StringComparison.OrdinalIgnoreCase)
|
||||
? ExportErrorCodes.InvalidSchedule
|
||||
: ExportErrorCodes.InvalidDestination;
|
||||
|
||||
return Results.Problem(
|
||||
title: "Validation failed",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = code });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListJobsAsync(
|
||||
[FromQuery] string? tenant_id,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await service.ListJobsAsync(tenant_id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await service.GetJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Job not found",
|
||||
detail: $"Job '{jobId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
||||
}
|
||||
|
||||
return Results.Ok(job);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
[FromBody] UpdateExportJobRequest request,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await service.UpdateJobAsync(jobId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(job);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Job not found",
|
||||
detail: $"Job '{jobId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Validation failed",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.InvalidSchedule });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await service.DeleteJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> TriggerJobAsync(
|
||||
[FromRoute] string jobId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await service.TriggerJobAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/api/v1/export/jobs/{jobId}/executions/{response.ExecutionId}", response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Job not found",
|
||||
detail: $"Job '{jobId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = ExportErrorCodes.JobNotFound });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetExecutionAsync(
|
||||
[FromRoute] string jobId,
|
||||
[FromRoute] string executionId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var execution = await service.GetExecutionAsync(executionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (execution is null || !string.Equals(execution.JobId, jobId, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(execution);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(bundle);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DownloadBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
ConsoleExportJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundle = await service.GetBundleAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var content = await service.GetBundleContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
if (content is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var contentType = bundle.Format switch
|
||||
{
|
||||
ExportFormats.Ndjson => "application/x-ndjson",
|
||||
_ => "application/json"
|
||||
};
|
||||
|
||||
var fileName = $"export-{bundle.BundleId}-{DateTime.UtcNow:yyyy-MM-dd}.json";
|
||||
|
||||
return Results.File(
|
||||
content,
|
||||
contentType,
|
||||
fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AirGap;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for policy pack bundle import per CONTRACT-MIRROR-BUNDLE-003.
|
||||
/// </summary>
|
||||
public static class PolicyPackBundleEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyPackBundles(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/v1/airgap/bundles");
|
||||
|
||||
group.MapPost("", RegisterBundleAsync)
|
||||
.WithName("AirGap.RegisterBundle")
|
||||
.WithDescription("Register a bundle for import");
|
||||
|
||||
group.MapGet("{bundleId}", GetBundleStatusAsync)
|
||||
.WithName("AirGap.GetBundleStatus")
|
||||
.WithDescription("Get bundle import status");
|
||||
|
||||
group.MapGet("", ListBundlesAsync)
|
||||
.WithName("AirGap.ListBundles")
|
||||
.WithDescription("List imported bundles");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> RegisterBundleAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromBody] RegisterBundleRequest request,
|
||||
PolicyPackBundleImportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Tenant ID required",
|
||||
detail: "X-Tenant-Id header is required",
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "TENANT_REQUIRED" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await service.RegisterBundleAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/api/v1/airgap/bundles/{response.ImportId}", response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: ex.Message,
|
||||
statusCode: 400,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "INVALID_REQUEST" });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetBundleStatusAsync(
|
||||
[FromRoute] string bundleId,
|
||||
PolicyPackBundleImportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await service.GetBundleStatusAsync(bundleId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Bundle not found",
|
||||
detail: $"Bundle '{bundleId}' not found",
|
||||
statusCode: 404,
|
||||
extensions: new Dictionary<string, object?> { ["code"] = "BUNDLE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
return Results.Ok(status);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListBundlesAsync(
|
||||
[FromQuery] string? tenant_id,
|
||||
PolicyPackBundleImportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundles = await service.ListBundlesAsync(tenant_id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new { items = bundles, total = bundles.Count });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user