feat: Add Bun language analyzer and related functionality

- Implemented BunPackageNormalizer to deduplicate packages by name and version.
- Created BunProjectDiscoverer to identify Bun project roots in the filesystem.
- Added project files for the Bun analyzer including manifest and project configuration.
- Developed comprehensive tests for Bun language analyzer covering various scenarios.
- Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces.
- Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -0,0 +1,327 @@
using StellaOps.Orchestrator.Core.Domain.AirGap;
namespace StellaOps.Orchestrator.Core.AirGap;
/// <summary>
/// Service for validating air-gap staleness against configured thresholds.
/// Per ORCH-AIRGAP-56-002.
/// </summary>
public interface IStalenessValidator
{
/// <summary>
/// Validates staleness for a specific domain.
/// </summary>
StalenessValidationResult ValidateDomain(
string domainId,
DomainStalenessMetric metric,
StalenessConfig config,
StalenessValidationContext context,
DateTimeOffset now);
/// <summary>
/// Validates staleness across multiple domains required for a job.
/// </summary>
StalenessValidationResult ValidateForJob(
IEnumerable<string> requiredDomains,
IReadOnlyDictionary<string, DomainStalenessMetric> domainMetrics,
StalenessConfig config,
DateTimeOffset now);
/// <summary>
/// Generates warnings for domains approaching staleness threshold.
/// </summary>
IReadOnlyList<StalenessWarning> GetApproachingThresholdWarnings(
IReadOnlyDictionary<string, DomainStalenessMetric> domainMetrics,
StalenessConfig config);
}
/// <summary>
/// Default implementation of staleness validator.
/// </summary>
public sealed class StalenessValidator : IStalenessValidator
{
/// <summary>
/// Validates staleness for a specific domain.
/// </summary>
public StalenessValidationResult ValidateDomain(
string domainId,
DomainStalenessMetric metric,
StalenessConfig config,
StalenessValidationContext context,
DateTimeOffset now)
{
ArgumentException.ThrowIfNullOrWhiteSpace(domainId);
ArgumentNullException.ThrowIfNull(metric);
ArgumentNullException.ThrowIfNull(config);
// Check if domain is exempt
if (config.IsDomainExempt(domainId))
{
return StalenessValidationResult.Pass(
now,
context,
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
config.EnforcementMode);
}
// Skip validation if disabled
if (config.EnforcementMode == StalenessEnforcementMode.Disabled)
{
return StalenessValidationResult.Pass(
now,
context,
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
config.EnforcementMode);
}
// Calculate effective threshold including grace period
var effectiveThreshold = config.FreshnessThresholdSeconds + config.GracePeriodSeconds;
// Check if stale
if (metric.StalenessSeconds > effectiveThreshold)
{
var error = new StalenessError(
StalenessErrorCode.AirgapStale,
$"Domain '{domainId}' data is stale ({FormatDuration(metric.StalenessSeconds)}, threshold {FormatDuration(config.FreshnessThresholdSeconds)})",
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
$"Import a fresh bundle for '{domainId}' from upstream using 'stella airgap import'");
var warnings = GetWarningsForMetric(domainId, metric, config);
return StalenessValidationResult.Fail(
now,
context,
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
config.EnforcementMode,
error,
warnings);
}
// Check for warnings (approaching threshold)
var validationWarnings = GetWarningsForMetric(domainId, metric, config);
return StalenessValidationResult.Pass(
now,
context,
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
config.EnforcementMode,
validationWarnings.Count > 0 ? validationWarnings : null);
}
/// <summary>
/// Validates staleness across multiple domains required for a job.
/// </summary>
public StalenessValidationResult ValidateForJob(
IEnumerable<string> requiredDomains,
IReadOnlyDictionary<string, DomainStalenessMetric> domainMetrics,
StalenessConfig config,
DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(requiredDomains);
ArgumentNullException.ThrowIfNull(domainMetrics);
ArgumentNullException.ThrowIfNull(config);
var domains = requiredDomains.ToList();
if (domains.Count == 0)
{
// No domain requirements - pass
return StalenessValidationResult.Pass(
now,
StalenessValidationContext.JobScheduling,
null,
0,
config.FreshnessThresholdSeconds,
config.EnforcementMode);
}
// Skip validation if disabled
if (config.EnforcementMode == StalenessEnforcementMode.Disabled)
{
return StalenessValidationResult.Pass(
now,
StalenessValidationContext.JobScheduling,
null,
0,
config.FreshnessThresholdSeconds,
config.EnforcementMode);
}
var allWarnings = new List<StalenessWarning>();
var effectiveThreshold = config.FreshnessThresholdSeconds + config.GracePeriodSeconds;
var maxStaleness = 0;
string? stalestDomain = null;
foreach (var domainId in domains)
{
// Check if domain is exempt
if (config.IsDomainExempt(domainId))
{
continue;
}
// Check if we have metrics for this domain
if (!domainMetrics.TryGetValue(domainId, out var metric))
{
// No bundle for domain
var noBundleError = new StalenessError(
StalenessErrorCode.AirgapNoBundle,
$"No bundle available for domain '{domainId}'",
domainId,
null,
config.FreshnessThresholdSeconds,
$"Import a bundle for '{domainId}' from upstream using 'stella airgap import'");
return StalenessValidationResult.Fail(
now,
StalenessValidationContext.JobScheduling,
domainId,
0,
config.FreshnessThresholdSeconds,
config.EnforcementMode,
noBundleError);
}
// Track max staleness
if (metric.StalenessSeconds > maxStaleness)
{
maxStaleness = metric.StalenessSeconds;
stalestDomain = domainId;
}
// Check if stale
if (metric.StalenessSeconds > effectiveThreshold)
{
var error = new StalenessError(
StalenessErrorCode.AirgapStale,
$"Domain '{domainId}' data is stale ({FormatDuration(metric.StalenessSeconds)}, threshold {FormatDuration(config.FreshnessThresholdSeconds)})",
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
$"Import a fresh bundle for '{domainId}' from upstream using 'stella airgap import'");
return StalenessValidationResult.Fail(
now,
StalenessValidationContext.JobScheduling,
domainId,
metric.StalenessSeconds,
config.FreshnessThresholdSeconds,
config.EnforcementMode,
error,
allWarnings.Count > 0 ? allWarnings : null);
}
// Collect warnings
allWarnings.AddRange(GetWarningsForMetric(domainId, metric, config));
}
return StalenessValidationResult.Pass(
now,
StalenessValidationContext.JobScheduling,
stalestDomain,
maxStaleness,
config.FreshnessThresholdSeconds,
config.EnforcementMode,
allWarnings.Count > 0 ? allWarnings : null);
}
/// <summary>
/// Generates warnings for domains approaching staleness threshold.
/// </summary>
public IReadOnlyList<StalenessWarning> GetApproachingThresholdWarnings(
IReadOnlyDictionary<string, DomainStalenessMetric> domainMetrics,
StalenessConfig config)
{
ArgumentNullException.ThrowIfNull(domainMetrics);
ArgumentNullException.ThrowIfNull(config);
var warnings = new List<StalenessWarning>();
foreach (var (domainId, metric) in domainMetrics)
{
if (config.IsDomainExempt(domainId))
{
continue;
}
warnings.AddRange(GetWarningsForMetric(domainId, metric, config));
}
return warnings;
}
private static List<StalenessWarning> GetWarningsForMetric(
string domainId,
DomainStalenessMetric metric,
StalenessConfig config)
{
var warnings = new List<StalenessWarning>();
var percentOfThreshold = (double)metric.StalenessSeconds / config.FreshnessThresholdSeconds * 100;
// Check notification thresholds
if (config.NotificationThresholds is not null)
{
foreach (var threshold in config.NotificationThresholds.OrderByDescending(t => t.PercentOfThreshold))
{
if (percentOfThreshold >= threshold.PercentOfThreshold)
{
var warningCode = threshold.Severity switch
{
NotificationSeverity.Critical => StalenessWarningCode.AirgapApproachingStale,
NotificationSeverity.Warning => StalenessWarningCode.AirgapBundleOld,
_ => StalenessWarningCode.AirgapNoRecentImport
};
var severityText = threshold.Severity switch
{
NotificationSeverity.Critical => "critical",
NotificationSeverity.Warning => "warning",
_ => "info"
};
warnings.Add(new StalenessWarning(
warningCode,
$"Domain '{domainId}' at {percentOfThreshold:F0}% of staleness threshold ({severityText})",
percentOfThreshold,
metric.ProjectedStaleAt));
break; // Only report highest severity threshold
}
}
}
else if (percentOfThreshold >= 75)
{
// Default warning at 75%
warnings.Add(new StalenessWarning(
StalenessWarningCode.AirgapApproachingStale,
$"Domain '{domainId}' at {percentOfThreshold:F0}% of staleness threshold",
percentOfThreshold,
metric.ProjectedStaleAt));
}
return warnings;
}
private static string FormatDuration(int seconds)
{
var span = TimeSpan.FromSeconds(seconds);
if (span.TotalDays >= 1)
{
return $"{span.TotalDays:F1} days";
}
if (span.TotalHours >= 1)
{
return $"{span.TotalHours:F1} hours";
}
return $"{span.TotalMinutes:F0} minutes";
}
}

View File

@@ -0,0 +1,116 @@
namespace StellaOps.Orchestrator.Core.Domain.AirGap;
/// <summary>
/// Provenance record for an imported air-gap bundle.
/// Per ORCH-AIRGAP-56-002 and ledger-airgap-staleness.schema.json.
/// </summary>
public sealed record BundleProvenance(
/// <summary>Unique bundle identifier.</summary>
Guid BundleId,
/// <summary>Bundle domain (vex-advisories, vulnerability-feeds, etc.).</summary>
string DomainId,
/// <summary>When bundle was imported into this environment.</summary>
DateTimeOffset ImportedAt,
/// <summary>Original generation timestamp from source environment.</summary>
DateTimeOffset SourceTimestamp,
/// <summary>Source environment identifier.</summary>
string? SourceEnvironment,
/// <summary>SHA-256 digest of the bundle contents.</summary>
string? BundleDigest,
/// <summary>SHA-256 digest of the bundle manifest.</summary>
string? ManifestDigest,
/// <summary>Time anchor used for staleness calculation.</summary>
TimeAnchor? TimeAnchor,
/// <summary>Exports included in this bundle.</summary>
IReadOnlyList<ExportRecord>? Exports,
/// <summary>Additional bundle metadata.</summary>
IReadOnlyDictionary<string, string>? Metadata)
{
/// <summary>
/// Calculates staleness in seconds (importedAt - sourceTimestamp).
/// </summary>
public int StalenessSeconds => (int)(ImportedAt - SourceTimestamp).TotalSeconds;
/// <summary>
/// Calculates current staleness based on provided time reference.
/// </summary>
public int CurrentStalenessSeconds(DateTimeOffset now) => (int)(now - SourceTimestamp).TotalSeconds;
}
/// <summary>
/// Trusted time reference for staleness calculations.
/// </summary>
public sealed record TimeAnchor(
/// <summary>Type of time anchor.</summary>
TimeAnchorType AnchorType,
/// <summary>Anchor timestamp (UTC).</summary>
DateTimeOffset Timestamp,
/// <summary>Time source identifier.</summary>
string? Source,
/// <summary>Time uncertainty in milliseconds.</summary>
int? Uncertainty,
/// <summary>Digest of time attestation signature if applicable.</summary>
string? SignatureDigest,
/// <summary>Whether time anchor was cryptographically verified.</summary>
bool Verified);
/// <summary>
/// Type of time anchor for staleness calculations.
/// </summary>
public enum TimeAnchorType
{
Ntp,
Roughtime,
HardwareClock,
AttestationTsa,
Manual
}
/// <summary>
/// Record of an export included in a bundle.
/// </summary>
public sealed record ExportRecord(
/// <summary>Export identifier.</summary>
Guid ExportId,
/// <summary>Export key.</summary>
string Key,
/// <summary>Export data format.</summary>
ExportFormat Format,
/// <summary>When export was created.</summary>
DateTimeOffset CreatedAt,
/// <summary>Export artifact digest.</summary>
string ArtifactDigest,
/// <summary>Number of records in export.</summary>
int? RecordCount);
/// <summary>
/// Export data format.
/// </summary>
public enum ExportFormat
{
OpenVex,
Csaf,
CycloneDx,
Spdx,
Ndjson,
Json
}

View File

@@ -0,0 +1,104 @@
namespace StellaOps.Orchestrator.Core.Domain.AirGap;
/// <summary>
/// Represents the current sealing status for air-gap mode.
/// Per ORCH-AIRGAP-56-002.
/// </summary>
public sealed record SealingStatus(
/// <summary>Whether the environment is currently sealed (air-gapped).</summary>
bool IsSealed,
/// <summary>When the environment was sealed.</summary>
DateTimeOffset? SealedAt,
/// <summary>Actor who sealed the environment.</summary>
string? SealedBy,
/// <summary>Reason for sealing.</summary>
string? SealReason,
/// <summary>Per-domain staleness metrics.</summary>
IReadOnlyDictionary<string, DomainStalenessMetric>? DomainStaleness,
/// <summary>Aggregate staleness metrics.</summary>
AggregateMetrics? Aggregates,
/// <summary>When staleness metrics were last calculated.</summary>
DateTimeOffset? MetricsCollectedAt)
{
/// <summary>
/// An unsealed (online) environment status.
/// </summary>
public static readonly SealingStatus Unsealed = new(
IsSealed: false,
SealedAt: null,
SealedBy: null,
SealReason: null,
DomainStaleness: null,
Aggregates: null,
MetricsCollectedAt: null);
/// <summary>
/// Gets the staleness for a specific domain.
/// </summary>
public DomainStalenessMetric? GetDomainStaleness(string domainId)
=> DomainStaleness?.GetValueOrDefault(domainId);
/// <summary>
/// Checks if any domain has exceeded staleness threshold.
/// </summary>
public bool HasStaleDomains => Aggregates?.StaleDomains > 0;
}
/// <summary>
/// Staleness metrics for a specific domain.
/// </summary>
public sealed record DomainStalenessMetric(
/// <summary>Domain identifier.</summary>
string DomainId,
/// <summary>Current staleness in seconds.</summary>
int StalenessSeconds,
/// <summary>Last bundle import timestamp.</summary>
DateTimeOffset LastImportAt,
/// <summary>Source timestamp of last import.</summary>
DateTimeOffset LastSourceTimestamp,
/// <summary>Total bundles imported for this domain.</summary>
int BundleCount,
/// <summary>Whether domain data exceeds staleness threshold.</summary>
bool IsStale,
/// <summary>Staleness as percentage of threshold.</summary>
double PercentOfThreshold,
/// <summary>When data will become stale if no updates.</summary>
DateTimeOffset? ProjectedStaleAt);
/// <summary>
/// Aggregate staleness metrics across all domains.
/// </summary>
public sealed record AggregateMetrics(
/// <summary>Total domains tracked.</summary>
int TotalDomains,
/// <summary>Domains exceeding staleness threshold.</summary>
int StaleDomains,
/// <summary>Domains approaching staleness threshold.</summary>
int WarningDomains,
/// <summary>Domains within healthy staleness range.</summary>
int HealthyDomains,
/// <summary>Maximum staleness across all domains.</summary>
int MaxStalenessSeconds,
/// <summary>Average staleness across all domains.</summary>
double AvgStalenessSeconds,
/// <summary>Timestamp of oldest bundle source data.</summary>
DateTimeOffset? OldestBundle);

View File

@@ -0,0 +1,88 @@
namespace StellaOps.Orchestrator.Core.Domain.AirGap;
/// <summary>
/// Configuration for air-gap staleness enforcement policies.
/// Per ORCH-AIRGAP-56-002.
/// </summary>
public sealed record StalenessConfig(
/// <summary>Maximum age in seconds before data is considered stale (default: 7 days = 604800).</summary>
int FreshnessThresholdSeconds = 604800,
/// <summary>How staleness violations are handled.</summary>
StalenessEnforcementMode EnforcementMode = StalenessEnforcementMode.Strict,
/// <summary>Grace period after threshold before hard enforcement (default: 1 day = 86400).</summary>
int GracePeriodSeconds = 86400,
/// <summary>Domains exempt from staleness enforcement.</summary>
IReadOnlyList<string>? AllowedDomains = null,
/// <summary>Alert thresholds for approaching staleness.</summary>
IReadOnlyList<NotificationThreshold>? NotificationThresholds = null)
{
/// <summary>
/// Default staleness configuration.
/// </summary>
public static readonly StalenessConfig Default = new();
/// <summary>
/// Creates a disabled staleness configuration.
/// </summary>
public static StalenessConfig Disabled() => new(EnforcementMode: StalenessEnforcementMode.Disabled);
/// <summary>
/// Checks if a domain is exempt from staleness enforcement.
/// </summary>
public bool IsDomainExempt(string domainId)
=> AllowedDomains?.Contains(domainId, StringComparer.OrdinalIgnoreCase) == true;
}
/// <summary>
/// How staleness violations are handled.
/// </summary>
public enum StalenessEnforcementMode
{
/// <summary>Violations block execution with error.</summary>
Strict,
/// <summary>Violations generate warnings but allow execution.</summary>
Warn,
/// <summary>Staleness checking is disabled.</summary>
Disabled
}
/// <summary>
/// Alert threshold for approaching staleness.
/// </summary>
public sealed record NotificationThreshold(
/// <summary>Percentage of freshness threshold to trigger notification (1-100).</summary>
int PercentOfThreshold,
/// <summary>Notification severity level.</summary>
NotificationSeverity Severity,
/// <summary>Notification delivery channels.</summary>
IReadOnlyList<NotificationChannel>? Channels = null);
/// <summary>
/// Notification severity level.
/// </summary>
public enum NotificationSeverity
{
Info,
Warning,
Critical
}
/// <summary>
/// Notification delivery channel.
/// </summary>
public enum NotificationChannel
{
Email,
Slack,
Teams,
Webhook,
Metric
}

View File

@@ -0,0 +1,172 @@
namespace StellaOps.Orchestrator.Core.Domain.AirGap;
/// <summary>
/// Result of staleness validation check.
/// Per ORCH-AIRGAP-56-002 and ledger-airgap-staleness.schema.json.
/// </summary>
public sealed record StalenessValidationResult(
/// <summary>When validation was performed.</summary>
DateTimeOffset ValidatedAt,
/// <summary>Whether validation passed.</summary>
bool Passed,
/// <summary>Context where validation was triggered.</summary>
StalenessValidationContext Context,
/// <summary>Domain being validated.</summary>
string? DomainId,
/// <summary>Current staleness at validation time.</summary>
int StalenessSeconds,
/// <summary>Threshold used for validation.</summary>
int ThresholdSeconds,
/// <summary>Enforcement mode at validation time.</summary>
StalenessEnforcementMode EnforcementMode,
/// <summary>Error details if validation failed.</summary>
StalenessError? Error,
/// <summary>Warnings generated during validation.</summary>
IReadOnlyList<StalenessWarning>? Warnings)
{
/// <summary>
/// Creates a passing validation result.
/// </summary>
public static StalenessValidationResult Pass(
DateTimeOffset validatedAt,
StalenessValidationContext context,
string? domainId,
int stalenessSeconds,
int thresholdSeconds,
StalenessEnforcementMode enforcementMode,
IReadOnlyList<StalenessWarning>? warnings = null)
=> new(validatedAt, true, context, domainId, stalenessSeconds, thresholdSeconds, enforcementMode, null, warnings);
/// <summary>
/// Creates a failing validation result.
/// </summary>
public static StalenessValidationResult Fail(
DateTimeOffset validatedAt,
StalenessValidationContext context,
string? domainId,
int stalenessSeconds,
int thresholdSeconds,
StalenessEnforcementMode enforcementMode,
StalenessError error,
IReadOnlyList<StalenessWarning>? warnings = null)
=> new(validatedAt, false, context, domainId, stalenessSeconds, thresholdSeconds, enforcementMode, error, warnings);
/// <summary>
/// Whether this result should block execution (depends on enforcement mode).
/// </summary>
public bool ShouldBlock => !Passed && EnforcementMode == StalenessEnforcementMode.Strict;
/// <summary>
/// Whether this result has warnings.
/// </summary>
public bool HasWarnings => Warnings is { Count: > 0 };
}
/// <summary>
/// Context where staleness validation was triggered.
/// </summary>
public enum StalenessValidationContext
{
/// <summary>Export operation.</summary>
Export,
/// <summary>Query operation.</summary>
Query,
/// <summary>Policy evaluation.</summary>
PolicyEval,
/// <summary>Attestation generation.</summary>
Attestation,
/// <summary>Job scheduling.</summary>
JobScheduling,
/// <summary>Run scheduling.</summary>
RunScheduling
}
/// <summary>
/// Error details for staleness validation failure.
/// </summary>
public sealed record StalenessError(
/// <summary>Error code.</summary>
StalenessErrorCode Code,
/// <summary>Human-readable error message.</summary>
string Message,
/// <summary>Affected domain.</summary>
string? DomainId,
/// <summary>Actual staleness when error occurred.</summary>
int? StalenessSeconds,
/// <summary>Threshold that was exceeded.</summary>
int? ThresholdSeconds,
/// <summary>Recommended action to resolve.</summary>
string? Recommendation);
/// <summary>
/// Staleness error codes.
/// </summary>
public enum StalenessErrorCode
{
/// <summary>Data is stale beyond threshold.</summary>
AirgapStale,
/// <summary>No bundle available for domain.</summary>
AirgapNoBundle,
/// <summary>Time anchor is missing.</summary>
AirgapTimeAnchorMissing,
/// <summary>Time drift detected.</summary>
AirgapTimeDrift,
/// <summary>Attestation is invalid.</summary>
AirgapAttestationInvalid
}
/// <summary>
/// Warning generated during staleness validation.
/// </summary>
public sealed record StalenessWarning(
/// <summary>Warning code.</summary>
StalenessWarningCode Code,
/// <summary>Human-readable warning message.</summary>
string Message,
/// <summary>Current staleness as percentage of threshold.</summary>
double? PercentOfThreshold,
/// <summary>When data will become stale.</summary>
DateTimeOffset? ProjectedStaleAt);
/// <summary>
/// Staleness warning codes.
/// </summary>
public enum StalenessWarningCode
{
/// <summary>Approaching staleness threshold.</summary>
AirgapApproachingStale,
/// <summary>Time uncertainty is high.</summary>
AirgapTimeUncertaintyHigh,
/// <summary>Bundle is old but within threshold.</summary>
AirgapBundleOld,
/// <summary>No recent import detected.</summary>
AirgapNoRecentImport
}

View File

@@ -0,0 +1,256 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Orchestrator.Core.Domain.Events;
/// <summary>
/// Unified timeline event for audit trail, observability, and evidence chain tracking.
/// Per ORCH-OBS-52-001 and timeline-event.schema.json.
/// </summary>
public sealed record TimelineEvent(
/// <summary>Monotonically increasing sequence number for ordering.</summary>
long? EventSeq,
/// <summary>Globally unique event identifier.</summary>
Guid EventId,
/// <summary>Tenant scope for multi-tenant isolation.</summary>
string TenantId,
/// <summary>Event type identifier following namespace convention.</summary>
string EventType,
/// <summary>Service or component that emitted this event.</summary>
string Source,
/// <summary>When the event actually occurred.</summary>
DateTimeOffset OccurredAt,
/// <summary>When the event was received by timeline indexer.</summary>
DateTimeOffset? ReceivedAt,
/// <summary>Correlation ID linking related events across services.</summary>
string? CorrelationId,
/// <summary>OpenTelemetry trace ID for distributed tracing.</summary>
string? TraceId,
/// <summary>OpenTelemetry span ID within the trace.</summary>
string? SpanId,
/// <summary>User, service account, or system that triggered the event.</summary>
string? Actor,
/// <summary>Event severity level.</summary>
TimelineEventSeverity Severity,
/// <summary>Key-value attributes for filtering and querying.</summary>
IReadOnlyDictionary<string, string>? Attributes,
/// <summary>SHA-256 hash of the raw payload for integrity.</summary>
string? PayloadHash,
/// <summary>Original event payload as JSON string.</summary>
string? RawPayloadJson,
/// <summary>Canonicalized JSON for deterministic hashing.</summary>
string? NormalizedPayloadJson,
/// <summary>Reference to associated evidence bundle or attestation.</summary>
EvidencePointer? EvidencePointer,
/// <summary>Run ID if this event is associated with a run.</summary>
Guid? RunId,
/// <summary>Job ID if this event is associated with a job.</summary>
Guid? JobId,
/// <summary>Project ID scope within tenant.</summary>
string? ProjectId)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new timeline event with generated ID.
/// </summary>
public static TimelineEvent Create(
string tenantId,
string eventType,
string source,
DateTimeOffset occurredAt,
string? actor = null,
TimelineEventSeverity severity = TimelineEventSeverity.Info,
IReadOnlyDictionary<string, string>? attributes = null,
string? correlationId = null,
string? traceId = null,
string? spanId = null,
Guid? runId = null,
Guid? jobId = null,
string? projectId = null,
object? payload = null,
EvidencePointer? evidencePointer = null)
{
string? rawPayload = null;
string? normalizedPayload = null;
string? payloadHash = null;
if (payload is not null)
{
rawPayload = JsonSerializer.Serialize(payload, JsonOptions);
normalizedPayload = NormalizeJson(rawPayload);
payloadHash = ComputeHash(normalizedPayload);
}
return new TimelineEvent(
EventSeq: null,
EventId: Guid.NewGuid(),
TenantId: tenantId,
EventType: eventType,
Source: source,
OccurredAt: occurredAt,
ReceivedAt: null,
CorrelationId: correlationId,
TraceId: traceId,
SpanId: spanId,
Actor: actor,
Severity: severity,
Attributes: attributes,
PayloadHash: payloadHash,
RawPayloadJson: rawPayload,
NormalizedPayloadJson: normalizedPayload,
EvidencePointer: evidencePointer,
RunId: runId,
JobId: jobId,
ProjectId: projectId);
}
/// <summary>
/// Serializes the event to JSON.
/// </summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>
/// Parses a timeline event from JSON.
/// </summary>
public static TimelineEvent? FromJson(string json)
=> JsonSerializer.Deserialize<TimelineEvent>(json, JsonOptions);
/// <summary>
/// Creates a copy with received timestamp set.
/// </summary>
public TimelineEvent WithReceivedAt(DateTimeOffset receivedAt)
=> this with { ReceivedAt = receivedAt };
/// <summary>
/// Creates a copy with sequence number set.
/// </summary>
public TimelineEvent WithSequence(long seq)
=> this with { EventSeq = seq };
/// <summary>
/// Generates an idempotency key for this event.
/// </summary>
public string GenerateIdempotencyKey()
=> $"timeline:{TenantId}:{EventType}:{EventId}";
private static string NormalizeJson(string json)
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, CanonicalJsonOptions);
}
private static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// Event severity level.
/// </summary>
public enum TimelineEventSeverity
{
Debug,
Info,
Warning,
Error,
Critical
}
/// <summary>
/// Reference to associated evidence bundle or attestation.
/// </summary>
public sealed record EvidencePointer(
/// <summary>Type of evidence being referenced.</summary>
EvidencePointerType Type,
/// <summary>Evidence bundle identifier.</summary>
Guid? BundleId,
/// <summary>Content digest of the evidence bundle.</summary>
string? BundleDigest,
/// <summary>Subject URI for the attestation.</summary>
string? AttestationSubject,
/// <summary>Digest of the attestation envelope.</summary>
string? AttestationDigest,
/// <summary>URI to the evidence manifest.</summary>
string? ManifestUri,
/// <summary>Path within evidence locker storage.</summary>
string? LockerPath)
{
/// <summary>
/// Creates a bundle evidence pointer.
/// </summary>
public static EvidencePointer Bundle(Guid bundleId, string? bundleDigest = null)
=> new(EvidencePointerType.Bundle, bundleId, bundleDigest, null, null, null, null);
/// <summary>
/// Creates an attestation evidence pointer.
/// </summary>
public static EvidencePointer Attestation(string subject, string? digest = null)
=> new(EvidencePointerType.Attestation, null, null, subject, digest, null, null);
/// <summary>
/// Creates a manifest evidence pointer.
/// </summary>
public static EvidencePointer Manifest(string uri, string? lockerPath = null)
=> new(EvidencePointerType.Manifest, null, null, null, null, uri, lockerPath);
/// <summary>
/// Creates an artifact evidence pointer.
/// </summary>
public static EvidencePointer Artifact(string lockerPath, string? digest = null)
=> new(EvidencePointerType.Artifact, null, digest, null, null, null, lockerPath);
}
/// <summary>
/// Type of evidence being referenced.
/// </summary>
public enum EvidencePointerType
{
Bundle,
Attestation,
Manifest,
Artifact
}

View File

@@ -0,0 +1,495 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Orchestrator.Core.Domain.Events;
/// <summary>
/// Service for emitting timeline events with trace IDs and retries.
/// Per ORCH-OBS-52-001.
/// </summary>
public interface ITimelineEventEmitter
{
/// <summary>
/// Emits a timeline event.
/// </summary>
Task<TimelineEmitResult> EmitAsync(TimelineEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Emits multiple timeline events in batch.
/// </summary>
Task<TimelineBatchEmitResult> EmitBatchAsync(IEnumerable<TimelineEvent> events, CancellationToken cancellationToken = default);
/// <summary>
/// Creates and emits a job lifecycle event.
/// </summary>
Task<TimelineEmitResult> EmitJobEventAsync(
string tenantId,
Guid jobId,
string eventType,
object? payload = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates and emits a run lifecycle event.
/// </summary>
Task<TimelineEmitResult> EmitRunEventAsync(
string tenantId,
Guid runId,
string eventType,
object? payload = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of timeline event emission.
/// </summary>
public sealed record TimelineEmitResult(
/// <summary>Whether the event was emitted successfully.</summary>
bool Success,
/// <summary>The emitted event (with sequence if assigned).</summary>
TimelineEvent Event,
/// <summary>Whether the event was deduplicated.</summary>
bool Deduplicated,
/// <summary>Error message if emission failed.</summary>
string? Error);
/// <summary>
/// Result of batch timeline event emission.
/// </summary>
public sealed record TimelineBatchEmitResult(
/// <summary>Number of events emitted successfully.</summary>
int Emitted,
/// <summary>Number of events deduplicated.</summary>
int Deduplicated,
/// <summary>Number of events that failed.</summary>
int Failed,
/// <summary>Errors encountered.</summary>
IReadOnlyList<string> Errors)
{
/// <summary>Total events processed.</summary>
public int Total => Emitted + Deduplicated + Failed;
/// <summary>Whether any events were emitted.</summary>
public bool HasEmitted => Emitted > 0;
/// <summary>Whether any errors occurred.</summary>
public bool HasErrors => Failed > 0 || Errors.Count > 0;
/// <summary>Creates an empty result.</summary>
public static TimelineBatchEmitResult Empty => new(0, 0, 0, []);
}
/// <summary>
/// Default implementation of timeline event emitter.
/// </summary>
public sealed class TimelineEventEmitter : ITimelineEventEmitter
{
private const string Source = "orchestrator";
private readonly ITimelineEventSink _sink;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TimelineEventEmitter> _logger;
private readonly TimelineEmitterOptions _options;
public TimelineEventEmitter(
ITimelineEventSink sink,
TimeProvider timeProvider,
ILogger<TimelineEventEmitter> logger,
TimelineEmitterOptions? options = null)
{
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? TimelineEmitterOptions.Default;
}
public async Task<TimelineEmitResult> EmitAsync(TimelineEvent evt, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
var eventWithReceived = evt.WithReceivedAt(_timeProvider.GetUtcNow());
try
{
var result = await EmitWithRetryAsync(eventWithReceived, cancellationToken);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to emit timeline event {EventId} type {EventType} for tenant {TenantId}",
evt.EventId, evt.EventType, evt.TenantId);
return new TimelineEmitResult(
Success: false,
Event: eventWithReceived,
Deduplicated: false,
Error: ex.Message);
}
}
public async Task<TimelineBatchEmitResult> EmitBatchAsync(
IEnumerable<TimelineEvent> events,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(events);
var emitted = 0;
var deduplicated = 0;
var failed = 0;
var errors = new List<string>();
// Order by occurredAt then eventId for deterministic fan-out
var ordered = events
.OrderBy(e => e.OccurredAt)
.ThenBy(e => e.EventId)
.ToList();
foreach (var evt in ordered)
{
var result = await EmitAsync(evt, cancellationToken);
if (result.Success)
{
if (result.Deduplicated)
deduplicated++;
else
emitted++;
}
else
{
failed++;
if (result.Error is not null)
errors.Add($"{evt.EventId}: {result.Error}");
}
}
return new TimelineBatchEmitResult(emitted, deduplicated, failed, errors);
}
public async Task<TimelineEmitResult> EmitJobEventAsync(
string tenantId,
Guid jobId,
string eventType,
object? payload = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["jobId"] = jobId.ToString()
});
var evt = TimelineEvent.Create(
tenantId: tenantId,
eventType: eventType,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
actor: actor,
severity: GetSeverityForEventType(eventType),
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
jobId: jobId,
projectId: projectId,
payload: payload);
return await EmitAsync(evt, cancellationToken);
}
public async Task<TimelineEmitResult> EmitRunEventAsync(
string tenantId,
Guid runId,
string eventType,
object? payload = null,
string? actor = null,
string? correlationId = null,
string? traceId = null,
string? projectId = null,
IReadOnlyDictionary<string, string>? attributes = null,
CancellationToken cancellationToken = default)
{
var attrs = MergeAttributes(attributes, new Dictionary<string, string>
{
["runId"] = runId.ToString()
});
var evt = TimelineEvent.Create(
tenantId: tenantId,
eventType: eventType,
source: Source,
occurredAt: _timeProvider.GetUtcNow(),
actor: actor,
severity: GetSeverityForEventType(eventType),
attributes: attrs,
correlationId: correlationId,
traceId: traceId,
runId: runId,
projectId: projectId,
payload: payload);
return await EmitAsync(evt, cancellationToken);
}
private async Task<TimelineEmitResult> EmitWithRetryAsync(
TimelineEvent evt,
CancellationToken cancellationToken)
{
var attempt = 0;
var delay = _options.RetryDelay;
while (true)
{
try
{
var sinkResult = await _sink.WriteAsync(evt, cancellationToken);
if (sinkResult.Deduplicated)
{
_logger.LogDebug(
"Timeline event {EventId} deduplicated",
evt.EventId);
return new TimelineEmitResult(
Success: true,
Event: evt,
Deduplicated: true,
Error: null);
}
_logger.LogInformation(
"Emitted timeline event {EventId} type {EventType} tenant {TenantId} seq {Seq}",
evt.EventId, evt.EventType, evt.TenantId, sinkResult.Sequence);
return new TimelineEmitResult(
Success: true,
Event: sinkResult.Sequence.HasValue ? evt.WithSequence(sinkResult.Sequence.Value) : evt,
Deduplicated: false,
Error: null);
}
catch (Exception ex) when (attempt < _options.MaxRetries && IsTransient(ex))
{
attempt++;
_logger.LogWarning(ex,
"Transient failure emitting timeline event {EventId}, attempt {Attempt}/{MaxRetries}",
evt.EventId, attempt, _options.MaxRetries);
await Task.Delay(delay, cancellationToken);
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
}
}
private static IReadOnlyDictionary<string, string> MergeAttributes(
IReadOnlyDictionary<string, string>? existing,
Dictionary<string, string> additional)
{
if (existing is null || existing.Count == 0)
return additional;
var merged = new Dictionary<string, string>(existing);
foreach (var (key, value) in additional)
{
merged.TryAdd(key, value);
}
return merged;
}
private static TimelineEventSeverity GetSeverityForEventType(string eventType)
{
return eventType switch
{
var t when t.Contains(".failed") => TimelineEventSeverity.Error,
var t when t.Contains(".error") => TimelineEventSeverity.Error,
var t when t.Contains(".warning") => TimelineEventSeverity.Warning,
var t when t.Contains(".critical") => TimelineEventSeverity.Critical,
_ => TimelineEventSeverity.Info
};
}
private static bool IsTransient(Exception ex)
{
return ex is TimeoutException or
TaskCanceledException or
System.Net.Http.HttpRequestException or
System.IO.IOException;
}
}
/// <summary>
/// Options for timeline event emitter.
/// </summary>
public sealed record TimelineEmitterOptions(
/// <summary>Maximum retry attempts for transient failures.</summary>
int MaxRetries,
/// <summary>Base delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Whether to include evidence pointers.</summary>
bool IncludeEvidencePointers)
{
/// <summary>Default emitter options.</summary>
public static TimelineEmitterOptions Default => new(
MaxRetries: 3,
RetryDelay: TimeSpan.FromSeconds(1),
IncludeEvidencePointers: true);
}
/// <summary>
/// Sink for timeline events (Kafka, NATS, file, etc.).
/// </summary>
public interface ITimelineEventSink
{
/// <summary>
/// Writes a timeline event to the sink.
/// </summary>
Task<TimelineSinkWriteResult> WriteAsync(TimelineEvent evt, CancellationToken cancellationToken = default);
/// <summary>
/// Writes multiple timeline events to the sink.
/// </summary>
Task<TimelineSinkBatchWriteResult> WriteBatchAsync(IEnumerable<TimelineEvent> events, CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of writing to timeline sink.
/// </summary>
public sealed record TimelineSinkWriteResult(
/// <summary>Whether the event was written successfully.</summary>
bool Success,
/// <summary>Assigned sequence number if applicable.</summary>
long? Sequence,
/// <summary>Whether the event was deduplicated.</summary>
bool Deduplicated,
/// <summary>Error message if write failed.</summary>
string? Error);
/// <summary>
/// Result of batch writing to timeline sink.
/// </summary>
public sealed record TimelineSinkBatchWriteResult(
/// <summary>Number of events written successfully.</summary>
int Written,
/// <summary>Number of events deduplicated.</summary>
int Deduplicated,
/// <summary>Number of events that failed.</summary>
int Failed);
/// <summary>
/// In-memory timeline event sink for testing.
/// </summary>
public sealed class InMemoryTimelineEventSink : ITimelineEventSink
{
private readonly List<TimelineEvent> _events = new();
private readonly HashSet<Guid> _seenIds = new();
private readonly object _lock = new();
private long _sequence;
public Task<TimelineSinkWriteResult> WriteAsync(TimelineEvent evt, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_seenIds.Add(evt.EventId))
{
return Task.FromResult(new TimelineSinkWriteResult(
Success: true,
Sequence: null,
Deduplicated: true,
Error: null));
}
var seq = ++_sequence;
var eventWithSeq = evt.WithSequence(seq);
_events.Add(eventWithSeq);
return Task.FromResult(new TimelineSinkWriteResult(
Success: true,
Sequence: seq,
Deduplicated: false,
Error: null));
}
}
public Task<TimelineSinkBatchWriteResult> WriteBatchAsync(IEnumerable<TimelineEvent> events, CancellationToken cancellationToken = default)
{
var written = 0;
var deduplicated = 0;
lock (_lock)
{
foreach (var evt in events)
{
if (!_seenIds.Add(evt.EventId))
{
deduplicated++;
continue;
}
var seq = ++_sequence;
_events.Add(evt.WithSequence(seq));
written++;
}
}
return Task.FromResult(new TimelineSinkBatchWriteResult(written, deduplicated, 0));
}
/// <summary>Gets all events (for testing).</summary>
public IReadOnlyList<TimelineEvent> GetEvents()
{
lock (_lock) { return _events.ToList(); }
}
/// <summary>Gets events for a tenant (for testing).</summary>
public IReadOnlyList<TimelineEvent> GetEvents(string tenantId)
{
lock (_lock) { return _events.Where(e => e.TenantId == tenantId).ToList(); }
}
/// <summary>Gets events by type (for testing).</summary>
public IReadOnlyList<TimelineEvent> GetEventsByType(string eventType)
{
lock (_lock) { return _events.Where(e => e.EventType == eventType).ToList(); }
}
/// <summary>Clears all events (for testing).</summary>
public void Clear()
{
lock (_lock)
{
_events.Clear();
_seenIds.Clear();
_sequence = 0;
}
}
/// <summary>Gets the current event count.</summary>
public int Count
{
get { lock (_lock) { return _events.Count; } }
}
}

View File

@@ -1,4 +1,5 @@
using StellaOps.Orchestrator.Core.Domain;
using StellaOps.Orchestrator.Core.Domain.AirGap;
namespace StellaOps.Orchestrator.Core.Scheduling;
@@ -67,6 +68,24 @@ public sealed class JobScheduler : IJobScheduler
return ScheduleDecision.Defer(context.ThrottleExpiresAt, context.ThrottleReason ?? "Throttled");
}
// ORCH-AIRGAP-56-002: Check air-gap sealing status and staleness
if (context.AirGap is { IsSealed: true })
{
var stalenessResult = context.AirGap.StalenessValidation;
// Block runs when staleness validation fails in strict mode
if (stalenessResult?.ShouldBlock == true)
{
var errorMsg = stalenessResult.Error?.Message ?? "Air-gap staleness validation failed";
var recommendation = stalenessResult.Error?.Recommendation;
var fullMessage = recommendation is not null
? $"{errorMsg}. {recommendation}"
: errorMsg;
return ScheduleDecision.Reject($"AIRGAP_STALE: {fullMessage}");
}
}
return ScheduleDecision.Schedule();
}
@@ -168,7 +187,8 @@ public sealed record SchedulingContext(
bool IsThrottled,
string? ThrottleReason,
DateTimeOffset? ThrottleExpiresAt,
IReadOnlySet<Guid>? ReadyJobIds = null)
IReadOnlySet<Guid>? ReadyJobIds = null,
AirGapSchedulingContext? AirGap = null)
{
/// <summary>
/// Creates a context where scheduling is allowed.
@@ -181,6 +201,72 @@ public sealed record SchedulingContext(
IsThrottled: false,
ThrottleReason: null,
ThrottleExpiresAt: null);
/// <summary>
/// Creates a context where scheduling is allowed with air-gap staleness info.
/// </summary>
public static SchedulingContext AllowSchedulingWithAirGap(
DateTimeOffset now,
AirGapSchedulingContext airGap) => new(
now,
AreDependenciesSatisfied: true,
HasQuotaAvailable: true,
QuotaAvailableAt: null,
IsThrottled: false,
ThrottleReason: null,
ThrottleExpiresAt: null,
AirGap: airGap);
}
/// <summary>
/// Air-gap specific context for scheduling decisions.
/// Per ORCH-AIRGAP-56-002.
/// </summary>
public sealed record AirGapSchedulingContext(
/// <summary>Whether the environment is currently sealed (air-gapped).</summary>
bool IsSealed,
/// <summary>Staleness validation result for the job's required domains.</summary>
StalenessValidationResult? StalenessValidation,
/// <summary>Per-domain staleness metrics relevant to the job.</summary>
IReadOnlyDictionary<string, DomainStalenessMetric>? DomainStaleness,
/// <summary>Staleness configuration in effect.</summary>
StalenessConfig? StalenessConfig,
/// <summary>When the environment was sealed.</summary>
DateTimeOffset? SealedAt,
/// <summary>Actor who sealed the environment.</summary>
string? SealedBy)
{
/// <summary>
/// Creates an unsealed (online) air-gap context.
/// </summary>
public static AirGapSchedulingContext Unsealed() => new(
IsSealed: false,
StalenessValidation: null,
DomainStaleness: null,
StalenessConfig: null,
SealedAt: null,
SealedBy: null);
/// <summary>
/// Creates a sealed air-gap context with staleness validation.
/// </summary>
public static AirGapSchedulingContext Sealed(
StalenessValidationResult validation,
StalenessConfig config,
IReadOnlyDictionary<string, DomainStalenessMetric>? domainStaleness = null,
DateTimeOffset? sealedAt = null,
string? sealedBy = null) => new(
IsSealed: true,
StalenessValidation: validation,
DomainStaleness: domainStaleness,
StalenessConfig: config,
SealedAt: sealedAt,
SealedBy: sealedBy);
}
/// <summary>

View File

@@ -0,0 +1,355 @@
using StellaOps.Orchestrator.Core.AirGap;
using StellaOps.Orchestrator.Core.Domain.AirGap;
namespace StellaOps.Orchestrator.Tests.AirGap;
/// <summary>
/// Tests for air-gap staleness validation.
/// Per ORCH-AIRGAP-56-002.
/// </summary>
public sealed class StalenessValidatorTests
{
private readonly StalenessValidator _validator = new();
private readonly DateTimeOffset _now = new(2025, 12, 6, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void ValidateDomain_WithinThreshold_ReturnsPass()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800, // 7 days
EnforcementMode: StalenessEnforcementMode.Strict);
var metric = new DomainStalenessMetric(
DomainId: "vex-advisories",
StalenessSeconds: 86400, // 1 day
LastImportAt: _now.AddDays(-1),
LastSourceTimestamp: _now.AddDays(-1),
BundleCount: 5,
IsStale: false,
PercentOfThreshold: 14.3,
ProjectedStaleAt: _now.AddDays(6));
// Act
var result = _validator.ValidateDomain(
"vex-advisories",
metric,
config,
StalenessValidationContext.JobScheduling,
_now);
// Assert
Assert.True(result.Passed);
Assert.False(result.ShouldBlock);
Assert.Null(result.Error);
}
[Fact]
public void ValidateDomain_ExceedsThreshold_ReturnsFailWithError()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800, // 7 days
GracePeriodSeconds: 86400, // 1 day grace
EnforcementMode: StalenessEnforcementMode.Strict);
var metric = new DomainStalenessMetric(
DomainId: "vex-advisories",
StalenessSeconds: 777600, // 9 days (exceeds 7+1=8 day effective threshold)
LastImportAt: _now.AddDays(-9),
LastSourceTimestamp: _now.AddDays(-9),
BundleCount: 5,
IsStale: true,
PercentOfThreshold: 128.6,
ProjectedStaleAt: null);
// Act
var result = _validator.ValidateDomain(
"vex-advisories",
metric,
config,
StalenessValidationContext.JobScheduling,
_now);
// Assert
Assert.False(result.Passed);
Assert.True(result.ShouldBlock);
Assert.NotNull(result.Error);
Assert.Equal(StalenessErrorCode.AirgapStale, result.Error.Code);
Assert.Contains("vex-advisories", result.Error.Message);
Assert.NotNull(result.Error.Recommendation);
}
[Fact]
public void ValidateDomain_ExceedsThreshold_WarnMode_ReturnsPassWithWarning()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800, // 7 days
EnforcementMode: StalenessEnforcementMode.Warn); // Warn only
var metric = new DomainStalenessMetric(
DomainId: "vex-advisories",
StalenessSeconds: 777600, // 9 days
LastImportAt: _now.AddDays(-9),
LastSourceTimestamp: _now.AddDays(-9),
BundleCount: 5,
IsStale: true,
PercentOfThreshold: 128.6,
ProjectedStaleAt: null);
// Act
var result = _validator.ValidateDomain(
"vex-advisories",
metric,
config,
StalenessValidationContext.JobScheduling,
_now);
// Assert - even though validation fails, it doesn't block in Warn mode
Assert.False(result.Passed);
Assert.False(result.ShouldBlock); // Key difference from Strict mode
Assert.NotNull(result.Error);
}
[Fact]
public void ValidateDomain_DisabledMode_ReturnsPass()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Disabled);
var metric = new DomainStalenessMetric(
DomainId: "vex-advisories",
StalenessSeconds: 1000000, // Very stale
LastImportAt: _now.AddDays(-12),
LastSourceTimestamp: _now.AddDays(-12),
BundleCount: 1,
IsStale: true,
PercentOfThreshold: 165.3,
ProjectedStaleAt: null);
// Act
var result = _validator.ValidateDomain(
"vex-advisories",
metric,
config,
StalenessValidationContext.JobScheduling,
_now);
// Assert
Assert.True(result.Passed);
Assert.False(result.ShouldBlock);
Assert.Null(result.Error);
}
[Fact]
public void ValidateDomain_ExemptDomain_ReturnsPass()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Strict,
AllowedDomains: new[] { "vex-advisories", "local-overrides" });
var metric = new DomainStalenessMetric(
DomainId: "vex-advisories",
StalenessSeconds: 1000000, // Very stale but exempt
LastImportAt: _now.AddDays(-12),
LastSourceTimestamp: _now.AddDays(-12),
BundleCount: 1,
IsStale: true,
PercentOfThreshold: 165.3,
ProjectedStaleAt: null);
// Act
var result = _validator.ValidateDomain(
"vex-advisories",
metric,
config,
StalenessValidationContext.JobScheduling,
_now);
// Assert
Assert.True(result.Passed);
Assert.False(result.ShouldBlock);
}
[Fact]
public void ValidateDomain_ApproachingThreshold_ReturnsPassWithWarning()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800, // 7 days
EnforcementMode: StalenessEnforcementMode.Strict,
NotificationThresholds: new[]
{
new NotificationThreshold(75, NotificationSeverity.Warning),
new NotificationThreshold(90, NotificationSeverity.Critical)
});
var metric = new DomainStalenessMetric(
DomainId: "vex-advisories",
StalenessSeconds: 544320, // 6.3 days = 90% of threshold
LastImportAt: _now.AddDays(-6.3),
LastSourceTimestamp: _now.AddDays(-6.3),
BundleCount: 5,
IsStale: false,
PercentOfThreshold: 90.0,
ProjectedStaleAt: _now.AddDays(0.7));
// Act
var result = _validator.ValidateDomain(
"vex-advisories",
metric,
config,
StalenessValidationContext.JobScheduling,
_now);
// Assert
Assert.True(result.Passed);
Assert.True(result.HasWarnings);
Assert.Contains(result.Warnings!, w => w.Code == StalenessWarningCode.AirgapApproachingStale);
}
[Fact]
public void ValidateForJob_AllDomainsHealthy_ReturnsPass()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Strict);
var domainMetrics = new Dictionary<string, DomainStalenessMetric>
{
["vex-advisories"] = new DomainStalenessMetric(
"vex-advisories", 86400, _now.AddDays(-1), _now.AddDays(-1), 5, false, 14.3, _now.AddDays(6)),
["vulnerability-feeds"] = new DomainStalenessMetric(
"vulnerability-feeds", 172800, _now.AddDays(-2), _now.AddDays(-2), 10, false, 28.6, _now.AddDays(5))
};
// Act
var result = _validator.ValidateForJob(
new[] { "vex-advisories", "vulnerability-feeds" },
domainMetrics,
config,
_now);
// Assert
Assert.True(result.Passed);
Assert.False(result.ShouldBlock);
}
[Fact]
public void ValidateForJob_OneDomainStale_ReturnsFail()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
GracePeriodSeconds: 86400,
EnforcementMode: StalenessEnforcementMode.Strict);
var domainMetrics = new Dictionary<string, DomainStalenessMetric>
{
["vex-advisories"] = new DomainStalenessMetric(
"vex-advisories", 86400, _now.AddDays(-1), _now.AddDays(-1), 5, false, 14.3, _now.AddDays(6)),
["vulnerability-feeds"] = new DomainStalenessMetric(
"vulnerability-feeds", 777600, _now.AddDays(-9), _now.AddDays(-9), 10, true, 128.6, null) // Stale
};
// Act
var result = _validator.ValidateForJob(
new[] { "vex-advisories", "vulnerability-feeds" },
domainMetrics,
config,
_now);
// Assert
Assert.False(result.Passed);
Assert.True(result.ShouldBlock);
Assert.NotNull(result.Error);
Assert.Equal("vulnerability-feeds", result.Error.DomainId);
}
[Fact]
public void ValidateForJob_MissingDomain_ReturnsNoBundleError()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Strict);
var domainMetrics = new Dictionary<string, DomainStalenessMetric>
{
["vex-advisories"] = new DomainStalenessMetric(
"vex-advisories", 86400, _now.AddDays(-1), _now.AddDays(-1), 5, false, 14.3, _now.AddDays(6))
};
// Act
var result = _validator.ValidateForJob(
new[] { "vex-advisories", "missing-domain" },
domainMetrics,
config,
_now);
// Assert
Assert.False(result.Passed);
Assert.True(result.ShouldBlock);
Assert.NotNull(result.Error);
Assert.Equal(StalenessErrorCode.AirgapNoBundle, result.Error.Code);
Assert.Equal("missing-domain", result.Error.DomainId);
}
[Fact]
public void ValidateForJob_NoRequiredDomains_ReturnsPass()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Strict);
var domainMetrics = new Dictionary<string, DomainStalenessMetric>();
// Act
var result = _validator.ValidateForJob(
Array.Empty<string>(),
domainMetrics,
config,
_now);
// Assert
Assert.True(result.Passed);
Assert.False(result.ShouldBlock);
}
[Fact]
public void GetApproachingThresholdWarnings_MultipleDomainsApproaching_ReturnsWarnings()
{
// Arrange
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800, // 7 days
EnforcementMode: StalenessEnforcementMode.Strict,
NotificationThresholds: new[]
{
new NotificationThreshold(75, NotificationSeverity.Warning),
new NotificationThreshold(90, NotificationSeverity.Critical)
});
var domainMetrics = new Dictionary<string, DomainStalenessMetric>
{
["vex-advisories"] = new DomainStalenessMetric(
"vex-advisories", 544320, _now.AddDays(-6.3), _now.AddDays(-6.3), 5, false, 90.0, _now.AddDays(0.7)),
["vulnerability-feeds"] = new DomainStalenessMetric(
"vulnerability-feeds", 483840, _now.AddDays(-5.6), _now.AddDays(-5.6), 10, false, 80.0, _now.AddDays(1.4))
};
// Act
var warnings = _validator.GetApproachingThresholdWarnings(domainMetrics, config);
// Assert
Assert.Equal(2, warnings.Count);
Assert.Contains(warnings, w => w.Message.Contains("vex-advisories"));
Assert.Contains(warnings, w => w.Message.Contains("vulnerability-feeds"));
}
}

View File

@@ -0,0 +1,399 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Orchestrator.Core.Domain.Events;
namespace StellaOps.Orchestrator.Tests.Events;
/// <summary>
/// Tests for timeline event emission.
/// Per ORCH-OBS-52-001.
/// </summary>
public sealed class TimelineEventTests
{
private readonly DateTimeOffset _now = new(2025, 12, 6, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void TimelineEvent_Create_GeneratesUniqueId()
{
// Act
var evt1 = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
var evt2 = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
// Assert
Assert.NotEqual(evt1.EventId, evt2.EventId);
}
[Fact]
public void TimelineEvent_Create_WithPayload_ComputesHash()
{
// Arrange
var payload = new { imageRef = "registry/app:v1", vulnerabilities = 42 };
// Act
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "scan.completed",
source: "scanner",
occurredAt: _now,
payload: payload);
// Assert
Assert.NotNull(evt.PayloadHash);
Assert.StartsWith("sha256:", evt.PayloadHash);
Assert.NotNull(evt.RawPayloadJson);
Assert.NotNull(evt.NormalizedPayloadJson);
}
[Fact]
public void TimelineEvent_Create_WithoutPayload_HasNullPayloadFields()
{
// Act
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
// Assert
Assert.Null(evt.PayloadHash);
Assert.Null(evt.RawPayloadJson);
Assert.Null(evt.NormalizedPayloadJson);
}
[Fact]
public void TimelineEvent_Create_WithAllFields_PreservesValues()
{
// Arrange
var runId = Guid.NewGuid();
var jobId = Guid.NewGuid();
var attributes = new Dictionary<string, string>
{
["imageRef"] = "registry/app:v1",
["status"] = "succeeded"
};
// Act
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.completed",
source: "orchestrator",
occurredAt: _now,
actor: "service:worker-1",
severity: TimelineEventSeverity.Info,
attributes: attributes,
correlationId: "corr-123",
traceId: "trace-abc",
spanId: "span-xyz",
runId: runId,
jobId: jobId,
projectId: "proj-1");
// Assert
Assert.Equal("test-tenant", evt.TenantId);
Assert.Equal("job.completed", evt.EventType);
Assert.Equal("orchestrator", evt.Source);
Assert.Equal(_now, evt.OccurredAt);
Assert.Equal("service:worker-1", evt.Actor);
Assert.Equal(TimelineEventSeverity.Info, evt.Severity);
Assert.Equal("corr-123", evt.CorrelationId);
Assert.Equal("trace-abc", evt.TraceId);
Assert.Equal("span-xyz", evt.SpanId);
Assert.Equal(runId, evt.RunId);
Assert.Equal(jobId, evt.JobId);
Assert.Equal("proj-1", evt.ProjectId);
Assert.Equal(2, evt.Attributes!.Count);
}
[Fact]
public void TimelineEvent_WithReceivedAt_CreatesNewInstance()
{
// Arrange
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
var receivedAt = _now.AddSeconds(1);
// Act
var eventWithReceived = evt.WithReceivedAt(receivedAt);
// Assert
Assert.Null(evt.ReceivedAt);
Assert.Equal(receivedAt, eventWithReceived.ReceivedAt);
Assert.Equal(evt.EventId, eventWithReceived.EventId);
}
[Fact]
public void TimelineEvent_WithSequence_CreatesNewInstance()
{
// Arrange
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
// Act
var eventWithSeq = evt.WithSequence(12345);
// Assert
Assert.Null(evt.EventSeq);
Assert.Equal(12345, eventWithSeq.EventSeq);
}
[Fact]
public void TimelineEvent_GenerateIdempotencyKey_IsDeterministic()
{
// Arrange
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
// Act
var key1 = evt.GenerateIdempotencyKey();
var key2 = evt.GenerateIdempotencyKey();
// Assert
Assert.Equal(key1, key2);
Assert.Contains("test-tenant", key1);
Assert.Contains("job.created", key1);
Assert.Contains(evt.EventId.ToString(), key1);
}
[Fact]
public void TimelineEvent_ToJson_RoundTrips()
{
// Arrange
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now,
actor: "user@example.com",
severity: TimelineEventSeverity.Info);
// Act
var json = evt.ToJson();
var parsed = TimelineEvent.FromJson(json);
// Assert
Assert.NotNull(parsed);
Assert.Equal(evt.EventId, parsed.EventId);
Assert.Equal(evt.TenantId, parsed.TenantId);
Assert.Equal(evt.EventType, parsed.EventType);
Assert.Equal(evt.Actor, parsed.Actor);
}
[Fact]
public void EvidencePointer_Bundle_CreatesCorrectType()
{
// Act
var pointer = EvidencePointer.Bundle(Guid.NewGuid(), "sha256:abc123");
// Assert
Assert.Equal(EvidencePointerType.Bundle, pointer.Type);
Assert.NotNull(pointer.BundleId);
Assert.Equal("sha256:abc123", pointer.BundleDigest);
}
[Fact]
public void EvidencePointer_Attestation_CreatesCorrectType()
{
// Act
var pointer = EvidencePointer.Attestation("pkg:docker/image@sha256:abc", "sha256:def456");
// Assert
Assert.Equal(EvidencePointerType.Attestation, pointer.Type);
Assert.Equal("pkg:docker/image@sha256:abc", pointer.AttestationSubject);
Assert.Equal("sha256:def456", pointer.AttestationDigest);
}
[Fact]
public async Task TimelineEventEmitter_EmitAsync_WritesToSink()
{
// Arrange
var sink = new InMemoryTimelineEventSink();
var timeProvider = new FakeTimeProvider(_now);
var emitter = new TimelineEventEmitter(
sink,
timeProvider,
NullLogger<TimelineEventEmitter>.Instance);
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
// Act
var result = await emitter.EmitAsync(evt, TestContext.Current.CancellationToken);
// Assert
Assert.True(result.Success);
Assert.False(result.Deduplicated);
Assert.Equal(1, sink.Count);
var stored = sink.GetEvents()[0];
Assert.Equal(evt.EventId, stored.EventId);
Assert.NotNull(stored.ReceivedAt);
Assert.NotNull(stored.EventSeq);
}
[Fact]
public async Task TimelineEventEmitter_EmitAsync_DeduplicatesDuplicates()
{
// Arrange
var sink = new InMemoryTimelineEventSink();
var timeProvider = new FakeTimeProvider(_now);
var emitter = new TimelineEventEmitter(
sink,
timeProvider,
NullLogger<TimelineEventEmitter>.Instance);
var evt = TimelineEvent.Create(
tenantId: "test-tenant",
eventType: "job.created",
source: "orchestrator",
occurredAt: _now);
var ct = TestContext.Current.CancellationToken;
// Act
var result1 = await emitter.EmitAsync(evt, ct);
var result2 = await emitter.EmitAsync(evt, ct);
// Assert
Assert.True(result1.Success);
Assert.False(result1.Deduplicated);
Assert.True(result2.Success);
Assert.True(result2.Deduplicated);
Assert.Equal(1, sink.Count);
}
[Fact]
public async Task TimelineEventEmitter_EmitJobEventAsync_CreatesEventWithJobId()
{
// Arrange
var sink = new InMemoryTimelineEventSink();
var timeProvider = new FakeTimeProvider(_now);
var emitter = new TimelineEventEmitter(
sink,
timeProvider,
NullLogger<TimelineEventEmitter>.Instance);
var jobId = Guid.NewGuid();
// Act
var result = await emitter.EmitJobEventAsync(
tenantId: "test-tenant",
jobId: jobId,
eventType: "job.started",
actor: "service:scheduler",
correlationId: "corr-123",
cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.True(result.Success);
Assert.Equal(jobId, result.Event.JobId);
Assert.NotNull(result.Event.Attributes);
Assert.Equal(jobId.ToString(), result.Event.Attributes["jobId"]);
}
[Fact]
public async Task TimelineEventEmitter_EmitRunEventAsync_CreatesEventWithRunId()
{
// Arrange
var sink = new InMemoryTimelineEventSink();
var timeProvider = new FakeTimeProvider(_now);
var emitter = new TimelineEventEmitter(
sink,
timeProvider,
NullLogger<TimelineEventEmitter>.Instance);
var runId = Guid.NewGuid();
// Act
var result = await emitter.EmitRunEventAsync(
tenantId: "test-tenant",
runId: runId,
eventType: "run.completed",
actor: "service:worker-1",
cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.True(result.Success);
Assert.Equal(runId, result.Event.RunId);
Assert.NotNull(result.Event.Attributes);
Assert.Equal(runId.ToString(), result.Event.Attributes["runId"]);
}
[Fact]
public async Task TimelineEventEmitter_EmitBatchAsync_OrdersByOccurredAt()
{
// Arrange
var sink = new InMemoryTimelineEventSink();
var timeProvider = new FakeTimeProvider(_now);
var emitter = new TimelineEventEmitter(
sink,
timeProvider,
NullLogger<TimelineEventEmitter>.Instance);
var events = new[]
{
TimelineEvent.Create("t1", "event.a", "src", _now.AddMinutes(2)),
TimelineEvent.Create("t1", "event.b", "src", _now.AddMinutes(1)),
TimelineEvent.Create("t1", "event.c", "src", _now)
};
// Act
var result = await emitter.EmitBatchAsync(events, TestContext.Current.CancellationToken);
// Assert
Assert.Equal(3, result.Emitted);
var stored = sink.GetEvents();
Assert.Equal("event.c", stored[0].EventType); // Earliest first
Assert.Equal("event.b", stored[1].EventType);
Assert.Equal("event.a", stored[2].EventType);
}
[Fact]
public async Task TimelineEvent_Create_FailedEventType_HasErrorSeverity()
{
// Arrange & Act - test the emitter's severity inference
var sink = new InMemoryTimelineEventSink();
var timeProvider = new FakeTimeProvider(_now);
var emitter = new TimelineEventEmitter(
sink,
timeProvider,
NullLogger<TimelineEventEmitter>.Instance);
// Using the job event helper which auto-determines severity
var result = await emitter.EmitJobEventAsync(
"tenant", Guid.NewGuid(), "job.failed", cancellationToken: TestContext.Current.CancellationToken);
// Assert
Assert.Equal(TimelineEventSeverity.Error, result.Event.Severity);
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,310 @@
using StellaOps.Orchestrator.Core.Domain;
using StellaOps.Orchestrator.Core.Domain.AirGap;
using StellaOps.Orchestrator.Core.Scheduling;
namespace StellaOps.Orchestrator.Tests.Scheduling;
/// <summary>
/// Tests for JobScheduler air-gap staleness enforcement.
/// Per ORCH-AIRGAP-56-002.
/// </summary>
public sealed class JobSchedulerAirGapTests
{
private readonly JobScheduler _scheduler = new();
private readonly DateTimeOffset _now = new(2025, 12, 6, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void EvaluateScheduling_NoAirGapContext_Schedules()
{
// Arrange
var job = CreatePendingJob();
var context = SchedulingContext.AllowScheduling(_now);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.True(decision.CanSchedule);
Assert.Null(decision.Reason);
}
[Fact]
public void EvaluateScheduling_UnsealedAirGap_Schedules()
{
// Arrange
var job = CreatePendingJob();
var airGap = AirGapSchedulingContext.Unsealed();
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.True(decision.CanSchedule);
}
[Fact]
public void EvaluateScheduling_SealedAirGap_PassingValidation_Schedules()
{
// Arrange
var job = CreatePendingJob();
var config = StalenessConfig.Default;
var validation = StalenessValidationResult.Pass(
_now,
StalenessValidationContext.JobScheduling,
"vex-advisories",
86400, // 1 day
604800, // 7 days threshold
StalenessEnforcementMode.Strict);
var airGap = AirGapSchedulingContext.Sealed(
validation,
config,
sealedAt: _now.AddDays(-5),
sealedBy: "operator@example.com");
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.True(decision.CanSchedule);
}
[Fact]
public void EvaluateScheduling_SealedAirGap_FailingValidation_StrictMode_Rejects()
{
// Arrange
var job = CreatePendingJob();
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Strict);
var error = new StalenessError(
StalenessErrorCode.AirgapStale,
"Domain 'vex-advisories' data is stale (9 days old, threshold 7 days)",
"vex-advisories",
777600,
604800,
"Import a fresh VEX bundle from upstream using 'stella airgap import'");
var validation = StalenessValidationResult.Fail(
_now,
StalenessValidationContext.JobScheduling,
"vex-advisories",
777600, // 9 days
604800, // 7 days threshold
StalenessEnforcementMode.Strict,
error);
var airGap = AirGapSchedulingContext.Sealed(
validation,
config,
sealedAt: _now.AddDays(-10),
sealedBy: "operator@example.com");
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.False(decision.CanSchedule);
Assert.False(decision.ShouldDefer);
Assert.NotNull(decision.Reason);
Assert.Contains("AIRGAP_STALE", decision.Reason);
Assert.Contains("vex-advisories", decision.Reason);
Assert.Contains("stella airgap import", decision.Reason);
}
[Fact]
public void EvaluateScheduling_SealedAirGap_FailingValidation_WarnMode_Schedules()
{
// Arrange
var job = CreatePendingJob();
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Warn);
var error = new StalenessError(
StalenessErrorCode.AirgapStale,
"Domain 'vex-advisories' data is stale",
"vex-advisories",
777600,
604800,
"Import a fresh bundle");
var validation = StalenessValidationResult.Fail(
_now,
StalenessValidationContext.JobScheduling,
"vex-advisories",
777600,
604800,
StalenessEnforcementMode.Warn, // Warn mode - doesn't block
error);
var airGap = AirGapSchedulingContext.Sealed(
validation,
config);
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.True(decision.CanSchedule); // Warn mode doesn't block
}
[Fact]
public void EvaluateScheduling_SealedAirGap_NoBundleError_Rejects()
{
// Arrange
var job = CreatePendingJob();
var config = new StalenessConfig(
FreshnessThresholdSeconds: 604800,
EnforcementMode: StalenessEnforcementMode.Strict);
var error = new StalenessError(
StalenessErrorCode.AirgapNoBundle,
"No bundle available for domain 'vulnerability-feeds'",
"vulnerability-feeds",
null,
604800,
"Import a bundle for 'vulnerability-feeds' from upstream using 'stella airgap import'");
var validation = StalenessValidationResult.Fail(
_now,
StalenessValidationContext.JobScheduling,
"vulnerability-feeds",
0,
604800,
StalenessEnforcementMode.Strict,
error);
var airGap = AirGapSchedulingContext.Sealed(
validation,
config);
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.False(decision.CanSchedule);
Assert.Contains("AIRGAP_STALE", decision.Reason);
Assert.Contains("vulnerability-feeds", decision.Reason!);
}
[Fact]
public void EvaluateScheduling_SealedAirGap_NullValidation_Schedules()
{
// Arrange - sealed but no validation performed (e.g., no domain requirements)
var job = CreatePendingJob();
var config = StalenessConfig.Default;
var airGap = new AirGapSchedulingContext(
IsSealed: true,
StalenessValidation: null, // No validation
DomainStaleness: null,
StalenessConfig: config,
SealedAt: _now.AddDays(-5),
SealedBy: "operator@example.com");
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.True(decision.CanSchedule);
}
[Fact]
public void EvaluateScheduling_OtherBlockers_TakePrecedence()
{
// Arrange - job is not pending (other blocker takes precedence)
var job = CreatePendingJob() with { Status = JobStatus.Scheduled };
var airGap = AirGapSchedulingContext.Unsealed();
var context = SchedulingContext.AllowSchedulingWithAirGap(_now, airGap);
// Act
var decision = _scheduler.EvaluateScheduling(job, context);
// Assert
Assert.False(decision.CanSchedule);
Assert.Contains("not pending", decision.Reason);
}
[Fact]
public void AirGapSchedulingContext_Sealed_FactoryMethod_Works()
{
// Arrange
var validation = StalenessValidationResult.Pass(
_now,
StalenessValidationContext.JobScheduling,
null,
0,
604800,
StalenessEnforcementMode.Strict);
var config = StalenessConfig.Default;
// Act
var context = AirGapSchedulingContext.Sealed(
validation,
config,
sealedAt: _now,
sealedBy: "test@example.com");
// Assert
Assert.True(context.IsSealed);
Assert.NotNull(context.StalenessValidation);
Assert.NotNull(context.StalenessConfig);
Assert.Equal(_now, context.SealedAt);
Assert.Equal("test@example.com", context.SealedBy);
}
[Fact]
public void AirGapSchedulingContext_Unsealed_FactoryMethod_Works()
{
// Act
var context = AirGapSchedulingContext.Unsealed();
// Assert
Assert.False(context.IsSealed);
Assert.Null(context.StalenessValidation);
Assert.Null(context.StalenessConfig);
Assert.Null(context.SealedAt);
Assert.Null(context.SealedBy);
}
private Job CreatePendingJob() => new(
JobId: Guid.NewGuid(),
TenantId: "test-tenant",
ProjectId: null,
RunId: null,
JobType: "scan.image",
Status: JobStatus.Pending,
Priority: 0,
Attempt: 1,
MaxAttempts: 3,
PayloadDigest: "sha256:abc123",
Payload: "{}",
IdempotencyKey: Guid.NewGuid().ToString(),
CorrelationId: null,
LeaseId: null,
WorkerId: null,
TaskRunnerId: null,
LeaseUntil: null,
CreatedAt: _now.AddMinutes(-5),
ScheduledAt: null,
LeasedAt: null,
CompletedAt: null,
NotBefore: null,
Reason: null,
ReplayOf: null,
CreatedBy: "test-user");
}