397 lines
14 KiB
C#
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;
|
|
}
|
|
}
|