feat: daily node restart controlled from the admin, connect timings as a default line task [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Agents and Probes exit in order at a configured time (default 04:30 UTC+8, switch and time zone in the admin settings) and the service manager starts them again; a local restart_at or MOSHIRO_RESTART_AT wins. The connect kind (TCP connect and TLS handshake timings) is now a default continuous line template, with a section on the line page.
This commit is contained in:
1 parent
388619b8b6
commit
9bcef56497
37 files changed
+928
-101
No files matched your search
@@ -44,7 +44,7 @@ public sealed class AgentEndToEndTests
|
||||
{
|
||||
// 默认模板的端口(7443/7444)不在这个 Agent 实际监听的端口里:任务被拒绝,原因是 policy_denied
|
||||
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == 2);
|
||||
await BackendFixture.EventuallyAsync(async () => (await AgentViewAsync(agent))["rejected"]!.AsArray().Count == Defaults.TaskCount);
|
||||
JsonNode rejected = (await AgentViewAsync(agent))["rejected"]!;
|
||||
Assert.All(rejected.AsArray(), item => Assert.Equal("policy_denied", item!["reason"]!.GetValue<string>()));
|
||||
|
||||
|
||||
@@ -88,16 +88,19 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
public async Task OverrideLineTasksAsync(int? rateHz = null)
|
||||
{
|
||||
JsonNode tasks = await Backend.GetJsonAsync(Backend.Admin, "/api/admin/tasks");
|
||||
foreach (JsonNode? task in tasks["items"]!.AsArray())
|
||||
// 先处理 connect(关掉),再改 udp_echo / tcp_echo 的速率:否则中途的带宽预算会多出 connect 那一份
|
||||
foreach (JsonNode? task in tasks["items"]!.AsArray().OrderBy(t => t!["kind"]!.GetValue<string>() == "connect" ? 0 : 1))
|
||||
{
|
||||
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("udp_echo" or "tcp_echo"))
|
||||
if (task!["scope"]!.GetValue<string>() != "line" || task["agent_id"] is not null || task["kind"]!.GetValue<string>() is not ("udp_echo" or "tcp_echo" or "connect"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string kind = task["kind"]!.GetValue<string>();
|
||||
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] };
|
||||
if (rateHz is { } hz)
|
||||
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
|
||||
bool enabled = kind != "connect";
|
||||
if (rateHz is { } hz && kind != "connect")
|
||||
{
|
||||
parameters["rate_hz"] = hz;
|
||||
}
|
||||
@@ -109,6 +112,7 @@ public sealed class AgentHarness : IAsyncDisposable
|
||||
["agent_id"] = Id,
|
||||
["base_task_id"] = task["id"]!.GetValue<string>(),
|
||||
["params"] = parameters,
|
||||
["enabled"] = enabled,
|
||||
}));
|
||||
Assert.True(created.StatusCode == System.Net.HttpStatusCode.Created, await created.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>首次启动种入的默认线路任务模板数:udp_echo、tcp_echo、connect。</summary>
|
||||
internal static class Defaults
|
||||
{
|
||||
public const int TaskCount = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在真实的 Kestrel 端口上启动整个后端(真实的 SQLite 与段文件,只是用临时目录和较快的轮询),
|
||||
/// 并提供管理员客户端(Bearer 令牌)和假 Agent。
|
||||
|
||||
@@ -24,8 +24,8 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
|
||||
Assert.Equal("active", agent.EnrollmentStatus);
|
||||
Assert.Equal("active", agent.AuthStatus);
|
||||
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
Assert.Equal(["tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray());
|
||||
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
Assert.Equal(["connect", "tcp_echo", "udp_echo"], set.Tasks.Select(t => t.Kind).Order().ToArray());
|
||||
Assert.All(set.Tasks, t =>
|
||||
{
|
||||
Assert.Equal("line", t.Scope);
|
||||
@@ -56,7 +56,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
public async Task Reconnecting_AlwaysGetsTheCurrentSnapshotAgain()
|
||||
{
|
||||
AgentSimulator first = await _backend.ConnectAgentAsync();
|
||||
TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
TaskSet before = await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
await first.DisposeAsync();
|
||||
|
||||
// 同一个身份重新连接:新连接的服务端状态为空,所以会重新推送当前版本
|
||||
@@ -157,7 +157,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
|
||||
using HttpResponseMessage approve = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/approve", null);
|
||||
Assert.Equal(HttpStatusCode.OK, approve.StatusCode);
|
||||
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
Assert.NotNull(set);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
public async Task DisablingAConnectedAgent_RevokesItAndRefusesFutureConnections()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
using HttpResponseMessage disable = await _backend.Admin.PostAsync($"/api/admin/agents/{agent.NodeId.Value}/disable", null);
|
||||
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
|
||||
@@ -239,7 +239,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
public async Task TaskOverride_ChangesTheDesiredStateAndBumpsTheVersion()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo");
|
||||
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
|
||||
@@ -254,7 +254,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
Assert.StartsWith("/api/admin/tasks/", created.Headers.Location!.ToString());
|
||||
|
||||
TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "udp_echo" && t.Params["rate_hz"].GetInt64() == 20));
|
||||
Assert.Equal(2, updated.Tasks.Count);
|
||||
Assert.Equal(Defaults.TaskCount, updated.Tasks.Count);
|
||||
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64());
|
||||
|
||||
// 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响)
|
||||
@@ -262,7 +262,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
using HttpResponseMessage disable = await _backend.Admin.PatchAsync("/api/admin/tasks/" + overrideId, BackendFixture.Json(new JsonObject { ["enabled"] = false }, "application/merge-patch+json"));
|
||||
Assert.Equal(HttpStatusCode.OK, disable.StatusCode);
|
||||
TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "udp_echo"));
|
||||
Assert.Single(disabled.Tasks);
|
||||
Assert.Equal(Defaults.TaskCount - 1, disabled.Tasks.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -314,7 +314,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
agent.RejectNextState = [new Fleet.Rpc.RejectedItem { Id = "tsk_x", Reason = "policy_denied" }];
|
||||
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
|
||||
@@ -330,7 +330,7 @@ public sealed class EnrollmentAndTaskSetTests
|
||||
public async Task ProbeLifecycle_CreateRotateRevokeDelete_IsReflectedInEveryAgentsPeerList()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/probes", BackendFixture.Json(new JsonObject { ["name"] = _backend.UniqueName("home") }));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class OnDemandTestsTests
|
||||
private async Task<AgentSimulator> AgentWithProbeAsync()
|
||||
{
|
||||
AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
await agent.SendEventAsync(AgentEventKinds.ProbeConnected, _backend.DefaultProbeId);
|
||||
await BackendFixture.EventuallyAsync(async () => (await _backend.GetJsonAsync(_backend.Admin, "/api/admin/agents/" + agent.NodeId.Value))["probe_connected"]!.GetValue<bool>());
|
||||
return agent;
|
||||
@@ -37,7 +37,7 @@ public sealed class OnDemandTestsTests
|
||||
public async Task Start_RequiresAProbeSession_APlanThatExists_AndValidParams()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
using HttpResponseMessage offline = await StartAsync(agent, Plan("hy2_ladder"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, offline.StatusCode);
|
||||
|
||||
@@ -139,7 +139,7 @@ public sealed class OperationsAndSecurityTests
|
||||
public async Task OversizedFrames_CloseTheConnection_WithoutHarmingTheServer()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
try
|
||||
{
|
||||
await agent.SendBinaryAsync(new byte[17 * 1024 * 1024]);
|
||||
@@ -153,14 +153,14 @@ public sealed class OperationsAndSecurityTests
|
||||
|
||||
// 服务端仍然正常
|
||||
await using AgentSimulator another = await _backend.ConnectAgentAsync();
|
||||
Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
|
||||
Assert.NotNull(await another.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedAndUnknownMessages_AreIgnoredOrRefused_AndTheConnectionStaysUsable()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
await agent.SendTextAsync("{not json");
|
||||
await agent.SendTextAsync("[]");
|
||||
@@ -187,7 +187,7 @@ public sealed class OperationsAndSecurityTests
|
||||
Assert.Empty(agent.TaskSets);
|
||||
|
||||
await agent.HelloAsync();
|
||||
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
|
||||
Assert.NotNull(await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -205,9 +205,9 @@ public sealed class OperationsAndSecurityTests
|
||||
public async Task ReconnectingTheSameNode_ReplacesTheOldConnection()
|
||||
{
|
||||
AgentSimulator first = await _backend.ConnectAgentAsync();
|
||||
await first.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await first.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
await using AgentSimulator second = await _backend.ReconnectAsync(first);
|
||||
await second.WaitForTaskSetAsync(s => s.Tasks.Count == 2);
|
||||
await second.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
|
||||
|
||||
// 旧连接被替换掉,但节点仍然在线(在线状态属于新连接)
|
||||
await first.WaitUntilAsync(() => first.CloseReason is not null);
|
||||
|
||||
@@ -57,7 +57,7 @@ public sealed class PersistenceTests : IAsyncLifetime
|
||||
|
||||
// 同一个身份可以重新连接,并拿回当前版本的期望状态
|
||||
await using AgentSimulator again = await _backend.ReconnectAsync(agent);
|
||||
Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == 2));
|
||||
Assert.NotNull(await again.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount));
|
||||
|
||||
// 已经落盘的块在重启后仍然可以按 ID 下载
|
||||
JsonNode chunks = await _backend.GetJsonAsync(admin, $"/api/admin/raw/chunks?agent_id={id}&limit=1");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using MoShiro.Backend.Application;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>定时重启:默认开、北京时间 04:30;节点连上时补发,管理端改了设置马上群发,关掉就发空。</summary>
|
||||
[Collection("probe-e2e")]
|
||||
public sealed class ScheduleTests
|
||||
{
|
||||
private readonly BackendFixture _backend;
|
||||
|
||||
public ScheduleTests(BackendFixture backend)
|
||||
{
|
||||
_backend = backend;
|
||||
}
|
||||
|
||||
private async Task PatchAsync(JsonObject settings)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/settings", BackendFixture.Json(settings, "application/merge-patch+json"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static Task<bool> LastIs(AgentSimulator agent, string? restartAt) =>
|
||||
Task.FromResult(agent.Schedules.ToArray() is { Length: > 0 } all && all[^1].RestartAt == restartAt);
|
||||
|
||||
[Fact]
|
||||
public void Format_WritesTheOffsetTheNodeParses()
|
||||
{
|
||||
Assert.Equal("04:30+08:00", ScheduleService.Format(4, 30, 480));
|
||||
Assert.Equal("00:00+00:00", ScheduleService.Format(0, 0, 0));
|
||||
Assert.Equal("23:05-05:30", ScheduleService.Format(23, 5, -330));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewNode_GetsTheDefaultSchedule_AndLaterChangesAreBroadcast()
|
||||
{
|
||||
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
|
||||
await BackendFixture.EventuallyAsync(() => LastIs(agent, "04:30+08:00"), because: "defaults: on, 04:30 Beijing time, sent right after hello");
|
||||
|
||||
try
|
||||
{
|
||||
await PatchAsync(new JsonObject { ["fleet.restart_hour"] = 3, ["fleet.restart_minute"] = 5, ["fleet.restart_utc_offset_minutes"] = 0 });
|
||||
await BackendFixture.EventuallyAsync(() => LastIs(agent, "03:05+00:00"), because: "a changed time is pushed to online nodes");
|
||||
|
||||
await PatchAsync(new JsonObject { ["fleet.restart_enabled"] = false });
|
||||
await BackendFixture.EventuallyAsync(() => LastIs(agent, null), because: "switching it off sends an empty schedule");
|
||||
|
||||
// 关着的时候新连上的节点也拿到「关」
|
||||
await using AgentSimulator late = await _backend.ConnectAgentAsync();
|
||||
await BackendFixture.EventuallyAsync(() => LastIs(late, null), because: "a node connecting while it is off is told so");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PatchAsync(new JsonObject { ["fleet.restart_enabled"] = true, ["fleet.restart_hour"] = 4, ["fleet.restart_minute"] = 30, ["fleet.restart_utc_offset_minutes"] = 480 });
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadValues_AreRefused()
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Admin.PatchAsync("/api/admin/settings",
|
||||
BackendFixture.Json(new JsonObject { ["fleet.restart_hour"] = 24, ["fleet.restart_minute"] = 60, ["fleet.restart_utc_offset_minutes"] = 900 }, "application/merge-patch+json"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultLineTemplates_IncludeTheConnectTimings()
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Admin.GetAsync("/api/admin/tasks");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
JsonArray items = JsonNode.Parse(await response.Content.ReadAsStringAsync())!["items"]!.AsArray();
|
||||
string[] kinds = [.. items.Select(i => i!["kind"]!.GetValue<string>())];
|
||||
Assert.Contains("connect", kinds);
|
||||
Assert.Contains("udp_echo", kinds);
|
||||
Assert.Contains("tcp_echo", kinds);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,9 @@ public sealed class AgentSimulator : IAsyncDisposable
|
||||
/// <summary>面板发来的发布清单(<c>moshiro.update.offer</c>)和更新请求(<c>moshiro.update.apply</c>)。</summary>
|
||||
public ConcurrentQueue<UpdateOfferParams> UpdateOffers { get; } = new();
|
||||
|
||||
/// <summary>面板下发的定时重启设置(<c>moshiro.schedule</c>),按到达顺序。</summary>
|
||||
public ConcurrentQueue<ScheduleParams> Schedules { get; } = new();
|
||||
|
||||
private int _updateApplies;
|
||||
|
||||
public int UpdateApplies => Volatile.Read(ref _updateApplies);
|
||||
@@ -406,6 +409,9 @@ public sealed class AgentSimulator : IAsyncDisposable
|
||||
case MoShiroMethods.UpdateOffer:
|
||||
UpdateOffers.Enqueue(message.Params!.Value.Deserialize(MoShiroJsonContext.Default.UpdateOfferParams)!);
|
||||
break;
|
||||
case MoShiroMethods.Schedule:
|
||||
Schedules.Enqueue(message.Params!.Value.Deserialize(MoShiroJsonContext.Default.ScheduleParams)!);
|
||||
break;
|
||||
case MoShiroMethods.UpdateApply:
|
||||
Interlocked.Increment(ref _updateApplies);
|
||||
break;
|
||||
|
||||
Reference in new issue
Block a user