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

@@ -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;
}
}