From 72b0764d1a17336d29683d70889cdcf8dee0158f Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 8 Sep 2026 22:45:22 +0200 Subject: [PATCH] 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 --- docs/Instanzmanager-Konzept.md | 30 +++ src/ClawdDotNet.App/AppHost.cs | 57 +--- src/ClawdDotNet.Manager/InstanceLogExcerpt.cs | 117 ++++++++ src/ClawdDotNet.Manager/InstanceRegistry.cs | 237 +++++++++++++++++ src/ClawdDotNet.Manager/ManagerSettings.cs | 208 +++++++++++++++ .../SupervisionReporter.cs | 129 +++++++++ tests/ClawdDotNet.Manager.Tests/Doubles.cs | 17 +- .../InstanceRegistryTests.cs | 249 ++++++++++++++++++ .../ManagerSettingsTests.cs | 200 ++++++++++++++ .../ReportingTests.cs | 163 ++++++++++++ 10 files changed, 1357 insertions(+), 50 deletions(-) create mode 100644 src/ClawdDotNet.Manager/InstanceLogExcerpt.cs create mode 100644 src/ClawdDotNet.Manager/InstanceRegistry.cs create mode 100644 src/ClawdDotNet.Manager/ManagerSettings.cs create mode 100644 src/ClawdDotNet.Manager/SupervisionReporter.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/ReportingTests.cs diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index 27f7993..be4298d 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -456,6 +456,36 @@ und meldet es", und zum Neustarten trifft dieses Papier keine Entscheidung. Eine in einer Schleife wieder hochzufahren, die jedes Mal abstürzt, wäre die falsche Vorgabe für etwas, das niemand entschieden hat. **Das ist eine offene Frage** — siehe Abschnitt 7. +**Das Register und die stellvertretende Meldung.** `InstanceRegistry` hält je Instanz eine +Aufsicht und hat zwei Zusagen, die beide aus E-M6 folgen: Beim Start wird *übernommen*, +nicht gestartet — ein Manager, der beim Hochkommen alles hochfährt, 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 schließt, will den Manager schließen. Die +einzige Ausnahme ist `StopAllAsync` — für einen Grund, der alle betrifft, und der heißt +Lizenzwiderruf. + +`SupervisionReporter` macht aus einem Befund eine Meldung im Bugtracker, **stellvertretend**: +mit Prozesskennung, Abschiedscode und dem Ausschnitt aus dem Protokoll der Instanz, der +noch auf der Platte liegt. Der Idempotenzschlüssel ist stabil je Instanz und Art — stürzt +dieselbe Instanz wiederholt ab, zählt der Server hoch, statt den Bugtracker mit Zwillingen +zu füllen; wiederholte Abstürze sind ein Befund und nicht ein Dutzend gleicher. + +**Ein leerer Protokollauszug ist die aussagekräftigste Zeile der Meldung.** Genau das war +der Zustand aus 1.1: Der Prozess wurde erschlagen, bevor sein Schreibpuffer geleert war, +und hinterließ nichts. Die Meldung sagt das ausdrücklich, statt eine leere Stelle zu +lassen. + +**`manager.json`** trägt die acht Felder aus Abschnitt 6 und sonst nichts. Lizenzschlüssel, +Master-Token und Web-Token liegen darin verschlüsselt und zur Laufzeit im Klartext — wie +in den Anwendungseinstellungen, mit Absicht dieselbe Bauart. Unsinnige Aufsichtswerte +(Abfragetakt 0, Alarm nach null Fehlversuchen) werden eingefangen: Die Datei lässt sich von +Hand bearbeiten. + +**Ein Defekt, den erst die Tests gezeigt haben.** Eine angehaltene Instanz erschien als +`Instance-Alpha` statt `Alpha` — die Aufsicht kennt den Namen erst, wenn die Instanz einmal +geantwortet hat, und eine angehaltene tut das nie. Genau diese Ansicht zeigt der Manager +aber an. Das Register führt den Namen jetzt aus `InstanceSettings.json` mit. + **Abnahme, mit echten Prozessen gemessen.** Der Manager hat die Instanz `Test` in 0,7 s gestartet und über die Named Pipe identifiziert, die Zustandsdatei geschrieben (`pid=54476`, `id=d68af6fd`), dann wurde der Prozess von außen abgeschossen. Der nächste diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 72416f6..c4e5a1b 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -197,60 +197,19 @@ public sealed class AppHost : IAsyncDisposable public readonly record struct StartupResult(AppHost? Host, string? Error); /// - /// Wohin diese Instanz protokolliert. Zwei Regeln: + /// Wohin diese Instanz protokolliert. /// - /// Leer heißt <Instanzverzeichnis>/Logs — nicht mehr das - /// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien - /// schrieben, stand in Startup.log abwechselnd, was zwei Instanzen zu sagen - /// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede - /// Fehlersuche damit Ratearbeit (#528, Stufe 1). - /// - /// Ein relativer Wert zählt gegen das Instanzverzeichnis, nicht gegen - /// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem - /// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne WorkingDirectory= - /// startet in /, und "./Logs" zeigt dann auf /Logs — für ein - /// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb - /// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt. + /// Die Regeln stehen in — sie beschreiben, wie eine + /// Instanz auf der Platte liegt, und das muss auch der Instanzmanager wissen, der + /// diese Schicht nicht kennen darf. Hier steht nur die Weiterleitung, damit die + /// Aufrufer im Fenster nicht zwei Wege lernen müssen. /// - /// Die geladene Instanz — liefert Vorrang und Bezugspunkt. - /// Das Verzeichnis, aus dem die Instanz geladen wurde. public static string ResolveLogDirectory(InstanceConfig instance, string instancePath) - => ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath); + => InstanceLayout.ResolveLogDirectory(instance, instancePath); - /// - /// Wohin die Sicherungen dieser Instanz gehen. Leer heißt - /// <Instanzverzeichnis>/Backups. - /// - /// Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt - /// dort nach dem Präfix backup_<Instanzname>_ auf, und bei zwei Namen, - /// die ineinander liegen (Test und Test_2), zählt die eine Instanz die - /// Archive der anderen mit (#528, Stufe 1). - /// + /// public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath) - => ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath); - - /// - /// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem - /// Instanzverzeichnis. - /// - 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. - var instanceBase = Path.IsPathRooted(instance.WorkingDirectory) - ? instance.WorkingDirectory - : instancePath; - - if (string.IsNullOrWhiteSpace(configured)) - return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder)); - - return Path.GetFullPath( - Path.IsPathRooted(configured) - ? configured - : Path.Combine(instanceBase, configured)); - } + => InstanceLayout.ResolveBackupDirectory(instance, instancePath); public static async Task StartAsync(Callbacks callbacks, CancellationToken ct = default) { diff --git a/src/ClawdDotNet.Manager/InstanceLogExcerpt.cs b/src/ClawdDotNet.Manager/InstanceLogExcerpt.cs new file mode 100644 index 0000000..d1b3f50 --- /dev/null +++ b/src/ClawdDotNet.Manager/InstanceLogExcerpt.cs @@ -0,0 +1,117 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; + +namespace ClawdDotNet.Manager; + +/// +/// Die letzten Protokollzeilen einer Instanz — das, was von ihr noch auf der Platte liegt. +/// +/// Warum das in die Absturzmeldung gehört. Ohne diesen Ausschnitt lautet die +/// Meldung „Instanz X ist weg", und damit kann niemand etwas anfangen. Mit ihm steht +/// daneben, was sie zuletzt getan hat. Am 2026-09-08 war selbst das nicht zu haben — der +/// Schreibpuffer wurde nie geleert, und es stand nichts da. Deshalb steht hier auch +/// ausdrücklich, dass ein leerer Ausschnitt eine Aussage ist und kein Fehler. +/// +/// Ausdrücklich nur lesend. Der Manager fasst in einem Instanzverzeichnis +/// nichts an außer der Zustandsdatei, die ihm selbst gehört. +/// +public static class InstanceLogExcerpt +{ + /// Was ohne Not niemand liest — und was in eine Meldung noch hineinpasst. + private const int DefaultLines = 60; + + /// Je Datei, damit eine sehr gesprächige nicht alle anderen verdrängt. + private const int LinesPerFile = 40; + + /// + /// Die letzten Zeilen aus dem jüngsten Protokolltag dieser Instanz, über die Module + /// hinweg zusammengeführt. + /// + /// Zusammengeführt und nicht nacheinander: Startup.log, + /// Deploymentcenter.log und Web.log beschreiben denselben Zeitraum, und + /// bei einem Absturz will man wissen, was gleichzeitig passiert ist. Sortiert wird über + /// den Zeitstempel am Zeilenanfang — [HH:mm:ss.fff] —, und zwar als Zeichenkette: + /// Innerhalb eines Tages ordnet das richtig, und mehr als einen Tag umfasst der + /// Ausschnitt nicht. + /// + /// + /// Leer, wenn es nichts gibt. Das ist ein Befund: Ein Prozess, der erschlagen wurde, + /// bevor sein Schreibpuffer geleert war, hinterlässt genau nichts. + /// + public static IReadOnlyList Read( + string instanceDirectory, InstanceConfig? config = null, int lines = DefaultLines) + { + try + { + var logDir = InstanceLayout.ResolveLogDirectory( + config ?? new InstanceConfig { WorkingDirectory = instanceDirectory }, + instanceDirectory); + + if (!Directory.Exists(logDir)) return []; + + // Die Ablage ist {LogDirectory}/{Datum}/{Modul}.log - der juengste Ordner ist + // der, der uns interessiert. + var tag = new DirectoryInfo(logDir) + .GetDirectories() + .OrderByDescending(d => d.Name, StringComparer.Ordinal) + .FirstOrDefault(); + + if (tag is null) return []; + + var zeilen = new List(); + + foreach (var datei in tag.GetFiles("*.log")) + { + foreach (var zeile in TailOf(datei.FullName, LinesPerFile)) + zeilen.Add($"{datei.Name}: {zeile}"); + } + + return zeilen + .OrderBy(Timestamp, StringComparer.Ordinal) + .TakeLast(Math.Clamp(lines, 1, 1000)) + .ToList(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Kein Zugriff auf das Protokoll ist ein Mangel an der Meldung, kein Grund, + // sie zu unterlassen - die Meldung selbst ist das Wichtigere. + return []; + } + } + + /// + /// Die letzten Zeilen einer Datei. + /// + /// Mit FileShare.ReadWrite: Die Datei kann in genau diesem Moment noch + /// beschrieben werden — von einer Instanz, die gerade endet. Wer sie exklusiv öffnen + /// wollte, bekäme im Zweifel gar nichts. + /// + private static IEnumerable TailOf(string path, int count) + { + var ring = new Queue(count); + + using var strom = new FileStream( + path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var leser = new StreamReader(strom); + + while (leser.ReadLine() is { } zeile) + { + if (zeile.Length == 0) continue; + + ring.Enqueue(zeile); + if (ring.Count > count) ring.Dequeue(); + } + + return ring; + } + + /// Der Zeitstempel einer Zeile, oder die ganze Zeile, wenn keiner dransteht. + private static string Timestamp(string line) + { + var start = line.IndexOf('['); + if (start < 0) return line; + + var ende = line.IndexOf(']', start); + return ende < 0 ? line : line[(start + 1)..ende]; + } +} diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs new file mode 100644 index 0000000..4ff80c1 --- /dev/null +++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs @@ -0,0 +1,237 @@ +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager; + +/// Eine Instanz, wie der Manager sie sieht. +/// Das Instanzverzeichnis — die Kennung, unter der alles läuft. +/// Der Name aus der Instanzdatei; sonst der Ordnername. +/// Was die letzte Aufsicht ergeben hat. +/// Die Prozesskennung, soweit der Manager sie greifen kann. +public sealed record ManagedInstance( + string Directory, string Name, InstanceCondition Condition, int? ProcessId); + +/// +/// Alle Instanzen dieser Maschine und je eine Aufsicht dafür. +/// +/// Der Manager selbst ist dünn — das ist der Punkt. Er kennt Verzeichnisse +/// und Prozesse, sonst nichts: keine Engine, keine Werkzeuge, keine Datenbank. Was eine +/// Instanz tut, tut sie in ihrem eigenen Prozess, und was ihr dabei zustößt, meldet sie +/// selbst. Hier steht nur, was aus der Sicht des Managers passiert. +/// +/// Beim Start wird übernommen, nicht gestartet. Fällt der Manager aus, laufen +/// die Instanzen weiter (E-M6) — er darf nicht der Einzelpunkt sein, gegen den dieses +/// Vorhaben gebaut wird. sieht deshalb erst nach, wer schon +/// läuft, bevor irgendetwas angefasst wird. +/// +/// Beim Beenden wird nichts beendet. Aus demselben Grund: Wer den Manager +/// schließt, will ihn schließen und nicht seine Instanzen. Nur ein Lizenzwiderruf ist +/// etwas anderes — der ist kein Ausfall, sondern ein Urteil. +/// +public sealed class InstanceRegistry : IAsyncDisposable +{ + private readonly InstanceDirectoryManager _directories; + private readonly IInstanceLauncher _launcher; + private readonly IInstanceChannel _channel; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly SupervisionOptions _options; + private readonly TimeProvider _clock; + + private readonly Dictionary _supervisors = + new(ClawdDotNet.Core.Storage.PathBoundary.Comparer); + + /// + /// Der Name aus InstanceSettings.json, je Instanzverzeichnis. + /// + /// Nötig, weil eine Aufsicht den Namen erst kennt, wenn die Instanz einmal + /// geantwortet hat — eine angehaltene tut das nie. Ohne diese Liste hieße jede + /// gestoppte Instanz nach ihrem Ordner (Instance-Alpha statt + /// Alpha), und das ist genau die Ansicht, die der Manager anzeigt. + /// + private readonly Dictionary _names = + new(ClawdDotNet.Core.Storage.PathBoundary.Comparer); + + public InstanceRegistry( + InstanceDirectoryManager directories, + IInstanceLauncher launcher, + IInstanceChannel channel, + ILoggerFactory loggerFactory, + SupervisionOptions? options = null, + TimeProvider? clock = null) + { + _directories = directories; + _launcher = launcher; + _channel = channel; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Manager"); + _options = options ?? SupervisionOptions.Default; + _clock = clock ?? TimeProvider.System; + } + + /// + /// Jedes Vorkommnis aus jeder Aufsicht, mit dem Instanzverzeichnis daran. + /// + /// Kommt auf dem Faden des Verursachers — wer eine Oberfläche daran + /// hängt, geht über den Dispatcher. + /// + public event Action? Observed; + + /// Was der Manager gerade über seine Instanzen weiß. + public IReadOnlyList Instances + => _supervisors.Values + .Select(s => new ManagedInstance( + s.InstanceDirectory, NameOf(s), s.Condition, s.ProcessId)) + .OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + /// + /// Die laufende Instanz sagt selbst, wie sie heißt; sonst gilt, was in ihrer + /// Konfiguration steht; und wenn auch die nichts hergibt, der Ordnername. + /// + private string NameOf(InstanceSupervisor supervisor) + { + if (!string.IsNullOrEmpty(supervisor.InstanceName)) + return supervisor.InstanceName; + + return _names.TryGetValue(supervisor.InstanceDirectory, out var name) + && !string.IsNullOrWhiteSpace(name) + ? name + : Path.GetFileName(supervisor.InstanceDirectory); + } + + // ─── Nachsehen, was da ist ─── + + /// + /// Liest das Instanzenverzeichnis und übernimmt, was schon läuft. + /// + /// Beim ersten Aufruf ist das die Wiederaufnahme nach einem Managerneustart + /// (E-M6); später hält es das Register mit dem Dateisystem im Gleichklang — jemand + /// kann eine Instanz von Hand angelegt oder weggeräumt haben. + /// + /// Startet nichts von selbst. Was nicht läuft, bleibt aus, bis jemand es + /// verlangt. Ein Manager, der beim Hochkommen alles hochfährt, was er findet, würde + /// nach einem Neustart der Maschine auch das starten, was jemand mit Absicht + /// angehalten hat. + /// + public async Task RefreshAsync(CancellationToken ct = default) + { + // ListInstances liest den Namen aus InstanceSettings.json gleich mit - und tut + // es nachsichtig: eine kaputte Datei ergibt Vorgabewerte, keinen Wurf. + var vorhanden = _directories.ListInstances() + .Select(i => (Pfad: Path.GetFullPath(i.FolderPath), i.InstanceName)) + .ToList(); + + var pfade = vorhanden.Select(v => v.Pfad).ToList(); + + // Weggeraeumte Instanzen loslassen. Ihr Prozess wird nicht beendet: Wenn das + // Verzeichnis weg ist und trotzdem etwas laeuft, ist das ein Fall fuer einen + // Menschen und nicht fuer einen stillen Abschuss. + foreach (var verschwunden in _supervisors.Keys.Except(pfade, _supervisors.Comparer).ToList()) + { + _logger.LogInformation( + "Instanzverzeichnis {Pfad} gibt es nicht mehr — aus der Aufsicht genommen.", + verschwunden); + + await _supervisors[verschwunden].DisposeAsync().ConfigureAwait(false); + _supervisors.Remove(verschwunden); + _names.Remove(verschwunden); + } + + foreach (var (pfad, name) in vorhanden) + { + // Der Name kann sich geaendert haben, ohne dass die Instanz neu startet. + _names[pfad] = name; + + if (_supervisors.ContainsKey(pfad)) continue; + + var aufsicht = Register(pfad, name); + + // Laeuft sie schon? Der Socket sagt es - und nur er sagt es richtig. + await aufsicht.ReattachAsync(ct).ConfigureAwait(false); + + if (aufsicht.Condition == InstanceCondition.Running) + aufsicht.BeginWatching(); + } + } + + private InstanceSupervisor Register(string instanceDirectory, string name) + { + var aufsicht = new InstanceSupervisor( + instanceDirectory, _launcher, _channel, + _loggerFactory.CreateLogger($"ClawdDotNet.Manager.{Path.GetFileName(instanceDirectory)}"), + _options, _clock); + + aufsicht.Observed += e => Observed?.Invoke(e); + + var pfad = Path.GetFullPath(instanceDirectory); + + _supervisors[pfad] = aufsicht; + _names[pfad] = name; + + return aufsicht; + } + + // ─── Anlegen, starten, anhalten ─── + + /// + /// Legt eine Instanz an und nimmt sie in die Aufsicht — startet sie aber nicht. + /// + /// Wenn es sie schon gibt. + public string Create(string instanceName) + { + var verzeichnis = _directories.CreateInstance(instanceName); + + Register(verzeichnis, instanceName); + _logger.LogInformation("Instanz {Name} angelegt: {Pfad}", instanceName, verzeichnis); + + return verzeichnis; + } + + public async Task StartAsync(string instanceDirectory, CancellationToken ct = default) + { + var aufsicht = Find(instanceDirectory); + var gestartet = await aufsicht.StartAsync(ct).ConfigureAwait(false); + + if (gestartet) aufsicht.BeginWatching(); + + return gestartet; + } + + public Task StopAsync(string instanceDirectory, CancellationToken ct = default) + => Find(instanceDirectory).StopAsync(ct); + + /// + /// Hält alle laufenden Instanzen an. Nicht beim Beenden des Managers, sondern + /// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf. + /// + public async Task StopAllAsync(string grund, CancellationToken ct = default) + { + _logger.LogWarning("Alle Instanzen werden angehalten: {Grund}", grund); + + foreach (var aufsicht in _supervisors.Values.ToList()) + { + if (aufsicht.Condition is InstanceCondition.Running or InstanceCondition.Silent) + await aufsicht.StopAsync(ct).ConfigureAwait(false); + } + } + + private InstanceSupervisor Find(string instanceDirectory) + { + var pfad = Path.GetFullPath(instanceDirectory); + + return _supervisors.TryGetValue(pfad, out var aufsicht) + ? aufsicht + : throw new KeyNotFoundException($"Keine Instanz unter {pfad} im Register."); + } + + public async ValueTask DisposeAsync() + { + // Die Instanzen laufen weiter (E-M6). Losgelassen werden nur die Aufsichten. + foreach (var aufsicht in _supervisors.Values) + await aufsicht.DisposeAsync().ConfigureAwait(false); + + _supervisors.Clear(); + _names.Clear(); + } +} diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs new file mode 100644 index 0000000..3c02349 --- /dev/null +++ b/src/ClawdDotNet.Manager/ManagerSettings.cs @@ -0,0 +1,208 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.Manager; + +/// +/// Alles, was der Instanzmanager weiß — und mehr soll er nicht wissen (E-M4). +/// +/// Die Kürze dieser Klasse ist die Zusage. 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. +/// +/// 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. +/// +/// Nicht dabei: eine Datenbank. Was der Manager braucht, liegt als JSON im +/// Projektverzeichnis — diese Datei und je Instanz eine kleine Zustandsdatei. Die +/// state.db einer Instanz fasst er nicht an: Sie gehört genau einem Prozess, und +/// prozessübergreifend gilt die Schreibsperre von SqliteStorage nicht. +/// +public sealed class ManagerSettings +{ + // ─── Wo die Instanzen liegen ─── + + [JsonPropertyName("instancesDirectory")] + public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances"); + + /// + /// Die Programmdatei, mit der ein Instanzprozess gestartet wird. + /// + /// 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. + /// + [JsonPropertyName("instanceExecutable")] + public string InstanceExecutable { get; set; } = ""; + + // ─── Die eine Weboberfläche (E-M5) ─── + + [JsonPropertyName("webUiPort")] + public int WebUiPort { get; set; } = 7000; + + /// loopback (Vorgabe) oder any. + [JsonPropertyName("webUiBinding")] + public string WebUiBinding { get; set; } = "loopback"; + + /// Zugangstoken der Weboberfläche. Verschlüsselt gespeichert. + [JsonPropertyName("webUiToken")] + public string WebUiToken { get; set; } = ""; + + // ─── Lizenz (E-M3) ─── + + /// + /// Der Lizenzschlüssel der Installation. Verschlüsselt gespeichert. + /// + /// Er liegt hier und nicht in den Instanzen: Eine Lizenz gilt der Installation + /// und erlaubt beliebig viele Instanzen. + /// + [JsonPropertyName("licenseKey")] + public string LicenseKey { get; set; } = ""; + + // ─── Deploymentcenter ─── + + [JsonPropertyName("deploymentcenterUrl")] + public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de"; + + /// + /// Das Master-Token. Verschlüsselt gespeichert. + /// + /// Aus ihm entstehen die Sub-Token der Instanzen — jedes eingeschränkt auf + /// watchdog:ping und bugtracker:report und einzeln widerrufbar. Nach dem + /// Umbau liegt auf keiner Instanz mehr das Master-Token. + /// + [JsonPropertyName("deploymentcenterMasterToken")] + public string DeploymentcenterMasterToken { get; set; } = ""; + + /// Aktualisiert wird das Programm, nicht die Instanz. + [JsonPropertyName("updateChannel")] + public string UpdateChannel { get; set; } = "prod"; + + // ─── Aufsicht ─── + + /// Wie oft nachgesehen wird, in Sekunden. + [JsonPropertyName("probeIntervalSeconds")] + public int ProbeIntervalSeconds { get; set; } = 15; + + /// Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden. + [JsonPropertyName("startTimeoutSeconds")] + public int StartTimeoutSeconds { get; set; } = 60; + + /// Wie viele Fehlversuche in Folge „antwortet nicht" ergeben. + [JsonPropertyName("silentChecksBeforeAlarm")] + public int SilentChecksBeforeAlarm { get; set; } = 3; + + /// Wie lange eine Instanz nach der Bitte zu enden noch bekommt. + [JsonPropertyName("stopGraceSeconds")] + public int StopGraceSeconds { get; set; } = 20; + + /// + /// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben. + /// + /// 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. + /// + 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))); +} + +/// +/// Lädt und speichert manager.json. +/// +/// Dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht. Der Ort +/// kommt von , 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. +/// +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); + + /// Der Ort der Datei — für Meldungen und Diagnose. + 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( + 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; + } + } + + /// + /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner) + /// leer, damit er neu eingetragen wird, statt unbrauchbar an eine Gegenstelle zu gehen. + /// + private static string TryUnprotect(string value) + { + try { return SecretProtector.Unprotect(value) ?? ""; } + catch (SecretProtectionException) { return ""; } + } +} diff --git a/src/ClawdDotNet.Manager/SupervisionReporter.cs b/src/ClawdDotNet.Manager/SupervisionReporter.cs new file mode 100644 index 0000000..eae67ac --- /dev/null +++ b/src/ClawdDotNet.Manager/SupervisionReporter.cs @@ -0,0 +1,129 @@ +using System.Text; +using ClawdDotNet.Core.Deploymentcenter; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager; + +/// +/// Meldet ans Deploymentcenter, was die Instanz selbst nicht mehr melden kann. +/// +/// Das ist der Grund, warum es den Manager gibt. Am 2026-09-08 hat sich eine +/// Instanz aufgehängt und wurde von Windows beendet: keine Protokollzeile, keine +/// Fehlermeldung, kein Bugtracker-Eintrag, kein Watchdog-Alarm. Bauartbedingt — Meldeweg +/// und Watchdog lebten in dem Prozess, über den sie berichten sollten. Fällt er hart, +/// fällt der Meldeweg mit, und zwar genau im Fall, der gemeldet werden müsste. +/// +/// Zwei Wege, mit Absicht. Die Instanz meldet, solange sie lebt — eigener +/// Fehler-Stream, eigener Heartbeat, eigenes Sub-Token. Der Manager meldet, was sie nicht +/// mehr melden kann. Jeder Weg deckt den Ausfall des anderen ab; ein Manager, der jede +/// Meldung durchreichte, wäre der neue Einzelpunkt. +/// +/// Und er meldet wenig. Nur Absturz, Verstummen und ein gescheiterter Start — +/// das, was aus seiner Sicht relevant ist. Ein fehlgeschlagener Werkzeugaufruf, ein +/// abgebrochener Agentenlauf, eine überschrittene Budgetgrenze bleiben in der Instanz. +/// Der Manager sieht davon nichts und soll davon nichts sehen (Konzept, Abschnitt 4). +/// +public sealed class SupervisionReporter( + BugtrackerClient bugtracker, ILogger logger, Func>? logExcerpt = null) +{ + private readonly Func> _logExcerpt = + logExcerpt ?? (dir => InstanceLogExcerpt.Read(dir)); + + /// + /// Meldet ein Vorkommnis, wenn es meldenswert ist — sonst nicht. + /// + /// Wirft nie. Ein Meldeweg, der selbst wirft, wäre die schlechteste aller + /// Welten: Der ursprüngliche Befund ginge dabei verloren, und die Aufsicht, die ihn + /// gefunden hat, stürbe an ihrem eigenen Bericht. + /// + /// true, wenn eine Meldung rausgegangen ist. + public async Task ReportAsync(SupervisionEvent ereignis, CancellationToken ct = default) + { + if (!ereignis.WorthReporting) return false; + + try + { + await bugtracker.ReportAsync( + type: BugtrackerItemType.Bug, + title: Title(ereignis), + description: Description(ereignis), + severity: ereignis.Kind == InstanceEventKind.StartFailed ? "medium" : "high", + + // Stabil je Instanz und Art: Stuerzt dieselbe Instanz wiederholt ab, zaehlt + // der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen. Genau + // dafuer gibt es occurrence_count - und wiederholte Abstuerze sind ein + // eigener Befund, kein Dutzend gleicher. + clientRef: $"manager-{ereignis.Kind}-{Path.GetFileName(ereignis.InstanceDirectory)}" + .ToLowerInvariant(), + + context: new Dictionary + { + ["quelle"] = "instanzmanager", + ["instanz"] = ereignis.InstanceName, + ["instanzverzeichnis"] = ereignis.InstanceDirectory, + ["prozesskennung"] = ereignis.ProcessId, + ["abschiedscode"] = ereignis.ExitCode, + ["beobachtet"] = ereignis.At.ToString("O") + }, + ct: ct).ConfigureAwait(false); + + logger.LogInformation( + "Stellvertretend gemeldet: {Art} an Instanz {Name}.", + ereignis.Kind, ereignis.InstanceName); + + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Die stellvertretende Meldung für Instanz {Name} ging nicht raus. " + + "Der Befund steht im Protokoll des Managers.", ereignis.InstanceName); + + return false; + } + } + + private static string Title(SupervisionEvent e) => e.Kind switch + { + InstanceEventKind.Crashed => + $"Instanz '{e.InstanceName}' unerwartet beendet", + InstanceEventKind.Unresponsive => + $"Instanz '{e.InstanceName}' laeuft, antwortet aber nicht mehr", + InstanceEventKind.StartFailed => + $"Instanz '{e.InstanceName}' liess sich nicht starten", + _ => $"Instanz '{e.InstanceName}': {e.Kind}" + }; + + private string Description(SupervisionEvent e) + { + var text = new StringBuilder(); + + text.AppendLine(e.Message).AppendLine(); + text.AppendLine("Gemeldet vom Instanzmanager, weil die Instanz es selbst nicht mehr konnte."); + text.AppendLine(); + text.AppendLine($"Instanz : {e.InstanceName}"); + text.AppendLine($"Verzeichnis : {e.InstanceDirectory}"); + text.AppendLine($"Prozesskennung : {e.ProcessId?.ToString() ?? "unbekannt"}"); + text.AppendLine($"Abschiedscode : {e.ExitCode?.ToString() ?? "unbekannt"}"); + text.AppendLine($"Beobachtet : {e.At:yyyy-MM-dd HH:mm:ss} UTC"); + text.AppendLine(); + + var zeilen = _logExcerpt(e.InstanceDirectory); + + if (zeilen.Count == 0) + { + // Kein Versehen, sondern die aussagekraeftigste Zeile der ganzen Meldung. + text.AppendLine( + "Letzte Protokollzeilen: KEINE. Die Instanz hat nichts hinterlassen — " + + "ihr Schreibpuffer wurde nie geleert. Genau dieser Zustand (#529) ist " + + "der Grund, warum diese Meldung von aussen kommt."); + } + else + { + text.AppendLine($"Letzte {zeilen.Count} Protokollzeilen der Instanz:"); + foreach (var zeile in zeilen) text.AppendLine(" " + zeile); + } + + return text.ToString(); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs index ac1d8d7..b48d4f2 100644 --- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs +++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs @@ -82,8 +82,19 @@ internal sealed class FakeLauncher : IInstanceLauncher /// Der Socket, den der Test antworten oder schweigen lässt. internal sealed class FakeChannel : IInstanceChannel { + /// Die Antwort für alle Verzeichnisse, sofern keine eigene hinterlegt ist. public InstanceIdentity? Answer { get; set; } + /// + /// Antworten je Instanzverzeichnis. + /// + /// Nötig, sobald mehr als eine Instanz im Spiel ist: Eine Auskunft, die für jedes + /// Verzeichnis dieselbe Instanz nennt, lässt Tests grün werden, die im Betrieb zwei + /// verschiedene Instanzen verwechseln würden. + /// + public Dictionary AnswerFor { get; } = + new(StringComparer.OrdinalIgnoreCase); + public int Calls { get; private set; } /// Wie oft die Instanz gebeten wurde, sich zu beenden. @@ -95,7 +106,11 @@ internal sealed class FakeChannel : IInstanceChannel public Task AskAsync(string instanceDirectory, CancellationToken ct = default) { Calls++; - return Task.FromResult(Answer); + + return Task.FromResult( + AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var eigene) + ? eigene + : Answer); } public Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default) diff --git a/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs b/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs new file mode 100644 index 0000000..76c90ee --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/InstanceRegistryTests.cs @@ -0,0 +1,249 @@ +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// Das Register aller Instanzen einer Maschine (#528, Stufe 3). +/// +/// Zwei Zusagen stehen hier über allem, und beide folgen aus E-M6: Beim Start +/// wird übernommen, nicht gestartet, und beim Beenden wird nichts beendet. +/// Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der Einzelpunkt +/// sein, gegen den dieses ganze Vorhaben gebaut wird. +/// +public sealed class InstanceRegistryTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "reg-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _kanal = new(); + private readonly List _ereignisse = []; + + private readonly InstanceDirectoryManager _verzeichnisse; + + private static readonly SupervisionOptions Options = new( + ProbeInterval: TimeSpan.FromMilliseconds(50), + StartTimeout: TimeSpan.FromMilliseconds(600), + SilentChecksBeforeAlarm: 3, + StopGrace: TimeSpan.FromMilliseconds(300)); + + public InstanceRegistryTests() + => _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + + private InstanceRegistry Register() + { + var register = new InstanceRegistry( + _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); + + register.Observed += _ereignisse.Add; + return register; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + // ─── Was da ist ─── + + [Fact] + public async Task Ein_leeres_Instanzenverzeichnis_ergibt_ein_leeres_Register() + { + await using var register = Register(); + await register.RefreshAsync(); + + register.Instances.ShouldBeEmpty(); + } + + [Fact] + public async Task Angelegte_Instanzen_stehen_im_Register() + { + _verzeichnisse.CreateInstance("Alpha"); + _verzeichnisse.CreateInstance("Beta"); + + await using var register = Register(); + await register.RefreshAsync(); + + register.Instances.Select(i => i.Name).ShouldBe(["Alpha", "Beta"]); + register.Instances.ShouldAllBe(i => i.Condition == InstanceCondition.Stopped); + } + + /// + /// Ein Manager, der beim Hochkommen alles startet, was er findet, würde nach einem + /// Neustart der Maschine auch das starten, was jemand mit Absicht angehalten hat. + /// + [Fact] + public async Task Beim_Nachsehen_wird_nichts_von_selbst_gestartet() + { + _verzeichnisse.CreateInstance("Alpha"); + + await using var register = Register(); + await register.RefreshAsync(); + + _launcher.Starts.ShouldBe(0); + _ereignisse.ShouldBeEmpty(); + } + + [Fact] + public async Task Eine_weggeraeumte_Instanz_faellt_aus_dem_Register() + { + var pfad = _verzeichnisse.CreateInstance("Vergaenglich"); + + await using var register = Register(); + await register.RefreshAsync(); + register.Instances.Count.ShouldBe(1); + + Directory.Delete(pfad, recursive: true); + await register.RefreshAsync(); + + register.Instances.ShouldBeEmpty(); + } + + // ─── Anlegen, starten, anhalten ─── + + [Fact] + public async Task Eine_neu_angelegte_Instanz_ist_sofort_im_Register_und_laeuft_nicht() + { + await using var register = Register(); + + var pfad = register.Create("Frisch"); + + Directory.Exists(pfad).ShouldBeTrue(); + File.Exists(Path.Combine(pfad, "InstanceSettings.json")).ShouldBeTrue(); + + var instanz = register.Instances.ShouldHaveSingleItem(); + instanz.Condition.ShouldBe(InstanceCondition.Unknown); + _launcher.Starts.ShouldBe(0); + } + + [Fact] + public async Task Starten_und_Anhalten_gehen_ueber_das_Register() + { + _kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + + await using var register = Register(); + var pfad = register.Create("Alpha"); + + (await register.StartAsync(pfad)).ShouldBeTrue(); + register.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + + // Die Instanz endet auf die Bitte hin. + _launcher.Last!.Exit(0); + _kanal.Answer = null; + + (await register.StopAsync(pfad)).ShouldBeTrue(); + register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); + } + + [Fact] + public async Task Ein_unbekanntes_Verzeichnis_wird_nicht_stillschweigend_angelegt() + { + await using var register = Register(); + + await Should.ThrowAsync( + () => register.StartAsync(Path.Combine(_root, "gibtsnicht"))); + } + + // ─── E-M6 ─── + + [Fact] + public async Task Laufende_Instanzen_werden_beim_Nachsehen_uebernommen() + { + string pfad; + + // Erster Manager legt zwei an und startet nur eine. + await using (var erster = Register()) + { + pfad = erster.Create("Alpha"); + erster.Create("Beta"); + + // Nur Alpha antwortet. Eine Auskunft, die fuer jedes Verzeichnis dieselbe + // Instanz nennt, wuerde hier gruen werden und im Betrieb zwei Instanzen + // verwechseln. + _kanal.AnswerFor[Path.GetFullPath(pfad)] = + new InstanceIdentity("i-1", "Alpha", "0.1.2"); + + (await erster.StartAsync(pfad)).ShouldBeTrue(); + } + + _ereignisse.Clear(); + + // Zweiter Manager, derselbe Rechner. + await using var zweiter = Register(); + await zweiter.RefreshAsync(); + + var alpha = zweiter.Instances.Single(i => i.Name == "Alpha"); + alpha.Condition.ShouldBe(InstanceCondition.Running); + alpha.ProcessId.ShouldBe(_launcher.Last!.Id); + + // Beta antwortet nicht und bleibt aus - sie wird nicht mit hochgezogen. + zweiter.Instances.Single(i => i.Name == "Beta").Condition + .ShouldBe(InstanceCondition.Stopped); + + // Uebernehmen ist kein Starten. + _launcher.Starts.ShouldBe(1); + _ereignisse.ShouldBeEmpty(); + } + + /// + /// Wer den Manager schließt, will den Manager schließen — nicht seine Instanzen. + /// + [Fact] + public async Task Das_Beenden_des_Managers_beendet_keine_Instanz() + { + _kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + + var register = Register(); + var pfad = register.Create("Alpha"); + await register.StartAsync(pfad); + + await register.DisposeAsync(); + + _kanal.StopRequests.ShouldBe(0); + _launcher.Last!.WasKilled.ShouldBeFalse(); + _launcher.Last.HasExited.ShouldBeFalse(); + + // Und die Zustandsdatei bleibt liegen - sie ist der Hinweis fuer den naechsten. + InstanceStateStore.Read(pfad).ShouldNotBeNull(); + } + + /// + /// Die eine Ausnahme: Ein Lizenzwiderruf ist kein Ausfall, sondern ein Urteil. + /// + [Fact] + public async Task Bei_einem_Grund_der_alle_betrifft_werden_alle_angehalten() + { + _kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + + await using var register = Register(); + var a = register.Create("Alpha"); + await register.StartAsync(a); + + _launcher.Last!.Exit(0); + _kanal.Answer = null; + + await register.StopAllAsync("Lizenz widerrufen"); + + register.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); + _kanal.StopRequests.ShouldBe(1); + } + + // ─── Vorkommnisse ─── + + [Fact] + public async Task Vorkommnisse_einzelner_Instanzen_kommen_beim_Register_an() + { + _kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + + await using var register = Register(); + var pfad = register.Create("Alpha"); + await register.StartAsync(pfad); + + _ereignisse.Select(e => e.Kind).ShouldBe([InstanceEventKind.Started]); + _ereignisse[0].InstanceDirectory.ShouldBe(Path.GetFullPath(pfad)); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs new file mode 100644 index 0000000..0f10580 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs @@ -0,0 +1,200 @@ +using ClawdDotNet.Core.Instances; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// manager.json und der Protokollauszug — die beiden Stellen, an denen der Manager +/// mit dem Dateisystem spricht (#528, Stufe 3). +/// +public sealed class ManagerSettingsTests : IDisposable +{ + private readonly string _dir = Path.Combine( + Path.GetTempPath(), "clawd-tests", "mgr-" + Guid.NewGuid().ToString("N")[..8]); + + public ManagerSettingsTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + [Fact] + public void Eine_fehlende_Datei_wird_mit_Vorgaben_angelegt() + { + var store = new ManagerSettingsStore(_dir); + var settings = store.Load(); + + File.Exists(store.SettingsPath).ShouldBeTrue("Der Ort soll sichtbar werden."); + settings.WebUiPort.ShouldBe(7000); + settings.UpdateChannel.ShouldBe("prod"); + } + + /// + /// Master-Token und Lizenzschlüssel liegen in der Datei nicht im Klartext — zur + /// Laufzeit aber schon, sonst könnte niemand damit arbeiten. + /// + [Fact] + public void Geheimnisse_stehen_nicht_im_Klartext_in_der_Datei() + { + var store = new ManagerSettingsStore(_dir); + store.Load(); + + store.Settings.DeploymentcenterMasterToken = "dc_master_geheim_1234567890"; + store.Settings.LicenseKey = "LIZ-1234-5678"; + store.Save(); + + File.ReadAllText(store.SettingsPath).ShouldNotContain("dc_master_geheim_1234567890"); + File.ReadAllText(store.SettingsPath).ShouldNotContain("LIZ-1234-5678"); + + var erneut = new ManagerSettingsStore(_dir).Load(); + erneut.DeploymentcenterMasterToken.ShouldBe("dc_master_geheim_1234567890"); + erneut.LicenseKey.ShouldBe("LIZ-1234-5678"); + } + + /// + /// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar + /// keine Instanz mehr, und das ist der schlechtere Zustand. + /// + [Fact] + public void Eine_kaputte_Datei_verhindert_den_Start_nicht() + { + File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON"); + + new ManagerSettingsStore(_dir).Load().WebUiPort.ShouldBe(7000); + } + + /// + /// Unsinnige Zahlen können in einer von Hand bearbeitbaren Datei stehen. Ein + /// Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null Fehlversuchen ein + /// Alarm bei jedem Aussetzer. + /// + [Fact] + public void Unsinnige_Aufsichtswerte_werden_eingefangen() + { + var settings = new ManagerSettings + { + ProbeIntervalSeconds = 0, + StartTimeoutSeconds = -5, + SilentChecksBeforeAlarm = 0, + StopGraceSeconds = 99999 + }; + + var options = settings.ToSupervisionOptions(); + + options.ProbeInterval.ShouldBeGreaterThan(TimeSpan.Zero); + options.StartTimeout.ShouldBeGreaterThan(TimeSpan.Zero); + options.SilentChecksBeforeAlarm.ShouldBeGreaterThanOrEqualTo(1); + options.StopGrace.ShouldBeLessThanOrEqualTo(TimeSpan.FromMinutes(10)); + } +} + +/// Der Protokollauszug, der einer Absturzmeldung erst ihren Wert gibt. +public sealed class InstanceLogExcerptTests : IDisposable +{ + private readonly string _instanz = Path.Combine( + Path.GetTempPath(), "clawd-tests", "log-" + Guid.NewGuid().ToString("N")[..8]); + + public InstanceLogExcerptTests() => Directory.CreateDirectory(_instanz); + + public void Dispose() + { + try { Directory.Delete(_instanz, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + private void Schreibe(string tag, string modul, params string[] zeilen) + { + var dir = Path.Combine(_instanz, "Logs", tag); + Directory.CreateDirectory(dir); + File.WriteAllLines(Path.Combine(dir, modul), zeilen); + } + + [Fact] + public void Ohne_Protokoll_kommt_nichts_zurueck() + { + // Kein Fehler, sondern ein Befund: Ein Prozess, der erschlagen wurde, bevor sein + // Schreibpuffer geleert war, hinterlaesst genau nichts (#529). + InstanceLogExcerpt.Read(_instanz).ShouldBeEmpty(); + } + + /// + /// Module beschreiben denselben Zeitraum. Bei einem Absturz will man wissen, was + /// gleichzeitig passiert ist — nicht erst alles aus einer Datei, dann alles aus der + /// nächsten. + /// + [Fact] + public void Die_Module_werden_nach_Zeit_zusammengefuehrt() + { + Schreibe("2026-09-08", "Startup.log", + "[17:26:48.100] [INF] Instanz Test2 angelegt", + "[17:26:50.900] [INF] Application is shutting down"); + + Schreibe("2026-09-08", "Web.log", + "[17:26:49.500] [INF] Weboberfläche beendet"); + + var zeilen = InstanceLogExcerpt.Read(_instanz); + + zeilen.Count.ShouldBe(3); + zeilen[0].ShouldContain("17:26:48"); + zeilen[1].ShouldContain("17:26:49"); + zeilen[2].ShouldContain("17:26:50"); + + // Aus welcher Datei eine Zeile stammt, gehoert dazu. + zeilen[1].ShouldStartWith("Web.log:"); + } + + [Fact] + public void Nur_der_juengste_Tag_zaehlt() + { + Schreibe("2026-09-01", "Startup.log", "[10:00:00.000] [INF] uralt"); + Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] frisch"); + + InstanceLogExcerpt.Read(_instanz).ShouldHaveSingleItem().ShouldContain("frisch"); + } + + [Fact] + public void Es_werden_nur_die_letzten_Zeilen_genommen() + { + Schreibe("2026-09-08", "Startup.log", + Enumerable.Range(0, 500).Select(i => $"[10:00:{i % 60:00}.000] [INF] Zeile {i}").ToArray()); + + InstanceLogExcerpt.Read(_instanz, lines: 10).Count.ShouldBeLessThanOrEqualTo(10); + } + + /// + /// Die Datei kann in genau diesem Moment noch beschrieben werden — von einer Instanz, + /// die gerade endet. Wer sie exklusiv öffnen wollte, bekäme im Zweifel gar nichts. + /// + [Fact] + public void Eine_offen_beschriebene_Datei_laesst_sich_lesen() + { + Schreibe("2026-09-08", "Startup.log", "[10:00:00.000] [INF] erste Zeile"); + + var pfad = Path.Combine(_instanz, "Logs", "2026-09-08", "Startup.log"); + + using var offen = new FileStream( + pfad, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + + InstanceLogExcerpt.Read(_instanz).ShouldNotBeEmpty(); + } + + [Fact] + public void Ein_eigenes_Protokollverzeichnis_wird_beachtet() + { + var eigen = Path.Combine(_instanz, "AndereLogs"); + Directory.CreateDirectory(Path.Combine(eigen, "2026-09-08")); + File.WriteAllLines( + Path.Combine(eigen, "2026-09-08", "Startup.log"), + ["[10:00:00.000] [INF] woanders"]); + + var config = new ClawdDotNet.Core.Config.InstanceConfig + { + WorkingDirectory = _instanz, + LogDirectory = "AndereLogs" + }; + + InstanceLogExcerpt.Read(_instanz, config).ShouldHaveSingleItem().ShouldContain("woanders"); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/ReportingTests.cs b/tests/ClawdDotNet.Manager.Tests/ReportingTests.cs new file mode 100644 index 0000000..b0da641 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ReportingTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text; +using ClawdDotNet.Core.Deploymentcenter; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff. +internal sealed class CapturingHandler( + HttpStatusCode status = HttpStatusCode.OK, + string responseBody = """{"status":"success","item_id":1,"is_new":true,"occurrence_count":1}""") + : HttpMessageHandler +{ + public string? LastBody { get; private set; } + + public int Calls { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + + if (request.Content is not null) + LastBody = await request.Content.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(status) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }; + } +} + +/// +/// Die stellvertretende Meldung (#528, Konzept Abschnitt 4). +/// +/// Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von Windows beendet: +/// keine Protokollzeile, keine Fehlermeldung, kein Bugtracker-Eintrag. Bauartbedingt — +/// Meldeweg und Watchdog lebten in dem Prozess, über den sie berichten sollten. Der +/// Manager meldet deshalb an ihrer Stelle, und zwar nur das, was aus seiner Sicht +/// relevant ist. +/// +public sealed class SupervisionReporterTests +{ + private readonly CapturingHandler _handler = new(); + + private SupervisionReporter Melder(Func>? auszug = null) + { + var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(_handler)); + var bugtracker = new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"); + + return new SupervisionReporter(bugtracker, NullLogger.Instance, auszug ?? (_ => [])); + } + + private static SupervisionEvent Ereignis( + InstanceEventKind kind, int? exitCode = null, string dir = @"C:\Instanzen\Instance-Alpha") + => new(kind, dir, "Alpha", 4711, exitCode, "Instanz Alpha ist unerwartet beendet worden.", + DateTimeOffset.Parse("2026-09-08T17:27:45Z")); + + [Fact] + public async Task Ein_Absturz_geht_raus() + { + (await Melder().ReportAsync(Ereignis(InstanceEventKind.Crashed, exitCode: 137))) + .ShouldBeTrue(); + + _handler.Calls.ShouldBe(1); + + var rumpf = _handler.LastBody.ShouldNotBeNull(); + rumpf.ShouldContain("unerwartet beendet"); + rumpf.ShouldContain("4711"); + rumpf.ShouldContain("137"); + rumpf.ShouldContain("instanzmanager"); + rumpf.ShouldContain("clawddotnet"); // Projektbindung + rumpf.ShouldContain("0.1.2"); // build_version + } + + /// + /// Was die Instanz betrifft, bleibt in der Instanz — sie meldet es selbst, mit ihrem + /// eigenen Sub-Token. Der Manager meldet nur, was niemand bestellt hat. + /// + [Theory] + [InlineData(InstanceEventKind.Started)] + [InlineData(InstanceEventKind.StoppedOrderly)] + [InlineData(InstanceEventKind.Recovered)] + public async Task Alltag_wird_nicht_gemeldet(InstanceEventKind kind) + { + (await Melder().ReportAsync(Ereignis(kind))).ShouldBeFalse(); + _handler.Calls.ShouldBe(0); + } + + /// + /// Stürzt dieselbe Instanz wiederholt ab, soll der Server hochzählen statt den + /// Bugtracker mit Zwillingen zu füllen — wiederholte Abstürze sind ein Befund und + /// nicht ein Dutzend gleicher. + /// + [Fact] + public async Task Dieselbe_Instanz_und_dieselbe_Art_ergeben_denselben_Schluessel() + { + var melder = Melder(); + + await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed)); + var erster = SchluesselAus(_handler.LastBody!); + + await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed)); + SchluesselAus(_handler.LastBody!).ShouldBe(erster); + + // Eine andere Art ist ein anderer Befund. + await melder.ReportAsync(Ereignis(InstanceEventKind.Unresponsive)); + SchluesselAus(_handler.LastBody!).ShouldNotBe(erster); + + // Und eine andere Instanz erst recht. + await melder.ReportAsync( + Ereignis(InstanceEventKind.Crashed, dir: @"C:\Instanzen\Instance-Beta")); + SchluesselAus(_handler.LastBody!).ShouldNotBe(erster); + } + + [Fact] + public async Task Der_Protokollauszug_kommt_mit() + { + await Melder(_ => ["Startup.log: [17:26:50] [INF] Application is shutting down"]) + .ReportAsync(Ereignis(InstanceEventKind.Crashed)); + + _handler.LastBody.ShouldNotBeNull().ShouldContain("Application is shutting down"); + } + + /// + /// Ein leerer Auszug ist die aussagekräftigste Zeile der ganzen Meldung: Ein Prozess, + /// der erschlagen wurde, bevor sein Schreibpuffer geleert war, hinterlässt nichts. + /// Genau das war der Zustand aus #529. + /// + [Fact] + public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt() + { + await Melder(_ => []).ReportAsync(Ereignis(InstanceEventKind.Crashed)); + + _handler.LastBody.ShouldNotBeNull().ShouldContain("KEINE"); + } + + /// + /// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: Der + /// ursprüngliche Befund ginge dabei verloren, und die Aufsicht stürbe an ihrem + /// eigenen Bericht. + /// + [Fact] + public async Task Ein_Serverfehler_reisst_die_Aufsicht_nicht_mit() + { + var handler = new CapturingHandler(HttpStatusCode.InternalServerError, + """{"status":"error","error":{"code":"boom","message":"kaputt"}}"""); + + var api = new DeploymentcenterApi("https://dc.example", "t", new HttpClient(handler)); + var melder = new SupervisionReporter( + new BugtrackerClient(api, "clawddotnet", "production", "0.1.2"), + NullLogger.Instance, _ => []); + + (await melder.ReportAsync(Ereignis(InstanceEventKind.Crashed))).ShouldBeFalse(); + } + + private static string SchluesselAus(string body) + { + using var doc = System.Text.Json.JsonDocument.Parse(body); + return doc.RootElement.GetProperty("client_ref").GetString()!; + } +}