backend: confirm a registered update by itself after a delay when auto update is switched on [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
ci/woodpecker/push/dev-backend-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-22 10:59:32 +08:00
1 parent 4e7a8c2ba3
commit 49e787a7a5
4 files changed
+138

No files matched your search

@@ -52,6 +52,8 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<ReleaseService>();
services.AddSingleton<ScheduleService>();
services.AddSingleton<BackendUpdates>();
services.AddSingleton<BackendAutoUpdater>();
services.AddHostedService(provider => provider.GetRequiredService<BackendAutoUpdater>());
services.AddSingleton<JoinTokenService>();
services.AddSingleton<RawBrowserService>();
services.AddSingleton<EventService>();
@@ -21,6 +21,15 @@ public sealed class BackendOptions
/// </summary>
public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE");
/// <summary>
/// 自动更新(环境变量 <c>MoShiro__AutoUpdate=true</c>):新版本登记之后过了 <see cref="AutoUpdateDelayMinutes"/> 分钟自动确认,和管理员点「更新并重启」是同一条路径
/// (先备份数据库,备份失败就不更新)。默认关:Backend 重启会让所有节点断一两分钟。同样需要 <see cref="UpdateMode"/> 是 <c>restart</c>。
/// </summary>
public bool AutoUpdate { get; set; }
/// <summary>新版本登记之后等多久再自动更新(分钟):连着推几次的时候只更新到最后一版,也给 CI 里同时发布的 Agent 版本留出时间。</summary>
public int AutoUpdateDelayMinutes { get; set; } = 10;
/// <summary>
/// 每个 Agent 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
@@ -0,0 +1,100 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 自动更新(<see cref="BackendOptions.AutoUpdate"/>,默认关):每分钟看一眼面板上登记的 Backend 新版本,登记满 <see cref="BackendOptions.AutoUpdateDelayMinutes"/> 分钟之后
/// 替管理员确认。确认走 <see cref="BackendUpdates.ConfirmAsync"/>,所以数据库备份、清单签名、「只更新到登记时的那一版」都和手动更新一样;
/// 更新是有序退出,由 Quadlet 单元(<c>Pull=always</c>、<c>Restart=always</c>)拉起新镜像。
/// </summary>
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<BackendAutoUpdater> _log;
public BackendAutoUpdater(BackendUpdates updates, BackendOptions options, TimeProvider time, ILogger<BackendAutoUpdater> 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)
{
// 后端正在停止
}
}
/// <summary>检查一次;确认了更新(Backend 即将有序退出)返回 true。</summary>
public async ValueTask<bool> 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<bool> 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);
}
@@ -69,6 +69,33 @@ public sealed class BackendUpdateTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.Conflict, replay.StatusCode);
}
/// <summary>自动更新默认关;打开之后只在新版本登记满设定的时间才确认,走的是和手动一样的路径(先备份)。</summary>
[Fact]
public async Task AutoUpdate_IsOffByDefault_AndConfirmsOnlyAfterTheOfferHasAged()
{
BackendOptions options = _backend.Services.GetRequiredService<BackendOptions>();
var updater = _backend.Services.GetRequiredService<BackendAutoUpdater>();
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()
{