diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index 8471ccb..1e0a1bc 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -52,6 +52,8 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 603272e..2d4617e 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -21,6 +21,15 @@ public sealed class BackendOptions /// public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE"); + /// + /// 自动更新(环境变量 MoShiro__AutoUpdate=true):新版本登记之后过了 分钟自动确认,和管理员点「更新并重启」是同一条路径 + /// (先备份数据库,备份失败就不更新)。默认关:Backend 重启会让所有节点断一两分钟。同样需要 是 restart。 + /// + public bool AutoUpdate { get; set; } + + /// 新版本登记之后等多久再自动更新(分钟):连着推几次的时候只更新到最后一版,也给 CI 里同时发布的 Agent 版本留出时间。 + public int AutoUpdateDelayMinutes { get; set; } = 10; + /// /// 每个 Agent 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 /// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。 diff --git a/backend/src/MoShiro.Backend.Application/Updates/BackendAutoUpdater.cs b/backend/src/MoShiro.Backend.Application/Updates/BackendAutoUpdater.cs new file mode 100644 index 0000000..95ca8ba --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Updates/BackendAutoUpdater.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// +/// 自动更新(,默认关):每分钟看一眼面板上登记的 Backend 新版本,登记满 分钟之后 +/// 替管理员确认。确认走 ,所以数据库备份、清单签名、「只更新到登记时的那一版」都和手动更新一样; +/// 更新是有序退出,由 Quadlet 单元(Pull=always、Restart=always)拉起新镜像。 +/// +public sealed partial class BackendAutoUpdater : BackgroundService +{ + private static readonly TimeSpan s_startupDelay = TimeSpan.FromMinutes(2); + private static readonly TimeSpan s_interval = TimeSpan.FromMinutes(1); + + private readonly BackendUpdates _updates; + private readonly BackendOptions _options; + private readonly TimeProvider _time; + private readonly ILogger _log; + + public BackendAutoUpdater(BackendUpdates updates, BackendOptions options, TimeProvider time, ILogger log) + { + _updates = updates; + _options = options; + _time = time; + _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.AutoUpdate) + { + return; + } + + try + { + await Task.Delay(s_startupDelay, _time, stoppingToken); + using var timer = new PeriodicTimer(s_interval, _time); + do + { + try + { + await RunOnceAsync(stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Failed(ex); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + catch (OperationCanceledException) + { + // 后端正在停止 + } + } + + /// 检查一次;确认了更新(Backend 即将有序退出)返回 true。 + public async ValueTask RunOnceAsync(CancellationToken ct) + { + if (!_options.AutoUpdate || !_updates.Enabled) + { + return false; + } + + BackendUpdateView view = await _updates.GetAsync(ct); + if (view.State != "available" || view.Available is not { } offer) + { + return false; + } + + // 清单的 seq 是发布时间(秒) + TimeSpan age = _time.GetUtcNow() - DateTimeOffset.FromUnixTimeSeconds(offer.Seq); + if (age < TimeSpan.FromMinutes(Math.Max(0, _options.AutoUpdateDelayMinutes))) + { + return false; + } + + Fleet.Core.Result result = await _updates.ConfirmAsync(offer.Seq, ct); + if (!result.IsSuccess) + { + Refused(offer.Tag, result.Error.Message); + return false; + } + + AutoConfirmed(offer.Tag, (int)age.TotalMinutes); + return true; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "auto update: {Tag} was offered {Minutes} minutes ago, confirming it")] + private partial void AutoConfirmed(string tag, int minutes); + + [LoggerMessage(Level = LogLevel.Warning, Message = "auto update to {Tag} was not started: {Reason}")] + private partial void Refused(string tag, string reason); + + [LoggerMessage(Level = LogLevel.Warning, Message = "the auto update check failed")] + private partial void Failed(Exception exception); +} diff --git a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs index fdf0d42..25c02ea 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs @@ -69,6 +69,33 @@ public sealed class BackendUpdateTests : IAsyncLifetime Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode); } + /// 自动更新默认关;打开之后只在新版本登记满设定的时间才确认,走的是和手动一样的路径(先备份)。 + [Fact] + public async Task AutoUpdate_IsOffByDefault_AndConfirmsOnlyAfterTheOfferHasAged() + { + BackendOptions options = _backend.Services.GetRequiredService(); + var updater = _backend.Services.GetRequiredService(); + using HttpResponseMessage old = await PublishAsync(10); // seq 是发布时间(秒):1970 年,早就过了等待时间 + Assert.Equal(HttpStatusCode.Created, old.StatusCode); + + Assert.False(await updater.RunOnceAsync(default)); // 默认关 + Assert.Equal(0, _backend.Restarts.Count); + + options.AutoUpdate = true; + options.AutoUpdateDelayMinutes = 10; + using HttpResponseMessage fresh = await PublishAsync(TimeProvider.System.GetUtcNow().ToUnixTimeSeconds()); + Assert.Equal(HttpStatusCode.Created, fresh.StatusCode); + Assert.False(await updater.RunOnceAsync(default)); // 刚登记的版本还要等 + Assert.Equal(0, _backend.Restarts.Count); + + options.AutoUpdateDelayMinutes = 0; + Assert.True(await updater.RunOnceAsync(default)); + Assert.Equal(1, _backend.Restarts.Count); + Assert.Single(Directory.GetFiles(Path.Combine(_backend.DataDirectory, "backups"), "pre-update-*.db")); // 先备份 + Assert.False(await updater.RunOnceAsync(default)); // 已经在更新,不会再确认第二次 + Assert.Equal(1, _backend.Restarts.Count); + } + [Fact] public async Task Confirm_BacksUpTheDatabase_ThenExits_AndRejectsASupersededSequence() {