Instanzmanager: Register, Einstellungen und stellvertretende Meldung (#528)
Der zweite Teil von Stufe 3. Die Aufsicht ueber eine einzelne Instanz stand schon; jetzt gibt es die Klammer darum und den Weg nach draussen. InstanceRegistry haelt je Instanz eine Aufsicht. Zwei Zusagen, beide aus E-M6: Beim Start wird UEBERNOMMEN, nicht gestartet - ein Manager, der beim Hochkommen alles hochfaehrt, was er findet, startet nach einem Neustart der Maschine auch das, was jemand mit Absicht angehalten hat. Und beim Beenden wird nichts beendet: Wer den Manager schliesst, will den Manager schliessen. Einzige Ausnahme ist StopAllAsync fuer einen Grund, der alle betrifft - und der heisst Lizenzwiderruf. SupervisionReporter macht aus einem Befund eine Bugtracker-Meldung, stellvertretend: mit Prozesskennung, Abschiedscode und dem Ausschnitt aus dem Instanzprotokoll, der noch auf der Platte liegt. Der Idempotenzschluessel ist stabil je Instanz und Art - stuerzt dieselbe Instanz wiederholt ab, zaehlt der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen. Ein leerer Protokollauszug ist dabei die aussagekraeftigste Zeile der Meldung und wird ausdruecklich als solche geschrieben. Genau das war der Zustand am 2026-09-08: Der Prozess wurde erschlagen, bevor sein Schreibpuffer geleert war, und hinterliess nichts. manager.json traegt die Felder aus Abschnitt 6 des Konzepts und sonst nichts. Lizenzschluessel, Master-Token und Web-Token liegen darin verschluesselt und zur Laufzeit im Klartext - dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht. Unsinnige Aufsichtswerte werden eingefangen, weil die Datei von Hand bearbeitbar ist. InstanceLayout ist nach Core gezogen (ResolveLogDirectory, ResolveBackupDirectory). Der Manager braucht das Protokollverzeichnis einer Instanz fuer den Auszug in der Meldung und darf ClawdDotNet.App nicht kennen; dieselbe Rechnung an zwei Stellen zu fuehren waere die schlechtere Antwort. AppHost leitet nur noch weiter. EIN DEFEKT, den erst die eigenen Tests gezeigt haben: Eine angehaltene Instanz erschien im Register als "Instance-Alpha" statt "Alpha". Die Aufsicht kennt den Namen erst, wenn die Instanz einmal geantwortet hat, und eine angehaltene tut das nie - waehrend genau diese Ansicht der Manager anzeigt. Das Register fuehrt den Namen jetzt aus InstanceSettings.json mit. Und ein Testdouble, das zu viel behauptet hat: Der falsche Socket antwortete fuer jedes Verzeichnis dieselbe Instanz. Das laesst Tests gruen werden, die im Betrieb zwei Instanzen verwechseln wuerden; er antwortet jetzt je Verzeichnis. 29 neue Tests. Build ohne Warnungen, 766 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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; } = "";
|
||||
|
||||
// ─── 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;
|
||||
|
||||
/// <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();
|
||||
|
||||
public ManagerSettings Load()
|
||||
{
|
||||
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.
|
||||
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 ""; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user