logs.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. //! Log window attachments — tracked SSH `docker logs -f` streams with a
  2. //! ring-buffer history per (cluster, node, recipe, rank). `POST /api/logs`
  3. //! attaches (idempotent per key), `DELETE /api/logs` detaches, `GET
  4. //! /api/logs` returns ring history for a fresh window. Lines stream to the
  5. //! SSE bus as `Event::Log`.
  6. use std::collections::VecDeque;
  7. use std::sync::atomic::{AtomicBool, Ordering};
  8. use std::sync::{Arc, Mutex};
  9. use tokio::task::JoinHandle;
  10. use crate::ssh::shq;
  11. use crate::web::events::Event;
  12. use crate::web::ssh_stream::stream_lines;
  13. /// Max concurrent log streams (each holds an SSH connection).
  14. pub const MAX_ATTACHES: usize = 8;
  15. /// Ring-buffer depth per attach (lines).
  16. pub const RING_LINES: usize = 1000;
  17. /// Lines of history a fresh window starts with (docker `--tail`).
  18. pub const TAIL_LINES: usize = 200;
  19. /// One live (or finished) log stream.
  20. pub struct LogAttach {
  21. pub id: String,
  22. pub cluster: String,
  23. /// Node name (SSH target).
  24. pub node: String,
  25. pub recipe: String,
  26. pub rank: u32,
  27. /// Bounded history for `GET /api/logs` and window re-opens.
  28. pub history: Arc<Mutex<VecDeque<String>>>,
  29. /// Set on detach; the stream task ends at its next line.
  30. pub stop: Arc<AtomicBool>,
  31. /// True once the stream ended on its own (container stopped, SSH drop).
  32. pub done: Arc<AtomicBool>,
  33. /// The streaming task (aborted on detach).
  34. pub task: JoinHandle<()>,
  35. }
  36. type AttachKey = (String, String, String, u32);
  37. impl LogAttach {
  38. fn key(&self) -> AttachKey {
  39. (
  40. self.cluster.clone(),
  41. self.node.clone(),
  42. self.recipe.clone(),
  43. self.rank,
  44. )
  45. }
  46. /// Last `n` history lines, oldest first.
  47. pub fn history_last(&self, n: usize) -> Vec<String> {
  48. let ring = self.history.lock().unwrap();
  49. ring.iter()
  50. .rev()
  51. .take(n)
  52. .map(String::clone)
  53. .collect::<Vec<_>>()
  54. .into_iter()
  55. .rev()
  56. .collect()
  57. }
  58. }
  59. /// `docker logs -f --tail=N --timestamps` for one recipe+rank's container(s)
  60. /// on the node — seeds the window with history, then follows.
  61. pub fn logs_stream_cmd(recipe: &str, rank: u32, tail: usize) -> String {
  62. format!(
  63. "docker ps -q --filter {} --filter label=sm121.rank={} \
  64. | xargs -r docker logs -f --tail={tail} --timestamps 2>&1",
  65. shq(&format!("label=sm121.recipe={}", recipe)),
  66. rank
  67. )
  68. }
  69. /// Append a line to a ring, evicting the oldest at capacity.
  70. pub(crate) fn push_line(ring: &Mutex<VecDeque<String>>, line: &str) {
  71. let mut r = ring.lock().unwrap();
  72. if r.len() >= RING_LINES {
  73. r.pop_front();
  74. }
  75. r.push_back(line.to_string());
  76. }
  77. /// Idempotent per (cluster, node, recipe, rank): a live stream for the same
  78. /// key is reused; a finished one is replaced.
  79. pub fn attach(
  80. app: &crate::web::AppState,
  81. attaches: &Mutex<Vec<LogAttach>>,
  82. cluster: &str,
  83. node: &str,
  84. recipe: &str,
  85. rank: u32,
  86. ) -> Result<(String, Vec<String>), String> {
  87. let config = &app.config;
  88. let cl = config
  89. .clusters
  90. .iter()
  91. .find(|c| c.name == cluster)
  92. .ok_or_else(|| format!("unknown cluster '{cluster}'"))?;
  93. let node_cfg = cl
  94. .nodes
  95. .iter()
  96. .find(|n| n.name == node)
  97. .ok_or_else(|| format!("unknown node '{node}' in cluster '{cluster}'"))?;
  98. let key: AttachKey = (
  99. cluster.to_string(),
  100. node.to_string(),
  101. recipe.to_string(),
  102. rank,
  103. );
  104. let mut list = attaches.lock().unwrap();
  105. if let Some(a) = list.iter().find(|a| a.key() == key) {
  106. if !a.done.load(Ordering::Relaxed) {
  107. return Ok((a.id.clone(), a.history_last(TAIL_LINES)));
  108. }
  109. // Finished stream — drop it and start fresh below.
  110. let pos = list.iter().position(|x| x.id == a.id).unwrap();
  111. list.remove(pos);
  112. }
  113. if list.len() >= MAX_ATTACHES {
  114. return Err(format!(
  115. "max {MAX_ATTACHES} log attaches — close a window first"
  116. ));
  117. }
  118. let id = format!("l{}", list.len() + 1);
  119. let history: Arc<Mutex<VecDeque<String>>> =
  120. Arc::new(Mutex::new(VecDeque::with_capacity(RING_LINES)));
  121. let stop = Arc::new(AtomicBool::new(false));
  122. let done = Arc::new(AtomicBool::new(false));
  123. let cmd = logs_stream_cmd(recipe, rank, TAIL_LINES);
  124. let (h, s, d) = (history.clone(), stop.clone(), done.clone());
  125. let (cfg, node_obj, bus) = (config.clone(), node_cfg.clone(), app.bus.clone());
  126. let (idc, cluster_s, node_s, recipe_s) = (
  127. id.clone(),
  128. cl.name.clone(),
  129. node_cfg.name.clone(),
  130. recipe.to_string(),
  131. );
  132. let appc = app.clone();
  133. let task = tokio::spawn(async move {
  134. let _ = stream_lines(&cfg, &node_obj, cmd, s, |line| {
  135. push_line(&h, line);
  136. let _ = bus.send(Event::Log {
  137. attach: idc.clone(),
  138. cluster: cluster_s.clone(),
  139. node: node_s.clone(),
  140. recipe: recipe_s.clone(),
  141. rank,
  142. line: line.to_string(),
  143. });
  144. })
  145. .await;
  146. // Stream errors (node offline, container gone) just end the window —
  147. // refresh the board so attaches[].done (and fleet changes) reach the
  148. // UI immediately instead of on the next 30 s tick.
  149. d.store(true, Ordering::Relaxed);
  150. crate::web::push_state(&appc).await;
  151. });
  152. let attach = LogAttach {
  153. id: id.clone(),
  154. cluster: cluster.to_string(),
  155. node: node.to_string(),
  156. recipe: recipe.to_string(),
  157. rank,
  158. history: history.clone(),
  159. stop,
  160. done,
  161. task,
  162. };
  163. list.push(attach);
  164. Ok((id, vec![]))
  165. }
  166. /// Detach: stop the stream, abort the task, drop the entry.
  167. pub fn detach(attaches: &Mutex<Vec<LogAttach>>, attach_id: &str) -> bool {
  168. let mut list = attaches.lock().unwrap();
  169. if let Some(pos) = list.iter().position(|a| a.id == attach_id) {
  170. list[pos].stop.store(true, Ordering::Relaxed);
  171. list[pos].task.abort();
  172. list.remove(pos);
  173. true
  174. } else {
  175. false
  176. }
  177. }
  178. /// Find an attach by key (for `GET /api/logs?cluster=&node=&recipe=&rank=`).
  179. pub fn by_key(
  180. attaches: &Mutex<Vec<LogAttach>>,
  181. cluster: &str,
  182. node: &str,
  183. recipe: &str,
  184. rank: u32,
  185. lines: usize,
  186. ) -> Option<(String, Vec<String>)> {
  187. let list = attaches.lock().unwrap();
  188. list.iter()
  189. .find(|a| {
  190. a.key()
  191. == (
  192. cluster.to_string(),
  193. node.to_string(),
  194. recipe.to_string(),
  195. rank,
  196. )
  197. })
  198. .map(|a| (a.id.clone(), a.history_last(lines)))
  199. }
  200. #[cfg(test)]
  201. mod tests {
  202. use super::*;
  203. fn ring() -> Arc<Mutex<VecDeque<String>>> {
  204. Arc::new(Mutex::new(VecDeque::new()))
  205. }
  206. #[test]
  207. fn push_line_trims_to_capacity_keeping_newest() {
  208. let r = ring();
  209. for i in 0..=RING_LINES {
  210. push_line(&r, &format!("line-{i}"));
  211. }
  212. let g = r.lock().unwrap();
  213. assert_eq!(g.len(), RING_LINES, "ring stays at capacity");
  214. assert_eq!(g.front().unwrap(), &"line-1".to_string(), "oldest evicted");
  215. assert_eq!(
  216. g.back().unwrap(),
  217. &format!("line-{}", RING_LINES),
  218. "newest kept"
  219. );
  220. }
  221. #[tokio::test]
  222. async fn history_last_returns_oldest_first() {
  223. let a = LogAttach {
  224. id: "l1".into(),
  225. cluster: "c".into(),
  226. node: "n".into(),
  227. recipe: "r".into(),
  228. rank: 0,
  229. history: ring(),
  230. stop: Arc::new(AtomicBool::new(false)),
  231. done: Arc::new(AtomicBool::new(false)),
  232. task: tokio::spawn(async {}),
  233. };
  234. for i in 0..5 {
  235. push_line(&a.history, &format!("x{i}"));
  236. }
  237. assert_eq!(a.history_last(3), ["x2", "x3", "x4"]);
  238. assert_eq!(a.history_last(99), ["x0", "x1", "x2", "x3", "x4"]);
  239. }
  240. #[test]
  241. fn stream_cmd_carries_recipe_rank_and_tail() {
  242. let cmd = logs_stream_cmd("glm52-nf4-lloyd", 1, 200);
  243. assert!(cmd.contains("label=sm121.recipe=glm52-nf4-lloyd"));
  244. assert!(cmd.contains("label=sm121.rank=1"));
  245. assert!(cmd.contains("--tail=200"));
  246. assert!(cmd.contains("docker logs -f"));
  247. }
  248. #[tokio::test]
  249. async fn attach_is_idempotent_and_bounded() {
  250. let app = crate::web::tests::app_state(None);
  251. let attaches: Mutex<Vec<LogAttach>> = Mutex::new(Vec::new());
  252. // 8 attaches fill the cap; the 9th is refused.
  253. for i in 0..MAX_ATTACHES {
  254. let (id, _) = attach(&app, &attaches, "fake", "n0", &format!("r{i}"), 0).unwrap();
  255. assert_eq!(id, format!("l{}", i + 1));
  256. }
  257. let err = attach(&app, &attaches, "fake", "n0", "overflow", 0).unwrap_err();
  258. assert!(err.contains("max"), "{err}");
  259. // Unknown cluster / node are 400-class errors.
  260. assert!(attach(&app, &attaches, "nope", "n0", "r", 0).is_err());
  261. assert!(attach(&app, &attaches, "fake", "nope", "r", 0).is_err());
  262. // by_key finds what exists, misses what doesn't.
  263. assert!(by_key(&attaches, "fake", "n0", "r0", 0, 10).is_some());
  264. assert!(by_key(&attaches, "fake", "n0", "r0", 7, 10).is_none());
  265. // Detach removes + frees a slot; re-attach after detach works.
  266. assert!(detach(&attaches, "l1"));
  267. assert!(!detach(&attaches, "l1"));
  268. attach(&app, &attaches, "fake", "n0", "new", 0).unwrap();
  269. assert_eq!(attaches.lock().unwrap().len(), MAX_ATTACHES);
  270. }
  271. /// A fake attach with a controlled done flag (no real SSH involved).
  272. fn fake_attach(id: &str, done: bool) -> LogAttach {
  273. LogAttach {
  274. id: id.into(),
  275. cluster: "fake".into(),
  276. node: "n0".into(),
  277. recipe: "r".into(),
  278. rank: 0,
  279. history: Arc::new(Mutex::new(VecDeque::new())),
  280. stop: Arc::new(AtomicBool::new(false)),
  281. done: Arc::new(AtomicBool::new(done)),
  282. // Long-lived task so the attach stays "live" deterministically.
  283. task: tokio::spawn(async {
  284. tokio::time::sleep(std::time::Duration::from_secs(60)).await
  285. }),
  286. }
  287. }
  288. #[tokio::test]
  289. async fn attach_reuses_live_stream_for_same_key() {
  290. let app = crate::web::tests::app_state(None);
  291. let list: Mutex<Vec<LogAttach>> = Mutex::new(Vec::new());
  292. list.lock().unwrap().push(fake_attach("l1", false));
  293. // Same key, live stream -> the existing attach is reused, no new task.
  294. let (id, history) = attach(&app, &list, "fake", "n0", "r", 0).unwrap();
  295. assert_eq!(id, "l1");
  296. assert!(history.is_empty());
  297. assert_eq!(list.lock().unwrap().len(), 1, "no second attach spawned");
  298. }
  299. #[tokio::test]
  300. async fn attach_replaces_finished_stream_for_same_key() {
  301. let app = crate::web::tests::app_state(None);
  302. let list: Mutex<Vec<LogAttach>> = Mutex::new(Vec::new());
  303. list.lock().unwrap().push(fake_attach("l1", true));
  304. // Same key, finished stream -> dropped and replaced with a new task.
  305. attach(&app, &list, "fake", "n0", "r", 0).unwrap();
  306. let g = list.lock().unwrap();
  307. assert_eq!(g.len(), 1, "old attach dropped");
  308. assert_eq!(g[0].id, "l1", "slot id may be reused");
  309. assert!(
  310. !g[0].done.load(Ordering::Relaxed),
  311. "fresh stream not yet done"
  312. );
  313. g[0].stop.store(true, Ordering::Relaxed); // end the fake task
  314. }
  315. }