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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user