using ClawdDotNet.Core.Deploymentcenter; using ClawdDotNet.Core.Instances; using ClawdDotNet.Licensing; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Manager; /// Die Ablage des Managers als Ort für den Lizenzschlüssel (E-M3). public sealed class ManagerLicenseStore(ManagerSettingsStore store) : ILicenseStore { public string ServerUrl => store.Settings.DeploymentcenterUrl; public string LicenseKey { get => store.Settings.LicenseKey; set => store.Settings.LicenseKey = value; } public void Save() => store.Save(); } /// Warum eine Instanz nicht startet. public enum StartRefusal { /// Sie startet. None, /// Keine gültige Lizenz. Eine Lizenz gilt der Installation, nicht der Instanz. NoLicense, /// Der Prozess kam nicht hoch oder hat sich nicht gemeldet. DidNotStart } /// /// Der Instanzmanager als Ganzes — ohne Fenster, ohne Weboberfläche, ohne Prozess. /// /// Was er zusammenhält: die eigenen Einstellungen, die Lizenz, das Register /// der Instanzen und den Weg, auf dem er stellvertretend meldet. Alles darüber — ein /// Fenster, die eine Weboberfläche für alle (E-M5) — setzt hier auf und gehört nicht /// hierher. /// /// Die Lizenz ist die eine Sperre, die er kennt (E-M3). Sie gilt der /// Installation und erlaubt beliebig viele Instanzen; ohne sie startet er keine. Und ein /// Widerruf ist kein Ausfall, sondern ein Urteil: Dann hält er die laufenden an — als /// einziger Fall, in dem er das von sich aus tut. /// /// Er hält am Laufen, was ihm anvertraut ist. Eine Instanz mit /// autostartByManager startet er beim Hochkommen und nach einem Absturz wieder — /// begrenzt durch , denn was an einer kaputten Konfiguration /// scheitert, scheitert beim zehnten Versuch genauso. Ohne den Schalter rührt er nichts /// an: Ein Manager, der nach einem Neustart der Maschine alles hochfährt, was er findet, /// startet auch das, was jemand mit Absicht angehalten hat. /// /// Was er nicht tut: Instanzen beim eigenen Ende beenden (E-M6: wer den /// Manager schließt, will den Manager schließen). /// public sealed class ManagerCore : IAsyncDisposable { private readonly ManagerSettingsStore _settings; private readonly IInstanceTokenSource _tokens; private readonly SupervisionReporter? _reporter; private readonly LicenseGate? _license; private readonly LicenseWatch? _watch; private readonly RestartLedger _restarts; private readonly ILogger _logger; /// /// Was die letzte Lizenzprüfung ergeben hat. /// /// Ohne Torwächter true: Ein Manager ohne hinterlegte Lizenzprüfung /// sperrt nichts — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand einen /// Schlüssel eintragen kann. /// public bool LicenseHolds { get; private set; } = true; public ManagerCore( ManagerSettingsStore settings, InstanceRegistry instances, IInstanceTokenSource tokens, ILoggerFactory loggerFactory, SupervisionReporter? reporter = null, LicenseGate? license = null, LicenseWatch? watch = null, RestartPolicy? restartPolicy = null, TimeProvider? clock = null) { _settings = settings; Instances = instances; _tokens = tokens; _reporter = reporter; _license = license; _watch = watch; _logger = loggerFactory.CreateLogger("ClawdDotNet.Manager"); _restarts = new RestartLedger(restartPolicy ?? RestartPolicy.Default, clock); // Jedes Vorkommnis geht ins Protokoll und, wenn es niemand bestellt hat, nach // draussen. Bewusst nicht abgewartet: Eine Aufsicht darf nicht auf einen Server // warten muessen, um weiter aufzupassen. Instances.Observed += ereignis => { if (_reporter is not null) _ = _reporter.ReportAsync(ereignis); // Nur beim Absturz, und das schliesst die verstummte Instanz ein: Die // Aufsicht beendet sie nach ihrer Frist und meldet das als Absturz - denn // gewollt hat es niemand. Ein zweiter Einstieg bei "Unresponsive" waere ein // zweiter Neustart fuer denselben Vorfall. if (ereignis.Kind == InstanceEventKind.Crashed) _ = RestartAfterCrashAsync(ereignis); }; if (_watch is not null) _watch.Revoked += HandleLicenseRevokedAsync; } public InstanceRegistry Instances { get; } public ManagerSettings Settings => _settings.Settings; // ─── Hochfahren ─── /// /// Prüft die Lizenz, sieht nach, was schon läuft, und übernimmt es. /// /// In dieser Reihenfolge. Erst die Lizenz, weil sie die Sperre ist. Dann /// übernehmen, nicht starten — was läuft, lief schon, und was aus war, bleibt aus, /// bis jemand es verlangt. /// /// false, wenn die Lizenz die Arbeit verbietet. public async Task StartAsync(CancellationToken ct = default) { if (_license is not null) { LicenseHolds = await _license.RunStartupCheckAsync(ct).ConfigureAwait(false); if (!LicenseHolds) { _logger.LogWarning( "Ohne gültige Lizenz startet der Instanzmanager keine Instanz."); // Trotzdem nachsehen: Laufende Instanzen gehoeren in die Liste, damit // jemand sie sieht - und damit sie sich anhalten lassen. await Instances.RefreshAsync(ct).ConfigureAwait(false); return false; } } _watch?.Start(); await Instances.RefreshAsync(ct).ConfigureAwait(false); var laufen = Instances.Instances.Count(i => i.Condition == InstanceCondition.Running); _logger.LogInformation( "Instanzmanager bereit: {Gesamt} Instanzen bekannt, {Laufend} laufen bereits.", Instances.Instances.Count, laufen); await StartAutostartInstancesAsync(ct).ConfigureAwait(false); return true; } /// /// Startet, was der Manager am Laufen halten soll und was gerade nicht läuft. /// /// Nach dem Übernehmen, nicht davor. Was schon läuft, wird nicht noch /// einmal gestartet — es lief schon, und ein zweiter Prozess auf derselben /// state.db wäre das Gegenteil von hilfreich. /// public async Task StartAutostartInstancesAsync(CancellationToken ct = default) { var offen = Instances.Instances .Where(i => i.AutostartByManager && i.Condition != InstanceCondition.Running) .ToList(); if (offen.Count == 0) return; _logger.LogInformation( "{Anzahl} Instanzen sind auf Autostart eingestellt und laufen nicht.", offen.Count); foreach (var instanz in offen) { // Der Zaehler gehoert dem Absturz, nicht dem Hochfahren: Ein frischer Manager // faengt bei null an. _restarts.Forget(instanz.Directory); var ergebnis = await StartInstanceAsync(instanz.Directory, ct).ConfigureAwait(false); if (ergebnis != StartRefusal.None) { _logger.LogWarning( "Autostart von {Name} nicht möglich: {Grund}", instanz.Name, ergebnis); } } } /// /// Startet eine abgestürzte Instanz wieder — wenn sie darauf eingestellt ist und die /// Grenze es zulässt. /// /// Der Sicherungsautomat ist der eigentliche Inhalt. Wieder hochzufahren /// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss. /// Nach Versuchen im Zeitfenster setzt der /// Manager aus und meldet es — die Instanz bleibt dann aus, bis jemand sie von Hand /// startet. /// /// Nur bei einem Absturz, nicht wenn eine Instanz verstummt ist. Eine /// stumme Instanz lebt noch; sie neu zu starten hieße, sie erst abzuschießen, und das /// ist eine eigene Entscheidung — sie steht als offene Frage im Konzept. /// /// Öffentlich, damit sich der Weg prüfen lässt, ohne einen echten Absturz /// herbeizuführen. /// public async Task RestartAfterCrashAsync(SupervisionEvent ereignis) { var instanz = Instances.Instances.FirstOrDefault( i => ClawdDotNet.Core.Storage.PathBoundary.Comparer.Equals( i.Directory, ereignis.InstanceDirectory)); if (instanz is not { AutostartByManager: true }) return; if (!_restarts.TryRecord(instanz.Directory, out var versuche)) { _logger.LogError( "Instanz {Name} ist nach {Versuche} Neustarts innerhalb von {Fenster} erneut " + "abgestürzt. Der Manager setzt aus — sie bleibt aus, bis jemand sie startet.", instanz.Name, versuche, _restarts.Policy.Window); return; } _logger.LogWarning( "Instanz {Name} wird nach einem Absturz wieder gestartet (Versuch {Nummer} von " + "{Grenze} innerhalb von {Fenster}).", instanz.Name, versuche, _restarts.Policy.MaxRestarts, _restarts.Policy.Window); try { await StartInstanceAsync(instanz.Directory).ConfigureAwait(false); } catch (Exception ex) { // Diese Methode laeuft ohne Aufrufer, der etwas auffangen koennte - sie haengt // an einem Ereignis. Was hier durchrutscht, waere sonst eine unbeobachtete // Ausnahme, und die nimmt im Zweifel den Manager mit. _logger.LogError(ex, "Neustart von {Name} fehlgeschlagen.", instanz.Name); } } // ─── Instanzen ─── /// /// Legt eine Instanz an und besorgt ihr gleich ihr eigenes Melde-Token. /// /// Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann /// zweifelsfrei niemandem gehört. Sie später zu schreiben, während der Instanzprozess /// läuft, hieße zwei Schreiber auf einer Datei. /// public async Task CreateInstanceAsync(string name, CancellationToken ct = default) { var verzeichnis = Instances.Create(name); await _tokens.EnsureTokenAsync(verzeichnis, ct).ConfigureAwait(false); return verzeichnis; } /// /// Startet eine Instanz — wenn die Lizenz es erlaubt. /// /// Das Token wird vorher besorgt, falls noch keines da ist: bei einer Instanz, /// die es vor dem Manager schon gab, oder wenn das Deploymentcenter beim Anlegen /// nicht erreichbar war. Danach ist die Datei dem Instanzprozess überlassen. /// public async Task StartInstanceAsync( string instanceDirectory, CancellationToken ct = default) { if (!LicenseHolds) { _logger.LogWarning( "Start von {Pfad} abgelehnt: keine gültige Lizenz.", instanceDirectory); return StartRefusal.NoLicense; } await _tokens.EnsureTokenAsync(instanceDirectory, ct).ConfigureAwait(false); return await Instances.StartAsync(instanceDirectory, ct).ConfigureAwait(false) ? StartRefusal.None : StartRefusal.DidNotStart; } public Task StopInstanceAsync(string instanceDirectory, CancellationToken ct = default) => Instances.StopAsync(instanceDirectory, ct); /// public Task FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default) => Instances.FocusAsync(instanceDirectory, ct); // ─── Das Urteil ─── /// /// Die Lizenz gilt nicht mehr. /// /// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält. Ein /// Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt beides, und nur ein /// Urteil kommt hier an. Sonst nähme ein Netzproblem sämtliche Installationen /// gleichzeitig mit. /// /// Öffentlich, weil sich das so prüfen lässt, ohne einen Lizenzserver zu /// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer /// ihn ausgelöst hat. /// public async Task HandleLicenseRevokedAsync(string grund) { LicenseHolds = false; _logger.LogError("Lizenz gilt nicht mehr: {Grund}", grund); await Instances.StopAllAsync($"Lizenz gilt nicht mehr: {grund}").ConfigureAwait(false); } public async ValueTask DisposeAsync() { if (_watch is not null) { _watch.Revoked -= HandleLicenseRevokedAsync; await _watch.DisposeAsync().ConfigureAwait(false); } // Die Instanzen laufen weiter (E-M6). await Instances.DisposeAsync().ConfigureAwait(false); } }