Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -46,6 +46,10 @@ public static class AirGapServiceCollectionExtensions
|
||||
timeProvider: timeProvider);
|
||||
});
|
||||
|
||||
// Register timeline emitter (CONCELIER-WEB-AIRGAP-58-001)
|
||||
services.TryAddSingleton<IBundleTimelineEmitter, BundleTimelineEmitter>();
|
||||
services.AddSingleton<IBundleTimelineEventSink, LoggingBundleTimelineEventSink>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Concelier.Core.AirGap.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of bundle timeline event emission.
|
||||
/// Per CONCELIER-WEB-AIRGAP-58-001.
|
||||
/// </summary>
|
||||
public sealed class BundleTimelineEmitter : IBundleTimelineEmitter
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<BundleTimelineEmitter> _logger;
|
||||
private readonly List<IBundleTimelineEventSink> _sinks;
|
||||
|
||||
public BundleTimelineEmitter(
|
||||
TimeProvider timeProvider,
|
||||
IEnumerable<IBundleTimelineEventSink> sinks,
|
||||
ILogger<BundleTimelineEmitter> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_sinks = sinks?.ToList() ?? [];
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task EmitImportAsync(
|
||||
BundleImportTimelineEvent timelineEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timelineEvent);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Emitting bundle import timeline event: TenantId={TenantId}, BundleId={BundleId}, SourceId={SourceId}, Type={BundleType}, Scope={Scope}, ItemsAdded={ItemsAdded}, ItemsUpdated={ItemsUpdated}",
|
||||
timelineEvent.TenantId,
|
||||
timelineEvent.BundleId,
|
||||
timelineEvent.SourceId,
|
||||
timelineEvent.BundleType,
|
||||
timelineEvent.Scope,
|
||||
timelineEvent.Stats.ItemsAdded,
|
||||
timelineEvent.Stats.ItemsUpdated);
|
||||
|
||||
// Emit to all registered sinks
|
||||
var tasks = _sinks.Select(sink => EmitToSinkAsync(sink, timelineEvent, cancellationToken));
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<BundleImportTimelineEvent> EmitImportAsync(
|
||||
BundleImportRequest request,
|
||||
BundleImportResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var timelineEvent = new BundleImportTimelineEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
TenantId = request.TenantId,
|
||||
BundleId = request.Bundle.BundleId,
|
||||
SourceId = request.Bundle.SourceId,
|
||||
BundleType = request.Bundle.Type,
|
||||
Scope = request.Scope,
|
||||
Actor = request.Actor,
|
||||
Stats = result.Stats,
|
||||
EvidenceBundleRef = result.EvidenceBundleRef,
|
||||
ContentHash = request.Bundle.ContentHash,
|
||||
OccurredAt = _timeProvider.GetUtcNow(),
|
||||
TraceId = request.TraceId ?? Activity.Current?.TraceId.ToString()
|
||||
};
|
||||
|
||||
await EmitImportAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return timelineEvent;
|
||||
}
|
||||
|
||||
private async Task EmitToSinkAsync(
|
||||
IBundleTimelineEventSink sink,
|
||||
BundleImportTimelineEvent timelineEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.WriteAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to emit timeline event to sink {SinkType}: EventId={EventId}, BundleId={BundleId}",
|
||||
sink.GetType().Name,
|
||||
timelineEvent.EventId,
|
||||
timelineEvent.BundleId);
|
||||
// Swallow exception to allow other sinks to process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sink for writing bundle timeline events to a destination.
|
||||
/// </summary>
|
||||
public interface IBundleTimelineEventSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes a timeline event.
|
||||
/// </summary>
|
||||
Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory sink for testing or local buffering.
|
||||
/// </summary>
|
||||
public sealed class InMemoryBundleTimelineEventSink : IBundleTimelineEventSink
|
||||
{
|
||||
private readonly List<BundleImportTimelineEvent> _events = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public IReadOnlyList<BundleImportTimelineEvent> Events
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _events.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_events.Add(timelineEvent);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_events.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logging sink that writes timeline events to structured logs.
|
||||
/// </summary>
|
||||
public sealed class LoggingBundleTimelineEventSink : IBundleTimelineEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingBundleTimelineEventSink> _logger;
|
||||
|
||||
public LoggingBundleTimelineEventSink(ILogger<LoggingBundleTimelineEventSink> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task WriteAsync(BundleImportTimelineEvent timelineEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"TIMELINE_EVENT: Type={Type}, EventId={EventId}, TenantId={TenantId}, BundleId={BundleId}, " +
|
||||
"SourceId={SourceId}, BundleType={BundleType}, Scope={Scope}, ActorId={ActorId}, " +
|
||||
"ItemsAdded={ItemsAdded}, ItemsUpdated={ItemsUpdated}, ItemsRemoved={ItemsRemoved}, " +
|
||||
"DurationMs={DurationMs}, ContentHash={ContentHash}, TraceId={TraceId}, OccurredAt={OccurredAt}",
|
||||
timelineEvent.Type,
|
||||
timelineEvent.EventId,
|
||||
timelineEvent.TenantId,
|
||||
timelineEvent.BundleId,
|
||||
timelineEvent.SourceId,
|
||||
timelineEvent.BundleType,
|
||||
timelineEvent.Scope,
|
||||
timelineEvent.Actor.Id,
|
||||
timelineEvent.Stats.ItemsAdded,
|
||||
timelineEvent.Stats.ItemsUpdated,
|
||||
timelineEvent.Stats.ItemsRemoved,
|
||||
timelineEvent.Stats.DurationMs,
|
||||
timelineEvent.ContentHash,
|
||||
timelineEvent.TraceId,
|
||||
timelineEvent.OccurredAt.ToString("O"));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using StellaOps.Concelier.Core.AirGap.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Service for emitting timeline events for bundle operations.
|
||||
/// Per CONCELIER-WEB-AIRGAP-58-001.
|
||||
/// </summary>
|
||||
public interface IBundleTimelineEmitter
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits a timeline event for a bundle import.
|
||||
/// </summary>
|
||||
Task EmitImportAsync(
|
||||
BundleImportTimelineEvent timelineEvent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates and emits a timeline event for a bundle import.
|
||||
/// </summary>
|
||||
Task<BundleImportTimelineEvent> EmitImportAsync(
|
||||
BundleImportRequest request,
|
||||
BundleImportResult result,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for a bundle import operation.
|
||||
/// </summary>
|
||||
public sealed record BundleImportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant performing the import.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle to import.
|
||||
/// </summary>
|
||||
public required BundleCatalogEntry Bundle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the import.
|
||||
/// </summary>
|
||||
public BundleImportScope Scope { get; init; } = BundleImportScope.Delta;
|
||||
|
||||
/// <summary>
|
||||
/// Actor performing the import.
|
||||
/// </summary>
|
||||
public required BundleImportActor Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional trace ID for correlation.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bundle import operation.
|
||||
/// </summary>
|
||||
public sealed record BundleImportResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the import succeeded.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Import statistics.
|
||||
/// </summary>
|
||||
public required BundleImportStats Stats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence bundle reference if generated.
|
||||
/// </summary>
|
||||
public string? EvidenceBundleRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using StellaOps.Concelier.Core.AirGap.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.AirGap;
|
||||
|
||||
// Per CONCELIER-WEB-AIRGAP-57-001: Egress blocking with remediation guidance
|
||||
|
||||
/// <summary>
|
||||
/// Enforces sealed mode by blocking direct internet feeds.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-001.
|
||||
@@ -37,16 +39,41 @@ public interface ISealedModeEnforcer
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a sealed mode violation occurs.
|
||||
/// Per CONCELIER-WEB-AIRGAP-57-001.
|
||||
/// </summary>
|
||||
public sealed class SealedModeViolationException : Exception
|
||||
{
|
||||
public SealedModeViolationException(string sourceName, Uri destination)
|
||||
: this(sourceName, destination, DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
public SealedModeViolationException(string sourceName, Uri destination, DateTimeOffset occurredAt)
|
||||
: base($"Sealed mode violation: source '{sourceName}' attempted to access '{destination}'")
|
||||
{
|
||||
SourceName = sourceName;
|
||||
Destination = destination;
|
||||
OccurredAt = occurredAt;
|
||||
Payload = AirGapEgressBlockedPayload.FromViolation(sourceName, destination, occurredAt, wasBlocked: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source name that attempted the egress.
|
||||
/// </summary>
|
||||
public string SourceName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Destination URI that was blocked.
|
||||
/// </summary>
|
||||
public Uri Destination { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the violation occurred.
|
||||
/// </summary>
|
||||
public DateTimeOffset OccurredAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Structured payload with remediation guidance.
|
||||
/// </summary>
|
||||
public AirGapEgressBlockedPayload Payload { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Concelier.Core.AirGap.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Structured payload for AIRGAP_EGRESS_BLOCKED events.
|
||||
/// Per CONCELIER-WEB-AIRGAP-57-001.
|
||||
/// </summary>
|
||||
public sealed record AirGapEgressBlockedPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code for this violation type.
|
||||
/// </summary>
|
||||
public const string ErrorCode = "AIRGAP_EGRESS_BLOCKED";
|
||||
|
||||
/// <summary>
|
||||
/// Source name that attempted the egress.
|
||||
/// </summary>
|
||||
public required string SourceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Destination URI that was blocked.
|
||||
/// </summary>
|
||||
public required string Destination { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Host portion of the destination.
|
||||
/// </summary>
|
||||
public required string DestinationHost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for blocking.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the violation occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this was actually blocked (vs. warn-only mode).
|
||||
/// </summary>
|
||||
public required bool WasBlocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Remediation guidance for the operator.
|
||||
/// </summary>
|
||||
public required AirGapRemediationGuidance Remediation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a payload from a violation exception.
|
||||
/// </summary>
|
||||
public static AirGapEgressBlockedPayload FromViolation(
|
||||
string sourceName,
|
||||
Uri destination,
|
||||
DateTimeOffset occurredAt,
|
||||
bool wasBlocked)
|
||||
{
|
||||
return new AirGapEgressBlockedPayload
|
||||
{
|
||||
SourceName = sourceName,
|
||||
Destination = destination.ToString(),
|
||||
DestinationHost = destination.Host,
|
||||
Reason = $"Source '{sourceName}' is not in the allowed sources list and host '{destination.Host}' is not in the allowed hosts list.",
|
||||
OccurredAt = occurredAt,
|
||||
WasBlocked = wasBlocked,
|
||||
Remediation = AirGapRemediationGuidance.ForEgressBlocked(sourceName, destination.Host)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remediation guidance for air-gap violations.
|
||||
/// Per CONCELIER-WEB-AIRGAP-57-001.
|
||||
/// </summary>
|
||||
public sealed record AirGapRemediationGuidance
|
||||
{
|
||||
/// <summary>
|
||||
/// Short summary of what to do.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed steps to remediate the issue.
|
||||
/// </summary>
|
||||
public required ImmutableArray<RemediationStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Configuration keys that can be modified to allow this access.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ConfigurationHint> ConfigurationHints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Links to relevant documentation.
|
||||
/// </summary>
|
||||
public required ImmutableArray<DocumentationLink> DocumentationLinks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates remediation guidance for an egress blocked violation.
|
||||
/// </summary>
|
||||
public static AirGapRemediationGuidance ForEgressBlocked(string sourceName, string host)
|
||||
{
|
||||
return new AirGapRemediationGuidance
|
||||
{
|
||||
Summary = $"Add '{sourceName}' to allowed sources or '{host}' to allowed hosts to permit this access.",
|
||||
Steps = ImmutableArray.Create(
|
||||
new RemediationStep(
|
||||
Order: 1,
|
||||
Action: "Review the blocked access",
|
||||
Description: $"Verify that '{sourceName}' should be allowed to access '{host}' based on your security policy."),
|
||||
new RemediationStep(
|
||||
Order: 2,
|
||||
Action: "Update configuration",
|
||||
Description: "Add the source or host to the appropriate allowlist in your configuration."),
|
||||
new RemediationStep(
|
||||
Order: 3,
|
||||
Action: "Restart or reload",
|
||||
Description: "Restart the service or trigger a configuration reload for changes to take effect.")
|
||||
),
|
||||
ConfigurationHints = ImmutableArray.Create(
|
||||
new ConfigurationHint(
|
||||
Key: "Concelier:AirGap:SealedMode:AllowedSources",
|
||||
Description: $"Add '{sourceName}' to this list to allow the source.",
|
||||
Example: $"[\"{sourceName}\"]"),
|
||||
new ConfigurationHint(
|
||||
Key: "Concelier:AirGap:SealedMode:AllowedHosts",
|
||||
Description: $"Add '{host}' to this list to allow the destination host.",
|
||||
Example: $"[\"{host}\"]")
|
||||
),
|
||||
DocumentationLinks = ImmutableArray.Create(
|
||||
new DocumentationLink(
|
||||
Title: "Air-Gap Configuration Guide",
|
||||
Url: "https://docs.stellaops.org/concelier/airgap/configuration"),
|
||||
new DocumentationLink(
|
||||
Title: "Sealed Mode Reference",
|
||||
Url: "https://docs.stellaops.org/concelier/airgap/sealed-mode")
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A remediation step.
|
||||
/// </summary>
|
||||
public sealed record RemediationStep(
|
||||
int Order,
|
||||
string Action,
|
||||
string Description);
|
||||
|
||||
/// <summary>
|
||||
/// A configuration hint for remediation.
|
||||
/// </summary>
|
||||
public sealed record ConfigurationHint(
|
||||
string Key,
|
||||
string Description,
|
||||
string Example);
|
||||
|
||||
/// <summary>
|
||||
/// A link to documentation.
|
||||
/// </summary>
|
||||
public sealed record DocumentationLink(
|
||||
string Title,
|
||||
string Url);
|
||||
@@ -0,0 +1,161 @@
|
||||
namespace StellaOps.Concelier.Core.AirGap.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Timeline event emitted when a bundle is imported.
|
||||
/// Per CONCELIER-WEB-AIRGAP-58-001.
|
||||
/// </summary>
|
||||
public sealed record BundleImportTimelineEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Event type identifier.
|
||||
/// </summary>
|
||||
public const string EventType = "airgap.bundle.imported";
|
||||
|
||||
/// <summary>
|
||||
/// Unique event identifier.
|
||||
/// </summary>
|
||||
public required Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the event (always "airgap.bundle.imported").
|
||||
/// </summary>
|
||||
public string Type => EventType;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant that owns this import.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle identifier.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source that provided the bundle.
|
||||
/// </summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (advisory, vex, sbom, etc.).
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the import (full, delta, patch).
|
||||
/// </summary>
|
||||
public required BundleImportScope Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who performed the import.
|
||||
/// </summary>
|
||||
public required BundleImportActor Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Import statistics.
|
||||
/// </summary>
|
||||
public required BundleImportStats Stats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence bundle reference if applicable.
|
||||
/// </summary>
|
||||
public string? EvidenceBundleRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash of the imported bundle.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the import occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Correlation trace ID for distributed tracing.
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the bundle import.
|
||||
/// </summary>
|
||||
public enum BundleImportScope
|
||||
{
|
||||
/// <summary>
|
||||
/// Full import replacing all existing data.
|
||||
/// </summary>
|
||||
Full,
|
||||
|
||||
/// <summary>
|
||||
/// Delta import with only changes.
|
||||
/// </summary>
|
||||
Delta,
|
||||
|
||||
/// <summary>
|
||||
/// Patch import for specific corrections.
|
||||
/// </summary>
|
||||
Patch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actor information for the import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportActor
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor type (user, service, system).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor display name.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for the bundle import.
|
||||
/// </summary>
|
||||
public sealed record BundleImportStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total items in the bundle.
|
||||
/// </summary>
|
||||
public int TotalItems { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Items added during import.
|
||||
/// </summary>
|
||||
public int ItemsAdded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Items updated during import.
|
||||
/// </summary>
|
||||
public int ItemsUpdated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Items removed during import.
|
||||
/// </summary>
|
||||
public int ItemsRemoved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Items skipped (unchanged).
|
||||
/// </summary>
|
||||
public int ItemsSkipped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the import in milliseconds.
|
||||
/// </summary>
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle size in bytes.
|
||||
/// </summary>
|
||||
public long SizeBytes { get; init; }
|
||||
}
|
||||
@@ -72,7 +72,7 @@ public sealed class SealedModeEnforcer : ISealedModeEnforcer
|
||||
"Sealed mode violation blocked: source '{SourceName}' attempted to access '{Destination}'",
|
||||
sourceName, destination);
|
||||
|
||||
throw new SealedModeViolationException(sourceName, destination);
|
||||
throw new SealedModeViolationException(sourceName, destination, _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user