Resolve Concelier/Excititor merge conflicts
This commit is contained in:
		
							
								
								
									
										34
									
								
								src/StellaOps.Concelier.WebService/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/StellaOps.Concelier.WebService/AGENTS.md
									
									
									
									
									
										Normal 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. | ||||
| @@ -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); | ||||
							
								
								
									
										25
									
								
								src/StellaOps.Concelier.WebService/Diagnostics/JobMetrics.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/StellaOps.Concelier.WebService/Diagnostics/JobMetrics.cs
									
									
									
									
									
										Normal 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."); | ||||
| } | ||||
| @@ -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"; | ||||
| } | ||||
| @@ -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); | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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)); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -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)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/StellaOps.Concelier.WebService/Jobs/JobRunResponse.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/StellaOps.Concelier.WebService/Jobs/JobRunResponse.cs
									
									
									
									
									
										Normal 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); | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
							
								
								
									
										138
									
								
								src/StellaOps.Concelier.WebService/Options/ConcelierOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/StellaOps.Concelier.WebService/Options/ConcelierOptions.cs
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
| @@ -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"; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										770
									
								
								src/StellaOps.Concelier.WebService/Program.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										770
									
								
								src/StellaOps.Concelier.WebService/Program.cs
									
									
									
									
									
										Normal 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; | ||||
| @@ -0,0 +1,12 @@ | ||||
| { | ||||
|   "profiles": { | ||||
|     "StellaOps.Concelier.WebService": { | ||||
|       "commandName": "Project", | ||||
|       "launchBrowser": true, | ||||
|       "environmentVariables": { | ||||
|         "ASPNETCORE_ENVIRONMENT": "Development" | ||||
|       }, | ||||
|       "applicationUrl": "https://localhost:50411;http://localhost:50412" | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										184
									
								
								src/StellaOps.Concelier.WebService/Services/MirrorFileLocator.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/StellaOps.Concelier.WebService/Services/MirrorFileLocator.cs
									
									
									
									
									
										Normal 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); | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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> | ||||
							
								
								
									
										27
									
								
								src/StellaOps.Concelier.WebService/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/StellaOps.Concelier.WebService/TASKS.md
									
									
									
									
									
										Normal 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|Wave 0A completion|BLOCKED (2025-10-19) – FEEDSTORAGE-MONGO-08-001 closed, but remaining Wave 0A items (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open; maintain current DOING workstreams only.|  | ||||
		Reference in New Issue
	
	Block a user