Skip to content

Commit 53241fd

Browse files
committed
Add health probes runner (#17)
HealthProbes manager keyed by probe id, spawning one task per probe. Sync diffs old vs new spec signature so unchanged probes keep running. HTTP via reqwest with timeout + optional status/body expectations; TCP via tokio::net::TcpStream::connect with timeout. Report only on state transitions (or first sample after sync).
1 parent 1d39fb7 commit 53241fd

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

src/health.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Per-agent health probes — HTTP and TCP. Each probe runs on its own
2+
//! tokio task; reports flow back through the shared message channel
3+
//! when the state transitions (or on first sample after a sync).
4+
5+
use shared::{HealthProbeKind, HealthProbeResult, HealthProbeSpec, HealthProbeState, Message};
6+
use std::collections::HashMap;
7+
use std::sync::Arc;
8+
use std::time::Duration;
9+
use tokio::sync::Mutex;
10+
use tokio::task::JoinHandle;
11+
use tokio::time::Instant;
12+
13+
#[derive(Default, Clone)]
14+
pub struct HealthProbes {
15+
inner: Arc<Mutex<Inner>>,
16+
}
17+
18+
#[derive(Default)]
19+
struct Inner {
20+
/// id → (signature, JoinHandle). Signature is the serde_json
21+
/// representation of the spec, so we can detect when *anything*
22+
/// changed and respawn.
23+
tasks: HashMap<String, (String, JoinHandle<()>)>,
24+
}
25+
26+
impl HealthProbes {
27+
/// Apply a new probe set. Spawns/aborts tasks so the running set
28+
/// matches `probes`.
29+
pub async fn sync(&self, probes: Vec<HealthProbeSpec>, tx: tokio::sync::mpsc::UnboundedSender<Message>) {
30+
let mut inner = self.inner.lock().await;
31+
let mut keep: HashMap<String, ()> = HashMap::with_capacity(probes.len());
32+
for p in probes {
33+
let sig = match serde_json::to_string(&p) {
34+
Ok(s) => s,
35+
Err(_) => continue,
36+
};
37+
keep.insert(p.id.clone(), ());
38+
if let Some((existing_sig, _)) = inner.tasks.get(&p.id) {
39+
if existing_sig == &sig {
40+
continue;
41+
}
42+
// Spec changed — abort old and respawn below.
43+
if let Some((_, h)) = inner.tasks.remove(&p.id) {
44+
h.abort();
45+
}
46+
}
47+
let tx_clone = tx.clone();
48+
let id_for_task = p.id.clone();
49+
let id_for_map = p.id.clone();
50+
let handle = tokio::spawn(async move {
51+
run_probe(p, tx_clone).await;
52+
tracing_drop(&id_for_task);
53+
});
54+
inner.tasks.insert(id_for_map, (sig, handle));
55+
}
56+
// Abort tasks not present in the new set.
57+
let to_remove: Vec<String> = inner
58+
.tasks
59+
.keys()
60+
.filter(|k| !keep.contains_key(*k))
61+
.cloned()
62+
.collect();
63+
for id in to_remove {
64+
if let Some((_, h)) = inner.tasks.remove(&id) {
65+
h.abort();
66+
}
67+
}
68+
}
69+
}
70+
71+
fn tracing_drop(_id: &str) {}
72+
73+
fn now_unix() -> i64 {
74+
std::time::SystemTime::now()
75+
.duration_since(std::time::UNIX_EPOCH)
76+
.map(|d| d.as_secs() as i64)
77+
.unwrap_or(0)
78+
}
79+
80+
async fn run_probe(spec: HealthProbeSpec, tx: tokio::sync::mpsc::UnboundedSender<Message>) {
81+
let interval = Duration::from_secs(spec.interval_secs.max(1) as u64);
82+
let timeout = Duration::from_secs(spec.timeout_secs.max(1) as u64);
83+
let mut last_state: Option<HealthProbeState> = None;
84+
// A short initial delay so a brand-new probe doesn't fire all at once
85+
// alongside every other probe on the host.
86+
let jitter = Duration::from_millis((spec.id.bytes().fold(0u64, |a, b| a.wrapping_add(b as u64)) % 5_000) as u64);
87+
tokio::time::sleep(jitter).await;
88+
loop {
89+
let started = Instant::now();
90+
let (state, detail) = match spec.kind {
91+
HealthProbeKind::Http => probe_http(&spec, timeout).await,
92+
HealthProbeKind::Tcp => probe_tcp(&spec, timeout).await,
93+
};
94+
let latency_ms = started.elapsed().as_millis().min(u32::MAX as u128) as u32;
95+
if last_state != Some(state) {
96+
// Send a report whenever state flips (or on first sample).
97+
let _ = tx.send(Message::HealthProbeReport {
98+
results: vec![HealthProbeResult {
99+
id: spec.id.clone(),
100+
state,
101+
latency_ms,
102+
detail: detail.clone(),
103+
at: now_unix(),
104+
}],
105+
});
106+
last_state = Some(state);
107+
}
108+
tokio::time::sleep(interval).await;
109+
}
110+
}
111+
112+
async fn probe_http(
113+
spec: &HealthProbeSpec,
114+
timeout: Duration,
115+
) -> (HealthProbeState, String) {
116+
let client = match reqwest::Client::builder().timeout(timeout).build() {
117+
Ok(c) => c,
118+
Err(e) => return (HealthProbeState::Red, format!("client build: {e}")),
119+
};
120+
let resp = match client.get(&spec.target).send().await {
121+
Ok(r) => r,
122+
Err(e) => return (HealthProbeState::Red, format!("connect: {e}")),
123+
};
124+
let status = resp.status().as_u16();
125+
let status_ok = match spec.expect_status {
126+
Some(want) => status == want,
127+
None => resp.status().is_success(),
128+
};
129+
if !status_ok {
130+
return (
131+
HealthProbeState::Red,
132+
format!("unexpected status {status}"),
133+
);
134+
}
135+
if let Some(want_body) = &spec.expect_body {
136+
let body = match resp.text().await {
137+
Ok(t) => t,
138+
Err(e) => return (HealthProbeState::Red, format!("read body: {e}")),
139+
};
140+
if !body.contains(want_body) {
141+
return (
142+
HealthProbeState::Red,
143+
format!("body missing {want_body:?} (status {status})"),
144+
);
145+
}
146+
}
147+
(HealthProbeState::Green, format!("ok ({status})"))
148+
}
149+
150+
async fn probe_tcp(
151+
spec: &HealthProbeSpec,
152+
timeout: Duration,
153+
) -> (HealthProbeState, String) {
154+
let target = spec.target.clone();
155+
let connect = tokio::net::TcpStream::connect(target);
156+
match tokio::time::timeout(timeout, connect).await {
157+
Ok(Ok(_)) => (HealthProbeState::Green, "ok".to_string()),
158+
Ok(Err(e)) => (HealthProbeState::Red, format!("connect: {e}")),
159+
Err(_) => (
160+
HealthProbeState::Red,
161+
format!("timeout after {}s", timeout.as_secs()),
162+
),
163+
}
164+
}

src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod apt;
22
mod deploy;
33
mod docker;
4+
mod health;
45
mod journal;
56
mod logs;
67
mod stats;
@@ -132,6 +133,7 @@ async fn main() {
132133
let mut term_session: Option<terminal::TerminalSession> = None;
133134
let log_streams = logs::LogStreams::default();
134135
let journal_streams = journal::JournalStreams::default();
136+
let health_probes = health::HealthProbes::default();
135137

136138
// Watchdog: if the WebSocket goes silent for 75s the connection is
137139
// probably dead at the TCP layer (Cloudflare or the kernel may drop
@@ -284,6 +286,13 @@ async fn main() {
284286
streams.stop(&unit).await;
285287
});
286288
}
289+
Message::HealthProbeSyncRequest { probes } => {
290+
let probes_mgr = health_probes.clone();
291+
let tx_clone = tx.clone();
292+
tokio::spawn(async move {
293+
probes_mgr.sync(probes, tx_clone).await;
294+
});
295+
}
287296
Message::SwarmServiceInspectRequest { name } => {
288297
let tx_clone = tx.clone();
289298
tokio::spawn(async move {

0 commit comments

Comments
 (0)