Files
ClawdDotNet/src/ClawdDotNet.Manager/ManagerSettings.cs
T
RichardandClaude Opus 5 7d4e8b3096 Autostart durch Manager, mit Sicherungsautomat (#528)
Entschieden vom Benutzer am 2026-09-08. Der Schalter heisst
autostartByManager und steht in InstanceSettings.json - bei der Instanz,
nicht beim Manager.

ZWEI DINGE IN EINEM SCHALTER, weil es dieselbe Zusage ist: Der Manager
startet diese Instanz, wenn er selbst hochkommt, und er startet sie wieder,
wenn sie abstuerzt. Ohne den Schalter ruehrt er nichts an - ein Manager,
der nach einem Neustart der Maschine alles hochfaehrt, was er findet,
startet auch das, was jemand mit Absicht angehalten hat. Ausgeschaltet
vorbelegt.

DER SICHERUNGSAUTOMAT ist der eigentliche Inhalt. Wieder hochzufahren ist
die einfache Haelfte; die schwierige ist zu erkennen, wann man es lassen
muss. Nach drei Neustarts in zehn Minuten (maxRestartsInWindow,
restartWindowMinutes) setzt der Manager aus und meldet es. Die Instanz
bleibt dann aus, bis jemand sie von Hand startet - und dieser Start raeumt
den Zaehler ab, denn wer von Hand startet, hat entweder etwas geaendert
oder weiss, was er tut. Das Zeitfenster wandert: Eine Instanz, die einmal
im Monat abstuerzt und wieder hochkommt, laeuft nie in die Grenze.

Nur bei einem Absturz, nicht wenn eine Instanz verstummt. Eine stumme
Instanz lebt noch; sie neu zu starten hiesse, sie erst abzuschiessen, und
ob der Manager das von sich aus darf, ist nicht entschieden. Als offene
Frage aufgeschrieben - genau dieser Zustand war der Anlass des Vorhabens.

Der Schalter steht bei der Instanz, weil er eine Eigenschaft dieser Instanz
ist und weil die Instanzdatei dem Instanzprozess gehoert: Sie vom Manager
schreiben zu lassen, waehrend die Instanz laeuft, waeren zwei Schreiber auf
einer Datei. Der Manager liest ihn nur; geaendert wird er auf der
Einstellungsseite der Instanz.

EIN DEFEKT, den erst diese Tests gezeigt haben: StartAsync gab "laeuft
schon" zurueck, wenn die Aufsicht die Instanz fuer laufend hielt - ohne
nachzusehen. Zwischen zwei Aufsichtsdurchgaengen liegen aber Sekunden, und
in denen kann sie wegbrechen. Wer in dieser Luecke "starten" verlangte,
bekam einen Erfolg, hinter dem nichts stand, und der naechste Durchgang
meldete einen Absturz, den niemand mehr erwartete. Jetzt wird gefragt,
bevor zugestimmt wird.

UND EIN TESTDOUBLE, das die Wirklichkeit nicht abbildete: Der falsche
Socket antwortete unabhaengig davon, ob ein Prozess lief. Damit war jeder
Testaufbau, der die Antwort vor dem Hochfahren hinterlegte, stillschweigend
ein anderer Test als beabsichtigt - eine Antwort sagt dem Manager "laeuft
bereits", er uebernimmt, und der Starter wird nie angefasst. Dreimal
passiert. Der falsche Socket haengt jetzt am falschen Starter.

Sicherungen im Manager sind als #530 zurueckgestellt, mit Ausloeser.
Vorab entschieden ist dort nur eines: Zurueckspielen ausschliesslich bei
inaktiver Instanz.

19 neue Tests. Build ohne Warnungen, 793 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:01:52 +02:00

261 lines
11 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Manager;
/// <summary>
/// Alles, was der Instanzmanager weiß — und mehr soll er nicht wissen (E-M4).
///
/// <para><b>Die Kürze dieser Klasse ist die Zusage.</b> 99 Prozent der Einstellungen
/// gehören der Instanz und gelten für jede einzeln; hier steht nur, was sich gar nicht je
/// Instanz sagen lässt: eine Weboberfläche für alle, eine Lizenz für die Installation, ein
/// Master-Token, aus dem die Sub-Token entstehen, und der Ort, an dem die Instanzen
/// liegen — den muss er wissen, bevor es überhaupt eine gibt.</para>
///
/// <para>Farbschema, Teilerstellungen, Protokollstufe, Sicherungszeiten: alles Werte einer
/// Oberfläche oder einer Instanz. Wer hier ein Feld ergänzt, sollte erst beantworten,
/// warum es nicht je Instanz gelten kann.</para>
///
/// <para><b>Nicht dabei: eine Datenbank.</b> Was der Manager braucht, liegt als JSON im
/// Projektverzeichnis — diese Datei und je Instanz eine kleine Zustandsdatei. Die
/// <c>state.db</c> einer Instanz fasst er nicht an: Sie gehört genau einem Prozess, und
/// prozessübergreifend gilt die Schreibsperre von <c>SqliteStorage</c> nicht.</para>
/// </summary>
public sealed class ManagerSettings
{
// ─── Wo die Instanzen liegen ───
[JsonPropertyName("instancesDirectory")]
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
/// <summary>
/// Die Programmdatei, mit der ein Instanzprozess gestartet wird.
///
/// <para>Leer heißt: neben der des Managers. Das ist der Normalfall und der Grund,
/// warum hier fast immer nichts steht — beide kommen aus demselben Paket.</para>
/// </summary>
[JsonPropertyName("instanceExecutable")]
public string InstanceExecutable { get; set; } = "";
/// <summary>
/// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2).
///
/// <para>Vorbelegt auf <c>true</c>: Jede Instanz behält ihr Desktop-Fenster, sie laufen
/// im Hintergrund, und beim Wechsel kommt eines nach vorn. Auf einer Maschine ohne
/// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier <c>false</c> hin;
/// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu
/// bedienen.</para>
/// </summary>
[JsonPropertyName("instanceWindows")]
public bool InstanceWindows { get; set; } = true;
// ─── Die eine Weboberfläche (E-M5) ───
[JsonPropertyName("webUiPort")]
public int WebUiPort { get; set; } = 7000;
/// <summary><c>loopback</c> (Vorgabe) oder <c>any</c>.</summary>
[JsonPropertyName("webUiBinding")]
public string WebUiBinding { get; set; } = "loopback";
/// <summary>Zugangstoken der Weboberfläche. Verschlüsselt gespeichert.</summary>
[JsonPropertyName("webUiToken")]
public string WebUiToken { get; set; } = "";
// ─── Lizenz (E-M3) ───
/// <summary>
/// Der Lizenzschlüssel der Installation. Verschlüsselt gespeichert.
///
/// <para>Er liegt hier und nicht in den Instanzen: Eine Lizenz gilt der Installation
/// und erlaubt beliebig viele Instanzen.</para>
/// </summary>
[JsonPropertyName("licenseKey")]
public string LicenseKey { get; set; } = "";
// ─── Deploymentcenter ───
[JsonPropertyName("deploymentcenterUrl")]
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
/// <summary>
/// Das Master-Token. Verschlüsselt gespeichert.
///
/// <para>Aus ihm entstehen die Sub-Token der Instanzen — jedes eingeschränkt auf
/// <c>watchdog:ping</c> und <c>bugtracker:report</c> und einzeln widerrufbar. Nach dem
/// Umbau liegt auf keiner Instanz mehr das Master-Token.</para>
/// </summary>
[JsonPropertyName("deploymentcenterMasterToken")]
public string DeploymentcenterMasterToken { get; set; } = "";
/// <summary>Aktualisiert wird das Programm, nicht die Instanz.</summary>
[JsonPropertyName("updateChannel")]
public string UpdateChannel { get; set; } = "prod";
// ─── Aufsicht ───
/// <summary>Wie oft nachgesehen wird, in Sekunden.</summary>
[JsonPropertyName("probeIntervalSeconds")]
public int ProbeIntervalSeconds { get; set; } = 15;
/// <summary>Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.</summary>
[JsonPropertyName("startTimeoutSeconds")]
public int StartTimeoutSeconds { get; set; } = 60;
/// <summary>Wie viele Fehlversuche in Folge „antwortet nicht" ergeben.</summary>
[JsonPropertyName("silentChecksBeforeAlarm")]
public int SilentChecksBeforeAlarm { get; set; } = 3;
/// <summary>Wie lange eine Instanz nach der Bitte zu enden noch bekommt.</summary>
[JsonPropertyName("stopGraceSeconds")]
public int StopGraceSeconds { get; set; } = 20;
// ─── Neustart nach Absturz ───
/// <summary>
/// Wie oft eine Instanz innerhalb von <see cref="RestartWindowMinutes"/> wieder
/// gestartet wird, bevor der Manager aussetzt.
///
/// <para>Der Sicherungsautomat zum Autostart: Was an einer kaputten Konfiguration
/// scheitert, scheitert beim zehnten Versuch genauso — dann hat man statt einer toten
/// Instanz eine tote Instanz und ein volles Protokoll. Nach dem Aussetzen bleibt sie
/// aus, bis jemand sie von Hand startet; das ist gemeldet und nicht still.</para>
/// </summary>
[JsonPropertyName("maxRestartsInWindow")]
public int MaxRestartsInWindow { get; set; } = 3;
/// <summary>Der Zeitraum, über den <see cref="MaxRestartsInWindow"/> zählt.</summary>
[JsonPropertyName("restartWindowMinutes")]
public int RestartWindowMinutes { get; set; } = 10;
/// <summary>Die geprüfte Neustartregel.</summary>
public RestartPolicy ToRestartPolicy() => new(
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
Window: TimeSpan.FromMinutes(Math.Clamp(RestartWindowMinutes, 1, 1440)));
/// <summary>
/// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben.
///
/// <para>Untergrenzen, weil unsinnige Zahlen in einer von Hand bearbeitbaren Datei
/// stehen können: Ein Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null
/// Fehlversuchen ein Alarm bei jedem Aussetzer.</para>
/// </summary>
public SupervisionOptions ToSupervisionOptions() => new(
ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)),
StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)),
SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100),
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)));
}
/// <summary>
/// Lädt und speichert <c>manager.json</c>.
///
/// <para><b>Dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht.</b> Der Ort
/// kommt von <see cref="AppPaths.ConfigDirectory"/>, Geheimnisse liegen in der Datei
/// verschlüsselt und zur Laufzeit im Klartext, geschrieben wird atomar, und eine
/// unlesbare Datei verhindert den Start nicht. Wer eine der beiden kennt, kennt die
/// andere.</para>
/// </summary>
public sealed class ManagerSettingsStore
{
private const string FileName = "manager.json";
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
public ManagerSettingsStore(string? basePath = null)
=> SettingsPath = Path.Combine(basePath ?? AppPaths.ConfigDirectory, FileName);
/// <summary>Der Ort der Datei — für Meldungen und Diagnose.</summary>
public string SettingsPath { get; }
public ManagerSettings Settings { get; private set; } = new();
/// <summary>
/// Warum die Datei nicht gelesen werden konnte — oder <c>null</c>, wenn alles ging.
///
/// <para><b>Warum das nach aussen muss.</b> Eine unlesbare Datei verhindert den Start
/// nicht; das wäre der schlechtere Zustand, denn dann liefe gar keine Instanz mehr.
/// Aber „läuft weiter" heisst nicht „niemand erfährt es": Mit Vorgaben statt der
/// eigenen Einstellungen sucht der Manager im falschen Verzeichnis nach Instanzen,
/// hört auf einem anderen Port und fragt nach einer Lizenz, die woanders steht — und
/// nichts davon sagt, woran es liegt. Genau das ist beim ersten Abnahmelauf passiert:
/// ein einzelner Backslash zu wenig, und eine Viertelstunde Ratelei.</para>
/// </summary>
public string? LoadProblem { get; private set; }
public ManagerSettings Load()
{
LoadProblem = null;
if (!File.Exists(SettingsPath))
{
Settings = new ManagerSettings();
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
return Settings;
}
try
{
Settings = JsonSerializer.Deserialize<ManagerSettings>(
AtomicFile.ReadAllText(SettingsPath), JsonOpts) ?? new ManagerSettings();
}
catch (Exception ex) when (ex is JsonException or IOException)
{
// Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe
// gar keine Instanz mehr, und das ist der schlechtere Zustand. Aber der Grund
// gehoert nach draussen, sonst arbeitet er stillschweigend mit Vorgaben.
LoadProblem = ex.Message;
Settings = new ManagerSettings();
return Settings;
}
Settings.LicenseKey = TryUnprotect(Settings.LicenseKey);
Settings.DeploymentcenterMasterToken = TryUnprotect(Settings.DeploymentcenterMasterToken);
Settings.WebUiToken = TryUnprotect(Settings.WebUiToken);
return Settings;
}
public void Save()
{
var lizenz = Settings.LicenseKey;
var master = Settings.DeploymentcenterMasterToken;
var web = Settings.WebUiToken;
try
{
Settings.LicenseKey = SecretProtector.Protect(lizenz) ?? "";
Settings.DeploymentcenterMasterToken = SecretProtector.Protect(master) ?? "";
Settings.WebUiToken = SecretProtector.Protect(web) ?? "";
AppPaths.EnsureDirectory(Path.GetDirectoryName(SettingsPath)!);
AtomicFile.WriteAllText(SettingsPath, JsonSerializer.Serialize(Settings, JsonOpts));
AppPaths.RestrictToOwner(SettingsPath);
}
finally
{
Settings.LicenseKey = lizenz;
Settings.DeploymentcenterMasterToken = master;
Settings.WebUiToken = web;
}
}
/// <summary>
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner)
/// leer, damit er neu eingetragen wird, statt unbrauchbar an eine Gegenstelle zu gehen.
/// </summary>
private static string TryUnprotect(string value)
{
try { return SecretProtector.Unprotect(value) ?? ""; }
catch (SecretProtectionException) { return ""; }
}
}