From 37b92082aa3d9a1b8b5ad2fe5d6126c663a0f780 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 6 Aug 2025 22:13:47 +0200 Subject: [PATCH 1/3] feat: add Claude config --- CLAUDE.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..26dcc69 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Server Runner is a Rust CLI tool that starts multiple web servers, waits for them to be ready (HTTP 200 responses), then executes a command when all servers are running. It's designed for automated testing workflows where you need to spin up multiple services before running tests. + +## Development Commands + +### Building +```bash +cargo build # Debug build +cargo build --release # Release build +``` + +### Testing +```bash +cargo test # Run all tests +cargo test -- --nocapture # Run tests with stdout output +``` + +### Running +```bash +cargo run # Run with default config (servers.yaml) +cargo run -- -c config.yaml # Run with custom config +cargo run -- -v # Run with verbose output +cargo run -- -a 5 # Run with custom max attempts (default: 10) +``` + +### Installation +```bash +cargo install --path . # Install locally from source +``` + +## Architecture + +### Core Components + +**Main Flow** (`src/main.rs:75-142`): +1. Parse CLI arguments and load YAML configuration +2. Start all server processes concurrently +3. Poll each server URL until HTTP 200 or max attempts reached +4. Execute the final command when all servers are ready +5. Clean up all processes on completion or failure + +**Key Structures**: +- `Config` - Deserializes YAML with servers list and final command +- `Server` - Individual server configuration (name, URL, command) +- `ServerProcess` - Running process wrapper with name and Child process +- `ServerStatus` - Enum tracking Waiting/Running states +- `Attempts` - Newtype for attempt counting with custom operators + +**Process Management**: +- Uses `std::process::Command` to spawn server processes +- Implements Ctrl+C handler for graceful shutdown +- Cross-platform process creation (Windows-specific flags) +- Automatic cleanup of all child processes + +**HTTP Health Checks**: +- Uses `reqwest::blocking` for synchronous HTTP requests +- Distinguishes between connection errors (retry) and other failures (abort) +- Configurable retry attempts with 1-second intervals + +### Configuration Format + +YAML configuration with two main sections: +```yaml +servers: # List of servers to start + - name: "Server Name" + url: "http://localhost:8080" + command: "node server.js" +command: "npm test" # Command to run when all servers ready +``` + +Example configs in repository: +- `servers.yaml` - Basic example with simple-http-server +- `max_attempts.yaml` - Test config for connection failure scenarios + +### Testing Strategy + +Uses `assert_cmd` for CLI integration tests (`tests/cli.rs`): +- Success case with default config +- Missing config file error handling +- Max attempts exceeded scenarios +- Custom attempt limit configuration + +Tests require `simple-http-server` to be installed for the working config scenario. \ No newline at end of file From 6f3e4e482254a953ce0b7c891ce05b088d75c410 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 6 Aug 2025 22:14:15 +0200 Subject: [PATCH 2/3] refactor: fix minor issues and add tests --- Cargo.lock | 1 + Cargo.toml | 1 + empty_command.yaml | 5 ++ empty_servers.yaml | 2 + invalid_yaml.yaml | 6 +++ missing_fields.yaml | 5 ++ multiple_servers.yaml | 13 +++++ servers.yaml | 1 + src/main.rs | 38 ++++++++++--- tests/cli.rs | 120 ++++++++++++++++++++++++++++++++++++++++++ timeout_test.yaml | 6 +++ zero_timeout.yaml | 6 +++ 12 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 empty_command.yaml create mode 100644 empty_servers.yaml create mode 100644 invalid_yaml.yaml create mode 100644 missing_fields.yaml create mode 100644 multiple_servers.yaml create mode 100644 timeout_test.yaml create mode 100644 zero_timeout.yaml diff --git a/Cargo.lock b/Cargo.lock index f9a01dd..1314ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,7 @@ dependencies = [ "predicates", "reqwest", "serde", + "shlex", "simplelog", ] diff --git a/Cargo.toml b/Cargo.toml index 4951235..7594141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { version = "0.12.19", features = [ "native-tls-vendored", ] } serde = { version = "1", features = ["derive"] } +shlex = "1.3.0" simplelog = "0.12.2" [dev-dependencies] diff --git a/empty_command.yaml b/empty_command.yaml new file mode 100644 index 0000000..67b5d74 --- /dev/null +++ b/empty_command.yaml @@ -0,0 +1,5 @@ +servers: + - name: "Test Server" + url: "http://localhost:3000" + command: "simple-http-server -p 3000 -i -s" +command: "" \ No newline at end of file diff --git a/empty_servers.yaml b/empty_servers.yaml new file mode 100644 index 0000000..1597e7a --- /dev/null +++ b/empty_servers.yaml @@ -0,0 +1,2 @@ +servers: [] +command: "echo 'test'" \ No newline at end of file diff --git a/invalid_yaml.yaml b/invalid_yaml.yaml new file mode 100644 index 0000000..b8d6dbf --- /dev/null +++ b/invalid_yaml.yaml @@ -0,0 +1,6 @@ +servers: + - name: "Test Server" + url: "http://localhost:3000" + command: "simple-http-server -p 3000 -i -s" +command: "echo test" +[invalid yaml syntax: unclosed bracket \ No newline at end of file diff --git a/missing_fields.yaml b/missing_fields.yaml new file mode 100644 index 0000000..b8d1963 --- /dev/null +++ b/missing_fields.yaml @@ -0,0 +1,5 @@ +servers: + - name: "Test Server" + url: "http://localhost:3000" + # missing command field +command: "echo test" \ No newline at end of file diff --git a/multiple_servers.yaml b/multiple_servers.yaml new file mode 100644 index 0000000..beb77b8 --- /dev/null +++ b/multiple_servers.yaml @@ -0,0 +1,13 @@ +servers: + - name: "Server One" + url: "http://localhost:8001" + command: "echo 'fake server 1'" + timeout: 2 + - name: "Server Two" + url: "http://localhost:8002" + command: "echo 'fake server 2'" + timeout: 1 + - name: "Server Three" + url: "http://localhost:8003" + command: "echo 'fake server 3'" +command: "echo 'all done'" \ No newline at end of file diff --git a/servers.yaml b/servers.yaml index ed08f92..152b847 100644 --- a/servers.yaml +++ b/servers.yaml @@ -2,4 +2,5 @@ servers: - name: "Hello World" url: "http://localhost:3000" command: "simple-http-server -p 3000 -i -s" + timeout: 10 command: "sleep 5s" diff --git a/src/main.rs b/src/main.rs index 53501ba..ce7c425 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,12 @@ struct Server { name: String, url: String, command: String, + #[serde(default = "default_timeout")] + timeout: u64, +} + +fn default_timeout() -> u64 { + 5 } #[derive(serde::Deserialize)] @@ -95,8 +101,11 @@ fn run(args: Args) -> anyhow::Result<()> { let mut processes = server_processes_clone.lock(); match stop_servers(&mut processes) { - Ok(_) => {} - Err(e) => exit_with_error(e), + Ok(_) => info!("All servers stopped successfully"), + Err(e) => { + eprintln!("Error stopping servers: {}", e); + std::process::exit(1); + } }; std::process::exit(0); @@ -163,6 +172,14 @@ fn get_config(filename: &str) -> anyhow::Result { .try_deserialize::() .context(format!("Could not parse config file {}", filename))?; + if config.servers.is_empty() { + bail!("Configuration must include at least one server"); + } + + if config.command.trim().is_empty() { + bail!("Configuration must include a command to run"); + } + Ok(config) } @@ -194,7 +211,9 @@ fn stop_servers( for p in processes.iter_mut() { info!("Stopping server {}", p.name); - if p.process.kill().is_err() { + if let Ok(_) = p.process.kill() { + let _ = p.process.wait(); + } else { bail!("Failed to stop process {}", p.name); } } @@ -205,13 +224,14 @@ fn stop_servers( } fn run_command(command: &str) -> anyhow::Result { - let command_parts: Vec<&str> = command.split(' ').collect(); + let command_parts = shlex::split(command) + .ok_or_else(|| anyhow::anyhow!("Invalid command: {}", command))?; if command_parts.is_empty() { bail!("Empty command provided"); } - let mut cmd = Command::new(command_parts[0]); + let mut cmd = Command::new(&command_parts[0]); for part in command_parts.iter().skip(1) { cmd.arg(part); @@ -230,7 +250,7 @@ fn check_server( server_attempts: &mut HashMap, max_attempts: u8, ) -> anyhow::Result { - let Server { name, url, .. } = server; + let Server { name, url, timeout, .. } = server; let attempts = server_attempts .entry(ServerName(name.to_owned())) @@ -250,7 +270,11 @@ fn check_server( name, url, attempts ); - let result = match reqwest::blocking::get(url) { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(*timeout)) + .build()?; + + let result = match client.get(url).send() { Ok(response) => response.status(), Err(error) => { if error.is_connect() { diff --git a/tests/cli.rs b/tests/cli.rs index 779c297..2283dc2 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -51,3 +51,123 @@ fn fails_on_too_many_attempts_custom() { "Could not connect to server Hello World after 5 attempts", )); } + +#[test] +fn fails_on_empty_server_list() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("empty_servers.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Configuration must include at least one server", + )); +} + +#[test] +fn fails_on_timeout_with_custom_timeout() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("timeout_test.yaml") + .arg("-a") + .arg("2") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Timeout Test Server after 2 attempts", + )); +} + +#[test] +fn fails_on_empty_command() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("empty_command.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Configuration must include a command to run", + )); +} + +#[test] +fn fails_on_invalid_yaml() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("invalid_yaml.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not find config file invalid_yaml.yaml", + )); +} + +#[test] +fn fails_on_missing_required_fields() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("missing_fields.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not parse config file missing_fields.yaml", + )); +} + +#[test] +fn fails_on_multiple_unreachable_servers() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("multiple_servers.yaml") + .arg("-a") + .arg("2") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server", + )); +} + +#[test] +fn fails_on_zero_timeout() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("zero_timeout.yaml") + .arg("-a") + .arg("1") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Zero Timeout Server after 1 attempts", + )); +} + +#[test] +fn fails_on_one_attempt() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("timeout_test.yaml") + .arg("-a") + .arg("1") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Timeout Test Server after 1 attempts", + )); +} diff --git a/timeout_test.yaml b/timeout_test.yaml new file mode 100644 index 0000000..96f313f --- /dev/null +++ b/timeout_test.yaml @@ -0,0 +1,6 @@ +servers: + - name: "Timeout Test Server" + url: "http://localhost:9999" + command: "echo 'fake server'" + timeout: 1 +command: "echo 'done'" \ No newline at end of file diff --git a/zero_timeout.yaml b/zero_timeout.yaml new file mode 100644 index 0000000..8a15064 --- /dev/null +++ b/zero_timeout.yaml @@ -0,0 +1,6 @@ +servers: + - name: "Zero Timeout Server" + url: "http://localhost:9998" + command: "echo 'fake server'" + timeout: 0 +command: "echo 'done'" \ No newline at end of file From 2a9e13c3fe001f99b1054cd6084737b0eb3754f2 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 6 Aug 2025 22:18:43 +0200 Subject: [PATCH 3/3] refactor: move test config files to directory `tests` --- tests/cli.rs | 24 +++++++++---------- .../empty_command.yaml | 0 .../empty_servers.yaml | 0 invalid_yaml.yaml => tests/invalid_yaml.yaml | 0 max_attempts.yaml => tests/max_attempts.yaml | 0 .../missing_fields.yaml | 0 .../multiple_servers.yaml | 0 timeout_test.yaml => tests/timeout.yaml | 0 zero_timeout.yaml => tests/zero_timeout.yaml | 0 9 files changed, 12 insertions(+), 12 deletions(-) rename empty_command.yaml => tests/empty_command.yaml (100%) rename empty_servers.yaml => tests/empty_servers.yaml (100%) rename invalid_yaml.yaml => tests/invalid_yaml.yaml (100%) rename max_attempts.yaml => tests/max_attempts.yaml (100%) rename missing_fields.yaml => tests/missing_fields.yaml (100%) rename multiple_servers.yaml => tests/multiple_servers.yaml (100%) rename timeout_test.yaml => tests/timeout.yaml (100%) rename zero_timeout.yaml => tests/zero_timeout.yaml (100%) diff --git a/tests/cli.rs b/tests/cli.rs index 2283dc2..f364374 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -28,7 +28,7 @@ fn fails_on_too_many_attempts() { command .arg("-c") - .arg("max_attempts.yaml") + .arg("tests/max_attempts.yaml") .assert() .failure() .stderr(predicate::str::contains( @@ -42,7 +42,7 @@ fn fails_on_too_many_attempts_custom() { command .arg("-c") - .arg("max_attempts.yaml") + .arg("tests/max_attempts.yaml") .arg("-a") .arg("5") .assert() @@ -58,7 +58,7 @@ fn fails_on_empty_server_list() { command .arg("-c") - .arg("empty_servers.yaml") + .arg("tests/empty_servers.yaml") .assert() .failure() .stderr(predicate::str::contains( @@ -72,7 +72,7 @@ fn fails_on_timeout_with_custom_timeout() { command .arg("-c") - .arg("timeout_test.yaml") + .arg("tests/timeout.yaml") .arg("-a") .arg("2") .assert() @@ -88,7 +88,7 @@ fn fails_on_empty_command() { command .arg("-c") - .arg("empty_command.yaml") + .arg("tests/empty_command.yaml") .assert() .failure() .stderr(predicate::str::contains( @@ -102,11 +102,11 @@ fn fails_on_invalid_yaml() { command .arg("-c") - .arg("invalid_yaml.yaml") + .arg("tests/invalid_yaml.yaml") .assert() .failure() .stderr(predicate::str::contains( - "Could not find config file invalid_yaml.yaml", + "Could not find config file tests/invalid_yaml.yaml", )); } @@ -116,11 +116,11 @@ fn fails_on_missing_required_fields() { command .arg("-c") - .arg("missing_fields.yaml") + .arg("tests/missing_fields.yaml") .assert() .failure() .stderr(predicate::str::contains( - "Could not parse config file missing_fields.yaml", + "Could not parse config file tests/missing_fields.yaml", )); } @@ -130,7 +130,7 @@ fn fails_on_multiple_unreachable_servers() { command .arg("-c") - .arg("multiple_servers.yaml") + .arg("tests/multiple_servers.yaml") .arg("-a") .arg("2") .assert() @@ -146,7 +146,7 @@ fn fails_on_zero_timeout() { command .arg("-c") - .arg("zero_timeout.yaml") + .arg("tests/zero_timeout.yaml") .arg("-a") .arg("1") .assert() @@ -162,7 +162,7 @@ fn fails_on_one_attempt() { command .arg("-c") - .arg("timeout_test.yaml") + .arg("tests/timeout.yaml") .arg("-a") .arg("1") .assert() diff --git a/empty_command.yaml b/tests/empty_command.yaml similarity index 100% rename from empty_command.yaml rename to tests/empty_command.yaml diff --git a/empty_servers.yaml b/tests/empty_servers.yaml similarity index 100% rename from empty_servers.yaml rename to tests/empty_servers.yaml diff --git a/invalid_yaml.yaml b/tests/invalid_yaml.yaml similarity index 100% rename from invalid_yaml.yaml rename to tests/invalid_yaml.yaml diff --git a/max_attempts.yaml b/tests/max_attempts.yaml similarity index 100% rename from max_attempts.yaml rename to tests/max_attempts.yaml diff --git a/missing_fields.yaml b/tests/missing_fields.yaml similarity index 100% rename from missing_fields.yaml rename to tests/missing_fields.yaml diff --git a/multiple_servers.yaml b/tests/multiple_servers.yaml similarity index 100% rename from multiple_servers.yaml rename to tests/multiple_servers.yaml diff --git a/timeout_test.yaml b/tests/timeout.yaml similarity index 100% rename from timeout_test.yaml rename to tests/timeout.yaml diff --git a/zero_timeout.yaml b/tests/zero_timeout.yaml similarity index 100% rename from zero_timeout.yaml rename to tests/zero_timeout.yaml