Files
pgsql-jellyfin/src/Jellyfin.Database/Jellyfin.Database.Providers.Postgres/PostgresBackupService.cs
T
wjones 667c3768a9 Enhanced logging & error handling for auth and pg backups
- Add detailed debug/warning logs to authentication flows (AuthService, AuthorizationContext, WebSocketManager) for better traceability of auth attempts and failures.
- Log WebSocket authentication attempts, including IP, path, and token preview.
- Introduce PostgresVersionMismatchException for pg_dump/pg_restore version mismatches; parse and log server/client versions, clean up failed backups, and provide upgrade guidance.
- Add helper for parsing version mismatch errors.
- Update using directives and minor code cleanups.
- Update .csproj.user with new publish profile path.
2026-03-04 12:37:39 -05:00

531 lines
18 KiB
C#

// <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.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Providers.Postgres.Exceptions;
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);
// Check for version mismatch error
if (errorMessage.Contains("server version mismatch", StringComparison.OrdinalIgnoreCase))
{
var (serverVersion, clientVersion) = ParseVersionMismatchError(errorMessage);
_logger.LogError(
"PostgreSQL version mismatch detected - Server: {ServerVersion}, pg_dump: {ClientVersion}. " +
"Please upgrade your PostgreSQL client tools to match the server version.",
serverVersion,
clientVersion);
// Clean up failed backup file
if (File.Exists(fullBackupPath))
{
File.Delete(fullBackupPath);
}
throw new PostgresVersionMismatchException(serverVersion, clientVersion);
}
// 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"
};
}
/// <summary>
/// Parses PostgreSQL version mismatch error message to extract server and client versions.
/// </summary>
/// <param name="errorMessage">The error message from pg_dump.</param>
/// <returns>A tuple containing server version and client version.</returns>
private static (string ServerVersion, string ClientVersion) ParseVersionMismatchError(string errorMessage)
{
var serverVersion = "unknown";
var clientVersion = "unknown";
// Parse server version: "server version: 18.3 (Debian 18.3-1.pgdg13+1)"
var serverMatch = Regex.Match(errorMessage, @"server version:\s*([^\s;]+(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase);
if (serverMatch.Success)
{
serverVersion = serverMatch.Groups[1].Value.Trim();
}
// Parse pg_dump version: "pg_dump version: 16.12 (Ubuntu 16.12-1.pgdg24.04+1)"
var clientMatch = Regex.Match(errorMessage, @"pg_dump version:\s*([^\s;]+(?:\s*\([^)]+\))?)", RegexOptions.IgnoreCase);
if (clientMatch.Success)
{
clientVersion = clientMatch.Groups[1].Value.Trim();
}
return (serverVersion, clientVersion);
}
}