diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index f56edb6..51678ec 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -20,6 +20,7 @@ + diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index be4298d..4ecff0e 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -499,3 +499,53 @@ in einem Job-Objekt, das seine Kinder sofort mitnimmt; der Instanzprozess starb bevor er die erste Protokollzeile schreiben konnte. Das ist ein Artefakt der Umgebung und keine Eigenschaft des Managers — aber es heißt, dass ein Abnahmetest dieser Art nicht in die gewöhnliche Testreihe gehört. + +### 9.4 Lizenz und Sub-Token im Manager + +**Ein neues Projekt: `ClawdDotNet.Licensing`.** `LicenseGate`, `LicenseWatch`, +`LicenseInfo` und `ILicensePrompt` sind aus `ClawdDotNet.App` dorthin gezogen. Der Grund +ist der Schnitt: Zwei Prozesse brauchen dieselbe Lizenzprüfung, und keiner von beiden darf +den anderen kennen. In `Core` konnte es nicht landen — dann zöge jedes der sechzehn +Werkzeugprojekte das Deploymentcenter-SDK mit. + +Dabei ist die Kopplung an `SettingsManager` verschwunden: `LicenseGate` kennt jetzt nur +`ILicenseStore` — Serveradresse lesen, Schlüssel lesen und schreiben, speichern. Die +Anwendung führt ihn in `Settings.json`, der Manager in `manager.json`. Der Torwächter prüft +Schlüssel; Dateien verwaltet er nicht. Die Anwendungsversion kommt als Parameter statt aus +einem globalen Zugriff — diese Schicht weiß nicht, welche Anwendung sie benutzt. + +**`ManagerCore` ist die Klammer:** eigene Einstellungen, Lizenz, Register, Meldeweg. Was +darüber liegt — ein Fenster, die eine Weboberfläche für alle — setzt darauf auf. + +- **Ohne gültige Lizenz startet er keine Instanz.** Laufende werden trotzdem in die Liste + genommen: Man soll sie sehen — und anhalten können. +- **Ein Widerruf hält alle an.** Der einzige Fall, in dem der Manager das von sich aus + tut. Ein Serverausfall gehört ausdrücklich nicht dazu; das SDK trennt Urteil und + gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein Netzproblem + sämtliche Installationen gleichzeitig mit. +- **Ohne hinterlegte Lizenzprüfung sperrt er nichts.** Sonst gäbe es eine Henne-Ei-Sperre, + bevor überhaupt jemand einen Schlüssel eintragen kann. + +**Sub-Token je Instanz.** Der Manager hält das Master-Token und tauscht es beim **Anlegen** +einer Instanz gegen ein eigenes — `watchdog:ping` und `bugtracker:report`, einzeln +widerrufbar. 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. Für Instanzen, die es vor dem Manager schon gab, wird +es vor dem Start nachgeholt. + +Danach wird nachgefragt (`whoami`), ob das neue Token an ein Projekt gebunden ist — seit +SDK 2.8.1 erbt ein Watchdog-Monitor das Projekt seines Ping-Tokens, und ohne Bindung landen +die Befunde im Scheinprojekt „default". Ein fehlgeschlagener Tokenbezug hält die Instanz +nicht auf: Monitoring, das nur bei perfekter Rechtelage läuft, ist genau dann still, wenn +man es braucht. + +**Was noch fehlt, damit E-M3 vollständig ist:** Die Instanzen prüfen die Lizenz weiterhin +selbst. Das lässt sich erst abschalten, wenn eine Instanz feststellen kann, ob ein Manager +da ist (Abschnitt 7) — vorher entstünde eine Fassung ganz ohne Durchsetzung. Bis dahin +führen beide ihren eigenen Schlüssel. + +**Ein Testfehler, der zweimal derselbe war.** In den Manager-Tests hatte ich die Antwort des +Sockets hinterlegt, *bevor* der Manager hochfuhr — damit sagt man „diese Instanz läuft +bereits", der Manager übernimmt sie (E-M6), der Starter wird nie angefasst, und der Test +prüft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die genau das +ausschließt. diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index c4e5a1b..08e72ab 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -30,6 +30,7 @@ using ClawdDotNet.Tools.WebFetch; using ClawdDotNet.Tools.WebMonitor; using Microsoft.Extensions.Logging; using ClawdDotNet.Core.Instances; +using ClawdDotNet.Licensing; namespace ClawdDotNet.App; @@ -269,8 +270,11 @@ public sealed class AppHost : IAsyncDisposable } // ─── 4. Lizenz ─── - var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"), - callbacks.License); + var license = new LicenseGate( + new SettingsLicenseStore(settings), + loggerFactory.CreateLogger("ClawdDotNet.License"), + callbacks.License, + ReleaseInfo.Version); if (!await license.RunStartupCheckAsync(ct)) { diff --git a/src/ClawdDotNet.App/ClawdDotNet.App.csproj b/src/ClawdDotNet.App/ClawdDotNet.App.csproj index ed7fc1c..c9aeb55 100644 --- a/src/ClawdDotNet.App/ClawdDotNet.App.csproj +++ b/src/ClawdDotNet.App/ClawdDotNet.App.csproj @@ -23,6 +23,7 @@ Verweis auf Avalonia oder WinForms ergaenzt, hat den Schnitt verletzt. --> + diff --git a/src/ClawdDotNet.App/Services/DeploymentcenterService.cs b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs index 92eb134..9276905 100644 --- a/src/ClawdDotNet.App/Services/DeploymentcenterService.cs +++ b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs @@ -3,6 +3,7 @@ using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Deploymentcenter; using ClawdDotNet.Core.Deploymentcenter.Watchdog; using Microsoft.Extensions.Logging; +using ClawdDotNet.Licensing; namespace ClawdDotNet.App.Services; diff --git a/src/ClawdDotNet.App/Settings/SettingsLicenseStore.cs b/src/ClawdDotNet.App/Settings/SettingsLicenseStore.cs new file mode 100644 index 0000000..428a52e --- /dev/null +++ b/src/ClawdDotNet.App/Settings/SettingsLicenseStore.cs @@ -0,0 +1,24 @@ +using ClawdDotNet.Licensing; + +namespace ClawdDotNet.App.Settings; + +/// +/// Die Anwendungseinstellungen als Ablage für den Lizenzschlüssel. +/// +/// Der Torwächter kennt seit #528 (E-M3) nur noch — er +/// prüft Schlüssel und verwaltet keine Dateien. Diese Klasse ist die eine Zeile, die aus +/// Settings.json eine solche Ablage macht; der Instanzmanager hat sein +/// Gegenstück für manager.json. +/// +public sealed class SettingsLicenseStore(SettingsManager settings) : ILicenseStore +{ + public string ServerUrl => settings.AppSettings.DeploymentcenterUrl; + + public string LicenseKey + { + get => settings.AppSettings.LicenseKey; + set => settings.AppSettings.LicenseKey = value; + } + + public void Save() => settings.Save(); +} diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs index 5014cb1..8eac663 100644 --- a/src/ClawdDotNet.Desktop/HeadlessRun.cs +++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using ClawdDotNet.App; using ClawdDotNet.App.Services; +using ClawdDotNet.Licensing; using ClawdDotNet.Web; using ClawdDotNet.Web.Endpoints; using Microsoft.Extensions.Logging; diff --git a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs index b483e7c..573fcd7 100644 --- a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs +++ b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs @@ -3,6 +3,7 @@ using Avalonia.Threading; using ClawdDotNet.App.Services; using ClawdDotNet.Desktop.ViewModels; using ClawdDotNet.Desktop.Views; +using ClawdDotNet.Licensing; namespace ClawdDotNet.Desktop.Services; diff --git a/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj new file mode 100644 index 0000000..458bebc --- /dev/null +++ b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + ClawdDotNet.Licensing + + + + + + + + + diff --git a/src/ClawdDotNet.App/Services/ILicensePrompt.cs b/src/ClawdDotNet.Licensing/ILicensePrompt.cs similarity index 99% rename from src/ClawdDotNet.App/Services/ILicensePrompt.cs rename to src/ClawdDotNet.Licensing/ILicensePrompt.cs index e8d3c07..4ba3b00 100644 --- a/src/ClawdDotNet.App/Services/ILicensePrompt.cs +++ b/src/ClawdDotNet.Licensing/ILicensePrompt.cs @@ -1,4 +1,4 @@ -namespace ClawdDotNet.App.Services; +namespace ClawdDotNet.Licensing; /// /// Wie der mit dem Benutzer spricht. diff --git a/src/ClawdDotNet.Licensing/ILicenseStore.cs b/src/ClawdDotNet.Licensing/ILicenseStore.cs new file mode 100644 index 0000000..17b3dc4 --- /dev/null +++ b/src/ClawdDotNet.Licensing/ILicenseStore.cs @@ -0,0 +1,31 @@ +namespace ClawdDotNet.Licensing; + +/// +/// Wo der Lizenzschlüssel liegt — und wo die Adresse steht, gegen die geprüft wird. +/// +/// Eine Schnittstelle, weil es zwei Ablagen gibt (#528, E-M3). Die Anwendung +/// führt den Schlüssel in Settings.json, der Instanzmanager in +/// manager.json. Der Torwächter selbst soll von beidem nichts wissen: Er prüft +/// Schlüssel, er verwaltet keine Dateien. +/// +/// Nach E-M3 gehört die Lizenz auf Dauer dem Manager — eine Lizenz gilt der +/// Installation und erlaubt beliebig viele Instanzen. Bis eine Instanz feststellen kann, +/// ob ein Manager da ist, führen beide ihren eigenen Schlüssel; sonst entstünde +/// zwischendurch eine Fassung ganz ohne Durchsetzung. +/// +public interface ILicenseStore +{ + /// Die Adresse des Deploymentcenters. Leer heißt: Vorgabe aus . + string ServerUrl { get; } + + /// Der hinterlegte Schlüssel. Der Torwächter schreibt ihn zurück, wenn ein neuer eingegeben wurde. + string LicenseKey { get; set; } + + /// + /// Schreibt den Schlüssel fest. + /// + /// Darf werfen — der Aufrufer behandelt das als „gilt, ließ sich aber nicht + /// sichern" und läuft weiter. + /// + void Save(); +} diff --git a/src/ClawdDotNet.App/Services/LicenseGate.cs b/src/ClawdDotNet.Licensing/LicenseGate.cs similarity index 88% rename from src/ClawdDotNet.App/Services/LicenseGate.cs rename to src/ClawdDotNet.Licensing/LicenseGate.cs index 4087d00..5df615a 100644 --- a/src/ClawdDotNet.App/Services/LicenseGate.cs +++ b/src/ClawdDotNet.Licensing/LicenseGate.cs @@ -1,8 +1,7 @@ -using ClawdDotNet.App.Settings; using Deploymentcenter.Client; using Microsoft.Extensions.Logging; -namespace ClawdDotNet.App.Services; +namespace ClawdDotNet.Licensing; /// /// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters. @@ -27,27 +26,39 @@ namespace ClawdDotNet.App.Services; /// public sealed class LicenseGate { - private readonly SettingsManager _settings; + private readonly ILicenseStore _store; private readonly ILogger _logger; private readonly ILicensePrompt _prompt; private readonly LicenseClient _client; private readonly string _serverUrl; + private readonly string _appVersion; private string? _hardwareId; - public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt) + /// + /// Wo Schlüssel und Serveradresse liegen. Eine Schnittstelle und keine + /// Einstellungsklasse, weil zwei Prozesse sie führen: die Anwendung in + /// Settings.json, der Instanzmanager in manager.json (E-M3). + /// + /// + /// Landet in der Aktivierungsliste des Deploymentcenters. Als Parameter und nicht + /// als globaler Zugriff: Diese Schicht weiß nicht, welche Anwendung sie benutzt. + /// + public LicenseGate( + ILicenseStore store, ILogger logger, ILicensePrompt prompt, string appVersion) { - _settings = settings; + _store = store; _logger = logger; _prompt = prompt; + _appVersion = appVersion; - _serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl) + _serverUrl = string.IsNullOrWhiteSpace(store.ServerUrl) ? LicenseInfo.DefaultServerUrl - : settings.AppSettings.DeploymentcenterUrl.Trim(); + : store.ServerUrl.Trim(); // Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das // trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist. - LicenseClient.DefaultAppVersion = ReleaseInfo.Version; + LicenseClient.DefaultAppVersion = appVersion; // Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze // von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der @@ -76,7 +87,7 @@ public sealed class LicenseGate /// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet. /// public Task RevalidateAsync(CancellationToken ct = default) - => ValidateAsync(_settings.AppSettings.LicenseKey, ct); + => ValidateAsync(_store.LicenseKey, ct); /// /// Startprüfung. Fragt über nach, bis eine nutzbare @@ -92,7 +103,7 @@ public sealed class LicenseGate return true; } - var key = _settings.AppSettings.LicenseKey; + var key = _store.LicenseKey; if (string.IsNullOrWhiteSpace(key)) { @@ -142,13 +153,13 @@ public sealed class LicenseGate result = await ValidateAsync(key, ct); } - _settings.AppSettings.LicenseKey = key; + _store.LicenseKey = key; try { - _settings.Save(); + _store.Save(); } - catch (SettingsPersistenceException ex) + catch (Exception ex) { // Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja — // beim nächsten Start wird eben erneut gefragt. @@ -183,7 +194,7 @@ public sealed class LicenseGate try { return await _client.ValidateAsync( - LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct); + LicenseInfo.ProductSlug, key, _serverUrl, _appVersion, ct); } catch (OperationCanceledException) { diff --git a/src/ClawdDotNet.App/Services/LicenseInfo.cs b/src/ClawdDotNet.Licensing/LicenseInfo.cs similarity index 98% rename from src/ClawdDotNet.App/Services/LicenseInfo.cs rename to src/ClawdDotNet.Licensing/LicenseInfo.cs index dd35388..80d5f1c 100644 --- a/src/ClawdDotNet.App/Services/LicenseInfo.cs +++ b/src/ClawdDotNet.Licensing/LicenseInfo.cs @@ -1,4 +1,4 @@ -namespace ClawdDotNet.App.Services; +namespace ClawdDotNet.Licensing; /// /// Fest hinterlegte Lizenz-Eckdaten. diff --git a/src/ClawdDotNet.App/Services/LicenseWatch.cs b/src/ClawdDotNet.Licensing/LicenseWatch.cs similarity index 98% rename from src/ClawdDotNet.App/Services/LicenseWatch.cs rename to src/ClawdDotNet.Licensing/LicenseWatch.cs index 7f3aa79..0e1619e 100644 --- a/src/ClawdDotNet.App/Services/LicenseWatch.cs +++ b/src/ClawdDotNet.Licensing/LicenseWatch.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Logging; -namespace ClawdDotNet.App.Services; +namespace ClawdDotNet.Licensing; /// /// Prüft die Lizenz im laufenden Betrieb nach. diff --git a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj index b50353c..d46f86a 100644 --- a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj +++ b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj @@ -20,6 +20,7 @@ Werkzeugprojekt ergaenzt, hat den Schnitt verletzt. --> + diff --git a/src/ClawdDotNet.Manager/InstanceTokenSource.cs b/src/ClawdDotNet.Manager/InstanceTokenSource.cs new file mode 100644 index 0000000..d14ac20 --- /dev/null +++ b/src/ClawdDotNet.Manager/InstanceTokenSource.cs @@ -0,0 +1,126 @@ +using ClawdDotNet.Core.Deploymentcenter; +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager; + +/// +/// Sorgt dafür, dass eine Instanz ein eigenes Melde-Token hat. +/// +/// Als Schnittstelle, damit das Register geprüft werden kann, ohne mit einem Server +/// zu sprechen — und damit ein Manager ohne Deploymentcenter-Anbindung einfach keinen +/// bekommt statt einen kaputten. +/// +public interface IInstanceTokenSource +{ + /// + /// true, wenn die Instanz danach ein eigenes Token hat — auch wenn sie schon + /// eines hatte. false heißt: Es bleibt beim bisherigen Weg. + /// + Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default); +} + +/// Tut nichts. Für einen Manager ohne Deploymentcenter-Anbindung. +public sealed class NoInstanceTokenSource : IInstanceTokenSource +{ + public Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default) + => Task.FromResult(false); +} + +/// +/// Tauscht das Master-Token des Managers gegen ein Sub-Token je Instanz. +/// +/// Warum der Manager das macht und nicht die Instanz (#528, E-M3): Er hält +/// das Master-Token, sie nicht. Nach dem Umbau liegt auf keiner Instanz mehr ein +/// Schlüssel, mit dem sich weitere ausstellen ließen — jedes Sub-Token kann nur +/// watchdog:ping und bugtracker:report und ist einzeln widerrufbar. Rechte +/// lassen sich beim Ausstellen ohnehin nur einschränken, nie erweitern. +/// +/// Nur vor dem Start. Hier wird die Instanzdatei geschrieben, und die gehört +/// dem Instanzprozess, sobald er läuft. Zwei Schreiber auf einer Datei sind keine +/// Verlangsamung, sondern ein verlorener Wert — deshalb geschieht das ausschließlich, +/// solange die Instanz aus ist. +/// +/// Und danach wird nachgefragt. Seit SDK 2.8.1 erbt ein Watchdog-Monitor das +/// Projekt seines Ping-Tokens; ohne Bindung landen die Befunde im Scheinprojekt „default", +/// und eine Abfrage nach dem eigenen Projekt liefert für eine laufende, fleißig meldende +/// Instanz null Befunde. Die Aufsicht sähe aus wie „nichts auffällig". Ob die +/// Bindung steht, sagt nur der Server (whoami) — also wird er gefragt. +/// +public sealed class DeploymentcenterTokenSource( + InstanceDirectoryManager directories, + DeploymentcenterApi api, + ILogger logger) : IInstanceTokenSource +{ + public async Task EnsureTokenAsync( + string instanceDirectory, CancellationToken ct = default) + { + try + { + var config = directories.LoadInstanceConfig(instanceDirectory); + + if (config.Watchdog.HasToken) + return true; // Hat schon eines. Ein zweites waere nur ein zweiter Eintrag. + + var provisioner = new TokenProvisioner(api); + + var token = await provisioner.ProvisionAsync( + clientName: $"ClawdDotNet {config.InstanceName}", + instanceId: config.InstanceId, + scopes: TokenProvisioner.InstanceScopes, + ct: ct).ConfigureAwait(false); + + config.Watchdog.AgentToken = token.Token; + directories.SaveInstanceConfig(instanceDirectory, config); + + logger.LogInformation( + "Instanz {Name} hat ein eigenes Melde-Token bekommen ({TokenId}, Rechte: {Scopes}).", + config.InstanceName, token.TokenId, string.Join(", ", token.Scopes)); + + await WarnIfUnboundAsync(token.Token, config.InstanceName, ct).ConfigureAwait(false); + + return true; + } + catch (Exception ex) + { + // Haeufigster Fall: Das Token des Managers ist selbst ein Sub-Token und darf + // keine weiteren ausstellen. Kein Grund, die Instanz nicht zu starten - + // Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still, + // wenn man es braucht. + logger.LogInformation( + "Kein eigenes Token für {Pfad} beziehbar ({Grund}) — die Instanz meldet " + + "mit dem, was sie hat.", instanceDirectory, ex.Message); + + return false; + } + } + + private async Task WarnIfUnboundAsync(string token, string name, CancellationToken ct) + { + try + { + using var eigene = new DeploymentcenterApi(api.BaseUrl, token); + var identity = await new TokenProvisioner(eigene).WhoAmIAsync(ct).ConfigureAwait(false); + + if (identity.IsProjectBound) + { + logger.LogInformation( + "Meldungen der Instanz {Name} gehen an Projekt {Projekt}.", + name, identity.ProjectSlug); + + return; + } + + logger.LogWarning( + "Das Melde-Token der Instanz {Name} ({TokenId}) ist an kein Projekt gebunden. " + + "Ihre Watchdog-Befunde landen im Scheinprojekt \"default\" und tauchen in " + + "der Projektansicht nicht auf.", name, identity.TokenId); + } + catch (Exception ex) + { + // Aelterer Server ohne whoami, kein Netz, kein Recht: Das ist eine Auskunft, + // die ausfaellt, und kein Grund, das eben bezogene Token zu verwerfen. + logger.LogDebug(ex, "Token-Auskunft für Instanz {Name} nicht verfügbar.", name); + } + } +} diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs new file mode 100644 index 0000000..28aff16 --- /dev/null +++ b/src/ClawdDotNet.Manager/ManagerCore.cs @@ -0,0 +1,226 @@ +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. +/// +/// Was er nicht tut: Instanzen von selbst starten (das täte nach einem +/// Neustart der Maschine auch das, was jemand mit Absicht angehalten hat), Instanzen beim +/// eigenen Ende beenden (E-M6: wer den Manager schließt, will den Manager schließen), und +/// abgestürzte Instanzen wieder hochfahren — dazu ist keine Entscheidung getroffen. +/// +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 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) + { + _settings = settings; + Instances = instances; + _tokens = tokens; + _reporter = reporter; + _license = license; + _watch = watch; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Manager"); + + // 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); + }; + + 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); + + return true; + } + + // ─── 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); + + // ─── 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); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs new file mode 100644 index 0000000..39247b5 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs @@ -0,0 +1,219 @@ +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// Zählt mit, wem ein Token besorgt wurde. +internal sealed class FakeTokenSource : IInstanceTokenSource +{ + public List Fuer { get; } = []; + + public bool Erfolg { get; set; } = true; + + public Task EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default) + { + Fuer.Add(Path.GetFullPath(instanceDirectory)); + return Task.FromResult(Erfolg); + } +} + +/// +/// Der Instanzmanager als Ganzes (#528, Stufe 3). +/// +/// Die Lizenz wird hier ohne Lizenzserver geprüft, und das ist Absicht: Die +/// Zusage lautet nicht „der Torwächter funktioniert" — das ist die Aufgabe des SDK —, +/// sondern „ein Urteil führt zum Anhalten aller Instanzen, ein Ausfall nicht". Genau +/// dieser Weg wird hier gegangen, ausgelöst über +/// . +/// +public sealed class ManagerCoreTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "core-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _kanal = new(); + private readonly FakeTokenSource _tokens = new(); + 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 ManagerCoreTests() + { + Directory.CreateDirectory(_root); + _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + } + + private ManagerCore Manager() + { + var register = new InstanceRegistry( + _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); + + return new ManagerCore( + new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance); + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + /// + /// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich + /// gestartet und nicht bloß übernommen wurde. + /// + /// Der Unterschied ist keine Feinheit. Wer die Antwort schon vor dem + /// Hochfahren des Managers hinterlegt, sagt damit „diese Instanz läuft bereits" — der + /// Manager übernimmt sie dann (E-M6), der Starter wird nie angefasst, und der Test + /// prüft etwas anderes, als er behauptet. Genau das ist mir hier zweimal + /// passiert. + /// + private async Task StarteUndPruefe(ManagerCore manager, string pfad, string name) + { + var vorher = _launcher.Starts; + + _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", name, "0.1.2"); + + var ergebnis = await manager.StartInstanceAsync(pfad); + + _launcher.Starts.ShouldBe(vorher + 1, "Gestartet, nicht uebernommen."); + return ergebnis; + } + + // ─── Hochfahren ─── + + [Fact] + public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager() + { + // Kein Torwaechter heisst keine Sperre - sonst gaebe es eine Henne-Ei-Sperre, + // bevor ueberhaupt jemand einen Schluessel eintragen kann. + await using var manager = Manager(); + + (await manager.StartAsync()).ShouldBeTrue(); + manager.LicenseHolds.ShouldBeTrue(); + } + + [Fact] + public async Task Beim_Hochfahren_wird_nichts_gestartet() + { + _verzeichnisse.CreateInstance("Alpha"); + + await using var manager = Manager(); + await manager.StartAsync(); + + manager.Instances.Instances.Count.ShouldBe(1); + _launcher.Starts.ShouldBe(0); + } + + // ─── Token ─── + + [Fact] + public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token() + { + // Beim Anlegen und nicht beim ersten Start: Dann gehoert die Instanzdatei + // zweifelsfrei niemandem. + await using var manager = Manager(); + + var pfad = await manager.CreateInstanceAsync("Frisch"); + + _tokens.Fuer.ShouldBe([Path.GetFullPath(pfad)]); + } + + [Fact] + public async Task Vor_dem_Start_wird_das_Token_nachgeholt() + { + // Der Fall einer Instanz, die es vor dem Manager schon gab - oder bei der das + // Deploymentcenter beim Anlegen nicht erreichbar war. + var pfad = _verzeichnisse.CreateInstance("Alt"); + + await using var manager = Manager(); + await manager.StartAsync(); + + (await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None); + + _tokens.Fuer.ShouldContain(Path.GetFullPath(pfad)); + } + + [Fact] + public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht() + { + // Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still, + // wenn man es braucht. + _tokens.Erfolg = false; + + var pfad = _verzeichnisse.CreateInstance("Alt"); + + await using var manager = Manager(); + await manager.StartAsync(); + + (await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None); + } + + // ─── Das Urteil ─── + + /// + /// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält. + /// + /// Ein Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt Urteil und + /// gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein + /// Netzproblem sämtliche Installationen gleichzeitig mit. + /// + [Fact] + public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an() + { + var pfad = _verzeichnisse.CreateInstance("Alpha"); + + await using var manager = Manager(); + await manager.StartAsync(); + await StarteUndPruefe(manager, pfad, "Alpha"); + + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + + // Die Instanz endet auf die Bitte hin. + _launcher.Last!.Exit(0); + _kanal.AnswerFor[Path.GetFullPath(pfad)] = null; + + await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller"); + + manager.LicenseHolds.ShouldBeFalse(); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); + _kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen."); + } + + [Fact] + public async Task Nach_einem_Widerruf_startet_keine_Instanz_mehr() + { + var pfad = _verzeichnisse.CreateInstance("Alpha"); + + await using var manager = Manager(); + await manager.StartAsync(); + + await manager.HandleLicenseRevokedAsync("Widerrufen"); + + (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.NoLicense); + + _launcher.Starts.ShouldBe(0); + _tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht."); + } + + // ─── Wenn der Start scheitert ─── + + [Fact] + public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet() + { + var pfad = _verzeichnisse.CreateInstance("Stumm"); + + await using var manager = Manager(); + await manager.StartAsync(); + + // Der Prozess laeuft, die Instanz meldet sich nie. + (await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart); + } +}