Files
git.stella-ops.org/src/AirGap/StellaOps.AirGap.Importer/Quarantine/FileSystemQuarantineService.cs
2026-02-01 21:37:40 +02:00

397 lines
14 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Importer.Telemetry;
using StellaOps.Determinism;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
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;
private readonly IGuidProvider _guidProvider;
public FileSystemQuarantineService(
IOptions<QuarantineOptions> options,
ILogger<FileSystemQuarantineService> logger,
TimeProvider timeProvider,
IGuidProvider? guidProvider = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
}
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);
using var tenantScope = _logger.BeginTenantScope(request.TenantId);
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}-{_guidProvider.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(
"offlinekit.quarantine created tenant_id={tenant_id} quarantine_id={quarantine_id} reason_code={reason_code} quarantine_path={quarantine_path} original_bundle={original_bundle}",
request.TenantId,
quarantineId,
request.ReasonCode,
quarantinePath,
Path.GetFileName(request.BundlePath));
return new QuarantineResult(
Success: true,
QuarantineId: quarantineId,
QuarantinePath: quarantinePath,
QuarantinedAt: now);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"offlinekit.quarantine failed tenant_id={tenant_id} quarantine_id={quarantine_id} quarantine_path={quarantine_path}",
request.TenantId,
quarantineId,
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);
using var tenantScope = _logger.BeginTenantScope(tenantId);
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}-{_guidProvider.NewGuid():N}");
}
Directory.Move(entryPath, removedPath);
_logger.LogInformation(
"offlinekit.quarantine removed tenant_id={tenant_id} quarantine_id={quarantine_id} removed_path={removed_path}",
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;
}
}