1use std::io::{Read, Write};
17use std::net::{TcpStream, ToSocketAddrs};
18use std::time::Duration;
19
20use crate::config::{self, DEFAULT_CONTROLLER_PORT};
21use crate::style;
22
23const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
26
27pub fn run(json: bool, addr_override: Option<&str>) -> i32 {
41 let (candidates, origin) = resolve_candidates(addr_override);
42
43 let mut last_err = String::new();
44 for addr in &candidates {
45 match fetch_state(addr) {
46 Ok(body) => {
47 if json {
48 println!("{body}");
49 } else {
50 print_status(addr, &body);
51 }
52 return 0;
53 }
54 Err(e) => last_err = e,
55 }
56 }
57 crate::cli_error!(
58 "no cluster run listening at {} ({origin}): {last_err}\n\
59 The status endpoint lives on the controller's training port for \
60 exactly as long as the run does — a refused connection usually \
61 just means no run is up.",
62 candidates.join(" / "),
63 );
64 1
65}
66
67pub fn run_start(addr_override: Option<&str>, token: Option<&str>) -> i32 {
72 let (candidates, origin) = resolve_candidates(addr_override);
73
74 let mut last_err = String::new();
75 for addr in &candidates {
76 match post_start(addr, token) {
77 Ok(body) => {
78 let joined = serde_json::from_str::<serde_json::Value>(&body)
79 .ok()
80 .and_then(|v| v["joined_ranks"].as_u64());
81 match joined {
82 Some(n) => println!(
83 "start armed @ {addr} — the world forms with the \
84 {n} rank(s) staged (watch `fdl status`)",
85 ),
86 None => println!("start armed @ {addr} — {body}"),
87 }
88 return 0;
89 }
90 Err(e) if e.starts_with("endpoint answered") => {
95 crate::cli_error!("start refused @ {addr}: {e}");
96 return 1;
97 }
98 Err(e) => last_err = e,
99 }
100 }
101 crate::cli_error!(
102 "start not armed at {} ({origin}): {last_err}",
103 candidates.join(" / "),
104 );
105 1
106}
107
108fn resolve_candidates(addr_override: Option<&str>) -> (Vec<String>, String) {
111 if let Some(addr) = addr_override {
112 let addr = if addr.contains(':') {
113 addr.to_string()
114 } else {
115 format!("{addr}:{DEFAULT_CONTROLLER_PORT}")
116 };
117 return (vec![addr], "--addr".to_string());
118 }
119 if let Ok(env_name) = std::env::var("FDL_ENV")
120 && let Some(cluster) = load_cluster_for_env(&env_name)
121 {
122 let host = cluster.controller.host.clone();
123 let port = cluster.controller.port;
124 let mut candidates = vec![format!("{host}:{port}")];
125 if host != "127.0.0.1" && host != "localhost" {
129 candidates.push(format!("127.0.0.1:{port}"));
130 }
131 return (candidates, format!("fdl.{env_name}.yml controller"));
132 }
133 eprintln!(
134 "{}",
135 style::dim(&format!(
136 "fdl status: no cluster env active; trying \
137 127.0.0.1:{DEFAULT_CONTROLLER_PORT} (pass --addr or use \
138 `fdl @<env> status` to target a specific controller)"
139 )),
140 );
141 (
142 vec![format!("127.0.0.1:{DEFAULT_CONTROLLER_PORT}")],
143 "convention default".to_string(),
144 )
145}
146
147fn load_cluster_for_env(env_name: &str) -> Option<config::ClusterConfig> {
148 let cwd = std::env::current_dir().ok()?;
152 let config_path = config::find_project_config(&cwd)?;
153 let project = config::load_project_with_env(&config_path, Some(env_name)).ok()?;
154 project.cluster
155}
156
157fn fetch_state(addr: &str) -> Result<String, String> {
160 http_round_trip(
161 addr,
162 &format!(
163 "GET /state.json HTTP/1.1\r\nHost: {addr}\r\n\
164 Connection: close\r\n\r\n"
165 ),
166 )
167}
168
169fn post_start(addr: &str, token: Option<&str>) -> Result<String, String> {
172 let path = match token {
173 Some(t) => format!("/start?token={t}"),
174 None => "/start".to_string(),
175 };
176 http_round_trip(
177 addr,
178 &format!(
179 "POST {path} HTTP/1.1\r\nHost: {addr}\r\n\
180 Connection: close\r\nContent-Length: 0\r\n\r\n"
181 ),
182 )
183}
184
185fn http_round_trip(addr: &str, request: &str) -> Result<String, String> {
188 let sock_addr = addr
189 .to_socket_addrs()
190 .map_err(|e| format!("cannot resolve {addr}: {e}"))?
191 .next()
192 .ok_or_else(|| format!("cannot resolve {addr}"))?;
193 let mut stream = TcpStream::connect_timeout(&sock_addr, HTTP_TIMEOUT)
194 .map_err(|e| format!("connect: {e}"))?;
195 stream
196 .set_read_timeout(Some(HTTP_TIMEOUT))
197 .and_then(|()| stream.set_write_timeout(Some(HTTP_TIMEOUT)))
198 .map_err(|e| format!("socket setup: {e}"))?;
199 stream
200 .write_all(request.as_bytes())
201 .map_err(|e| format!("send request: {e}"))?;
202 let mut response = String::new();
203 stream
204 .read_to_string(&mut response)
205 .map_err(|e| format!("read response: {e}"))?;
206
207 let (head, body) = response
208 .split_once("\r\n\r\n")
209 .ok_or_else(|| "malformed HTTP response".to_string())?;
210 let status_line = head.lines().next().unwrap_or_default();
211 if !status_line.contains(" 200 ") {
212 return Err(format!(
213 "endpoint answered {} — {}",
214 status_line.trim_start_matches("HTTP/1.1 "),
215 body.trim(),
216 ));
217 }
218 Ok(body.trim().to_string())
219}
220
221fn print_status(addr: &str, body: &str) {
229 let state: serde_json::Value = match serde_json::from_str(body) {
230 Ok(v) => v,
231 Err(_) => {
232 println!("{body}");
234 return;
235 }
236 };
237
238 let phase = state["phase"].as_str().unwrap_or("unknown");
239 let painted_phase = match phase {
240 "training" | "done" => style::green(phase),
241 "waiting" | "staging" | "forming" => style::yellow(phase),
242 "failed" => style::red(phase),
243 other => other.to_string(),
244 };
245 println!("cluster run @ {addr} — {}", style::bold(&painted_phase),);
246
247 let joined_ranks = state["joined_ranks"].as_u64().unwrap_or(0);
248 let joined_hosts = state["joined_hosts"].as_u64().unwrap_or(0);
249 let quorum = state["min_rank_start"].as_u64().unwrap_or(0);
250 let target = state["target_ranks"]
251 .as_u64()
252 .map(|t| t.to_string())
253 .unwrap_or_else(|| "none".to_string());
254 println!(
255 " ranks: {joined_ranks} joined across {joined_hosts} host(s) \
256 (quorum {quorum}, target {target})",
257 );
258 if phase == "waiting" || phase == "staging" {
261 let fmt_remaining = |v: &serde_json::Value| match v.as_u64() {
262 Some(s) => format!("{s}s left"),
263 None => "expired".to_string(),
264 };
265 println!(
266 " window: {} hard cap: {}",
267 fmt_remaining(&state["window_remaining_secs"]),
268 fmt_remaining(&state["cap_remaining_secs"]),
269 );
270 }
271 if let Some(mode) = state["start_mode"].as_str() {
274 if mode != "auto" && matches!(phase, "waiting" | "staging") {
277 let armed = state["start_armed"].as_bool().unwrap_or(false);
278 if armed {
279 println!(" start: {mode} — armed (forming at the next poll)");
280 } else if phase == "staging" {
281 println!(
282 " start: {mode} — {}",
283 style::bold("roster startable, fire with `fdl start`"),
284 );
285 } else if phase == "waiting" {
286 println!(" start: {mode} — waiting for quorum");
287 }
288 }
289 }
290
291 let Some(members) = state["members"].as_array() else {
292 return;
293 };
294 if members.is_empty() {
295 println!(" hosts: none joined yet");
296 return;
297 }
298 println!(" hosts:");
299 let host_width = members
300 .iter()
301 .filter_map(|m| m["host"].as_str())
302 .map(str::len)
303 .max()
304 .unwrap_or(0);
305 for m in members {
306 let host = m["host"].as_str().unwrap_or("?");
307 let ranks: Vec<String> = m["ranks"]
308 .as_array()
309 .map(|a| {
310 a.iter()
311 .filter_map(|r| r.as_u64())
312 .map(|r| r.to_string())
313 .collect()
314 })
315 .unwrap_or_default();
316 let joined_at = m["joined_at_secs"].as_u64().unwrap_or(0);
317 let libtorch = m["libtorch"].as_str().unwrap_or("?");
318 let padded_host = format!("{host:<host_width$}");
320 println!(
321 " {} ranks [{}] {} libtorch {} joined +{joined_at}s",
322 style::bold(&padded_host),
323 ranks.join(", "),
324 summarize_gpus(&m["gpus"]),
325 libtorch,
326 );
327 }
328}
329
330fn summarize_gpus(gpus: &serde_json::Value) -> String {
333 let names: Vec<&str> = gpus
334 .as_array()
335 .map(|a| a.iter().filter_map(|g| g.as_str()).collect())
336 .unwrap_or_default();
337 if names.is_empty() {
338 return "no GPUs listed".to_string();
339 }
340 if names.iter().all(|n| *n == names[0]) {
341 return format!("{}x {}", names.len(), names[0]);
342 }
343 names.join(", ")
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn addr_override_gets_default_port_when_bare() {
352 let (candidates, origin) = resolve_candidates(Some("10.0.0.7"));
353 assert_eq!(candidates, vec!["10.0.0.7:1337".to_string()]);
354 assert_eq!(origin, "--addr");
355 let (candidates, _) = resolve_candidates(Some("10.0.0.7:9000"));
356 assert_eq!(candidates, vec!["10.0.0.7:9000".to_string()]);
357 }
358
359 #[test]
360 fn gpu_summary_groups_identical_names() {
361 let gpus = serde_json::json!(["GP106", "GP106"]);
362 assert_eq!(summarize_gpus(&gpus), "2x GP106");
363 let gpus = serde_json::json!(["GP106", "RTX 5060 Ti"]);
364 assert_eq!(summarize_gpus(&gpus), "GP106, RTX 5060 Ti");
365 assert_eq!(summarize_gpus(&serde_json::json!([])), "no GPUs listed");
366 }
367
368 #[test]
369 fn fetch_state_reports_non_200_with_body() {
370 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
372 let addr = listener.local_addr().unwrap();
373 let server = std::thread::spawn(move || {
374 let (mut stream, _) = listener.accept().unwrap();
375 let mut buf = [0u8; 512];
376 let _ = stream.read(&mut buf);
377 let body = r#"{"error":"no membership state published yet"}"#;
378 let _ = stream.write_all(
379 format!(
380 "HTTP/1.1 503 Service Unavailable\r\n\
381 Content-Type: application/json\r\n\
382 Connection: close\r\n\
383 Content-Length: {}\r\n\r\n{body}",
384 body.len(),
385 )
386 .as_bytes(),
387 );
388 });
389 let err = fetch_state(&addr.to_string()).unwrap_err();
390 assert!(err.contains("503"), "{err}");
391 assert!(err.contains("no membership state"), "{err}");
392 server.join().unwrap();
393 }
394
395 #[test]
396 fn fetch_state_round_trips_200_body() {
397 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
398 let addr = listener.local_addr().unwrap();
399 let server = std::thread::spawn(move || {
400 let (mut stream, _) = listener.accept().unwrap();
401 let mut buf = [0u8; 512];
402 let _ = stream.read(&mut buf);
403 let body = r#"{"phase":"training","joined_ranks":3}"#;
404 let _ = stream.write_all(
405 format!(
406 "HTTP/1.1 200 OK\r\n\
407 Content-Type: application/json\r\n\
408 Connection: close\r\n\
409 Content-Length: {}\r\n\r\n{body}",
410 body.len(),
411 )
412 .as_bytes(),
413 );
414 });
415 let body = fetch_state(&addr.to_string()).unwrap();
416 assert_eq!(body, r#"{"phase":"training","joined_ranks":3}"#);
417 server.join().unwrap();
418 }
419}