audit, advisories and doctors/setup work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,18 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net40;net452</TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<UseXunitV3></UseXunitV3>
|
||||
<NoWarn>CS0104;CS0168;CS0219;CS0414;CS0649;CS8600;CS8602;CS8603;CS8604</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Security" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="nunit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" PrivateAssets="All" />
|
||||
<PackageReference Include="nunit" Version="4.3.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.1.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit.v3" Version="3.2.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using StellaOps.Doctor.Engine;
|
||||
using StellaOps.Doctor.Export;
|
||||
using StellaOps.Doctor.Output;
|
||||
using StellaOps.Doctor.Packs;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Doctor.Resolver;
|
||||
|
||||
namespace StellaOps.Doctor.DependencyInjection;
|
||||
|
||||
@@ -22,16 +25,26 @@ public static class DoctorServiceCollectionExtensions
|
||||
services.TryAddSingleton<CheckExecutor>();
|
||||
services.TryAddSingleton<DoctorEngine>();
|
||||
|
||||
// Pack loader and command runner
|
||||
services.TryAddSingleton<IDoctorPackCommandRunner, DoctorPackCommandRunner>();
|
||||
services.TryAddSingleton<DoctorPackLoader>();
|
||||
|
||||
// Default formatters
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, TextReportFormatter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, JsonReportFormatter>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDoctorReportFormatter, MarkdownReportFormatter>());
|
||||
services.TryAddSingleton<ReportFormatterFactory>();
|
||||
services.TryAddSingleton<DoctorEvidenceLogWriter>();
|
||||
|
||||
// Export services
|
||||
services.TryAddSingleton<ConfigurationSanitizer>();
|
||||
services.TryAddSingleton<DiagnosticBundleGenerator>();
|
||||
|
||||
// Runtime detection and remediation services
|
||||
services.TryAddSingleton<IRuntimeDetector, RuntimeDetector>();
|
||||
services.TryAddSingleton<IPlaceholderResolver, PlaceholderResolver>();
|
||||
services.TryAddSingleton<IVerificationExecutor, VerificationExecutor>();
|
||||
|
||||
// Ensure TimeProvider is registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace StellaOps.Doctor.Detection;
|
||||
|
||||
/// <summary>
|
||||
/// Detects the runtime environment where Stella Ops is deployed.
|
||||
/// Used to generate runtime-specific remediation commands.
|
||||
/// </summary>
|
||||
public interface IRuntimeDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects the current runtime environment.
|
||||
/// </summary>
|
||||
/// <returns>The detected runtime environment.</returns>
|
||||
RuntimeEnvironment Detect();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Docker is available on the system.
|
||||
/// </summary>
|
||||
bool IsDockerAvailable();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if running within a Kubernetes cluster.
|
||||
/// </summary>
|
||||
bool IsKubernetesContext();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific service is managed by systemd.
|
||||
/// </summary>
|
||||
/// <param name="serviceName">The name of the service to check.</param>
|
||||
bool IsSystemdManaged(string serviceName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Docker Compose project path if available.
|
||||
/// </summary>
|
||||
/// <returns>The compose file path, or null if not found.</returns>
|
||||
string? GetComposeProjectPath();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current Kubernetes namespace.
|
||||
/// </summary>
|
||||
/// <returns>The namespace, or null if not in Kubernetes.</returns>
|
||||
string? GetKubernetesNamespace();
|
||||
|
||||
/// <summary>
|
||||
/// Gets environment-specific context values.
|
||||
/// </summary>
|
||||
/// <returns>Dictionary of context key-value pairs.</returns>
|
||||
IReadOnlyDictionary<string, string> GetContextValues();
|
||||
}
|
||||
339
src/__Libraries/StellaOps.Doctor/Detection/RuntimeDetector.cs
Normal file
339
src/__Libraries/StellaOps.Doctor/Detection/RuntimeDetector.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Doctor.Detection;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IRuntimeDetector"/>.
|
||||
/// Detects Docker Compose, Kubernetes, systemd, and Windows Service environments.
|
||||
/// </summary>
|
||||
public sealed class RuntimeDetector : IRuntimeDetector
|
||||
{
|
||||
private readonly ILogger<RuntimeDetector> _logger;
|
||||
private readonly Lazy<RuntimeEnvironment> _detectedRuntime;
|
||||
private readonly Lazy<IReadOnlyDictionary<string, string>> _contextValues;
|
||||
|
||||
private static readonly string[] ComposeFileNames =
|
||||
[
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"compose.yml",
|
||||
"compose.yaml"
|
||||
];
|
||||
|
||||
private static readonly string[] ComposeSearchPaths =
|
||||
[
|
||||
".",
|
||||
"..",
|
||||
"devops/compose",
|
||||
"../devops/compose"
|
||||
];
|
||||
|
||||
public RuntimeDetector(ILogger<RuntimeDetector> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_detectedRuntime = new Lazy<RuntimeEnvironment>(DetectInternal);
|
||||
_contextValues = new Lazy<IReadOnlyDictionary<string, string>>(BuildContextValues);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RuntimeEnvironment Detect() => _detectedRuntime.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDockerAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check for docker command
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "docker.exe" : "docker",
|
||||
Arguments = "--version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return false;
|
||||
|
||||
process.WaitForExit(5000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Docker availability check failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsKubernetesContext()
|
||||
{
|
||||
// Check for KUBERNETES_SERVICE_HOST environment variable
|
||||
var kubeHost = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST");
|
||||
if (!string.IsNullOrEmpty(kubeHost))
|
||||
{
|
||||
_logger.LogDebug("Detected Kubernetes via KUBERNETES_SERVICE_HOST: {Host}", kubeHost);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for kubeconfig file
|
||||
var kubeConfig = Environment.GetEnvironmentVariable("KUBECONFIG");
|
||||
if (!string.IsNullOrEmpty(kubeConfig) && File.Exists(kubeConfig))
|
||||
{
|
||||
_logger.LogDebug("Detected Kubernetes via KUBECONFIG: {Path}", kubeConfig);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check default kubeconfig location
|
||||
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var defaultKubeConfig = Path.Combine(homeDir, ".kube", "config");
|
||||
if (File.Exists(defaultKubeConfig))
|
||||
{
|
||||
_logger.LogDebug("Detected Kubernetes via default kubeconfig: {Path}", defaultKubeConfig);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSystemdManaged(string serviceName)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "systemctl",
|
||||
Arguments = $"is-enabled {serviceName}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return false;
|
||||
|
||||
process.WaitForExit(5000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "systemd check failed for service {ServiceName}", serviceName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetComposeProjectPath()
|
||||
{
|
||||
// Check COMPOSE_FILE environment variable
|
||||
var composeFile = Environment.GetEnvironmentVariable("COMPOSE_FILE");
|
||||
if (!string.IsNullOrEmpty(composeFile) && File.Exists(composeFile))
|
||||
{
|
||||
return composeFile;
|
||||
}
|
||||
|
||||
// Search common locations
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
foreach (var searchPath in ComposeSearchPaths)
|
||||
{
|
||||
var searchDir = Path.GetFullPath(Path.Combine(currentDir, searchPath));
|
||||
if (!Directory.Exists(searchDir)) continue;
|
||||
|
||||
foreach (var fileName in ComposeFileNames)
|
||||
{
|
||||
var fullPath = Path.Combine(searchDir, fileName);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogDebug("Found Docker Compose file at: {Path}", fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? GetKubernetesNamespace()
|
||||
{
|
||||
// Check environment variable
|
||||
var ns = Environment.GetEnvironmentVariable("KUBERNETES_NAMESPACE");
|
||||
if (!string.IsNullOrEmpty(ns))
|
||||
{
|
||||
return ns;
|
||||
}
|
||||
|
||||
// Check namespace file (mounted in pods)
|
||||
const string namespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace";
|
||||
if (File.Exists(namespaceFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ReadAllText(namespaceFile).Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read Kubernetes namespace file");
|
||||
}
|
||||
}
|
||||
|
||||
// Default namespace
|
||||
return "stellaops";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetContextValues() => _contextValues.Value;
|
||||
|
||||
private RuntimeEnvironment DetectInternal()
|
||||
{
|
||||
_logger.LogDebug("Detecting runtime environment...");
|
||||
|
||||
// Check if running in Docker container
|
||||
if (File.Exists("/.dockerenv"))
|
||||
{
|
||||
_logger.LogInformation("Detected Docker container environment");
|
||||
return RuntimeEnvironment.DockerCompose;
|
||||
}
|
||||
|
||||
// Check for Kubernetes
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST")))
|
||||
{
|
||||
_logger.LogInformation("Detected Kubernetes environment");
|
||||
return RuntimeEnvironment.Kubernetes;
|
||||
}
|
||||
|
||||
// Check for Docker Compose
|
||||
if (IsDockerAvailable() && GetComposeProjectPath() != null)
|
||||
{
|
||||
_logger.LogInformation("Detected Docker Compose environment");
|
||||
return RuntimeEnvironment.DockerCompose;
|
||||
}
|
||||
|
||||
// Check for systemd (Linux)
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "systemctl",
|
||||
Arguments = "--version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process != null)
|
||||
{
|
||||
process.WaitForExit(5000);
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Detected systemd environment");
|
||||
return RuntimeEnvironment.Systemd;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "systemd detection failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Windows Service
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// If running as a service, parent process is services.exe
|
||||
try
|
||||
{
|
||||
using var current = Process.GetCurrentProcess();
|
||||
var parentId = GetParentProcessId(current.Id);
|
||||
if (parentId > 0)
|
||||
{
|
||||
using var parent = Process.GetProcessById(parentId);
|
||||
if (parent.ProcessName.Equals("services", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Detected Windows Service environment");
|
||||
return RuntimeEnvironment.WindowsService;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Windows Service detection failed");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Detected bare/manual environment");
|
||||
return RuntimeEnvironment.Bare;
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string> BuildContextValues()
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var runtime = Detect();
|
||||
|
||||
// Common values
|
||||
values["RUNTIME"] = runtime.ToString();
|
||||
|
||||
switch (runtime)
|
||||
{
|
||||
case RuntimeEnvironment.DockerCompose:
|
||||
var composePath = GetComposeProjectPath();
|
||||
if (composePath != null)
|
||||
{
|
||||
values["COMPOSE_FILE"] = composePath;
|
||||
}
|
||||
var projectName = Environment.GetEnvironmentVariable("COMPOSE_PROJECT_NAME") ?? "stellaops";
|
||||
values["COMPOSE_PROJECT_NAME"] = projectName;
|
||||
break;
|
||||
|
||||
case RuntimeEnvironment.Kubernetes:
|
||||
var ns = GetKubernetesNamespace() ?? "stellaops";
|
||||
values["NAMESPACE"] = ns;
|
||||
var kubeHost = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST");
|
||||
if (kubeHost != null)
|
||||
{
|
||||
values["KUBERNETES_HOST"] = kubeHost;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Database defaults
|
||||
values["DB_HOST"] = Environment.GetEnvironmentVariable("STELLAOPS_DB_HOST") ?? "localhost";
|
||||
values["DB_PORT"] = Environment.GetEnvironmentVariable("STELLAOPS_DB_PORT") ?? "5432";
|
||||
|
||||
// Valkey defaults
|
||||
values["VALKEY_HOST"] = Environment.GetEnvironmentVariable("STELLAOPS_VALKEY_HOST") ?? "localhost";
|
||||
values["VALKEY_PORT"] = Environment.GetEnvironmentVariable("STELLAOPS_VALKEY_PORT") ?? "6379";
|
||||
|
||||
// Vault defaults
|
||||
var vaultAddr = Environment.GetEnvironmentVariable("VAULT_ADDR");
|
||||
if (vaultAddr != null)
|
||||
{
|
||||
values["VAULT_ADDR"] = vaultAddr;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static int GetParentProcessId(int processId)
|
||||
{
|
||||
// Skip parent process detection - not reliable across platforms
|
||||
// Windows Service detection is done via other signals
|
||||
_ = processId; // Suppress unused parameter warning
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.Doctor.Detection;
|
||||
|
||||
/// <summary>
|
||||
/// The runtime environment where Stella Ops is deployed.
|
||||
/// Used to generate runtime-specific remediation commands.
|
||||
/// </summary>
|
||||
public enum RuntimeEnvironment
|
||||
{
|
||||
/// <summary>Docker Compose deployment.</summary>
|
||||
DockerCompose,
|
||||
|
||||
/// <summary>Kubernetes deployment.</summary>
|
||||
Kubernetes,
|
||||
|
||||
/// <summary>systemd-managed services (Linux).</summary>
|
||||
Systemd,
|
||||
|
||||
/// <summary>Windows Service deployment.</summary>
|
||||
WindowsService,
|
||||
|
||||
/// <summary>Bare metal / manual deployment.</summary>
|
||||
Bare,
|
||||
|
||||
/// <summary>Commands that work in any environment.</summary>
|
||||
Any
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Packs;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Engine;
|
||||
@@ -11,29 +12,34 @@ namespace StellaOps.Doctor.Engine;
|
||||
public sealed class CheckRegistry
|
||||
{
|
||||
private readonly IEnumerable<IDoctorPlugin> _plugins;
|
||||
private readonly DoctorPackLoader _packLoader;
|
||||
private readonly ILogger<CheckRegistry> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new check registry.
|
||||
/// </summary>
|
||||
public CheckRegistry(IEnumerable<IDoctorPlugin> plugins, ILogger<CheckRegistry> logger)
|
||||
public CheckRegistry(
|
||||
IEnumerable<IDoctorPlugin> plugins,
|
||||
DoctorPackLoader packLoader,
|
||||
ILogger<CheckRegistry> logger)
|
||||
{
|
||||
_plugins = plugins;
|
||||
_packLoader = packLoader;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all plugins that are available in the current environment.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IDoctorPlugin> GetAvailablePlugins(IServiceProvider services)
|
||||
public IReadOnlyList<IDoctorPlugin> GetAvailablePlugins(DoctorPluginContext context)
|
||||
{
|
||||
var available = new List<IDoctorPlugin>();
|
||||
|
||||
foreach (var plugin in _plugins)
|
||||
foreach (var plugin in GetAllPlugins(context))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (plugin.IsAvailable(services))
|
||||
if (plugin.IsAvailable(context.Services))
|
||||
{
|
||||
available.Add(plugin);
|
||||
_logger.LogDebug("Plugin {PluginId} is available", plugin.PluginId);
|
||||
@@ -62,7 +68,7 @@ public sealed class CheckRegistry
|
||||
DoctorPluginContext context,
|
||||
DoctorRunOptions options)
|
||||
{
|
||||
var plugins = GetFilteredPlugins(context.Services, options);
|
||||
var plugins = GetFilteredPlugins(context, options);
|
||||
var checks = new List<(IDoctorCheck, string, string)>();
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
@@ -104,10 +110,10 @@ public sealed class CheckRegistry
|
||||
}
|
||||
|
||||
private IEnumerable<IDoctorPlugin> GetFilteredPlugins(
|
||||
IServiceProvider services,
|
||||
DoctorPluginContext context,
|
||||
DoctorRunOptions options)
|
||||
{
|
||||
var plugins = GetAvailablePlugins(services);
|
||||
var plugins = GetAvailablePlugins(context);
|
||||
|
||||
// Filter by category
|
||||
if (options.Categories is { Count: > 0 })
|
||||
@@ -128,9 +134,52 @@ public sealed class CheckRegistry
|
||||
plugins = plugins.Where(p => pluginIds.Contains(p.PluginId)).ToImmutableArray();
|
||||
}
|
||||
|
||||
// Filter by pack names or labels
|
||||
if (options.Packs is { Count: > 0 })
|
||||
{
|
||||
var packNames = options.Packs.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
plugins = plugins.Where(p => IsPackMatch(p, packNames)).ToImmutableArray();
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private IReadOnlyList<IDoctorPlugin> GetAllPlugins(DoctorPluginContext context)
|
||||
{
|
||||
var packPlugins = _packLoader.LoadPlugins(context);
|
||||
return _plugins.Concat(packPlugins).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsPackMatch(IDoctorPlugin plugin, ISet<string> packNames)
|
||||
{
|
||||
if (packNames.Contains(plugin.PluginId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (plugin is not IDoctorPackMetadata metadata)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.PackName) && packNames.Contains(metadata.PackName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Module) && packNames.Contains(metadata.Module))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Integration) && packNames.Contains(metadata.Integration))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private IEnumerable<IDoctorCheck> FilterChecks(
|
||||
IEnumerable<IDoctorCheck> checks,
|
||||
DoctorRunOptions options)
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Output;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Engine;
|
||||
@@ -19,6 +20,7 @@ public sealed class DoctorEngine
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DoctorEvidenceLogWriter _evidenceLogWriter;
|
||||
private readonly ILogger<DoctorEngine> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,6 +32,7 @@ public sealed class DoctorEngine
|
||||
IServiceProvider services,
|
||||
IConfiguration configuration,
|
||||
TimeProvider timeProvider,
|
||||
DoctorEvidenceLogWriter evidenceLogWriter,
|
||||
ILogger<DoctorEngine> logger)
|
||||
{
|
||||
_registry = registry;
|
||||
@@ -37,6 +40,7 @@ public sealed class DoctorEngine
|
||||
_services = services;
|
||||
_configuration = configuration;
|
||||
_timeProvider = timeProvider;
|
||||
_evidenceLogWriter = evidenceLogWriter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,7 +54,9 @@ public sealed class DoctorEngine
|
||||
{
|
||||
options ??= new DoctorRunOptions();
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var runId = GenerateRunId(startTime);
|
||||
var runId = string.IsNullOrWhiteSpace(options.RunId)
|
||||
? GenerateRunId(startTime)
|
||||
: options.RunId.Trim();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting doctor run {RunId} with mode {Mode}, parallelism {Parallelism}",
|
||||
@@ -63,7 +69,9 @@ public sealed class DoctorEngine
|
||||
|
||||
if (checks.Count == 0)
|
||||
{
|
||||
return CreateEmptyReport(runId, startTime);
|
||||
var emptyReport = CreateEmptyReport(runId, startTime);
|
||||
await _evidenceLogWriter.WriteAsync(emptyReport, options, ct);
|
||||
return emptyReport;
|
||||
}
|
||||
|
||||
var results = await _executor.ExecuteAsync(checks, context, options, progress, ct);
|
||||
@@ -71,6 +79,8 @@ public sealed class DoctorEngine
|
||||
var endTime = _timeProvider.GetUtcNow();
|
||||
var report = CreateReport(runId, results, startTime, endTime);
|
||||
|
||||
await _evidenceLogWriter.WriteAsync(report, options, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Doctor run {RunId} completed: {Passed} passed, {Warnings} warnings, {Failed} failed, {Skipped} skipped",
|
||||
runId, report.Summary.Passed, report.Summary.Warnings, report.Summary.Failed, report.Summary.Skipped);
|
||||
@@ -94,7 +104,7 @@ public sealed class DoctorEngine
|
||||
public IReadOnlyList<DoctorPluginMetadata> ListPlugins()
|
||||
{
|
||||
var context = CreateContext(new DoctorRunOptions());
|
||||
var plugins = _registry.GetAvailablePlugins(_services);
|
||||
var plugins = _registry.GetAvailablePlugins(context);
|
||||
|
||||
return plugins
|
||||
.Select(p => DoctorPluginMetadata.FromPlugin(p, context))
|
||||
@@ -106,7 +116,8 @@ public sealed class DoctorEngine
|
||||
/// </summary>
|
||||
public IReadOnlyList<DoctorCategory> GetAvailableCategories()
|
||||
{
|
||||
var plugins = _registry.GetAvailablePlugins(_services);
|
||||
var context = CreateContext(new DoctorRunOptions());
|
||||
var plugins = _registry.GetAvailablePlugins(context);
|
||||
|
||||
return plugins
|
||||
.Select(p => p.Category)
|
||||
|
||||
@@ -20,6 +20,11 @@ public enum DoctorRunMode
|
||||
/// </summary>
|
||||
public sealed record DoctorRunOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional run identifier. When set, overrides auto-generated run IDs.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run mode (quick, normal, or full).
|
||||
/// </summary>
|
||||
@@ -31,10 +36,15 @@ public sealed record DoctorRunOptions
|
||||
public IReadOnlyList<string>? Categories { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by plugin IDs. If null or empty, all plugins are included.
|
||||
/// Filter by plugin IDs. If null or empty, all plugins are included.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by pack names or labels. If null or empty, all packs are included.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Packs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Run specific checks by ID. If set, other filters are ignored.
|
||||
/// </summary>
|
||||
@@ -61,10 +71,15 @@ public sealed record DoctorRunOptions
|
||||
public bool IncludeRemediation { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID for multi-tenant checks. If null, runs in system context.
|
||||
/// Tenant ID for multi-tenant checks. If null, runs in system context.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Command used to invoke the run (for evidence logs).
|
||||
/// </summary>
|
||||
public string? DoctorCommand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default options for a quick check.
|
||||
/// </summary>
|
||||
|
||||
36
src/__Libraries/StellaOps.Doctor/Models/LikelyCause.cs
Normal file
36
src/__Libraries/StellaOps.Doctor/Models/LikelyCause.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A likely cause for a failed or warning check, with priority ranking.
|
||||
/// </summary>
|
||||
public sealed record LikelyCause
|
||||
{
|
||||
/// <summary>
|
||||
/// Priority of this cause (1 = most likely).
|
||||
/// Lower numbers should be investigated first.
|
||||
/// </summary>
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of the likely cause.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional URL to documentation explaining this cause and fix.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a likely cause with the specified priority and description.
|
||||
/// </summary>
|
||||
public static LikelyCause Create(int priority, string description, string? documentationUrl = null)
|
||||
{
|
||||
return new LikelyCause
|
||||
{
|
||||
Priority = priority,
|
||||
Description = description,
|
||||
DocumentationUrl = documentationUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Doctor.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A runtime-specific remediation command.
|
||||
/// </summary>
|
||||
public sealed record RemediationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// The runtime environment this command applies to.
|
||||
/// </summary>
|
||||
public required RuntimeEnvironment Runtime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The command to execute.
|
||||
/// May contain placeholders like {{HOST}} or {{NAMESPACE}}.
|
||||
/// </summary>
|
||||
public required string Command { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description of what this command does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this command requires sudo/administrator privileges.
|
||||
/// </summary>
|
||||
public bool RequiresSudo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this command is dangerous and requires user confirmation.
|
||||
/// Examples: database migrations, service restarts, data deletion.
|
||||
/// </summary>
|
||||
public bool IsDangerous { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Placeholders in the command that need values.
|
||||
/// Key is the placeholder name (e.g., "HOST"), value is the default or description.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Placeholders { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional warning message for dangerous commands.
|
||||
/// </summary>
|
||||
public string? DangerWarning { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended remediation with runtime-specific commands and likely causes.
|
||||
/// </summary>
|
||||
public sealed record WizardRemediation
|
||||
{
|
||||
/// <summary>
|
||||
/// Likely causes of the issue, ordered by priority.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<LikelyCause> LikelyCauses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-specific remediation commands.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RemediationCommand> Commands { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Command to verify the fix was applied correctly.
|
||||
/// May contain placeholders.
|
||||
/// </summary>
|
||||
public string? VerificationCommand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets commands for a specific runtime environment.
|
||||
/// Falls back to commands marked as <see cref="RuntimeEnvironment.Any"/>.
|
||||
/// </summary>
|
||||
public IEnumerable<RemediationCommand> GetCommandsForRuntime(RuntimeEnvironment runtime)
|
||||
{
|
||||
// First return exact matches
|
||||
foreach (var cmd in Commands.Where(c => c.Runtime == runtime))
|
||||
{
|
||||
yield return cmd;
|
||||
}
|
||||
|
||||
// Then return universal commands
|
||||
foreach (var cmd in Commands.Where(c => c.Runtime == RuntimeEnvironment.Any))
|
||||
{
|
||||
yield return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty remediation.
|
||||
/// </summary>
|
||||
public static WizardRemediation Empty => new()
|
||||
{
|
||||
LikelyCauses = [],
|
||||
Commands = []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Paths to evidence artifacts produced by a doctor run.
|
||||
/// </summary>
|
||||
public sealed record DoctorEvidenceArtifacts
|
||||
{
|
||||
/// <summary>
|
||||
/// Full path to the JSONL evidence log.
|
||||
/// </summary>
|
||||
public string? JsonlPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full path to the DSSE summary, if emitted.
|
||||
/// </summary>
|
||||
public string? DssePath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Unicode;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Canonicalization.Json;
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.Output;
|
||||
|
||||
/// <summary>
|
||||
/// Writes JSONL evidence logs and optional DSSE summaries for doctor runs.
|
||||
/// </summary>
|
||||
public sealed class DoctorEvidenceLogWriter
|
||||
{
|
||||
private const string DefaultJsonlTemplate = "artifacts/doctor/doctor-run-{runId}.ndjson";
|
||||
private const string DefaultDsseTemplate = "artifacts/doctor/doctor-run-{runId}.dsse.json";
|
||||
private const string DefaultPayloadType = "application/vnd.stellaops.doctor.summary+json";
|
||||
private const string DefaultDoctorCommand = "stella doctor run";
|
||||
private const string Redacted = "[REDACTED]";
|
||||
private static readonly byte[] LineBreak = [(byte)'\n'];
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin),
|
||||
Indented = false
|
||||
};
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHostEnvironment? _hostEnvironment;
|
||||
private readonly ILogger<DoctorEvidenceLogWriter> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new evidence log writer.
|
||||
/// </summary>
|
||||
public DoctorEvidenceLogWriter(
|
||||
IConfiguration configuration,
|
||||
ILogger<DoctorEvidenceLogWriter> logger,
|
||||
IHostEnvironment? hostEnvironment = null)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_hostEnvironment = hostEnvironment;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes evidence artifacts for a doctor report.
|
||||
/// </summary>
|
||||
public async Task<DoctorEvidenceArtifacts> WriteAsync(
|
||||
DoctorReport report,
|
||||
DoctorRunOptions options,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
if (!ReadBool("Doctor:Evidence:Enabled", true))
|
||||
{
|
||||
return new DoctorEvidenceArtifacts();
|
||||
}
|
||||
|
||||
var outputRoot = ResolveOutputRoot();
|
||||
var doctorCommand = ResolveDoctorCommand(options);
|
||||
var includeEvidence = ReadBool("Doctor:Evidence:IncludeEvidence", true);
|
||||
var redactSensitive = ReadBool("Doctor:Evidence:RedactSensitive", true);
|
||||
|
||||
var jsonlTemplate = ResolveTemplate("Doctor:Evidence:JsonlPath", DefaultJsonlTemplate, report.RunId);
|
||||
var jsonlPath = ResolvePath(outputRoot, jsonlTemplate);
|
||||
|
||||
string? dssePath = null;
|
||||
try
|
||||
{
|
||||
await WriteJsonlAsync(jsonlPath, report, doctorCommand, includeEvidence, redactSensitive, ct);
|
||||
|
||||
if (ReadBool("Doctor:Evidence:Dsse:Enabled", false))
|
||||
{
|
||||
var dsseTemplate = ResolveTemplate("Doctor:Evidence:Dsse:Path", DefaultDsseTemplate, report.RunId);
|
||||
dssePath = ResolvePath(outputRoot, dsseTemplate);
|
||||
await WriteDsseSummaryAsync(
|
||||
dssePath,
|
||||
report,
|
||||
doctorCommand,
|
||||
jsonlPath,
|
||||
jsonlTemplate,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write doctor evidence artifacts for run {RunId}", report.RunId);
|
||||
}
|
||||
|
||||
return new DoctorEvidenceArtifacts
|
||||
{
|
||||
JsonlPath = jsonlPath,
|
||||
DssePath = dssePath
|
||||
};
|
||||
}
|
||||
|
||||
private string ResolveOutputRoot()
|
||||
{
|
||||
var root = _configuration["Doctor:Evidence:Root"];
|
||||
if (!string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return root.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_hostEnvironment?.ContentRootPath))
|
||||
{
|
||||
return _hostEnvironment!.ContentRootPath;
|
||||
}
|
||||
|
||||
return Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
private bool ReadBool(string key, bool defaultValue)
|
||||
{
|
||||
var value = _configuration[key];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return bool.TryParse(value, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
private string ResolveTemplate(string key, string fallback, string runId)
|
||||
{
|
||||
var template = _configuration[key];
|
||||
if (string.IsNullOrWhiteSpace(template))
|
||||
{
|
||||
template = fallback;
|
||||
}
|
||||
|
||||
return template.Replace("{runId}", runId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ResolvePath(string root, string path)
|
||||
{
|
||||
if (Path.IsPathRooted(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(root, path));
|
||||
}
|
||||
|
||||
private static string ResolveDoctorCommand(DoctorRunOptions options)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(options.DoctorCommand)
|
||||
? DefaultDoctorCommand
|
||||
: options.DoctorCommand.Trim();
|
||||
}
|
||||
|
||||
private static async Task WriteJsonlAsync(
|
||||
string path,
|
||||
DoctorReport report,
|
||||
string doctorCommand,
|
||||
bool includeEvidence,
|
||||
bool redactSensitive,
|
||||
CancellationToken ct)
|
||||
{
|
||||
EnsureDirectory(path);
|
||||
|
||||
await using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read);
|
||||
|
||||
foreach (var result in report.Results
|
||||
.OrderBy(r => r.Severity.ToSortOrder())
|
||||
.ThenBy(r => r.CheckId, StringComparer.Ordinal))
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, JsonOptions);
|
||||
WriteResultRecord(writer, report, result, doctorCommand, includeEvidence, redactSensitive);
|
||||
writer.Flush();
|
||||
|
||||
await stream.WriteAsync(buffer.WrittenMemory, ct);
|
||||
await stream.WriteAsync(LineBreak, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteResultRecord(
|
||||
Utf8JsonWriter writer,
|
||||
DoctorReport report,
|
||||
DoctorCheckResult result,
|
||||
string doctorCommand,
|
||||
bool includeEvidence,
|
||||
bool redactSensitive)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("runId", report.RunId);
|
||||
writer.WriteString("doctor_command", doctorCommand);
|
||||
writer.WriteString("checkId", result.CheckId);
|
||||
writer.WriteString("pluginId", result.PluginId);
|
||||
writer.WriteString("category", result.Category);
|
||||
writer.WriteString("severity", result.Severity.ToString().ToLowerInvariant());
|
||||
writer.WriteString("diagnosis", result.Diagnosis);
|
||||
writer.WriteString("executedAt", FormatTimestamp(result.ExecutedAt));
|
||||
writer.WriteNumber("durationMs", (long)result.Duration.TotalMilliseconds);
|
||||
WriteHowToFix(writer, result);
|
||||
|
||||
if (includeEvidence && result.Evidence.Data.Count > 0)
|
||||
{
|
||||
WriteEvidence(writer, result.Evidence, redactSensitive);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteHowToFix(Utf8JsonWriter writer, DoctorCheckResult result)
|
||||
{
|
||||
var commands = result.Remediation?.Steps
|
||||
.OrderBy(s => s.Order)
|
||||
.Select(s => s.Command)
|
||||
.Where(cmd => !string.IsNullOrWhiteSpace(cmd))
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
writer.WritePropertyName("how_to_fix");
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("commands");
|
||||
writer.WriteStartArray();
|
||||
foreach (var command in commands)
|
||||
{
|
||||
writer.WriteStringValue(command);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteEvidence(Utf8JsonWriter writer, Evidence evidence, bool redactSensitive)
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("description", evidence.Description);
|
||||
writer.WritePropertyName("data");
|
||||
writer.WriteStartObject();
|
||||
|
||||
HashSet<string>? sensitiveKeys = null;
|
||||
if (redactSensitive && evidence.SensitiveKeys is { Count: > 0 })
|
||||
{
|
||||
sensitiveKeys = new HashSet<string>(evidence.SensitiveKeys, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
foreach (var entry in evidence.Data.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
var value = entry.Value;
|
||||
if (sensitiveKeys is not null && sensitiveKeys.Contains(entry.Key))
|
||||
{
|
||||
value = Redacted;
|
||||
}
|
||||
|
||||
writer.WriteString(entry.Key, value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private async Task WriteDsseSummaryAsync(
|
||||
string dssePath,
|
||||
DoctorReport report,
|
||||
string doctorCommand,
|
||||
string jsonlPath,
|
||||
string jsonlDisplayPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var payload = new DoctorEvidenceSummary
|
||||
{
|
||||
RunId = report.RunId,
|
||||
DoctorCommand = doctorCommand,
|
||||
StartedAt = report.StartedAt,
|
||||
CompletedAt = report.CompletedAt,
|
||||
DurationMs = (long)report.Duration.TotalMilliseconds,
|
||||
OverallSeverity = report.OverallSeverity.ToString().ToLowerInvariant(),
|
||||
Summary = new DoctorEvidenceSummaryCounts
|
||||
{
|
||||
Passed = report.Summary.Passed,
|
||||
Info = report.Summary.Info,
|
||||
Warnings = report.Summary.Warnings,
|
||||
Failed = report.Summary.Failed,
|
||||
Skipped = report.Summary.Skipped,
|
||||
Total = report.Summary.Total
|
||||
},
|
||||
EvidenceLog = new DoctorEvidenceLogDescriptor
|
||||
{
|
||||
JsonlPath = ResolveDisplayPath(jsonlPath, jsonlDisplayPath),
|
||||
Sha256 = await ComputeSha256HexAsync(jsonlPath, ct),
|
||||
Records = report.Results.Count
|
||||
}
|
||||
};
|
||||
|
||||
var payloadJson = CanonicalJsonSerializer.Serialize(payload);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
var envelope = new DoctorDsseEnvelope
|
||||
{
|
||||
PayloadType = ResolveDssePayloadType(),
|
||||
Payload = payloadBase64,
|
||||
Signatures = Array.Empty<DoctorDsseSignature>()
|
||||
};
|
||||
|
||||
var envelopeJson = CanonicalJsonSerializer.Serialize(envelope);
|
||||
EnsureDirectory(dssePath);
|
||||
await File.WriteAllTextAsync(dssePath, envelopeJson, new UTF8Encoding(false), ct);
|
||||
}
|
||||
|
||||
private string ResolveDssePayloadType()
|
||||
{
|
||||
var payloadType = _configuration["Doctor:Evidence:Dsse:PayloadType"];
|
||||
return string.IsNullOrWhiteSpace(payloadType) ? DefaultPayloadType : payloadType.Trim();
|
||||
}
|
||||
|
||||
private static string ResolveDisplayPath(string fullPath, string templatePath)
|
||||
{
|
||||
return Path.IsPathRooted(templatePath) ? fullPath : templatePath;
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256HexAsync(string path, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var hasher = SHA256.Create();
|
||||
var hash = await hasher.ComputeHashAsync(stream, ct);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void EnsureDirectory(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value)
|
||||
{
|
||||
return value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private sealed record DoctorEvidenceSummary
|
||||
{
|
||||
public string RunId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("doctor_command")]
|
||||
public string DoctorCommand { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
public DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public string OverallSeverity { get; init; } = string.Empty;
|
||||
|
||||
public DoctorEvidenceSummaryCounts Summary { get; init; } = new();
|
||||
|
||||
public DoctorEvidenceLogDescriptor EvidenceLog { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed record DoctorEvidenceSummaryCounts
|
||||
{
|
||||
public int Passed { get; init; }
|
||||
public int Info { get; init; }
|
||||
public int Warnings { get; init; }
|
||||
public int Failed { get; init; }
|
||||
public int Skipped { get; init; }
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DoctorEvidenceLogDescriptor
|
||||
{
|
||||
public string JsonlPath { get; init; } = string.Empty;
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
public int Records { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DoctorDsseEnvelope
|
||||
{
|
||||
public string PayloadType { get; init; } = string.Empty;
|
||||
public string Payload { get; init; } = string.Empty;
|
||||
public IReadOnlyList<DoctorDsseSignature> Signatures { get; init; } = Array.Empty<DoctorDsseSignature>();
|
||||
}
|
||||
|
||||
private sealed record DoctorDsseSignature
|
||||
{
|
||||
[JsonPropertyName("keyid")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string? Sig { get; init; }
|
||||
}
|
||||
}
|
||||
507
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackCheck.cs
Normal file
507
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackCheck.cs
Normal file
@@ -0,0 +1,507 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Packs;
|
||||
|
||||
public sealed class DoctorPackCheck : IDoctorCheck
|
||||
{
|
||||
private const int DefaultMaxOutputChars = 4000;
|
||||
private readonly DoctorPackCheckDefinition _definition;
|
||||
private readonly string _pluginId;
|
||||
private readonly string _category;
|
||||
private readonly IDoctorPackCommandRunner _runner;
|
||||
|
||||
public DoctorPackCheck(
|
||||
DoctorPackCheckDefinition definition,
|
||||
string pluginId,
|
||||
DoctorCategory category,
|
||||
IDoctorPackCommandRunner runner)
|
||||
{
|
||||
_definition = definition;
|
||||
_pluginId = pluginId;
|
||||
_category = category.ToString();
|
||||
_runner = runner;
|
||||
}
|
||||
|
||||
public string CheckId => _definition.CheckId;
|
||||
public string Name => _definition.Name;
|
||||
public string Description => _definition.Description;
|
||||
public DoctorSeverity DefaultSeverity => _definition.DefaultSeverity;
|
||||
public IReadOnlyList<string> Tags => _definition.Tags;
|
||||
public TimeSpan EstimatedDuration => _definition.EstimatedDuration;
|
||||
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_definition.Run.Exec);
|
||||
}
|
||||
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, _pluginId, _category);
|
||||
var commandResult = await _runner.RunAsync(_definition.Run, context, ct).ConfigureAwait(false);
|
||||
var evaluation = Evaluate(commandResult, _definition.Parse);
|
||||
var evidence = BuildEvidence(commandResult, evaluation, context);
|
||||
|
||||
builder.WithEvidence(evidence);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(commandResult.Error))
|
||||
{
|
||||
builder.Fail($"Command execution failed: {commandResult.Error}");
|
||||
}
|
||||
else if (evaluation.Passed)
|
||||
{
|
||||
builder.Pass("All expectations met.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.WithSeverity(_definition.DefaultSeverity, evaluation.Diagnosis);
|
||||
}
|
||||
|
||||
if (!evaluation.Passed && _definition.HowToFix is not null)
|
||||
{
|
||||
var remediation = BuildRemediation(_definition.HowToFix);
|
||||
if (remediation is not null)
|
||||
{
|
||||
builder.WithRemediation(remediation);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static PackEvaluationResult Evaluate(DoctorPackCommandResult result, DoctorPackParseRules parse)
|
||||
{
|
||||
var missing = new List<string>();
|
||||
|
||||
if (result.ExitCode != 0)
|
||||
{
|
||||
missing.Add($"exit_code:{result.ExitCode.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
var combinedOutput = CombineOutput(result);
|
||||
foreach (var expect in parse.ExpectContains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expect.Contains))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!combinedOutput.Contains(expect.Contains, StringComparison.Ordinal))
|
||||
{
|
||||
missing.Add($"contains:{expect.Contains}");
|
||||
}
|
||||
}
|
||||
|
||||
if (parse.ExpectJson.Count > 0)
|
||||
{
|
||||
if (!TryParseJson(result.StdOut, out var root))
|
||||
{
|
||||
missing.Add("expect_json:parse_failed");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var expect in parse.ExpectJson)
|
||||
{
|
||||
if (!TryResolveJsonPath(root, expect.Path, out var actual))
|
||||
{
|
||||
missing.Add($"json:{expect.Path}=<missing>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!JsonValueMatches(actual, expect.ExpectedValue, out var expectedText, out var actualText))
|
||||
{
|
||||
missing.Add($"json:{expect.Path} expected {expectedText} got {actualText}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return new PackEvaluationResult(true, "All expectations met.", ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
var diagnosis = $"Expectations failed: {string.Join("; ", missing)}";
|
||||
return new PackEvaluationResult(false, diagnosis, missing.ToImmutableArray());
|
||||
}
|
||||
|
||||
private Evidence BuildEvidence(
|
||||
DoctorPackCommandResult result,
|
||||
PackEvaluationResult evaluation,
|
||||
DoctorPluginContext context)
|
||||
{
|
||||
var maxOutputChars = ResolveMaxOutputChars(context);
|
||||
var data = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["command"] = _definition.Run.Exec,
|
||||
["exit_code"] = result.ExitCode.ToString(CultureInfo.InvariantCulture),
|
||||
["stdout"] = TrimOutput(result.StdOut, maxOutputChars),
|
||||
["stderr"] = TrimOutput(result.StdErr, maxOutputChars)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.Error))
|
||||
{
|
||||
data["error"] = result.Error;
|
||||
}
|
||||
|
||||
if (_definition.Parse.ExpectContains.Count > 0)
|
||||
{
|
||||
var expected = _definition.Parse.ExpectContains
|
||||
.Select(e => e.Contains)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v));
|
||||
data["expect_contains"] = string.Join("; ", expected);
|
||||
}
|
||||
|
||||
if (_definition.Parse.ExpectJson.Count > 0)
|
||||
{
|
||||
var expected = _definition.Parse.ExpectJson
|
||||
.Select(FormatExpectJson);
|
||||
data["expect_json"] = string.Join("; ", expected);
|
||||
}
|
||||
|
||||
if (evaluation.MissingExpectations.Count > 0)
|
||||
{
|
||||
data["missing_expectations"] = string.Join("; ", evaluation.MissingExpectations);
|
||||
}
|
||||
|
||||
return new Evidence
|
||||
{
|
||||
Description = $"Pack evidence for {CheckId}",
|
||||
Data = data.ToImmutableSortedDictionary(StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
private static int ResolveMaxOutputChars(DoctorPluginContext context)
|
||||
{
|
||||
var value = context.Configuration["Doctor:Packs:MaxOutputChars"];
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) &&
|
||||
parsed > 0)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DefaultMaxOutputChars;
|
||||
}
|
||||
|
||||
private static string TrimOutput(string value, int maxChars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxChars] + "...(truncated)";
|
||||
}
|
||||
|
||||
private static Remediation? BuildRemediation(DoctorPackHowToFix howToFix)
|
||||
{
|
||||
if (howToFix.Commands.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var steps = new List<RemediationStep>();
|
||||
var summary = string.IsNullOrWhiteSpace(howToFix.Summary)
|
||||
? null
|
||||
: howToFix.Summary.Trim();
|
||||
|
||||
for (var i = 0; i < howToFix.Commands.Count; i++)
|
||||
{
|
||||
var command = howToFix.Commands[i];
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var order = i + 1;
|
||||
var description = summary switch
|
||||
{
|
||||
null => $"Run fix command {order}",
|
||||
_ when howToFix.Commands.Count == 1 => summary,
|
||||
_ => $"{summary} (step {order})"
|
||||
};
|
||||
|
||||
steps.Add(new RemediationStep
|
||||
{
|
||||
Order = order,
|
||||
Description = description,
|
||||
Command = command,
|
||||
CommandType = CommandType.Shell
|
||||
});
|
||||
}
|
||||
|
||||
if (steps.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Remediation
|
||||
{
|
||||
Steps = steps.ToImmutableArray(),
|
||||
SafetyNote = howToFix.SafetyNote,
|
||||
RequiresBackup = howToFix.RequiresBackup
|
||||
};
|
||||
}
|
||||
|
||||
private static string CombineOutput(DoctorPackCommandResult result)
|
||||
{
|
||||
if (string.IsNullOrEmpty(result.StdErr))
|
||||
{
|
||||
return result.StdOut;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(result.StdOut))
|
||||
{
|
||||
return result.StdErr;
|
||||
}
|
||||
|
||||
return $"{result.StdOut}\n{result.StdErr}";
|
||||
}
|
||||
|
||||
private static bool TryParseJson(string input, out JsonElement root)
|
||||
{
|
||||
root = default;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = input.Trim();
|
||||
if (TryParseJsonDocument(trimmed, out root))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var start = trimmed.IndexOf('{');
|
||||
var end = trimmed.LastIndexOf('}');
|
||||
if (start >= 0 && end > start)
|
||||
{
|
||||
var slice = trimmed[start..(end + 1)];
|
||||
return TryParseJsonDocument(slice, out root);
|
||||
}
|
||||
|
||||
start = trimmed.IndexOf('[');
|
||||
end = trimmed.LastIndexOf(']');
|
||||
if (start >= 0 && end > start)
|
||||
{
|
||||
var slice = trimmed[start..(end + 1)];
|
||||
return TryParseJsonDocument(slice, out root);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseJsonDocument(string input, out JsonElement root)
|
||||
{
|
||||
root = default;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(input);
|
||||
root = doc.RootElement.Clone();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryResolveJsonPath(JsonElement root, string path, out JsonElement value)
|
||||
{
|
||||
value = root;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = path.Trim();
|
||||
if (!trimmed.StartsWith("$", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
trimmed = trimmed[1..];
|
||||
if (trimmed.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
trimmed = trimmed[1..];
|
||||
}
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var segment in SplitPath(trimmed))
|
||||
{
|
||||
if (!TryParseSegment(segment, out var property, out var index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(property))
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Object ||
|
||||
!value.TryGetProperty(property, out value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (index.HasValue)
|
||||
{
|
||||
if (value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var idx = index.Value;
|
||||
if (idx < 0 || idx >= value.GetArrayLength())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value[idx];
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitPath(string path)
|
||||
{
|
||||
var buffer = new List<char>();
|
||||
var depth = 0;
|
||||
|
||||
foreach (var ch in path)
|
||||
{
|
||||
if (ch == '.' && depth == 0)
|
||||
{
|
||||
if (buffer.Count > 0)
|
||||
{
|
||||
yield return new string(buffer.ToArray());
|
||||
buffer.Clear();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch == '[')
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (ch == ']')
|
||||
{
|
||||
depth = Math.Max(0, depth - 1);
|
||||
}
|
||||
|
||||
buffer.Add(ch);
|
||||
}
|
||||
|
||||
if (buffer.Count > 0)
|
||||
{
|
||||
yield return new string(buffer.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseSegment(string segment, out string? property, out int? index)
|
||||
{
|
||||
property = segment;
|
||||
index = null;
|
||||
|
||||
var bracketStart = segment.IndexOf('[', StringComparison.Ordinal);
|
||||
if (bracketStart < 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var bracketEnd = segment.IndexOf(']', bracketStart + 1);
|
||||
if (bracketEnd < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
property = bracketStart > 0 ? segment[..bracketStart] : null;
|
||||
var indexText = segment[(bracketStart + 1)..bracketEnd];
|
||||
if (!int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
index = idx;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool JsonValueMatches(
|
||||
JsonElement actual,
|
||||
object? expected,
|
||||
out string expectedText,
|
||||
out string actualText)
|
||||
{
|
||||
actualText = FormatJsonValue(actual);
|
||||
expectedText = expected is null ? "null" : Convert.ToString(expected, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
if (expected is null)
|
||||
{
|
||||
return actual.ValueKind == JsonValueKind.Null;
|
||||
}
|
||||
|
||||
switch (expected)
|
||||
{
|
||||
case bool expectedBool:
|
||||
if (actual.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return actual.GetBoolean() == expectedBool;
|
||||
}
|
||||
return false;
|
||||
case int expectedInt:
|
||||
return actual.ValueKind == JsonValueKind.Number &&
|
||||
actual.TryGetInt64(out var actualInt) &&
|
||||
actualInt == expectedInt;
|
||||
case long expectedLong:
|
||||
return actual.ValueKind == JsonValueKind.Number &&
|
||||
actual.TryGetInt64(out var actualLong) &&
|
||||
actualLong == expectedLong;
|
||||
case double expectedDouble:
|
||||
return actual.ValueKind == JsonValueKind.Number &&
|
||||
actual.TryGetDouble(out var actualDouble) &&
|
||||
Math.Abs(actualDouble - expectedDouble) < double.Epsilon;
|
||||
case decimal expectedDecimal:
|
||||
return actual.ValueKind == JsonValueKind.Number &&
|
||||
actual.TryGetDecimal(out var actualDecimal) &&
|
||||
actualDecimal == expectedDecimal;
|
||||
case string expectedString:
|
||||
return actual.ValueKind == JsonValueKind.String &&
|
||||
actual.GetString() == expectedString;
|
||||
default:
|
||||
return actualText == expectedText;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatJsonValue(JsonElement value)
|
||||
{
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||||
JsonValueKind.Null => "null",
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatExpectJson(DoctorPackExpectJson expect)
|
||||
{
|
||||
var expected = expect.ExpectedValue is null
|
||||
? "null"
|
||||
: Convert.ToString(expect.ExpectedValue, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
return $"{expect.Path}=={expected}";
|
||||
}
|
||||
|
||||
private sealed record PackEvaluationResult(
|
||||
bool Passed,
|
||||
string Diagnosis,
|
||||
IReadOnlyList<string> MissingExpectations);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Packs;
|
||||
|
||||
public interface IDoctorPackCommandRunner
|
||||
{
|
||||
Task<DoctorPackCommandResult> RunAsync(
|
||||
DoctorPackCommand command,
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record DoctorPackCommandResult
|
||||
{
|
||||
public required int ExitCode { get; init; }
|
||||
public string StdOut { get; init; } = string.Empty;
|
||||
public string StdErr { get; init; } = string.Empty;
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static DoctorPackCommandResult Failed(string error) => new()
|
||||
{
|
||||
ExitCode = -1,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class DoctorPackCommandRunner : IDoctorPackCommandRunner
|
||||
{
|
||||
private static readonly string TempRoot = Path.Combine(Path.GetTempPath(), "stellaops-doctor");
|
||||
private readonly ILogger<DoctorPackCommandRunner> _logger;
|
||||
|
||||
public DoctorPackCommandRunner(ILogger<DoctorPackCommandRunner> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<DoctorPackCommandResult> RunAsync(
|
||||
DoctorPackCommand command,
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Exec))
|
||||
{
|
||||
return DoctorPackCommandResult.Failed("Command exec is empty.");
|
||||
}
|
||||
|
||||
var shell = ResolveShell(command, context);
|
||||
var scriptPath = CreateScriptFile(command.Exec, shell.ScriptExtension);
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = shell.FileName,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(command.WorkingDirectory))
|
||||
{
|
||||
startInfo.WorkingDirectory = command.WorkingDirectory;
|
||||
}
|
||||
|
||||
foreach (var arg in shell.ArgumentsPrefix)
|
||||
{
|
||||
startInfo.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(scriptPath);
|
||||
|
||||
process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return DoctorPackCommandResult.Failed($"Failed to start shell: {shell.FileName}");
|
||||
}
|
||||
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = process.StandardError.ReadToEndAsync(ct);
|
||||
|
||||
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var stdout = await stdoutTask.ConfigureAwait(false);
|
||||
var stderr = await stderrTask.ConfigureAwait(false);
|
||||
|
||||
return new DoctorPackCommandResult
|
||||
{
|
||||
ExitCode = process.ExitCode,
|
||||
StdOut = stdout,
|
||||
StdErr = stderr
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Doctor pack command execution failed");
|
||||
return DoctorPackCommandResult.Failed(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
process?.Dispose();
|
||||
TryDeleteScript(scriptPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static ShellDefinition ResolveShell(DoctorPackCommand command, DoctorPluginContext context)
|
||||
{
|
||||
var overrideShell = command.Shell ?? context.Configuration["Doctor:Packs:Shell"];
|
||||
if (!string.IsNullOrWhiteSpace(overrideShell))
|
||||
{
|
||||
return CreateShellDefinition(overrideShell);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var pwsh = FindOnPath("pwsh") ?? FindOnPath("powershell");
|
||||
if (!string.IsNullOrWhiteSpace(pwsh))
|
||||
{
|
||||
return new ShellDefinition(pwsh, ["-NoProfile", "-File"], ".ps1");
|
||||
}
|
||||
|
||||
return new ShellDefinition("cmd.exe", ["/c"], ".cmd");
|
||||
}
|
||||
|
||||
var bash = FindOnPath("bash") ?? "/bin/sh";
|
||||
return new ShellDefinition(bash, [], ".sh");
|
||||
}
|
||||
|
||||
private static ShellDefinition CreateShellDefinition(string shellPath)
|
||||
{
|
||||
var name = Path.GetFileName(shellPath).ToLowerInvariant();
|
||||
if (name.Contains("pwsh", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Contains("powershell", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ShellDefinition(shellPath, ["-NoProfile", "-File"], ".ps1");
|
||||
}
|
||||
|
||||
if (name.StartsWith("cmd", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new ShellDefinition(shellPath, ["/c"], ".cmd");
|
||||
}
|
||||
|
||||
return new ShellDefinition(shellPath, [], ".sh");
|
||||
}
|
||||
|
||||
private static string CreateScriptFile(string exec, string extension)
|
||||
{
|
||||
Directory.CreateDirectory(TempRoot);
|
||||
var fileName = $"doctor-pack-{Path.GetRandomFileName()}{extension}";
|
||||
var path = Path.Combine(TempRoot, fileName);
|
||||
var normalized = NormalizeScript(exec);
|
||||
File.WriteAllText(path, normalized, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string NormalizeScript(string exec)
|
||||
{
|
||||
return exec.Replace("\r\n", "\n", StringComparison.Ordinal).Trim();
|
||||
}
|
||||
|
||||
private static void TryDeleteScript(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryKillProcess(Process? process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (process is not null && !process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore kill failures.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindOnPath(string tool)
|
||||
{
|
||||
if (File.Exists(tool))
|
||||
{
|
||||
return Path.GetFullPath(tool);
|
||||
}
|
||||
|
||||
var path = Environment.GetEnvironmentVariable("PATH");
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var dir in path.Split(Path.PathSeparator))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = Path.Combine(dir, tool);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var exeCandidate = candidate + ".exe";
|
||||
if (File.Exists(exeCandidate))
|
||||
{
|
||||
return exeCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record ShellDefinition(
|
||||
string FileName,
|
||||
IReadOnlyList<string> ArgumentsPrefix,
|
||||
string ScriptExtension);
|
||||
}
|
||||
604
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackLoader.cs
Normal file
604
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackLoader.cs
Normal file
@@ -0,0 +1,604 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Doctor.Packs;
|
||||
|
||||
public sealed class DoctorPackLoader
|
||||
{
|
||||
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly IDoctorPackCommandRunner _commandRunner;
|
||||
private readonly ILogger<DoctorPackLoader> _logger;
|
||||
|
||||
public DoctorPackLoader(IDoctorPackCommandRunner commandRunner, ILogger<DoctorPackLoader> logger)
|
||||
{
|
||||
_commandRunner = commandRunner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IDoctorPlugin> LoadPlugins(DoctorPluginContext context)
|
||||
{
|
||||
var plugins = new List<IDoctorPlugin>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var rootPath = ResolveRootPath(context);
|
||||
|
||||
foreach (var searchPath in ResolveSearchPaths(context, rootPath))
|
||||
{
|
||||
if (!Directory.Exists(searchPath))
|
||||
{
|
||||
_logger.LogDebug("Doctor pack search path not found: {Path}", searchPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var manifestPath in EnumeratePackFiles(searchPath))
|
||||
{
|
||||
if (!TryParseManifest(manifestPath, rootPath, out var manifest, out var error))
|
||||
{
|
||||
_logger.LogWarning("Failed to parse doctor pack {Path}: {Error}", manifestPath, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSupportedManifest(manifest, out var reason))
|
||||
{
|
||||
_logger.LogWarning("Skipping doctor pack {Path}: {Reason}", manifestPath, reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
var plugin = new DoctorPackPlugin(manifest, _commandRunner, context.Logger);
|
||||
if (!seen.Add(plugin.PluginId))
|
||||
{
|
||||
_logger.LogWarning("Duplicate doctor pack plugin id: {PluginId}", plugin.PluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
plugins.Add(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins
|
||||
.OrderBy(p => p.Category)
|
||||
.ThenBy(p => p.PluginId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveSearchPaths(DoctorPluginContext context, string rootPath)
|
||||
{
|
||||
var paths = context.Configuration
|
||||
.GetSection("Doctor:Packs:SearchPaths")
|
||||
.GetChildren()
|
||||
.Select(c => c.Value)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => ResolvePath(rootPath, v!))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (paths.Count == 0)
|
||||
{
|
||||
paths.Add(Path.Combine(rootPath, "plugins", "doctor"));
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static string ResolveRootPath(DoctorPluginContext context)
|
||||
{
|
||||
var configuredRoot = context.Configuration["Doctor:Packs:Root"];
|
||||
if (!string.IsNullOrWhiteSpace(configuredRoot))
|
||||
{
|
||||
return Path.GetFullPath(Environment.ExpandEnvironmentVariables(configuredRoot));
|
||||
}
|
||||
|
||||
var hostEnvironment = context.Services.GetService(typeof(Microsoft.Extensions.Hosting.IHostEnvironment))
|
||||
as Microsoft.Extensions.Hosting.IHostEnvironment;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hostEnvironment?.ContentRootPath))
|
||||
{
|
||||
return hostEnvironment.ContentRootPath;
|
||||
}
|
||||
|
||||
return Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
private static string ResolvePath(string rootPath, string value)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(value);
|
||||
return Path.GetFullPath(Path.IsPathRooted(expanded) ? expanded : Path.Combine(rootPath, expanded));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumeratePackFiles(string directory)
|
||||
{
|
||||
var yaml = Directory.EnumerateFiles(directory, "*.yaml", SearchOption.TopDirectoryOnly);
|
||||
var yml = Directory.EnumerateFiles(directory, "*.yml", SearchOption.TopDirectoryOnly);
|
||||
return yaml.Concat(yml).OrderBy(p => p, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static bool TryParseManifest(
|
||||
string path,
|
||||
string rootPath,
|
||||
out DoctorPackManifest manifest,
|
||||
out string error)
|
||||
{
|
||||
manifest = default!;
|
||||
error = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(path);
|
||||
var dto = Deserializer.Deserialize<ManifestDto>(content);
|
||||
if (dto is null)
|
||||
{
|
||||
error = "Manifest content is empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.ApiVersion))
|
||||
{
|
||||
error = "apiVersion is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.Kind))
|
||||
{
|
||||
error = "kind is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dto.Metadata?.Name is null || string.IsNullOrWhiteSpace(dto.Metadata.Name))
|
||||
{
|
||||
error = "metadata.name is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dto.Spec?.Checks is null || dto.Spec.Checks.Count == 0)
|
||||
{
|
||||
error = "spec.checks must include at least one check.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var metadata = dto.Metadata.ToMetadata();
|
||||
var spec = dto.Spec.ToSpec(rootPath, path, metadata);
|
||||
|
||||
manifest = new DoctorPackManifest
|
||||
{
|
||||
ApiVersion = dto.ApiVersion,
|
||||
Kind = dto.Kind,
|
||||
Metadata = metadata,
|
||||
Spec = spec,
|
||||
SourcePath = path
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSupportedManifest(DoctorPackManifest manifest, out string reason)
|
||||
{
|
||||
if (!manifest.ApiVersion.StartsWith("stella.ops/doctor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reason = $"Unsupported apiVersion: {manifest.ApiVersion}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!manifest.Kind.Equals("DoctorPlugin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reason = $"Unsupported kind: {manifest.Kind}";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DoctorPackParseRules BuildParseRules(ParseDto? parse)
|
||||
{
|
||||
if (parse is null)
|
||||
{
|
||||
return DoctorPackParseRules.Empty;
|
||||
}
|
||||
|
||||
var contains = (parse.Expect ?? [])
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Contains))
|
||||
.Select(e => new DoctorPackExpectContains { Contains = e.Contains! })
|
||||
.ToImmutableArray();
|
||||
|
||||
var json = NormalizeExpectJson(parse.ExpectJson).ToImmutableArray();
|
||||
|
||||
return new DoctorPackParseRules
|
||||
{
|
||||
ExpectContains = contains,
|
||||
ExpectJson = json
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<DoctorPackExpectJson> NormalizeExpectJson(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (value is IDictionary<object, object> map)
|
||||
{
|
||||
var expectation = ParseExpectJson(map);
|
||||
if (expectation is not null)
|
||||
{
|
||||
yield return expectation;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (value is IEnumerable<object> list)
|
||||
{
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is IDictionary<object, object> listMap)
|
||||
{
|
||||
var expectation = ParseExpectJson(listMap);
|
||||
if (expectation is not null)
|
||||
{
|
||||
yield return expectation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DoctorPackExpectJson? ParseExpectJson(IDictionary<object, object> map)
|
||||
{
|
||||
if (!TryGetMapValue(map, "path", out var pathValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Convert.ToString(pathValue, CultureInfo.InvariantCulture);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TryGetMapValue(map, "equals", out var expected);
|
||||
|
||||
return new DoctorPackExpectJson
|
||||
{
|
||||
Path = path,
|
||||
ExpectedValue = expected
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetMapValue(
|
||||
IDictionary<object, object> map,
|
||||
string key,
|
||||
out object? value)
|
||||
{
|
||||
foreach (var entry in map)
|
||||
{
|
||||
var entryKey = Convert.ToString(entry.Key, CultureInfo.InvariantCulture);
|
||||
if (string.Equals(entryKey, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class ManifestDto
|
||||
{
|
||||
public string? ApiVersion { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public MetadataDto? Metadata { get; set; }
|
||||
public SpecDto? Spec { get; set; }
|
||||
}
|
||||
|
||||
private sealed class MetadataDto
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
|
||||
public DoctorPackMetadata ToMetadata()
|
||||
{
|
||||
return new DoctorPackMetadata
|
||||
{
|
||||
Name = Name ?? string.Empty,
|
||||
Labels = Labels is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: Labels.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SpecDto
|
||||
{
|
||||
public DiscoveryDto? Discovery { get; set; }
|
||||
public List<DiscoveryConditionDto>? When { get; set; }
|
||||
public List<CheckDto>? Checks { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public AttestationsDto? Attestations { get; set; }
|
||||
|
||||
public DoctorPackSpec ToSpec(
|
||||
string rootPath,
|
||||
string sourcePath,
|
||||
DoctorPackMetadata metadata)
|
||||
{
|
||||
var discovery = (Discovery?.When ?? When ?? [])
|
||||
.Select(c => new DoctorPackDiscoveryCondition
|
||||
{
|
||||
Env = c.Env,
|
||||
FileExists = ResolveDiscoveryPath(c.FileExists, rootPath, sourcePath)
|
||||
})
|
||||
.Where(c => !(string.IsNullOrWhiteSpace(c.Env) && string.IsNullOrWhiteSpace(c.FileExists)))
|
||||
.ToImmutableArray();
|
||||
|
||||
var checks = (Checks ?? [])
|
||||
.Select(c => c.ToDefinition(rootPath, metadata))
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.CheckId))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new DoctorPackSpec
|
||||
{
|
||||
Discovery = discovery,
|
||||
Checks = checks,
|
||||
Category = string.IsNullOrWhiteSpace(Category) ? null : Category.Trim(),
|
||||
Attestations = Attestations?.ToAttestations()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DiscoveryDto
|
||||
{
|
||||
public List<DiscoveryConditionDto>? When { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DiscoveryConditionDto
|
||||
{
|
||||
public string? Env { get; set; }
|
||||
public string? FileExists { get; set; }
|
||||
}
|
||||
|
||||
private sealed class CheckDto
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Severity { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public double? EstimatedSeconds { get; set; }
|
||||
public double? EstimatedDurationSeconds { get; set; }
|
||||
public RunDto? Run { get; set; }
|
||||
public ParseDto? Parse { get; set; }
|
||||
|
||||
[YamlMember(Alias = "how_to_fix")]
|
||||
public HowToFixDto? HowToFix { get; set; }
|
||||
|
||||
public HowToFixDto? Remediation { get; set; }
|
||||
|
||||
public DoctorPackCheckDefinition ToDefinition(string rootPath, DoctorPackMetadata metadata)
|
||||
{
|
||||
var checkId = (Id ?? string.Empty).Trim();
|
||||
var name = !string.IsNullOrWhiteSpace(Name)
|
||||
? Name!.Trim()
|
||||
: !string.IsNullOrWhiteSpace(Description)
|
||||
? Description!.Trim()
|
||||
: checkId;
|
||||
var description = !string.IsNullOrWhiteSpace(Description)
|
||||
? Description!.Trim()
|
||||
: name;
|
||||
|
||||
var severity = ParseSeverity(Severity);
|
||||
var estimated = ResolveEstimatedDuration();
|
||||
var parseRules = BuildParseRules(Parse);
|
||||
|
||||
var command = BuildCommand(rootPath);
|
||||
var howToFix = (HowToFix ?? Remediation)?.ToModel();
|
||||
|
||||
var tags = BuildTags(metadata);
|
||||
|
||||
return new DoctorPackCheckDefinition
|
||||
{
|
||||
CheckId = checkId,
|
||||
Name = name,
|
||||
Description = description,
|
||||
DefaultSeverity = severity,
|
||||
Tags = tags,
|
||||
EstimatedDuration = estimated,
|
||||
Run = command,
|
||||
Parse = parseRules,
|
||||
HowToFix = howToFix
|
||||
};
|
||||
}
|
||||
|
||||
private DoctorPackCommand BuildCommand(string rootPath)
|
||||
{
|
||||
var exec = Run?.Exec ?? string.Empty;
|
||||
var workingDir = Run?.WorkingDirectory;
|
||||
if (string.IsNullOrWhiteSpace(workingDir))
|
||||
{
|
||||
workingDir = rootPath;
|
||||
}
|
||||
else if (!Path.IsPathRooted(workingDir))
|
||||
{
|
||||
workingDir = Path.GetFullPath(Path.Combine(rootPath, workingDir));
|
||||
}
|
||||
|
||||
return new DoctorPackCommand(exec)
|
||||
{
|
||||
WorkingDirectory = workingDir,
|
||||
Shell = Run?.Shell
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan ResolveEstimatedDuration()
|
||||
{
|
||||
var seconds = EstimatedDurationSeconds ?? EstimatedSeconds;
|
||||
if (seconds is null || seconds <= 0)
|
||||
{
|
||||
return TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds(seconds.Value);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> BuildTags(DoctorPackMetadata metadata)
|
||||
{
|
||||
var tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
tags.Add($"pack:{metadata.Name}");
|
||||
|
||||
if (metadata.Labels.TryGetValue("module", out var module) &&
|
||||
!string.IsNullOrWhiteSpace(module))
|
||||
{
|
||||
tags.Add($"module:{module}");
|
||||
}
|
||||
|
||||
if (metadata.Labels.TryGetValue("integration", out var integration) &&
|
||||
!string.IsNullOrWhiteSpace(integration))
|
||||
{
|
||||
tags.Add($"integration:{integration}");
|
||||
}
|
||||
|
||||
if (Tags is not null)
|
||||
{
|
||||
foreach (var tag in Tags.Where(t => !string.IsNullOrWhiteSpace(t)))
|
||||
{
|
||||
var trimmed = tag.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
tags.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags.OrderBy(t => t, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RunDto
|
||||
{
|
||||
public string? Exec { get; set; }
|
||||
public string? Shell { get; set; }
|
||||
public string? WorkingDirectory { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ParseDto
|
||||
{
|
||||
public List<ExpectContainsDto>? Expect { get; set; }
|
||||
|
||||
[YamlMember(Alias = "expectJson")]
|
||||
public object? ExpectJson { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ExpectContainsDto
|
||||
{
|
||||
public string? Contains { get; set; }
|
||||
}
|
||||
|
||||
private sealed class HowToFixDto
|
||||
{
|
||||
public string? Summary { get; set; }
|
||||
public string? SafetyNote { get; set; }
|
||||
public bool RequiresBackup { get; set; }
|
||||
public List<string>? Commands { get; set; }
|
||||
|
||||
public DoctorPackHowToFix ToModel()
|
||||
{
|
||||
return new DoctorPackHowToFix
|
||||
{
|
||||
Summary = Summary,
|
||||
SafetyNote = SafetyNote,
|
||||
RequiresBackup = RequiresBackup,
|
||||
Commands = (Commands ?? []).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AttestationsDto
|
||||
{
|
||||
public DsseDto? Dsse { get; set; }
|
||||
|
||||
public DoctorPackAttestations ToAttestations()
|
||||
{
|
||||
return new DoctorPackAttestations
|
||||
{
|
||||
Dsse = Dsse?.ToModel()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DsseDto
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
[YamlMember(Alias = "outFile")]
|
||||
public string? OutFile { get; set; }
|
||||
|
||||
public DoctorPackDsseAttestation ToModel()
|
||||
{
|
||||
return new DoctorPackDsseAttestation
|
||||
{
|
||||
Enabled = Enabled,
|
||||
OutFile = OutFile
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static DoctorSeverity ParseSeverity(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return DoctorSeverity.Fail;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<DoctorSeverity>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DoctorSeverity.Fail;
|
||||
}
|
||||
|
||||
private static string? ResolveDiscoveryPath(string? value, string rootPath, string sourcePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(value);
|
||||
if (Path.IsPathRooted(expanded))
|
||||
{
|
||||
return expanded;
|
||||
}
|
||||
|
||||
var rootCandidate = Path.GetFullPath(Path.Combine(rootPath, expanded));
|
||||
if (File.Exists(rootCandidate) || Directory.Exists(rootCandidate))
|
||||
{
|
||||
return rootCandidate;
|
||||
}
|
||||
|
||||
var manifestDir = Path.GetDirectoryName(sourcePath);
|
||||
if (!string.IsNullOrWhiteSpace(manifestDir))
|
||||
{
|
||||
var manifestCandidate = Path.GetFullPath(Path.Combine(manifestDir, expanded));
|
||||
if (File.Exists(manifestCandidate) || Directory.Exists(manifestCandidate))
|
||||
{
|
||||
return manifestCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return rootCandidate;
|
||||
}
|
||||
}
|
||||
99
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackModels.cs
Normal file
99
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackModels.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Doctor.Models;
|
||||
|
||||
namespace StellaOps.Doctor.Packs;
|
||||
|
||||
public sealed record DoctorPackManifest
|
||||
{
|
||||
public required string ApiVersion { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required DoctorPackMetadata Metadata { get; init; }
|
||||
public required DoctorPackSpec Spec { get; init; }
|
||||
public string? SourcePath { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackMetadata
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Labels { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public sealed record DoctorPackSpec
|
||||
{
|
||||
public IReadOnlyList<DoctorPackDiscoveryCondition> Discovery { get; init; } =
|
||||
ImmutableArray<DoctorPackDiscoveryCondition>.Empty;
|
||||
|
||||
public IReadOnlyList<DoctorPackCheckDefinition> Checks { get; init; } =
|
||||
ImmutableArray<DoctorPackCheckDefinition>.Empty;
|
||||
|
||||
public string? Category { get; init; }
|
||||
|
||||
public DoctorPackAttestations? Attestations { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackDiscoveryCondition
|
||||
{
|
||||
public string? Env { get; init; }
|
||||
public string? FileExists { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackCheckDefinition
|
||||
{
|
||||
public required string CheckId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public DoctorSeverity DefaultSeverity { get; init; } = DoctorSeverity.Fail;
|
||||
public IReadOnlyList<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
public TimeSpan EstimatedDuration { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public DoctorPackCommand Run { get; init; } = new(string.Empty);
|
||||
public DoctorPackParseRules Parse { get; init; } = DoctorPackParseRules.Empty;
|
||||
public DoctorPackHowToFix? HowToFix { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackCommand(string Exec)
|
||||
{
|
||||
public string? WorkingDirectory { get; init; }
|
||||
public string? Shell { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackParseRules
|
||||
{
|
||||
public static DoctorPackParseRules Empty => new();
|
||||
|
||||
public IReadOnlyList<DoctorPackExpectContains> ExpectContains { get; init; } =
|
||||
ImmutableArray<DoctorPackExpectContains>.Empty;
|
||||
|
||||
public IReadOnlyList<DoctorPackExpectJson> ExpectJson { get; init; } =
|
||||
ImmutableArray<DoctorPackExpectJson>.Empty;
|
||||
}
|
||||
|
||||
public sealed record DoctorPackExpectContains
|
||||
{
|
||||
public required string Contains { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackExpectJson
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public object? ExpectedValue { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackHowToFix
|
||||
{
|
||||
public string? Summary { get; init; }
|
||||
public string? SafetyNote { get; init; }
|
||||
public bool RequiresBackup { get; init; }
|
||||
public IReadOnlyList<string> Commands { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
public sealed record DoctorPackAttestations
|
||||
{
|
||||
public DoctorPackDsseAttestation? Dsse { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DoctorPackDsseAttestation
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
public string? OutFile { get; init; }
|
||||
}
|
||||
134
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackPlugin.cs
Normal file
134
src/__Libraries/StellaOps.Doctor/Packs/DoctorPackPlugin.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Packs;
|
||||
|
||||
internal interface IDoctorPackMetadata
|
||||
{
|
||||
string PackName { get; }
|
||||
string? Module { get; }
|
||||
string? Integration { get; }
|
||||
}
|
||||
|
||||
public sealed class DoctorPackPlugin : IDoctorPlugin, IDoctorPackMetadata
|
||||
{
|
||||
private static readonly Version PluginVersion = new(1, 0, 0);
|
||||
private static readonly Version MinVersion = new(1, 0, 0);
|
||||
private readonly DoctorPackManifest _manifest;
|
||||
private readonly DoctorCategory _category;
|
||||
private readonly IReadOnlyList<IDoctorCheck> _checks;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public DoctorPackPlugin(
|
||||
DoctorPackManifest manifest,
|
||||
IDoctorPackCommandRunner runner,
|
||||
ILogger logger)
|
||||
{
|
||||
_manifest = manifest;
|
||||
_logger = logger;
|
||||
_category = ResolveCategory(manifest);
|
||||
_checks = manifest.Spec.Checks
|
||||
.Select(c => new DoctorPackCheck(c, PluginId, _category, runner))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
public string PluginId => _manifest.Metadata.Name;
|
||||
public string DisplayName => _manifest.Metadata.Name;
|
||||
public DoctorCategory Category => _category;
|
||||
public Version Version => PluginVersion;
|
||||
public Version MinEngineVersion => MinVersion;
|
||||
|
||||
public string PackName => _manifest.Metadata.Name;
|
||||
public string? Module => GetLabel("module");
|
||||
public string? Integration => GetLabel("integration");
|
||||
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
if (_manifest.Spec.Discovery.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var condition in _manifest.Spec.Discovery)
|
||||
{
|
||||
if (!IsConditionMet(condition))
|
||||
{
|
||||
_logger.LogDebug("Doctor pack {PackName} not available in current context", PackName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return _checks;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DoctorCategory ResolveCategory(DoctorPackManifest manifest)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(manifest.Spec.Category) &&
|
||||
DoctorCategoryExtensions.TryParse(manifest.Spec.Category, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (manifest.Metadata.Labels.TryGetValue("category", out var label) &&
|
||||
DoctorCategoryExtensions.TryParse(label, out parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DoctorCategory.Integration;
|
||||
}
|
||||
|
||||
private bool IsConditionMet(DoctorPackDiscoveryCondition condition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(condition.Env) && !IsEnvMatch(condition.Env))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(condition.FileExists) &&
|
||||
!PathExists(condition.FileExists))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsEnvMatch(string envCondition)
|
||||
{
|
||||
var parts = envCondition.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = Environment.GetEnvironmentVariable(parts[0]);
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
return string.Equals(value, parts[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool PathExists(string path)
|
||||
{
|
||||
return File.Exists(path) || Directory.Exists(path);
|
||||
}
|
||||
|
||||
private string? GetLabel(string key)
|
||||
{
|
||||
return _manifest.Metadata.Labels.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace StellaOps.Doctor.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves placeholders in remediation commands.
|
||||
/// Placeholders use the syntax {{NAME}} or {{NAME:-default}}.
|
||||
/// </summary>
|
||||
public interface IPlaceholderResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves all placeholders in a command string.
|
||||
/// </summary>
|
||||
/// <param name="command">The command with placeholders.</param>
|
||||
/// <param name="userValues">User-provided values for placeholders.</param>
|
||||
/// <returns>The resolved command string.</returns>
|
||||
string Resolve(string command, IReadOnlyDictionary<string, string>? userValues = null);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all placeholders from a command string.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to parse.</param>
|
||||
/// <returns>List of placeholder info with names and default values.</returns>
|
||||
IReadOnlyList<PlaceholderInfo> ExtractPlaceholders(string command);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a placeholder contains sensitive data and should not be displayed.
|
||||
/// </summary>
|
||||
/// <param name="placeholderName">The placeholder name.</param>
|
||||
/// <returns>True if the placeholder is sensitive.</returns>
|
||||
bool IsSensitivePlaceholder(string placeholderName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a placeholder in a command.
|
||||
/// </summary>
|
||||
public sealed record PlaceholderInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The full placeholder text including braces (e.g., "{{HOST:-localhost}}").
|
||||
/// </summary>
|
||||
public required string FullText { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The placeholder name (e.g., "HOST").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The default value, if specified (e.g., "localhost").
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this placeholder is required (has no default).
|
||||
/// </summary>
|
||||
public bool IsRequired => DefaultValue == null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this placeholder contains sensitive data.
|
||||
/// </summary>
|
||||
public bool IsSensitive { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace StellaOps.Doctor.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Executes verification commands to confirm fixes were applied correctly.
|
||||
/// </summary>
|
||||
public interface IVerificationExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a verification command and returns the result.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to execute.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the command to complete.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the verification.</returns>
|
||||
Task<VerificationResult> ExecuteAsync(
|
||||
string command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a verification command with placeholders resolved.
|
||||
/// </summary>
|
||||
/// <param name="command">The command with placeholders.</param>
|
||||
/// <param name="userValues">User-provided values for placeholders.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the command to complete.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the verification.</returns>
|
||||
Task<VerificationResult> ExecuteWithPlaceholdersAsync(
|
||||
string command,
|
||||
IReadOnlyDictionary<string, string>? userValues,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing a verification command.
|
||||
/// </summary>
|
||||
public sealed record VerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the verification succeeded (exit code 0).
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The exit code of the command.
|
||||
/// </summary>
|
||||
public required int ExitCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Combined standard output and error from the command.
|
||||
/// </summary>
|
||||
public required string Output { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How long the command took to execute.
|
||||
/// </summary>
|
||||
public required TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the command timed out.
|
||||
/// </summary>
|
||||
public bool TimedOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the command failed to start.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
public static VerificationResult Successful(string output, TimeSpan duration) => new()
|
||||
{
|
||||
Success = true,
|
||||
ExitCode = 0,
|
||||
Output = output,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static VerificationResult Failed(int exitCode, string output, TimeSpan duration) => new()
|
||||
{
|
||||
Success = false,
|
||||
ExitCode = exitCode,
|
||||
Output = output,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a timeout result.
|
||||
/// </summary>
|
||||
public static VerificationResult Timeout(TimeSpan duration) => new()
|
||||
{
|
||||
Success = false,
|
||||
ExitCode = -1,
|
||||
Output = "Command timed out",
|
||||
Duration = duration,
|
||||
TimedOut = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error result (command failed to start).
|
||||
/// </summary>
|
||||
public static VerificationResult FromError(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
ExitCode = -1,
|
||||
Output = string.Empty,
|
||||
Duration = TimeSpan.Zero,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Doctor.Detection;
|
||||
|
||||
namespace StellaOps.Doctor.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IPlaceholderResolver"/>.
|
||||
/// Resolves placeholders in the format {{NAME}} or {{NAME:-default}}.
|
||||
/// </summary>
|
||||
public sealed partial class PlaceholderResolver : IPlaceholderResolver
|
||||
{
|
||||
private readonly IRuntimeDetector _runtimeDetector;
|
||||
|
||||
// Sensitive placeholder names that should never be displayed with actual values
|
||||
private static readonly HashSet<string> SensitivePlaceholders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"PASSWORD",
|
||||
"TOKEN",
|
||||
"SECRET",
|
||||
"SECRET_KEY",
|
||||
"SECRET_ID",
|
||||
"API_KEY",
|
||||
"APIKEY",
|
||||
"PRIVATE_KEY",
|
||||
"CREDENTIALS",
|
||||
"AUTH_TOKEN",
|
||||
"ACCESS_TOKEN",
|
||||
"REFRESH_TOKEN",
|
||||
"CLIENT_SECRET",
|
||||
"DB_PASSWORD",
|
||||
"REDIS_PASSWORD",
|
||||
"VALKEY_PASSWORD",
|
||||
"VAULT_TOKEN",
|
||||
"ROLE_ID",
|
||||
"SECRET_ID"
|
||||
};
|
||||
|
||||
public PlaceholderResolver(IRuntimeDetector runtimeDetector)
|
||||
{
|
||||
_runtimeDetector = runtimeDetector ?? throw new ArgumentNullException(nameof(runtimeDetector));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Resolve(string command, IReadOnlyDictionary<string, string>? userValues = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command))
|
||||
{
|
||||
return command;
|
||||
}
|
||||
|
||||
var contextValues = _runtimeDetector.GetContextValues();
|
||||
var result = command;
|
||||
|
||||
// Find all placeholders
|
||||
var placeholders = ExtractPlaceholders(command);
|
||||
|
||||
foreach (var placeholder in placeholders)
|
||||
{
|
||||
string? value = null;
|
||||
|
||||
// Priority 1: User-provided values (highest priority)
|
||||
if (userValues != null && userValues.TryGetValue(placeholder.Name, out var userValue))
|
||||
{
|
||||
value = userValue;
|
||||
}
|
||||
// Priority 2: Environment variables
|
||||
else
|
||||
{
|
||||
var envValue = Environment.GetEnvironmentVariable(placeholder.Name);
|
||||
if (!string.IsNullOrEmpty(envValue))
|
||||
{
|
||||
value = envValue;
|
||||
}
|
||||
// Priority 3: Context values from runtime detector
|
||||
else if (contextValues.TryGetValue(placeholder.Name, out var contextValue))
|
||||
{
|
||||
value = contextValue;
|
||||
}
|
||||
// Priority 4: Default value (lowest priority)
|
||||
else if (placeholder.DefaultValue != null)
|
||||
{
|
||||
value = placeholder.DefaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace placeholder with resolved value
|
||||
if (value != null)
|
||||
{
|
||||
// For sensitive placeholders, keep the placeholder syntax in display
|
||||
if (!placeholder.IsSensitive)
|
||||
{
|
||||
result = result.Replace(placeholder.FullText, value);
|
||||
}
|
||||
// Sensitive placeholders are NOT replaced - they stay as {{TOKEN}}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<PlaceholderInfo> ExtractPlaceholders(string command)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var placeholders = new List<PlaceholderInfo>();
|
||||
var matches = PlaceholderRegex().Matches(command);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var fullText = match.Value;
|
||||
var name = match.Groups["name"].Value;
|
||||
var defaultValue = match.Groups["default"].Success ? match.Groups["default"].Value : null;
|
||||
|
||||
placeholders.Add(new PlaceholderInfo
|
||||
{
|
||||
FullText = fullText,
|
||||
Name = name,
|
||||
DefaultValue = defaultValue,
|
||||
IsSensitive = IsSensitivePlaceholder(name)
|
||||
});
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSensitivePlaceholder(string placeholderName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(placeholderName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
if (SensitivePlaceholders.Contains(placeholderName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if name contains sensitive keywords
|
||||
var upperName = placeholderName.ToUpperInvariant();
|
||||
return upperName.Contains("PASSWORD") ||
|
||||
upperName.Contains("SECRET") ||
|
||||
upperName.Contains("TOKEN") ||
|
||||
upperName.Contains("KEY") && (upperName.Contains("API") || upperName.Contains("PRIVATE"));
|
||||
}
|
||||
|
||||
// Regex pattern: {{NAME}} or {{NAME:-default}}
|
||||
// NAME can be alphanumeric with underscores
|
||||
// Default value can contain anything except }}
|
||||
[GeneratedRegex(@"\{\{(?<name>[A-Za-z_][A-Za-z0-9_]*)(?::-(?<default>[^}]*))?}}")]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Doctor.Resolver;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IVerificationExecutor"/>.
|
||||
/// Executes shell commands to verify fixes were applied.
|
||||
/// </summary>
|
||||
public sealed class VerificationExecutor : IVerificationExecutor
|
||||
{
|
||||
private readonly IPlaceholderResolver _placeholderResolver;
|
||||
private readonly ILogger<VerificationExecutor> _logger;
|
||||
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public VerificationExecutor(
|
||||
IPlaceholderResolver placeholderResolver,
|
||||
ILogger<VerificationExecutor> logger)
|
||||
{
|
||||
_placeholderResolver = placeholderResolver ?? throw new ArgumentNullException(nameof(placeholderResolver));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VerificationResult> ExecuteAsync(
|
||||
string command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await ExecuteWithPlaceholdersAsync(command, null, timeout, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VerificationResult> ExecuteWithPlaceholdersAsync(
|
||||
string command,
|
||||
IReadOnlyDictionary<string, string>? userValues,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return VerificationResult.FromError("Command is empty");
|
||||
}
|
||||
|
||||
// Clamp timeout
|
||||
if (timeout <= TimeSpan.Zero) timeout = DefaultTimeout;
|
||||
if (timeout > MaxTimeout) timeout = MaxTimeout;
|
||||
|
||||
// Resolve placeholders
|
||||
var resolvedCommand = _placeholderResolver.Resolve(command, userValues);
|
||||
|
||||
// Check for unresolved required placeholders
|
||||
var remainingPlaceholders = _placeholderResolver.ExtractPlaceholders(resolvedCommand);
|
||||
var unresolvedRequired = remainingPlaceholders.Where(p => p.IsRequired && !p.IsSensitive).ToList();
|
||||
if (unresolvedRequired.Count > 0)
|
||||
{
|
||||
var missing = string.Join(", ", unresolvedRequired.Select(p => p.Name));
|
||||
return VerificationResult.FromError($"Missing required placeholder values: {missing}");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Executing verification command: {Command}", SanitizeForLogging(resolvedCommand));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var (shellCommand, shellArgs) = GetShellCommand(resolvedCommand);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = shellCommand,
|
||||
Arguments = shellArgs,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
var outputBuilder = new StringBuilder();
|
||||
var errorBuilder = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data != null) outputBuilder.AppendLine(e.Data);
|
||||
};
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data != null) errorBuilder.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
return VerificationResult.FromError("Failed to start process");
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
// Timeout occurred
|
||||
try { process.Kill(entireProcessTree: true); }
|
||||
catch { /* Best effort cleanup */ }
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogWarning("Verification command timed out after {Timeout}", timeout);
|
||||
return VerificationResult.Timeout(sw.Elapsed);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
var output = outputBuilder.ToString();
|
||||
var error = errorBuilder.ToString();
|
||||
var combinedOutput = string.IsNullOrEmpty(error)
|
||||
? output
|
||||
: $"{output}\n{error}";
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogDebug("Verification command succeeded in {Duration}ms", sw.ElapsedMilliseconds);
|
||||
return VerificationResult.Successful(combinedOutput.Trim(), sw.Elapsed);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Verification command failed with exit code {ExitCode}", process.ExitCode);
|
||||
return VerificationResult.Failed(process.ExitCode, combinedOutput.Trim(), sw.Elapsed);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogError(ex, "Failed to execute verification command");
|
||||
return VerificationResult.FromError($"Failed to execute command: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static (string command, string args) GetShellCommand(string command)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return ("cmd.exe", $"/c \"{command}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use bash on Unix
|
||||
return ("/bin/bash", $"-c \"{command.Replace("\"", "\\\"")}\"");
|
||||
}
|
||||
}
|
||||
|
||||
private string SanitizeForLogging(string command)
|
||||
{
|
||||
// Remove any resolved sensitive values from logs
|
||||
// This is a basic implementation - in production, use proper redaction
|
||||
var sensitivePatterns = new[]
|
||||
{
|
||||
"password=",
|
||||
"token=",
|
||||
"secret=",
|
||||
"api_key=",
|
||||
"apikey=",
|
||||
"auth="
|
||||
};
|
||||
|
||||
var result = command;
|
||||
foreach (var pattern in sensitivePatterns)
|
||||
{
|
||||
var index = result.IndexOf(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
while (index >= 0)
|
||||
{
|
||||
var endIndex = result.IndexOfAny([' ', '\n', '\r', '&', ';'], index + pattern.Length);
|
||||
if (endIndex < 0) endIndex = result.Length;
|
||||
|
||||
result = result[..(index + pattern.Length)] + "***REDACTED***" + result[endIndex..];
|
||||
index = result.IndexOf(pattern, index + pattern.Length + 12, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.FeatureFlags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class CompositeFeatureFlagServiceTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IFeatureFlagProvider> _primaryProvider;
|
||||
private readonly Mock<IFeatureFlagProvider> _secondaryProvider;
|
||||
private readonly CompositeFeatureFlagService _sut;
|
||||
|
||||
public CompositeFeatureFlagServiceTests()
|
||||
{
|
||||
_primaryProvider = new Mock<IFeatureFlagProvider>();
|
||||
_primaryProvider.Setup(p => p.Name).Returns("Primary");
|
||||
_primaryProvider.Setup(p => p.Priority).Returns(10);
|
||||
_primaryProvider.Setup(p => p.SupportsWatch).Returns(false);
|
||||
|
||||
_secondaryProvider = new Mock<IFeatureFlagProvider>();
|
||||
_secondaryProvider.Setup(p => p.Name).Returns("Secondary");
|
||||
_secondaryProvider.Setup(p => p.Priority).Returns(20);
|
||||
_secondaryProvider.Setup(p => p.SupportsWatch).Returns(false);
|
||||
|
||||
var options = Options.Create(new FeatureFlagOptions
|
||||
{
|
||||
EnableCaching = false,
|
||||
EnableLogging = false
|
||||
});
|
||||
|
||||
_sut = new CompositeFeatureFlagService(
|
||||
[_primaryProvider.Object, _secondaryProvider.Object],
|
||||
options,
|
||||
NullLogger<CompositeFeatureFlagService>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sut.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsEnabledAsync_ReturnsTrueWhenFlagEnabled()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Test reason", "Primary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.IsEnabledAsync("test-flag");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsEnabledAsync_ReturnsFalseWhenFlagDisabled()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", false, null, "Test reason", "Primary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.IsEnabledAsync("test-flag");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsEnabledAsync_ReturnsDefaultWhenFlagNotFound()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("unknown-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeatureFlagResult?)null);
|
||||
_secondaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("unknown-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeatureFlagResult?)null);
|
||||
|
||||
// Act
|
||||
var result = await _sut.IsEnabledAsync("unknown-flag");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // Default is false
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsResultFromHighestPriorityProvider()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", true, "variant-a", "Primary reason", "Primary"));
|
||||
_secondaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", false, "variant-b", "Secondary reason", "Secondary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync("test-flag");
|
||||
|
||||
// Assert
|
||||
result.Enabled.Should().BeTrue();
|
||||
result.Source.Should().Be("Primary");
|
||||
result.Variant.Should().Be("variant-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_FallsBackToSecondaryProviderWhenPrimaryReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeatureFlagResult?)null);
|
||||
_secondaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Secondary reason", "Secondary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync("test-flag");
|
||||
|
||||
// Assert
|
||||
result.Enabled.Should().BeTrue();
|
||||
result.Source.Should().Be("Secondary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_PassesContextToProvider()
|
||||
{
|
||||
// Arrange
|
||||
var context = new FeatureFlagEvaluationContext(
|
||||
UserId: "user-123",
|
||||
TenantId: "tenant-456",
|
||||
Environment: "production",
|
||||
Attributes: new Dictionary<string, object?> { { "role", "admin" } });
|
||||
|
||||
FeatureFlagEvaluationContext? capturedContext = null;
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, FeatureFlagEvaluationContext, CancellationToken>((_, ctx, _) => capturedContext = ctx)
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Test", "Primary"));
|
||||
|
||||
// Act
|
||||
await _sut.EvaluateAsync("test-flag", context);
|
||||
|
||||
// Assert
|
||||
capturedContext.Should().NotBeNull();
|
||||
capturedContext!.UserId.Should().Be("user-123");
|
||||
capturedContext.TenantId.Should().Be("tenant-456");
|
||||
capturedContext.Environment.Should().Be("production");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_HandlesProviderExceptionGracefully()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Provider error"));
|
||||
_secondaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("test-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("test-flag", true, null, "Fallback", "Secondary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync("test-flag");
|
||||
|
||||
// Assert
|
||||
result.Enabled.Should().BeTrue();
|
||||
result.Source.Should().Be("Secondary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVariantAsync_ReturnsVariantValue()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("variant-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("variant-flag", true, "blue", "Test", "Primary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetVariantAsync("variant-flag", "default");
|
||||
|
||||
// Assert
|
||||
result.Should().Be("blue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetVariantAsync_ReturnsDefaultWhenNoVariant()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.TryGetFlagAsync("simple-flag", It.IsAny<FeatureFlagEvaluationContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new FeatureFlagResult("simple-flag", true, null, "Test", "Primary"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetVariantAsync("simple-flag", "fallback");
|
||||
|
||||
// Assert
|
||||
result.Should().Be("fallback");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_AggregatesFlagsFromAllProviders()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([
|
||||
new FeatureFlagDefinition("flag-a", "Description A", true, true),
|
||||
new FeatureFlagDefinition("flag-b", "Description B", false, false)
|
||||
]);
|
||||
_secondaryProvider
|
||||
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([
|
||||
new FeatureFlagDefinition("flag-b", "Override B", true, true),
|
||||
new FeatureFlagDefinition("flag-c", "Description C", true, true)
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result.Should().Contain(f => f.Key == "flag-a");
|
||||
result.Should().Contain(f => f.Key == "flag-b" && f.DefaultValue == false); // Primary wins
|
||||
result.Should().Contain(f => f.Key == "flag-c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_ReturnsOrderedByKey()
|
||||
{
|
||||
// Arrange
|
||||
_primaryProvider
|
||||
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([
|
||||
new FeatureFlagDefinition("zebra", null, true, true),
|
||||
new FeatureFlagDefinition("alpha", null, true, true)
|
||||
]);
|
||||
_secondaryProvider
|
||||
.Setup(p => p.ListFlagsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
result.Select(f => f.Key).Should().BeInAscendingOrder();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.FeatureFlags.Providers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.FeatureFlags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class ConfigurationFeatureFlagProviderTests : IDisposable
|
||||
{
|
||||
private readonly ConfigurationFeatureFlagProvider _sut;
|
||||
private readonly IConfigurationRoot _configuration;
|
||||
|
||||
public ConfigurationFeatureFlagProviderTests()
|
||||
{
|
||||
var configData = new Dictionary<string, string?>
|
||||
{
|
||||
{ "FeatureFlags:SimpleFlag", "true" },
|
||||
{ "FeatureFlags:DisabledFlag", "false" },
|
||||
{ "FeatureFlags:ComplexFlag:Enabled", "true" },
|
||||
{ "FeatureFlags:ComplexFlag:Variant", "blue" },
|
||||
{ "FeatureFlags:ComplexFlag:Description", "A complex feature flag" },
|
||||
{ "FeatureFlags:VariantOnlyFlag:Enabled", "false" },
|
||||
{ "FeatureFlags:VariantOnlyFlag:Variant", "control" },
|
||||
{ "CustomSection:MyFlag", "true" }
|
||||
};
|
||||
|
||||
_configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configData)
|
||||
.Build();
|
||||
|
||||
_sut = new ConfigurationFeatureFlagProvider(_configuration);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sut.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsConfiguration()
|
||||
{
|
||||
_sut.Name.Should().Be("Configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Priority_ReturnsDefaultValue()
|
||||
{
|
||||
_sut.Priority.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsWatch_ReturnsTrue()
|
||||
{
|
||||
_sut.SupportsWatch.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ReturnsTrueForEnabledSimpleFlag()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.TryGetFlagAsync("SimpleFlag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
result.Key.Should().Be("SimpleFlag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ReturnsFalseForDisabledSimpleFlag()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.TryGetFlagAsync("DisabledFlag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ReturnsNullForUnknownFlag()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.TryGetFlagAsync("UnknownFlag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ParsesComplexFlagWithVariant()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.TryGetFlagAsync("ComplexFlag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
result.Variant.Should().Be("blue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ParsesComplexFlagWithEnabledFalse()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.TryGetFlagAsync("VariantOnlyFlag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeFalse();
|
||||
result.Variant.Should().Be("control");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_ReturnsAllDefinedFlags()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(4);
|
||||
result.Select(f => f.Key).Should().Contain([
|
||||
"SimpleFlag", "DisabledFlag", "ComplexFlag", "VariantOnlyFlag"
|
||||
]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_ParsesDefaultValuesCorrectly()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
var simpleFlag = result.Single(f => f.Key == "SimpleFlag");
|
||||
simpleFlag.DefaultValue.Should().BeTrue();
|
||||
|
||||
var disabledFlag = result.Single(f => f.Key == "DisabledFlag");
|
||||
disabledFlag.DefaultValue.Should().BeFalse();
|
||||
|
||||
var complexFlag = result.Single(f => f.Key == "ComplexFlag");
|
||||
complexFlag.DefaultValue.Should().BeTrue();
|
||||
complexFlag.Description.Should().Be("A complex feature flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithCustomSection_ReadsFromThatSection()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new ConfigurationFeatureFlagProvider(_configuration, "CustomSection");
|
||||
|
||||
// Act & Assert
|
||||
var result = provider.TryGetFlagAsync("MyFlag", FeatureFlagEvaluationContext.Empty).Result;
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithCustomPriority_SetsCorrectPriority()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new ConfigurationFeatureFlagProvider(_configuration, priority: 25);
|
||||
|
||||
// Assert
|
||||
provider.Priority.Should().Be(25);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("TRUE", true)]
|
||||
[InlineData("True", true)]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("FALSE", false)]
|
||||
[InlineData("False", false)]
|
||||
[InlineData("false", false)]
|
||||
public async Task TryGetFlagAsync_HandlesCaseInsensitiveBooleanValues(string configValue, bool expected)
|
||||
{
|
||||
// Arrange
|
||||
var configData = new Dictionary<string, string?>
|
||||
{
|
||||
{ "FeatureFlags:CaseFlag", configValue }
|
||||
};
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configData)
|
||||
.Build();
|
||||
|
||||
using var provider = new ConfigurationFeatureFlagProvider(configuration);
|
||||
|
||||
// Act
|
||||
var result = await provider.TryGetFlagAsync("CaseFlag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.FeatureFlags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class FeatureFlagModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void FeatureFlagResult_CanBeCreated()
|
||||
{
|
||||
// Act
|
||||
var result = new FeatureFlagResult(
|
||||
Key: "test-flag",
|
||||
Enabled: true,
|
||||
Variant: "blue",
|
||||
Reason: "Test reason",
|
||||
Source: "TestProvider");
|
||||
|
||||
// Assert
|
||||
result.Key.Should().Be("test-flag");
|
||||
result.Enabled.Should().BeTrue();
|
||||
result.Variant.Should().Be("blue");
|
||||
result.Reason.Should().Be("Test reason");
|
||||
result.Source.Should().Be("TestProvider");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagResult_WithNullOptionalValues()
|
||||
{
|
||||
// Act
|
||||
var result = new FeatureFlagResult(
|
||||
Key: "simple-flag",
|
||||
Enabled: false,
|
||||
Variant: null,
|
||||
Reason: null,
|
||||
Source: "TestProvider");
|
||||
|
||||
// Assert
|
||||
result.Variant.Should().BeNull();
|
||||
result.Reason.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagEvaluationContext_Empty_HasNullValues()
|
||||
{
|
||||
// Act
|
||||
var context = FeatureFlagEvaluationContext.Empty;
|
||||
|
||||
// Assert
|
||||
context.UserId.Should().BeNull();
|
||||
context.TenantId.Should().BeNull();
|
||||
context.Environment.Should().BeNull();
|
||||
context.Attributes.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagEvaluationContext_CanBeCreatedWithAllValues()
|
||||
{
|
||||
// Arrange
|
||||
var attributes = new Dictionary<string, object?>
|
||||
{
|
||||
{ "role", "admin" },
|
||||
{ "subscription", "premium" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = new FeatureFlagEvaluationContext(
|
||||
UserId: "user-123",
|
||||
TenantId: "tenant-456",
|
||||
Environment: "production",
|
||||
Attributes: attributes);
|
||||
|
||||
// Assert
|
||||
context.UserId.Should().Be("user-123");
|
||||
context.TenantId.Should().Be("tenant-456");
|
||||
context.Environment.Should().Be("production");
|
||||
context.Attributes.Should().HaveCount(2);
|
||||
context.Attributes!["role"].Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagDefinition_CanBeCreatedWithRequiredValues()
|
||||
{
|
||||
// Act
|
||||
var definition = new FeatureFlagDefinition(
|
||||
Key: "my-feature",
|
||||
Description: "My feature description",
|
||||
DefaultValue: true,
|
||||
Enabled: false);
|
||||
|
||||
// Assert
|
||||
definition.Key.Should().Be("my-feature");
|
||||
definition.Description.Should().Be("My feature description");
|
||||
definition.DefaultValue.Should().BeTrue();
|
||||
definition.Enabled.Should().BeFalse();
|
||||
definition.Tags.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagDefinition_CanBeCreatedWithTags()
|
||||
{
|
||||
// Arrange
|
||||
var tags = new List<string> { "team-a", "critical" };
|
||||
|
||||
// Act
|
||||
var definition = new FeatureFlagDefinition(
|
||||
Key: "feature",
|
||||
Description: null,
|
||||
DefaultValue: false,
|
||||
Enabled: true,
|
||||
Tags: tags);
|
||||
|
||||
// Assert
|
||||
definition.Tags.Should().NotBeNull();
|
||||
definition.Tags.Should().Contain("team-a");
|
||||
definition.Tags.Should().Contain("critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagChangedEvent_CanBeCreated()
|
||||
{
|
||||
// Arrange
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var evt = new FeatureFlagChangedEvent(
|
||||
Key: "toggle-flag",
|
||||
OldValue: false,
|
||||
NewValue: true,
|
||||
Source: "ConfigProvider",
|
||||
Timestamp: timestamp);
|
||||
|
||||
// Assert
|
||||
evt.Key.Should().Be("toggle-flag");
|
||||
evt.OldValue.Should().BeFalse();
|
||||
evt.NewValue.Should().BeTrue();
|
||||
evt.Source.Should().Be("ConfigProvider");
|
||||
evt.Timestamp.Should().Be(timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagOptions_HasCorrectDefaults()
|
||||
{
|
||||
// Act
|
||||
var options = new FeatureFlagOptions();
|
||||
|
||||
// Assert
|
||||
options.DefaultValue.Should().BeFalse();
|
||||
options.EnableCaching.Should().BeTrue();
|
||||
options.CacheDuration.Should().Be(TimeSpan.FromSeconds(30));
|
||||
options.EnableLogging.Should().BeTrue();
|
||||
options.EnableMetrics.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagOptions_CanBeModified()
|
||||
{
|
||||
// Act
|
||||
var options = new FeatureFlagOptions
|
||||
{
|
||||
DefaultValue = true,
|
||||
EnableCaching = false,
|
||||
CacheDuration = TimeSpan.FromHours(1),
|
||||
EnableLogging = false
|
||||
};
|
||||
|
||||
// Assert
|
||||
options.DefaultValue.Should().BeTrue();
|
||||
options.EnableCaching.Should().BeFalse();
|
||||
options.CacheDuration.Should().Be(TimeSpan.FromHours(1));
|
||||
options.EnableLogging.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagEvaluationContext_RecordEquality()
|
||||
{
|
||||
// Arrange
|
||||
var context1 = new FeatureFlagEvaluationContext("user", "tenant", "env", null);
|
||||
var context2 = new FeatureFlagEvaluationContext("user", "tenant", "env", null);
|
||||
var context3 = new FeatureFlagEvaluationContext("other", "tenant", "env", null);
|
||||
|
||||
// Assert - Records have value equality
|
||||
context1.Should().Be(context2);
|
||||
context1.Should().NotBe(context3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeatureFlagResult_RecordEquality()
|
||||
{
|
||||
// Arrange
|
||||
var result1 = new FeatureFlagResult("key", true, null, "reason", "source");
|
||||
var result2 = new FeatureFlagResult("key", true, null, "reason", "source");
|
||||
var result3 = new FeatureFlagResult("key", false, null, "reason", "source");
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(result2);
|
||||
result1.Should().NotBe(result3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.FeatureFlags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class FeatureFlagServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddFeatureFlags_RegistersFeatureFlagService()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddFeatureFlags();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var service = provider.GetService<IFeatureFlagService>();
|
||||
service.Should().NotBeNull();
|
||||
service.Should().BeOfType<CompositeFeatureFlagService>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFeatureFlags_WithOptions_ConfiguresOptions()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddFeatureFlags(options =>
|
||||
{
|
||||
options.EnableCaching = true;
|
||||
options.CacheDuration = TimeSpan.FromMinutes(5);
|
||||
options.DefaultValue = true;
|
||||
});
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<FeatureFlagOptions>>();
|
||||
|
||||
// Assert
|
||||
options.Value.EnableCaching.Should().BeTrue();
|
||||
options.Value.CacheDuration.Should().Be(TimeSpan.FromMinutes(5));
|
||||
options.Value.DefaultValue.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationFeatureFlags_RegistersConfigurationProvider()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configData = new Dictionary<string, string?>
|
||||
{
|
||||
{ "FeatureFlags:TestFlag", "true" }
|
||||
};
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configData)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddFeatureFlags();
|
||||
services.AddConfigurationFeatureFlags();
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
|
||||
|
||||
// Assert
|
||||
featureFlagProviders.Should().ContainSingle();
|
||||
featureFlagProviders[0].Name.Should().Be("Configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationFeatureFlags_WithCustomSectionName_UsesCorrectSection()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configData = new Dictionary<string, string?>
|
||||
{
|
||||
{ "CustomFlags:MyFlag", "true" }
|
||||
};
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configData)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddFeatureFlags();
|
||||
services.AddConfigurationFeatureFlags(sectionName: "CustomFlags");
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagService = provider.GetRequiredService<IFeatureFlagService>();
|
||||
var result = featureFlagService.IsEnabledAsync("MyFlag").Result;
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddInMemoryFeatureFlags_RegistersInMemoryProvider()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "InMemoryFlag", true }
|
||||
};
|
||||
|
||||
services.AddFeatureFlags();
|
||||
services.AddInMemoryFeatureFlags(flags);
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagService = provider.GetRequiredService<IFeatureFlagService>();
|
||||
var result = featureFlagService.IsEnabledAsync("InMemoryFlag").Result;
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddInMemoryFeatureFlags_WithPriority_SetsCorrectPriority()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddFeatureFlags();
|
||||
services.AddInMemoryFeatureFlags(new Dictionary<string, bool> { { "Flag", true } }, priority: 5);
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
|
||||
|
||||
// Assert
|
||||
featureFlagProviders.Single().Priority.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFeatureFlagProvider_RegistersCustomProvider()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddFeatureFlags();
|
||||
services.AddFeatureFlagProvider<TestFeatureFlagProvider>();
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
|
||||
|
||||
// Assert
|
||||
featureFlagProviders.Should().ContainSingle();
|
||||
featureFlagProviders[0].Should().BeOfType<TestFeatureFlagProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFeatureFlagProvider_WithFactory_RegistersCustomProvider()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.AddFeatureFlags();
|
||||
services.AddFeatureFlagProvider(_ => new TestFeatureFlagProvider());
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagProviders = provider.GetServices<IFeatureFlagProvider>().ToList();
|
||||
|
||||
// Assert
|
||||
featureFlagProviders.Should().ContainSingle();
|
||||
featureFlagProviders[0].Should().BeOfType<TestFeatureFlagProvider>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleProviders_AreRegisteredInPriorityOrder()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
var configData = new Dictionary<string, string?>
|
||||
{
|
||||
{ "FeatureFlags:SharedFlag", "false" }
|
||||
};
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configData)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddFeatureFlags();
|
||||
services.AddConfigurationFeatureFlags(priority: 50);
|
||||
services.AddInMemoryFeatureFlags(new Dictionary<string, bool> { { "SharedFlag", true } }, priority: 10);
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var featureFlagService = provider.GetRequiredService<IFeatureFlagService>();
|
||||
var result = featureFlagService.IsEnabledAsync("SharedFlag").Result;
|
||||
|
||||
// Assert - InMemory has lower priority number (higher precedence), so it wins
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
private class TestFeatureFlagProvider : FeatureFlagProviderBase
|
||||
{
|
||||
public override string Name => "Test";
|
||||
|
||||
public override Task<FeatureFlagResult?> TryGetFlagAsync(
|
||||
string flagKey,
|
||||
FeatureFlagEvaluationContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult<FeatureFlagResult?>(
|
||||
new FeatureFlagResult(flagKey, true, null, "Test", Name));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.FeatureFlags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class InMemoryFeatureFlagProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Name_ReturnsInMemory()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
|
||||
// Assert
|
||||
provider.Name.Should().Be("InMemory");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Priority_ReturnsConfiguredValue()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>(), priority: 5);
|
||||
|
||||
// Assert
|
||||
provider.Priority.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ReturnsTrueForEnabledFlag()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "enabled-flag", true }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
var result = await provider.TryGetFlagAsync("enabled-flag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
result.Key.Should().Be("enabled-flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ReturnsFalseForDisabledFlag()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "disabled-flag", false }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
var result = await provider.TryGetFlagAsync("disabled-flag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_ReturnsNullForUnknownFlag()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
|
||||
// Act
|
||||
var result = await provider.TryGetFlagAsync("unknown", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryGetFlagAsync_IsCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "MyFlag", true }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
var result = await provider.TryGetFlagAsync("myflag", FeatureFlagEvaluationContext.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetFlag_AddsNewFlag()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
|
||||
// Act
|
||||
provider.SetFlag("new-flag", true);
|
||||
var result = provider.TryGetFlagAsync("new-flag", FeatureFlagEvaluationContext.Empty).Result;
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetFlag_UpdatesExistingFlag()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "toggle-flag", false }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
provider.SetFlag("toggle-flag", true);
|
||||
var result = provider.TryGetFlagAsync("toggle-flag", FeatureFlagEvaluationContext.Empty).Result;
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetFlag_WithVariant_SetsVariantValue()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
|
||||
// Act
|
||||
provider.SetFlag("variant-flag", true, "blue");
|
||||
var result = provider.TryGetFlagAsync("variant-flag", FeatureFlagEvaluationContext.Empty).Result;
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Variant.Should().Be("blue");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveFlag_RemovesExistingFlag()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "to-remove", true }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
provider.RemoveFlag("to-remove");
|
||||
var result = provider.TryGetFlagAsync("to-remove", FeatureFlagEvaluationContext.Empty).Result;
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveFlag_DoesNotThrowForNonexistentFlag()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
|
||||
// Act & Assert
|
||||
provider.Invoking(p => p.RemoveFlag("nonexistent"))
|
||||
.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllFlags()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "flag-1", true },
|
||||
{ "flag-2", false }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
provider.Clear();
|
||||
var result1 = provider.TryGetFlagAsync("flag-1", FeatureFlagEvaluationContext.Empty).Result;
|
||||
var result2 = provider.TryGetFlagAsync("flag-2", FeatureFlagEvaluationContext.Empty).Result;
|
||||
|
||||
// Assert
|
||||
result1.Should().BeNull();
|
||||
result2.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_ReturnsAllFlags()
|
||||
{
|
||||
// Arrange
|
||||
var flags = new Dictionary<string, bool>
|
||||
{
|
||||
{ "flag-a", true },
|
||||
{ "flag-b", false },
|
||||
{ "flag-c", true }
|
||||
};
|
||||
var provider = new InMemoryFeatureFlagProvider(flags);
|
||||
|
||||
// Act
|
||||
var result = await provider.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(3);
|
||||
result.Select(f => f.Key).Should().Contain(["flag-a", "flag-b", "flag-c"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_ReturnsEmptyWhenNoFlags()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
|
||||
// Act
|
||||
var result = await provider.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFlagsAsync_ReflectsCurrentStateAfterModifications()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new InMemoryFeatureFlagProvider(new Dictionary<string, bool>());
|
||||
provider.SetFlag("dynamic-flag", true);
|
||||
|
||||
// Act
|
||||
var result = await provider.ListFlagsAsync();
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result.Single().Key.Should().Be("dynamic-flag");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.FeatureFlags.Tests</RootNamespace>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.FeatureFlags\StellaOps.FeatureFlags.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -21,13 +21,27 @@ public static class PolicyDslValidatorApp
|
||||
var root = PolicyDslValidatorCommand.Build(runner);
|
||||
var parseResult = root.Parse(args, new ParserConfiguration());
|
||||
var invocationConfiguration = new InvocationConfiguration();
|
||||
|
||||
if (parseResult.Errors.Count > 0)
|
||||
using var cancellationSource = new CancellationTokenSource();
|
||||
ConsoleCancelEventHandler? handler = (_, e) =>
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
e.Cancel = true;
|
||||
cancellationSource.Cancel();
|
||||
};
|
||||
Console.CancelKeyPress += handler;
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (parseResult.Errors.Count > 0)
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, cancellationSource.Token);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, cancellationSource.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,27 @@ public static class PolicySchemaExporterApp
|
||||
var root = PolicySchemaExporterCommand.Build(runner);
|
||||
var parseResult = root.Parse(args, new ParserConfiguration());
|
||||
var invocationConfiguration = new InvocationConfiguration();
|
||||
|
||||
if (parseResult.Errors.Count > 0)
|
||||
using var cancellationSource = new CancellationTokenSource();
|
||||
ConsoleCancelEventHandler? handler = (_, e) =>
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
e.Cancel = true;
|
||||
cancellationSource.Cancel();
|
||||
};
|
||||
Console.CancelKeyPress += handler;
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (parseResult.Errors.Count > 0)
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, cancellationSource.Token);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, cancellationSource.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class PolicySchemaExporterRunner
|
||||
}
|
||||
|
||||
var outputPath = Path.Combine(outputDirectory, export.FileName);
|
||||
await File.WriteAllTextAsync(outputPath, json + Environment.NewLine, cancellationToken);
|
||||
await File.WriteAllTextAsync(outputPath, json + "\n", cancellationToken);
|
||||
Console.WriteLine($"Wrote {outputPath}");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,27 @@ public static class PolicySimulationSmokeApp
|
||||
var root = PolicySimulationSmokeCommand.Build(runner);
|
||||
var parseResult = root.Parse(args, new ParserConfiguration());
|
||||
var invocationConfiguration = new InvocationConfiguration();
|
||||
|
||||
if (parseResult.Errors.Count > 0)
|
||||
using var cancellationSource = new CancellationTokenSource();
|
||||
ConsoleCancelEventHandler? handler = (_, e) =>
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
e.Cancel = true;
|
||||
cancellationSource.Cancel();
|
||||
};
|
||||
Console.CancelKeyPress += handler;
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (parseResult.Errors.Count > 0)
|
||||
{
|
||||
await parseResult.InvokeAsync(invocationConfiguration, cancellationSource.Token);
|
||||
return 64; // EX_USAGE
|
||||
}
|
||||
|
||||
return await parseResult.InvokeAsync(invocationConfiguration, cancellationSource.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ public sealed record PolicySimulationSmokeOptions
|
||||
public DateTimeOffset? FixedTime { get; init; }
|
||||
}
|
||||
|
||||
internal static class PolicySimulationSmokeDefaults
|
||||
{
|
||||
public static readonly DateTimeOffset DefaultFixedTime = DateTimeOffset.UnixEpoch;
|
||||
|
||||
public static DateTimeOffset ResolveFixedTime(DateTimeOffset? fixedTime)
|
||||
=> fixedTime ?? DefaultFixedTime;
|
||||
}
|
||||
|
||||
public sealed class PolicySimulationSmokeRunner
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
@@ -54,9 +62,8 @@ public sealed class PolicySimulationSmokeRunner
|
||||
return 0;
|
||||
}
|
||||
|
||||
var timeProvider = options.FixedTime.HasValue
|
||||
? new FixedTimeProvider(options.FixedTime.Value)
|
||||
: TimeProvider.System;
|
||||
var fixedTime = PolicySimulationSmokeDefaults.ResolveFixedTime(options.FixedTime);
|
||||
var timeProvider = new FixedTimeProvider(fixedTime);
|
||||
|
||||
var snapshotStore = new PolicySnapshotStore(
|
||||
new NullPolicySnapshotRepository(),
|
||||
@@ -64,7 +71,10 @@ public sealed class PolicySimulationSmokeRunner
|
||||
timeProvider,
|
||||
null,
|
||||
_loggerFactory.CreateLogger<PolicySnapshotStore>());
|
||||
var previewService = new PolicyPreviewService(snapshotStore, _loggerFactory.CreateLogger<PolicyPreviewService>());
|
||||
var previewService = new PolicyPreviewService(
|
||||
snapshotStore,
|
||||
_loggerFactory.CreateLogger<PolicyPreviewService>(),
|
||||
timeProvider);
|
||||
|
||||
var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
@@ -80,10 +90,28 @@ public sealed class PolicySimulationSmokeRunner
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var scenarioText = await File.ReadAllTextAsync(scenarioFile, cancellationToken);
|
||||
var scenario = JsonSerializer.Deserialize<PolicySimulationScenario>(scenarioText, serializerOptions);
|
||||
PolicySimulationScenario? scenario;
|
||||
string? scenarioError = null;
|
||||
try
|
||||
{
|
||||
scenario = JsonSerializer.Deserialize<PolicySimulationScenario>(scenarioText, serializerOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
scenarioError = ex.Message;
|
||||
scenario = null;
|
||||
}
|
||||
|
||||
var scenarioName = ResolveScenarioName(scenario, scenarioFile, scenarioRoot);
|
||||
var scenarioResult = new ScenarioResult(scenarioName);
|
||||
|
||||
if (scenario is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to deserialize scenario '{scenarioFile}'.");
|
||||
var message = scenarioError is null
|
||||
? $"Failed to deserialize scenario '{scenarioFile}'."
|
||||
: $"Failed to deserialize scenario '{scenarioFile}': {scenarioError}";
|
||||
AddFailure(scenarioResult, message);
|
||||
summary.Add(scenarioResult with { Success = false });
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
@@ -91,26 +119,73 @@ public sealed class PolicySimulationSmokeRunner
|
||||
var policyPath = PolicySimulationSmokePaths.ResolvePolicyPath(scenario.PolicyPath, repoRoot);
|
||||
if (policyPath is null)
|
||||
{
|
||||
Console.Error.WriteLine($"Policy path '{scenario.PolicyPath}' is relative; provide --repo-root or use an absolute path.");
|
||||
var message = $"Scenario '{scenarioName}' policy path '{scenario.PolicyPath}' is relative; provide --repo-root or use an absolute path.";
|
||||
AddFailure(scenarioResult, message);
|
||||
summary.Add(scenarioResult with { Success = false });
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(policyPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Policy file '{scenario.PolicyPath}' referenced by scenario '{scenario.Name}' does not exist.");
|
||||
var message = $"Scenario '{scenarioName}' policy file '{scenario.PolicyPath}' does not exist.";
|
||||
AddFailure(scenarioResult, message);
|
||||
summary.Add(scenarioResult with { Success = false });
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var policyContent = await File.ReadAllTextAsync(policyPath, cancellationToken);
|
||||
var policyFormat = PolicySchema.DetectFormat(policyPath);
|
||||
var findings = scenario.Findings.Select(ToPolicyFinding).ToImmutableArray();
|
||||
var baseline = scenario.Baseline?.Select(ToPolicyVerdict).ToImmutableArray() ?? ImmutableArray<PolicyVerdict>.Empty;
|
||||
var findings = ImmutableArray.CreateBuilder<PolicyFinding>(scenario.Findings.Count);
|
||||
var hasErrors = false;
|
||||
|
||||
foreach (var finding in scenario.Findings)
|
||||
{
|
||||
if (!TryBuildFinding(finding, scenarioName, out var policyFinding, out var error))
|
||||
{
|
||||
AddFailure(scenarioResult, error ?? "Unknown error building finding");
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
findings.Add(policyFinding);
|
||||
}
|
||||
|
||||
ImmutableArray<PolicyVerdict> baseline;
|
||||
if (scenario.Baseline is { Count: > 0 })
|
||||
{
|
||||
var baselineBuilder = ImmutableArray.CreateBuilder<PolicyVerdict>(scenario.Baseline.Count);
|
||||
foreach (var entry in scenario.Baseline)
|
||||
{
|
||||
if (!TryBuildVerdict(entry, scenarioName, out var verdict, out var error))
|
||||
{
|
||||
AddFailure(scenarioResult, error ?? "Unknown error building verdict");
|
||||
hasErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
baselineBuilder.Add(verdict);
|
||||
}
|
||||
|
||||
baseline = baselineBuilder.ToImmutable();
|
||||
}
|
||||
else
|
||||
{
|
||||
baseline = ImmutableArray<PolicyVerdict>.Empty;
|
||||
}
|
||||
|
||||
if (hasErrors)
|
||||
{
|
||||
summary.Add(scenarioResult with { Success = false });
|
||||
success = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var scenarioIdentifier = NormalizeScenarioIdentifier(scenarioName);
|
||||
var request = new PolicyPreviewRequest(
|
||||
ImageDigest: $"sha256:simulation-{scenario.Name}",
|
||||
Findings: findings,
|
||||
ImageDigest: $"sha256:simulation-{scenarioIdentifier}",
|
||||
Findings: findings.ToImmutable(),
|
||||
BaselineVerdicts: baseline,
|
||||
SnapshotOverride: null,
|
||||
ProposedPolicy: new PolicySnapshotContent(
|
||||
@@ -118,13 +193,13 @@ public sealed class PolicySimulationSmokeRunner
|
||||
Format: policyFormat,
|
||||
Actor: "ci",
|
||||
Source: "ci/simulation-smoke",
|
||||
Description: $"CI simulation for scenario '{scenario.Name}'"));
|
||||
Description: $"CI simulation for scenario '{scenarioName}'"));
|
||||
|
||||
var response = await previewService.PreviewAsync(request, cancellationToken);
|
||||
var scenarioResult = PolicySimulationSmokeEvaluator.EvaluateScenario(scenario, response);
|
||||
summary.Add(scenarioResult);
|
||||
var evaluatedResult = PolicySimulationSmokeEvaluator.EvaluateScenario(scenario, response);
|
||||
summary.Add(evaluatedResult);
|
||||
|
||||
if (!scenarioResult.Success)
|
||||
if (!evaluatedResult.Success)
|
||||
{
|
||||
success = false;
|
||||
}
|
||||
@@ -141,18 +216,53 @@ public sealed class PolicySimulationSmokeRunner
|
||||
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
var summaryPath = Path.Combine(outputDirectory, "policy-simulation-summary.json");
|
||||
var summaryJson = JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true });
|
||||
var summaryOutput = BuildSummaryOutput(summary);
|
||||
var summaryJson = JsonSerializer.Serialize(summaryOutput, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(summaryPath, summaryJson, cancellationToken);
|
||||
}
|
||||
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static PolicyFinding ToPolicyFinding(ScenarioFinding finding)
|
||||
private static string ResolveScenarioName(PolicySimulationScenario? scenario, string scenarioFile, string scenarioRoot)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(scenario?.Name))
|
||||
{
|
||||
return scenario!.Name;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(scenarioRoot, scenarioFile);
|
||||
return string.IsNullOrWhiteSpace(relative) ? scenarioFile : relative;
|
||||
}
|
||||
|
||||
private static string NormalizeScenarioIdentifier(string scenarioName)
|
||||
=> scenarioName
|
||||
.Replace(Path.DirectorySeparatorChar, '-')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '-');
|
||||
|
||||
private static void AddFailure(ScenarioResult result, string message)
|
||||
{
|
||||
result.Failures.Add(message);
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
|
||||
private static bool TryBuildFinding(
|
||||
ScenarioFinding finding,
|
||||
string scenarioName,
|
||||
out PolicyFinding policyFinding,
|
||||
out string? error)
|
||||
{
|
||||
error = null;
|
||||
policyFinding = default!;
|
||||
|
||||
if (!TryParseSeverity(finding.Severity, out var severity))
|
||||
{
|
||||
error = $"Scenario '{scenarioName}' finding '{finding.FindingId}' has invalid severity '{finding.Severity}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var tags = finding.Tags is null ? ImmutableArray<string>.Empty : ImmutableArray.CreateRange(finding.Tags);
|
||||
var severity = Enum.Parse<PolicySeverity>(finding.Severity, ignoreCase: true);
|
||||
return new PolicyFinding(
|
||||
policyFinding = new PolicyFinding(
|
||||
finding.FindingId,
|
||||
severity,
|
||||
finding.Environment,
|
||||
@@ -167,13 +277,26 @@ public sealed class PolicySimulationSmokeRunner
|
||||
finding.Path,
|
||||
finding.LayerDigest,
|
||||
tags);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static PolicyVerdict ToPolicyVerdict(ScenarioBaseline baseline)
|
||||
private static bool TryBuildVerdict(
|
||||
ScenarioBaseline baseline,
|
||||
string scenarioName,
|
||||
out PolicyVerdict verdict,
|
||||
out string? error)
|
||||
{
|
||||
var status = Enum.Parse<PolicyVerdictStatus>(baseline.Status, ignoreCase: true);
|
||||
error = null;
|
||||
verdict = default!;
|
||||
|
||||
if (!TryParseVerdictStatus(baseline.Status, out var status))
|
||||
{
|
||||
error = $"Scenario '{scenarioName}' baseline '{baseline.FindingId}' has invalid status '{baseline.Status}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var inputs = baseline.Inputs?.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase) ?? ImmutableDictionary<string, double>.Empty;
|
||||
return new PolicyVerdict(
|
||||
verdict = new PolicyVerdict(
|
||||
baseline.FindingId,
|
||||
status,
|
||||
RuleName: baseline.RuleName,
|
||||
@@ -189,7 +312,70 @@ public sealed class PolicySimulationSmokeRunner
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseSeverity(string? value, out PolicySeverity severity)
|
||||
{
|
||||
severity = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(value, ignoreCase: true, out severity))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Enum.IsDefined(typeof(PolicySeverity), severity);
|
||||
}
|
||||
|
||||
private static bool TryParseVerdictStatus(string? value, out PolicyVerdictStatus status)
|
||||
{
|
||||
status = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(value, ignoreCase: true, out status))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Enum.IsDefined(typeof(PolicyVerdictStatus), status);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ScenarioResultOutput> BuildSummaryOutput(IReadOnlyList<ScenarioResult> summary)
|
||||
{
|
||||
var output = new List<ScenarioResultOutput>(summary.Count);
|
||||
foreach (var result in summary)
|
||||
{
|
||||
var failures = result.Failures.Count == 0 ? new List<string>() : new List<string>(result.Failures);
|
||||
var statuses = new SortedDictionary<string, string>(result.ActualStatuses, StringComparer.OrdinalIgnoreCase);
|
||||
output.Add(new ScenarioResultOutput(result.ScenarioName, result.Success, result.ChangedCount, failures, statuses));
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private sealed record ScenarioResultOutput(
|
||||
string ScenarioName,
|
||||
bool Success,
|
||||
int ChangedCount,
|
||||
IReadOnlyList<string> Failures,
|
||||
SortedDictionary<string, string> ActualStatuses);
|
||||
}
|
||||
|
||||
public static class PolicySimulationSmokeEvaluator
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Tools.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="..\..\Scheduler\__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj" />
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0096-M | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0096-T | DONE | Revalidated 2026-01-08. |
|
||||
| AUDIT-0096-A | TODO | Revalidated 2026-01-08 (open findings). |
|
||||
| AUDIT-0096-A | DONE | Applied 2026-01-14 (deterministic output, parsing guards, tests). |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma warning disable ASPDEPR002 // WithOpenApi is deprecated - will migrate to new OpenAPI approach
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -166,10 +167,7 @@ public static partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting cache entry for VeriKey {VeriKey}", veriKey);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache lookup failed");
|
||||
return InternalError("Cache lookup failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,10 +212,7 @@ public static partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error storing cache entry for VeriKey {VeriKey}", request.Entry?.VeriKey);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache write failed");
|
||||
return InternalError("Cache write failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,10 +264,7 @@ public static partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error invalidating cache entries");
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache invalidation failed");
|
||||
return InternalError("Cache invalidation failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,10 +304,7 @@ public static partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting cache metrics");
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Metrics retrieval failed");
|
||||
return InternalError("Metrics retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,10 +366,7 @@ public static partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting input manifest for VeriKey {VeriKey}", veriKey);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Manifest retrieval failed");
|
||||
return InternalError("Manifest retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +377,7 @@ public static partial class ProvcacheEndpointExtensions
|
||||
{
|
||||
// Build input manifest from the entry and its embedded DecisionDigest
|
||||
// The DecisionDigest contains the VeriKey components as hashes
|
||||
var decision = entry.Decision;
|
||||
var placeholderHash = BuildPlaceholderHash(entry.VeriKey);
|
||||
|
||||
return new InputManifestResponse
|
||||
{
|
||||
@@ -405,12 +391,12 @@ public static partial class ProvcacheEndpointExtensions
|
||||
{
|
||||
// SBOM hash is embedded in VeriKey computation
|
||||
// In a full implementation, we'd resolve this from the SBOM store
|
||||
Hash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder - actual hash would come from VeriKey decomposition
|
||||
Hash = placeholderHash, // Placeholder - actual hash would come from VeriKey decomposition
|
||||
},
|
||||
Vex = new VexInfoDto
|
||||
{
|
||||
// VEX hash set is embedded in VeriKey computation
|
||||
HashSetHash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder
|
||||
HashSetHash = placeholderHash, // Placeholder
|
||||
StatementCount = 0, // Would be resolved from VEX store
|
||||
},
|
||||
Policy = new PolicyInfoDto
|
||||
@@ -431,6 +417,43 @@ public static partial class ProvcacheEndpointExtensions
|
||||
GeneratedAt = timeProvider.GetUtcNow(),
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildPlaceholderHash(string veriKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(veriKey))
|
||||
{
|
||||
return "sha256:unknown";
|
||||
}
|
||||
|
||||
var trimmed = veriKey;
|
||||
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
trimmed = trimmed["sha256:".Length..];
|
||||
}
|
||||
|
||||
if (trimmed.Length < 32)
|
||||
{
|
||||
return "sha256:unknown";
|
||||
}
|
||||
|
||||
return $"sha256:{trimmed[..32]}...";
|
||||
}
|
||||
|
||||
private static IResult BadRequest(string detail, string title)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: detail,
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: title);
|
||||
}
|
||||
|
||||
private static IResult InternalError(string title)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "An unexpected error occurred while processing the request.",
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -471,6 +494,16 @@ partial class ProvcacheEndpointExtensions
|
||||
|
||||
try
|
||||
{
|
||||
if (offset is < 0)
|
||||
{
|
||||
return BadRequest("Offset must be zero or greater.", "Invalid pagination");
|
||||
}
|
||||
|
||||
if (limit is <= 0)
|
||||
{
|
||||
return BadRequest("Limit must be greater than zero.", "Invalid pagination");
|
||||
}
|
||||
|
||||
var startIndex = offset ?? 0;
|
||||
var pageSize = Math.Min(limit ?? DefaultPageSize, MaxPageSize);
|
||||
|
||||
@@ -481,10 +514,25 @@ partial class ProvcacheEndpointExtensions
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (startIndex >= manifest.TotalChunks)
|
||||
{
|
||||
return Results.Ok(new ProofEvidenceResponse
|
||||
{
|
||||
ProofRoot = proofRoot,
|
||||
TotalChunks = manifest.TotalChunks,
|
||||
TotalSize = manifest.TotalSize,
|
||||
Chunks = [],
|
||||
NextCursor = null,
|
||||
HasMore = false
|
||||
});
|
||||
}
|
||||
|
||||
// Get chunk range
|
||||
var chunks = await chunkRepository.GetChunkRangeAsync(proofRoot, startIndex, pageSize, cancellationToken);
|
||||
|
||||
var chunkResponses = chunks.Select(c => new ProofChunkResponse
|
||||
var chunkResponses = chunks
|
||||
.OrderBy(c => c.ChunkIndex)
|
||||
.Select(c => new ProofChunkResponse
|
||||
{
|
||||
ChunkId = c.ChunkId,
|
||||
Index = c.ChunkIndex,
|
||||
@@ -495,7 +543,9 @@ partial class ProvcacheEndpointExtensions
|
||||
}).ToList();
|
||||
|
||||
var hasMore = startIndex + chunks.Count < manifest.TotalChunks;
|
||||
var nextCursor = hasMore ? (startIndex + pageSize).ToString() : null;
|
||||
var nextCursor = hasMore
|
||||
? (startIndex + pageSize).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
return Results.Ok(new ProofEvidenceResponse
|
||||
{
|
||||
@@ -510,10 +560,7 @@ partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting evidence chunks for proof root {ProofRoot}", proofRoot);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Evidence retrieval failed");
|
||||
return InternalError("Evidence retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +583,9 @@ partial class ProvcacheEndpointExtensions
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var chunkMetadata = manifest.Chunks.Select(c => new ChunkMetadataResponse
|
||||
var chunkMetadata = manifest.Chunks
|
||||
.OrderBy(c => c.Index)
|
||||
.Select(c => new ChunkMetadataResponse
|
||||
{
|
||||
ChunkId = c.ChunkId,
|
||||
Index = c.Index,
|
||||
@@ -557,10 +606,7 @@ partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting manifest for proof root {ProofRoot}", proofRoot);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Manifest retrieval failed");
|
||||
return InternalError("Manifest retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,10 +643,7 @@ partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting chunk {ChunkIndex} for proof root {ProofRoot}", chunkIndex, proofRoot);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Chunk retrieval failed");
|
||||
return InternalError("Chunk retrieval failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,10 +667,11 @@ partial class ProvcacheEndpointExtensions
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var orderedChunks = chunks.OrderBy(c => c.ChunkIndex).ToList();
|
||||
var chunkResults = new List<ChunkVerificationResult>();
|
||||
var allValid = true;
|
||||
|
||||
foreach (var chunk in chunks)
|
||||
foreach (var chunk in orderedChunks)
|
||||
{
|
||||
var isValid = chunker.VerifyChunk(chunk);
|
||||
var computedHash = isValid ? chunk.ChunkHash : ComputeChunkHash(chunk.Blob);
|
||||
@@ -647,7 +691,7 @@ partial class ProvcacheEndpointExtensions
|
||||
}
|
||||
|
||||
// Verify Merkle root
|
||||
var chunkHashes = chunks.Select(c => c.ChunkHash).ToList();
|
||||
var chunkHashes = orderedChunks.Select(c => c.ChunkHash).ToList();
|
||||
var computedRoot = chunker.ComputeMerkleRoot(chunkHashes);
|
||||
var rootMatches = string.Equals(computedRoot, proofRoot, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -662,10 +706,7 @@ partial class ProvcacheEndpointExtensions
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error verifying proof root {ProofRoot}", proofRoot);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Proof verification failed");
|
||||
return InternalError("Proof verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0098-M | DONE | Revalidated 2026-01-08; maintainability audit for Provcache.Api. |
|
||||
| AUDIT-0098-T | DONE | Revalidated 2026-01-08; test coverage audit for Provcache.Api. |
|
||||
| AUDIT-0098-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| AUDIT-0098-A | DONE | Applied 2026-01-13 (error redaction, ordering/pagination, placeholder guard, tests). |
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Provcache.Entities;
|
||||
|
||||
@@ -16,7 +17,11 @@ public sealed class PostgresProvcacheRepository : IProvcacheRepository
|
||||
private readonly ILogger<PostgresProvcacheRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private static readonly JsonSerializerOptions ReplaySeedJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public PostgresProvcacheRepository(
|
||||
ProvcacheDbContext context,
|
||||
@@ -28,11 +33,6 @@ public sealed class PostgresProvcacheRepository : IProvcacheRepository
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -295,7 +295,7 @@ public sealed class PostgresProvcacheRepository : IProvcacheRepository
|
||||
|
||||
private ProvcacheEntry MapToEntry(ProvcacheItemEntity entity)
|
||||
{
|
||||
var replaySeed = JsonSerializer.Deserialize<ReplaySeed>(entity.ReplaySeed, _jsonOptions)
|
||||
var replaySeed = JsonSerializer.Deserialize<ReplaySeed>(entity.ReplaySeed, ReplaySeedJsonOptions)
|
||||
?? new ReplaySeed { FeedIds = [], RuleIds = [] };
|
||||
|
||||
return new ProvcacheEntry
|
||||
@@ -330,7 +330,7 @@ public sealed class PostgresProvcacheRepository : IProvcacheRepository
|
||||
DigestVersion = entry.Decision.DigestVersion,
|
||||
VerdictHash = entry.Decision.VerdictHash,
|
||||
ProofRoot = entry.Decision.ProofRoot,
|
||||
ReplaySeed = JsonSerializer.Serialize(entry.Decision.ReplaySeed, _jsonOptions),
|
||||
ReplaySeed = CanonJson.Serialize(entry.Decision.ReplaySeed, ReplaySeedJsonOptions),
|
||||
PolicyHash = entry.PolicyHash,
|
||||
SignerSetHash = entry.SignerSetHash,
|
||||
FeedEpoch = entry.FeedEpoch,
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0099-M | DONE | Revalidated 2026-01-08; maintainability audit for Provcache.Postgres. |
|
||||
| AUDIT-0099-T | DONE | Revalidated 2026-01-08; test coverage audit for Provcache.Postgres. |
|
||||
| AUDIT-0099-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| AUDIT-0099-A | DONE | Applied 2026-01-13 (CanonJson replay seeds; test gaps tracked). |
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0100-M | DONE | Revalidated 2026-01-08; maintainability audit for Provcache.Valkey. |
|
||||
| AUDIT-0100-T | DONE | Revalidated 2026-01-08; test coverage audit for Provcache.Valkey. |
|
||||
| AUDIT-0100-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| AUDIT-0100-A | DONE | Applied 2026-01-13 (SCAN invalidation, cancellation propagation; test gaps tracked). |
|
||||
|
||||
@@ -18,6 +18,8 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private IDatabase? _database;
|
||||
private const int DefaultScanPageSize = 200;
|
||||
private const int DefaultDeleteBatchSize = 500;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string ProviderName => "valkey";
|
||||
@@ -43,10 +45,11 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
public async ValueTask<ProvcacheLookupResult> GetAsync(string veriKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync().ConfigureAwait(false);
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var redisKey = BuildKey(veriKey);
|
||||
|
||||
var value = await db.StringGetAsync(redisKey).ConfigureAwait(false);
|
||||
@@ -92,6 +95,7 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var keyList = veriKeys.ToList();
|
||||
|
||||
if (keyList.Count == 0)
|
||||
@@ -106,7 +110,7 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync().ConfigureAwait(false);
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var redisKeys = keyList.Select(k => (RedisKey)BuildKey(k)).ToArray();
|
||||
|
||||
var values = await db.StringGetAsync(redisKeys).ConfigureAwait(false);
|
||||
@@ -168,7 +172,8 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var redisKey = BuildKey(entry.VeriKey);
|
||||
var value = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
|
||||
@@ -200,18 +205,20 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
public async ValueTask SetManyAsync(IEnumerable<ProvcacheEntry> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entryList = entries.ToList();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (entryList.Count == 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync().ConfigureAwait(false);
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var batch = db.CreateBatch();
|
||||
var tasks = new List<Task>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var entry in entryList)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var redisKey = BuildKey(entry.VeriKey);
|
||||
var value = JsonSerializer.Serialize(entry, _jsonOptions);
|
||||
|
||||
@@ -242,7 +249,8 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync().ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var redisKey = BuildKey(veriKey);
|
||||
|
||||
var deleted = await db.KeyDeleteAsync(redisKey).ConfigureAwait(false);
|
||||
@@ -262,16 +270,44 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync().ConfigureAwait(false);
|
||||
var server = _connectionMultiplexer.GetServer(_connectionMultiplexer.GetEndPoints().First());
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var endpoints = _connectionMultiplexer.GetEndPoints();
|
||||
if (endpoints.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fullPattern = $"{_options.ValkeyKeyPrefix}{pattern}";
|
||||
var keys = server.Keys(pattern: fullPattern).ToArray();
|
||||
long deleted = 0;
|
||||
|
||||
if (keys.Length == 0)
|
||||
return 0;
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var server = _connectionMultiplexer.GetServer(endpoint);
|
||||
if (server is null || !server.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var deleted = await db.KeyDeleteAsync(keys).ConfigureAwait(false);
|
||||
var batchKeys = new List<RedisKey>(DefaultDeleteBatchSize);
|
||||
foreach (var key in server.Keys(pattern: fullPattern, pageSize: DefaultScanPageSize))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
batchKeys.Add(key);
|
||||
if (batchKeys.Count >= DefaultDeleteBatchSize)
|
||||
{
|
||||
deleted += await db.KeyDeleteAsync(batchKeys.ToArray()).ConfigureAwait(false);
|
||||
batchKeys.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (batchKeys.Count > 0)
|
||||
{
|
||||
deleted += await db.KeyDeleteAsync(batchKeys.ToArray()).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Invalidated {Count} cache entries matching pattern {Pattern}", deleted, pattern);
|
||||
return deleted;
|
||||
@@ -289,6 +325,7 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
Func<CancellationToken, ValueTask<ProvcacheEntry>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var result = await GetAsync(veriKey, cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsHit && result.Entry is not null)
|
||||
{
|
||||
@@ -303,12 +340,14 @@ public sealed class ValkeyProvcacheStore : IProvcacheStore, IAsyncDisposable
|
||||
|
||||
private string BuildKey(string veriKey) => $"{_options.ValkeyKeyPrefix}{veriKey}";
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync()
|
||||
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_database is not null)
|
||||
return _database;
|
||||
|
||||
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_database ??= _connectionMultiplexer.GetDatabase();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Provcache.Events;
|
||||
|
||||
/// <summary>
|
||||
@@ -91,6 +93,8 @@ public sealed record FeedEpochAdvancedEvent
|
||||
/// <param name="correlationId">Correlation ID for tracing.</param>
|
||||
/// <param name="eventId">Optional event ID (defaults to new GUID).</param>
|
||||
/// <param name="timestamp">Optional timestamp (defaults to current UTC time).</param>
|
||||
/// <param name="guidProvider">Optional GUID provider for deterministic IDs.</param>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public static FeedEpochAdvancedEvent Create(
|
||||
string feedId,
|
||||
string previousEpoch,
|
||||
@@ -102,12 +106,17 @@ public sealed record FeedEpochAdvancedEvent
|
||||
string? tenantId = null,
|
||||
string? correlationId = null,
|
||||
Guid? eventId = null,
|
||||
DateTimeOffset? timestamp = null)
|
||||
DateTimeOffset? timestamp = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var guidSource = guidProvider ?? SystemGuidProvider.Instance;
|
||||
var timeSource = timeProvider ?? TimeProvider.System;
|
||||
|
||||
return new FeedEpochAdvancedEvent
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
|
||||
EventId = eventId ?? guidSource.NewGuid(),
|
||||
Timestamp = timestamp ?? timeSource.GetUtcNow(),
|
||||
FeedId = feedId,
|
||||
PreviousEpoch = previousEpoch,
|
||||
NewEpoch = newEpoch,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Provcache.Events;
|
||||
|
||||
/// <summary>
|
||||
@@ -80,6 +82,8 @@ public sealed record SignerRevokedEvent
|
||||
/// <param name="correlationId">Correlation ID for tracing.</param>
|
||||
/// <param name="eventId">Optional event ID (defaults to new GUID).</param>
|
||||
/// <param name="timestamp">Optional timestamp (defaults to current UTC time).</param>
|
||||
/// <param name="guidProvider">Optional GUID provider for deterministic IDs.</param>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamps.</param>
|
||||
public static SignerRevokedEvent Create(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
@@ -89,12 +93,17 @@ public sealed record SignerRevokedEvent
|
||||
string? actor = null,
|
||||
string? correlationId = null,
|
||||
Guid? eventId = null,
|
||||
DateTimeOffset? timestamp = null)
|
||||
DateTimeOffset? timestamp = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var guidSource = guidProvider ?? SystemGuidProvider.Instance;
|
||||
var timeSource = timeProvider ?? TimeProvider.System;
|
||||
|
||||
return new SignerRevokedEvent
|
||||
{
|
||||
EventId = eventId ?? Guid.NewGuid(),
|
||||
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
|
||||
EventId = eventId ?? guidSource.NewGuid(),
|
||||
Timestamp = timestamp ?? timeSource.GetUtcNow(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = keyId,
|
||||
SignerHash = signerHash,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
|
||||
@@ -15,6 +17,8 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
|
||||
private readonly IProvcacheService _provcacheService;
|
||||
private readonly IEvidenceChunkRepository _chunkRepository;
|
||||
private readonly ISigner? _signer;
|
||||
private readonly ICryptoHmac? _cryptoHmac;
|
||||
private readonly IKeyProvider? _keyProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<MinimalProofExporter> _logger;
|
||||
@@ -32,11 +36,15 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
|
||||
ISigner? signer = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
ILogger<MinimalProofExporter>? logger = null)
|
||||
ILogger<MinimalProofExporter>? logger = null,
|
||||
ICryptoHmac? cryptoHmac = null,
|
||||
IKeyProvider? keyProvider = null)
|
||||
{
|
||||
_provcacheService = provcacheService ?? throw new ArgumentNullException(nameof(provcacheService));
|
||||
_chunkRepository = chunkRepository ?? throw new ArgumentNullException(nameof(chunkRepository));
|
||||
_signer = signer;
|
||||
_cryptoHmac = cryptoHmac;
|
||||
_keyProvider = keyProvider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<MinimalProofExporter>.Instance;
|
||||
@@ -114,7 +122,7 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundle = await ExportAsync(veriKey, options, cancellationToken);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(bundle, s_jsonOptions);
|
||||
return CanonJson.Canonicalize(bundle, s_jsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -127,7 +135,8 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
|
||||
ArgumentNullException.ThrowIfNull(outputStream);
|
||||
|
||||
var bundle = await ExportAsync(veriKey, options, cancellationToken);
|
||||
await JsonSerializer.SerializeAsync(outputStream, bundle, s_jsonOptions, cancellationToken);
|
||||
var payload = CanonJson.Canonicalize(bundle, s_jsonOptions);
|
||||
await outputStream.WriteAsync(payload, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -384,19 +393,20 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
|
||||
|
||||
// Serialize bundle without signature for signing
|
||||
var bundleWithoutSig = bundle with { Signature = null };
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(bundleWithoutSig, s_jsonOptions);
|
||||
var payload = CanonJson.Canonicalize(bundleWithoutSig, s_jsonOptions);
|
||||
|
||||
var signRequest = new SignRequest(
|
||||
Payload: payload,
|
||||
ContentType: "application/vnd.stellaops.proof-bundle+json");
|
||||
|
||||
var signResult = await _signer.SignAsync(signRequest, cancellationToken);
|
||||
var algorithm = _cryptoHmac?.GetAlgorithmForPurpose(HmacPurpose.Signing) ?? "HMAC-SHA256";
|
||||
|
||||
return bundle with
|
||||
{
|
||||
Signature = new BundleSignature
|
||||
{
|
||||
Algorithm = "HMAC-SHA256", // Could be made configurable
|
||||
Algorithm = algorithm,
|
||||
KeyId = signResult.KeyId,
|
||||
SignatureBytes = Convert.ToBase64String(signResult.Signature),
|
||||
SignedAt = signResult.SignedAt
|
||||
@@ -436,11 +446,44 @@ public sealed class MinimalProofExporter : IMinimalProofExporter
|
||||
|
||||
private bool VerifySignature(MinimalProofBundle bundle)
|
||||
{
|
||||
// For now, we don't have signature verification implemented
|
||||
// This would require the signer's public key or certificate
|
||||
// Return true as a placeholder - signature presence is enough for MVP
|
||||
_logger.LogWarning("Signature verification not fully implemented - assuming valid");
|
||||
return bundle.Signature is not null;
|
||||
if (bundle.Signature is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cryptoHmac is null || _keyProvider is null)
|
||||
{
|
||||
_logger.LogWarning("Signature verification skipped: no HMAC verifier or key configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(bundle.Signature.KeyId, _keyProvider.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Signature key mismatch: expected {Expected}, got {Actual}",
|
||||
_keyProvider.KeyId,
|
||||
bundle.Signature.KeyId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedAlgorithm = _cryptoHmac.GetAlgorithmForPurpose(HmacPurpose.Signing);
|
||||
if (!string.Equals(bundle.Signature.Algorithm, expectedAlgorithm, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Signature algorithm mismatch: expected {Expected}, got {Actual}",
|
||||
expectedAlgorithm,
|
||||
bundle.Signature.Algorithm);
|
||||
return false;
|
||||
}
|
||||
|
||||
var bundleWithoutSig = bundle with { Signature = null };
|
||||
var payload = CanonJson.Canonicalize(bundleWithoutSig, s_jsonOptions);
|
||||
|
||||
return _cryptoHmac.VerifyHmacBase64ForPurpose(
|
||||
_keyProvider.KeyMaterial,
|
||||
payload,
|
||||
bundle.Signature.SignatureBytes,
|
||||
HmacPurpose.Signing);
|
||||
}
|
||||
|
||||
private static long CalculateChunkDataSize(ChunkManifest manifest, int chunkCount)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
@@ -11,10 +12,21 @@ namespace StellaOps.Provcache;
|
||||
/// </summary>
|
||||
public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Named client for use with IHttpClientFactory.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "provcache-lazy-fetch";
|
||||
|
||||
private static readonly string[] DefaultSchemes = ["https", "http"];
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly ILogger<HttpChunkFetcher> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly LazyFetchHttpOptions _options;
|
||||
private readonly IReadOnlyList<string> _allowedHosts;
|
||||
private readonly IReadOnlySet<string> _allowedSchemes;
|
||||
private readonly bool _allowAllHosts;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string FetcherType => "http";
|
||||
@@ -23,9 +35,15 @@ public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
/// Creates an HTTP chunk fetcher with the specified base URL.
|
||||
/// </summary>
|
||||
/// <param name="baseUrl">The base URL of the Stella API.</param>
|
||||
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public HttpChunkFetcher(Uri baseUrl, ILogger<HttpChunkFetcher> logger)
|
||||
: this(CreateClient(baseUrl), ownsClient: true, logger)
|
||||
/// <param name="options">Lazy fetch HTTP options.</param>
|
||||
public HttpChunkFetcher(
|
||||
Uri baseUrl,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<HttpChunkFetcher> logger,
|
||||
LazyFetchHttpOptions? options = null)
|
||||
: this(CreateClient(httpClientFactory, baseUrl), ownsClient: false, logger, options)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,25 +53,149 @@ public sealed class HttpChunkFetcher : ILazyEvidenceFetcher, IDisposable
|
||||
/// <param name="httpClient">The HTTP client to use.</param>
|
||||
/// <param name="ownsClient">Whether this fetcher owns the client lifecycle.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public HttpChunkFetcher(HttpClient httpClient, bool ownsClient, ILogger<HttpChunkFetcher> logger)
|
||||
/// <param name="options">Lazy fetch HTTP options.</param>
|
||||
public HttpChunkFetcher(
|
||||
HttpClient httpClient,
|
||||
bool ownsClient,
|
||||
ILogger<HttpChunkFetcher> logger,
|
||||
LazyFetchHttpOptions? options = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_ownsClient = ownsClient;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options ?? new LazyFetchHttpOptions();
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
var baseAddress = _httpClient.BaseAddress
|
||||
?? throw new InvalidOperationException("HttpChunkFetcher requires a BaseAddress on the HTTP client.");
|
||||
|
||||
_allowedSchemes = NormalizeSchemes(_options.AllowedSchemes);
|
||||
_allowedHosts = NormalizeHosts(_options.AllowedHosts, baseAddress.Host, out _allowAllHosts);
|
||||
|
||||
ValidateBaseAddress(baseAddress);
|
||||
ApplyClientConfiguration();
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(Uri baseUrl)
|
||||
private static HttpClient CreateClient(IHttpClientFactory httpClientFactory, Uri baseUrl)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = baseUrl };
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
ArgumentNullException.ThrowIfNull(httpClientFactory);
|
||||
ArgumentNullException.ThrowIfNull(baseUrl);
|
||||
|
||||
var client = httpClientFactory.CreateClient(HttpClientName);
|
||||
client.BaseAddress = baseUrl;
|
||||
return client;
|
||||
}
|
||||
|
||||
private void ApplyClientConfiguration()
|
||||
{
|
||||
var timeout = _options.Timeout;
|
||||
if (timeout <= TimeSpan.Zero || timeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch HTTP timeout must be a positive, non-infinite duration.");
|
||||
}
|
||||
|
||||
if (_httpClient.Timeout == Timeout.InfiniteTimeSpan || _httpClient.Timeout > timeout)
|
||||
{
|
||||
_httpClient.Timeout = timeout;
|
||||
}
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.Accept.Any(header =>
|
||||
string.Equals(header.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateBaseAddress(Uri baseAddress)
|
||||
{
|
||||
if (!baseAddress.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch base URL must be absolute.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(baseAddress.UserInfo))
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch base URL must not include user info.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(baseAddress.Host))
|
||||
{
|
||||
throw new InvalidOperationException("Lazy fetch base URL must include a host.");
|
||||
}
|
||||
|
||||
if (!_allowedSchemes.Contains(baseAddress.Scheme))
|
||||
{
|
||||
throw new InvalidOperationException($"Lazy fetch base URL scheme '{baseAddress.Scheme}' is not allowed.");
|
||||
}
|
||||
|
||||
if (!_allowAllHosts && !IsHostAllowed(baseAddress.Host, _allowedHosts))
|
||||
{
|
||||
throw new InvalidOperationException($"Lazy fetch base URL host '{baseAddress.Host}' is not allowlisted.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> NormalizeSchemes(IList<string> schemes)
|
||||
{
|
||||
var normalized = schemes
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.ToArray();
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
normalized = DefaultSchemes;
|
||||
}
|
||||
|
||||
return new HashSet<string>(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeHosts(
|
||||
IList<string> hosts,
|
||||
string baseHost,
|
||||
out bool allowAllHosts)
|
||||
{
|
||||
var normalized = hosts
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h))
|
||||
.Select(h => h.Trim())
|
||||
.ToList();
|
||||
|
||||
allowAllHosts = normalized.Any(h => string.Equals(h, "*", StringComparison.Ordinal));
|
||||
|
||||
if (!allowAllHosts && normalized.Count == 0)
|
||||
{
|
||||
normalized.Add(baseHost);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsHostAllowed(string host, IReadOnlyList<string> allowedHosts)
|
||||
{
|
||||
foreach (var allowed in allowedHosts)
|
||||
{
|
||||
if (string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = allowed[1..];
|
||||
if (host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FetchedChunk?> FetchChunkAsync(
|
||||
string proofRoot,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
/// <summary>
|
||||
/// Options for HTTP lazy evidence fetching.
|
||||
/// </summary>
|
||||
public sealed class LazyFetchHttpOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name under Provcache.
|
||||
/// </summary>
|
||||
public const string SectionName = "LazyFetchHttp";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP timeout for fetch requests.
|
||||
/// Default: 10 seconds.
|
||||
/// </summary>
|
||||
[Range(typeof(TimeSpan), "00:00:01", "00:05:00", ErrorMessage = "Timeout must be between 1 second and 5 minutes")]
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Allowlisted hostnames for HTTP fetches.
|
||||
/// Supports exact match and "*.example.com" suffix entries.
|
||||
/// </summary>
|
||||
public IList<string> AllowedHosts { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Allowlisted schemes for HTTP fetches.
|
||||
/// When empty, defaults to http and https.
|
||||
/// </summary>
|
||||
public IList<string> AllowedSchemes { get; } = new List<string>();
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Provcache.Oci;
|
||||
|
||||
@@ -16,11 +18,12 @@ namespace StellaOps.Provcache.Oci;
|
||||
/// </summary>
|
||||
public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false // Deterministic output
|
||||
WriteIndented = false,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
@@ -53,8 +56,8 @@ public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBui
|
||||
};
|
||||
|
||||
// Serialize to canonical JSON (deterministic)
|
||||
var statementJson = JsonSerializer.Serialize(statement, SerializerOptions);
|
||||
var statementBytes = Encoding.UTF8.GetBytes(statementJson);
|
||||
var statementBytes = CanonJson.Canonicalize(statement, CanonicalOptions);
|
||||
var statementJson = Encoding.UTF8.GetString(statementBytes);
|
||||
|
||||
// Build OCI annotations
|
||||
var annotations = BuildAnnotations(request, predicate);
|
||||
@@ -275,7 +278,7 @@ public sealed class ProvcacheOciAttestationBuilder : IProvcacheOciAttestationBui
|
||||
["stellaops.provcache.verikey"] = predicate.VeriKey,
|
||||
["stellaops.provcache.verdict-hash"] = predicate.VerdictHash,
|
||||
["stellaops.provcache.proof-root"] = predicate.ProofRoot,
|
||||
["stellaops.provcache.trust-score"] = predicate.TrustScore.ToString(),
|
||||
["stellaops.provcache.trust-score"] = predicate.TrustScore.ToString(CultureInfo.InvariantCulture),
|
||||
["stellaops.provcache.expires-at"] = predicate.ExpiresAt
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
@@ -144,6 +145,6 @@ public sealed class ProvcacheOptions
|
||||
{
|
||||
var bucketTicks = TimeWindowBucket.Ticks;
|
||||
var epoch = timestamp.UtcTicks / bucketTicks * bucketTicks;
|
||||
return new DateTimeOffset(epoch, TimeSpan.Zero).ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
return new DateTimeOffset(epoch, TimeSpan.Zero).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Provcache;
|
||||
|
||||
@@ -21,8 +22,18 @@ public static class ProvcacheServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var section = configuration.GetSection(ProvcacheOptions.SectionName);
|
||||
|
||||
// Register options
|
||||
services.Configure<ProvcacheOptions>(configuration.GetSection(ProvcacheOptions.SectionName));
|
||||
services.AddOptions<ProvcacheOptions>()
|
||||
.Bind(section)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<LazyFetchHttpOptions>()
|
||||
.Bind(section.GetSection(LazyFetchHttpOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register core services
|
||||
services.AddSingleton<IProvcacheService, ProvcacheService>();
|
||||
@@ -31,6 +42,7 @@ public static class ProvcacheServiceCollectionExtensions
|
||||
services.AddSingleton<WriteBehindQueue>();
|
||||
services.AddSingleton<IWriteBehindQueue>(sp => sp.GetRequiredService<WriteBehindQueue>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WriteBehindQueue>());
|
||||
services.AddHttpClient(HttpChunkFetcher.HttpClientName);
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -49,7 +61,14 @@ public static class ProvcacheServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// Register options
|
||||
services.Configure(configure);
|
||||
services.AddOptions<ProvcacheOptions>()
|
||||
.Configure(configure)
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<LazyFetchHttpOptions>()
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// Register core services
|
||||
services.AddSingleton<IProvcacheService, ProvcacheService>();
|
||||
@@ -58,6 +77,7 @@ public static class ProvcacheServiceCollectionExtensions
|
||||
services.AddSingleton<WriteBehindQueue>();
|
||||
services.AddSingleton<IWriteBehindQueue>(sp => sp.GetRequiredService<WriteBehindQueue>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WriteBehindQueue>());
|
||||
services.AddHttpClient(HttpChunkFetcher.HttpClientName);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0101-M | DONE | Revalidated 2026-01-08; maintainability audit for Provcache core. |
|
||||
| AUDIT-0101-T | DONE | Revalidated 2026-01-08; test coverage audit for Provcache core. |
|
||||
| AUDIT-0101-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| AUDIT-0101-A | DONE | Applied 2026-01-13; hotlist fixes and tests added. |
|
||||
|
||||
@@ -133,7 +133,7 @@ public sealed class WriteBehindQueue : BackgroundService, IWriteBehindQueue
|
||||
}
|
||||
|
||||
// Drain remaining items on shutdown
|
||||
await DrainAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
await DrainAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Write-behind queue stopped");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace StellaOps.TestKit.Assertions;
|
||||
/// - Consistent number formatting
|
||||
/// - No whitespace variations
|
||||
/// - UTF-8 encoding
|
||||
/// - Deterministic output (same input → same bytes)
|
||||
/// - Deterministic output (same input -> same bytes)
|
||||
/// </remarks>
|
||||
public static class CanonicalJsonAssert
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
@@ -9,8 +10,11 @@ namespace StellaOps.TestKit.Connectors;
|
||||
/// </summary>
|
||||
public sealed class ConnectorHttpFixture : IDisposable
|
||||
{
|
||||
private const string ClientName = "ConnectorHttpFixture";
|
||||
private readonly Dictionary<string, HttpResponseEntry> _responses = new();
|
||||
private readonly List<HttpRequestMessage> _capturedRequests = new();
|
||||
private ServiceProvider? _serviceProvider;
|
||||
private IHttpClientFactory? _httpClientFactory;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,7 +27,7 @@ public sealed class ConnectorHttpFixture : IDisposable
|
||||
/// </summary>
|
||||
public HttpClient CreateClient()
|
||||
{
|
||||
return new HttpClient(new CannedMessageHandler(this));
|
||||
return GetClientFactory().CreateClient(ClientName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,9 +167,28 @@ public sealed class ConnectorHttpFixture : IDisposable
|
||||
if (_disposed) return;
|
||||
_responses.Clear();
|
||||
_capturedRequests.Clear();
|
||||
_serviceProvider?.Dispose();
|
||||
_serviceProvider = null;
|
||||
_httpClientFactory = null;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private IHttpClientFactory GetClientFactory()
|
||||
{
|
||||
if (_httpClientFactory != null)
|
||||
{
|
||||
return _httpClientFactory;
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(ClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new CannedMessageHandler(this));
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
|
||||
return _httpClientFactory;
|
||||
}
|
||||
|
||||
private sealed record HttpResponseEntry(
|
||||
HttpStatusCode StatusCode = HttpStatusCode.OK,
|
||||
string ContentType = "application/json",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.TestKit.Connectors;
|
||||
|
||||
/// <summary>
|
||||
@@ -34,19 +34,31 @@ namespace StellaOps.TestKit.Connectors;
|
||||
/// </remarks>
|
||||
public abstract class ConnectorLiveSchemaTestBase : IAsyncLifetime
|
||||
{
|
||||
private const string LiveClientName = "ConnectorLiveSchema";
|
||||
private static readonly Lazy<IServiceProvider> LiveServices = new(() =>
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddHttpClient(LiveClientName)
|
||||
.ConfigureHttpClient(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
return services.BuildServiceProvider();
|
||||
});
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly FixtureUpdater _fixtureUpdater;
|
||||
private readonly List<FixtureDriftReport> _driftReports = new();
|
||||
|
||||
protected ConnectorLiveSchemaTestBase()
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
_httpClient = LiveHttpClientFactory.CreateClient(LiveClientName);
|
||||
_fixtureUpdater = new FixtureUpdater(FixturesDirectory, _httpClient);
|
||||
}
|
||||
|
||||
private static IHttpClientFactory LiveHttpClientFactory =>
|
||||
LiveServices.Value.GetRequiredService<IHttpClientFactory>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base directory for test fixtures (relative to test assembly).
|
||||
/// </summary>
|
||||
|
||||
@@ -12,10 +12,10 @@ public sealed class FixtureUpdater
|
||||
private readonly string _fixturesDirectory;
|
||||
private readonly bool _enabled;
|
||||
|
||||
public FixtureUpdater(string fixturesDirectory, HttpClient? httpClient = null)
|
||||
public FixtureUpdater(string fixturesDirectory, HttpClient httpClient)
|
||||
{
|
||||
_fixturesDirectory = fixturesDirectory;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_enabled = Environment.GetEnvironmentVariable("STELLAOPS_UPDATE_FIXTURES") == "true";
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,11 @@ public sealed class HttpFixtureServer<TProgram> : WebApplicationFactory<TProgram
|
||||
/// .WhenRequest("https://api.example.com/data")
|
||||
/// .Responds(HttpStatusCode.OK, "{\"status\":\"ok\"}");
|
||||
///
|
||||
/// var httpClient = new HttpClient(handler);
|
||||
/// var services = new ServiceCollection();
|
||||
/// services.AddHttpClient("stub")
|
||||
/// .ConfigurePrimaryHttpMessageHandler(() => handler);
|
||||
/// using var provider = services.BuildServiceProvider();
|
||||
/// var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient("stub");
|
||||
/// var response = await httpClient.GetAsync("https://api.example.com/data");
|
||||
/// // response.StatusCode == HttpStatusCode.OK
|
||||
/// </code>
|
||||
|
||||
@@ -43,11 +43,10 @@ public enum ValkeyIsolationMode
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
|
||||
public sealed class ValkeyFixture : IAsyncLifetime
|
||||
{
|
||||
private IContainer? _container;
|
||||
private ConnectionMultiplexer? _connection;
|
||||
private bool _disposed;
|
||||
private int _databaseCounter;
|
||||
|
||||
/// <summary>
|
||||
@@ -206,19 +205,6 @@ public sealed class ValkeyFixture : IAsyncLifetime, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the fixture.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DisposeAsync().GetAwaiter().GetResult();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
@@ -38,6 +39,7 @@ public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsy
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Add default test services
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<TestRequestContext>();
|
||||
|
||||
// Apply custom configuration
|
||||
@@ -81,12 +83,18 @@ public class WebServiceFixture<TProgram> : WebApplicationFactory<TProgram>, IAsy
|
||||
public sealed class TestRequestContext
|
||||
{
|
||||
private readonly List<RequestRecord> _requests = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TestRequestContext(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public void RecordRequest(string method, string path, int statusCode)
|
||||
{
|
||||
lock (_requests)
|
||||
{
|
||||
_requests.Add(new RequestRecord(method, path, statusCode, DateTime.UtcNow));
|
||||
_requests.Add(new RequestRecord(method, path, statusCode, _timeProvider.GetUtcNow().UtcDateTime));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,11 +155,7 @@ public abstract class CacheIdempotencyTests<TEntity, TKey> : IClassFixture<Valke
|
||||
|
||||
// Act - Fire multiple concurrent sets
|
||||
var tasks = Enumerable.Range(1, 10)
|
||||
.Select(i => Task.Run(async () =>
|
||||
{
|
||||
var entity = CreateTestEntity(key);
|
||||
await SetAsync(session, key, entity);
|
||||
}));
|
||||
.Select(_ => SetAsync(session, key, CreateTestEntity(key)));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Diagnostics;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
|
||||
namespace StellaOps.TestKit.Templates;
|
||||
|
||||
@@ -17,13 +18,13 @@ namespace StellaOps.TestKit.Templates;
|
||||
/// <remarks>
|
||||
/// Common sources of test flakiness and their solutions:
|
||||
///
|
||||
/// 1. **DateTime.Now/UtcNow** → Use injected TimeProvider or DeterministicTime
|
||||
/// 2. **Random without seed** → Use DeterministicRandom with fixed seed
|
||||
/// 3. **Task.Delay for timing** → Use polling with configurable timeout or fake timers
|
||||
/// 4. **External service calls** → Use HttpFixtureServer or mocks
|
||||
/// 5. **Ordering assumptions** → Ensure explicit ORDER BY or use sorted assertions
|
||||
/// 6. **Parallel test interference** → Use test isolation (schema-per-test, unique IDs)
|
||||
/// 7. **Environment dependencies** → Use TestContainers with fixed versions
|
||||
/// 1. **DateTime.Now/UtcNow** -> Use injected TimeProvider or DeterministicTime
|
||||
/// 2. **Random without seed** -> Use DeterministicRandom with fixed seed
|
||||
/// 3. **Task.Delay for timing** -> Use polling with configurable timeout or fake timers
|
||||
/// 4. **External service calls** -> Use HttpFixtureServer or mocks
|
||||
/// 5. **Ordering assumptions** -> Ensure explicit ORDER BY or use sorted assertions
|
||||
/// 6. **Parallel test interference** -> Use test isolation (schema-per-test, unique IDs)
|
||||
/// 7. **Environment dependencies** -> Use TestContainers with fixed versions
|
||||
/// </remarks>
|
||||
public static class FlakyToDeterministicPattern
|
||||
{
|
||||
@@ -32,7 +33,7 @@ public static class FlakyToDeterministicPattern
|
||||
// FLAKY: Uses system clock - different results on each run
|
||||
// public void Flaky_DateTimeNow()
|
||||
// {
|
||||
// var record = new AuditRecord { CreatedAt = DateTime.UtcNow };
|
||||
// var record = new AuditRecord { CreatedAt = GetSystemUtcNow() };
|
||||
// Assert.True(record.CreatedAt.Hour == 12); // Fails at any other hour
|
||||
// }
|
||||
|
||||
@@ -59,7 +60,7 @@ public static class FlakyToDeterministicPattern
|
||||
// FLAKY: Different random sequence each run
|
||||
// public void Flaky_Random()
|
||||
// {
|
||||
// var random = new Random();
|
||||
// var random = CreateUnseededRandom();
|
||||
// var value = random.Next(1, 100);
|
||||
// Assert.Equal(42, value); // Almost never passes
|
||||
// }
|
||||
@@ -70,7 +71,7 @@ public static class FlakyToDeterministicPattern
|
||||
public static int Deterministic_SeededRandom(int seed = 12345)
|
||||
{
|
||||
// Same seed always produces same sequence
|
||||
var random = new Random(seed);
|
||||
var random = new DeterministicRandom(seed);
|
||||
return random.Next(1, 100); // Always returns same value for same seed
|
||||
}
|
||||
|
||||
@@ -129,9 +130,9 @@ public static class FlakyToDeterministicPattern
|
||||
#region Pattern 4: Replace External HTTP with Fixture Server
|
||||
|
||||
// FLAKY: Depends on external service availability
|
||||
// public async Task Flaky_ExternalHttp()
|
||||
// public async Task Flaky_ExternalHttp(IHttpClientFactory httpClientFactory)
|
||||
// {
|
||||
// var client = new HttpClient();
|
||||
// var client = httpClientFactory.CreateClient("live");
|
||||
// var response = await client.GetAsync("https://api.example.com/data");
|
||||
// Assert.True(response.IsSuccessStatusCode);
|
||||
// }
|
||||
@@ -207,12 +208,14 @@ public static class FlakyToDeterministicPattern
|
||||
// }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic version with unique identifiers.
|
||||
/// Deterministic version with seeded identifiers.
|
||||
/// </summary>
|
||||
public static string GenerateTestId(string testName)
|
||||
public static string GenerateTestId(string testName, DeterministicRandom random)
|
||||
{
|
||||
// Each test gets unique ID based on test name + timestamp
|
||||
return $"{testName}-{Guid.NewGuid():N}";
|
||||
ArgumentNullException.ThrowIfNull(testName);
|
||||
ArgumentNullException.ThrowIfNull(random);
|
||||
// Each test gets deterministic ID based on test name + seeded random
|
||||
return $"{testName}-{random.NextGuid():N}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
@@ -72,7 +73,7 @@ public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<Postg
|
||||
.ToList();
|
||||
|
||||
// Insert in random order
|
||||
var random = new Random(42); // Fixed seed for determinism
|
||||
var random = new DeterministicRandom(42);
|
||||
foreach (var entity in entities.OrderBy(_ => random.Next()))
|
||||
{
|
||||
await InsertAsync(session, entity);
|
||||
@@ -234,7 +235,7 @@ public abstract class QueryDeterminismTests<TEntity, TKey> : IClassFixture<Postg
|
||||
{
|
||||
// Arrange
|
||||
await using var session = await Fixture.CreateSessionAsync(nameof(Large_Result_Set_Maintains_Deterministic_Order));
|
||||
var random = new Random(12345);
|
||||
var random = new DeterministicRandom(12345);
|
||||
var entities = Enumerable.Range(1, 100)
|
||||
.Select(i => CreateTestEntity(GenerateKey(i), random.Next(1, 1000)))
|
||||
.ToList();
|
||||
|
||||
@@ -67,7 +67,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
.ToList();
|
||||
|
||||
// Act
|
||||
var tasks = entities.Select(e => Task.Run(async () => await InsertAsync(session, e)));
|
||||
var tasks = entities.Select(e => InsertAsync(session, e));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
@@ -91,19 +91,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
// Act
|
||||
var successCount = 0;
|
||||
var tasks = Enumerable.Range(1, DefaultConcurrency)
|
||||
.Select(i => Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = CreateTestEntity(key, i);
|
||||
await UpdateAsync(session, entity);
|
||||
Interlocked.Increment(ref successCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Some updates may fail due to optimistic concurrency
|
||||
}
|
||||
}));
|
||||
.Select(UpdateSafelyAsync);
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
@@ -111,6 +99,20 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
successCount.Should().BeGreaterThan(0, "at least some updates should succeed");
|
||||
var final = await GetByKeyAsync(session, key);
|
||||
final.Should().NotBeNull();
|
||||
|
||||
async Task UpdateSafelyAsync(int i)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = CreateTestEntity(key, i);
|
||||
await UpdateAsync(session, entity);
|
||||
Interlocked.Increment(ref successCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Some updates may fail due to optimistic concurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -124,7 +126,16 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
|
||||
// Act
|
||||
var readResults = new List<TEntity?>();
|
||||
var readTask = Task.Run(async () =>
|
||||
var readTask = ReadLoopAsync();
|
||||
var writeTask = WriteLoopAsync();
|
||||
|
||||
await Task.WhenAll(readTask, writeTask);
|
||||
|
||||
// Assert
|
||||
readResults.Should().NotBeEmpty();
|
||||
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
|
||||
|
||||
async Task ReadLoopAsync()
|
||||
{
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
@@ -135,9 +146,9 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
}
|
||||
await Task.Delay(10);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var writeTask = Task.Run(async () =>
|
||||
async Task WriteLoopAsync()
|
||||
{
|
||||
for (int i = 2; i <= 10; i++)
|
||||
{
|
||||
@@ -145,13 +156,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
await UpdateAsync(session, entity);
|
||||
await Task.Delay(15);
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(readTask, writeTask);
|
||||
|
||||
// Assert
|
||||
readResults.Should().NotBeEmpty();
|
||||
readResults.Where(r => r != null).Should().OnlyContain(r => GetVersion(r!) >= 1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,17 +178,7 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
operations.Add(Task.Run(async () =>
|
||||
{
|
||||
// Read
|
||||
var entity = await GetByKeyAsync(session, key);
|
||||
if (entity != null)
|
||||
{
|
||||
// Update
|
||||
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
|
||||
await UpdateAsync(session, updated);
|
||||
}
|
||||
}));
|
||||
operations.Add(RunOperationAsync(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +190,18 @@ public abstract class StorageConcurrencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
var final = await GetByKeyAsync(session, key);
|
||||
final.Should().NotBeNull("entity should exist after parallel operations");
|
||||
}
|
||||
|
||||
async Task RunOperationAsync(TKey key)
|
||||
{
|
||||
// Read
|
||||
var entity = await GetByKeyAsync(session, key);
|
||||
if (entity != null)
|
||||
{
|
||||
// Update
|
||||
var updated = CreateTestEntity(key, GetVersion(entity) + 1);
|
||||
await UpdateAsync(session, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -136,11 +136,7 @@ public abstract class StorageIdempotencyTests<TEntity, TKey> : IClassFixture<Pos
|
||||
|
||||
// Act
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(_ => Task.Run(async () =>
|
||||
{
|
||||
var entity = CreateTestEntity(key);
|
||||
await UpsertAsync(session, entity);
|
||||
}));
|
||||
.Select(_ => UpsertAsync(session, CreateTestEntity(key)));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Detection;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RuntimeDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detect_ReturnsConsistentResult()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var result1 = detector.Detect();
|
||||
var result2 = detector.Detect();
|
||||
|
||||
// Assert
|
||||
result1.Should().Be(result2, "detection should be deterministic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContextValues_ReturnsNonEmptyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var values = detector.GetContextValues();
|
||||
|
||||
// Assert
|
||||
values.Should().NotBeNull();
|
||||
values.Should().ContainKey("RUNTIME");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContextValues_ContainsRuntimeValue()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var values = detector.GetContextValues();
|
||||
var runtime = detector.Detect();
|
||||
|
||||
// Assert
|
||||
values["RUNTIME"].Should().Be(runtime.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContextValues_ContainsDatabaseDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var values = detector.GetContextValues();
|
||||
|
||||
// Assert - These are defaults if no env vars are set
|
||||
values.Should().ContainKey("DB_HOST");
|
||||
values.Should().ContainKey("DB_PORT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetContextValues_ContainsValkeyDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var values = detector.GetContextValues();
|
||||
|
||||
// Assert
|
||||
values.Should().ContainKey("VALKEY_HOST");
|
||||
values.Should().ContainKey("VALKEY_PORT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsKubernetesContext_ReturnsFalse_WhenNoKubernetesEnvVars()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act - In test environment, there should be no Kubernetes context
|
||||
var result = detector.IsKubernetesContext();
|
||||
|
||||
// Assert - We can't guarantee the environment, so just check it doesn't throw
|
||||
result.Should().Be(result); // Tautology, but confirms no exception
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetKubernetesNamespace_ReturnsDefaultIfNotInCluster()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var ns = detector.GetKubernetesNamespace();
|
||||
|
||||
// Assert - Returns default or environment value
|
||||
ns.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDockerAvailable_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act & Assert - Should not throw regardless of Docker availability
|
||||
var action = () => detector.IsDockerAvailable();
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSystemdManaged_DoesNotThrow_ForNonExistentService()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act & Assert
|
||||
var action = () => detector.IsSystemdManaged("nonexistent-service-12345");
|
||||
action.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetComposeProjectPath_ReturnsNullOrValidPath()
|
||||
{
|
||||
// Arrange
|
||||
var detector = CreateDetector();
|
||||
|
||||
// Act
|
||||
var path = detector.GetComposeProjectPath();
|
||||
|
||||
// Assert
|
||||
if (path != null)
|
||||
{
|
||||
(path.EndsWith(".yml") || path.EndsWith(".yaml")).Should().BeTrue(
|
||||
"compose file should have .yml or .yaml extension");
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeDetector CreateDetector()
|
||||
{
|
||||
return new RuntimeDetector(NullLogger<RuntimeDetector>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,10 @@ public sealed class DoctorEngineTests
|
||||
|
||||
// Add configuration
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Doctor:Evidence:Enabled"] = "false"
|
||||
})
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Models;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RemediationModelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void LikelyCause_Create_SetsAllProperties()
|
||||
{
|
||||
// Act
|
||||
var cause = LikelyCause.Create(1, "Test description", "https://docs.example.com");
|
||||
|
||||
// Assert
|
||||
cause.Priority.Should().Be(1);
|
||||
cause.Description.Should().Be("Test description");
|
||||
cause.DocumentationUrl.Should().Be("https://docs.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LikelyCause_Create_WithoutUrl_HasNullUrl()
|
||||
{
|
||||
// Act
|
||||
var cause = LikelyCause.Create(2, "No docs");
|
||||
|
||||
// Assert
|
||||
cause.Priority.Should().Be(2);
|
||||
cause.DocumentationUrl.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemediationCommand_RequiresMinimalProperties()
|
||||
{
|
||||
// Act
|
||||
var command = new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.DockerCompose,
|
||||
Command = "docker compose up -d",
|
||||
Description = "Start containers"
|
||||
};
|
||||
|
||||
// Assert
|
||||
command.Runtime.Should().Be(RuntimeEnvironment.DockerCompose);
|
||||
command.Command.Should().Be("docker compose up -d");
|
||||
command.Description.Should().Be("Start containers");
|
||||
command.RequiresSudo.Should().BeFalse();
|
||||
command.IsDangerous.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemediationCommand_WithSudo_SetsSudoFlag()
|
||||
{
|
||||
// Act
|
||||
var command = new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.Systemd,
|
||||
Command = "sudo systemctl start postgresql",
|
||||
Description = "Start PostgreSQL",
|
||||
RequiresSudo = true
|
||||
};
|
||||
|
||||
// Assert
|
||||
command.RequiresSudo.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemediationCommand_WithDangerous_SetsDangerousFlag()
|
||||
{
|
||||
// Act
|
||||
var command = new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.Any,
|
||||
Command = "stella migrations-run --module all",
|
||||
Description = "Apply all migrations",
|
||||
IsDangerous = true,
|
||||
DangerWarning = "This will modify the database schema"
|
||||
};
|
||||
|
||||
// Assert
|
||||
command.IsDangerous.Should().BeTrue();
|
||||
command.DangerWarning.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemediationCommand_WithPlaceholders_StoresPlaceholders()
|
||||
{
|
||||
// Act
|
||||
var command = new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.Any,
|
||||
Command = "pg_isready -h {{HOST}} -p {{PORT}}",
|
||||
Description = "Check PostgreSQL",
|
||||
Placeholders = new Dictionary<string, string>
|
||||
{
|
||||
["HOST"] = "localhost",
|
||||
["PORT"] = "5432"
|
||||
}
|
||||
};
|
||||
|
||||
// Assert
|
||||
command.Placeholders.Should().HaveCount(2);
|
||||
command.Placeholders!["HOST"].Should().Be("localhost");
|
||||
command.Placeholders["PORT"].Should().Be("5432");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WizardRemediation_Empty_HasNoCommands()
|
||||
{
|
||||
// Act
|
||||
var remediation = WizardRemediation.Empty;
|
||||
|
||||
// Assert
|
||||
remediation.LikelyCauses.Should().BeEmpty();
|
||||
remediation.Commands.Should().BeEmpty();
|
||||
remediation.VerificationCommand.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WizardRemediation_GetCommandsForRuntime_ReturnsMatchingCommands()
|
||||
{
|
||||
// Arrange
|
||||
var remediation = new WizardRemediation
|
||||
{
|
||||
LikelyCauses = [],
|
||||
Commands =
|
||||
[
|
||||
new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.DockerCompose,
|
||||
Command = "docker compose up -d",
|
||||
Description = "Docker"
|
||||
},
|
||||
new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.Kubernetes,
|
||||
Command = "kubectl apply",
|
||||
Description = "K8s"
|
||||
},
|
||||
new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.Any,
|
||||
Command = "echo verify",
|
||||
Description = "Any"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var dockerCommands = remediation.GetCommandsForRuntime(RuntimeEnvironment.DockerCompose).ToList();
|
||||
var k8sCommands = remediation.GetCommandsForRuntime(RuntimeEnvironment.Kubernetes).ToList();
|
||||
|
||||
// Assert
|
||||
dockerCommands.Should().HaveCount(2); // Docker + Any
|
||||
dockerCommands.Should().Contain(c => c.Description == "Docker");
|
||||
dockerCommands.Should().Contain(c => c.Description == "Any");
|
||||
|
||||
k8sCommands.Should().HaveCount(2); // K8s + Any
|
||||
k8sCommands.Should().Contain(c => c.Description == "K8s");
|
||||
k8sCommands.Should().Contain(c => c.Description == "Any");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WizardRemediation_GetCommandsForRuntime_ReturnsAnyCommands_WhenNoExactMatch()
|
||||
{
|
||||
// Arrange
|
||||
var remediation = new WizardRemediation
|
||||
{
|
||||
LikelyCauses = [],
|
||||
Commands =
|
||||
[
|
||||
new RemediationCommand
|
||||
{
|
||||
Runtime = RuntimeEnvironment.Any,
|
||||
Command = "generic command",
|
||||
Description = "Works everywhere"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var systemdCommands = remediation.GetCommandsForRuntime(RuntimeEnvironment.Systemd).ToList();
|
||||
|
||||
// Assert
|
||||
systemdCommands.Should().HaveCount(1);
|
||||
systemdCommands[0].Description.Should().Be("Works everywhere");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeEnvironment_HasExpectedValues()
|
||||
{
|
||||
// Assert - Verify all expected runtime types exist
|
||||
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.DockerCompose);
|
||||
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Kubernetes);
|
||||
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Systemd);
|
||||
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.WindowsService);
|
||||
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Bare);
|
||||
Enum.GetValues<RuntimeEnvironment>().Should().Contain(RuntimeEnvironment.Any);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// <copyright file="DoctorEvidenceLogWriterTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Output;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Output;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DoctorEvidenceLogWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAsync_WritesJsonlWithDoctorCommand()
|
||||
{
|
||||
var outputRoot = CreateTempRoot();
|
||||
var configuration = CreateConfiguration(outputRoot, dsseEnabled: false);
|
||||
var writer = new DoctorEvidenceLogWriter(
|
||||
configuration,
|
||||
NullLogger<DoctorEvidenceLogWriter>.Instance);
|
||||
|
||||
var report = CreateReport();
|
||||
var options = new DoctorRunOptions
|
||||
{
|
||||
DoctorCommand = "stella doctor run --format json"
|
||||
};
|
||||
|
||||
var artifacts = await writer.WriteAsync(report, options, CancellationToken.None);
|
||||
|
||||
artifacts.JsonlPath.Should().NotBeNullOrEmpty();
|
||||
File.Exists(artifacts.JsonlPath!).Should().BeTrue();
|
||||
|
||||
var lines = await File.ReadAllLinesAsync(artifacts.JsonlPath!, CancellationToken.None);
|
||||
lines.Should().HaveCount(1);
|
||||
|
||||
using var doc = JsonDocument.Parse(lines[0]);
|
||||
var root = doc.RootElement;
|
||||
|
||||
root.GetProperty("runId").GetString().Should().Be("dr_test_001");
|
||||
root.GetProperty("doctor_command").GetString().Should().Be("stella doctor run --format json");
|
||||
root.GetProperty("severity").GetString().Should().Be("fail");
|
||||
root.GetProperty("how_to_fix").GetProperty("commands").GetArrayLength().Should().Be(1);
|
||||
root.GetProperty("evidence").GetProperty("data").GetProperty("token").GetString().Should().Be("[REDACTED]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_WritesDsseSummaryWhenEnabled()
|
||||
{
|
||||
var outputRoot = CreateTempRoot();
|
||||
var configuration = CreateConfiguration(outputRoot, dsseEnabled: true);
|
||||
var writer = new DoctorEvidenceLogWriter(
|
||||
configuration,
|
||||
NullLogger<DoctorEvidenceLogWriter>.Instance);
|
||||
|
||||
var report = CreateReport();
|
||||
var options = new DoctorRunOptions
|
||||
{
|
||||
DoctorCommand = "stella doctor run --format json"
|
||||
};
|
||||
|
||||
var artifacts = await writer.WriteAsync(report, options, CancellationToken.None);
|
||||
|
||||
artifacts.DssePath.Should().NotBeNullOrEmpty();
|
||||
File.Exists(artifacts.DssePath!).Should().BeTrue();
|
||||
|
||||
var envelopeJson = await File.ReadAllTextAsync(artifacts.DssePath!, CancellationToken.None);
|
||||
using var envelopeDoc = JsonDocument.Parse(envelopeJson);
|
||||
var envelope = envelopeDoc.RootElement;
|
||||
|
||||
envelope.GetProperty("payloadType").GetString().Should()
|
||||
.Be("application/vnd.stellaops.doctor.summary+json");
|
||||
envelope.GetProperty("signatures").GetArrayLength().Should().Be(0);
|
||||
|
||||
var payloadBase64 = envelope.GetProperty("payload").GetString();
|
||||
payloadBase64.Should().NotBeNullOrEmpty();
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(payloadBase64!));
|
||||
using var payloadDoc = JsonDocument.Parse(payloadJson);
|
||||
var payload = payloadDoc.RootElement;
|
||||
|
||||
payload.GetProperty("doctor_command").GetString().Should().Be("stella doctor run --format json");
|
||||
payload.GetProperty("evidenceLog").GetProperty("jsonlPath").GetString().Should()
|
||||
.Be("artifacts/doctor/doctor-run-dr_test_001.ndjson");
|
||||
|
||||
var expectedDigest = ComputeSha256Hex(artifacts.JsonlPath!);
|
||||
payload.GetProperty("evidenceLog").GetProperty("sha256").GetString().Should().Be(expectedDigest);
|
||||
}
|
||||
|
||||
private static IConfiguration CreateConfiguration(string outputRoot, bool dsseEnabled)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Doctor:Evidence:Enabled"] = "true",
|
||||
["Doctor:Evidence:Root"] = outputRoot,
|
||||
["Doctor:Evidence:IncludeEvidence"] = "true",
|
||||
["Doctor:Evidence:RedactSensitive"] = "true",
|
||||
["Doctor:Evidence:Dsse:Enabled"] = dsseEnabled.ToString()
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static DoctorReport CreateReport()
|
||||
{
|
||||
var startedAt = new DateTimeOffset(2026, 1, 12, 14, 30, 52, TimeSpan.Zero);
|
||||
var completedAt = startedAt.AddSeconds(1);
|
||||
var evidence = new Evidence
|
||||
{
|
||||
Description = "Test evidence",
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = "https://example.test",
|
||||
["token"] = "super-secret"
|
||||
}.ToImmutableDictionary(StringComparer.Ordinal),
|
||||
SensitiveKeys = ImmutableArray.Create("token")
|
||||
};
|
||||
|
||||
var remediation = new Remediation
|
||||
{
|
||||
Steps = ImmutableArray.Create(
|
||||
new RemediationStep
|
||||
{
|
||||
Order = 1,
|
||||
Description = "Apply fix",
|
||||
Command = "stella doctor fix --from report.json",
|
||||
CommandType = CommandType.Shell
|
||||
})
|
||||
};
|
||||
|
||||
var result = new DoctorCheckResult
|
||||
{
|
||||
CheckId = "check.test.mock",
|
||||
PluginId = "test.plugin",
|
||||
Category = "Core",
|
||||
Severity = DoctorSeverity.Fail,
|
||||
Diagnosis = "Test failure",
|
||||
Evidence = evidence,
|
||||
Remediation = remediation,
|
||||
Duration = TimeSpan.FromMilliseconds(250),
|
||||
ExecutedAt = startedAt
|
||||
};
|
||||
|
||||
var summary = DoctorReportSummary.FromResults(new[] { result });
|
||||
return new DoctorReport
|
||||
{
|
||||
RunId = "dr_test_001",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Duration = completedAt - startedAt,
|
||||
OverallSeverity = DoctorSeverity.Fail,
|
||||
Summary = summary,
|
||||
Results = ImmutableArray.Create(result)
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateTempRoot()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "stellaops-doctor-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var hasher = SHA256.Create();
|
||||
var hash = hasher.ComputeHash(stream);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// <copyright file="DoctorPackCheckTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Packs;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Packs;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DoctorPackCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_PassesWhenExpectationsMet()
|
||||
{
|
||||
var definition = CreateDefinition(
|
||||
new DoctorPackParseRules
|
||||
{
|
||||
ExpectContains =
|
||||
[
|
||||
new DoctorPackExpectContains { Contains = "OK" }
|
||||
]
|
||||
});
|
||||
|
||||
var check = new DoctorPackCheck(
|
||||
definition,
|
||||
"doctor.pack",
|
||||
DoctorCategory.Integration,
|
||||
new FakeRunner(new DoctorPackCommandResult
|
||||
{
|
||||
ExitCode = 0,
|
||||
StdOut = "OK",
|
||||
StdErr = string.Empty
|
||||
}));
|
||||
|
||||
var context = CreateContext();
|
||||
var result = await check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Remediation.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_FailsWhenJsonExpectationNotMet()
|
||||
{
|
||||
var definition = CreateDefinition(
|
||||
new DoctorPackParseRules
|
||||
{
|
||||
ExpectJson =
|
||||
[
|
||||
new DoctorPackExpectJson
|
||||
{
|
||||
Path = "$.allCompliant",
|
||||
ExpectedValue = true
|
||||
}
|
||||
]
|
||||
},
|
||||
new DoctorPackHowToFix
|
||||
{
|
||||
Summary = "Apply policy pack",
|
||||
Commands = ["stella policy apply --preset strict"]
|
||||
});
|
||||
|
||||
var check = new DoctorPackCheck(
|
||||
definition,
|
||||
"doctor.pack",
|
||||
DoctorCategory.Integration,
|
||||
new FakeRunner(new DoctorPackCommandResult
|
||||
{
|
||||
ExitCode = 0,
|
||||
StdOut = "{\"allCompliant\":false}",
|
||||
StdErr = string.Empty
|
||||
}));
|
||||
|
||||
var context = CreateContext();
|
||||
var result = await check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("Expectations failed");
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IConfiguration>(configuration)
|
||||
.BuildServiceProvider();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services,
|
||||
Configuration = configuration,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = configuration.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private static DoctorPackCheckDefinition CreateDefinition(
|
||||
DoctorPackParseRules parse,
|
||||
DoctorPackHowToFix? howToFix = null)
|
||||
{
|
||||
return new DoctorPackCheckDefinition
|
||||
{
|
||||
CheckId = "pack.check",
|
||||
Name = "Pack check",
|
||||
Description = "Pack check description",
|
||||
DefaultSeverity = DoctorSeverity.Fail,
|
||||
Tags = ImmutableArray<string>.Empty,
|
||||
EstimatedDuration = TimeSpan.FromSeconds(1),
|
||||
Run = new DoctorPackCommand("echo ok"),
|
||||
Parse = parse,
|
||||
HowToFix = howToFix
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeRunner : IDoctorPackCommandRunner
|
||||
{
|
||||
private readonly DoctorPackCommandResult _result;
|
||||
|
||||
public FakeRunner(DoctorPackCommandResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<DoctorPackCommandResult> RunAsync(
|
||||
DoctorPackCommand command,
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// <copyright file="DoctorPackLoaderTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Packs;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Packs;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DoctorPackLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoadPlugins_LoadsYamlPack()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
try
|
||||
{
|
||||
var packDir = Path.Combine(root, "plugins", "doctor");
|
||||
Directory.CreateDirectory(packDir);
|
||||
|
||||
var manifestPath = Path.Combine(packDir, "release-orchestrator.gitlab.yaml");
|
||||
File.WriteAllText(manifestPath, GetSampleManifest());
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Doctor:Packs:Root"] = root,
|
||||
["Doctor:Packs:SearchPaths:0"] = packDir
|
||||
})
|
||||
.Build();
|
||||
|
||||
var context = CreateContext(config);
|
||||
var loader = new DoctorPackLoader(new FakeRunner(), NullLogger<DoctorPackLoader>.Instance);
|
||||
|
||||
var plugins = loader.LoadPlugins(context);
|
||||
|
||||
plugins.Should().HaveCount(1);
|
||||
plugins[0].PluginId.Should().Be("doctor-release-orchestrator-gitlab");
|
||||
plugins[0].GetChecks(context).Should().HaveCount(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoctorPackPlugin_IsAvailable_RespectsDiscovery()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
var previousEnv = Environment.GetEnvironmentVariable("PACK_TEST_ENV");
|
||||
try
|
||||
{
|
||||
var packDir = Path.Combine(root, "plugins", "doctor");
|
||||
Directory.CreateDirectory(packDir);
|
||||
|
||||
var configDir = Path.Combine(root, "config");
|
||||
Directory.CreateDirectory(configDir);
|
||||
File.WriteAllText(Path.Combine(configDir, "doctor-pack.yaml"), "ok");
|
||||
|
||||
var manifestPath = Path.Combine(packDir, "discovery.yaml");
|
||||
File.WriteAllText(manifestPath, GetDiscoveryManifest());
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Doctor:Packs:Root"] = root,
|
||||
["Doctor:Packs:SearchPaths:0"] = packDir
|
||||
})
|
||||
.Build();
|
||||
|
||||
Environment.SetEnvironmentVariable("PACK_TEST_ENV", "ready");
|
||||
var context = CreateContext(config);
|
||||
var loader = new DoctorPackLoader(new FakeRunner(), NullLogger<DoctorPackLoader>.Instance);
|
||||
|
||||
var plugin = loader.LoadPlugins(context).Single();
|
||||
plugin.IsAvailable(context.Services).Should().BeTrue();
|
||||
|
||||
Environment.SetEnvironmentVariable("PACK_TEST_ENV", null);
|
||||
plugin.IsAvailable(context.Services).Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PACK_TEST_ENV", previousEnv);
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(IConfiguration configuration)
|
||||
{
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(configuration)
|
||||
.BuildServiceProvider();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = services,
|
||||
Configuration = configuration,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = configuration.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateTempRoot()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "stellaops-doctor-tests", Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static string GetSampleManifest()
|
||||
{
|
||||
return """
|
||||
apiVersion: stella.ops/doctor.v1
|
||||
kind: DoctorPlugin
|
||||
metadata:
|
||||
name: doctor-release-orchestrator-gitlab
|
||||
labels:
|
||||
module: release-orchestrator
|
||||
integration: gitlab
|
||||
spec:
|
||||
checks:
|
||||
- id: scm.webhook.reachability
|
||||
description: "GitLab webhook is reachable"
|
||||
run:
|
||||
exec: "echo 200 OK"
|
||||
parse:
|
||||
expect:
|
||||
- contains: "200 OK"
|
||||
how_to_fix:
|
||||
summary: "Fix webhook"
|
||||
commands:
|
||||
- "stella orchestrator scm create-webhook"
|
||||
""";
|
||||
}
|
||||
|
||||
private static string GetDiscoveryManifest()
|
||||
{
|
||||
return """
|
||||
apiVersion: stella.ops/doctor.v1
|
||||
kind: DoctorPlugin
|
||||
metadata:
|
||||
name: doctor-pack-discovery
|
||||
spec:
|
||||
discovery:
|
||||
when:
|
||||
- env: PACK_TEST_ENV
|
||||
- fileExists: config/doctor-pack.yaml
|
||||
checks:
|
||||
- id: discovery.check
|
||||
description: "Discovery check"
|
||||
run:
|
||||
exec: "echo ok"
|
||||
parse:
|
||||
expect:
|
||||
- contains: "ok"
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class FakeRunner : IDoctorPackCommandRunner
|
||||
{
|
||||
public Task<DoctorPackCommandResult> RunAsync(
|
||||
DoctorPackCommand command,
|
||||
DoctorPluginContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(new DoctorPackCommandResult
|
||||
{
|
||||
ExitCode = 0,
|
||||
StdOut = "ok",
|
||||
StdErr = string.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using StellaOps.Doctor.Resolver;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Resolver;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PlaceholderResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_WithNoPlaceholders_ReturnsOriginalCommand()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "echo hello world";
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(command);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("echo hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithUserValues_ReplacesPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "curl http://{{HOST}}:{{PORT}}/health";
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["HOST"] = "localhost",
|
||||
["PORT"] = "8080"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(command, values);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("curl http://localhost:8080/health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithDefaultValues_UsesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "ping {{HOST:-localhost}}";
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(command);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("ping localhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithUserValueOverridesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "ping {{HOST:-localhost}}";
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["HOST"] = "192.168.1.1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(command, values);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("ping 192.168.1.1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithSensitivePlaceholder_DoesNotResolve()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "vault write auth/approle/login secret_id={{SECRET_ID}}";
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["SECRET_ID"] = "supersecret"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(command, values);
|
||||
|
||||
// Assert - Sensitive placeholders are NOT replaced
|
||||
result.Should().Contain("{{SECRET_ID}}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithNullCommand_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(null!);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithEmptyCommand_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(string.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PASSWORD")]
|
||||
[InlineData("TOKEN")]
|
||||
[InlineData("SECRET")]
|
||||
[InlineData("SECRET_KEY")]
|
||||
[InlineData("API_KEY")]
|
||||
[InlineData("APIKEY")]
|
||||
[InlineData("DB_PASSWORD")]
|
||||
public void IsSensitivePlaceholder_ReturnsTrueForSensitiveNames(string name)
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
|
||||
// Act
|
||||
var result = resolver.IsSensitivePlaceholder(name);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue($"'{name}' should be considered sensitive");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("HOST")]
|
||||
[InlineData("PORT")]
|
||||
[InlineData("NAMESPACE")]
|
||||
[InlineData("DATABASE")]
|
||||
[InlineData("USER")]
|
||||
public void IsSensitivePlaceholder_ReturnsFalseForNonSensitiveNames(string name)
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
|
||||
// Act
|
||||
var result = resolver.IsSensitivePlaceholder(name);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse($"'{name}' should not be considered sensitive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPlaceholders_FindsAllPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "{{HOST}}:{{PORT:-5432}}/{{DB_NAME}}";
|
||||
|
||||
// Act
|
||||
var placeholders = resolver.ExtractPlaceholders(command);
|
||||
|
||||
// Assert
|
||||
placeholders.Should().HaveCount(3);
|
||||
placeholders.Should().Contain(p => p.Name == "HOST");
|
||||
placeholders.Should().Contain(p => p.Name == "PORT");
|
||||
placeholders.Should().Contain(p => p.Name == "DB_NAME");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPlaceholders_IdentifiesDefaultValues()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "{{HOST:-localhost}}:{{PORT:-5432}}";
|
||||
|
||||
// Act
|
||||
var placeholders = resolver.ExtractPlaceholders(command);
|
||||
|
||||
// Assert
|
||||
var hostPlaceholder = placeholders.Single(p => p.Name == "HOST");
|
||||
var portPlaceholder = placeholders.Single(p => p.Name == "PORT");
|
||||
|
||||
hostPlaceholder.DefaultValue.Should().Be("localhost");
|
||||
portPlaceholder.DefaultValue.Should().Be("5432");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPlaceholders_MarksSensitivePlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "{{HOST}} {{PASSWORD}}";
|
||||
|
||||
// Act
|
||||
var placeholders = resolver.ExtractPlaceholders(command);
|
||||
|
||||
// Assert
|
||||
var hostPlaceholder = placeholders.Single(p => p.Name == "HOST");
|
||||
var passwordPlaceholder = placeholders.Single(p => p.Name == "PASSWORD");
|
||||
|
||||
hostPlaceholder.IsSensitive.Should().BeFalse();
|
||||
passwordPlaceholder.IsSensitive.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPlaceholders_IdentifiesRequiredPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "{{HOST}} {{PORT:-5432}}";
|
||||
|
||||
// Act
|
||||
var placeholders = resolver.ExtractPlaceholders(command);
|
||||
|
||||
// Assert
|
||||
var hostPlaceholder = placeholders.Single(p => p.Name == "HOST");
|
||||
var portPlaceholder = placeholders.Single(p => p.Name == "PORT");
|
||||
|
||||
hostPlaceholder.IsRequired.Should().BeTrue();
|
||||
portPlaceholder.IsRequired.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPlaceholders_HandlesEmptyCommand()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
|
||||
// Act
|
||||
var placeholders = resolver.ExtractPlaceholders(string.Empty);
|
||||
|
||||
// Assert
|
||||
placeholders.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPlaceholders_HandlesNoPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
var resolver = CreateResolver();
|
||||
var command = "echo hello world";
|
||||
|
||||
// Act
|
||||
var placeholders = resolver.ExtractPlaceholders(command);
|
||||
|
||||
// Assert
|
||||
placeholders.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_UsesContextValuesFromRuntimeDetector()
|
||||
{
|
||||
// Arrange
|
||||
var mockDetector = new Mock<IRuntimeDetector>();
|
||||
mockDetector.Setup(d => d.GetContextValues())
|
||||
.Returns(new Dictionary<string, string>
|
||||
{
|
||||
["NAMESPACE"] = "custom-ns"
|
||||
});
|
||||
|
||||
var resolver = new PlaceholderResolver(mockDetector.Object);
|
||||
var command = "kubectl get pods -n {{NAMESPACE}}";
|
||||
|
||||
// Act
|
||||
var result = resolver.Resolve(command);
|
||||
|
||||
// Assert
|
||||
result.Should().Be("kubectl get pods -n custom-ns");
|
||||
}
|
||||
|
||||
private static PlaceholderResolver CreateResolver()
|
||||
{
|
||||
var mockDetector = new Mock<IRuntimeDetector>();
|
||||
mockDetector.Setup(d => d.GetContextValues())
|
||||
.Returns(new Dictionary<string, string>());
|
||||
return new PlaceholderResolver(mockDetector.Object);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Doctor.Detection;
|
||||
using StellaOps.Doctor.Resolver;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Tests.Resolver;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VerificationExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithEmptyCommand_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync("", TimeSpan.FromSeconds(5));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithWhitespaceCommand_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync(" ", TimeSpan.FromSeconds(5));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithSimpleCommand_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "echo hello"
|
||||
: "echo hello";
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(10));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ExitCode.Should().Be(0);
|
||||
result.Output.Should().Contain("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithFailingCommand_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "cmd /c exit 1"
|
||||
: "exit 1";
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(10));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ExitCode.Should().NotBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecordsDuration()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "echo test"
|
||||
: "echo test";
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(10));
|
||||
|
||||
// Assert
|
||||
result.Duration.Should().BeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithTimeout_TimesOut()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
// Command that takes too long
|
||||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ping -n 30 127.0.0.1"
|
||||
: "sleep 30";
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync(command, TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.TimedOut.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteWithPlaceholdersAsync_ResolvesPlaceholders()
|
||||
{
|
||||
// Arrange
|
||||
var mockDetector = new Mock<IRuntimeDetector>();
|
||||
mockDetector.Setup(d => d.GetContextValues())
|
||||
.Returns(new Dictionary<string, string>());
|
||||
|
||||
var placeholderResolver = new PlaceholderResolver(mockDetector.Object);
|
||||
var executor = new VerificationExecutor(
|
||||
placeholderResolver,
|
||||
NullLogger<VerificationExecutor>.Instance);
|
||||
|
||||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "echo {{MESSAGE}}"
|
||||
: "echo {{MESSAGE}}";
|
||||
var values = new Dictionary<string, string> { ["MESSAGE"] = "resolved" };
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteWithPlaceholdersAsync(
|
||||
command, values, TimeSpan.FromSeconds(10));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Output.Should().Contain("resolved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteWithPlaceholdersAsync_WithMissingRequired_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var mockDetector = new Mock<IRuntimeDetector>();
|
||||
mockDetector.Setup(d => d.GetContextValues())
|
||||
.Returns(new Dictionary<string, string>());
|
||||
|
||||
var placeholderResolver = new PlaceholderResolver(mockDetector.Object);
|
||||
var executor = new VerificationExecutor(
|
||||
placeholderResolver,
|
||||
NullLogger<VerificationExecutor>.Instance);
|
||||
|
||||
var command = "curl http://{{HOST}}:{{PORT}}/health";
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteWithPlaceholdersAsync(
|
||||
command, null, TimeSpan.FromSeconds(10));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Error.Should().Contain("Missing required placeholder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithNonExistentCommand_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
var command = "nonexistent_command_12345";
|
||||
|
||||
// Act
|
||||
var result = await executor.ExecuteAsync(command, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithCancellation_StopsEarly()
|
||||
{
|
||||
// Arrange
|
||||
var executor = CreateExecutor();
|
||||
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ping -n 30 127.0.0.1"
|
||||
: "sleep 30";
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
var task = executor.ExecuteAsync(command, TimeSpan.FromMinutes(1), cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
|
||||
// Assert
|
||||
var result = await task;
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static VerificationExecutor CreateExecutor()
|
||||
{
|
||||
var mockDetector = new Mock<IRuntimeDetector>();
|
||||
mockDetector.Setup(d => d.GetContextValues())
|
||||
.Returns(new Dictionary<string, string>());
|
||||
|
||||
var placeholderResolver = new PlaceholderResolver(mockDetector.Object);
|
||||
return new VerificationExecutor(
|
||||
placeholderResolver,
|
||||
NullLogger<VerificationExecutor>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Policy Tools Tests Charter
|
||||
|
||||
## Mission
|
||||
Validate policy tool runner behavior and deterministic outputs.
|
||||
|
||||
## Responsibilities
|
||||
- Keep tests deterministic and offline-friendly.
|
||||
- Use local fixtures; avoid network calls.
|
||||
- Track task status in `TASKS.md`.
|
||||
|
||||
## Required Reading
|
||||
- `docs/modules/policy/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status in the sprint file and local `TASKS.md`.
|
||||
- 2. Prefer fixed timestamps and stable temp paths.
|
||||
- 3. Add tests for new runner behaviors and summary outputs.
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tools.Tests;
|
||||
|
||||
public sealed class PolicySchemaExporterRunnerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WritesLfLineEndings()
|
||||
{
|
||||
using var temp = new TempDirectory("schema-export");
|
||||
var runner = new PolicySchemaExporterRunner();
|
||||
var options = new PolicySchemaExportOptions
|
||||
{
|
||||
OutputDirectory = temp.RootPath
|
||||
};
|
||||
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
var export = PolicySchemaExporterSchema.BuildExports().First();
|
||||
var outputPath = Path.Combine(temp.RootPath, export.FileName);
|
||||
var bytes = await File.ReadAllBytesAsync(outputPath, CancellationToken.None);
|
||||
|
||||
Assert.True(bytes.Length > 1);
|
||||
Assert.Equal((byte)'\n', bytes[^1]);
|
||||
Assert.NotEqual((byte)'\r', bytes[^2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Tools.Tests;
|
||||
|
||||
public sealed class PolicySimulationSmokeRunnerTests
|
||||
{
|
||||
private const string PolicyJson = "{\n \"version\": \"1.0\",\n \"rules\": [\n {\n \"name\": \"block-low\",\n \"action\": \"block\",\n \"severity\": [\"low\"]\n }\n ]\n}\n";
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsInvalidSeverity()
|
||||
{
|
||||
using var temp = new TempDirectory("policy-sim-invalid-severity");
|
||||
WritePolicy(temp.RootPath);
|
||||
|
||||
var scenario = new PolicySimulationScenario
|
||||
{
|
||||
Name = "invalid-severity",
|
||||
PolicyPath = "policy.json",
|
||||
Findings = new List<ScenarioFinding>
|
||||
{
|
||||
new() { FindingId = "F-1", Severity = "NotASeverity" }
|
||||
},
|
||||
ExpectedDiffs = new List<ScenarioExpectedDiff>()
|
||||
};
|
||||
|
||||
var scenarioRoot = WriteScenario(temp.RootPath, scenario);
|
||||
var outputRoot = Path.Combine(temp.RootPath, "out");
|
||||
var options = BuildOptions(scenarioRoot, outputRoot, temp.RootPath);
|
||||
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
|
||||
var summaryPath = Path.Combine(outputRoot, "policy-simulation-summary.json");
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(summaryPath, CancellationToken.None));
|
||||
var entry = document.RootElement.EnumerateArray().Single();
|
||||
|
||||
Assert.False(entry.GetProperty("Success").GetBoolean());
|
||||
|
||||
var failures = entry.GetProperty("Failures")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("Scenario 'invalid-severity' finding 'F-1' has invalid severity 'NotASeverity'.", failures);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ReportsInvalidBaselineStatus()
|
||||
{
|
||||
using var temp = new TempDirectory("policy-sim-invalid-status");
|
||||
WritePolicy(temp.RootPath);
|
||||
|
||||
var scenario = new PolicySimulationScenario
|
||||
{
|
||||
Name = "invalid-status",
|
||||
PolicyPath = "policy.json",
|
||||
Findings = new List<ScenarioFinding>
|
||||
{
|
||||
new() { FindingId = "F-1", Severity = "Low" }
|
||||
},
|
||||
ExpectedDiffs = new List<ScenarioExpectedDiff>(),
|
||||
Baseline = new List<ScenarioBaseline>
|
||||
{
|
||||
new() { FindingId = "F-1", Status = "BadStatus" }
|
||||
}
|
||||
};
|
||||
|
||||
var scenarioRoot = WriteScenario(temp.RootPath, scenario);
|
||||
var outputRoot = Path.Combine(temp.RootPath, "out");
|
||||
var options = BuildOptions(scenarioRoot, outputRoot, temp.RootPath);
|
||||
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
|
||||
var summaryPath = Path.Combine(outputRoot, "policy-simulation-summary.json");
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(summaryPath, CancellationToken.None));
|
||||
var entry = document.RootElement.EnumerateArray().Single();
|
||||
|
||||
Assert.False(entry.GetProperty("Success").GetBoolean());
|
||||
|
||||
var failures = entry.GetProperty("Failures")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains("Scenario 'invalid-status' baseline 'F-1' has invalid status 'BadStatus'.", failures);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_SortsActualStatusesInSummary()
|
||||
{
|
||||
using var temp = new TempDirectory("policy-sim-ordering");
|
||||
WritePolicy(temp.RootPath);
|
||||
|
||||
var scenario = new PolicySimulationScenario
|
||||
{
|
||||
Name = "ordering",
|
||||
PolicyPath = "policy.json",
|
||||
Findings = new List<ScenarioFinding>
|
||||
{
|
||||
new() { FindingId = "b", Severity = "Low" },
|
||||
new() { FindingId = "a", Severity = "Low" }
|
||||
},
|
||||
ExpectedDiffs = new List<ScenarioExpectedDiff>
|
||||
{
|
||||
new() { FindingId = "b", Status = "Blocked" },
|
||||
new() { FindingId = "a", Status = "Blocked" }
|
||||
}
|
||||
};
|
||||
|
||||
var scenarioRoot = WriteScenario(temp.RootPath, scenario);
|
||||
var outputRoot = Path.Combine(temp.RootPath, "out");
|
||||
var options = BuildOptions(scenarioRoot, outputRoot, temp.RootPath);
|
||||
|
||||
var runner = new PolicySimulationSmokeRunner();
|
||||
var exitCode = await runner.RunAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
|
||||
var summaryPath = Path.Combine(outputRoot, "policy-simulation-summary.json");
|
||||
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(summaryPath, CancellationToken.None));
|
||||
var entry = document.RootElement.EnumerateArray().Single();
|
||||
|
||||
var actualStatuses = entry.GetProperty("ActualStatuses").EnumerateObject().Select(pair => pair.Name).ToArray();
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, actualStatuses);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveFixedTime_UsesDefaultWhenMissing()
|
||||
{
|
||||
var resolved = PolicySimulationSmokeDefaults.ResolveFixedTime(null);
|
||||
|
||||
Assert.Equal(PolicySimulationSmokeDefaults.DefaultFixedTime, resolved);
|
||||
}
|
||||
|
||||
private static PolicySimulationSmokeOptions BuildOptions(string scenarioRoot, string outputRoot, string repoRoot)
|
||||
=> new()
|
||||
{
|
||||
ScenarioRoot = scenarioRoot,
|
||||
OutputDirectory = outputRoot,
|
||||
RepoRoot = repoRoot,
|
||||
FixedTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
private static void WritePolicy(string rootPath)
|
||||
{
|
||||
var policyPath = Path.Combine(rootPath, "policy.json");
|
||||
File.WriteAllText(policyPath, PolicyJson);
|
||||
}
|
||||
|
||||
private static string WriteScenario(string rootPath, PolicySimulationScenario scenario)
|
||||
{
|
||||
var scenarioRoot = Path.Combine(rootPath, "scenarios");
|
||||
Directory.CreateDirectory(scenarioRoot);
|
||||
|
||||
var scenarioPath = Path.Combine(scenarioRoot, "scenario.json");
|
||||
var scenarioJson = JsonSerializer.Serialize(
|
||||
scenario,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
||||
File.WriteAllText(scenarioPath, scenarioJson);
|
||||
|
||||
return scenarioRoot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# Policy Tools Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0096-A | DONE | Added Policy.Tools runner coverage 2026-01-14. |
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Policy.Tools.Tests;
|
||||
|
||||
internal sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory(string name)
|
||||
{
|
||||
RootPath = Path.Combine(Path.GetTempPath(), "stellaops-policy-tools-tests", $"{name}-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(RootPath);
|
||||
}
|
||||
|
||||
public string RootPath { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(RootPath))
|
||||
{
|
||||
Directory.Delete(RootPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2025 StellaOps Contributors
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache.Api;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,6 +15,8 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class ApiContractTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
@@ -24,7 +26,7 @@ public sealed class ApiContractTests
|
||||
#region CacheSource Contract Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("inMemory")]
|
||||
[InlineData("redis")]
|
||||
@@ -48,7 +50,7 @@ public sealed class ApiContractTests
|
||||
#region TrustScoreBreakdown Contract Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_DefaultWeights_SumToOne()
|
||||
{
|
||||
// Verify the standard weights sum to 1.0 (100%)
|
||||
@@ -64,7 +66,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_StandardWeights_MatchDocumentation()
|
||||
{
|
||||
// Verify weights match the documented percentages
|
||||
@@ -79,7 +81,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_ReturnsCorrectWeightedSum()
|
||||
{
|
||||
// Given all scores at 100, total should be 100
|
||||
@@ -94,7 +96,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_WithZeroScores_ReturnsZero()
|
||||
{
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault();
|
||||
@@ -103,7 +105,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_ComputeTotal_WithMixedScores_ComputesCorrectly()
|
||||
{
|
||||
// Specific test case:
|
||||
@@ -124,7 +126,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreBreakdown_Serialization_IncludesAllComponents()
|
||||
{
|
||||
var breakdown = TrustScoreBreakdown.CreateDefault(50, 60, 70, 80, 90);
|
||||
@@ -139,7 +141,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void TrustScoreComponent_Contribution_CalculatesCorrectly()
|
||||
{
|
||||
var component = new TrustScoreComponent { Score = 80, Weight = 0.25m };
|
||||
@@ -164,8 +166,8 @@ public sealed class ApiContractTests
|
||||
VerdictHash = "sha256:def",
|
||||
ProofRoot = "sha256:ghi",
|
||||
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(1),
|
||||
TrustScore = 85,
|
||||
TrustScoreBreakdown = null
|
||||
};
|
||||
@@ -179,7 +181,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void DecisionDigest_WithBreakdown_SerializesCorrectly()
|
||||
{
|
||||
var digest = new DecisionDigest
|
||||
@@ -189,8 +191,8 @@ public sealed class ApiContractTests
|
||||
VerdictHash = "sha256:def",
|
||||
ProofRoot = "sha256:ghi",
|
||||
ReplaySeed = new ReplaySeed { FeedIds = [], RuleIds = [] },
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(1),
|
||||
TrustScore = 79,
|
||||
TrustScoreBreakdown = TrustScoreBreakdown.CreateDefault(80, 90, 70, 100, 60)
|
||||
};
|
||||
@@ -206,7 +208,7 @@ public sealed class ApiContractTests
|
||||
#region InputManifest Contract Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void InputManifestResponse_RequiredFields_NotNull()
|
||||
{
|
||||
var manifest = new InputManifestResponse
|
||||
@@ -218,7 +220,7 @@ public sealed class ApiContractTests
|
||||
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
|
||||
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
|
||||
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = FixedNow,
|
||||
};
|
||||
|
||||
manifest.VeriKey.Should().NotBeNull();
|
||||
@@ -231,7 +233,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void InputManifestResponse_Serialization_IncludesAllComponents()
|
||||
{
|
||||
var manifest = new InputManifestResponse
|
||||
@@ -243,7 +245,7 @@ public sealed class ApiContractTests
|
||||
Policy = new PolicyInfoDto { Hash = "sha256:policy" },
|
||||
Signers = new SignerInfoDto { SetHash = "sha256:signers" },
|
||||
TimeWindow = new TimeWindowInfoDto { Bucket = "2024-12-24" },
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = FixedNow,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
@@ -259,7 +261,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SbomInfoDto_OptionalFields_CanBeNull()
|
||||
{
|
||||
var sbom = new SbomInfoDto
|
||||
@@ -277,7 +279,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VexInfoDto_Sources_CanBeEmpty()
|
||||
{
|
||||
var vex = new VexInfoDto
|
||||
@@ -292,7 +294,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void PolicyInfoDto_OptionalFields_PreserveValues()
|
||||
{
|
||||
var policy = new PolicyInfoDto
|
||||
@@ -310,7 +312,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SignerInfoDto_Certificates_CanBeNull()
|
||||
{
|
||||
var signers = new SignerInfoDto
|
||||
@@ -324,7 +326,7 @@ public sealed class ApiContractTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SignerCertificateDto_AllFields_AreOptional()
|
||||
{
|
||||
var cert = new SignerCertificateDto
|
||||
@@ -346,8 +348,8 @@ public sealed class ApiContractTests
|
||||
var timeWindow = new TimeWindowInfoDto
|
||||
{
|
||||
Bucket = "2024-W52",
|
||||
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z"),
|
||||
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z")
|
||||
StartsAt = DateTimeOffset.Parse("2024-12-23T00:00:00Z", CultureInfo.InvariantCulture),
|
||||
EndsAt = DateTimeOffset.Parse("2024-12-30T00:00:00Z", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
timeWindow.Bucket.Should().NotBeNullOrEmpty();
|
||||
@@ -358,7 +360,7 @@ public sealed class ApiContractTests
|
||||
#region API Response Backwards Compatibility
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ProvcacheGetResponse_Status_ValidValues()
|
||||
{
|
||||
// Verify status field uses expected values
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using StellaOps.Provcache.Api;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
@@ -18,6 +19,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private IHost? _host;
|
||||
private HttpClient? _client;
|
||||
private Mock<IEvidenceChunkRepository>? _mockChunkRepository;
|
||||
@@ -42,7 +44,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
// Add mock IProvcacheService to satisfy the main endpoints
|
||||
services.AddSingleton(Mock.Of<IProvcacheService>());
|
||||
// Add TimeProvider for InputManifest endpoint
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(FixedNow));
|
||||
})
|
||||
.Configure(app =>
|
||||
{
|
||||
@@ -68,7 +70,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_ReturnsChunksWithPagination()
|
||||
{
|
||||
@@ -80,7 +82,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalChunks = 15,
|
||||
TotalSize = 15000,
|
||||
Chunks = [],
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var chunks = new List<EvidenceChunk>
|
||||
@@ -108,7 +110,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.NextCursor.Should().Be("10");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithOffset_ReturnsPaginatedResults()
|
||||
{
|
||||
@@ -120,7 +122,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalChunks = 5,
|
||||
TotalSize = 5000,
|
||||
Chunks = [],
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var chunks = new List<EvidenceChunk>
|
||||
@@ -147,7 +149,35 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.HasMore.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithNegativeOffset_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var proofRoot = "sha256:bad-offset";
|
||||
|
||||
// Act
|
||||
var response = await _client!.GetAsync($"/v1/provcache/proofs/{Uri.EscapeDataString(proofRoot)}?offset=-1");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithInvalidLimit_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var proofRoot = "sha256:bad-limit";
|
||||
|
||||
// Act
|
||||
var response = await _client!.GetAsync($"/v1/provcache/proofs/{Uri.EscapeDataString(proofRoot)}?limit=0");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_WithIncludeData_ReturnsBase64Blobs()
|
||||
{
|
||||
@@ -159,7 +189,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalChunks = 1,
|
||||
TotalSize = 100,
|
||||
Chunks = [],
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
var chunks = new List<EvidenceChunk>
|
||||
@@ -182,7 +212,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result!.Chunks[0].Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetEvidenceChunks_NotFound_Returns404()
|
||||
{
|
||||
@@ -198,7 +228,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetProofManifest_ReturnsManifestWithChunkMetadata()
|
||||
{
|
||||
@@ -211,11 +241,11 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
TotalSize = 3000,
|
||||
Chunks = new List<ChunkMetadata>
|
||||
{
|
||||
new() { ChunkId = Guid.NewGuid(), Index = 0, Hash = "sha256:chunk0", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = Guid.NewGuid(), Index = 1, Hash = "sha256:chunk1", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = Guid.NewGuid(), Index = 2, Hash = "sha256:chunk2", Size = 1000, ContentType = "application/octet-stream" }
|
||||
new() { ChunkId = CreateGuid(1), Index = 0, Hash = "sha256:chunk0", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = CreateGuid(2), Index = 1, Hash = "sha256:chunk1", Size = 1000, ContentType = "application/octet-stream" },
|
||||
new() { ChunkId = CreateGuid(3), Index = 2, Hash = "sha256:chunk2", Size = 1000, ContentType = "application/octet-stream" }
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
|
||||
_mockChunkRepository!.Setup(x => x.GetManifestAsync(proofRoot, It.IsAny<CancellationToken>()))
|
||||
@@ -233,7 +263,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.Chunks.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetProofManifest_NotFound_Returns404()
|
||||
{
|
||||
@@ -249,7 +279,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetSingleChunk_ReturnsChunkWithData()
|
||||
{
|
||||
@@ -272,7 +302,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.Data.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetSingleChunk_NotFound_Returns404()
|
||||
{
|
||||
@@ -288,7 +318,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_ValidChunks_ReturnsIsValidTrue()
|
||||
{
|
||||
@@ -320,7 +350,40 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.ChunkResults.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_OrdersChunksBeforeHashing()
|
||||
{
|
||||
// Arrange
|
||||
var proofRoot = "sha256:ordered-proof";
|
||||
var chunk1 = CreateChunk(proofRoot, 1, 100);
|
||||
var chunk0 = CreateChunk(proofRoot, 0, 100);
|
||||
var orderedHashes = new[] { chunk0.ChunkHash, chunk1.ChunkHash };
|
||||
var captured = new List<string>();
|
||||
|
||||
_mockChunkRepository!.Setup(x => x.GetChunksAsync(proofRoot, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EvidenceChunk> { chunk1, chunk0 });
|
||||
|
||||
_mockChunker!.Setup(x => x.VerifyChunk(It.IsAny<EvidenceChunk>()))
|
||||
.Returns(true);
|
||||
|
||||
_mockChunker.Setup(x => x.ComputeMerkleRoot(It.IsAny<IEnumerable<string>>()))
|
||||
.Callback<IEnumerable<string>>(hashes =>
|
||||
{
|
||||
captured.Clear();
|
||||
captured.AddRange(hashes);
|
||||
})
|
||||
.Returns(proofRoot);
|
||||
|
||||
// Act
|
||||
var response = await _client!.PostAsync($"/v1/provcache/proofs/{Uri.EscapeDataString(proofRoot)}/verify", null);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
captured.Should().Equal(orderedHashes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_MerkleRootMismatch_ReturnsIsValidFalse()
|
||||
{
|
||||
@@ -351,7 +414,7 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
result.Error.Should().Contain("Merkle root mismatch");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task VerifyProof_NoChunks_Returns404()
|
||||
{
|
||||
@@ -369,21 +432,27 @@ public sealed class EvidenceApiTests : IAsyncLifetime
|
||||
|
||||
private static EvidenceChunk CreateChunk(string proofRoot, int index, int size)
|
||||
{
|
||||
var random = new DeterministicRandom(index + size);
|
||||
var data = new byte[size];
|
||||
Random.Shared.NextBytes(data);
|
||||
random.NextBytes(data);
|
||||
|
||||
return new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = random.NextGuid(),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = index,
|
||||
ChunkHash = $"sha256:chunk{index}",
|
||||
Blob = data,
|
||||
BlobSize = size,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow.AddMinutes(index)
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int seed)
|
||||
{
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provcache;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,6 +11,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class EvidenceChunkerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly ProvcacheOptions _options;
|
||||
private readonly EvidenceChunker _chunker;
|
||||
|
||||
@@ -21,12 +22,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldSplitEvidenceIntoMultipleChunks_WhenLargerThanChunkSize()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[200];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(200, 1);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
// Act
|
||||
@@ -48,12 +48,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldCreateSingleChunk_WhenSmallerThanChunkSize()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[32];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(32, 2);
|
||||
const string contentType = "application/json";
|
||||
|
||||
// Act
|
||||
@@ -67,7 +66,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldHandleEmptyEvidence()
|
||||
{
|
||||
// Arrange
|
||||
@@ -84,7 +83,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkAsync_ShouldProduceUniqueHashForEachChunk()
|
||||
{
|
||||
// Arrange - create evidence with distinct bytes per chunk
|
||||
@@ -102,12 +101,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReassembleAsync_ShouldRecoverOriginalEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var original = new byte[200];
|
||||
Random.Shared.NextBytes(original);
|
||||
var original = CreateDeterministicBytes(200, 3);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
var chunked = await _chunker.ChunkAsync(original, contentType);
|
||||
@@ -120,12 +118,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReassembleAsync_ShouldThrow_WhenMerkleRootMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[100];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(100, 4);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
var chunked = await _chunker.ChunkAsync(evidence, contentType);
|
||||
@@ -137,12 +134,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReassembleAsync_ShouldThrow_WhenChunkCorrupted()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[100];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(100, 5);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
var chunked = await _chunker.ChunkAsync(evidence, contentType);
|
||||
@@ -161,24 +157,23 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyChunk_ShouldReturnTrue_WhenChunkValid()
|
||||
{
|
||||
// Arrange
|
||||
var data = new byte[32];
|
||||
Random.Shared.NextBytes(data);
|
||||
var data = CreateDeterministicBytes(32, 6);
|
||||
var hash = ComputeHash(data);
|
||||
|
||||
var chunk = new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(6),
|
||||
ProofRoot = "sha256:test",
|
||||
ChunkIndex = 0,
|
||||
ChunkHash = hash,
|
||||
Blob = data,
|
||||
BlobSize = data.Length,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
@@ -186,20 +181,20 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void VerifyChunk_ShouldReturnFalse_WhenHashMismatch()
|
||||
{
|
||||
// Arrange
|
||||
var chunk = new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(7),
|
||||
ProofRoot = "sha256:test",
|
||||
ChunkIndex = 0,
|
||||
ChunkHash = "sha256:wrong_hash",
|
||||
Blob = new byte[32],
|
||||
BlobSize = 32,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
@@ -207,7 +202,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeMerkleRoot_ShouldReturnSameResult_ForSameInput()
|
||||
{
|
||||
// Arrange
|
||||
@@ -223,7 +218,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeMerkleRoot_ShouldHandleSingleHash()
|
||||
{
|
||||
// Arrange
|
||||
@@ -237,7 +232,7 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void ComputeMerkleRoot_ShouldHandleOddNumberOfHashes()
|
||||
{
|
||||
// Arrange
|
||||
@@ -252,12 +247,11 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ChunkStreamAsync_ShouldYieldChunksInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = new byte[200];
|
||||
Random.Shared.NextBytes(evidence);
|
||||
var evidence = CreateDeterministicBytes(200, 8);
|
||||
using var stream = new MemoryStream(evidence);
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
@@ -277,15 +271,14 @@ public sealed class EvidenceChunkerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Roundtrip_ShouldPreserveDataIntegrity()
|
||||
{
|
||||
// Arrange - use realistic chunk size
|
||||
var options = new ProvcacheOptions { ChunkSize = 1024 };
|
||||
var chunker = new EvidenceChunker(options);
|
||||
|
||||
var original = new byte[5000]; // ~5 chunks
|
||||
Random.Shared.NextBytes(original);
|
||||
var original = CreateDeterministicBytes(5000, 9); // ~5 chunks
|
||||
const string contentType = "application/octet-stream";
|
||||
|
||||
// Act
|
||||
@@ -302,4 +295,17 @@ public sealed class EvidenceChunkerTests
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static byte[] CreateDeterministicBytes(int length, int seed)
|
||||
{
|
||||
var random = new DeterministicRandom(seed);
|
||||
var data = new byte[length];
|
||||
random.NextBytes(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int seed)
|
||||
{
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed class LazyFetchTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IEvidenceChunkRepository> _repositoryMock;
|
||||
private readonly LazyFetchOrchestrator _orchestrator;
|
||||
|
||||
@@ -259,11 +261,11 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void FileChunkFetcher_FetcherType_ReturnsFile()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
var tempDir = CreateTempDir(1);
|
||||
var fetcher = new FileChunkFetcher(tempDir, NullLogger<FileChunkFetcher>.Instance);
|
||||
|
||||
// Act & Assert
|
||||
@@ -271,12 +273,12 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task FileChunkFetcher_IsAvailableAsync_ReturnsTrueWhenDirectoryExists()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var tempDir = CreateTempDir(2);
|
||||
EnsureCleanDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -290,16 +292,17 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task FileChunkFetcher_IsAvailableAsync_ReturnsFalseWhenDirectoryMissing()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
var tempDir = CreateTempDir(3);
|
||||
TryDeleteDirectory(tempDir);
|
||||
var fetcher = new FileChunkFetcher(tempDir, NullLogger<FileChunkFetcher>.Instance);
|
||||
|
||||
// Act
|
||||
@@ -310,12 +313,12 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task FileChunkFetcher_FetchChunkAsync_ReturnsNullWhenChunkNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var tempDir = CreateTempDir(4);
|
||||
EnsureCleanDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -329,7 +332,7 @@ public sealed class LazyFetchTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, true);
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,15 +350,93 @@ public sealed class LazyFetchTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpChunkFetcher_DisallowedHost_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient { BaseAddress = new Uri("https://blocked.example") };
|
||||
var options = new LazyFetchHttpOptions();
|
||||
options.AllowedHosts.Add("allowed.example");
|
||||
|
||||
// Act
|
||||
var action = () => new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*not allowlisted*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpChunkFetcher_DisallowedScheme_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient { BaseAddress = new Uri("http://example.test") };
|
||||
var options = new LazyFetchHttpOptions();
|
||||
options.AllowedHosts.Add("example.test");
|
||||
options.AllowedSchemes.Add("https");
|
||||
|
||||
// Act
|
||||
var action = () => new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Assert
|
||||
action.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("*scheme*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void HttpChunkFetcher_AppliesTimeout()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("https://example.test"),
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
var options = new LazyFetchHttpOptions
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(2)
|
||||
};
|
||||
options.AllowedHosts.Add("example.test");
|
||||
|
||||
// Act
|
||||
_ = new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Assert
|
||||
httpClient.Timeout.Should().Be(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HttpChunkFetcher_IsAvailableAsync_ReturnsFalseWhenHostUnreachable()
|
||||
{
|
||||
// Arrange - use a non-routable IP to ensure connection failure
|
||||
var httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("http://192.0.2.1:9999"),
|
||||
Timeout = TimeSpan.FromMilliseconds(100) // Short timeout for test speed
|
||||
// Arrange - handler throws to avoid network access
|
||||
var options = new LazyFetchHttpOptions { Timeout = TimeSpan.FromMilliseconds(50) };
|
||||
options.AllowedHosts.Add("example.test");
|
||||
|
||||
var httpClient = new HttpClient(new ThrowingHttpMessageHandler())
|
||||
{
|
||||
BaseAddress = new Uri("https://example.test"),
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
var fetcher = new HttpChunkFetcher(httpClient, ownsClient: false, NullLogger<HttpChunkFetcher>.Instance);
|
||||
|
||||
var fetcher = new HttpChunkFetcher(
|
||||
httpClient,
|
||||
ownsClient: false,
|
||||
NullLogger<HttpChunkFetcher>.Instance,
|
||||
options);
|
||||
|
||||
// Act
|
||||
var result = await fetcher.IsAvailableAsync();
|
||||
@@ -371,7 +452,7 @@ public sealed class LazyFetchTests
|
||||
var chunks = Enumerable.Range(0, chunkCount)
|
||||
.Select(i => new ChunkMetadata
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(100 + i),
|
||||
Index = i,
|
||||
Hash = ComputeTestHash(i),
|
||||
Size = 100 + i,
|
||||
@@ -385,7 +466,7 @@ public sealed class LazyFetchTests
|
||||
TotalChunks = chunkCount,
|
||||
TotalSize = chunks.Sum(c => c.Size),
|
||||
Chunks = chunks,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,14 +478,14 @@ public sealed class LazyFetchTests
|
||||
var data = CreateTestData(i);
|
||||
return new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = CreateGuid(200 + i),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = i,
|
||||
ChunkHash = ComputeActualHash(data),
|
||||
Blob = data,
|
||||
BlobSize = data.Length,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = FixedNow.AddMinutes(i)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
@@ -438,6 +519,41 @@ public sealed class LazyFetchTests
|
||||
{
|
||||
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static Guid CreateGuid(int seed)
|
||||
{
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
|
||||
private static string CreateTempDir(int seed)
|
||||
{
|
||||
var directoryName = CreateGuid(seed).ToString("N");
|
||||
return Path.Combine(Path.GetTempPath(), "stellaops-provcache-tests", directoryName);
|
||||
}
|
||||
|
||||
private static void EnsureCleanDirectory(string path)
|
||||
{
|
||||
TryDeleteDirectory(path);
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new HttpRequestException("Simulated failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension method for async enumerable from list
|
||||
|
||||
@@ -8,6 +8,8 @@ using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,6 +20,7 @@ public sealed class MinimalProofExporterTests
|
||||
private readonly Mock<IProvcacheService> _mockService;
|
||||
private readonly Mock<IEvidenceChunkRepository> _mockChunkRepo;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly SequentialGuidProvider _guidProvider;
|
||||
private readonly MinimalProofExporter _exporter;
|
||||
|
||||
// Test data
|
||||
@@ -39,13 +42,14 @@ public sealed class MinimalProofExporterTests
|
||||
_mockService = new Mock<IProvcacheService>();
|
||||
_mockChunkRepo = new Mock<IEvidenceChunkRepository>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
_guidProvider = new SequentialGuidProvider();
|
||||
|
||||
_exporter = new MinimalProofExporter(
|
||||
_mockService.Object,
|
||||
_mockChunkRepo.Object,
|
||||
signer: null,
|
||||
_timeProvider,
|
||||
guidProvider: null,
|
||||
guidProvider: _guidProvider,
|
||||
NullLogger<MinimalProofExporter>.Instance);
|
||||
|
||||
// Create test data
|
||||
@@ -81,18 +85,19 @@ public sealed class MinimalProofExporterTests
|
||||
_testChunks = Enumerable.Range(0, 5)
|
||||
.Select(i =>
|
||||
{
|
||||
var random = new DeterministicRandom(i + 10);
|
||||
var data = new byte[1024];
|
||||
Random.Shared.NextBytes(data);
|
||||
random.NextBytes(data);
|
||||
return new EvidenceChunk
|
||||
{
|
||||
ChunkId = Guid.NewGuid(),
|
||||
ChunkId = random.NextGuid(),
|
||||
ProofRoot = proofRoot,
|
||||
ChunkIndex = i,
|
||||
ChunkHash = $"sha256:{Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(data))}",
|
||||
Blob = data,
|
||||
BlobSize = 1024,
|
||||
ContentType = "application/octet-stream",
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
CreatedAt = _timeProvider.GetUtcNow().AddMinutes(i)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
@@ -455,6 +460,81 @@ public sealed class MinimalProofExporterTests
|
||||
bundle.Signature.SignatureBytes.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedBundle_ReturnsValidSignature()
|
||||
{
|
||||
// Arrange
|
||||
SetupMocks();
|
||||
var exporter = CreateExporterWithSigner([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
var options = new MinimalProofExportOptions
|
||||
{
|
||||
Density = ProofDensity.Lite,
|
||||
Sign = true,
|
||||
SigningKeyId = "test-key"
|
||||
};
|
||||
|
||||
// Act
|
||||
var bundle = await exporter.ExportAsync(_testEntry.VeriKey, options);
|
||||
var verification = await exporter.VerifyAsync(bundle);
|
||||
|
||||
// Assert
|
||||
verification.SignatureValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedBundle_TamperedSignature_Fails()
|
||||
{
|
||||
// Arrange
|
||||
SetupMocks();
|
||||
var exporter = CreateExporterWithSigner([9, 8, 7, 6, 5, 4, 3, 2]);
|
||||
var options = new MinimalProofExportOptions
|
||||
{
|
||||
Density = ProofDensity.Lite,
|
||||
Sign = true,
|
||||
SigningKeyId = "test-key"
|
||||
};
|
||||
|
||||
var bundle = await exporter.ExportAsync(_testEntry.VeriKey, options);
|
||||
var tamperedBundle = bundle with
|
||||
{
|
||||
Signature = bundle.Signature with
|
||||
{
|
||||
SignatureBytes = Convert.ToBase64String(new byte[] { 1, 1, 1, 1 })
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var verification = await exporter.VerifyAsync(tamperedBundle);
|
||||
|
||||
// Assert
|
||||
verification.SignatureValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAsync_SignedBundleWithoutVerifier_ReturnsInvalidSignature()
|
||||
{
|
||||
// Arrange
|
||||
SetupMocks();
|
||||
var exporter = CreateExporterWithSigner([1, 1, 2, 3, 5, 8, 13, 21]);
|
||||
var options = new MinimalProofExportOptions
|
||||
{
|
||||
Density = ProofDensity.Lite,
|
||||
Sign = true,
|
||||
SigningKeyId = "test-key"
|
||||
};
|
||||
|
||||
var bundle = await exporter.ExportAsync(_testEntry.VeriKey, options);
|
||||
|
||||
// Act
|
||||
var verification = await _exporter.VerifyAsync(bundle);
|
||||
|
||||
// Assert
|
||||
verification.SignatureValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void SetupMocks()
|
||||
@@ -474,6 +554,23 @@ public sealed class MinimalProofExporterTests
|
||||
_testChunks.Skip(start).Take(count).ToList());
|
||||
}
|
||||
|
||||
private MinimalProofExporter CreateExporterWithSigner(byte[] keyMaterial)
|
||||
{
|
||||
var keyProvider = new InMemoryKeyProvider("test-key", keyMaterial);
|
||||
var hmac = DefaultCryptoHmac.CreateForTests();
|
||||
var signer = new HmacSigner(keyProvider, hmac);
|
||||
|
||||
return new MinimalProofExporter(
|
||||
_mockService.Object,
|
||||
_mockChunkRepo.Object,
|
||||
signer,
|
||||
_timeProvider,
|
||||
guidProvider: new SequentialGuidProvider(),
|
||||
NullLogger<MinimalProofExporter>.Instance,
|
||||
cryptoHmac: hmac,
|
||||
keyProvider: keyProvider);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace StellaOps.Provcache.Tests;
|
||||
/// </summary>
|
||||
public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IProvcacheService> _mockService;
|
||||
private readonly IHost _host;
|
||||
private readonly HttpClient _client;
|
||||
@@ -38,7 +39,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(_mockService.Object);
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<TimeProvider>(new FixedTimeProvider(FixedNow));
|
||||
services.AddRouting();
|
||||
services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning));
|
||||
})
|
||||
@@ -66,7 +67,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#region GET /v1/provcache/{veriKey}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_CacheHit_Returns200WithEntry()
|
||||
{
|
||||
@@ -90,7 +91,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content.Source.Should().Be("valkey");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_CacheMiss_Returns204()
|
||||
{
|
||||
@@ -108,7 +109,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_Expired_Returns410Gone()
|
||||
{
|
||||
@@ -127,8 +128,8 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Gone);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_WithBypassCache_PassesFlagToService()
|
||||
{
|
||||
// Arrange
|
||||
@@ -145,11 +146,30 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
_mockService.Verify(s => s.GetAsync(veriKey, true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_WhenException_Returns500WithRedactedDetail()
|
||||
{
|
||||
// Arrange
|
||||
const string veriKey = "sha256:error123error123error123error123error123error123error123error123";
|
||||
_mockService.Setup(s => s.GetAsync(veriKey, false, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/provcache/{Uri.EscapeDataString(veriKey)}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
payload.Should().Contain("An unexpected error occurred");
|
||||
payload.Should().NotContain("boom");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region POST /v1/provcache
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdate_ValidRequest_Returns201Created()
|
||||
{
|
||||
@@ -176,7 +196,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdate_NullEntry_Returns400BadRequest()
|
||||
{
|
||||
@@ -194,7 +214,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#region POST /v1/provcache/invalidate
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Invalidate_SingleVeriKey_Returns200WithAffectedCount()
|
||||
{
|
||||
@@ -222,7 +242,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content.Type.Should().Be("verikey");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Invalidate_ByPolicyHash_Returns200WithBulkResult()
|
||||
{
|
||||
@@ -233,7 +253,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
{
|
||||
EntriesAffected = 5,
|
||||
Request = invalidationRequest,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.InvalidateByAsync(
|
||||
@@ -258,7 +278,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
content!.EntriesAffected.Should().Be(5);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Invalidate_ByPattern_Returns200WithPatternResult()
|
||||
{
|
||||
@@ -269,7 +289,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
{
|
||||
EntriesAffected = 10,
|
||||
Request = invalidationRequest,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.InvalidateByAsync(
|
||||
@@ -298,7 +318,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#region GET /v1/provcache/metrics
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetMetrics_Returns200WithMetrics()
|
||||
{
|
||||
@@ -314,7 +334,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
P99LatencyMs = 10.0,
|
||||
ValkeyCacheHealthy = true,
|
||||
PostgresRepositoryHealthy = true,
|
||||
CollectedAt = DateTimeOffset.UtcNow
|
||||
CollectedAt = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||
@@ -334,9 +354,38 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region GET /v1/provcache/{veriKey}/manifest
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetInputManifest_ReturnsPlaceholderHashes()
|
||||
{
|
||||
// Arrange
|
||||
const string veriKey = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var entry = CreateTestEntry(veriKey);
|
||||
var result = ProvcacheServiceResult.Hit(entry, "valkey", 1.0);
|
||||
|
||||
_mockService.Setup(s => s.GetAsync(veriKey, false, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/v1/provcache/{Uri.EscapeDataString(veriKey)}/manifest");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var manifest = await response.Content.ReadFromJsonAsync<InputManifestResponse>();
|
||||
manifest.Should().NotBeNull();
|
||||
var expectedHash = "sha256:" + new string('a', 32) + "...";
|
||||
manifest!.Sbom.Hash.Should().Be(expectedHash);
|
||||
manifest.Vex.HashSetHash.Should().Be(expectedHash);
|
||||
manifest.GeneratedAt.Should().Be(FixedNow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Contract Verification Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task GetByVeriKey_ResponseContract_HasRequiredFields()
|
||||
{
|
||||
@@ -362,7 +411,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
root.TryGetProperty("entry", out _).Should().BeTrue("Response must have 'entry' field");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateOrUpdate_ResponseContract_HasRequiredFields()
|
||||
{
|
||||
@@ -387,7 +436,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
root.TryGetProperty("expiresAt", out _).Should().BeTrue("Response must have 'expiresAt' field");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task InvalidateResponse_Contract_HasRequiredFields()
|
||||
{
|
||||
@@ -415,7 +464,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
root.TryGetProperty("value", out _).Should().BeTrue("Response must have 'value' field");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task MetricsResponse_Contract_HasRequiredFields()
|
||||
{
|
||||
@@ -431,7 +480,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
P99LatencyMs = 5.0,
|
||||
ValkeyCacheHealthy = true,
|
||||
PostgresRepositoryHealthy = true,
|
||||
CollectedAt = DateTimeOffset.UtcNow
|
||||
CollectedAt = FixedNow
|
||||
};
|
||||
|
||||
_mockService.Setup(s => s.GetMetricsAsync(It.IsAny<CancellationToken>()))
|
||||
@@ -457,7 +506,7 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
|
||||
private static ProvcacheEntry CreateTestEntry(string veriKey, bool expired = false)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = FixedNow;
|
||||
return new ProvcacheEntry
|
||||
{
|
||||
VeriKey = veriKey,
|
||||
@@ -484,8 +533,8 @@ public sealed class ProvcacheApiTests : IAsyncDisposable
|
||||
FeedIds = ["cve-nvd", "ghsa-2024"],
|
||||
RuleIds = ["base-policy"]
|
||||
},
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
CreatedAt = FixedNow,
|
||||
ExpiresAt = FixedNow.AddHours(1),
|
||||
TrustScore = 85
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Provcache.Entities;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Deterministic;
|
||||
namespace StellaOps.Provcache.Tests;
|
||||
|
||||
public sealed class RevocationLedgerTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryRevocationLedger _ledger;
|
||||
|
||||
public RevocationLedgerTests()
|
||||
@@ -16,7 +17,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task RecordAsync_AssignsSeqNo()
|
||||
{
|
||||
// Arrange
|
||||
@@ -32,7 +33,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task RecordAsync_AssignsIncrementingSeqNos()
|
||||
{
|
||||
// Arrange
|
||||
@@ -52,7 +53,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesSinceAsync_ReturnsEntriesAfterSeqNo()
|
||||
{
|
||||
// Arrange
|
||||
@@ -71,7 +72,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesSinceAsync_RespectsLimit()
|
||||
{
|
||||
// Arrange
|
||||
@@ -88,7 +89,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesByTypeAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -106,17 +107,17 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetEntriesByTypeAsync_FiltersBySinceTime()
|
||||
{
|
||||
// Arrange
|
||||
var oldEntry = CreateTestEntry(RevocationTypes.Signer, "s1") with
|
||||
{
|
||||
RevokedAt = DateTimeOffset.UtcNow.AddDays(-5)
|
||||
RevokedAt = FixedNow.AddDays(-5)
|
||||
};
|
||||
var newEntry = CreateTestEntry(RevocationTypes.Signer, "s2") with
|
||||
{
|
||||
RevokedAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
RevokedAt = FixedNow.AddDays(-1)
|
||||
};
|
||||
|
||||
await _ledger.RecordAsync(oldEntry);
|
||||
@@ -125,7 +126,7 @@ public sealed class RevocationLedgerTests
|
||||
// Act
|
||||
var entries = await _ledger.GetEntriesByTypeAsync(
|
||||
RevocationTypes.Signer,
|
||||
since: DateTimeOffset.UtcNow.AddDays(-2));
|
||||
since: FixedNow.AddDays(-2));
|
||||
|
||||
// Assert
|
||||
entries.Should().HaveCount(1);
|
||||
@@ -133,7 +134,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetLatestSeqNoAsync_ReturnsZeroWhenEmpty()
|
||||
{
|
||||
// Act
|
||||
@@ -144,7 +145,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetLatestSeqNoAsync_ReturnsLatest()
|
||||
{
|
||||
// Arrange
|
||||
@@ -160,7 +161,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetRevocationsForKeyAsync_ReturnsMatchingEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -177,7 +178,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetStatsAsync_ReturnsCorrectStats()
|
||||
{
|
||||
// Arrange
|
||||
@@ -200,7 +201,7 @@ public sealed class RevocationLedgerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -222,19 +223,45 @@ public sealed class RevocationLedgerTests
|
||||
{
|
||||
return new RevocationEntry
|
||||
{
|
||||
RevocationId = Guid.NewGuid(),
|
||||
RevocationId = CreateDeterministicGuid(revocationType, revokedKey),
|
||||
RevocationType = revocationType,
|
||||
RevokedKey = revokedKey,
|
||||
Reason = "Test revocation",
|
||||
EntriesInvalidated = invalidated,
|
||||
Source = "unit-test",
|
||||
RevokedAt = DateTimeOffset.UtcNow
|
||||
RevokedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicGuid(string revocationType, string revokedKey)
|
||||
{
|
||||
var seed = ComputeSeed(revocationType, revokedKey);
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
|
||||
private static int ComputeSeed(string revocationType, string revokedKey)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var seed = 17;
|
||||
foreach (var ch in revocationType)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
foreach (var ch in revokedKey)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RevocationReplayServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly InMemoryRevocationLedger _ledger;
|
||||
private readonly Mock<IProvcacheRepository> _repositoryMock;
|
||||
private readonly RevocationReplayService _replayService;
|
||||
@@ -250,7 +277,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_ReplaysAllEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -276,7 +303,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_StartsFromCheckpoint()
|
||||
{
|
||||
// Arrange
|
||||
@@ -297,7 +324,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_RespectsMaxEntries()
|
||||
{
|
||||
// Arrange
|
||||
@@ -319,7 +346,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task ReplayFromAsync_ReturnsEmptyWhenNoEntries()
|
||||
{
|
||||
// Act
|
||||
@@ -331,7 +358,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task GetCheckpointAsync_ReturnsZeroInitially()
|
||||
{
|
||||
// Act
|
||||
@@ -342,7 +369,7 @@ public sealed class RevocationReplayServiceTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task SaveCheckpointAsync_PersistsCheckpoint()
|
||||
{
|
||||
// Act
|
||||
@@ -357,13 +384,38 @@ public sealed class RevocationReplayServiceTests
|
||||
{
|
||||
return new RevocationEntry
|
||||
{
|
||||
RevocationId = Guid.NewGuid(),
|
||||
RevocationId = CreateDeterministicGuid(revocationType, revokedKey),
|
||||
RevocationType = revocationType,
|
||||
RevokedKey = revokedKey,
|
||||
Reason = "Test revocation",
|
||||
EntriesInvalidated = 0,
|
||||
Source = "unit-test",
|
||||
RevokedAt = DateTimeOffset.UtcNow
|
||||
RevokedAt = FixedNow
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicGuid(string revocationType, string revokedKey)
|
||||
{
|
||||
var seed = ComputeSeed(revocationType, revokedKey);
|
||||
return new DeterministicRandom(seed).NextGuid();
|
||||
}
|
||||
|
||||
private static int ComputeSeed(string revocationType, string revokedKey)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var seed = 17;
|
||||
foreach (var ch in revocationType)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
foreach (var ch in revokedKey)
|
||||
{
|
||||
seed = (seed * 31) + ch;
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user