1use 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 Local(PathBuf),
31 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";
41pub 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 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 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 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 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
138fn http(addr: &str, method: &str, path: &str, body: Option<&str>) -> Result<String, String> {
147 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 _ if text.trim_start().starts_with('{') => Ok(text),
187 _ => Err(format!("{addr} returned HTTP {status}: {}", text.trim())),
188 }
189}
190
191pub 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 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#[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 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#[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 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 #[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 #[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 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 let d = src.post(ENTITIES, r#"{"from":0,"to":100000}"#).unwrap();
353 assert_eq!(d["entities"][0]["name"].as_str().unwrap(), "checkout");
354
355 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 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 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 #[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 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 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 #[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 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 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}