1use std::io::{Read, Write};
17use std::net::{TcpStream, ToSocketAddrs};
18use std::time::Duration;
19
20use crate::config::{self, DEFAULT_CONTROLLER_PORT};
21use crate::context::Context;
22use crate::style;
23
24const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
27
28pub fn run(json: bool, addr_override: Option<&str>) -> i32 {
42 let (candidates, origin) = resolve_candidates(addr_override);
43
44 let mut last_err = String::new();
45 for addr in &candidates {
46 match fetch_state(addr) {
47 Ok(body) => {
48 if json {
49 println!("{body}");
50 } else {
51 print_status(addr, &body);
52 }
53 return 0;
54 }
55 Err(e) => last_err = e,
56 }
57 }
58 crate::cli_error!(
59 "no cluster run listening at {} ({origin}): {last_err}\n\
60 The status endpoint lives on the controller's training port for \
61 exactly as long as the run does — a refused connection usually \
62 just means no run is up.",
63 candidates.join(" / "),
64 );
65 1
66}
67
68fn resolve_candidates(addr_override: Option<&str>) -> (Vec<String>, String) {
71 if let Some(addr) = addr_override {
72 let addr = if addr.contains(':') {
73 addr.to_string()
74 } else {
75 format!("{addr}:{DEFAULT_CONTROLLER_PORT}")
76 };
77 return (vec![addr], "--addr".to_string());
78 }
79 if let Ok(env_name) = std::env::var("FDL_ENV") {
80 if let Some(cluster) = load_cluster_for_env(&env_name) {
81 let host = cluster.controller.host.clone();
82 let port = cluster.controller.port;
83 let mut candidates = vec![format!("{host}:{port}")];
84 if host != "127.0.0.1" && host != "localhost" {
88 candidates.push(format!("127.0.0.1:{port}"));
89 }
90 return (candidates, format!("fdl.{env_name}.yml controller"));
91 }
92 }
93 eprintln!(
94 "{}",
95 style::dim(&format!(
96 "fdl status: no cluster env active; trying \
97 127.0.0.1:{DEFAULT_CONTROLLER_PORT} (pass --addr or use \
98 `fdl @<env> status` to target a specific controller)"
99 )),
100 );
101 (
102 vec![format!("127.0.0.1:{DEFAULT_CONTROLLER_PORT}")],
103 "convention default".to_string(),
104 )
105}
106
107fn load_cluster_for_env(env_name: &str) -> Option<config::ClusterConfig> {
108 let ctx = Context::resolve();
109 let config_path = config::find_config(&ctx.root)?;
110 let project = config::load_project_with_env(&config_path, Some(env_name)).ok()?;
111 project.cluster
112}
113
114fn fetch_state(addr: &str) -> Result<String, String> {
117 let sock_addr = addr
118 .to_socket_addrs()
119 .map_err(|e| format!("cannot resolve {addr}: {e}"))?
120 .next()
121 .ok_or_else(|| format!("cannot resolve {addr}"))?;
122 let mut stream = TcpStream::connect_timeout(&sock_addr, HTTP_TIMEOUT)
123 .map_err(|e| format!("connect: {e}"))?;
124 stream
125 .set_read_timeout(Some(HTTP_TIMEOUT))
126 .and_then(|()| stream.set_write_timeout(Some(HTTP_TIMEOUT)))
127 .map_err(|e| format!("socket setup: {e}"))?;
128 stream
129 .write_all(
130 format!(
131 "GET /state.json HTTP/1.1\r\nHost: {addr}\r\n\
132 Connection: close\r\n\r\n"
133 )
134 .as_bytes(),
135 )
136 .map_err(|e| format!("send request: {e}"))?;
137 let mut response = String::new();
138 stream
139 .read_to_string(&mut response)
140 .map_err(|e| format!("read response: {e}"))?;
141
142 let (head, body) = response
143 .split_once("\r\n\r\n")
144 .ok_or_else(|| "malformed HTTP response".to_string())?;
145 let status_line = head.lines().next().unwrap_or_default();
146 if !status_line.contains(" 200 ") {
147 return Err(format!(
148 "endpoint answered {} — {}",
149 status_line.trim_start_matches("HTTP/1.1 "),
150 body.trim(),
151 ));
152 }
153 Ok(body.trim().to_string())
154}
155
156fn print_status(addr: &str, body: &str) {
164 let state: serde_json::Value = match serde_json::from_str(body) {
165 Ok(v) => v,
166 Err(_) => {
167 println!("{body}");
169 return;
170 }
171 };
172
173 let phase = state["phase"].as_str().unwrap_or("unknown");
174 let painted_phase = match phase {
175 "training" | "done" => style::green(phase),
176 "waiting" | "forming" => style::yellow(phase),
177 "failed" => style::red(phase),
178 other => other.to_string(),
179 };
180 println!(
181 "cluster run @ {addr} — {}",
182 style::bold(&painted_phase),
183 );
184
185 let joined_ranks = state["joined_ranks"].as_u64().unwrap_or(0);
186 let joined_hosts = state["joined_hosts"].as_u64().unwrap_or(0);
187 let quorum = state["min_rank_start"].as_u64().unwrap_or(0);
188 let target = state["target_ranks"]
189 .as_u64()
190 .map(|t| t.to_string())
191 .unwrap_or_else(|| "none".to_string());
192 println!(
193 " ranks: {joined_ranks} joined across {joined_hosts} host(s) \
194 (quorum {quorum}, target {target})",
195 );
196 if phase == "waiting" {
199 let fmt_remaining = |v: &serde_json::Value| match v.as_u64() {
200 Some(s) => format!("{s}s left"),
201 None => "expired".to_string(),
202 };
203 println!(
204 " window: {} hard cap: {}",
205 fmt_remaining(&state["window_remaining_secs"]),
206 fmt_remaining(&state["cap_remaining_secs"]),
207 );
208 }
209
210 let Some(members) = state["members"].as_array() else {
211 return;
212 };
213 if members.is_empty() {
214 println!(" hosts: none joined yet");
215 return;
216 }
217 println!(" hosts:");
218 let host_width = members
219 .iter()
220 .filter_map(|m| m["host"].as_str())
221 .map(str::len)
222 .max()
223 .unwrap_or(0);
224 for m in members {
225 let host = m["host"].as_str().unwrap_or("?");
226 let ranks: Vec<String> = m["ranks"]
227 .as_array()
228 .map(|a| a.iter().filter_map(|r| r.as_u64()).map(|r| r.to_string()).collect())
229 .unwrap_or_default();
230 let joined_at = m["joined_at_secs"].as_u64().unwrap_or(0);
231 let libtorch = m["libtorch"].as_str().unwrap_or("?");
232 let padded_host = format!("{host:<host_width$}");
234 println!(
235 " {} ranks [{}] {} libtorch {} joined +{joined_at}s",
236 style::bold(&padded_host),
237 ranks.join(", "),
238 summarize_gpus(&m["gpus"]),
239 libtorch,
240 );
241 }
242}
243
244fn summarize_gpus(gpus: &serde_json::Value) -> String {
247 let names: Vec<&str> = gpus
248 .as_array()
249 .map(|a| a.iter().filter_map(|g| g.as_str()).collect())
250 .unwrap_or_default();
251 if names.is_empty() {
252 return "no GPUs listed".to_string();
253 }
254 if names.iter().all(|n| *n == names[0]) {
255 return format!("{}x {}", names.len(), names[0]);
256 }
257 names.join(", ")
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn addr_override_gets_default_port_when_bare() {
266 let (candidates, origin) = resolve_candidates(Some("10.0.0.7"));
267 assert_eq!(candidates, vec!["10.0.0.7:1337".to_string()]);
268 assert_eq!(origin, "--addr");
269 let (candidates, _) = resolve_candidates(Some("10.0.0.7:9000"));
270 assert_eq!(candidates, vec!["10.0.0.7:9000".to_string()]);
271 }
272
273 #[test]
274 fn gpu_summary_groups_identical_names() {
275 let gpus = serde_json::json!(["GP106", "GP106"]);
276 assert_eq!(summarize_gpus(&gpus), "2x GP106");
277 let gpus = serde_json::json!(["GP106", "RTX 5060 Ti"]);
278 assert_eq!(summarize_gpus(&gpus), "GP106, RTX 5060 Ti");
279 assert_eq!(summarize_gpus(&serde_json::json!([])), "no GPUs listed");
280 }
281
282 #[test]
283 fn fetch_state_reports_non_200_with_body() {
284 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
286 let addr = listener.local_addr().unwrap();
287 let server = std::thread::spawn(move || {
288 let (mut stream, _) = listener.accept().unwrap();
289 let mut buf = [0u8; 512];
290 let _ = stream.read(&mut buf);
291 let body = r#"{"error":"no membership state published yet"}"#;
292 let _ = stream.write_all(
293 format!(
294 "HTTP/1.1 503 Service Unavailable\r\n\
295 Content-Type: application/json\r\n\
296 Connection: close\r\n\
297 Content-Length: {}\r\n\r\n{body}",
298 body.len(),
299 )
300 .as_bytes(),
301 );
302 });
303 let err = fetch_state(&addr.to_string()).unwrap_err();
304 assert!(err.contains("503"), "{err}");
305 assert!(err.contains("no membership state"), "{err}");
306 server.join().unwrap();
307 }
308
309 #[test]
310 fn fetch_state_round_trips_200_body() {
311 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
312 let addr = listener.local_addr().unwrap();
313 let server = std::thread::spawn(move || {
314 let (mut stream, _) = listener.accept().unwrap();
315 let mut buf = [0u8; 512];
316 let _ = stream.read(&mut buf);
317 let body = r#"{"phase":"training","joined_ranks":3}"#;
318 let _ = stream.write_all(
319 format!(
320 "HTTP/1.1 200 OK\r\n\
321 Content-Type: application/json\r\n\
322 Connection: close\r\n\
323 Content-Length: {}\r\n\r\n{body}",
324 body.len(),
325 )
326 .as_bytes(),
327 );
328 });
329 let body = fetch_state(&addr.to_string()).unwrap();
330 assert_eq!(body, r#"{"phase":"training","joined_ranks":3}"#);
331 server.join().unwrap();
332 }
333}