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; /// /// Der Instanzmanager als Prozess (#528, Stufe 4). /// /// Ein Prozess je Maschine. 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. /// /// Und er beendet beim Herunterfahren keine Instanz (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. /// internal static class Program { private const int ExitOk = 0; private const int ExitNoWebUi = 2; /// Wo manager.json liegt, wenn nicht am üblichen Ort. private const string ConfigArgument = "--config"; public static async Task 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); logger.LogInformation( "Instanzen laufen {Art}. Neustart nach Absturz: hoechstens {Grenze} in {Fenster}.", settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster", settings.Settings.ToRestartPolicy().MaxRestarts, settings.Settings.ToRestartPolicy().Window); 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."); // Die Sicherungen aller Instanzen - verschluesselt mit dem Lizenzschluessel, auf // Wunsch zusaetzlich per FTP hinaus (#530). Nach dem Manager, weil sie dessen // Register braucht, und vor der Weboberflaeche, weil die sie bedient. var sicherungsLog = loggerFactory.CreateLogger("ClawdDotNet.Manager.Sicherung"); await using var backups = new ManagerBackups( settings, manager.Instances, new FtpBackupUploader(() => settings.Settings.BackupFtp, sicherungsLog), sicherungsLog); if (backups.LicenseKey is null) { logger.LogWarning( "Im Manager ist kein Lizenzschlüssel hinterlegt: Sicherungen lassen sich nicht " + "verschlüsseln und werden deshalb nicht angelegt — auch die täglichen nicht."); } backups.Start(); await using var web = ManagerWebHost.TryStart(manager, backups, 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(); Console.WriteLine("================================================="); Console.WriteLine(" ClawdDotNet Instanzmanager bereit"); Console.WriteLine($" Weboberfläche: {web.Url}"); if (string.IsNullOrWhiteSpace(settings.Settings.WebUiToken)) { Console.WriteLine(" Token: (kein Token erforderlich)"); } else if (Console.IsOutputRedirected) { // #588: Unter systemd geht die Standardausgabe ins Journal - dauerhaft und fuer // die Gruppen adm/systemd-journal lesbar. Dort gehoert der Generalschluessel // nicht hin. Wer ihn braucht, startet den Manager einmal auf einer Konsole. Console.WriteLine(" Token: (nur auf einer interaktiven Konsole angezeigt)"); } else { Console.WriteLine($" Token: {settings.Settings.WebUiToken}"); } Console.WriteLine("================================================="); Console.WriteLine(); // Das Token steht auf der Konsole, aber ausdruecklich NICHT im Protokoll: Es ist // der Generalschluessel zu allen Instanzen, und die Protokolldatei ist genau die, // die man an einen Fehlerbericht haengt. Vor der Konsole steht, wer den Manager // gestartet hat; die Datei liest hinterher irgendwer. 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), headless: !s.InstanceWindows); 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, s.ToRestartPolicy()); } /// /// Das Verzeichnis hinter --config, oder null für den üblichen Ort. /// /// Wozu, wenn es CLAWD_CONFIG_DIR gibt. 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 /// Settings.json an. Ein Argument sagt nur diesem einen Prozess, wo seine Datei /// liegt, und genau das ist gemeint. /// /// Für eine systemd-Unit ist es ohnehin der geradere Weg als eine /// Environment=-Zeile. /// 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; } /// /// Wie der Manager nach einem Lizenzschlüssel fragt: gar nicht. /// /// 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. Console.ReadLine 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. /// /// Genau so ist der erste Abnahmelauf ausgegangen: Der Prozess hing an /// „Lizenzschlüssel: ", die Weboberfläche kam nie hoch, und im Protokoll stand nichts /// davon — die Frage steht auf der Standardausgabe. /// /// Ein Test auf Console.IsInputRedirected reicht nicht. 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 manager.json, hineingeschrieben von der Einrichtung /// oder vom Fenster. /// private static ILicensePrompt LicensePrompt(ILogger logger) => new NonInteractiveLicensePrompt(text => logger.LogWarning("{Meldung}", text)); /// /// Wo die Programmdatei der Instanz liegt. /// /// 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. /// 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"; /// /// Wartet, bis jemand Schluss sagt. /// /// Das Signal wird abgefangen, nicht durchgelassen /// (context.Cancel = true). 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. /// 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; } }