Sube capa Infrastructure, Solution y archivos de configuracion del Backend

This commit is contained in:
FrancoRu
2026-09-18 13:36:47 -03:00
parent 2eb4eeef03
commit 1ef9a0bd03
205 changed files with 77651 additions and 0 deletions
@@ -0,0 +1,90 @@
using Application.Interfaces.Backup;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Infrastructure.Backup;
/// <summary>
/// IBackupStorage implementation backed by a local or mounted directory, used as the fallback storage adapter when Supabase storage is not configured.
/// </summary>
public sealed class LocalDirectoryBackupStorage : IBackupStorage
{
private readonly string _directoryPath;
private readonly ILogger<LocalDirectoryBackupStorage> _logger;
public LocalDirectoryBackupStorage(string directoryPath, ILogger<LocalDirectoryBackupStorage> logger)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directoryPath);
_logger = logger;
_directoryPath = Path.GetFullPath(directoryPath);
Directory.CreateDirectory(_directoryPath);
}
public async Task StoreAsync(string name, Stream content, CancellationToken ct = default)
{
string path = ResolveSafePath(name);
await using FileStream file = File.Create(path);
await content.CopyToAsync(file, ct);
}
public Task<IReadOnlyList<BackupFile>> ListAsync(CancellationToken ct = default)
{
IReadOnlyList<BackupFile> files = Directory.EnumerateFiles(_directoryPath)
.Select(path => new BackupFile(
Path.GetFileName(path),
new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero)))
.ToList();
return Task.FromResult(files);
}
public Task<Stream> OpenReadAsync(string name, CancellationToken ct = default)
{
string path = ResolveSafePath(name);
Stream stream = File.OpenRead(path);
return Task.FromResult(stream);
}
public Task DeleteAsync(string name, CancellationToken ct = default)
{
string path = ResolveSafePath(name);
if (File.Exists(path))
{
File.Delete(path);
}
else
{
_logger.LogWarning("DeleteAsync called for a backup that no longer exists: {Name}", name);
}
return Task.CompletedTask;
}
/// <summary>
/// Resolves name against the configured directory and throws ArgumentException unless the resolved path stays strictly inside it.
/// </summary>
private string ResolveSafePath(string name)
{
if (string.IsNullOrWhiteSpace(name) || Path.IsPathRooted(name))
{
throw new ArgumentException("Backup file name must be a non-empty, relative name.", nameof(name));
}
string fullPath = Path.GetFullPath(Path.Combine(_directoryPath, name));
string prefix = _directoryPath.EndsWith(Path.DirectorySeparatorChar)
? _directoryPath
: _directoryPath + Path.DirectorySeparatorChar;
return !fullPath.StartsWith(prefix, StringComparison.Ordinal)
? throw new ArgumentException(
$"Backup file name '{name}' resolves outside the configured backup directory.", nameof(name))
: fullPath;
}
}
@@ -0,0 +1,97 @@
using Application.Interfaces.Backup;
using Application.Utils.Constants.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Infrastructure.Persistance;
using Npgsql;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Infrastructure.Backup;
/// <summary>
/// IDatabaseBackupService implementation that shells out to pg_dump via IProcessRunner, passing connection details as a discrete argument vector instead of a shell command string.
/// </summary>
public sealed class PgDumpBackupService(
IProcessRunner processRunner,
IConfiguration configuration,
ILogger<PgDumpBackupService> logger) : IDatabaseBackupService
{
/// <summary>
/// Matches a CREATE, DROP, ALTER, or COMMENT ON EVENT TRIGGER statement so it can be stripped from the dump before restore.
/// </summary>
private static readonly Regex EventTriggerStatementPattern = new(
@"^\s*(CREATE|DROP|ALTER|COMMENT\s+ON)\s+EVENT\s+TRIGGER\b.*?;\s*$",
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
public async Task<Stream> CreateDumpAsync(CancellationToken ct = default)
{
string connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection)
?? throw new BackupExecutionException(
$"ConnectionStrings:{ConfigurationKeys.DbConnection} is not configured.");
string? configuredPgDumpPath = configuration[ConfigurationKeys.Backup.PgDumpPath];
string pgDumpPath = string.IsNullOrWhiteSpace(configuredPgDumpPath) ? "pg_dump" : configuredPgDumpPath;
NpgsqlConnectionStringBuilder builder = new(connectionString);
List<string> args =
[
"-h", builder.Host ?? "localhost",
"-p", builder.Port.ToString(),
"-U", builder.Username ?? string.Empty,
"-d", builder.Database ?? string.Empty,
// These flags shift ownership and role safety to the dump so restoring into a Supabase-managed database with different owners does not fail on CREATE or ALTER OWNER statements.
"--clean", "--if-exists", "--no-owner", "--no-privileges",
// Restricting to public and Club12 schemas keeps pg_dump from capturing Supabase-platform-owned objects the app's role doesn't own, which would make --clean's DROP fail on restore.
// Club12 must be double-quoted because pg_dump's -n pattern matching lowercases an unquoted argument before comparing it against the catalog, and the schema's real name is mixed-case, so an unquoted argument silently matches nothing.
"-n", "public", "-n", $"\"{EntityConstants.Schema}\"",
];
Dictionary<string, string>? environmentVariables = null;
if (!string.IsNullOrEmpty(builder.Password))
{
environmentVariables = new Dictionary<string, string>
{
["PGPASSWORD"] = builder.Password,
};
}
ProcessResult result = await processRunner.RunAsync(pgDumpPath, args, environmentVariables, ct);
if (result.ExitCode != 0)
{
logger.LogError(
"pg_dump failed with exit code {ExitCode}. Path: '{PgDumpPath}'. StdErr: {StdErr}",
result.ExitCode, pgDumpPath, result.StdErr);
throw new BackupExecutionException(
$"pg_dump failed (exit code {result.ExitCode}) using '{pgDumpPath}'. " +
"Verify pg_dump is installed and on PATH, or set Backup:PgDumpPath to its full path. " +
$"Details: {result.StdErr}");
}
int strippedCount = EventTriggerStatementPattern.Matches(result.StdOut).Count;
string filteredDump = EventTriggerStatementPattern.Replace(result.StdOut, string.Empty);
if (strippedCount > 0)
{
logger.LogInformation(
"Stripped {Count} EVENT TRIGGER statement(s) from the pg_dump output — these are " +
"Supabase/PostgREST-managed infrastructure objects the app does not own and cannot " +
"DROP/CREATE on restore.",
strippedCount);
}
return new MemoryStream(Encoding.UTF8.GetBytes(filteredDump));
}
}
@@ -0,0 +1,64 @@
using Application.Interfaces.Backup;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace Infrastructure.Backup;
/// <summary>
/// IProcessRunner adapter backed by Process that passes arguments via ArgumentList instead of a concatenated shell command string.
/// </summary>
public sealed class ProcessRunner : IProcessRunner
{
public async Task<ProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> args,
IReadOnlyDictionary<string, string>? environmentVariables = null,
CancellationToken ct = default)
{
ProcessStartInfo startInfo = new()
{
FileName = fileName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (string arg in args)
{
startInfo.ArgumentList.Add(arg);
}
if (environmentVariables is not null)
{
foreach (KeyValuePair<string, string> kvp in environmentVariables)
{
startInfo.Environment[kvp.Key] = kvp.Value;
}
}
using Process process = new() { StartInfo = startInfo };
try
{
process.Start();
}
catch (Win32Exception ex)
{
return new ProcessResult(-1, string.Empty, $"Failed to start process '{fileName}': {ex.Message}");
}
Task<string> stdOutTask = process.StandardOutput.ReadToEndAsync(ct);
Task<string> stdErrTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
string stdOut = await stdOutTask;
string stdErr = await stdErrTask;
return new ProcessResult(process.ExitCode, stdOut, stdErr);
}
}
@@ -0,0 +1,68 @@
using Application.Interfaces.Backup;
using Application.Utils.Constants.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Npgsql;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Infrastructure.Backup;
/// <summary>
/// IDatabaseRestoreService implementation that shells out to psql via IProcessRunner because pg_dump produces plain SQL, which only psql can replay.
/// </summary>
public sealed class PsqlDatabaseRestoreService(
IProcessRunner processRunner,
IConfiguration configuration,
ILogger<PsqlDatabaseRestoreService> logger) : IDatabaseRestoreService
{
public async Task RestoreAsync(string dumpFilePath, CancellationToken ct = default)
{
string connectionString = configuration.GetConnectionString(ConfigurationKeys.DbConnection)
?? throw new BackupExecutionException(
$"ConnectionStrings:{ConfigurationKeys.DbConnection} is not configured.");
string? configuredPsqlPath = configuration[ConfigurationKeys.Backup.PsqlPath];
string psqlPath = string.IsNullOrWhiteSpace(configuredPsqlPath) ? "psql" : configuredPsqlPath;
NpgsqlConnectionStringBuilder builder = new(connectionString);
List<string> args =
[
"-h", builder.Host ?? "localhost",
"-p", builder.Port.ToString(),
"-U", builder.Username ?? string.Empty,
"-d", builder.Database ?? string.Empty,
// ON_ERROR_STOP=1 turns the first SQL error in the dump into a non-zero exit code instead of continuing silently, so a partial or failed restore is detected instead of reported as success.
"-v", "ON_ERROR_STOP=1",
"-f", dumpFilePath,
];
Dictionary<string, string>? environmentVariables = null;
if (!string.IsNullOrEmpty(builder.Password))
{
environmentVariables = new Dictionary<string, string>
{
["PGPASSWORD"] = builder.Password,
};
}
ProcessResult result = await processRunner.RunAsync(psqlPath, args, environmentVariables, ct);
if (result.ExitCode != 0)
{
logger.LogError(
"psql restore failed with exit code {ExitCode}. Path: '{PsqlPath}'. StdErr: {StdErr}",
result.ExitCode, psqlPath, result.StdErr);
throw new BackupExecutionException(
$"psql restore failed (exit code {result.ExitCode}) using '{psqlPath}'. " +
"Verify psql is installed and on PATH, or set Backup:PsqlPath to its full path. " +
$"Details: {result.StdErr}");
}
}
}
@@ -0,0 +1,105 @@
using Application.Interfaces.Backup;
using Application.Utils.Helper.SupabaseHelper;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Infrastructure.Backup;
/// <summary>
/// IBackupStorage implementation that reuses the existing Supabase client and credentials through ISupabaseRawStorage instead of provisioning a second Supabase client.
/// </summary>
public sealed class SupabaseBackupStorage(ISupabaseRawStorage rawStorage) : IBackupStorage
{
private const string Prefix = "backups/";
public async Task StoreAsync(string name, Stream content, CancellationToken ct = default)
{
string objectPath = ToObjectPath(name);
try
{
await rawStorage.UploadRawAsync(objectPath, content);
}
catch (Exception ex)
{
throw new BackupExecutionException(
$"Failed to upload backup '{name}' to Supabase storage: {ex.Message}", ex);
}
}
public async Task<Stream> OpenReadAsync(string name, CancellationToken ct = default)
{
string objectPath = ToObjectPath(name);
try
{
byte[] bytes = await rawStorage.DownloadRawAsync(objectPath);
return new MemoryStream(bytes);
}
catch (Exception ex)
{
throw new BackupExecutionException(
$"Failed to download backup '{name}' from Supabase storage: {ex.Message}", ex);
}
}
public async Task<IReadOnlyList<BackupFile>> ListAsync(CancellationToken ct = default)
{
try
{
IReadOnlyList<SupabaseStorageEntry> entries = await rawStorage.ListRawAsync(Prefix);
return entries
.Where(entry => !string.IsNullOrEmpty(entry.Name))
.Select(entry => new BackupFile(entry.Name, entry.UpdatedAt ?? DateTimeOffset.MinValue))
.ToList();
}
catch (Exception ex)
{
throw new BackupExecutionException($"Failed to list Supabase backups: {ex.Message}", ex);
}
}
public async Task DeleteAsync(string name, CancellationToken ct = default)
{
string objectPath = ToObjectPath(name);
try
{
await rawStorage.RemoveRawAsync(objectPath);
}
catch (Exception ex)
{
throw new BackupExecutionException(
$"Failed to delete backup '{name}' from Supabase storage: {ex.Message}", ex);
}
}
/// <summary>
/// Resolves name against the backups/ prefix and throws ArgumentException unless it is a simple, non-rooted, non-traversing relative name.
/// </summary>
private static string ToObjectPath(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Backup file name must be non-empty.", nameof(name));
}
if (Path.IsPathRooted(name))
{
throw new ArgumentException(
$"Backup file name '{name}' must be a relative name, not a rooted path.", nameof(name));
}
string normalized = name.Replace('\\', '/');
string[] segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
return segments.Length == 0 || Array.Exists(segments, segment => segment is ".." or ".")
? throw new ArgumentException(
$"Backup file name '{name}' resolves outside the configured '{Prefix}' prefix.", nameof(name))
: Prefix + normalized;
}
}