Files
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

202 lines
9.7 KiB
C#

using System.Text.Json.Nodes;
using MoShiro.Agent;
using MoShiro.Measurement;
using MoShiro.Protocol;
using MoShiro.TestSupport;
namespace MoShiro.Backend.Tests;
public sealed class HostSamplerTests
{
private const string Stat1 = "cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 0 0 0 0 0\n";
private const string Stat2 = "cpu 200 0 200 800 100 0 0 0 0 0\n"; // 比上一次:总时间 +300,其中空闲 +100
private const string Dev1 = "Inter-| Receive | Transmit\n face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\n lo: 999 1 0 0 0 0 0 0 999 1 0 0 0 0 0 0\n eth0: 1000 10 0 0 0 0 0 0 5000 10 0 0 0 0 0 0\ndocker0: 777 1 0 0 0 0 0 0 777 1 0 0 0 0 0 0\n";
private const string Dev2 = "Inter-| Receive\n face |bytes\n eth0: 3000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0\n";
[Fact]
public void Parsers_ReadTheProcFormats_AndSkipLoopbackAndContainerInterfaces()
{
Assert.Equal((1000L, 800L), HostSampler.ParseCpu(Stat1));
Assert.Equal((8L * 1024 * 1024, 2L * 1024 * 1024), HostSampler.ParseMemory("MemTotal: 8192 kB\nMemFree: 100 kB\nMemAvailable: 6144 kB\n"));
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1));
Assert.Equal(3600L, HostSampler.ParseUptime("3600.55 7000.1\n"));
Assert.Null(HostSampler.ParseNetwork("nothing useful"));
Assert.Equal((1000L, 5000L), HostSampler.ParseNetwork(Dev1 + "podman0: 8888 1 0 0 0 0 0 0 9999 1 0 0 0 0 0 0\n"));
}
[Fact]
public async Task Sample_OnWindows_ReadsCpuMemoryAndNetworkFromTheSystem()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var sampler = new HostSampler(TimeProvider.System);
sampler.Sample();
await Task.Delay(300, TestContext.Current.CancellationToken);
HostSnapshot snapshot = sampler.Sample();
Assert.NotNull(snapshot.CpuPercent);
Assert.InRange(snapshot.CpuPercent!.Value, 0, 100);
Assert.True(snapshot.MemTotal > 0 && snapshot.MemUsed >= 0 && snapshot.MemUsed <= snapshot.MemTotal);
}
[Fact]
public void Sample_DerivesCpuPercentAndNetworkSpeedFromTheDifferenceBetweenTwoReads()
{
var time = new Microsoft.Extensions.Time.Testing.FakeTimeProvider();
string stat = Stat1;
string dev = Dev1;
var sampler = new HostSampler(time, path => path.Replace('\\', '/') switch { "/proc/stat" => stat, "/proc/net/dev" => dev, "/proc/uptime" => "100.0 1.0", _ => null }, () => (1000, 250), procRoot: "/proc", sysRoot: "/sys");
HostSnapshot first = sampler.Sample();
Assert.Null(first.CpuPercent); // 第一次没有基线
Assert.Null(first.NetRxBps);
Assert.Equal(250, first.DiskUsed);
Assert.Equal(100, first.UptimeSeconds);
time.Advance(TimeSpan.FromSeconds(2));
stat = Stat2;
dev = Dev2;
HostSnapshot second = sampler.Sample();
Assert.Equal(66.7, second.CpuPercent); // 总时间 +300、空闲 +100:忙碌 200/300
Assert.Equal(1000, second.NetRxBps); // (3000 - 1000) / 2 s
Assert.Equal(2000, second.NetTxBps);
Assert.Equal(3000, second.NetRxTotal);
}
[Fact]
public void Sample_UsesMountedHostProcAndSys_InsteadOfContainerPaths()
{
var reads = new List<string>();
var sampler = new HostSampler(TimeProvider.System, path =>
{
path = path.Replace('\\', '/'); // 测试里的 /proc 格式统一用斜杠,兼容 Windows 的 Path.Combine
reads.Add(path);
return path switch
{
"/host/proc/stat" => Stat1,
"/host/proc/meminfo" => "MemTotal: 8192 kB\nMemAvailable: 6144 kB\n",
"/host/proc/net/dev" => Dev1,
"/host/proc/uptime" => "3600.0 7000.0",
"/host/sys/class/net/eth0/speed" => "10000\n",
_ => throw new InvalidOperationException("unexpected container path: " + path),
};
}, () => (1000, 250), procRoot: "/host/proc", sysRoot: "/host/sys");
HostSnapshot snapshot = sampler.Sample();
Assert.Equal(8L * 1024 * 1024, snapshot.MemTotal);
Assert.Equal(1000, snapshot.NetRxTotal);
Assert.Equal(10000, snapshot.NetLinkMbps);
Assert.Equal(3600, snapshot.UptimeSeconds);
Assert.DoesNotContain(reads, path => path.StartsWith("/proc/", StringComparison.Ordinal));
Assert.DoesNotContain(reads, path => path.Contains("docker0", StringComparison.Ordinal) || path.Contains("/lo/", StringComparison.Ordinal));
}
[Theory]
[InlineData(null)]
[InlineData("-1")]
[InlineData("0")]
[InlineData("not supported")]
public void Sample_DoesNotInventLinkSpeedOrContainerDisk_WhenHostDataIsUnavailable(string? speed)
{
var sampler = new HostSampler(TimeProvider.System,
path => path.EndsWith("net/dev", StringComparison.Ordinal) ? Dev1 : path.EndsWith("/speed", StringComparison.Ordinal) ? speed : null,
procRoot: "/host/proc", sysRoot: "/host/sys", diskRoot: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
HostSnapshot snapshot = sampler.Sample();
Assert.Null(snapshot.NetLinkMbps);
Assert.Null(snapshot.DiskTotal);
Assert.Null(snapshot.DiskUsed);
if (OperatingSystem.IsLinux())
{
Assert.Null(snapshot.UptimeSeconds);
}
}
}
public sealed class HostStatusTests : IClassFixture<BackendFixture>
{
private readonly BackendFixture _backend;
public HostStatusTests(BackendFixture backend)
{
_backend = backend;
}
private static HostSnapshot Snapshot(double cpu) => new()
{
CpuPercent = cpu, MemUsed = 2L << 30, MemTotal = 8L << 30, DiskUsed = 10L << 30, DiskTotal = 40L << 30,
NetRxTotal = 600L << 30, NetTxTotal = 180L << 30, NetRxBps = 2500, NetTxBps = 4800, UptimeSeconds = 3_000_000,
NetLinkMbps = 1000,
};
[Fact]
public async Task AgentHostSnapshots_AreVisibleToTheDisplayInRealTime_StoredAsHistory_AndNeverCarryModelsOrAddresses()
{
await _backend.SiteMustBePublicAsync();
await using AgentSimulator agent = await _backend.ConnectAgentAsync();
string id = agent.NodeId.Value;
await using SseClient sse = await SseClient.ConnectAsync(_backend.Anonymous, "/api/public/live?topics=hosts");
await agent.SendHostAsync(Snapshot(18.5));
SseEvent live = await sse.WaitForAsync(e => e.Name == "host" && e.Data!["id"]!.GetValue<string>() == id);
Assert.Equal(18.5, live.Data!["host"]!["cpu_percent"]!.GetValue<double>());
Assert.True(live.Data["online"]!.GetValue<bool>());
// REST:最新值,没有过期
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == id)!;
Assert.False(item["stale"]!.GetValue<bool>());
Assert.Equal(40L << 30, item["host"]!["disk_total"]!.GetValue<long>());
Assert.Equal(1000, item["host"]!["net_link_mbps"]!.GetValue<long>());
// 展示页能拿到的只有汇总数字:整份响应里没有型号、主机名、地址之类的字段
string text = list.ToJsonString();
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
{
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
}
// 线路详情也不泄漏 Dialer 的地址和版本
string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString();
Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dialer_version", line, StringComparison.OrdinalIgnoreCase);
// 历史:每分钟一个样本
JsonNode history = null!;
await BackendFixture.EventuallyAsync(async () =>
{
history = await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/hosts/{id}?range=1h");
return history["values"]!["cpu"]!.AsArray().Any(v => v is not null);
});
Assert.Equal(25.0, history["values"]!["mem"]!.AsArray().Last(v => v is not null)!.GetValue<double>());
Assert.Equal(2500, history["values"]!["rx_bps"]!.AsArray().Last(v => v is not null)!.GetValue<double>());
// 隐藏的 Agent 对匿名访客不可见
await _backend.Admin.PatchAsync($"/api/admin/agents/{id}", BackendFixture.Json(new JsonObject { ["public_visible"] = false }));
JsonNode hidden = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
Assert.DoesNotContain(hidden["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == id);
using HttpResponseMessage denied = await _backend.Anonymous.GetAsync($"/api/public/hosts/{id}");
Assert.Equal(System.Net.HttpStatusCode.NotFound, denied.StatusCode);
}
[Fact]
[Trait("Category", "Slow")]
public async Task ADialerThatTurnsReportingOff_SendsNothing()
{
await _backend.SiteMustBePublicAsync();
await using DialerHarness dialer = await DialerHarness.StartAsync(_backend, file =>
{
file.Reporting.HostStats = false;
file.Reporting.HostIntervalSeconds = 1;
});
await Task.Delay(TimeSpan.FromSeconds(4));
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
JsonNode item = list["items"]!.AsArray().Single(i => i!["id"]!.GetValue<string>() == dialer.DialerId)!;
Assert.Null(item["host"]); // 节点还在,但一个字也没上报
}
}