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:
@@ -22,6 +22,8 @@
|
|||||||
<Project Path="src/ClawdDotNet.Web/ClawdDotNet.Web.csproj" />
|
<Project Path="src/ClawdDotNet.Web/ClawdDotNet.Web.csproj" />
|
||||||
<Project Path="src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj" />
|
<Project Path="src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj" />
|
||||||
<Project Path="src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj" />
|
<Project Path="src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj" />
|
||||||
|
<Project Path="src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj" />
|
||||||
|
<Project Path="src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClawdDotNet.App.Tests/ClawdDotNet.App.Tests.csproj" />
|
<Project Path="tests/ClawdDotNet.App.Tests/ClawdDotNet.App.Tests.csproj" />
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis.
|
|||||||
| 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll |
|
| 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll |
|
||||||
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
|
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
|
||||||
| 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
|
| 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
|
||||||
| 4 | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
|
| 4 ◑ | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
|
||||||
| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
|
| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
|
||||||
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
|
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
|
||||||
|
|
||||||
@@ -549,3 +549,65 @@ Sockets hinterlegt, *bevor* der Manager hochfuhr — damit sagt man „diese Ins
|
|||||||
bereits", der Manager übernimmt sie (E-M6), der Starter wird nie angefasst, und der Test
|
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
|
prüft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die genau das
|
||||||
ausschließt.
|
ausschließt.
|
||||||
|
|
||||||
|
### 9.5 Was Stufe 4 gebracht hat
|
||||||
|
|
||||||
|
**Den Managerprozess.** `ClawdDotNet.Instanzmanager` ist eine Konsolenanwendung: Sie prüft
|
||||||
|
die Lizenz, übernimmt, was läuft, hängt die Weboberfläche auf und wartet. Sie beendet beim
|
||||||
|
Herunterfahren keine Instanz.
|
||||||
|
|
||||||
|
**Zwei neue Projekte.** `ClawdDotNet.Manager.Web` trägt die Weboberfläche — ohne jeden
|
||||||
|
Verweis auf `ClawdDotNet.Web`, denn dort hängt `App` und daran sechzehn Werkzeugprojekte.
|
||||||
|
`ClawdDotNet.ManagerApp` ist der Einstiegspunkt. `wwwroot` ist aus der Instanz in den
|
||||||
|
Manager gezogen (Abschnitt 8).
|
||||||
|
|
||||||
|
**Der Schnitt der HTTP-Fläche.** Unter `/api/verwaltung` steht, was den Manager angeht:
|
||||||
|
Instanzen auflisten, anlegen, starten, anhalten. **Alles andere unter `/api` geht
|
||||||
|
unverändert an eine Instanz** — er leitet weiter, er implementiert nicht. Welche Instanz
|
||||||
|
gemeint ist, sagt die Kopfzeile `X-Instanz`; läuft genau eine, ist sie gemeint, und laufen
|
||||||
|
mehrere, wird nachgefragt statt geraten. Eine Anfrage an die falsche Instanz ist schlimmer
|
||||||
|
als eine abgelehnte.
|
||||||
|
|
||||||
|
**Der zusammengeführte Ereignisstrom.** Der Manager hält je laufender Instanz eine
|
||||||
|
Zuleitung und schreibt an jedes Ereignis, von wem es kommt (`instanz`, `instanzName`).
|
||||||
|
Ergänzt statt verpackt: Ein Umschlag wäre einfacher gewesen, hätte aber jede Ansicht der
|
||||||
|
Weboberfläche zum Umschreiben gezwungen.
|
||||||
|
|
||||||
|
**Abnahme, gegen laufende Prozesse gemessen:**
|
||||||
|
|
||||||
|
| Geprüft | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| `/api/verwaltung/instanzen` ohne Token | 401 |
|
||||||
|
| Oberfläche ohne Token | 200 — die leere Hülle, wie vorgesehen |
|
||||||
|
| Instanz über den Manager starten | `Running`, PID 18104 |
|
||||||
|
| `/api/info` durchgereicht | Antwort **der Instanz** (`instanceId d68af6fd`) |
|
||||||
|
| `/api/status` durchgereicht | 503 der Instanz, unverfälscht |
|
||||||
|
| `/events/logs` | Zeilen der Instanz, mit `"instanz":"Instance-Test"` daran |
|
||||||
|
| Anhalten über den Manager | `Stopped` |
|
||||||
|
| **Manager abgeschossen** | Instanz läuft weiter (E-M6) |
|
||||||
|
| **Manager neu gestartet** | „1 Instanzen bekannt, 1 laufen bereits" — übernommen |
|
||||||
|
|
||||||
|
**Drei Defekte, die erst der Lauf gezeigt hat:**
|
||||||
|
|
||||||
|
1. **Der Manager hing an einer Konsolenabfrage.** `ConsoleLicensePrompt` wartete auf eine
|
||||||
|
Eingabe, die niemand geben konnte; die Weboberfläche kam nie hoch, und im Protokoll
|
||||||
|
stand nichts davon, weil die Frage auf der Standardausgabe steht. Ein Test auf
|
||||||
|
`Console.IsInputRedirected` reicht dafür **nicht**: Er beantwortet „ist die Eingabe
|
||||||
|
umgeleitet", nicht „kann jemand antworten" — ein Prozess ganz ohne Konsole, der
|
||||||
|
gefährlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht; ein
|
||||||
|
Schlüssel gehört in `manager.json`.
|
||||||
|
2. **Eine unlesbare `manager.json` wurde stillschweigend durch Vorgaben ersetzt.** Der
|
||||||
|
Manager suchte dann im falschen Verzeichnis, hörte auf einem anderen Port und fragte
|
||||||
|
nach einer Lizenz, die woanders stand — und nichts sagte, woran es lag. Es kostete eine
|
||||||
|
halbe Stunde und einen fehlenden Backslash. `LoadProblem` meldet den Grund jetzt.
|
||||||
|
3. **Der Instanzprozess erbte `CLAWD_CONFIG_DIR` vom Manager** und legte sich im
|
||||||
|
Konfigurationsverzeichnis des Managers eine eigene `Settings.json` an. Eine
|
||||||
|
Umgebungsvariable vererbt sich, ein Argument nicht — deshalb nimmt der Manager sein
|
||||||
|
Konfigurationsverzeichnis jetzt über `--config`. Für eine systemd-Unit ist das ohnehin
|
||||||
|
der geradere Weg.
|
||||||
|
|
||||||
|
**Was in Stufe 4 noch fehlt:** Die Weboberfläche selbst kennt noch keine Instanzen — sie
|
||||||
|
spricht `/api/…` an und bekommt die eine laufende. Instanzauswahl, `X-Instanz` und die
|
||||||
|
Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und
|
||||||
|
`webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die
|
||||||
|
Oberfläche im Manager vollständig ist.
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ClawdDotNet.Web;
|
namespace ClawdDotNet.Core.Logging;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch.
|
/// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch.
|
||||||
@@ -12,9 +12,12 @@ namespace ClawdDotNet.Web;
|
|||||||
/// Anwendung protokolliert ab da ins Leere.</para>
|
/// Anwendung protokolliert ab da ins Leere.</para>
|
||||||
///
|
///
|
||||||
/// <para>Deshalb: durchreichen, und beim Abräumen <b>nichts</b> tun. Die Fabrik gehört
|
/// <para>Deshalb: durchreichen, und beim Abräumen <b>nichts</b> tun. Die Fabrik gehört
|
||||||
/// dem <c>AppHost</c>, und der räumt sie selbst ab.</para>
|
/// dem, der sie angelegt hat, und der räumt sie selbst ab.</para>
|
||||||
|
///
|
||||||
|
/// <para>In Core, weil es inzwischen zwei Webhosts gibt, die dasselbe brauchen: den der
|
||||||
|
/// Instanz und den des Instanzmanagers (#528). Die beiden dürfen einander nicht kennen.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
|
public sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
|
||||||
{
|
{
|
||||||
public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName);
|
public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName);
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
|
||||||
|
///
|
||||||
|
/// <para><b>Warum der Manager einen eigenen hat und nicht den der Instanz.</b> Die Instanz
|
||||||
|
/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem —
|
||||||
|
/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die
|
||||||
|
/// nicht: Alles unter <c>/api</c> und <c>/events</c> kommt über TCP herein, und alles davon
|
||||||
|
/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
|
||||||
|
/// werden kann.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — HTML, CSS und
|
||||||
|
/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
|
||||||
|
/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
|
||||||
|
/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
|
||||||
|
/// In den Dateien steht nichts, was jemand nicht wissen dürfte.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> Ein gewöhnlicher
|
||||||
|
/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
|
||||||
|
/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
|
||||||
|
/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AccessToken(byte[] expected)
|
||||||
|
{
|
||||||
|
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
|
||||||
|
public const int MinimumLength = 20;
|
||||||
|
|
||||||
|
public static AccessToken For(string token)
|
||||||
|
=> new(Encoding.UTF8.GetBytes(token));
|
||||||
|
|
||||||
|
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
||||||
|
public static bool NeedsToken(PathString path)
|
||||||
|
=> path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holt das Token aus der Anfrage.
|
||||||
|
///
|
||||||
|
/// <para><c>Authorization: Bearer …</c> ist der Weg. Ein Cookie wäre bequemer und
|
||||||
|
/// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
|
||||||
|
/// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
|
||||||
|
/// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
|
||||||
|
/// keine eigene Baustelle.</para>
|
||||||
|
///
|
||||||
|
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> im Browser kann keine
|
||||||
|
/// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
|
||||||
|
/// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
|
||||||
|
/// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
|
||||||
|
/// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy
|
||||||
|
/// davor und entscheidet dort, was protokolliert wird.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static string? Read(HttpRequest request)
|
||||||
|
{
|
||||||
|
var header = request.Headers.Authorization.ToString();
|
||||||
|
|
||||||
|
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return header["Bearer ".Length..].Trim();
|
||||||
|
|
||||||
|
if (request.Path.StartsWithSegments("/events")
|
||||||
|
&& request.Query.TryGetValue("token", out var fromQuery))
|
||||||
|
return fromQuery.ToString();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Matches(string? candidate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(candidate)) return false;
|
||||||
|
|
||||||
|
// FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
|
||||||
|
// dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
|
||||||
|
// sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
|
||||||
|
// verglichen.
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(candidate), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ClawdDotNet.Manager.Web</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Die EINE Weboberflaeche fuer alle Instanzen (#528, E-M5).
|
||||||
|
|
||||||
|
Bewusst Microsoft.NET.Sdk und nicht Microsoft.NET.Sdk.Web: Dieses Projekt ist eine
|
||||||
|
Bibliothek, die in einem fremden Prozess einen Kestrel hochfaehrt. ASP.NET Core
|
||||||
|
kommt als Framework-Referenz und damit ohne NuGet-Paket.
|
||||||
|
|
||||||
|
KEIN Verweis auf ClawdDotNet.Web: Dort haengt ClawdDotNet.App dran und daran
|
||||||
|
sechzehn Werkzeugprojekte. Der Manager spricht mit den Instanzen ueber ihren
|
||||||
|
Socket, nicht ueber geteilte Objekte - und genau deshalb leitet er weiter, statt
|
||||||
|
Fachlogik zu haben. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ClawdDotNet.Manager\ClawdDotNet.Manager.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ClawdDotNet.Manager.Web.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Die Oberflaeche als gewoehnliche Dateien neben der Programmdatei, nicht
|
||||||
|
eingebettet: Wer am HTML arbeitet, sieht seine Aenderung nach einem Neuladen im
|
||||||
|
Browser statt nach einem Neubau. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>Eine Instanz, wie die Weboberfläche sie sieht.</summary>
|
||||||
|
public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess);
|
||||||
|
|
||||||
|
/// <summary>Was beim Anlegen mitkommt.</summary>
|
||||||
|
public sealed record NewInstanceDto(string Name);
|
||||||
|
|
||||||
|
/// <summary>Antwort auf etwas, das gelingen oder scheitern kann.</summary>
|
||||||
|
public sealed record OutcomeDto(bool Ok, string Meldung);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die HTTP-Fläche des Managers.
|
||||||
|
///
|
||||||
|
/// <para><b>Zwei Sorten, und der Unterschied ist die ganze Architektur:</b> Unter
|
||||||
|
/// <c>/api/verwaltung</c> steht, was den Manager selbst angeht — welche Instanzen es gibt,
|
||||||
|
/// anlegen, starten, anhalten. Alles andere unter <c>/api</c> wird unverändert an eine
|
||||||
|
/// Instanz weitergereicht.</para>
|
||||||
|
///
|
||||||
|
/// <para>Wer hier einen Endpunkt ergänzt, der etwas über Agenten, Aufgaben oder
|
||||||
|
/// Freigaben weiß, hat Fachlogik in den Manager gelegt — die gehört in die Instanz, wo
|
||||||
|
/// die Avalonia-Oberfläche sie auch findet.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class ManagerEndpoints
|
||||||
|
{
|
||||||
|
// ─── Was den Manager selbst angeht ───
|
||||||
|
|
||||||
|
public static void MapVerwaltung(this WebApplication app)
|
||||||
|
{
|
||||||
|
var verwaltung = app.MapGroup("/api/verwaltung");
|
||||||
|
|
||||||
|
verwaltung.MapGet("/instanzen", (InstanceRegistry registry) =>
|
||||||
|
registry.Instances.Select(Map).ToList());
|
||||||
|
|
||||||
|
verwaltung.MapPost("/instanzen", async (
|
||||||
|
NewInstanceDto neu, ManagerCore manager, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(neu.Name))
|
||||||
|
return Results.BadRequest(new OutcomeDto(false, "Eine Instanz braucht einen Namen."));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pfad = await manager.CreateInstanceAsync(neu.Name.Trim(), ct);
|
||||||
|
|
||||||
|
return Results.Ok(new OutcomeDto(
|
||||||
|
true, $"Instanz '{neu.Name.Trim()}' angelegt: {pfad}"));
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
// Gibt es schon. Das ist eine Antwort auf eine Frage und keine Stoerung
|
||||||
|
// des Servers - deshalb 400 und nicht 500.
|
||||||
|
return Results.BadRequest(new OutcomeDto(false, ex.Message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
verwaltung.MapPost("/instanzen/{id}/start", async (
|
||||||
|
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Find(registry, id) is not { } instanz)
|
||||||
|
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||||
|
|
||||||
|
var ergebnis = await manager.StartInstanceAsync(instanz.Directory, ct);
|
||||||
|
|
||||||
|
return ergebnis switch
|
||||||
|
{
|
||||||
|
StartRefusal.None =>
|
||||||
|
Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' läuft.")),
|
||||||
|
|
||||||
|
// 402: Der Grund liegt nicht an der Anfrage, sondern an der Lizenz.
|
||||||
|
StartRefusal.NoLicense => Results.Json(
|
||||||
|
new OutcomeDto(false, "Ohne gültige Lizenz startet keine Instanz."),
|
||||||
|
statusCode: StatusCodes.Status402PaymentRequired),
|
||||||
|
|
||||||
|
_ => Results.Json(
|
||||||
|
new OutcomeDto(false,
|
||||||
|
$"Instanz '{instanz.Name}' ist nicht hochgekommen. "
|
||||||
|
+ "Der Grund steht im Protokoll des Managers."),
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
verwaltung.MapPost("/instanzen/{id}/stop", async (
|
||||||
|
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (Find(registry, id) is not { } instanz)
|
||||||
|
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||||
|
|
||||||
|
var beendet = await manager.StopInstanceAsync(instanz.Directory, ct);
|
||||||
|
|
||||||
|
return beendet
|
||||||
|
? Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' beendet."))
|
||||||
|
: Results.Json(
|
||||||
|
new OutcomeDto(false,
|
||||||
|
$"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."),
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Der Ereignisstrom ───
|
||||||
|
|
||||||
|
public static void MapEvents(this WebApplication app)
|
||||||
|
{
|
||||||
|
var logger = app.Services
|
||||||
|
.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("ClawdDotNet.Manager.Web");
|
||||||
|
|
||||||
|
// Zwei Stroeme, dieselbe Zusammenfuehrung: die Kernereignisse und das Protokoll.
|
||||||
|
// Beide gewinnen dadurch, dass an jeder Zeile steht, aus welcher Instanz sie kommt.
|
||||||
|
app.MapGet("/events", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
|
||||||
|
=> MergedEventStream.StreamAsync(context, registry, "/events", logger, ct));
|
||||||
|
|
||||||
|
app.MapGet("/events/logs", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
|
||||||
|
=> MergedEventStream.StreamAsync(
|
||||||
|
context, registry, "/events/logs" + context.Request.QueryString, logger, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Alles andere: durchreichen ───
|
||||||
|
|
||||||
|
public static void MapForwarding(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.Map("/api/{**pfad}", async (
|
||||||
|
HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var (instanz, problem) = InstanceRouting.Resolve(registry, context);
|
||||||
|
|
||||||
|
if (instanz is null)
|
||||||
|
{
|
||||||
|
// 409 und nicht 404: Der Pfad ist in Ordnung, nur ist unklar oder
|
||||||
|
// unmoeglich, an wen er gehen soll.
|
||||||
|
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||||
|
await context.Response.WriteAsJsonAsync(new OutcomeDto(false, problem!), ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await forwarding.ForwardAsync(context, instanz, context.Request.Path, ct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Kleinkram ───
|
||||||
|
|
||||||
|
private static InstanceDto Map(ManagedInstance instanz) => new(
|
||||||
|
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId);
|
||||||
|
|
||||||
|
private static ManagedInstance? Find(InstanceRegistry registry, string id)
|
||||||
|
=> registry.Instances.FirstOrDefault(
|
||||||
|
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using ClawdDotNet.Core.Instances;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reicht eine Anfrage an den Socket der gemeinten Instanz durch.
|
||||||
|
///
|
||||||
|
/// <para><b>Er leitet weiter, er implementiert nicht</b> (E-M5). Diese Klasse kennt
|
||||||
|
/// Kennungen und Pfade, sonst nichts — keinen Agenten, keine Aufgabe, kein Taskboard. Wer
|
||||||
|
/// hier anfängt, eine Antwort umzuformen oder eine Regel anzuwenden, hat Fachlogik in den
|
||||||
|
/// Manager gelegt, und die gehört in die Instanz.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Deshalb bleibt das Protokoll HTTP</b> (E-M1). Weil auf beiden Seiten HTTP
|
||||||
|
/// steht, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Die Endpunkte, die
|
||||||
|
/// Übertragungstypen und der Ereignisstrom aus #494 gelten unverändert weiter, es ändert
|
||||||
|
/// sich nur die Bindung.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InstanceForwarding(ILogger logger) : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Je Instanz ein Client, und der bleibt.
|
||||||
|
///
|
||||||
|
/// <para>Ein <c>HttpClient</c> je Anfrage wäre hier besonders teuer: Jeder brächte
|
||||||
|
/// einen eigenen Verbindungsaufbau zum Socket mit, und die Weboberfläche fragt viel
|
||||||
|
/// und oft.</para>
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, HttpClient> _clients =
|
||||||
|
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
|
||||||
|
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kopfzeilen, die nicht weitergereicht werden.
|
||||||
|
///
|
||||||
|
/// <para><c>Authorization</c>, weil der Socket kein Token kennt und keines bekommen
|
||||||
|
/// soll — die Grenze steht dort am Dateisystem. <c>Host</c>, weil die Gegenstelle
|
||||||
|
/// keinen Hostnamen hat. Die Übertragungskopfzeilen, weil sie die Verbindung
|
||||||
|
/// beschreiben und nicht den Inhalt: Sie einfach mitzunehmen ergibt eine Antwort, die
|
||||||
|
/// doppelt gestückelt ist.</para>
|
||||||
|
/// </summary>
|
||||||
|
private static readonly HashSet<string> NotForwarded = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"Authorization", "Host", "Connection", "Transfer-Encoding",
|
||||||
|
"Keep-Alive", "Upgrade", "Proxy-Authorization", "TE", "Trailer",
|
||||||
|
InstanceRouting.HeaderName
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task ForwardAsync(
|
||||||
|
HttpContext context, ManagedInstance ziel, string pfad, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var client = ClientFor(ziel.Directory);
|
||||||
|
|
||||||
|
using var anfrage = new HttpRequestMessage(
|
||||||
|
new HttpMethod(context.Request.Method),
|
||||||
|
pfad + context.Request.QueryString);
|
||||||
|
|
||||||
|
if (context.Request.ContentLength is > 0 || context.Request.Headers.ContentLength is null)
|
||||||
|
{
|
||||||
|
// Den Rumpf stroemen lassen statt ihn zu puffern: Eine hochgeladene Datei
|
||||||
|
// muss nicht erst vollstaendig im Manager liegen, bevor die Instanz sie sieht.
|
||||||
|
anfrage.Content = new StreamContent(context.Request.Body);
|
||||||
|
|
||||||
|
foreach (var (name, werte) in context.Request.Headers)
|
||||||
|
{
|
||||||
|
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase))
|
||||||
|
anfrage.Content.Headers.TryAddWithoutValidation(name, werte.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (name, werte) in context.Request.Headers)
|
||||||
|
{
|
||||||
|
if (NotForwarded.Contains(name)) continue;
|
||||||
|
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
anfrage.Headers.TryAddWithoutValidation(name, werte.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponseMessage antwort;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
antwort = await client.SendAsync(
|
||||||
|
anfrage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Die Instanz antwortet nicht. 502 und nicht 500: Der Fehler liegt nicht hier,
|
||||||
|
// und das gehoert in die Antwort - sonst sucht jemand im Manager nach einem
|
||||||
|
// Problem, das in der Instanz steckt oder darin, dass sie gar nicht laeuft.
|
||||||
|
logger.LogWarning(ex,
|
||||||
|
"Instanz {Name} war für {Methode} {Pfad} nicht erreichbar.",
|
||||||
|
ziel.Name, context.Request.Method, pfad);
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||||
|
await context.Response.WriteAsJsonAsync(
|
||||||
|
new { fehler = $"Die Instanz '{ziel.Name}' antwortet nicht." }, ct);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (antwort)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = (int)antwort.StatusCode;
|
||||||
|
|
||||||
|
foreach (var (name, werte) in antwort.Headers)
|
||||||
|
{
|
||||||
|
if (!NotForwarded.Contains(name))
|
||||||
|
context.Response.Headers[name] = werte.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (name, werte) in antwort.Content.Headers)
|
||||||
|
{
|
||||||
|
if (!NotForwarded.Contains(name))
|
||||||
|
context.Response.Headers[name] = werte.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kestrel bestimmt selbst, wie er stueckelt.
|
||||||
|
context.Response.Headers.Remove("transfer-encoding");
|
||||||
|
|
||||||
|
await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient ClientFor(string instanceDirectory)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_clients.TryGetValue(instanceDirectory, out var vorhanden))
|
||||||
|
return vorhanden;
|
||||||
|
|
||||||
|
var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||||
|
|
||||||
|
// Grosszuegig: Hinter einer weitergeleiteten Anfrage kann ein Agentenlauf
|
||||||
|
// stecken, und der dauert, so lange er dauert. Der Ereignisstrom laeuft
|
||||||
|
// ohnehin nicht hierueber.
|
||||||
|
client.Timeout = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
_clients[instanceDirectory] = client;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
foreach (var client in _clients.Values) client.Dispose();
|
||||||
|
_clients.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Welche Instanz eine Anfrage meint.
|
||||||
|
///
|
||||||
|
/// <para><b>Der Ordnername ist die Kennung.</b> Er ist eindeutig — zwei Instanzen können
|
||||||
|
/// nicht im selben Verzeichnis liegen —, er ist stabil, und er ist lesbar. Die
|
||||||
|
/// Instanzkennung aus der Konfiguration wäre ebenfalls eindeutig, aber man müsste jede
|
||||||
|
/// Instanzdatei lesen, um von ihr auf ein Verzeichnis zu kommen; der Manager hat den Pfad
|
||||||
|
/// ohnehin in der Hand.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Und wenn nichts dabeisteht?</b> Läuft genau eine Instanz, ist sie gemeint —
|
||||||
|
/// alles andere wäre Förmlichkeit gegenüber dem Normalfall. Laufen mehrere, wird
|
||||||
|
/// nachgefragt statt geraten: Eine Anfrage an die falsche Instanz ist schlimmer als eine
|
||||||
|
/// abgelehnte.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class InstanceRouting
|
||||||
|
{
|
||||||
|
/// <summary>Die Kopfzeile, mit der die Weboberfläche die Instanz benennt.</summary>
|
||||||
|
public const string HeaderName = "X-Instanz";
|
||||||
|
|
||||||
|
public static string IdOf(ManagedInstance instance) => Path.GetFileName(instance.Directory);
|
||||||
|
|
||||||
|
/// <summary>Die gemeinte Instanz, oder eine Begründung, warum keine bestimmbar ist.</summary>
|
||||||
|
public static (ManagedInstance? Instance, string? Problem) Resolve(
|
||||||
|
InstanceRegistry registry, HttpContext context)
|
||||||
|
{
|
||||||
|
var alle = registry.Instances;
|
||||||
|
|
||||||
|
if (context.Request.Headers.TryGetValue(HeaderName, out var genannt)
|
||||||
|
&& !string.IsNullOrWhiteSpace(genannt))
|
||||||
|
{
|
||||||
|
var id = genannt.ToString().Trim();
|
||||||
|
|
||||||
|
var treffer = alle.FirstOrDefault(
|
||||||
|
i => string.Equals(IdOf(i), id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return treffer is null
|
||||||
|
? (null, $"Es gibt keine Instanz '{id}'.")
|
||||||
|
: (treffer, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var laufend = alle.Where(i => i.Condition == InstanceCondition.Running).ToList();
|
||||||
|
|
||||||
|
return laufend.Count switch
|
||||||
|
{
|
||||||
|
1 => (laufend[0], null),
|
||||||
|
0 => (null, "Es läuft keine Instanz."),
|
||||||
|
_ => (null, $"Es laufen {laufend.Count} Instanzen. "
|
||||||
|
+ $"Welche gemeint ist, gehört in die Kopfzeile {HeaderName}.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using ClawdDotNet.Core.Logging;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die eine Weboberfläche für alle Instanzen (#528, E-M5).
|
||||||
|
///
|
||||||
|
/// <para><b>Eine Adresse, ein Zugang, alle Instanzen.</b> Der Manager besitzt Port, Zugang
|
||||||
|
/// und Rahmen; Anfragen zu Chat, Agenten, Aufgaben und Freigaben reicht er über den Socket
|
||||||
|
/// an die betroffene Instanz weiter. Vorher hatte jede Instanz ihren eigenen Port, und
|
||||||
|
/// zwei Instanzen stritten sich stillschweigend um die Vorbelegung 7001.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Er leitet weiter, er implementiert nicht.</b> Unter <c>/api/verwaltung</c>
|
||||||
|
/// steht, was den Manager selbst angeht: welche Instanzen es gibt, anlegen, starten,
|
||||||
|
/// anhalten. Alles andere unter <c>/api</c> geht unverändert an eine Instanz. Wer hier
|
||||||
|
/// einen Endpunkt ergänzt, der etwas über Agenten oder Aufgaben weiß, hat Fachlogik in
|
||||||
|
/// den Manager gelegt.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Zwei Dinge, die er nicht kann und nicht können soll:</b> Er fasst keine
|
||||||
|
/// <c>state.db</c> an — die gehört genau einem Prozess, und prozessübergreifend gilt die
|
||||||
|
/// Schreibsperre von <c>SqliteStorage</c> nicht. Und er führt keine Werkzeuge aus;
|
||||||
|
/// Telegram, Rocket.Chat, Mail und FTP laufen ausschließlich in den Instanzen.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ManagerWebHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplication _app;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly InstanceForwarding _forwarding;
|
||||||
|
|
||||||
|
private ManagerWebHost(
|
||||||
|
WebApplication app, ILogger logger, InstanceForwarding forwarding, string url)
|
||||||
|
{
|
||||||
|
_app = app;
|
||||||
|
_logger = logger;
|
||||||
|
_forwarding = forwarding;
|
||||||
|
Url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die Adresse, unter der die Oberfläche hört.</summary>
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fährt die Weboberfläche hoch.
|
||||||
|
///
|
||||||
|
/// <para><b>Gibt in jedem Fehlerfall <c>null</c> zurück und wirft nicht.</b> Ein
|
||||||
|
/// Manager, der nicht anläuft, weil ein Port belegt ist, wäre genau dann im Weg, wenn
|
||||||
|
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
|
||||||
|
/// steht im Protokoll.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static ManagerWebHost? TryStart(ManagerCore manager, ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
|
||||||
|
var settings = manager.Settings;
|
||||||
|
|
||||||
|
var token = settings.WebUiToken?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (token.Length < AccessToken.MinimumLength)
|
||||||
|
{
|
||||||
|
// Keine halben Sachen: Ein Netzwerkdienst, der Agenten mit Datei-, Mail- und
|
||||||
|
// FTP-Zugriff steuert, ist nichts, was mit einem schwachen oder gar keinem
|
||||||
|
// Token offen stehen darf.
|
||||||
|
logger.LogWarning(
|
||||||
|
"Weboberfläche nicht gestartet: Es fehlt ein Zugangstoken von mindestens "
|
||||||
|
+ "{Mindestlänge} Zeichen (webUiToken in manager.json).",
|
||||||
|
AccessToken.MinimumLength);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loopback = !string.Equals(settings.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var host = loopback ? "127.0.0.1" : "0.0.0.0";
|
||||||
|
var url = $"http://{host}:{settings.WebUiPort}";
|
||||||
|
|
||||||
|
var forwarding = new InstanceForwarding(logger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var app = Build(manager, loggerFactory, url, token, forwarding);
|
||||||
|
|
||||||
|
app.StartAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Weboberfläche hört auf {Url} ({Bindung})",
|
||||||
|
anzeige, loopback ? "nur lokal" : "alle Adressen");
|
||||||
|
|
||||||
|
return new ManagerWebHost(app, logger, forwarding, anzeige);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
forwarding.Dispose();
|
||||||
|
logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WebApplication Build(
|
||||||
|
ManagerCore manager,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
string url,
|
||||||
|
string token,
|
||||||
|
InstanceForwarding forwarding)
|
||||||
|
{
|
||||||
|
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
|
||||||
|
{
|
||||||
|
ContentRootPath = AppContext.BaseDirectory,
|
||||||
|
EnvironmentName = Environments.Production,
|
||||||
|
ApplicationName = typeof(ManagerWebHost).Assembly.GetName().Name
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
|
||||||
|
|
||||||
|
// ASP.NET protokolliert auf Information jede einzelne Anfrage - auch jede
|
||||||
|
// Schriftart und jedes Stylesheet. Ab Warnung ist es das, was man wissen will.
|
||||||
|
builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
|
builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
|
|
||||||
|
builder.WebHost.UseUrls(url);
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(manager);
|
||||||
|
builder.Services.AddSingleton(manager.Instances);
|
||||||
|
builder.Services.AddSingleton(AccessToken.For(token));
|
||||||
|
builder.Services.AddSingleton(forwarding);
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.Use(ReportUnhandled(loggerFactory));
|
||||||
|
app.Use(CheckToken);
|
||||||
|
|
||||||
|
MapStaticFiles(app);
|
||||||
|
|
||||||
|
app.MapVerwaltung();
|
||||||
|
app.MapEvents();
|
||||||
|
app.MapForwarding();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Func<HttpContext, RequestDelegate, Task> ReportUnhandled(
|
||||||
|
ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
|
||||||
|
|
||||||
|
return async (context, next) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Der Browser hat aufgelegt, oder der Manager faehrt herunter. Bewusst
|
||||||
|
// ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
|
||||||
|
// ApplicationStopping ab, und RequestAborted ist dabei nicht gesetzt.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
|
||||||
|
|
||||||
|
if (context.Response.HasStarted) return;
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CheckToken(HttpContext context, RequestDelegate next)
|
||||||
|
{
|
||||||
|
if (!AccessToken.NeedsToken(context.Request.Path))
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gate = context.RequestServices.GetRequiredService<AccessToken>();
|
||||||
|
|
||||||
|
if (!gate.Matches(AccessToken.Read(context.Request)))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MapStaticFiles(WebApplication app)
|
||||||
|
{
|
||||||
|
var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||||
|
|
||||||
|
if (!Directory.Exists(webRoot))
|
||||||
|
{
|
||||||
|
app.Logger.LogWarning(
|
||||||
|
"Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = new PhysicalFileProvider(webRoot);
|
||||||
|
|
||||||
|
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
|
||||||
|
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _app.StopAsync(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
|
||||||
|
}
|
||||||
|
|
||||||
|
_forwarding.Dispose();
|
||||||
|
await _app.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using ClawdDotNet.Core.Instances;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Manager.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// N Instanzen, ein Ereignisstrom zum Browser.
|
||||||
|
///
|
||||||
|
/// <para><b>Die eine Stelle, die wirklich neu gedacht werden musste</b> (Konzept,
|
||||||
|
/// Abschnitt 8). Alles andere an der Weboberfläche ließ sich weiterleiten; ein
|
||||||
|
/// Ereignisstrom nicht. Der Browser hält eine Verbindung, die Instanzen halten je eine,
|
||||||
|
/// und dazwischen muss jemand zusammenführen — und an jedes Ereignis schreiben, von wem
|
||||||
|
/// es kommt. Ohne das wäre in einem Fenster mit drei Instanzen nicht zu erkennen, welcher
|
||||||
|
/// Agent gerade spricht.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Ergänzt statt verpackt.</b> Es wäre einfacher, jedes Ereignis in einen
|
||||||
|
/// Umschlag zu stecken (<c>{"instanz":…,"daten":…}</c>) — dann müsste aber jede Ansicht
|
||||||
|
/// der Weboberfläche umgeschrieben werden. Stattdessen bekommt das vorhandene Objekt ein
|
||||||
|
/// Feld <c>instanz</c> dazu; alles, was heute Felder liest, liest sie weiter.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Eine tote Instanz reißt den Strom nicht ab.</b> Fällt eine weg, endet ihre
|
||||||
|
/// Zuleitung, und die anderen laufen weiter. Der Browser merkt davon nichts — was er
|
||||||
|
/// merken soll, sagt ihm die Aufsicht als eigenes Ereignis.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class MergedEventStream
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die
|
||||||
|
/// ältesten wegfallen.
|
||||||
|
///
|
||||||
|
/// <para>Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund
|
||||||
|
/// eingefroren ist, würde sonst den Speicher des Managers füllen. Ein verlorener
|
||||||
|
/// Zwischenstand ist der bessere Verlust — die Seite lädt den Verlauf beim Verbinden
|
||||||
|
/// ohnehin neu.</para>
|
||||||
|
/// </summary>
|
||||||
|
private const int QueueDepth = 512;
|
||||||
|
|
||||||
|
public static async Task StreamAsync(
|
||||||
|
HttpContext context,
|
||||||
|
InstanceRegistry registry,
|
||||||
|
string instancePath,
|
||||||
|
ILogger logger,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest,
|
||||||
|
SingleReader = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var laufend = registry.Instances
|
||||||
|
.Where(i => i.Condition == InstanceCondition.Running)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
using var leben = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
|
||||||
|
var zuleitungen = laufend
|
||||||
|
.Select(instanz => PumpAsync(instanz, instancePath, queue.Writer, logger, leben.Token))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
PrepareResponse(context);
|
||||||
|
|
||||||
|
// Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und
|
||||||
|
// onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis
|
||||||
|
// von selbst kommt - was Stunden dauern kann.
|
||||||
|
await context.Response.WriteAsync(
|
||||||
|
$": bereit ({laufend.Count} Instanzen)\n\n", ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var naechstes = queue.Reader.ReadAsync(ct).AsTask();
|
||||||
|
var schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var fertig = await Task.WhenAny(naechstes, schlag).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (fertig == naechstes)
|
||||||
|
{
|
||||||
|
await context.Response.WriteAsync(await naechstes, ct).ConfigureAwait(false);
|
||||||
|
naechstes = queue.Reader.ReadAsync(ct).AsTask();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!await schlag) break;
|
||||||
|
|
||||||
|
// Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis
|
||||||
|
// vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute.
|
||||||
|
await context.Response.WriteAsync(": still\n\n", ct).ConfigureAwait(false);
|
||||||
|
schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Der Browser hat aufgelegt, oder der Manager faehrt herunter.
|
||||||
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
// Der Strom wird abgeraeumt.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await leben.CancelAsync().ConfigureAwait(false);
|
||||||
|
queue.Writer.TryComplete();
|
||||||
|
|
||||||
|
try { await Task.WhenAll(zuleitungen).ConfigureAwait(false); }
|
||||||
|
catch { /* Abgebrochene Zuleitungen sind der Normalfall beim Aufraeumen. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eine Zuleitung: hört am Socket einer Instanz und schiebt weiter, was kommt.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task PumpAsync(
|
||||||
|
ManagedInstance instanz,
|
||||||
|
string instancePath,
|
||||||
|
ChannelWriter<string> ziel,
|
||||||
|
ILogger logger,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = InstanceRouting.IdOf(instanz);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = InstanceEndpoint.CreateClient(instanz.Directory);
|
||||||
|
|
||||||
|
// Kein Zeitlimit: Ein Ereignisstrom endet nie von selbst - das ist sein Zweck.
|
||||||
|
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||||
|
|
||||||
|
using var antwort = await client.GetAsync(
|
||||||
|
instancePath, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
antwort.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await using var strom = await antwort.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
|
using var leser = new StreamReader(strom, Encoding.UTF8);
|
||||||
|
|
||||||
|
string? art = null;
|
||||||
|
|
||||||
|
while (await leser.ReadLineAsync(ct).ConfigureAwait(false) is { } zeile)
|
||||||
|
{
|
||||||
|
if (zeile.StartsWith(": ", StringComparison.Ordinal))
|
||||||
|
continue; // Herzschlag der Instanz. Der Manager hat einen eigenen.
|
||||||
|
|
||||||
|
if (zeile.StartsWith("event: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
art = zeile["event: ".Length..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zeile.StartsWith("data: ", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ziel.TryWrite(Format(art ?? "message", zeile["data: ".Length..], id, instanz.Name));
|
||||||
|
art = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Der Strom wird abgeraeumt oder der Browser hat aufgelegt.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Eine Instanz, die waehrend des Zuhoerens weggeht, ist kein Fehler des
|
||||||
|
// Managers - und sie darf die anderen Zuleitungen nicht mitnehmen.
|
||||||
|
logger.LogInformation(
|
||||||
|
"Ereigniszuleitung von Instanz {Name} beendet: {Grund}", instanz.Name, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hängt <c>instanz</c> und <c>instanzName</c> an das Ereignis.
|
||||||
|
///
|
||||||
|
/// <para>Ist der Inhalt kein Objekt — oder gar kein JSON —, wird er unverändert
|
||||||
|
/// weitergereicht. Ein Ereignis zu verschlucken, weil sein Inhalt unerwartet aussieht,
|
||||||
|
/// wäre die schlechtere Antwort: Der Manager leitet weiter, er urteilt nicht.</para>
|
||||||
|
/// </summary>
|
||||||
|
private static string Format(string art, string daten, string id, string name)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (JsonNode.Parse(daten) is JsonObject objekt)
|
||||||
|
{
|
||||||
|
objekt["instanz"] = id;
|
||||||
|
objekt["instanzName"] = name;
|
||||||
|
return $"event: {art}\ndata: {objekt.ToJsonString()}\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
// Siehe oben.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"event: {art}\ndata: {daten}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrepareResponse(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.Headers.ContentType = "text/event-stream";
|
||||||
|
context.Response.Headers.CacheControl = "no-cache";
|
||||||
|
|
||||||
|
// Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn
|
||||||
|
// er voll ist - aus einem Ereignisstrom wird dann eine Ereignissammlung.
|
||||||
|
context.Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,8 +143,23 @@ public sealed class ManagerSettingsStore
|
|||||||
|
|
||||||
public ManagerSettings Settings { get; private set; } = new();
|
public ManagerSettings Settings { get; private set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warum die Datei nicht gelesen werden konnte — oder <c>null</c>, wenn alles ging.
|
||||||
|
///
|
||||||
|
/// <para><b>Warum das nach aussen muss.</b> Eine unlesbare Datei verhindert den Start
|
||||||
|
/// nicht; das wäre der schlechtere Zustand, denn dann liefe gar keine Instanz mehr.
|
||||||
|
/// Aber „läuft weiter" heisst nicht „niemand erfährt es": Mit Vorgaben statt der
|
||||||
|
/// eigenen Einstellungen sucht der Manager im falschen Verzeichnis nach Instanzen,
|
||||||
|
/// hört auf einem anderen Port und fragt nach einer Lizenz, die woanders steht — und
|
||||||
|
/// nichts davon sagt, woran es liegt. Genau das ist beim ersten Abnahmelauf passiert:
|
||||||
|
/// ein einzelner Backslash zu wenig, und eine Viertelstunde Ratelei.</para>
|
||||||
|
/// </summary>
|
||||||
|
public string? LoadProblem { get; private set; }
|
||||||
|
|
||||||
public ManagerSettings Load()
|
public ManagerSettings Load()
|
||||||
{
|
{
|
||||||
|
LoadProblem = null;
|
||||||
|
|
||||||
if (!File.Exists(SettingsPath))
|
if (!File.Exists(SettingsPath))
|
||||||
{
|
{
|
||||||
Settings = new ManagerSettings();
|
Settings = new ManagerSettings();
|
||||||
@@ -160,7 +175,9 @@ public sealed class ManagerSettingsStore
|
|||||||
catch (Exception ex) when (ex is JsonException or IOException)
|
catch (Exception ex) when (ex is JsonException or IOException)
|
||||||
{
|
{
|
||||||
// Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe
|
// Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe
|
||||||
// gar keine Instanz mehr, und das ist der schlechtere Zustand.
|
// gar keine Instanz mehr, und das ist der schlechtere Zustand. Aber der Grund
|
||||||
|
// gehoert nach draussen, sonst arbeitet er stillschweigend mit Vorgaben.
|
||||||
|
LoadProblem = ex.Message;
|
||||||
Settings = new ManagerSettings();
|
Settings = new ManagerSettings();
|
||||||
return Settings;
|
return Settings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ClawdDotNet.ManagerApp</RootNamespace>
|
||||||
|
|
||||||
|
<!-- Heisst wie das, was man startet, und nicht wie das Projekt. Nicht
|
||||||
|
"ClawdDotNet.Manager": So heisst die Bibliothek daneben, und zwei Erzeugnisse
|
||||||
|
mit demselben Namen kann NuGet nicht auseinanderhalten. -->
|
||||||
|
<AssemblyName>ClawdDotNet.Instanzmanager</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Eine Konsolenanwendung, kein Fenster. Das Fenster des Managers kommt mit Stufe 5;
|
||||||
|
bis dahin ist die Weboberflaeche sein Zugang, und die haengt er selbst auf. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ClawdDotNet.Manager.Web\ClawdDotNet.Manager.Web.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
|
|||||||
|
|
||||||
// Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen.
|
// Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen.
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
|
builder.Logging.AddProvider(new Core.Logging.SharedLoggerProvider(loggerFactory));
|
||||||
|
|
||||||
// ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener
|
// ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener
|
||||||
// Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes
|
// Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes
|
||||||
|
|||||||
@@ -53,6 +53,40 @@ public sealed class ManagerSettingsTests : IDisposable
|
|||||||
erneut.LicenseKey.ShouldBe("LIZ-1234-5678");
|
erneut.LicenseKey.ShouldBe("LIZ-1234-5678");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark,
|
||||||
|
/// und sie darf trotzdem gelten.
|
||||||
|
///
|
||||||
|
/// <para>Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete
|
||||||
|
/// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
|
||||||
|
{
|
||||||
|
var pfad = Path.Combine(_dir, "manager.json");
|
||||||
|
|
||||||
|
// Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach
|
||||||
|
// dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad.
|
||||||
|
var inhalt = """
|
||||||
|
{
|
||||||
|
"instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
|
||||||
|
"webUiPort": 7009,
|
||||||
|
"deploymentcenterUrl": ""
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
|
||||||
|
File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM.");
|
||||||
|
|
||||||
|
var store = new ManagerSettingsStore(_dir);
|
||||||
|
var settings = store.Load();
|
||||||
|
|
||||||
|
store.LoadProblem.ShouldBeNull();
|
||||||
|
settings.InstanceExecutable.ShouldBe(@"J:\Programme\ClawdDotNet.exe");
|
||||||
|
settings.WebUiPort.ShouldBe(7009);
|
||||||
|
settings.DeploymentcenterUrl.ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
|
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
|
||||||
/// keine Instanz mehr, und das ist der schlechtere Zustand.
|
/// keine Instanz mehr, und das ist der schlechtere Zustand.
|
||||||
@@ -62,7 +96,13 @@ public sealed class ManagerSettingsTests : IDisposable
|
|||||||
{
|
{
|
||||||
File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON");
|
File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON");
|
||||||
|
|
||||||
new ManagerSettingsStore(_dir).Load().WebUiPort.ShouldBe(7000);
|
var store = new ManagerSettingsStore(_dir);
|
||||||
|
|
||||||
|
store.Load().WebUiPort.ShouldBe(7000);
|
||||||
|
|
||||||
|
// Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit
|
||||||
|
// Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist.
|
||||||
|
store.LoadProblem.ShouldNotBeNullOrWhiteSpace();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user