667c3768a9
- 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.
531 lines
18 KiB
C#
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);
|
|
}
|
|
}
|