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
+36 -18
View File
@@ -1,5 +1,5 @@
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging;
@@ -12,8 +12,16 @@ namespace ClawdDotNet.App.Services;
/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
/// mitsichern will, macht das von Hand.
///
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit
/// eine Änderung ohne Neustart greift.
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft,
/// damit eine Änderung ohne Neustart greift.
///
/// <para><b>Schalter, Uhrzeit, Zielordner und Aufbewahrung gehören der Instanz</b>
/// (#528, Stufe 1). Alle vier standen in den Anwendungseinstellungen und galten damit
/// für jede Instanz: Wer die tägliche Sicherung für eine einschaltete, schaltete sie für
/// alle ein, und alle Archive landeten in einem Ordner. Dort räumt
/// <see cref="ApplyRotation"/> nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c> auf —
/// bei zwei Namen, die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine
/// Instanz die Archive der anderen mit und wirft die überzähligen weg.</para>
///
/// <para><b>Takt.</b> Früher ein <c>System.Windows.Forms.Timer</c> — der braucht eine
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über
@@ -23,8 +31,7 @@ namespace ClawdDotNet.App.Services;
public sealed class BackupScheduler : IAsyncDisposable
{
private readonly string _instanceDir;
private readonly string _instanceName;
private readonly SettingsManager _settings;
private readonly InstanceConfig _instance;
private readonly ILogger _logger;
private readonly TimeProvider _clock;
private readonly TimeSpan _tick;
@@ -38,17 +45,24 @@ public sealed class BackupScheduler : IAsyncDisposable
public event Action<string>? OnBackupCreated;
/// <param name="instanceDir">
/// Das Instanzverzeichnis. Zugleich der Bezugspunkt, gegen den ein relativ
/// eingetragener Zielordner zählt.
/// </param>
/// <param name="instance">
/// Die laufende Instanzkonfiguration — als Verweis, nicht als Abzug: Wer Zielordner
/// oder Uhrzeit in der Oberfläche ändert, ändert dieses Objekt, und der nächste
/// Durchlauf sieht es.
/// </param>
public BackupScheduler(
string instanceDir,
string instanceName,
SettingsManager settings,
InstanceConfig instance,
ILogger logger,
TimeProvider? clock = null,
TimeSpan? tick = null)
{
_instanceDir = instanceDir;
_instanceName = instanceName;
_settings = settings;
_instance = instance;
_logger = logger;
_clock = clock ?? TimeProvider.System;
@@ -79,11 +93,10 @@ public sealed class BackupScheduler : IAsyncDisposable
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
public async Task TickAsync()
{
var settings = _settings.AppSettings;
if (!settings.AutoBackupEnabled)
if (!_instance.AutoBackupEnabled)
return;
if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled))
return;
var now = _clock.GetLocalNow().DateTime;
@@ -96,7 +109,10 @@ public sealed class BackupScheduler : IAsyncDisposable
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
_lastRun = now;
await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now);
await RunAsync(
AppHost.ResolveBackupDirectory(_instance, _instanceDir),
_instance.BackupKeepCount,
now);
}
private async Task RunAsync(string folder, int keepCount, DateTime now)
@@ -104,11 +120,13 @@ public sealed class BackupScheduler : IAsyncDisposable
// PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
// nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
// Windows nicht mehr anlegen lässt.
var safeName = PortableFileName.Sanitize(_instanceName, "Instanz");
var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz");
var file = Path.Combine(
Path.GetFullPath(folder),
$"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
// Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten
// Lauf erst entstehen.
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
{
@@ -120,7 +138,7 @@ public sealed class BackupScheduler : IAsyncDisposable
_logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
result.ZipPath, result.SizeBytes);
ApplyRotation(Path.GetFullPath(folder), safeName, keepCount);
ApplyRotation(folder, safeName, keepCount);
OnBackupCreated?.Invoke(result.ZipPath);
}
@@ -120,10 +120,9 @@ public sealed class InstanceDirectoryManager
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
// LogDirectory bleibt leer: dann gilt die Anwendungseinstellung, und die
// steht absolut. Hier stand vorher "./Logs" — ein Wert, der jede neue
// Instanz an das Arbeitsverzeichnis des Prozesses band und die
// Einstellungsseite wirkungslos machte.
// LogDirectory und BackupDirectory bleiben leer: Dann gelten
// <Instanz>/Logs und <Instanz>/Backups. Hier stand vorher "./Logs" — ein
// Wert, der jede neue Instanz an das Arbeitsverzeichnis des Prozesses band.
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
@@ -153,11 +152,24 @@ public sealed class InstanceDirectoryManager
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Migration: Die alte Vorbelegung "./Logs" wird zu „leer" — also zur
// Anwendungseinstellung. Der Wert hatte nie die Bedeutung, die er vorgab: Er
// wurde gegen das Arbeitsverzeichnis des Prozesses aufgelöst, nicht gegen das
// Instanzverzeichnis. Wer bewusst einen anderen Pfad eingetragen hat, behält
// ihn; nur genau die Vorbelegung fällt weg.
// Zugangsdaten der Instanz lesbar machen — OpenRouter-Schlüssel, Watchdog-Token,
// Telegram-Zugang, Token der Weboberfläche.
//
// <para>Das fehlte. SaveInstanceConfig verschlüsselt beim Schreiben, hier wurde
// aber nie entschlüsselt: Was einmal über die Oberfläche gespeichert war, kam
// als "enc:v2:…" zurück und wurde als Wert benutzt. Der OpenRouter-Schlüssel
// ging so verschlüsselt an die API. Aufgefallen ist es nie, weil
// ConfigLoader.LoadAsync — das es richtig macht — im laufenden Programm gar
// nicht aufgerufen wird; nur seine Migrate-Methode wird gebraucht.</para>
//
// <para>Gefahrlos für Klartext aus älteren Dateien: SecretProtector.Unprotect
// gibt zurück, was es nicht als geschützt erkennt.</para>
ConfigSecrets.Unprotect(config);
// Die alte Vorbelegung "./Logs" wird zu „leer". Beide Schreibweisen zeigen
// seit #528 (Stufe 1) auf denselben Ort — <Instanz>/Logs —, der leere Wert sagt
// das aber, ohne es festzuschreiben: Zieht die Instanz um, zieht das Protokoll
// mit.
//
// Nur im Speicher: Die Datei bleibt unangetastet, damit ein Ladevorgang nichts
// zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst.