fix tests. new product advisories enhancements
This commit is contained in:
@@ -38,6 +38,12 @@ public sealed class AttestorOptions
|
||||
/// </summary>
|
||||
public TimeSkewOptions TimeSkew { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TrustRepo (TUF-based trust distribution) options.
|
||||
/// Sprint: SPRINT_20260125_002 - PROXY-007
|
||||
/// </summary>
|
||||
public TrustRepoIntegrationOptions? TrustRepo { get; set; }
|
||||
|
||||
|
||||
public sealed class SecurityOptions
|
||||
{
|
||||
@@ -110,6 +116,59 @@ public sealed class AttestorOptions
|
||||
public RekorBackendOptions Primary { get; set; } = new();
|
||||
|
||||
public RekorMirrorOptions Mirror { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker options for resilient Rekor calls.
|
||||
/// Sprint: SPRINT_20260125_003 - WORKFLOW-006
|
||||
/// </summary>
|
||||
public RekorCircuitBreakerOptions CircuitBreaker { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker configuration for Rekor client.
|
||||
/// Sprint: SPRINT_20260125_003 - WORKFLOW-006
|
||||
/// </summary>
|
||||
public sealed class RekorCircuitBreakerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the circuit breaker is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of failures before opening the circuit.
|
||||
/// </summary>
|
||||
public int FailureThreshold { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Number of successes required to close from half-open state.
|
||||
/// </summary>
|
||||
public int SuccessThreshold { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Duration in seconds the circuit stays open.
|
||||
/// </summary>
|
||||
public int OpenDurationSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Time window in seconds for counting failures.
|
||||
/// </summary>
|
||||
public int FailureWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum requests allowed in half-open state.
|
||||
/// </summary>
|
||||
public int HalfOpenMaxRequests { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Use cached data when circuit is open.
|
||||
/// </summary>
|
||||
public bool UseCacheWhenOpen { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Failover to mirror when primary circuit is open.
|
||||
/// </summary>
|
||||
public bool FailoverToMirrorWhenOpen { get; set; } = true;
|
||||
}
|
||||
|
||||
public class RekorBackendOptions
|
||||
@@ -324,4 +383,48 @@ public sealed class AttestorOptions
|
||||
|
||||
public IList<string> CertificateChain { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TrustRepo integration options for TUF-based trust distribution.
|
||||
/// Sprint: SPRINT_20260125_002 - PROXY-007
|
||||
/// </summary>
|
||||
public sealed class TrustRepoIntegrationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable TUF-based service map discovery for Rekor endpoints.
|
||||
/// When enabled, Rekor URLs can be dynamically updated via TUF.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TUF repository URL for trust metadata.
|
||||
/// </summary>
|
||||
public string? TufRepositoryUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local cache path for TUF metadata.
|
||||
/// </summary>
|
||||
public string? LocalCachePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target name for the Sigstore service map.
|
||||
/// Default: sigstore-services-v1.json
|
||||
/// </summary>
|
||||
public string ServiceMapTarget { get; set; } = "sigstore-services-v1.json";
|
||||
|
||||
/// <summary>
|
||||
/// Environment name for service map overrides.
|
||||
/// </summary>
|
||||
public string? Environment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Refresh interval for TUF metadata.
|
||||
/// </summary>
|
||||
public int RefreshIntervalMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Enable offline mode (no network calls).
|
||||
/// </summary>
|
||||
public bool OfflineMode { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IRekorBackendResolver.cs
|
||||
// Sprint: SPRINT_20260125_002_Attestor_trust_automation
|
||||
// Task: PROXY-007 - Integrate service map with HttpRekorClient
|
||||
// Description: Interface for resolving Rekor backends with service map support
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Core.Rekor;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves Rekor backend configuration from various sources.
|
||||
/// </summary>
|
||||
public interface IRekorBackendResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the primary Rekor backend.
|
||||
/// May use TUF service map for dynamic endpoint discovery.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Primary Rekor backend configuration.</returns>
|
||||
Task<RekorBackend> GetPrimaryBackendAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the mirror Rekor backend, if configured.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Mirror Rekor backend, or null if not configured.</returns>
|
||||
Task<RekorBackend?> GetMirrorBackendAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a named Rekor backend.
|
||||
/// </summary>
|
||||
/// <param name="backendName">Backend name (primary, mirror, or custom).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Resolved Rekor backend.</returns>
|
||||
Task<RekorBackend> ResolveBackendAsync(string? backendName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available backends.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of available backends.</returns>
|
||||
Task<IReadOnlyList<RekorBackend>> GetAllBackendsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether service map-based discovery is available and enabled.
|
||||
/// </summary>
|
||||
bool IsServiceMapEnabled { get; }
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CircuitBreaker.cs
|
||||
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
|
||||
// Task: WORKFLOW-005 - Implement circuit breaker for Rekor client
|
||||
// Description: Circuit breaker implementation for resilient service calls
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker for protecting against cascading failures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State transitions:
|
||||
/// <code>
|
||||
/// CLOSED → (failures exceed threshold) → OPEN
|
||||
/// OPEN → (after timeout) → HALF_OPEN
|
||||
/// HALF_OPEN → (success threshold met) → CLOSED
|
||||
/// HALF_OPEN → (failure) → OPEN
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class CircuitBreaker : IDisposable
|
||||
{
|
||||
private readonly CircuitBreakerOptions _options;
|
||||
private readonly ILogger<CircuitBreaker>? _logger;
|
||||
private readonly string _name;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private CircuitState _state = CircuitState.Closed;
|
||||
private readonly object _stateLock = new();
|
||||
|
||||
private readonly ConcurrentQueue<DateTimeOffset> _failureTimestamps = new();
|
||||
private int _consecutiveSuccesses;
|
||||
private int _halfOpenRequests;
|
||||
private DateTimeOffset? _openedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when circuit state changes.
|
||||
/// </summary>
|
||||
public event Action<CircuitState, CircuitState>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new circuit breaker.
|
||||
/// </summary>
|
||||
public CircuitBreaker(
|
||||
string name,
|
||||
CircuitBreakerOptions options,
|
||||
ILogger<CircuitBreaker>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current circuit state.
|
||||
/// </summary>
|
||||
public CircuitState State
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
// Check if we should transition from Open to HalfOpen
|
||||
if (_state == CircuitState.Open && ShouldTransitionToHalfOpen())
|
||||
{
|
||||
TransitionTo(CircuitState.HalfOpen);
|
||||
}
|
||||
return _state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the circuit breaker name.
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a request is allowed through the circuit.
|
||||
/// </summary>
|
||||
/// <returns>True if request can proceed, false if circuit is open.</returns>
|
||||
public bool AllowRequest()
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
var currentState = State; // This may trigger Open→HalfOpen transition
|
||||
|
||||
switch (currentState)
|
||||
{
|
||||
case CircuitState.Closed:
|
||||
return true;
|
||||
|
||||
case CircuitState.Open:
|
||||
_logger?.LogDebug(
|
||||
"Circuit {Name} is OPEN, rejecting request",
|
||||
_name);
|
||||
return false;
|
||||
|
||||
case CircuitState.HalfOpen:
|
||||
if (_halfOpenRequests < _options.HalfOpenMaxRequests)
|
||||
{
|
||||
_halfOpenRequests++;
|
||||
_logger?.LogDebug(
|
||||
"Circuit {Name} is HALF-OPEN, allowing probe request ({Count}/{Max})",
|
||||
_name, _halfOpenRequests, _options.HalfOpenMaxRequests);
|
||||
return true;
|
||||
}
|
||||
_logger?.LogDebug(
|
||||
"Circuit {Name} is HALF-OPEN but max probes reached, rejecting request",
|
||||
_name);
|
||||
return false;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful request.
|
||||
/// </summary>
|
||||
public void RecordSuccess()
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
switch (_state)
|
||||
{
|
||||
case CircuitState.Closed:
|
||||
// Clear failure history on success
|
||||
while (_failureTimestamps.TryDequeue(out _)) { }
|
||||
break;
|
||||
|
||||
case CircuitState.HalfOpen:
|
||||
_consecutiveSuccesses++;
|
||||
_logger?.LogDebug(
|
||||
"Circuit {Name} recorded success in HALF-OPEN ({Count}/{Threshold})",
|
||||
_name, _consecutiveSuccesses, _options.SuccessThreshold);
|
||||
|
||||
if (_consecutiveSuccesses >= _options.SuccessThreshold)
|
||||
{
|
||||
TransitionTo(CircuitState.Closed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a failed request.
|
||||
/// </summary>
|
||||
public void RecordFailure()
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
switch (_state)
|
||||
{
|
||||
case CircuitState.Closed:
|
||||
_failureTimestamps.Enqueue(now);
|
||||
CleanupOldFailures(now);
|
||||
|
||||
var failureCount = _failureTimestamps.Count;
|
||||
_logger?.LogDebug(
|
||||
"Circuit {Name} recorded failure ({Count}/{Threshold})",
|
||||
_name, failureCount, _options.FailureThreshold);
|
||||
|
||||
if (failureCount >= _options.FailureThreshold)
|
||||
{
|
||||
TransitionTo(CircuitState.Open);
|
||||
}
|
||||
break;
|
||||
|
||||
case CircuitState.HalfOpen:
|
||||
_logger?.LogDebug(
|
||||
"Circuit {Name} recorded failure in HALF-OPEN, reopening",
|
||||
_name);
|
||||
TransitionTo(CircuitState.Open);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an action with circuit breaker protection.
|
||||
/// </summary>
|
||||
public async Task<T> ExecuteAsync<T>(
|
||||
Func<CancellationToken, Task<T>> action,
|
||||
Func<CancellationToken, Task<T>>? fallback = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!AllowRequest())
|
||||
{
|
||||
if (fallback != null)
|
||||
{
|
||||
_logger?.LogDebug("Circuit {Name} using fallback", _name);
|
||||
return await fallback(cancellationToken);
|
||||
}
|
||||
|
||||
throw new CircuitBreakerOpenException(_name, _state);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await action(cancellationToken);
|
||||
RecordSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (IsTransientException(ex))
|
||||
{
|
||||
RecordFailure();
|
||||
|
||||
if (fallback != null && _state == CircuitState.Open)
|
||||
{
|
||||
_logger?.LogDebug(ex, "Circuit {Name} action failed, using fallback", _name);
|
||||
return await fallback(cancellationToken);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an action with circuit breaker protection.
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync(
|
||||
Func<CancellationToken, Task> action,
|
||||
Func<CancellationToken, Task>? fallback = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
async ct =>
|
||||
{
|
||||
await action(ct);
|
||||
return true;
|
||||
},
|
||||
fallback != null
|
||||
? async ct =>
|
||||
{
|
||||
await fallback(ct);
|
||||
return true;
|
||||
}
|
||||
: null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually resets the circuit to closed state.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
TransitionTo(CircuitState.Closed);
|
||||
while (_failureTimestamps.TryDequeue(out _)) { }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionTo(CircuitState newState)
|
||||
{
|
||||
var oldState = _state;
|
||||
if (oldState == newState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = newState;
|
||||
|
||||
switch (newState)
|
||||
{
|
||||
case CircuitState.Closed:
|
||||
_consecutiveSuccesses = 0;
|
||||
_halfOpenRequests = 0;
|
||||
_openedAt = null;
|
||||
while (_failureTimestamps.TryDequeue(out _)) { }
|
||||
break;
|
||||
|
||||
case CircuitState.Open:
|
||||
_openedAt = _timeProvider.GetUtcNow();
|
||||
_consecutiveSuccesses = 0;
|
||||
_halfOpenRequests = 0;
|
||||
break;
|
||||
|
||||
case CircuitState.HalfOpen:
|
||||
_consecutiveSuccesses = 0;
|
||||
_halfOpenRequests = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Circuit {Name} transitioned from {OldState} to {NewState}",
|
||||
_name, oldState, newState);
|
||||
|
||||
StateChanged?.Invoke(oldState, newState);
|
||||
}
|
||||
|
||||
private bool ShouldTransitionToHalfOpen()
|
||||
{
|
||||
if (_state != CircuitState.Open || !_openedAt.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow() - _openedAt.Value;
|
||||
return elapsed.TotalSeconds >= _options.OpenDurationSeconds;
|
||||
}
|
||||
|
||||
private void CleanupOldFailures(DateTimeOffset now)
|
||||
{
|
||||
var cutoff = now.AddSeconds(-_options.FailureWindowSeconds);
|
||||
|
||||
while (_failureTimestamps.TryPeek(out var oldest) && oldest < cutoff)
|
||||
{
|
||||
_failureTimestamps.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransientException(Exception ex)
|
||||
{
|
||||
return ex is HttpRequestException
|
||||
or TaskCanceledException
|
||||
or TimeoutException
|
||||
or OperationCanceledException;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose, but implement for future resource cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when circuit breaker is open.
|
||||
/// </summary>
|
||||
public sealed class CircuitBreakerOpenException : Exception
|
||||
{
|
||||
public string CircuitName { get; }
|
||||
public CircuitState State { get; }
|
||||
|
||||
public CircuitBreakerOpenException(string circuitName, CircuitState state)
|
||||
: base($"Circuit breaker '{circuitName}' is {state}, request rejected")
|
||||
{
|
||||
CircuitName = circuitName;
|
||||
State = state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CircuitBreakerOptions.cs
|
||||
// Sprint: SPRINT_20260125_003_Attestor_trust_workflows_conformance
|
||||
// Task: WORKFLOW-005 - Implement circuit breaker for Rekor client
|
||||
// Description: Configuration options for circuit breaker pattern
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Core.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the circuit breaker pattern.
|
||||
/// </summary>
|
||||
public sealed record CircuitBreakerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the circuit breaker is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before opening the circuit.
|
||||
/// </summary>
|
||||
public int FailureThreshold { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Number of successful requests required to close the circuit from half-open state.
|
||||
/// </summary>
|
||||
public int SuccessThreshold { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Duration in seconds the circuit stays open before transitioning to half-open.
|
||||
/// </summary>
|
||||
public int OpenDurationSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Time window in seconds for counting failures.
|
||||
/// Failures outside this window are not counted.
|
||||
/// </summary>
|
||||
public int FailureWindowSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of requests allowed through in half-open state.
|
||||
/// </summary>
|
||||
public int HalfOpenMaxRequests { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use cached data when circuit is open.
|
||||
/// </summary>
|
||||
public bool UseCacheWhenOpen { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to attempt failover to mirror when circuit is open.
|
||||
/// </summary>
|
||||
public bool FailoverToMirrorWhenOpen { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Circuit breaker state.
|
||||
/// </summary>
|
||||
public enum CircuitState
|
||||
{
|
||||
/// <summary>
|
||||
/// Circuit is closed, requests flow normally.
|
||||
/// </summary>
|
||||
Closed,
|
||||
|
||||
/// <summary>
|
||||
/// Circuit is open, requests fail fast.
|
||||
/// </summary>
|
||||
Open,
|
||||
|
||||
/// <summary>
|
||||
/// Circuit is testing if backend has recovered.
|
||||
/// </summary>
|
||||
HalfOpen
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user