ci/woodpecker/push/main-amd64-build Pipeline was successful
Probes connect to the panel directly and get the agent list as desired state. CI signs the image digest of agent and probe releases, the panel forwards the manifest, and a host updater verifies it with a local public key, pulls by digest and rolls back when the new version is unhealthy.
97 lines
5.8 KiB
C#
97 lines
5.8 KiB
C#
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
using Monitor.Protocol;
|
|
using Monitor.TestSupport;
|
|
|
|
namespace Monitor.Backend.Tests;
|
|
|
|
[Collection("probe-e2e")]
|
|
public sealed class UpdateTests
|
|
{
|
|
private const string Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
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/monitor-dev/monitor-{{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 = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000;
|
|
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 = TimeProvider.System.GetUtcNow().ToUnixTimeSeconds() * 1000 + 5;
|
|
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 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;
|
|
});
|
|
}
|
|
}
|