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:
Richard
2026-09-08 21:06:40 +02:00
co-authored by Claude Opus 5
parent 928da7e6af
commit facd21459c
20 changed files with 1404 additions and 158 deletions
+50 -18
View File
@@ -196,13 +196,13 @@ public sealed class AppHost : IAsyncDisposable
public readonly record struct StartupResult(AppHost? Host, string? Error);
/// <summary>
/// Wohin protokolliert wird. Zwei Regeln, die vorher beide nicht galten:
/// Wohin diese Instanz protokolliert. Zwei Regeln:
///
/// <para><b>Leer heißt „nimm die Anwendungseinstellung".</b> Die steht absolut unter
/// <c>AppPaths.DataDirectory</c> und ist der Wert, den die Einstellungsseite zeigt.
/// Vorher war <c>InstanceConfig.LogDirectory</c> mit <c>"./Logs"</c> vorbelegt und
/// damit nie leer — der Zweig auf die Anwendungseinstellung war unerreichbar, und
/// das Feld auf der Einstellungsseite ließ sich ändern, ohne etwas zu ändern.</para>
/// <para><b>Leer heißt <c>&lt;Instanzverzeichnis&gt;/Logs</c></b> — nicht mehr das
/// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien
/// schrieben, stand in <c>Startup.log</c> abwechselnd, was zwei Instanzen zu sagen
/// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
/// Fehlersuche damit Ratearbeit (#528, Stufe 1).</para>
///
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen
/// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
@@ -212,15 +212,29 @@ public sealed class AppHost : IAsyncDisposable
/// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
/// </summary>
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
/// <param name="applicationLogDirectory">Der Wert aus den Anwendungseinstellungen.</param>
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
public static string ResolveLogDirectory(
InstanceConfig instance, string applicationLogDirectory, string instancePath)
{
var configured = !string.IsNullOrWhiteSpace(instance.LogDirectory)
? instance.LogDirectory
: applicationLogDirectory;
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
=> ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath);
/// <summary>
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
/// <c>&lt;Instanzverzeichnis&gt;/Backups</c>.
///
/// <para>Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt
/// dort nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c> auf, und bei zwei Namen,
/// die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine Instanz die
/// Archive der anderen mit (#528, Stufe 1).</para>
/// </summary>
public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath)
=> ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath);
/// <summary>
/// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem
/// Instanzverzeichnis.
/// </summary>
private static string ResolveInstanceDirectory(
string configured, string fallbackFolder, InstanceConfig instance, string instancePath)
{
// InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten
// Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann,
// wenn jemand das Feld von Hand geleert oder relativ gesetzt hat.
@@ -228,6 +242,9 @@ public sealed class AppHost : IAsyncDisposable
? instance.WorkingDirectory
: instancePath;
if (string.IsNullOrWhiteSpace(configured))
return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder));
return Path.GetFullPath(
Path.IsPathRooted(configured)
? configured
@@ -260,8 +277,7 @@ public sealed class AppHost : IAsyncDisposable
}
// ─── 3. Protokollierung ───
var logDirectory = ResolveLogDirectory(
instance, settings.AppSettings.LogDirectory, instancePath);
var logDirectory = ResolveLogDirectory(instance, instancePath);
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
settings.AppSettings.MinimumLogLevel, true, out var parsed)
@@ -274,8 +290,24 @@ public sealed class AppHost : IAsyncDisposable
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
instance.InstanceName, instance.InstanceId);
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
logger.LogInformation("Protokoll-Verzeichnis: {Path}", logDirectory);
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
// Ein Wechsel ohne Ausweichlogik braucht wenigstens eine Ansage: Wer ein eigenes
// Log- oder Sicherungsverzeichnis in den Anwendungseinstellungen stehen hatte,
// soll nicht raten muessen, warum dort nichts mehr ankommt.
if (settings.AbandonedKeys.Count > 0)
{
logger.LogWarning(
"Diese Einstellungen gelten nicht mehr anwendungsweit und wurden aus "
+ "{Path} entfernt: {Keys}. Sie stehen jetzt je Instanz in "
+ "InstanceSettings.json; die Vorgaben dieser Instanz sind {Logs} und {Backups}.",
settings.SettingsPath,
string.Join(", ", settings.AbandonedKeys),
logDirectory,
ResolveBackupDirectory(instance, instancePath));
}
// ─── 4. Lizenz ───
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
callbacks.License);
@@ -324,10 +356,10 @@ public sealed class AppHost : IAsyncDisposable
await host.StartDeploymentcenterAsync(loggerFactory, ct);
// Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und
// Zielordner in die Einstellungen — gestartet wurde der Takt dazu bisher nicht,
// die Einstellung lief also ins Leere.
// Zielordner in die Instanzkonfiguration — gestartet wurde der Takt dazu bisher
// nicht, die Einstellung lief also ins Leere.
host.Backups = new BackupScheduler(
instancePath, instance.InstanceName, settings,
instancePath, instance,
loggerFactory.CreateLogger("ClawdDotNet.Backup"));
host.Backups.Start();