Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt. Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder. Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass: Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden. Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202 beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf seiner Frist jedes Mal ab. InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist. - ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten, Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb. - GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt genau dann, wenn sie haengt. - Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten, nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz. - EnsureDeadAsync vor jedem Start. Weiteres aus dem Review: - .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/. InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt. - Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein. - Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus. - [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand abstellen konnte, solange E-M5 nicht fertig ist. - Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht. - Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und der Port kommt aus manager.json statt fest aus der Zeile. Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535). 806 Tests gruen, 0 Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -21,6 +26,32 @@ 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>Eine gefundene Sicherungsdatei einer Instanz.</summary>
|
||||
public sealed record BackupItemDto(
|
||||
string FileName,
|
||||
long SizeBytes,
|
||||
DateTime CreatedAt,
|
||||
string InstanceName,
|
||||
string Secrets,
|
||||
int SecretCount,
|
||||
int FileCount);
|
||||
|
||||
/// <summary>Übersicht aller Sicherungen einer Instanz mit Verzeichnisangabe.</summary>
|
||||
public sealed record BackupListDto(
|
||||
string Directory,
|
||||
IReadOnlyList<BackupItemDto> Backups);
|
||||
|
||||
/// <summary>Parameter zum Anlegen einer neuen Sicherung.</summary>
|
||||
public sealed record CreateBackupRequestDto(
|
||||
string? Secrets,
|
||||
string? Passphrase,
|
||||
bool IncludeLogs,
|
||||
bool IncludeChatHistory);
|
||||
|
||||
/// <summary>Parameter zur Wiederherstellung einer Sicherung.</summary>
|
||||
public sealed record RestoreBackupRequestDto(
|
||||
string? Passphrase);
|
||||
|
||||
/// <summary>
|
||||
/// Die HTTP-Fläche des Managers.
|
||||
///
|
||||
@@ -125,6 +156,190 @@ internal static class ManagerEndpoints
|
||||
$"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
});
|
||||
|
||||
// ─── Datensicherung & Wiederherstellung (#530, Stufe 6) ───
|
||||
|
||||
verwaltung.MapGet("/instanzen/{id}/backups", async (
|
||||
string id, InstanceRegistry registry, ILoggerFactory protokoll,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
if (!Directory.Exists(backupDir))
|
||||
return Results.Ok(new BackupListDto(backupDir, []));
|
||||
|
||||
var service = new BackupService();
|
||||
var list = new List<BackupItemDto>();
|
||||
|
||||
var files = new DirectoryInfo(backupDir)
|
||||
.GetFiles("*.zip")
|
||||
.OrderByDescending(f => f.LastWriteTimeUtc);
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var manifest = await service.InspectAsync(f.FullName, ct);
|
||||
list.Add(new BackupItemDto(
|
||||
f.Name,
|
||||
f.Length,
|
||||
manifest.CreatedAt != default ? manifest.CreatedAt : f.CreationTimeUtc,
|
||||
manifest.InstanceName,
|
||||
manifest.Secrets,
|
||||
manifest.SecretCount,
|
||||
manifest.Files.Count));
|
||||
}
|
||||
catch
|
||||
{
|
||||
list.Add(new BackupItemDto(
|
||||
f.Name,
|
||||
f.Length,
|
||||
f.CreationTimeUtc,
|
||||
instanz.Name,
|
||||
"Unbekannt",
|
||||
0,
|
||||
0));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new BackupListDto(backupDir, list));
|
||||
});
|
||||
|
||||
verwaltung.MapPost("/instanzen/{id}/backups", async (
|
||||
string id, CreateBackupRequestDto? req, InstanceRegistry registry,
|
||||
ILoggerFactory protokoll, CancellationToken ct) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
var safeName = string.Join("_", instanz.Name.Split(Path.GetInvalidFileNameChars()));
|
||||
var zipName = $"backup_{safeName}_{timestamp}.zip";
|
||||
var zipPath = Path.Combine(backupDir, zipName);
|
||||
|
||||
var secretMode = string.Equals(req?.Secrets, "Passphrase", StringComparison.OrdinalIgnoreCase)
|
||||
? SecretMode.Passphrase
|
||||
: SecretMode.Exclude;
|
||||
|
||||
if (secretMode == SecretMode.Passphrase && string.IsNullOrWhiteSpace(req?.Passphrase))
|
||||
return Results.BadRequest(new OutcomeDto(false, "Für den Schutz der Zugangsdaten wird eine Passphrase benötigt."));
|
||||
|
||||
var options = new BackupOptions
|
||||
{
|
||||
Secrets = secretMode,
|
||||
Passphrase = req?.Passphrase,
|
||||
IncludeLogs = req?.IncludeLogs ?? false,
|
||||
IncludeChatHistory = req?.IncludeChatHistory ?? true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var service = new BackupService();
|
||||
var result = await service.CreateAsync(instanz.Directory, zipPath, options, ct);
|
||||
return Results.Ok(new OutcomeDto(true, $"Sicherung '{zipName}' erfolgreich erstellt ({result.SizeBytes / 1024} KB)."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new OutcomeDto(false, $"Sicherung fehlgeschlagen: {ex.Message}"));
|
||||
}
|
||||
});
|
||||
|
||||
verwaltung.MapPost("/instanzen/{id}/backups/{filename}/restore", async (
|
||||
string id, string filename, RestoreBackupRequestDto? req, ManagerCore manager,
|
||||
InstanceRegistry registry, ILoggerFactory protokoll) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
|
||||
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
var zipPath = Path.Combine(backupDir, filename);
|
||||
|
||||
if (!File.Exists(zipPath))
|
||||
return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' wurde nicht gefunden."));
|
||||
|
||||
// Ausdruecklich ohne das Token der Anfrage: Wer mitten im Zurueckspielen den
|
||||
// Browser schliesst, hinterliesse sonst ein halb ueberschriebenes
|
||||
// Instanzverzeichnis - und das wuerde anschliessend auch noch gestartet. Was
|
||||
// hier beginnt, wird zu Ende gebracht.
|
||||
var ct = CancellationToken.None;
|
||||
|
||||
var wasRunning = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent;
|
||||
if (wasRunning)
|
||||
{
|
||||
var stopped = await manager.StopInstanceAsync(instanz.Directory, ct);
|
||||
if (!stopped)
|
||||
{
|
||||
return Results.Json(
|
||||
new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."),
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var service = new BackupService();
|
||||
var restoreResult = await service.RestoreAsync(
|
||||
zipPath,
|
||||
instanz.Directory,
|
||||
new RestoreOptions
|
||||
{
|
||||
Passphrase = req?.Passphrase,
|
||||
Overwrite = true
|
||||
},
|
||||
ct);
|
||||
|
||||
if (wasRunning)
|
||||
{
|
||||
await manager.StartInstanceAsync(instanz.Directory, ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new OutcomeDto(true, $"Sicherung '{filename}' erfolgreich wiederhergestellt ({restoreResult.Written.Count} Dateien geschrieben)."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (wasRunning)
|
||||
{
|
||||
try { await manager.StartInstanceAsync(instanz.Directory, ct); } catch { }
|
||||
}
|
||||
|
||||
return Results.BadRequest(new OutcomeDto(false, $"Wiederherstellung fehlgeschlagen: {ex.Message}"));
|
||||
}
|
||||
});
|
||||
|
||||
verwaltung.MapDelete("/instanzen/{id}/backups/{filename}", (
|
||||
string id, string filename, InstanceRegistry registry, ILoggerFactory protokoll) =>
|
||||
{
|
||||
if (Find(registry, id) is not { } instanz)
|
||||
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
|
||||
|
||||
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename)
|
||||
return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname."));
|
||||
|
||||
var backupDir = GetBackupDirectory(instanz, Log(protokoll));
|
||||
var zipPath = Path.Combine(backupDir, filename);
|
||||
|
||||
if (!File.Exists(zipPath))
|
||||
return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' nicht gefunden."));
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(zipPath);
|
||||
return Results.Ok(new OutcomeDto(true, $"Sicherungsdatei '{filename}' gelöscht."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new OutcomeDto(false, $"Konnte nicht gelöscht werden: {ex.Message}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Der Ereignisstrom ───
|
||||
@@ -174,7 +389,44 @@ internal static class ManagerEndpoints
|
||||
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(),
|
||||
instanz.ProcessId, instanz.AutostartByManager);
|
||||
|
||||
private static ILogger Log(ILoggerFactory factory)
|
||||
=> factory.CreateLogger("ClawdDotNet.Manager.Web");
|
||||
|
||||
private static ManagedInstance? Find(InstanceRegistry registry, string id)
|
||||
=> registry.Instances.FirstOrDefault(
|
||||
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Sicherungen dieser Instanz liegen.
|
||||
///
|
||||
/// <para><b>Eine unlesbare Instanzdatei wird gemeldet, nicht verschluckt.</b> Sonst
|
||||
/// landeten die Sicherungen wortlos im Standardverzeichnis statt im eingestellten —
|
||||
/// und niemand sähe, warum die alten verschwunden sind. Genau diese Bauart hat
|
||||
/// <c>manager.json</c> schon einmal eine halbe Stunde Fehlersuche gekostet.</para>
|
||||
/// </summary>
|
||||
private static string GetBackupDirectory(ManagedInstance instanz, ILogger? logger = null)
|
||||
{
|
||||
InstanceConfig? config = null;
|
||||
var settingsFile = Path.Combine(instanz.Directory, "InstanceSettings.json");
|
||||
if (File.Exists(settingsFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
config = JsonSerializer.Deserialize<InstanceConfig>(
|
||||
File.ReadAllText(settingsFile),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex,
|
||||
"InstanceSettings.json von {Name} ist nicht lesbar — für die Sicherungen "
|
||||
+ "gilt das Standardverzeichnis, nicht das dort eingetragene.",
|
||||
instanz.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return InstanceLayout.ResolveBackupDirectory(
|
||||
config ?? new InstanceConfig { WorkingDirectory = instanz.Directory },
|
||||
instanz.Directory);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user