feat: daily node restart controlled from the admin, connect timings as a default line task [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Agents and Probes exit in order at a configured time (default 04:30 UTC+8, switch and time zone in the admin settings) and the service manager starts them again; a local restart_at or MOSHIRO_RESTART_AT wins. The connect kind (TCP connect and TLS handshake timings) is now a default continuous line template, with a section on the line page.
This commit is contained in:
1 parent
388619b8b6
commit
9bcef56497
37 files changed
+928
-101
No files matched your search
@@ -9,6 +9,7 @@
|
||||
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
|
||||
<ProjectReference Include="..\..\src\Fleet.Hosting\Fleet.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Hosting;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
public sealed class RestartScheduleTests
|
||||
{
|
||||
private static RestartSchedule Parse(string text)
|
||||
{
|
||||
Assert.True(RestartSchedule.TryParse(text, out RestartSchedule? schedule), text);
|
||||
return schedule!;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("04:00")]
|
||||
[InlineData("04:00Z")]
|
||||
[InlineData("04:00+08:00")]
|
||||
[InlineData("23:59-05:30")]
|
||||
[InlineData(" 00:00 ")]
|
||||
public void Parse_AcceptsTimeWithOptionalOffset(string text) => Parse(text);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("4:00")]
|
||||
[InlineData("24:00")]
|
||||
[InlineData("04:60")]
|
||||
[InlineData("04:00+8:00")]
|
||||
[InlineData("04:00+15:00")]
|
||||
[InlineData("04:00 UTC")]
|
||||
[InlineData("0400")]
|
||||
[InlineData("04:00+08:60")]
|
||||
public void Parse_RejectsEverythingElse(string text) => Assert.False(RestartSchedule.TryParse(text, out _));
|
||||
|
||||
[Fact]
|
||||
public void NextAfter_IsStrictlyLater_AndRollsToTomorrow()
|
||||
{
|
||||
RestartSchedule utc = Parse("04:00");
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 3, 59, 59, TimeSpan.Zero)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextAfter_UsesTheWrittenOffset()
|
||||
{
|
||||
// 04:00+08:00 就是 UTC 前一天 20:00
|
||||
RestartSchedule china = Parse("04:00+08:00");
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 19, 0, 0, TimeSpan.Zero)));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextAfter_WithMinimumUptime_DoesNotRestartAgainRightAfterARestart()
|
||||
{
|
||||
RestartSchedule schedule = Parse("04:00");
|
||||
// 04:00:03 重启后启动:加上最短运行时间再算,下一次是明天,而不是几秒后
|
||||
var started = new DateTimeOffset(2026, 10, 7, 4, 0, 3, TimeSpan.Zero);
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(started + RestartSchedule.MinimumUptime));
|
||||
// 03:55 启动:离 04:00 只有 5 分钟,不够最短运行时间,顺延到明天
|
||||
var early = new DateTimeOffset(2026, 10, 7, 3, 55, 0, TimeSpan.Zero);
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(early + RestartSchedule.MinimumUptime));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ReportsABadValueInsteadOfIgnoringIt()
|
||||
{
|
||||
Assert.Null(RestartSchedule.Resolve(null));
|
||||
Assert.Null(RestartSchedule.Resolve(" "));
|
||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => RestartSchedule.Resolve("tomorrow"));
|
||||
Assert.Contains("restart_at", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceShell_StopsAtTheScheduledTime()
|
||||
{
|
||||
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
var log = new ListLog();
|
||||
bool stoppedByToken = false;
|
||||
|
||||
// 业务循环只在被取消时返回;定时重启到点以后令牌被取消,RunAsync 正常返回
|
||||
await ServiceShell.RunAsync(async ct =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
stoppedByToken = true;
|
||||
throw;
|
||||
}
|
||||
}, log, new RestartController(Parse("04:00")), time).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(stoppedByToken);
|
||||
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceShell_PicksUpAScheduleThePanelSendsLater()
|
||||
{
|
||||
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
var log = new ListLog();
|
||||
using var controller = new RestartController();
|
||||
|
||||
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
|
||||
await Task.Delay(300);
|
||||
Assert.False(shell.IsCompleted, "no schedule yet: the node keeps running");
|
||||
|
||||
Assert.True(controller.SetRemote("04:30+08:00"));
|
||||
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||
Assert.Contains(log.Lines, l => l.Contains("source=panel", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceShell_ClearingTheScheduleCancelsTheRestart()
|
||||
{
|
||||
var time = new ManualTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
|
||||
var log = new ListLog();
|
||||
using var controller = new RestartController();
|
||||
|
||||
controller.SetRemote("04:30+08:00");
|
||||
|
||||
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
|
||||
await Task.Delay(300);
|
||||
controller.SetRemote(null); // 面板关掉了
|
||||
await Task.Delay(300);
|
||||
Assert.False(shell.IsCompleted);
|
||||
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||
// 清理:把进程停掉
|
||||
Assert.True(controller.SetRemote("04:30+08:00"));
|
||||
time.Advance();
|
||||
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Controller_LocalSettingWinsOverThePanel_AndBadTextIsRejected()
|
||||
{
|
||||
using var controller = new RestartController(Parse("04:00"));
|
||||
Assert.True(controller.IsLocal);
|
||||
Assert.True(controller.SetRemote("05:00"));
|
||||
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), controller.Current!.NextAfter(new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero)));
|
||||
|
||||
using var remoteOnly = new RestartController();
|
||||
Assert.Null(remoteOnly.Current);
|
||||
Assert.False(remoteOnly.SetRemote("whenever"));
|
||||
Assert.Null(remoteOnly.Current);
|
||||
Assert.True(remoteOnly.SetRemote("05:00"));
|
||||
Assert.NotNull(remoteOnly.Current);
|
||||
Assert.True(remoteOnly.SetRemote(null));
|
||||
Assert.Null(remoteOnly.Current);
|
||||
}
|
||||
|
||||
/// <summary>计时器马上触发:用来验证「到点」之后的行为,不用真的等。</summary>
|
||||
private sealed class ImmediateTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public ImmediateTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
// TrimHeapAsync 这类每 30 秒一次的循环也会走到这里;只让「很长」的延迟(定时重启,至少 10 分钟)马上触发
|
||||
var timer = new ImmediateTimer(callback, state);
|
||||
if (dueTime >= RestartSchedule.MinimumUptime)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => timer.Fire());
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>计时器只在 <see cref="Advance"/> 时触发:用来验证「还没到点」时不会退出。</summary>
|
||||
private sealed class ManualTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
private readonly List<ImmediateTimer> _timers = [];
|
||||
|
||||
public ManualTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
|
||||
{
|
||||
var timer = new ImmediateTimer(callback, state);
|
||||
lock (_timers)
|
||||
{
|
||||
_timers.Add(timer);
|
||||
}
|
||||
|
||||
return timer;
|
||||
}
|
||||
|
||||
public void Advance()
|
||||
{
|
||||
ImmediateTimer[] due;
|
||||
lock (_timers)
|
||||
{
|
||||
due = [.. _timers];
|
||||
_timers.Clear();
|
||||
}
|
||||
|
||||
foreach (ImmediateTimer timer in due)
|
||||
{
|
||||
timer.Fire();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ImmediateTimer : ITimer
|
||||
{
|
||||
private readonly TimerCallback _callback;
|
||||
private readonly object? _state;
|
||||
|
||||
public ImmediateTimer(TimerCallback callback, object? state)
|
||||
{
|
||||
_callback = callback;
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public void Fire() => _callback(_state);
|
||||
|
||||
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class ListLog : INodeLog
|
||||
{
|
||||
private readonly List<string> _lines = [];
|
||||
|
||||
public IReadOnlyList<string> Lines
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
return [.. _lines];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
|
||||
{
|
||||
lock (_lines)
|
||||
{
|
||||
_lines.Add(message + " " + string.Join(' ', fields.Select(f => f.Key + "=" + f.Value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user