From d4b7f34200e5b317fa7221e407f17a353216f188 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Oct 2026 20:15:18 +0200 Subject: [PATCH] #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 --- deploy/systemd/clawddotnet-manager.service | 31 +++ deploy/systemd/clawddotnet-update.service | 30 +++ docs/InstanceSetupGuide.md | 33 +++ src/ClawdDotNet.Manager.Web/Endpoints.cs | 15 ++ src/ClawdDotNet.Manager.Web/ManagerWebHost.cs | 7 +- src/ClawdDotNet.Manager.Web/wwwroot/app.js | 12 +- .../wwwroot/i18n/de.json | 13 + .../wwwroot/i18n/en.json | 13 + .../wwwroot/views/settings.js | 84 ++++++- src/ClawdDotNet.Manager/ManagerUpdates.cs | 236 ++++++++++++++++++ src/ClawdDotNet.ManagerApp/Program.cs | 14 +- .../ManagerUpdatesTests.cs | 154 ++++++++++++ 12 files changed, 630 insertions(+), 12 deletions(-) create mode 100644 deploy/systemd/clawddotnet-manager.service create mode 100644 deploy/systemd/clawddotnet-update.service create mode 100644 src/ClawdDotNet.Manager/ManagerUpdates.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/ManagerUpdatesTests.cs diff --git a/deploy/systemd/clawddotnet-manager.service b/deploy/systemd/clawddotnet-manager.service new file mode 100644 index 0000000..4515fa2 --- /dev/null +++ b/deploy/systemd/clawddotnet-manager.service @@ -0,0 +1,31 @@ +# ClawdDotNet instance manager as a systemd user service (#795). +# +# Install (as the user that runs ClawdDotNet): +# cp clawddotnet-manager.service ~/.config/systemd/user/ +# # adjust the paths below to your installation directory +# systemctl --user daemon-reload +# systemctl --user enable --now clawddotnet-manager.service +# loginctl enable-linger "$USER" # keeps it running without a login session +# +# KillMode=process: stopping the service stops the manager, not the instances it started. +# The manager stops them itself when that is wanted (before an update, for example); +# a restart of the manager alone takes them over again. + +[Unit] +Description=ClawdDotNet instance manager +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/clawddotnet +ExecStart=%h/clawddotnet/ClawdDotNet.Instanzmanager +Restart=on-failure +RestartSec=15 +KillMode=process +# The console output carries the web access token on an interactive console only; +# the journal never gets it, but there is nothing else in it worth keeping. +StandardOutput=null +StandardError=journal + +[Install] +WantedBy=default.target diff --git a/deploy/systemd/clawddotnet-update.service b/deploy/systemd/clawddotnet-update.service new file mode 100644 index 0000000..30b005c --- /dev/null +++ b/deploy/systemd/clawddotnet-update.service @@ -0,0 +1,30 @@ +# Installs an update for ClawdDotNet, started from the web interface (#795). +# +# Why a unit of its own (Deploymentcenter SETUP_INTEGRATION_GUIDE §4A): started from the +# manager, the update agent would restart the manager itself - a second manager next to +# the service. Here systemd stops the service, the agent updates, and systemd starts the +# service again. The manager only stops its instances and triggers this unit: +# systemctl --user start --no-block clawddotnet-update.service +# +# Install next to clawddotnet-manager.service: +# cp clawddotnet-update.service ~/.config/systemd/user/ +# # adjust paths and channel below +# install -m 600 /dev/null ~/.config/clawddotnet/update.env +# echo 'DC_LICENSE_KEY=' >> ~/.config/clawddotnet/update.env +# systemctl --user daemon-reload +# +# The license key goes through the environment file (0600), never the command line: +# "ps" shows command lines to every user of the machine. + +[Unit] +Description=ClawdDotNet update (stop manager, update, start manager) + +[Service] +Type=oneshot +EnvironmentFile=%h/.config/clawddotnet/update.env +TimeoutStartSec=20min +ExecStartPre=/usr/bin/systemctl --user stop clawddotnet-manager.service +ExecStart=%h/clawddotnet/update-agent --action update --project clawddotnet --base-url https://dc.mhdf.de --channel prod --version latest --target-dir %h/clawddotnet --require-signature +# ExecStopPost and not ExecStartPost: it also runs when the update failed. A failed update +# must not leave the manager down. +ExecStopPost=/usr/bin/systemctl --user start clawddotnet-manager.service diff --git a/docs/InstanceSetupGuide.md b/docs/InstanceSetupGuide.md index 5242b8b..4cbdfd4 100644 --- a/docs/InstanceSetupGuide.md +++ b/docs/InstanceSetupGuide.md @@ -609,3 +609,36 @@ Multi-Agent-Setup für automatisierte Web-Recherche mit Berichterstattung. - [ ] Logs geprüft — keine Tool-Fehler? - [ ] Scheduler aktiviert - [ ] Nach 24h: TokenUsage.json prüfen, Kosten validieren + +--- + +## 8. Betrieb unter Linux: Dienst und Updates (#795) + +Der Instanzmanager läuft als systemd-Benutzerdienst; die Instanzen startet er selbst. Vorlagen +liegen unter `deploy/systemd/`: + +| Datei | Wofür | +|---|---| +| `clawddotnet-manager.service` | Der Manager als Dienst (`Restart=on-failure`, `KillMode=process`) | +| `clawddotnet-update.service` | Einmal-Unit für Updates: Manager stoppen → `update-agent` → Manager starten | + +```bash +mkdir -p ~/.config/systemd/user +cp deploy/systemd/*.service ~/.config/systemd/user/ +# Pfade (%h/clawddotnet) und Kanal (--channel) in beiden Dateien anpassen +install -m 600 /dev/null ~/.config/clawddotnet/update.env +echo 'DC_LICENSE_KEY=' >> ~/.config/clawddotnet/update.env +systemctl --user daemon-reload +systemctl --user enable --now clawddotnet-manager.service +loginctl enable-linger "$USER" +``` + +**Update aus der Weboberfläche** (Einstellungen → Instanzmanager → Update): Der Manager hält +erst alle Instanzen geordnet an und stößt dann `clawddotnet-update.service` an. Die Unit stoppt +den Dienst, spielt das Update ein und startet ihn wieder — auch wenn das Update scheitert +(`ExecStopPost`). Fehlt die Unit, hält der Manager nichts an und sagt, was fehlt. + +Warum nicht der update-agent allein: Aus dem Manager gestartet, würde er den Manager nach dem +Update selbst neu starten — an systemd vorbei, als zweiten Prozess neben dem Dienst +(Deploymentcenter, SETUP_INTEGRATION_GUIDE §4A). Ohne systemd (Windows, von Hand gestartet) +ist genau das der richtige Weg, und den geht der Manager dann auch. diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 8230c34..0bbb055 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -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) => diff --git a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs index 6792d76..1910c67 100644 --- a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs +++ b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs @@ -54,7 +54,7 @@ public sealed class ManagerWebHost : IAsyncDisposable /// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging, /// steht im Protokoll. /// - 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); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index 0bc4119..b351cc8 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -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(); } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 4204cf6..050b919 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -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", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 3db56ae..3404d67 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -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", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js index d619f31..3f9b101 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js @@ -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; +} diff --git a/src/ClawdDotNet.Manager/ManagerUpdates.cs b/src/ClawdDotNet.Manager/ManagerUpdates.cs new file mode 100644 index 0000000..42539ae --- /dev/null +++ b/src/ClawdDotNet.Manager/ManagerUpdates.cs @@ -0,0 +1,236 @@ +using System.Diagnostics; +using ClawdDotNet.Licensing; +using Deploymentcenter.Client; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Manager; + +/// #795: What the last update check found. +public sealed record UpdateStatus( + string InstalledVersion, + bool Available, + string? LatestVersion, + bool Critical, + string? Notes, + DateTimeOffset? CheckedAt, + string? Problem, + bool UnderSystemd); + +/// #795: The outcome of asking for an update to be installed. +/// The manager ends now; the update agent or systemd brings it back. +public sealed record UpdateStart(bool Ok, string Message, bool ShutdownFollows = false); + +/// +/// #795: Offering and installing an update from the web interface — until now only the desktop +/// window did that (App.OfferUpdateAsync), and every installation without a window was +/// updated by hand. +/// +/// In the manager, not the instance. 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. +/// +/// Two ways, chosen by how the manager runs (Deploymentcenter +/// SETUP_INTEGRATION_GUIDE §4A): +/// +/// Without systemd the update agent waits for the manager to end +/// (--wait-for-pid) and starts it again afterwards (--restart). +/// Under systemd 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 +/// (deploy/systemd/clawddotnet-update.service); 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. +/// +/// +public sealed class ManagerUpdates +{ + /// The oneshot unit that stops, updates and starts the manager service. + public const string UpdateUnit = "clawddotnet-update.service"; + + private readonly ManagerCore _manager; + private readonly ILogger _logger; + private readonly string _version; + private readonly Func> _check; + private readonly Func _launchAgent; + private readonly Func _systemctl; + private readonly bool _underSystemd; + private readonly SemaphoreSlim _once = new(1, 1); + + private UpdateStatus _status; + + /// Raised after a successful hand-over: the caller ends the manager. + 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"))) + { + } + + /// For tests: server, agent and systemctl given, not real. + internal ManagerUpdates( + ManagerCore manager, string version, ILogger logger, + Func>? check, + Func? launchAgent, + Func? 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 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 }; + } + } + + /// + /// 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. + /// + public async Task 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 CheckWithServerAsync(CancellationToken ct) + => new UpdateClient().CheckForUpdateAsync( + _manager.Settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, _version, + _manager.Settings.UpdateChannel, + credentials: ReleaseCredentials.FromLicenseKey(_manager.Settings.LicenseKey), + cancellationToken: ct); + + /// The reason it did not start, or null. + 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); + } + } +} diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs index d686b96..9eed6d9 100644 --- a/src/ClawdDotNet.ManagerApp/Program.cs +++ b/src/ClawdDotNet.ManagerApp/Program.cs @@ -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. /// - 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; diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerUpdatesTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerUpdatesTests.cs new file mode 100644 index 0000000..d67bd3e --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/ManagerUpdatesTests.cs @@ -0,0 +1,154 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using Deploymentcenter.Client; +using Deploymentcenter.Client.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// #795: Updates from the web interface. The promise: instances are stopped before anything is +/// handed over, nothing is stopped for nothing, and a failed hand-over brings them back. +/// +public sealed class ManagerUpdatesTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "mupd-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _channel = new(); + private readonly InstanceDirectoryManager _directories; + private readonly List _systemctlCalls = []; + + private static readonly SupervisionOptions Options = new( + ProbeInterval: TimeSpan.FromMilliseconds(50), + StartTimeout: TimeSpan.FromMilliseconds(600), + SilentChecksBeforeAlarm: 3, + StopGrace: TimeSpan.FromMilliseconds(300), + SilentKillGrace: TimeSpan.FromMilliseconds(300), + MaxDrainWait: TimeSpan.FromSeconds(2)); + + public ManagerUpdatesTests() + { + Directory.CreateDirectory(_root); + _directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + _channel.Launcher = _launcher; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* A leftover test folder does not turn a run red. */ } + } + + private async Task RunningManagerAsync(string? license = "CLAWD-TEST-KEY") + { + var path = _directories.CreateInstance("Alpha"); + var file = Path.Combine(path, "InstanceSettings.json"); + var config = JsonSerializer.Deserialize(File.ReadAllText(file))!; + config.AutostartByManager = true; + File.WriteAllText(file, JsonSerializer.Serialize(config)); + _channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-alpha", "Alpha", "0.1.18"); + + var store = new ManagerSettingsStore(_root); + store.Settings.LicenseKey = license ?? ""; + var registry = new InstanceRegistry(_directories, _launcher, _channel, NullLoggerFactory.Instance, Options); + var manager = new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance); + await manager.StartAsync(); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + return manager; + } + + private ManagerUpdates Updates(ManagerCore manager, bool systemd, bool unitInstalled = true, + string? agentProblem = null, int startExit = 0) + => new(manager, "0.1.18", NullLogger.Instance, + check: _ => Task.FromResult(new UpdateCheckResult { UpdateAvailable = true, LatestRelease = new VersionInfo { Version = "0.1.19" } }), + launchAgent: () => agentProblem, + systemctl: args => + { + _systemctlCalls.Add(args); + return args.Contains("cat") ? (unitInstalled ? 0 : 1, "") : (startExit, startExit == 0 ? "" : "failed"); + }, + underSystemd: systemd); + + [Fact] + public async Task Without_systemd_the_instances_stop_and_the_manager_ends() + { + await using var manager = await RunningManagerAsync(); + var updates = Updates(manager, systemd: false); + var ended = false; + updates.ShutdownRequested += () => ended = true; + + var result = await updates.InstallAsync(); + + result.Ok.ShouldBeTrue(result.Message); + ended.ShouldBeTrue(); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); + } + + [Fact] + public async Task Under_systemd_the_update_unit_is_started_without_blocking() + { + await using var manager = await RunningManagerAsync(); + var updates = Updates(manager, systemd: true); + var ended = false; + updates.ShutdownRequested += () => ended = true; + + (await updates.InstallAsync()).Ok.ShouldBeTrue(); + + _systemctlCalls.ShouldContain(a => a.SequenceEqual(new[] { "--user", "start", "--no-block", ManagerUpdates.UpdateUnit })); + ended.ShouldBeFalse(); // the unit stops the service; the manager does not end itself + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); + } + + [Fact] + public async Task Without_the_update_unit_nothing_is_stopped() + { + await using var manager = await RunningManagerAsync(); + + var result = await Updates(manager, systemd: true, unitInstalled: false).InstallAsync(); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldContain(ManagerUpdates.UpdateUnit); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + } + + [Fact] + public async Task A_failed_hand_over_brings_the_instances_back() + { + await using var manager = await RunningManagerAsync(); + var updates = Updates(manager, systemd: false, agentProblem: "agent not found"); + var ended = false; + updates.ShutdownRequested += () => ended = true; + + var result = await updates.InstallAsync(); + + result.Ok.ShouldBeFalse(); + ended.ShouldBeFalse(); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + } + + [Fact] + public async Task Without_a_license_key_nothing_happens() + { + await using var manager = await RunningManagerAsync(license: null); + + (await Updates(manager, systemd: false).InstallAsync()).Ok.ShouldBeFalse(); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + } + + [Fact] + public async Task The_check_reports_the_new_version() + { + await using var manager = await RunningManagerAsync(); + + var status = await Updates(manager, systemd: false).CheckAsync(); + + status.Available.ShouldBeTrue(); + status.LatestVersion.ShouldBe("0.1.19"); + status.InstalledVersion.ShouldBe("0.1.18"); + } +}