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); } } }