Restructure solution layout by module
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user