- Engine: leere Antworten nachfassen, Aufgaben enden mit Ergebniszeile (EmptyResponse/Incomplete), gleiche Fehlschlaege erkennen (RepeatedFailure), Zeitzeile vor Chatnachrichten, Werkzeugergebnisse gerahmt, Vorpruefung vor dem Staging, Belegkosten wie RunUsage - Staging: gescheiterte Freigaben als Failed; AgentEditor-Aenderungen freigabepflichtig, Selbstbearbeitung gesperrt (allowSelfEdit) - Taskboard: Termine beim Anlegen pruefen, naechster/letzter Lauf, pausieren, Sofortlauf, Kostenprognose, Hinweis auf wachsenden Kontext; Jobs-Reiter und /api/jobs entfernt - Release: Instanzmanager samt Weboberflaeche im Paket, setup.json fragt manager.json ab, Token nicht mehr ins systemd-Journal Version 0.1.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
325 lines
14 KiB
C#
325 lines
14 KiB
C#
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);
|
|
|
|
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());
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|