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 options, IOptionsMonitor app, IMediaService media) { /// Where backups are kept: Backups:Root (production's /var/lib/privapub/backups), else beside the media root. 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 List() => ServerBackup.List(Root); public BackupInfo Find(string id) => ServerBackup.Find(Root, id); public Task> 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 // out as each is made. Personas' archives a week old go too. public class BackupScheduler(Backups backups, ILogger 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); await Domain.Portability.PersonaArchives.Forget(backups.Root, stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "The nightly backup failed"); } } } /// Backs up when the day's time has come and there is no nightly backup since: whether it did. public async Task 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; } /// Whether the last time of day for a nightly backup has passed with no nightly backup made since. public static bool IsDue(DateTime now, TimeOnly at, IEnumerable nightlies) { var due = now.Date + at.ToTimeSpan(); if (now < due) due = due.AddDays(-1); return !nightlies.Any(made => made >= due); } } }