#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:
co-authored by
Claude Opus 5.5
parent
4e41fc12d7
commit
d4b7f34200
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user