From ed13f057ef8e3c8b8216d934a5b44619fe215470 Mon Sep 17 00:00:00 2001 From: Piotr Galar Date: Sun, 30 Aug 2026 12:48:00 +0100 Subject: [PATCH] feat: add combined logs command --- README_ADVANCED.md | 30 ++- src/cli.rs | 72 +++++++ src/commands/logs.rs | 343 +++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/commands/status/mod.rs | 2 +- src/commands/status/running.rs | 13 +- src/commands/status/uptime.rs | 8 +- src/docker/logs.rs | 2 +- src/main.rs | 1 + src/main_app/command_handlers.rs | 6 + 10 files changed, 466 insertions(+), 13 deletions(-) create mode 100644 src/commands/logs.rs diff --git a/README_ADVANCED.md b/README_ADVANCED.md index b02c5f2b..c1adb361 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -141,6 +141,24 @@ Displays: - Network information - Port allocations +### `logs` +Shows combined Docker logs for the current foc-devnet run. + +```bash +foc-devnet logs [OPTIONS] +``` + +**Options:** +- `--follow`, `-f` - Follow logs from all current-run containers +- `--tail ` - Number of recent lines to show per container before following (default: 100, only valid with `--follow`) + +**Examples:** +```bash +foc-devnet logs # Print all current-run container logs in timestamp order +foc-devnet logs --follow # Tail combined logs from all current-run containers +foc-devnet logs -f --tail 50 # Tail logs, starting with 50 recent lines per container +``` + ### `version` Shows version information. @@ -1148,10 +1166,8 @@ docker ps | grep curio docker network ls | grep cur-m-net # Should see: foc--cur-m-net-{1,2,3,4,5} -# Monitor logs -docker logs -f foc--curio-1 -docker logs -f foc--curio-2 -# ... etc +# Monitor combined logs +foc-devnet logs --follow ``` ### Querying SP Status @@ -1160,8 +1176,8 @@ docker logs -f foc--curio-2 # List all containers docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" -# Check specific SP logs -docker logs foc--curio-2 +# Print combined logs in timestamp order +foc-devnet logs # Query provider IDs cat ~/.foc-devnet/state/latest/pdp_sps/*.provider_id.json @@ -1207,7 +1223,7 @@ vim ~/.foc-devnet/config.toml ### Container won't start ```bash # Check logs -docker logs foc--lotus +foc-devnet logs # Check if image exists docker images | grep foc-lotus diff --git a/src/cli.rs b/src/cli.rs index 0b798313..35cc93c6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -81,6 +81,15 @@ pub enum Commands { }, /// Show status of the foc-devnet system Status, + /// Show combined logs for the current foc-devnet run + Logs { + /// Follow logs from all current-run containers + #[arg(short, long)] + follow: bool, + /// Number of recent lines to show per container before following + #[arg(long, value_name = "LINES", requires = "follow")] + tail: Option, + }, /// Show version information Version { /// Force plain output without tracing prefixes, even when stdout is a terminal @@ -89,6 +98,69 @@ pub enum Commands { }, } +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn test_parse_logs_default() { + let cli = Cli::try_parse_from(["foc-devnet", "logs"]).unwrap(); + + match cli.command { + Commands::Logs { follow, tail } => { + assert!(!follow); + assert_eq!(tail, None); + } + _ => panic!("expected logs command"), + } + } + + #[test] + fn test_parse_logs_follow_long() { + let cli = Cli::try_parse_from(["foc-devnet", "logs", "--follow"]).unwrap(); + + match cli.command { + Commands::Logs { follow, tail } => { + assert!(follow); + assert_eq!(tail, None); + } + _ => panic!("expected logs command"), + } + } + + #[test] + fn test_parse_logs_follow_short() { + let cli = Cli::try_parse_from(["foc-devnet", "logs", "-f"]).unwrap(); + + match cli.command { + Commands::Logs { follow, tail } => { + assert!(follow); + assert_eq!(tail, None); + } + _ => panic!("expected logs command"), + } + } + + #[test] + fn test_parse_logs_follow_tail() { + let cli = Cli::try_parse_from(["foc-devnet", "logs", "--follow", "--tail", "50"]).unwrap(); + + match cli.command { + Commands::Logs { follow, tail } => { + assert!(follow); + assert_eq!(tail, Some(50)); + } + _ => panic!("expected logs command"), + } + } + + #[test] + fn test_parse_logs_tail_requires_follow() { + assert!(Cli::try_parse_from(["foc-devnet", "logs", "--tail", "50"]).is_err()); + } +} + /// Build subcommands #[derive(Subcommand)] pub enum BuildCommands { diff --git a/src/commands/logs.rs b/src/commands/logs.rs new file mode 100644 index 00000000..9b01e286 --- /dev/null +++ b/src/commands/logs.rs @@ -0,0 +1,343 @@ +//! Combined Docker log output for the active foc-devnet run. + +use crate::docker::core::docker_command; +use crate::docker::logs::{list_all_containers, ContainerInfo}; +use crate::run_id::load_current_run_id; +use chrono::{DateTime, Utc}; +use std::cmp::Ordering; +use std::error::Error; +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +const DEFAULT_FOLLOW_TAIL_LINES: usize = 100; + +#[derive(Debug, Clone, Eq, PartialEq)] +struct LogEntry { + timestamp: Option>, + timestamp_text: String, + container_name: String, + message: String, + container_index: usize, + line_index: usize, +} + +/// Print combined logs for the active foc-devnet run. +pub fn logs(follow: bool, tail: Option) -> Result<(), Box> { + if !follow && tail.is_some() { + return Err("--tail can only be used with --follow".into()); + } + + let run_id = load_current_run_id()?; + let containers = current_run_containers(&run_id)?; + + if containers.is_empty() { + return Err(format!( + "No foc-devnet containers found for current run ID '{}'", + run_id + ) + .into()); + } + + if follow { + follow_logs(containers, tail.unwrap_or(DEFAULT_FOLLOW_TAIL_LINES)) + } else { + print_sorted_logs(containers) + } +} + +fn current_run_containers(run_id: &str) -> Result, Box> { + let current_run_prefix = format!("foc-{}-", run_id); + let mut containers: Vec = list_all_containers()? + .into_iter() + .filter(|container| is_current_run_container_name(&container.name, ¤t_run_prefix)) + .collect(); + + containers.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(containers) +} + +fn is_current_run_container_name(container_name: &str, current_run_prefix: &str) -> bool { + container_name.starts_with(current_run_prefix) +} + +fn print_sorted_logs(containers: Vec) -> Result<(), Box> { + let mut entries = Vec::new(); + + for (container_index, container) in containers.iter().enumerate() { + let output = docker_command(&["logs", "--timestamps", &container.name]).map_err(|e| { + format!( + "Failed to get logs for container '{}': {}", + container.name, e + ) + })?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + entries.extend(parse_container_logs( + &container.name, + container_index, + stdout.lines().chain(stderr.lines()), + )); + } + + sort_log_entries(&mut entries); + + for entry in entries { + println!( + "{} {} | {}", + entry.timestamp_text, entry.container_name, entry.message + ); + } + + Ok(()) +} + +fn parse_container_logs<'a>( + container_name: &str, + container_index: usize, + lines: impl Iterator, +) -> Vec { + lines + .enumerate() + .map(|(line_index, line)| parse_log_line(container_name, container_index, line_index, line)) + .collect() +} + +fn parse_log_line( + container_name: &str, + container_index: usize, + line_index: usize, + line: &str, +) -> LogEntry { + if let Some((timestamp_text, message)) = line.split_once(' ') { + if let Ok(timestamp) = DateTime::parse_from_rfc3339(timestamp_text) { + return LogEntry { + timestamp: Some(timestamp.with_timezone(&Utc)), + timestamp_text: timestamp_text.to_string(), + container_name: container_name.to_string(), + message: message.to_string(), + container_index, + line_index, + }; + } + } + + LogEntry { + timestamp: None, + timestamp_text: "NO_TIMESTAMP".to_string(), + container_name: container_name.to_string(), + message: line.to_string(), + container_index, + line_index, + } +} + +fn sort_log_entries(entries: &mut [LogEntry]) { + entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) { + (Some(a_time), Some(b_time)) => a_time + .cmp(b_time) + .then_with(|| a.container_name.cmp(&b.container_name)) + .then_with(|| a.line_index.cmp(&b.line_index)), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a + .container_index + .cmp(&b.container_index) + .then_with(|| a.line_index.cmp(&b.line_index)), + }); +} + +fn follow_logs(containers: Vec, tail: usize) -> Result<(), Box> { + let mut children = Vec::new(); + let tail_arg = tail.to_string(); + + for container in containers { + let mut child = Command::new("docker") + .args([ + "logs", + "--timestamps", + "--follow", + "--tail", + &tail_arg, + &container.name, + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + format!( + "Failed to follow logs for container '{}': {}", + container.name, e + ) + })?; + + if let Some(stdout) = child.stdout.take() { + let container_name = container.name.clone(); + thread::spawn(move || stream_prefixed_lines(container_name, stdout)); + } + + if let Some(stderr) = child.stderr.take() { + let container_name = container.name.clone(); + thread::spawn(move || stream_prefixed_lines(container_name, stderr)); + } + + children.push(child); + } + + let mut guard = ChildGuard { children }; + loop { + let mut running_count = 0; + for child in &mut guard.children { + if child.try_wait()?.is_none() { + running_count += 1; + } + } + + if running_count == 0 { + return Ok(()); + } + + thread::sleep(Duration::from_millis(250)); + } +} + +fn stream_prefixed_lines(container_name: String, reader: R) +where + R: std::io::Read, +{ + let reader = BufReader::new(reader); + for line in reader.lines() { + match line { + Ok(line) => print_prefixed_line(&container_name, &line), + Err(e) => eprintln!( + "NO_TIMESTAMP {} | failed to read log stream: {}", + container_name, e + ), + } + } +} + +fn print_prefixed_line(container_name: &str, line: &str) { + let entry = parse_log_line(container_name, 0, 0, line); + println!( + "{} {} | {}", + entry.timestamp_text, entry.container_name, entry.message + ); +} + +struct ChildGuard { + children: Vec, +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + for child in &mut self.children { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sort_log_entries_orders_by_timestamp_across_containers() { + let mut entries = vec![ + parse_log_line("foc-run-curio-1", 1, 0, "2026-06-05T12:00:03Z curio"), + parse_log_line("foc-run-lotus", 0, 0, "2026-06-05T12:00:01Z lotus"), + parse_log_line("foc-run-builder", 2, 0, "2026-06-05T12:00:02Z builder"), + ]; + + sort_log_entries(&mut entries); + + let messages: Vec<&str> = entries.iter().map(|entry| entry.message.as_str()).collect(); + assert_eq!(messages, vec!["lotus", "builder", "curio"]); + } + + #[test] + fn test_parse_log_line_supports_nanosecond_timestamps() { + let entry = parse_log_line( + "foc-run-lotus", + 0, + 0, + "2026-06-05T12:00:01.123456789Z ready", + ); + + assert!(entry.timestamp.is_some()); + assert_eq!(entry.timestamp_text, "2026-06-05T12:00:01.123456789Z"); + assert_eq!(entry.message, "ready"); + } + + #[test] + fn test_malformed_lines_are_printable_with_deterministic_order() { + let mut entries = vec![ + parse_log_line("foc-run-curio-1", 1, 0, "curio without timestamp"), + parse_log_line("foc-run-lotus", 0, 0, "lotus without timestamp"), + parse_log_line( + "foc-run-lotus", + 0, + 1, + "2026-06-05T12:00:01Z lotus timestamped", + ), + ]; + + sort_log_entries(&mut entries); + + let printable: Vec = entries + .iter() + .map(|entry| { + format!( + "{} {} | {}", + entry.timestamp_text, entry.container_name, entry.message + ) + }) + .collect(); + + assert_eq!( + printable, + vec![ + "2026-06-05T12:00:01Z foc-run-lotus | lotus timestamped", + "NO_TIMESTAMP foc-run-lotus | lotus without timestamp", + "NO_TIMESTAMP foc-run-curio-1 | curio without timestamp", + ] + ); + } + + #[test] + fn test_current_run_container_filter_prefix() { + let run_id = "20260605T1234_TestRun"; + let current_run_prefix = format!("foc-{}-", run_id); + let containers = [ + "foc-20260605T1234_OldRun-lotus", + "foc-20260605T1234_TestRun-lotus", + "foc-20260605T1234_TestRun-curio-1", + "foc-20260605T1234_TestRun-portainer", + "foc-observer-postgres-calibnet-1", + ]; + + let filtered: Vec<&str> = containers + .iter() + .copied() + .filter(|name| is_current_run_container_name(name, ¤t_run_prefix)) + .collect(); + + assert_eq!( + filtered, + vec![ + "foc-20260605T1234_TestRun-lotus", + "foc-20260605T1234_TestRun-curio-1", + "foc-20260605T1234_TestRun-portainer", + ] + ); + } + + #[test] + fn test_tail_requires_follow() { + let err = logs(false, Some(50)).unwrap_err().to_string(); + assert_eq!(err, "--tail can only be used with --follow"); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b701b11d..464af2e0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod build; pub mod clean; pub mod config; pub mod init; +pub mod logs; pub mod requirements; pub mod start; pub mod status; @@ -17,6 +18,7 @@ pub use build::build_project; pub use clean::{clean, is_clean_for_init}; pub use config::{config_curio, config_lotus}; pub use init::init_environment; +pub use logs::logs; pub use requirements::check_requirements; pub use status::status; pub use stop::stop_cluster; diff --git a/src/commands/status/mod.rs b/src/commands/status/mod.rs index 52cc9a30..e696516c 100644 --- a/src/commands/status/mod.rs +++ b/src/commands/status/mod.rs @@ -13,7 +13,7 @@ //! //! ## Usage //! -//! ```rust +//! ```rust,no_run //! use foc_devnet::commands::status; //! //! // Display full system status diff --git a/src/commands/status/running.rs b/src/commands/status/running.rs index 670f9fff..abe94cb8 100644 --- a/src/commands/status/running.rs +++ b/src/commands/status/running.rs @@ -8,7 +8,7 @@ //! - Show port accessibility //! - Indicate overall system health -use tracing::info; +use tracing::{info, warn}; use crate::constants::MAX_PDP_SP_COUNT; use crate::docker::containers::{ @@ -32,8 +32,15 @@ pub fn print_running_status() -> Result<(), Box> { // Try to get current run ID let run_id = load_current_run_id().ok(); - // Check for running Docker containers - let containers = get_running_foc_containers()?; + // Check for running Docker containers. Status should remain usable on + // hosts where Docker is not installed or not currently available. + let containers = match get_running_foc_containers() { + Ok(containers) => containers, + Err(e) => { + warn!("Unable to query Docker containers: {}", e); + Vec::new() + } + }; let expected_containers = if let Some(ref id) = run_id { vec![ diff --git a/src/commands/status/uptime.rs b/src/commands/status/uptime.rs index c334ba9a..122c1c46 100644 --- a/src/commands/status/uptime.rs +++ b/src/commands/status/uptime.rs @@ -156,7 +156,13 @@ fn parse_memory_value(mem_str: &str) -> Option { /// /// Returns an error if Docker commands fail. pub fn print_uptime() -> Result<(), Box> { - let containers = get_running_foc_containers()?; + let containers = match get_running_foc_containers() { + Ok(containers) => containers, + Err(e) => { + warn!("Unable to query Docker containers: {}", e); + Vec::new() + } + }; if containers.is_empty() { info!("System is not running"); diff --git a/src/docker/logs.rs b/src/docker/logs.rs index 693e6e70..ac3a7d12 100644 --- a/src/docker/logs.rs +++ b/src/docker/logs.rs @@ -25,7 +25,7 @@ pub struct ContainerInfo { } /// List all containers (running or stopped) with name, image, and status. -fn list_all_containers() -> Result, Box> { +pub(crate) fn list_all_containers() -> Result, Box> { let output = docker_command(&["ps", "-a", "--format", "{{.Names}}|{{.Image}}|{{.Status}}"])?; let stdout = String::from_utf8_lossy(&output.stdout); diff --git a/src/main.rs b/src/main.rs index b7c8489b..ec9781ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ fn main() -> Result<(), Box> { main_app::command_handlers::handle_build(build_command) } Commands::Status => main_app::command_handlers::handle_status(), + Commands::Logs { follow, tail } => main_app::command_handlers::handle_logs(follow, tail), Commands::Version { notty } => main_app::version::handle_version(notty), }; diff --git a/src/main_app/command_handlers.rs b/src/main_app/command_handlers.rs index 461f568d..72f31fb5 100644 --- a/src/main_app/command_handlers.rs +++ b/src/main_app/command_handlers.rs @@ -82,3 +82,9 @@ pub fn handle_status() -> Result<(), Box> { // Status is read-only, no poison protection needed commands::status() } + +/// Execute the logs command +pub fn handle_logs(follow: bool, tail: Option) -> Result<(), Box> { + // Logs is read-only, no poison protection needed + commands::logs(follow, tail) +}