using System.Globalization; using System.Text.Json; using Fleet.Core; using MoShiro.Protocol; namespace MoShiro.Measurement; /// /// 节点的「更新」:面板建议、请求,节点只负责有序退出,换新版本由外面的服务管理器完成—— /// 例如 Podman 的 Quadlet 单元写了 Pull=always,每次启动都先从镜像仓库拉最新的镜像,所以退出再被拉起就是更新。 /// 节点自己不下载、不安装、不执行任何东西,所以不需要验证什么:最多让面板(管理员)请求一次重启,和定时重启是同一种能力。 /// /// 只有设了环境变量 MOSHIRO_UPDATE=restart 才启用(Quadlet 模板里写好了):别的部署方式退出之后不会重新拉取,开了也不会更新,所以没有启用就不报告更新状态。 /// 策略由环境变量 MOSHIRO_UPDATE_POLICY 决定:auto(默认)发现新版本就自己退出重启,manual 等管理端请求。自动更新有保护: /// 进程至少运行 才会自动退出,新镜像没拉到(标签没变、仓库还没更新)最多每个这么久重启一次,不会陷入重启循环。 /// 面板转来的发布清单(面板登记时已验过 CI 签名)只用来显示「有没有新版本」:组件对、比这个程序新(seq 晚于构建时间)、tag 和提交不同。 /// /// public sealed class RestartUpdater { public const string EnvironmentVariable = "MOSHIRO_UPDATE"; public const string PolicyVariable = "MOSHIRO_UPDATE_POLICY"; /// 自动更新之前,进程至少要运行这么久。 public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10); private const int MaxManifestBytes = 4096; private readonly string _component; private readonly Action _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; /// 这个程序的构建标签、提交和构建时间(秒):默认取 ,测试可以传入。 public RestartUpdater(string component, Action 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() }; } /// 环境变量 MOSHIRO_UPDATE=restart 才启用;否则返回 null,节点不报告更新状态。 public static RestartUpdater? FromEnvironment(string component, Action 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; /// 面板转来的发布清单:只用来判断有没有新版本。 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"); } } /// 自动更新:等进程运行够久再退出(刚启动就发现新版本,通常是重启之后标签还没变,立刻再退出会变成重启循环)。 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(); } /// 面板请求更新:有新版本才退出(由外面的服务管理器拉起并拉取最新镜像)。 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; } /// 随心跳上报的状态。 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() ?? "" : ""; }