Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung: Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten. Beim Herunterfahren beendet er keine Instanz. Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche - ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt. wwwroot ist aus der Instanz in den Manager gezogen. DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api geht unveraendert an eine Instanz. Er leitet weiter, er implementiert nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine abgelehnte. Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht der Weboberflaeche zum Umschreiben gezwungen. ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token 200, Instanz ueber den Manager gestartet und angehalten, /api/info und /api/status unverfaelscht durchgereicht, /events/logs mit "instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen bereits" - uebernommen. E-M6 damit in beide Richtungen belegt. DREI DEFEKTE, die erst der Lauf gezeigt hat: 1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht. 2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt. 3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt sein Verzeichnis jetzt ueber --config. SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts, die dasselbe brauchen, und sie duerfen einander nicht kennen. NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen - Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an wwwroot. Und webServerPort steht noch in der Instanz. Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
8.6 KiB
C#
241 lines
8.6 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 von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark,
|
|
/// und sie darf trotzdem gelten.
|
|
///
|
|
/// <para>Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete
|
|
/// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
|
|
{
|
|
var pfad = Path.Combine(_dir, "manager.json");
|
|
|
|
// Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach
|
|
// dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad.
|
|
var inhalt = """
|
|
{
|
|
"instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
|
|
"webUiPort": 7009,
|
|
"deploymentcenterUrl": ""
|
|
}
|
|
""";
|
|
|
|
File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
|
|
File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM.");
|
|
|
|
var store = new ManagerSettingsStore(_dir);
|
|
var settings = store.Load();
|
|
|
|
store.LoadProblem.ShouldBeNull();
|
|
settings.InstanceExecutable.ShouldBe(@"J:\Programme\ClawdDotNet.exe");
|
|
settings.WebUiPort.ShouldBe(7009);
|
|
settings.DeploymentcenterUrl.ShouldBe("");
|
|
}
|
|
|
|
/// <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");
|
|
|
|
var store = new ManagerSettingsStore(_dir);
|
|
|
|
store.Load().WebUiPort.ShouldBe(7000);
|
|
|
|
// Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit
|
|
// Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist.
|
|
store.LoadProblem.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
/// <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");
|
|
}
|
|
}
|