workspace и reboot-модуль: второй продакшн бинарь и упаковка
This commit is contained in:
+11
-12
@@ -1,18 +1,17 @@
|
||||
[package]
|
||||
name = "reboot-scheduler"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
description = "Демон планового перезапуска сервера в случайное время в окне 01:00–02:00 UTC"
|
||||
name = "awg2-reboot-scheduler"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
description = "AWG2 reboot scheduler service"
|
||||
|
||||
[[bin]]
|
||||
name = "reboot-scheduler"
|
||||
name = "awg2-reboot-scheduler"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
anyhow = "1.0"
|
||||
chrono = { version = "0.4", features = ["clock"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
+210
-259
@@ -1,70 +1,191 @@
|
||||
//! reboot-scheduler — демон планового перезапуска сервера.
|
||||
//!
|
||||
//! Логика работы:
|
||||
//! 1. При запуске вычисляет случайное время в окне [01:00, 02:00) UTC
|
||||
//! текущих суток (если окно ещё не началось) либо следующих суток.
|
||||
//! 2. Ждёт до наступления выбранного момента.
|
||||
//! 3. Выполняет `systemctl --no-block reboot` и завершается:
|
||||
//! - код 0 при успешном запросе;
|
||||
//! - код 1 при ошибке.
|
||||
//!
|
||||
//! Логирование ведётся только в stderr/stdout и собирается journald.
|
||||
//! Нет сторонних зависимостей — только std.
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
thread,
|
||||
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use anyhow::Context;
|
||||
use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike, Utc};
|
||||
use clap::Parser;
|
||||
use rand::Rng;
|
||||
use serde::Deserialize;
|
||||
|
||||
// ─── Константы ────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_CONFIG_PATH: &str = "/etc/awg2-harness/config.toml";
|
||||
const REBOOT_PLAN_FILE: &str = "/run/awg2-harness/reboot.plan";
|
||||
|
||||
/// Начало окна перезагрузки — 01:00:00 UTC в секундах от полуночи.
|
||||
const WINDOW_START_SEC: u64 = 3600;
|
||||
/// Конец окна перезагрузки — 02:00:00 UTC в секундах от полуночи (не включая).
|
||||
const WINDOW_END_SEC: u64 = 2 * 3600;
|
||||
|
||||
/// Интервал опроса при ожидании (проверяем раз в 30 секунд).
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
// ─── Логгер ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Возвращает текущее время в виде строки «YYYY-MM-DD HH:MM:SS UTC».
|
||||
fn fmt_time(ts: u64) -> String {
|
||||
let secs = ts;
|
||||
let days_total = secs / 86400;
|
||||
let time_of_day = secs % 86400;
|
||||
|
||||
let hh = time_of_day / 3600;
|
||||
let mm = (time_of_day % 3600) / 60;
|
||||
let ss = time_of_day % 60;
|
||||
|
||||
// Алгоритм Фликера для перевода дней с эпохи в дату.
|
||||
let z = days_total as i64 + 719468;
|
||||
let era: i64 = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!(
|
||||
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
|
||||
y, m, d, hh, mm, ss
|
||||
)
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "awg2-reboot-scheduler")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Записывает строку в журнал с временной меткой.
|
||||
fn log(msg: &str) {
|
||||
let now = unix_now();
|
||||
let line = format!("[{}] {}", fmt_time(now), msg);
|
||||
eprintln!("{}", line);
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RootConfig {
|
||||
reboot: RebootConfig,
|
||||
}
|
||||
|
||||
// ─── Время ────────────────────────────────────────────────────────────────────
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RebootConfig {
|
||||
enabled: bool,
|
||||
window_start_utc: String,
|
||||
window_end_utc: String,
|
||||
}
|
||||
|
||||
/// Возвращает текущий unix-timestamp (секунды).
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let config_path = cli
|
||||
.config
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
|
||||
|
||||
let cfg = load_config(&config_path)?;
|
||||
if !cfg.reboot.enabled {
|
||||
println!("disabled by config");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let planned = next_reboot_time(&cfg.reboot.window_start_utc, &cfg.reboot.window_end_utc)?;
|
||||
persist_plan(planned);
|
||||
println!("reboot planned at UTC {}", planned);
|
||||
|
||||
loop {
|
||||
let now = Utc::now().naive_utc();
|
||||
if now >= planned {
|
||||
break;
|
||||
}
|
||||
thread::sleep(StdDuration::from_secs(30));
|
||||
}
|
||||
|
||||
loop {
|
||||
let out = Command::new("systemctl")
|
||||
.args(["--no-block", "reboot"])
|
||||
.output()
|
||||
.context("failed to run systemctl reboot")?;
|
||||
if out.status.success() {
|
||||
println!("reboot request accepted by systemctl");
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!(
|
||||
"reboot request failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
thread::sleep(StdDuration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
fn load_config(path: &PathBuf) -> anyhow::Result<RootConfig> {
|
||||
let raw = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read config: {}", path.display()))?;
|
||||
let cfg: RootConfig = toml::from_str(&raw)
|
||||
.with_context(|| format!("failed to parse config TOML: {}", path.display()))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn persist_plan(planned: NaiveDateTime) {
|
||||
let _ = fs::create_dir_all("/run/awg2-harness");
|
||||
let line = format!(
|
||||
"planned_reboot_utc={}\nplanned_reboot_unix={}\n",
|
||||
planned.format("%Y-%m-%dT%H:%M:%SZ"),
|
||||
planned.and_utc().timestamp()
|
||||
);
|
||||
let _ = fs::write(REBOOT_PLAN_FILE, line);
|
||||
}
|
||||
|
||||
fn next_reboot_time(start_hhmm: &str, end_hhmm: &str) -> anyhow::Result<NaiveDateTime> {
|
||||
let start = parse_hhmm(start_hhmm)?;
|
||||
let end = parse_hhmm(end_hhmm)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let now_t = NaiveTime::from_hms_opt(now.hour(), now.minute(), now.second())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad current time"))?;
|
||||
|
||||
let (range_start, range_end) = current_or_next_window(now, now_t, start, end)?;
|
||||
let span = (range_end - range_start).num_seconds().max(1);
|
||||
let offset = rand::thread_rng().gen_range(0..span);
|
||||
Ok(range_start + Duration::seconds(offset))
|
||||
}
|
||||
|
||||
fn current_or_next_window(
|
||||
now: NaiveDateTime,
|
||||
now_t: NaiveTime,
|
||||
start: NaiveTime,
|
||||
end: NaiveTime,
|
||||
) -> anyhow::Result<(NaiveDateTime, NaiveDateTime)> {
|
||||
let today = now.date();
|
||||
let tomorrow = today
|
||||
.succ_opt()
|
||||
.ok_or_else(|| anyhow::anyhow!("date overflow"))?;
|
||||
|
||||
if start < end {
|
||||
if now_t < start {
|
||||
let s = today
|
||||
.and_hms_opt(start.hour(), start.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid start dt"))?;
|
||||
let e = today
|
||||
.and_hms_opt(end.hour(), end.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid end dt"))?;
|
||||
return Ok((s, e));
|
||||
}
|
||||
if now_t < end {
|
||||
let e = today
|
||||
.and_hms_opt(end.hour(), end.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid end dt"))?;
|
||||
return Ok((now, e));
|
||||
}
|
||||
let s = tomorrow
|
||||
.and_hms_opt(start.hour(), start.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid start dt"))?;
|
||||
let e = tomorrow
|
||||
.and_hms_opt(end.hour(), end.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid end dt"))?;
|
||||
return Ok((s, e));
|
||||
}
|
||||
|
||||
// cross-midnight window, e.g. 23:00..02:00
|
||||
if now_t >= start {
|
||||
let end_day = tomorrow;
|
||||
let e = end_day
|
||||
.and_hms_opt(end.hour(), end.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid end dt"))?;
|
||||
return Ok((now, e));
|
||||
}
|
||||
if now_t < end {
|
||||
let e = today
|
||||
.and_hms_opt(end.hour(), end.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid end dt"))?;
|
||||
return Ok((now, e));
|
||||
}
|
||||
let s = today
|
||||
.and_hms_opt(start.hour(), start.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid start dt"))?;
|
||||
let e = tomorrow
|
||||
.and_hms_opt(end.hour(), end.minute(), 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid end dt"))?;
|
||||
Ok((s, e))
|
||||
}
|
||||
|
||||
fn parse_hhmm(v: &str) -> anyhow::Result<NaiveTime> {
|
||||
let mut s = v.split(':');
|
||||
let h = s
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("bad time"))?
|
||||
.parse()?;
|
||||
let m = s
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("bad time"))?
|
||||
.parse()?;
|
||||
if s.next().is_some() {
|
||||
anyhow::bail!("bad time")
|
||||
}
|
||||
if h > 23 || m > 59 {
|
||||
anyhow::bail!("bad time")
|
||||
}
|
||||
NaiveTime::from_hms_opt(h, m, 0).ok_or_else(|| anyhow::anyhow!("bad time"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -72,215 +193,45 @@ fn unix_now() -> u64 {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Простой LCG-генератор псевдослучайных чисел, инициализированный от времени.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Перемешиваем seed splitmix64, чтобы даже близкие значения давали
|
||||
// разные последовательности.
|
||||
let mut x = seed.wrapping_add(0x9e3779b97f4a7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||||
x ^= x >> 31;
|
||||
Self(x)
|
||||
}
|
||||
|
||||
/// Возвращает следующее псевдослучайное число в диапазоне [0, range).
|
||||
fn next_u64(&mut self, range: u64) -> u64 {
|
||||
// LCG параметры (Knuth).
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.0 % range
|
||||
}
|
||||
}
|
||||
|
||||
/// Вычисляет unix-timestamp следующего момента перезагрузки.
|
||||
///
|
||||
/// Правила:
|
||||
/// - Берём сегодняшнюю полночь UTC.
|
||||
/// - Если сейчас до WINDOW_START, цель — сегодняшнее окно.
|
||||
/// - Иначе — окно следующего дня.
|
||||
/// - Внутри окна выбираем случайную секунду.
|
||||
fn next_reboot_ts_from(now: u64, random_offset: u64) -> u64 {
|
||||
let today_midnight = now - (now % 86400); // полночь текущих суток UTC
|
||||
let time_of_day = now % 86400;
|
||||
|
||||
let base_midnight = if time_of_day < WINDOW_START_SEC {
|
||||
today_midnight
|
||||
} else {
|
||||
today_midnight + 86400 // следующие сутки
|
||||
};
|
||||
|
||||
let window_len = WINDOW_END_SEC - WINDOW_START_SEC; // 3600 секунд
|
||||
let offset = random_offset % window_len;
|
||||
|
||||
base_midnight + WINDOW_START_SEC + offset
|
||||
}
|
||||
|
||||
/// Вычисляет unix-timestamp следующего момента перезагрузки.
|
||||
fn next_reboot_ts() -> u64 {
|
||||
let now = unix_now();
|
||||
let window_len = WINDOW_END_SEC - WINDOW_START_SEC;
|
||||
let mut rng = Lcg::new(now);
|
||||
let offset = rng.next_u64(window_len);
|
||||
next_reboot_ts_from(now, offset)
|
||||
}
|
||||
|
||||
// ─── Перезагрузка ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn run_command(program: &str, args: &[&str]) -> Result<std::process::ExitStatus, String> {
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| format!("не удалось запустить `{}`: {}", program, e))
|
||||
}
|
||||
|
||||
/// Выполняет reboot-запрос через указанную команду.
|
||||
fn do_reboot_with(program: &str, args: &[&str]) -> Result<(), String> {
|
||||
let status = run_command(program, args)?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"команда `{}` завершилась с кодом {:?}",
|
||||
program,
|
||||
status.code()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Выполняет мягкую перезагрузку через systemctl.
|
||||
fn do_reboot() -> Result<(), String> {
|
||||
do_reboot_with("systemctl", &["--no-block", "reboot"])
|
||||
}
|
||||
|
||||
// ─── Точка входа ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
log("Демон запущен.");
|
||||
|
||||
let target_ts = next_reboot_ts();
|
||||
log(&format!(
|
||||
"Запланированная перезагрузка: {}",
|
||||
fmt_time(target_ts)
|
||||
));
|
||||
|
||||
loop {
|
||||
let now = unix_now();
|
||||
|
||||
if now >= target_ts {
|
||||
log("Наступило запланированное время. Инициируем перезагрузку.");
|
||||
match do_reboot() {
|
||||
Ok(()) => {
|
||||
log("Запрос на перезагрузку принят (systemctl --no-block reboot).");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!(
|
||||
"ОШИБКА: запрос на перезагрузку не выполнен: {}",
|
||||
e
|
||||
));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = target_ts - now;
|
||||
// Логируем оставшееся время раз в час (попадаем в окно ±30 с).
|
||||
if remaining % 3600 < 30 {
|
||||
log(&format!("До перезагрузки осталось {} мин.", remaining / 60));
|
||||
}
|
||||
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn day(seconds: u64) -> u64 {
|
||||
seconds - (seconds % 86400)
|
||||
#[test]
|
||||
fn parse_time_rejects_invalid() {
|
||||
assert!(parse_hhmm("24:00").is_err());
|
||||
assert!(parse_hhmm("10:60").is_err());
|
||||
assert!(parse_hhmm("ab:cd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_reboot_before_window_uses_today_window() {
|
||||
let now = 25 * 60;
|
||||
let ts = next_reboot_ts_from(now, 123);
|
||||
let base = day(now);
|
||||
assert!(ts >= base + WINDOW_START_SEC);
|
||||
assert!(ts < base + WINDOW_END_SEC);
|
||||
fn window_inside_day_now_inside_window_uses_now_to_end() {
|
||||
let now = NaiveDateTime::parse_from_str("2026-01-10 01:30:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("now");
|
||||
let (s, e) = current_or_next_window(
|
||||
now,
|
||||
parse_hhmm("01:30").expect("time"),
|
||||
parse_hhmm("01:00").expect("start"),
|
||||
parse_hhmm("02:00").expect("end"),
|
||||
)
|
||||
.expect("window");
|
||||
assert_eq!(s, now);
|
||||
assert_eq!(e.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-01-10 02:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_reboot_on_start_boundary_uses_next_day_window() {
|
||||
let now = WINDOW_START_SEC;
|
||||
let ts = next_reboot_ts_from(now, 0);
|
||||
let base = day(now) + 86400;
|
||||
assert!(ts >= base + WINDOW_START_SEC);
|
||||
assert!(ts < base + WINDOW_END_SEC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_reboot_after_window_uses_next_day_window() {
|
||||
let now = WINDOW_END_SEC + 1;
|
||||
let ts = next_reboot_ts_from(now, 42);
|
||||
let base = day(now) + 86400;
|
||||
assert!(ts >= base + WINDOW_START_SEC);
|
||||
assert!(ts < base + WINDOW_END_SEC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_reboot_always_inside_window() {
|
||||
let now = 1_700_000_000;
|
||||
let ts = next_reboot_ts_from(now, 3599);
|
||||
let tod = ts % 86400;
|
||||
assert!((WINDOW_START_SEC..WINDOW_END_SEC).contains(&tod));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn do_reboot_with_success_command_returns_ok() {
|
||||
let res = do_reboot_with("/bin/true", &[]);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn do_reboot_with_failing_command_returns_err() {
|
||||
let res = do_reboot_with("/bin/false", &[]);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn do_reboot_with_missing_command_returns_err() {
|
||||
let res = do_reboot_with("/definitely/not/exist", &[]);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn do_reboot_with_success_command_returns_ok() {
|
||||
let res = do_reboot_with("cmd", &["/C", "exit", "0"]);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn do_reboot_with_failing_command_returns_err() {
|
||||
let res = do_reboot_with("cmd", &["/C", "exit", "1"]);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn do_reboot_with_missing_command_returns_err() {
|
||||
let res = do_reboot_with("C:\\definitely\\not\\exist\\cmd.exe", &[]);
|
||||
assert!(res.is_err());
|
||||
fn cross_midnight_window_supported() {
|
||||
let now = NaiveDateTime::parse_from_str("2026-01-10 23:30:00", "%Y-%m-%d %H:%M:%S")
|
||||
.expect("now");
|
||||
let (s, e) = current_or_next_window(
|
||||
now,
|
||||
parse_hhmm("23:30").expect("time"),
|
||||
parse_hhmm("23:00").expect("start"),
|
||||
parse_hhmm("02:00").expect("end"),
|
||||
)
|
||||
.expect("window");
|
||||
assert_eq!(s, now);
|
||||
assert_eq!(e.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-01-11 02:00:00");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user