Skip to main content

flodl_cli/
status.rs

1//! `fdl status` — live cluster run status.
2//!
3//! Fetches the controller's `state.json` and pretty-prints it. The
4//! endpoint rides the controller's single training port (flodl's
5//! port mux routes plain HTTP GETs to a status responder), so the only
6//! thing this command needs is the controller address — resolved from
7//! the active env overlay's `cluster.controller`, or passed explicitly
8//! with `--addr` (e.g. by a self-deployed worker's owner who has
9//! nothing but the address).
10//!
11//! The endpoint lives exactly as long as the launcher process:
12//! connection-refused is the honest "no run listening" signal, not an
13//! error in this command's plumbing — it is still reported as a
14//! failure exit so scripts can gate on it.
15
16use 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
24/// Connect/read budget per attempt. Status answers are one small JSON
25/// body; anything slower than this is a wedged endpoint, not a run.
26const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
27
28/// Run `fdl status`.
29///
30/// Address resolution, in order:
31/// 1. `--addr <host[:port]>` — used exactly as given.
32/// 2. Active env overlay (`fdl @cluster status` / `FDL_ENV=cluster`):
33///    `cluster.controller.host:port`, with a loopback retry on
34///    connection-refused (an all-tunneled run binds loopback only, and
35///    `fdl status` typically runs on the controller box).
36/// 3. Convention default `127.0.0.1:1337` (single-host / auto-promoted
37///    runs), noted on stderr.
38///
39/// Exit code: 0 when the state was fetched and printed; 1 when no
40/// endpoint answered.
41pub 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
68/// Resolve the ordered list of addresses to try + a human tag saying
69/// where they came from (for the failure message).
70fn 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            // All-tunneled runs bind loopback only; when fdl runs on the
85            // controller box (the common case) the loopback retry finds
86            // them without reimplementing flodl's bind-scope rules.
87            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
114/// One HTTP GET of `/state.json`. Hand-rolled over TcpStream: the
115/// endpoint is plain HTTP on a cleartext port, no TLS involved.
116fn 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
156// ---------------------------------------------------------------------------
157// Rendering
158// ---------------------------------------------------------------------------
159
160/// Pretty-print a `state.json` body. Parsed as a loose `Value` so an
161/// fdl one version ahead of (or behind) the running flodl still renders
162/// what it recognizes instead of failing on an exact-shape mismatch.
163fn 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            // Not JSON we understand — show it raw rather than nothing.
168            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    // The countdown is only meaningful while the window is open; once
197    // formed, the snapshot's remaining-times are frozen at formation.
198    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        // Pad BEFORE painting: ANSI escapes would break {:width$}.
233        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
244/// Collapse a GPU label list: identical names group as `2x <name>`,
245/// mixed inventories list out.
246fn 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        // Minimal one-shot HTTP server answering 503.
285        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}