using System.Globalization; namespace Fleet.Hosting; /// /// 每天固定时刻有序退出一次,由 systemd、容器引擎或 procd 的「总是重启」拉起。写法 HH:mm(按 UTC)、 /// HH:mm±hh:mm 或 HH:mm 加 Z:容器里没有时区数据库,所以偏移要写明,例如 04:00+08:00。 /// 进程自己退出,不需要任何执行外部命令的能力。 /// public sealed class RestartSchedule { /// 环境变量,设置了就覆盖配置文件里的值。 public const string EnvironmentVariable = "MOSHIRO_RESTART_AT"; /// 刚启动的一段时间内不会触发:重启之后不会因为时刻还没过去而再重启一次,也避免重启风暴。 public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10); private readonly TimeSpan _time; private readonly TimeSpan _offset; private RestartSchedule(TimeSpan time, TimeSpan offset) { _time = time; _offset = offset; } public static bool TryParse(string? text, out RestartSchedule? schedule) { schedule = null; string value = text?.Trim() ?? ""; // HH:mm,后面可以跟 Z 或 ±hh:mm if (value.Length < 5 || value[2] != ':' || !TryTwoDigits(value, 0, out int hour) || !TryTwoDigits(value, 3, out int minute) || hour > 23 || minute > 59) { return false; } TimeSpan offset = TimeSpan.Zero; string rest = value[5..]; if (rest.Length > 0 && rest != "Z") { if (rest.Length != 6 || rest[0] is not ('+' or '-') || rest[3] != ':' || !TryTwoDigits(rest, 1, out int offsetHours) || !TryTwoDigits(rest, 4, out int offsetMinutes) || offsetMinutes > 59) { return false; } offset = new TimeSpan(offsetHours, offsetMinutes, 0); if (offset > TimeSpan.FromHours(14)) { return false; } if (rest[0] == '-') { offset = -offset; } } schedule = new RestartSchedule(new TimeSpan(hour, minute, 0), offset); return true; } private static bool TryTwoDigits(string text, int start, out int value) { value = 0; if (!char.IsAsciiDigit(text[start]) || !char.IsAsciiDigit(text[start + 1])) { return false; } value = (text[start] - '0') * 10 + (text[start + 1] - '0'); return true; } /// /// 环境变量优先,其次配置文件的值;都没有就不定时重启。写错了抛 , /// 启动时就能看到,而不是悄悄不生效。 /// public static RestartSchedule? Resolve(string? configured) { string? text = Environment.GetEnvironmentVariable(EnvironmentVariable); if (string.IsNullOrWhiteSpace(text)) { text = configured; } if (string.IsNullOrWhiteSpace(text)) { return null; } return TryParse(text, out RestartSchedule? schedule) ? schedule : throw new InvalidOperationException($"restart_at must look like 04:00, 04:00Z or 04:00+08:00 (got '{text}')"); } /// 严格晚于 的下一个触发时刻。 public DateTimeOffset NextAfter(DateTimeOffset now) { DateTimeOffset local = now.ToOffset(_offset); DateTimeOffset candidate = new DateTimeOffset(local.Year, local.Month, local.Day, 0, 0, 0, _offset) + _time; return candidate <= local ? candidate.AddDays(1) : candidate; } }