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), SilentKillGrace: TimeSpan.FromMilliseconds(300), MaxDrainWait: TimeSpan.FromSeconds(2)); public InstanceRegistryTests() { _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im // Betrieb (siehe FakeChannel.Launcher). _kanal.Launcher = _launcher; } 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. var prozess = _launcher.Last!; _kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); }; (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); var prozess = _launcher.Last!; _kanal.OnStopRequest = _ => { _kanal.Answer = null; prozess.Exit(0); }; 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)); } }