backend: require confirmation for signed channel updates [CI]
ci/woodpecker/pr/verify Pipeline failed
ci/woodpecker/pr/web-build unknown status

This commit is contained in:
justme committed 2026-10-07 12:57:20 +08:00
1 parent 7160de2a36
commit 84f9d211bc
62 files changed
+1717 -70

No files matched your search

@@ -0,0 +1,118 @@
using Fleet.Core;
using Microsoft.Extensions.Hosting;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>宿主机更新器的文件信箱端口。面板只写签名清单和确认的 seq,不执行命令。</summary>
public interface IBackendUpdateFiles
{
ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct);
ValueTask<UpdateStatus?> ReadStatusAsync(CancellationToken ct);
ValueTask<long?> ReadRequestAsync(CancellationToken ct);
ValueTask RequestAsync(long seq, CancellationToken ct);
}
public sealed record BackendUpdateView(string Channel, bool Enabled, string State, string? CurrentTag, string? CurrentDigest, ReleaseOffer? Available, string? Error);
/// <summary>Backend 的发布通知和确认。新清单只显示为可用更新,宿主机必须收到对应 seq 的确认才会重启。</summary>
public sealed class BackendUpdates : IHostedService
{
private readonly ReleaseService _releases;
private readonly IBackendUpdateFiles _files;
private readonly BackendOptions _options;
public BackendUpdates(ReleaseService releases, IBackendUpdateFiles files, BackendOptions options)
{
_releases = releases;
_files = files;
_options = options;
}
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 BuildInfo.Tag is { } tag && (tag.StartsWith("dev-", StringComparison.Ordinal) || tag.StartsWith("main-", StringComparison.Ordinal)) ? "dev" : "release";
}
public async ValueTask<BackendUpdateView> GetAsync(CancellationToken ct)
{
string channel = ChannelOf(_options);
ReleaseOffer? offer = await _releases.GetBackendAsync(channel, ct);
UpdateStatus? status = await _files.ReadStatusAsync(ct);
bool installed = offer is not null && (status?.Current == offer.Digest || BuildInfo.Tag == offer.Tag);
// 只有更新器确认过同一份清单才允许按钮执行;旧状态不能被套到新的清单上。
bool verified = offer is not null && status?.Available?.Seq == offer.Seq && status.Available.Digest == offer.Digest;
string state = status?.State ?? "unavailable";
if (installed)
{
state = "up_to_date";
}
else if (offer is not null && !verified && state is not ("invalid" or "failed" or "rolled_back"))
{
state = status is null ? "unavailable" : "checking";
}
if (verified && state == "available" && await _files.ReadRequestAsync(ct) == offer!.Seq)
{
state = "requested";
}
return new BackendUpdateView(channel, status is not null, state, BuildInfo.Tag, status?.Current, installed ? null : offer, status?.Error);
}
public async ValueTask<Result<bool>> ConfirmAsync(long seq, CancellationToken ct)
{
// 与 webhook 共用发布锁:确认之后即使又来了一个 tag,信箱中的请求仍只认本次确认的 seq。
await _releases.BackendGate.WaitAsync(ct);
try
{
BackendUpdateView view = await GetAsync(ct);
if (!view.Enabled || view.State != "available" || view.Available?.Seq != seq)
{
return Result<bool>.Fail(MoShiroErrors.PreconditionFailed, "the offered release changed or has not been verified by the host updater; refresh before confirming");
}
await _files.RequestAsync(seq, ct);
return Result<bool>.Ok(true);
}
finally
{
_releases.BackendGate.Release();
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
string channel = ChannelOf(_options);
if (await _releases.GetBackendAsync(channel, cancellationToken) is { } offer)
{
await _files.WriteOfferAsync(offer.Manifest, offer.Signature, cancellationToken);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}