Add user-configurable PostgreSQL backup system
Introduced backup/restore via external pg_dump/pg_restore tools, configurable in database.xml. Added DatabaseBackupOptions, PostgresBackupService, and example/config docs. Backup features are enabled only for local databases; remote DBs require manual management. Logging and documentation clarify external tool usage, error handling, and security. Updated provider logic and assembly files accordingly.
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
// <copyright file="PostgresBackupService.cs" company="PlaceholderCompany">
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace Jellyfin.Database.Providers.Postgres;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and restoring PostgreSQL database backups using pg_dump/pg_restore.
|
||||
/// </summary>
|
||||
public class PostgresBackupService
|
||||
{
|
||||
private readonly ILogger<PostgresBackupService> _logger;
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresBackupService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="configurationManager">The configuration manager.</param>
|
||||
public PostgresBackupService(
|
||||
ILogger<PostgresBackupService> logger,
|
||||
IConfigurationManager configurationManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_configurationManager = configurationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a backup of the PostgreSQL database.
|
||||
/// </summary>
|
||||
/// <param name="backupDirectory">The directory to save the backup file.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The path to the created backup file.</returns>
|
||||
public async Task<string> CreateBackupAsync(string backupDirectory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dbConfig = _configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database");
|
||||
var backupOptions = dbConfig.BackupOptions ?? new DatabaseBackupOptions();
|
||||
|
||||
if (dbConfig.CustomProviderOptions?.ConnectionString == null)
|
||||
{
|
||||
throw new InvalidOperationException("Database connection string is not configured");
|
||||
}
|
||||
|
||||
var connectionString = dbConfig.CustomProviderOptions.ConnectionString;
|
||||
var connectionParams = ParseConnectionString(connectionString);
|
||||
|
||||
var pgDumpPath = FindExecutable(backupOptions.PgDumpPath, "pg_dump");
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
var extension = GetFileExtension(backupOptions.BackupFormat);
|
||||
var backupFileName = $"jellyfin_postgres_backup_{timestamp}.{extension}";
|
||||
var fullBackupPath = Path.Combine(backupDirectory, backupFileName);
|
||||
|
||||
Directory.CreateDirectory(backupDirectory);
|
||||
|
||||
var arguments = BuildPgDumpArguments(connectionParams, backupOptions, fullBackupPath);
|
||||
|
||||
_logger.LogInformation("Starting PostgreSQL backup using external pg_dump tool to {BackupPath}", fullBackupPath);
|
||||
_logger.LogDebug("Executing external command: {PgDump} {Arguments}", pgDumpPath, string.Join(" ", arguments.Where(a => !a.Contains("PGPASSWORD", StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = pgDumpPath,
|
||||
Arguments = string.Join(" ", arguments),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
// Set password via environment variable (more secure than command line)
|
||||
if (connectionParams.TryGetValue("Password", out var password))
|
||||
{
|
||||
processStartInfo.EnvironmentVariables["PGPASSWORD"] = password;
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = processStartInfo };
|
||||
|
||||
var outputBuilder = new StringBuilder();
|
||||
var errorBuilder = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
if (backupOptions.VerboseOutput)
|
||||
{
|
||||
_logger.LogDebug("pg_dump: {Output}", e.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
if (backupOptions.VerboseOutput)
|
||||
{
|
||||
_logger.LogDebug("pg_dump stderr: {Error}", e.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
// Wait with timeout
|
||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(backupOptions.TimeoutSeconds));
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill();
|
||||
throw new TimeoutException($"Backup operation timed out after {backupOptions.TimeoutSeconds} seconds");
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var errorMessage = errorBuilder.ToString();
|
||||
_logger.LogError("pg_dump failed with exit code {ExitCode}: {Error}", process.ExitCode, errorMessage);
|
||||
|
||||
// Clean up failed backup file
|
||||
if (File.Exists(fullBackupPath))
|
||||
{
|
||||
File.Delete(fullBackupPath);
|
||||
}
|
||||
|
||||
throw new Exception($"Database backup failed: {errorMessage}");
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(fullBackupPath);
|
||||
_logger.LogInformation(
|
||||
"Successfully created PostgreSQL backup at {BackupPath} ({Size:N0} bytes)",
|
||||
fullBackupPath,
|
||||
fileInfo.Length);
|
||||
|
||||
return fullBackupPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a PostgreSQL database from a backup file.
|
||||
/// </summary>
|
||||
/// <param name="backupFilePath">The path to the backup file.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the restore operation.</returns>
|
||||
public async Task RestoreBackupAsync(string backupFilePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dbConfig = _configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database");
|
||||
var backupOptions = dbConfig.BackupOptions ?? new DatabaseBackupOptions();
|
||||
|
||||
if (!File.Exists(backupFilePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Backup file not found: {backupFilePath}");
|
||||
}
|
||||
|
||||
if (dbConfig.CustomProviderOptions?.ConnectionString == null)
|
||||
{
|
||||
throw new InvalidOperationException("Database connection string is not configured");
|
||||
}
|
||||
|
||||
var connectionString = dbConfig.CustomProviderOptions.ConnectionString;
|
||||
var connectionParams = ParseConnectionString(connectionString);
|
||||
|
||||
var pgRestorePath = FindExecutable(backupOptions.PgRestorePath, "pg_restore");
|
||||
|
||||
var arguments = BuildPgRestoreArguments(connectionParams, backupOptions, backupFilePath);
|
||||
|
||||
_logger.LogInformation("Starting PostgreSQL restore from {BackupPath}", backupFilePath);
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = pgRestorePath,
|
||||
Arguments = string.Join(" ", arguments),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (connectionParams.TryGetValue("Password", out var password))
|
||||
{
|
||||
processStartInfo.EnvironmentVariables["PGPASSWORD"] = password;
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = processStartInfo };
|
||||
|
||||
var outputBuilder = new StringBuilder();
|
||||
var errorBuilder = new StringBuilder();
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
outputBuilder.AppendLine(e.Data);
|
||||
if (backupOptions.VerboseOutput)
|
||||
{
|
||||
_logger.LogDebug("pg_restore: {Output}", e.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
errorBuilder.AppendLine(e.Data);
|
||||
if (backupOptions.VerboseOutput)
|
||||
{
|
||||
_logger.LogDebug("pg_restore stderr: {Error}", e.Data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(backupOptions.TimeoutSeconds));
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
process.Kill();
|
||||
throw new TimeoutException($"Restore operation timed out after {backupOptions.TimeoutSeconds} seconds");
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var errorMessage = errorBuilder.ToString();
|
||||
_logger.LogError("pg_restore failed with exit code {ExitCode}: {Error}", process.ExitCode, errorMessage);
|
||||
throw new Exception($"Database restore failed: {errorMessage}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Successfully restored PostgreSQL backup from {BackupPath}", backupFilePath);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseConnectionString(string connectionString)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var parts = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var kvp = part.Split('=', 2);
|
||||
if (kvp.Length == 2)
|
||||
{
|
||||
result[kvp[0].Trim()] = kvp[1].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<string> BuildPgDumpArguments(
|
||||
Dictionary<string, string> connectionParams,
|
||||
DatabaseBackupOptions options,
|
||||
string outputPath)
|
||||
{
|
||||
var args = new List<string>();
|
||||
|
||||
// Connection parameters
|
||||
if (connectionParams.TryGetValue("Host", out var host))
|
||||
{
|
||||
args.Add($"-h {host}");
|
||||
}
|
||||
|
||||
if (connectionParams.TryGetValue("Port", out var port))
|
||||
{
|
||||
args.Add($"-p {port}");
|
||||
}
|
||||
|
||||
if (connectionParams.TryGetValue("Username", out var username))
|
||||
{
|
||||
args.Add($"-U {username}");
|
||||
}
|
||||
|
||||
if (connectionParams.TryGetValue("Database", out var database))
|
||||
{
|
||||
args.Add($"-d {database}");
|
||||
}
|
||||
|
||||
// Format
|
||||
var formatFlag = options.BackupFormat.ToLowerInvariant() switch
|
||||
{
|
||||
"plain" => "p",
|
||||
"custom" => "c",
|
||||
"directory" => "d",
|
||||
"tar" => "t",
|
||||
_ => "c"
|
||||
};
|
||||
args.Add($"-F {formatFlag}");
|
||||
|
||||
// Compression level (for custom and directory formats)
|
||||
if (formatFlag is "c" or "d")
|
||||
{
|
||||
args.Add($"-Z {options.CompressionLevel}");
|
||||
}
|
||||
|
||||
// Include blobs
|
||||
if (options.IncludeBlobs)
|
||||
{
|
||||
args.Add("-b");
|
||||
}
|
||||
|
||||
// Verbose output
|
||||
if (options.VerboseOutput)
|
||||
{
|
||||
args.Add("-v");
|
||||
}
|
||||
|
||||
// Parallel jobs (directory format only)
|
||||
if (options.ParallelJobs.HasValue && formatFlag == "d")
|
||||
{
|
||||
args.Add($"-j {options.ParallelJobs.Value}");
|
||||
}
|
||||
|
||||
// Additional arguments
|
||||
if (!string.IsNullOrWhiteSpace(options.AdditionalArguments))
|
||||
{
|
||||
args.Add(options.AdditionalArguments);
|
||||
}
|
||||
|
||||
// Output file
|
||||
args.Add($"-f \"{outputPath}\"");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private List<string> BuildPgRestoreArguments(
|
||||
Dictionary<string, string> connectionParams,
|
||||
DatabaseBackupOptions options,
|
||||
string backupPath)
|
||||
{
|
||||
var args = new List<string>();
|
||||
|
||||
// Connection parameters
|
||||
if (connectionParams.TryGetValue("Host", out var host))
|
||||
{
|
||||
args.Add($"-h {host}");
|
||||
}
|
||||
|
||||
if (connectionParams.TryGetValue("Port", out var port))
|
||||
{
|
||||
args.Add($"-p {port}");
|
||||
}
|
||||
|
||||
if (connectionParams.TryGetValue("Username", out var username))
|
||||
{
|
||||
args.Add($"-U {username}");
|
||||
}
|
||||
|
||||
if (connectionParams.TryGetValue("Database", out var database))
|
||||
{
|
||||
args.Add($"-d {database}");
|
||||
}
|
||||
|
||||
// Clean before restore
|
||||
args.Add("--clean");
|
||||
args.Add("--if-exists");
|
||||
|
||||
// Verbose output
|
||||
if (options.VerboseOutput)
|
||||
{
|
||||
args.Add("-v");
|
||||
}
|
||||
|
||||
// Parallel jobs
|
||||
if (options.ParallelJobs.HasValue)
|
||||
{
|
||||
args.Add($"-j {options.ParallelJobs.Value}");
|
||||
}
|
||||
|
||||
// Input file
|
||||
args.Add($"\"{backupPath}\"");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private string FindExecutable(string? configuredPath, string defaultExecutableName)
|
||||
{
|
||||
// If configured path is provided, use it
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
if (File.Exists(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Configured path {Path} does not exist, searching in PATH", configuredPath);
|
||||
}
|
||||
|
||||
// Try to find in PATH
|
||||
var possiblePaths = new[]
|
||||
{
|
||||
defaultExecutableName, // In PATH
|
||||
Path.Combine("/usr/bin", defaultExecutableName),
|
||||
Path.Combine("/usr/local/bin", defaultExecutableName),
|
||||
Path.Combine(@"C:\Program Files\PostgreSQL\16\bin", $"{defaultExecutableName}.exe"),
|
||||
Path.Combine(@"C:\Program Files\PostgreSQL\15\bin", $"{defaultExecutableName}.exe"),
|
||||
Path.Combine(@"C:\Program Files\PostgreSQL\14\bin", $"{defaultExecutableName}.exe"),
|
||||
};
|
||||
|
||||
foreach (var path in possiblePaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
_logger.LogDebug("Found {Executable} at {Path}", defaultExecutableName, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
// Check if it's in PATH by trying to execute --version
|
||||
if (IsInPath(path))
|
||||
{
|
||||
_logger.LogDebug("Found {Executable} in PATH", defaultExecutableName);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"{defaultExecutableName} executable not found. Please configure the path in database.xml or ensure it's in the system PATH.");
|
||||
}
|
||||
|
||||
private bool IsInPath(string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = "--version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
process.WaitForExit(2000);
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFileExtension(string format)
|
||||
{
|
||||
return format.ToLowerInvariant() switch
|
||||
{
|
||||
"plain" => "sql",
|
||||
"custom" => "dump",
|
||||
"directory" => "dir",
|
||||
"tar" => "tar",
|
||||
_ => "dump"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user