workspace и reboot-модуль: второй продакшн бинарь и упаковка

This commit is contained in:
2026-05-10 21:30:06 +05:00
parent d4cd6a4e43
commit 83c4e3ba05
9 changed files with 1151 additions and 290 deletions
+210 -259
View File
@@ -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");
}
}