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
@@ -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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user