Instanzmanager: Lizenz und Sub-Token wandern in den Manager (#528, E-M3)
Neues Projekt ClawdDotNet.Licensing. LicenseGate, LicenseWatch, LicenseInfo und ILicensePrompt sind aus ClawdDotNet.App dorthin gezogen, weil zwei Prozesse dieselbe Lizenzpruefung brauchen und keiner von beiden den anderen kennen darf. In Core konnte es nicht landen - dann zoege jedes der sechzehn Werkzeugprojekte das Deploymentcenter-SDK mit. Dabei ist die Kopplung an SettingsManager verschwunden. LicenseGate kennt jetzt nur ILicenseStore: Serveradresse lesen, Schluessel lesen und schreiben, speichern. Die Anwendung fuehrt ihn in Settings.json, der Manager in manager.json. Der Torwaechter prueft Schluessel; Dateien verwaltet er nicht. Auch die Anwendungsversion kommt als Parameter statt aus einem globalen Zugriff - diese Schicht weiss nicht, welche Anwendung sie benutzt. ManagerCore ist die Klammer: eigene Einstellungen, Lizenz, Register, Meldeweg. Drei Regeln: - Ohne gueltige Lizenz startet er keine Instanz. Laufende werden trotzdem in die Liste genommen - man soll sie sehen und anhalten koennen. - Ein Widerruf haelt alle an. Der einzige Fall, in dem der Manager das von sich aus tut. Ein Serverausfall gehoert ausdruecklich nicht dazu; das SDK trennt Urteil und gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst naehme ein Netzproblem saemtliche Installationen mit. - Ohne hinterlegte Lizenzpruefung sperrt er nichts, sonst gaebe es eine Henne-Ei-Sperre, bevor jemand einen Schluessel eintragen kann. Sub-Token je Instanz: Der Manager haelt das Master-Token und tauscht es beim ANLEGEN einer Instanz gegen ein eigenes (watchdog:ping, bugtracker:report, einzeln widerrufbar). Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann zweifelsfrei niemandem gehoert - sie spaeter zu schreiben, waehrend der Instanzprozess laeuft, hiesse zwei Schreiber auf einer Datei. Fuer Instanzen, die es vor dem Manager schon gab, wird es vor dem Start nachgeholt. Danach fragt er ueber whoami nach, ob das Token an ein Projekt gebunden ist (SDK 2.8.1). Ein fehlgeschlagener Tokenbezug haelt die Instanz nicht auf. NOCH NICHT VOLLSTAENDIG: Die Instanzen pruefen die Lizenz weiterhin selbst. Das laesst sich erst abschalten, wenn eine Instanz feststellen kann, ob ein Manager da ist - vorher entstuende eine Fassung ganz ohne Durchsetzung. Und ein Testfehler, der zweimal derselbe war: Die Socket-Antwort war hinterlegt, BEVOR der Manager hochfuhr. Damit sagt man "diese Instanz laeuft bereits", der Manager uebernimmt sie, der Starter wird nie angefasst - und der Test prueft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die das ausschliesst. Build ohne Warnungen, 774 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
<Project Path="src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Web/ClawdDotNet.Web.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
Verweis auf Avalonia oder WinForms ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Licensing\ClawdDotNet.Licensing.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Telegram\ClawdDotNet.Tools.Telegram.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Mail\ClawdDotNet.Tools.Mail.csproj" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using ClawdDotNet.Licensing;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Die Anwendungseinstellungen als Ablage für den Lizenzschlüssel.
|
||||
///
|
||||
/// <para>Der Torwächter kennt seit #528 (E-M3) nur noch <see cref="ILicenseStore"/> — er
|
||||
/// prüft Schlüssel und verwaltet keine Dateien. Diese Klasse ist die eine Zeile, die aus
|
||||
/// <c>Settings.json</c> eine solche Ablage macht; der Instanzmanager hat sein
|
||||
/// Gegenstück für <c>manager.json</c>.</para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Licensing</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Ein eigenes Projekt, weil zwei Prozesse dieselbe Lizenzpruefung brauchen und
|
||||
keiner von beiden den anderen kennen darf (#528, E-M3).
|
||||
|
||||
Der Instanzmanager wird sie fuehren: Eine Lizenz gilt der Installation und erlaubt
|
||||
beliebig viele Instanzen. Bis die Instanzen feststellen koennen, ob ein Manager da
|
||||
ist (offener Punkt in docs/Instanzmanager-Konzept.md, Abschnitt 7), pruefen sie
|
||||
weiter selbst - sonst entstuende zwischendurch eine Fassung ganz ohne Durchsetzung.
|
||||
|
||||
Warum nicht in Core: Dann zoege jedes der sechzehn Werkzeugprojekte das
|
||||
Deploymentcenter-SDK mit. Warum nicht in App: Das darf der Manager nicht kennen. -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<ProjectReference Include="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
namespace ClawdDotNet.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Wie der <see cref="LicenseGate"/> mit dem Benutzer spricht.
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ClawdDotNet.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Wo der Lizenzschlüssel liegt — und wo die Adresse steht, gegen die geprüft wird.
|
||||
///
|
||||
/// <para><b>Eine Schnittstelle, weil es zwei Ablagen gibt</b> (#528, E-M3). Die Anwendung
|
||||
/// führt den Schlüssel in <c>Settings.json</c>, der Instanzmanager in
|
||||
/// <c>manager.json</c>. Der Torwächter selbst soll von beidem nichts wissen: Er prüft
|
||||
/// Schlüssel, er verwaltet keine Dateien.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public interface ILicenseStore
|
||||
{
|
||||
/// <summary>Die Adresse des Deploymentcenters. Leer heißt: Vorgabe aus <see cref="LicenseInfo"/>.</summary>
|
||||
string ServerUrl { get; }
|
||||
|
||||
/// <summary>Der hinterlegte Schlüssel. Der Torwächter schreibt ihn zurück, wenn ein neuer eingegeben wurde.</summary>
|
||||
string LicenseKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt den Schlüssel fest.
|
||||
///
|
||||
/// <para>Darf werfen — der Aufrufer behandelt das als „gilt, ließ sich aber nicht
|
||||
/// sichern" und läuft weiter.</para>
|
||||
/// </summary>
|
||||
void Save();
|
||||
}
|
||||
+25
-14
@@ -1,8 +1,7 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
namespace ClawdDotNet.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
|
||||
@@ -27,27 +26,39 @@ namespace ClawdDotNet.App.Services;
|
||||
/// </summary>
|
||||
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)
|
||||
/// <param name="store">
|
||||
/// Wo Schlüssel und Serveradresse liegen. Eine Schnittstelle und keine
|
||||
/// Einstellungsklasse, weil zwei Prozesse sie führen: die Anwendung in
|
||||
/// <c>Settings.json</c>, der Instanzmanager in <c>manager.json</c> (E-M3).
|
||||
/// </param>
|
||||
/// <param name="appVersion">
|
||||
/// Landet in der Aktivierungsliste des Deploymentcenters. Als Parameter und nicht
|
||||
/// als globaler Zugriff: Diese Schicht weiß nicht, welche Anwendung sie benutzt.
|
||||
/// </param>
|
||||
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.
|
||||
/// </summary>
|
||||
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
|
||||
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
|
||||
=> ValidateAsync(_store.LicenseKey, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> 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)
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
namespace ClawdDotNet.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Fest hinterlegte Lizenz-Eckdaten.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
namespace ClawdDotNet.Licensing;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die Lizenz im laufenden Betrieb nach.
|
||||
@@ -20,6 +20,7 @@
|
||||
Werkzeugprojekt ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Licensing\ClawdDotNet.Licensing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>
|
||||
/// Sorgt dafür, dass eine Instanz ein eigenes Melde-Token hat.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public interface IInstanceTokenSource
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c>, wenn die Instanz danach ein eigenes Token hat — auch wenn sie schon
|
||||
/// eines hatte. <c>false</c> heißt: Es bleibt beim bisherigen Weg.
|
||||
/// </summary>
|
||||
Task<bool> EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Tut nichts. Für einen Manager ohne Deploymentcenter-Anbindung.</summary>
|
||||
public sealed class NoInstanceTokenSource : IInstanceTokenSource
|
||||
{
|
||||
public Task<bool> EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tauscht das Master-Token des Managers gegen ein Sub-Token je Instanz.
|
||||
///
|
||||
/// <para><b>Warum der Manager das macht und nicht die Instanz</b> (#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
|
||||
/// <c>watchdog:ping</c> und <c>bugtracker:report</c> und ist einzeln widerrufbar. Rechte
|
||||
/// lassen sich beim Ausstellen ohnehin nur einschränken, nie erweitern.</para>
|
||||
///
|
||||
/// <para><b>Nur vor dem Start.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Und danach wird nachgefragt.</b> 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 <b>null Befunde</b>. Die Aufsicht sähe aus wie „nichts auffällig". Ob die
|
||||
/// Bindung steht, sagt nur der Server (<c>whoami</c>) — also wird er gefragt.</para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterTokenSource(
|
||||
InstanceDirectoryManager directories,
|
||||
DeploymentcenterApi api,
|
||||
ILogger logger) : IInstanceTokenSource
|
||||
{
|
||||
public async Task<bool> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using ClawdDotNet.Licensing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>Die Ablage des Managers als Ort für den Lizenzschlüssel (E-M3).</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Warum eine Instanz nicht startet.</summary>
|
||||
public enum StartRefusal
|
||||
{
|
||||
/// <summary>Sie startet.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Keine gültige Lizenz. Eine Lizenz gilt der Installation, nicht der Instanz.</summary>
|
||||
NoLicense,
|
||||
|
||||
/// <summary>Der Prozess kam nicht hoch oder hat sich nicht gemeldet.</summary>
|
||||
DidNotStart
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Instanzmanager als Ganzes — ohne Fenster, ohne Weboberfläche, ohne Prozess.
|
||||
///
|
||||
/// <para><b>Was er zusammenhält:</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Die Lizenz ist die eine Sperre, die er kennt</b> (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.</para>
|
||||
///
|
||||
/// <para><b>Was er nicht tut:</b> 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.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Was die letzte Lizenzprüfung ergeben hat.
|
||||
///
|
||||
/// <para>Ohne Torwächter <c>true</c>: Ein Manager ohne hinterlegte Lizenzprüfung
|
||||
/// sperrt nichts — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand einen
|
||||
/// Schlüssel eintragen kann.</para>
|
||||
/// </summary>
|
||||
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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die Lizenz, sieht nach, was schon läuft, und übernimmt es.
|
||||
///
|
||||
/// <para><b>In dieser Reihenfolge.</b> 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.</para>
|
||||
/// </summary>
|
||||
/// <returns><c>false</c>, wenn die Lizenz die Arbeit verbietet.</returns>
|
||||
public async Task<bool> 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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Legt eine Instanz an und besorgt ihr gleich ihr eigenes Melde-Token.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public async Task<string> CreateInstanceAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var verzeichnis = Instances.Create(name);
|
||||
|
||||
await _tokens.EnsureTokenAsync(verzeichnis, ct).ConfigureAwait(false);
|
||||
|
||||
return verzeichnis;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet eine Instanz — wenn die Lizenz es erlaubt.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public async Task<StartRefusal> 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<bool> StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
=> Instances.StopAsync(instanceDirectory, ct);
|
||||
|
||||
// ─── Das Urteil ───
|
||||
|
||||
/// <summary>
|
||||
/// Die Lizenz gilt nicht mehr.
|
||||
///
|
||||
/// <para><b>Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.</b> 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.</para>
|
||||
///
|
||||
/// <para>Ö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.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>Zählt mit, wem ein Token besorgt wurde.</summary>
|
||||
internal sealed class FakeTokenSource : IInstanceTokenSource
|
||||
{
|
||||
public List<string> Fuer { get; } = [];
|
||||
|
||||
public bool Erfolg { get; set; } = true;
|
||||
|
||||
public Task<bool> EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
Fuer.Add(Path.GetFullPath(instanceDirectory));
|
||||
return Task.FromResult(Erfolg);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Instanzmanager als Ganzes (#528, Stufe 3).
|
||||
///
|
||||
/// <para><b>Die Lizenz wird hier ohne Lizenzserver geprüft</b>, 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
|
||||
/// <see cref="ManagerCore.HandleLicenseRevokedAsync"/>.</para>
|
||||
/// </summary>
|
||||
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. */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich
|
||||
/// gestartet und nicht bloß übernommen wurde.
|
||||
///
|
||||
/// <para><b>Der Unterschied ist keine Feinheit.</b> 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.</para>
|
||||
/// </summary>
|
||||
private async Task<StartRefusal> 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 ───
|
||||
|
||||
/// <summary>
|
||||
/// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user