Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
440 changes: 413 additions & 27 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ gumdrop = "0.8.1"
ignore = "0.4.23"
indicatif = "0.17.11"
walkdir = "2.4.0"
log = "0.4"
env_logger = "0.11"
ctrlc = "3.4"
dirs = "5.0"

[dev-dependencies]
tempfile = "3.16.0"
Expand Down
45 changes: 37 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# DockSprout 🌱🐳

# DockSprout

**A simple CLI tool to bring up Docker containers from multiple `docker-compose.yml` files in subdirectories.**

---
## **📌 Features**
## **Features**

- Scans a **central directory** for `docker-compose.yml` files.
- Recursively searches **subdirectories** for Docker Compose projects.
Expand All @@ -17,25 +18,53 @@
1. Install Rust toolchain <https://www.rust-lang.org/tools/install>
2. Run `cargo install dock_sprout`

### **🔹 Homebrew**
### **Homebrew**
```bash
brew install Thompson-Jason/tap/dock_sprout
```

### **🔹 Build from Source**
### **Build from Source**
```bash
git clone https://github.com/Thompson-Jason/DockSprout.git
cd DockSprout
cargo build --release
```

---
## **🚀 Usage**
## **Usage**


### **Basic Usage**
```
sprout <root-directory> <docker-compose-direction>
```

### **Options**

- `--concurrent` : Run all docker compose commands in parallel (faster for many projects)
- `--verbose` : Output docker compose logs to stdout/stderr

## **Examples**

**Bring up all containers, sequentially:**
```
sprout ~/my-docker-projects up
```

`sprout <root-directory> <docker-compose-direction>`
**Bring up all containers concurrently:**
```
sprout --concurrent ~/my-docker-projects up
```

## 🔹 **Example**:
`sprout ~/my-docker-projects up`
**Bring up all containers and show all output:**
```
sprout --verbose ~/my-docker-projects up
```

**Bring down all containers concurrently and show output:**
```
sprout --concurrent --verbose ~/my-docker-projects down
```

```
─── my-docker-projects
Expand Down
118 changes: 112 additions & 6 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,127 @@
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
#[cfg(windows)]
use std::os::windows::process::ExitStatusExt;
pub mod walker;

pub fn run_docker_compose<F>(files: Vec<String>, direction_args: Vec<String>, verbose: bool, mut command_runner: F)
use log::error;
use std::sync::{Arc, Mutex};
use std::sync::atomic::AtomicBool;

pub fn run_docker_compose<F>(files: Vec<String>, direction_args: &[String], verbose: bool, mut command_runner: F)
where
F: FnMut(&str, &Vec<String>, bool) -> std::io::Result<std::process::ExitStatus>,
F: FnMut(&str, &[String], bool) -> std::io::Result<std::process::ExitStatus>,
{
for file_path in files.iter() {
match command_runner(file_path, direction_args, verbose) {
Ok(_status) => {},
Err(e) => error!("Failed to run docker compose for {}: {}", file_path, e),
}
}
}

command_runner(file_path, &direction_args, verbose).unwrap();
pub fn run_docker_compose_collect<F>(files: Vec<String>, direction_args: &[String], verbose: bool, mut command_runner: F) -> Vec<(String, String)>
where
F: FnMut(&str, &[String], bool) -> std::io::Result<std::process::ExitStatus>,
{
let mut errors = Vec::new();
for file_path in files.iter() {
match command_runner(file_path, direction_args, verbose) {
Ok(status) => {
if !status.success() {
errors.push((file_path.clone(), format!("Exited with status: {}", status)));
}
},
Err(e) => errors.push((file_path.clone(), e.to_string())),
}
}
errors
}

pub fn run_docker_compose_concurrent<F>(files: Vec<String>, direction_args: Vec<String>, verbose: bool, mut command_runner: F)
pub fn run_docker_compose_concurrent<F>(files: Vec<String>, direction_args: &[String], verbose: bool, mut command_runner: F)
where
F: FnMut(&str, &Vec<String>, bool) -> std::io::Result<std::process::Child>,
F: FnMut(&str, &[String], bool) -> std::io::Result<std::process::Child>,
{
for file_path in files.iter() {
match command_runner(file_path, direction_args, verbose) {
Ok(_child) => {},
Err(e) => error!("Failed to run docker compose for {}: {}", file_path, e),
}
}
}

command_runner(file_path, &direction_args, verbose).unwrap();
pub fn run_docker_compose_concurrent_collect<F>(
files: Vec<String>,
direction_args: &[String],
verbose: bool,
command_runner: F,
children: Arc<Mutex<Vec<std::process::Child>>>,
shutdown_flag: Arc<AtomicBool>,
) -> Vec<(String, String)>
where
F: FnMut(&str, &[String], bool) -> std::io::Result<std::process::Child> + Send + Clone + 'static,
{
use std::thread;
let mut handles = Vec::new();
let errors = Arc::new(Mutex::new(Vec::new()));
for file_path in files.iter() {
let file = file_path.clone();
let args = direction_args.to_vec();
let mut runner = command_runner.clone();
let errors = Arc::clone(&errors);
let children = Arc::clone(&children);
let shutdown_flag = Arc::clone(&shutdown_flag);
let handle = std::thread::spawn(move || {
if shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
match runner(&file, &args, verbose) {
Ok(child) => {
let child_id = child.id();
{
let mut ch = children.lock().unwrap();
ch.push(child);
}
let status = {
let mut ch = children.lock().unwrap();
let idx = ch.iter().position(|c| c.id() == child_id);
// Remove the child from the list before waiting
let child_opt = idx.map(|i| ch.remove(i));
let status = if let Some(mut child) = child_opt {
child.wait()
} else {
// Should not happen, but fallback
#[cfg(unix)]
{ Ok(std::process::ExitStatus::from_raw(1)) }
#[cfg(windows)]
{ Ok(std::process::ExitStatus::from_raw(1)) }
};
status
};
match status {
Ok(status) => {
if !status.success() {
let mut errs = errors.lock().unwrap();
errs.push((file.clone(), format!("Exited with status: {}", status)));
}
},
Err(e) => {
let mut errs = errors.lock().unwrap();
errs.push((file.clone(), e.to_string()));
}
}
},
Err(e) => {
let mut errs = errors.lock().unwrap();
errs.push((file.clone(), e.to_string()));
}
}
});
handles.push(handle);
}
for handle in handles {
let _ = handle.join();
}
let errors = Arc::try_unwrap(errors).map(|m| m.into_inner().unwrap()).unwrap_or_else(|arc| (*arc.lock().unwrap()).clone());
errors
}
86 changes: 63 additions & 23 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
mod walker;
mod signal;

use gumdrop::Options;
use indicatif::{ProgressBar, ProgressStyle};
use std::{env};
use std::time::Duration;
use std::path::PathBuf;
use std::process::{Command, Stdio, ExitStatus, Child};
use dock_sprout::{run_docker_compose, run_docker_compose_concurrent};
use std::sync::{Arc, Mutex};
use std::sync::atomic::AtomicBool;
use log::error;
// ...existing code...



Expand Down Expand Up @@ -34,8 +38,11 @@ struct Opts {

}

fn real_docker_runner(file_path: &str, direction_args: &Vec<String>, verbose: bool) -> std::io::Result<ExitStatus> {
fn real_docker_runner(file_path: &str, direction_args: &[String], verbose: bool) -> std::io::Result<ExitStatus> {

if file_path.contains(';') || file_path.contains('&') || file_path.contains('|') || file_path.contains('`') || file_path.contains('$') {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid characters in file path"));
}
if verbose {
Command::new("docker")
.arg("compose")
Expand All @@ -45,7 +52,7 @@ fn real_docker_runner(file_path: &str, direction_args: &Vec<String>, verbose: bo
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
}else{
} else {
Command::new("docker")
.arg("compose")
.arg("-f")
Expand All @@ -57,7 +64,11 @@ fn real_docker_runner(file_path: &str, direction_args: &Vec<String>, verbose: bo
}
}

fn real_docker_runner_concurrent(file_path: &str, direction_args: &Vec<String>, verbose: bool) -> std::io::Result<Child> {
fn real_docker_runner_concurrent(file_path: &str, direction_args: &[String], verbose: bool) -> std::io::Result<Child> {
// Security: Validate file_path to prevent command injection
if file_path.contains(';') || file_path.contains('&') || file_path.contains('|') || file_path.contains('`') || file_path.contains('$') {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid characters in file path"));
}
if verbose {
Command::new("docker")
.arg("compose")
Expand All @@ -67,7 +78,7 @@ fn real_docker_runner_concurrent(file_path: &str, direction_args: &Vec<String>,
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
}else{
} else {
Command::new("docker")
.arg("compose")
.arg("-f")
Expand All @@ -80,42 +91,71 @@ fn real_docker_runner_concurrent(file_path: &str, direction_args: &Vec<String>,
}

fn main() {
env_logger::init();
if env::args().any(|arg| arg == "-v" || arg == "--version") {
println!("DockSprout {VERSION}");
std::process::exit(0);
}


let args = Opts::parse_args_default_or_exit();
let root = args.source.unwrap();
let root = match args.source {
Some(r) => r,
None => {
error!("No source directory provided.");
std::process::exit(1);
}
};
let option = args.option.to_lowercase();
let mut direction_args = vec![];
let direction_args = if option != "up" && option != "down" && option != "pull" {
error!("Docker Compose direction has to be one of the following (up|down|pull). Argument given = {}", option);
std::process::exit(1);
} else if option == "up" {
vec![option, "-d".to_string()]
} else {
vec![option]
};

let files = walker::get_compose_filepaths(&root);

if option != "up" && option != "down" && option != "pull" {
eprintln!("Docker Compose direction has to be one of the following (up|down|pull). Argument given = {}", option);
if files.is_empty() {
error!("No docker-compose.yml files found in {:?}", root);
std::process::exit(1);
}else if option == "up" {
direction_args = vec![option, "-d".to_string()];
}else if option == "down" || option == "pull"{
direction_args = vec![option];
}

let files = walker::get_compose_filepaths(&root);

// Spinner is used for visual feedback, but does not track per-file progress
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.tick_strings(&["", "", "", "", "", "", "", "", "", ""])
.template("{spinner:.blue} Running {msg}...") // Custom styling
.unwrap(),
ProgressStyle::default_spinner()
.tick_strings(&["\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}", "\u{2807}", "\u{280f}"])
.template("{spinner:.blue} Running {msg}...")
.unwrap(),
);
spinner.enable_steady_tick(Duration::from_millis(100));

if args.concurrent {
run_docker_compose_concurrent(files, direction_args, args.verbose, real_docker_runner_concurrent);
}else{
run_docker_compose(files, direction_args, args.verbose, real_docker_runner);
// ...existing code...
let children = Arc::new(Mutex::new(Vec::new()));
let shutdown_flag = Arc::new(AtomicBool::new(false));
signal::setup_signal_handler(Arc::clone(&children), Arc::clone(&shutdown_flag));
let errors = dock_sprout::run_docker_compose_concurrent_collect(files, &direction_args, args.verbose, real_docker_runner_concurrent, Arc::clone(&children), Arc::clone(&shutdown_flag));
spinner.finish_and_clear();
if !errors.is_empty() {
error!("Some docker compose commands failed:");
for (file, err) in errors {
error!(" {}: {}", file, err);
}
std::process::exit(2);
}
} else {
let errors = dock_sprout::run_docker_compose_collect(files, &direction_args, args.verbose, real_docker_runner);
spinner.finish_and_clear();
if !errors.is_empty() {
error!("Some docker compose commands failed:");
for (file, err) in errors {
error!(" {}: {}", file, err);
}
std::process::exit(2);
}
}
}

21 changes: 21 additions & 0 deletions src/signal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Logging and signal handling utilities for DockSprout
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::process::Child;
use std::sync::Mutex;
use log::warn;

/// Sets up Ctrl+C handler to terminate all running children gracefully.
pub fn setup_signal_handler(children: Arc<Mutex<Vec<Child>>>, shutdown_flag: Arc<AtomicBool>) {
let children = Arc::clone(&children);
let shutdown_flag = Arc::clone(&shutdown_flag);
ctrlc::set_handler(move || {
warn!("Received Ctrl+C! Attempting to terminate all child processes...");
shutdown_flag.store(true, Ordering::SeqCst);
let mut children = children.lock().unwrap();
for child in children.iter_mut() {
let _ = child.kill();
}
std::process::exit(130); // 130 = 128 + SIGINT
}).expect("Error setting Ctrl+C handler");
}
Loading
Loading