using System.IO.Compression; using BrassAndSigil.Server.Models; namespace BrassAndSigil.Server.Services; /// /// 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. /// public sealed class BackupService { private readonly ServerConfig _config; private readonly ServerProcess _proc; private readonly Broadcaster _broadcast; private readonly Action _log; private readonly SemaphoreSlim _gate = new(1, 1); public BackupService(ServerConfig config, ServerProcess proc, Broadcaster broadcast, Action 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 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(); } /// /// Create a ZIP backup of the world dir. Online (no shutdown) when the server is /// running. /// /// = false (default): just save-off + brief drain + /// ZIP + save-on. Near-zero player-visible lag. Backup captures state up to /// MC's last autosave (within ~5 min) -- fine for hourly snapshots. /// /// /// = true: also runs save-all flush 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. /// /// public async Task 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(); } } /// /// Stop the server, move the current world out of the way as a "pre-restore" safety /// copy, extract the chosen archive, restart. /// 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; } }