backend: serve single-agent task templates with their status and results, rename the kinds in storage

This commit is contained in:
justme committed 2026-10-22 07:02:28 +08:00
1 parent e77880d372
commit 3a9ea46d6f
45 files changed
+763 -216

No files matched your search

@@ -24,15 +24,15 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
Assert.Equal("active", agent.EnrollmentStatus);
Assert.Equal("active", agent.AuthStatus);
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.Equal(["quic_ping", "tcp_connect", "tcp_ping"], set.Tasks.Select(t => t.Kind).Order().ToArray());
Assert.All(set.Tasks, t =>
{
Assert.Equal("line", t.Scope);
Assert.Equal(TaskSchedule.Continuous, t.Schedule);
Assert.NotEmpty(t.Params); // 参数已经补全了默认值
});
Assert.Equal(10, set.Tasks.Single(t => t.Kind == "udp_echo").Params["rate_hz"].GetInt64());
Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_echo").Params["port"].GetInt64());
Assert.Equal(10, set.Tasks.Single(t => t.Kind == "quic_ping").Params["rate_hz"].GetInt64());
Assert.Equal(7444, set.Tasks.Single(t => t.Kind == "tcp_ping").Params["port"].GetInt64());
Assert.True(set.Version >= 1);
JsonNode view = await AgentAsync(agent.NodeId.Value);
@@ -46,9 +46,9 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
[Fact]
public async Task TaskSet_OnlyContainsKindsTheAgentSupports()
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["udp_echo"]);
await using AgentSimulator agent = await _backend.ConnectAgentAsync(kinds: ["quic_ping"]);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Count >= 1);
Assert.Equal(["udp_echo"], set.Tasks.Select(t => t.Kind).ToArray());
Assert.Equal(["quic_ping"], set.Tasks.Select(t => t.Kind).ToArray());
}
[Fact]
@@ -265,12 +265,12 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
{
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
TaskSet initial = await agent.WaitForTaskSetAsync(s => s.Tasks.Count == Defaults.TaskCount);
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "udp_echo");
TaskSpec udp = initial.Tasks.Single(t => t.Kind == "quic_ping");
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line",
["kind"] = "udp_echo",
["kind"] = "quic_ping",
["agent_id"] = agent.NodeId.Value,
["base_task_id"] = udp.Id.Value,
["params"] = new JsonObject { ["rate_hz"] = 20 },
@@ -278,15 +278,15 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
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));
TaskSet updated = await agent.WaitForTaskSetAsync(s => s.Version > initial.Version && s.Tasks.Any(t => t.Kind == "quic_ping" && t.Params["rate_hz"].GetInt64() == 20));
Assert.Equal(Defaults.TaskCount, updated.Tasks.Count);
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "udp_echo").Params["port"].GetInt64());
Assert.Equal(7443, updated.Tasks.Single(t => t.Kind == "quic_ping").Params["port"].GetInt64());
// 停用覆盖 → 该 Agent 不再运行这条任务(模板对其他 Agent 不受影响)
string overrideId = JsonNode.Parse(await created.Content.ReadAsStringAsync())!["id"]!.GetValue<string>();
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"));
TaskSet disabled = await agent.WaitForTaskSetAsync(s => s.Version > updated.Version && s.Tasks.All(t => t.Kind != "quic_ping"));
Assert.Equal(Defaults.TaskCount - 1, disabled.Tasks.Count);
}
@@ -295,7 +295,7 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
{
using HttpResponseMessage badParams = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 },
["scope"] = "line", ["kind"] = "quic_ping", ["params"] = new JsonObject { ["rate_hz"] = 5000, ["bogus"] = 1 },
}));
Assert.Equal(HttpStatusCode.BadRequest, badParams.StatusCode);
JsonNode problem = JsonNode.Parse(await badParams.Content.ReadAsStringAsync())!;
@@ -308,33 +308,107 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
using HttpResponseMessage overBudget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "line", ["kind"] = "udp_echo", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 },
["scope"] = "line", ["kind"] = "quic_ping", ["params"] = new JsonObject { ["rate_hz"] = 200, ["size"] = 1400 },
}));
Assert.Equal(HttpStatusCode.BadRequest, overBudget.StatusCode);
Assert.Equal("budget_exceeded", JsonNode.Parse(await overBudget.Content.ReadAsStringAsync())!["code"]!.GetValue<string>());
using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_echo" }));
using HttpResponseMessage wrongScope = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "icmp_ping" }));
Assert.Equal(HttpStatusCode.BadRequest, wrongScope.StatusCode);
// 目标任务:只接受 IP(不做 DNS 解析),需要指定 Agent
// 单个 Agent 的任务:目标的格式按 Kind 检查(Backend 不解析域名),需要指定 Agent
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
using HttpResponseMessage dns = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
foreach ((string kind, string badTarget) in new[] { ("icmp_ping", "https://example.com/"), ("icmp_ping", "not a host"), ("http_request", "example.com") })
{
["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "example.com",
using HttpResponseMessage malformed = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = kind, ["agent_id"] = agent.NodeId.Value, ["target"] = badTarget,
}));
Assert.Equal(HttpStatusCode.BadRequest, malformed.StatusCode);
}
using HttpResponseMessage page = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "http_request", ["agent_id"] = agent.NodeId.Value, ["target"] = "https://Example.com/health", ["params"] = new JsonObject { ["version"] = "h3" },
}));
Assert.Equal(HttpStatusCode.BadRequest, dns.StatusCode);
Assert.Equal(HttpStatusCode.Created, page.StatusCode);
Assert.Equal("https://example.com/health", JsonNode.Parse(await page.Content.ReadAsStringAsync())!["target"]!.GetValue<string>());
using HttpResponseMessage target = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "icmp_echo", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1",
["scope"] = "target", ["kind"] = "icmp_ping", ["agent_id"] = agent.NodeId.Value, ["target"] = "1.1.1.1",
}));
Assert.Equal(HttpStatusCode.Created, target.StatusCode);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target"));
Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target").Target);
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Scope == "target" && t.Kind == "icmp_ping"));
Assert.Equal("1.1.1.1", set.Tasks.Single(t => t.Scope == "target" && t.Kind == "icmp_ping").Target);
using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "udp_echo", ["schedule"] = "every:30" }));
using HttpResponseMessage onceSchedule = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject { ["scope"] = "line", ["kind"] = "quic_ping", ["schedule"] = "every:30" }));
Assert.Equal(HttpStatusCode.BadRequest, onceSchedule.StatusCode);
}
[Fact]
public async Task TargetTemplate_AppliesToEveryAgent_AndAnOverrideCanDisableOrRetargetItForOne()
{
await using AgentSimulator first = await _backend.ConnectAgentAsync();
await using AgentSimulator second = await _backend.ConnectAgentAsync();
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "tcp_connect", ["target"] = "Example.com:443",
}));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
JsonNode template = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
string templateId = template["id"]!.GetValue<string>();
Assert.Null(template["agent_id"]);
try
{
foreach (AgentSimulator agent in new[] { first, second })
{
TaskSet set = await agent.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Kind == "tcp_connect" && t.Scope == "target"));
Assert.Equal("example.com:443", set.Tasks.Single(t => t.Kind == "tcp_connect" && t.Scope == "target").Target);
}
// 展示接口列出每个 Agent 实际执行的目标;模拟的 Agent 没有上传数据,所以是「等待数据」
JsonNode listed = await _backend.GetJsonAsync(_backend.Admin, "/api/public/targets");
string[] both = [first.NodeId.Value, second.NodeId.Value];
JsonNode[] mine = [.. listed["items"]!.AsArray().Select(i => i!).Where(i => i["task_id"]!.GetValue<string>() == templateId && both.Contains(i["agent_id"]!.GetValue<string>()))];
Assert.Equal(both.Order(), mine.Select(i => i["agent_id"]!.GetValue<string>()).Order());
Assert.All(mine, i =>
{
Assert.Equal("example.com:443", i["target"]!.GetValue<string>());
Assert.Equal("waiting", i["status"]!.GetValue<string>());
Assert.Null(i["last_sample"]);
});
JsonNode one = await _backend.GetJsonAsync(_backend.Admin, $"/api/public/targets?agent_id={first.NodeId.Value}&task_id={templateId}&full=true");
Assert.Equal(first.NodeId.Value, Assert.Single(one["items"]!.AsArray())!["agent_id"]!.GetValue<string>());
// 覆盖:第一个 Agent 换一个目标,第二个 Agent 停用
using HttpResponseMessage retarget = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "tcp_connect", ["agent_id"] = first.NodeId.Value, ["base_task_id"] = templateId, ["target"] = "192.0.2.9:8443",
}));
Assert.Equal(HttpStatusCode.Created, retarget.StatusCode);
using HttpResponseMessage off = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "tcp_connect", ["agent_id"] = second.NodeId.Value, ["base_task_id"] = templateId, ["enabled"] = false,
}));
Assert.Equal(HttpStatusCode.Created, off.StatusCode);
TaskSet firstSet = await first.WaitForTaskSetAsync(s => s.Tasks.Any(t => t.Target == "192.0.2.9:8443"));
Assert.Single(firstSet.Tasks, t => t.Kind == "tcp_connect" && t.Scope == "target");
await second.WaitForTaskSetAsync(s => s.Tasks.All(t => t.Scope != "target"));
// 覆盖只能指向同类的单个 Agent 模板
using HttpResponseMessage wrongKind = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "icmp_ping", ["agent_id"] = first.NodeId.Value, ["base_task_id"] = templateId,
}));
Assert.Equal(HttpStatusCode.BadRequest, wrongKind.StatusCode);
}
finally
{
using HttpResponseMessage deleted = await _backend.Admin.DeleteAsync("/api/admin/tasks/" + templateId);
}
}
[Fact]
public async Task TaskSet_RejectedItemsReportedByTheAgent_AreVisibleToTheAdmin()
{
@@ -344,7 +418,7 @@ public sealed class EnrollmentAndTaskSetTests : IClassFixture<BackendFixture>
using HttpResponseMessage created = await _backend.Admin.PostAsync("/api/admin/tasks", BackendFixture.Json(new JsonObject
{
["scope"] = "target", ["kind"] = "connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443",
["scope"] = "target", ["kind"] = "tcp_connect", ["agent_id"] = agent.NodeId.Value, ["target"] = "192.0.2.1:443",
}));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
await BackendFixture.EventuallyAsync(async () => (await AgentAsync(agent.NodeId.Value))["rejected"]!.AsArray().Count == 1);