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,302 @@
using System.Collections.Concurrent;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.FeatureFlags;
/// <summary>
/// Composite feature flag service that aggregates flags from multiple providers.
/// Providers are checked in priority order; first match wins.
/// </summary>
public sealed class CompositeFeatureFlagService : IFeatureFlagService, IDisposable
{
private readonly IReadOnlyList<IFeatureFlagProvider> _providers;
private readonly FeatureFlagOptions _options;
private readonly ILogger<CompositeFeatureFlagService> _logger;
private readonly MemoryCache _cache;
private readonly Subject<FeatureFlagChangedEvent> _changeSubject;
private readonly CancellationTokenSource _watchCts;
private readonly List<Task> _watchTasks;
private bool _disposed;
/// <summary>
/// Creates a new composite feature flag service.
/// </summary>
public CompositeFeatureFlagService(
IEnumerable<IFeatureFlagProvider> providers,
IOptions<FeatureFlagOptions> options,
ILogger<CompositeFeatureFlagService> logger)
{
_providers = providers.OrderBy(p => p.Priority).ToList();
_options = options.Value;
_logger = logger;
_cache = new MemoryCache(new MemoryCacheOptions());
_changeSubject = new Subject<FeatureFlagChangedEvent>();
_watchCts = new CancellationTokenSource();
_watchTasks = [];
// Start watching providers that support it
StartWatching();
_logger.LogInformation(
"CompositeFeatureFlagService initialized with {ProviderCount} providers: {Providers}",
_providers.Count,
string.Join(", ", _providers.Select(p => $"{p.Name}(priority={p.Priority})")));
}
/// <inheritdoc />
public IObservable<FeatureFlagChangedEvent> OnFlagChanged => _changeSubject.AsObservable();
/// <inheritdoc />
public Task<bool> IsEnabledAsync(string flagKey, CancellationToken ct = default)
{
return IsEnabledAsync(flagKey, FeatureFlagEvaluationContext.Empty, ct);
}
/// <inheritdoc />
public async Task<bool> IsEnabledAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default)
{
var result = await EvaluateAsync(flagKey, context, ct);
return result.Enabled;
}
/// <inheritdoc />
public async Task<FeatureFlagResult> EvaluateAsync(
string flagKey,
FeatureFlagEvaluationContext? context = null,
CancellationToken ct = default)
{
context ??= FeatureFlagEvaluationContext.Empty;
// Check cache first
var cacheKey = GetCacheKey(flagKey, context);
if (_options.EnableCaching && _cache.TryGetValue(cacheKey, out FeatureFlagResult? cached) && cached is not null)
{
if (_options.EnableLogging)
{
_logger.LogDebug("Flag '{FlagKey}' returned from cache: {Enabled}", flagKey, cached.Enabled);
}
return cached;
}
// Try each provider in priority order
foreach (var provider in _providers)
{
try
{
var result = await provider.TryGetFlagAsync(flagKey, context, ct);
if (result is not null)
{
// Cache the result
if (_options.EnableCaching)
{
_cache.Set(cacheKey, result, _options.CacheDuration);
}
if (_options.EnableLogging)
{
_logger.LogDebug(
"Flag '{FlagKey}' evaluated by {Provider}: Enabled={Enabled}, Reason={Reason}",
flagKey, provider.Name, result.Enabled, result.Reason);
}
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Provider {Provider} failed to evaluate flag '{FlagKey}'",
provider.Name, flagKey);
}
}
// No provider had the flag, return default
var defaultResult = new FeatureFlagResult(
flagKey,
_options.DefaultValue,
null,
"Flag not found in any provider",
"default");
if (_options.EnableLogging)
{
_logger.LogDebug(
"Flag '{FlagKey}' not found, using default: {Default}",
flagKey, _options.DefaultValue);
}
return defaultResult;
}
/// <inheritdoc />
public async Task<T> GetVariantAsync<T>(
string flagKey,
T defaultValue,
FeatureFlagEvaluationContext? context = null,
CancellationToken ct = default)
{
var result = await EvaluateAsync(flagKey, context, ct);
if (result.Variant is null)
{
return defaultValue;
}
try
{
// Handle direct type match
if (result.Variant is T typedValue)
{
return typedValue;
}
// Handle JSON string variant
if (result.Variant is string jsonString)
{
return JsonSerializer.Deserialize<T>(jsonString) ?? defaultValue;
}
// Handle JsonElement variant
if (result.Variant is JsonElement jsonElement)
{
return JsonSerializer.Deserialize<T>(jsonElement.GetRawText()) ?? defaultValue;
}
// Try conversion
return (T)Convert.ChangeType(result.Variant, typeof(T));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to convert variant for flag '{FlagKey}' to type {Type}",
flagKey, typeof(T).Name);
return defaultValue;
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(CancellationToken ct = default)
{
var allFlags = new Dictionary<string, FeatureFlagDefinition>();
foreach (var provider in _providers)
{
try
{
var flags = await provider.ListFlagsAsync(ct);
foreach (var flag in flags)
{
// First provider to define a flag wins (priority order)
if (!allFlags.ContainsKey(flag.Key))
{
allFlags[flag.Key] = flag;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Provider {Provider} failed to list flags", provider.Name);
}
}
return allFlags.Values.OrderBy(f => f.Key).ToList();
}
/// <inheritdoc />
public void InvalidateCache(string? flagKey = null)
{
if (flagKey is null)
{
// Clear all cached values
_cache.Compact(1.0);
_logger.LogDebug("All feature flag cache entries invalidated");
}
else
{
// We can't easily invalidate a single key with all contexts,
// so we compact the entire cache
_cache.Compact(1.0);
_logger.LogDebug("Feature flag cache invalidated for key '{FlagKey}'", flagKey);
}
}
private void StartWatching()
{
foreach (var provider in _providers.Where(p => p.SupportsWatch))
{
var task = Task.Run(async () =>
{
try
{
await foreach (var change in provider.WatchAsync(_watchCts.Token))
{
// Invalidate cache for changed flag
InvalidateCache(change.Key);
// Publish change event
_changeSubject.OnNext(change);
if (_options.EnableLogging)
{
_logger.LogInformation(
"Flag '{FlagKey}' changed from {OldValue} to {NewValue} (source: {Source})",
change.Key, change.OldValue, change.NewValue, change.Source);
}
}
}
catch (OperationCanceledException) when (_watchCts.Token.IsCancellationRequested)
{
// Expected during shutdown
}
catch (Exception ex)
{
_logger.LogError(ex, "Error watching provider {Provider}", provider.Name);
}
});
_watchTasks.Add(task);
}
}
private static string GetCacheKey(string flagKey, FeatureFlagEvaluationContext context)
{
// Include relevant context in cache key
var contextHash = HashCode.Combine(
context.UserId,
context.TenantId,
context.Environment);
return $"ff:{flagKey}:{contextHash}";
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_watchCts.Cancel();
_watchCts.Dispose();
try
{
Task.WaitAll([.. _watchTasks], TimeSpan.FromSeconds(5));
}
catch (AggregateException)
{
// Ignore cancellation exceptions
}
_changeSubject.Dispose();
_cache.Dispose();
_disposed = true;
}
}

View File

@@ -0,0 +1,96 @@
namespace StellaOps.FeatureFlags;
/// <summary>
/// Result of a feature flag evaluation.
/// </summary>
/// <param name="Key">The feature flag key.</param>
/// <param name="Enabled">Whether the flag is enabled.</param>
/// <param name="Variant">Optional variant value for multivariate flags.</param>
/// <param name="Reason">Explanation of why the flag evaluated to this value.</param>
/// <param name="Source">The provider that returned this value.</param>
public sealed record FeatureFlagResult(
string Key,
bool Enabled,
object? Variant = null,
string? Reason = null,
string? Source = null);
/// <summary>
/// Context for evaluating feature flags with targeting.
/// </summary>
/// <param name="UserId">Optional user identifier for user-based targeting.</param>
/// <param name="TenantId">Optional tenant identifier for multi-tenant targeting.</param>
/// <param name="Environment">Optional environment name (dev, staging, prod).</param>
/// <param name="Attributes">Additional attributes for custom targeting rules.</param>
public sealed record FeatureFlagEvaluationContext(
string? UserId = null,
string? TenantId = null,
string? Environment = null,
IReadOnlyDictionary<string, object>? Attributes = null)
{
/// <summary>
/// Empty evaluation context with no targeting information.
/// </summary>
public static readonly FeatureFlagEvaluationContext Empty = new();
}
/// <summary>
/// Definition of a feature flag.
/// </summary>
/// <param name="Key">Unique identifier for the flag.</param>
/// <param name="Description">Human-readable description.</param>
/// <param name="DefaultValue">Default value when no rules match.</param>
/// <param name="Enabled">Whether the flag is globally enabled.</param>
/// <param name="Tags">Optional tags for categorization.</param>
public sealed record FeatureFlagDefinition(
string Key,
string? Description = null,
bool DefaultValue = false,
bool Enabled = true,
IReadOnlyList<string>? Tags = null);
/// <summary>
/// Event raised when a feature flag value changes.
/// </summary>
/// <param name="Key">The feature flag key that changed.</param>
/// <param name="OldValue">Previous enabled state.</param>
/// <param name="NewValue">New enabled state.</param>
/// <param name="Source">The provider that detected the change.</param>
/// <param name="Timestamp">When the change was detected.</param>
public sealed record FeatureFlagChangedEvent(
string Key,
bool OldValue,
bool NewValue,
string Source,
DateTimeOffset Timestamp);
/// <summary>
/// Options for configuring the feature flag service.
/// </summary>
public sealed class FeatureFlagOptions
{
/// <summary>
/// Default value when a flag is not found in any provider.
/// </summary>
public bool DefaultValue { get; set; } = false;
/// <summary>
/// Whether to cache flag evaluations.
/// </summary>
public bool EnableCaching { get; set; } = true;
/// <summary>
/// Cache duration for flag values.
/// </summary>
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether to log flag evaluations.
/// </summary>
public bool EnableLogging { get; set; } = true;
/// <summary>
/// Whether to emit metrics for flag evaluations.
/// </summary>
public bool EnableMetrics { get; set; } = true;
}

View File

@@ -0,0 +1,176 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.FeatureFlags.Providers;
namespace StellaOps.FeatureFlags;
/// <summary>
/// Extension methods for configuring feature flag services.
/// </summary>
public static class FeatureFlagServiceCollectionExtensions
{
/// <summary>
/// Adds the feature flag service with default options.
/// </summary>
public static IServiceCollection AddFeatureFlags(
this IServiceCollection services)
{
return services.AddFeatureFlags(_ => { });
}
/// <summary>
/// Adds the feature flag service with configuration.
/// </summary>
public static IServiceCollection AddFeatureFlags(
this IServiceCollection services,
Action<FeatureFlagOptions> configure)
{
services.Configure(configure);
services.TryAddSingleton<IFeatureFlagService, CompositeFeatureFlagService>();
return services;
}
/// <summary>
/// Adds a configuration-based feature flag provider.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="sectionName">Configuration section name (default: "FeatureFlags").</param>
/// <param name="priority">Provider priority (lower = higher priority).</param>
public static IServiceCollection AddConfigurationFeatureFlags(
this IServiceCollection services,
string sectionName = "FeatureFlags",
int priority = 50)
{
services.AddSingleton<IFeatureFlagProvider>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
return new ConfigurationFeatureFlagProvider(configuration, sectionName, priority);
});
return services;
}
/// <summary>
/// Adds a custom feature flag provider.
/// </summary>
public static IServiceCollection AddFeatureFlagProvider<TProvider>(
this IServiceCollection services)
where TProvider : class, IFeatureFlagProvider
{
services.AddSingleton<IFeatureFlagProvider, TProvider>();
return services;
}
/// <summary>
/// Adds a custom feature flag provider using a factory.
/// </summary>
public static IServiceCollection AddFeatureFlagProvider(
this IServiceCollection services,
Func<IServiceProvider, IFeatureFlagProvider> factory)
{
services.AddSingleton(factory);
return services;
}
/// <summary>
/// Adds an in-memory feature flag provider for testing.
/// </summary>
public static IServiceCollection AddInMemoryFeatureFlags(
this IServiceCollection services,
IDictionary<string, bool> flags,
int priority = 0)
{
services.AddSingleton<IFeatureFlagProvider>(
new InMemoryFeatureFlagProvider(flags, priority));
return services;
}
}
/// <summary>
/// In-memory feature flag provider for testing and overrides.
/// </summary>
public sealed class InMemoryFeatureFlagProvider : FeatureFlagProviderBase
{
private readonly Dictionary<string, bool> _flags;
private readonly Dictionary<string, object?> _variants;
/// <summary>
/// Creates a new in-memory provider with the specified flags.
/// </summary>
public InMemoryFeatureFlagProvider(
IDictionary<string, bool> flags,
int priority = 0)
{
_flags = new Dictionary<string, bool>(flags, StringComparer.OrdinalIgnoreCase);
_variants = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
Priority = priority;
}
/// <inheritdoc />
public override string Name => "InMemory";
/// <inheritdoc />
public override int Priority { get; }
/// <inheritdoc />
public override Task<FeatureFlagResult?> TryGetFlagAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default)
{
if (_flags.TryGetValue(flagKey, out var enabled))
{
_variants.TryGetValue(flagKey, out var variant);
return Task.FromResult<FeatureFlagResult?>(
CreateResult(flagKey, enabled, variant, "From in-memory provider"));
}
return Task.FromResult<FeatureFlagResult?>(null);
}
/// <inheritdoc />
public override Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(
CancellationToken ct = default)
{
var flags = _flags.Select(kvp => new FeatureFlagDefinition(
kvp.Key,
null,
kvp.Value,
kvp.Value)).ToList();
return Task.FromResult<IReadOnlyList<FeatureFlagDefinition>>(flags);
}
/// <summary>
/// Sets a flag value.
/// </summary>
public void SetFlag(string key, bool enabled, object? variant = null)
{
_flags[key] = enabled;
if (variant is not null)
{
_variants[key] = variant;
}
}
/// <summary>
/// Removes a flag.
/// </summary>
public void RemoveFlag(string key)
{
_flags.Remove(key);
_variants.Remove(key);
}
/// <summary>
/// Clears all flags.
/// </summary>
public void Clear()
{
_flags.Clear();
_variants.Clear();
}
}

View File

@@ -0,0 +1,99 @@
namespace StellaOps.FeatureFlags;
/// <summary>
/// Provider that supplies feature flag values from a specific source.
/// Providers are ordered by priority in the composite service.
/// </summary>
public interface IFeatureFlagProvider
{
/// <summary>
/// Unique name identifying this provider.
/// </summary>
string Name { get; }
/// <summary>
/// Priority order (lower = higher priority, checked first).
/// </summary>
int Priority { get; }
/// <summary>
/// Whether this provider supports watching for changes.
/// </summary>
bool SupportsWatch { get; }
/// <summary>
/// Tries to get the value of a feature flag.
/// </summary>
/// <param name="flagKey">The feature flag key.</param>
/// <param name="context">Evaluation context for targeting.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The flag result, or null if this provider doesn't have the flag.</returns>
Task<FeatureFlagResult?> TryGetFlagAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default);
/// <summary>
/// Lists all feature flags known to this provider.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>All flag definitions from this provider.</returns>
Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(CancellationToken ct = default);
/// <summary>
/// Watches for changes to feature flags.
/// Only called if SupportsWatch is true.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Stream of change events.</returns>
IAsyncEnumerable<FeatureFlagChangedEvent> WatchAsync(CancellationToken ct = default);
}
/// <summary>
/// Base class for feature flag providers with common functionality.
/// </summary>
public abstract class FeatureFlagProviderBase : IFeatureFlagProvider
{
/// <inheritdoc />
public abstract string Name { get; }
/// <inheritdoc />
public virtual int Priority => 100;
/// <inheritdoc />
public virtual bool SupportsWatch => false;
/// <inheritdoc />
public abstract Task<FeatureFlagResult?> TryGetFlagAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default);
/// <inheritdoc />
public virtual Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(
CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<FeatureFlagDefinition>>([]);
}
/// <inheritdoc />
public virtual async IAsyncEnumerable<FeatureFlagChangedEvent> WatchAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
// Default implementation does nothing
await Task.CompletedTask;
yield break;
}
/// <summary>
/// Creates a successful flag result.
/// </summary>
protected FeatureFlagResult CreateResult(
string key,
bool enabled,
object? variant = null,
string? reason = null)
{
return new FeatureFlagResult(key, enabled, variant, reason, Name);
}
}

View File

@@ -0,0 +1,74 @@
namespace StellaOps.FeatureFlags;
/// <summary>
/// Central service for evaluating feature flags.
/// Aggregates flags from multiple providers with priority ordering.
/// </summary>
public interface IFeatureFlagService
{
/// <summary>
/// Checks if a feature flag is enabled using the default context.
/// </summary>
/// <param name="flagKey">The feature flag key.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the flag is enabled, false otherwise.</returns>
Task<bool> IsEnabledAsync(string flagKey, CancellationToken ct = default);
/// <summary>
/// Checks if a feature flag is enabled for a specific context.
/// </summary>
/// <param name="flagKey">The feature flag key.</param>
/// <param name="context">Evaluation context for targeting.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the flag is enabled for the context.</returns>
Task<bool> IsEnabledAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default);
/// <summary>
/// Gets the full evaluation result for a feature flag.
/// </summary>
/// <param name="flagKey">The feature flag key.</param>
/// <param name="context">Optional evaluation context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Full evaluation result including reason and source.</returns>
Task<FeatureFlagResult> EvaluateAsync(
string flagKey,
FeatureFlagEvaluationContext? context = null,
CancellationToken ct = default);
/// <summary>
/// Gets the variant value for a multivariate feature flag.
/// </summary>
/// <typeparam name="T">Expected variant type.</typeparam>
/// <param name="flagKey">The feature flag key.</param>
/// <param name="defaultValue">Default value if flag not found or variant is null.</param>
/// <param name="context">Optional evaluation context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The variant value or default.</returns>
Task<T> GetVariantAsync<T>(
string flagKey,
T defaultValue,
FeatureFlagEvaluationContext? context = null,
CancellationToken ct = default);
/// <summary>
/// Lists all known feature flags across all providers.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>All feature flag definitions.</returns>
Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(CancellationToken ct = default);
/// <summary>
/// Observable stream of feature flag change events.
/// Subscribe to receive notifications when flags change.
/// </summary>
IObservable<FeatureFlagChangedEvent> OnFlagChanged { get; }
/// <summary>
/// Invalidates cached values for a specific flag.
/// </summary>
/// <param name="flagKey">The flag key to invalidate, or null to invalidate all.</param>
void InvalidateCache(string? flagKey = null);
}

View File

@@ -0,0 +1,239 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace StellaOps.FeatureFlags.Providers;
/// <summary>
/// Feature flag provider that reads flags from IConfiguration.
/// Supports simple boolean flags and structured flag definitions.
/// </summary>
/// <remarks>
/// Configuration format:
/// <code>
/// {
/// "FeatureFlags": {
/// "MyFeature": true,
/// "MyComplexFeature": {
/// "Enabled": true,
/// "Variant": "blue"
/// }
/// }
/// }
/// </code>
/// </remarks>
public sealed class ConfigurationFeatureFlagProvider : FeatureFlagProviderBase, IDisposable
{
private readonly IConfiguration _configuration;
private readonly string _sectionName;
private readonly Dictionary<string, bool> _lastValues = new();
private readonly IDisposable? _changeToken;
private Action<FeatureFlagChangedEvent>? _changeCallback;
/// <summary>
/// Creates a new configuration-based feature flag provider.
/// </summary>
/// <param name="configuration">The configuration root.</param>
/// <param name="sectionName">Configuration section name (default: "FeatureFlags").</param>
/// <param name="priority">Provider priority (default: 50).</param>
public ConfigurationFeatureFlagProvider(
IConfiguration configuration,
string sectionName = "FeatureFlags",
int priority = 50)
{
_configuration = configuration;
_sectionName = sectionName;
Priority = priority;
// Initialize last values
InitializeLastValues();
// Watch for configuration changes
_changeToken = ChangeToken.OnChange(
() => _configuration.GetReloadToken(),
OnConfigurationChanged);
}
/// <inheritdoc />
public override string Name => "Configuration";
/// <inheritdoc />
public override int Priority { get; }
/// <inheritdoc />
public override bool SupportsWatch => true;
/// <inheritdoc />
public override Task<FeatureFlagResult?> TryGetFlagAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default)
{
var section = _configuration.GetSection($"{_sectionName}:{flagKey}");
if (!section.Exists())
{
return Task.FromResult<FeatureFlagResult?>(null);
}
// Check if it's a simple boolean value
if (bool.TryParse(section.Value, out var boolValue))
{
return Task.FromResult<FeatureFlagResult?>(
CreateResult(flagKey, boolValue, null, "From configuration (boolean)"));
}
// Check if it's a structured definition
var enabled = section.GetValue<bool?>("Enabled") ?? section.GetValue<bool?>("enabled");
if (enabled.HasValue)
{
var variant = section.GetValue<string?>("Variant") ?? section.GetValue<string?>("variant");
return Task.FromResult<FeatureFlagResult?>(
CreateResult(flagKey, enabled.Value, variant, "From configuration (structured)"));
}
// Treat any non-empty value as enabled
if (!string.IsNullOrEmpty(section.Value))
{
return Task.FromResult<FeatureFlagResult?>(
CreateResult(flagKey, true, section.Value, "From configuration (value)"));
}
return Task.FromResult<FeatureFlagResult?>(null);
}
/// <inheritdoc />
public override Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(
CancellationToken ct = default)
{
var section = _configuration.GetSection(_sectionName);
var flags = new List<FeatureFlagDefinition>();
foreach (var child in section.GetChildren())
{
var key = child.Key;
bool defaultValue = false;
string? description = null;
if (bool.TryParse(child.Value, out var boolValue))
{
defaultValue = boolValue;
}
else if (child.GetChildren().Any())
{
defaultValue = child.GetValue<bool?>("Enabled") ?? child.GetValue<bool?>("enabled") ?? false;
description = child.GetValue<string?>("Description") ?? child.GetValue<string?>("description");
}
flags.Add(new FeatureFlagDefinition(
key,
description,
defaultValue,
defaultValue));
}
return Task.FromResult<IReadOnlyList<FeatureFlagDefinition>>(flags);
}
/// <inheritdoc />
public override async IAsyncEnumerable<FeatureFlagChangedEvent> WatchAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
var channel = System.Threading.Channels.Channel.CreateUnbounded<FeatureFlagChangedEvent>();
_changeCallback = evt => channel.Writer.TryWrite(evt);
try
{
await foreach (var evt in channel.Reader.ReadAllAsync(ct))
{
yield return evt;
}
}
finally
{
_changeCallback = null;
channel.Writer.Complete();
}
}
private void InitializeLastValues()
{
var section = _configuration.GetSection(_sectionName);
foreach (var child in section.GetChildren())
{
var value = GetFlagValue(child);
if (value.HasValue)
{
_lastValues[child.Key] = value.Value;
}
}
}
private void OnConfigurationChanged()
{
var section = _configuration.GetSection(_sectionName);
var currentKeys = new HashSet<string>();
foreach (var child in section.GetChildren())
{
currentKeys.Add(child.Key);
var newValue = GetFlagValue(child);
if (newValue.HasValue)
{
if (_lastValues.TryGetValue(child.Key, out var oldValue))
{
if (oldValue != newValue.Value)
{
// Value changed
_lastValues[child.Key] = newValue.Value;
NotifyChange(child.Key, oldValue, newValue.Value);
}
}
else
{
// New flag
_lastValues[child.Key] = newValue.Value;
NotifyChange(child.Key, false, newValue.Value);
}
}
}
// Check for deleted flags
foreach (var key in _lastValues.Keys.Except(currentKeys).ToList())
{
var oldValue = _lastValues[key];
_lastValues.Remove(key);
NotifyChange(key, oldValue, false);
}
}
private static bool? GetFlagValue(IConfigurationSection section)
{
if (bool.TryParse(section.Value, out var boolValue))
{
return boolValue;
}
var enabled = section.GetValue<bool?>("Enabled") ?? section.GetValue<bool?>("enabled");
return enabled;
}
private void NotifyChange(string key, bool oldValue, bool newValue)
{
var evt = new FeatureFlagChangedEvent(
key,
oldValue,
newValue,
Name,
DateTimeOffset.UtcNow);
_changeCallback?.Invoke(evt);
}
/// <inheritdoc />
public void Dispose()
{
_changeToken?.Dispose();
}
}

View File

@@ -0,0 +1,241 @@
using System.Runtime.CompilerServices;
using StellaOps.ReleaseOrchestrator.Plugin.Capabilities;
using StellaOps.ReleaseOrchestrator.Plugin.Models;
namespace StellaOps.FeatureFlags.Providers;
/// <summary>
/// Feature flag provider that reads flags from a settings store connector.
/// Works with connectors that support native feature flags (Azure App Config, AWS AppConfig).
/// </summary>
public sealed class SettingsStoreFeatureFlagProvider : FeatureFlagProviderBase, IDisposable
{
private readonly ISettingsStoreConnectorCapability _connector;
private readonly ConnectorContext _context;
private readonly string _providerName;
private readonly Dictionary<string, bool> _lastValues = new();
private Action<FeatureFlagChangedEvent>? _changeCallback;
private CancellationTokenSource? _watchCts;
private Task? _watchTask;
private bool _disposed;
/// <summary>
/// Creates a new settings store feature flag provider.
/// </summary>
/// <param name="connector">The settings store connector.</param>
/// <param name="context">The connector context.</param>
/// <param name="providerName">Display name for this provider.</param>
/// <param name="priority">Provider priority (default: 100).</param>
public SettingsStoreFeatureFlagProvider(
ISettingsStoreConnectorCapability connector,
ConnectorContext context,
string? providerName = null,
int priority = 100)
{
_connector = connector;
_context = context;
_providerName = providerName ?? connector.DisplayName;
Priority = priority;
if (!connector.SupportsFeatureFlags)
{
throw new ArgumentException(
$"Connector '{connector.ConnectorType}' does not support native feature flags. " +
"Use ConfigurationFeatureFlagProvider with a convention-based approach instead.",
nameof(connector));
}
}
/// <inheritdoc />
public override string Name => _providerName;
/// <inheritdoc />
public override int Priority { get; }
/// <inheritdoc />
public override bool SupportsWatch => _connector.SupportsWatch;
/// <inheritdoc />
public override async Task<FeatureFlagResult?> TryGetFlagAsync(
string flagKey,
FeatureFlagEvaluationContext context,
CancellationToken ct = default)
{
// Convert our context to the connector's context format
var connectorContext = new FeatureFlagContext(
context.UserId,
context.TenantId,
context.Attributes?.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? string.Empty));
var result = await _connector.GetFeatureFlagAsync(
_context,
flagKey,
connectorContext,
ct);
if (result is null)
{
return null;
}
return new FeatureFlagResult(
result.Key,
result.Enabled,
result.Variant,
result.EvaluationReason,
Name);
}
/// <inheritdoc />
public override async Task<IReadOnlyList<FeatureFlagDefinition>> ListFlagsAsync(
CancellationToken ct = default)
{
var flags = await _connector.ListFeatureFlagsAsync(_context, ct);
return flags.Select(f => new FeatureFlagDefinition(
f.Key,
f.Description,
f.DefaultValue,
f.DefaultValue,
null)).ToList();
}
/// <inheritdoc />
public override async IAsyncEnumerable<FeatureFlagChangedEvent> WatchAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
if (!_connector.SupportsWatch)
{
yield break;
}
var channel = System.Threading.Channels.Channel.CreateUnbounded<FeatureFlagChangedEvent>();
_changeCallback = evt => channel.Writer.TryWrite(evt);
// Initialize last values
try
{
var flags = await _connector.ListFeatureFlagsAsync(_context, ct);
foreach (var flag in flags)
{
_lastValues[flag.Key] = flag.DefaultValue;
}
}
catch
{
// Ignore initialization errors
}
// Start watching for changes in the background
_watchCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_watchTask = WatchSettingsAsync(_watchCts.Token);
try
{
await foreach (var evt in channel.Reader.ReadAllAsync(ct))
{
yield return evt;
}
}
finally
{
_changeCallback = null;
_watchCts?.Cancel();
channel.Writer.Complete();
}
}
private async Task WatchSettingsAsync(CancellationToken ct)
{
// Poll for flag changes since settings store watch is for KV, not flags
var pollInterval = TimeSpan.FromSeconds(30);
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(pollInterval, ct);
var flags = await _connector.ListFeatureFlagsAsync(_context, ct);
var currentKeys = new HashSet<string>();
foreach (var flag in flags)
{
currentKeys.Add(flag.Key);
if (_lastValues.TryGetValue(flag.Key, out var oldValue))
{
if (oldValue != flag.DefaultValue)
{
_lastValues[flag.Key] = flag.DefaultValue;
NotifyChange(flag.Key, oldValue, flag.DefaultValue);
}
}
else
{
_lastValues[flag.Key] = flag.DefaultValue;
NotifyChange(flag.Key, false, flag.DefaultValue);
}
}
// Check for deleted flags
foreach (var key in _lastValues.Keys.Except(currentKeys).ToList())
{
var oldValue = _lastValues[key];
_lastValues.Remove(key);
NotifyChange(key, oldValue, false);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch
{
// Ignore errors and retry
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}
private void NotifyChange(string key, bool oldValue, bool newValue)
{
var evt = new FeatureFlagChangedEvent(
key,
oldValue,
newValue,
Name,
DateTimeOffset.UtcNow);
_changeCallback?.Invoke(evt);
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_watchCts?.Cancel();
_watchCts?.Dispose();
try
{
_watchTask?.Wait(TimeSpan.FromSeconds(5));
}
catch
{
// Ignore
}
if (_connector is IDisposable disposable)
{
disposable.Dispose();
}
_disposed = true;
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.FeatureFlags</RootNamespace>
<Description>Centralized feature flag service with multi-provider support</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.Reactive" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Plugin\StellaOps.ReleaseOrchestrator.Plugin.csproj" />
</ItemGroup>
</Project>