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:
2026-02-23 18:31:27 -05:00
parent 43cdbf9b35
commit 88dba54087
16 changed files with 1637 additions and 14 deletions
@@ -9,6 +9,7 @@ namespace Jellyfin.Database.Providers.Postgres;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -29,16 +30,24 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
{
private readonly IApplicationPaths applicationPaths;
private readonly ILogger<PostgresDatabaseProvider> logger;
private readonly IConfigurationManager? configurationManager;
private PostgresBackupService? backupService;
private string? currentHost;
/// <summary>
/// Initializes a new instance of the <see cref="PostgresDatabaseProvider"/> class.
/// </summary>
/// <param name="applicationPaths">Service to construct the fallback when the old data path configuration is used.</param>
/// <param name="logger">A logger.</param>
public PostgresDatabaseProvider(IApplicationPaths applicationPaths, ILogger<PostgresDatabaseProvider> logger)
/// <param name="configurationManager">The configuration manager (optional, for backup support).</param>
public PostgresDatabaseProvider(
IApplicationPaths applicationPaths,
ILogger<PostgresDatabaseProvider> logger,
IConfigurationManager? configurationManager = null)
{
this.applicationPaths = applicationPaths;
this.logger = logger;
this.configurationManager = configurationManager;
}
/// <summary>
@@ -118,6 +127,9 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
var connectionString = connectionBuilder.ToString();
// Store the host for backup operations
currentHost = connectionBuilder.Host;
// Log PostgreSQL connection parameters (without password)
logger.LogInformation(
"PostgreSQL connection: Host={Host}, Port={Port}, Database={Database}, Username={Username}, Pooling={Pooling}, MaxPoolSize={MaxPoolSize}, Multiplexing={Multiplexing}",
@@ -129,6 +141,19 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
connectionBuilder.MaxPoolSize,
connectionBuilder.Multiplexing);
// Initialize backup service if host is local and configuration manager is available
if (IsLocalHost(currentHost) && configurationManager is not null)
{
var loggerFactory = Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
var backupLogger = loggerFactory.CreateLogger<PostgresBackupService>();
backupService = new PostgresBackupService(backupLogger, configurationManager);
logger.LogInformation("PostgreSQL backup service initialized for local database (will use external pg_dump/pg_restore tools)");
}
else if (!IsLocalHost(currentHost))
{
logger.LogInformation("PostgreSQL database is on remote server ({Host}). Backup operations via external pg_dump/pg_restore tools are disabled", currentHost);
}
options
.UseNpgsql(
connectionString,
@@ -394,28 +419,134 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc />
public async Task<string> MigrationBackupFast(CancellationToken cancellationToken)
{
if (IsLocalHost(currentHost) && backupService is not null)
{
try
{
var backupDirectory = Path.Combine(applicationPaths.DataPath, "backups");
var backupPath = await backupService.CreateBackupAsync(backupDirectory, cancellationToken).ConfigureAwait(false);
logger.LogInformation("PostgreSQL local backup created: {BackupPath}", backupPath);
return Path.GetFileName(backupPath);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create local PostgreSQL backup using pg_dump");
throw;
}
}
var key = DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
logger.LogWarning("Fast backup is not implemented for PostgreSQL. Use pg_dump for proper backups.");
// Return a key for compatibility, but actual backup should be done externally
logger.LogWarning("Fast backup is not implemented for PostgreSQL on a remote server. Use pg_dump manually for proper backups.");
return await Task.FromResult(key).ConfigureAwait(false);
}
/// <inheritdoc />
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
public async Task RestoreBackupFast(string key, CancellationToken cancellationToken)
{
logger.LogWarning("Fast restore is not implemented for PostgreSQL. Use pg_restore for proper restoration.");
return Task.CompletedTask;
if (IsLocalHost(currentHost) && backupService is not null)
{
try
{
var backupDirectory = Path.Combine(applicationPaths.DataPath, "backups");
var backupPath = Path.Combine(backupDirectory, key);
if (!File.Exists(backupPath))
{
// Try to find the backup file with common extensions
var possibleExtensions = new[] { ".dump", ".sql", ".tar" };
foreach (var ext in possibleExtensions)
{
var pathWithExt = backupPath + ext;
if (File.Exists(pathWithExt))
{
backupPath = pathWithExt;
break;
}
}
}
if (File.Exists(backupPath))
{
await backupService.RestoreBackupAsync(backupPath, cancellationToken).ConfigureAwait(false);
logger.LogInformation("PostgreSQL local backup restored from: {BackupPath}", backupPath);
return;
}
logger.LogWarning("Backup file not found: {BackupPath}", backupPath);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to restore local PostgreSQL backup using pg_restore");
throw;
}
}
logger.LogWarning("Fast restore is not implemented for PostgreSQL on a remote server. Use pg_restore manually for proper restoration.");
}
/// <inheritdoc />
public Task DeleteBackup(string key)
{
logger.LogWarning("Backup deletion is not implemented for PostgreSQL. Manage backups externally.");
if (IsLocalHost(currentHost) && backupService is not null)
{
try
{
var backupDirectory = Path.Combine(applicationPaths.DataPath, "backups");
var backupPath = Path.Combine(backupDirectory, key);
// Try to find and delete the backup file
if (File.Exists(backupPath))
{
File.Delete(backupPath);
logger.LogInformation("Deleted local PostgreSQL backup: {BackupPath}", backupPath);
}
else
{
// Try with common extensions
var possibleExtensions = new[] { ".dump", ".sql", ".tar" };
foreach (var ext in possibleExtensions)
{
var pathWithExt = backupPath + ext;
if (File.Exists(pathWithExt))
{
File.Delete(pathWithExt);
logger.LogInformation("Deleted local PostgreSQL backup: {BackupPath}", pathWithExt);
return Task.CompletedTask;
}
}
logger.LogWarning("Backup file not found for deletion: {BackupPath}", backupPath);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete local PostgreSQL backup: {BackupKey}", key);
}
return Task.CompletedTask;
}
logger.LogWarning("Backup deletion is not implemented for PostgreSQL on a remote server. Manage backups externally.");
return Task.CompletedTask;
}
/// <summary>
/// Determines if the given host is localhost.
/// </summary>
/// <param name="host">The host to check.</param>
/// <returns>True if the host is localhost or 127.0.0.1, false otherwise.</returns>
private static bool IsLocalHost(string? host)
{
if (string.IsNullOrWhiteSpace(host))
{
return false;
}
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
host.Equals("127.0.0.1", StringComparison.Ordinal) ||
host.Equals("::1", StringComparison.Ordinal); // IPv6 localhost
}
/// <inheritdoc/>
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
{