fix tests. new product advisories enhancements

This commit is contained in:
master
2026-01-25 19:11:36 +02:00
parent c70e83719e
commit 6e687b523a
504 changed files with 40610 additions and 3785 deletions

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// ResilientRekorClient.cs
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
// Task: WORKFLOW-006 - Implement mirror failover
// Description: Resilient Rekor client with circuit breaker and mirror failover
// -----------------------------------------------------------------------------
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Resilience;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Core.Verification;
namespace StellaOps.Attestor.Infrastructure.Rekor;
/// <summary>
/// Resilient Rekor client with circuit breaker and automatic mirror failover.
/// </summary>
/// <remarks>
/// Flow:
/// 1. Try primary backend
/// 2. If primary circuit is OPEN and mirror is enabled, try mirror
/// 3. If primary fails and circuit is HALF_OPEN, mark failure and try mirror
/// 4. Track success/failure for circuit breaker state transitions
/// </remarks>
public sealed class ResilientRekorClient : IRekorClient, IDisposable
{
private readonly IRekorClient _innerClient;
private readonly IRekorBackendResolver _backendResolver;
private readonly CircuitBreaker _primaryCircuitBreaker;
private readonly CircuitBreaker? _mirrorCircuitBreaker;
private readonly AttestorOptions _options;
private readonly ILogger<ResilientRekorClient> _logger;
public ResilientRekorClient(
IRekorClient innerClient,
IRekorBackendResolver backendResolver,
IOptions<AttestorOptions> options,
ILogger<ResilientRekorClient> logger,
TimeProvider? timeProvider = null)
{
_innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient));
_backendResolver = backendResolver ?? throw new ArgumentNullException(nameof(backendResolver));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var cbOptions = MapCircuitBreakerOptions(_options.Rekor.CircuitBreaker);
var time = timeProvider ?? TimeProvider.System;
_primaryCircuitBreaker = new CircuitBreaker(
"rekor-primary",
cbOptions,
logger as ILogger<CircuitBreaker>,
time);
_primaryCircuitBreaker.StateChanged += OnPrimaryCircuitStateChanged;
// Create mirror circuit breaker if mirror is enabled
if (_options.Rekor.Mirror.Enabled)
{
_mirrorCircuitBreaker = new CircuitBreaker(
"rekor-mirror",
cbOptions,
logger as ILogger<CircuitBreaker>,
time);
_mirrorCircuitBreaker.StateChanged += OnMirrorCircuitStateChanged;
}
}
/// <summary>
/// Gets the current state of the primary circuit breaker.
/// </summary>
public CircuitState PrimaryCircuitState => _primaryCircuitBreaker.State;
/// <summary>
/// Gets the current state of the mirror circuit breaker.
/// </summary>
public CircuitState? MirrorCircuitState => _mirrorCircuitBreaker?.State;
/// <summary>
/// Gets whether requests are currently being routed to the mirror.
/// </summary>
public bool IsUsingMirror => _options.Rekor.Mirror.Enabled
&& _options.Rekor.CircuitBreaker.FailoverToMirrorWhenOpen
&& _primaryCircuitBreaker.State == CircuitState.Open
&& _mirrorCircuitBreaker?.State != CircuitState.Open;
/// <summary>
/// Raised when failover to mirror occurs.
/// </summary>
public event Action<string>? FailoverOccurred;
/// <summary>
/// Raised when failback to primary occurs.
/// </summary>
public event Action<string>? FailbackOccurred;
public async Task<RekorSubmissionResponse> SubmitAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
// Submissions always go to primary (or resolved backend)
// We don't submit to mirrors to avoid duplicates
return await ExecuteWithResilienceAsync(
async (b, ct) => await _innerClient.SubmitAsync(request, b, ct),
backend,
"Submit",
allowMirror: false, // Never submit to mirror
cancellationToken);
}
public async Task<RekorProofResponse?> GetProofAsync(
string rekorUuid,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
return await ExecuteWithResilienceAsync(
async (b, ct) => await _innerClient.GetProofAsync(rekorUuid, b, ct),
backend,
"GetProof",
allowMirror: true,
cancellationToken);
}
public async Task<RekorInclusionVerificationResult> VerifyInclusionAsync(
string rekorUuid,
byte[] payloadDigest,
RekorBackend backend,
CancellationToken cancellationToken = default)
{
return await ExecuteWithResilienceAsync(
async (b, ct) => await _innerClient.VerifyInclusionAsync(rekorUuid, payloadDigest, b, ct),
backend,
"VerifyInclusion",
allowMirror: true,
cancellationToken);
}
private async Task<T> ExecuteWithResilienceAsync<T>(
Func<RekorBackend, CancellationToken, Task<T>> operation,
RekorBackend requestedBackend,
string operationName,
bool allowMirror,
CancellationToken cancellationToken)
{
var cbOptions = _options.Rekor.CircuitBreaker;
// If circuit breaker is disabled, just execute directly
if (!cbOptions.Enabled)
{
return await operation(requestedBackend, cancellationToken);
}
// Check if we should use mirror due to primary circuit being open
if (allowMirror && ShouldUseMirror())
{
_logger.LogDebug(
"Primary circuit is OPEN, routing {Operation} to mirror",
operationName);
var mirrorBackend = await GetMirrorBackendAsync(cancellationToken);
if (mirrorBackend != null && _mirrorCircuitBreaker!.AllowRequest())
{
try
{
var result = await operation(mirrorBackend, cancellationToken);
_mirrorCircuitBreaker.RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
_mirrorCircuitBreaker.RecordFailure();
_logger.LogWarning(ex,
"Mirror {Operation} failed, no fallback available",
operationName);
throw;
}
}
}
// Try primary
if (_primaryCircuitBreaker.AllowRequest())
{
try
{
var result = await operation(requestedBackend, cancellationToken);
_primaryCircuitBreaker.RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
_primaryCircuitBreaker.RecordFailure();
// Try mirror on primary failure (if allowed and available)
if (allowMirror && cbOptions.FailoverToMirrorWhenOpen)
{
var mirrorBackend = await GetMirrorBackendAsync(cancellationToken);
if (mirrorBackend != null && _mirrorCircuitBreaker?.AllowRequest() == true)
{
_logger.LogWarning(ex,
"Primary {Operation} failed, failing over to mirror",
operationName);
try
{
var result = await operation(mirrorBackend, cancellationToken);
_mirrorCircuitBreaker.RecordSuccess();
OnFailover("immediate-failover");
return result;
}
catch (Exception mirrorEx) when (IsTransientException(mirrorEx))
{
_mirrorCircuitBreaker.RecordFailure();
_logger.LogWarning(mirrorEx,
"Mirror {Operation} also failed",
operationName);
}
}
}
throw;
}
}
// Primary circuit is open, check for mirror
if (allowMirror && cbOptions.FailoverToMirrorWhenOpen)
{
var mirrorBackend = await GetMirrorBackendAsync(cancellationToken);
if (mirrorBackend != null && _mirrorCircuitBreaker?.AllowRequest() == true)
{
_logger.LogDebug(
"Primary circuit OPEN, using mirror for {Operation}",
operationName);
try
{
var result = await operation(mirrorBackend, cancellationToken);
_mirrorCircuitBreaker.RecordSuccess();
return result;
}
catch (Exception ex) when (IsTransientException(ex))
{
_mirrorCircuitBreaker.RecordFailure();
throw;
}
}
}
throw new CircuitBreakerOpenException(
_primaryCircuitBreaker.Name,
_primaryCircuitBreaker.State);
}
private bool ShouldUseMirror()
{
return _options.Rekor.Mirror.Enabled
&& _options.Rekor.CircuitBreaker.FailoverToMirrorWhenOpen
&& _primaryCircuitBreaker.State == CircuitState.Open
&& _mirrorCircuitBreaker?.State != CircuitState.Open;
}
private async Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken)
{
if (!_options.Rekor.Mirror.Enabled)
{
return null;
}
return await _backendResolver.GetMirrorBackendAsync(cancellationToken);
}
private void OnPrimaryCircuitStateChanged(CircuitState oldState, CircuitState newState)
{
_logger.LogInformation(
"Primary Rekor circuit breaker: {OldState} -> {NewState}",
oldState, newState);
if (newState == CircuitState.Open && _options.Rekor.Mirror.Enabled)
{
OnFailover("circuit-open");
}
else if (oldState == CircuitState.Open && newState == CircuitState.Closed)
{
OnFailback("circuit-closed");
}
}
private void OnMirrorCircuitStateChanged(CircuitState oldState, CircuitState newState)
{
_logger.LogInformation(
"Mirror Rekor circuit breaker: {OldState} -> {NewState}",
oldState, newState);
}
private void OnFailover(string reason)
{
_logger.LogWarning(
"Rekor failover to mirror activated: {Reason}",
reason);
FailoverOccurred?.Invoke(reason);
}
private void OnFailback(string reason)
{
_logger.LogInformation(
"Rekor failback to primary activated: {Reason}",
reason);
FailbackOccurred?.Invoke(reason);
}
private static CircuitBreakerOptions MapCircuitBreakerOptions(
AttestorOptions.RekorCircuitBreakerOptions options)
{
return new CircuitBreakerOptions
{
Enabled = options.Enabled,
FailureThreshold = options.FailureThreshold,
SuccessThreshold = options.SuccessThreshold,
OpenDurationSeconds = options.OpenDurationSeconds,
FailureWindowSeconds = options.FailureWindowSeconds,
HalfOpenMaxRequests = options.HalfOpenMaxRequests,
UseCacheWhenOpen = options.UseCacheWhenOpen,
FailoverToMirrorWhenOpen = options.FailoverToMirrorWhenOpen
};
}
private static bool IsTransientException(Exception ex)
{
return ex is HttpRequestException
or TaskCanceledException
or TimeoutException
or OperationCanceledException;
}
/// <summary>
/// Resets both circuit breakers to closed state.
/// </summary>
public void Reset()
{
_primaryCircuitBreaker.Reset();
_mirrorCircuitBreaker?.Reset();
}
public void Dispose()
{
_primaryCircuitBreaker.StateChanged -= OnPrimaryCircuitStateChanged;
_primaryCircuitBreaker.Dispose();
if (_mirrorCircuitBreaker != null)
{
_mirrorCircuitBreaker.StateChanged -= OnMirrorCircuitStateChanged;
_mirrorCircuitBreaker.Dispose();
}
}
}

View File

@@ -0,0 +1,285 @@
// -----------------------------------------------------------------------------
// ServiceMapAwareRekorBackendResolver.cs
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
// Task: PROXY-007 - Integrate service map with HttpRekorClient
// Description: Resolves Rekor backends using TUF service map with configuration fallback
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.TrustRepo;
using StellaOps.Attestor.TrustRepo.Models;
namespace StellaOps.Attestor.Infrastructure.Rekor;
/// <summary>
/// Resolves Rekor backends using TUF service map for dynamic endpoint discovery,
/// with fallback to static configuration when service map is unavailable.
/// </summary>
internal sealed class ServiceMapAwareRekorBackendResolver : IRekorBackendResolver
{
private readonly ISigstoreServiceMapLoader _serviceMapLoader;
private readonly IOptions<AttestorOptions> _options;
private readonly ILogger<ServiceMapAwareRekorBackendResolver> _logger;
private readonly bool _serviceMapEnabled;
// Cached backend from service map
private RekorBackend? _cachedServiceMapBackend;
private DateTimeOffset? _cachedAt;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
private readonly SemaphoreSlim _cacheLock = new(1, 1);
public ServiceMapAwareRekorBackendResolver(
ISigstoreServiceMapLoader serviceMapLoader,
IOptions<AttestorOptions> options,
ILogger<ServiceMapAwareRekorBackendResolver> logger)
{
_serviceMapLoader = serviceMapLoader ?? throw new ArgumentNullException(nameof(serviceMapLoader));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Service map is enabled if TrustRepo is configured
_serviceMapEnabled = options.Value.TrustRepo?.Enabled ?? false;
}
/// <inheritdoc />
public bool IsServiceMapEnabled => _serviceMapEnabled;
/// <inheritdoc />
public async Task<RekorBackend> GetPrimaryBackendAsync(CancellationToken cancellationToken = default)
{
// Try service map first if enabled
if (_serviceMapEnabled)
{
var serviceMapBackend = await TryGetServiceMapBackendAsync(cancellationToken);
if (serviceMapBackend != null)
{
_logger.LogDebug("Using Rekor backend from TUF service map: {Url}", serviceMapBackend.Url);
return serviceMapBackend;
}
_logger.LogDebug("Service map unavailable, falling back to configuration");
}
// Fallback to configuration
return RekorBackendResolver.ResolveBackend(_options.Value, "primary", allowFallbackToPrimary: true);
}
/// <inheritdoc />
public Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken = default)
{
var opts = _options.Value;
if (!opts.Rekor.Mirror.Enabled || string.IsNullOrWhiteSpace(opts.Rekor.Mirror.Url))
{
return Task.FromResult<RekorBackend?>(null);
}
var mirror = RekorBackendResolver.ResolveBackend(opts, "mirror", allowFallbackToPrimary: false);
return Task.FromResult<RekorBackend?>(mirror);
}
/// <inheritdoc />
public async Task<RekorBackend> ResolveBackendAsync(string? backendName, CancellationToken cancellationToken = default)
{
var normalized = string.IsNullOrWhiteSpace(backendName)
? "primary"
: backendName.Trim().ToLowerInvariant();
if (normalized == "primary")
{
return await GetPrimaryBackendAsync(cancellationToken);
}
if (normalized == "mirror")
{
var mirror = await GetMirrorBackendAsync(cancellationToken);
if (mirror == null)
{
throw new InvalidOperationException("Mirror backend is not configured");
}
return mirror;
}
// Unknown backend name - try configuration fallback
return RekorBackendResolver.ResolveBackend(_options.Value, backendName, allowFallbackToPrimary: true);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RekorBackend>> GetAllBackendsAsync(CancellationToken cancellationToken = default)
{
var backends = new List<RekorBackend>();
// Add primary
backends.Add(await GetPrimaryBackendAsync(cancellationToken));
// Add mirror if configured
var mirror = await GetMirrorBackendAsync(cancellationToken);
if (mirror != null)
{
backends.Add(mirror);
}
return backends;
}
/// <summary>
/// Attempts to get Rekor backend from TUF service map.
/// </summary>
private async Task<RekorBackend?> TryGetServiceMapBackendAsync(CancellationToken cancellationToken)
{
// Check cache first
if (_cachedServiceMapBackend != null && _cachedAt != null)
{
var age = DateTimeOffset.UtcNow - _cachedAt.Value;
if (age < _cacheDuration)
{
return _cachedServiceMapBackend;
}
}
await _cacheLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
if (_cachedServiceMapBackend != null && _cachedAt != null)
{
var age = DateTimeOffset.UtcNow - _cachedAt.Value;
if (age < _cacheDuration)
{
return _cachedServiceMapBackend;
}
}
return await LoadFromServiceMapAsync(cancellationToken);
}
finally
{
_cacheLock.Release();
}
}
/// <summary>
/// Loads Rekor backend from service map.
/// </summary>
private async Task<RekorBackend?> LoadFromServiceMapAsync(CancellationToken cancellationToken)
{
try
{
var serviceMap = await _serviceMapLoader.GetServiceMapAsync(cancellationToken);
if (serviceMap?.Rekor == null || string.IsNullOrEmpty(serviceMap.Rekor.Url))
{
_logger.LogDebug("Service map does not contain Rekor configuration");
return null;
}
var rekor = serviceMap.Rekor;
var opts = _options.Value;
// Build backend from service map, using config for non-mapped settings
var backend = new RekorBackend
{
Name = "primary-servicemap",
Url = new Uri(rekor.Url, UriKind.Absolute),
Version = ParseLogVersion(opts.Rekor.Primary.Version),
TileBaseUrl = !string.IsNullOrEmpty(rekor.TileBaseUrl)
? new Uri(rekor.TileBaseUrl, UriKind.Absolute)
: null,
LogId = !string.IsNullOrEmpty(rekor.LogId)
? rekor.LogId
: opts.Rekor.Primary.LogId,
ProofTimeout = TimeSpan.FromMilliseconds(opts.Rekor.Primary.ProofTimeoutMs),
PollInterval = TimeSpan.FromMilliseconds(opts.Rekor.Primary.PollIntervalMs),
MaxAttempts = opts.Rekor.Primary.MaxAttempts
};
_cachedServiceMapBackend = backend;
_cachedAt = DateTimeOffset.UtcNow;
_logger.LogInformation(
"Loaded Rekor endpoint from TUF service map v{Version}: {Url}",
serviceMap.Version,
backend.Url);
return backend;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Rekor backend from service map");
return null;
}
}
/// <summary>
/// Parses the log version string to the enum value.
/// </summary>
private static RekorLogVersion ParseLogVersion(string? version)
{
if (string.IsNullOrWhiteSpace(version))
{
return RekorLogVersion.Auto;
}
return version.Trim().ToUpperInvariant() switch
{
"AUTO" => RekorLogVersion.Auto,
"V2" or "2" => RekorLogVersion.V2,
_ => RekorLogVersion.Auto
};
}
}
/// <summary>
/// Simple resolver that uses only static configuration (no service map).
/// </summary>
internal sealed class ConfiguredRekorBackendResolver : IRekorBackendResolver
{
private readonly IOptions<AttestorOptions> _options;
public ConfiguredRekorBackendResolver(IOptions<AttestorOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool IsServiceMapEnabled => false;
public Task<RekorBackend> GetPrimaryBackendAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(RekorBackendResolver.ResolveBackend(_options.Value, "primary", true));
}
public Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken = default)
{
var opts = _options.Value;
if (!opts.Rekor.Mirror.Enabled || string.IsNullOrWhiteSpace(opts.Rekor.Mirror.Url))
{
return Task.FromResult<RekorBackend?>(null);
}
var mirror = RekorBackendResolver.ResolveBackend(opts, "mirror", false);
return Task.FromResult<RekorBackend?>(mirror);
}
public Task<RekorBackend> ResolveBackendAsync(string? backendName, CancellationToken cancellationToken = default)
{
return Task.FromResult(RekorBackendResolver.ResolveBackend(_options.Value, backendName, true));
}
public async Task<IReadOnlyList<RekorBackend>> GetAllBackendsAsync(CancellationToken cancellationToken = default)
{
var backends = new List<RekorBackend>
{
await GetPrimaryBackendAsync(cancellationToken)
};
var mirror = await GetMirrorBackendAsync(cancellationToken);
if (mirror != null)
{
backends.Add(mirror);
}
return backends;
}
}

View File

@@ -30,6 +30,7 @@ using StellaOps.Attestor.Core.InToto;
using StellaOps.Attestor.Core.InToto.Layout;
using StellaOps.Attestor.Infrastructure.InToto;
using StellaOps.Attestor.Verify;
using StellaOps.Attestor.TrustRepo;
using StellaOps.Determinism;
namespace StellaOps.Attestor.Infrastructure;
@@ -96,6 +97,27 @@ public static class ServiceCollectionExtensions
});
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
// Register Rekor backend resolver with service map support
// Sprint: SPRINT_20260125_002 - PROXY-007
services.AddSingleton<IRekorBackendResolver>(sp =>
{
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
// If TrustRepo integration is enabled, use service map-aware resolver
if (options.TrustRepo?.Enabled == true)
{
var serviceMapLoader = sp.GetRequiredService<ISigstoreServiceMapLoader>();
var logger = sp.GetRequiredService<ILogger<ServiceMapAwareRekorBackendResolver>>();
return new ServiceMapAwareRekorBackendResolver(
serviceMapLoader,
sp.GetRequiredService<IOptions<AttestorOptions>>(),
logger);
}
// Otherwise, use static configuration resolver
return new ConfiguredRekorBackendResolver(sp.GetRequiredService<IOptions<AttestorOptions>>());
});
// Rekor v2 tile-based client for Sunlight/tile log format
services.AddHttpClient<HttpRekorTileClient>((sp, client) =>
{

View File

@@ -15,6 +15,7 @@
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.TrustRepo\StellaOps.Attestor.TrustRepo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />