feat(update): update the backend like nodes via quadlet restart [CI]
This commit is contained in:
1 parent
34190f5a04
commit
68c7113adf
23 files changed
+309
-357
No files matched your search
@@ -23,7 +23,7 @@
|
||||
2. **安全默认值**:Fleet 永远不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 `allow_methods` 还能收窄);调用对方不支持的方法在本地直接返回 `fleet_unsupported`。
|
||||
3. **权限分离**:注册(`fleet-enroll`,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 `HttpClient`。
|
||||
4. **NativeAOT**:节点侧只依赖 BCL,泛型扩展点通过产品传入的 `JsonTypeInfo<T>` 序列化,不使用反射。
|
||||
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)。
|
||||
|
||||
## 身份
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<ReleaseService>();
|
||||
services.AddSingleton<ScheduleService>();
|
||||
services.AddSingleton<BackendUpdates>();
|
||||
services.AddHostedService(p => p.GetRequiredService<BackendUpdates>());
|
||||
services.AddSingleton<JoinTokenService>();
|
||||
services.AddSingleton<RawBrowserService>();
|
||||
services.AddSingleton<EventService>();
|
||||
|
||||
@@ -15,11 +15,11 @@ public sealed class BackendOptions
|
||||
/// <summary>节点镜像所在的仓库地址(管理端「添加 Agent / Probe」生成的容器命令用它拼镜像名)。</summary>
|
||||
public string NodeImageRegistry { get; set; } = "registry.oeo.one";
|
||||
|
||||
/// <summary>容器内更新请求目录;留空使用数据目录下的 update/。</summary>
|
||||
public string? BackendUpdateDirectory { get; set; }
|
||||
|
||||
/// <summary>宿主机更新器的只读状态目录;留空使用数据目录下的 update-status/。</summary>
|
||||
public string? BackendUpdateStatusDirectory { get; set; }
|
||||
/// <summary>
|
||||
/// 更新方式:<c>restart</c>(环境变量 <c>MOSHIRO_UPDATE=restart</c>,Quadlet 模板里写好了)表示管理员确认之后 Backend 有序退出,外面的服务管理器拉最新镜像再启动,和 Agent、Probe 一样;
|
||||
/// 留空就不能从管理端更新(别的部署方式退出之后不会重新拉取)。
|
||||
/// </summary>
|
||||
public string? UpdateMode { get; set; } = Environment.GetEnvironmentVariable("MOSHIRO_UPDATE");
|
||||
|
||||
/// <summary>
|
||||
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>宿主机更新器的文件信箱端口。面板只写签名清单和确认的 seq,不执行命令。</summary>
|
||||
public interface IBackendUpdateFiles
|
||||
/// <summary>让外面的服务管理器(Quadlet 的 <c>Pull=always</c> 加 <c>Restart=always</c>)换上新镜像:Backend 只负责有序退出。</summary>
|
||||
public interface IBackendRestart
|
||||
{
|
||||
ValueTask WriteOfferAsync(string manifest, string signature, CancellationToken ct);
|
||||
void Request();
|
||||
}
|
||||
|
||||
ValueTask<UpdateStatus?> ReadStatusAsync(CancellationToken ct);
|
||||
|
||||
ValueTask<long?> ReadRequestAsync(CancellationToken ct);
|
||||
|
||||
ValueTask RequestAsync(long seq, CancellationToken ct);
|
||||
/// <summary>更新之前备份数据库:新版启动时会迁移数据库,旧版不认比自己新的库,回滚要靠这份备份。</summary>
|
||||
public interface IDatabaseBackup
|
||||
{
|
||||
/// <summary>在 <paramref name="directory"/> 里写一份一致的数据库副本,返回文件路径。</summary>
|
||||
ValueTask<string> 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);
|
||||
|
||||
/// <summary>Backend 的发布通知和确认。新清单只显示为可用更新,宿主机必须收到对应 seq 的确认才会重启。</summary>
|
||||
public sealed class BackendUpdates : IHostedService
|
||||
/// <summary>
|
||||
/// Backend 的发布通知和确认,和 Agent、Probe 是同一种更新:面板登记 CI 签名的清单,管理员确认之后 Backend 有序退出,
|
||||
/// Quadlet 单元每次启动都先拉最新镜像(<c>Pull=always</c>),所以退出再被拉起就是更新。Backend 自己不下载、不替换任何东西。
|
||||
/// 只有设了环境变量 <c>MOSHIRO_UPDATE=restart</c>(Quadlet 模板里写好了)才启用;别的部署方式退出之后不会重新拉取。
|
||||
/// 只提示不自动重启:Backend 指挥所有节点,重启会让它们全部断一两分钟。
|
||||
/// </summary>
|
||||
public sealed partial class BackendUpdates
|
||||
{
|
||||
private readonly ReleaseService _releases;
|
||||
private readonly IBackendUpdateFiles _files;
|
||||
private readonly BackendOptions _options;
|
||||
/// <summary>更新前的数据库备份保留几份。</summary>
|
||||
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<BackendUpdates> _log;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _gate = new();
|
||||
private long _applyingSeq;
|
||||
|
||||
public BackendUpdates(ReleaseService releases, BackendOptions options, IBackendRestart restart, IDatabaseBackup backup, ILogger<BackendUpdates> 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));
|
||||
|
||||
/// <summary>环境变量 <c>MOSHIRO_UPDATE=restart</c> 才启用。</summary>
|
||||
public bool Enabled => string.Equals(_options.UpdateMode, "restart", StringComparison.Ordinal);
|
||||
|
||||
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)
|
||||
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<Result<bool>> 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<bool>.Fail(MoShiroErrors.PreconditionFailed, "the offered release changed or has not been verified by the host updater; refresh before confirming");
|
||||
return Result<bool>.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<bool>.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<bool>.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, long> _feedChecked = new();
|
||||
private readonly ConcurrentDictionary<string, UpdateStatus> _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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Backend 到宿主机的文件信箱。状态卷只读挂载,业务进程改写不了验签和安装结果。</summary>
|
||||
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<UpdateStatus?> 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<long?> 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<string?> 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; // 更新器正做原子替换,下一次读取再试
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Storage.Sqlite;
|
||||
|
||||
/// <summary>用 <c>VACUUM INTO</c> 写一份一致的数据库副本:不停服务,WAL 里还没合并的写入也在里面。</summary>
|
||||
public sealed class SqliteDatabaseBackup : IDatabaseBackup
|
||||
{
|
||||
private readonly SqliteDatabase _database;
|
||||
|
||||
public SqliteDatabaseBackup(SqliteDatabase database)
|
||||
{
|
||||
_database = database;
|
||||
}
|
||||
|
||||
public ValueTask<string> 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);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public static class StorageServiceCollectionExtensions
|
||||
services.AddSingleton<IRawStore, SqliteRawStore>();
|
||||
services.AddSingleton<IMetricStore, SqliteMetricStore>();
|
||||
services.AddSingleton<IStorageHealth, SqliteStorageHealth>();
|
||||
services.AddSingleton<IBackendUpdateFiles, BackendUpdateFiles>();
|
||||
services.AddSingleton<IDatabaseBackup, SqliteDatabaseBackup>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public static class BackendHost
|
||||
services.AddSingleton<IGeoLookup>(new HttpGeoLookup(options.GeoLookupUrl));
|
||||
}
|
||||
|
||||
services.AddSingleton<IBackendRestart, HostBackendRestart>();
|
||||
services.AddMoShiroApplication(options);
|
||||
services.AddMoShiroGateway();
|
||||
services.AddMoShiroApi();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend;
|
||||
|
||||
/// <summary>更新确认之后让 Backend 有序退出:晚一会儿再停,先把确认请求的响应送出去。外面的 Quadlet 单元(<c>Restart=always</c>、<c>Pull=always</c>)会拉最新镜像再启动。</summary>
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,15 @@ internal static class Defaults
|
||||
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
|
||||
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
|
||||
/// </summary>
|
||||
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
|
||||
/// <summary>带 admin 作用域的 API 令牌的客户端。</summary>
|
||||
public HttpClient Admin { get; private set; } = null!;
|
||||
|
||||
/// <summary>Backend 更新确认之后「请求退出」的次数(测试里不真的退出)。</summary>
|
||||
public RecordingBackendRestart Restarts { get; } = new();
|
||||
|
||||
public IServiceProvider Services => App.Services;
|
||||
|
||||
/// <summary>夹具创建的默认 Probe:合成块(<see cref="ChunkFactory"/>)和单线路的查询都用它。</summary>
|
||||
@@ -267,7 +279,11 @@ public class BackendFixture : IAsyncLifetime
|
||||
options.NodeImageRegistry = string.Empty; // 同理:后台不去真实的镜像仓库取清单
|
||||
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
|
||||
},
|
||||
configureServices: services => services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance)); // 登记清单要验发布签名:测试用自己的密钥签
|
||||
configureServices: services =>
|
||||
{
|
||||
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey>(TestReleaseKey.Instance); // 登记清单要验发布签名:测试用自己的密钥签
|
||||
services.AddSingleton<MoShiro.Backend.Application.IBackendRestart>(Restarts); // 更新确认之后只记一笔,不真的退出
|
||||
});
|
||||
await App.StartAsync();
|
||||
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
BaseAddress = new Uri(address);
|
||||
|
||||
@@ -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<BackendOptions>().UpdateChannel = "dev";
|
||||
BackendOptions options = _backend.Services.GetRequiredService<BackendOptions>();
|
||||
options.UpdateChannel = "dev";
|
||||
options.UpdateMode = "restart";
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => _backend.DisposeAsync();
|
||||
@@ -50,19 +51,6 @@ public sealed class BackendUpdateTests : IAsyncLifetime
|
||||
|
||||
private Task<JsonNode> 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<string>());
|
||||
Assert.Equal("dev-1", view["available"]!["tag"]!.GetValue<string>());
|
||||
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<string>());
|
||||
Assert.Equal(0, _backend.Restarts.Count); // 发布只提示,不会自己重启
|
||||
ReleaseOffer? stable = await _backend.Services.GetRequiredService<ReleaseService>().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<string>());
|
||||
Assert.Equal(1, _backend.Restarts.Count);
|
||||
Assert.Equal("applying", (await ViewAsync())["state"]!.GetValue<string>());
|
||||
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<BackendOptions>().UpdateMode = null;
|
||||
using HttpResponseMessage published = await PublishAsync(30);
|
||||
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
||||
Assert.False((await ViewAsync())["enabled"]!.GetValue<bool>());
|
||||
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<long>() == 20);
|
||||
Assert.Equal("dev", updated.Data!["channel"]!.GetValue<string>());
|
||||
Assert.False(File.Exists(Path.Combine(_backend.DataDirectory, "update", "apply.request")));
|
||||
Assert.Equal(0, _backend.Restarts.Count);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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:<probe_id>` 单独存放,互不混合。对外的线路 id 是 `<agent_id>~<probe_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 回显的四个时间戳
|
||||
|
||||
|
||||
+13
-29
@@ -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://<backend> --join <token> --name <name>
|
||||
sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https://<backend> --join <probe 令牌> --name <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-<时间>-<seq>.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:<version> \
|
||||
--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 拉到新镜像),之后就能从管理端更新。
|
||||
@@ -65,7 +65,7 @@
|
||||
- **对账测试**:所有 `/api/admin` 端点必须声明能力,没声明测试就失败;OpenAPI 的每个操作带 `x-moshiro-capability`。
|
||||
- **信任类**是界面上特别标出的(吊销、账户管理),授权和其他能力一样靠 API key 或会话。真正由 passkey 签名保护的是操作员集合,那是节点自己验证的。
|
||||
- **不能越权**:创建 API key 时勾选的能力必须是创建者当前拥有能力的子集。
|
||||
- **Backend 更新确认**:更新清单由 CI 的发布密钥签名,`seq` 防回滚,真正的门是发布密钥,不是确认这一步。
|
||||
- **Backend 更新确认**:更新清单由 CI 的发布密钥签名(面板登记时验签),`seq` 防重放;确认只是让 Backend 有序退出并备份数据库,拉哪个镜像由 Quadlet 单元里写死的仓库和标签决定,面板决定不了。
|
||||
|
||||
## 4. 登录与会话
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
|
||||
echo "usage: deploy-node.sh <agent|probe> [--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' </dev/null
|
||||
in_volume 'mkdir -p /etc/moshiro/update' </dev/null
|
||||
|
||||
# 3. 配置:身份由节点自己生成(Agent 和 Probe 一样),注册之后面板只认这把公钥
|
||||
if [ "$ROLE" = backend ]; then
|
||||
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
|
||||
install -d -m 755 /etc/moshiro
|
||||
if [ ! -f /etc/moshiro/backend.env ]; then
|
||||
(
|
||||
umask 077
|
||||
{
|
||||
echo "MoShiro__DataDirectory=/data"
|
||||
echo "MoShiro__UpdateChannel=$CHANNEL"
|
||||
echo "MoShiro__ImageNamespace=$NAMESPACE"
|
||||
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
|
||||
if [ -n "$ADMIN_PASSWORD" ]; then
|
||||
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
|
||||
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
|
||||
fi
|
||||
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
|
||||
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
|
||||
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
|
||||
} > /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" </dev/null; then
|
||||
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&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" </dev/null; then
|
||||
[ -n "$SERVER" ] && [ -n "$JOIN" ] && [ -n "$NAME" ] || { echo "first deployment needs --server, --join and --name" >&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' </dev/null; then
|
||||
@@ -199,10 +157,8 @@ fi
|
||||
install -d -m 755 "$CONF_DIR"
|
||||
printf 'IMAGE=%s\n' "$IMAGE" > "$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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@
|
||||
set -eu
|
||||
|
||||
COMPONENT="${1:-}"; shift || true
|
||||
case "$COMPONENT" in agent|probe|backend) ;; *) echo "usage: install-updater.sh <agent|probe|backend> [--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 <agent|probe> [--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; }
|
||||
|
||||
@@ -14,8 +14,8 @@ umask 077
|
||||
|
||||
CMD="${1:-}"
|
||||
COMPONENT="${2:-}"
|
||||
case "$CMD" in check|apply) ;; *) echo "usage: moshiro-update <check|apply> <agent|probe|backend>" >&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 <check|apply> <agent|probe>" >&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"
|
||||
|
||||
@@ -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<BackendUpdateDto>()
|
||||
@@ -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 (
|
||||
<div className="notice" style={{ marginBottom: 16 }}>
|
||||
@@ -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 拉取最新镜像重新启动,期间面板会断开一两分钟,所有节点也会短暂断连;恢复后自动刷新。如果发布清单已变化,需要重新确认。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user