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,202 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using BrassAndSigil.Server.Models;
|
||||
|
||||
namespace BrassAndSigil.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-backup driven by config.BackupSchedule. Accepted formats:
|
||||
/// - "HH:mm" single daily slot (e.g. "04:00")
|
||||
/// - "HH:mm,HH:mm,..." multiple daily slots
|
||||
/// - "every Nh" every N hours (>= 15 minutes)
|
||||
/// - "every Nm" every N minutes
|
||||
/// Wakes once a minute and fires backups when the clock matches the spec.
|
||||
/// Doesn't catch up if the server was off when a slot passed -- daily/interval
|
||||
/// backups don't need replay logic.
|
||||
/// </summary>
|
||||
public sealed class BackupScheduler : IDisposable
|
||||
{
|
||||
private readonly ServerConfig _config;
|
||||
private readonly BackupService _backup;
|
||||
private readonly Action<string> _log;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
// Tracking for "fired" state. For interval: just the last fire time. For
|
||||
// daily-times: which times have fired today, reset at day rollover.
|
||||
private DateTimeOffset? _lastIntervalFire;
|
||||
private DateOnly _lastFireDay = DateOnly.MinValue;
|
||||
private readonly HashSet<TimeOnly> _firedToday = new();
|
||||
|
||||
public BackupScheduler(ServerConfig config, BackupService backup, Action<string> log)
|
||||
{
|
||||
_config = config;
|
||||
_backup = backup;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return;
|
||||
if (Parse(_config.BackupSchedule) == default)
|
||||
{
|
||||
_log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled.");
|
||||
return;
|
||||
}
|
||||
_cts?.Cancel();
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = Task.Run(() => RunAsync(_cts.Token));
|
||||
_log($"[backup-scheduler] Schedule active: {Describe()}");
|
||||
}
|
||||
|
||||
/// <summary>Stop the current loop and re-Start with the latest config values.</summary>
|
||||
public void Reload()
|
||||
{
|
||||
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
_loop = null;
|
||||
Start();
|
||||
}
|
||||
|
||||
/// <summary>Compute the next future scheduled fire time. Null if no schedule.</summary>
|
||||
public DateTimeOffset? NextRun()
|
||||
{
|
||||
var (interval, times) = Parse(_config.BackupSchedule);
|
||||
if (interval.HasValue)
|
||||
{
|
||||
var baseTime = _lastIntervalFire ?? DateTimeOffset.UtcNow.AddSeconds(-1);
|
||||
var next = baseTime + interval.Value;
|
||||
// If we've never fired and we're past the implied first slot, "next" might be
|
||||
// in the past -- clamp to "imminent" by using now + small buffer.
|
||||
if (next <= DateTimeOffset.UtcNow) next = DateTimeOffset.UtcNow.AddMinutes(1);
|
||||
return next.ToLocalTime();
|
||||
}
|
||||
if (times is not null)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nowTime = TimeOnly.FromDateTime(now);
|
||||
// Use Cast<TimeOnly?>().FirstOrDefault() so "no pending" is null rather than 00:00.
|
||||
var pendingToday = times
|
||||
.Where(t => t > nowTime && !_firedToday.Contains(t))
|
||||
.OrderBy(t => t)
|
||||
.Cast<TimeOnly?>()
|
||||
.FirstOrDefault();
|
||||
if (pendingToday.HasValue)
|
||||
return new DateTimeOffset(now.Date.Add(pendingToday.Value.ToTimeSpan()));
|
||||
// None left today -- first slot tomorrow.
|
||||
var firstTomorrow = times.Min();
|
||||
return new DateTimeOffset(now.Date.AddDays(1).Add(firstTomorrow.ToTimeSpan()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromMinutes(1), ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
var (interval, times) = Parse(_config.BackupSchedule);
|
||||
if (interval is null && times is null) continue;
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var nowLocal = DateTime.Now;
|
||||
var today = DateOnly.FromDateTime(nowLocal);
|
||||
var nowTime = TimeOnly.FromDateTime(nowLocal);
|
||||
|
||||
bool shouldFire = false;
|
||||
if (interval.HasValue)
|
||||
{
|
||||
shouldFire = !_lastIntervalFire.HasValue
|
||||
|| (nowUtc - _lastIntervalFire.Value) >= interval.Value;
|
||||
}
|
||||
else if (times is not null)
|
||||
{
|
||||
if (today != _lastFireDay)
|
||||
{
|
||||
_firedToday.Clear();
|
||||
_lastFireDay = today;
|
||||
}
|
||||
foreach (var t in times)
|
||||
{
|
||||
if (t <= nowTime && !_firedToday.Contains(t))
|
||||
{
|
||||
shouldFire = true;
|
||||
_firedToday.Add(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldFire) continue;
|
||||
|
||||
_log("[backup-scheduler] Triggering scheduled backup.");
|
||||
try
|
||||
{
|
||||
var result = await _backup.CreateAsync("scheduled", ct: ct);
|
||||
if (result.Ok) _log($"[backup-scheduler] Done: {result.Name} ({result.SizeBytes / (1024 * 1024)} MB).");
|
||||
else _log($"[backup-scheduler] Failed: {result.Error}");
|
||||
if (interval.HasValue) _lastIntervalFire = nowUtc;
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _log($"[backup-scheduler] Exception: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns (interval, times) -- exactly one will be non-null on success, or (null,null) for invalid/empty.</summary>
|
||||
private static (TimeSpan? Interval, TimeOnly[]? Times) Parse(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return (null, null);
|
||||
var s = input.Trim().ToLowerInvariant();
|
||||
|
||||
// every Nh / every Nm
|
||||
var m = Regex.Match(s, @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$");
|
||||
if (m.Success)
|
||||
{
|
||||
var n = int.Parse(m.Groups[1].Value);
|
||||
var unit = m.Groups[2].Value;
|
||||
var span = unit.StartsWith("h") ? TimeSpan.FromHours(n) : TimeSpan.FromMinutes(n);
|
||||
// Sanity floor -- anything below 15 min creates more save-lag than backups are worth.
|
||||
if (span < TimeSpan.FromMinutes(15)) return (null, null);
|
||||
if (span > TimeSpan.FromDays(7)) return (null, null);
|
||||
return (span, null);
|
||||
}
|
||||
|
||||
// Comma-separated HH:mm
|
||||
var list = new List<TimeOnly>();
|
||||
foreach (var tok in s.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!TimeOnly.TryParse(tok.Trim(), out var t)) return (null, null);
|
||||
list.Add(t);
|
||||
}
|
||||
return list.Count == 0 ? (null, null) : (null, list.OrderBy(t => t).ToArray());
|
||||
}
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
var (interval, times) = Parse(_config.BackupSchedule);
|
||||
if (interval.HasValue)
|
||||
{
|
||||
var totalMin = (int)interval.Value.TotalMinutes;
|
||||
if (totalMin >= 60 && totalMin % 60 == 0)
|
||||
{
|
||||
var h = totalMin / 60;
|
||||
return h == 1 ? "Every hour" : $"Every {h} hours";
|
||||
}
|
||||
return totalMin == 1 ? "Every minute" : $"Every {totalMin} minutes";
|
||||
}
|
||||
if (times is not null)
|
||||
{
|
||||
if (times.Length == 1) return $"Daily at {times[0]:HH\\:mm}";
|
||||
return "Daily at " + string.Join(", ", times.Select(t => t.ToString("HH:mm")));
|
||||
}
|
||||
return "Disabled";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _cts?.Cancel(); _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user