#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
@@ -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
+30
View File
@@ -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=<your 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
+33
View File
@@ -609,3 +609,36 @@ Multi-Agent-Setup für automatisierte Web-Recherche mit Berichterstattung.
- [ ] Logs geprüft — keine Tool-Fehler? - [ ] Logs geprüft — keine Tool-Fehler?
- [ ] Scheduler aktiviert - [ ] Scheduler aktiviert
- [ ] Nach 24h: TokenUsage.json prüfen, Kosten validieren - [ ] 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=<Lizenzschlüssel>' >> ~/.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.
+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) ─── // ─── License and manager settings (#764) ───
verwaltung.MapGet("/license", (ManagerCore manager) => 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, /// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
/// steht im Protokoll.</para> /// steht im Protokoll.</para>
/// </summary> /// </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 logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
var settings = manager.Settings; var settings = manager.Settings;
@@ -82,7 +82,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
try try
{ {
var app = Build(manager, backups, loggerFactory, url, token, forwarding); var app = Build(manager, backups, updates, loggerFactory, url, token, forwarding);
app.StartAsync().GetAwaiter().GetResult(); app.StartAsync().GetAwaiter().GetResult();
@@ -105,6 +105,7 @@ public sealed class ManagerWebHost : IAsyncDisposable
private static WebApplication Build( private static WebApplication Build(
ManagerCore manager, ManagerCore manager,
ManagerBackups backups, ManagerBackups backups,
ManagerUpdates? updates,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
string url, string url,
string token, string token,
@@ -130,6 +131,8 @@ public sealed class ManagerWebHost : IAsyncDisposable
builder.Services.AddSingleton(manager); builder.Services.AddSingleton(manager);
builder.Services.AddSingleton(manager.Instances); builder.Services.AddSingleton(manager.Instances);
builder.Services.AddSingleton(backups); 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(AccessToken.For(token));
builder.Services.AddSingleton(forwarding); builder.Services.AddSingleton(forwarding);
+7 -5
View File
@@ -367,6 +367,13 @@ async function aktualisiereStagingZaehler() {
async function starteHauptAnwendung(instanzen) { async function starteHauptAnwendung(instanzen) {
versteckeAnmeldung(); 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 // Instanzenliste initialisieren
await aktualisiereInstanzenListe(Array.isArray(instanzen) ? instanzen : null); 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 // Router initialisieren
initialisiereRouter(); initialisiereRouter();
} }
@@ -669,6 +669,19 @@
"logs.empty": "Noch keine Protokolleinträge vorhanden.", "logs.empty": "Noch keine Protokolleinträge vorhanden.",
"settings.title": "Einstellungen & Systemarchitektur", "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.telegramCard": "Telegram-Client",
"settings.telegramState.Connecting": "Verbindet", "settings.telegramState.Connecting": "Verbindet",
"settings.telegramState.NeedsCode": "Code nötig", "settings.telegramState.NeedsCode": "Code nötig",
@@ -669,6 +669,19 @@
"logs.empty": "No log messages yet.", "logs.empty": "No log messages yet.",
"settings.title": "Settings & System Architecture", "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.telegramCard": "Telegram client",
"settings.telegramState.Connecting": "Connecting", "settings.telegramState.Connecting": "Connecting",
"settings.telegramState.NeedsCode": "Code needed", "settings.telegramState.NeedsCode": "Code needed",
@@ -129,14 +129,16 @@ async function managerSection() {
const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"), const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"),
[erstellePlakette(t("settings.managerBadge"), "warnung")], t("settings.managerIntro")); [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/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" }); const grid = erstelleElement("div", { klasse: "settings-grid" });
grid.append(licenseCard(license)); grid.append(licenseCard(license));
if (manager) grid.append(managerCard(manager)); if (manager) grid.append(managerCard(manager));
if (update) grid.append(updateCard(update));
el.append(grid); el.append(grid);
return el; return el;
} }
@@ -420,3 +422,81 @@ function telegramCard(initial) {
render(initial); render(initial);
return card; 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(); 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) if (web is null)
{ {
@@ -143,7 +149,7 @@ internal static class Program
// gestartet hat; die Datei liest hinterher irgendwer. // gestartet hat; die Datei liest hinterher irgendwer.
logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url); logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url);
await WaitForShutdownAsync(logger); await WaitForShutdownAsync(logger, updates);
logger.LogInformation( logger.LogInformation(
"Instanzmanager wird beendet. Laufende Instanzen laufen weiter."); "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 /// Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529, nur diesmal
/// beim Aufpasser.</para> /// beim Aufpasser.</para>
/// </summary> /// </summary>
private static async Task WaitForShutdownAsync(ILogger logger) private static async Task WaitForShutdownAsync(ILogger logger, ManagerUpdates updates)
{ {
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -305,6 +311,8 @@ internal static class Program
logger.LogInformation("Beendigung angefordert ({Grund}).", grund); logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
} }
updates.ShutdownRequested += () => Beenden("Update");
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{ {
context.Cancel = true; context.Cancel = true;
@@ -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;
/// <summary>
/// #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.
/// </summary>
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<string[]> _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<ManagerCore> RunningManagerAsync(string? license = "CLAWD-TEST-KEY")
{
var path = _directories.CreateInstance("Alpha");
var file = Path.Combine(path, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(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");
}
}