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

@@ -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; }
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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
}

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" />