2026-10-07 11:40:06 +02:00
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
|
|
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
|
|
|
|
using PrivaPub.Domain.Media;
|
|
|
|
|
using PrivaPub.Models;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Infrastructure.Backup
|
|
|
|
|
{
|
|
|
|
|
// The server's backups, wherever they are started from: the CLI, the nightly schedule, the administrator's page.
|
|
|
|
|
public class Backups(IOptionsMonitor<BackupOptions> options, IOptionsMonitor<AppConfiguration> app, IMediaService media)
|
|
|
|
|
{
|
|
|
|
|
/// <summary>Where backups are kept: Backups:Root (production's /var/lib/privapub/backups), else beside the media root.</summary>
|
|
|
|
|
public string Root => Path.GetFullPath(options.CurrentValue.Root ?? media.Root.TrimEnd(Path.DirectorySeparatorChar) + "-backups");
|
|
|
|
|
|
|
|
|
|
public BackupOptions Options => options.CurrentValue;
|
|
|
|
|
|
|
|
|
|
public string Host => app.CurrentValue.BackendBaseAddress?.TrimEnd('/');
|
|
|
|
|
|
|
|
|
|
public BackupContext Context() => new(DB.Default.Database(), Root, media.Root, media.TrashRoot, Host, options.CurrentValue);
|
|
|
|
|
|
|
|
|
|
public Task<(BackupInfo Backup, string Error)> Create(string kind, bool dbOnly, CancellationToken token) =>
|
|
|
|
|
ServerBackup.Create(Context(), kind, dbOnly, token);
|
|
|
|
|
|
|
|
|
|
public List<BackupInfo> List() => ServerBackup.List(Root);
|
|
|
|
|
|
|
|
|
|
public BackupInfo Find(string id) => ServerBackup.Find(Root, id);
|
|
|
|
|
|
|
|
|
|
public Task<List<string>> Verify(string id, CancellationToken token) => ServerBackup.Verify(Root, id, token);
|
|
|
|
|
|
|
|
|
|
public bool Delete(string id) => ServerBackup.Delete(Root, id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A nightly backup at Backups:NightlyAt (UTC), or as soon as the service is up after missing it; the old ones rotated
|
2026-10-07 12:43:12 +02:00
|
|
|
// out as each is made. Personas' archives a week old go too.
|
2026-10-07 11:40:06 +02:00
|
|
|
public class BackupScheduler(Backups backups, ILogger<BackupScheduler> logger) : BackgroundService
|
|
|
|
|
{
|
|
|
|
|
static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
|
|
|
|
|
|
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
|
|
|
{
|
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay(Interval, stoppingToken);
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await RunIfDue(DateTime.UtcNow, stoppingToken);
|
2026-10-07 12:43:12 +02:00
|
|
|
await Domain.Portability.PersonaArchives.Forget(backups.Root, stoppingToken);
|
2026-10-07 11:40:06 +02:00
|
|
|
}
|
|
|
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
logger.LogError(ex, "The nightly backup failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Backs up when the day's time has come and there is no nightly backup since: whether it did.</summary>
|
|
|
|
|
public async Task<bool> RunIfDue(DateTime now, CancellationToken token)
|
|
|
|
|
{
|
|
|
|
|
var options = backups.Options;
|
|
|
|
|
if (!options.Nightly || !TimeOnly.TryParse(options.NightlyAt, System.Globalization.CultureInfo.InvariantCulture, out var at)
|
|
|
|
|
|| !IsDue(now, at, backups.List().Where(b => b.Kind == "nightly").Select(b => b.CreatedAt)))
|
|
|
|
|
return false;
|
|
|
|
|
var (made, error) = await backups.Create("nightly", dbOnly: false, token);
|
|
|
|
|
if (made == default)
|
|
|
|
|
{
|
|
|
|
|
logger.LogWarning("The nightly backup was not made: {Error}", error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
logger.LogInformation("Nightly backup {Id}: {Collections} collections, {Files} media files", made.Id, made.Manifest.Collections.Count, made.Manifest.Media.Files);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Whether the last time of day for a nightly backup has passed with no nightly backup made since.</summary>
|
|
|
|
|
public static bool IsDue(DateTime now, TimeOnly at, IEnumerable<DateTime> nightlies)
|
|
|
|
|
{
|
|
|
|
|
var due = now.Date + at.ToTimeSpan();
|
|
|
|
|
if (now < due)
|
|
|
|
|
due = due.AddDays(-1);
|
|
|
|
|
return !nightlies.Any(made => made >= due);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|