Skip to main content

mira/tui/
source.rs

1//! Where the TUI's answers come from.
2//!
3//! Two transports, one return type: a parsed response envelope. A local source
4//! calls `mira_core::query` on a block directory with no server anywhere in the
5//! picture — which is the whole reason the CLI is worth having, because it means
6//! a detached PVC or a dead pod's volume is still readable. A remote source
7//! POSTs the identical document to `/api/v1/*` on a running replica.
8//!
9//! Both go through `api.rs`'s parsers and `api.rs`'s envelope, so the query
10//! grammar is not reimplemented here — a filter that works against a server
11//! works against a directory because it is the same code deciding what it means.
12//!
13//! Responses are parsed with the KYAML loader, not a JSON parser. That is the
14//! KYAML-first principle paying for itself a second time: the API emits JSON,
15//! JSON is valid KYAML, and `yaml-rust2` is already in the tree for the config
16//! file. There is no JSON parsing dependency in this binary and this does not
17//! add one.
18
19use std::io::{Read, Write};
20use std::net::TcpStream;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use yaml_rust2::Yaml;
25
26use crate::api;
27
28pub enum Source {
29    /// A block directory, read in-process.
30    Local(PathBuf),
31    /// `host:port` of a running Mira's HTTP listener.
32    Remote(String),
33}
34
35pub const QUERY: &str = "/api/v1/query";
36pub const SERIES: &str = "/api/v1/metrics/query";
37pub const NAMES: &str = "/api/v1/metrics/names";
38pub const CORRELATE: &str = "/api/v1/correlate";
39pub const MAP: &str = "/api/v1/map";
40pub const ENTITIES: &str = "/api/v1/entities";
41/// The two read-only GETs. Both answer for the *process*, not for the blocks,
42/// which is why neither has a local arm: a directory has no uptime, no query
43/// counters and nobody evaluating rules against it.
44pub const STATS: &str = "/api/v1/stats";
45pub const ALERTS: &str = "/api/v1/alerts";
46
47impl Source {
48    pub fn label(&self) -> String {
49        match self {
50            Source::Local(p) => format!("local {}", p.display()),
51            Source::Remote(a) => format!("http {a}"),
52        }
53    }
54
55    /// Run one query and hand back the parsed envelope.
56    ///
57    /// Errors are `String` because every one of them ends up in the status bar
58    /// verbatim. A TUI that reports "query failed" and keeps the reason to
59    /// itself is worse than no TUI.
60    pub fn post(&self, route: &str, body: &str) -> Result<Yaml, String> {
61        let text = match self {
62            Source::Local(dir) => local(dir, route, body)?,
63            Source::Remote(addr) => http(addr, "POST", route, Some(body))?,
64        };
65        Self::envelope(&text)
66    }
67
68    /// Fetch one of the process-scoped documents.
69    ///
70    /// Separate from [`Source::post`] rather than a route arm inside it because
71    /// the local case is not "a route this transport does not implement" — it is
72    /// that the thing being asked about does not exist. A block directory is
73    /// readable without a server, which is the point of the local source; a node
74    /// that is not running has no counters and is not paging anyone.
75    pub fn get(&self, route: &str) -> Result<Yaml, String> {
76        let text = match self {
77            Source::Local(_) => {
78                return Err(format!(
79                    "{} reports a running node; this is a directory, so open it with --addr",
80                    route.rsplit('/').next().unwrap_or(route)
81                ));
82            }
83            Source::Remote(addr) => http(addr, "GET", route, None)?,
84        };
85        Self::envelope(&text)
86    }
87
88    fn envelope(text: &str) -> Result<Yaml, String> {
89        let doc = api::parse(text)?;
90        // The API answers errors as JSON too, so a 200 is not the only thing
91        // worth checking — and on the local path there is no status code at all.
92        match doc["error"].as_str() {
93            Some(e) => Err(e.to_owned()),
94            None => Ok(doc),
95        }
96    }
97}
98
99fn local(dir: &Path, route: &str, body: &str) -> Result<String, String> {
100    let now = api::now_nanos();
101    let t = std::time::Instant::now();
102    let run = |field, r: mira_core::error::Result<mira_core::query::Results>| match r {
103        Ok(r) => Ok(api::envelope(field, &r, t.elapsed())),
104        Err(e) => Err(e.to_string()),
105    };
106    match route {
107        QUERY => run(
108            "rows",
109            mira_core::query::search(dir, &api::parse_search(body, now)?),
110        ),
111        SERIES => run(
112            "series",
113            mira_core::series::series(dir, &api::parse_series(body, now)?),
114        ),
115        NAMES => {
116            let (from, to) = api::window(body, now)?;
117            run("names", mira_core::series::names(dir, from, to))
118        }
119        // The frame algebra, over a directory. `open` is empty on all three
120        // because nothing is writing here — an open block only exists inside the
121        // process that accepted the export, and this path has no ingest side.
122        CORRELATE => {
123            let (q, ops) = api::parse_correlate(body, now)?;
124            run("frame", api::correlate(dir, &q, &ops, &[], &[]))
125        }
126        MAP => {
127            let (from, to, max) = api::map_doc(&api::parse(body)?, now)?;
128            run("map", mira_core::frame::map(dir, from, to, max, &[]))
129        }
130        ENTITIES => {
131            let (from, to) = api::window(body, now)?;
132            run("entities", mira_core::frame::entities(dir, from, to, &[]))
133        }
134        other => Err(format!("no such route {other}")),
135    }
136}
137
138/// HTTP/1.1 POST, one connection per request.
139///
140/// ponytail: `Connection: close` and `read_to_end`, which is what lets this
141/// skip chunked-transfer decoding and keep-alive bookkeeping entirely — the
142/// server closes the socket and EOF delimits the body. The ceiling is one TCP
143/// handshake per query, invisible next to the query itself; add pooling if the
144/// TUI ever polls faster than a human types. No TLS: Mira serves plain HTTP, and
145/// a TLS client is where the dependency budget would actually go.
146fn http(addr: &str, method: &str, path: &str, body: Option<&str>) -> Result<String, String> {
147    // Which half of the exchange failed is the diagnosis, and the errno alone
148    // does not carry it — ECONNRESET reads the same going out as coming back.
149    // A write that died is this end's socket going away mid-request; a read
150    // that died is the node. They send the operator to different machines.
151    let io = || -> Result<Vec<u8>, String> {
152        let mut s = TcpStream::connect(addr).map_err(|e| format!("{addr}: {e}"))?;
153        s.set_read_timeout(Some(Duration::from_secs(60)))
154            .map_err(|e| format!("{addr}: {e}"))?;
155        let body = body.unwrap_or("");
156        write!(
157            s,
158            "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\
159             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
160            body.len()
161        )
162        .and_then(|()| s.flush())
163        .map_err(|e| format!("{addr}: sending the request: {e}"))?;
164        let mut buf = Vec::new();
165        s.read_to_end(&mut buf)
166            .map_err(|e| format!("{addr}: reading the response: {e}"))?;
167        Ok(buf)
168    };
169    let raw = io()?;
170    let split = raw
171        .windows(4)
172        .position(|w| w == b"\r\n\r\n")
173        .ok_or_else(|| format!("{addr}: response has no header terminator"))?;
174    let head = String::from_utf8_lossy(&raw[..split]);
175    let text = String::from_utf8_lossy(&raw[split + 4..]).into_owned();
176
177    let status = head
178        .lines()
179        .next()
180        .and_then(|l| l.split_whitespace().nth(1))
181        .unwrap_or("?");
182    match status {
183        "200" => Ok(text),
184        // The body is the JSON error envelope; hand it up so `post` can pull the
185        // message out of it rather than showing the caller a bare number.
186        _ if text.trim_start().starts_with('{') => Ok(text),
187        _ => Err(format!("{addr} returned HTTP {status}: {}", text.trim())),
188    }
189}
190
191/// Normalise what someone types after `--addr` into `host:port`.
192///
193/// `http://` because that is what they will copy out of a browser, and a bare
194/// host because that is what they will type. Port 4318 is where the query API
195/// lives, so it is the only sensible default.
196pub fn parse_addr(s: &str) -> Result<String, String> {
197    let s = s
198        .trim()
199        .trim_start_matches("http://")
200        .trim_end_matches('/')
201        .trim();
202    if s.starts_with("https://") {
203        return Err("--addr: Mira serves plain HTTP; there is no TLS client here".into());
204    }
205    if s.is_empty() {
206        return Err("--addr needs a host".into());
207    }
208    // Only a bare `host` needs the default appended. An IPv6 literal already
209    // carries colons inside its brackets, so counting them would be wrong.
210    Ok(
211        match s
212            .rsplit(':')
213            .next()
214            .is_some_and(|p| p.parse::<u16>().is_ok())
215        {
216            true => s.to_owned(),
217            false => format!("{s}:4318"),
218        },
219    )
220}
221
222/// A listener that answers each connection with the next canned reply, in
223/// order, and stops when they run out.
224///
225/// Out of the test module because the TUI's tests need it too: the alert and
226/// diagnostics panes only exist against a remote source, so there is no way to
227/// render them without something on a socket. A second copy of this would be a
228/// second set of HTTP framing bugs.
229#[cfg(test)]
230pub fn serve(replies: Vec<String>) -> String {
231    let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
232    let addr = l.local_addr().unwrap().to_string();
233    std::thread::spawn(move || {
234        for reply in replies {
235            let Ok((mut s, _)) = l.accept() else { return };
236            // The whole request, head then body. Replying while the body is
237            // still unread closes the socket with data in the receive queue,
238            // which is an RST on the client rather than the answer — a real
239            // server has the same obligation.
240            let mut head = Vec::new();
241            let mut byte = [0u8; 1];
242            while std::io::Read::read(&mut s, &mut byte).unwrap_or(0) == 1 {
243                head.push(byte[0]);
244                if head.ends_with(b"\r\n\r\n") {
245                    break;
246                }
247            }
248            let len: usize = String::from_utf8_lossy(&head)
249                .lines()
250                .find_map(|l| l.strip_prefix("Content-Length: ")?.trim().parse().ok())
251                .unwrap_or(0);
252            let mut body = vec![0u8; len];
253            let _ = std::io::Read::read_exact(&mut s, &mut body);
254            let _ = s.write_all(reply.as_bytes());
255        }
256    });
257    addr
258}
259
260/// An HTTP/1.1 200 carrying `body`, for [`serve`].
261#[cfg(test)]
262pub fn ok(body: &str) -> String {
263    format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{body}")
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn addresses_normalise_to_host_port() {
272        assert_eq!(parse_addr("localhost").unwrap(), "localhost:4318");
273        assert_eq!(parse_addr("http://mira:4318/").unwrap(), "mira:4318");
274        assert_eq!(parse_addr("10.0.0.4:9000").unwrap(), "10.0.0.4:9000");
275        assert_eq!(parse_addr("[::1]:4318").unwrap(), "[::1]:4318");
276        // No port, and the last colon-segment is not a number: still a host.
277        assert_eq!(parse_addr("[::1]").unwrap(), "[::1]:4318");
278        assert!(parse_addr("https://mira").is_err());
279        assert!(parse_addr("  ").is_err());
280    }
281
282    /// The response the API emits has to survive the loader the config file
283    /// uses. This is the "JSON is valid KYAML" claim, checked on the exact
284    /// envelope rather than taken from the spec.
285    #[test]
286    fn a_response_envelope_parses_with_the_kyaml_loader() {
287        let text = r#"{"rows":[{"body":"line1\nline2 \"q\" \u0001","severity_number":17,
288                       "ratio":0.5,"ok":true,"gone":null,
289                       "attributes":{"service.name":"checkout"}}],
290                       "stats":{"blocks_total":69,"blocks_scanned":1,
291                       "rows_scanned":100,"rows_matched":2}}"#;
292        let d = api::parse(text).unwrap();
293        let row = &d["rows"][0];
294        assert_eq!(row["body"].as_str().unwrap(), "line1\nline2 \"q\" \u{1}");
295        assert_eq!(row["severity_number"].as_i64().unwrap(), 17);
296        assert_eq!(row["ratio"].as_f64().unwrap(), 0.5);
297        assert!(row["ok"].as_bool().unwrap());
298        assert!(row["gone"].is_null());
299        assert_eq!(
300            row["attributes"]["service.name"].as_str().unwrap(),
301            "checkout"
302        );
303        assert_eq!(d["stats"]["blocks_scanned"].as_i64().unwrap(), 1);
304    }
305
306    /// The claim that pays for this whole module: a block directory answers with
307    /// nothing running. No server, no port, no process that has to have survived
308    /// — a detached PVC is still readable.
309    ///
310    /// All four route arms go through here because the field name each one puts
311    /// in the envelope (`rows`, `series`, `names`) is what the TUI reads back
312    /// out, and a route that answered under the wrong key would look like an
313    /// empty result rather than an error.
314    #[test]
315    fn a_local_source_answers_out_of_a_directory_with_no_server() {
316        let dir = std::env::temp_dir().join(format!("mira-src-{}", std::process::id()));
317        let _ = std::fs::remove_dir_all(&dir);
318        std::fs::create_dir_all(&dir).unwrap();
319
320        let mut b = mira_core::logs::LogsBuilder::new();
321        b.append_request(&crate::e2e::logs_export("checkout", 1_000, 6))
322            .unwrap();
323        let sealed = b.finish().unwrap();
324        mira_core::block::publish(&dir, "logs", mira_core::block::node_id("a"), 0, 0, &sealed)
325            .unwrap();
326
327        let src = Source::Local(dir.clone());
328        assert!(src.label().starts_with("local "));
329
330        let d = src
331            .post(QUERY, r#"{"signal":"logs","from":0,"to":100000,"limit":2}"#)
332            .unwrap();
333        assert_eq!(d["rows"].as_vec().unwrap().len(), 2);
334        assert_eq!(d["stats"]["rows_matched"].as_i64().unwrap(), 6);
335
336        // No metrics in this directory, so these answer empty — which is the
337        // point: they answer, under their own key, rather than erroring.
338        // The name listing takes a window and nothing else — the same document
339        // the TUI sends it — because a key an endpoint does not implement is now
340        // an error rather than a silently dropped filter.
341        for (route, field, body) in [
342            (SERIES, "series", r#"{"name":"anything"}"#),
343            (NAMES, "names", "{}"),
344        ] {
345            let d = src.post(route, body).unwrap();
346            assert!(d[field].as_vec().unwrap().is_empty(), "{route}");
347            assert_eq!(d["stats"]["blocks_total"].as_i64().unwrap(), 0);
348        }
349
350        // The entity facet reads the resource tables of all three signals, so
351        // the one service in the logs block above is in it.
352        let d = src.post(ENTITIES, r#"{"from":0,"to":100000}"#).unwrap();
353        assert_eq!(d["entities"][0]["name"].as_str().unwrap(), "checkout");
354
355        // The frame algebra reads the same directory. Correlate anchors on the
356        // logs above, so it finds their entity; the map is built from spans and
357        // there are none, so it answers an empty graph rather than an error.
358        let d = src
359            .post(
360                CORRELATE,
361                r#"{"signal":"logs","from":0,"to":100000,"expand":["traces","peers"]}"#,
362            )
363            .unwrap();
364        assert_eq!(
365            d["frame"]["entities"][0]["name"].as_str().unwrap(),
366            "checkout"
367        );
368        assert!(!d["frame"]["truncated"].as_bool().unwrap());
369
370        let d = src.post(MAP, r#"{"from":0,"to":100000}"#).unwrap();
371        assert!(d["map"]["nodes"].as_vec().unwrap().is_empty());
372        assert_eq!(d["map"]["unresolved"].as_i64().unwrap(), 0);
373
374        // Every one of them is strict about its document, the same way the
375        // server is — a key an endpoint does not implement is an error, not a
376        // filter that was quietly dropped.
377        for (route, body, want) in [
378            (CORRELATE, r#"{"expand":["sideways"]}"#, "sideways"),
379            (MAP, r#"{"limit":5}"#, "unknown query key"),
380            (ENTITIES, r#"{"to":"soon"}"#, "soon"),
381        ] {
382            let e = src.post(route, body).unwrap_err();
383            assert!(e.contains(want), "{route}: {e}");
384        }
385
386        // A malformed document is the engine's error, reported verbatim rather
387        // than swallowed into "query failed".
388        let e = src.post(QUERY, r#"{"signal":"nope"}"#).unwrap_err();
389        assert!(e.contains("nope"), "{e}");
390        assert!(
391            src.post("/api/v1/nope", "{}")
392                .unwrap_err()
393                .contains("route")
394        );
395
396        let _ = std::fs::remove_dir_all(&dir);
397    }
398
399    /// Enough HTTP to read an answer, and no more — so what it does with the
400    /// three replies it can get has to be pinned down here.
401    ///
402    /// A non-200 carrying a JSON body is handed up rather than reported as a
403    /// number, because the body is the error envelope and the message inside it
404    /// is the only useful thing on the screen.
405    #[test]
406    fn a_remote_source_reports_what_the_server_actually_said() {
407        let ok = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"rows\":[],\
408                  \"stats\":{\"blocks_total\":0,\"blocks_scanned\":0,\"rows_scanned\":0,\
409                  \"rows_matched\":0}}";
410        let bad = "HTTP/1.1 400 Bad Request\r\n\r\n{\"error\":\"unknown signal \\\"nope\\\"\"}";
411        let plain = "HTTP/1.1 502 Bad Gateway\r\n\r\nupstream is down";
412        let truncated = "HTTP/1.1 200 OK\r\nContent-Type: application/json";
413
414        let src = Source::Remote(serve(
415            [ok, bad, plain, truncated].map(str::to_owned).to_vec(),
416        ));
417        assert!(src.label().starts_with("http "));
418
419        let d = src.post(QUERY, "{}").unwrap();
420        assert!(d["rows"].as_vec().unwrap().is_empty());
421        // 400, but the message is what reaches the status bar, not the number.
422        assert_eq!(
423            src.post(QUERY, "{}").unwrap_err(),
424            r#"unknown signal "nope""#
425        );
426        let e = src.post(QUERY, "{}").unwrap_err();
427        assert!(e.contains("502") && e.contains("upstream is down"), "{e}");
428        assert!(
429            src.post(QUERY, "{}").unwrap_err().contains("terminator"),
430            "a reply with no blank line is not an empty answer"
431        );
432
433        // Nothing listening at all. The address is in the message because the
434        // usual cause is a typo in `--addr`.
435        let dead = Source::Remote("127.0.0.1:1".into());
436        let e = dead.post(QUERY, "{}").unwrap_err();
437        assert!(e.starts_with("127.0.0.1:1: "), "{e}");
438    }
439
440    /// A connection that dies while the request is still going out says so, in
441    /// those words.
442    ///
443    /// Every other failure in `http` is read on the way back; this one happens
444    /// before a single byte of answer exists, and the `?` on the write is the
445    /// only thing between it and a `read_to_end` of nothing — which would reach
446    /// the caller as "the response has no header terminator", blaming the
447    /// server for a socket that died under this end. The message has to name
448    /// the send, because an operator reading it off the status bar decides from
449    /// it which machine to go and look at.
450    #[test]
451    fn a_request_that_cannot_be_written_reports_the_write_and_not_the_reply() {
452        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
453        let addr = l.local_addr().unwrap().to_string();
454        std::thread::spawn(move || {
455            // Accepted and closed with the request unread, which is what a
456            // server being rolled does to every connection it is holding. Once
457            // per connection, because the body below may take more than one.
458            for s in l.incoming() {
459                match s {
460                    Ok(s) => drop(s.shutdown(std::net::Shutdown::Both)),
461                    Err(_) => return,
462                }
463            }
464        });
465        // The write has to still be running when the reset lands, and that is
466        // a property of this host's send buffer, not of a number picked here:
467        // a request that fits in it whole is written successfully and the
468        // failure moves to the read. So grow the body until it cannot fit.
469        // ponytail: `SO_SNDBUF` would answer directly instead of doubling, and
470        // reading it means `libc` — a dependency for one `getsockopt`, against
471        // a tree of 117.
472        let mut e = String::new();
473        for mib in [1usize, 8, 64] {
474            let body = format!(r#"{{"signal":"logs","q":"{}"}}"#, "x".repeat(mib << 20));
475            e = Source::Remote(addr.clone()).post(QUERY, &body).unwrap_err();
476            if e.contains("sending the request") {
477                break;
478            }
479        }
480        assert!(
481            e.starts_with(&format!("{addr}: sending the request: ")),
482            "the write failed and the message says which half died: {e}"
483        );
484    }
485}