241 lines
12 KiB
C#
241 lines
12 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Collection("backend")]
|
|
public sealed class HostStatusTests
|
|
{
|
|
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("agent", live.Data!["kind"]!.GetValue<string>());
|
|
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.Equal("agent", item["kind"]!.GetValue<string>());
|
|
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);
|
|
}
|
|
|
|
// 线路详情也不泄漏 Probe 的地址和版本
|
|
string line = (await _backend.GetJsonAsync(_backend.Anonymous, $"/api/public/lines/{_backend.Line(id)}")).ToJsonString();
|
|
Assert.DoesNotContain("remote", line, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("probe_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]
|
|
public async Task ARealProbeReportsItsHostStatus_LikeAnAgent_NeverCarriesModelsOrAddresses_AndDisappearsWhenRevoked()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using ProbeHarness probe = await ProbeHarness.StartAsync(_backend, file => file.Reporting.HostIntervalSeconds = 1);
|
|
|
|
JsonNode? item = null;
|
|
await BackendFixture.EventuallyAsync(
|
|
async () =>
|
|
{
|
|
JsonNode list = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
|
item = list["items"]!.AsArray().SingleOrDefault(i => i!["id"]!.GetValue<string>() == probe.ProbeId);
|
|
return item?["host"] is not null;
|
|
},
|
|
TimeSpan.FromSeconds(30),
|
|
"the probe's host status shows up");
|
|
Assert.Equal("probe", item!["kind"]!.GetValue<string>());
|
|
Assert.NotNull(item["host"]!["uptime_seconds"]); // 任何平台都读得到运行时长
|
|
foreach (string forbidden in new[] { "model", "hostname", "\"ip\"", "address", "remote", "mac", "iface", "public_host" })
|
|
{
|
|
Assert.DoesNotContain(forbidden, item.ToJsonString(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// 历史也按节点 id 取
|
|
using HttpResponseMessage history = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}?range=1h");
|
|
Assert.Equal(System.Net.HttpStatusCode.OK, history.StatusCode);
|
|
|
|
// 吊销之后展示页就看不到它了
|
|
using HttpResponseMessage revoked = await _backend.Admin.PostAsync($"/api/admin/probes/{probe.ProbeId}/revoke", null);
|
|
Assert.True(revoked.IsSuccessStatusCode);
|
|
JsonNode after = await _backend.GetJsonAsync(_backend.Anonymous, "/api/public/hosts");
|
|
Assert.DoesNotContain(after["items"]!.AsArray(), i => i!["id"]!.GetValue<string>() == probe.ProbeId);
|
|
using HttpResponseMessage gone = await _backend.Anonymous.GetAsync($"/api/public/hosts/{probe.ProbeId}");
|
|
Assert.Equal(System.Net.HttpStatusCode.NotFound, gone.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
[Trait("Category", "Slow")]
|
|
public async Task AProbeThatTurnsReportingOff_SendsNothing()
|
|
{
|
|
await _backend.SiteMustBePublicAsync();
|
|
await using ProbeHarness probe = await ProbeHarness.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>() == probe.ProbeId)!;
|
|
Assert.Null(item["host"]); // 节点还在,但一个字也没上报
|
|
}
|
|
}
|