Mehrere Instanzen sollen unabhaengig voneinander laufen. Heute tun sie das nicht: Sie teilen sich Protokollverzeichnis, Sicherungsordner und Sicherungszeitplan. In einer gemeinsamen Startup.log standen abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche gerade spricht -- im Mehrinstanzbetrieb ist damit jede Fehlersuche Ratearbeit. Genau in dieser Lage ist der Aufhaenger aus #529 entstanden. Fuenf Schluessel wandern aus AppSettings nach InstanceSettings.json: logDirectory, backupDirectory, backupKeepCount, autoBackupEnabled, autoBackupTime. Leer heisst jetzt <Instanz>/Logs beziehungsweise <Instanz>/Backups; ein relativer Wert zaehlt weiterhin gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses (#375 bleibt gewahrt). autoBackupEnabled und autoBackupTime standen in der Planung nicht, sind aber mitgegangen: Ein Schalter, der die taegliche Sicherung fuer eine Instanz einschaltet und sie damit fuer alle einschaltet, ist derselbe Fehler wie ein Ordner fuer alle -- und zwei von vier Sicherungsfeldern zu verschieben hinterliesse eine Einstellungsseite, auf der die Haelfte anwendungsweit gilt. Keine Uebernahme der Altwerte, aber eine Ansage: SettingsManager. AbandonedKeys meldet die aufgegebenen Schluessel, solange sie noch in Settings.json stehen, und der Start protokolliert einmal, was nicht mehr gilt und wohin diese Instanz stattdessen schreibt. Ein anwendungsweiter Pfad, in alle Instanzen kopiert, waere wieder ein Ordner fuer alle. webServerPort bleibt vorerst stehen. Es entfaellt mit Stufe 2, sobald Kestrel auf dem Socket hoert; vorher wegzunehmen hiesse, die Weboberflaeche ohne Ersatz abzuschalten. Korrektur an der eigenen Befundlage: "Instanz A wirft die Sicherungen von Instanz B weg" war zu breit. Beide Rotationen filtern bereits nach dem Praefix backup_<Instanzname>_. Der reale Defekt ist enger, und ein Praefix ist keine Grenze: backup_Test_2_*.zip faengt mit backup_Test_ an, also zaehlt Test im gemeinsamen Ordner die Archive von Test_2 mit. Belegt in BackupSchedulerTests, das den alten Zustand eigens wiederherstellt. Nachgeprueft an der laufenden Installation, nicht nur im Test: Nach dem Umbau schreibt Instance-Test nach Instances/Instance-Test/Logs, das alte gemeinsame Verzeichnis bekommt keine Zeile mehr, und die Warnung zu den aufgegebenen Schluesseln erscheint genau einmal. Enthaelt ausserdem, was seit der Weboberflaeche liegengeblieben war: - #526: Instanzgeheimnisse wurden beim Speichern verschluesselt und beim Laden nie entschluesselt. LoadInstanceConfig hatte kein ConfigSecrets.Unprotect; ConfigLoader.LoadAsync macht es richtig, wird zur Laufzeit aber gar nicht aufgerufen. Der OpenRouter-Schluessel ging so verschluesselt an die API. - Ein offener Ereignisstrom hielt das Beenden fuenf Sekunden auf. Die Stroeme hingen nur an RequestAborted, das beim geordneten Herunterfahren nicht feuert; jetzt zusaetzlich an ApplicationStopping. Der Fang in WebUiHost gilt dazu ohne Bedingung auf RequestAborted -- mit ihr waere jeder offene Strom beim Beenden als Fehler im Bugtracker gelandet. - Nach der Anmeldung blieb die Weboberflaeche leer: #anmeldung { display:flex } schlaegt als ID-Selektor das hidden-Attribut. - docs/Instanzmanager-Konzept.md: Befund, die Entscheidungen E-M1 bis E-M6, der Schnitt zwischen Manager und Instanz und die sechs Stufen. Build ohne Warnungen, 709 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
7.5 KiB
C#
202 lines
7.5 KiB
C#
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Core.Config;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.App.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// Die tägliche Sicherung, seit sie der Instanz gehört (#528, Stufe 1).
|
|
///
|
|
/// <para>Schalter, Uhrzeit, Zielordner und Aufbewahrung standen in den
|
|
/// Anwendungseinstellungen und galten damit für jede Instanz. Zwei Folgen: Wer die
|
|
/// Sicherung für eine Instanz einschaltete, schaltete sie für alle ein — und alle
|
|
/// Archive lagen in einem Ordner, in dem die Aufbewahrung nach dem Präfix
|
|
/// <c>backup_<Instanzname>_</c> aufräumt. Dass ein Präfix keine Grenze ist, zeigt
|
|
/// <see cref="Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus"/>.</para>
|
|
/// </summary>
|
|
public sealed class BackupSchedulerTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
|
|
}
|
|
|
|
/// <summary>Ein Instanzverzeichnis mit genug Inhalt, dass sich eine Sicherung lohnt.</summary>
|
|
private string NeueInstanz(string name)
|
|
{
|
|
var dir = Path.Combine(_root, "Instance-" + name);
|
|
Directory.CreateDirectory(dir);
|
|
File.WriteAllText(Path.Combine(dir, "InstanceSettings.json"), $$"""{"instanceName":"{{name}}"}""");
|
|
return dir;
|
|
}
|
|
|
|
private static InstanceConfig Config(string name, string? backupDirectory = null, int keep = 14)
|
|
=> new()
|
|
{
|
|
InstanceName = name,
|
|
WorkingDirectory = "",
|
|
AutoBackupEnabled = true,
|
|
AutoBackupTime = "03:00",
|
|
BackupDirectory = backupDirectory ?? "",
|
|
BackupKeepCount = keep
|
|
};
|
|
|
|
/// <summary>
|
|
/// Eine Uhr, die auf einer Zeit steht — der Zeitpunkt entscheidet, ob
|
|
/// <c>TickAsync</c> etwas tut.
|
|
/// </summary>
|
|
private sealed class FesteUhr(DateTime jetzt) : TimeProvider
|
|
{
|
|
public override DateTimeOffset GetUtcNow() => new(jetzt, TimeSpan.Zero);
|
|
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
|
|
}
|
|
|
|
private static BackupScheduler Scheduler(string dir, InstanceConfig config, DateTime jetzt)
|
|
=> new(dir, config, NullLogger.Instance, new FesteUhr(jetzt));
|
|
|
|
private static DateTime Nachts(int tag) => new(2026, 9, tag, 4, 0, 0, DateTimeKind.Utc);
|
|
|
|
// ─── Der Zielordner ───
|
|
|
|
[Fact]
|
|
public async Task Ohne_Angabe_landet_die_Sicherung_im_Instanzverzeichnis()
|
|
{
|
|
var dir = NeueInstanz("Allein");
|
|
|
|
await Scheduler(dir, Config("Allein"), Nachts(1)).TickAsync();
|
|
|
|
var ziel = Path.Combine(dir, "Backups");
|
|
Directory.Exists(ziel).ShouldBeTrue();
|
|
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zwei_Instanzen_sichern_ohne_Angabe_getrennt()
|
|
{
|
|
var a = NeueInstanz("Eins");
|
|
var b = NeueInstanz("Zwei");
|
|
|
|
await Scheduler(a, Config("Eins"), Nachts(1)).TickAsync();
|
|
await Scheduler(b, Config("Zwei"), Nachts(1)).TickAsync();
|
|
|
|
Directory.GetFiles(Path.Combine(a, "Backups"), "*.zip").Length.ShouldBe(1);
|
|
Directory.GetFiles(Path.Combine(b, "Backups"), "*.zip").Length.ShouldBe(1);
|
|
}
|
|
|
|
// ─── Der Schalter ───
|
|
|
|
[Fact]
|
|
public async Task Ausgeschaltet_passiert_nichts()
|
|
{
|
|
var dir = NeueInstanz("Ruhig");
|
|
var config = Config("Ruhig");
|
|
config.AutoBackupEnabled = false;
|
|
|
|
await Scheduler(dir, config, Nachts(1)).TickAsync();
|
|
|
|
Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Vor_der_eingestellten_Uhrzeit_passiert_nichts()
|
|
{
|
|
var dir = NeueInstanz("Frueh");
|
|
|
|
await Scheduler(dir, Config("Frueh"), new DateTime(2026, 9, 1, 2, 0, 0, DateTimeKind.Utc))
|
|
.TickAsync();
|
|
|
|
Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zweimal_am_selben_Tag_sichert_nur_einmal()
|
|
{
|
|
var dir = NeueInstanz("Einmal");
|
|
var scheduler = Scheduler(dir, Config("Einmal"), Nachts(1));
|
|
|
|
await scheduler.TickAsync();
|
|
await scheduler.TickAsync();
|
|
|
|
Directory.GetFiles(Path.Combine(dir, "Backups"), "*.zip").Length.ShouldBe(1);
|
|
}
|
|
|
|
// ─── Die Aufbewahrung ───
|
|
|
|
[Fact]
|
|
public async Task Die_Aufbewahrung_raeumt_die_eigenen_Archive_auf()
|
|
{
|
|
var dir = NeueInstanz("Alt");
|
|
var ziel = Path.Combine(dir, "Backups");
|
|
Directory.CreateDirectory(ziel);
|
|
|
|
// Drei Archive aus der Vergangenheit, die aelter sind als das gleich entstehende.
|
|
foreach (var tag in new[] { 1, 2, 3 })
|
|
{
|
|
var datei = Path.Combine(ziel, $"backup_Alt_2026-08-0{tag}_0300.zip");
|
|
File.WriteAllText(datei, "alt");
|
|
File.SetLastWriteTime(datei, new DateTime(2026, 8, tag, 3, 0, 0));
|
|
}
|
|
|
|
await Scheduler(dir, Config("Alt", keep: 2), Nachts(1)).TickAsync();
|
|
|
|
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Aufbewahrung_null_behaelt_alles()
|
|
{
|
|
var dir = NeueInstanz("Sammler");
|
|
var ziel = Path.Combine(dir, "Backups");
|
|
Directory.CreateDirectory(ziel);
|
|
File.WriteAllText(Path.Combine(ziel, "backup_Sammler_2026-08-01_0300.zip"), "alt");
|
|
|
|
await Scheduler(dir, Config("Sammler", keep: 0), Nachts(1)).TickAsync();
|
|
|
|
Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Warum der Zielordner der Instanz gehören muss.
|
|
///
|
|
/// <para>Die Aufbewahrung sucht nach dem Präfix <c>backup_<Instanzname>_</c>,
|
|
/// und ein Präfix ist keine Grenze: <c>backup_Test_2_….zip</c> — das Archiv der
|
|
/// Instanz <c>Test_2</c> — fängt mit <c>backup_Test_</c> an. Im gemeinsamen Ordner
|
|
/// zählt <c>Test</c> es also zu den eigenen und wirft es weg. Getrennte Ordner
|
|
/// nehmen der Namensfrage ihre Wirkung: Der Test hier stellt den alten Zustand
|
|
/// eigens wieder her, indem er beiden Instanzen denselben Ordner einträgt.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus()
|
|
{
|
|
var gemeinsam = Path.Combine(_root, "Backups");
|
|
Directory.CreateDirectory(gemeinsam);
|
|
|
|
var fremd = Path.Combine(gemeinsam, "backup_Test_2_2026-08-01_0300.zip");
|
|
File.WriteAllText(fremd, "gehoert Test_2");
|
|
File.SetLastWriteTime(fremd, new DateTime(2026, 8, 1, 3, 0, 0));
|
|
|
|
var dir = NeueInstanz("Test");
|
|
await Scheduler(dir, Config("Test", gemeinsam, keep: 1), Nachts(1)).TickAsync();
|
|
|
|
File.Exists(fremd).ShouldBeFalse(
|
|
"Der alte Zustand ist damit belegt — im gemeinsamen Ordner räumt eine "
|
|
+ "Instanz über ihre eigenen Archive hinaus auf.");
|
|
|
|
// Und die Gegenprobe: mit getrennten Ordnern kommen sie sich nicht in die Quere.
|
|
var eigen = NeueInstanz("Test2");
|
|
var eigenesArchiv = Path.Combine(eigen, "Backups", "backup_Test2_2026-08-01_0300.zip");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(eigenesArchiv)!);
|
|
File.WriteAllText(eigenesArchiv, "gehoert Test2");
|
|
|
|
var dir2 = NeueInstanz("Test3");
|
|
await Scheduler(dir2, Config("Test3", keep: 1), Nachts(2)).TickAsync();
|
|
|
|
File.Exists(eigenesArchiv).ShouldBeTrue();
|
|
}
|
|
}
|