audit, advisories and doctors/setup work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
96
src/__Libraries/StellaOps.FeatureFlags/FeatureFlagModels.cs
Normal file
96
src/__Libraries/StellaOps.FeatureFlags/FeatureFlagModels.cs
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user