diff --git a/README.md b/README.md index 8dc27db..8d99bc1 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,20 @@ initContainers: value: postgres ``` +### How do I run initium as a sidecar container? + +Use the `--sidecar` global flag to keep the process alive after tasks complete: + +```yaml +containers: + - name: initium-sidecar + image: ghcr.io/kitstream/initium:latest + restartPolicy: Always + args: ["--sidecar", "wait-for", "--target", "tcp://postgres:5432"] +``` + +The process sleeps indefinitely after success. On failure it exits with code `1` immediately. + ### How do I get JSON logs? Add the `--json` global flag: diff --git a/docs/usage.md b/docs/usage.md index c21ddec..8b98028 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -364,12 +364,39 @@ initContainers: ## Global Flags -| Flag | Default | Env Var | Description | -| -------- | ------- | -------------- | -------------------------------- | -| `--json` | `false` | `INITIUM_JSON` | Enable JSON-formatted log output | +| Flag | Default | Env Var | Description | +| ----------- | ------- | ------------------ | ------------------------------------------------------------ | +| `--json` | `false` | `INITIUM_JSON` | Enable JSON-formatted log output | +| `--sidecar` | `false` | `INITIUM_SIDECAR` | Keep process alive after task completion (sidecar containers) | All flags can be set via environment variables. Flag values take precedence over environment variables. Boolean env vars accept `true`/`false`, `1`/`0`, `yes`/`no`. The `INITIUM_TARGET` env var accepts comma-separated values for multiple targets. +### Sidecar mode + +When running initium as a Kubernetes sidecar container (rather than an init container), use `--sidecar` to keep the process alive after tasks complete. Without this flag, the process exits on success, which causes Kubernetes to restart the sidecar container in a loop. + +```bash +# Via flag +initium --sidecar wait-for --target tcp://postgres:5432 + +# Via environment variable +INITIUM_SIDECAR=true initium seed --spec /seeds/seed.yaml +``` + +**Behavior:** + +- On **success**: logs completion, then sleeps indefinitely +- On **failure**: exits with code `1` immediately (does not sleep) + +```yaml +# Kubernetes sidecar example (requires K8s 1.29+) +containers: + - name: initium-sidecar + image: ghcr.io/kitstream/initium:latest + restartPolicy: Always + args: ["--sidecar", "wait-for", "--target", "tcp://postgres:5432"] +``` + **Duration format:** All time parameters (`--timeout`, `--initial-delay`, `--max-delay`) accept values with optional time unit suffixes: `ms` (milliseconds), `s` (seconds), `m` (minutes), `h` (hours). Decimal values are supported (e.g. `1.5m`, `2.7s`). Multiple units can be combined (e.g. `1m30s`, `2s700ms`, `18h36m4s200ms`). Bare numbers without a unit are treated as seconds. Examples: `30s`, `5m`, `1h`, `500ms`, `1m30s`, `120` (= 120 seconds). ## Exit Codes diff --git a/src/duration.rs b/src/duration.rs index 8f670fe..518766d 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -197,7 +197,7 @@ mod tests { ); assert_eq!( parse_duration("18h36m4s200ms").unwrap(), - Duration::from_millis(18 * 3600_000 + 36 * 60_000 + 4_000 + 200) + Duration::from_millis(18 * 3_600_000 + 36 * 60_000 + 4_000 + 200) ); assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400)); assert_eq!( diff --git a/src/main.rs b/src/main.rs index b849b79..129cdee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,14 @@ struct Cli { )] json: bool, + #[arg( + long, + global = true, + env = "INITIUM_SIDECAR", + help = "Keep process alive after task completion (for sidecar containers)" + )] + sidecar: bool, + #[command(subcommand)] command: Commands, } @@ -360,4 +368,14 @@ fn main() { log.error(&e, &[]); std::process::exit(1); } + + if cli.sidecar { + log.info( + "tasks completed, entering sidecar mode (sleeping indefinitely)", + &[], + ); + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } + } } diff --git a/src/safety.rs b/src/safety.rs index 110d5e3..6ee368b 100644 --- a/src/safety.rs +++ b/src/safety.rs @@ -55,7 +55,7 @@ mod tests { #[test] fn test_traversal_rejected() { let dir = TempDir::new().unwrap(); - let traversal = ["..", "..", "..", "tmp", "x"].join(&std::path::MAIN_SEPARATOR.to_string()); + let traversal = ["..", "..", "..", "tmp", "x"].join(std::path::MAIN_SEPARATOR_STR); let result = validate_file_path(dir.path().to_str().unwrap(), &traversal); assert!(result.is_err()); } diff --git a/tests/env_var_flags.rs b/tests/env_var_flags.rs index e37910b..7e9a51f 100644 --- a/tests/env_var_flags.rs +++ b/tests/env_var_flags.rs @@ -1,4 +1,5 @@ use std::process::Command; +use std::time::{Duration, Instant}; fn initium_bin() -> String { env!("CARGO_BIN_EXE_initium").to_string() @@ -239,3 +240,122 @@ fn test_env_var_false_boolean_not_set() { stderr ); } + +#[test] +fn test_sidecar_flag_on_failure_exits_immediately() { + // --sidecar should NOT keep the process alive when the subcommand fails + let start = Instant::now(); + let output = Command::new(initium_bin()) + .args([ + "--sidecar", + "wait-for", + "--target", + "tcp://localhost:1", + "--timeout", + "1s", + "--max-attempts", + "1", + ]) + .output() + .unwrap(); + let elapsed = start.elapsed(); + assert!( + !output.status.success(), + "expected failure exit code with --sidecar on error" + ); + // Should exit quickly (well under 10s), not sleep + assert!( + elapsed < Duration::from_secs(10), + "sidecar should not sleep on failure, took {:?}", + elapsed + ); +} + +#[test] +fn test_sidecar_flag_on_success_sleeps() { + // --sidecar on a successful command should keep the process alive. + // We use `exec -- true` which succeeds immediately, then verify the + // process is still running after a short delay. + let mut child = Command::new(initium_bin()) + .args(["--sidecar", "exec", "--", "true"]) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + // Wait briefly to give the process time to complete the subcommand + std::thread::sleep(Duration::from_secs(2)); + + // The process should still be running (sidecar sleep) + let status = child.try_wait().unwrap(); + assert!( + status.is_none(), + "expected sidecar process to still be running, but it exited: {:?}", + status + ); + + // Clean up: kill the process + child.kill().unwrap(); + child.wait().unwrap(); +} + +#[test] +fn test_sidecar_env_var() { + // INITIUM_SIDECAR=true should enable sidecar mode via env var + let mut child = Command::new(initium_bin()) + .args(["exec", "--", "true"]) + .env("INITIUM_SIDECAR", "true") + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + std::thread::sleep(Duration::from_secs(2)); + + let status = child.try_wait().unwrap(); + assert!( + status.is_none(), + "expected sidecar process to still be running via env var, but it exited: {:?}", + status + ); + + child.kill().unwrap(); + child.wait().unwrap(); +} + +#[test] +fn test_sidecar_logs_message_on_success() { + // --sidecar should log "sidecar mode" message on success + let mut child = Command::new(initium_bin()) + .args(["--sidecar", "exec", "--", "true"]) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + std::thread::sleep(Duration::from_secs(2)); + + // Kill and collect stderr + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("sidecar mode"), + "expected sidecar mode log message, got: {}", + stderr + ); +} + +#[test] +fn test_without_sidecar_exits_on_success() { + // Without --sidecar, a successful command should exit immediately + let start = Instant::now(); + let output = Command::new(initium_bin()) + .args(["exec", "--", "true"]) + .output() + .unwrap(); + let elapsed = start.elapsed(); + assert!(output.status.success(), "exec true should succeed"); + assert!( + elapsed < Duration::from_secs(5), + "without --sidecar, process should exit immediately, took {:?}", + elapsed + ); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 05204ba..dc5334d 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -15,7 +15,7 @@ fn initium_bin() -> String { } fn integration_enabled() -> bool { - std::env::var("INTEGRATION").map_or(false, |v| v == "1") + std::env::var("INTEGRATION").is_ok_and(|v| v == "1") } fn input_dir() -> String {