up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -35,6 +35,7 @@
- Use Mongo2Go/in-memory stores; no network.
- Cover sealed/unsealed transitions, staleness budgets, trust-root failures, deterministic ordering.
- API tests via WebApplicationFactory; importer tests use local fixture bundles (no downloads).
- If Mongo2Go fails to start (OpenSSL 1.1 missing), see `tests/AirGap/README.md` for the shim note.
## Delivery Discipline
- Update sprint tracker statuses (`TODO → DOING → DONE/BLOCKED`); log decisions in Execution Log and Decisions & Risks.

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.AirGap.Controller.Tests")]

View File

@@ -1,10 +1,12 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.AirGap.Controller.Options;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Controller.DependencyInjection;
@@ -14,24 +16,33 @@ public static class AirGapControllerServiceCollectionExtensions
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AirGapControllerMongoOptions>(configuration.GetSection("AirGap:Mongo"));
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
services.AddSingleton<AirGapTelemetry>();
services.AddSingleton<StalenessCalculator>();
services.AddSingleton<AirGapStateService>();
services.AddSingleton<TufMetadataValidator>();
services.AddSingleton<RootRotationPolicy>();
services.AddSingleton<IAirGapStateStore>(sp =>
{
var opts = sp.GetRequiredService<IOptions<AirGapControllerMongoOptions>>().Value;
var logger = sp.GetRequiredService<ILogger<MongoAirGapStateStore>>();
if (string.IsNullOrWhiteSpace(opts.ConnectionString))
{
logger.LogInformation("AirGap controller using in-memory state store (Mongo connection string not configured).");
return new InMemoryAirGapStateStore();
}
var mongoClient = new MongoClient(opts.ConnectionString);
var database = mongoClient.GetDatabase(string.IsNullOrWhiteSpace(opts.Database) ? "stellaops_airgap" : opts.Database);
var collection = MongoAirGapStateStore.EnsureCollection(database);
logger.LogInformation("AirGap controller using Mongo state store (db={Database}, collection={Collection}).", opts.Database, opts.Collection);
return new MongoAirGapStateStore(collection);
});
services.AddHostedService<AirGapStartupDiagnosticsHostedService>();
return services;
}
}

View File

@@ -36,11 +36,13 @@ internal static class AirGapEndpoints
ClaimsPrincipal user,
AirGapStateService service,
TimeProvider timeProvider,
AirGapTelemetry telemetry,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
telemetry.RecordStatus(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}
@@ -50,6 +52,7 @@ internal static class AirGapEndpoints
AirGapStateService service,
StalenessCalculator stalenessCalculator,
TimeProvider timeProvider,
AirGapTelemetry telemetry,
HttpContext httpContext,
CancellationToken cancellationToken)
{
@@ -65,6 +68,7 @@ internal static class AirGapEndpoints
var now = timeProvider.GetUtcNow();
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken);
var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now);
telemetry.RecordSeal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}
@@ -72,12 +76,14 @@ internal static class AirGapEndpoints
ClaimsPrincipal user,
AirGapStateService service,
TimeProvider timeProvider,
AirGapTelemetry telemetry,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow());
telemetry.RecordUnseal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}

View File

@@ -10,6 +10,8 @@ public sealed record AirGapStatusResponse(
string? PolicyHash,
TimeAnchor TimeAnchor,
StalenessEvaluation Staleness,
long DriftSeconds,
long SecondsRemaining,
DateTimeOffset LastTransitionAt,
DateTimeOffset EvaluatedAt)
{
@@ -20,6 +22,8 @@ public sealed record AirGapStatusResponse(
status.State.PolicyHash,
status.State.TimeAnchor,
status.Staleness,
status.Staleness.AgeSeconds,
status.Staleness.SecondsRemaining,
status.State.LastTransitionAt,
status.EvaluatedAt);
}

View File

@@ -0,0 +1,44 @@
namespace StellaOps.AirGap.Controller.Options;
public sealed class AirGapStartupOptions
{
/// <summary>
/// Tenant to validate at startup. Defaults to single-tenant controller deployment.
/// </summary>
public string TenantId { get; set; } = "default";
/// <summary>
/// Optional egress allowlist. When null, startup diagnostics consider it missing.
/// </summary>
public string[]? EgressAllowlist { get; set; }
= null;
/// <summary>
/// Trust material required to prove bundles and egress policy inputs are present.
/// </summary>
public TrustMaterialOptions Trust { get; set; } = new();
/// <summary>
/// Pending root rotation metadata; validated when pending keys exist.
/// </summary>
public RotationOptions Rotation { get; set; } = new();
}
public sealed class TrustMaterialOptions
{
public string RootJsonPath { get; set; } = string.Empty;
public string SnapshotJsonPath { get; set; } = string.Empty;
public string TimestampJsonPath { get; set; } = string.Empty;
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(RootJsonPath)
&& !string.IsNullOrWhiteSpace(SnapshotJsonPath)
&& !string.IsNullOrWhiteSpace(TimestampJsonPath);
}
public sealed class RotationOptions
{
public Dictionary<string, string> ActiveKeys { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, string> PendingKeys { get; set; } = new(StringComparer.Ordinal);
public List<string> ApproverIds { get; set; } = new();
}

View File

@@ -0,0 +1,163 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Options;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Controller.Services;
internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
{
private readonly IAirGapStateStore _stateStore;
private readonly StalenessCalculator _stalenessCalculator;
private readonly TimeProvider _timeProvider;
private readonly AirGapStartupOptions _options;
private readonly ILogger<AirGapStartupDiagnosticsHostedService> _logger;
private readonly AirGapTelemetry _telemetry;
private readonly TufMetadataValidator _tufValidator;
private readonly RootRotationPolicy _rotationPolicy;
public AirGapStartupDiagnosticsHostedService(
IAirGapStateStore stateStore,
StalenessCalculator stalenessCalculator,
TimeProvider timeProvider,
IOptions<AirGapStartupOptions> options,
ILogger<AirGapStartupDiagnosticsHostedService> logger,
AirGapTelemetry telemetry,
TufMetadataValidator tufValidator,
RootRotationPolicy rotationPolicy)
{
_stateStore = stateStore;
_stalenessCalculator = stalenessCalculator;
_timeProvider = timeProvider;
_options = options.Value;
_logger = logger;
_telemetry = telemetry;
_tufValidator = tufValidator;
_rotationPolicy = rotationPolicy;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var tenantId = string.IsNullOrWhiteSpace(_options.TenantId) ? "default" : _options.TenantId;
var state = await _stateStore.GetAsync(tenantId, cancellationToken);
if (!state.Sealed)
{
_logger.LogInformation("AirGap startup diagnostics skipped: tenant {TenantId} not sealed.", tenantId);
return;
}
var now = _timeProvider.GetUtcNow();
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, now);
var failures = new List<string>();
if (_options.EgressAllowlist is null)
{
failures.Add("egress-allowlist-missing");
}
if (state.TimeAnchor == TimeAnchor.Unknown)
{
failures.Add("time-anchor-missing");
}
else if (staleness.IsBreach)
{
failures.Add("time-anchor-stale");
}
var trustResult = ValidateTrustMaterials(_options.Trust);
if (!trustResult.IsValid)
{
failures.Add($"trust:{trustResult.Reason}");
}
var rotationResult = ValidateRotation(_options.Rotation);
if (!rotationResult.IsValid)
{
failures.Add($"rotation:{rotationResult.Reason}");
}
if (failures.Count > 0)
{
var reason = string.Join(',', failures);
_telemetry.RecordStartupBlocked(tenantId, reason, staleness);
_logger.LogCritical(
"AirGap sealed-startup blocked tenant={TenantId} reasons={Reasons} policy_hash={PolicyHash} anchor_digest={Anchor}",
tenantId,
reason,
state.PolicyHash,
state.TimeAnchor.TokenDigest);
throw new InvalidOperationException($"sealed-startup-blocked:{reason}");
}
_telemetry.RecordStartupPassed(tenantId, staleness, _options.EgressAllowlist?.Length ?? 0);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private StartupCheckResult ValidateTrustMaterials(TrustMaterialOptions trust)
{
if (!trust.IsConfigured)
{
return StartupCheckResult.Failure("trust-roots-missing");
}
try
{
var rootJson = File.ReadAllText(trust.RootJsonPath);
var snapshotJson = File.ReadAllText(trust.SnapshotJsonPath);
var timestampJson = File.ReadAllText(trust.TimestampJsonPath);
var result = _tufValidator.Validate(rootJson, snapshotJson, timestampJson);
return result.IsValid
? StartupCheckResult.Success()
: StartupCheckResult.Failure(result.Reason);
}
catch (Exception ex)
{
return StartupCheckResult.Failure($"trust-read-failed:{ex.GetType().Name.ToLowerInvariant()}");
}
}
private StartupCheckResult ValidateRotation(RotationOptions rotation)
{
if (rotation.PendingKeys.Count == 0)
{
return StartupCheckResult.Success();
}
try
{
var active = DecodeKeys(rotation.ActiveKeys);
var pending = DecodeKeys(rotation.PendingKeys);
var result = _rotationPolicy.Validate(active, pending, rotation.ApproverIds);
return result.IsValid
? StartupCheckResult.Success()
: StartupCheckResult.Failure(result.Reason);
}
catch (FormatException)
{
return StartupCheckResult.Failure("rotation-key-invalid");
}
}
private static Dictionary<string, byte[]> DecodeKeys(Dictionary<string, string> source)
{
var decoded = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (var kvp in source)
{
decoded[kvp.Key] = Convert.FromBase64String(kvp.Value);
}
return decoded;
}
private sealed record StartupCheckResult(bool IsValid, string Reason)
{
public static StartupCheckResult Success() => new(true, "ok");
public static StartupCheckResult Failure(string reason) => new(false, reason);
}
}

View File

@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Services;
/// <summary>
/// Centralised metrics + trace hooks for the AirGap controller.
/// </summary>
public sealed class AirGapTelemetry
{
private static readonly Meter Meter = new("StellaOps.AirGap.Controller", "1.0.0");
private static readonly ActivitySource ActivitySource = new("StellaOps.AirGap.Controller");
private static readonly Counter<long> SealCounter = Meter.CreateCounter<long>("airgap_seal_total");
private static readonly Counter<long> UnsealCounter = Meter.CreateCounter<long>("airgap_unseal_total");
private static readonly Counter<long> StartupBlockedCounter = Meter.CreateCounter<long>("airgap_startup_blocked_total");
private readonly ConcurrentDictionary<string, (long Age, long Budget)> _latestByTenant = new(StringComparer.Ordinal);
private readonly ObservableGauge<long> _anchorAgeGauge;
private readonly ObservableGauge<long> _budgetGauge;
private readonly ILogger<AirGapTelemetry> _logger;
public AirGapTelemetry(ILogger<AirGapTelemetry> logger)
{
_logger = logger;
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
}
private IEnumerable<Measurement<long>> ObserveAges()
{
foreach (var kvp in _latestByTenant)
{
yield return new Measurement<long>(kvp.Value.Age, new KeyValuePair<string, object?>("tenant", kvp.Key));
}
}
private IEnumerable<Measurement<long>> ObserveBudgets()
{
foreach (var kvp in _latestByTenant)
{
yield return new Measurement<long>(kvp.Value.Budget, new KeyValuePair<string, object?>("tenant", kvp.Key));
}
}
public void RecordStatus(string tenantId, AirGapStatus status)
{
_latestByTenant[tenantId] = (status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
using var activity = ActivitySource.StartActivity("airgap.status.read");
activity?.SetTag("tenant", tenantId);
activity?.SetTag("sealed", status.State.Sealed);
activity?.SetTag("policy_hash", status.State.PolicyHash);
activity?.SetTag("anchor_source", status.State.TimeAnchor.Source);
activity?.SetTag("staleness_age_seconds", status.Staleness.AgeSeconds);
_logger.LogInformation(
"airgap.status.read tenant={Tenant} sealed={Sealed} policy_hash={PolicyHash} anchor_source={Source} age_seconds={Age}",
tenantId,
status.State.Sealed,
status.State.PolicyHash,
status.State.TimeAnchor.Source,
status.Staleness.AgeSeconds);
}
public void RecordSeal(string tenantId, AirGapStatus status)
{
SealCounter.Add(1, new TagList { { "tenant", tenantId }, { "sealed", true } });
RecordStatus(tenantId, status);
_logger.LogInformation(
"airgap.sealed tenant={Tenant} policy_hash={PolicyHash} anchor_source={Source} anchor_digest={Digest} age_seconds={Age}",
tenantId,
status.State.PolicyHash,
status.State.TimeAnchor.Source,
status.State.TimeAnchor.TokenDigest,
status.Staleness.AgeSeconds);
}
public void RecordUnseal(string tenantId, AirGapStatus status)
{
UnsealCounter.Add(1, new TagList { { "tenant", tenantId }, { "sealed", false } });
RecordStatus(tenantId, status);
_logger.LogInformation(
"airgap.unsealed tenant={Tenant} last_transition_at={TransitionAt}",
tenantId,
status.State.LastTransitionAt);
}
public void RecordStartupBlocked(string tenantId, string reason, StalenessEvaluation staleness)
{
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
StartupBlockedCounter.Add(1, new TagList { { "tenant", tenantId }, { "reason", reason } });
_logger.LogCritical("airgap.startup.validation failed tenant={Tenant} reason={Reason}", tenantId, reason);
}
public void RecordStartupPassed(string tenantId, StalenessEvaluation staleness, int allowlistCount)
{
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
using var activity = ActivitySource.StartActivity("airgap.startup.validation");
activity?.SetTag("tenant", tenantId);
activity?.SetTag("result", "success");
activity?.SetTag("allowlist_count", allowlistCount);
activity?.SetTag("staleness_age_seconds", staleness.AgeSeconds);
_logger.LogInformation(
"airgap.startup.validation passed tenant={Tenant} allowlist_count={AllowlistCount} anchor_age_seconds={Age}",
tenantId,
allowlistCount,
staleness.AgeSeconds);
}
}

View File

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />

View File

@@ -27,6 +27,19 @@ public sealed class AirGapOptionsValidator : IValidateOptions<AirGapOptions>
// no-op; explicitly allowed for offline testing
}
foreach (var kvp in options.ContentBudgets)
{
if (kvp.Value.WarningSeconds < 0 || kvp.Value.BreachSeconds < 0)
{
return ValidateOptionsResult.Fail($"Content budget '{kvp.Key}' must be non-negative");
}
if (kvp.Value.WarningSeconds > kvp.Value.BreachSeconds)
{
return ValidateOptionsResult.Fail($"Content budget '{kvp.Key}' warning cannot exceed breach");
}
}
return ValidateOptionsResult.Success;
}
}

View File

@@ -6,6 +6,16 @@ public sealed class AirGapOptions
public StalenessOptions Staleness { get; set; } = new();
/// <summary>
/// Optional per-content staleness budgets (advisories, vex, policy). Values fall back to global staleness when missing.
/// </summary>
public Dictionary<string, StalenessOptions> ContentBudgets { get; set; } = new(StringComparer.OrdinalIgnoreCase)
{
{ "advisories", new StalenessOptions { WarningSeconds = StalenessBudget.Default.WarningSeconds, BreachSeconds = StalenessBudget.Default.BreachSeconds } },
{ "vex", new StalenessOptions { WarningSeconds = StalenessBudget.Default.WarningSeconds, BreachSeconds = StalenessBudget.Default.BreachSeconds } },
{ "policy", new StalenessOptions { WarningSeconds = StalenessBudget.Default.WarningSeconds, BreachSeconds = StalenessBudget.Default.BreachSeconds } }
};
/// <summary>
/// Path to trust roots bundle (JSON). Used by AirGap Time to validate anchors when supplied.
/// </summary>

View File

@@ -7,5 +7,6 @@ public sealed record StalenessEvaluation(
bool IsWarning,
bool IsBreach)
{
public long SecondsRemaining => Math.Max(0, BreachSeconds - AgeSeconds);
public static StalenessEvaluation Unknown => new(0, 0, 0, false, false);
}

View File

@@ -4,7 +4,8 @@ public sealed record TimeStatus(
TimeAnchor Anchor,
StalenessEvaluation Staleness,
StalenessBudget Budget,
IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
DateTimeOffset EvaluatedAtUtc)
{
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, new Dictionary<string, StalenessEvaluation>(), DateTimeOffset.UnixEpoch);
}

View File

@@ -14,6 +14,7 @@ public sealed record TimeStatusDto(
[property: JsonPropertyName("breachSeconds")] long BreachSeconds,
[property: JsonPropertyName("isWarning")] bool IsWarning,
[property: JsonPropertyName("isBreach")] bool IsBreach,
[property: JsonPropertyName("contentStaleness")] IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
[property: JsonPropertyName("evaluatedAtUtc")] string EvaluatedAtUtc)
{
public static TimeStatusDto FromStatus(TimeStatus status)
@@ -29,6 +30,7 @@ public sealed record TimeStatusDto(
status.Staleness.BreachSeconds,
status.Staleness.IsWarning,
status.Staleness.IsBreach,
status.ContentStaleness,
status.EvaluatedAtUtc.ToUniversalTime().ToString("O"));
}

View File

@@ -10,6 +10,7 @@ using StellaOps.AirGap.Time.Parsing;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StalenessCalculator>();
builder.Services.AddSingleton<TimeTelemetry>();
builder.Services.AddSingleton<TimeStatusService>();
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
builder.Services.AddSingleton<TimeVerificationService>();

View File

@@ -22,4 +22,17 @@ public sealed class StalenessCalculator
return new StalenessEvaluation(ageSeconds, budget.WarningSeconds, budget.BreachSeconds, isWarning, isBreach);
}
public IReadOnlyDictionary<string, StalenessEvaluation> EvaluateContent(
TimeAnchor anchor,
IReadOnlyDictionary<string, StalenessBudget> budgets,
DateTimeOffset nowUtc)
{
var result = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in budgets)
{
result[kvp.Key] = Evaluate(anchor, kvp.Value, nowUtc);
}
return result;
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Stores;
@@ -10,11 +11,15 @@ public sealed class TimeStatusService
{
private readonly ITimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
private readonly TimeTelemetry _telemetry;
private readonly IReadOnlyDictionary<string, StalenessBudget> _contentBudgets;
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator)
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator, TimeTelemetry telemetry, IOptions<AirGapOptions> options)
{
_store = store;
_calculator = calculator;
_telemetry = telemetry;
_contentBudgets = BuildContentBudgets(options.Value);
}
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
@@ -27,6 +32,29 @@ public sealed class TimeStatusService
{
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
return new TimeStatus(anchor, eval, budget, nowUtc);
var content = _calculator.EvaluateContent(anchor, _contentBudgets, nowUtc);
var status = new TimeStatus(anchor, eval, budget, content, nowUtc);
_telemetry.Record(tenantId, status);
return status;
}
private static IReadOnlyDictionary<string, StalenessBudget> BuildContentBudgets(AirGapOptions opts)
{
var dict = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in opts.ContentBudgets)
{
dict[kvp.Key] = new StalenessBudget(kvp.Value.WarningSeconds, kvp.Value.BreachSeconds);
}
// Ensure common keys exist.
foreach (var key in new[] { "advisories", "vex", "policy" })
{
if (!dict.ContainsKey(key))
{
dict[key] = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
}
}
return dict;
}
}

View File

@@ -0,0 +1,52 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace StellaOps.AirGap.Time.Services;
public sealed class TimeTelemetry
{
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
private static readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.Ordinal);
private static readonly ObservableGauge<long> AnchorAgeGauge = Meter.CreateObservableGauge(
"airgap_time_anchor_age_seconds",
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
private static readonly Counter<long> StatusCounter = Meter.CreateCounter<long>("airgap_time_anchor_status_total");
private static readonly Counter<long> WarningCounter = Meter.CreateCounter<long>("airgap_time_anchor_warning_total");
private static readonly Counter<long> BreachCounter = Meter.CreateCounter<long>("airgap_time_anchor_breach_total");
public void Record(string tenantId, Models.TimeStatus status)
{
var snapshot = new Snapshot(status.Staleness.AgeSeconds, status.Staleness.IsWarning, status.Staleness.IsBreach);
_latest[tenantId] = snapshot;
var tags = new TagList
{
{ "tenant", tenantId },
{ "is_warning", status.Staleness.IsWarning },
{ "is_breach", status.Staleness.IsBreach }
};
StatusCounter.Add(1, tags);
if (status.Staleness.IsWarning)
{
WarningCounter.Add(1, tags);
}
if (status.Staleness.IsBreach)
{
BreachCounter.Add(1, tags);
}
}
public Snapshot? GetLatest(string tenantId)
{
return _latest.TryGetValue(tenantId, out var snap) ? snap : null;
}
public sealed record Snapshot(long AgeSeconds, bool IsWarning, bool IsBreach);
}