Sube capa Infrastructure, Solution y archivos de configuracion del Backend
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user