logs.rs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. //! Container log operations — show logs and follow logs for a recipe.
  2. use anyhow::{Context, Result};
  3. use tracing::info;
  4. use crate::config::{Cluster, Config, Node};
  5. use crate::ssh::{run_ssh_follow, run_ssh_with_retry};
  6. use super::shq;
  7. pub fn logs_tail_cmd(recipe_name: &str, rank: u32, tail: usize) -> String {
  8. format!(
  9. "docker ps -q --filter {} --filter label=sm121.rank={} | xargs -r docker logs --timestamps --tail={} 2>&1",
  10. shq(&format!("label=sm121.recipe={}", recipe_name)),
  11. rank,
  12. tail
  13. )
  14. }
  15. pub fn logs_follow_cmd(recipe_name: &str) -> String {
  16. format!(
  17. "docker ps -q --filter {} --filter label=sm121.rank=0 | xargs -r docker logs -f --timestamps 2>&1",
  18. shq(&format!("label=sm121.recipe={}", recipe_name))
  19. )
  20. }
  21. pub async fn show_logs(
  22. config: &Config,
  23. cluster: &Cluster,
  24. recipe_name: &str,
  25. follow: bool,
  26. node_name: Option<&str>,
  27. dry_run: bool,
  28. ) -> Result<()> {
  29. // Resolve target nodes
  30. let targets: Vec<&Node> = match node_name {
  31. Some(name) => cluster.nodes.iter().filter(|n| n.name == *name).collect(),
  32. None => cluster.nodes.iter().collect(),
  33. };
  34. if targets.is_empty() {
  35. if let Some(n) = node_name {
  36. let name = n.to_string();
  37. anyhow::bail!(
  38. fluent_i18n::t!("exec-node-not-found", { "name" => &name, "cluster" => &cluster.name })
  39. );
  40. }
  41. anyhow::bail!(fluent_i18n::t!("exec-cluster-empty", { "name" => &cluster.name }));
  42. }
  43. if follow {
  44. // Follow mode: only head node (rank 0) or specified node with rank 0
  45. let target_node = targets
  46. .iter()
  47. .find(|n| n.rank == 0)
  48. .or(targets.first())
  49. .context("No nodes in cluster")?;
  50. info!(
  51. "{}",
  52. fluent_i18n::t!("logs-follow-head", { "name" => &target_node.name })
  53. );
  54. let cmd = logs_follow_cmd(recipe_name);
  55. run_ssh_follow(config, target_node, cmd, dry_run).await?;
  56. } else {
  57. let cn = cluster.name.clone();
  58. info!(
  59. "{}",
  60. fluent_i18n::t!("logs-header", { "name" => cn, "count" => targets.len() })
  61. );
  62. let mut handles = Vec::new();
  63. for node in targets {
  64. let cmd = logs_tail_cmd(recipe_name, node.rank, 100);
  65. let config = config.clone();
  66. let node = (*node).clone();
  67. let dry = dry_run;
  68. let name = node.name.clone();
  69. handles.push(crate::locale::spawn_with_locale(async move {
  70. (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await)
  71. }));
  72. }
  73. for result in futures::future::join_all(handles).await {
  74. if let Ok((name, Ok(output))) = result
  75. && !output.trim().is_empty()
  76. {
  77. println!("{}", fluent_i18n::t!("logs-section", { "name" => &name }));
  78. println!("{}", output);
  79. }
  80. }
  81. }
  82. Ok(())
  83. }
  84. /// Follow the head node's logs with an engine-liveness watch: a dead engine
  85. /// (the API stops answering for two consecutive probes, or the log stream
  86. /// ends while the engine is down) fails loudly and closes the recipe's run
  87. /// in the ledger. Returns cleanly only when the follow ends with the engine
  88. /// still up (ssh drop).
  89. pub async fn follow_with_liveness(
  90. config: &Config,
  91. cluster: &Cluster,
  92. recipe_name: &str,
  93. port: u16,
  94. node_name: Option<&str>,
  95. dry_run: bool,
  96. ) -> Result<()> {
  97. let watch = crate::readiness::watch(cluster, port);
  98. tokio::select! {
  99. r = show_logs(config, cluster, recipe_name, true, node_name, dry_run) => {
  100. r?;
  101. // The log stream ended. Do NOT decide death from the one-shot API
  102. // probe (`alive`): the API port is closed for the whole (multi-
  103. // minute) weight load, so a false answer here is indistinguishable
  104. // from "still starting" — that mislabelled a slow boot as "engine
  105. // is down — containers died". A real death is a container that has
  106. // EXITED; a still-Up container means the engine is loading or the
  107. // ssh follow simply dropped. Mirror `wait_engine`'s check.
  108. if let Err(e) =
  109. crate::readiness::containers_alive(config, cluster, recipe_name).await
  110. {
  111. let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
  112. anyhow::bail!("log stream ended and a container exited — {e}");
  113. }
  114. tracing::warn!(
  115. "log stream ended but the containers are still up (ssh drop, or the engine is still starting)"
  116. );
  117. Ok(())
  118. }
  119. r = watch => {
  120. let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
  121. r
  122. }
  123. }
  124. }
  125. #[cfg(test)]
  126. mod tests {
  127. use super::{logs_follow_cmd, logs_tail_cmd};
  128. #[test]
  129. fn tail_cmd_shell_quotes_recipe_name() {
  130. // A recipe name with shell metacharacters (`"`, `;`, spaces) must be
  131. // single-quoted so it can't break out of the docker --filter argument
  132. // or inject a command separator. (Regression for C-1.)
  133. let cmd = logs_tail_cmd("foo\";rm -rf /", 0, 100);
  134. assert!(
  135. cmd.contains("'label=sm121.recipe=foo\";rm -rf /'"),
  136. "recipe name not shell-quoted: {cmd}"
  137. );
  138. // No bare, unquoted command separator reaches the shell.
  139. assert!(!cmd.contains("--filter label=sm121.recipe=foo\";"));
  140. }
  141. #[test]
  142. fn follow_cmd_shell_quotes_recipe_name_with_single_quote() {
  143. // `$HOME` and spaces must stay literal (single-quoted) — not shell-
  144. // expanded. The inner-single-quote *escaping* of `shq` itself is
  145. // covered by `docker::tests::shq_escapes_inner_single_quotes`.
  146. let cmd = logs_follow_cmd("a b$HOME");
  147. assert!(
  148. cmd.contains("'label=sm121.recipe=a b$HOME'"),
  149. "recipe name not shell-quoted (would let $HOME expand): {cmd}"
  150. );
  151. }
  152. }