Initial commit: Brass & Sigil monorepo
Self-hosted Minecraft modpack distribution + administration system.
- launcher/ Avalonia 12 desktop client; single-file win-x64 publish.
Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
portable install path, sidecar settings.
- server/ brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
MC subprocess, embedded Kestrel admin panel with cookie
auth + rate limiting, RCON bridge, scheduled backups,
BlueMap CLI integration with player markers + skin proxy,
friend-side whitelist request flow, world wipe with seed
selection (keep current / random / custom).
- pack/ pack.lock.json (Modrinth + manual CurseForge entries),
data-only tweak source under tweaks/, build outputs in
overrides/ (gitignored).
- scripts/ Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
plus Deploy-Brass.ps1 unified one-shot deploy with
version-bump pre-flight and daemon-state detection.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
using System.IO.Compression;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// World backups: ZIP the level-name dir into a separate (typically slower-but-bigger)
|
||||
/// backup directory. Online backups via /save-all flush + /save-off while the server is
|
||||
/// running mean players don't see downtime -- just a brief save lag.
|
||||
/// </summary>
|
||||
public sealed class BackupService
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly ServerProcess _proc;
|
||||
private readonly Broadcaster _broadcast;
|
||||
private readonly Action<string> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
_proc = proc;
|
||||
_broadcast = broadcast;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public sealed record BackupInfo(string Name, long SizeBytes, DateTimeOffset CreatedAt);
|
||||
public sealed record CreateResult(bool Ok, string? Name, long SizeBytes, string? Error);
|
||||
|
||||
public string BackupDir => ResolveBackupDir();
|
||||
|
||||
public List<BackupInfo> List()
|
||||
{
|
||||
var dir = BackupDir;
|
||||
if (!Directory.Exists(dir)) return new();
|
||||
return Directory.EnumerateFiles(dir, "*.zip")
|
||||
.Select(p =>
|
||||
{
|
||||
var fi = new FileInfo(p);
|
||||
return new BackupInfo(fi.Name, fi.Length, new DateTimeOffset(fi.CreationTimeUtc, TimeSpan.Zero));
|
||||
})
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a ZIP backup of the world dir. Online (no shutdown) when the server is
|
||||
/// running.
|
||||
/// <para>
|
||||
/// <paramref name="flush"/> = false (default): just <c>save-off</c> + brief drain +
|
||||
/// ZIP + <c>save-on</c>. Near-zero player-visible lag. Backup captures state up to
|
||||
/// MC's last autosave (within ~5 min) -- fine for hourly snapshots.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="flush"/> = true: also runs <c>save-all flush</c> first, which
|
||||
/// synchronously serialises every loaded chunk before the ZIP. Captures state up
|
||||
/// to NOW. Causes a tick spike of seconds-to-tens-of-seconds depending on world
|
||||
/// size. Used only for irreversible operations (pre-wipe) where freshness matters.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<CreateResult> CreateAsync(string? reason = null, bool flush = false, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct))
|
||||
return new CreateResult(false, null, 0, "Another backup is already in progress.");
|
||||
|
||||
var dir = ResolveBackupDir();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_gate.Release();
|
||||
return new CreateResult(false, null, 0, $"Couldn't create backup dir '{dir}': {ex.Message}");
|
||||
}
|
||||
|
||||
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
|
||||
var worldDir = Path.Combine(_config.ServerDir, levelName);
|
||||
if (!Directory.Exists(worldDir))
|
||||
{
|
||||
_gate.Release();
|
||||
return new CreateResult(false, null, 0, $"World directory not found at '{worldDir}'.");
|
||||
}
|
||||
|
||||
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var slug = string.IsNullOrWhiteSpace(reason) ? "" : "-" + Slugify(reason);
|
||||
var name = $"{levelName}-{stamp}{slug}.zip";
|
||||
var path = Path.Combine(dir, name);
|
||||
|
||||
var serverRunning = _proc.IsRunning;
|
||||
try
|
||||
{
|
||||
if (serverRunning)
|
||||
{
|
||||
if (flush)
|
||||
{
|
||||
// Loud path: fresh state, but pays the tick spike. Tell players.
|
||||
try { await _broadcast.SayAsync("Saving world for backup (brief lag possible)...", ct); } catch { }
|
||||
_log("[backup] save-all flush");
|
||||
await _proc.SendInputAsync("save-all flush", ct);
|
||||
await Task.Delay(2500, ct);
|
||||
}
|
||||
_log("[backup] save-off");
|
||||
await _proc.SendInputAsync("save-off", ct);
|
||||
// Brief drain so any save tasks already enqueued can finish before we
|
||||
// start reading from disk for the ZIP.
|
||||
await Task.Delay(500, ct);
|
||||
}
|
||||
|
||||
_log($"[backup] Archiving {worldDir} -> {name}");
|
||||
// Run on a worker thread so the request thread doesn't block on disk I/O.
|
||||
await Task.Run(() =>
|
||||
ZipFile.CreateFromDirectory(worldDir, path, CompressionLevel.Fastest, includeBaseDirectory: false), ct);
|
||||
|
||||
var size = new FileInfo(path).Length;
|
||||
_log($"[backup] Created {name} ({size / (1024 * 1024)} MB).");
|
||||
// No completion broadcast for silent path -- backup was invisible to players,
|
||||
// no need to tell them it finished. Loud path is wipe-only and the wipe
|
||||
// sequence has its own messaging.
|
||||
|
||||
RotateOldest(dir, _config.BackupKeep);
|
||||
|
||||
return new CreateResult(true, name, size, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"[backup] Failed: {ex.Message}");
|
||||
try { File.Delete(path); } catch { } // partial archive
|
||||
return new CreateResult(false, null, 0, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (serverRunning && _proc.IsRunning)
|
||||
{
|
||||
_log("[backup] save-on");
|
||||
await _proc.SendInputAsync("save-on");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop the server, move the current world out of the way as a "pre-restore" safety
|
||||
/// copy, extract the chosen archive, restart.
|
||||
/// </summary>
|
||||
public async Task<(bool Ok, string? Error)> RestoreAsync(string backupName, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0, ct))
|
||||
return (false, "Another backup operation is already in progress.");
|
||||
|
||||
try
|
||||
{
|
||||
var dir = ResolveBackupDir();
|
||||
var path = Path.Combine(dir, Path.GetFileName(backupName));
|
||||
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
|
||||
|
||||
var levelName = ReadLevelName(_config.ServerDir) ?? "world";
|
||||
var worldDir = Path.Combine(_config.ServerDir, levelName);
|
||||
|
||||
if (_proc.IsRunning)
|
||||
{
|
||||
_log("[restore] Stopping server before restore...");
|
||||
await _proc.StopAsync(TimeSpan.FromSeconds(30), ct);
|
||||
}
|
||||
|
||||
// Always preserve the current world as a pre-restore snapshot in case the
|
||||
// chosen archive is corrupt or wrong.
|
||||
if (Directory.Exists(worldDir))
|
||||
{
|
||||
var preRestore = $"{worldDir}-prerestore-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
_log($"[restore] Moving current world to {Path.GetFileName(preRestore)}");
|
||||
Directory.Move(worldDir, preRestore);
|
||||
}
|
||||
|
||||
_log($"[restore] Extracting {backupName}");
|
||||
Directory.CreateDirectory(worldDir);
|
||||
await Task.Run(() => ZipFile.ExtractToDirectory(path, worldDir, overwriteFiles: true), ct);
|
||||
|
||||
_log("[restore] Starting server.");
|
||||
_proc.Start();
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log($"[restore] Failed: {ex.Message}");
|
||||
try { if (!_proc.IsRunning) _proc.Start(); } catch { }
|
||||
return (false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public (bool Ok, string? Error) Delete(string backupName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(ResolveBackupDir(), Path.GetFileName(backupName));
|
||||
if (!File.Exists(path)) return (false, $"Backup '{backupName}' not found.");
|
||||
File.Delete(path);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
private string ResolveBackupDir()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_config.BackupDir))
|
||||
return Path.GetFullPath(_config.BackupDir);
|
||||
// Default: sibling of serverDir so it survives server-dir wipes.
|
||||
var serverFull = Path.GetFullPath(_config.ServerDir);
|
||||
var parent = Path.GetDirectoryName(serverFull) ?? serverFull;
|
||||
return Path.Combine(parent, "backups");
|
||||
}
|
||||
|
||||
private static void RotateOldest(string dir, int keep)
|
||||
{
|
||||
if (keep <= 0) return;
|
||||
try
|
||||
{
|
||||
var zips = Directory.EnumerateFiles(dir, "*.zip")
|
||||
.Select(p => new FileInfo(p))
|
||||
.OrderByDescending(fi => fi.CreationTimeUtc)
|
||||
.ToList();
|
||||
foreach (var old in zips.Skip(keep))
|
||||
{
|
||||
try { old.Delete(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string? ReadLevelName(string serverDir)
|
||||
{
|
||||
var path = Path.Combine(serverDir, "server.properties");
|
||||
if (!File.Exists(path)) return null;
|
||||
foreach (var line in File.ReadAllLines(path))
|
||||
{
|
||||
if (line.StartsWith("level-name=", StringComparison.Ordinal))
|
||||
return line.Substring("level-name=".Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Slugify(string s)
|
||||
{
|
||||
var chars = s.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray();
|
||||
var slug = new string(chars).ToLowerInvariant();
|
||||
return slug.Length > 32 ? slug.Substring(0, 32) : slug;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user