feat(detmir): add rust-first operations tooling

This commit is contained in:
igor04091968
2026-06-02 17:57:58 +03:00
parent 60670d30a8
commit 19e3682bc8
263 changed files with 51678 additions and 718 deletions
@@ -0,0 +1,345 @@
use std::fs;
use std::process::Command;
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use detmir_core::{exit_codes, now_utc_rfc3339};
use serde::Serialize;
const DEFAULT_HEARTBEAT_FILE: &str = "/opt/infra-admin/.state/tsj_guardian_heartbeat";
const DEFAULT_SERVICE_NAME: &str = "tsj-guardian-bot.service";
const DEFAULT_GOST_SERVICE_NAME: &str = "gost-tg.service";
const DEFAULT_GOST_PATTERN: &str = "/usr/local/bin/gost -L http+socks5://127.0.0.1:11090 -F socks5+wss://gw.iri1968.dpdns.org:4443";
#[derive(Debug, Parser)]
#[command(about = "TSJ Guardian bot heartbeat watchdog and gost duplicate guard.")]
struct Cli {
#[arg(long, default_value = DEFAULT_HEARTBEAT_FILE)]
heartbeat_file: String,
#[arg(long, default_value_t = 180)]
max_age_seconds: i64,
#[arg(long, default_value = DEFAULT_SERVICE_NAME)]
service_name: String,
#[arg(long, default_value = DEFAULT_GOST_SERVICE_NAME)]
gost_service_name: String,
#[arg(long, default_value = DEFAULT_GOST_PATTERN)]
gost_dup_pattern: String,
#[arg(long)]
apply: bool,
#[arg(long)]
json: bool,
#[arg(long)]
loop_forever: bool,
#[arg(long, default_value_t = 60)]
interval_seconds: u64,
}
impl Cli {
fn apply_env(mut self) -> Self {
self.heartbeat_file = env_string("HEARTBEAT_FILE").unwrap_or(self.heartbeat_file);
self.max_age_seconds = env_string("MAX_AGE_SEC")
.and_then(|value| value.parse().ok())
.unwrap_or(self.max_age_seconds);
self.service_name = env_string("SERVICE_NAME").unwrap_or(self.service_name);
self.gost_service_name = env_string("GOST_SERVICE_NAME").unwrap_or(self.gost_service_name);
self.gost_dup_pattern = env_string("GOST_DUP_PATTERN").unwrap_or(self.gost_dup_pattern);
self
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
struct WatchdogAction {
action: String,
target: String,
status: String,
}
#[derive(Debug, Serialize)]
struct WatchdogReport {
ok: bool,
dry_run: bool,
generated_at_utc: String,
heartbeat_file: String,
heartbeat_age_seconds: Option<i64>,
heartbeat_status: String,
gost_main_pid: Option<u32>,
gost_pids: Vec<u32>,
actions: Vec<WatchdogAction>,
}
fn env_string(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn main_pid(service_name: &str) -> Option<u32> {
let output = Command::new("systemctl")
.args(["show", "-p", "MainPID", "--value", service_name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.ok()
.filter(|pid| *pid > 1)
}
fn matching_pids(pattern: &str) -> Vec<u32> {
let output = match Command::new("ps").args(["-eo", "pid=,args="]).output() {
Ok(output) => output,
Err(_) => return Vec::new(),
};
if !output.status.success() {
return Vec::new();
}
let self_pid = std::process::id();
let mut pids = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let line = line.trim_start();
let (pid, args) = line.split_once(char::is_whitespace)?;
let pid = pid.parse::<u32>().ok()?;
if pid != self_pid && args.contains(pattern) {
Some(pid)
} else {
None
}
})
.collect::<Vec<_>>();
pids.sort_unstable();
pids.dedup();
pids
}
fn choose_keep_pid(main_pid: Option<u32>, pids: &[u32]) -> Option<u32> {
if pids.is_empty() {
return None;
}
match main_pid {
Some(pid) if pids.contains(&pid) => Some(pid),
_ => pids.first().copied(),
}
}
fn signal_pid(pid: u32, signal: &str) -> Result<()> {
let status = Command::new("kill")
.args([format!("-{signal}"), pid.to_string()])
.status()
.with_context(|| format!("failed to signal pid {pid}"))?;
if status.success() {
Ok(())
} else {
Err(anyhow::anyhow!("kill -{signal} {pid} exited with {status}"))
}
}
fn pid_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn restart_service(service_name: &str) -> Result<()> {
let status = Command::new("systemctl")
.args(["restart", service_name])
.status()
.with_context(|| format!("failed to restart {service_name}"))?;
if status.success() {
Ok(())
} else {
Err(anyhow::anyhow!(
"systemctl restart {service_name} exited with {status}"
))
}
}
fn heartbeat_status(path: &str, max_age_seconds: i64, now: i64) -> (String, Option<i64>) {
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(_) => return ("missing".to_string(), None),
};
let heartbeat = match raw.trim().parse::<i64>() {
Ok(value) => value,
Err(_) => return ("invalid".to_string(), None),
};
let age = now.saturating_sub(heartbeat);
if age > max_age_seconds {
("stale".to_string(), Some(age))
} else {
("fresh".to_string(), Some(age))
}
}
fn run_once(cli: &Cli) -> Result<WatchdogReport> {
let mut actions = Vec::new();
let gost_main_pid = main_pid(&cli.gost_service_name);
let gost_pids = matching_pids(&cli.gost_dup_pattern);
if let Some(keep_pid) = choose_keep_pid(gost_main_pid, &gost_pids) {
for pid in gost_pids.iter().copied().filter(|pid| *pid != keep_pid) {
if cli.apply {
let status = signal_pid(pid, "TERM")
.map(|_| "terminated")
.unwrap_or("term-failed");
actions.push(WatchdogAction {
action: "kill-term".to_string(),
target: pid.to_string(),
status: status.to_string(),
});
} else {
actions.push(WatchdogAction {
action: "kill-term".to_string(),
target: pid.to_string(),
status: "planned".to_string(),
});
}
}
if cli.apply && gost_pids.len() > 1 {
thread::sleep(Duration::from_secs(2));
for pid in gost_pids.iter().copied().filter(|pid| *pid != keep_pid) {
if pid_alive(pid) {
let status = signal_pid(pid, "KILL")
.map(|_| "killed")
.unwrap_or("kill-failed");
actions.push(WatchdogAction {
action: "kill-kill".to_string(),
target: pid.to_string(),
status: status.to_string(),
});
}
}
}
}
let now = chrono_like_now_epoch();
let (hb_status, hb_age) = heartbeat_status(&cli.heartbeat_file, cli.max_age_seconds, now);
if hb_status != "fresh" {
if cli.apply {
let status = restart_service(&cli.service_name)
.map(|_| "restarted")
.unwrap_or("restart-failed");
actions.push(WatchdogAction {
action: "restart-service".to_string(),
target: cli.service_name.clone(),
status: status.to_string(),
});
} else {
actions.push(WatchdogAction {
action: "restart-service".to_string(),
target: cli.service_name.clone(),
status: "planned".to_string(),
});
}
}
Ok(WatchdogReport {
ok: true,
dry_run: !cli.apply,
generated_at_utc: now_utc_rfc3339(),
heartbeat_file: cli.heartbeat_file.clone(),
heartbeat_age_seconds: hb_age,
heartbeat_status: hb_status,
gost_main_pid,
gost_pids,
actions,
})
}
fn chrono_like_now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(0)
}
fn print_report(report: &WatchdogReport, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(report)?);
} else {
println!(
"tsj-guardian-watchdog: heartbeat={} age={:?} dry_run={}",
report.heartbeat_status, report.heartbeat_age_seconds, report.dry_run
);
for action in &report.actions {
println!("{} {} {}", action.action, action.target, action.status);
}
}
Ok(())
}
fn run(cli: Cli) -> Result<()> {
if cli.loop_forever {
loop {
let report = run_once(&cli)?;
print_report(&report, cli.json)?;
thread::sleep(Duration::from_secs(cli.interval_seconds));
}
}
let report = run_once(&cli)?;
print_report(&report, cli.json)?;
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse().apply_env();
match run(cli) {
Ok(()) => Ok(()),
Err(err) => {
eprintln!("{err:#}");
std::process::exit(exit_codes::ERROR);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_main_pid_when_present() {
assert_eq!(choose_keep_pid(Some(20), &[10, 20, 30]), Some(20));
}
#[test]
fn keeps_first_pid_when_main_missing() {
assert_eq!(choose_keep_pid(Some(99), &[10, 20, 30]), Some(10));
}
#[test]
fn detects_heartbeat_states() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("heartbeat");
assert_eq!(
heartbeat_status(path.to_str().unwrap(), 180, 1_000),
("missing".to_string(), None)
);
fs::write(&path, "bad\n").unwrap();
assert_eq!(
heartbeat_status(path.to_str().unwrap(), 180, 1_000),
("invalid".to_string(), None)
);
fs::write(&path, "900\n").unwrap();
assert_eq!(
heartbeat_status(path.to_str().unwrap(), 180, 1_000),
("fresh".to_string(), Some(100))
);
fs::write(&path, "700\n").unwrap();
assert_eq!(
heartbeat_status(path.to_str().unwrap(), 180, 1_000),
("stale".to_string(), Some(300))
);
}
}