Sube carpetas API, API.Tests, Application y Domain del Backend

This commit is contained in:
FrancoRu
2026-09-18 13:35:59 -03:00
parent bbb09554bb
commit 2eb4eeef03
572 changed files with 56405 additions and 0 deletions
@@ -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;
}
}