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::style;
22
23/// Connect/read budget per attempt. Status answers are one small JSON
24/// body; anything slower than this is a wedged endpoint, not a run.
25const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
26
27/// Run `fdl status`.
28///
29/// Address resolution, in order:
30/// 1. `--addr <host[:port]>` — used exactly as given.
31/// 2. Active env overlay (`fdl @cluster status` / `FDL_ENV=cluster`):
32///    `cluster.controller.host:port`, with a loopback retry on
33///    connection-refused (an all-tunneled run binds loopback only, and
34///    `fdl status` typically runs on the controller box).
35/// 3. Convention default `127.0.0.1:1337` (single-host / auto-promoted
36///    runs), noted on stderr.
37///
38/// Exit code: 0 when the state was fetched and printed; 1 when no
39/// endpoint answered.
40pub 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
67/// Run `fdl start`: fire the operator start switch of a staging run.
68/// Same address resolution as `fdl status`; the refusal reasons the
69/// controller sends back (auto mode, quorum not met, window closed,
70/// bad token) ARE the UX, so they print verbatim.
71pub 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            // A served refusal means the controller WAS found — its
91            // reason (auto mode, quorum, bad token) is the answer, and
92            // trying further addresses would only mask it behind a
93            // connect error.
94            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
108/// Resolve the ordered list of addresses to try + a human tag saying
109/// where they came from (for the failure message).
110fn 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        // All-tunneled runs bind loopback only; when fdl runs on the
126        // controller box (the common case) the loopback retry finds
127        // them without reimplementing flodl's bind-scope rules.
128        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    // Project-level walk (not the plain context): run from inside a
149    // command dir (e.g. ddp-bench/), the nearest fdl.yml is a command
150    // config with no `cluster:` — the block lives a level up.
151    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
157/// One HTTP GET of `/state.json`. Hand-rolled over TcpStream: the
158/// endpoint is plain HTTP on a cleartext port, no TLS involved.
159fn 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
169/// One `POST /start` (the operator start switch), token as a query
170/// param when given.
171fn 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
185/// Send one request, read the whole response, return the body on 200
186/// and `Err(status — body)` otherwise.
187fn 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
221// ---------------------------------------------------------------------------
222// Rendering
223// ---------------------------------------------------------------------------
224
225/// Pretty-print a `state.json` body. Parsed as a loose `Value` so an
226/// fdl one version ahead of (or behind) the running flodl still renders
227/// what it recognizes instead of failing on an exact-shape mismatch.
228fn 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            // Not JSON we understand — show it raw rather than nothing.
233            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    // The countdown is only meaningful while the window is open; once
259    // formed, the snapshot's remaining-times are frozen at formation.
260    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    // Operator start switch: only rendered when the run has one (older
272    // flodl snapshots have no start_mode field — absent ≠ auto).
273    if let Some(mode) = state["start_mode"].as_str() {
274        // Only meaningful while the window still holds — once the world
275        // forms, the switch is history.
276        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        // Pad BEFORE painting: ANSI escapes would break {:width$}.
319        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
330/// Collapse a GPU label list: identical names group as `2x <name>`,
331/// mixed inventories list out.
332fn 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        // Minimal one-shot HTTP server answering 503.
371        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}