diff --git a/backend/fleet/README.md b/backend/fleet/README.md index a2a822e..e3557ce 100644 --- a/backend/fleet/README.md +++ b/backend/fleet/README.md @@ -23,7 +23,7 @@ 2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。 3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。 4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo` 序列化,不使用反射。 -5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点由退出后 systemd 重新拉镜像、Backend 和脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。 +5. **更新不在 Fleet 里**:节点进程没有自更新能力。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新是待办,见 [`TODO.md`](../../TODO.md)。 ## 身份 diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index c42f29a..33936b6 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -52,7 +52,6 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddHostedService(p => p.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 3336c7a..be820e0 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -15,11 +15,11 @@ public sealed class BackendOptions /// 节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。 public string NodeImageRegistry { get; set; } = "registry.oeo.one"; - /// 容器内更新请求目录;留空使用数据目录下的 update/。 - public string? BackendUpdateDirectory { get; set; } - - /// 宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。 - public string? BackendUpdateStatusDirectory { get; set; } + /// + /// 更新方式:restart(环境变量 MOSHIRO_UPDATE=restart,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent、Probe 一样; + /// 留空就不能从管理端更新(别的部署方式退出之后不会重新拉取)。 + /// + public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE"); /// /// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 diff --git a/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs b/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs index 42a48a5..9ec5a88 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/BackendUpdates.cs @@ -1,35 +1,53 @@ +using System.Globalization; using Fleet.Core; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using MoShiro.Protocol; namespace MoShiro.Backend.Application; -/// 宿主机更新器的文件信箱端口。面板只写签名清单和确认的 seq,不执行命令。 -public interface IBackendUpdateFiles +/// 让外面的服务管理器(Quadlet 的 Pull=always 加 Restart=always)换上新镜像:Backend 只负责有序退出。 +public interface IBackendRestart { - ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct); + void Request(); +} - ValueTask ReadStatusAsync(CancellationToken ct); - - ValueTask ReadRequestAsync(CancellationToken ct); - - ValueTask RequestAsync(long seq, CancellationToken ct); +/// 更新之前备份数据库:新版启动时会迁移数据库,旧版不认比自己新的库,回滚要靠这份备份。 +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 的发布通知和确认。新清单只显示为可用更新,宿主机必须收到对应 seq 的确认才会重启。 -public sealed class BackendUpdates : IHostedService +/// +/// Backend 的发布通知和确认,和 Agent、Probe 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出, +/// Quadlet 单元每次启动都先拉最新镜像(Pull=always),所以退出再被拉起就是更新。Backend 自己不下载、不替换任何东西。 +/// 只有设了环境变量 MOSHIRO_UPDATE=restart(Quadlet 模板里写好了)才启用;别的部署方式退出之后不会重新拉取。 +/// 只提示不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟。 +/// +public sealed partial class BackendUpdates { - private readonly ReleaseService _releases; - private readonly IBackendUpdateFiles _files; - private readonly BackendOptions _options; + /// 更新前的数据库备份保留几份。 + private const int BackupsKept = 3; - public BackendUpdates(ReleaseService releases, IBackendUpdateFiles files, BackendOptions options) + 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; - _files = files; _options = options; + _restart = restart; + _backup = backup; + _log = log; + _time = time; } public static string ChannelOf(BackendOptions options) @@ -63,45 +81,63 @@ public sealed class BackendUpdates : IHostedService 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); - 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) + bool installed = offer is not null && IsInstalled(offer); + long applying; + lock (_gate) { - 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"; + applying = _applyingSeq; } - 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); + 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,信箱中的请求仍只认本次确认的 seq。 + // 与 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, "the offered release changed or has not been verified by the host updater; refresh before confirming"); + 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)"); } - await _files.RequestAsync(seq, ct); + 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 @@ -110,14 +146,31 @@ public sealed class BackendUpdates : IHostedService } } - public async Task StartAsync(CancellationToken cancellationToken) + [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) { - string channel = ChannelOf(_options); - if (await _releases.GetBackendAsync(channel, cancellationToken) is { } offer) + if (BuildInfo.Tag.Length > 0 && offer.Tag == BuildInfo.Tag) { - await _files.WriteOfferAsync(offer.Manifest, offer.Signature, cancellationToken); + return true; } + + // 清单的 seq 是发布时间(秒):不比正在运行的这个构建新,就没有更新 + return DateTimeOffset.TryParse(BuildInfo.BuiltAt, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset built) && offer.Seq <= built.ToUnixTimeSeconds(); } - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + 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(); + } + } } diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index bf9b081..b74c817 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -24,20 +24,18 @@ public sealed partial class ReleaseService : IDisposable private readonly ISettingsStore _settings; private readonly IAgentGateway _gateway; - private readonly IBackendUpdateFiles _backendFiles; private readonly BackendOptions _options; private readonly ReleaseKeyService _keys; private readonly ReleaseFeed _feed; private readonly ConcurrentDictionary _feedChecked = new(); private readonly ConcurrentDictionary _status = new(); - public ReleaseService(ISettingsStore settings, IAgentGateway gateway, IBackendUpdateFiles backendFiles, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed) + public ReleaseService(ISettingsStore settings, IAgentGateway gateway, BackendOptions options, ReleaseKeyService keys, ReleaseFeed feed) { _keys = keys; _feed = feed; _settings = settings; _gateway = gateway; - _backendFiles = backendFiles; _options = options; } @@ -71,14 +69,7 @@ public sealed partial class ReleaseService : IDisposable string componentKey = backend ? BackendComponent + "." + offer.Channel : offer.Component; await _settings.SetAsync(Key(componentKey, "manifest"), offer.Manifest, ct); await _settings.SetAsync(Key(componentKey, "signature"), offer.Signature, ct); - if (backend) - { - if (offer.Channel == BackendUpdates.ChannelOf(_options)) - { - await _backendFiles.WriteOfferAsync(offer.Manifest, offer.Signature, ct); - } - } - else + if (!backend) { foreach (NodeId node in OnlineOf(offer.Component)) { diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/BackendUpdateFiles.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/BackendUpdateFiles.cs deleted file mode 100644 index 2b08f8b..0000000 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/BackendUpdateFiles.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; -using MoShiro.Backend.Application; -using MoShiro.Protocol; - -namespace MoShiro.Backend.Storage.Sqlite; - -/// Backend 到宿主机的文件信箱。状态卷只读挂载,业务进程改写不了验签和安装结果。 -public sealed class BackendUpdateFiles : IBackendUpdateFiles -{ - private readonly string _requests; - private readonly string _status; - - public BackendUpdateFiles(BackendOptions options) - { - _requests = Path.GetFullPath(options.BackendUpdateDirectory ?? Path.Combine(options.DataDirectory, "update")); - _status = Path.GetFullPath(options.BackendUpdateStatusDirectory ?? Path.Combine(options.DataDirectory, "update-status")); - } - - public async ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct) - { - await WriteAsync("offer.manifest", manifest, ct); - await WriteAsync("offer.sig", signature, ct); - } - - public async ValueTask ReadStatusAsync(CancellationToken ct) - { - string? json = await ReadAsync(Path.Combine(_status, "status.json"), 4096, ct); - if (json is null) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.UpdateStatus); - } - catch (JsonException) - { - return null; - } - } - - public async ValueTask ReadRequestAsync(CancellationToken ct) - { - string? request = await ReadAsync(Path.Combine(_requests, "apply.request"), 64, ct); - return long.TryParse(request, NumberStyles.None, CultureInfo.InvariantCulture, out long seq) ? seq : null; - } - - public ValueTask RequestAsync(long seq, CancellationToken ct) => WriteAsync("apply.request", seq.ToString(CultureInfo.InvariantCulture), ct); - - private async ValueTask WriteAsync(string name, string value, CancellationToken ct) - { - Directory.CreateDirectory(_requests); - string path = Path.Combine(_requests, name); - string temporary = Path.Combine(_requests, $".{name}.{Guid.NewGuid():N}"); - try - { - await File.WriteAllTextAsync(temporary, value, new UTF8Encoding(false), ct); - File.Move(temporary, path, overwrite: true); - } - finally - { - if (File.Exists(temporary)) - { - File.Delete(temporary); - } - } - } - - private static async ValueTask ReadAsync(string path, int limit, CancellationToken ct) - { - try - { - var file = new FileInfo(path); - if (!file.Exists || file.LinkTarget is not null || file.Length > limit) - { - return null; - } - - return await File.ReadAllTextAsync(path, ct); - } - catch (IOException) - { - return null; // 更新器正做原子替换,下一次读取再试 - } - } -} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteDatabaseBackup.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteDatabaseBackup.cs new file mode 100644 index 0000000..a2f2962 --- /dev/null +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/SqliteDatabaseBackup.cs @@ -0,0 +1,37 @@ +using Microsoft.Data.Sqlite; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Storage.Sqlite; + +/// 用 VACUUM INTO 写一份一致的数据库副本:不停服务,WAL 里还没合并的写入也在里面。 +public sealed class SqliteDatabaseBackup : IDatabaseBackup +{ + private readonly SqliteDatabase _database; + + public SqliteDatabaseBackup(SqliteDatabase database) + { + _database = database; + } + + public ValueTask BackupAsync(string directory, string name, CancellationToken ct) + { + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, name); + try + { + _database.Read(connection => + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "VACUUM INTO $path"; + command.Parameters.AddWithValue("$path", path); + return command.ExecuteNonQuery(); + }); + } + catch (SqliteException ex) + { + throw new IOException("the database backup failed: " + ex.Message, ex); + } + + return ValueTask.FromResult(path); + } +} diff --git a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs index 5370a80..6c3ad76 100644 --- a/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Storage.Sqlite/StorageServiceCollectionExtensions.cs @@ -27,7 +27,7 @@ public static class StorageServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/backend/src/MoShiro.Backend/BackendHost.cs b/backend/src/MoShiro.Backend/BackendHost.cs index 3433f79..bb6e647 100644 --- a/backend/src/MoShiro.Backend/BackendHost.cs +++ b/backend/src/MoShiro.Backend/BackendHost.cs @@ -48,6 +48,7 @@ public static class BackendHost services.AddSingleton(new HttpGeoLookup(options.GeoLookupUrl)); } + services.AddSingleton(); services.AddMoShiroApplication(options); services.AddMoShiroGateway(); services.AddMoShiroApi(); diff --git a/backend/src/MoShiro.Backend/HostBackendRestart.cs b/backend/src/MoShiro.Backend/HostBackendRestart.cs new file mode 100644 index 0000000..fe5848e --- /dev/null +++ b/backend/src/MoShiro.Backend/HostBackendRestart.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Hosting; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend; + +/// 更新确认之后让 Backend 有序退出:晚一会儿再停,先把确认请求的响应送出去。外面的 Quadlet 单元(Restart=always、Pull=always)会拉最新镜像再启动。 +public sealed class HostBackendRestart : IBackendRestart +{ + private static readonly TimeSpan s_delay = TimeSpan.FromSeconds(1); + + private readonly IHostApplicationLifetime _lifetime; + private readonly TimeProvider _time; + + public HostBackendRestart(IHostApplicationLifetime lifetime, TimeProvider time) + { + _lifetime = lifetime; + _time = time; + } + + public void Request() => + _ = Task.Run(async () => + { + await Task.Delay(s_delay, _time); + _lifetime.StopApplication(); + }); +} diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index f330c1e..0242576 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -21,6 +21,15 @@ internal static class Defaults /// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询), /// 并提供管理员客户端(Bearer 令牌)和假 Agent。 /// +public sealed class RecordingBackendRestart : MoShiro.Backend.Application.IBackendRestart +{ + private int _count; + + public int Count => Volatile.Read(ref _count); + + public void Request() => Interlocked.Increment(ref _count); +} + public class BackendFixture : IAsyncLifetime { public const string AdminUser = "admin"; @@ -47,6 +56,9 @@ public class BackendFixture : IAsyncLifetime /// 带 admin 作用域的 API 令牌的客户端。 public HttpClient Admin { get; private set; } = null!; + /// Backend 更新确认之后「请求退出」的次数(测试里不真的退出)。 + public RecordingBackendRestart Restarts { get; } = new(); + public IServiceProvider Services => App.Services; /// 夹具创建的默认 Probe:合成块()和单线路的查询都用它。 @@ -267,7 +279,11 @@ public class BackendFixture : IAsyncLifetime options.NodeImageRegistry = string.Empty; // 同理:后台不去真实的镜像仓库取清单 options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }, - configureServices: services => services.AddSingleton(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签 + configureServices: services => + { + services.AddSingleton(TestReleaseKey.Instance); // 登记清单要验发布签名:测试用自己的密钥签 + services.AddSingleton(Restarts); // 更新确认之后只记一笔,不真的退出 + }); await App.StartAsync(); string address = App.Services.GetRequiredService().Features.Get()!.Addresses.First(); BaseAddress = new Uri(address); diff --git a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs index a13e83a..fdf0d42 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendUpdateTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; @@ -15,7 +14,9 @@ public sealed class BackendUpdateTests : IAsyncLifetime public async ValueTask InitializeAsync() { await _backend.InitializeAsync(); - _backend.Services.GetRequiredService().UpdateChannel = "dev"; + BackendOptions options = _backend.Services.GetRequiredService(); + options.UpdateChannel = "dev"; + options.UpdateMode = "restart"; } public ValueTask DisposeAsync() => _backend.DisposeAsync(); @@ -50,19 +51,6 @@ public sealed class BackendUpdateTests : IAsyncLifetime private Task ViewAsync() => _backend.GetJsonAsync(_backend.Admin, "/api/admin/backend/update"); - private async Task VerifiedAsync(long seq) - { - string directory = Path.Combine(_backend.DataDirectory, "update-status"); - Directory.CreateDirectory(directory); - var status = new UpdateStatus - { - State = "available", - Current = "sha256:" + new string('b', 64), - Available = new UpdateAvailable { Seq = seq, Digest = Digest, Tag = "dev-" + seq, Version = "0.1.1" }, - }; - await File.WriteAllTextAsync(Path.Combine(directory, "status.json"), JsonSerializer.Serialize(status, MoShiroJsonContext.Default.UpdateStatus)); - } - [Fact] public async Task Webhook_PreservesChannels_AndNeverConfirmsAnUpdateByItself() { @@ -73,9 +61,8 @@ public sealed class BackendUpdateTests : IAsyncLifetime JsonNode view = await ViewAsync(); Assert.Equal("dev", view["channel"]!.GetValue()); Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue()); - string requestDirectory = Path.Combine(_backend.DataDirectory, "update"); - Assert.False(File.Exists(Path.Combine(requestDirectory, "apply.request"))); - Assert.Contains("\"channel\":\"dev\"", await File.ReadAllTextAsync(Path.Combine(requestDirectory, "offer.manifest")), StringComparison.Ordinal); + Assert.Equal("available", view["state"]!.GetValue()); + Assert.Equal(0, _backend.Restarts.Count); // 发布只提示,不会自己重启 ReleaseOffer? stable = await _backend.Services.GetRequiredService().GetBackendAsync("release", default); Assert.Equal("release-1", stable!.Tag); using HttpResponseMessage replay = await PublishAsync(1); @@ -83,27 +70,41 @@ public sealed class BackendUpdateTests : IAsyncLifetime } [Fact] - public async Task Confirm_RequiresHostVerification_AndRejectsASupersededSequence() + public async Task Confirm_BacksUpTheDatabase_ThenExits_AndRejectsASupersededSequence() { using HttpResponseMessage first = await PublishAsync(10); Assert.Equal(HttpStatusCode.Created, first.StatusCode); - using HttpResponseMessage unverified = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); - Assert.Equal(HttpStatusCode.PreconditionFailed, unverified.StatusCode); - await VerifiedAsync(10); + using HttpResponseMessage wrong = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 9 })); + Assert.Equal(HttpStatusCode.PreconditionFailed, wrong.StatusCode); + Assert.Equal(0, _backend.Restarts.Count); + using HttpResponseMessage accepted = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); Assert.Equal(HttpStatusCode.NoContent, accepted.StatusCode); - string path = Path.Combine(_backend.DataDirectory, "update", "apply.request"); - Assert.Equal("10", await File.ReadAllTextAsync(path)); - Assert.Equal("requested", (await ViewAsync())["state"]!.GetValue()); + Assert.Equal(1, _backend.Restarts.Count); + Assert.Equal("applying", (await ViewAsync())["state"]!.GetValue()); + string backup = Assert.Single(Directory.GetFiles(Path.Combine(_backend.DataDirectory, "backups"), "pre-update-*.db")); + Assert.True(new FileInfo(backup).Length > 0); + using HttpResponseMessage second = await PublishAsync(11); Assert.Equal(HttpStatusCode.Created, second.StatusCode); using HttpResponseMessage stale = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 10 })); Assert.Equal(HttpStatusCode.PreconditionFailed, stale.StatusCode); - Assert.Equal("10", await File.ReadAllTextAsync(path)); // 新 webhook 不会把确认请求悄悄改成 11 - await VerifiedAsync(11); + Assert.Equal(1, _backend.Restarts.Count); // 新清单没有被悄悄当成已确认 using HttpResponseMessage newConfirmation = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 11 })); Assert.Equal(HttpStatusCode.NoContent, newConfirmation.StatusCode); - Assert.Equal("11", await File.ReadAllTextAsync(path)); + Assert.Equal(2, _backend.Restarts.Count); + } + + [Fact] + public async Task Confirm_IsRefusedWithoutRestartMode() + { + _backend.Services.GetRequiredService().UpdateMode = null; + using HttpResponseMessage published = await PublishAsync(30); + Assert.Equal(HttpStatusCode.Created, published.StatusCode); + Assert.False((await ViewAsync())["enabled"]!.GetValue()); + using HttpResponseMessage refused = await _backend.Admin.PostAsync("/api/admin/backend/update", BackendFixture.Json(new JsonObject { ["seq"] = 30 })); + Assert.Equal(HttpStatusCode.PreconditionFailed, refused.StatusCode); + Assert.Equal(0, _backend.Restarts.Count); } [Fact] @@ -119,6 +120,6 @@ public sealed class BackendUpdateTests : IAsyncLifetime Assert.Equal(HttpStatusCode.Created, release.StatusCode); SseEvent updated = await live.WaitForAsync(e => e.Name == "backend_update" && e.Data?["available"]?["seq"]?.GetValue() == 20); Assert.Equal("dev", updated.Data!["channel"]!.GetValue()); - Assert.False(File.Exists(Path.Combine(_backend.DataDirectory, "update", "apply.request"))); + Assert.Equal(0, _backend.Restarts.Count); } } diff --git a/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs b/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs index a71e9d4..91ce8ad 100644 --- a/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/ReleaseFeedTests.cs @@ -109,7 +109,7 @@ public sealed class ReleaseFeedTests : IAsyncLifetime Assert.Equal(seq, offer!.Seq); Assert.Equal(seq, (await releases.GetBackendAsync("dev", default))!.Seq); - Assert.Equal(manifest, await File.ReadAllTextAsync(Path.Combine(directory, "update", "offer.manifest"))); + } finally { diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 6919ed9..cfe84dd 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -31,7 +31,7 @@ │ 有公网 IP,监听 │ 先敲门再 TLS │ 家宽等出口,NAT 后 │ └────────────────────┘ └──────────────────────┘ 测量会话:TLS 控制通道 + QUIC Datagram/TCP 数据通道,由 Probe 主动拨出(Agent 固定 QUIC 端口,Probe 本地源端口轮换) - 宿主机更新器(容器节点):用本机公钥验签 → 按 digest 拉镜像 → 重启,失败回滚 + 更新(Agent、Probe、Backend):面板登记 CI 签名的清单 → 管理端确认 → 节点(Backend 先备份数据库)有序退出 → systemd Pull=always 拉最新镜像重启 ``` - **线路(line)** = 一对(Probe,Agent)。可以有多个 Probe(身份由 Probe 自己生成并用加入令牌注册,可以吊销、删除),每个 Probe 连接多个 Agent,每个 Agent 同时服务多个 Probe;一条线路的数据按 `line:` 单独存放,互不混合。对外的线路 id 是 `~`。 @@ -74,18 +74,20 @@ 发布清单(镜像 digest)由 CI 签名,放进镜像仓库(`<仓库>-manifest` 小镜像的标签里);Backend 定时取、验签后登记(也接受 CI 直接 `POST /api/release/offer`),再原样转发给在线的 Agent / Probe。之后分两条路: ```text -Podman Quadlet 节点(Agent、Probe,推荐) +Podman Quadlet(Agent、Probe、Backend,推荐) 节点比对清单和自己的版本 ──▶ 心跳报「有新版本」──▶ 管理端显示 自动(默认,运行满 10 分钟)或管理员点「更新」──▶ 节点有序退出 ──▶ systemd 重启单元(Pull=always)──▶ 新镜像、新容器 节点不下载、不替换任何东西,也不验证清单;最多被请求一次有序重启 -Backend 和一行脚本装的节点 +Backend:不自动,只有管理员点「更新并重启」;退出之前先把数据库备份到 backups/ + +一行脚本装的节点 宿主机 moshiro-update(root):用本机公钥验签 → 核对组件、仓库和 seq → podman pull 仓库@digest → 重启 → 不健康就回滚 ──▶ 状态写进只读的 update-status/ ──▶ 节点随心跳上报 ──▶ Backend ──▶ 管理端(有新版本时点「更新」) ``` -细节见 [deployment.md](../ops/deployment.md)「Podman(Quadlet)节点的更新」和「宿主机更新器」。 +细节见 [deployment.md](../ops/deployment.md)「Podman(Quadlet)的更新」(一行脚本装的节点另见「宿主机更新器」)。 ### 4.1 回显的四个时间戳 diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 2220e54..b35e66b 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -56,7 +56,7 @@ EOF podman compose up -d # Docker 用 docker compose ``` -**更新**:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但**旧版不认比自己新的数据库**:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用宿主机更新器(见下文「Backend 签名更新」,清单有发布签名,更新前自动备份,不健康自动回滚),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。 +**更新**:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但**旧版不认比自己新的数据库**:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用 Quadlet(`packaging/quadlet/moshiro-backend.container`,见下文「Backend 的更新」:管理端确认,更新前自动备份数据库),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。 **首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/admin/` 会进入认领向导: 1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码; @@ -258,19 +258,12 @@ f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "< 脚本是静态的(`packaging/install/install.sh` 和 `install.ps1`,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 `MOSHIRO_NAME`、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令);Podman 会顺便启用 `podman-restart`。 -**用脚本部署和更新(Backend;Agent 和 Probe 请用 Quadlet)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 +**用脚本部署和更新(Agent、Probe;推荐用 Quadlet,Backend 只有 Quadlet 和 compose)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。 ```sh # 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起) sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https:// --join --name sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https:// --join --name -# Backend(只支持 Podman,更新策略固定为管理端确认;--port 默认 29181,环境文件写在 /etc/moshiro/backend.env,已有的不覆盖) -sudo sh deploy-node.sh backend --channel dev --public-url https://<面板域名> -# 没有默认管理员:首次打开面板按页面引导认领(认领码写在后端日志里,或用 --env MoShiro__ClaimCode=… 自己指定) -# 其他环境变量用 --env 原样写入(只在首次创建 backend.env 时生效):本地开发前端要连这个面板时加 -# --env MoShiro__CorsOrigins=http://localhost:5173,http://localhost:5174 --env MoShiro__SecureCookies=false -# 面板前面有反向代理(Caddy、frp、Cloudflare)时,登录限流和审计看到的是代理的地址:把代理的地址写进可信代理,才会采信转发头里的客户端地址 -# --env MoShiro__TrustedProxies=127.0.0.1,172.16.0.0/12 (头默认 X-Forwarded-For,可用 MoShiro__TrustedProxyHeader 改成 CF-Connecting-IP) # 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old) sudo sh deploy-node.sh agent ``` @@ -309,7 +302,7 @@ Agent 的 Quadlet 单元已经包含以下只读挂载和环境变量: Probe 也上报同样的汇总数字(`probe.json` 的 `reporting.host_stats` 默认开,设为 `false` 就一个字也不上报,同时可以去掉 `/proc`、`/sys`、`/` 这三个挂载),所以 Probe 的 Quadlet 单元和 Agent 一样挂载它们并设置 `HOST_*` 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。 -## Podman(Quadlet)节点的更新(Agent、Probe,推荐) +## Podman(Quadlet)的更新(Agent、Probe、Backend,推荐) 节点跑成一个 Podman Quadlet 单元(`packaging/quadlet/moshiro-agent.container`、`moshiro-probe.container`,Podman 4.6 及以上),更新是**销毁容器、重新拉最新镜像再启动**:节点自己不下载、不替换任何东西,只是有序退出;Quadlet 生成的 systemd 服务用 `podman run --pull always` 启动,所以被拉起时先从镜像仓库拉最新的镜像。**默认自动更新**:面板发现新版本后,节点运行满 10 分钟(防止重启循环)就自己退出重启;想改成在管理端点「更新」才更新,在 env 文件里写 `MOSHIRO_UPDATE_POLICY=manual`。 @@ -328,9 +321,9 @@ CI 推镜像到 registry ─▶ 面板收到 CI 签名的清单 ─▶ 转发给 - **回滚**:新镜像起不来时 systemd 一直重试;把模板里的标签改回旧版本再 `systemctl --user restart` 即可。 - 测试:`packaging/quadlet` 里的模板用 `/usr/libexec/podman/quadlet -dryrun -user` 检查过(Podman 5.4);节点一侧的逻辑见 `RestartUpdaterTests`。 -## 宿主机更新器(Backend、一行脚本装的节点) +## 宿主机更新器(一行脚本装的节点) -节点的更新走上面的 Quadlet。下面的宿主机更新器现在只服务 Backend 和一行安装脚本装的节点,Backend 改成 Quadlet 之后会一并移除。 +节点和 Backend 的更新都走上面的 Quadlet。下面的宿主机更新器现在只服务一行安装脚本装的节点(它们没有 systemd 的 Pull=always 可用)。 更新器支持 rootless Podman 用户服务、rootful Podman 和 Docker 系统服务。配置、身份和更新请求通过命名 volume 传递,不需要指定宿主数据目录。短时辅助容器读取请求、写入独立状态 volume;辅助镜像必须已在本机拉取,更新器不会自动拉取辅助镜像。配置、公钥和已安装版本记录留在容器外。 @@ -378,24 +371,15 @@ CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给 - **发版**:打 `vMAJOR.MINOR.PATCH` tag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到 `moshiro/…-manifest:release`。 - **压缩包的校验和也签名**:Release 里除了每个压缩包的 `.sha256`,还有汇总的 `SHA256SUMS` 和 `SHA256SUMS.sig`(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:`base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS`,通过之后再 `sha256sum -c SHA256SUMS`。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。 - **首次部署**:`deploy-node.sh` 默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。`--tag` 指定标签时不验签并有警告;`--image` 直接用你给的引用。 -- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后 `sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>`,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。 +- **别人自己部署后端,什么都不用改**:仓库对匿名读开放(`moshiro/*` 和各 `*-manifest` 仓库),然后按 `packaging/quadlet/moshiro-backend.container` 的说明装 Backend(`MoShiro__PublicUrl` 写自己的域名),只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(`--image-repo`)并重新构建,这是 fork 的正常代价。 - 测试:`bash packaging/update/tests/feed-test.sh`(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。 -## Backend 签名更新 +## Backend 的更新 -Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 `/healthz`;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。 +Backend 和节点是同一种更新:`packaging/quadlet/moshiro-backend.container`(Quadlet,`Pull=always`、`Restart=always`、`Environment=MOSHIRO_UPDATE=restart`)。CI 推镜像并把 CI 签名的清单登记到面板,管理端显示「Backend 有新版本」,管理员点「更新并重启」(`POST /api/admin/backend/update`,带当时登记的清单 `seq`,清单变了就要重新确认);Backend 先把数据库备份到数据卷的 `backups/pre-update-<时间>-.db`(`VACUUM INTO`,保留最近 3 份,备份失败就不更新),再有序退出,systemd 重启单元并拉最新镜像。 -`MOSHIRO_UPDATE_CHANNEL=auto` 时,`MOSHIRO_IMAGE_NAMESPACE=moshiro-dev` 选择 `dev`,`moshiro` 选择 `release`。CI 发布流水线使用对应 channel 签名并登记到 Backend webhook。旧版 Backend 不识别 Backend 清单,因此首次升级需按常规部署流程引导一次;该期间 CI 对这类旧版面板的明确“不认识 backend 组件”响应会提示引导信息,其他 webhook 错误仍会令发布失败。 - -更新器以手动策略运行,并通过独立状态 volume 将状态只读挂载回 Backend: - -```sh -sudo packaging/update/install-updater.sh backend --engine podman \ - --volume moshiro-backend-data --status-volume moshiro-backend-status \ - --helper-image registry.oeo.one/moshiro-dev/moshiro-backend: \ - --image-repo registry.oeo.one/moshiro-dev/moshiro-backend \ - --unit moshiro-backend.service --policy manual --channel dev \ - --health-url http://127.0.0.1:29181/healthz -``` - -Backend 服务需要将 `moshiro-backend-status` 挂载到 `/data/update-status:ro`,并将镜像引用文件 `/etc/moshiro-update/backend.image` 作为 `IMAGE_FILE` 提供给 systemd 单元。release 实例使用 `--channel release` 及 `registry.oeo.one/moshiro` 仓库。更新请求由管理端确认后写入状态接口,不要启用全局 Podman 自动更新 timer。 +- **不自动重启**:Backend 指挥所有节点,重启会让它们全部断一两分钟,所以只提示,不像节点默认那样自动退出。 +- **没有 `MOSHIRO_UPDATE=restart` 就不能从管理端更新**(compose、一行脚本装的 Backend 没有重新拉取的机制),管理端会提示;这种部署继续用 `compose pull && compose up -d`。 +- **通道**:`MoShiro__UpdateChannel` 为 `auto` 时,镜像命名空间 `moshiro-dev` 选 `dev`,`moshiro` 选 `release`;没写命名空间就按当前运行的构建 tag 判断。 +- **回滚**:新版启动时会迁移数据库,旧版不认比自己新的库。要回旧版:停服务,把 `backups/pre-update-*.db` 复制成数据卷里的 `moshiro.db`(删掉旧的 `-wal`、`-shm`),把单元里的标签固定成旧版本号再启动。新版起不来时 systemd 会一直重试同一个镜像,没有自动回滚,也没有 `/healthz` 的探针(镜像里没有 curl)。 +- **首次升级**:还没有这个机制的旧版 Backend 不认 `MOSHIRO_UPDATE`,第一次要手动 `systemctl --user restart moshiro-backend`(Pull=always 拉到新镜像),之后就能从管理端更新。 diff --git a/docs/security/access.md b/docs/security/access.md index f930319..86392c6 100644 --- a/docs/security/access.md +++ b/docs/security/access.md @@ -65,7 +65,7 @@ - **对账测试**:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。 - **信任类**是界面上特别标出的(吊销、账户管理),授权和其他能力一样靠 API key 或会话。真正由 passkey 签名保护的是操作员集合,那是节点自己验证的。 - **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集。 -- **Backend 更新确认**:更新清单由 CI 的发布密钥签名,`seq` 防回滚,真正的门是发布密钥,不是确认这一步。 +- **Backend 更新确认**:更新清单由 CI 的发布密钥签名(面板登记时验签),`seq` 防重放;确认只是让 Backend 有序退出并备份数据库,拉哪个镜像由 Quadlet 单元里写死的仓库和标签决定,面板决定不了。 ## 4. 登录与会话 diff --git a/packaging/compose/backend/compose.yaml b/packaging/compose/backend/compose.yaml index d7af60b..c520253 100644 --- a/packaging/compose/backend/compose.yaml +++ b/packaging/compose/backend/compose.yaml @@ -27,7 +27,6 @@ services: MoShiro__DataDirectory: /data volumes: - moshiro-backend-data:/data - - moshiro-backend-status:/data/update-status:ro read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 @@ -40,5 +39,3 @@ services: volumes: moshiro-backend-data: name: moshiro-backend-data - moshiro-backend-status: - name: moshiro-backend-status diff --git a/packaging/container/deploy-node.sh b/packaging/container/deploy-node.sh index 6929027..31094c8 100644 --- a/packaging/container/deploy-node.sh +++ b/packaging/container/deploy-node.sh @@ -1,29 +1,25 @@ #!/bin/sh -# 部署或更新容器服务(Backend、Agent、Probe)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、 +# 部署或更新容器服务(Agent、Probe;Backend 用 Quadlet,见 packaging/quadlet/moshiro-backend.container)。可以重复运行:每次都会拉取镜像、把配置迁移到当前版本需要的样子、 # 重新安装单元和更新器,然后重启节点;已有的身份和配置不会被覆盖。 # # 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1 # 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1 -# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]... -# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。 # 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置) # 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old # -# Backend 只支持 Podman,更新策略固定为 manual(管理端确认),监听宿主网络的 --port。 # 需要 root、systemd,以及 Podman 或 Docker;脚本要和 packaging/ 里的 systemd/、update/ 放在一起。 # rootless Podman 见 docs/ops/deployment.md。 set -eu usage() { - echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2 + echo "usage: deploy-node.sh [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2 echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2 - echo " backend: [--channel dev|release] [--port N] [--admin-user U --admin-password P:只给自动化用,正常不要] [--public-url URL] [--env KEY=VALUE]..." >&2 exit 2 } ROLE="${1:-}"; shift || true -case "$ROLE" in agent|probe|backend) ;; *) usage ;; esac -ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL=""; PORT=""; ADMIN_USER=""; ADMIN_PASSWORD=""; PUBLIC_URL=""; EXTRA_ENV="" +case "$ROLE" in agent|probe) ;; *) usage ;; esac +ENGINE=""; REPO=""; TAG=""; IMAGE=""; SERVER=""; JOIN=""; NAME=""; POLICY=""; AUTHFILE=""; CHANNEL="" while [ $# -gt 0 ]; do case "$1" in --engine) ENGINE="$2"; shift 2 ;; @@ -36,12 +32,6 @@ while [ $# -gt 0 ]; do --policy) POLICY="$2"; shift 2 ;; --authfile) AUTHFILE="$2"; shift 2 ;; --channel) CHANNEL="$2"; shift 2 ;; - --port) PORT="$2"; shift 2 ;; - --admin-user) ADMIN_USER="$2"; shift 2 ;; - --admin-password) ADMIN_PASSWORD="$2"; shift 2 ;; - --public-url) PUBLIC_URL="$2"; shift 2 ;; - --env) EXTRA_ENV="$EXTRA_ENV$2 -"; shift 2 ;; *) usage ;; esac done @@ -59,13 +49,6 @@ conf_get() { [ -r "$CONF" ] && sed -n "s/^$1='\\(.*\\)'\$/\\1/p" "$CONF" | head [ -n "$AUTHFILE" ] || AUTHFILE="$(conf_get AUTHFILE)" [ -n "$ENGINE" ] || ENGINE="$(conf_get ENGINE)" [ -n "$CHANNEL" ] || CHANNEL="$(conf_get CHANNEL)" -if [ "$ROLE" = backend ]; then - [ -n "$CHANNEL" ] || { echo "backend needs --channel dev or release" >&2; exit 2; } - case "$CHANNEL" in dev) NAMESPACE=moshiro-dev ;; release) NAMESPACE=moshiro ;; *) echo "--channel must be dev or release" >&2; exit 2 ;; esac - [ -n "$REPO" ] || REPO="registry.oeo.one/$NAMESPACE/moshiro-backend" - [ -n "$PORT" ] || PORT=29181 - ENGINE=podman; POLICY=manual -fi [ -n "$REPO" ] || REPO="registry.oeo.one/moshiro/moshiro-$ROLE" [ -n "$POLICY" ] || POLICY=manual if [ -z "$ENGINE" ]; then @@ -78,14 +61,13 @@ case "$ENGINE" in esac V_CFG="moshiro-$ROLE-config" V_STATUS="moshiro-$ROLE-status" -if [ "$ROLE" = backend ]; then UNIT=moshiro-backend.service; V_CFG=moshiro-backend-data; fi for f in "$PACKAGING/systemd/$UNIT_SRC" "$PACKAGING/update/install-updater.sh" "$PACKAGING/update/moshiro-update.sh" "$PACKAGING/update/release-key.pub.pem"; do [ -r "$f" ] || { echo "missing $f (keep deploy-node.sh next to the packaging/ directory)" >&2; exit 1; } done # 1. 镜像:固定到 digest,后面的单元、辅助容器都用同一个引用。 # 默认从仓库里的签名清单取(<仓库>-manifest:<通道>,CI 发布的),用随脚本带的发布公钥验签,按清单里的 digest 固定—— -# 仓库和网络都不需要可信,也不需要面板在线(Backend 第一次部署时还没有面板)。 +# 仓库和网络都不需要可信,也不需要面板在线。 # --tag 指定标签就不验签(拉那个标签再固定 digest);--image 直接用你给的引用。 if [ "$ENGINE" = docker ] && [ -n "$AUTHFILE" ]; then export DOCKER_CONFIG="$(dirname "$AUTHFILE")"; fi pull_quiet() { @@ -137,38 +119,14 @@ in_volume() { "$ENGINE" run --rm -i --pull=never --network none --cap-drop ALL --security-opt no-new-privileges \ -v "$V_CFG:/etc/moshiro" --entrypoint /bin/sh "$IMAGE" -c "$1" } -[ "$ROLE" = backend ] || in_volume 'mkdir -p /etc/moshiro/update' /etc/moshiro/backend.env - ) - echo "config: created /etc/moshiro/backend.env" - fi -else - ID_DIR="/etc/moshiro/$ROLE-id" - if ! in_volume "test -f $ID_DIR/node.json" &2; exit 1; } - "$ENGINE" run --rm --pull=never --network host -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \ - --server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR" - fi +ID_DIR="/etc/moshiro/$ROLE-id" +if ! in_volume "test -f $ID_DIR/node.json" &2; exit 1; } + "$ENGINE" run --rm --pull=never --network host -v "$V_CFG:/etc/moshiro" --entrypoint /usr/local/bin/fleet-enroll "$IMAGE" \ + --server "$SERVER" --join "$JOIN" --name "$NAME" --identity-dir "$ID_DIR" fi if [ "$ROLE" = agent ]; then if ! in_volume 'test -f /etc/moshiro/agent.json' "$CONF_DIR/$ROLE.image" install -m 644 "$PACKAGING/systemd/$UNIT_SRC" "/etc/systemd/system/$UNIT" -[ "$ROLE" != backend ] || sed -i "s/:29181/:$PORT/" "/etc/systemd/system/$UNIT" set -- "$ROLE" --engine "$ENGINE" --volume "$V_CFG" --status-volume "$V_STATUS" --helper-image "$IMAGE" \ --image-repo "$REPO" --unit "$UNIT" --policy "$POLICY" --no-enable -[ "$ROLE" != backend ] || set -- "$@" --channel "$CHANNEL" --health-url "http://127.0.0.1:$PORT/healthz" [ -z "$AUTHFILE" ] || set -- "$@" --authfile "$AUTHFILE" sh "$PACKAGING/update/install-updater.sh" "$@" @@ -211,13 +167,5 @@ systemctl daemon-reload systemctl enable "$UNIT" "moshiro-update-$ROLE.timer" >/dev/null 2>&1 systemctl restart "$UNIT" systemctl start "moshiro-update-$ROLE.timer" -if [ "$ROLE" = backend ]; then - n=0 - until curl -fsS -o /dev/null "http://127.0.0.1:$PORT/healthz" 2>/dev/null; do - n=$((n + 1)); [ "$n" -lt 30 ] || { echo "backend did not become healthy" >&2; systemctl status "$UNIT" --no-pager >&2 || true; exit 1; } - sleep 2 - done -else - sleep 5 -fi +sleep 5 systemctl is-active "$UNIT" diff --git a/packaging/quadlet/moshiro-backend.container b/packaging/quadlet/moshiro-backend.container new file mode 100644 index 0000000..9383132 --- /dev/null +++ b/packaging/quadlet/moshiro-backend.container @@ -0,0 +1,50 @@ +# MoShiro Backend(面板)的 Podman Quadlet 单元(Podman 4.6 及以上),更新方式和 Agent、Probe 完全一样: +# systemd 每次启动这个单元时 Pull=always 先从镜像仓库拉最新的镜像,Backend 在管理端确认更新之后有序退出(先把数据库备份到数据卷的 backups/), +# Restart=always 把它拉起来,退出再被拉起就是更新(Backend 自己不下载、不替换任何东西)。 +# 不会自动重启:Backend 指挥所有节点,更新要管理员在管理端点「更新并重启」。 +# 回滚:新版启动时会迁移数据库,旧版不认比自己新的数据库。想回到旧版,先停服务,把 backups/pre-update-*.db 复制成数据卷里的 moshiro.db,再把 Image 固定成旧版本号。 +# +# 安装(rootless,普通用户,不用 sudo): +# mkdir -p ~/.config/containers/systemd ~/.config/moshiro +# 写 ~/.config/moshiro/backend.env,chmod 600,每行一个变量,至少: +# MoShiro__DataDirectory=/data +# MoShiro__PublicUrl=https://panel.example.com # 面板对外的地址;passkey 绑定在这个域名上,改了会让已注册的 passkey 失效 +# ASPNETCORE_HTTP_PORTS=8080 +# 前面有反向代理(Caddy、cloudflared、frp)时再加 MoShiro__TrustedProxies(代理连到 Backend 的地址,rootless pasta 网络下是宿主的局域网地址和 127.0.0.1) +# 和 MoShiro__TrustedProxyHeader(Cloudflare 用 CF-Connecting-IP,其他用 X-Forwarded-For),否则登录限流、审计和节点的国家识别看到的都是代理的地址。 +# 把本文件放进 ~/.config/containers/systemd/ +# systemctl --user daemon-reload && systemctl --user start moshiro-backend # Quadlet 会自动生成服务,不用 enable;[Install] 让它开机自启 +# loginctl enable-linger $USER # 退出登录后继续运行(需要管理员执行一次) +# 第一次打开面板按页面引导认领:没有默认管理员,认领码在日志里(podman logs moshiro-backend)。 +# rootful:本文件放进 /etc/containers/systemd/,env 文件放 /etc/moshiro/backend.env,把下面的 EnvironmentFile 改成这个路径、 +# WantedBy 改成 multi-user.target,用 systemctl(不加 --user)。 +# 开发通道:把 Image 的 moshiro 换成 moshiro-dev,并在 env 文件里加 MoShiro__UpdateChannel=dev。固定版本:把 :latest 换成 :<版本号>(固定之后更新不会换版本)。 +# 说明见 docs/ops/deployment.md。 +[Unit] +Description=MoShiro Backend +After=network-online.target +Wants=network-online.target + +[Container] +Image=registry.oeo.one/moshiro/moshiro-backend:latest +ContainerName=moshiro-backend +Pull=always +PublishPort=127.0.0.1:8080:8080 +EnvironmentFile=%h/.config/moshiro/backend.env +Volume=moshiro-backend-data:/data +ReadOnly=true +NoNewPrivileges=true +DropCapability=ALL +Tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 +StopTimeout=30 +# 告诉 Backend:退出之后外面会重新拉取,所以「更新」就是退出(没有这一行,管理端不能更新 Backend) +Environment=MOSHIRO_UPDATE=restart + +[Service] +Restart=always +RestartSec=5 +# 第一次拉取镜像可能要一会儿 +TimeoutStartSec=600 + +[Install] +WantedBy=default.target diff --git a/packaging/systemd/moshiro-backend-container.service b/packaging/systemd/moshiro-backend-container.service deleted file mode 100644 index 7cb586a..0000000 --- a/packaging/systemd/moshiro-backend-container.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=MoShiro Backend (signed container updates) -After=network-online.target -Wants=network-online.target - -[Service] -EnvironmentFile=/etc/moshiro-update/backend.image -Restart=always -RestartSec=5 -TimeoutStopSec=30 -ExecStartPre=-/usr/bin/podman rm -f moshiro-backend -ExecStart=/usr/bin/podman run --rm --name moshiro-backend --network host -e ASPNETCORE_URLS=http://0.0.0.0:29181 --read-only --tmpfs /tmp --security-opt no-new-privileges --cap-drop all --env-file /etc/moshiro/backend.env -v moshiro-backend-data:/data -v moshiro-backend-status:/data/update-status:ro ${IMAGE} -ExecStop=/usr/bin/podman stop -t 20 moshiro-backend - -[Install] -WantedBy=multi-user.target diff --git a/packaging/update/install-updater.sh b/packaging/update/install-updater.sh index d8bb477..e5a9b4e 100644 --- a/packaging/update/install-updater.sh +++ b/packaging/update/install-updater.sh @@ -5,7 +5,7 @@ set -eu COMPONENT="${1:-}"; shift || true -case "$COMPONENT" in agent|probe|backend) ;; *) echo "usage: install-updater.sh [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac +case "$COMPONENT" in agent|probe) ;; *) echo "usage: install-updater.sh [--user] [--engine podman|docker] --data-dir D --image-repo R --unit U [--policy auto|manual] [--channel dev|release] [--health-url URL] [--authfile F] [--no-enable]" >&2; exit 2 ;; esac DATA_DIR=""; IMAGE_REPO=""; UNIT=""; POLICY=manual; AUTHFILE=""; ENGINE=podman; SERVICE_SCOPE=system; ENABLE=true; DATA_VOLUME=""; STATUS_VOLUME=""; HELPER_IMAGE="" CHANNEL=""; HEALTH_URL="" while [ $# -gt 0 ]; do @@ -31,10 +31,6 @@ if [ -z "$IMAGE_REPO" ] || [ -z "$UNIT" ] || { [ -z "$DATA_DIR" ] && [ -z "$DATA fi case "$POLICY" in auto|manual) ;; *) echo "--policy must be auto or manual" >&2; exit 2 ;; esac case "$ENGINE" in podman|docker) ;; *) echo "--engine must be podman or docker" >&2; exit 2 ;; esac -if [ "$COMPONENT" = backend ]; then - [ "$POLICY" = manual ] && [ -n "$DATA_VOLUME" ] && [ -n "$HEALTH_URL" ] || { echo "Backend requires manual policy, a data volume and --health-url" >&2; exit 2; } - case "$CHANNEL" in dev|release) ;; *) echo "Backend requires --channel dev or release" >&2; exit 2 ;; esac -fi if [ -n "$DATA_VOLUME" ]; then [ -z "$DATA_DIR" ] || { echo "use --volume or --data-dir, not both" >&2; exit 2; } [ -n "$HELPER_IMAGE" ] || { echo "--volume needs --helper-image (an already pulled MoShiro image)" >&2; exit 2; } diff --git a/packaging/update/moshiro-update.sh b/packaging/update/moshiro-update.sh index 54883c2..9d6aae4 100644 --- a/packaging/update/moshiro-update.sh +++ b/packaging/update/moshiro-update.sh @@ -14,8 +14,8 @@ umask 077 CMD="${1:-}" COMPONENT="${2:-}" -case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update " >&2; exit 2 ;; esac -case "$COMPONENT" in agent|probe|backend) ;; *) echo "component must be agent, probe or backend" >&2; exit 2 ;; esac +case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update " >&2; exit 2 ;; esac +case "$COMPONENT" in agent|probe) ;; *) echo "component must be agent or probe" >&2; exit 2 ;; esac if [ "$(id -u)" -eq 0 ]; then DEFAULT_CONF_DIR=/etc/moshiro-update @@ -37,12 +37,6 @@ DATA_VOLUME="${DATA_VOLUME:-}" STATUS_VOLUME="${STATUS_VOLUME:-moshiro-$COMPONENT-status}" CHANNEL="${CHANNEL:-}" HEALTH_URL="${HEALTH_URL:-}" -if [ "$COMPONENT" = backend ]; then - [ "$POLICY" = manual ] || { echo "Backend updates require manual confirmation" >&2; exit 2; } - case "$CHANNEL" in dev|release) ;; *) echo "Backend needs CHANNEL=dev or release" >&2; exit 2 ;; esac - [ -n "$HEALTH_URL" ] || { echo "Backend needs a local HEALTH_URL" >&2; exit 2; } - [ -n "$DATA_VOLUME" ] || { echo "Backend backup needs DATA_VOLUME" >&2; exit 2; } -fi case "$ENGINE" in podman|docker) ;; *) echo "unsupported engine: $ENGINE" >&2; exit 2 ;; esac case "$SERVICE_SCOPE" in system|user) ;; *) echo "unsupported service scope: $SERVICE_SCOPE" >&2; exit 2 ;; esac service_ctl() { @@ -174,38 +168,17 @@ seed_installed() { current_digest() { [ -r "$INSTALLED" ] && sed -n 2p "$INSTALLED" || echo ""; } installed_seq() { [ -r "$INSTALLED" ] && sed -n 1p "$INSTALLED" || echo 0; } -# Backend 的请求带用户确认的 seq;复制以后再验清单,避免后来到达的 tag 被自动套用。 +# 面板(管理员)请求更新:apply.request 在数据卷里放着就是请求了 fetch_request() { if [ -n "$DATA_VOLUME" ]; then "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ -v "$DATA_VOLUME:/etc/moshiro:ro" --entrypoint /bin/sh "$HELPER_IMAGE" \ - -c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then if [ "$1" = backend ]; then head -c 64 "$f"; else printf yes; fi; fi' sh "$COMPONENT" + -c 'f=/etc/moshiro/update/apply.request; if [ -f "$f" ] && [ ! -L "$f" ]; then printf yes; fi' elif [ -f "$UPDATE_DIR/apply.request" ] && [ ! -L "$UPDATE_DIR/apply.request" ]; then - if [ "$COMPONENT" = backend ]; then head -c 64 "$UPDATE_DIR/apply.request"; else printf yes; fi + printf yes fi } -backend_backup() { - BACKUP="$STATE_DIR/backend-before-$M_SEQ.tar.gz" - service_ctl stop "$UNIT" - # 容器的标准输出就是备份本身:不能再让引擎把它当日志写进 journal(每次更新会多灌一份备份大小的日志) - if ! "$ENGINE" run --pull=never --rm --log-driver=none --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ - -v "$DATA_VOLUME:/data:ro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -czf - -C /data .' > "$BACKUP.tmp"; then - rm -f "$BACKUP.tmp" - service_ctl start "$UNIT" || true - write_status failed "$avail" "database backup failed" - return 1 - fi - chmod 600 "$BACKUP.tmp" - mv -f "$BACKUP.tmp" "$BACKUP" -} - -backend_restore() { - service_ctl stop "$UNIT" || true - "$ENGINE" run --pull=never --rm -i --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ - -v "$DATA_VOLUME:/data" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'tar -xzf - --no-same-owner -C /data' < "$BACKUP" -} - # 从扁平的清单里取字段(清单由 CI 生成,只有字符串和整数,没有嵌套) field() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$OFFER_MANIFEST" | head -n 1; } num_field() { sed -n 's/.*"'"$1"'":\([0-9][0-9]*\).*/\1/p' "$OFFER_MANIFEST" | head -n 1; } @@ -277,7 +250,6 @@ do_check() { } do_apply() { - requested_seq="$(fetch_request)" if [ -n "$DATA_VOLUME" ]; then "$ENGINE" run --pull=never --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges \ -v "$DATA_VOLUME:/etc/moshiro" --entrypoint /bin/sh "$HELPER_IMAGE" -c 'rm -f /etc/moshiro/update/apply.request' @@ -287,10 +259,6 @@ do_apply() { fetch_offer verify_offer || { write_status invalid null "$ERR"; echo "refusing to apply: $ERR" >&2; return 1; } adopt_next_key - if [ "$COMPONENT" = backend ]; then - case "$requested_seq" in ''|*[!0-9]*) write_status failed "$(available_json)" "a confirmed release sequence is required"; return 1 ;; esac - [ "$requested_seq" = "$M_SEQ" ] || { write_status available "$(available_json)" "release changed; confirm again"; return 1; } - fi avail="$(available_json)" write_status applying "$avail" "" ref="$IMAGE_REPO@$M_DIGEST" @@ -298,7 +266,6 @@ do_apply() { write_status failed "$avail" "pull failed"; echo "pull failed: $ref" >&2; return 1 fi prev="$(cat "$ENV_FILE" 2>/dev/null || true)" - if [ "$COMPONENT" = backend ]; then backend_backup || return 1; fi printf 'IMAGE=%s\n' "$ref" > "$ENV_FILE.new" && mv -f "$ENV_FILE.new" "$ENV_FILE" service_ctl restart "$UNIT" || true # 健康确认:服务在跑,并且 20 秒内没有再被重启(崩溃循环算失败) @@ -317,12 +284,6 @@ do_apply() { return 0 fi echo "new version did not become healthy, rolling back" >&2 - if [ "$COMPONENT" = backend ]; then - if ! backend_restore; then - write_status failed "$avail" "database restore failed; backup retained" - return 1 - fi - fi if [ -n "$prev" ]; then printf '%s\n' "$prev" > "$ENV_FILE"; fi service_ctl restart "$UNIT" || true write_status rolled_back "$avail" "new version did not become healthy" diff --git a/web/admin/src/BackendUpdateNotice.tsx b/web/admin/src/BackendUpdateNotice.tsx index 4c20f9d..c8e01e3 100644 --- a/web/admin/src/BackendUpdateNotice.tsx +++ b/web/admin/src/BackendUpdateNotice.tsx @@ -5,7 +5,7 @@ import type { BackendUpdateDto } from '@shared/api/types' import { Confirm, ErrorNotice, useAction } from '@shared/ui' import { useAuth } from './auth' -/** webhook 发布后显示当前通道的更新;宿主机验签完成且用户确认同一个 seq 后才升级。 */ +/** webhook 发布后显示当前通道的更新;用户确认同一个 seq 后,Backend 先备份数据库再退出,由 Quadlet 拉最新镜像重启(和 Agent、Probe 一样)。 */ export function BackendUpdateNotice() { const { me } = useAuth() const [update, setUpdate] = useState() @@ -32,19 +32,14 @@ export function BackendUpdateNotice() { useEffect(() => { if (!requested || !update) return if (update.state === 'up_to_date' || (update.current_tag && update.current_tag !== originalTag.current)) location.reload() - else if (['failed', 'rolled_back', 'invalid'].includes(update.state) || (update.state === 'available' && update.error)) setRequested(false) }, [update, requested]) if (!update || (!update.available && !requested && update.state !== 'applying')) return null - const waiting = requested || update.state === 'requested' || update.state === 'applying' + const waiting = requested || update.state === 'applying' const canUpdate = update.enabled && update.state === 'available' && update.available - const message = update.state === 'applying' ? 'Backend 正在升级重启,连接恢复后会自动刷新。' - : waiting ? '已确认 Backend 更新,正在等待宿主机更新器执行。' - : update.state === 'checking' ? '新版本已发布,正在等待宿主机验签。' - : !update.enabled ? '新版本已发布,需要在宿主机安装 Backend 更新器。' - : update.state === 'invalid' ? '发布清单未通过更新器校验。' - : update.state === 'rolled_back' ? '更新失败,已回滚。' - : update.state === 'failed' ? '更新失败。' : 'Backend 有新版本。' + const message = waiting ? '已确认 Backend 更新,正在备份数据并重启,连接恢复后会自动刷新。' + : !update.enabled ? '新版本已发布,但这个 Backend 没有启用重启式更新(需要环境变量 MOSHIRO_UPDATE=restart,Quadlet 模板里已写好)。' + : 'Backend 有新版本。' return (
@@ -66,7 +61,7 @@ export function BackendUpdateNotice() { setRequested(true) } }}> - 将从 {update.current_tag || '当前版本'} 升级到 {confirming.available.tag}({confirming.channel})。宿主机会先备份数据,再升级并重启,期间面板会短暂断开;恢复后自动刷新。如果发布清单已变化,需要重新确认。 + 将从 {update.current_tag || '当前版本'} 升级到 {confirming.available.tag}({confirming.channel})。Backend 会先备份数据库(数据目录下的 backups/),再退出,由 systemd 拉取最新镜像重新启动,期间面板会断开一两分钟,所有节点也会短暂断连;恢复后自动刷新。如果发布清单已变化,需要重新确认。 ) : null}