audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,111 @@
using Microsoft.Extensions.Logging;
using StellaOps.Plugin.Abstractions.Context;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.Configuration.SettingsStore;
/// <summary>
/// A no-op plugin logger for standalone configuration providers.
/// </summary>
internal sealed class NullPluginLogger : IPluginLogger
{
/// <summary>
/// Singleton instance of the null logger.
/// </summary>
public static readonly NullPluginLogger Instance = new();
private NullPluginLogger() { }
/// <inheritdoc />
public void Log(LogLevel level, string message, params object[] args) { }
/// <inheritdoc />
public void Log(LogLevel level, Exception exception, string message, params object[] args) { }
/// <inheritdoc />
public IPluginLogger WithProperty(string name, object value) => this;
/// <inheritdoc />
public IPluginLogger ForOperation(string operationName) => this;
/// <inheritdoc />
public bool IsEnabled(LogLevel level) => false;
}
/// <summary>
/// A no-op secret resolver that returns null for all paths.
/// Used when secrets are provided directly in configuration rather than via references.
/// </summary>
internal sealed class NullSecretResolver : ISecretResolver
{
/// <summary>
/// Singleton instance of the null secret resolver.
/// </summary>
public static readonly NullSecretResolver Instance = new();
private NullSecretResolver() { }
/// <inheritdoc />
public Task<string?> ResolveAsync(string path, CancellationToken ct = default)
{
return Task.FromResult<string?>(null);
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<string, string>> ResolveManyAsync(
IEnumerable<string> paths,
CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyDictionary<string, string>>(
new Dictionary<string, string>());
}
}
/// <summary>
/// A secret resolver that uses environment variables.
/// Secret paths are treated as environment variable names.
/// </summary>
internal sealed class EnvironmentSecretResolver : ISecretResolver
{
/// <summary>
/// Singleton instance of the environment secret resolver.
/// </summary>
public static readonly EnvironmentSecretResolver Instance = new();
private EnvironmentSecretResolver() { }
/// <inheritdoc />
public Task<string?> ResolveAsync(string path, CancellationToken ct = default)
{
// Remove common prefixes like "env://" or "$"
var varName = path;
if (varName.StartsWith("env://", StringComparison.OrdinalIgnoreCase))
{
varName = varName[6..];
}
else if (varName.StartsWith("$", StringComparison.Ordinal))
{
varName = varName[1..];
}
var value = Environment.GetEnvironmentVariable(varName);
return Task.FromResult(value);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, string>> ResolveManyAsync(
IEnumerable<string> paths,
CancellationToken ct = default)
{
var results = new Dictionary<string, string>();
foreach (var path in paths)
{
var value = await ResolveAsync(path, ct);
if (value is not null)
{
results[path] = value;
}
}
return results;
}
}

View File

@@ -0,0 +1,197 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.SettingsStore;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.Configuration.SettingsStore.Providers;
/// <summary>
/// Options for configuring the AWS Parameter Store configuration provider.
/// </summary>
public sealed class AwsParameterStoreConfigurationOptions
{
/// <summary>
/// AWS region (e.g., "us-east-1").
/// </summary>
public string Region { get; set; } = "us-east-1";
/// <summary>
/// AWS access key ID (optional - can use instance profile).
/// </summary>
public string? AccessKeyId { get; set; }
/// <summary>
/// AWS secret access key (optional - can use instance profile).
/// </summary>
public string? SecretAccessKey { get; set; }
/// <summary>
/// Environment variable name containing the secret access key.
/// </summary>
public string? SecretAccessKeyEnvironmentVariable { get; set; }
/// <summary>
/// AWS session token for temporary credentials (optional).
/// </summary>
public string? SessionToken { get; set; }
/// <summary>
/// Parameter path prefix (e.g., "/myapp/config/").
/// </summary>
public string Path { get; set; } = "/";
/// <summary>
/// Whether to reload configuration when changes are detected.
/// Note: AWS Parameter Store uses polling, not push notifications.
/// </summary>
public bool ReloadOnChange { get; set; } = false;
/// <summary>
/// Polling interval for change detection.
/// </summary>
public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Whether the configuration source is optional.
/// </summary>
public bool Optional { get; set; } = false;
/// <summary>
/// Optional logger factory.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }
}
/// <summary>
/// Configuration source for AWS Parameter Store.
/// </summary>
public sealed class AwsParameterStoreConfigurationSource : SettingsStoreConfigurationSource
{
private readonly AwsParameterStoreConfigurationOptions _options;
/// <summary>
/// Creates a new AWS Parameter Store configuration source.
/// </summary>
public AwsParameterStoreConfigurationSource(AwsParameterStoreConfigurationOptions options)
{
_options = options;
Prefix = options.Path;
ReloadOnChange = options.ReloadOnChange;
ReloadInterval = options.ReloadInterval;
Optional = options.Optional;
LoggerFactory = options.LoggerFactory;
}
/// <inheritdoc />
protected override ISettingsStoreConnectorCapability CreateConnector()
{
return new AwsParameterStoreConnector();
}
/// <inheritdoc />
protected override ConnectorContext CreateContext()
{
var configObj = new Dictionary<string, object?>
{
["region"] = _options.Region
};
if (!string.IsNullOrEmpty(_options.AccessKeyId))
{
configObj["accessKeyId"] = _options.AccessKeyId;
}
// Resolve secret access key
var secretKey = _options.SecretAccessKey;
if (string.IsNullOrEmpty(secretKey) && !string.IsNullOrEmpty(_options.SecretAccessKeyEnvironmentVariable))
{
secretKey = Environment.GetEnvironmentVariable(_options.SecretAccessKeyEnvironmentVariable);
}
if (!string.IsNullOrEmpty(secretKey))
{
configObj["secretAccessKey"] = secretKey;
}
if (!string.IsNullOrEmpty(_options.SessionToken))
{
configObj["sessionToken"] = _options.SessionToken;
}
var configJson = JsonSerializer.Serialize(configObj);
var config = JsonDocument.Parse(configJson).RootElement;
return new ConnectorContext(
Guid.Empty,
Guid.Empty,
config,
NullSecretResolver.Instance,
NullPluginLogger.Instance);
}
/// <inheritdoc />
public override IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new AwsParameterStoreConfigurationProvider(this, CreateConnector(), CreateContext());
}
}
/// <summary>
/// Configuration provider that loads settings from AWS Parameter Store.
/// </summary>
public sealed class AwsParameterStoreConfigurationProvider : SettingsStoreConfigurationProvider
{
/// <summary>
/// Creates a new AWS Parameter Store configuration provider.
/// </summary>
public AwsParameterStoreConfigurationProvider(
SettingsStoreConfigurationSource source,
ISettingsStoreConnectorCapability connector,
ConnectorContext context)
: base(source, connector, context)
{
}
}
/// <summary>
/// Extension methods for adding AWS Parameter Store configuration.
/// </summary>
public static class AwsParameterStoreConfigurationExtensions
{
/// <summary>
/// Adds AWS Parameter Store as a configuration source.
/// </summary>
public static IConfigurationBuilder AddAwsParameterStore(
this IConfigurationBuilder builder,
string region,
string path = "/",
bool reloadOnChange = false,
Action<AwsParameterStoreConfigurationOptions>? configure = null)
{
var options = new AwsParameterStoreConfigurationOptions
{
Region = region,
Path = path,
ReloadOnChange = reloadOnChange
};
configure?.Invoke(options);
return builder.Add(new AwsParameterStoreConfigurationSource(options));
}
/// <summary>
/// Adds AWS Parameter Store as a configuration source using options.
/// </summary>
public static IConfigurationBuilder AddAwsParameterStore(
this IConfigurationBuilder builder,
Action<AwsParameterStoreConfigurationOptions> configure)
{
var options = new AwsParameterStoreConfigurationOptions();
configure(options);
return builder.Add(new AwsParameterStoreConfigurationSource(options));
}
}

View File

@@ -0,0 +1,218 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.SettingsStore;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.Configuration.SettingsStore.Providers;
/// <summary>
/// Options for configuring the Azure App Configuration provider.
/// </summary>
public sealed class AzureAppConfigurationOptions
{
/// <summary>
/// Connection string for Azure App Configuration.
/// Format: Endpoint=https://xxx.azconfig.io;Id=xxx;Secret=xxx
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Environment variable name containing the connection string.
/// </summary>
public string? ConnectionStringEnvironmentVariable { get; set; }
/// <summary>
/// Azure App Configuration endpoint (alternative to connection string).
/// </summary>
public string? Endpoint { get; set; }
/// <summary>
/// Credential ID for HMAC authentication (used with Endpoint).
/// </summary>
public string? Credential { get; set; }
/// <summary>
/// Secret for HMAC authentication (used with Endpoint).
/// </summary>
public string? Secret { get; set; }
/// <summary>
/// Environment variable name containing the secret.
/// </summary>
public string? SecretEnvironmentVariable { get; set; }
/// <summary>
/// Label to filter settings by (e.g., "production", "staging").
/// </summary>
public string? Label { get; set; }
/// <summary>
/// Key prefix to filter settings by.
/// </summary>
public string Prefix { get; set; } = string.Empty;
/// <summary>
/// Whether to reload configuration when changes are detected.
/// </summary>
public bool ReloadOnChange { get; set; } = true;
/// <summary>
/// Whether the configuration source is optional.
/// </summary>
public bool Optional { get; set; } = false;
/// <summary>
/// Optional logger factory.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }
}
/// <summary>
/// Configuration source for Azure App Configuration.
/// </summary>
public sealed class AzureAppConfigurationSource : SettingsStoreConfigurationSource
{
private readonly AzureAppConfigurationOptions _options;
/// <summary>
/// Creates a new Azure App Configuration source.
/// </summary>
public AzureAppConfigurationSource(AzureAppConfigurationOptions options)
{
_options = options;
Prefix = options.Prefix;
ReloadOnChange = options.ReloadOnChange;
Optional = options.Optional;
LoggerFactory = options.LoggerFactory;
}
/// <inheritdoc />
protected override ISettingsStoreConnectorCapability CreateConnector()
{
return new AzureAppConfigConnector();
}
/// <inheritdoc />
protected override ConnectorContext CreateContext()
{
var configObj = new Dictionary<string, object?>();
// Resolve connection string
var connectionString = _options.ConnectionString;
if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(_options.ConnectionStringEnvironmentVariable))
{
connectionString = Environment.GetEnvironmentVariable(_options.ConnectionStringEnvironmentVariable);
}
if (!string.IsNullOrEmpty(connectionString))
{
configObj["connectionString"] = connectionString;
}
else
{
// Use endpoint + credential + secret
if (!string.IsNullOrEmpty(_options.Endpoint))
{
configObj["endpoint"] = _options.Endpoint;
}
if (!string.IsNullOrEmpty(_options.Credential))
{
configObj["credential"] = _options.Credential;
}
// Resolve secret
var secret = _options.Secret;
if (string.IsNullOrEmpty(secret) && !string.IsNullOrEmpty(_options.SecretEnvironmentVariable))
{
secret = Environment.GetEnvironmentVariable(_options.SecretEnvironmentVariable);
}
if (!string.IsNullOrEmpty(secret))
{
configObj["secret"] = secret;
}
}
if (!string.IsNullOrEmpty(_options.Label))
{
configObj["label"] = _options.Label;
}
var configJson = JsonSerializer.Serialize(configObj);
var config = JsonDocument.Parse(configJson).RootElement;
return new ConnectorContext(
Guid.Empty,
Guid.Empty,
config,
NullSecretResolver.Instance,
NullPluginLogger.Instance);
}
/// <inheritdoc />
public override IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new AzureAppConfigurationProvider(this, CreateConnector(), CreateContext());
}
}
/// <summary>
/// Configuration provider that loads settings from Azure App Configuration.
/// </summary>
public sealed class AzureAppConfigurationProvider : SettingsStoreConfigurationProvider
{
/// <summary>
/// Creates a new Azure App Configuration provider.
/// </summary>
public AzureAppConfigurationProvider(
SettingsStoreConfigurationSource source,
ISettingsStoreConnectorCapability connector,
ConnectorContext context)
: base(source, connector, context)
{
}
}
/// <summary>
/// Extension methods for adding Azure App Configuration.
/// </summary>
public static class AzureAppConfigurationExtensions
{
/// <summary>
/// Adds Azure App Configuration as a configuration source using a connection string.
/// </summary>
public static IConfigurationBuilder AddAzureAppConfiguration(
this IConfigurationBuilder builder,
string connectionString,
string? label = null,
bool reloadOnChange = true,
Action<AzureAppConfigurationOptions>? configure = null)
{
var options = new AzureAppConfigurationOptions
{
ConnectionString = connectionString,
Label = label,
ReloadOnChange = reloadOnChange
};
configure?.Invoke(options);
return builder.Add(new AzureAppConfigurationSource(options));
}
/// <summary>
/// Adds Azure App Configuration as a configuration source using options.
/// </summary>
public static IConfigurationBuilder AddAzureAppConfiguration(
this IConfigurationBuilder builder,
Action<AzureAppConfigurationOptions> configure)
{
var options = new AzureAppConfigurationOptions();
configure(options);
return builder.Add(new AzureAppConfigurationSource(options));
}
}

View File

@@ -0,0 +1,170 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.SettingsStore;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.Configuration.SettingsStore.Providers;
/// <summary>
/// Options for configuring the Consul KV configuration provider.
/// </summary>
public sealed class ConsulConfigurationOptions
{
/// <summary>
/// Consul server address (e.g., "http://localhost:8500").
/// </summary>
public string Address { get; set; } = "http://localhost:8500";
/// <summary>
/// ACL token for authentication (optional).
/// </summary>
public string? Token { get; set; }
/// <summary>
/// Environment variable name containing the ACL token.
/// </summary>
public string? TokenEnvironmentVariable { get; set; }
/// <summary>
/// Key prefix to filter settings by (e.g., "myapp/config/").
/// </summary>
public string Prefix { get; set; } = string.Empty;
/// <summary>
/// Whether to reload configuration when changes are detected.
/// </summary>
public bool ReloadOnChange { get; set; } = true;
/// <summary>
/// Whether the configuration source is optional.
/// </summary>
public bool Optional { get; set; } = false;
/// <summary>
/// Optional logger factory.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }
}
/// <summary>
/// Configuration source for Consul KV.
/// </summary>
public sealed class ConsulConfigurationSource : SettingsStoreConfigurationSource
{
private readonly ConsulConfigurationOptions _options;
/// <summary>
/// Creates a new Consul configuration source.
/// </summary>
public ConsulConfigurationSource(ConsulConfigurationOptions options)
{
_options = options;
Prefix = options.Prefix;
ReloadOnChange = options.ReloadOnChange;
Optional = options.Optional;
LoggerFactory = options.LoggerFactory;
}
/// <inheritdoc />
protected override ISettingsStoreConnectorCapability CreateConnector()
{
return new ConsulKvConnector();
}
/// <inheritdoc />
protected override ConnectorContext CreateContext()
{
var configObj = new Dictionary<string, object?>
{
["address"] = _options.Address
};
// Resolve token
var token = _options.Token;
if (string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(_options.TokenEnvironmentVariable))
{
token = Environment.GetEnvironmentVariable(_options.TokenEnvironmentVariable);
}
if (!string.IsNullOrEmpty(token))
{
configObj["token"] = token;
}
var configJson = JsonSerializer.Serialize(configObj);
var config = JsonDocument.Parse(configJson).RootElement;
return new ConnectorContext(
Guid.Empty,
Guid.Empty,
config,
NullSecretResolver.Instance,
NullPluginLogger.Instance);
}
/// <inheritdoc />
public override IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new ConsulConfigurationProvider(this, CreateConnector(), CreateContext());
}
}
/// <summary>
/// Configuration provider that loads settings from Consul KV.
/// </summary>
public sealed class ConsulConfigurationProvider : SettingsStoreConfigurationProvider
{
/// <summary>
/// Creates a new Consul configuration provider.
/// </summary>
public ConsulConfigurationProvider(
SettingsStoreConfigurationSource source,
ISettingsStoreConnectorCapability connector,
ConnectorContext context)
: base(source, connector, context)
{
}
}
/// <summary>
/// Extension methods for adding Consul configuration.
/// </summary>
public static class ConsulConfigurationExtensions
{
/// <summary>
/// Adds Consul KV as a configuration source.
/// </summary>
public static IConfigurationBuilder AddConsulKv(
this IConfigurationBuilder builder,
string address,
string prefix = "",
bool reloadOnChange = true,
Action<ConsulConfigurationOptions>? configure = null)
{
var options = new ConsulConfigurationOptions
{
Address = address,
Prefix = prefix,
ReloadOnChange = reloadOnChange
};
configure?.Invoke(options);
return builder.Add(new ConsulConfigurationSource(options));
}
/// <summary>
/// Adds Consul KV as a configuration source using options.
/// </summary>
public static IConfigurationBuilder AddConsulKv(
this IConfigurationBuilder builder,
Action<ConsulConfigurationOptions> configure)
{
var options = new ConsulConfigurationOptions();
configure(options);
return builder.Add(new ConsulConfigurationSource(options));
}
}

View File

@@ -0,0 +1,218 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.ReleaseOrchestrator.IntegrationHub.Connectors.SettingsStore;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.Configuration.SettingsStore.Providers;
/// <summary>
/// Options for configuring the etcd configuration provider.
/// </summary>
public sealed class EtcdConfigurationOptions
{
/// <summary>
/// Single etcd server address (e.g., "http://localhost:2379").
/// Use either Address or Endpoints, not both.
/// </summary>
public string? Address { get; set; }
/// <summary>
/// Multiple etcd server endpoints for cluster configuration.
/// </summary>
public string[]? Endpoints { get; set; }
/// <summary>
/// Username for basic authentication (optional).
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Password for basic authentication (optional).
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Environment variable name containing the password.
/// </summary>
public string? PasswordEnvironmentVariable { get; set; }
/// <summary>
/// Key prefix to filter settings by (e.g., "/myapp/config/").
/// </summary>
public string Prefix { get; set; } = string.Empty;
/// <summary>
/// Whether to reload configuration when changes are detected.
/// </summary>
public bool ReloadOnChange { get; set; } = true;
/// <summary>
/// Whether the configuration source is optional.
/// </summary>
public bool Optional { get; set; } = false;
/// <summary>
/// Optional logger factory.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }
}
/// <summary>
/// Configuration source for etcd.
/// </summary>
public sealed class EtcdConfigurationSource : SettingsStoreConfigurationSource
{
private readonly EtcdConfigurationOptions _options;
/// <summary>
/// Creates a new etcd configuration source.
/// </summary>
public EtcdConfigurationSource(EtcdConfigurationOptions options)
{
_options = options;
Prefix = options.Prefix;
ReloadOnChange = options.ReloadOnChange;
Optional = options.Optional;
LoggerFactory = options.LoggerFactory;
}
/// <inheritdoc />
protected override ISettingsStoreConnectorCapability CreateConnector()
{
return new EtcdConnector();
}
/// <inheritdoc />
protected override ConnectorContext CreateContext()
{
var configObj = new Dictionary<string, object?>();
if (!string.IsNullOrEmpty(_options.Address))
{
configObj["address"] = _options.Address;
}
else if (_options.Endpoints is { Length: > 0 })
{
configObj["endpoints"] = _options.Endpoints;
}
else
{
configObj["address"] = "http://localhost:2379";
}
if (!string.IsNullOrEmpty(_options.Username))
{
configObj["username"] = _options.Username;
}
// Resolve password
var password = _options.Password;
if (string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(_options.PasswordEnvironmentVariable))
{
password = Environment.GetEnvironmentVariable(_options.PasswordEnvironmentVariable);
}
if (!string.IsNullOrEmpty(password))
{
configObj["password"] = password;
}
var configJson = JsonSerializer.Serialize(configObj);
var config = JsonDocument.Parse(configJson).RootElement;
return new ConnectorContext(
Guid.Empty,
Guid.Empty,
config,
NullSecretResolver.Instance,
NullPluginLogger.Instance);
}
/// <inheritdoc />
public override IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new EtcdConfigurationProvider(this, CreateConnector(), CreateContext());
}
}
/// <summary>
/// Configuration provider that loads settings from etcd.
/// </summary>
public sealed class EtcdConfigurationProvider : SettingsStoreConfigurationProvider
{
/// <summary>
/// Creates a new etcd configuration provider.
/// </summary>
public EtcdConfigurationProvider(
SettingsStoreConfigurationSource source,
ISettingsStoreConnectorCapability connector,
ConnectorContext context)
: base(source, connector, context)
{
}
}
/// <summary>
/// Extension methods for adding etcd configuration.
/// </summary>
public static class EtcdConfigurationExtensions
{
/// <summary>
/// Adds etcd as a configuration source.
/// </summary>
public static IConfigurationBuilder AddEtcd(
this IConfigurationBuilder builder,
string address,
string prefix = "",
bool reloadOnChange = true,
Action<EtcdConfigurationOptions>? configure = null)
{
var options = new EtcdConfigurationOptions
{
Address = address,
Prefix = prefix,
ReloadOnChange = reloadOnChange
};
configure?.Invoke(options);
return builder.Add(new EtcdConfigurationSource(options));
}
/// <summary>
/// Adds etcd as a configuration source with multiple endpoints.
/// </summary>
public static IConfigurationBuilder AddEtcd(
this IConfigurationBuilder builder,
string[] endpoints,
string prefix = "",
bool reloadOnChange = true,
Action<EtcdConfigurationOptions>? configure = null)
{
var options = new EtcdConfigurationOptions
{
Endpoints = endpoints,
Prefix = prefix,
ReloadOnChange = reloadOnChange
};
configure?.Invoke(options);
return builder.Add(new EtcdConfigurationSource(options));
}
/// <summary>
/// Adds etcd as a configuration source using options.
/// </summary>
public static IConfigurationBuilder AddEtcd(
this IConfigurationBuilder builder,
Action<EtcdConfigurationOptions> configure)
{
var options = new EtcdConfigurationOptions();
configure(options);
return builder.Add(new EtcdConfigurationSource(options));
}
}

View File

@@ -0,0 +1,231 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.Configuration.SettingsStore;
/// <summary>
/// Base configuration source for settings store providers.
/// </summary>
public abstract class SettingsStoreConfigurationSource : IConfigurationSource
{
/// <summary>
/// The prefix to filter settings by (e.g., "myapp/").
/// </summary>
public string Prefix { get; set; } = string.Empty;
/// <summary>
/// Whether to reload configuration when changes are detected.
/// </summary>
public bool ReloadOnChange { get; set; } = true;
/// <summary>
/// Interval between polling for changes (when connector doesn't support native watch).
/// </summary>
public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Optional logger factory for diagnostics.
/// </summary>
public ILoggerFactory? LoggerFactory { get; set; }
/// <summary>
/// Whether the configuration source is optional (won't throw if unavailable).
/// </summary>
public bool Optional { get; set; } = false;
/// <summary>
/// Key separator to use when normalizing keys for .NET configuration.
/// Settings store keys like "app/db/connection" become "app:db:connection".
/// </summary>
public char KeySeparator { get; set; } = '/';
/// <summary>
/// Creates the settings store connector for this source.
/// </summary>
protected abstract ISettingsStoreConnectorCapability CreateConnector();
/// <summary>
/// Creates the connector context with configuration and secret resolver.
/// </summary>
protected abstract ConnectorContext CreateContext();
/// <inheritdoc />
public abstract IConfigurationProvider Build(IConfigurationBuilder builder);
}
/// <summary>
/// Base configuration provider for settings stores with hot-reload support.
/// </summary>
public abstract class SettingsStoreConfigurationProvider : ConfigurationProvider, IDisposable
{
private readonly SettingsStoreConfigurationSource _source;
private readonly ISettingsStoreConnectorCapability _connector;
private readonly ConnectorContext _context;
private readonly ILogger? _logger;
private CancellationTokenSource? _watchCts;
private Task? _watchTask;
private bool _disposed;
/// <summary>
/// Creates a new settings store configuration provider.
/// </summary>
protected SettingsStoreConfigurationProvider(
SettingsStoreConfigurationSource source,
ISettingsStoreConnectorCapability connector,
ConnectorContext context)
{
_source = source;
_connector = connector;
_context = context;
_logger = source.LoggerFactory?.CreateLogger(GetType());
}
/// <inheritdoc />
public override void Load()
{
try
{
LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
if (_source.ReloadOnChange && _connector.SupportsWatch)
{
StartWatching();
}
}
catch (Exception ex)
{
if (!_source.Optional)
{
throw new InvalidOperationException(
$"Failed to load configuration from {_connector.DisplayName}: {ex.Message}", ex);
}
_logger?.LogWarning(ex, "Optional configuration source {ConnectorType} failed to load",
_connector.ConnectorType);
}
}
private async Task LoadAsync(CancellationToken ct)
{
var settings = await _connector.GetSettingsAsync(_context, _source.Prefix, ct);
var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var setting in settings)
{
var key = NormalizeKey(setting.Key);
data[key] = setting.Value;
}
Data = data;
_logger?.LogDebug("Loaded {Count} settings from {ConnectorType} with prefix '{Prefix}'",
settings.Count, _connector.ConnectorType, _source.Prefix);
}
private void StartWatching()
{
_watchCts = new CancellationTokenSource();
_watchTask = Task.Run(async () =>
{
try
{
await foreach (var change in _connector.WatchAsync(_context, _source.Prefix, _watchCts.Token))
{
HandleChange(change);
}
}
catch (OperationCanceledException) when (_watchCts.Token.IsCancellationRequested)
{
// Expected during shutdown
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error watching {ConnectorType} for changes", _connector.ConnectorType);
}
});
}
private void HandleChange(SettingChange change)
{
var key = NormalizeKey(change.Key);
switch (change.ChangeType)
{
case SettingChangeType.Created:
case SettingChangeType.Updated:
if (change.NewValue is not null)
{
Data[key] = change.NewValue.Value;
_logger?.LogDebug("Configuration key '{Key}' {ChangeType}", key, change.ChangeType);
}
break;
case SettingChangeType.Deleted:
Data.Remove(key);
_logger?.LogDebug("Configuration key '{Key}' deleted", key);
break;
}
OnReload();
}
/// <summary>
/// Normalizes a settings store key to .NET configuration format.
/// Replaces the key separator with colons and removes the prefix.
/// </summary>
protected string NormalizeKey(string key)
{
// Remove prefix if present
if (!string.IsNullOrEmpty(_source.Prefix) && key.StartsWith(_source.Prefix, StringComparison.Ordinal))
{
key = key[_source.Prefix.Length..];
}
// Trim leading separator
key = key.TrimStart(_source.KeySeparator);
// Replace separator with colon (standard .NET configuration separator)
return key.Replace(_source.KeySeparator, ':');
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes resources used by the provider.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
_watchCts?.Cancel();
_watchCts?.Dispose();
try
{
_watchTask?.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
// Ignore cancellation exceptions
}
if (_connector is IDisposable disposable)
{
disposable.Dispose();
}
}
_disposed = true;
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Configuration.SettingsStore</RootNamespace>
<Description>Configuration providers for settings stores (Consul, etcd, Azure App Config, AWS Parameter Store)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Plugin\StellaOps.ReleaseOrchestrator.Plugin.csproj" />
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.IntegrationHub\StellaOps.ReleaseOrchestrator.IntegrationHub.csproj" />
</ItemGroup>
</Project>