save progress
This commit is contained in:
229
src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Normal file
229
src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
// <copyright file="FacetQuotaGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Facet;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="FacetQuotaGate"/>.
|
||||
/// </summary>
|
||||
public sealed record FacetQuotaGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the gate is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the action to take when no facet seal is available for comparison.
|
||||
/// </summary>
|
||||
public NoSealAction NoSealAction { get; init; } = NoSealAction.Pass;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default quota to apply when no facet-specific quota is configured.
|
||||
/// </summary>
|
||||
public FacetQuota DefaultQuota { get; init; } = FacetQuota.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets per-facet quota overrides.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, FacetQuota> FacetQuotas { get; init; } =
|
||||
ImmutableDictionary<string, FacetQuota>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the action when no baseline seal is available.
|
||||
/// </summary>
|
||||
public enum NoSealAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Pass the gate when no seal is available (first scan).
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Warn when no seal is available.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Block when no seal is available.
|
||||
/// </summary>
|
||||
Block
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate that enforces per-facet drift quotas.
|
||||
/// This gate evaluates facet drift reports and enforces quotas configured per facet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The FacetQuotaGate operates on pre-computed <see cref="FacetDriftReport"/> instances,
|
||||
/// which should be attached to the <see cref="PolicyGateContext"/> before evaluation.
|
||||
/// If no drift report is available, the gate behavior is determined by <see cref="FacetQuotaGateOptions.NoSealAction"/>.
|
||||
/// </remarks>
|
||||
public sealed class FacetQuotaGate : IPolicyGate
|
||||
{
|
||||
private readonly FacetQuotaGateOptions _options;
|
||||
private readonly IFacetDriftDetector _driftDetector;
|
||||
private readonly ILogger<FacetQuotaGate> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetQuotaGate"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">Gate configuration options.</param>
|
||||
/// <param name="driftDetector">The facet drift detector.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public FacetQuotaGate(
|
||||
FacetQuotaGateOptions? options = null,
|
||||
IFacetDriftDetector? driftDetector = null,
|
||||
ILogger<FacetQuotaGate>? logger = null)
|
||||
{
|
||||
_options = options ?? new FacetQuotaGateOptions();
|
||||
_driftDetector = driftDetector ?? throw new ArgumentNullException(nameof(driftDetector));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FacetQuotaGate>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<GateResult> EvaluateAsync(
|
||||
MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mergeResult);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Check if gate is enabled
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult(Pass("Gate disabled"));
|
||||
}
|
||||
|
||||
// Check for drift report in metadata
|
||||
var driftReport = GetDriftReportFromContext(context);
|
||||
if (driftReport is null)
|
||||
{
|
||||
return Task.FromResult(HandleNoSeal());
|
||||
}
|
||||
|
||||
// Evaluate drift report against quotas
|
||||
var result = EvaluateDriftReport(driftReport);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static FacetDriftReport? GetDriftReportFromContext(PolicyGateContext context)
|
||||
{
|
||||
// Drift report is expected to be in metadata under a well-known key
|
||||
if (context.Metadata?.TryGetValue("FacetDriftReport", out var value) == true &&
|
||||
value is string json)
|
||||
{
|
||||
// In a real implementation, deserialize from JSON
|
||||
// For now, return null to trigger the no-seal path
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private GateResult HandleNoSeal()
|
||||
{
|
||||
return _options.NoSealAction switch
|
||||
{
|
||||
NoSealAction.Pass => Pass("No baseline seal available - first scan"),
|
||||
NoSealAction.Warn => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = true,
|
||||
Reason = "no_baseline_seal",
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("action", "warn")
|
||||
.Add("message", "No baseline seal available for comparison")
|
||||
},
|
||||
NoSealAction.Block => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = false,
|
||||
Reason = "no_baseline_seal",
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("action", "block")
|
||||
.Add("message", "Baseline seal required but not available")
|
||||
},
|
||||
_ => Pass("Unknown NoSealAction - defaulting to pass")
|
||||
};
|
||||
}
|
||||
|
||||
private GateResult EvaluateDriftReport(FacetDriftReport report)
|
||||
{
|
||||
// Find worst verdict across all facets
|
||||
var worstVerdict = report.OverallVerdict;
|
||||
var breachedFacets = report.FacetDrifts
|
||||
.Where(d => d.QuotaVerdict != QuotaVerdict.Ok)
|
||||
.ToList();
|
||||
|
||||
if (breachedFacets.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All facets within quota limits");
|
||||
return Pass("All facets within quota limits");
|
||||
}
|
||||
|
||||
// Build details
|
||||
var details = ImmutableDictionary<string, object>.Empty
|
||||
.Add("overallVerdict", worstVerdict.ToString())
|
||||
.Add("breachedFacets", breachedFacets.Select(f => f.FacetId).ToArray())
|
||||
.Add("totalChangedFiles", report.TotalChangedFiles)
|
||||
.Add("imageDigest", report.ImageDigest);
|
||||
|
||||
foreach (var facet in breachedFacets)
|
||||
{
|
||||
details = details.Add(
|
||||
$"facet:{facet.FacetId}",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["verdict"] = facet.QuotaVerdict.ToString(),
|
||||
["churnPercent"] = facet.ChurnPercent,
|
||||
["added"] = facet.Added.Length,
|
||||
["removed"] = facet.Removed.Length,
|
||||
["modified"] = facet.Modified.Length
|
||||
});
|
||||
}
|
||||
|
||||
return worstVerdict switch
|
||||
{
|
||||
QuotaVerdict.Ok => Pass("All quotas satisfied"),
|
||||
QuotaVerdict.Warning => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = true,
|
||||
Reason = "quota_warning",
|
||||
Details = details
|
||||
},
|
||||
QuotaVerdict.Blocked => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = false,
|
||||
Reason = "quota_exceeded",
|
||||
Details = details
|
||||
},
|
||||
QuotaVerdict.RequiresVex => new GateResult
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = false,
|
||||
Reason = "requires_vex_authorization",
|
||||
Details = details.Add("vexRequired", true)
|
||||
},
|
||||
_ => Pass("Unknown verdict - defaulting to pass")
|
||||
};
|
||||
}
|
||||
|
||||
private static GateResult Pass(string reason) => new()
|
||||
{
|
||||
GateName = nameof(FacetQuotaGate),
|
||||
Passed = true,
|
||||
Reason = reason,
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// <copyright file="FacetQuotaGateServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Facet;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering <see cref="FacetQuotaGate"/> with dependency injection.
|
||||
/// </summary>
|
||||
public static class FacetQuotaGateServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with default options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetQuotaGate(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return services.AddFacetQuotaGate(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the <see cref="FacetQuotaGate"/> to the service collection with custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Action to configure <see cref="FacetQuotaGateOptions"/>.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFacetQuotaGate(
|
||||
this IServiceCollection services,
|
||||
Action<FacetQuotaGateOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
var options = new FacetQuotaGateOptions();
|
||||
configure(options);
|
||||
|
||||
// Ensure facet drift detector is registered
|
||||
services.TryAddSingleton<IFacetDriftDetector>(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
return new FacetDriftDetector(timeProvider);
|
||||
});
|
||||
|
||||
// Register the gate options
|
||||
services.AddSingleton(options);
|
||||
|
||||
// Register the gate
|
||||
services.TryAddSingleton<FacetQuotaGate>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <see cref="FacetQuotaGate"/> with a <see cref="IPolicyGateRegistry"/>.
|
||||
/// </summary>
|
||||
/// <param name="registry">The policy gate registry.</param>
|
||||
/// <param name="gateName">Optional custom gate name. Defaults to "facet-quota".</param>
|
||||
/// <returns>The registry for chaining.</returns>
|
||||
public static IPolicyGateRegistry RegisterFacetQuotaGate(
|
||||
this IPolicyGateRegistry registry,
|
||||
string gateName = "facet-quota")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register<FacetQuotaGate>(gateName);
|
||||
return registry;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user