Resolve Concelier/Excititor merge conflicts

This commit is contained in:
root
2025-10-20 14:19:25 +03:00
2687 changed files with 212646 additions and 85913 deletions

View File

@@ -0,0 +1,34 @@
# AGENTS
## Role
Minimal API host wiring configuration, storage, plugin routines, and job endpoints. Operational surface for health, readiness, and job control.
## Scope
- Configuration: appsettings.json + etc/concelier.yaml (yaml path = ../etc/concelier.yaml); bind into ConcelierOptions with validation (Only Mongo supported).
- Mongo: MongoUrl from options.Storage.Dsn; IMongoClient/IMongoDatabase singletons; default database name fallback (options -> URL -> "concelier").
- Services: AddMongoStorage(); AddSourceHttpClients(); RegisterPluginRoutines(configuration, PluginHostOptions).
- Bootstrap: MongoBootstrapper.InitializeAsync on startup.
- Endpoints (configuration & job control only; root path intentionally unbound):
- GET /health -> {status:"healthy"} after options validation binds.
- GET /ready -> MongoDB ping; 503 on MongoException/Timeout.
- GET /jobs?kind=&limit= -> recent runs.
- GET /jobs/{id} -> run detail.
- GET /jobs/definitions -> definitions with lastRun.
- GET /jobs/definitions/{kind} -> definition + lastRun or 404.
- GET /jobs/definitions/{kind}/runs?limit= -> recent runs or 404 if kind unknown.
- GET /jobs/active -> currently running.
- POST /jobs/{*jobKind} with {trigger?,parameters?} -> 202 Accepted (Location:/jobs/{runId}) | 404 | 409 | 423.
- PluginHost defaults: BaseDirectory = solution root; PluginsDirectory = "StellaOps.Concelier.PluginBinaries"; SearchPatterns += "StellaOps.Concelier.Plugin.*.dll"; EnsureDirectoryExists = true.
## Participants
- Core job system; Storage.Mongo; Source.Common HTTP clients; Exporter and Connector plugin routines discover/register jobs.
## Interfaces & contracts
- Dependency injection boundary for all connectors/exporters; IOptions<ConcelierOptions> validated on start.
- Cancellation: pass app.Lifetime.ApplicationStopping to bootstrapper.
## In/Out of scope
In: hosting, DI composition, REST surface, readiness checks.
Out: business logic of jobs, HTML UI, authn/z (future).
## Observability & security expectations
- Log startup config (redact DSN credentials), plugin scan results (missing ordered plugins if any).
- Structured responses with status codes; no stack traces in HTTP bodies; errors mapped cleanly.
## Tests
- Author and review coverage in `../StellaOps.Concelier.WebService.Tests`.
- Shared fixtures (e.g., `MongoIntegrationFixture`, `ConnectorTestHarness`) live in `../StellaOps.Concelier.Testing`.
- Keep fixtures deterministic; match new cases to real-world advisories or regression scenarios.

View File

@@ -0,0 +1,32 @@
namespace StellaOps.Concelier.WebService.Diagnostics;
internal sealed record StorageBootstrapHealth(
string Driver,
bool Completed,
DateTimeOffset? CompletedAt,
double? DurationMs);
internal sealed record TelemetryHealth(
bool Enabled,
bool Tracing,
bool Metrics,
bool Logging);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
double UptimeSeconds,
StorageBootstrapHealth Storage,
TelemetryHealth Telemetry);
internal sealed record MongoReadyHealth(
string Status,
double? LatencyMs,
DateTimeOffset? CheckedAt,
string? Error);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset StartedAt,
double UptimeSeconds,
MongoReadyHealth Mongo);

View File

@@ -0,0 +1,25 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Concelier.WebService.Diagnostics;
internal static class JobMetrics
{
internal const string MeterName = "StellaOps.Concelier.WebService.Jobs";
private static readonly Meter Meter = new(MeterName);
internal static readonly Counter<long> TriggerCounter = Meter.CreateCounter<long>(
"web.jobs.triggered",
unit: "count",
description: "Number of job trigger requests accepted by the web service.");
internal static readonly Counter<long> TriggerConflictCounter = Meter.CreateCounter<long>(
"web.jobs.trigger.conflict",
unit: "count",
description: "Number of job trigger requests that resulted in conflicts or rejections.");
internal static readonly Counter<long> TriggerFailureCounter = Meter.CreateCounter<long>(
"web.jobs.trigger.failed",
unit: "count",
description: "Number of job trigger requests that failed at runtime.");
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Concelier.WebService.Diagnostics;
internal static class ProblemTypes
{
public const string NotFound = "https://stellaops.org/problems/not-found";
public const string Validation = "https://stellaops.org/problems/validation";
public const string Conflict = "https://stellaops.org/problems/conflict";
public const string Locked = "https://stellaops.org/problems/locked";
public const string LeaseRejected = "https://stellaops.org/problems/lease-rejected";
public const string JobFailure = "https://stellaops.org/problems/job-failure";
public const string ServiceUnavailable = "https://stellaops.org/problems/service-unavailable";
}

View File

@@ -0,0 +1,74 @@
using System.Diagnostics;
namespace StellaOps.Concelier.WebService.Diagnostics;
internal sealed class ServiceStatus
{
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _startedAt;
private readonly object _sync = new();
private DateTimeOffset? _bootstrapCompletedAt;
private TimeSpan? _bootstrapDuration;
private DateTimeOffset? _lastReadyCheckAt;
private TimeSpan? _lastMongoLatency;
private string? _lastMongoError;
private bool _lastReadySucceeded;
public ServiceStatus(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_startedAt = _timeProvider.GetUtcNow();
}
public ServiceHealthSnapshot CreateSnapshot()
{
lock (_sync)
{
return new ServiceHealthSnapshot(
CapturedAt: _timeProvider.GetUtcNow(),
StartedAt: _startedAt,
BootstrapCompletedAt: _bootstrapCompletedAt,
BootstrapDuration: _bootstrapDuration,
LastReadyCheckAt: _lastReadyCheckAt,
LastMongoLatency: _lastMongoLatency,
LastMongoError: _lastMongoError,
LastReadySucceeded: _lastReadySucceeded);
}
}
public void MarkBootstrapCompleted(TimeSpan duration)
{
lock (_sync)
{
var completedAt = _timeProvider.GetUtcNow();
_bootstrapCompletedAt = completedAt;
_bootstrapDuration = duration;
_lastReadySucceeded = true;
_lastMongoLatency = duration;
_lastMongoError = null;
_lastReadyCheckAt = completedAt;
}
}
public void RecordMongoCheck(bool success, TimeSpan latency, string? error)
{
lock (_sync)
{
_lastReadySucceeded = success;
_lastMongoLatency = latency;
_lastMongoError = success ? null : error;
_lastReadyCheckAt = _timeProvider.GetUtcNow();
}
}
}
internal sealed record ServiceHealthSnapshot(
DateTimeOffset CapturedAt,
DateTimeOffset StartedAt,
DateTimeOffset? BootstrapCompletedAt,
TimeSpan? BootstrapDuration,
DateTimeOffset? LastReadyCheckAt,
TimeSpan? LastMongoLatency,
string? LastMongoError,
bool LastReadySucceeded);

View File

@@ -0,0 +1,38 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Concelier.WebService.Extensions;
public static class ConfigurationExtensions
{
public static IConfigurationBuilder AddConcelierYaml(this IConfigurationBuilder builder, string path)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return builder;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
using var reader = File.OpenText(path);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
return builder;
}
var json = JsonSerializer.Serialize(yamlObject);
var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
return builder.AddJsonStream(stream);
}
}

View File

@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Merge.Jobs;
namespace StellaOps.Concelier.WebService.Extensions;
internal static class JobRegistrationExtensions
{
private sealed record BuiltInJob(
string Kind,
string JobType,
string AssemblyName,
TimeSpan Timeout,
TimeSpan LeaseDuration,
string? CronExpression = null);
private static readonly IReadOnlyList<BuiltInJob> BuiltInJobs = new List<BuiltInJob>
{
new("source:redhat:fetch", "StellaOps.Concelier.Connector.Distro.RedHat.RedHatFetchJob", "StellaOps.Concelier.Connector.Distro.RedHat", TimeSpan.FromMinutes(12), TimeSpan.FromMinutes(6), "0,15,30,45 * * * *"),
new("source:redhat:parse", "StellaOps.Concelier.Connector.Distro.RedHat.RedHatParseJob", "StellaOps.Concelier.Connector.Distro.RedHat", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(6), "5,20,35,50 * * * *"),
new("source:redhat:map", "StellaOps.Concelier.Connector.Distro.RedHat.RedHatMapJob", "StellaOps.Concelier.Connector.Distro.RedHat", TimeSpan.FromMinutes(20), TimeSpan.FromMinutes(6), "10,25,40,55 * * * *"),
new("source:cert-in:fetch", "StellaOps.Concelier.Connector.CertIn.CertInFetchJob", "StellaOps.Concelier.Connector.CertIn", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:cert-in:parse", "StellaOps.Concelier.Connector.CertIn.CertInParseJob", "StellaOps.Concelier.Connector.CertIn", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:cert-in:map", "StellaOps.Concelier.Connector.CertIn.CertInMapJob", "StellaOps.Concelier.Connector.CertIn", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:cert-fr:fetch", "StellaOps.Concelier.Connector.CertFr.CertFrFetchJob", "StellaOps.Concelier.Connector.CertFr", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:cert-fr:parse", "StellaOps.Concelier.Connector.CertFr.CertFrParseJob", "StellaOps.Concelier.Connector.CertFr", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:cert-fr:map", "StellaOps.Concelier.Connector.CertFr.CertFrMapJob", "StellaOps.Concelier.Connector.CertFr", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:jvn:fetch", "StellaOps.Concelier.Connector.Jvn.JvnFetchJob", "StellaOps.Concelier.Connector.Jvn", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:jvn:parse", "StellaOps.Concelier.Connector.Jvn.JvnParseJob", "StellaOps.Concelier.Connector.Jvn", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:jvn:map", "StellaOps.Concelier.Connector.Jvn.JvnMapJob", "StellaOps.Concelier.Connector.Jvn", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:ics-kaspersky:fetch", "StellaOps.Concelier.Connector.Ics.Kaspersky.KasperskyFetchJob", "StellaOps.Concelier.Connector.Ics.Kaspersky", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:ics-kaspersky:parse", "StellaOps.Concelier.Connector.Ics.Kaspersky.KasperskyParseJob", "StellaOps.Concelier.Connector.Ics.Kaspersky", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:ics-kaspersky:map", "StellaOps.Concelier.Connector.Ics.Kaspersky.KasperskyMapJob", "StellaOps.Concelier.Connector.Ics.Kaspersky", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:osv:fetch", "StellaOps.Concelier.Connector.Osv.OsvFetchJob", "StellaOps.Concelier.Connector.Osv", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:osv:parse", "StellaOps.Concelier.Connector.Osv.OsvParseJob", "StellaOps.Concelier.Connector.Osv", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:osv:map", "StellaOps.Concelier.Connector.Osv.OsvMapJob", "StellaOps.Concelier.Connector.Osv", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:vmware:fetch", "StellaOps.Concelier.Connector.Vndr.Vmware.VmwareFetchJob", "StellaOps.Concelier.Connector.Vndr.Vmware", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:vmware:parse", "StellaOps.Concelier.Connector.Vndr.Vmware.VmwareParseJob", "StellaOps.Concelier.Connector.Vndr.Vmware", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:vmware:map", "StellaOps.Concelier.Connector.Vndr.Vmware.VmwareMapJob", "StellaOps.Concelier.Connector.Vndr.Vmware", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:vndr-oracle:fetch", "StellaOps.Concelier.Connector.Vndr.Oracle.OracleFetchJob", "StellaOps.Concelier.Connector.Vndr.Oracle", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:vndr-oracle:parse", "StellaOps.Concelier.Connector.Vndr.Oracle.OracleParseJob", "StellaOps.Concelier.Connector.Vndr.Oracle", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("source:vndr-oracle:map", "StellaOps.Concelier.Connector.Vndr.Oracle.OracleMapJob", "StellaOps.Concelier.Connector.Vndr.Oracle", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5)),
new("export:json", "StellaOps.Concelier.Exporter.Json.JsonExportJob", "StellaOps.Concelier.Exporter.Json", TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(5)),
new("export:trivy-db", "StellaOps.Concelier.Exporter.TrivyDb.TrivyDbExportJob", "StellaOps.Concelier.Exporter.TrivyDb", TimeSpan.FromMinutes(20), TimeSpan.FromMinutes(10)),
new("merge:reconcile", "StellaOps.Concelier.Merge.Jobs.MergeReconcileJob", "StellaOps.Concelier.Merge", TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(5))
};
public static IServiceCollection AddBuiltInConcelierJobs(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.PostConfigure<JobSchedulerOptions>(options =>
{
foreach (var registration in BuiltInJobs)
{
if (options.Definitions.ContainsKey(registration.Kind))
{
continue;
}
var jobType = Type.GetType(
$"{registration.JobType}, {registration.AssemblyName}",
throwOnError: false,
ignoreCase: false);
if (jobType is null)
{
continue;
}
var timeout = registration.Timeout > TimeSpan.Zero ? registration.Timeout : options.DefaultTimeout;
var lease = registration.LeaseDuration > TimeSpan.Zero ? registration.LeaseDuration : options.DefaultLeaseDuration;
options.Definitions[registration.Kind] = new JobDefinition(
registration.Kind,
jobType,
timeout,
lease,
registration.CronExpression,
Enabled: true);
}
});
return services;
}
}

View File

@@ -0,0 +1,181 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Services;
namespace StellaOps.Concelier.WebService.Extensions;
internal static class MirrorEndpointExtensions
{
private const string IndexScope = "index";
private const string DownloadScope = "download";
public static void MapConcelierMirrorEndpoints(this WebApplication app, bool authorityConfigured, bool enforceAuthority)
{
app.MapGet("/concelier/exports/index.json", async (
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return Results.NotFound();
}
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
if (!locator.TryResolveIndex(out var path, out _))
{
return Results.NotFound();
}
return await WriteFileAsync(path, context.Response, "application/json").ConfigureAwait(false);
});
app.MapGet("/concelier/exports/{**relativePath}", async (
string? relativePath,
MirrorFileLocator locator,
MirrorRateLimiter limiter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
HttpContext context,
CancellationToken cancellationToken) =>
{
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirrorOptions.Enabled)
{
return Results.NotFound();
}
if (string.IsNullOrWhiteSpace(relativePath))
{
return Results.NotFound();
}
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
{
return Results.NotFound();
}
var domain = FindDomain(mirrorOptions, domainId);
if (!TryAuthorize(domain?.RequireAuthentication ?? mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
{
return unauthorizedResult;
}
var limit = domain?.MaxDownloadRequestsPerHour ?? mirrorOptions.MaxIndexRequestsPerHour;
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
{
ApplyRetryAfter(context.Response, retryAfter);
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
var contentType = ResolveContentType(path);
return await WriteFileAsync(path, context.Response, contentType).ConfigureAwait(false);
});
}
private static ConcelierOptions.MirrorDomainOptions? FindDomain(ConcelierOptions.MirrorOptions mirrorOptions, string? domainId)
{
if (domainId is null)
{
return null;
}
foreach (var candidate in mirrorOptions.Domains)
{
if (candidate is null)
{
continue;
}
if (string.Equals(candidate.Id, domainId, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
return null;
}
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
{
result = Results.Empty;
if (!requireAuthentication)
{
return true;
}
if (!enforceAuthority || !authorityConfigured)
{
return true;
}
if (context.User?.Identity?.IsAuthenticated == true)
{
return true;
}
result = Results.StatusCode(StatusCodes.Status401Unauthorized);
return false;
}
private static Task<IResult> WriteFileAsync(string path, HttpResponse response, string contentType)
{
var fileInfo = new FileInfo(path);
if (!fileInfo.Exists)
{
return Task.FromResult(Results.NotFound());
}
var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read | FileShare.Delete);
response.Headers.CacheControl = "public, max-age=60";
response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
response.ContentLength = fileInfo.Length;
return Task.FromResult(Results.Stream(stream, contentType));
}
private static string ResolveContentType(string path)
{
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
if (path.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
{
return "application/jose+json";
}
return "application/octet-stream";
}
private static void ApplyRetryAfter(HttpResponse response, TimeSpan? retryAfter)
{
if (retryAfter is null)
{
return;
}
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,219 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Connector.Common.Telemetry;
using StellaOps.Concelier.WebService.Diagnostics;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Extensions;
public static class TelemetryExtensions
{
public static void ConfigureConcelierTelemetry(this WebApplicationBuilder builder, ConcelierOptions options)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(options);
var telemetry = options.Telemetry ?? new ConcelierOptions.TelemetryOptions();
if (telemetry.EnableLogging)
{
builder.Host.UseSerilog((context, services, configuration) =>
{
ConfigureSerilog(configuration, telemetry, builder.Environment.EnvironmentName, builder.Environment.ApplicationName);
});
}
if (!telemetry.Enabled || (!telemetry.EnableTracing && !telemetry.EnableMetrics))
{
return;
}
var openTelemetry = builder.Services.AddOpenTelemetry();
openTelemetry.ConfigureResource(resource =>
{
var serviceName = telemetry.ServiceName ?? builder.Environment.ApplicationName;
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
resource.AddService(serviceName, serviceVersion: version, serviceInstanceId: Environment.MachineName);
resource.AddAttributes(new[]
{
new KeyValuePair<string, object>("deployment.environment", builder.Environment.EnvironmentName),
});
foreach (var attribute in telemetry.ResourceAttributes)
{
if (string.IsNullOrWhiteSpace(attribute.Key) || attribute.Value is null)
{
continue;
}
resource.AddAttributes(new[] { new KeyValuePair<string, object>(attribute.Key, attribute.Value) });
}
});
if (telemetry.EnableTracing)
{
openTelemetry.WithTracing(tracing =>
{
tracing
.AddSource(JobDiagnostics.ActivitySourceName)
.AddSource(SourceDiagnostics.ActivitySourceName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
ConfigureExporters(telemetry, tracing);
});
}
if (telemetry.EnableMetrics)
{
openTelemetry.WithMetrics(metrics =>
{
metrics
.AddMeter(JobDiagnostics.MeterName)
.AddMeter(SourceDiagnostics.MeterName)
.AddMeter("StellaOps.Concelier.Connector.CertBund")
.AddMeter("StellaOps.Concelier.Connector.Nvd")
.AddMeter("StellaOps.Concelier.Connector.Vndr.Chromium")
.AddMeter("StellaOps.Concelier.Connector.Vndr.Apple")
.AddMeter("StellaOps.Concelier.Connector.Vndr.Adobe")
.AddMeter(JobMetrics.MeterName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
ConfigureExporters(telemetry, metrics);
});
}
}
private static void ConfigureSerilog(LoggerConfiguration configuration, ConcelierOptions.TelemetryOptions telemetry, string environmentName, string applicationName)
{
if (!Enum.TryParse(telemetry.MinimumLogLevel, ignoreCase: true, out LogEventLevel level))
{
level = LogEventLevel.Information;
}
configuration
.MinimumLevel.Is(level)
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.With<ActivityEnricher>()
.Enrich.WithProperty("service.name", telemetry.ServiceName ?? applicationName)
.Enrich.WithProperty("deployment.environment", environmentName)
.WriteTo.Console(outputTemplate: "[{Timestamp:O}] [{Level:u3}] {Message:lj} {Properties}{NewLine}{Exception}");
}
private static void ConfigureExporters(ConcelierOptions.TelemetryOptions telemetry, TracerProviderBuilder tracing)
{
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
{
if (telemetry.ExportConsole)
{
tracing.AddConsoleExporter();
}
return;
}
tracing.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
var headers = BuildHeaders(telemetry);
if (!string.IsNullOrEmpty(headers))
{
options.Headers = headers;
}
});
if (telemetry.ExportConsole)
{
tracing.AddConsoleExporter();
}
}
private static void ConfigureExporters(ConcelierOptions.TelemetryOptions telemetry, MeterProviderBuilder metrics)
{
if (string.IsNullOrWhiteSpace(telemetry.OtlpEndpoint))
{
if (telemetry.ExportConsole)
{
metrics.AddConsoleExporter();
}
return;
}
metrics.AddOtlpExporter(options =>
{
options.Endpoint = new Uri(telemetry.OtlpEndpoint);
var headers = BuildHeaders(telemetry);
if (!string.IsNullOrEmpty(headers))
{
options.Headers = headers;
}
});
if (telemetry.ExportConsole)
{
metrics.AddConsoleExporter();
}
}
private static string? BuildHeaders(ConcelierOptions.TelemetryOptions telemetry)
{
if (telemetry.OtlpHeaders.Count == 0)
{
return null;
}
return string.Join(",", telemetry.OtlpHeaders
.Where(static kvp => !string.IsNullOrWhiteSpace(kvp.Key) && !string.IsNullOrWhiteSpace(kvp.Value))
.Select(static kvp => $"{kvp.Key}={kvp.Value}"));
}
}
internal sealed class ActivityEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var activity = Activity.Current;
if (activity is null)
{
return;
}
if (activity.TraceId != default)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_id", activity.TraceId.ToString()));
}
if (activity.SpanId != default)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("span_id", activity.SpanId.ToString()));
}
if (activity.ParentSpanId != default)
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("parent_span_id", activity.ParentSpanId.ToString()));
}
if (!string.IsNullOrEmpty(activity.TraceStateString))
{
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("trace_state", activity.TraceStateString));
}
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Filters;
/// <summary>
/// Emits structured audit logs for job endpoint authorization decisions, including bypass usage.
/// </summary>
public sealed class JobAuthorizationAuditFilter : IEndpointFilter
{
internal const string LoggerName = "Concelier.Authorization.Audit";
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
var httpContext = context.HttpContext;
var options = httpContext.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value;
var authority = options.Authority;
if (authority is null || !authority.Enabled)
{
return await next(context).ConfigureAwait(false);
}
var logger = httpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(LoggerName);
var remoteAddress = httpContext.Connection.RemoteIpAddress;
var matcher = new NetworkMaskMatcher(authority.BypassNetworks);
var user = httpContext.User;
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress);
var result = await next(context).ConfigureAwait(false);
var scopes = ExtractScopes(user);
var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
logger.LogInformation(
"Concelier authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}",
httpContext.Request.Path.Value ?? string.Empty,
httpContext.Response.StatusCode,
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
scopes.Length == 0 ? "(none)" : string.Join(',', scopes),
bypassUsed,
remoteAddress?.ToString() ?? IPAddress.None.ToString());
return result;
}
private static string[] ExtractScopes(ClaimsPrincipal? principal)
{
if (principal is null)
{
return Array.Empty<string>();
}
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
values.Add(claim.Value);
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (!string.IsNullOrEmpty(normalized))
{
values.Add(normalized);
}
}
}
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
}
}

View File

@@ -0,0 +1,23 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Jobs;
public sealed record JobDefinitionResponse(
string Kind,
bool Enabled,
string? CronExpression,
TimeSpan Timeout,
TimeSpan LeaseDuration,
JobRunResponse? LastRun)
{
public static JobDefinitionResponse FromDefinition(JobDefinition definition, JobRunSnapshot? lastRun)
{
return new JobDefinitionResponse(
definition.Kind,
definition.Enabled,
definition.CronExpression,
definition.Timeout,
definition.LeaseDuration,
lastRun is null ? null : JobRunResponse.FromSnapshot(lastRun));
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.WebService.Jobs;
public sealed record JobRunResponse(
Guid RunId,
string Kind,
JobRunStatus Status,
string Trigger,
DateTimeOffset CreatedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
string? Error,
TimeSpan? Duration,
IReadOnlyDictionary<string, object?> Parameters)
{
public static JobRunResponse FromSnapshot(JobRunSnapshot snapshot)
=> new(
snapshot.RunId,
snapshot.Kind,
snapshot.Status,
snapshot.Trigger,
snapshot.CreatedAt,
snapshot.StartedAt,
snapshot.CompletedAt,
snapshot.Error,
snapshot.Duration,
snapshot.Parameters);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Concelier.WebService.Jobs;
public sealed class JobTriggerRequest
{
public string Trigger { get; set; } = "api";
public Dictionary<string, object?> Parameters { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Options;
public sealed class ConcelierOptions
{
public StorageOptions Storage { get; set; } = new();
public PluginOptions Plugins { get; set; } = new();
public TelemetryOptions Telemetry { get; set; } = new();
public AuthorityOptions Authority { get; set; } = new();
public MirrorOptions Mirror { get; set; } = new();
public sealed class StorageOptions
{
public string Driver { get; set; } = "mongo";
public string Dsn { get; set; } = string.Empty;
public string? Database { get; set; }
public int CommandTimeoutSeconds { get; set; } = 30;
}
public sealed class PluginOptions
{
public string? BaseDirectory { get; set; }
public string? Directory { get; set; }
public IList<string> SearchPatterns { get; set; } = new List<string>();
}
public sealed class TelemetryOptions
{
public bool Enabled { get; set; } = true;
public bool EnableTracing { get; set; } = true;
public bool EnableMetrics { get; set; } = true;
public bool EnableLogging { get; set; } = true;
public string MinimumLogLevel { get; set; } = "Information";
public string? ServiceName { get; set; }
public string? OtlpEndpoint { get; set; }
public IDictionary<string, string> OtlpHeaders { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public IDictionary<string, string> ResourceAttributes { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public bool ExportConsole { get; set; }
}
public sealed class AuthorityOptions
{
public bool Enabled { get; set; }
public bool AllowAnonymousFallback { get; set; } = true;
public string Issuer { get; set; } = string.Empty;
public string? MetadataAddress { get; set; }
public bool RequireHttpsMetadata { get; set; } = true;
public int BackchannelTimeoutSeconds { get; set; } = 30;
public int TokenClockSkewSeconds { get; set; } = 60;
public IList<string> Audiences { get; set; } = new List<string>();
public IList<string> RequiredScopes { get; set; } = new List<string>();
public IList<string> BypassNetworks { get; set; } = new List<string>();
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? ClientSecretFile { get; set; }
public IList<string> ClientScopes { get; set; } = new List<string>();
public ResilienceOptions Resilience { get; set; } = new();
public sealed class ResilienceOptions
{
public bool? EnableRetries { get; set; }
public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>();
public bool? AllowOfflineCacheFallback { get; set; }
public TimeSpan? OfflineCacheTolerance { get; set; }
}
}
public sealed class MirrorOptions
{
public bool Enabled { get; set; }
public string ExportRoot { get; set; } = System.IO.Path.Combine("exports", "json");
public string? ActiveExportId { get; set; }
public string LatestDirectoryName { get; set; } = "latest";
public string MirrorDirectoryName { get; set; } = "mirror";
public bool RequireAuthentication { get; set; }
public int MaxIndexRequestsPerHour { get; set; } = 600;
public IList<MirrorDomainOptions> Domains { get; } = new List<MirrorDomainOptions>();
[JsonIgnore]
public string ExportRootAbsolute { get; internal set; } = string.Empty;
}
public sealed class MirrorDomainOptions
{
public string Id { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public bool RequireAuthentication { get; set; }
public int MaxDownloadRequestsPerHour { get; set; } = 1200;
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.IO;
namespace StellaOps.Concelier.WebService.Options;
/// <summary>
/// Post-configuration helpers for <see cref="ConcelierOptions"/>.
/// </summary>
public static class ConcelierOptionsPostConfigure
{
/// <summary>
/// Applies derived settings that require filesystem access, such as loading client secrets from disk.
/// </summary>
/// <param name="options">The options to mutate.</param>
/// <param name="contentRootPath">Application content root used to resolve relative paths.</param>
public static void Apply(ConcelierOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
options.Authority ??= new ConcelierOptions.AuthorityOptions();
var authority = options.Authority;
if (string.IsNullOrWhiteSpace(authority.ClientSecret)
&& !string.IsNullOrWhiteSpace(authority.ClientSecretFile))
{
var resolvedPath = authority.ClientSecretFile!;
if (!Path.IsPathRooted(resolvedPath))
{
resolvedPath = Path.Combine(contentRootPath, resolvedPath);
}
if (!File.Exists(resolvedPath))
{
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' was not found.");
}
var secret = File.ReadAllText(resolvedPath).Trim();
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' is empty.");
}
authority.ClientSecret = secret;
}
options.Mirror ??= new ConcelierOptions.MirrorOptions();
var mirror = options.Mirror;
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
{
mirror.ExportRoot = Path.Combine("exports", "json");
}
var resolvedRoot = mirror.ExportRoot;
if (!Path.IsPathRooted(resolvedRoot))
{
resolvedRoot = Path.Combine(contentRootPath, resolvedRoot);
}
mirror.ExportRootAbsolute = Path.GetFullPath(resolvedRoot);
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
{
mirror.LatestDirectoryName = "latest";
}
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
{
mirror.MirrorDirectoryName = "mirror";
}
}
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Concelier.WebService.Options;
public static class ConcelierOptionsValidator
{
public static void Validate(ConcelierOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!string.Equals(options.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Only Mongo storage driver is supported (storage.driver == 'mongo').");
}
if (string.IsNullOrWhiteSpace(options.Storage.Dsn))
{
throw new InvalidOperationException("Storage DSN must be configured.");
}
if (options.Storage.CommandTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Command timeout must be greater than zero seconds.");
}
options.Telemetry ??= new ConcelierOptions.TelemetryOptions();
options.Authority ??= new ConcelierOptions.AuthorityOptions();
options.Authority.Resilience ??= new ConcelierOptions.AuthorityOptions.ResilienceOptions();
NormalizeList(options.Authority.Audiences, toLower: false);
NormalizeList(options.Authority.RequiredScopes, toLower: true);
NormalizeList(options.Authority.BypassNetworks, toLower: false);
NormalizeList(options.Authority.ClientScopes, toLower: true);
ValidateResilience(options.Authority.Resilience);
if (options.Authority.RequiredScopes.Count == 0)
{
options.Authority.RequiredScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
}
if (options.Authority.ClientScopes.Count == 0)
{
foreach (var scope in options.Authority.RequiredScopes)
{
options.Authority.ClientScopes.Add(scope);
}
}
if (options.Authority.ClientScopes.Count == 0)
{
options.Authority.ClientScopes.Add(StellaOpsScopes.ConcelierJobsTrigger);
}
if (options.Authority.BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero.");
}
if (options.Authority.TokenClockSkewSeconds < 0 || options.Authority.TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Authority tokenClockSkewSeconds must be between 0 and 300 seconds.");
}
if (options.Authority.Enabled)
{
if (string.IsNullOrWhiteSpace(options.Authority.Issuer))
{
throw new InvalidOperationException("Authority issuer must be configured when authority is enabled.");
}
if (!Uri.TryCreate(options.Authority.Issuer, UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Authority issuer must be an absolute URI.");
}
if (options.Authority.RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Authority issuer must use HTTPS when requireHttpsMetadata is enabled.");
}
if (!string.IsNullOrWhiteSpace(options.Authority.MetadataAddress) && !Uri.TryCreate(options.Authority.MetadataAddress, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Authority metadataAddress must be an absolute URI when specified.");
}
if (options.Authority.Audiences.Count == 0)
{
throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled.");
}
if (!options.Authority.AllowAnonymousFallback)
{
if (string.IsNullOrWhiteSpace(options.Authority.ClientId))
{
throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled.");
}
if (string.IsNullOrWhiteSpace(options.Authority.ClientSecret))
{
throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled.");
}
}
}
if (!Enum.TryParse(options.Telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _))
{
throw new InvalidOperationException($"Telemetry minimum log level '{options.Telemetry.MinimumLogLevel}' is invalid.");
}
if (!string.IsNullOrWhiteSpace(options.Telemetry.OtlpEndpoint) && !Uri.TryCreate(options.Telemetry.OtlpEndpoint, UriKind.Absolute, out _))
{
throw new InvalidOperationException("Telemetry OTLP endpoint must be an absolute URI.");
}
foreach (var attribute in options.Telemetry.ResourceAttributes)
{
if (string.IsNullOrWhiteSpace(attribute.Key))
{
throw new InvalidOperationException("Telemetry resource attribute keys must be non-empty.");
}
}
foreach (var header in options.Telemetry.OtlpHeaders)
{
if (string.IsNullOrWhiteSpace(header.Key))
{
throw new InvalidOperationException("Telemetry OTLP header names must be non-empty.");
}
}
options.Mirror ??= new ConcelierOptions.MirrorOptions();
ValidateMirror(options.Mirror);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values is null || values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var entry = values[index];
if (string.IsNullOrWhiteSpace(entry))
{
values.RemoveAt(index);
continue;
}
var normalized = entry.Trim();
if (toLower)
{
normalized = normalized.ToLowerInvariant();
}
if (!seen.Add(normalized))
{
values.RemoveAt(index);
continue;
}
values[index] = normalized;
}
}
private static void ValidateResilience(ConcelierOptions.AuthorityOptions.ResilienceOptions resilience)
{
if (resilience.RetryDelays is null)
{
return;
}
foreach (var delay in resilience.RetryDelays)
{
if (delay <= TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience retryDelays must be greater than zero.");
}
}
if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value < TimeSpan.Zero)
{
throw new InvalidOperationException("Authority resilience offlineCacheTolerance must be greater than or equal to zero.");
}
}
private static void ValidateMirror(ConcelierOptions.MirrorOptions mirror)
{
if (mirror.MaxIndexRequestsPerHour < 0)
{
throw new InvalidOperationException("Mirror maxIndexRequestsPerHour must be greater than or equal to zero.");
}
if (string.IsNullOrWhiteSpace(mirror.ExportRoot))
{
throw new InvalidOperationException("Mirror exportRoot must be configured.");
}
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
{
throw new InvalidOperationException("Mirror export root could not be resolved.");
}
if (string.IsNullOrWhiteSpace(mirror.LatestDirectoryName))
{
throw new InvalidOperationException("Mirror latestDirectoryName must be provided.");
}
if (string.IsNullOrWhiteSpace(mirror.MirrorDirectoryName))
{
throw new InvalidOperationException("Mirror mirrorDirectoryName must be provided.");
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var domain in mirror.Domains)
{
if (string.IsNullOrWhiteSpace(domain.Id))
{
throw new InvalidOperationException("Mirror domain id must be provided.");
}
var normalized = domain.Id.Trim();
if (!seen.Add(normalized))
{
throw new InvalidOperationException($"Mirror domain id '{normalized}' is duplicated.");
}
if (domain.MaxDownloadRequestsPerHour < 0)
{
throw new InvalidOperationException($"Mirror domain '{normalized}' maxDownloadRequestsPerHour must be greater than or equal to zero.");
}
}
if (mirror.Enabled && mirror.Domains.Count == 0)
{
throw new InvalidOperationException("Mirror distribution requires at least one domain when enabled.");
}
}
}

View File

@@ -0,0 +1,770 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
using StellaOps.Concelier.Merge;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.WebService.Extensions;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Filters;
using StellaOps.Concelier.WebService.Services;
using Serilog.Events;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
var builder = WebApplication.CreateBuilder(args);
const string JobsPolicyName = "Concelier.Jobs.Trigger";
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "CONCELIER_";
options.ConfigureBuilder = configurationBuilder =>
{
configurationBuilder.AddConcelierYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/concelier.yaml"));
};
});
var contentRootPath = builder.Environment.ContentRootPath;
var concelierOptions = builder.Configuration.BindOptions<ConcelierOptions>(postConfigure: (opts, _) =>
{
ConcelierOptionsPostConfigure.Apply(opts, contentRootPath);
ConcelierOptionsValidator.Validate(opts);
});
builder.Services.AddOptions<ConcelierOptions>()
.Bind(builder.Configuration)
.PostConfigure(options =>
{
ConcelierOptionsPostConfigure.Apply(options, contentRootPath);
ConcelierOptionsValidator.Validate(options);
})
.ValidateOnStart();
builder.ConfigureConcelierTelemetry(concelierOptions);
builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<MirrorRateLimiter>();
builder.Services.AddSingleton<MirrorFileLocator>();
builder.Services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = concelierOptions.Storage.Dsn;
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
builder.Services.AddMergeModule(builder.Configuration);
builder.Services.AddJobScheduler();
builder.Services.AddBuiltInConcelierJobs();
builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>()));
var authorityConfigured = concelierOptions.Authority is { Enabled: true };
if (authorityConfigured)
{
builder.Services.AddStellaOpsAuthClient(clientOptions =>
{
clientOptions.Authority = concelierOptions.Authority.Issuer;
clientOptions.ClientId = concelierOptions.Authority.ClientId ?? string.Empty;
clientOptions.ClientSecret = concelierOptions.Authority.ClientSecret;
clientOptions.HttpTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds);
clientOptions.DefaultScopes.Clear();
foreach (var scope in concelierOptions.Authority.ClientScopes)
{
clientOptions.DefaultScopes.Add(scope);
}
var resilience = concelierOptions.Authority.Resilience ?? new ConcelierOptions.AuthorityOptions.ResilienceOptions();
if (resilience.EnableRetries.HasValue)
{
clientOptions.EnableRetries = resilience.EnableRetries.Value;
}
if (resilience.RetryDelays is { Count: > 0 })
{
clientOptions.RetryDelays.Clear();
foreach (var delay in resilience.RetryDelays)
{
clientOptions.RetryDelays.Add(delay);
}
}
if (resilience.AllowOfflineCacheFallback.HasValue)
{
clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value;
}
if (resilience.OfflineCacheTolerance.HasValue)
{
clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value;
}
});
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: null,
configure: resourceOptions =>
{
resourceOptions.Authority = concelierOptions.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = concelierOptions.Authority.RequireHttpsMetadata;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(concelierOptions.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(concelierOptions.Authority.TokenClockSkewSeconds);
if (!string.IsNullOrWhiteSpace(concelierOptions.Authority.MetadataAddress))
{
resourceOptions.MetadataAddress = concelierOptions.Authority.MetadataAddress;
}
foreach (var audience in concelierOptions.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
foreach (var scope in concelierOptions.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
foreach (var network in concelierOptions.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray());
});
}
var pluginHostOptions = BuildPluginOptions(concelierOptions, builder.Environment.ContentRootPath);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
var resolvedConcelierOptions = app.Services.GetRequiredService<IOptions<ConcelierOptions>>().Value;
var resolvedAuthority = resolvedConcelierOptions.Authority ?? new ConcelierOptions.AuthorityOptions();
authorityConfigured = resolvedAuthority.Enabled;
var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback;
if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
{
app.Logger.LogWarning(
"Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout.");
}
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
jsonOptions.Converters.Add(new JsonStringEnumConverter());
app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
string vulnerabilityKey,
DateTimeOffset? asOf,
IAdvisoryEventLog eventLog,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
{
return Results.BadRequest("vulnerabilityKey must be provided.");
}
var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false);
if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0)
{
return Results.NotFound();
}
var response = new
{
replay.VulnerabilityKey,
replay.AsOf,
Statements = replay.Statements.Select(statement => new
{
statement.StatementId,
statement.VulnerabilityKey,
statement.AdvisoryKey,
statement.Advisory,
StatementHash = Convert.ToHexString(statement.StatementHash.ToArray()),
statement.AsOf,
statement.RecordedAt,
InputDocumentIds = statement.InputDocumentIds
}).ToArray(),
Conflicts = replay.Conflicts.Select(conflict => new
{
conflict.ConflictId,
conflict.VulnerabilityKey,
conflict.StatementIds,
ConflictHash = Convert.ToHexString(conflict.ConflictHash.ToArray()),
conflict.AsOf,
conflict.RecordedAt,
Details = conflict.CanonicalJson
}).ToArray()
};
return JsonResult(response);
});
var loggingEnabled = concelierOptions.Telemetry?.EnableLogging ?? true;
if (loggingEnabled)
{
app.UseSerilogRequestLogging(options =>
{
options.IncludeQueryInRequestPath = true;
options.GetLevel = (httpContext, elapsedMs, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestId", httpContext.TraceIdentifier);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
if (Activity.Current is { TraceId: var traceId } && traceId != default)
{
diagnosticContext.Set("TraceId", traceId.ToString());
}
};
});
}
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.ContentType = "application/problem+json";
var feature = context.Features.Get<IExceptionHandlerFeature>();
var error = feature?.Error;
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["traceId"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
};
var problem = Results.Problem(
detail: error?.Message,
instance: context.Request.Path,
statusCode: StatusCodes.Status500InternalServerError,
title: "Unexpected server error",
type: ProblemTypes.JobFailure,
extensions: extensions);
await problem.ExecuteAsync(context);
});
});
if (authorityConfigured)
{
app.Use(async (context, next) =>
{
await next().ConfigureAwait(false);
if (!context.Request.Path.StartsWithSegments("/jobs", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (context.Response.StatusCode != StatusCodes.Status401Unauthorized)
{
return;
}
var optionsMonitor = context.RequestServices.GetRequiredService<IOptions<ConcelierOptions>>().Value.Authority;
if (optionsMonitor is null || !optionsMonitor.Enabled)
{
return;
}
var logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger(JobAuthorizationAuditFilter.LoggerName);
var matcher = new NetworkMaskMatcher(optionsMonitor.BypassNetworks);
var remote = context.Connection.RemoteIpAddress;
var bypassAllowed = matcher.IsAllowed(remote);
logger.LogWarning(
"Concelier authorization denied route={Route} remote={RemoteAddress} bypassAllowed={BypassAllowed} hasPrincipal={HasPrincipal}",
context.Request.Path.Value ?? string.Empty,
remote?.ToString() ?? "unknown",
bypassAllowed,
context.User?.Identity?.IsAuthenticated ?? false);
});
}
if (authorityConfigured)
{
app.UseAuthentication();
app.UseAuthorization();
}
IResult JsonResult<T>(T value, int? statusCode = null)
{
var payload = JsonSerializer.Serialize(value, jsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary<string, object?>? extensions = null)
{
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
extensions ??= new Dictionary<string, object?>(StringComparer.Ordinal)
{
["traceId"] = traceId,
};
if (!extensions.ContainsKey("traceId"))
{
extensions["traceId"] = traceId;
}
var problemDetails = new ProblemDetails
{
Type = type,
Title = title,
Detail = detail,
Status = statusCode,
Instance = context.Request.Path
};
foreach (var entry in extensions)
{
problemDetails.Extensions[entry.Key] = entry.Value;
}
var payload = JsonSerializer.Serialize(problemDetails, jsonOptions);
return Results.Content(payload, "application/problem+json", Encoding.UTF8, statusCode);
}
static KeyValuePair<string, object?>[] BuildJobMetricTags(string jobKind, string trigger, string outcome)
=> new[]
{
new KeyValuePair<string, object?>("job.kind", jobKind),
new KeyValuePair<string, object?>("job.trigger", trigger),
new KeyValuePair<string, object?>("job.outcome", outcome),
};
void ApplyNoCache(HttpResponse response)
{
if (response is null)
{
return;
}
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
response.Headers.Pragma = "no-cache";
response.Headers["Expires"] = "0";
}
await InitializeMongoAsync(app);
app.MapGet("/health", (IOptions<ConcelierOptions> opts, ServiceStatus status, HttpContext context) =>
{
ApplyNoCache(context.Response);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var storage = new StorageBootstrapHealth(
Driver: opts.Value.Storage.Driver,
Completed: snapshot.BootstrapCompletedAt is not null,
CompletedAt: snapshot.BootstrapCompletedAt,
DurationMs: snapshot.BootstrapDuration?.TotalMilliseconds);
var telemetry = new TelemetryHealth(
Enabled: opts.Value.Telemetry.Enabled,
Tracing: opts.Value.Telemetry.EnableTracing,
Metrics: opts.Value.Telemetry.EnableMetrics,
Logging: opts.Value.Telemetry.EnableLogging);
var response = new HealthDocument(
Status: "healthy",
StartedAt: snapshot.StartedAt,
UptimeSeconds: uptimeSeconds,
Storage: storage,
Telemetry: telemetry);
return JsonResult(response);
});
app.MapGet("/ready", async (IMongoDatabase database, ServiceStatus status, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var stopwatch = Stopwatch.StartNew();
try
{
await database.RunCommandAsync((Command<BsonDocument>)"{ ping: 1 }", cancellationToken: cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
status.RecordMongoCheck(success: true, latency: stopwatch.Elapsed, error: null);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var mongo = new MongoReadyHealth(
Status: "ready",
LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
CheckedAt: snapshot.LastReadyCheckAt,
Error: null);
var response = new ReadyDocument(
Status: "ready",
StartedAt: snapshot.StartedAt,
UptimeSeconds: uptimeSeconds,
Mongo: mongo);
return JsonResult(response);
}
catch (Exception ex)
{
stopwatch.Stop();
status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var mongo = new MongoReadyHealth(
Status: "unready",
LatencyMs: snapshot.LastMongoLatency?.TotalMilliseconds,
CheckedAt: snapshot.LastReadyCheckAt,
Error: snapshot.LastMongoError ?? ex.Message);
var response = new ReadyDocument(
Status: "unready",
StartedAt: snapshot.StartedAt,
UptimeSeconds: uptimeSeconds,
Mongo: mongo);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["mongoLatencyMs"] = snapshot.LastMongoLatency?.TotalMilliseconds,
["mongoError"] = snapshot.LastMongoError ?? ex.Message,
};
return Problem(context, "Mongo unavailable", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, snapshot.LastMongoError ?? ex.Message, extensions);
}
});
app.MapGet("/diagnostics/aliases/{seed}", async (string seed, AliasGraphResolver resolver, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (string.IsNullOrWhiteSpace(seed))
{
return Problem(context, "Seed advisory key is required.", StatusCodes.Status400BadRequest, ProblemTypes.Validation);
}
var component = await resolver.BuildComponentAsync(seed, cancellationToken).ConfigureAwait(false);
var aliases = component.AliasMap.ToDictionary(
static kvp => kvp.Key,
static kvp => kvp.Value
.Select(record => new
{
record.Scheme,
record.Value,
UpdatedAt = record.UpdatedAt
})
.ToArray());
var response = new
{
Seed = component.SeedAdvisoryKey,
Advisories = component.AdvisoryKeys,
Collisions = component.Collisions
.Select(collision => new
{
collision.Scheme,
collision.Value,
AdvisoryKeys = collision.AdvisoryKeys
})
.ToArray(),
Aliases = aliases
};
return JsonResult(response);
});
var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 200);
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
return JsonResult(payload);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobsListEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var run = await coordinator.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
if (run is null)
{
return Problem(context, "Job run not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job run '{runId}' was not found.");
}
return JsonResult(JobRunResponse.FromSnapshot(run));
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobByIdEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var definitions = await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false);
if (definitions.Count == 0)
{
return JsonResult(Array.Empty<JobDefinitionResponse>());
}
var definitionKinds = definitions.Select(static definition => definition.Kind).ToArray();
var lastRuns = await coordinator.GetLastRunsAsync(definitionKinds, cancellationToken).ConfigureAwait(false);
var responses = new List<JobDefinitionResponse>(definitions.Count);
foreach (var definition in definitions)
{
lastRuns.TryGetValue(definition.Kind, out var lastRun);
responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun));
}
return JsonResult(responses);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
.FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
if (definition is null)
{
return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
}
var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false);
lastRuns.TryGetValue(definition.Kind, out var lastRun);
var response = JobDefinitionResponse.FromDefinition(definition, lastRun);
return JsonResult(response);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName);
}
var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var definition = (await coordinator.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false))
.FirstOrDefault(d => string.Equals(d.Kind, kind, StringComparison.Ordinal));
if (definition is null)
{
return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
}
var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200);
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
return JsonResult(payload);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName);
}
var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false);
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
return JsonResult(payload);
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
activeJobsEndpoint.RequireAuthorization(JobsPolicyName);
}
var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, IJobCoordinator coordinator, HttpContext context) =>
{
ApplyNoCache(context.Response);
request ??= new JobTriggerRequest();
request.Parameters ??= new Dictionary<string, object?>(StringComparer.Ordinal);
var trigger = string.IsNullOrWhiteSpace(request.Trigger) ? "api" : request.Trigger;
var lifetime = context.RequestServices.GetRequiredService<IHostApplicationLifetime>();
var result = await coordinator.TriggerAsync(jobKind, request.Parameters, trigger, lifetime.ApplicationStopping).ConfigureAwait(false);
var outcome = result.Outcome;
var tags = BuildJobMetricTags(jobKind, trigger, outcome.ToString().ToLowerInvariant());
switch (outcome)
{
case JobTriggerOutcome.Accepted:
JobMetrics.TriggerCounter.Add(1, tags);
if (result.Run is null)
{
return Results.StatusCode(StatusCodes.Status202Accepted);
}
var acceptedRun = JobRunResponse.FromSnapshot(result.Run);
context.Response.Headers.Location = $"/jobs/{acceptedRun.RunId}";
return JsonResult(acceptedRun, StatusCodes.Status202Accepted);
case JobTriggerOutcome.NotFound:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, result.ErrorMessage ?? $"Job '{jobKind}' is not registered.");
case JobTriggerOutcome.Disabled:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job disabled", StatusCodes.Status423Locked, ProblemTypes.Locked, result.ErrorMessage ?? $"Job '{jobKind}' is disabled.");
case JobTriggerOutcome.AlreadyRunning:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job already running", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' already has an active run.");
case JobTriggerOutcome.LeaseRejected:
JobMetrics.TriggerConflictCounter.Add(1, tags);
return Problem(context, "Job lease rejected", StatusCodes.Status409Conflict, ProblemTypes.LeaseRejected, result.ErrorMessage ?? $"Job '{jobKind}' could not acquire a lease.");
case JobTriggerOutcome.InvalidParameters:
{
JobMetrics.TriggerConflictCounter.Add(1, tags);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["parameters"] = request.Parameters,
};
return Problem(context, "Invalid job parameters", StatusCodes.Status400BadRequest, ProblemTypes.Validation, result.ErrorMessage, extensions);
}
case JobTriggerOutcome.Cancelled:
{
JobMetrics.TriggerConflictCounter.Add(1, tags);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
};
return Problem(context, "Job cancelled", StatusCodes.Status409Conflict, ProblemTypes.Conflict, result.ErrorMessage ?? $"Job '{jobKind}' was cancelled before completion.", extensions);
}
case JobTriggerOutcome.Failed:
{
JobMetrics.TriggerFailureCounter.Add(1, tags);
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["run"] = result.Run is null ? null : JobRunResponse.FromSnapshot(result.Run),
};
return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions);
}
default:
JobMetrics.TriggerFailureCounter.Add(1, tags);
return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'.");
}
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
if (enforceAuthority)
{
triggerJobEndpoint.RequireAuthorization(JobsPolicyName);
}
await app.RunAsync();
static PluginHostOptions BuildPluginOptions(ConcelierOptions options, string contentRoot)
{
var pluginOptions = new PluginHostOptions
{
BaseDirectory = options.Plugins.BaseDirectory ?? contentRoot,
PluginsDirectory = options.Plugins.Directory ?? Path.Combine(contentRoot, "StellaOps.Concelier.PluginBinaries"),
PrimaryPrefix = "StellaOps.Concelier",
EnsureDirectoryExists = true,
RecursiveSearch = false,
};
if (options.Plugins.SearchPatterns.Count == 0)
{
pluginOptions.SearchPatterns.Add("StellaOps.Concelier.Plugin.*.dll");
}
else
{
foreach (var pattern in options.Plugins.SearchPatterns)
{
if (!string.IsNullOrWhiteSpace(pattern))
{
pluginOptions.SearchPatterns.Add(pattern);
}
}
}
return pluginOptions;
}
static async Task InitializeMongoAsync(WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var bootstrapper = scope.ServiceProvider.GetRequiredService<MongoBootstrapper>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("MongoBootstrapper");
var status = scope.ServiceProvider.GetRequiredService<ServiceStatus>();
var stopwatch = Stopwatch.StartNew();
try
{
await bootstrapper.InitializeAsync(app.Lifetime.ApplicationStopping).ConfigureAwait(false);
stopwatch.Stop();
status.MarkBootstrapCompleted(stopwatch.Elapsed);
logger.LogInformation("Mongo bootstrap completed in {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
status.RecordMongoCheck(success: false, latency: stopwatch.Elapsed, error: ex.Message);
logger.LogCritical(ex, "Mongo bootstrap failed after {ElapsedMs} ms", stopwatch.Elapsed.TotalMilliseconds);
throw;
}
}
public partial class Program;

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.Concelier.WebService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:50411;http://localhost:50412"
}
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorFileLocator
{
private readonly IOptionsMonitor<ConcelierOptions> _options;
private readonly ILogger<MirrorFileLocator> _logger;
public MirrorFileLocator(IOptionsMonitor<ConcelierOptions> options, ILogger<MirrorFileLocator> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool TryResolveIndex([NotNullWhen(true)] out string? path, [NotNullWhen(true)] out string? exportId)
=> TryResolveRelativePath("index.json", out path, out exportId, out _);
public bool TryResolveRelativePath(string relativePath, [NotNullWhen(true)] out string? fullPath, [NotNullWhen(true)] out string? exportId, out string? domainId)
{
fullPath = null;
exportId = null;
domainId = null;
var mirror = _options.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
if (!mirror.Enabled)
{
return false;
}
if (!TryResolveExportDirectory(mirror, out var exportDirectory, out exportId))
{
return false;
}
var sanitized = SanitizeRelativePath(relativePath);
if (sanitized.Length == 0 || string.Equals(sanitized, "index.json", StringComparison.OrdinalIgnoreCase))
{
sanitized = $"{mirror.MirrorDirectoryName}/index.json";
}
if (!sanitized.StartsWith($"{mirror.MirrorDirectoryName}/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var candidate = Combine(exportDirectory, sanitized);
if (!CandidateWithinExport(exportDirectory, candidate))
{
_logger.LogWarning("Rejected mirror export request for path '{RelativePath}' due to traversal attempt.", relativePath);
return false;
}
if (!File.Exists(candidate))
{
return false;
}
// Extract domain id from path mirror/<domain>/...
var segments = sanitized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length >= 2)
{
domainId = segments[1];
}
fullPath = candidate;
return true;
}
private bool TryResolveExportDirectory(ConcelierOptions.MirrorOptions mirror, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(mirror.ExportRootAbsolute))
{
_logger.LogWarning("Mirror export root is not configured; unable to serve mirror content.");
return false;
}
var root = mirror.ExportRootAbsolute;
var candidateSegment = string.IsNullOrWhiteSpace(mirror.ActiveExportId)
? mirror.LatestDirectoryName
: mirror.ActiveExportId!;
if (TryResolveCandidate(root, candidateSegment, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
if (!string.Equals(candidateSegment, mirror.LatestDirectoryName, StringComparison.OrdinalIgnoreCase)
&& TryResolveCandidate(root, mirror.LatestDirectoryName, mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
try
{
var directories = Directory.Exists(root)
? Directory.GetDirectories(root)
: Array.Empty<string>();
Array.Sort(directories, StringComparer.Ordinal);
Array.Reverse(directories);
foreach (var directory in directories)
{
if (TryResolveCandidate(root, Path.GetFileName(directory), mirror.MirrorDirectoryName, out exportDirectory, out exportId))
{
return true;
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Failed to enumerate export directories under {Root}.", root);
}
return false;
}
private bool TryResolveCandidate(string root, string segment, string mirrorDirectory, [NotNullWhen(true)] out string? exportDirectory, [NotNullWhen(true)] out string? exportId)
{
exportDirectory = null;
exportId = null;
if (string.IsNullOrWhiteSpace(segment))
{
return false;
}
var candidate = Path.Combine(root, segment);
if (!Directory.Exists(candidate))
{
return false;
}
var mirrorPath = Path.Combine(candidate, mirrorDirectory);
if (!Directory.Exists(mirrorPath))
{
return false;
}
exportDirectory = candidate;
exportId = segment;
return true;
}
private static string SanitizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
return string.Empty;
}
var trimmed = relativePath.Replace('\\', '/').Trim().TrimStart('/');
return trimmed;
}
private static string Combine(string root, string relativePath)
{
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return Path.GetFullPath(root);
}
var combinedRelative = Path.Combine(segments);
return Path.GetFullPath(Path.Combine(root, combinedRelative));
}
private static bool CandidateWithinExport(string exportDirectory, string candidate)
{
var exportRoot = Path.GetFullPath(exportDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var candidatePath = Path.GetFullPath(candidate);
return candidatePath.StartsWith(exportRoot, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.Caching.Memory;
namespace StellaOps.Concelier.WebService.Services;
internal sealed class MirrorRateLimiter
{
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan Window = TimeSpan.FromHours(1);
public MirrorRateLimiter(IMemoryCache cache, TimeProvider timeProvider)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public bool TryAcquire(string domainId, string scope, int limit, out TimeSpan? retryAfter)
{
retryAfter = null;
if (limit <= 0 || limit == int.MaxValue)
{
return true;
}
var key = CreateKey(domainId, scope);
var now = _timeProvider.GetUtcNow();
var counter = _cache.Get<Counter>(key);
if (counter is null || now - counter.WindowStart >= Window)
{
counter = new Counter(now, 0);
}
if (counter.Count >= limit)
{
var windowEnd = counter.WindowStart + Window;
retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
return false;
}
counter = counter with { Count = counter.Count + 1 };
var absoluteExpiration = counter.WindowStart + Window;
_cache.Set(key, counter, absoluteExpiration);
return true;
}
private static string CreateKey(string domainId, string scope)
=> string.Create(domainId.Length + scope.Length + 1, (domainId, scope), static (span, state) =>
{
state.domainId.AsSpan().CopyTo(span);
span[state.domainId.Length] = '|';
state.scope.AsSpan().CopyTo(span[(state.domainId.Length + 1)..]);
});
private sealed record Counter(DateTimeOffset WindowStart, int Count);
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Concelier.WebService</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.12.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Connector.Common\StellaOps.Concelier.Connector.Common.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Merge\StellaOps.Concelier.Merge.csproj" />
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|FEEDWEB-EVENTS-07-001 Advisory event replay API|Concelier WebService Guild|FEEDCORE-ENGINE-07-001|**DONE (2025-10-19)** Added `/concelier/advisories/{vulnerabilityKey}/replay` endpoint with optional `asOf`, hex hashes, and conflict payloads; integration covered via `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj`.|
|Bind & validate ConcelierOptions|BE-Base|WebService|DONE options bound/validated with failure logging.|
|Mongo service wiring|BE-Base|Storage.Mongo|DONE wiring delegated to `AddMongoStorage`.|
|Bootstrapper execution on start|BE-Base|Storage.Mongo|DONE startup calls `MongoBootstrapper.InitializeAsync`.|
|Plugin host options finalization|BE-Base|Plugins|DONE default plugin directories/search patterns configured.|
|Jobs API contract tests|QA|Core|DONE WebServiceEndpointsTests now cover success payloads, filtering, and trigger outcome mapping.|
|Health/Ready probes|DevOps|Ops|DONE `/health` and `/ready` endpoints implemented.|
|Serilog + OTEL integration hooks|BE-Base|Observability|DONE `TelemetryExtensions` wires Serilog + OTEL with configurable exporters.|
|Register built-in jobs (sources/exporters)|BE-Base|Core|DONE AddBuiltInConcelierJobs adds fallback scheduler definitions for core connectors and exporters via reflection.|
|HTTP problem details consistency|BE-Base|WebService|DONE API errors now emit RFC7807 responses with trace identifiers and typed problem categories.|
|Request logging and metrics|BE-Base|Observability|DONE Serilog request logging enabled with enriched context and web.jobs counters published via OpenTelemetry.|
|Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.|
|Batch job definition last-run lookup|BE-Base|Core|DONE definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.|
|Add no-cache headers to health/readiness/jobs APIs|BE-Base|WebService|DONE helper applies Cache-Control/Pragma/Expires on all health/ready/jobs endpoints; awaiting automated probe tests once connector fixtures stabilize.|
|Authority configuration parity (FSR1)|DevEx/Concelier|Authority options schema|**DONE (2025-10-10)** Options post-config loads clientSecretFile fallback, validators normalize scopes/audiences, and sample config documents issuer/credential/bypass settings.|
|Document authority toggle & scope requirements|Docs/Concelier|Authority integration|**DOING (2025-10-10)** Quickstart updated with staging flag, client credentials, env overrides; operator guide refresh pending Docs guild review.|
|Plumb Authority client resilience options|BE-Base|Auth libraries LIB5|**DONE (2025-10-12)** `Program.cs` wires `authority.resilience.*` + client scopes into `AddStellaOpsAuthClient`; new integration test asserts binding and retries.|
|Author ops guidance for resilience tuning|Docs/Concelier|Plumb Authority client resilience options|**DONE (2025-10-12)** `docs/21_INSTALL_GUIDE.md` + `docs/ops/concelier-authority-audit-runbook.md` document resilience profiles for connected vs air-gapped installs and reference monitoring cues.|
|Document authority bypass logging patterns|Docs/Concelier|FSR3 logging|**DONE (2025-10-12)** Updated operator guides clarify `Concelier.Authorization.Audit` fields (route/status/subject/clientId/scopes/bypass/remote) and SIEM triggers.|
|Update Concelier operator guide for enforcement cutoff|Docs/Concelier|FSR1 rollout|**DONE (2025-10-12)** Installation guide emphasises disabling `allowAnonymousFallback` before 2025-12-31 UTC and connects audit signals to the rollout checklist.|
|Rename plugin drop directory to namespaced path|BE-Base|Plugins|**DONE (2025-10-19)** Build outputs now target `StellaOps.Concelier.PluginBinaries`/`StellaOps.Authority.PluginBinaries`, plugin host defaults updated, config/docs refreshed, and `dotnet test src/StellaOps.Concelier.WebService.Tests/StellaOps.Concelier.WebService.Tests.csproj --no-restore` covers the change.|
|Authority resilience adoption|Concelier WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|
|CONCELIER-WEB-08-201 Mirror distribution endpoints|Concelier WebService Guild|CONCELIER-EXPORT-08-201, DEVOPS-MIRROR-08-001|DOING (2025-10-19) HTTP endpoints wired (`/concelier/exports/index.json`, `/concelier/exports/mirror/*`), mirror options bound/validated, and integration tests added; pending auth docs + smoke in ops handbook.|
|Wave 0B readiness checkpoint|Team WebService & Authority|Wave0A completion|BLOCKED (2025-10-19) FEEDSTORAGE-MONGO-08-001 closed, but remaining Wave0A items (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open; maintain current DOING workstreams only.|