From 9d6e98b906568b2669639ce10c58bce7a767f6fc Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 7 May 2026 19:09:55 +1000 Subject: [PATCH 1/3] fix(stop,clean): scope cleanup to foc-devnet images, not the foc- prefix --- src/commands/clean.rs | 7 ++++--- src/commands/stop.rs | 40 ++++++++++++++++++---------------------- src/constants.rs | 16 ++++++++++++++++ src/docker/logs.rs | 20 +++++++++++--------- src/docker/mod.rs | 2 +- 5 files changed, 50 insertions(+), 35 deletions(-) diff --git a/src/commands/clean.rs b/src/commands/clean.rs index a9110b64..a73bbc73 100644 --- a/src/commands/clean.rs +++ b/src/commands/clean.rs @@ -2,6 +2,7 @@ use std::io::ErrorKind; use std::process::Command; use tracing::{info, warn}; +use crate::constants::is_foc_devnet_image; use crate::paths::{foc_devnet_config, foc_devnet_home}; /// Remove foc-devnet state from the home directory. @@ -81,7 +82,7 @@ fn docker_not_found_error() -> Box { } fn clean_docker_images() -> Result<(), Box> { - info!("Removing foc-* Docker images"); + info!("Removing foc-devnet Docker images"); let output = Command::new("docker") .args(["images", "--format", "{{.Repository}}:{{.Tag}}"]) .output() @@ -99,7 +100,7 @@ fn clean_docker_images() -> Result<(), Box> { let mut removed_count = 0; for line in stdout.lines() { - if line.starts_with("foc-") { + if is_foc_devnet_image(line) { let remove_output = Command::new("docker") .args(["rmi", line]) .output() @@ -117,7 +118,7 @@ fn clean_docker_images() -> Result<(), Box> { if removed_count > 0 { info!("Removed {} Docker image(s)", removed_count); } else { - info!("No foc-* Docker images found"); + info!("No foc-devnet Docker images found"); } Ok(()) diff --git a/src/commands/stop.rs b/src/commands/stop.rs index b9945e9e..c9289f27 100644 --- a/src/commands/stop.rs +++ b/src/commands/stop.rs @@ -1,5 +1,5 @@ use crate::docker::core::{container_exists, container_is_running, docker_command}; -use crate::docker::delete_all_networks; +use crate::docker::{delete_all_networks, list_foc_devnet_containers}; use crate::run_id::{delete_current_run_id, load_current_run_id}; use std::error::Error; use tracing::{info, warn}; @@ -155,29 +155,24 @@ fn get_run_containers(run_id: &str) -> Vec<(String, &'static str)> { ] } -/// Force kill all containers whose name starts with "foc-" +/// Force kill all foc-devnet containers, identified by exact image name. Avoids +/// touching unrelated containers that happen to share the "foc-" prefix +/// (e.g. foc-observer-*). fn force_kill_foc_containers() -> Result<(), Box> { - info!("Force killing any remaining foc* containers..."); + info!("Force killing any remaining foc-devnet containers..."); - // Get all containers (running and stopped) whose name starts with "foc" - let output = docker_command(&["ps", "-aq", "--filter", "name=^foc*"])?; - let stdout_str = String::from_utf8_lossy(&output.stdout); - let container_ids: Vec<&str> = stdout_str - .lines() - .filter(|line| !line.trim().is_empty()) - .collect(); + let containers = list_foc_devnet_containers()?; - if container_ids.is_empty() { - info!("No remaining foc* containers found"); + if containers.is_empty() { + info!("No remaining foc-devnet containers found"); return Ok(()); } - info!("Found {} remaining container(s)", container_ids.len()); + info!("Found {} remaining container(s)", containers.len()); - for container_id in container_ids { - info!("Force removing container {}...", container_id); - let result = docker_command(&["rm", "-f", container_id]); - match result { + for c in containers { + info!("Force removing container {}...", c.name); + match docker_command(&["rm", "-f", &c.name]) { Ok(_) => info!("Removed"), Err(e) => warn!("Failed: {}", e), } @@ -187,16 +182,17 @@ fn force_kill_foc_containers() -> Result<(), Box> { Ok(()) } -/// Force remove all Docker networks starting with "foc-" or "foc_" +/// Force remove all Docker networks belonging to foc-devnet. Devnet networks are +/// named `foc_{run_id}_*` (underscore separator); the `^foc_` anchor avoids +/// matching unrelated networks like `foc-observer_default`. fn force_remove_foc_networks() -> Result<(), Box> { - info!("Force removing any remaining foc* networks..."); + info!("Force removing any remaining foc-devnet networks..."); - // Get all networks starting with foc- or foc_ let output = docker_command(&[ "network", "ls", "--filter", - "name=^foc*", + "name=^foc_", "--format", "{{.Name}}", ])?; @@ -208,7 +204,7 @@ fn force_remove_foc_networks() -> Result<(), Box> { .collect(); if network_names.is_empty() { - info!("No remaining foc-* networks found"); + info!("No remaining foc-devnet networks found"); return Ok(()); } diff --git a/src/constants.rs b/src/constants.rs index 24cd0735..d5351de4 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -31,6 +31,22 @@ pub const REQUIRED_DOCKER_IMAGES: &[&str] = &[ CURIO_DOCKER_IMAGE, ]; +/// Check whether a Docker image identifier (optionally tagged, e.g. "foc-lotus:latest") +/// belongs to foc-devnet. Used to scope destructive cleanup to our own images +/// and avoid sweeping up unrelated images that happen to start with "foc-" +/// (e.g. foc-observer-*). +pub fn is_foc_devnet_image(image: &str) -> bool { + let repo = image.split(':').next().unwrap_or(image); + matches!( + repo, + LOTUS_DOCKER_IMAGE + | LOTUS_MINER_DOCKER_IMAGE + | BUILDER_DOCKER_IMAGE + | YUGABYTE_DOCKER_IMAGE + | CURIO_DOCKER_IMAGE + ) +} + /// Docker container names (base - will be prefixed with foc-c-- in practice) pub const LOTUS_CONTAINER: &str = "foc-lotus"; pub const LOTUS_MINER_CONTAINER: &str = "foc-lotus-miner"; diff --git a/src/docker/logs.rs b/src/docker/logs.rs index 4d13baa6..28bb84c0 100644 --- a/src/docker/logs.rs +++ b/src/docker/logs.rs @@ -7,6 +7,7 @@ //! The log files are stored under the run-specific directory: //! ~/.foc-devnet/run//logs/..docker.log +use crate::constants::is_foc_devnet_image; use crate::docker::core::{docker_command, get_container_logs}; use crate::paths::foc_devnet_run_dir; use std::error::Error; @@ -22,8 +23,9 @@ pub struct ContainerInfo { pub status: String, } -/// List all containers (running or stopped) whose image name starts with the given prefix. -pub fn list_containers_by_image_prefix(prefix: &str) -> Result, Box> { +/// List all containers (running or stopped) belonging to foc-devnet, identified by +/// exact image name (with optional tag). Excludes unrelated images like foc-observer-*. +pub fn list_foc_devnet_containers() -> Result, Box> { let output = docker_command(&["ps", "-a", "--format", "{{.Names}}|{{.Image}}|{{.Status}}"])?; let stdout = String::from_utf8_lossy(&output.stdout); @@ -34,7 +36,7 @@ pub fn list_containers_by_image_prefix(prefix: &str) -> Result Result Result<(), Box> { - let containers = list_containers_by_image_prefix("foc")?; + let containers = list_foc_devnet_containers()?; let logs_dir = foc_devnet_run_dir(run_id).join("logs"); fs::create_dir_all(&logs_dir)?; info!( - "Persisting logs for {} foc* containers to {}", + "Persisting logs for {} foc-devnet containers to {}", containers.len(), logs_dir.display() ); @@ -77,9 +79,9 @@ pub fn persist_foc_container_logs(run_id: &str) -> Result<(), Box> { Ok(()) } -/// Remove all containers whose image starts with "foc" and are not running. +/// Remove all foc-devnet containers that are not running. pub fn remove_dead_foc_containers() -> Result<(), Box> { - let containers = list_containers_by_image_prefix("foc")?; + let containers = list_foc_devnet_containers()?; let mut removed_count = 0; for c in containers { @@ -106,7 +108,7 @@ pub fn remove_dead_foc_containers() -> Result<(), Box> { } } } - info!("✓ Removed {} dead foc* containers", removed_count); + info!("✓ Removed {} dead foc-devnet containers", removed_count); Ok(()) } diff --git a/src/docker/mod.rs b/src/docker/mod.rs index 900e88c0..a60c944f 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -36,7 +36,7 @@ pub use containers::{ }; pub use init::{create_volume_directories_for_images, set_volume_ownership}; pub use logs::{ - list_containers_by_image_prefix, persist_foc_container_logs, remove_dead_foc_containers, + list_foc_devnet_containers, persist_foc_container_logs, remove_dead_foc_containers, write_post_start_status_log, }; pub use network::{ From f969607dd632f7c649e62c41605b7206335cc8cd Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 7 May 2026 21:24:29 +1200 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/docker/logs.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/docker/logs.rs b/src/docker/logs.rs index 28bb84c0..b3120c83 100644 --- a/src/docker/logs.rs +++ b/src/docker/logs.rs @@ -23,8 +23,9 @@ pub struct ContainerInfo { pub status: String, } -/// List all containers (running or stopped) belonging to foc-devnet, identified by -/// exact image name (with optional tag). Excludes unrelated images like foc-observer-*. +/// List all containers (running or stopped) whose image repository is recognized as a +/// foc-devnet image. Matching is based on the repository name via `is_foc_devnet_image` +/// and does not distinguish between tags; unrelated repositories are excluded. pub fn list_foc_devnet_containers() -> Result, Box> { let output = docker_command(&["ps", "-a", "--format", "{{.Names}}|{{.Image}}|{{.Status}}"])?; let stdout = String::from_utf8_lossy(&output.stdout); From 4ba812437473f756e2ce94b3075b30d4fb1b3c77 Mon Sep 17 00:00:00 2001 From: Rod Vagg Date: Thu, 7 May 2026 19:49:30 +1000 Subject: [PATCH 3/3] fixup! fix(stop,clean): scope cleanup to foc-devnet images, not the foc- prefix --- src/commands/stop.rs | 21 +++++++------------ src/constants.rs | 28 +++++++++++++++++++++++++ src/docker/mod.rs | 2 +- src/docker/network.rs | 49 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/commands/stop.rs b/src/commands/stop.rs index c9289f27..5ef13855 100644 --- a/src/commands/stop.rs +++ b/src/commands/stop.rs @@ -1,5 +1,5 @@ use crate::docker::core::{container_exists, container_is_running, docker_command}; -use crate::docker::{delete_all_networks, list_foc_devnet_containers}; +use crate::docker::{delete_all_networks, is_foc_devnet_network, list_foc_devnet_containers}; use crate::run_id::{delete_current_run_id, load_current_run_id}; use std::error::Error; use tracing::{info, warn}; @@ -182,25 +182,20 @@ fn force_kill_foc_containers() -> Result<(), Box> { Ok(()) } -/// Force remove all Docker networks belonging to foc-devnet. Devnet networks are -/// named `foc_{run_id}_*` (underscore separator); the `^foc_` anchor avoids -/// matching unrelated networks like `foc-observer_default`. +/// Force remove all Docker networks belonging to foc-devnet, identified by exact +/// match against the `foc_{run_id}_{lot-net|lot-m-net|cur-m-net-N}` naming scheme. +/// Listing all networks then filtering in Rust avoids edge cases with docker's +/// own filter syntax and any unrelated networks that share a `foc_` prefix. fn force_remove_foc_networks() -> Result<(), Box> { info!("Force removing any remaining foc-devnet networks..."); - let output = docker_command(&[ - "network", - "ls", - "--filter", - "name=^foc_", - "--format", - "{{.Name}}", - ])?; + let output = docker_command(&["network", "ls", "--format", "{{.Name}}"])?; let stdout_str = String::from_utf8_lossy(&output.stdout); let network_names: Vec<&str> = stdout_str .lines() - .filter(|line| !line.trim().is_empty()) + .map(str::trim) + .filter(|line| !line.is_empty() && is_foc_devnet_network(line)) .collect(); if network_names.is_empty() { diff --git a/src/constants.rs b/src/constants.rs index d5351de4..9038496f 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -118,3 +118,31 @@ pub const CURIO_LOG_LEVEL: &str = "GOLOG_LOG_LEVEL=pdp=debug"; /// File paths within containers pub const LOTUS_BINARY_PATH: &str = "/usr/local/bin/lotus-bins/lotus"; pub const LOTUS_MINER_BINARY_PATH: &str = "/usr/local/bin/lotus-bins/lotus-miner"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_foc_devnet_image_accepts_known_images() { + for image in REQUIRED_DOCKER_IMAGES { + assert!(is_foc_devnet_image(image), "{} should match", image); + assert!( + is_foc_devnet_image(&format!("{}:latest", image)), + "{}:latest should match", + image + ); + } + } + + #[test] + fn test_is_foc_devnet_image_rejects_unrelated() { + assert!(!is_foc_devnet_image("foc-observer-foc-observer")); + assert!(!is_foc_devnet_image("foc-observer-foc-observer:latest")); + assert!(!is_foc_devnet_image("foc-observer-ponder-mainnet")); + assert!(!is_foc_devnet_image("portainer/portainer-ce:latest")); + assert!(!is_foc_devnet_image("foc-portainer")); + assert!(!is_foc_devnet_image("nginx")); + assert!(!is_foc_devnet_image("")); + } +} diff --git a/src/docker/mod.rs b/src/docker/mod.rs index a60c944f..67aaffb9 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -40,7 +40,7 @@ pub use logs::{ write_post_start_status_log, }; pub use network::{ - connect_container_to_network, create_all_networks, delete_all_networks, + connect_container_to_network, create_all_networks, delete_all_networks, is_foc_devnet_network, lotus_miner_network_name, lotus_network_name, pdp_miner_network_name, }; pub use portainer::{start_portainer, stop_portainer}; diff --git a/src/docker/network.rs b/src/docker/network.rs index 69b9835c..db88ba59 100644 --- a/src/docker/network.rs +++ b/src/docker/network.rs @@ -7,7 +7,9 @@ use crate::constants::MAX_PDP_SP_COUNT; use super::core::docker_command; +use regex::Regex; use std::error::Error; +use std::sync::LazyLock; use tracing::info; /// Network names (suffixes) @@ -15,6 +17,13 @@ const LOTUS_NET_SUFFIX: &str = "lot-net"; const LOTUS_MINER_NET_SUFFIX: &str = "lot-m-net"; const CURIO_MINER_NET_SUFFIX: &str = "cur-m-net"; +/// Matches the exact foc-devnet network naming scheme: `foc_{run_id}_{suffix}`, +/// where suffix is one of the known network types. Used to scope cleanup so +/// unrelated docker-compose networks (e.g. `foc_observer_default`) are not touched. +static FOC_DEVNET_NETWORK_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^foc_.+_(lot-net|lot-m-net|cur-m-net-\d+)$").expect("static regex pattern") +}); + /// Get the Lotus network name for a run ID pub fn lotus_network_name(run_id: &str) -> String { format!("foc_{}_{}", run_id, LOTUS_NET_SUFFIX) @@ -30,6 +39,11 @@ pub fn pdp_miner_network_name(run_id: &str, sp_idx: usize) -> String { format!("foc_{}_{}-{}", run_id, CURIO_MINER_NET_SUFFIX, sp_idx) } +/// Check whether a Docker network name matches the foc-devnet naming scheme. +pub fn is_foc_devnet_network(name: &str) -> bool { + FOC_DEVNET_NETWORK_RE.is_match(name) +} + /// Check if a Docker network exists pub fn network_exists(network_name: &str) -> Result> { let output = docker_command(&[ @@ -163,3 +177,38 @@ pub fn connect_container_to_network( docker_command(&["network", "connect", network_name, container_name])?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_foc_devnet_network_matches_real_names() { + let run_id = "20260507T1747_DoofyBear"; + assert!(is_foc_devnet_network(&lotus_network_name(run_id))); + assert!(is_foc_devnet_network(&lotus_miner_network_name(run_id))); + assert!(is_foc_devnet_network(&pdp_miner_network_name(run_id, 1))); + assert!(is_foc_devnet_network(&pdp_miner_network_name(run_id, 5))); + } + + #[test] + fn test_is_foc_devnet_network_rejects_unrelated() { + assert!(!is_foc_devnet_network("foc-observer_default")); + assert!(!is_foc_devnet_network("foc_observer_default")); + assert!(!is_foc_devnet_network("bridge")); + assert!(!is_foc_devnet_network("foc-portainer")); + assert!(!is_foc_devnet_network("")); + } + + #[test] + fn test_is_foc_devnet_network_rejects_partial_matches() { + // Must end with a known suffix, no trailing junk. + assert!(!is_foc_devnet_network("foc_run_lot-net-extra")); + // cur-m-net requires a numeric SP index. + assert!(!is_foc_devnet_network("foc_run_cur-m-net")); + assert!(!is_foc_devnet_network("foc_run_cur-m-net-")); + assert!(!is_foc_devnet_network("foc_run_cur-m-net-x")); + // Run ID portion must be non-empty. + assert!(!is_foc_devnet_network("foc__lot-net")); + } +}