Instanzmanager Stufe 1: Protokolle und Sicherungen je Instanz (#528)
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>
This commit is contained in:
@@ -1,80 +1,139 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #375: Die Einstellungsseite zeigte ein Log-Verzeichnis, das nie galt.
|
||||
/// Wohin eine Instanz protokolliert und sichert — und dass zwei Instanzen dabei nicht
|
||||
/// im selben Ordner landen (#528, Stufe 1).
|
||||
///
|
||||
/// <c>InstanceConfig.LogDirectory</c> war mit <c>"./Logs"</c> vorbelegt und damit nie
|
||||
/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der
|
||||
/// Wert zudem mit <c>Path.GetFullPath</c> ohne Basis, also gegen das Arbeitsverzeichnis
|
||||
/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand
|
||||
/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in <c>/</c>) war das
|
||||
/// <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.
|
||||
/// <para><b>Der Anlass.</b> Beide Vorgaben waren anwendungsweit. In <c>Startup.log</c>
|
||||
/// standen deshalb abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche
|
||||
/// gerade spricht; und weil die Aufbewahrung nach Präfix in genau einem Ordner
|
||||
/// aufräumt, warf die laufende Instanz die Archive der anderen mit weg. Solange das so
|
||||
/// ist, ist jede Fehlersuche im Mehrinstanzbetrieb Ratearbeit.</para>
|
||||
///
|
||||
/// <para><b>Was aus #375 bleibt.</b> Ein relativer Wert zählt gegen das
|
||||
/// Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein
|
||||
/// systemd-Dienst ohne <c>WorkingDirectory=</c> startet in <c>/</c>, und <c>"./Logs"</c>
|
||||
/// zeigte dann auf <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.</para>
|
||||
/// </summary>
|
||||
public sealed class LogDirectoryResolutionTests
|
||||
{
|
||||
private static readonly string InstancePath =
|
||||
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test"));
|
||||
|
||||
private static readonly string ApplicationLogDirectory =
|
||||
Path.Combine(AppPaths.DataDirectory, "Logs");
|
||||
|
||||
private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null)
|
||||
private static InstanceConfig Instance(
|
||||
string logDirectory = "", string backupDirectory = "", string? workingDirectory = null)
|
||||
=> new()
|
||||
{
|
||||
LogDirectory = logDirectory,
|
||||
BackupDirectory = backupDirectory,
|
||||
WorkingDirectory = workingDirectory ?? InstancePath
|
||||
};
|
||||
|
||||
// ─── Vorgaben ───
|
||||
|
||||
[Fact]
|
||||
public void Die_Vorbelegung_ist_leer()
|
||||
public void Die_Vorbelegungen_sind_leer()
|
||||
{
|
||||
// Der Kern des Befunds: Solange hier "./Logs" stand, konnte die
|
||||
// Anwendungseinstellung nicht greifen.
|
||||
// Der Kern: Leer heisst „nimm den Ordner dieser Instanz". Steht hier ein Wert,
|
||||
// traegt ihn jede neue Instanz mit sich herum.
|
||||
new InstanceConfig().LogDirectory.ShouldBe("");
|
||||
new InstanceConfig().BackupDirectory.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Angabe_in_der_Instanz_gilt_die_Anwendungseinstellung()
|
||||
public void Ohne_Angabe_protokolliert_die_Instanz_in_ihr_eigenes_Verzeichnis()
|
||||
{
|
||||
AppHost.ResolveLogDirectory(Instance(""), ApplicationLogDirectory, InstancePath)
|
||||
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
|
||||
AppHost.ResolveLogDirectory(Instance(), InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Logs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_absoluter_Wert_in_der_Instanz_geht_vor()
|
||||
public void Ohne_Angabe_sichert_die_Instanz_in_ihr_eigenes_Verzeichnis()
|
||||
{
|
||||
var eigen = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
|
||||
AppHost.ResolveBackupDirectory(Instance(), InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Backups"));
|
||||
}
|
||||
|
||||
AppHost.ResolveLogDirectory(Instance(eigen), ApplicationLogDirectory, InstancePath)
|
||||
.ShouldBe(eigen);
|
||||
/// <summary>
|
||||
/// Das eigentliche Versprechen von Stufe 1. Alles andere in dieser Datei prüft die
|
||||
/// Mechanik; dieser Test prüft die Zusage.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Zwei_Instanzen_ohne_Angabe_teilen_sich_nichts()
|
||||
{
|
||||
var a = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-A"));
|
||||
var b = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-B"));
|
||||
|
||||
var logA = AppHost.ResolveLogDirectory(Instance(workingDirectory: a), a);
|
||||
var logB = AppHost.ResolveLogDirectory(Instance(workingDirectory: b), b);
|
||||
|
||||
var backupA = AppHost.ResolveBackupDirectory(Instance(workingDirectory: a), a);
|
||||
var backupB = AppHost.ResolveBackupDirectory(Instance(workingDirectory: b), b);
|
||||
|
||||
logA.ShouldNotBe(logB);
|
||||
backupA.ShouldNotBe(backupB);
|
||||
}
|
||||
|
||||
// ─── Eingetragene Werte ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_absoluter_Wert_geht_vor()
|
||||
{
|
||||
var eigenesLog = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs"));
|
||||
var eigenesBackup = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-sicherungen"));
|
||||
|
||||
AppHost.ResolveLogDirectory(Instance(logDirectory: eigenesLog), InstancePath)
|
||||
.ShouldBe(eigenesLog);
|
||||
|
||||
AppHost.ResolveBackupDirectory(Instance(backupDirectory: eigenesBackup), InstancePath)
|
||||
.ShouldBe(eigenesBackup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis()
|
||||
{
|
||||
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler.
|
||||
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Logs"));
|
||||
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler aus #375.
|
||||
AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Protokolle"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses()
|
||||
{
|
||||
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath)
|
||||
.ShouldNotBe(Path.GetFullPath("./Logs"));
|
||||
AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath)
|
||||
.ShouldNotBe(Path.GetFullPath("./Protokolle"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("./Logs")]
|
||||
[InlineData("Logs")]
|
||||
public void Die_alte_Vorbelegung_zeigt_auf_dieselbe_Stelle_wie_keine_Angabe(string alt)
|
||||
{
|
||||
// Deshalb darf LoadInstanceConfig sie zu „leer" machen, ohne dass sich etwas
|
||||
// verschiebt.
|
||||
AppHost.ResolveLogDirectory(Instance(logDirectory: alt), InstancePath)
|
||||
.ShouldBe(AppHost.ResolveLogDirectory(Instance(), InstancePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
|
||||
{
|
||||
AppHost.ResolveLogDirectory(Instance(logDirectory: " "), InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Logs"));
|
||||
}
|
||||
|
||||
// ─── Bezugspunkt ───
|
||||
|
||||
[Fact]
|
||||
public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck()
|
||||
{
|
||||
// Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand
|
||||
// geleert worden sein. Dann trägt der Ort, aus dem geladen wurde.
|
||||
AppHost.ResolveLogDirectory(
|
||||
Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath)
|
||||
Instance(logDirectory: "Logs", workingDirectory: ""), InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Logs"));
|
||||
}
|
||||
|
||||
@@ -84,14 +143,7 @@ public sealed class LogDirectoryResolutionTests
|
||||
// "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das
|
||||
// Arbeitsverzeichnis des Prozesses aufgelöst worden.
|
||||
AppHost.ResolveLogDirectory(
|
||||
Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath)
|
||||
Instance(logDirectory: "Logs", workingDirectory: "./data/"), InstancePath)
|
||||
.ShouldBe(Path.Combine(InstancePath, "Logs"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
|
||||
{
|
||||
AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath)
|
||||
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user