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:
@@ -29,6 +29,7 @@
|
||||
- Tenancy/scopes: enforce Authority scopes (`airgap:seal`, `airgap:status:read`, importer scopes) on every API.
|
||||
- Validation: prefer `$jsonSchema`/FluentValidation; fail closed on trust-root mismatch.
|
||||
- Logging/Telemetry: structured logs; counters/histograms prefixed `airgap.*`; tag `tenant`, `sealed`, `result`.
|
||||
- Monotonicity/quarantine: enforce version rollback prevention per tenant/type and quarantine failed bundles under `/updates/quarantine/<tenantId>/...` with TTL + quota guardrails.
|
||||
- Cross-module edits require sprint note; otherwise stay within `src/AirGap`.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
144
src/AirGap/StellaOps.AirGap.Importer/Versioning/BundleVersion.cs
Normal file
144
src/AirGap/StellaOps.AirGap.Importer/Versioning/BundleVersion.cs
Normal 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})";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AirGap.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed store for AirGap bundle version activation tracking.
|
||||
/// </summary>
|
||||
public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource>, IBundleVersionStore
|
||||
{
|
||||
private volatile bool _initialized;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public PostgresBundleVersionStore(AirGapDataSource dataSource, ILogger<PostgresBundleVersionStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<BundleVersionRecord?> GetCurrentAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
|
||||
|
||||
await EnsureTablesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var tenantKey = NormalizeKey(tenantId);
|
||||
var bundleTypeKey = NormalizeKey(bundleType);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
FROM airgap.bundle_versions
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantKey);
|
||||
AddParameter(command, "bundle_type", bundleTypeKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
return await reader.ReadAsync(ct).ConfigureAwait(false) ? Map(reader) : null;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(BundleVersionRecord record, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
await EnsureTablesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var tenantKey = NormalizeKey(record.TenantId);
|
||||
var bundleTypeKey = NormalizeKey(record.BundleType);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", ct).ConfigureAwait(false);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
const string closeHistorySql = """
|
||||
UPDATE airgap.bundle_version_history
|
||||
SET deactivated_at = @activated_at
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type AND deactivated_at IS NULL;
|
||||
""";
|
||||
|
||||
await using (var closeCmd = CreateCommand(closeHistorySql, connection))
|
||||
{
|
||||
closeCmd.Transaction = tx;
|
||||
AddParameter(closeCmd, "tenant_id", tenantKey);
|
||||
AddParameter(closeCmd, "bundle_type", bundleTypeKey);
|
||||
AddParameter(closeCmd, "activated_at", record.ActivatedAt);
|
||||
await closeCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
const string historySql = """
|
||||
INSERT INTO airgap.bundle_version_history (
|
||||
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, deactivated_at, was_force_activated, force_activate_reason
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id, @bundle_type, @version_string, @major, @minor, @patch, @prerelease,
|
||||
@bundle_created_at, @bundle_digest, @activated_at, NULL, @was_force_activated, @force_activate_reason
|
||||
);
|
||||
""";
|
||||
|
||||
await using (var historyCmd = CreateCommand(historySql, connection))
|
||||
{
|
||||
historyCmd.Transaction = tx;
|
||||
AddParameter(historyCmd, "tenant_id", tenantKey);
|
||||
AddParameter(historyCmd, "bundle_type", bundleTypeKey);
|
||||
AddParameter(historyCmd, "version_string", record.VersionString);
|
||||
AddParameter(historyCmd, "major", record.Major);
|
||||
AddParameter(historyCmd, "minor", record.Minor);
|
||||
AddParameter(historyCmd, "patch", record.Patch);
|
||||
AddParameter(historyCmd, "prerelease", (object?)record.Prerelease ?? DBNull.Value);
|
||||
AddParameter(historyCmd, "bundle_created_at", record.BundleCreatedAt);
|
||||
AddParameter(historyCmd, "bundle_digest", record.BundleDigest);
|
||||
AddParameter(historyCmd, "activated_at", record.ActivatedAt);
|
||||
AddParameter(historyCmd, "was_force_activated", record.WasForceActivated);
|
||||
AddParameter(historyCmd, "force_activate_reason", (object?)record.ForceActivateReason ?? DBNull.Value);
|
||||
await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
const string upsertSql = """
|
||||
INSERT INTO airgap.bundle_versions (
|
||||
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
)
|
||||
VALUES (
|
||||
@tenant_id, @bundle_type, @version_string, @major, @minor, @patch, @prerelease,
|
||||
@bundle_created_at, @bundle_digest, @activated_at, @was_force_activated, @force_activate_reason
|
||||
)
|
||||
ON CONFLICT (tenant_id, bundle_type) DO UPDATE SET
|
||||
version_string = EXCLUDED.version_string,
|
||||
major = EXCLUDED.major,
|
||||
minor = EXCLUDED.minor,
|
||||
patch = EXCLUDED.patch,
|
||||
prerelease = EXCLUDED.prerelease,
|
||||
bundle_created_at = EXCLUDED.bundle_created_at,
|
||||
bundle_digest = EXCLUDED.bundle_digest,
|
||||
activated_at = EXCLUDED.activated_at,
|
||||
was_force_activated = EXCLUDED.was_force_activated,
|
||||
force_activate_reason = EXCLUDED.force_activate_reason,
|
||||
updated_at = NOW();
|
||||
""";
|
||||
|
||||
await using (var upsertCmd = CreateCommand(upsertSql, connection))
|
||||
{
|
||||
upsertCmd.Transaction = tx;
|
||||
AddParameter(upsertCmd, "tenant_id", tenantKey);
|
||||
AddParameter(upsertCmd, "bundle_type", bundleTypeKey);
|
||||
AddParameter(upsertCmd, "version_string", record.VersionString);
|
||||
AddParameter(upsertCmd, "major", record.Major);
|
||||
AddParameter(upsertCmd, "minor", record.Minor);
|
||||
AddParameter(upsertCmd, "patch", record.Patch);
|
||||
AddParameter(upsertCmd, "prerelease", (object?)record.Prerelease ?? DBNull.Value);
|
||||
AddParameter(upsertCmd, "bundle_created_at", record.BundleCreatedAt);
|
||||
AddParameter(upsertCmd, "bundle_digest", record.BundleDigest);
|
||||
AddParameter(upsertCmd, "activated_at", record.ActivatedAt);
|
||||
AddParameter(upsertCmd, "was_force_activated", record.WasForceActivated);
|
||||
AddParameter(upsertCmd, "force_activate_reason", (object?)record.ForceActivateReason ?? DBNull.Value);
|
||||
await upsertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
int limit = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
|
||||
|
||||
if (limit <= 0)
|
||||
{
|
||||
return Array.Empty<BundleVersionRecord>();
|
||||
}
|
||||
|
||||
await EnsureTablesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var tenantKey = NormalizeKey(tenantId);
|
||||
var bundleTypeKey = NormalizeKey(bundleType);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
FROM airgap.bundle_version_history
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
|
||||
ORDER BY activated_at DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantKey);
|
||||
AddParameter(command, "bundle_type", bundleTypeKey);
|
||||
AddParameter(command, "limit", limit);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
var results = new List<BundleVersionRecord>();
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(Map(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static BundleVersionRecord Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var tenantId = reader.GetString(0);
|
||||
var bundleType = reader.GetString(1);
|
||||
var versionString = reader.GetString(2);
|
||||
var major = reader.GetInt32(3);
|
||||
var minor = reader.GetInt32(4);
|
||||
var patch = reader.GetInt32(5);
|
||||
var prerelease = reader.IsDBNull(6) ? null : reader.GetString(6);
|
||||
var bundleCreatedAt = reader.GetFieldValue<DateTimeOffset>(7);
|
||||
var bundleDigest = reader.GetString(8);
|
||||
var activatedAt = reader.GetFieldValue<DateTimeOffset>(9);
|
||||
var wasForceActivated = reader.GetBoolean(10);
|
||||
var forceActivateReason = reader.IsDBNull(11) ? null : reader.GetString(11);
|
||||
|
||||
return new BundleVersionRecord(
|
||||
TenantId: tenantId,
|
||||
BundleType: bundleType,
|
||||
VersionString: versionString,
|
||||
Major: major,
|
||||
Minor: minor,
|
||||
Patch: patch,
|
||||
Prerelease: prerelease,
|
||||
BundleCreatedAt: bundleCreatedAt,
|
||||
BundleDigest: bundleDigest,
|
||||
ActivatedAt: activatedAt,
|
||||
WasForceActivated: wasForceActivated,
|
||||
ForceActivateReason: forceActivateReason);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureTablesAsync(CancellationToken ct)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
CREATE SCHEMA IF NOT EXISTS airgap;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airgap.bundle_versions (
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_type TEXT NOT NULL,
|
||||
version_string TEXT NOT NULL,
|
||||
major INTEGER NOT NULL,
|
||||
minor INTEGER NOT NULL,
|
||||
patch INTEGER NOT NULL,
|
||||
prerelease TEXT,
|
||||
bundle_created_at TIMESTAMPTZ NOT NULL,
|
||||
bundle_digest TEXT NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL,
|
||||
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
force_activate_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, bundle_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_versions_tenant
|
||||
ON airgap.bundle_versions(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airgap.bundle_version_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_type TEXT NOT NULL,
|
||||
version_string TEXT NOT NULL,
|
||||
major INTEGER NOT NULL,
|
||||
minor INTEGER NOT NULL,
|
||||
patch INTEGER NOT NULL,
|
||||
prerelease TEXT,
|
||||
bundle_created_at TIMESTAMPTZ NOT NULL,
|
||||
bundle_digest TEXT NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL,
|
||||
deactivated_at TIMESTAMPTZ,
|
||||
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
force_activate_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_version_history_tenant
|
||||
ON airgap.bundle_version_history(tenant_id, bundle_type, activated_at DESC);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string value) => value.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
using StellaOps.AirGap.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
@@ -26,6 +27,7 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
services.AddSingleton<AirGapDataSource>();
|
||||
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
|
||||
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -43,6 +45,7 @@ public static class ServiceCollectionExtensions
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<AirGapDataSource>();
|
||||
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
|
||||
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
| MR-T10.6.1 | DONE | Removed Mongo-backed air-gap state store; controller now uses in-memory store only. | 2025-12-11 |
|
||||
| MR-T10.6.2 | DONE | DI simplified to register in-memory air-gap state store (no Mongo options or client). | 2025-12-11 |
|
||||
| MR-T10.6.3 | DONE | Converted controller tests to in-memory store; dropped Mongo2Go dependency. | 2025-12-11 |
|
||||
| AIRGAP-IMP-0338 | DONE | Implemented monotonicity enforcement + quarantine service (version primitives/checker, Postgres version store, importer validator integration, unit/integration tests). | 2025-12-15 |
|
||||
|
||||
Reference in New Issue
Block a user