Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
136 lines
8.2 KiB
C#
136 lines
8.2 KiB
C#
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
using MoShiro.Protocol;
|
|
using MoShiro.TestSupport;
|
|
|
|
namespace MoShiro.Backend.Tests;
|
|
|
|
[Collection("probe-e2e")]
|
|
public sealed class UpdateTests
|
|
{
|
|
private const string Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
// 每个用例取自己的 seq(单调递增):共用的后端只接受比已登记的更大的 seq,用例的先后顺序不能影响结果
|
|
private static long s_seq = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000;
|
|
|
|
private static long NextSeq() => Interlocked.Add(ref s_seq, 100);
|
|
|
|
private readonly BackendFixture _backend;
|
|
|
|
public UpdateTests(BackendFixture backend)
|
|
{
|
|
_backend = backend;
|
|
}
|
|
|
|
// 面板不验签(节点宿主机上的更新器验),所以测试里签名随便写一个格式对的
|
|
private static string Manifest(string component, long seq, string digest = Digest) =>
|
|
$$"""{"schema":1,"component":"{{component}}","channel":"dev","image":"registry.example/moshiro-dev/moshiro-{{component}}","digest":"{{digest}}","tag":"dev-{{seq}}","version":"0.1.1","commit":"abc","built_at":"2026-10-06T00:00:00Z","seq":{{seq}}}""";
|
|
|
|
private async Task<HttpResponseMessage> PublishAsync(string manifest, string signature = "MEUCIQDc") =>
|
|
await _backend.Admin.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = manifest, ["signature"] = signature }));
|
|
|
|
[Fact]
|
|
public async Task Release_IsForwardedByteForByte_ToOnlineNodesOfThatComponent_AndToNodesThatConnectLater()
|
|
{
|
|
long seq = NextSeq();
|
|
await using AgentSimulator online = await _backend.ConnectAgentAsync();
|
|
string manifest = Manifest("agent", seq);
|
|
|
|
using HttpResponseMessage published = await PublishAsync(manifest);
|
|
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
|
JsonNode release = JsonNode.Parse(await published.Content.ReadAsStringAsync())!;
|
|
Assert.Equal("agent", release["component"]!.GetValue<string>());
|
|
Assert.Equal(Digest, release["digest"]!.GetValue<string>());
|
|
|
|
await BackendFixture.EventuallyAsync(() => Task.FromResult(online.UpdateOffers.ToArray().Any(o => o.Manifest == manifest && o.Signature == "MEUCIQDc")), because: "the offer reaches online agents unchanged");
|
|
|
|
// 之后才连上的节点,hello 之后补发
|
|
await using AgentSimulator late = await _backend.ConnectAgentAsync();
|
|
await BackendFixture.EventuallyAsync(() => Task.FromResult(late.UpdateOffers.ToArray().Any(o => o.Manifest == manifest)), because: "a node that connects later still gets the latest offer");
|
|
|
|
// 另一个组件(probe)的清单不会发给 Agent
|
|
using HttpResponseMessage probeRelease = await PublishAsync(Manifest("probe", seq));
|
|
Assert.Equal(HttpStatusCode.Created, probeRelease.StatusCode);
|
|
await Task.Delay(300);
|
|
Assert.DoesNotContain(online.UpdateOffers.ToArray(), o => o.Manifest.Contains("\"component\":\"probe\"", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Release_Registration_RefusesBadManifests_StaleSeq_AndNeedsAnAdmin()
|
|
{
|
|
long seq = NextSeq();
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync("{not json")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq, digest: "sha256:short"))).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("backend", seq))).StatusCode);
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await PublishAsync(Manifest("agent", seq), signature: "not base64!")).StatusCode);
|
|
|
|
Assert.Equal(HttpStatusCode.Created, (await PublishAsync(Manifest("agent", seq))).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq))).StatusCode); // 同一个 seq:重放
|
|
Assert.Equal(HttpStatusCode.Conflict, (await PublishAsync(Manifest("agent", seq - 1))).StatusCode); // 更旧的 seq
|
|
|
|
using HttpResponseMessage anonymous = await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("agent", seq + 1), ["signature"] = "MEUC" }));
|
|
Assert.Contains(anonymous.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReleaseToken_CanOnlyRegisterReleases_AndAReadTokenCannotEvenDoThat()
|
|
{
|
|
async Task<string> NewTokenAsync(string scope)
|
|
{
|
|
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/api-tokens", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("ci"), ["scope"] = scope }));
|
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
return JsonNode.Parse(await created.Content.ReadAsStringAsync())!["plaintext"]!.GetValue<string>();
|
|
}
|
|
|
|
HttpClient Client(string token)
|
|
{
|
|
var client = new HttpClient { BaseAddress = _backend.BaseAddress };
|
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
|
return client;
|
|
}
|
|
|
|
long seq = NextSeq();
|
|
using HttpClient release = Client(await NewTokenAsync("release"));
|
|
using HttpClient read = Client(await NewTokenAsync("read"));
|
|
var body = BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" });
|
|
|
|
using HttpResponseMessage forbidden = await read.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq), ["signature"] = "MEUC" }));
|
|
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
|
using HttpResponseMessage published = await release.PostAsync("/api/admin/releases", body);
|
|
Assert.Equal(HttpStatusCode.Created, published.StatusCode);
|
|
|
|
// release 令牌只能登记发布清单:读不了任何东西,也不能做别的管理操作
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/agents")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await release.GetAsync("/api/admin/releases")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await release.PostAsync("/api/admin/nodes/agt_x/update", null)).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Unauthorized, (await _backend.Anonymous.PostAsync("/api/admin/releases", BackendFixture.Json(new JsonObject { ["manifest"] = Manifest("probe", seq + 1), ["signature"] = "MEUC" }))).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateRequest_ReachesTheNode_AndItsReportedStatusShowsInBuildInfo()
|
|
{
|
|
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
|
using HttpResponseMessage request = await _backend.Admin.PostAsync($"/api/admin/nodes/{agent.NodeId.Value}/update", null);
|
|
Assert.Equal(HttpStatusCode.NoContent, request.StatusCode);
|
|
await BackendFixture.EventuallyAsync(() => Task.FromResult(agent.UpdateApplies == 1));
|
|
|
|
// 节点(宿主机更新器)报告状态:面板在构建信息里显示
|
|
await agent.SendUpdateStatusAsync(new UpdateStatus { State = "available", Available = new UpdateAvailable { Tag = "dev-9", Version = "0.1.1", Digest = Digest, Seq = 9 }, At = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() });
|
|
await BackendFixture.EventuallyAsync(async () =>
|
|
{
|
|
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
|
|
JsonNode? node = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.NodeId.Value);
|
|
return node?["update"]?["state"]?.GetValue<string>() == "available" && node["update"]!["available_tag"]!.GetValue<string>() == "dev-9";
|
|
}, because: "the update state comes from the node's heartbeat");
|
|
|
|
// 节点离线之后不能请求更新
|
|
string id = agent.NodeId.Value;
|
|
await agent.DisposeAsync();
|
|
await BackendFixture.EventuallyAsync(async () =>
|
|
{
|
|
using HttpResponseMessage offline = await _backend.Admin.PostAsync($"/api/admin/nodes/{id}/update", null);
|
|
return offline.StatusCode == HttpStatusCode.Conflict || offline.StatusCode == HttpStatusCode.NotFound;
|
|
});
|
|
}
|
|
}
|