refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
This commit is contained in:
1 parent
17b35223aa
commit
83e5120377
309 files changed
+1505
-1499
No files matched your search
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Protocol.Tests;
|
||||
|
||||
public sealed class CatalogTests
|
||||
{
|
||||
private static JsonElement Json(string text) => JsonDocument.Parse(text).RootElement.Clone();
|
||||
|
||||
[Fact]
|
||||
public void EveryKind_HasBoundedParams_ValidDefaults_AndUniqueNames()
|
||||
{
|
||||
Assert.Equal(Kinds.All.Count, Kinds.All.Select(k => k.Id).Distinct().Count());
|
||||
foreach (KindSpec kind in Kinds.All)
|
||||
{
|
||||
ParamResolution defaults = ParamResolver.Resolve(kind.Params, null);
|
||||
Assert.True(defaults.IsValid, $"{kind.Id}: defaults must be valid");
|
||||
Assert.Equal(kind.Params.Count, kind.Params.Select(p => p.Name).Distinct().Count());
|
||||
Assert.True(kind.BudgetBitsPerSecond(defaults.Values!) >= 0);
|
||||
foreach (ParamSpec spec in kind.Params)
|
||||
{
|
||||
// 每个参数的默认值必须能通过自己的校验(上下限在构造时已写明)
|
||||
Assert.True(spec.TryNormalize(spec.DefaultValue, out _, out string error), $"{kind.Id}.{spec.Name}: {error}");
|
||||
}
|
||||
|
||||
foreach (StreamSpec stream in kind.Streams)
|
||||
{
|
||||
Assert.Equal(stream.Columns.Count, stream.Columns.Select(c => c.Name).Distinct().Count());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultLineTemplates_StayWithinTheLineBudget()
|
||||
{
|
||||
long total = 0;
|
||||
foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
|
||||
{
|
||||
total += kind.BudgetBitsPerSecond(ParamResolver.Resolve(kind.Params, null).Values!);
|
||||
}
|
||||
|
||||
Assert.InRange(total, 1, ProtocolLimits.MaxLineBudgetBitsPerSecond);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FillsDefaults_AndReportsEveryProblem()
|
||||
{
|
||||
var input = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["rate_hz"] = Json("500"),
|
||||
["size"] = Json("\"big\""),
|
||||
["bogus"] = Json("1"),
|
||||
};
|
||||
ParamResolution result = ParamResolver.Resolve(Kinds.UdpEcho.Params, input);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("between 1 and 200", result.Errors["rate_hz"]);
|
||||
Assert.Equal("must be an integer", result.Errors["size"]);
|
||||
Assert.Contains("not a parameter", result.Errors["bogus"]);
|
||||
Assert.False(result.Errors.ContainsKey("port"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_AcceptsBoundariesAndRejectsJustOutside()
|
||||
{
|
||||
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1") }).IsValid);
|
||||
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("200") }).IsValid);
|
||||
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("0") }).IsValid);
|
||||
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("201") }).IsValid);
|
||||
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1.5") }).IsValid);
|
||||
Assert.False(ParamResolver.Resolve(Kinds.UdpTrain.Params, new Dictionary<string, JsonElement> { ["direction"] = Json("\"sideways\"") }).IsValid);
|
||||
Assert.False(ParamResolver.Resolve(Kinds.TcpEcho.Params, new Dictionary<string, JsonElement> { ["tls"] = Json("1") }).IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kinds_AreUnknownWhenNotDeclared()
|
||||
{
|
||||
Assert.Null(Kinds.Find("nope"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("continuous", true)]
|
||||
[InlineData("once", true)]
|
||||
[InlineData("every:60", true)]
|
||||
[InlineData("every:59", false)]
|
||||
[InlineData("every:", false)]
|
||||
[InlineData("every:-5", false)]
|
||||
[InlineData("hourly", false)]
|
||||
public void Schedule_Validation(string schedule, bool valid)
|
||||
{
|
||||
Assert.Equal(valid, TaskSchedule.Validate(schedule).IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskSet_RoundTripsThroughTheJsonContext()
|
||||
{
|
||||
var set = new TaskSet
|
||||
{
|
||||
Version = 7,
|
||||
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
|
||||
Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }],
|
||||
};
|
||||
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
|
||||
TaskSet back = JsonSerializer.Deserialize(text, MoShiroJsonContext.Default.TaskSet)!;
|
||||
|
||||
Assert.Contains("\"id\":\"tsk_1\"", text);
|
||||
Assert.Equal(7, back.Version);
|
||||
Assert.Equal("tsk_1", back.Tasks[0].Id.Value);
|
||||
Assert.Equal(10, back.Tasks[0].Params["rate_hz"].GetInt64());
|
||||
Assert.Equal("prb_1", back.Peers[0].ProbeId.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
|
||||
{
|
||||
AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.AgentEnrollMeta)!;
|
||||
Assert.Equal(7443, meta.ProbeTcpPort);
|
||||
Assert.Equal(["udp_echo"], meta.Kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ErrorCodes_AreSnakeCase()
|
||||
{
|
||||
foreach (System.Reflection.FieldInfo field in typeof(MoShiroErrors).GetFields())
|
||||
{
|
||||
string value = ((ErrorCode)field.GetValue(null)!).Value;
|
||||
Assert.Matches("^[a-z]+(_[a-z]+)*$", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<ProjectReference Include="..\..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
||||
<ProjectReference Include="..\..\..\tests\MoShiro.TestSupport\MoShiro.TestSupport.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
using MoShiro.TestSupport;
|
||||
|
||||
namespace MoShiro.Protocol.Tests;
|
||||
|
||||
public sealed class RawChunkCodecTests
|
||||
{
|
||||
private static readonly NodeId s_agent = new("agt_test");
|
||||
|
||||
private static RawChunk SampleChunk()
|
||||
{
|
||||
RawChunk chunk = ChunkFactory.Echo(
|
||||
s_agent, "udp_echo", 1_700_000_000_000, 6, 100,
|
||||
i => new EchoPacket(LostUp: i == 2, LostDown: i == 4, 20_000 + (i * 100), 21_000));
|
||||
chunk.Header.ChunkId = new ChunkId(Guid.Parse("01890000-0000-7000-8000-000000000001"));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesEveryValue_IncludingMissingOnes()
|
||||
{
|
||||
RawChunk original = SampleChunk();
|
||||
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(original)).Value;
|
||||
|
||||
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
|
||||
Assert.Equal("udp_echo", decoded.Header.Kind);
|
||||
Assert.Equal(original.Header.Clock.ProbeOffsetUs, decoded.Header.Clock.ProbeOffsetUs);
|
||||
RawStream before = original.Find("msg")!;
|
||||
RawStream after = decoded.Find("msg")!;
|
||||
Assert.Equal(before.Count, after.Count);
|
||||
foreach (RawColumn column in before.Columns)
|
||||
{
|
||||
RawColumn other = after.Find(column.Name)!;
|
||||
Assert.Equal(column.Role, other.Role);
|
||||
for (int row = 0; row < before.Count; row++)
|
||||
{
|
||||
Assert.Equal(column.IsValid(row), other.IsValid(row));
|
||||
if (column.IsValid(row))
|
||||
{
|
||||
Assert.Equal(column.Values[row], other.Values[row]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.False(after.Find("t2")!.IsValid(2));
|
||||
Assert.False(after.Find("t4")!.IsValid(4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_HandlesTypeExtremes_AndNegativeDeltas()
|
||||
{
|
||||
var spec = new StreamSpec("x", [
|
||||
Col.U8("a"), Col.U16("b"), Col.U32("c"), Col.U64("d"), Col.Seq("e"),
|
||||
new ColumnSpec("f", ColumnType.I32), new ColumnSpec("g", ColumnType.I64, ColumnEnc.Delta, true),
|
||||
]);
|
||||
var builder = new StreamBuilder(spec)
|
||||
.Add(byte.MaxValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, 5, int.MinValue, long.MinValue / 2)
|
||||
.Add(0, 0, 0, 0, 2, int.MaxValue, null)
|
||||
.Add(1, 1, 1, 1, 9, -1, long.MaxValue / 2);
|
||||
var header = new RawChunkHeader { Kind = "x", AgentId = s_agent };
|
||||
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(new RawChunk(header, [builder.Build()]))).Value;
|
||||
RawStream s = decoded.Find("x")!;
|
||||
|
||||
Assert.Equal(uint.MaxValue, s.Find("c")!.Values[0]);
|
||||
Assert.Equal(long.MaxValue, s.Find("d")!.Values[0]);
|
||||
Assert.Equal(new long[] { 5, 2, 9 }, s.Find("e")!.Values);
|
||||
Assert.Equal(int.MinValue, s.Find("f")!.Values[0]);
|
||||
Assert.Equal(long.MinValue / 2, s.Find("g")!.Values[0]);
|
||||
Assert.False(s.Find("g")!.IsValid(1));
|
||||
Assert.Equal(long.MaxValue / 2, s.Find("g")!.Values[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenBody_LayoutIsStable()
|
||||
{
|
||||
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
|
||||
RawChunk chunk = SampleChunk();
|
||||
chunk.Header.Clock = new ChunkClock { AgentMonoUs = 1000, UtcMs = 1_700_000_000_000, ProbeOffsetUs = 500 };
|
||||
byte[] body = RawChunkCodec.EncodeBody(chunk);
|
||||
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
|
||||
string header = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)headerLength));
|
||||
|
||||
Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"udp_echo\"", header);
|
||||
Assert.Contains("\"streams\":[{\"name\":\"msg\",\"count\":6,\"columns\":[{\"name\":\"seq\",\"type\":\"u32\",\"enc\":\"delta\",\"nullable\":false},", header);
|
||||
Assert.Equal(GoldenColumns, columns);
|
||||
}
|
||||
|
||||
private const string GoldenColumns = "00020202020240404040404080fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
|
||||
|
||||
[Fact]
|
||||
public void Decode_RejectsBadMagicVersionCrcAndTruncation()
|
||||
{
|
||||
byte[] good = RawChunkCodec.Encode(SampleChunk());
|
||||
|
||||
byte[] badMagic = (byte[])good.Clone();
|
||||
badMagic[0] = (byte)'X';
|
||||
byte[] badVersion = (byte[])good.Clone();
|
||||
badVersion[4] = 99;
|
||||
byte[] badCrc = (byte[])good.Clone();
|
||||
badCrc[^1] ^= 0xFF;
|
||||
|
||||
Assert.Equal(MoShiroErrors.RawCorrupt, RawChunkCodec.Decode(badMagic).Error.Code);
|
||||
Assert.Contains("version", RawChunkCodec.Decode(badVersion).Error.Message);
|
||||
Assert.Contains("crc", RawChunkCodec.Decode(badCrc).Error.Message);
|
||||
Assert.False(RawChunkCodec.Decode(good.AsSpan(0, good.Length / 2)).IsSuccess);
|
||||
Assert.False(RawChunkCodec.Decode([]).IsSuccess);
|
||||
Assert.False(RawChunkCodec.Decode(good.AsSpan(0, 8)).IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_RejectsDecompressionBombs()
|
||||
{
|
||||
byte[] zeros = new byte[ProtocolLimits.MaxDecodedChunkBytes + 1024];
|
||||
using var compressed = new MemoryStream();
|
||||
using (var brotli = new BrotliStream(compressed, CompressionLevel.Fastest, leaveOpen: true))
|
||||
{
|
||||
brotli.Write(zeros);
|
||||
}
|
||||
|
||||
byte[] payload = compressed.ToArray();
|
||||
Assert.True(payload.Length < 1_000_000, "the bomb must be small on the wire");
|
||||
Assert.False(RawChunkCodec.Decode(Wrap(payload)).IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_RejectsHeaderLengthBeyondData_AndTrailingBytes()
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, 1_000_000);
|
||||
Assert.Contains("header length", RawChunkCodec.Decode(Wrap(Brotli(body))).Error.Message);
|
||||
|
||||
byte[] valid = RawChunkCodec.EncodeBody(SampleChunk());
|
||||
byte[] withTrailing = [.. valid, 1, 2, 3];
|
||||
Assert.Contains("trailing", RawChunkCodec.Decode(Wrap(Brotli(withTrailing))).Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_RejectsOutOfRangeValues_AndTooManyRecords()
|
||||
{
|
||||
// u8 列里写入 300
|
||||
var spec = new StreamSpec("x", [new ColumnSpec("a", ColumnType.I32)]);
|
||||
RawChunk chunk = new(new RawChunkHeader { Kind = "x", AgentId = s_agent }, [new StreamBuilder(spec).Add(300).Build()]);
|
||||
byte[] body = RawChunkCodec.EncodeBody(chunk);
|
||||
string json = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)BinaryPrimitives.ReadUInt32LittleEndian(body)));
|
||||
string patched = json.Replace("\"type\":\"i32\"", "\"type\":\"u8\"");
|
||||
byte[] patchedJson = System.Text.Encoding.UTF8.GetBytes(patched);
|
||||
byte[] rebuilt = new byte[4 + patchedJson.Length + (body.Length - 4 - json.Length)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(rebuilt, (uint)patchedJson.Length);
|
||||
patchedJson.CopyTo(rebuilt, 4);
|
||||
body.AsSpan(4 + json.Length).CopyTo(rebuilt.AsSpan(4 + patchedJson.Length));
|
||||
Assert.Contains("out of range", RawChunkCodec.Decode(Wrap(Brotli(rebuilt))).Error.Message);
|
||||
|
||||
string tooMany = json.Replace("\"count\":1", $"\"count\":{ProtocolLimits.MaxRecordsPerChunk + 1}");
|
||||
byte[] tooManyJson = System.Text.Encoding.UTF8.GetBytes(tooMany);
|
||||
byte[] big = new byte[4 + tooManyJson.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(big, (uint)tooManyJson.Length);
|
||||
tooManyJson.CopyTo(big, 4);
|
||||
Assert.Contains("invalid size", RawChunkCodec.Decode(Wrap(Brotli(big))).Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_IsSmallerThanTheNaiveRepresentation()
|
||||
{
|
||||
// 10 Hz 的 udp_echo 一个 10 秒块应当远小于每行 6×8 字节
|
||||
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, 1_700_000_000_000);
|
||||
Assert.True(RawChunkCodec.Encode(chunk).Length < 100 * 6 * 8 / 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Crc32c_MatchesTheStandardCheckValue()
|
||||
{
|
||||
Assert.Equal(0xE3069283u, Crc32c.Compute("123456789"u8));
|
||||
}
|
||||
|
||||
private static byte[] Brotli(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var brotli = new BrotliStream(output, CompressionLevel.Fastest, leaveOpen: true))
|
||||
{
|
||||
brotli.Write(data);
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] Wrap(byte[] payload)
|
||||
{
|
||||
byte[] result = new byte[9 + payload.Length];
|
||||
"MSRC"u8.CopyTo(result);
|
||||
result[4] = ProtocolVersion.Container;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(result.AsSpan(5), Crc32c.Compute(payload));
|
||||
payload.CopyTo(result, 9);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user