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:
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; } }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user