sprints and audit work
This commit is contained in:
@@ -7,7 +7,7 @@ namespace StellaOps.Facet;
|
||||
/// <summary>
|
||||
/// Standard implementation of <see cref="IFacet"/> for defining facets.
|
||||
/// </summary>
|
||||
internal sealed class FacetDefinition : IFacet
|
||||
public sealed class FacetDefinition : IFacet
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string FacetId { get; }
|
||||
|
||||
349
src/__Libraries/StellaOps.Facet/FacetDriftVexEmitter.cs
Normal file
349
src/__Libraries/StellaOps.Facet/FacetDriftVexEmitter.cs
Normal file
@@ -0,0 +1,349 @@
|
||||
// <copyright file="FacetDriftVexEmitter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_003_FACET (QTA-016)
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Emits VEX drafts for facet drift that requires authorization.
|
||||
/// When drift exceeds quota and action is RequireVex, this emitter
|
||||
/// generates a draft VEX document for human review.
|
||||
/// </summary>
|
||||
public sealed class FacetDriftVexEmitter
|
||||
{
|
||||
private readonly FacetDriftVexEmitterOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetDriftVexEmitter"/> class.
|
||||
/// </summary>
|
||||
public FacetDriftVexEmitter(
|
||||
FacetDriftVexEmitterOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? FacetDriftVexEmitterOptions.Default;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates facet drift and emits VEX drafts for facets that exceed quotas.
|
||||
/// </summary>
|
||||
public FacetDriftVexEmissionResult EmitDrafts(FacetDriftVexEmissionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var drafts = new List<FacetDriftVexDraft>();
|
||||
|
||||
foreach (var facetDrift in context.DriftReport.FacetDrifts)
|
||||
{
|
||||
// Only emit drafts for facets that require VEX authorization
|
||||
if (facetDrift.QuotaVerdict != QuotaVerdict.RequiresVex)
|
||||
continue;
|
||||
|
||||
var draft = CreateVexDraft(facetDrift, context);
|
||||
drafts.Add(draft);
|
||||
|
||||
if (drafts.Count >= _options.MaxDraftsPerBatch)
|
||||
break;
|
||||
}
|
||||
|
||||
return new FacetDriftVexEmissionResult(
|
||||
ImageDigest: context.DriftReport.ImageDigest,
|
||||
BaselineSealId: context.DriftReport.BaselineSealId,
|
||||
DraftsEmitted: drafts.Count,
|
||||
Drafts: [.. drafts],
|
||||
GeneratedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VEX draft for a single facet that exceeded its quota.
|
||||
/// </summary>
|
||||
private FacetDriftVexDraft CreateVexDraft(
|
||||
FacetDrift drift,
|
||||
FacetDriftVexEmissionContext context)
|
||||
{
|
||||
var draftId = GenerateDraftId(drift, context);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build evidence links
|
||||
var evidenceLinks = new List<FacetDriftEvidenceLink>
|
||||
{
|
||||
new(
|
||||
Type: "facet_drift_analysis",
|
||||
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}",
|
||||
Description: $"Facet drift analysis for {drift.FacetId}"),
|
||||
new(
|
||||
Type: "baseline_seal",
|
||||
Uri: $"seal://{context.DriftReport.BaselineSealId}",
|
||||
Description: "Baseline seal used for comparison")
|
||||
};
|
||||
|
||||
// Add links for significant changes
|
||||
if (drift.Added.Length > 0)
|
||||
{
|
||||
evidenceLinks.Add(new FacetDriftEvidenceLink(
|
||||
Type: "added_files",
|
||||
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}/added",
|
||||
Description: $"{drift.Added.Length} files added"));
|
||||
}
|
||||
|
||||
if (drift.Removed.Length > 0)
|
||||
{
|
||||
evidenceLinks.Add(new FacetDriftEvidenceLink(
|
||||
Type: "removed_files",
|
||||
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}/removed",
|
||||
Description: $"{drift.Removed.Length} files removed"));
|
||||
}
|
||||
|
||||
if (drift.Modified.Length > 0)
|
||||
{
|
||||
evidenceLinks.Add(new FacetDriftEvidenceLink(
|
||||
Type: "modified_files",
|
||||
Uri: $"facet://{context.DriftReport.ImageDigest}/{drift.FacetId}/modified",
|
||||
Description: $"{drift.Modified.Length} files modified"));
|
||||
}
|
||||
|
||||
return new FacetDriftVexDraft(
|
||||
DraftId: draftId,
|
||||
FacetId: drift.FacetId,
|
||||
ImageDigest: context.DriftReport.ImageDigest,
|
||||
BaselineSealId: context.DriftReport.BaselineSealId,
|
||||
SuggestedStatus: FacetDriftVexStatus.Accepted,
|
||||
Justification: FacetDriftVexJustification.IntentionalChange,
|
||||
Rationale: GenerateRationale(drift, context),
|
||||
DriftSummary: CreateDriftSummary(drift),
|
||||
EvidenceLinks: [.. evidenceLinks],
|
||||
GeneratedAt: now,
|
||||
ExpiresAt: now.Add(_options.DraftTtl),
|
||||
ReviewDeadline: now.AddDays(_options.ReviewSlaDays),
|
||||
RequiresReview: true,
|
||||
ReviewerNotes: GenerateReviewerNotes(drift));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a human-readable rationale for the VEX draft.
|
||||
/// </summary>
|
||||
private string GenerateRationale(FacetDrift drift, FacetDriftVexEmissionContext context)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture, $"Facet '{drift.FacetId}' drift exceeds configured quota. ");
|
||||
sb.Append(CultureInfo.InvariantCulture, $"Churn: {drift.ChurnPercent:F1}% ({drift.TotalChanges} of {drift.BaselineFileCount} files changed). ");
|
||||
|
||||
if (drift.Added.Length > 0)
|
||||
{
|
||||
sb.Append($"{drift.Added.Length} file(s) added. ");
|
||||
}
|
||||
|
||||
if (drift.Removed.Length > 0)
|
||||
{
|
||||
sb.Append($"{drift.Removed.Length} file(s) removed. ");
|
||||
}
|
||||
|
||||
if (drift.Modified.Length > 0)
|
||||
{
|
||||
sb.Append($"{drift.Modified.Length} file(s) modified. ");
|
||||
}
|
||||
|
||||
sb.Append("VEX authorization required to proceed with deployment.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a summary of the drift for the VEX draft.
|
||||
/// </summary>
|
||||
private static FacetDriftSummary CreateDriftSummary(FacetDrift drift)
|
||||
{
|
||||
return new FacetDriftSummary(
|
||||
TotalChanges: drift.TotalChanges,
|
||||
AddedCount: drift.Added.Length,
|
||||
RemovedCount: drift.Removed.Length,
|
||||
ModifiedCount: drift.Modified.Length,
|
||||
ChurnPercent: drift.ChurnPercent,
|
||||
DriftScore: drift.DriftScore,
|
||||
BaselineFileCount: drift.BaselineFileCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates notes for the reviewer.
|
||||
/// </summary>
|
||||
private string GenerateReviewerNotes(FacetDrift drift)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## Review Checklist");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("- [ ] Verify the drift is intentional and authorized");
|
||||
sb.AppendLine("- [ ] Confirm no security-sensitive files were unexpectedly modified");
|
||||
sb.AppendLine("- [ ] Check if the changes align with the current release scope");
|
||||
|
||||
if (drift.ChurnPercent > _options.HighChurnThreshold)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**WARNING**: High churn detected ({drift.ChurnPercent:F1}%). Consider additional scrutiny.");
|
||||
}
|
||||
|
||||
if (drift.Removed.Length > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**NOTE**: Files were removed. Verify these removals are intentional.");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic draft ID.
|
||||
/// </summary>
|
||||
private string GenerateDraftId(FacetDrift drift, FacetDriftVexEmissionContext context)
|
||||
{
|
||||
var input = $"{context.DriftReport.ImageDigest}:{drift.FacetId}:{context.DriftReport.BaselineSealId}:{context.DriftReport.AnalyzedAt.Ticks}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"vexfd-{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for facet drift VEX emission.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexEmitterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum drafts to emit per batch.
|
||||
/// </summary>
|
||||
public int MaxDraftsPerBatch { get; init; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for drafts before they expire.
|
||||
/// </summary>
|
||||
public TimeSpan DraftTtl { get; init; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// SLA in days for human review.
|
||||
/// </summary>
|
||||
public int ReviewSlaDays { get; init; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Churn percentage that triggers high-churn warning.
|
||||
/// </summary>
|
||||
public decimal HighChurnThreshold { get; init; } = 30m;
|
||||
|
||||
/// <summary>
|
||||
/// Default options.
|
||||
/// </summary>
|
||||
public static FacetDriftVexEmitterOptions Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for facet drift VEX emission.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexEmissionContext(
|
||||
FacetDriftReport DriftReport,
|
||||
string? TenantId = null,
|
||||
string? RequestedBy = null);
|
||||
|
||||
/// <summary>
|
||||
/// Result of facet drift VEX emission.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexEmissionResult(
|
||||
string ImageDigest,
|
||||
string BaselineSealId,
|
||||
int DraftsEmitted,
|
||||
ImmutableArray<FacetDriftVexDraft> Drafts,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A VEX draft generated from facet drift analysis.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexDraft(
|
||||
string DraftId,
|
||||
string FacetId,
|
||||
string ImageDigest,
|
||||
string BaselineSealId,
|
||||
FacetDriftVexStatus SuggestedStatus,
|
||||
FacetDriftVexJustification Justification,
|
||||
string Rationale,
|
||||
FacetDriftSummary DriftSummary,
|
||||
ImmutableArray<FacetDriftEvidenceLink> EvidenceLinks,
|
||||
DateTimeOffset GeneratedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset ReviewDeadline,
|
||||
bool RequiresReview,
|
||||
string? ReviewerNotes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of drift for a VEX draft.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftSummary(
|
||||
int TotalChanges,
|
||||
int AddedCount,
|
||||
int RemovedCount,
|
||||
int ModifiedCount,
|
||||
decimal ChurnPercent,
|
||||
decimal DriftScore,
|
||||
int BaselineFileCount);
|
||||
|
||||
/// <summary>
|
||||
/// VEX status for facet drift drafts.
|
||||
/// </summary>
|
||||
public enum FacetDriftVexStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Drift is accepted and authorized.
|
||||
/// </summary>
|
||||
Accepted,
|
||||
|
||||
/// <summary>
|
||||
/// Drift is rejected - requires remediation.
|
||||
/// </summary>
|
||||
Rejected,
|
||||
|
||||
/// <summary>
|
||||
/// Under investigation - awaiting review.
|
||||
/// </summary>
|
||||
UnderReview
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification for facet drift drafts.
|
||||
/// </summary>
|
||||
public enum FacetDriftVexJustification
|
||||
{
|
||||
/// <summary>
|
||||
/// Drift is an intentional change (upgrade, refactor, etc.).
|
||||
/// </summary>
|
||||
IntentionalChange,
|
||||
|
||||
/// <summary>
|
||||
/// Security fix applied.
|
||||
/// </summary>
|
||||
SecurityFix,
|
||||
|
||||
/// <summary>
|
||||
/// Dependency update.
|
||||
/// </summary>
|
||||
DependencyUpdate,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration change.
|
||||
/// </summary>
|
||||
ConfigurationChange,
|
||||
|
||||
/// <summary>
|
||||
/// Other reason (requires explanation).
|
||||
/// </summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence link for facet drift VEX drafts.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftEvidenceLink(
|
||||
string Type,
|
||||
string Uri,
|
||||
string? Description = null);
|
||||
@@ -0,0 +1,73 @@
|
||||
// <copyright file="FacetDriftVexServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_003_FACET (QTA-019)
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering facet drift VEX services.
|
||||
/// </summary>
|
||||
public static class FacetDriftVexServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds facet drift VEX emitter and workflow services.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional options configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetDriftVexServices(
|
||||
this IServiceCollection services,
|
||||
Action<FacetDriftVexEmitterOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
// Register options
|
||||
var options = FacetDriftVexEmitterOptions.Default;
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
configureOptions(options);
|
||||
}
|
||||
|
||||
services.TryAddSingleton(options);
|
||||
|
||||
// Register emitter
|
||||
services.TryAddSingleton<FacetDriftVexEmitter>();
|
||||
|
||||
// Register workflow
|
||||
services.TryAddScoped<FacetDriftVexWorkflow>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the in-memory draft store for testing.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddInMemoryFacetDriftVexDraftStore(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<IFacetDriftVexDraftStore, InMemoryFacetDriftVexDraftStore>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds facet drift VEX services with in-memory store (for testing).
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Optional options configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetDriftVexServicesWithInMemoryStore(
|
||||
this IServiceCollection services,
|
||||
Action<FacetDriftVexEmitterOptions>? configureOptions = null)
|
||||
{
|
||||
return services
|
||||
.AddFacetDriftVexServices(configureOptions)
|
||||
.AddInMemoryFacetDriftVexDraftStore();
|
||||
}
|
||||
}
|
||||
266
src/__Libraries/StellaOps.Facet/FacetDriftVexWorkflow.cs
Normal file
266
src/__Libraries/StellaOps.Facet/FacetDriftVexWorkflow.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
// <copyright file="FacetDriftVexWorkflow.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_003_FACET (QTA-019)
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a facet drift VEX workflow execution.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexWorkflowResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Emission result from the emitter.
|
||||
/// </summary>
|
||||
public required FacetDriftVexEmissionResult EmissionResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of drafts that were newly created.
|
||||
/// </summary>
|
||||
public int NewDraftsCreated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of drafts that already existed (skipped).
|
||||
/// </summary>
|
||||
public int ExistingDraftsSkipped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of newly created drafts.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CreatedDraftIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Any errors that occurred during storage.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether all operations completed successfully.
|
||||
/// </summary>
|
||||
public bool Success => Errors.Length == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the facet drift VEX workflow: emit drafts + store.
|
||||
/// This integrates with the Excititor VEX workflow by providing
|
||||
/// drafts that can be picked up for human review.
|
||||
/// </summary>
|
||||
public sealed class FacetDriftVexWorkflow
|
||||
{
|
||||
private readonly FacetDriftVexEmitter _emitter;
|
||||
private readonly IFacetDriftVexDraftStore _draftStore;
|
||||
private readonly ILogger<FacetDriftVexWorkflow> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetDriftVexWorkflow"/> class.
|
||||
/// </summary>
|
||||
public FacetDriftVexWorkflow(
|
||||
FacetDriftVexEmitter emitter,
|
||||
IFacetDriftVexDraftStore draftStore,
|
||||
ILogger<FacetDriftVexWorkflow>? logger = null)
|
||||
{
|
||||
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
|
||||
_draftStore = draftStore ?? throw new ArgumentNullException(nameof(draftStore));
|
||||
_logger = logger ?? NullLogger<FacetDriftVexWorkflow>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the full workflow: emit drafts from drift report and store them.
|
||||
/// </summary>
|
||||
/// <param name="driftReport">The drift report to process.</param>
|
||||
/// <param name="skipExisting">If true, skip creating drafts that already exist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Workflow result with draft IDs and status.</returns>
|
||||
public async Task<FacetDriftVexWorkflowResult> ExecuteAsync(
|
||||
FacetDriftReport driftReport,
|
||||
bool skipExisting = true,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(driftReport);
|
||||
|
||||
// Emit drafts from drift report
|
||||
var context = new FacetDriftVexEmissionContext(driftReport);
|
||||
var emissionResult = _emitter.EmitDrafts(context);
|
||||
|
||||
if (emissionResult.DraftsEmitted == 0)
|
||||
{
|
||||
_logger.LogDebug("No drafts to emit for image {ImageDigest}", driftReport.ImageDigest);
|
||||
return new FacetDriftVexWorkflowResult
|
||||
{
|
||||
EmissionResult = emissionResult,
|
||||
NewDraftsCreated = 0,
|
||||
ExistingDraftsSkipped = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Store drafts
|
||||
var createdIds = new List<string>();
|
||||
var skippedCount = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var draft in emissionResult.Drafts)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
if (skipExisting)
|
||||
{
|
||||
var exists = await _draftStore.ExistsAsync(
|
||||
draft.ImageDigest,
|
||||
draft.FacetId,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping existing draft for {ImageDigest}/{FacetId}",
|
||||
draft.ImageDigest,
|
||||
draft.FacetId);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await _draftStore.SaveAsync(draft, ct).ConfigureAwait(false);
|
||||
createdIds.Add(draft.DraftId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created VEX draft {DraftId} for {ImageDigest}/{FacetId} with churn {ChurnPercent:F1}%",
|
||||
draft.DraftId,
|
||||
draft.ImageDigest,
|
||||
draft.FacetId,
|
||||
draft.DriftSummary.ChurnPercent);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to store draft for {ImageDigest}/{FacetId}",
|
||||
draft.ImageDigest,
|
||||
draft.FacetId);
|
||||
errors.Add($"Failed to store draft for {draft.FacetId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new FacetDriftVexWorkflowResult
|
||||
{
|
||||
EmissionResult = emissionResult,
|
||||
NewDraftsCreated = createdIds.Count,
|
||||
ExistingDraftsSkipped = skippedCount,
|
||||
CreatedDraftIds = [.. createdIds],
|
||||
Errors = [.. errors]
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves a draft and converts it to a VEX statement.
|
||||
/// </summary>
|
||||
/// <param name="draftId">ID of the draft to approve.</param>
|
||||
/// <param name="reviewedBy">Who approved the draft.</param>
|
||||
/// <param name="notes">Optional review notes.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if approval succeeded.</returns>
|
||||
public async Task<bool> ApproveAsync(
|
||||
string draftId,
|
||||
string reviewedBy,
|
||||
string? notes = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reviewedBy);
|
||||
|
||||
try
|
||||
{
|
||||
await _draftStore.UpdateReviewStatusAsync(
|
||||
draftId,
|
||||
FacetDriftVexReviewStatus.Approved,
|
||||
reviewedBy,
|
||||
notes,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Draft {DraftId} approved by {ReviewedBy}",
|
||||
draftId,
|
||||
reviewedBy);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
_logger.LogWarning("Draft {DraftId} not found for approval", draftId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a draft.
|
||||
/// </summary>
|
||||
/// <param name="draftId">ID of the draft to reject.</param>
|
||||
/// <param name="reviewedBy">Who rejected the draft.</param>
|
||||
/// <param name="reason">Reason for rejection.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if rejection succeeded.</returns>
|
||||
public async Task<bool> RejectAsync(
|
||||
string draftId,
|
||||
string reviewedBy,
|
||||
string reason,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reviewedBy);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||
|
||||
try
|
||||
{
|
||||
await _draftStore.UpdateReviewStatusAsync(
|
||||
draftId,
|
||||
FacetDriftVexReviewStatus.Rejected,
|
||||
reviewedBy,
|
||||
reason,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Draft {DraftId} rejected by {ReviewedBy}: {Reason}",
|
||||
draftId,
|
||||
reviewedBy,
|
||||
reason);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
_logger.LogWarning("Draft {DraftId} not found for rejection", draftId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets drafts pending review.
|
||||
/// </summary>
|
||||
public Task<ImmutableArray<FacetDriftVexDraft>> GetPendingDraftsAsync(
|
||||
string? imageDigest = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = new FacetDriftVexDraftQuery
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
ReviewStatus = FacetDriftVexReviewStatus.Pending
|
||||
};
|
||||
|
||||
return _draftStore.QueryAsync(query, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets drafts that have exceeded their review deadline.
|
||||
/// </summary>
|
||||
public Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueDraftsAsync(CancellationToken ct = default)
|
||||
{
|
||||
return _draftStore.GetOverdueAsync(DateTimeOffset.UtcNow, ct);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,15 @@ public static class FacetServiceCollectionExtensions
|
||||
return new FacetDriftDetector(timeProvider);
|
||||
});
|
||||
|
||||
// Register facet extractor
|
||||
services.TryAddSingleton<IFacetExtractor>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
|
||||
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<GlobFacetExtractor>>();
|
||||
return new GlobFacetExtractor(timeProvider, crypto, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -111,6 +120,15 @@ public static class FacetServiceCollectionExtensions
|
||||
return new FacetDriftDetector(timeProvider);
|
||||
});
|
||||
|
||||
// Register facet extractor
|
||||
services.TryAddSingleton<IFacetExtractor>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var crypto = sp.GetService<ICryptoHash>() ?? DefaultCryptoHash.Instance;
|
||||
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<GlobFacetExtractor>>();
|
||||
return new GlobFacetExtractor(timeProvider, crypto, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
379
src/__Libraries/StellaOps.Facet/GlobFacetExtractor.cs
Normal file
379
src/__Libraries/StellaOps.Facet/GlobFacetExtractor.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
// <copyright file="GlobFacetExtractor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts facets from container images using glob pattern matching.
|
||||
/// </summary>
|
||||
public sealed class GlobFacetExtractor : IFacetExtractor
|
||||
{
|
||||
private readonly FacetSealer _sealer;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ILogger<GlobFacetExtractor> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GlobFacetExtractor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <param name="cryptoHash">Hash implementation.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public GlobFacetExtractor(
|
||||
TimeProvider? timeProvider = null,
|
||||
ICryptoHash? cryptoHash = null,
|
||||
ILogger<GlobFacetExtractor>? logger = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? new DefaultCryptoHash();
|
||||
_sealer = new FacetSealer(timeProvider, cryptoHash);
|
||||
_logger = logger ?? NullLogger<GlobFacetExtractor>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<FacetExtractionResult> ExtractFromDirectoryAsync(
|
||||
string rootPath,
|
||||
FacetExtractionOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
|
||||
if (!Directory.Exists(rootPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Directory not found: {rootPath}");
|
||||
}
|
||||
|
||||
options ??= FacetExtractionOptions.Default;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var facets = options.Facets.IsDefault || options.Facets.IsEmpty
|
||||
? BuiltInFacets.All.ToList()
|
||||
: options.Facets.ToList();
|
||||
|
||||
var matchers = facets.ToDictionary(f => f.FacetId, GlobMatcher.ForFacet);
|
||||
var excludeMatcher = options.ExcludePatterns.Length > 0
|
||||
? new GlobMatcher(options.ExcludePatterns)
|
||||
: null;
|
||||
|
||||
var facetFiles = facets.ToDictionary(f => f.FacetId, _ => new List<FacetFileEntry>());
|
||||
var unmatchedFiles = new List<FacetFileEntry>();
|
||||
var skippedFiles = new List<SkippedFile>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
int totalFilesProcessed = 0;
|
||||
long totalBytes = 0;
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = GetRelativePath(rootPath, filePath);
|
||||
|
||||
// Check exclusion patterns
|
||||
if (excludeMatcher?.IsMatch(relativePath) == true)
|
||||
{
|
||||
skippedFiles.Add(new SkippedFile(relativePath, "Matched exclusion pattern"));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
// Skip symlinks if not following
|
||||
if (!options.FollowSymlinks && fileInfo.LinkTarget is not null)
|
||||
{
|
||||
skippedFiles.Add(new SkippedFile(relativePath, "Symlink"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files too large
|
||||
if (fileInfo.Length > options.MaxFileSizeBytes)
|
||||
{
|
||||
skippedFiles.Add(new SkippedFile(relativePath, $"Exceeds max size ({fileInfo.Length} > {options.MaxFileSizeBytes})"));
|
||||
continue;
|
||||
}
|
||||
|
||||
totalFilesProcessed++;
|
||||
totalBytes += fileInfo.Length;
|
||||
|
||||
var entry = await CreateFileEntryAsync(filePath, relativePath, fileInfo, options.HashAlgorithm, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
bool matched = false;
|
||||
foreach (var facet in facets)
|
||||
{
|
||||
if (matchers[facet.FacetId].IsMatch(relativePath))
|
||||
{
|
||||
facetFiles[facet.FacetId].Add(entry);
|
||||
matched = true;
|
||||
// Don't break - a file can match multiple facets
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
{
|
||||
unmatchedFiles.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process file: {Path}", relativePath);
|
||||
skippedFiles.Add(new SkippedFile(relativePath, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return BuildResult(facets, facetFiles, unmatchedFiles, skippedFiles, warnings, totalFilesProcessed, totalBytes, sw.Elapsed, options);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<FacetExtractionResult> ExtractFromTarAsync(
|
||||
Stream tarStream,
|
||||
FacetExtractionOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tarStream);
|
||||
|
||||
options ??= FacetExtractionOptions.Default;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var facets = options.Facets.IsDefault || options.Facets.IsEmpty
|
||||
? BuiltInFacets.All.ToList()
|
||||
: options.Facets.ToList();
|
||||
|
||||
var matchers = facets.ToDictionary(f => f.FacetId, GlobMatcher.ForFacet);
|
||||
var excludeMatcher = options.ExcludePatterns.Length > 0
|
||||
? new GlobMatcher(options.ExcludePatterns)
|
||||
: null;
|
||||
|
||||
var facetFiles = facets.ToDictionary(f => f.FacetId, _ => new List<FacetFileEntry>());
|
||||
var unmatchedFiles = new List<FacetFileEntry>();
|
||||
var skippedFiles = new List<SkippedFile>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
int totalFilesProcessed = 0;
|
||||
long totalBytes = 0;
|
||||
|
||||
using var tarReader = new TarReader(tarStream, leaveOpen: true);
|
||||
|
||||
while (await tarReader.GetNextEntryAsync(copyData: false, ct).ConfigureAwait(false) is { } tarEntry)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Skip non-regular files
|
||||
if (tarEntry.EntryType != TarEntryType.RegularFile &&
|
||||
tarEntry.EntryType != TarEntryType.V7RegularFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = NormalizeTarPath(tarEntry.Name);
|
||||
|
||||
if (excludeMatcher?.IsMatch(path) == true)
|
||||
{
|
||||
skippedFiles.Add(new SkippedFile(path, "Matched exclusion pattern"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tarEntry.Length > options.MaxFileSizeBytes)
|
||||
{
|
||||
skippedFiles.Add(new SkippedFile(path, $"Exceeds max size ({tarEntry.Length} > {options.MaxFileSizeBytes})"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip symlinks if not following
|
||||
if (!options.FollowSymlinks && tarEntry.EntryType == TarEntryType.SymbolicLink)
|
||||
{
|
||||
skippedFiles.Add(new SkippedFile(path, "Symlink"));
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
totalFilesProcessed++;
|
||||
totalBytes += tarEntry.Length;
|
||||
|
||||
var entry = await CreateFileEntryFromTarAsync(tarEntry, path, options.HashAlgorithm, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
bool matched = false;
|
||||
foreach (var facet in facets)
|
||||
{
|
||||
if (matchers[facet.FacetId].IsMatch(path))
|
||||
{
|
||||
facetFiles[facet.FacetId].Add(entry);
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
{
|
||||
unmatchedFiles.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or InvalidDataException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process tar entry: {Path}", path);
|
||||
skippedFiles.Add(new SkippedFile(path, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return BuildResult(facets, facetFiles, unmatchedFiles, skippedFiles, warnings, totalFilesProcessed, totalBytes, sw.Elapsed, options);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<FacetExtractionResult> ExtractFromOciLayerAsync(
|
||||
Stream layerStream,
|
||||
FacetExtractionOptions? options = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layerStream);
|
||||
|
||||
// OCI layers are gzipped tars - decompress then delegate
|
||||
await using var gzipStream = new GZipStream(layerStream, CompressionMode.Decompress, leaveOpen: true);
|
||||
return await ExtractFromTarAsync(gzipStream, options, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<FacetFileEntry> CreateFileEntryAsync(
|
||||
string fullPath,
|
||||
string relativePath,
|
||||
FileInfo fileInfo,
|
||||
string algorithm,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(fullPath);
|
||||
var hashBytes = await _cryptoHash.ComputeHashAsync(stream, algorithm, ct).ConfigureAwait(false);
|
||||
var digest = FormatDigest(hashBytes, algorithm);
|
||||
|
||||
return new FacetFileEntry(
|
||||
relativePath,
|
||||
digest,
|
||||
fileInfo.Length,
|
||||
fileInfo.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
private async Task<FacetFileEntry> CreateFileEntryFromTarAsync(
|
||||
TarEntry entry,
|
||||
string path,
|
||||
string algorithm,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var dataStream = entry.DataStream;
|
||||
if (dataStream is null)
|
||||
{
|
||||
// Empty file
|
||||
var emptyHashBytes = await _cryptoHash.ComputeHashAsync(Stream.Null, algorithm, ct).ConfigureAwait(false);
|
||||
var emptyDigest = FormatDigest(emptyHashBytes, algorithm);
|
||||
return new FacetFileEntry(path, emptyDigest, 0, entry.ModificationTime);
|
||||
}
|
||||
|
||||
var hashBytes = await _cryptoHash.ComputeHashAsync(dataStream, algorithm, ct).ConfigureAwait(false);
|
||||
var digest = FormatDigest(hashBytes, algorithm);
|
||||
|
||||
return new FacetFileEntry(
|
||||
path,
|
||||
digest,
|
||||
entry.Length,
|
||||
entry.ModificationTime);
|
||||
}
|
||||
|
||||
private static string FormatDigest(byte[] hashBytes, string algorithm)
|
||||
{
|
||||
var hex = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
return $"{algorithm.ToLowerInvariant()}:{hex}";
|
||||
}
|
||||
|
||||
private FacetExtractionResult BuildResult(
|
||||
List<IFacet> facets,
|
||||
Dictionary<string, List<FacetFileEntry>> facetFiles,
|
||||
List<FacetFileEntry> unmatchedFiles,
|
||||
List<SkippedFile> skippedFiles,
|
||||
List<string> warnings,
|
||||
int totalFilesProcessed,
|
||||
long totalBytes,
|
||||
TimeSpan duration,
|
||||
FacetExtractionOptions options)
|
||||
{
|
||||
var facetEntries = new List<FacetEntry>();
|
||||
int filesMatched = 0;
|
||||
|
||||
foreach (var facet in facets)
|
||||
{
|
||||
var files = facetFiles[facet.FacetId];
|
||||
if (files.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
filesMatched += files.Count;
|
||||
|
||||
// Sort files deterministically for consistent Merkle root
|
||||
var sortedFiles = files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
|
||||
|
||||
var entry = _sealer.CreateFacetEntry(facet, sortedFiles, options.IncludeFileDetails);
|
||||
facetEntries.Add(entry);
|
||||
}
|
||||
|
||||
// Sort facet entries deterministically
|
||||
var sortedFacets = facetEntries.OrderBy(f => f.FacetId, StringComparer.Ordinal).ToImmutableArray();
|
||||
|
||||
var merkleTree = new FacetMerkleTree(_cryptoHash);
|
||||
var combinedRoot = merkleTree.ComputeCombinedRoot(sortedFacets);
|
||||
|
||||
var stats = new FacetExtractionStats
|
||||
{
|
||||
TotalFilesProcessed = totalFilesProcessed,
|
||||
TotalBytes = totalBytes,
|
||||
FilesMatched = filesMatched,
|
||||
FilesUnmatched = unmatchedFiles.Count,
|
||||
FilesSkipped = skippedFiles.Count,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
return new FacetExtractionResult
|
||||
{
|
||||
Facets = sortedFacets,
|
||||
UnmatchedFiles = options.IncludeFileDetails
|
||||
? [.. unmatchedFiles.OrderBy(f => f.Path, StringComparer.Ordinal)]
|
||||
: [],
|
||||
SkippedFiles = [.. skippedFiles],
|
||||
CombinedMerkleRoot = combinedRoot,
|
||||
Stats = stats,
|
||||
Warnings = [.. warnings]
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetRelativePath(string rootPath, string fullPath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(rootPath, fullPath);
|
||||
// Normalize to Unix-style path with leading slash
|
||||
return "/" + relative.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string NormalizeTarPath(string path)
|
||||
{
|
||||
// Remove leading ./ if present
|
||||
if (path.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
path = path[2..];
|
||||
}
|
||||
|
||||
// Ensure leading slash
|
||||
if (!path.StartsWith('/'))
|
||||
{
|
||||
path = "/" + path;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
329
src/__Libraries/StellaOps.Facet/IFacetDriftVexDraftStore.cs
Normal file
329
src/__Libraries/StellaOps.Facet/IFacetDriftVexDraftStore.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
// <copyright file="IFacetDriftVexDraftStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// Sprint: SPRINT_20260105_002_003_FACET (QTA-018)
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for listing VEX drafts.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexDraftQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by facet ID.
|
||||
/// </summary>
|
||||
public string? FacetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by review status.
|
||||
/// </summary>
|
||||
public FacetDriftVexReviewStatus? ReviewStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include only drafts created since this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include only drafts created until this time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results to return.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review status for facet drift VEX drafts.
|
||||
/// </summary>
|
||||
public enum FacetDriftVexReviewStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Draft is pending review.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// Draft has been approved.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Draft has been rejected.
|
||||
/// </summary>
|
||||
Rejected,
|
||||
|
||||
/// <summary>
|
||||
/// Draft has expired without review.
|
||||
/// </summary>
|
||||
Expired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage abstraction for facet drift VEX drafts.
|
||||
/// </summary>
|
||||
public interface IFacetDriftVexDraftStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a new draft. Throws if a draft with the same ID already exists.
|
||||
/// </summary>
|
||||
Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves multiple drafts atomically.
|
||||
/// </summary>
|
||||
Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> drafts, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a draft by its unique ID.
|
||||
/// </summary>
|
||||
Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds drafts matching the query parameters.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<FacetDriftVexDraft>> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a draft's review status.
|
||||
/// </summary>
|
||||
Task UpdateReviewStatusAsync(
|
||||
string draftId,
|
||||
FacetDriftVexReviewStatus status,
|
||||
string? reviewedBy = null,
|
||||
string? reviewNotes = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending drafts that have passed their review deadline.
|
||||
/// </summary>
|
||||
Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes expired drafts older than the retention period.
|
||||
/// </summary>
|
||||
Task<int> PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a draft exists for the given image/facet combination.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended draft record with review tracking.
|
||||
/// </summary>
|
||||
public sealed record FacetDriftVexDraftWithReview
|
||||
{
|
||||
/// <summary>
|
||||
/// The original draft.
|
||||
/// </summary>
|
||||
public required FacetDriftVexDraft Draft { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current review status.
|
||||
/// </summary>
|
||||
public FacetDriftVexReviewStatus ReviewStatus { get; init; } = FacetDriftVexReviewStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Who reviewed the draft.
|
||||
/// </summary>
|
||||
public string? ReviewedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the draft was reviewed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReviewedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notes from the reviewer.
|
||||
/// </summary>
|
||||
public string? ReviewNotes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IFacetDriftVexDraftStore"/> for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryFacetDriftVexDraftStore : IFacetDriftVexDraftStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FacetDriftVexDraftWithReview> _drafts = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryFacetDriftVexDraftStore"/> class.
|
||||
/// </summary>
|
||||
public InMemoryFacetDriftVexDraftStore(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(FacetDriftVexDraft draft, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(draft);
|
||||
|
||||
var wrapper = new FacetDriftVexDraftWithReview { Draft = draft };
|
||||
if (!_drafts.TryAdd(draft.DraftId, wrapper))
|
||||
{
|
||||
throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveBatchAsync(IEnumerable<FacetDriftVexDraft> drafts, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drafts);
|
||||
|
||||
foreach (var draft in drafts)
|
||||
{
|
||||
var wrapper = new FacetDriftVexDraftWithReview { Draft = draft };
|
||||
if (!_drafts.TryAdd(draft.DraftId, wrapper))
|
||||
{
|
||||
throw new InvalidOperationException($"Draft with ID '{draft.DraftId}' already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FacetDriftVexDraft?> FindByIdAsync(string draftId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
|
||||
|
||||
_drafts.TryGetValue(draftId, out var wrapper);
|
||||
return Task.FromResult<FacetDriftVexDraft?>(wrapper?.Draft);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<FacetDriftVexDraft>> QueryAsync(FacetDriftVexDraftQuery query, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var results = _drafts.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ImageDigest))
|
||||
{
|
||||
results = results.Where(w => w.Draft.ImageDigest == query.ImageDigest);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.FacetId))
|
||||
{
|
||||
results = results.Where(w => w.Draft.FacetId == query.FacetId);
|
||||
}
|
||||
|
||||
if (query.ReviewStatus.HasValue)
|
||||
{
|
||||
results = results.Where(w => w.ReviewStatus == query.ReviewStatus.Value);
|
||||
}
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
results = results.Where(w => w.Draft.GeneratedAt >= query.Since.Value);
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
results = results.Where(w => w.Draft.GeneratedAt <= query.Until.Value);
|
||||
}
|
||||
|
||||
var paged = results
|
||||
.OrderByDescending(w => w.Draft.GeneratedAt)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.Select(w => w.Draft)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(paged);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateReviewStatusAsync(
|
||||
string draftId,
|
||||
FacetDriftVexReviewStatus status,
|
||||
string? reviewedBy = null,
|
||||
string? reviewNotes = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(draftId);
|
||||
|
||||
if (!_drafts.TryGetValue(draftId, out var wrapper))
|
||||
{
|
||||
throw new KeyNotFoundException($"Draft with ID '{draftId}' not found.");
|
||||
}
|
||||
|
||||
var updated = wrapper with
|
||||
{
|
||||
ReviewStatus = status,
|
||||
ReviewedBy = reviewedBy,
|
||||
ReviewedAt = _timeProvider.GetUtcNow(),
|
||||
ReviewNotes = reviewNotes
|
||||
};
|
||||
|
||||
_drafts[draftId] = updated;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueAsync(DateTimeOffset asOf, CancellationToken ct = default)
|
||||
{
|
||||
var overdue = _drafts.Values
|
||||
.Where(w => w.ReviewStatus == FacetDriftVexReviewStatus.Pending)
|
||||
.Where(w => w.Draft.ReviewDeadline < asOf)
|
||||
.Select(w => w.Draft)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(overdue);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeExpiredAsync(DateTimeOffset asOf, CancellationToken ct = default)
|
||||
{
|
||||
var expiredIds = _drafts
|
||||
.Where(kvp => kvp.Value.Draft.ExpiresAt < asOf)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in expiredIds)
|
||||
{
|
||||
_drafts.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult(expiredIds.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string imageDigest, string facetId, CancellationToken ct = default)
|
||||
{
|
||||
var exists = _drafts.Values.Any(w =>
|
||||
w.Draft.ImageDigest == imageDigest &&
|
||||
w.Draft.FacetId == facetId &&
|
||||
w.ReviewStatus == FacetDriftVexReviewStatus.Pending);
|
||||
|
||||
return Task.FromResult(exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all drafts for testing purposes.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<FacetDriftVexDraftWithReview> GetAllForTesting()
|
||||
=> _drafts.Values.ToList();
|
||||
}
|
||||
109
src/__Libraries/StellaOps.Facet/IFacetSealStore.cs
Normal file
109
src/__Libraries/StellaOps.Facet/IFacetSealStore.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
// <copyright file="IFacetSealStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent store for <see cref="FacetSeal"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations provide storage and retrieval of facet seals for drift detection
|
||||
/// and quota enforcement. Seals are indexed by image digest and creation time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-012)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IFacetSealStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the most recent seal for an image digest.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest (e.g., "sha256:{hex}").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The latest seal, or null if no seal exists for this image.</returns>
|
||||
Task<FacetSeal?> GetLatestSealAsync(string imageDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a seal by its combined Merkle root (unique identifier).
|
||||
/// </summary>
|
||||
/// <param name="combinedMerkleRoot">The seal's combined Merkle root.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The seal, or null if not found.</returns>
|
||||
Task<FacetSeal?> GetByCombinedRootAsync(string combinedMerkleRoot, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get seal history for an image digest.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="limit">Maximum number of seals to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Seals in descending order by creation time (most recent first).</returns>
|
||||
Task<ImmutableArray<FacetSeal>> GetHistoryAsync(
|
||||
string imageDigest,
|
||||
int limit = 10,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save a seal to the store.
|
||||
/// </summary>
|
||||
/// <param name="seal">The seal to save.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
/// <exception cref="ArgumentNullException">If seal is null.</exception>
|
||||
/// <exception cref="SealAlreadyExistsException">If a seal with the same combined root exists.</exception>
|
||||
Task SaveAsync(FacetSeal seal, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a seal exists for an image digest.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>True if at least one seal exists.</returns>
|
||||
Task<bool> ExistsAsync(string imageDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete all seals for an image digest.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of seals deleted.</returns>
|
||||
Task<int> DeleteByImageAsync(string imageDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Purge seals older than the specified retention period.
|
||||
/// </summary>
|
||||
/// <param name="retentionPeriod">Retention period from creation time.</param>
|
||||
/// <param name="keepAtLeast">Minimum seals to keep per image digest.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of seals purged.</returns>
|
||||
Task<int> PurgeOldSealsAsync(
|
||||
TimeSpan retentionPeriod,
|
||||
int keepAtLeast = 1,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when attempting to save a duplicate seal.
|
||||
/// </summary>
|
||||
public sealed class SealAlreadyExistsException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SealAlreadyExistsException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="combinedMerkleRoot">The duplicate seal's combined root.</param>
|
||||
public SealAlreadyExistsException(string combinedMerkleRoot)
|
||||
: base($"A seal with combined Merkle root '{combinedMerkleRoot}' already exists.")
|
||||
{
|
||||
CombinedMerkleRoot = combinedMerkleRoot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the duplicate seal's combined Merkle root.
|
||||
/// </summary>
|
||||
public string CombinedMerkleRoot { get; }
|
||||
}
|
||||
228
src/__Libraries/StellaOps.Facet/InMemoryFacetSealStore.cs
Normal file
228
src/__Libraries/StellaOps.Facet/InMemoryFacetSealStore.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
// <copyright file="InMemoryFacetSealStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Facet;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IFacetSealStore"/> for testing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Thread-safe but not persistent. Useful for unit tests and local development.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Sprint: SPRINT_20260105_002_003_FACET (QTA-012)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InMemoryFacetSealStore : IFacetSealStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, FacetSeal> _sealsByRoot = new();
|
||||
private readonly ConcurrentDictionary<string, SortedSet<string>> _rootsByImage = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<FacetSeal?> GetLatestSealAsync(string imageDigest, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
if (!_rootsByImage.TryGetValue(imageDigest, out var roots) || roots.Count == 0)
|
||||
{
|
||||
return Task.FromResult<FacetSeal?>(null);
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Get the most recent seal (highest creation time)
|
||||
FacetSeal? latest = null;
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (_sealsByRoot.TryGetValue(root, out var seal))
|
||||
{
|
||||
if (latest is null || seal.CreatedAt > latest.CreatedAt)
|
||||
{
|
||||
latest = seal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(latest);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<FacetSeal?> GetByCombinedRootAsync(string combinedMerkleRoot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(combinedMerkleRoot);
|
||||
|
||||
_sealsByRoot.TryGetValue(combinedMerkleRoot, out var seal);
|
||||
return Task.FromResult(seal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ImmutableArray<FacetSeal>> GetHistoryAsync(
|
||||
string imageDigest,
|
||||
int limit = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
|
||||
|
||||
if (!_rootsByImage.TryGetValue(imageDigest, out var roots) || roots.Count == 0)
|
||||
{
|
||||
return Task.FromResult(ImmutableArray<FacetSeal>.Empty);
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var seals = roots
|
||||
.Select(r => _sealsByRoot.TryGetValue(r, out var s) ? s : null)
|
||||
.Where(s => s is not null)
|
||||
.Cast<FacetSeal>()
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(seals);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SaveAsync(FacetSeal seal, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(seal);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_sealsByRoot.ContainsKey(seal.CombinedMerkleRoot))
|
||||
{
|
||||
throw new SealAlreadyExistsException(seal.CombinedMerkleRoot);
|
||||
}
|
||||
|
||||
_sealsByRoot[seal.CombinedMerkleRoot] = seal;
|
||||
|
||||
var roots = _rootsByImage.GetOrAdd(seal.ImageDigest, _ => new SortedSet<string>());
|
||||
lock (roots)
|
||||
{
|
||||
roots.Add(seal.CombinedMerkleRoot);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> ExistsAsync(string imageDigest, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
if (_rootsByImage.TryGetValue(imageDigest, out var roots))
|
||||
{
|
||||
lock (roots)
|
||||
{
|
||||
return Task.FromResult(roots.Count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<int> DeleteByImageAsync(string imageDigest, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_rootsByImage.TryRemove(imageDigest, out var roots))
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (_sealsByRoot.TryRemove(root, out _))
|
||||
{
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<int> PurgeOldSealsAsync(
|
||||
TimeSpan retentionPeriod,
|
||||
int keepAtLeast = 1,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(keepAtLeast);
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow - retentionPeriod;
|
||||
int purged = 0;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var (imageDigest, roots) in _rootsByImage)
|
||||
{
|
||||
// Get seals for this image, sorted by creation time descending
|
||||
var seals = roots
|
||||
.Select(r => _sealsByRoot.TryGetValue(r, out var s) ? s : null)
|
||||
.Where(s => s is not null)
|
||||
.Cast<FacetSeal>()
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
// Skip keepAtLeast, then purge old ones
|
||||
var toPurge = seals
|
||||
.Skip(keepAtLeast)
|
||||
.Where(s => s.CreatedAt < cutoff)
|
||||
.ToList();
|
||||
|
||||
foreach (var seal in toPurge)
|
||||
{
|
||||
if (_sealsByRoot.TryRemove(seal.CombinedMerkleRoot, out _))
|
||||
{
|
||||
lock (roots)
|
||||
{
|
||||
roots.Remove(seal.CombinedMerkleRoot);
|
||||
}
|
||||
|
||||
purged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(purged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all seals from the store.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sealsByRoot.Clear();
|
||||
_rootsByImage.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the total number of seals in the store.
|
||||
/// </summary>
|
||||
public int Count => _sealsByRoot.Count;
|
||||
}
|
||||
Reference in New Issue
Block a user