Instanzmanager Stufe 4: eine Adresse, ein Zugang, alle Instanzen (#528, E-M5)
Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung: Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten. Beim Herunterfahren beendet er keine Instanz. Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche - ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt. wwwroot ist aus der Instanz in den Manager gezogen. DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api geht unveraendert an eine Instanz. Er leitet weiter, er implementiert nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine abgelehnte. Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht der Weboberflaeche zum Umschreiben gezwungen. ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token 200, Instanz ueber den Manager gestartet und angehalten, /api/info und /api/status unverfaelscht durchgereicht, /events/logs mit "instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen bereits" - uebernommen. E-M6 damit in beide Richtungen belegt. DREI DEFEKTE, die erst der Lauf gezeigt hat: 1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht. 2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt. 3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt sein Verzeichnis jetzt ueber --config. SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts, die dasselbe brauchen, und sie duerfen einander nicht kennen. NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen - Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an wwwroot. Und webServerPort steht noch in der Instanz. Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using ClawdDotNet.Licensing;
|
||||
using ClawdDotNet.Manager;
|
||||
using ClawdDotNet.Manager.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.ManagerApp;
|
||||
|
||||
/// <summary>
|
||||
/// Der Instanzmanager als Prozess (#528, Stufe 4).
|
||||
///
|
||||
/// <para><b>Ein Prozess je Maschine.</b> Er startet Instanzen, beaufsichtigt sie, meldet
|
||||
/// stellvertretend, was sie nicht mehr melden können, und trägt die eine Weboberfläche für
|
||||
/// alle. Er hält keine Datenbank, führt keine Werkzeuge aus und kennt keine Fachlogik —
|
||||
/// das alles läuft in den Instanzprozessen.</para>
|
||||
///
|
||||
/// <para><b>Und er beendet beim Herunterfahren keine Instanz</b> (E-M6). Wer den Manager
|
||||
/// schließt, will den Manager schließen. Läuft er wieder an, findet er die laufenden über
|
||||
/// ihren Socket wieder. Das ist der Grund, warum er nicht der Einzelpunkt ist, gegen den
|
||||
/// dieses Vorhaben gebaut wurde.</para>
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const int ExitOk = 0;
|
||||
private const int ExitNoWebUi = 2;
|
||||
|
||||
/// <summary>Wo <c>manager.json</c> liegt, wenn nicht am üblichen Ort.</summary>
|
||||
private const string ConfigArgument = "--config";
|
||||
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var settings = new ManagerSettingsStore(ReadConfigDirectory(args));
|
||||
settings.Load();
|
||||
|
||||
var logDirectory = Path.Combine(
|
||||
ClawdDotNet.Core.Storage.AppPaths.DataDirectory, "Logs", "Manager");
|
||||
|
||||
using var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(
|
||||
logDirectory, ClawdDotNet.Core.Logging.LogLevel.Info);
|
||||
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
|
||||
|
||||
logger.LogInformation("Instanzmanager startet.");
|
||||
logger.LogInformation("Einstellungen: {Pfad}", settings.SettingsPath);
|
||||
|
||||
if (settings.LoadProblem is { } problem)
|
||||
{
|
||||
logger.LogError(
|
||||
"Die Einstellungen unter {Pfad} liessen sich nicht lesen ({Grund}). "
|
||||
+ "Der Manager arbeitet mit Vorgaben — Instanzverzeichnis, Port und Lizenz "
|
||||
+ "stehen damit anders, als in der Datei steht.",
|
||||
settings.SettingsPath, problem);
|
||||
}
|
||||
|
||||
logger.LogInformation("Protokoll: {Pfad}", logDirectory);
|
||||
logger.LogInformation("Instanzen: {Pfad}", settings.Settings.InstancesDirectory);
|
||||
|
||||
// Was tatsaechlich gilt, nicht was in der Datei steht: Beim Abnahmelauf hat eine
|
||||
// halbe Stunde gefehlt, weil beides auseinanderlief und nichts es sagte.
|
||||
logger.LogInformation(
|
||||
"Weboberfläche: Port {Port}, Bindung {Bindung}, Token {Token}. "
|
||||
+ "Instanz-Programmdatei: {Exe}",
|
||||
settings.Settings.WebUiPort,
|
||||
settings.Settings.WebUiBinding,
|
||||
string.IsNullOrWhiteSpace(settings.Settings.WebUiToken)
|
||||
? "fehlt"
|
||||
: $"{settings.Settings.WebUiToken.Length} Zeichen",
|
||||
string.IsNullOrWhiteSpace(settings.Settings.InstanceExecutable)
|
||||
? "(neben dem Manager)"
|
||||
: settings.Settings.InstanceExecutable);
|
||||
|
||||
await using var manager = Build(settings, loggerFactory, logger);
|
||||
|
||||
if (!await manager.StartAsync())
|
||||
logger.LogWarning("Der Manager läuft ohne gültige Lizenz — er startet keine Instanz.");
|
||||
|
||||
await using var web = ManagerWebHost.TryStart(manager, loggerFactory);
|
||||
|
||||
if (web is null)
|
||||
{
|
||||
// Ohne Weboberflaeche hat dieser Prozess keinen Zugang: Ein Fenster hat er
|
||||
// noch nicht (Stufe 5), und ohne Zugang laesst sich nichts starten und nichts
|
||||
// ansehen. Dann ist Beenden ehrlicher als Weiterlaufen.
|
||||
logger.LogError(
|
||||
"Kein Zugang zum Manager: Die Weboberfläche ist nicht hochgekommen. "
|
||||
+ "Ohne sie lässt sich nichts bedienen — der Manager beendet sich.");
|
||||
|
||||
return ExitNoWebUi;
|
||||
}
|
||||
|
||||
Console.WriteLine(web.Url);
|
||||
logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url);
|
||||
|
||||
await WaitForShutdownAsync(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Instanzmanager wird beendet. Laufende Instanzen laufen weiter.");
|
||||
|
||||
return ExitOk;
|
||||
}
|
||||
|
||||
private static ManagerCore Build(
|
||||
ManagerSettingsStore settings, ILoggerFactory loggerFactory, ILogger logger)
|
||||
{
|
||||
var s = settings.Settings;
|
||||
|
||||
var directories = new InstanceDirectoryManager(
|
||||
Path.GetFullPath(s.InstancesDirectory));
|
||||
|
||||
var launcher = new ProcessInstanceLauncher(ResolveInstanceExecutable(s, logger));
|
||||
var channel = new SocketInstanceChannel();
|
||||
|
||||
var registry = new InstanceRegistry(
|
||||
directories, launcher, channel, loggerFactory, s.ToSupervisionOptions());
|
||||
|
||||
// ─── Deploymentcenter: Sub-Token und stellvertretende Meldung ───
|
||||
IInstanceTokenSource tokens = new NoInstanceTokenSource();
|
||||
SupervisionReporter? reporter = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken))
|
||||
{
|
||||
var api = new DeploymentcenterApi(s.DeploymentcenterUrl, s.DeploymentcenterMasterToken);
|
||||
|
||||
tokens = new DeploymentcenterTokenSource(
|
||||
directories, api, loggerFactory.CreateLogger("ClawdDotNet.Manager.Token"));
|
||||
|
||||
reporter = new SupervisionReporter(
|
||||
new BugtrackerClient(api, LicenseInfo.ProductSlug, "production", Version()),
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Manager.Meldung"));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Kein Deploymentcenter-Token hinterlegt: keine Sub-Token, keine "
|
||||
+ "stellvertretenden Meldungen. Befunde stehen im Protokoll.");
|
||||
}
|
||||
|
||||
// ─── Lizenz (E-M3) ───
|
||||
LicenseGate? gate = null;
|
||||
LicenseWatch? watch = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(s.LicenseKey) || !string.IsNullOrWhiteSpace(s.DeploymentcenterUrl))
|
||||
{
|
||||
gate = new LicenseGate(
|
||||
new ManagerLicenseStore(settings),
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"),
|
||||
LicensePrompt(logger),
|
||||
Version());
|
||||
|
||||
watch = new LicenseWatch(gate, loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"));
|
||||
}
|
||||
|
||||
return new ManagerCore(settings, registry, tokens, loggerFactory, reporter, gate, watch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Verzeichnis hinter <c>--config</c>, oder <c>null</c> für den üblichen Ort.
|
||||
///
|
||||
/// <para><b>Wozu, wenn es <c>CLAWD_CONFIG_DIR</c> gibt.</b> Weil eine Umgebungsvariable
|
||||
/// an Kindprozesse vererbt wird und ein Argument nicht. Der Manager startet Instanzen;
|
||||
/// mit der Variablen läse jede von ihnen ihre Anwendungseinstellungen aus dem
|
||||
/// Verzeichnis des Managers — nachgemessen, sie legt sich dort eine eigene
|
||||
/// <c>Settings.json</c> an. Ein Argument sagt nur diesem einen Prozess, wo seine Datei
|
||||
/// liegt, und genau das ist gemeint.</para>
|
||||
///
|
||||
/// <para>Für eine systemd-Unit ist es ohnehin der geradere Weg als eine
|
||||
/// <c>Environment=</c>-Zeile.</para>
|
||||
/// </summary>
|
||||
private static string? ReadConfigDirectory(string[] args)
|
||||
{
|
||||
for (var i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(args[i], ConfigArgument, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wie der Manager nach einem Lizenzschlüssel fragt: <b>gar nicht.</b>
|
||||
///
|
||||
/// <para>Er ist ein Hintergrundprozess. Er wird von einem Dienst gestartet, von einem
|
||||
/// Skript oder von einem Fenster, das es noch nicht gibt (Stufe 5) — in keinem dieser
|
||||
/// Fälle sitzt jemand an seiner Konsole. <c>Console.ReadLine</c> steht dort für immer,
|
||||
/// ohne dass jemand die Frage je zu sehen bekommt, und weil der Manager die Instanzen
|
||||
/// startet, steht damit die ganze Maschine, nicht bloss eine Anwendung.</para>
|
||||
///
|
||||
/// <para><b>Genau so ist der erste Abnahmelauf ausgegangen:</b> Der Prozess hing an
|
||||
/// „Lizenzschlüssel: ", die Weboberfläche kam nie hoch, und im Protokoll stand nichts
|
||||
/// davon — die Frage steht auf der Standardausgabe.</para>
|
||||
///
|
||||
/// <para><b>Ein Test auf <c>Console.IsInputRedirected</c> reicht nicht.</b> Er
|
||||
/// beantwortet „ist die Eingabe umgeleitet", nicht „kann jemand antworten": Ein
|
||||
/// Prozess ganz ohne Konsole — der gefährlichste Fall — ist nicht umgeleitet. Wo die
|
||||
/// falsche Antwort einen Aufpasser für immer anhält, ist die Frage die falsche Frage.
|
||||
/// Ein Schlüssel gehört in <c>manager.json</c>, hineingeschrieben von der Einrichtung
|
||||
/// oder vom Fenster.</para>
|
||||
/// </summary>
|
||||
private static ILicensePrompt LicensePrompt(ILogger logger)
|
||||
=> new NonInteractiveLicensePrompt(text => logger.LogWarning("{Meldung}", text));
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Programmdatei der Instanz liegt.
|
||||
///
|
||||
/// <para>Ohne Eintrag: neben der des Managers. Das ist der Normalfall — beide kommen
|
||||
/// aus demselben Paket —, und ein Eintrag ist die Ausnahme für eine Entwicklungsablage,
|
||||
/// in der die beiden auseinanderliegen.</para>
|
||||
/// </summary>
|
||||
private static string ResolveInstanceExecutable(ManagerSettings settings, ILogger logger)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settings.InstanceExecutable))
|
||||
return Path.GetFullPath(settings.InstanceExecutable);
|
||||
|
||||
var name = OperatingSystem.IsWindows() ? "ClawdDotNet.exe" : "ClawdDotNet";
|
||||
var daneben = Path.Combine(AppContext.BaseDirectory, name);
|
||||
|
||||
if (!File.Exists(daneben))
|
||||
{
|
||||
// Nicht abbrechen: Der Manager kann trotzdem beaufsichtigen, was schon laeuft,
|
||||
// und die Weboberflaeche zeigt es an. Nur starten kann er dann nichts - und
|
||||
// das soll man wissen, bevor man es versucht.
|
||||
logger.LogWarning(
|
||||
"Die Programmdatei der Instanzen liegt nicht unter {Pfad}. Ohne sie lässt "
|
||||
+ "sich keine Instanz starten (instanceExecutable in manager.json).", daneben);
|
||||
}
|
||||
|
||||
return daneben;
|
||||
}
|
||||
|
||||
private static string Version()
|
||||
=> typeof(Program).Assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Wartet, bis jemand Schluss sagt.
|
||||
///
|
||||
/// <para><b>Das Signal wird abgefangen, nicht durchgelassen</b>
|
||||
/// (<c>context.Cancel = true</c>). Sonst räumte die Laufzeit den Prozess ab, während
|
||||
/// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer des
|
||||
/// Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529, nur diesmal
|
||||
/// beim Aufpasser.</para>
|
||||
/// </summary>
|
||||
private static async Task WaitForShutdownAsync(ILogger logger)
|
||||
{
|
||||
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void Beenden(string grund)
|
||||
{
|
||||
if (schluss.TrySetResult())
|
||||
logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
|
||||
}
|
||||
|
||||
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
Beenden("SIGTERM");
|
||||
});
|
||||
|
||||
using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
|
||||
{
|
||||
context.Cancel = true;
|
||||
Beenden("SIGINT");
|
||||
});
|
||||
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Beenden("ProcessExit");
|
||||
|
||||
await schluss.Task;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user