Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -18,12 +18,13 @@ Deliver offline bundle verification and ingestion tooling for sealed environment
### Versioning (Sprint 0338)
- `IVersionMonotonicityChecker` - Validates incoming versions are newer than active
- `IBundleVersionStore` - Postgres-backed version tracking per tenant/type
- Activation records include `bundleDigest`, `activatedAt`, and (when forced) `forceActivateReason`
- `BundleVersion` - SemVer + timestamp model with `IsNewerThan()` comparison
### Quarantine (Sprint 0338)
- `IQuarantineService` - Preserves failed bundles with diagnostics
- `FileSystemQuarantineService` - Implementation with TTL cleanup
- Structure: `/updates/quarantine/<timestamp>-<reason>/` with bundle, manifest, verification.log, failure-reason.txt
- `FileSystemQuarantineService` - Implementation with TTL cleanup + per-tenant quota enforcement
- Structure: `/updates/quarantine/<tenantId>/<timestamp>-<reason>-<id>/` with `bundle.tar.zst`, optional `manifest.json`, `verification.log`, `failure-reason.txt`, and `quarantine.json` metadata (removals move to `.../<tenantId>/.removed/`)
### Telemetry (Sprint 0341)
- `OfflineKitMetrics` - Prometheus metrics (import counts, latencies)

View File

@@ -0,0 +1,380 @@
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AirGap.Importer.Quarantine;
public sealed class FileSystemQuarantineService : IQuarantineService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly QuarantineOptions _options;
private readonly ILogger<FileSystemQuarantineService> _logger;
private readonly TimeProvider _timeProvider;
public FileSystemQuarantineService(
IOptions<QuarantineOptions> options,
ILogger<FileSystemQuarantineService> logger,
TimeProvider timeProvider)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<QuarantineResult> QuarantineAsync(
QuarantineRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ReasonCode);
if (!File.Exists(request.BundlePath))
{
return new QuarantineResult(
Success: false,
QuarantineId: "",
QuarantinePath: "",
QuarantinedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "bundle-path-not-found");
}
var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(request.TenantId));
if (_options.EnableAutomaticCleanup && _options.RetentionPeriod > TimeSpan.Zero)
{
_ = await CleanupExpiredAsync(_options.RetentionPeriod, cancellationToken).ConfigureAwait(false);
}
if (_options.MaxQuarantineSizeBytes > 0)
{
var bundleSize = new FileInfo(request.BundlePath).Length;
var currentSize = GetDirectorySizeBytes(tenantRoot);
if (currentSize + bundleSize > _options.MaxQuarantineSizeBytes)
{
return new QuarantineResult(
Success: false,
QuarantineId: "",
QuarantinePath: "",
QuarantinedAt: _timeProvider.GetUtcNow(),
ErrorMessage: "quarantine-quota-exceeded");
}
}
var now = _timeProvider.GetUtcNow();
var timestamp = now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
var sanitizedReason = SanitizeForPathSegment(request.ReasonCode);
var quarantineId = $"{timestamp}-{sanitizedReason}-{Guid.NewGuid():N}";
var quarantinePath = Path.Combine(tenantRoot, quarantineId);
try
{
Directory.CreateDirectory(quarantinePath);
var bundleDestination = Path.Combine(quarantinePath, "bundle.tar.zst");
File.Copy(request.BundlePath, bundleDestination, overwrite: false);
if (request.ManifestJson is not null)
{
await File.WriteAllTextAsync(
Path.Combine(quarantinePath, "manifest.json"),
request.ManifestJson,
cancellationToken).ConfigureAwait(false);
}
var verificationLogPath = Path.Combine(quarantinePath, "verification.log");
await File.WriteAllLinesAsync(verificationLogPath, request.VerificationLog, cancellationToken).ConfigureAwait(false);
var failureReasonPath = Path.Combine(quarantinePath, "failure-reason.txt");
await File.WriteAllTextAsync(
failureReasonPath,
BuildFailureReasonText(request, now),
cancellationToken).ConfigureAwait(false);
var bundleSize = new FileInfo(bundleDestination).Length;
var entry = new QuarantineEntry(
QuarantineId: quarantineId,
TenantId: request.TenantId,
OriginalBundleName: Path.GetFileName(request.BundlePath),
ReasonCode: request.ReasonCode,
ReasonMessage: request.ReasonMessage,
QuarantinedAt: now,
BundleSizeBytes: bundleSize,
QuarantinePath: quarantinePath);
await File.WriteAllTextAsync(
Path.Combine(quarantinePath, "quarantine.json"),
JsonSerializer.Serialize(entry, JsonOptions),
cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"Bundle quarantined: tenant={TenantId} quarantineId={QuarantineId} reason={ReasonCode} path={Path}",
request.TenantId,
quarantineId,
request.ReasonCode,
quarantinePath);
return new QuarantineResult(
Success: true,
QuarantineId: quarantineId,
QuarantinePath: quarantinePath,
QuarantinedAt: now);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to quarantine bundle to {Path}", quarantinePath);
return new QuarantineResult(
Success: false,
QuarantineId: quarantineId,
QuarantinePath: quarantinePath,
QuarantinedAt: now,
ErrorMessage: ex.Message);
}
}
public async Task<IReadOnlyList<QuarantineEntry>> ListAsync(
string tenantId,
QuarantineListOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
options ??= new QuarantineListOptions();
var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(tenantId));
if (!Directory.Exists(tenantRoot))
{
return Array.Empty<QuarantineEntry>();
}
var entries = new List<QuarantineEntry>();
foreach (var dir in Directory.EnumerateDirectories(tenantRoot))
{
cancellationToken.ThrowIfCancellationRequested();
if (Path.GetFileName(dir).Equals(".removed", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var jsonPath = Path.Combine(dir, "quarantine.json");
if (!File.Exists(jsonPath))
{
continue;
}
try
{
var json = await File.ReadAllTextAsync(jsonPath, cancellationToken).ConfigureAwait(false);
var entry = JsonSerializer.Deserialize<QuarantineEntry>(json, JsonOptions);
if (entry is null)
{
continue;
}
if (!string.IsNullOrWhiteSpace(options.ReasonCodeFilter) &&
!entry.ReasonCode.Equals(options.ReasonCodeFilter, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (options.Since is { } since && entry.QuarantinedAt < since)
{
continue;
}
if (options.Until is { } until && entry.QuarantinedAt > until)
{
continue;
}
entries.Add(entry);
}
catch
{
continue;
}
}
return entries
.OrderBy(e => e.QuarantinedAt)
.ThenBy(e => e.QuarantineId, StringComparer.Ordinal)
.Take(Math.Max(0, options.Limit))
.ToArray();
}
public async Task<bool> RemoveAsync(
string tenantId,
string quarantineId,
string removalReason,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(quarantineId);
ArgumentException.ThrowIfNullOrWhiteSpace(removalReason);
var tenantRoot = Path.Combine(_options.QuarantineRoot, SanitizeForPathSegment(tenantId));
var entryPath = Path.Combine(tenantRoot, quarantineId);
if (!Directory.Exists(entryPath))
{
return false;
}
var removalPath = Path.Combine(entryPath, "removal-reason.txt");
await File.WriteAllTextAsync(
removalPath,
$"RemovedAt: {_timeProvider.GetUtcNow():O}{Environment.NewLine}Reason: {removalReason}{Environment.NewLine}",
cancellationToken).ConfigureAwait(false);
var removedRoot = Path.Combine(tenantRoot, ".removed");
Directory.CreateDirectory(removedRoot);
var removedPath = Path.Combine(removedRoot, quarantineId);
if (Directory.Exists(removedPath))
{
removedPath = Path.Combine(removedRoot, $"{quarantineId}-{Guid.NewGuid():N}");
}
Directory.Move(entryPath, removedPath);
_logger.LogInformation(
"Quarantine removed: tenant={TenantId} quarantineId={QuarantineId} removedPath={RemovedPath}",
tenantId,
quarantineId,
removedPath);
return true;
}
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default)
{
if (retentionPeriod <= TimeSpan.Zero)
{
return Task.FromResult(0);
}
var now = _timeProvider.GetUtcNow();
var threshold = now - retentionPeriod;
if (!Directory.Exists(_options.QuarantineRoot))
{
return Task.FromResult(0);
}
var removedCount = 0;
foreach (var tenantRoot in Directory.EnumerateDirectories(_options.QuarantineRoot))
{
cancellationToken.ThrowIfCancellationRequested();
removedCount += CleanupExpiredInTenant(tenantRoot, threshold, cancellationToken);
var removedRoot = Path.Combine(tenantRoot, ".removed");
if (Directory.Exists(removedRoot))
{
removedCount += CleanupExpiredInTenant(removedRoot, threshold, cancellationToken);
}
}
return Task.FromResult(removedCount);
}
private static int CleanupExpiredInTenant(string tenantRoot, DateTimeOffset threshold, CancellationToken cancellationToken)
{
var removedCount = 0;
foreach (var dir in Directory.EnumerateDirectories(tenantRoot))
{
cancellationToken.ThrowIfCancellationRequested();
var jsonPath = Path.Combine(dir, "quarantine.json");
if (!File.Exists(jsonPath))
{
continue;
}
try
{
var json = File.ReadAllText(jsonPath);
var entry = JsonSerializer.Deserialize<QuarantineEntry>(json, JsonOptions);
if (entry is null)
{
continue;
}
if (entry.QuarantinedAt >= threshold)
{
continue;
}
Directory.Delete(dir, recursive: true);
removedCount++;
}
catch
{
continue;
}
}
return removedCount;
}
private static string BuildFailureReasonText(QuarantineRequest request, DateTimeOffset now)
{
var metadataLines = request.Metadata is null
? Array.Empty<string>()
: request.Metadata
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => $" {kv.Key}: {kv.Value}")
.ToArray();
return $"""
Quarantine Reason: {request.ReasonCode}
Message: {request.ReasonMessage}
Timestamp: {now:O}
Tenant: {request.TenantId}
Original Bundle: {Path.GetFileName(request.BundlePath)}
Metadata:
{string.Join(Environment.NewLine, metadataLines)}
""";
}
private static string SanitizeForPathSegment(string input)
{
input = input.Trim();
if (input.Length == 0)
{
return "_";
}
return Regex.Replace(input, @"[^a-zA-Z0-9_-]", "_");
}
private static long GetDirectorySizeBytes(string directory)
{
if (!Directory.Exists(directory))
{
return 0;
}
long total = 0;
foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
{
try
{
total += new FileInfo(file).Length;
}
catch
{
continue;
}
}
return total;
}
}

View File

@@ -0,0 +1,55 @@
namespace StellaOps.AirGap.Importer.Quarantine;
public interface IQuarantineService
{
Task<QuarantineResult> QuarantineAsync(
QuarantineRequest request,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<QuarantineEntry>> ListAsync(
string tenantId,
QuarantineListOptions? options = null,
CancellationToken cancellationToken = default);
Task<bool> RemoveAsync(
string tenantId,
string quarantineId,
string removalReason,
CancellationToken cancellationToken = default);
Task<int> CleanupExpiredAsync(
TimeSpan retentionPeriod,
CancellationToken cancellationToken = default);
}
public sealed record QuarantineRequest(
string TenantId,
string BundlePath,
string? ManifestJson,
string ReasonCode,
string ReasonMessage,
IReadOnlyList<string> VerificationLog,
IReadOnlyDictionary<string, string>? Metadata = null);
public sealed record QuarantineResult(
bool Success,
string QuarantineId,
string QuarantinePath,
DateTimeOffset QuarantinedAt,
string? ErrorMessage = null);
public sealed record QuarantineEntry(
string QuarantineId,
string TenantId,
string OriginalBundleName,
string ReasonCode,
string ReasonMessage,
DateTimeOffset QuarantinedAt,
long BundleSizeBytes,
string QuarantinePath);
public sealed record QuarantineListOptions(
string? ReasonCodeFilter = null,
DateTimeOffset? Since = null,
DateTimeOffset? Until = null,
int Limit = 100);

View File

@@ -0,0 +1,30 @@
namespace StellaOps.AirGap.Importer.Quarantine;
public sealed class QuarantineOptions
{
public const string SectionName = "AirGap:Quarantine";
/// <summary>
/// Root directory for quarantined bundles.
/// Default: /updates/quarantine
/// </summary>
public string QuarantineRoot { get; set; } = "/updates/quarantine";
/// <summary>
/// Retention period for quarantined bundles before automatic cleanup.
/// Default: 30 days
/// </summary>
public TimeSpan RetentionPeriod { get; set; } = TimeSpan.FromDays(30);
/// <summary>
/// Maximum total size of the quarantine directory per tenant, in bytes.
/// Default: 10 GB
/// </summary>
public long MaxQuarantineSizeBytes { get; set; } = 10L * 1024 * 1024 * 1024;
/// <summary>
/// Whether to run TTL cleanup during quarantine operations.
/// Default: true
/// </summary>
public bool EnableAutomaticCleanup { get; set; } = true;
}

View File

@@ -5,4 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Importer</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -1,9 +1,12 @@
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Coordinates DSSE, TUF, and Merkle verification for an offline import. Stateless and deterministic.
/// Coordinates DSSE, TUF, Merkle, monotonicity, and quarantine behaviors for an offline import.
/// </summary>
public sealed class ImportValidator
{
@@ -11,46 +14,214 @@ public sealed class ImportValidator
private readonly TufMetadataValidator _tuf;
private readonly MerkleRootCalculator _merkle;
private readonly RootRotationPolicy _rotation;
private readonly IVersionMonotonicityChecker _monotonicityChecker;
private readonly IQuarantineService _quarantineService;
private readonly ILogger<ImportValidator> _logger;
public ImportValidator()
public ImportValidator(
DsseVerifier dsse,
TufMetadataValidator tuf,
MerkleRootCalculator merkle,
RootRotationPolicy rotation,
IVersionMonotonicityChecker monotonicityChecker,
IQuarantineService quarantineService,
ILogger<ImportValidator> logger)
{
_dsse = new DsseVerifier();
_tuf = new TufMetadataValidator();
_merkle = new MerkleRootCalculator();
_rotation = new RootRotationPolicy();
_dsse = dsse ?? throw new ArgumentNullException(nameof(dsse));
_tuf = tuf ?? throw new ArgumentNullException(nameof(tuf));
_merkle = merkle ?? throw new ArgumentNullException(nameof(merkle));
_rotation = rotation ?? throw new ArgumentNullException(nameof(rotation));
_monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker));
_quarantineService = quarantineService ?? throw new ArgumentNullException(nameof(quarantineService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public BundleValidationResult Validate(ImportValidationRequest request)
public async Task<BundleValidationResult> ValidateAsync(
ImportValidationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleType);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(request.ManifestVersion);
var verificationLog = new List<string>(capacity: 16);
var tufResult = _tuf.Validate(request.RootJson, request.SnapshotJson, request.TimestampJson);
if (!tufResult.IsValid)
{
return tufResult with { Reason = $"tuf:{tufResult.Reason}" };
var failed = tufResult with { Reason = $"tuf:{tufResult.Reason}" };
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"tuf:{tufResult.Reason}");
var dsseResult = _dsse.Verify(request.Envelope, request.TrustRoots);
if (!dsseResult.IsValid)
{
return dsseResult with { Reason = $"dsse:{dsseResult.Reason}" };
var failed = dsseResult with { Reason = $"dsse:{dsseResult.Reason}" };
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"dsse:{dsseResult.Reason}");
var merkleRoot = _merkle.ComputeRoot(request.PayloadEntries);
if (string.IsNullOrEmpty(merkleRoot))
{
return BundleValidationResult.Failure("merkle-empty");
var failed = BundleValidationResult.Failure("merkle-empty");
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"merkle:{merkleRoot}");
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
if (!rotationResult.IsValid)
{
return rotationResult with { Reason = $"rotation:{rotationResult.Reason}" };
var failed = rotationResult with { Reason = $"rotation:{rotationResult.Reason}" };
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"rotation:{rotationResult.Reason}");
BundleVersion incomingVersion;
try
{
incomingVersion = BundleVersion.Parse(request.ManifestVersion, request.ManifestCreatedAt);
}
catch (Exception ex)
{
var failed = BundleValidationResult.Failure($"manifest-version-parse-failed:{ex.GetType().Name.ToLowerInvariant()}");
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
var monotonicity = await _monotonicityChecker.CheckAsync(
request.TenantId,
request.BundleType,
incomingVersion,
cancellationToken).ConfigureAwait(false);
if (!monotonicity.IsMonotonic && !request.ForceActivate)
{
var failed = BundleValidationResult.Failure(
$"version-non-monotonic:incoming={incomingVersion.SemVer}:current={monotonicity.CurrentVersion?.SemVer ?? "(none)"}");
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
if (!monotonicity.IsMonotonic && request.ForceActivate)
{
if (string.IsNullOrWhiteSpace(request.ForceActivateReason))
{
var failed = BundleValidationResult.Failure("force-activate-reason-required");
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
_logger.LogWarning(
"Non-monotonic activation forced: tenant={TenantId} bundleType={BundleType} incoming={Incoming} current={Current} reason={Reason}",
request.TenantId,
request.BundleType,
incomingVersion.SemVer,
monotonicity.CurrentVersion?.SemVer,
request.ForceActivateReason);
}
try
{
await _monotonicityChecker.RecordActivationAsync(
request.TenantId,
request.BundleType,
incomingVersion,
request.BundleDigest,
request.ForceActivate,
request.ForceActivateReason,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to record bundle activation for tenant={TenantId} bundleType={BundleType}", request.TenantId, request.BundleType);
var failed = BundleValidationResult.Failure($"version-store-write-failed:{ex.GetType().Name.ToLowerInvariant()}");
verificationLog.Add(failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
return BundleValidationResult.Success("import-validated");
}
private async Task TryQuarantineAsync(
ImportValidationRequest request,
BundleValidationResult failure,
IReadOnlyList<string> verificationLog,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.BundlePath) || !File.Exists(request.BundlePath))
{
return;
}
try
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["bundleType"] = request.BundleType,
["bundleDigest"] = request.BundleDigest,
["manifestVersion"] = request.ManifestVersion,
["manifestCreatedAt"] = request.ManifestCreatedAt.ToString("O"),
["forceActivate"] = request.ForceActivate.ToString()
};
if (!string.IsNullOrWhiteSpace(request.ForceActivateReason))
{
metadata["forceActivateReason"] = request.ForceActivateReason;
}
var quarantine = await _quarantineService.QuarantineAsync(
new QuarantineRequest(
request.TenantId,
request.BundlePath,
request.ManifestJson,
failure.Reason,
failure.Reason,
verificationLog,
metadata),
cancellationToken).ConfigureAwait(false);
if (!quarantine.Success)
{
_logger.LogError(
"Failed to quarantine bundle for tenant={TenantId} path={BundlePath} error={Error}",
request.TenantId,
request.BundlePath,
quarantine.ErrorMessage);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to quarantine bundle for tenant={TenantId} path={BundlePath}", request.TenantId, request.BundlePath);
}
}
}
public sealed record ImportValidationRequest(
string TenantId,
string BundleType,
string BundleDigest,
string BundlePath,
string? ManifestJson,
string ManifestVersion,
DateTimeOffset ManifestCreatedAt,
bool ForceActivate,
string? ForceActivateReason,
DsseEnvelope Envelope,
TrustRootConfig TrustRoots,
string RootJson,

View File

@@ -0,0 +1,144 @@
using System.Globalization;
namespace StellaOps.AirGap.Importer.Versioning;
/// <summary>
/// Represents a bundle version with semantic versioning and timestamp.
/// Monotonicity is enforced by comparing (Major, Minor, Patch, Prerelease, CreatedAt).
/// </summary>
public sealed record BundleVersion(
int Major,
int Minor,
int Patch,
DateTimeOffset CreatedAt,
string? Prerelease = null)
{
public static BundleVersion Parse(string version, DateTimeOffset createdAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var dashIndex = version.IndexOf('-', StringComparison.Ordinal);
var core = dashIndex < 0 ? version : version[..dashIndex];
var prerelease = dashIndex < 0 ? null : version[(dashIndex + 1)..];
var parts = core.Split('.', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3)
{
throw new FormatException($"Invalid version core '{core}'. Expected '<major>.<minor>.<patch>'.");
}
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) ||
!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor) ||
!int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
{
throw new FormatException($"Invalid version numbers in '{core}'.");
}
if (major < 0 || minor < 0 || patch < 0)
{
throw new FormatException($"Invalid version numbers in '{core}'.");
}
prerelease = string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim();
return new BundleVersion(major, minor, patch, createdAt, prerelease);
}
public string SemVer =>
string.IsNullOrWhiteSpace(Prerelease)
? $"{Major}.{Minor}.{Patch}"
: $"{Major}.{Minor}.{Patch}-{Prerelease}";
public bool IsNewerThan(BundleVersion other)
{
ArgumentNullException.ThrowIfNull(other);
if (Major != other.Major)
{
return Major > other.Major;
}
if (Minor != other.Minor)
{
return Minor > other.Minor;
}
if (Patch != other.Patch)
{
return Patch > other.Patch;
}
var prereleaseComparison = ComparePrerelease(Prerelease, other.Prerelease);
if (prereleaseComparison != 0)
{
return prereleaseComparison > 0;
}
return CreatedAt > other.CreatedAt;
}
private static int ComparePrerelease(string? left, string? right)
{
var leftEmpty = string.IsNullOrWhiteSpace(left);
var rightEmpty = string.IsNullOrWhiteSpace(right);
// Per SemVer: absence of prerelease indicates higher precedence than any prerelease.
if (leftEmpty && rightEmpty)
{
return 0;
}
if (leftEmpty)
{
return 1;
}
if (rightEmpty)
{
return -1;
}
var leftIds = left!.Split('.', StringSplitOptions.None);
var rightIds = right!.Split('.', StringSplitOptions.None);
var min = Math.Min(leftIds.Length, rightIds.Length);
for (var i = 0; i < min; i++)
{
var a = leftIds[i];
var b = rightIds[i];
var aIsNum = int.TryParse(a, NumberStyles.None, CultureInfo.InvariantCulture, out var aNum);
var bIsNum = int.TryParse(b, NumberStyles.None, CultureInfo.InvariantCulture, out var bNum);
if (aIsNum && bIsNum)
{
var cmp = aNum.CompareTo(bNum);
if (cmp != 0)
{
return cmp;
}
continue;
}
if (aIsNum && !bIsNum)
{
return -1;
}
if (!aIsNum && bIsNum)
{
return 1;
}
var s = string.Compare(a, b, StringComparison.Ordinal);
if (s != 0)
{
return s;
}
}
return leftIds.Length.CompareTo(rightIds.Length);
}
public override string ToString() => $"{SemVer} ({CreatedAt:O})";
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.AirGap.Importer.Versioning;
public interface IBundleVersionStore
{
Task<BundleVersionRecord?> GetCurrentAsync(
string tenantId,
string bundleType,
CancellationToken ct = default);
Task UpsertAsync(
BundleVersionRecord record,
CancellationToken ct = default);
Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(
string tenantId,
string bundleType,
int limit = 10,
CancellationToken ct = default);
}
public sealed record BundleVersionRecord(
string TenantId,
string BundleType,
string VersionString,
int Major,
int Minor,
int Patch,
string? Prerelease,
DateTimeOffset BundleCreatedAt,
string BundleDigest,
DateTimeOffset ActivatedAt,
bool WasForceActivated,
string? ForceActivateReason);

View File

@@ -0,0 +1,26 @@
namespace StellaOps.AirGap.Importer.Versioning;
public interface IVersionMonotonicityChecker
{
Task<MonotonicityCheckResult> CheckAsync(
string tenantId,
string bundleType,
BundleVersion incomingVersion,
CancellationToken cancellationToken = default);
Task RecordActivationAsync(
string tenantId,
string bundleType,
BundleVersion version,
string bundleDigest,
bool wasForceActivated = false,
string? forceActivateReason = null,
CancellationToken cancellationToken = default);
}
public sealed record MonotonicityCheckResult(
bool IsMonotonic,
BundleVersion? CurrentVersion,
string? CurrentBundleDigest,
DateTimeOffset? CurrentActivatedAt,
string ReasonCode); // "MONOTONIC_OK" | "VERSION_NON_MONOTONIC" | "FIRST_ACTIVATION"

View File

@@ -0,0 +1,95 @@
namespace StellaOps.AirGap.Importer.Versioning;
public sealed class VersionMonotonicityChecker : IVersionMonotonicityChecker
{
private readonly IBundleVersionStore _store;
private readonly TimeProvider _timeProvider;
public VersionMonotonicityChecker(IBundleVersionStore store, TimeProvider timeProvider)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<MonotonicityCheckResult> CheckAsync(
string tenantId,
string bundleType,
BundleVersion incomingVersion,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
ArgumentNullException.ThrowIfNull(incomingVersion);
var current = await _store.GetCurrentAsync(tenantId, bundleType, cancellationToken).ConfigureAwait(false);
if (current is null)
{
return new MonotonicityCheckResult(
IsMonotonic: true,
CurrentVersion: null,
CurrentBundleDigest: null,
CurrentActivatedAt: null,
ReasonCode: "FIRST_ACTIVATION");
}
var currentVersion = new BundleVersion(
current.Major,
current.Minor,
current.Patch,
current.BundleCreatedAt,
current.Prerelease);
var isMonotonic = incomingVersion.IsNewerThan(currentVersion);
return new MonotonicityCheckResult(
IsMonotonic: isMonotonic,
CurrentVersion: currentVersion,
CurrentBundleDigest: current.BundleDigest,
CurrentActivatedAt: current.ActivatedAt,
ReasonCode: isMonotonic ? "MONOTONIC_OK" : "VERSION_NON_MONOTONIC");
}
public async Task RecordActivationAsync(
string tenantId,
string bundleType,
BundleVersion version,
string bundleDigest,
bool wasForceActivated = false,
string? forceActivateReason = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
ArgumentNullException.ThrowIfNull(version);
ArgumentException.ThrowIfNullOrWhiteSpace(bundleDigest);
if (wasForceActivated && string.IsNullOrWhiteSpace(forceActivateReason))
{
throw new ArgumentException("Force-activate requires a non-empty reason.", nameof(forceActivateReason));
}
var check = await CheckAsync(tenantId, bundleType, version, cancellationToken).ConfigureAwait(false);
if (!check.IsMonotonic && !wasForceActivated)
{
throw new InvalidOperationException(
$"Incoming version '{version.SemVer}' is not monotonic vs current '{check.CurrentVersion?.SemVer}'.");
}
var activatedAt = _timeProvider.GetUtcNow();
var record = new BundleVersionRecord(
TenantId: tenantId,
BundleType: bundleType,
VersionString: version.SemVer,
Major: version.Major,
Minor: version.Minor,
Patch: version.Patch,
Prerelease: version.Prerelease,
BundleCreatedAt: version.CreatedAt,
BundleDigest: bundleDigest,
ActivatedAt: activatedAt,
WasForceActivated: wasForceActivated,
ForceActivateReason: wasForceActivated ? forceActivateReason : null);
await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
}
}