A backup is a directory <stamp>-<kind> under Backups:Root (/var/lib/privapub/backups, 2770, files 0640), written as .partial and renamed once whole: a manifest (host, build, newest migration, each collection's count, size, sha256 and indexes, what was left out and why, the media list), each collection as gzipped canonical Extended JSON read raw, and hard links to the files of untrashed media rows (copies where a link can't be made). On a replica set every collection is read in one snapshot session. Never in a backup: the statistics salt, jobs, recovery codes, sessions, the maintenance lock and the configuration's copy with its SMTP password. One backup or restore at a time (MaintenanceLock, a heartbeat document), and the janitor purges nothing meanwhile. BackupScheduler backs up nightly at 03:30 UTC (or at once after missing a night); rotation keeps 7 daily, 4 weekly, 3 pre-deploy and 3 pre-restore backups. CLI: admin backup [--kind] [--db-only], admin backups, admin backup verify; these run before migrations, so the deploy's own pre-deploy backup, which replaces mongodump, is of the database as the live build left it. EntityMaps.Warm runs once under a lock, since test hosts now boot side by side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
238 lines
10 KiB
C#
238 lines
10 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.User;
|
|
using PrivaPub.ClientModels.User.Avatar;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Services;
|
|
using PrivaPub.Services.ClientToServer.Private;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace PrivaPub.Infrastructure.Cli
|
|
{
|
|
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
|
|
public static class AdminCommands
|
|
{
|
|
public const string SmokeLogin = "deploy-smoke";
|
|
|
|
const string Usage = """
|
|
usage: PrivaPub admin promote|demote <root>
|
|
PrivaPub admin create-root <login> [--admin] the password is read from standard input
|
|
PrivaPub admin smoke <persona> prints "<login> <password>" for the deploy's signed-in check
|
|
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
|
|
what nothing holds and gives today's pictures their rows
|
|
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
|
|
backs the server up; --db-only lists the media without
|
|
linking them (the deploy's, which can't link www-data's files)
|
|
PrivaPub admin backups the backups kept, newest first
|
|
PrivaPub admin backup verify <id> whether a backup's files are what its manifest says
|
|
""";
|
|
|
|
/// <summary>Whether a command runs before migrations: backups are of the database as it was.</summary>
|
|
public static bool BeforeMigrations(string[] args) => args is ["backup", ..] or ["backups"];
|
|
|
|
public static async Task<int> Run(string[] args, IServiceProvider services, TextReader input = default, TextWriter output = default)
|
|
{
|
|
input ??= Console.In;
|
|
output ??= Console.Out;
|
|
switch (args)
|
|
{
|
|
case [("promote" or "demote") and var verb, var userName]:
|
|
return await Promote(verb == "promote", userName, output);
|
|
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
|
|
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
|
|
case ["smoke", var persona]:
|
|
return await Smoke(services, persona, output);
|
|
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
|
|
return await AuditMedia(services, flags.Contains("--fix"), output);
|
|
case ["backup", "verify", var id]:
|
|
return await VerifyBackup(services, id, output);
|
|
case ["backup", .. var flags] when BackupFlags(flags, out var kind, out var dbOnly):
|
|
return await Backup(services, kind, dbOnly, output);
|
|
case ["backups"]:
|
|
return ListBackups(services, output);
|
|
default:
|
|
Console.Error.WriteLine(Usage);
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
static readonly string[] CommandKinds = ["manual", "pre-deploy"];
|
|
|
|
static bool BackupFlags(string[] flags, out string kind, out bool dbOnly)
|
|
{
|
|
kind = "manual";
|
|
dbOnly = false;
|
|
for (var i = 0; i < flags.Length; i++)
|
|
switch (flags[i])
|
|
{
|
|
case "--db-only":
|
|
dbOnly = true;
|
|
break;
|
|
case "--kind" when i + 1 < flags.Length && CommandKinds.Contains(flags[i + 1]):
|
|
kind = flags[++i];
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static async Task<int> Backup(IServiceProvider services, string kind, bool dbOnly, TextWriter output)
|
|
{
|
|
var (made, error) = await services.GetRequiredService<Backup.Backups>().Create(kind, dbOnly, CancellationToken.None);
|
|
if (made == default)
|
|
{
|
|
Console.Error.WriteLine(error);
|
|
return 1;
|
|
}
|
|
var manifest = made.Manifest;
|
|
output.WriteLine($"{made.Id}: {manifest.Collections.Count} collections, {manifest.Collections.Sum(c => c.Count)} documents, {made.Bytes / 1024} KiB");
|
|
output.WriteLine(dbOnly
|
|
? $"{manifest.Media.List.Count} media files listed, not linked"
|
|
: $"{manifest.Media.Files} media files, {manifest.Media.Bytes / 1024 / 1024} MiB, {manifest.Media.Missing} missing");
|
|
if (!manifest.Consistent)
|
|
output.WriteLine("not read at one instant: Mongo is not a replica set");
|
|
return 0;
|
|
}
|
|
|
|
static int ListBackups(IServiceProvider services, TextWriter output)
|
|
{
|
|
foreach (var backup in services.GetRequiredService<Backup.Backups>().List())
|
|
output.WriteLine($"{backup.Id}\t{backup.Kind}\t{backup.CreatedAt:u}\t{backup.Bytes / 1024} KiB\t{backup.Manifest?.Media.Files ?? 0} media{(backup.Manifest?.DbOnly == true ? " (listed)" : string.Empty)}");
|
|
return 0;
|
|
}
|
|
|
|
static async Task<int> VerifyBackup(IServiceProvider services, string id, TextWriter output)
|
|
{
|
|
var problems = await services.GetRequiredService<Backup.Backups>().Verify(id, CancellationToken.None);
|
|
foreach (var problem in problems)
|
|
output.WriteLine(problem);
|
|
if (problems.Count > 0)
|
|
return 1;
|
|
output.WriteLine($"{id}: whole");
|
|
return 0;
|
|
}
|
|
|
|
static async Task<int> AuditMedia(IServiceProvider services, bool fix, TextWriter output)
|
|
{
|
|
var report = await Domain.Media.MediaAudit.Run(services.GetRequiredService<Domain.Media.IMediaService>(), fix, CancellationToken.None);
|
|
output.WriteLine($"{report.Files} files served, {report.Held} rows holding media");
|
|
output.WriteLine($"{report.Adopted} pictures personas show without a row{(fix ? ": given one" : string.Empty)}");
|
|
output.WriteLine($"{report.OfDeleted} media of deleted posts or personas{(fix ? ": trashed" : string.Empty)}");
|
|
output.WriteLine($"{report.MissingFiles} rows whose files are missing{(fix ? ": trashed" : string.Empty)}");
|
|
output.WriteLine($"{report.Unheld} files nothing holds{(fix ? ": trashed" : string.Empty)}");
|
|
foreach (var example in report.Examples)
|
|
output.WriteLine($" {example}");
|
|
if (!fix && report.Adopted + report.OfDeleted + report.MissingFiles + report.Unheld > 0)
|
|
output.WriteLine("run again with --fix, as www-data, to apply this; trashed files are deleted after a day");
|
|
return 0;
|
|
}
|
|
|
|
static async Task<int> Promote(bool promote, string userName, TextWriter output)
|
|
{
|
|
userName = userName.ToLowerInvariant();
|
|
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
|
|
if (user == default)
|
|
{
|
|
Console.Error.WriteLine($"no root user '{userName}'");
|
|
return 1;
|
|
}
|
|
|
|
user.Policies.RemoveAll(p => p is Policies.IsAdmin or Policies.IsModerator);
|
|
if (promote)
|
|
user.Policies.AddRange(new[] { Policies.IsAdmin, Policies.IsModerator });
|
|
if (!user.Policies.Contains(Policies.IsUser))
|
|
user.Policies.Add(Policies.IsUser);
|
|
user.UpdatedAt = DateTime.UtcNow;
|
|
await DB.Default.SaveAsync(user);
|
|
|
|
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
|
|
return 0;
|
|
}
|
|
|
|
// With sign-up closed this is how the first root is made; group invitations make the rest.
|
|
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
|
|
{
|
|
var form = new LoginForm { UserName = login, Password = password?.Trim() };
|
|
var problems = new List<ValidationResult>();
|
|
if (!Validator.TryValidateObject(form, new ValidationContext(form), problems, validateAllProperties: true))
|
|
{
|
|
Console.Error.WriteLine(string.Join(Environment.NewLine, problems.Select(p => p.ErrorMessage)));
|
|
return 1;
|
|
}
|
|
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(form);
|
|
if (!created.IsValid)
|
|
{
|
|
Console.Error.WriteLine(created.ErrorMessage);
|
|
return 1;
|
|
}
|
|
return admin ? await Promote(true, login, output) : await Report(login, output);
|
|
}
|
|
|
|
static Task<int> Report(string login, TextWriter output)
|
|
{
|
|
output.WriteLine($"{login.ToLowerInvariant()}: created");
|
|
return Task.FromResult(0);
|
|
}
|
|
|
|
// The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run
|
|
// sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person.
|
|
static async Task<int> Smoke(IServiceProvider services, string personaName, TextWriter output)
|
|
{
|
|
personaName = personaName.ToLowerInvariant();
|
|
var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24));
|
|
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
|
|
if (root == default)
|
|
{
|
|
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password });
|
|
if (!created.IsValid)
|
|
{
|
|
Console.Error.WriteLine(created.ErrorMessage);
|
|
return 1;
|
|
}
|
|
root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
|
|
}
|
|
else if (root.DeletedAt.HasValue || root.IsBanned)
|
|
{
|
|
Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned");
|
|
return 1;
|
|
}
|
|
else
|
|
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
|
.Modify(u => u.HashedPassword, services.GetRequiredService<IPasswordHasher>().Hash(password))
|
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
|
.ExecuteAsync();
|
|
|
|
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
|
|
var persona = await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
|
|
if (persona == default)
|
|
{
|
|
var inserted = await services.GetRequiredService<IPrivateAvatarUsersService>().InsertAvatar(new InsertAvatarForm
|
|
{
|
|
RootId = root.ID,
|
|
UserName = personaName,
|
|
Name = personaName,
|
|
Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona."
|
|
});
|
|
if (!inserted.IsValid)
|
|
{
|
|
Console.Error.WriteLine(inserted.ErrorMessage);
|
|
return 1;
|
|
}
|
|
persona = await DB.Default.Find<Avatar>().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync();
|
|
}
|
|
await DB.Default.Update<Avatar>().MatchID(persona.ID)
|
|
.Modify(a => a.Settings.IsDiscoverable, false)
|
|
.Modify(a => a.Settings.IsIndexable, false)
|
|
.ExecuteAsync();
|
|
|
|
output.WriteLine($"{SmokeLogin} {password}");
|
|
return 0;
|
|
}
|
|
}
|
|
}
|