feat: Implement MongoDB orchestrator storage with registry, commands, and heartbeats
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added NullAdvisoryObservationEventTransport for handling advisory observation events. - Created IOrchestratorRegistryStore interface for orchestrator registry operations. - Implemented MongoOrchestratorRegistryStore for MongoDB interactions with orchestrator data. - Defined OrchestratorCommandDocument and OrchestratorCommandRecord for command handling. - Added OrchestratorHeartbeatDocument and OrchestratorHeartbeatRecord for heartbeat tracking. - Created OrchestratorRegistryDocument and OrchestratorRegistryRecord for registry management. - Developed tests for orchestrator collections migration and MongoOrchestratorRegistryStore functionality. - Introduced AirgapImportRequest and AirgapImportValidator for air-gapped VEX bundle imports. - Added incident mode rules sample JSON for notifier configuration.
This commit is contained in:
@@ -55,6 +55,7 @@ using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||
using StellaOps.Provenance.Mongo;
|
||||
using StellaOps.Concelier.Core.Attestation;
|
||||
using StellaOps.Concelier.Storage.Mongo.Orchestrator;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -398,6 +399,162 @@ app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvi
|
||||
}
|
||||
}).WithName("GetConcelierOpenApiDocument");
|
||||
|
||||
var orchestratorGroup = app.MapGroup("/internal/orch");
|
||||
if (authorityConfigured)
|
||||
{
|
||||
orchestratorGroup.RequireAuthorization();
|
||||
}
|
||||
|
||||
orchestratorGroup.MapPost("/registry", async (
|
||||
HttpContext context,
|
||||
[FromBody] OrchestratorRegistryRequest request,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ConnectorId) || string.IsNullOrWhiteSpace(request.Source))
|
||||
{
|
||||
return Problem(context, "connectorId and source are required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId and source.");
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var record = new OrchestratorRegistryRecord(
|
||||
tenant,
|
||||
request.ConnectorId.Trim(),
|
||||
request.Source.Trim(),
|
||||
request.Capabilities,
|
||||
request.AuthRef,
|
||||
new OrchestratorSchedule(
|
||||
request.Schedule.Cron,
|
||||
string.IsNullOrWhiteSpace(request.Schedule.TimeZone) ? "UTC" : request.Schedule.TimeZone,
|
||||
request.Schedule.MaxParallelRuns,
|
||||
request.Schedule.MaxLagMinutes),
|
||||
new OrchestratorRatePolicy(request.RatePolicy.Rpm, request.RatePolicy.Burst, request.RatePolicy.CooldownSeconds),
|
||||
request.ArtifactKinds,
|
||||
request.LockKey,
|
||||
new OrchestratorEgressGuard(request.EgressGuard.Allowlist, request.EgressGuard.AirgapMode),
|
||||
now,
|
||||
now);
|
||||
|
||||
await store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Accepted();
|
||||
}).WithName("UpsertOrchestratorRegistry");
|
||||
|
||||
orchestratorGroup.MapPost("/heartbeat", async (
|
||||
HttpContext context,
|
||||
[FromBody] OrchestratorHeartbeatRequest request,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ConnectorId))
|
||||
{
|
||||
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
|
||||
}
|
||||
|
||||
if (request.Sequence < 0)
|
||||
{
|
||||
return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence.");
|
||||
}
|
||||
|
||||
var timestamp = request.TimestampUtc ?? timeProvider.GetUtcNow();
|
||||
var heartbeat = new OrchestratorHeartbeatRecord(
|
||||
tenant,
|
||||
request.ConnectorId.Trim(),
|
||||
request.RunId,
|
||||
request.Sequence,
|
||||
request.Status,
|
||||
request.Progress,
|
||||
request.QueueDepth,
|
||||
request.LastArtifactHash,
|
||||
request.LastArtifactKind,
|
||||
request.ErrorCode,
|
||||
request.RetryAfterSeconds,
|
||||
timestamp);
|
||||
|
||||
await store.AppendHeartbeatAsync(heartbeat, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted();
|
||||
}).WithName("RecordOrchestratorHeartbeat");
|
||||
|
||||
orchestratorGroup.MapPost("/commands", async (
|
||||
HttpContext context,
|
||||
[FromBody] OrchestratorCommandRequest request,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ConnectorId))
|
||||
{
|
||||
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
|
||||
}
|
||||
|
||||
if (request.Sequence < 0)
|
||||
{
|
||||
return Problem(context, "sequence must be non-negative", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide a non-negative sequence.");
|
||||
}
|
||||
|
||||
var command = new OrchestratorCommandRecord(
|
||||
tenant,
|
||||
request.ConnectorId.Trim(),
|
||||
request.RunId,
|
||||
request.Sequence,
|
||||
request.Command,
|
||||
request.Throttle is null
|
||||
? null
|
||||
: new OrchestratorThrottleOverride(
|
||||
request.Throttle.Rpm,
|
||||
request.Throttle.Burst,
|
||||
request.Throttle.CooldownSeconds,
|
||||
request.Throttle.ExpiresAt),
|
||||
request.Backfill is null
|
||||
? null
|
||||
: new OrchestratorBackfillRange(request.Backfill.FromCursor, request.Backfill.ToCursor),
|
||||
DateTimeOffset.UtcNow,
|
||||
request.ExpiresAt);
|
||||
|
||||
await store.EnqueueCommandAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted();
|
||||
}).WithName("EnqueueOrchestratorCommand");
|
||||
|
||||
orchestratorGroup.MapGet("/commands", async (
|
||||
HttpContext context,
|
||||
[FromQuery] string connectorId,
|
||||
[FromQuery] Guid runId,
|
||||
[FromQuery] long? afterSequence,
|
||||
[FromServices] IOrchestratorRegistryStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectorId))
|
||||
{
|
||||
return Problem(context, "connectorId is required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide connectorId.");
|
||||
}
|
||||
|
||||
var commands = await store.GetPendingCommandsAsync(tenant, connectorId.Trim(), runId, afterSequence, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(commands);
|
||||
}).WithName("GetOrchestratorCommands");
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
|
||||
@@ -836,6 +993,7 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryRawService rawService,
|
||||
[FromServices] EvidenceBundleAttestationBuilder attestationBuilder,
|
||||
[FromServices] ILogger<Program> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -879,10 +1037,16 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
|
||||
record.Document))
|
||||
.ToArray();
|
||||
|
||||
var evidenceOptions = resolvedConcelierOptions.Evidence ?? new ConcelierOptions.EvidenceBundleOptions();
|
||||
var attestation = await TryBuildAttestationAsync(
|
||||
context,
|
||||
evidenceOptions,
|
||||
attestationBuilder,
|
||||
logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey;
|
||||
var response = new AdvisoryEvidenceResponse(responseKey, recordResponses);
|
||||
// TODO: Attach attestation metadata when Evidence Bundle tarball is available per tenant/advisory.
|
||||
// The builder is registered for future use once bundle paths are discoverable from evidence storage.
|
||||
var response = new AdvisoryEvidenceResponse(responseKey, recordResponses, attestation);
|
||||
return JsonResult(response);
|
||||
});
|
||||
if (authorityConfigured)
|
||||
@@ -1622,6 +1786,120 @@ static KeyValuePair<string, object?>[] BuildJobMetricTags(string jobKind, string
|
||||
new KeyValuePair<string, object?>("job.outcome", outcome),
|
||||
};
|
||||
|
||||
static async Task<AttestationClaims?> TryBuildAttestationAsync(
|
||||
HttpContext context,
|
||||
ConcelierOptions.EvidenceBundleOptions evidenceOptions,
|
||||
EvidenceBundleAttestationBuilder builder,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bundlePath = context.Request.Query.TryGetValue("bundlePath", out var bundleValues)
|
||||
? bundleValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifestPath = context.Request.Query.TryGetValue("manifestPath", out var manifestValues)
|
||||
? manifestValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
var transparencyPath = context.Request.Query.TryGetValue("transparencyPath", out var transparencyValues)
|
||||
? transparencyValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
var pipelineVersion = context.Request.Query.TryGetValue("pipelineVersion", out var pipelineValues)
|
||||
? pipelineValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
pipelineVersion = string.IsNullOrWhiteSpace(pipelineVersion)
|
||||
? evidenceOptions.PipelineVersion
|
||||
: pipelineVersion.Trim();
|
||||
|
||||
var root = evidenceOptions.RootAbsolute;
|
||||
var resolvedBundlePath = ResolveEvidencePath(bundlePath, root);
|
||||
if (string.IsNullOrWhiteSpace(resolvedBundlePath) || !File.Exists(resolvedBundlePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedManifestPath = string.IsNullOrWhiteSpace(manifestPath)
|
||||
? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultManifestFileName)
|
||||
: ResolveEvidencePath(manifestPath!, root);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedManifestPath) || !File.Exists(resolvedManifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedTransparencyPath = string.IsNullOrWhiteSpace(transparencyPath)
|
||||
? ResolveSibling(resolvedBundlePath, evidenceOptions.DefaultTransparencyFileName)
|
||||
: ResolveEvidencePath(transparencyPath!, root);
|
||||
|
||||
try
|
||||
{
|
||||
return await builder.BuildAsync(
|
||||
new EvidenceBundleAttestationRequest(
|
||||
resolvedBundlePath!,
|
||||
resolvedManifestPath!,
|
||||
resolvedTransparencyPath,
|
||||
pipelineVersion ?? "git:unknown"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to build attestation for evidence bundle {BundlePath}", resolvedBundlePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static string? ResolveEvidencePath(string candidate, string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = candidate;
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(root, path);
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
var rootPath = Path.GetFullPath(root)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
static string? ResolveSibling(string? bundlePath, string? fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(bundlePath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(directory, fileName);
|
||||
}
|
||||
|
||||
void ApplyNoCache(HttpResponse response)
|
||||
{
|
||||
if (response is null)
|
||||
|
||||
Reference in New Issue
Block a user