#795: Updates aus der WebUI ueber den Instanzmanager

Das Update-Angebot gab es nur im Desktop-Fenster. Der Manager prueft jetzt
selbst und spielt ein: erst alle Instanzen geordnet anhalten, dann
- ohne systemd: update-agent mit --wait-for-pid und --restart,
- unter systemd: clawddotnet-update.service anstossen (Vorlage unter
  deploy/systemd), damit der agent keinen zweiten Manager neben dem
  Dienst startet (DC SETUP_INTEGRATION_GUIDE 4A).
Fehlt die Unit, wird nichts angehalten; scheitert die Uebergabe, kommen
die Instanzen wieder hoch. Anleitung in InstanceSetupGuide Abschnitt 8.

Nebenbei: Sprachpaket wird vor dem Kopf geladen (rohe Schluessel im Kopf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 20:15:18 +02:00
co-authored by Claude Opus 5.5
parent 4e41fc12d7
commit d4b7f34200
12 changed files with 630 additions and 12 deletions
+15
View File
@@ -439,6 +439,21 @@ internal static class ManagerEndpoints
}
});
// ─── Updates (#795) ───
verwaltung.MapGet("/update", async (ManagerUpdates updates, HttpContext context, CancellationToken ct) =>
Results.Ok(context.Request.Query.ContainsKey("refresh") || updates.Status.CheckedAt is null
? await updates.CheckAsync(ct)
: updates.Status));
verwaltung.MapPost("/update/install", async (ManagerUpdates updates) =>
{
var result = await updates.InstallAsync();
return result.Ok
? Results.Ok(new OutcomeDto(true, result.Message))
: Results.BadRequest(new OutcomeDto(false, result.Message));
});
// ─── License and manager settings (#764) ───
verwaltung.MapGet("/license", (ManagerCore manager) =>
@@ -54,7 +54,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
/// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
/// steht im Protokoll.</para>
/// </summary>
public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory)
public static ManagerWebHost? TryStart(ManagerCore manager, ManagerBackups backups, ILoggerFactory loggerFactory, ManagerUpdates? updates = null)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
var settings = manager.Settings;
@@ -82,7 +82,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
try
{
var app = Build(manager, backups, loggerFactory, url, token, forwarding);
var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
app.StartAsync().GetAwaiter().GetResult();
@@ -105,6 +105,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
private static WebApplication Build(
ManagerCore manager,
ManagerBackups backups,
ManagerUpdates? updates,
ILoggerFactory loggerFactory,
string url,
string token,
@@ -130,6 +131,8 @@ public sealed class ManagerWebHost : IAsyncDisposable
builder.Services.AddSingleton(manager);
builder.Services.AddSingleton(manager.Instances);
builder.Services.AddSingleton(backups);
// #795: Updates from the web interface. Without one given (tests), one that knows no version.
builder.Services.AddSingleton(updates ?? new ManagerUpdates(manager, "0.0.0", loggerFactory.CreateLogger("ClawdDotNet.Manager.Update")));
builder.Services.AddSingleton(AccessToken.For(token));
builder.Services.AddSingleton(forwarding);
+7 -5
View File
@@ -367,6 +367,13 @@ async function aktualisiereStagingZaehler() {
async function starteHauptAnwendung(instanzen) {
versteckeAnmeldung();
// #661: language packs before the first render. #764: Before the header as well - it stood
// below, so the instance list and the header status were drawn first and showed raw keys
// ("header.instanceOffline", "status-running") until something drew them again.
await loadLanguage();
translateDocument();
if (spracheSchalter) spracheSchalter.value = currentLanguage();
// Instanzenliste initialisieren
await aktualisiereInstanzenListe(Array.isArray(instanzen) ? instanzen : null);
@@ -394,11 +401,6 @@ async function starteHauptAnwendung(instanzen) {
}
});
// #661: language packs before the first render
await loadLanguage();
translateDocument();
if (spracheSchalter) spracheSchalter.value = currentLanguage();
// Router initialisieren
initialisiereRouter();
}
@@ -669,6 +669,19 @@
"logs.empty": "Noch keine Protokolleinträge vorhanden.",
"settings.title": "Einstellungen & Systemarchitektur",
"settings.updateCard": "Update",
"settings.updateInstalled": "Installiert",
"settings.updateLatest": "Neueste",
"settings.updateState": "Zustand",
"settings.updateCurrent": "Aktuell",
"settings.updateAvailable": "Update verfügbar",
"settings.updateCritical": "Wichtiges Update",
"settings.updateProblem": "Nicht prüfbar",
"settings.updateCheckNow": "Jetzt prüfen",
"settings.updateInstall": "Einspielen",
"settings.updateConfirm": "Version {version} einspielen? Zuerst werden alle Instanzen angehalten; der Instanzmanager ist ein bis zwei Minuten nicht erreichbar.",
"settings.updateHint": "Der update-agent spielt das Release ein und startet den Instanzmanager wieder. Instanzen mit Autostart kommen mit ihm zurück.",
"settings.updateHintSystemd": "Läuft als systemd-Dienst: Das Update geht über clawddotnet-update.service (siehe docs/InstanceSetupGuide.md, Abschnitt 8). Instanzen mit Autostart kommen mit dem Manager zurück.",
"settings.telegramCard": "Telegram-Client",
"settings.telegramState.Connecting": "Verbindet",
"settings.telegramState.NeedsCode": "Code nötig",
@@ -669,6 +669,19 @@
"logs.empty": "No log messages yet.",
"settings.title": "Settings & System Architecture",
"settings.updateCard": "Update",
"settings.updateInstalled": "Installed",
"settings.updateLatest": "Latest",
"settings.updateState": "State",
"settings.updateCurrent": "Up to date",
"settings.updateAvailable": "Update available",
"settings.updateCritical": "Important update",
"settings.updateProblem": "Not checkable",
"settings.updateCheckNow": "Check now",
"settings.updateInstall": "Install",
"settings.updateConfirm": "Install version {version}? All instances are stopped first; the instance manager is unreachable for a minute or two.",
"settings.updateHint": "The update agent installs the release and starts the instance manager again. Instances with autostart come back with it.",
"settings.updateHintSystemd": "Runs as a systemd service: the update goes through clawddotnet-update.service (see docs/InstanceSetupGuide.md, section 8). Instances with autostart come back with the manager.",
"settings.telegramCard": "Telegram client",
"settings.telegramState.Connecting": "Connecting",
"settings.telegramState.NeedsCode": "Code needed",
@@ -129,14 +129,16 @@ async function managerSection() {
const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"),
[erstellePlakette(t("settings.managerBadge"), "warnung")], t("settings.managerIntro"));
const [license, manager] = await Promise.all([
const [license, manager, update] = await Promise.all([
api("/verwaltung/license").catch(() => null),
api("/verwaltung/settings").catch(() => null)
api("/verwaltung/settings").catch(() => null),
api("/verwaltung/update").catch(() => null)
]);
const grid = erstelleElement("div", { klasse: "settings-grid" });
grid.append(licenseCard(license));
if (manager) grid.append(managerCard(manager));
if (update) grid.append(updateCard(update));
el.append(grid);
return el;
}
@@ -420,3 +422,81 @@ function telegramCard(initial) {
render(initial);
return card;
}
// ─── Updates (#795) ───
//
// Installing stops every instance first; the manager then hands over to the update agent (or,
// under systemd, to clawddotnet-update.service) and goes away for a while. The page waits for
// it to answer again and reloads.
function updateCard(status) {
const body = erstelleElement("div", { klasse: "formular-raster" });
const card = erstelleKarte(t("settings.updateCard"), [body]);
function render(s) {
const state = s.problem
? erstellePlakette(t("settings.updateProblem"), "warnung")
: s.available
? erstellePlakette(s.critical ? t("settings.updateCritical") : t("settings.updateAvailable"), s.critical ? "gefahr" : "warnung")
: erstellePlakette(t("settings.updateCurrent"), "erfolg");
body.replaceChildren(dataList([
[t("settings.updateInstalled"), s.installedVersion],
[t("settings.updateLatest"), s.latestVersion ?? "—"],
[t("settings.updateState"), state]
]));
if (s.problem) body.append(erstelleElement("p", { klasse: "gedaempft text-klein", text: s.problem }));
if (s.available && s.notes) body.append(erstelleElement("pre", { klasse: "text-klein", text: s.notes }));
const check = erstelleElement("button", {
type: "button", klasse: "schaltflaeche flach klein", text: t("settings.updateCheckNow"),
onclick: async () => {
check.disabled = true;
try { render(await api("/verwaltung/update?refresh=1")); } catch (err) { zeigeMeldung(err.message, "gefahr"); check.disabled = false; }
}
});
const actions = erstelleElement("div", { klasse: "gruppe-horizontal" }, check);
if (s.available) {
actions.append(erstelleElement("button", {
type: "button", klasse: "schaltflaeche primaer klein", text: t("settings.updateInstall"),
onclick: () => install(s)
}));
}
body.append(actions,
erstelleElement("p", { klasse: "gedaempft text-klein", text: s.underSystemd ? t("settings.updateHintSystemd") : t("settings.updateHint") }));
}
async function install(s) {
if (!confirm(t("settings.updateConfirm", { version: s.latestVersion ?? "?" }))) return;
try {
const result = await api("/verwaltung/update/install", { method: "POST" });
body.replaceChildren(erstelleElement("p", { klasse: "hinweis", text: result?.meldung ?? result?.message ?? "" }));
waitForManager();
} catch (err) {
zeigeMeldung(err.message, "gefahr", 10000);
}
}
// The manager is gone for a while; ask until it answers again, then show the new state.
// Reload only after it was gone once — otherwise the old manager, still shutting down,
// would answer and the page would show the old version.
function waitForManager() {
let tries = 0;
let wasDown = false;
const timer = setInterval(async () => {
tries++;
if (!card.isConnected || tries > 120) { clearInterval(timer); return; }
try {
await api("/verwaltung/update");
if (!wasDown) return;
clearInterval(timer);
location.reload();
} catch {
wasDown = true;
}
}, 3000);
}
render(status);
return card;
}
+236
View File
@@ -0,0 +1,236 @@
using System.Diagnostics;
using ClawdDotNet.Licensing;
using Deploymentcenter.Client;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// <summary>#795: What the last update check found.</summary>
public sealed record UpdateStatus(
string InstalledVersion,
bool Available,
string? LatestVersion,
bool Critical,
string? Notes,
DateTimeOffset? CheckedAt,
string? Problem,
bool UnderSystemd);
/// <summary>#795: The outcome of asking for an update to be installed.</summary>
/// <param name="ShutdownFollows">The manager ends now; the update agent or systemd brings it back.</param>
public sealed record UpdateStart(bool Ok, string Message, bool ShutdownFollows = false);
/// <summary>
/// #795: Offering and installing an update from the web interface — until now only the desktop
/// window did that (<c>App.OfferUpdateAsync</c>), and every installation without a window was
/// updated by hand.
///
/// <para><b>In the manager, not the instance.</b> The package holds the manager and the instance
/// program together; the manager is the one process that can stop all instances first, so the
/// update agent does not write over files that running instances hold open.</para>
///
/// <para><b>Two ways, chosen by how the manager runs</b> (Deploymentcenter
/// SETUP_INTEGRATION_GUIDE §4A):</para>
/// <list type="bullet">
/// <item><b>Without systemd</b> the update agent waits for the manager to end
/// (<c>--wait-for-pid</c>) and starts it again afterwards (<c>--restart</c>).</item>
/// <item><b>Under systemd</b> a restart by the agent would be a second manager next to the
/// service. Instead a oneshot unit stops the service, runs the agent and starts the service
/// (<c>deploy/systemd/clawddotnet-update.service</c>); the manager only triggers it. Without
/// the unit nothing is stopped for nothing: the instances come back up and the answer says
/// what is missing.</item>
/// </list>
/// </summary>
public sealed class ManagerUpdates
{
/// <summary>The oneshot unit that stops, updates and starts the manager service.</summary>
public const string UpdateUnit = "clawddotnet-update.service";
private readonly ManagerCore _manager;
private readonly ILogger _logger;
private readonly string _version;
private readonly Func<CancellationToken, Task<UpdateCheckResult>> _check;
private readonly Func<string?> _launchAgent;
private readonly Func<string[], (int ExitCode, string Output)> _systemctl;
private readonly bool _underSystemd;
private readonly SemaphoreSlim _once = new(1, 1);
private UpdateStatus _status;
/// <summary>Raised after a successful hand-over: the caller ends the manager.</summary>
public event Action? ShutdownRequested;
public ManagerUpdates(ManagerCore manager, string version, ILogger logger)
: this(manager, version, logger,
check: null, launchAgent: null, systemctl: null,
underSystemd: OperatingSystem.IsLinux() && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("INVOCATION_ID")))
{
}
/// <summary>For tests: server, agent and systemctl given, not real.</summary>
internal ManagerUpdates(
ManagerCore manager, string version, ILogger logger,
Func<CancellationToken, Task<UpdateCheckResult>>? check,
Func<string?>? launchAgent,
Func<string[], (int, string)>? systemctl,
bool underSystemd)
{
_manager = manager;
_version = version;
_logger = logger;
_underSystemd = underSystemd;
_check = check ?? CheckWithServerAsync;
_launchAgent = launchAgent ?? LaunchAgent;
_systemctl = systemctl ?? RunSystemctl;
_status = new UpdateStatus(version, false, null, false, null, null, null, underSystemd);
}
public UpdateStatus Status => _status;
public async Task<UpdateStatus> CheckAsync(CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_manager.Settings.LicenseKey))
return _status = _status with { Problem = "Without a license key there is no access to the releases.", CheckedAt = DateTimeOffset.Now };
try
{
var result = await _check(ct).ConfigureAwait(false);
// A 401 says something about the license, not the network.
var problem = result.Unauthorized
? "The release store refused the license key."
: result.Error is not null ? $"Update check failed: {result.Message}" : null;
return _status = new UpdateStatus(
_version, problem is null && result.UpdateAvailable, result.LatestRelease?.Version,
result.IsCritical, result.LatestRelease?.Changelog, DateTimeOffset.Now, problem, _underSystemd);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Update check failed");
return _status = _status with { Problem = $"Update check failed: {ex.Message}", CheckedAt = DateTimeOffset.Now };
}
}
/// <summary>
/// Stops every instance in order and hands over to the update agent (or the update unit).
/// On any failure before the hand-over, the instances that ran are started again.
/// </summary>
public async Task<UpdateStart> InstallAsync()
{
if (!await _once.WaitAsync(0).ConfigureAwait(false))
return new UpdateStart(false, "An update is already being started.");
try
{
if (string.IsNullOrWhiteSpace(_manager.Settings.LicenseKey))
return new UpdateStart(false, "Without a license key the update agent cannot fetch the release.");
if (_underSystemd && _systemctl(["--user", "cat", UpdateUnit]).ExitCode != 0)
{
return new UpdateStart(false,
$"The manager runs under systemd, but the unit {UpdateUnit} is not installed. "
+ "Install it from deploy/systemd (see docs/InstanceSetupGuide.md) — nothing was stopped.");
}
// Which ones ran, so a failed hand-over can bring exactly those back.
var running = _manager.Instances.Instances
.Where(i => i.Condition is InstanceCondition.Running or InstanceCondition.Silent)
.Select(i => i.Directory)
.ToList();
// Without the request's token: a closed browser must not leave half the instances stopped.
await _manager.Instances.StopAllAsync("Update wird eingespielt", CancellationToken.None).ConfigureAwait(false);
string? problem;
if (_underSystemd)
{
// --no-block: the unit stops this very service; waiting for it would wait for our own end.
var (exit, output) = _systemctl(["--user", "start", "--no-block", UpdateUnit]);
problem = exit == 0 ? null : $"systemctl could not start {UpdateUnit}: {output.Trim()}";
}
else
{
problem = _launchAgent();
}
if (problem is not null)
{
_logger.LogWarning("Update not started: {Problem} - restarting instances", problem);
foreach (var directory in running)
await _manager.StartInstanceAsync(directory, CancellationToken.None).ConfigureAwait(false);
return new UpdateStart(false, problem);
}
_logger.LogInformation("Update handed over ({Way}); the manager ends now.", _underSystemd ? UpdateUnit : "update-agent");
if (!_underSystemd)
ShutdownRequested?.Invoke();
return new UpdateStart(true, _underSystemd
? "Update started. The service is stopped, updated and started again; this page reconnects in a minute or two."
: "Update started. The manager ends now and is started again by the update agent.",
ShutdownFollows: true);
}
finally
{
_once.Release();
}
}
private Task<UpdateCheckResult> CheckWithServerAsync(CancellationToken ct)
=> new UpdateClient().CheckForUpdateAsync(
_manager.Settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, _version,
_manager.Settings.UpdateChannel,
credentials: ReleaseCredentials.FromLicenseKey(_manager.Settings.LicenseKey),
cancellationToken: ct);
/// <returns>The reason it did not start, or <c>null</c>.</returns>
private string? LaunchAgent()
{
if (UpdateClient.ResolveAgentPath() is not { } agent)
return "The update agent was not found next to the manager; the installation package brings it.";
try
{
// The key goes through the environment, never argv (the SDK takes care of that).
// Unsigned releases are not installed: the hash comes from the same source as the
// package, so whoever swaps one swaps both.
return UpdateClient.LaunchUpdateAgent(
agentPath: agent,
projectId: LicenseInfo.ProductSlug,
channel: _manager.Settings.UpdateChannel,
action: "update",
version: "latest",
exitCurrentApp: false,
currentVersion: _version,
waitForCurrentProcess: true,
licenseKey: _manager.Settings.LicenseKey,
waitTimeoutSeconds: 120,
requireSignature: true)
? null
: "The update agent could not be started.";
}
catch (Exception ex)
{
return $"The update agent could not be started: {ex.Message}";
}
}
private static (int, string) RunSystemctl(string[] args)
{
try
{
var start = new ProcessStartInfo("systemctl") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var a in args) start.ArgumentList.Add(a);
using var p = Process.Start(start)!;
var output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd();
p.WaitForExit(30_000);
return (p.HasExited ? p.ExitCode : -1, output);
}
catch (Exception ex)
{
return (-1, ex.Message);
}
}
}
+11 -3
View File
@@ -102,7 +102,13 @@ internal static class Program
backups.Start();
await using var web = ManagerWebHost.TryStart(manager, backups, loggerFactory);
// #795: Updates from the web interface. After a hand-over to the update agent the
// manager ends like on SIGTERM - the agent waits for exactly that.
// Three parts, as releases are named ("0.1.18", not the assembly's "0.1.18.0").
var updates = new ManagerUpdates(
manager, string.Join('.', Version().Split('.').Take(3)), loggerFactory.CreateLogger("ClawdDotNet.Manager.Update"));
await using var web = ManagerWebHost.TryStart(manager, backups, loggerFactory, updates);
if (web is null)
{
@@ -143,7 +149,7 @@ internal static class Program
// gestartet hat; die Datei liest hinterher irgendwer.
logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url);
await WaitForShutdownAsync(logger);
await WaitForShutdownAsync(logger, updates);
logger.LogInformation(
"Instanzmanager wird beendet. Laufende Instanzen laufen weiter.");
@@ -295,7 +301,7 @@ internal static class Program
/// 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)
private static async Task WaitForShutdownAsync(ILogger logger, ManagerUpdates updates)
{
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -305,6 +311,8 @@ internal static class Program
logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
}
updates.ShutdownRequested += () => Beenden("Update");
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
context.Cancel = true;