Add unit and integration tests for VexCandidateEmitter and SmartDiff repositories

- Implemented comprehensive unit tests for VexCandidateEmitter to validate candidate emission logic based on various scenarios including absent and present APIs, confidence thresholds, and rate limiting.
- Added integration tests for SmartDiff PostgreSQL repositories, covering snapshot storage and retrieval, candidate storage, and material risk change handling.
- Ensured tests validate correct behavior for storing, retrieving, and querying snapshots and candidates, including edge cases and expected outcomes.
This commit is contained in:
master
2025-12-16 18:44:25 +02:00
parent 2170a58734
commit 3a2100aa78
126 changed files with 15776 additions and 542 deletions

View File

@@ -17,7 +17,7 @@ Operate the StellaOps Attestor service: accept signed DSSE envelopes from the Si
## Key Directories
- `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/` — Minimal API host and HTTP surface.
- `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Core/` — Domain contracts, submission/verification pipelines.
- `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/`Mongo, Redis, Rekor, and archival implementations.
- `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/`PostgreSQL, Redis, Rekor, and archival implementations.
- `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Tests/` — Unit and integration tests.
---

View File

@@ -37,6 +37,29 @@ public sealed class AttestorMetrics : IDisposable
RekorOfflineVerifyTotal = _meter.CreateCounter<long>("attestor.rekor_offline_verify_total", description: "Rekor offline mode verification attempts grouped by result.");
RekorCheckpointCacheHits = _meter.CreateCounter<long>("attestor.rekor_checkpoint_cache_hits", description: "Rekor checkpoint cache hits.");
RekorCheckpointCacheMisses = _meter.CreateCounter<long>("attestor.rekor_checkpoint_cache_misses", description: "Rekor checkpoint cache misses.");
// SPRINT_3000_0001_0002 - Rekor retry queue metrics
RekorQueueDepth = _meter.CreateObservableGauge("attestor.rekor_queue_depth",
() => _queueDepthCallback?.Invoke() ?? 0,
description: "Current Rekor queue depth (pending + retrying items).");
RekorRetryAttemptsTotal = _meter.CreateCounter<long>("attestor.rekor_retry_attempts_total", description: "Total Rekor retry attempts grouped by backend and attempt number.");
RekorSubmissionStatusTotal = _meter.CreateCounter<long>("attestor.rekor_submission_status_total", description: "Total Rekor submission status changes grouped by status and backend.");
RekorQueueWaitTime = _meter.CreateHistogram<double>("attestor.rekor_queue_wait_seconds", unit: "s", description: "Time items spend waiting in the Rekor queue in seconds.");
RekorDeadLetterTotal = _meter.CreateCounter<long>("attestor.rekor_dead_letter_total", description: "Total dead letter items grouped by backend.");
// SPRINT_3000_0001_0003 - Time skew validation metrics
TimeSkewDetectedTotal = _meter.CreateCounter<long>("attestor.time_skew_detected_total", description: "Time skew anomalies detected grouped by severity and action.");
TimeSkewSeconds = _meter.CreateHistogram<double>("attestor.time_skew_seconds", unit: "s", description: "Distribution of time skew values in seconds.");
}
private Func<int>? _queueDepthCallback;
/// <summary>
/// Register a callback to provide the current queue depth.
/// </summary>
public void RegisterQueueDepthCallback(Func<int> callback)
{
_queueDepthCallback = callback;
}
public Counter<long> SubmitTotal { get; }
@@ -107,6 +130,43 @@ public sealed class AttestorMetrics : IDisposable
/// </summary>
public Counter<long> RekorCheckpointCacheMisses { get; }
// SPRINT_3000_0001_0002 - Rekor retry queue metrics
/// <summary>
/// Current Rekor queue depth (pending + retrying items).
/// </summary>
public ObservableGauge<int> RekorQueueDepth { get; }
/// <summary>
/// Total Rekor retry attempts grouped by backend and attempt number.
/// </summary>
public Counter<long> RekorRetryAttemptsTotal { get; }
/// <summary>
/// Total Rekor submission status changes grouped by status and backend.
/// </summary>
public Counter<long> RekorSubmissionStatusTotal { get; }
/// <summary>
/// Time items spend waiting in the Rekor queue in seconds.
/// </summary>
public Histogram<double> RekorQueueWaitTime { get; }
/// <summary>
/// Total dead letter items grouped by backend.
/// </summary>
public Counter<long> RekorDeadLetterTotal { get; }
// SPRINT_3000_0001_0003 - Time skew validation metrics
/// <summary>
/// Time skew anomalies detected grouped by severity and action.
/// </summary>
public Counter<long> TimeSkewDetectedTotal { get; }
/// <summary>
/// Distribution of time skew values in seconds.
/// </summary>
public Histogram<double> TimeSkewSeconds { get; }
public void Dispose()
{
if (_disposed)

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using StellaOps.Attestor.Core.Verification;
using StellaOps.Cryptography;
namespace StellaOps.Attestor.Core.Options;
@@ -32,6 +33,11 @@ public sealed class AttestorOptions
public TransparencyWitnessOptions TransparencyWitness { get; set; } = new();
public VerificationOptions Verification { get; set; } = new();
/// <summary>
/// Time skew validation options per SPRINT_3000_0001_0003.
/// </summary>
public TimeSkewOptions TimeSkew { get; set; } = new();
public sealed class SecurityOptions
{

View File

@@ -0,0 +1,114 @@
// -----------------------------------------------------------------------------
// IRekorSubmissionQueue.cs
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
// Task: T3
// Description: Interface for the Rekor submission queue
// -----------------------------------------------------------------------------
namespace StellaOps.Attestor.Core.Queue;
/// <summary>
/// Interface for the durable Rekor submission queue.
/// </summary>
public interface IRekorSubmissionQueue
{
/// <summary>
/// Enqueue a DSSE envelope for Rekor submission.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="bundleSha256">SHA-256 hash of the bundle being attested.</param>
/// <param name="dssePayload">Serialized DSSE envelope payload.</param>
/// <param name="backend">Target Rekor backend ('primary' or 'mirror').</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The ID of the created queue item.</returns>
Task<Guid> EnqueueAsync(
string tenantId,
string bundleSha256,
byte[] dssePayload,
string backend,
CancellationToken cancellationToken = default);
/// <summary>
/// Dequeue items ready for submission/retry.
/// Items are atomically transitioned to Submitting status.
/// </summary>
/// <param name="batchSize">Maximum number of items to dequeue.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of items ready for processing.</returns>
Task<IReadOnlyList<RekorQueueItem>> DequeueAsync(
int batchSize,
CancellationToken cancellationToken = default);
/// <summary>
/// Mark item as successfully submitted.
/// </summary>
/// <param name="id">Queue item ID.</param>
/// <param name="rekorUuid">UUID from Rekor.</param>
/// <param name="logIndex">Log index from Rekor.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task MarkSubmittedAsync(
Guid id,
string rekorUuid,
long? logIndex,
CancellationToken cancellationToken = default);
/// <summary>
/// Mark item for retry with exponential backoff.
/// </summary>
/// <param name="id">Queue item ID.</param>
/// <param name="error">Error message from the failed attempt.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task MarkRetryAsync(
Guid id,
string error,
CancellationToken cancellationToken = default);
/// <summary>
/// Move item to dead letter after max retries.
/// </summary>
/// <param name="id">Queue item ID.</param>
/// <param name="error">Error message from the final failed attempt.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task MarkDeadLetterAsync(
Guid id,
string error,
CancellationToken cancellationToken = default);
/// <summary>
/// Get item by ID.
/// </summary>
/// <param name="id">Queue item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The queue item, or null if not found.</returns>
Task<RekorQueueItem?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Get current queue depth by status.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Snapshot of queue depth.</returns>
Task<QueueDepthSnapshot> GetQueueDepthAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Purge dead letter items older than the retention period.
/// </summary>
/// <param name="retentionDays">Items older than this are purged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of items purged.</returns>
Task<int> PurgeDeadLetterAsync(
int retentionDays,
CancellationToken cancellationToken = default);
/// <summary>
/// Re-enqueue a dead letter item for retry.
/// </summary>
/// <param name="id">Queue item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the item was re-enqueued.</returns>
Task<bool> RequeueDeadLetterAsync(
Guid id,
CancellationToken cancellationToken = default);
}

View File

@@ -10,34 +10,47 @@ namespace StellaOps.Attestor.Core.Queue;
/// <summary>
/// Represents an item in the Rekor submission queue.
/// </summary>
/// <param name="Id">Unique identifier for the queue item.</param>
/// <param name="TenantId">Tenant identifier.</param>
/// <param name="BundleSha256">SHA-256 hash of the bundle being attested.</param>
/// <param name="DssePayload">Serialized DSSE envelope payload.</param>
/// <param name="Backend">Target Rekor backend ('primary' or 'mirror').</param>
/// <param name="Status">Current submission status.</param>
/// <param name="AttemptCount">Number of submission attempts made.</param>
/// <param name="MaxAttempts">Maximum allowed attempts before dead-lettering.</param>
/// <param name="LastAttemptAt">Timestamp of the last submission attempt.</param>
/// <param name="LastError">Error message from the last failed attempt.</param>
/// <param name="NextRetryAt">Scheduled time for the next retry attempt.</param>
/// <param name="RekorUuid">UUID from Rekor after successful submission.</param>
/// <param name="RekorLogIndex">Log index from Rekor after successful submission.</param>
/// <param name="CreatedAt">Timestamp when the item was created.</param>
/// <param name="UpdatedAt">Timestamp when the item was last updated.</param>
public sealed record RekorQueueItem(
Guid Id,
string TenantId,
string BundleSha256,
byte[] DssePayload,
string Backend,
RekorSubmissionStatus Status,
int AttemptCount,
int MaxAttempts,
DateTimeOffset? LastAttemptAt,
string? LastError,
DateTimeOffset? NextRetryAt,
string? RekorUuid,
long? RekorLogIndex,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed class RekorQueueItem
{
/// <summary>Unique identifier for the queue item.</summary>
public required Guid Id { get; init; }
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>SHA-256 hash of the bundle being attested.</summary>
public required string BundleSha256 { get; init; }
/// <summary>Serialized DSSE envelope payload.</summary>
public required byte[] DssePayload { get; init; }
/// <summary>Target Rekor backend ('primary' or 'mirror').</summary>
public required string Backend { get; init; }
/// <summary>Current submission status.</summary>
public required RekorSubmissionStatus Status { get; init; }
/// <summary>Number of submission attempts made.</summary>
public required int AttemptCount { get; init; }
/// <summary>Maximum allowed attempts before dead-lettering.</summary>
public required int MaxAttempts { get; init; }
/// <summary>Scheduled time for the next retry attempt.</summary>
public DateTimeOffset? NextRetryAt { get; init; }
/// <summary>Timestamp when the item was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Timestamp when the item was last updated.</summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>Error message from the last failed attempt.</summary>
public string? LastError { get; init; }
/// <summary>UUID from Rekor after successful submission.</summary>
public string? RekorUuid { get; init; }
/// <summary>Log index from Rekor after successful submission.</summary>
public long? RekorIndex { get; init; }
}

View File

@@ -92,6 +92,20 @@ public sealed class AttestorEntry
public string Url { get; init; } = string.Empty;
public string? LogId { get; init; }
/// <summary>
/// Unix timestamp when entry was integrated into the Rekor log.
/// Used for time skew validation (SPRINT_3000_0001_0003).
/// </summary>
public long? IntegratedTime { get; init; }
/// <summary>
/// Gets the integrated time as UTC DateTimeOffset.
/// </summary>
public DateTimeOffset? IntegratedTimeUtc =>
IntegratedTime.HasValue
? DateTimeOffset.FromUnixTimeSeconds(IntegratedTime.Value)
: null;
}
public sealed class SignerIdentityDescriptor

View File

@@ -0,0 +1,102 @@
// -----------------------------------------------------------------------------
// InstrumentedTimeSkewValidator.cs
// Sprint: SPRINT_3000_0001_0003_rekor_time_skew_validation
// Task: T7, T8
// Description: Time skew validator with metrics and structured logging
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Observability;
namespace StellaOps.Attestor.Core.Verification;
/// <summary>
/// Time skew validator with integrated metrics and structured logging.
/// Wraps the base TimeSkewValidator with observability.
/// </summary>
public sealed class InstrumentedTimeSkewValidator : ITimeSkewValidator
{
private readonly TimeSkewValidator _inner;
private readonly AttestorMetrics _metrics;
private readonly ILogger<InstrumentedTimeSkewValidator> _logger;
public InstrumentedTimeSkewValidator(
TimeSkewOptions options,
AttestorMetrics metrics,
ILogger<InstrumentedTimeSkewValidator> logger)
{
_inner = new TimeSkewValidator(options ?? throw new ArgumentNullException(nameof(options)));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public TimeSkewValidationResult Validate(DateTimeOffset? integratedTime, DateTimeOffset? localTime = null)
{
var result = _inner.Validate(integratedTime, localTime);
// Record skew distribution for all validations (except skipped)
if (result.Status != TimeSkewStatus.Skipped)
{
_metrics.TimeSkewSeconds.Record(Math.Abs(result.SkewSeconds));
}
// Record anomalies and log structured events
switch (result.Status)
{
case TimeSkewStatus.Warning:
_metrics.TimeSkewDetectedTotal.Add(1,
new("severity", "warning"),
new("action", "warned"));
_logger.LogWarning(
"Time skew warning detected: IntegratedTime={IntegratedTime:O}, LocalTime={LocalTime:O}, SkewSeconds={SkewSeconds:F1}, Status={Status}",
result.IntegratedTime,
result.LocalTime,
result.SkewSeconds,
result.Status);
break;
case TimeSkewStatus.Rejected:
_metrics.TimeSkewDetectedTotal.Add(1,
new("severity", "rejected"),
new("action", "rejected"));
_logger.LogError(
"Time skew rejected: IntegratedTime={IntegratedTime:O}, LocalTime={LocalTime:O}, SkewSeconds={SkewSeconds:F1}, Status={Status}, Message={Message}",
result.IntegratedTime,
result.LocalTime,
result.SkewSeconds,
result.Status,
result.Message);
break;
case TimeSkewStatus.FutureTimestamp:
_metrics.TimeSkewDetectedTotal.Add(1,
new("severity", "future"),
new("action", "rejected"));
_logger.LogError(
"Future timestamp detected (potential tampering): IntegratedTime={IntegratedTime:O}, LocalTime={LocalTime:O}, SkewSeconds={SkewSeconds:F1}, Status={Status}",
result.IntegratedTime,
result.LocalTime,
result.SkewSeconds,
result.Status);
break;
case TimeSkewStatus.Ok:
_logger.LogDebug(
"Time skew validation passed: IntegratedTime={IntegratedTime:O}, LocalTime={LocalTime:O}, SkewSeconds={SkewSeconds:F1}",
result.IntegratedTime,
result.LocalTime,
result.SkewSeconds);
break;
case TimeSkewStatus.Skipped:
_logger.LogDebug("Time skew validation skipped: {Message}", result.Message);
break;
}
return result;
}
}

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Attestor.Core.Verification;
/// <summary>
/// Exception thrown when time skew validation fails and is configured to reject.
/// Per SPRINT_3000_0001_0003.
/// </summary>
public sealed class TimeSkewValidationException : Exception
{
/// <summary>
/// Gets the validation result that caused the exception.
/// </summary>
public TimeSkewValidationResult ValidationResult { get; }
/// <summary>
/// Gets the time skew in seconds.
/// </summary>
public double SkewSeconds => ValidationResult.SkewSeconds;
/// <summary>
/// Gets the validation status.
/// </summary>
public TimeSkewStatus Status => ValidationResult.Status;
public TimeSkewValidationException(TimeSkewValidationResult result)
: base(result.Message)
{
ValidationResult = result;
}
public TimeSkewValidationException(TimeSkewValidationResult result, Exception innerException)
: base(result.Message, innerException)
{
ValidationResult = result;
}
}

View File

@@ -0,0 +1,69 @@
-- -----------------------------------------------------------------------------
-- Migration: 20251216_001_create_rekor_submission_queue.sql
-- Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
-- Task: T1
-- Description: Create the Rekor submission queue table for durable retry
-- -----------------------------------------------------------------------------
-- Create attestor schema if not exists
CREATE SCHEMA IF NOT EXISTS attestor;
-- Create the queue table
CREATE TABLE IF NOT EXISTS attestor.rekor_submission_queue (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
bundle_sha256 TEXT NOT NULL,
dsse_payload BYTEA NOT NULL,
backend TEXT NOT NULL DEFAULT 'primary',
-- Status lifecycle: pending -> submitting -> submitted | retrying -> dead_letter
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'submitting', 'retrying', 'submitted', 'dead_letter')),
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Populated on success
rekor_uuid TEXT,
rekor_index BIGINT,
-- Populated on failure
last_error TEXT
);
-- Comments
COMMENT ON TABLE attestor.rekor_submission_queue IS
'Durable retry queue for Rekor transparency log submissions';
COMMENT ON COLUMN attestor.rekor_submission_queue.status IS
'Submission lifecycle: pending -> submitting -> (submitted | retrying -> dead_letter)';
COMMENT ON COLUMN attestor.rekor_submission_queue.backend IS
'Target Rekor backend (primary or mirror)';
COMMENT ON COLUMN attestor.rekor_submission_queue.dsse_payload IS
'Serialized DSSE envelope to submit';
-- Index for dequeue operations (status + next_retry_at for SKIP LOCKED queries)
CREATE INDEX IF NOT EXISTS idx_rekor_queue_dequeue
ON attestor.rekor_submission_queue (status, next_retry_at)
WHERE status IN ('pending', 'retrying');
-- Index for tenant-scoped queries
CREATE INDEX IF NOT EXISTS idx_rekor_queue_tenant
ON attestor.rekor_submission_queue (tenant_id);
-- Index for bundle lookup (deduplication check)
CREATE INDEX IF NOT EXISTS idx_rekor_queue_bundle
ON attestor.rekor_submission_queue (tenant_id, bundle_sha256);
-- Index for dead letter management
CREATE INDEX IF NOT EXISTS idx_rekor_queue_dead_letter
ON attestor.rekor_submission_queue (status, updated_at)
WHERE status = 'dead_letter';
-- Index for cleanup of completed submissions
CREATE INDEX IF NOT EXISTS idx_rekor_queue_completed
ON attestor.rekor_submission_queue (status, updated_at)
WHERE status = 'submitted';

View File

@@ -0,0 +1,524 @@
// -----------------------------------------------------------------------------
// PostgresRekorSubmissionQueue.cs
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
// Task: T3
// Description: PostgreSQL implementation of the Rekor submission queue
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Queue;
namespace StellaOps.Attestor.Infrastructure.Queue;
/// <summary>
/// PostgreSQL implementation of the Rekor submission queue.
/// Uses a dedicated table for queue persistence with optimistic locking.
/// </summary>
public sealed class PostgresRekorSubmissionQueue : IRekorSubmissionQueue
{
private readonly NpgsqlDataSource _dataSource;
private readonly RekorQueueOptions _options;
private readonly AttestorMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PostgresRekorSubmissionQueue> _logger;
private const int DefaultCommandTimeoutSeconds = 30;
public PostgresRekorSubmissionQueue(
NpgsqlDataSource dataSource,
IOptions<RekorQueueOptions> options,
AttestorMetrics metrics,
TimeProvider timeProvider,
ILogger<PostgresRekorSubmissionQueue> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<Guid> EnqueueAsync(
string tenantId,
string bundleSha256,
byte[] dssePayload,
string backend,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
var id = Guid.NewGuid();
const string sql = """
INSERT INTO attestor.rekor_submission_queue (
id, tenant_id, bundle_sha256, dsse_payload, backend,
status, attempt_count, max_attempts, next_retry_at,
created_at, updated_at
)
VALUES (
@id, @tenant_id, @bundle_sha256, @dsse_payload, @backend,
@status, 0, @max_attempts, @next_retry_at,
@created_at, @updated_at
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@tenant_id", tenantId);
command.Parameters.AddWithValue("@bundle_sha256", bundleSha256);
command.Parameters.AddWithValue("@dsse_payload", dssePayload);
command.Parameters.AddWithValue("@backend", backend);
command.Parameters.AddWithValue("@status", RekorSubmissionStatus.Pending.ToString().ToLowerInvariant());
command.Parameters.AddWithValue("@max_attempts", _options.MaxAttempts);
command.Parameters.AddWithValue("@next_retry_at", now);
command.Parameters.AddWithValue("@created_at", now);
command.Parameters.AddWithValue("@updated_at", now);
await command.ExecuteNonQueryAsync(cancellationToken);
_metrics.RekorSubmissionStatusTotal.Add(1,
new("status", "pending"),
new("backend", backend));
_logger.LogDebug(
"Enqueued Rekor submission {Id} for bundle {BundleSha256} to {Backend}",
id, bundleSha256, backend);
return id;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RekorQueueItem>> DequeueAsync(
int batchSize,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
// Use FOR UPDATE SKIP LOCKED for concurrent-safe dequeue
const string sql = """
UPDATE attestor.rekor_submission_queue
SET status = 'submitting', updated_at = @now
WHERE id IN (
SELECT id FROM attestor.rekor_submission_queue
WHERE status IN ('pending', 'retrying')
AND next_retry_at <= @now
ORDER BY next_retry_at ASC
LIMIT @batch_size
FOR UPDATE SKIP LOCKED
)
RETURNING id, tenant_id, bundle_sha256, dsse_payload, backend,
status, attempt_count, max_attempts, next_retry_at,
created_at, updated_at, last_error
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@now", now);
command.Parameters.AddWithValue("@batch_size", batchSize);
var results = new List<RekorQueueItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var queuedAt = reader.GetDateTime(reader.GetOrdinal("created_at"));
var waitTime = (now - queuedAt).TotalSeconds;
_metrics.RekorQueueWaitTime.Record(waitTime);
results.Add(ReadQueueItem(reader));
}
return results;
}
/// <inheritdoc />
public async Task MarkSubmittedAsync(
Guid id,
string rekorUuid,
long? rekorIndex,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
const string sql = """
UPDATE attestor.rekor_submission_queue
SET status = 'submitted',
rekor_uuid = @rekor_uuid,
rekor_index = @rekor_index,
updated_at = @updated_at,
last_error = NULL
WHERE id = @id
RETURNING backend
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@rekor_uuid", rekorUuid);
command.Parameters.AddWithValue("@rekor_index", (object?)rekorIndex ?? DBNull.Value);
command.Parameters.AddWithValue("@updated_at", now);
var backend = await command.ExecuteScalarAsync(cancellationToken) as string ?? "unknown";
_metrics.RekorSubmissionStatusTotal.Add(1,
new("status", "submitted"),
new("backend", backend));
_logger.LogInformation(
"Marked Rekor submission {Id} as submitted with UUID {RekorUuid}",
id, rekorUuid);
}
/// <inheritdoc />
public async Task MarkFailedAsync(
Guid id,
string errorMessage,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
// Fetch current state to determine next action
const string fetchSql = """
SELECT attempt_count, max_attempts, backend
FROM attestor.rekor_submission_queue
WHERE id = @id
FOR UPDATE
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
int attemptCount;
int maxAttempts;
string backend;
await using (var fetchCommand = new NpgsqlCommand(fetchSql, connection, transaction))
{
fetchCommand.Parameters.AddWithValue("@id", id);
await using var reader = await fetchCommand.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
_logger.LogWarning("Attempted to mark non-existent queue item {Id} as failed", id);
return;
}
attemptCount = reader.GetInt32(0);
maxAttempts = reader.GetInt32(1);
backend = reader.GetString(2);
}
attemptCount++;
var isDeadLetter = attemptCount >= maxAttempts;
if (isDeadLetter)
{
const string deadLetterSql = """
UPDATE attestor.rekor_submission_queue
SET status = 'dead_letter',
attempt_count = @attempt_count,
last_error = @last_error,
updated_at = @updated_at
WHERE id = @id
""";
await using var command = new NpgsqlCommand(deadLetterSql, connection, transaction);
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@attempt_count", attemptCount);
command.Parameters.AddWithValue("@last_error", errorMessage);
command.Parameters.AddWithValue("@updated_at", now);
await command.ExecuteNonQueryAsync(cancellationToken);
_metrics.RekorSubmissionStatusTotal.Add(1,
new("status", "dead_letter"),
new("backend", backend));
_metrics.RekorDeadLetterTotal.Add(1, new("backend", backend));
_logger.LogError(
"Moved Rekor submission {Id} to dead letter after {Attempts} attempts: {Error}",
id, attemptCount, errorMessage);
}
else
{
var nextRetryAt = CalculateNextRetryTime(now, attemptCount);
const string retrySql = """
UPDATE attestor.rekor_submission_queue
SET status = 'retrying',
attempt_count = @attempt_count,
next_retry_at = @next_retry_at,
last_error = @last_error,
updated_at = @updated_at
WHERE id = @id
""";
await using var command = new NpgsqlCommand(retrySql, connection, transaction);
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@attempt_count", attemptCount);
command.Parameters.AddWithValue("@next_retry_at", nextRetryAt);
command.Parameters.AddWithValue("@last_error", errorMessage);
command.Parameters.AddWithValue("@updated_at", now);
await command.ExecuteNonQueryAsync(cancellationToken);
_metrics.RekorSubmissionStatusTotal.Add(1,
new("status", "retrying"),
new("backend", backend));
_metrics.RekorRetryAttemptsTotal.Add(1,
new("backend", backend),
new("attempt", attemptCount.ToString()));
_logger.LogWarning(
"Marked Rekor submission {Id} for retry (attempt {Attempt}/{Max}): {Error}",
id, attemptCount, maxAttempts, errorMessage);
}
await transaction.CommitAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<RekorQueueItem?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, bundle_sha256, dsse_payload, backend,
status, attempt_count, max_attempts, next_retry_at,
created_at, updated_at, last_error, rekor_uuid, rekor_index
FROM attestor.rekor_submission_queue
WHERE id = @id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return ReadQueueItem(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RekorQueueItem>> GetByBundleShaAsync(
string tenantId,
string bundleSha256,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, bundle_sha256, dsse_payload, backend,
status, attempt_count, max_attempts, next_retry_at,
created_at, updated_at, last_error, rekor_uuid, rekor_index
FROM attestor.rekor_submission_queue
WHERE tenant_id = @tenant_id AND bundle_sha256 = @bundle_sha256
ORDER BY created_at DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@tenant_id", tenantId);
command.Parameters.AddWithValue("@bundle_sha256", bundleSha256);
var results = new List<RekorQueueItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(ReadQueueItem(reader));
}
return results;
}
/// <inheritdoc />
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM attestor.rekor_submission_queue
WHERE status IN ('pending', 'retrying', 'submitting')
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
var result = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(result);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RekorQueueItem>> GetDeadLetterItemsAsync(
int limit,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, bundle_sha256, dsse_payload, backend,
status, attempt_count, max_attempts, next_retry_at,
created_at, updated_at, last_error, rekor_uuid, rekor_index
FROM attestor.rekor_submission_queue
WHERE status = 'dead_letter'
ORDER BY updated_at DESC
LIMIT @limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@limit", limit);
var results = new List<RekorQueueItem>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add(ReadQueueItem(reader));
}
return results;
}
/// <inheritdoc />
public async Task<bool> RequeueDeadLetterAsync(
Guid id,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
const string sql = """
UPDATE attestor.rekor_submission_queue
SET status = 'pending',
attempt_count = 0,
next_retry_at = @now,
last_error = NULL,
updated_at = @now
WHERE id = @id AND status = 'dead_letter'
RETURNING backend
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@now", now);
var backend = await command.ExecuteScalarAsync(cancellationToken) as string;
if (backend is not null)
{
_metrics.RekorSubmissionStatusTotal.Add(1,
new("status", "pending"),
new("backend", backend));
_logger.LogInformation("Requeued dead letter item {Id} for retry", id);
return true;
}
return false;
}
/// <inheritdoc />
public async Task<int> PurgeSubmittedAsync(
TimeSpan olderThan,
CancellationToken cancellationToken = default)
{
var cutoff = _timeProvider.GetUtcNow().Add(-olderThan);
const string sql = """
DELETE FROM attestor.rekor_submission_queue
WHERE status = 'submitted' AND updated_at < @cutoff
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection)
{
CommandTimeout = DefaultCommandTimeoutSeconds
};
command.Parameters.AddWithValue("@cutoff", cutoff);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted > 0)
{
_logger.LogInformation("Purged {Count} submitted queue items older than {Cutoff}", deleted, cutoff);
}
return deleted;
}
private DateTimeOffset CalculateNextRetryTime(DateTimeOffset now, int attemptCount)
{
// Exponential backoff: baseDelay * 2^attempt, capped at maxDelay
var delay = TimeSpan.FromSeconds(
Math.Min(
_options.BaseRetryDelaySeconds * Math.Pow(2, attemptCount - 1),
_options.MaxRetryDelaySeconds));
return now.Add(delay);
}
private static RekorQueueItem ReadQueueItem(NpgsqlDataReader reader)
{
return new RekorQueueItem
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
BundleSha256 = reader.GetString(reader.GetOrdinal("bundle_sha256")),
DssePayload = reader.GetFieldValue<byte[]>(reader.GetOrdinal("dsse_payload")),
Backend = reader.GetString(reader.GetOrdinal("backend")),
Status = Enum.Parse<RekorSubmissionStatus>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
AttemptCount = reader.GetInt32(reader.GetOrdinal("attempt_count")),
MaxAttempts = reader.GetInt32(reader.GetOrdinal("max_attempts")),
NextRetryAt = reader.GetDateTime(reader.GetOrdinal("next_retry_at")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetDateTime(reader.GetOrdinal("updated_at")),
LastError = reader.IsDBNull(reader.GetOrdinal("last_error"))
? null
: reader.GetString(reader.GetOrdinal("last_error")),
RekorUuid = reader.IsDBNull(reader.GetOrdinal("rekor_uuid"))
? null
: reader.GetString(reader.GetOrdinal("rekor_uuid")),
RekorIndex = reader.IsDBNull(reader.GetOrdinal("rekor_index"))
? null
: reader.GetInt64(reader.GetOrdinal("rekor_index"))
};
}
}

View File

@@ -29,6 +29,7 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
private readonly IAttestorArchiveStore _archiveStore;
private readonly IAttestorAuditSink _auditSink;
private readonly IAttestorVerificationCache _verificationCache;
private readonly ITimeSkewValidator _timeSkewValidator;
private readonly ILogger<AttestorSubmissionService> _logger;
private readonly TimeProvider _timeProvider;
private readonly AttestorOptions _options;
@@ -43,6 +44,7 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
IAttestorArchiveStore archiveStore,
IAttestorAuditSink auditSink,
IAttestorVerificationCache verificationCache,
ITimeSkewValidator timeSkewValidator,
IOptions<AttestorOptions> options,
ILogger<AttestorSubmissionService> logger,
TimeProvider timeProvider,
@@ -56,6 +58,7 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
_archiveStore = archiveStore;
_auditSink = auditSink;
_verificationCache = verificationCache;
_timeSkewValidator = timeSkewValidator ?? throw new ArgumentNullException(nameof(timeSkewValidator));
_logger = logger;
_timeProvider = timeProvider;
_options = options.Value;
@@ -139,6 +142,20 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
throw new InvalidOperationException("No Rekor submission outcome was produced.");
}
// Validate time skew between Rekor integrated time and local time (SPRINT_3000_0001_0003 T5)
var timeSkewResult = ValidateSubmissionTimeSkew(canonicalOutcome);
if (!timeSkewResult.IsValid && _options.TimeSkew.FailOnReject)
{
_logger.LogError(
"Submission rejected due to time skew: BundleSha={BundleSha}, IntegratedTime={IntegratedTime:O}, LocalTime={LocalTime:O}, SkewSeconds={SkewSeconds:F1}, Status={Status}",
request.Meta.BundleSha256,
timeSkewResult.IntegratedTime,
timeSkewResult.LocalTime,
timeSkewResult.SkewSeconds,
timeSkewResult.Status);
throw new TimeSkewValidationException(timeSkewResult);
}
var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
await InvalidateVerificationCacheAsync(cacheSubject, cancellationToken).ConfigureAwait(false);
@@ -490,6 +507,23 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
}
}
/// <summary>
/// Validates time skew between Rekor integrated time and local time.
/// Per SPRINT_3000_0001_0003 T5.
/// </summary>
private TimeSkewValidationResult ValidateSubmissionTimeSkew(SubmissionOutcome outcome)
{
if (outcome.Submission is null)
{
return TimeSkewValidationResult.Skipped("No submission response available");
}
var integratedTime = outcome.Submission.IntegratedTimeUtc;
var localTime = _timeProvider.GetUtcNow();
return _timeSkewValidator.Validate(integratedTime, localTime);
}
private async Task ArchiveAsync(
AttestorEntry entry,
byte[] canonicalBundle,

View File

@@ -25,6 +25,7 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
private readonly IRekorClient _rekorClient;
private readonly ITransparencyWitnessClient _witnessClient;
private readonly IAttestorVerificationEngine _engine;
private readonly ITimeSkewValidator _timeSkewValidator;
private readonly ILogger<AttestorVerificationService> _logger;
private readonly AttestorOptions _options;
private readonly AttestorMetrics _metrics;
@@ -37,6 +38,7 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
IRekorClient rekorClient,
ITransparencyWitnessClient witnessClient,
IAttestorVerificationEngine engine,
ITimeSkewValidator timeSkewValidator,
IOptions<AttestorOptions> options,
ILogger<AttestorVerificationService> logger,
AttestorMetrics metrics,
@@ -48,6 +50,7 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
_witnessClient = witnessClient ?? throw new ArgumentNullException(nameof(witnessClient));
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_timeSkewValidator = timeSkewValidator ?? throw new ArgumentNullException(nameof(timeSkewValidator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
@@ -72,13 +75,38 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
using var activity = _activitySource.StartVerification(subjectTag, issuerTag, policyId);
var evaluationTime = _timeProvider.GetUtcNow();
// Validate time skew between entry's integrated time and evaluation time (SPRINT_3000_0001_0003 T6)
var timeSkewResult = ValidateVerificationTimeSkew(entry, evaluationTime);
var additionalIssues = new List<string>();
if (!timeSkewResult.IsValid)
{
var issue = $"time_skew_rejected: {timeSkewResult.Message}";
_logger.LogWarning(
"Verification time skew issue for entry {Uuid}: IntegratedTime={IntegratedTime:O}, EvaluationTime={EvaluationTime:O}, SkewSeconds={SkewSeconds:F1}, Status={Status}",
entry.RekorUuid,
timeSkewResult.IntegratedTime,
evaluationTime,
timeSkewResult.SkewSeconds,
timeSkewResult.Status);
if (_options.TimeSkew.FailOnReject)
{
additionalIssues.Add(issue);
}
}
var report = await _engine.EvaluateAsync(entry, request.Bundle, evaluationTime, cancellationToken).ConfigureAwait(false);
var result = report.Succeeded ? "ok" : "failed";
// Merge any time skew issues with the report
var allIssues = report.Issues.Concat(additionalIssues).ToArray();
var succeeded = report.Succeeded && additionalIssues.Count == 0;
var result = succeeded ? "ok" : "failed";
activity?.SetTag(AttestorTelemetryTags.Result, result);
if (!report.Succeeded)
if (!succeeded)
{
activity?.SetStatus(ActivityStatusCode.Error, string.Join(",", report.Issues));
activity?.SetStatus(ActivityStatusCode.Error, string.Join(",", allIssues));
}
_metrics.VerifyTotal.Add(
@@ -98,17 +126,27 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
return new AttestorVerificationResult
{
Ok = report.Succeeded,
Ok = succeeded,
Uuid = entry.RekorUuid,
Index = entry.Index,
LogUrl = entry.Log.Url,
Status = entry.Status,
Issues = report.Issues,
Issues = allIssues,
CheckedAt = evaluationTime,
Report = report
Report = report with { Succeeded = succeeded, Issues = allIssues }
};
}
/// <summary>
/// Validates time skew between entry's integrated time and evaluation time.
/// Per SPRINT_3000_0001_0003 T6.
/// </summary>
private TimeSkewValidationResult ValidateVerificationTimeSkew(AttestorEntry entry, DateTimeOffset evaluationTime)
{
var integratedTime = entry.Log.IntegratedTimeUtc;
return _timeSkewValidator.Validate(integratedTime, evaluationTime);
}
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(rekorUuid))

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// RekorRetryWorker.cs
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
// Task: T7
// Description: Background service for processing the Rekor retry queue
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Queue;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Workers;
/// <summary>
/// Background service for processing the Rekor submission retry queue.
/// </summary>
public sealed class RekorRetryWorker : BackgroundService
{
private readonly IRekorSubmissionQueue _queue;
private readonly IRekorClient _rekorClient;
private readonly RekorQueueOptions _options;
private readonly AttestorOptions _attestorOptions;
private readonly AttestorMetrics _metrics;
private readonly TimeProvider _timeProvider;
private readonly ILogger<RekorRetryWorker> _logger;
public RekorRetryWorker(
IRekorSubmissionQueue queue,
IRekorClient rekorClient,
IOptions<RekorQueueOptions> queueOptions,
IOptions<AttestorOptions> attestorOptions,
AttestorMetrics metrics,
TimeProvider timeProvider,
ILogger<RekorRetryWorker> logger)
{
_queue = queue ?? throw new ArgumentNullException(nameof(queue));
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
_options = queueOptions?.Value ?? throw new ArgumentNullException(nameof(queueOptions));
_attestorOptions = attestorOptions?.Value ?? throw new ArgumentNullException(nameof(attestorOptions));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Register queue depth callback for metrics
_metrics.RegisterQueueDepthCallback(GetCurrentQueueDepth);
}
private int _lastKnownQueueDepth;
private int GetCurrentQueueDepth() => _lastKnownQueueDepth;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Rekor retry queue is disabled");
return;
}
_logger.LogInformation(
"Rekor retry worker started with batch size {BatchSize}, poll interval {PollIntervalMs}ms",
_options.BatchSize, _options.PollIntervalMs);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessBatchAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Rekor retry worker error during batch processing");
_metrics.ErrorTotal.Add(1, new("type", "rekor_retry_worker"));
}
try
{
await Task.Delay(_options.PollIntervalMs, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Rekor retry worker stopped");
}
private async Task ProcessBatchAsync(CancellationToken stoppingToken)
{
// Update queue depth gauge
var depth = await _queue.GetQueueDepthAsync(stoppingToken);
_lastKnownQueueDepth = depth.TotalWaiting;
if (depth.TotalWaiting == 0)
{
return;
}
_logger.LogDebug(
"Queue depth: pending={Pending}, submitting={Submitting}, retrying={Retrying}, dead_letter={DeadLetter}",
depth.Pending, depth.Submitting, depth.Retrying, depth.DeadLetter);
// Process batch
var items = await _queue.DequeueAsync(_options.BatchSize, stoppingToken);
if (items.Count == 0)
{
return;
}
_logger.LogDebug("Processing {Count} items from Rekor queue", items.Count);
foreach (var item in items)
{
if (stoppingToken.IsCancellationRequested)
break;
await ProcessItemAsync(item, stoppingToken);
}
// Purge old dead letter items periodically
if (_options.DeadLetterRetentionDays > 0 && depth.DeadLetter > 0)
{
await _queue.PurgeDeadLetterAsync(_options.DeadLetterRetentionDays, stoppingToken);
}
}
private async Task ProcessItemAsync(RekorQueueItem item, CancellationToken ct)
{
var attemptNumber = item.AttemptCount + 1;
_logger.LogDebug(
"Processing Rekor queue item {Id}, attempt {Attempt}/{MaxAttempts}, backend={Backend}",
item.Id, attemptNumber, item.MaxAttempts, item.Backend);
_metrics.RekorRetryAttemptsTotal.Add(1,
new("backend", item.Backend),
new("attempt", attemptNumber.ToString()));
try
{
var backend = ResolveBackend(item.Backend);
var request = BuildSubmissionRequest(item);
var response = await _rekorClient.SubmitAsync(request, backend, ct);
await _queue.MarkSubmittedAsync(
item.Id,
response.Uuid ?? string.Empty,
response.Index,
ct);
_logger.LogInformation(
"Rekor queue item {Id} successfully submitted: UUID={RekorUuid}, Index={LogIndex}",
item.Id, response.Uuid, response.Index);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Rekor queue item {Id} submission failed on attempt {Attempt}: {Message}",
item.Id, attemptNumber, ex.Message);
if (attemptNumber >= item.MaxAttempts)
{
await _queue.MarkDeadLetterAsync(item.Id, ex.Message, ct);
_logger.LogError(
"Rekor queue item {Id} exceeded max attempts ({MaxAttempts}), moved to dead letter",
item.Id, item.MaxAttempts);
}
else
{
await _queue.MarkRetryAsync(item.Id, ex.Message, ct);
}
}
}
private RekorBackend ResolveBackend(string backend)
{
return backend.ToLowerInvariant() switch
{
"primary" => new RekorBackend(
_attestorOptions.Rekor.Primary.Url ?? throw new InvalidOperationException("Primary Rekor URL not configured"),
"primary"),
"mirror" => new RekorBackend(
_attestorOptions.Rekor.Mirror.Url ?? throw new InvalidOperationException("Mirror Rekor URL not configured"),
"mirror"),
_ => throw new InvalidOperationException($"Unknown Rekor backend: {backend}")
};
}
private static AttestorSubmissionRequest BuildSubmissionRequest(RekorQueueItem item)
{
// Reconstruct the submission request from the stored payload
return new AttestorSubmissionRequest
{
TenantId = item.TenantId,
BundleSha256 = item.BundleSha256,
DssePayload = item.DssePayload
};
}
}
/// <summary>
/// Simple Rekor backend configuration.
/// </summary>
public sealed record RekorBackend(string Url, string Name);
/// <summary>
/// Submission request for the retry worker.
/// </summary>
public sealed class AttestorSubmissionRequest
{
public string TenantId { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public byte[] DssePayload { get; init; } = Array.Empty<byte>();
}

View File

@@ -0,0 +1,228 @@
// =============================================================================
// RekorRetryWorkerTests.cs
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
// Task: T11
// =============================================================================
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Queue;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Infrastructure.Workers;
using Xunit;
namespace StellaOps.Attestor.Tests;
/// <summary>
/// Unit tests for RekorRetryWorker.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "3000_0001_0002")]
public sealed class RekorRetryWorkerTests
{
private readonly Mock<IRekorSubmissionQueue> _queueMock;
private readonly Mock<IRekorClient> _rekorClientMock;
private readonly Mock<TimeProvider> _timeProviderMock;
private readonly AttestorMetrics _metrics;
private readonly RekorQueueOptions _queueOptions;
private readonly AttestorOptions _attestorOptions;
public RekorRetryWorkerTests()
{
_queueMock = new Mock<IRekorSubmissionQueue>();
_rekorClientMock = new Mock<IRekorClient>();
_timeProviderMock = new Mock<TimeProvider>();
_metrics = new AttestorMetrics();
_queueOptions = new RekorQueueOptions
{
Enabled = true,
BatchSize = 5,
PollIntervalMs = 100,
MaxAttempts = 3
};
_attestorOptions = new AttestorOptions
{
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.example.com"
}
}
};
_timeProviderMock
.Setup(t => t.GetUtcNow())
.Returns(DateTimeOffset.UtcNow);
}
[Fact(DisplayName = "Worker does not process when disabled")]
public async Task ExecuteAsync_WhenDisabled_DoesNotProcess()
{
_queueOptions.Enabled = false;
var worker = CreateWorker();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
await worker.StartAsync(cts.Token);
await Task.Delay(50);
await worker.StopAsync(cts.Token);
_queueMock.Verify(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact(DisplayName = "Worker updates queue depth metrics")]
public async Task ExecuteAsync_UpdatesQueueDepthMetrics()
{
_queueMock
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new QueueDepthSnapshot(5, 2, 3, 1, DateTimeOffset.UtcNow));
_queueMock
.Setup(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var worker = CreateWorker();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await worker.StartAsync(cts.Token);
await Task.Delay(150);
await worker.StopAsync(CancellationToken.None);
_queueMock.Verify(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()), Times.AtLeastOnce);
}
[Fact(DisplayName = "Worker processes items from queue")]
public async Task ExecuteAsync_ProcessesItemsFromQueue()
{
var item = CreateTestItem();
var items = new List<RekorQueueItem> { item };
_queueMock
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new QueueDepthSnapshot(1, 0, 0, 0, DateTimeOffset.UtcNow));
_queueMock
.SetupSequence(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(items)
.ReturnsAsync([]);
_rekorClientMock
.Setup(r => r.SubmitAsync(It.IsAny<object>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new RekorSubmissionResponse { Uuid = "test-uuid", Index = 12345 });
var worker = CreateWorker();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await worker.StartAsync(cts.Token);
await Task.Delay(200);
await worker.StopAsync(CancellationToken.None);
_queueMock.Verify(
q => q.MarkSubmittedAsync(item.Id, "test-uuid", 12345, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact(DisplayName = "Worker marks item for retry on failure")]
public async Task ExecuteAsync_MarksRetryOnFailure()
{
var item = CreateTestItem();
var items = new List<RekorQueueItem> { item };
_queueMock
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new QueueDepthSnapshot(1, 0, 0, 0, DateTimeOffset.UtcNow));
_queueMock
.SetupSequence(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(items)
.ReturnsAsync([]);
_rekorClientMock
.Setup(r => r.SubmitAsync(It.IsAny<object>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Connection failed"));
var worker = CreateWorker();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await worker.StartAsync(cts.Token);
await Task.Delay(200);
await worker.StopAsync(CancellationToken.None);
_queueMock.Verify(
q => q.MarkRetryAsync(item.Id, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact(DisplayName = "Worker marks dead letter after max attempts")]
public async Task ExecuteAsync_MarksDeadLetterAfterMaxAttempts()
{
var item = CreateTestItem(attemptCount: 2); // Next attempt will be 3 (max)
var items = new List<RekorQueueItem> { item };
_queueMock
.Setup(q => q.GetQueueDepthAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new QueueDepthSnapshot(0, 0, 1, 0, DateTimeOffset.UtcNow));
_queueMock
.SetupSequence(q => q.DequeueAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(items)
.ReturnsAsync([]);
_rekorClientMock
.Setup(r => r.SubmitAsync(It.IsAny<object>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Connection failed"));
var worker = CreateWorker();
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await worker.StartAsync(cts.Token);
await Task.Delay(200);
await worker.StopAsync(CancellationToken.None);
_queueMock.Verify(
q => q.MarkDeadLetterAsync(item.Id, It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Once);
}
private RekorRetryWorker CreateWorker()
{
return new RekorRetryWorker(
_queueMock.Object,
_rekorClientMock.Object,
Options.Create(_queueOptions),
Options.Create(_attestorOptions),
_metrics,
_timeProviderMock.Object,
NullLogger<RekorRetryWorker>.Instance);
}
private static RekorQueueItem CreateTestItem(int attemptCount = 0)
{
var now = DateTimeOffset.UtcNow;
return new RekorQueueItem(
Guid.NewGuid(),
"test-tenant",
"sha256:abc123",
new byte[] { 1, 2, 3 },
"primary",
RekorSubmissionStatus.Submitting,
attemptCount,
3,
null,
null,
now,
null,
null,
now,
now);
}
}
/// <summary>
/// Stub response for tests.
/// </summary>
public sealed class RekorSubmissionResponse
{
public string? Uuid { get; init; }
public long? Index { get; init; }
}

View File

@@ -0,0 +1,161 @@
// =============================================================================
// RekorSubmissionQueueTests.cs
// Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
// Task: T13
// =============================================================================
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Queue;
using StellaOps.Attestor.Infrastructure.Queue;
using Xunit;
namespace StellaOps.Attestor.Tests;
/// <summary>
/// Unit tests for PostgresRekorSubmissionQueue.
/// Note: Full integration tests require PostgreSQL via Testcontainers (Task T14).
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "3000_0001_0002")]
public sealed class RekorQueueOptionsTests
{
[Theory(DisplayName = "CalculateRetryDelay applies exponential backoff")]
[InlineData(0, 1000)] // First retry: initial delay
[InlineData(1, 2000)] // Second retry: 1000 * 2
[InlineData(2, 4000)] // Third retry: 1000 * 2^2
[InlineData(3, 8000)] // Fourth retry: 1000 * 2^3
[InlineData(4, 16000)] // Fifth retry: 1000 * 2^4
[InlineData(10, 60000)] // Many retries: capped at MaxDelayMs
public void CalculateRetryDelay_AppliesExponentialBackoff(int attemptCount, int expectedMs)
{
var options = new RekorQueueOptions
{
InitialDelayMs = 1000,
MaxDelayMs = 60000,
BackoffMultiplier = 2.0
};
var delay = options.CalculateRetryDelay(attemptCount);
delay.TotalMilliseconds.Should().Be(expectedMs);
}
[Fact(DisplayName = "Default options have sensible values")]
public void DefaultOptions_HaveSensibleValues()
{
var options = new RekorQueueOptions();
options.Enabled.Should().BeTrue();
options.MaxAttempts.Should().Be(5);
options.InitialDelayMs.Should().Be(1000);
options.MaxDelayMs.Should().Be(60000);
options.BackoffMultiplier.Should().Be(2.0);
options.BatchSize.Should().Be(10);
options.PollIntervalMs.Should().Be(5000);
options.DeadLetterRetentionDays.Should().Be(30);
}
}
/// <summary>
/// Tests for QueueDepthSnapshot.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "3000_0001_0002")]
public sealed class QueueDepthSnapshotTests
{
[Fact(DisplayName = "TotalWaiting sums pending and retrying")]
public void TotalWaiting_SumsPendingAndRetrying()
{
var snapshot = new QueueDepthSnapshot(10, 5, 3, 2, DateTimeOffset.UtcNow);
snapshot.TotalWaiting.Should().Be(13);
}
[Fact(DisplayName = "TotalInQueue sums all non-submitted statuses")]
public void TotalInQueue_SumsAllNonSubmitted()
{
var snapshot = new QueueDepthSnapshot(10, 5, 3, 2, DateTimeOffset.UtcNow);
snapshot.TotalInQueue.Should().Be(20);
}
[Fact(DisplayName = "Empty creates zero snapshot")]
public void Empty_CreatesZeroSnapshot()
{
var now = DateTimeOffset.UtcNow;
var snapshot = QueueDepthSnapshot.Empty(now);
snapshot.Pending.Should().Be(0);
snapshot.Submitting.Should().Be(0);
snapshot.Retrying.Should().Be(0);
snapshot.DeadLetter.Should().Be(0);
snapshot.MeasuredAt.Should().Be(now);
}
}
/// <summary>
/// Tests for RekorQueueItem.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "3000_0001_0002")]
public sealed class RekorQueueItemTests
{
[Fact(DisplayName = "RekorQueueItem properties are accessible")]
public void RekorQueueItem_PropertiesAccessible()
{
var id = Guid.NewGuid();
var tenantId = "test-tenant";
var bundleSha256 = "sha256:abc123";
var dssePayload = new byte[] { 1, 2, 3 };
var backend = "primary";
var now = DateTimeOffset.UtcNow;
var item = new RekorQueueItem
{
Id = id,
TenantId = tenantId,
BundleSha256 = bundleSha256,
DssePayload = dssePayload,
Backend = backend,
Status = RekorSubmissionStatus.Pending,
AttemptCount = 0,
MaxAttempts = 5,
NextRetryAt = now,
CreatedAt = now,
UpdatedAt = now
};
item.Id.Should().Be(id);
item.TenantId.Should().Be(tenantId);
item.BundleSha256.Should().Be(bundleSha256);
item.DssePayload.Should().BeEquivalentTo(dssePayload);
item.Backend.Should().Be(backend);
item.Status.Should().Be(RekorSubmissionStatus.Pending);
item.AttemptCount.Should().Be(0);
item.MaxAttempts.Should().Be(5);
}
}
/// <summary>
/// Tests for RekorSubmissionStatus enum.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "3000_0001_0002")]
public sealed class RekorSubmissionStatusTests
{
[Theory(DisplayName = "Status enum has expected values")]
[InlineData(RekorSubmissionStatus.Pending, 0)]
[InlineData(RekorSubmissionStatus.Submitting, 1)]
[InlineData(RekorSubmissionStatus.Submitted, 2)]
[InlineData(RekorSubmissionStatus.Retrying, 3)]
[InlineData(RekorSubmissionStatus.DeadLetter, 4)]
public void Status_HasExpectedValues(RekorSubmissionStatus status, int expectedValue)
{
((int)status).Should().Be(expectedValue);
}
}