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>
237 lines
10 KiB
C#
237 lines
10 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|