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:
Richard
2026-09-08 22:54:51 +02:00
co-authored by Claude Opus 5
parent 72b0764d1a
commit cb56175d20
18 changed files with 741 additions and 19 deletions
+226
View File
@@ -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);
}
}