using System.Globalization; using Fleet.Core; using Microsoft.Extensions.Logging; using MoShiro.Protocol; namespace MoShiro.Backend.Application; /// 让外面的服务管理器(Quadlet 的 Pull=always 加 Restart=always)换上新镜像:Backend 只负责有序退出。 public interface IBackendRestart { void Request(); } /// 更新之前备份数据库:新版启动时会迁移数据库,旧版不认比自己新的库,回滚要靠这份备份。 public interface IDatabaseBackup { /// 在 里写一份一致的数据库副本,返回文件路径。 ValueTask BackupAsync(string directory, string name, CancellationToken ct); } public sealed record BackendUpdateView(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseOffer? Available, string? Error); /// /// Backend 的发布通知和确认,和 Agent 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出, /// Quadlet 单元每次启动都先拉最新镜像(Pull=always),所以退出再被拉起就是更新。Backend 自己不下载、不替换任何东西。 /// 只有设了环境变量 MOSHIRO_UPDATE=restart(Quadlet 模板里写好了)才启用;别的部署方式退出之后不会重新拉取。 /// 只提示不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟。 /// public sealed partial class BackendUpdates { /// 更新前的数据库备份保留几份。 private const int BackupsKept = 3; private readonly ReleaseService _releases; private readonly BackendOptions _options; private readonly IBackendRestart _restart; private readonly IDatabaseBackup _backup; private readonly ILogger _log; private readonly TimeProvider _time; private readonly object _gate = new(); private long _applyingSeq; public BackendUpdates(ReleaseService releases, BackendOptions options, IBackendRestart restart, IDatabaseBackup backup, ILogger log, TimeProvider time) { _releases = releases; _options = options; _restart = restart; _backup = backup; _log = log; _time = time; } public static string ChannelOf(BackendOptions options) { if (options.UpdateChannel is "dev" or "release") { return options.UpdateChannel; } if (options.UpdateChannel != "auto") { throw new InvalidOperationException("UpdateChannel must be auto, dev or release"); } string imageNamespace = options.ImageNamespace.Trim('/'); if (imageNamespace == "moshiro-dev" || imageNamespace.EndsWith("/moshiro-dev", StringComparison.Ordinal)) { return "dev"; } if (imageNamespace == "moshiro" || imageNamespace.EndsWith("/moshiro", StringComparison.Ordinal)) { return "release"; } return IsDevTag(BuildInfo.Tag) ? "dev" : "release"; } /// 开发构建的 tag:新格式 v0.4.1-dev.12-abc1234(含 -dev.),以及旧的 dev-<sha>、main-<sha>。 public static bool IsDevTag(string? tag) => tag is not null && (tag.Contains("-dev.", StringComparison.Ordinal) || tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal)); /// 环境变量 MOSHIRO_UPDATE=restart 才启用。 public bool Enabled => string.Equals(_options.UpdateMode, "restart", StringComparison.Ordinal); public async ValueTask GetAsync(CancellationToken ct) { string channel = ChannelOf(_options); ReleaseOffer? offer = await _releases.GetBackendAsync(channel, ct); bool installed = offer is not null && IsInstalled(offer); long applying; lock (_gate) { applying = _applyingSeq; } string state = !Enabled ? "unavailable" : installed || offer is null ? "up_to_date" : applying == offer.Seq ? "applying" : "available"; return new BackendUpdateView(channel, Enabled, state, BuildInfo.Tag, null, installed ? null : offer, null); } public async ValueTask> ConfirmAsync(long seq, CancellationToken ct) { // 与 webhook 共用发布锁:确认的是此刻面板上登记的那一份清单,确认之后又来了新 tag 也不会改变要更新到哪里。 await _releases.BackendGate.WaitAsync(ct); try { BackendUpdateView view = await GetAsync(ct); if (!view.Enabled || view.State != "available" || view.Available?.Seq != seq) { return Result.Fail(MoShiroErrors.PreconditionFailed, view.Enabled ? "the offered release changed or is already installed; refresh before confirming" : "updating needs MOSHIRO_UPDATE=restart (the Quadlet unit sets it)"); } string name = $"pre-update-{_time.GetUtcNow():yyyyMMddTHHmmssZ}-{seq}.db"; string directory = Path.Combine(_options.DataDirectory, "backups"); try { string path = await _backup.BackupAsync(directory, name, ct); BackedUp(path); Prune(directory); } catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException) { // 备份不了就不更新:旧版不认新库,没有备份就没有回头路 BackupFailed(ex); return Result.Fail(MoShiroErrors.Internal, "the database could not be backed up; the update was not started"); } lock (_gate) { _applyingSeq = seq; } Confirmed(view.Available.Tag); _restart.Request(); return Result.Ok(true); } finally { _releases.BackendGate.Release(); } } [LoggerMessage(Level = LogLevel.Information, Message = "database backed up before the update to {Path}")] private partial void BackedUp(string path); [LoggerMessage(Level = LogLevel.Error, Message = "the database backup failed; the update was not started")] private partial void BackupFailed(Exception ex); [LoggerMessage(Level = LogLevel.Information, Message = "update to {Tag} confirmed; exiting so the supervisor pulls the latest image")] private partial void Confirmed(string tag); private static bool IsInstalled(ReleaseOffer offer) { if (BuildInfo.Tag.Length > 0 && offer.Tag == BuildInfo.Tag) { return true; } // 清单的 seq 是发布时间(秒):不比正在运行的这个构建新,就没有更新 return DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) && offer.Seq <= built.ToUnixTimeSeconds(); } private static void Prune(string directory) { foreach (FileInfo old in new DirectoryInfo(directory).GetFiles("pre-update-*.db").OrderByDescending(f => f.Name, StringComparer.Ordinal).Skip(BackupsKept)) { old.Delete(); } } }