Files
ClawdDotNet/src/ClawdDotNet.App/Settings/SettingsManager.cs
T
RichardandClaude Opus 5 facd21459c 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>
2026-09-08 21:06:40 +02:00

189 lines
7.5 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.App.Settings;
/// <summary>
/// Lädt und speichert die anwendungsweiten Einstellungen.
///
/// <para><b>Ablageort.</b> Bis zur Linux-Portierung lag <c>Settings.json</c> neben der
/// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt
/// die Anwendung in <c>/opt</c> oder <c>/usr/lib</c> und ist für den Dienstbenutzer nicht
/// beschreibbar. Jetzt entscheidet <see cref="AppPaths.ConfigDirectory"/> — XDG unter
/// Linux, <c>%APPDATA%</c> unter Windows, per <c>CLAWD_CONFIG_DIR</c> überschreibbar.</para>
///
/// <para><b>Kein Migrationspfad.</b> Bewusst: Zum Zeitpunkt der Umstellung lief noch
/// keine Installation produktiv. Eine bestehende <c>Settings.json</c> neben der
/// Programmdatei wird also <em>nicht</em> übernommen — der Ort wechselt einmal sauber,
/// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst.</para>
///
/// <para><b>Keine Meldungsfenster.</b> Diese Schicht kennt keine Oberfläche. Ein
/// Speicherfehler kommt als <see cref="SettingsPersistenceException"/> heraus; ob daraus
/// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer.</para>
/// </summary>
public sealed class SettingsManager
{
private const string SettingsFileName = "Settings.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Schlüssel, die es in <see cref="AppSettings"/> einmal gab und die dorthin nicht
/// zurückkommen. Steht eine alte Datei noch voll davon, sagt der Start das einmal an
/// (siehe <see cref="AbandonedKeys"/>).
/// </summary>
private static readonly string[] RetiredKeys =
[
// Alle fünf sind mit #528 (Stufe 1) in die Instanz gewandert: Sie galten
// anwendungsweit und waren damit fuer alle Instanzen derselbe Ordner
// beziehungsweise derselbe Zeitplan.
"logDirectory", "backupDirectory", "backupKeepCount",
"autoBackupEnabled", "autoBackupTime"
];
private readonly string _settingsPath;
public AppSettings AppSettings { get; private set; } = new();
/// <summary>
/// Welche der aufgegebenen Schlüssel beim letzten <see cref="Load"/> noch in der
/// Datei standen.
///
/// <para>System.Text.Json überliest unbekannte Schlüssel wortlos, und
/// <see cref="Save"/> schreibt sie danach nicht wieder — der Wert verschwindet also
/// still. Wer ein eigenes Log- oder Sicherungsverzeichnis eingetragen hatte, soll
/// nicht raten müssen, warum dort nichts mehr ankommt; deshalb wird das hier
/// festgehalten und beim Start einmal protokolliert.</para>
///
/// <para>Bewusst nur eine Ansage und keine Übernahme: Ein anwendungsweiter Pfad, in
/// alle Instanzen kopiert, wäre wieder derselbe Ordner für alle — also genau der
/// Zustand, den Stufe 1 beseitigt.</para>
/// </summary>
public IReadOnlyList<string> AbandonedKeys { get; private set; } = [];
/// <summary>Der Ort der Einstellungsdatei — für Meldungen und Diagnose.</summary>
public string SettingsPath => _settingsPath;
public SettingsManager(string? basePath = null)
{
var dir = basePath ?? AppPaths.ConfigDirectory;
_settingsPath = Path.Combine(dir, SettingsFileName);
}
public void Load()
{
AbandonedKeys = [];
if (!File.Exists(_settingsPath))
{
AppSettings = new AppSettings();
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
return;
}
try
{
var json = AtomicFile.ReadAllText(_settingsPath);
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? new AppSettings();
AbandonedKeys = FindRetiredKeys(json);
}
catch (Exception ex) when (ex is JsonException or IOException)
{
// Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben
// weiterzumachen ist besser, als gar nicht zu starten.
AppSettings = new AppSettings();
return;
}
// Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im
// Klartext gehalten.
AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey);
AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
}
/// <summary>
/// Welche aufgegebenen Schlüssel die Datei noch enthält. Ein zweiter Lesevorgang
/// über denselben Text — die Deserialisierung kann es nicht sagen, weil sie
/// Unbekanntes überliest.
/// </summary>
private static IReadOnlyList<string> FindRetiredKeys(string json)
{
try
{
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
});
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return [];
return RetiredKeys
.Where(key => doc.RootElement.TryGetProperty(key, out _))
.ToList();
}
catch (JsonException)
{
// Der Deserialisierer kam durch, dieser Weg nicht — dann ist die Ansage
// verzichtbar, der Start nicht.
return [];
}
}
/// <summary>
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner,
/// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen
/// unbrauchbaren Wert an eine Gegenstelle zu schicken.
/// </summary>
private static string TryUnprotect(string value)
{
try { return SecretProtector.Unprotect(value) ?? ""; }
catch (SecretProtectionException) { return ""; }
}
/// <exception cref="SettingsPersistenceException">Wenn die Datei nicht geschrieben werden kann.</exception>
public void Save()
{
// Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext.
var plainLicenseKey = AppSettings.LicenseKey;
var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken;
try
{
AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? "";
AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? "";
AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!);
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
AtomicFile.WriteAllText(_settingsPath, json);
AppPaths.RestrictToOwner(_settingsPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or SecretProtectionException)
{
throw new SettingsPersistenceException(
$"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}",
ex);
}
finally
{
AppSettings.LicenseKey = plainLicenseKey;
AppSettings.DeploymentcenterToken = plainDeploymentcenterToken;
}
}
}
public sealed class SettingsPersistenceException(string message, Exception inner)
: Exception(message, inner);