154 lines
7.2 KiB
C#
154 lines
7.2 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using Fleet.Core;
|
|
using MoShiro.Protocol;
|
|
|
|
namespace MoShiro.Measurement;
|
|
|
|
/// <summary>
|
|
/// 节点的「更新」:面板建议、请求,节点<b>只负责有序退出</b>,换新版本由外面的服务管理器完成——
|
|
/// 例如 Podman 的 Quadlet 单元写了 <c>Pull=always</c>,每次启动都先从镜像仓库拉最新的镜像,所以退出再被拉起就是更新。
|
|
/// 节点自己不下载、不安装、不执行任何东西,所以不需要验证什么:最多让面板(管理员)请求一次重启,和定时重启是同一种能力。
|
|
/// <list type="bullet">
|
|
/// <item>只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用(Quadlet 模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新,所以没有启用就不报告更新状态。</item>
|
|
/// <item>策略由环境变量 <c>MOSHIRO_UPDATE_POLICY</c> 决定:<c>auto</c>(默认)发现新版本就自己退出重启,<c>manual</c> 等管理端请求。自动更新有保护:
|
|
/// 进程至少运行 <see cref="MinimumUptime"/> 才会自动退出,新镜像没拉到(标签没变、仓库还没更新)最多每个这么久重启一次,不会陷入重启循环。</item>
|
|
/// <item>面板转来的发布清单(面板登记时已验过 CI 签名)只用来显示「有没有新版本」:组件对、比这个程序新(seq 晚于构建时间)、tag 和提交不同。</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public sealed class RestartUpdater
|
|
{
|
|
public const string EnvironmentVariable = "MOSHIRO_UPDATE";
|
|
public const string PolicyVariable = "MOSHIRO_UPDATE_POLICY";
|
|
|
|
/// <summary>自动更新之前,进程至少要运行这么久。</summary>
|
|
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
|
|
|
|
private const int MaxManifestBytes = 4096;
|
|
|
|
private readonly string _component;
|
|
private readonly Action<string> _requestRestart;
|
|
private readonly TimeProvider _time;
|
|
private readonly INodeLog _log;
|
|
private readonly string _tag;
|
|
private readonly string _commit;
|
|
private readonly long _builtAt;
|
|
private readonly object _gate = new();
|
|
private readonly bool _auto;
|
|
private int _autoScheduled;
|
|
private readonly long _startedAt;
|
|
private UpdateStatus _status;
|
|
|
|
/// <param name="tag">这个程序的构建标签、提交和构建时间(秒):默认取 <see cref="BuildInfo"/>,测试可以传入。</param>
|
|
public RestartUpdater(string component, Action<string> requestRestart, TimeProvider time, INodeLog log, string? tag = null, string? commit = null, long? builtAtEpoch = null, bool auto = false)
|
|
{
|
|
_auto = auto;
|
|
_startedAt = time.GetTimestamp();
|
|
_component = component;
|
|
_requestRestart = requestRestart;
|
|
_time = time;
|
|
_log = log;
|
|
_tag = tag ?? BuildInfo.Tag;
|
|
_commit = commit ?? BuildInfo.Commit;
|
|
_builtAt = builtAtEpoch ?? (DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) ? built.ToUnixTimeSeconds() : 0);
|
|
_status = new UpdateStatus { State = "up_to_date", At = Now() };
|
|
}
|
|
|
|
/// <summary>环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用;否则返回 null,节点不报告更新状态。</summary>
|
|
public static RestartUpdater? FromEnvironment(string component, Action<string> requestRestart, TimeProvider time, INodeLog log) =>
|
|
string.Equals(Environment.GetEnvironmentVariable(EnvironmentVariable), "restart", StringComparison.Ordinal)
|
|
? new RestartUpdater(component, requestRestart, time, log, auto: !string.Equals(Environment.GetEnvironmentVariable(PolicyVariable), "manual", StringComparison.OrdinalIgnoreCase))
|
|
: null;
|
|
|
|
/// <summary>面板转来的发布清单:只用来判断有没有新版本。</summary>
|
|
public void Offer(UpdateOfferParams offer)
|
|
{
|
|
if (offer.Manifest.Length is 0 or > MaxManifestBytes)
|
|
{
|
|
_log.Warn("ignoring an update offer of an unexpected size");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using JsonDocument document = JsonDocument.Parse(offer.Manifest);
|
|
JsonElement root = document.RootElement;
|
|
if (root.ValueKind != JsonValueKind.Object || Text(root, "component") != _component || !root.TryGetProperty("seq", out JsonElement seqValue) || !seqValue.TryGetInt64(out long seq))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string tag = Text(root, "tag");
|
|
string commit = Text(root, "commit");
|
|
bool same = (_tag.Length > 0 && tag == _tag) || (_commit.Length > 0 && commit.Length > 0 && commit == _commit);
|
|
bool available;
|
|
lock (_gate)
|
|
{
|
|
_status = same || seq <= _builtAt
|
|
? new UpdateStatus { State = "up_to_date", At = Now() }
|
|
: new UpdateStatus
|
|
{
|
|
State = "available",
|
|
Available = new UpdateAvailable { Tag = tag, Version = Text(root, "version"), Digest = Text(root, "digest"), Seq = seq },
|
|
At = Now(),
|
|
};
|
|
available = _status.State == "available";
|
|
}
|
|
|
|
if (available && _auto && Interlocked.Exchange(ref _autoScheduled, 1) == 0)
|
|
{
|
|
_ = ApplyAfterMinimumUptimeAsync();
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
_log.Warn("ignoring an update offer that is not valid JSON");
|
|
}
|
|
}
|
|
|
|
/// <summary>自动更新:等进程运行够久再退出(刚启动就发现新版本,通常是重启之后标签还没变,立刻再退出会变成重启循环)。</summary>
|
|
private async Task ApplyAfterMinimumUptimeAsync()
|
|
{
|
|
TimeSpan wait = MinimumUptime - _time.GetElapsedTime(_startedAt);
|
|
if (wait > TimeSpan.Zero)
|
|
{
|
|
_log.Info("update available, restarting automatically later", ("in", wait.ToString("c", CultureInfo.InvariantCulture)));
|
|
await Task.Delay(wait, _time);
|
|
}
|
|
|
|
RequestApply();
|
|
}
|
|
|
|
/// <summary>面板请求更新:有新版本才退出(由外面的服务管理器拉起并拉取最新镜像)。</summary>
|
|
public bool RequestApply()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (_status.State != "available")
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_status = new UpdateStatus { State = "applying", Available = _status.Available, At = Now() };
|
|
}
|
|
|
|
_log.Info("update requested, restarting so the supervisor pulls the latest image");
|
|
_requestRestart("update");
|
|
return true;
|
|
}
|
|
|
|
/// <summary>随心跳上报的状态。</summary>
|
|
public UpdateStatus Status()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
return new UpdateStatus { State = _status.State, Current = _status.Current, Available = _status.Available, Error = _status.Error, At = _status.At };
|
|
}
|
|
}
|
|
|
|
private long Now() => _time.GetUtcNow().ToUnixTimeSeconds();
|
|
|
|
private static string Text(JsonElement root, string name) =>
|
|
root.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
|
|
}
|