audit, advisories and doctors/setup work
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user