refactor!: rename the project from Monitor to MoShiro [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
This commit is contained in:
1 parent
27ac7fb787
commit
2e146d4da7
309 files changed
+1565
-1572
No files matched your search
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed record PhaseSpec(string Kind, IReadOnlyDictionary<string, JsonElement> Overrides, int DurationSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// 测试方案:业务层的概念,只在 Backend。方案只是若干采集原语(Kind)阶段的编排,采集层(Agent、Probe、Measurement)不认识它。
|
||||
/// 测试方案:多个阶段的 once 任务组合(例如 hy2_ladder 在 10、25、50、100 Mbps 下各跑 10 秒)。</summary>
|
||||
public sealed record PlanSpec(string Id, string Title, string Description, IReadOnlyList<PhaseSpec> Phases);
|
||||
|
||||
public static class Plans
|
||||
{
|
||||
private static JsonElement N(double value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.Double);
|
||||
|
||||
private static JsonElement S(string value) => JsonSerializer.SerializeToElement(value, PlanJson.Default.String);
|
||||
|
||||
private static PhaseSpec Train(double mbps) =>
|
||||
new("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = N(mbps), ["duration_s"] = N(10) }, 10);
|
||||
|
||||
public static readonly PlanSpec Hy2Ladder = new(
|
||||
"hy2_ladder",
|
||||
"Hysteria2 ladder",
|
||||
"UDP train at 10, 25, 50 and 100 Mbit/s for 10 s each; models Brutal fixed-rate congestion control.",
|
||||
[Train(10), Train(25), Train(50), Train(100)]);
|
||||
|
||||
public static readonly PlanSpec RealitySession = new(
|
||||
"reality_session",
|
||||
"REALITY session",
|
||||
"Connection establishment, a short (4 s) bulk transfer for the peak throughput and a held connection.",
|
||||
[
|
||||
new PhaseSpec("connect", new Dictionary<string, JsonElement> { ["per_minute"] = N(20) }, 30),
|
||||
new PhaseSpec("tcp_bulk", new Dictionary<string, JsonElement> { ["duration_s"] = N(4), ["direction"] = S("rev") }, 4),
|
||||
new PhaseSpec("hold", new Dictionary<string, JsonElement> { ["heartbeat_s"] = N(5) }, 30),
|
||||
]);
|
||||
|
||||
public static readonly IReadOnlyList<PlanSpec> All = [Hy2Ladder, RealitySession];
|
||||
|
||||
public static PlanSpec? Find(string id)
|
||||
{
|
||||
foreach (PlanSpec plan in All)
|
||||
{
|
||||
if (plan.Id == id)
|
||||
{
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(double))]
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(string))]
|
||||
internal sealed partial class PlanJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
@@ -0,0 +1,95 @@
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 后台任务:给已成功结束的测试算结果(所有阶段的块到达之后,或结束一分钟后以现有的块为准),
|
||||
/// 并把长时间没有汇报的测试标记为失败。
|
||||
/// </summary>
|
||||
public sealed partial class TestFinalizer : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan s_poll = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan s_chunkWait = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan s_stale = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly ITestStore _tests;
|
||||
private readonly IRawStore _raw;
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<TestFinalizer> _logger;
|
||||
|
||||
public TestFinalizer(ITestStore tests, IRawStore raw, LiveHub live, TimeProvider time, ILogger<TestFinalizer> logger)
|
||||
{
|
||||
_tests = tests;
|
||||
_raw = raw;
|
||||
_live = live;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunOnceAsync(stoppingToken);
|
||||
await Task.Delay(s_poll, _time, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 后台循环不能因为一次失败而退出
|
||||
LogFailed(ex);
|
||||
await Task.Delay(s_poll, _time, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>执行一轮(也供测试直接调用)。</summary>
|
||||
public async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
await _tests.FailStaleAsync(now - (long)s_stale.TotalMilliseconds, "test_timeout", now, ct);
|
||||
|
||||
foreach (TestRecord test in await _tests.ListNeedingResultAsync(20, ct))
|
||||
{
|
||||
IReadOnlyList<RawChunkMeta> metas = await _raw.FindAsync(new RawQuery(TestId: test.Id, Limit: 1000), ct);
|
||||
int phases = metas.Where(m => m.Phase is not null).Select(m => m.Phase).Distinct().Count();
|
||||
bool waited = test.FinishedAtMs is { } finished && now - finished >= (long)s_chunkWait.TotalMilliseconds;
|
||||
if (metas.Count == 0 ? !waited : phases < test.PhaseCount && !waited)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var chunks = new List<RawChunk>(metas.Count);
|
||||
foreach (RawChunkMeta meta in metas)
|
||||
{
|
||||
byte[]? bytes = await _raw.ReadAsync(meta, ct);
|
||||
if (bytes is not null && RawChunkCodec.Decode(bytes).TryGet(out RawChunk? chunk, out _))
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
TestResult result = TestEvaluator.Evaluate(test.Plan, chunks);
|
||||
if (chunks.Count < test.PhaseCount)
|
||||
{
|
||||
result.Caveats.Add("partial_data");
|
||||
}
|
||||
|
||||
await _tests.SaveResultAsync(TestService.ToRecord(test.Id, result), ct);
|
||||
_live.Publish(new TestSignal(test.Id));
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "test finalizer iteration failed")]
|
||||
private partial void LogFailed(Exception exception);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MoShiro.Backend.Analysis;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
public sealed class StoredTestMetrics
|
||||
{
|
||||
[JsonPropertyName("metrics")]
|
||||
public Dictionary<string, double> Metrics { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("caveats")]
|
||||
public List<string> Caveats { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public List<TestStepResult> Steps { get; set; } = [];
|
||||
}
|
||||
|
||||
[JsonSerializable(typeof(StoredTestMetrics))]
|
||||
[JsonSerializable(typeof(Dictionary<string, double?[]>), TypeInfoPropertyName = "SeriesMap")]
|
||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "ParamMap")]
|
||||
internal sealed partial class TestJson : JsonSerializerContext;
|
||||
|
||||
public sealed record ScoreView(double? Score, int Version, bool Estimated, IReadOnlyList<ModelDimension> Dimensions, IReadOnlyList<string> Caveats);
|
||||
|
||||
public sealed record TestResultView(
|
||||
IReadOnlyDictionary<string, double> Metrics,
|
||||
IReadOnlyDictionary<string, ScoreView> Scores,
|
||||
IReadOnlyDictionary<string, double?[]> Series,
|
||||
IReadOnlyList<TestStepResult> Steps,
|
||||
IReadOnlyList<string> Caveats);
|
||||
|
||||
public sealed record TestDetail(TestRecord Test, IReadOnlyList<string> PhaseKinds, TestResultView? Result);
|
||||
|
||||
public sealed record StartTestRequest(string Plan, IReadOnlyDictionary<string, JsonElement>? Params);
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Server;
|
||||
using MoShiro.Backend.Analysis;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>按需测试(once):校验与排队、下发给 Agent、状态更新、结果查询。</summary>
|
||||
public sealed class TestService
|
||||
{
|
||||
private readonly double _phaseScale;
|
||||
private readonly ITestStore _tests;
|
||||
private readonly IAgentStore _agents;
|
||||
private readonly AgentRuntime _runtime;
|
||||
private readonly IAgentGateway _gateway;
|
||||
private readonly LiveHub _live;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TestService(ITestStore tests, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, LiveHub live, TimeProvider time, BackendOptions options)
|
||||
{
|
||||
_phaseScale = options.PhaseDurationScale;
|
||||
_tests = tests;
|
||||
_agents = agents;
|
||||
_runtime = runtime;
|
||||
_gateway = gateway;
|
||||
_live = live;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TestRecord>> StartAsync(NodeId agentId, ProbeId probeId, StartTestRequest request, string trigger, CancellationToken ct)
|
||||
{
|
||||
PlanSpec? plan = Plans.Find(request.Plan);
|
||||
if (plan is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.UnknownPlan, $"unknown plan {request.Plan}");
|
||||
}
|
||||
|
||||
AgentRecord? agent = await _agents.GetAsync(agentId, ct);
|
||||
if (agent is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentNotFound, "agent not found");
|
||||
}
|
||||
|
||||
if (agent.Status != NodeStatus.Active)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentNotActive, "agent is not active");
|
||||
}
|
||||
|
||||
// Agent 在 hello 里声明了它支持的采集方法:方案里有它不支持的就不下发(否则要等到阶段开始才失败)
|
||||
string? unsupported = plan.Phases.Select(p => p.Kind).FirstOrDefault(kind => !agent.Kinds.Contains(kind));
|
||||
if (unsupported is not null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(FleetErrors.Unsupported, $"this agent does not support {unsupported}, which the {plan.Id} plan needs");
|
||||
}
|
||||
|
||||
Result<List<TestPhase>> phases = BuildPhases(plan, request.Params, agent, _phaseScale);
|
||||
if (!phases.TryGet(out List<TestPhase>? phaseList, out FleetError? error))
|
||||
{
|
||||
return Result<TestRecord>.Fail(error);
|
||||
}
|
||||
|
||||
if (!_gateway.IsOnline(agentId))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.AgentOffline, "agent is offline");
|
||||
}
|
||||
|
||||
if (!(_runtime.TryGet(agentId, out AgentRuntimeState? state) && state.IsConnected(probeId.Value)))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.ProbeOffline, $"probe {probeId.Value} has no session with this agent");
|
||||
}
|
||||
|
||||
if (await _tests.HasActiveAsync(agentId, probeId, ct))
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.TestBusy, "another test is already running on this line");
|
||||
}
|
||||
|
||||
var testId = new TestId(Ulid.NewPrefixed(TestId.Prefix, _time));
|
||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
string paramsJson = request.Params is null ? "{}" : JsonSerializer.Serialize(new Dictionary<string, JsonElement>(request.Params), TestJson.Default.ParamMap);
|
||||
var record = new TestRecord(testId, agentId, probeId, plan.Id, paramsJson, trigger, TestStatuses.Queued, null, null, phaseList.Count, now, null, null);
|
||||
await _tests.CreateAsync(record, ct);
|
||||
|
||||
Result<bool> sent = await _gateway.RunTestAsync(agentId, new TestRunParams { TestId = testId, ProbeId = probeId, Plan = plan.Id, Phases = phaseList }, ct);
|
||||
if (!sent.TryGet(out _, out FleetError? sendError))
|
||||
{
|
||||
await _tests.UpdateStatusAsync(testId, TestStatuses.Failed, null, sendError.Code.Value, now, ct);
|
||||
return Result<TestRecord>.Fail(sendError);
|
||||
}
|
||||
|
||||
return Result<TestRecord>.Ok(record);
|
||||
}
|
||||
|
||||
public async ValueTask<Result<TestRecord>> CancelAsync(TestId id, CancellationToken ct)
|
||||
{
|
||||
TestRecord? test = await _tests.GetAsync(id, ct);
|
||||
if (test is null)
|
||||
{
|
||||
return Result<TestRecord>.Fail(MoShiroErrors.TestNotFound, "test not found");
|
||||
}
|
||||
|
||||
if (IsTerminal(test.Status))
|
||||
{
|
||||
return Result<TestRecord>.Ok(test);
|
||||
}
|
||||
|
||||
await _gateway.CancelTestAsync(test.AgentId, id, ct);
|
||||
if (!_gateway.IsOnline(test.AgentId))
|
||||
{
|
||||
// 节点已经不在线,没有人会再汇报这个测试
|
||||
await _tests.UpdateStatusAsync(id, TestStatuses.Aborted, null, null, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
_live.Publish(new TestSignal(id));
|
||||
}
|
||||
|
||||
return Result<TestRecord>.Ok(await _tests.GetAsync(id, ct) ?? test);
|
||||
}
|
||||
|
||||
/// <summary><c>moshiro.test.status</c>:只接受该测试所属 Agent 的汇报。</summary>
|
||||
public async ValueTask OnStatusAsync(NodeId agent, TestStatusParams status, CancellationToken ct)
|
||||
{
|
||||
TestRecord? test = await _tests.GetAsync(status.TestId, ct);
|
||||
if (test is null || test.AgentId != agent || IsTerminal(test.Status))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string next = status.Status switch
|
||||
{
|
||||
TestStatuses.Running or TestStatuses.PhaseDone or TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted => status.Status,
|
||||
_ => test.Status,
|
||||
};
|
||||
string stored = next == TestStatuses.PhaseDone ? TestStatuses.Running : next;
|
||||
await _tests.UpdateStatusAsync(status.TestId, stored, status.Phase, status.ErrorCode, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
_live.Publish(new TestSignal(status.TestId));
|
||||
}
|
||||
|
||||
public async ValueTask<TestDetail?> GetAsync(TestId id, CancellationToken ct)
|
||||
{
|
||||
TestRecord? test = await _tests.GetAsync(id, ct);
|
||||
if (test is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PlanSpec? plan = Plans.Find(test.Plan);
|
||||
TestResultRecord? stored = await _tests.GetResultAsync(id, ct);
|
||||
return new TestDetail(test, plan?.Phases.Select(p => p.Kind).ToList() ?? [], stored is null ? null : ToView(stored));
|
||||
}
|
||||
|
||||
public ValueTask<IReadOnlyList<TestRecord>> ListAsync(NodeId? agentId, ProbeId? probeId, int limit, CancellationToken ct) => _tests.ListAsync(agentId, probeId, limit, ct);
|
||||
|
||||
public static TestResultView ToView(TestResultRecord stored)
|
||||
{
|
||||
StoredTestMetrics metrics = JsonSerializer.Deserialize(stored.MetricsJson, TestJson.Default.StoredTestMetrics) ?? new StoredTestMetrics();
|
||||
Dictionary<string, double?[]> series = JsonSerializer.Deserialize(stored.SeriesJson, TestJson.Default.SeriesMap) ?? [];
|
||||
var view = new MetricView(metrics.Metrics) { MeasuredOnServicePort = !metrics.Caveats.Contains("port_differs_from_service") };
|
||||
var scores = new Dictionary<string, ScoreView>();
|
||||
foreach ((string id, ModelScore score) in ModelRegistry.ScoreAll(view))
|
||||
{
|
||||
scores[id] = new ScoreView(score.Score, score.Version, score.Estimated, score.Dimensions, score.Caveats);
|
||||
}
|
||||
|
||||
return new TestResultView(metrics.Metrics, scores, series, metrics.Steps, metrics.Caveats);
|
||||
}
|
||||
|
||||
public static TestResultRecord ToRecord(TestId id, TestResult result)
|
||||
{
|
||||
var stored = new StoredTestMetrics { Metrics = result.Metrics, Caveats = result.Caveats, Steps = result.Steps };
|
||||
var series = result.Series.ToDictionary(s => s.Key, s => s.Value.Select(v => double.IsNaN(v) || double.IsInfinity(v) ? (double?)null : v).ToArray());
|
||||
return new TestResultRecord(
|
||||
id,
|
||||
JsonSerializer.SerializeToUtf8Bytes(stored, TestJson.Default.StoredTestMetrics),
|
||||
JsonSerializer.SerializeToUtf8Bytes(series, TestJson.Default.SeriesMap));
|
||||
}
|
||||
|
||||
public static bool IsTerminal(string status) => status is TestStatuses.Succeeded or TestStatuses.Failed or TestStatuses.Aborted;
|
||||
|
||||
private static Result<List<TestPhase>> BuildPhases(PlanSpec plan, IReadOnlyDictionary<string, JsonElement>? overrides, AgentRecord agent, double phaseScale)
|
||||
{
|
||||
if (overrides is not null)
|
||||
{
|
||||
var unknown = new Dictionary<string, string>();
|
||||
foreach (string key in overrides.Keys)
|
||||
{
|
||||
if (!plan.Phases.Any(p => Kinds.Find(p.Kind)!.Params.Any(s => s.Name == key)))
|
||||
{
|
||||
unknown[$"params.{key}"] = "is not a parameter of any phase of this plan";
|
||||
}
|
||||
}
|
||||
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
return ValidationError.Fail<List<TestPhase>>(unknown);
|
||||
}
|
||||
}
|
||||
|
||||
double? limitMbps = null;
|
||||
if (agent.LocalLimitsJson is { } limitsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
limitMbps = JsonSerializer.Deserialize(limitsJson, LocalLimitsJson.Default.LocalLimits)?.MaxTotalMbps;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 无法解析就当作没有声明
|
||||
}
|
||||
}
|
||||
|
||||
var phases = new List<TestPhase>();
|
||||
var errors = new Dictionary<string, string>();
|
||||
foreach (PhaseSpec phase in plan.Phases)
|
||||
{
|
||||
KindSpec kind = Kinds.Find(phase.Kind)!;
|
||||
var merged = new Dictionary<string, JsonElement>(phase.Overrides);
|
||||
if (overrides is not null)
|
||||
{
|
||||
foreach ((string key, JsonElement value) in overrides)
|
||||
{
|
||||
if (kind.Params.Any(s => s.Name == key))
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 没有指定端口的 UDP 阶段使用 Agent 上报的数据端口(目录里的默认值只是出厂默认,每个 Agent 可以改)
|
||||
if (!merged.ContainsKey("port") && kind.Blocks.Transport == "udp" && kind.Params.Any(s => s.Name == "port") && agent.ProbeUdpPorts.Count > 0)
|
||||
{
|
||||
merged["port"] = JsonSerializer.SerializeToElement(agent.ProbeUdpPorts[0], PlanPortJson.Default.Int32);
|
||||
}
|
||||
|
||||
ParamResolution resolved = ParamResolver.Resolve(kind.Params, merged);
|
||||
if (!resolved.IsValid)
|
||||
{
|
||||
foreach ((string key, string message) in resolved.Errors)
|
||||
{
|
||||
errors[$"params.{key}"] = message;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
long bits = kind.BudgetBitsPerSecond(resolved.Values!);
|
||||
if (limitMbps is > 0 && bits > limitMbps * 1_000_000)
|
||||
{
|
||||
return Result<List<TestPhase>>.Fail(MoShiroErrors.BudgetExceeded, $"{phase.Kind} needs {bits / 1_000_000.0:0.#} Mbit/s but the agent allows {limitMbps} Mbit/s");
|
||||
}
|
||||
|
||||
int duration = resolved.Values!.Values.TryGetValue("duration_s", out JsonElement d) && d.TryGetInt64(out long seconds) ? (int)seconds : Math.Max(1, (int)Math.Round(phase.DurationSeconds * phaseScale));
|
||||
phases.Add(new TestPhase { Kind = phase.Kind, Params = new Dictionary<string, JsonElement>(resolved.Values.Values), DurationSeconds = duration });
|
||||
}
|
||||
|
||||
return errors.Count > 0 ? ValidationError.Fail<List<TestPhase>>(errors) : Result<List<TestPhase>>.Ok(phases);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(int))]
|
||||
internal sealed partial class PlanPortJson : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
Reference in new issue
Block a user