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:
@@ -82,8 +82,19 @@ internal sealed class FakeLauncher : IInstanceLauncher
|
||||
/// <summary>Der Socket, den der Test antworten oder schweigen lässt.</summary>
|
||||
internal sealed class FakeChannel : IInstanceChannel
|
||||
{
|
||||
/// <summary>Die Antwort für alle Verzeichnisse, sofern keine eigene hinterlegt ist.</summary>
|
||||
public InstanceIdentity? Answer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Antworten je Instanzverzeichnis.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public Dictionary<string, InstanceIdentity?> AnswerFor { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
|
||||
@@ -95,7 +106,11 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
public Task<InstanceIdentity?> 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<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Das Register aller Instanzen einer Maschine (#528, Stufe 3).
|
||||
///
|
||||
/// <para>Zwei Zusagen stehen hier über allem, und beide folgen aus E-M6: <b>Beim Start
|
||||
/// wird übernommen, nicht gestartet</b>, und <b>beim Beenden wird nichts beendet.</b>
|
||||
/// Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der Einzelpunkt
|
||||
/// sein, gegen den dieses ganze Vorhaben gebaut wird.</para>
|
||||
/// </summary>
|
||||
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<SupervisionEvent> _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<KeyNotFoundException>(
|
||||
() => 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer den Manager schließt, will den Manager schließen — nicht seine Instanzen.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die eine Ausnahme: Ein Lizenzwiderruf ist kein Ausfall, sondern ein Urteil.
|
||||
/// </summary>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Fängt die letzte Anfrage ab und antwortet fest, ohne Netzwerkzugriff.</summary>
|
||||
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<HttpResponseMessage> 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die stellvertretende Meldung (#528, Konzept Abschnitt 4).
|
||||
///
|
||||
/// <para>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 <b>nur</b> das, was aus seiner Sicht
|
||||
/// relevant ist.</para>
|
||||
/// </summary>
|
||||
public sealed class SupervisionReporterTests
|
||||
{
|
||||
private readonly CapturingHandler _handler = new();
|
||||
|
||||
private SupervisionReporter Melder(Func<string, IReadOnlyList<string>>? 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_leerer_Protokollauszug_wird_ausdruecklich_gesagt()
|
||||
{
|
||||
await Melder(_ => []).ReportAsync(Ereignis(InstanceEventKind.Crashed));
|
||||
|
||||
_handler.LastBody.ShouldNotBeNull().ShouldContain("KEINE");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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()!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user