Instanzmanager Stufe 4: eine Adresse, ein Zugang, alle Instanzen (#528, E-M5)
Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung: Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten. Beim Herunterfahren beendet er keine Instanz. Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche - ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt. wwwroot ist aus der Instanz in den Manager gezogen. DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api geht unveraendert an eine Instanz. Er leitet weiter, er implementiert nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine abgelehnte. Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht der Weboberflaeche zum Umschreiben gezwungen. ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token 200, Instanz ueber den Manager gestartet und angehalten, /api/info und /api/status unverfaelscht durchgereicht, /events/logs mit "instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen bereits" - uebernommen. E-M6 damit in beide Richtungen belegt. DREI DEFEKTE, die erst der Lauf gezeigt hat: 1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht. 2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt. 3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt sein Verzeichnis jetzt ueber --config. SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts, die dasselbe brauchen, und sie duerfen einander nicht kennen. NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen - Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an wwwroot. Und webServerPort steht noch in der Instanz. Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user