| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- //! Container log operations — show logs and follow logs for a recipe.
- use anyhow::{Context, Result};
- use tracing::info;
- use crate::config::{Cluster, Config, Node};
- use crate::ssh::{run_ssh_follow, run_ssh_with_retry};
- use super::shq;
- pub fn logs_tail_cmd(recipe_name: &str, rank: u32, tail: usize) -> String {
- format!(
- "docker ps -q --filter {} --filter label=sm121.rank={} | xargs -r docker logs --timestamps --tail={} 2>&1",
- shq(&format!("label=sm121.recipe={}", recipe_name)),
- rank,
- tail
- )
- }
- pub fn logs_follow_cmd(recipe_name: &str) -> String {
- format!(
- "docker ps -q --filter {} --filter label=sm121.rank=0 | xargs -r docker logs -f --timestamps 2>&1",
- shq(&format!("label=sm121.recipe={}", recipe_name))
- )
- }
- pub async fn show_logs(
- config: &Config,
- cluster: &Cluster,
- recipe_name: &str,
- follow: bool,
- node_name: Option<&str>,
- dry_run: bool,
- ) -> Result<()> {
- // Resolve target nodes
- let targets: Vec<&Node> = match node_name {
- Some(name) => cluster.nodes.iter().filter(|n| n.name == *name).collect(),
- None => cluster.nodes.iter().collect(),
- };
- if targets.is_empty() {
- if let Some(n) = node_name {
- let name = n.to_string();
- anyhow::bail!(
- fluent_i18n::t!("exec-node-not-found", { "name" => &name, "cluster" => &cluster.name })
- );
- }
- anyhow::bail!(fluent_i18n::t!("exec-cluster-empty", { "name" => &cluster.name }));
- }
- if follow {
- // Follow mode: only head node (rank 0) or specified node with rank 0
- let target_node = targets
- .iter()
- .find(|n| n.rank == 0)
- .or(targets.first())
- .context("No nodes in cluster")?;
- info!(
- "{}",
- fluent_i18n::t!("logs-follow-head", { "name" => &target_node.name })
- );
- let cmd = logs_follow_cmd(recipe_name);
- run_ssh_follow(config, target_node, cmd, dry_run).await?;
- } else {
- let cn = cluster.name.clone();
- info!(
- "{}",
- fluent_i18n::t!("logs-header", { "name" => cn, "count" => targets.len() })
- );
- let mut handles = Vec::new();
- for node in targets {
- let cmd = logs_tail_cmd(recipe_name, node.rank, 100);
- let config = config.clone();
- let node = (*node).clone();
- let dry = dry_run;
- let name = node.name.clone();
- handles.push(crate::locale::spawn_with_locale(async move {
- (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await)
- }));
- }
- for result in futures::future::join_all(handles).await {
- if let Ok((name, Ok(output))) = result
- && !output.trim().is_empty()
- {
- println!("{}", fluent_i18n::t!("logs-section", { "name" => &name }));
- println!("{}", output);
- }
- }
- }
- Ok(())
- }
- /// Follow the head node's logs with an engine-liveness watch: a dead engine
- /// (the API stops answering for two consecutive probes, or the log stream
- /// ends while the engine is down) fails loudly and closes the recipe's run
- /// in the ledger. Returns cleanly only when the follow ends with the engine
- /// still up (ssh drop).
- pub async fn follow_with_liveness(
- config: &Config,
- cluster: &Cluster,
- recipe_name: &str,
- port: u16,
- node_name: Option<&str>,
- dry_run: bool,
- ) -> Result<()> {
- let watch = crate::readiness::watch(cluster, port);
- tokio::select! {
- r = show_logs(config, cluster, recipe_name, true, node_name, dry_run) => {
- r?;
- // The log stream ended. Do NOT decide death from the one-shot API
- // probe (`alive`): the API port is closed for the whole (multi-
- // minute) weight load, so a false answer here is indistinguishable
- // from "still starting" — that mislabelled a slow boot as "engine
- // is down — containers died". A real death is a container that has
- // EXITED; a still-Up container means the engine is loading or the
- // ssh follow simply dropped. Mirror `wait_engine`'s check.
- if let Err(e) =
- crate::readiness::containers_alive(config, cluster, recipe_name).await
- {
- let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
- anyhow::bail!("log stream ended and a container exited — {e}");
- }
- tracing::warn!(
- "log stream ended but the containers are still up (ssh drop, or the engine is still starting)"
- );
- Ok(())
- }
- r = watch => {
- let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
- r
- }
- }
- }
- #[cfg(test)]
- mod tests {
- use super::{logs_follow_cmd, logs_tail_cmd};
- #[test]
- fn tail_cmd_shell_quotes_recipe_name() {
- // A recipe name with shell metacharacters (`"`, `;`, spaces) must be
- // single-quoted so it can't break out of the docker --filter argument
- // or inject a command separator. (Regression for C-1.)
- let cmd = logs_tail_cmd("foo\";rm -rf /", 0, 100);
- assert!(
- cmd.contains("'label=sm121.recipe=foo\";rm -rf /'"),
- "recipe name not shell-quoted: {cmd}"
- );
- // No bare, unquoted command separator reaches the shell.
- assert!(!cmd.contains("--filter label=sm121.recipe=foo\";"));
- }
- #[test]
- fn follow_cmd_shell_quotes_recipe_name_with_single_quote() {
- // `$HOME` and spaces must stay literal (single-quoted) — not shell-
- // expanded. The inner-single-quote *escaping* of `shq` itself is
- // covered by `docker::tests::shq_escapes_inner_single_quotes`.
- let cmd = logs_follow_cmd("a b$HOME");
- assert!(
- cmd.contains("'label=sm121.recipe=a b$HOME'"),
- "recipe name not shell-quoted (would let $HOME expand): {cmd}"
- );
- }
- }
|