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
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:
@@ -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.
|
||||
|
||||
3
src/AirGap/StellaOps.AirGap.Controller/AssemblyInfo.cs
Normal file
3
src/AirGap/StellaOps.AirGap.Controller/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.AirGap.Controller.Tests")]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
52
src/AirGap/StellaOps.AirGap.Time/Services/TimeTelemetry.cs
Normal file
52
src/AirGap/StellaOps.AirGap.Time/Services/TimeTelemetry.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user