Files
ClawdDotNet/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs
T
RichardandClaude Opus 5 72b0764d1a 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>
2026-09-08 22:45:22 +02:00

201 lines
7.1 KiB
C#

using ClawdDotNet.Core.Instances;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// <c>manager.json</c> und der Protokollauszug — die beiden Stellen, an denen der Manager
/// mit dem Dateisystem spricht (#528, Stufe 3).
/// </summary>
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");
}
/// <summary>
/// Master-Token und Lizenzschlüssel liegen in der Datei nicht im Klartext — zur
/// Laufzeit aber schon, sonst könnte niemand damit arbeiten.
/// </summary>
[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");
}
/// <summary>
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
/// keine Instanz mehr, und das ist der schlechtere Zustand.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
}
/// <summary>Der Protokollauszug, der einer Absturzmeldung erst ihren Wert gibt.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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");
}
}