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:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -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);
}
}

View File

@@ -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 });
}
}