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