diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 90d912a..54a8f74 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -5023,8 +5023,10 @@ "/install/openwrt.sh": { "get": { "tags": [ - "moshiro-backend" + "ops" ], + "summary": "The static native OpenWrt Probe installer and updater", + "operationId": "install_openwrt", "responses": { "200": { "description": "OK" @@ -10146,9 +10148,6 @@ { "name": "ops" }, - { - "name": "moshiro-backend" - }, { "name": "release" }, diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/PlatformDetectorTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/PlatformDetectorTests.cs index ddb2575..d52e45a 100644 --- a/backend/shared/tests/MoShiro.Measurement.Tests/PlatformDetectorTests.cs +++ b/backend/shared/tests/MoShiro.Measurement.Tests/PlatformDetectorTests.cs @@ -7,6 +7,7 @@ public sealed class PlatformDetectorTests [InlineData("NAME=\"OpenWrt\"|VERSION_ID=\"24.10.0\"|ID=openwrt", "OpenWrt 24.10")] [InlineData("NAME=\"Alpine Linux\"|ID=alpine|VERSION_ID=3.22.1", "Alpine Linux 3.22")] [InlineData("NAME=\"Ubuntu\"|VERSION_ID=\"24.04\"", "Ubuntu 24.04")] + [InlineData("NAME=\"Fedora Linux\"|VERSION_ID=44|HOSTNAME=private-router|KERNEL_VERSION=6.18.1|PRETTY_NAME=\"Fedora Linux 44 (Workstation Edition)\"", "Fedora Linux 44")] [InlineData("ID=arch", "arch")] [InlineData("", "Linux")] public void LinuxName_IsTheDistroAndItsMajorMinorVersion(string osRelease, string expected) @@ -22,6 +23,7 @@ public sealed class PlatformDetectorTests Assert.Contains(platform.Arch, new[] { "x64", "arm64", "arm", "x86" }); // 没有主机名、内核版本:名字很短,也不含小数点以外的版本细节 Assert.True((platform.Name ?? string.Empty).Length < 40); - Assert.DoesNotContain(Environment.MachineName, platform.Name ?? string.Empty, StringComparison.OrdinalIgnoreCase); + // 主机名可能恰好就是发行版名(例如 fedora),不能用字符串重合判断泄漏。 + // 发行版字段的白名单和版本截断由上面的固定输入覆盖。 } } diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 8b0041d..9fdaee6 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -21,7 +21,9 @@ public static class OpsEndpoints .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); // 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。 // 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。 - app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript")); + app.MapGet("/install/openwrt.sh", (HttpContext http) => Static(http, "openwrt.sh", "text/x-shellscript")) + .Api("install_openwrt", "The static native OpenWrt Probe installer and updater", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript"); app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript")) .Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) diff --git a/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs b/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs index 716777d..f0e562c 100644 --- a/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs +++ b/backend/src/MoShiro.Backend.Application/Query/RowLoader.cs @@ -79,16 +79,15 @@ public sealed class RowLoader if (earliest is null || at < earliest.Value + width) { - earliest = earliest is null ? at : Math.Min(earliest.Value, at); + earliest = at; + width = level.WidthMs(); } - - width = level.WidthMs(); } long? hot = _hot.GetRange(agent, series, nowMs - Steps.Hour, nowMs + 10_000).Select(r => (long?)r.Key).Min(); if (hot is { } h && (earliest is null || h < earliest.Value + width)) { - earliest = earliest is null ? h : Math.Min(earliest.Value, h); + earliest = h; } return earliest; diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index a0e645c..fa20903 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -52,7 +52,7 @@ public sealed partial class OpenApiConformanceTests : IClassFixture path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { diff --git a/backend/tests/MoShiro.Backend.Tests/RowLoaderTests.cs b/backend/tests/MoShiro.Backend.Tests/RowLoaderTests.cs new file mode 100644 index 0000000..9c84b98 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/RowLoaderTests.cs @@ -0,0 +1,72 @@ +using Fleet.Core; +using Microsoft.Extensions.Time.Testing; +using MoShiro.Backend.Analysis; +using MoShiro.Backend.Application; +using MoShiro.Backend.Storage.Sqlite; +using MoShiro.Protocol; +using MoShiro.TestSupport; + +namespace MoShiro.Backend.Tests; + +public sealed class RowLoaderTests : IDisposable +{ + private static readonly NodeId s_agent = new("agt_earliest"); + + private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-earliest-" + Guid.NewGuid().ToString("N")); + private readonly SqliteDatabase _database; + private readonly RawQueryGate _gate = new(); + + public RowLoaderTests() + { + _database = new SqliteDatabase(_directory); + } + + [Theory] + [InlineData(0L, 1_800_000L, 1_920_000L, 1_930_000L, 1_931_000L, 1_931_000L)] + [InlineData(0L, 7_200_000L, null, null, 1_800_000L, 1_800_000L)] + [InlineData(0L, 7_200_000L, 1_800_000L, null, null, 1_800_000L)] + [InlineData(0L, 3_600_000L, 3_660_000L, 3_670_000L, null, 0L)] + [InlineData(null, null, null, null, null, null)] + [InlineData(null, null, null, null, 1_850_000L, 1_850_000L)] + [InlineData(null, null, 1_800_000L, null, 1_801_000L, 1_801_000L)] + [InlineData(null, null, 1_800_000L, null, 1_860_000L, 1_800_000L)] + [InlineData(0L, null, 0L, null, null, 0L)] + [InlineData(0L, null, 3_540_000L, null, null, 3_540_000L)] + [InlineData(0L, null, 3_600_000L, null, null, 0L)] + [InlineData(null, null, null, 1_930_000L, null, 1_930_000L)] + public async Task EarliestSampleMs_RefinesWithinTheFirstBucket_WithoutDiscardingOlderHistory( + long? hourStart, long? fiveMinuteStart, long? minuteStart, long? tenSecondStart, long? hotStart, long? expected) + { + var time = new FakeTimeProvider(DateTimeOffset.UnixEpoch.AddHours(1)); + var store = new SqliteMetricStore(_database); + var hot = new HotLayer(time); + const string Series = "line"; + foreach ((MetricLevel level, long? start) in new[] + { + (MetricLevel.H1, hourStart), (MetricLevel.M5, fiveMinuteStart), + (MetricLevel.M1, minuteStart), (MetricLevel.S10, tenSecondStart), + }) + { + if (start is { } at) + { + var row = new MetricRow(s_agent, Series, at, false, AccumulatorCodec.Encode(new MetricSet())); + await store.UpsertAsync(level, [row], default); + } + } + + if (hotStart is { } sample) + { + hot.Merge(s_agent, Series, ChunkFactory.UdpEchoClean(s_agent, sample)); + } + + var loader = new RowLoader(store, hot, new WindowComputer(new SqliteRawStore(_database)), _gate); + Assert.Equal(expected, await loader.EarliestSampleMsAsync(s_agent, Series, time.GetUtcNow().ToUnixTimeMilliseconds(), default)); + } + + public void Dispose() + { + _gate.Dispose(); + _database.Dispose(); + Directory.Delete(_directory, recursive: true); + } +}