Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Application.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide single-flight guard for backup and restore write operations.
|
||||
/// </summary>
|
||||
public sealed class BackupOperationLock
|
||||
{
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to acquire the lock, waiting up to timeout; callers pass TimeSpan.Zero to fail fast.
|
||||
/// </summary>
|
||||
public Task<bool> WaitAsync(TimeSpan timeout, CancellationToken ct = default)
|
||||
{
|
||||
return _semaphore.WaitAsync(timeout, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lock; must be called exactly once for every successful WaitAsync that returned true.
|
||||
/// </summary>
|
||||
public void Release()
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Application.DTOs.Backup.Response;
|
||||
using Application.Interfaces.Backup;
|
||||
using Application.Interfaces.Services;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Application.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// The one shared write path for backup operations.
|
||||
/// </summary>
|
||||
public sealed class BackupOperationsService(
|
||||
IBackupCatalog catalog,
|
||||
IBackupStorage storage,
|
||||
IDatabaseBackupService backupService,
|
||||
IDatabaseRestoreService restoreService,
|
||||
IBackupRetentionPolicy retentionPolicy,
|
||||
BackupOptions options,
|
||||
BackupOperationLock operationLock,
|
||||
IMaintenanceModeState maintenanceModeState,
|
||||
IAuditService auditService,
|
||||
ILogger<BackupOperationsService> logger) : IBackupOperationsService
|
||||
{
|
||||
public Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default) =>
|
||||
catalog.ListNewestFirstAsync(ct);
|
||||
|
||||
public async Task<BackupOperationResult> CreateBackupAsync(BackupOrigin origin, CancellationToken ct = default)
|
||||
{
|
||||
if (!await operationLock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await CreateBackupCoreAsync(origin, applyRetention: true, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BackupOperationResult> DeleteBackupAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
if (!await operationLock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
BackupRecord? record = await catalog.GetByIdAsync(id, ct);
|
||||
if (record is null)
|
||||
{
|
||||
return new BackupOperationResult(BackupOperationOutcome.NotFound, null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await storage.DeleteAsync(record.StoragePath, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// An already-missing file deleted out-of-band must not block removing the catalog row, so log and continue.
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Backup file could not be deleted from storage; removing catalog record anyway. StoragePath: {StoragePath}",
|
||||
record.StoragePath);
|
||||
}
|
||||
|
||||
await catalog.RemoveAsync(record.Id, ct);
|
||||
|
||||
return new BackupOperationResult(BackupOperationOutcome.Completed, BackupRecordResponse.FromEntity(record), null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enters maintenance mode, takes an automatic safety backup, restores the selected one, then exits.
|
||||
/// </summary>
|
||||
public async Task<BackupOperationResult> RestoreBackupAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
if (!await operationLock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
return new BackupOperationResult(BackupOperationOutcome.Busy, null, ErrorMessages.Backup.OperationInProgress);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
BackupRecord? record = await catalog.GetByIdAsync(id, ct);
|
||||
if (record is null)
|
||||
{
|
||||
return new BackupOperationResult(BackupOperationOutcome.NotFound, null, null);
|
||||
}
|
||||
|
||||
maintenanceModeState.Enter($"Restoring backup {record.Id}.");
|
||||
string? tempFilePath = null;
|
||||
try
|
||||
{
|
||||
BackupOperationResult safetyBackup = await CreateBackupCoreAsync(BackupOrigin.Job, applyRetention: false, ct);
|
||||
if (safetyBackup.Outcome != BackupOperationOutcome.Completed)
|
||||
{
|
||||
return new BackupOperationResult(BackupOperationOutcome.Failed, null, safetyBackup.Message);
|
||||
}
|
||||
|
||||
tempFilePath = Path.Combine(Path.GetTempPath(), $"restore-{Guid.NewGuid():N}.sql");
|
||||
await using (Stream source = await storage.OpenReadAsync(record.StoragePath, ct))
|
||||
await using (FileStream destination = File.Create(tempFilePath))
|
||||
{
|
||||
await source.CopyToAsync(destination, ct);
|
||||
}
|
||||
|
||||
await restoreService.RestoreAsync(tempFilePath, ct);
|
||||
|
||||
// Logging the restore is non-throwing by contract via IAuditService.LogAsync, so a logging hiccup never turns a successful restore into a reported failure.
|
||||
await auditService.LogAsync(
|
||||
AuditAction.BackupRestore,
|
||||
targetType: nameof(BackupRecord),
|
||||
targetId: record.Id.ToString(),
|
||||
targetName: record.StoragePath,
|
||||
detail: $"Origen: {record.Origin}",
|
||||
ct: ct);
|
||||
|
||||
return new BackupOperationResult(BackupOperationOutcome.Completed, safetyBackup.Record, null);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Restore failed; maintenance mode will be cleared and the host keeps running.");
|
||||
return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tempFilePath is not null && File.Exists(tempFilePath))
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
|
||||
maintenanceModeState.Exit();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assumes the lock is already held by the caller, so a restore's safety backup avoids self-deadlock.
|
||||
/// </summary>
|
||||
private async Task<BackupOperationResult> CreateBackupCoreAsync(BackupOrigin origin, bool applyRetention, CancellationToken ct)
|
||||
{
|
||||
Stream dump;
|
||||
try
|
||||
{
|
||||
dump = await backupService.CreateDumpAsync(ct);
|
||||
}
|
||||
catch (BackupExecutionException ex)
|
||||
{
|
||||
logger.LogError(ex, "Backup dump failed; no catalog record written.");
|
||||
return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message);
|
||||
}
|
||||
|
||||
await using (dump)
|
||||
{
|
||||
string name = $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.sql";
|
||||
long sizeBytes = dump.Length;
|
||||
|
||||
try
|
||||
{
|
||||
await storage.StoreAsync(name, dump, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Failed to store backup dump; no catalog record written.");
|
||||
return new BackupOperationResult(BackupOperationOutcome.Failed, null, ex.Message);
|
||||
}
|
||||
|
||||
BackupRecord record = new()
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
StoragePath = name,
|
||||
SizeBytes = sizeBytes,
|
||||
Origin = origin,
|
||||
};
|
||||
BackupRecord added = await catalog.AddAsync(record, ct);
|
||||
|
||||
if (applyRetention)
|
||||
{
|
||||
await ApplyRetentionAsync(ct);
|
||||
}
|
||||
|
||||
return new BackupOperationResult(BackupOperationOutcome.Completed, BackupRecordResponse.FromEntity(added), null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyRetentionAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<BackupRecord> all = await catalog.ListNewestFirstAsync(ct);
|
||||
IReadOnlyList<BackupFile> files = all
|
||||
.Select(r => new BackupFile(r.StoragePath, new DateTimeOffset(DateTime.SpecifyKind(r.DateCreated, DateTimeKind.Utc))))
|
||||
.ToList();
|
||||
|
||||
IReadOnlyList<BackupFile> toDelete = retentionPolicy.SelectForDeletion(files, options.RetentionCount);
|
||||
if (toDelete.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, BackupRecord> byStoragePath = all.ToDictionary(r => r.StoragePath, StringComparer.Ordinal);
|
||||
IEnumerable<BackupRecord> recordsToDelete = toDelete
|
||||
.Select(stale => byStoragePath.GetValueOrDefault(stale.Name))
|
||||
.OfType<BackupRecord>();
|
||||
|
||||
foreach (BackupRecord record in recordsToDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
await storage.DeleteAsync(record.StoragePath, ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete stale backup file {StoragePath} during retention pruning.", record.StoragePath);
|
||||
}
|
||||
|
||||
await catalog.RemoveAsync(record.Id, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Application.Interfaces.Backup;
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// Pure keep-last-N retention policy that retains the newest entries by timestamp with no I/O.
|
||||
/// </summary>
|
||||
public sealed class KeepLastNRetentionPolicy : IBackupRetentionPolicy
|
||||
{
|
||||
public IReadOnlyList<BackupFile> SelectForDeletion(IReadOnlyList<BackupFile> existing, int retainCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(existing);
|
||||
|
||||
if (retainCount < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(retainCount), ErrorMessages.Backup.RetentionCountNegative);
|
||||
}
|
||||
|
||||
if (existing.Count <= retainCount)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return existing
|
||||
.OrderByDescending(f => f.Timestamp)
|
||||
.ThenBy(f => f.Name, StringComparer.Ordinal)
|
||||
.Skip(retainCount)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Application.Backup;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory IMaintenanceModeState singleton, pure process state with no OS or network I/O.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModeState : IMaintenanceModeState
|
||||
{
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public string? Reason { get; private set; }
|
||||
|
||||
public DateTimeOffset? EnteredAtUtc { get; private set; }
|
||||
|
||||
public void Enter(string reason)
|
||||
{
|
||||
Reason = reason;
|
||||
EnteredAtUtc = DateTimeOffset.UtcNow;
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
public void Exit()
|
||||
{
|
||||
IsActive = false;
|
||||
Reason = null;
|
||||
EnteredAtUtc = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user