diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 83aecc1..30e2470 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -49,3 +49,92 @@ jobs: - name: Run tests run: | cargo test + + integration: + name: Integration (Docker + runc) + if: github.event.pull_request.draft == false + runs-on: ubuntu-24.04 # current challenge server target + needs: ci + steps: + - name: Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # v1.94.1 + with: + toolchain: stable + - name: Set up Rust caching + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - name: Record Docker and runc versions + run: | + docker version + docker info --format '{{json .Runtimes}}' || true + runc --version || true + - name: Build oci-interceptor + run: cargo build --release + - name: Install binary + run: sudo install -m 0755 target/release/oci-interceptor /usr/local/bin/oci-interceptor + - name: Create debug output directory + run: | + sudo mkdir -p /tmp/oi-interceptor-debug + sudo chmod 0777 /tmp/oi-interceptor-debug + - name: Configure Docker daemon + run: | + sudo mkdir -p /etc/docker + sudo tee /etc/docker/daemon.json > /dev/null <<'EOF' + { + "runtimes": { + "oi-default": { + "path": "/usr/local/bin/oci-interceptor" + }, + "oi-ro-net": { + "path": "/usr/local/bin/oci-interceptor", + "runtimeArgs": ["--oi-readonly-networking-mounts"] + }, + "oi-env-foo": { + "path": "/usr/local/bin/oci-interceptor", + "runtimeArgs": ["--oi-env", "FOO=bar"] + }, + "oi-env-force-foo": { + "path": "/usr/local/bin/oci-interceptor", + "runtimeArgs": ["--oi-env-force", "FOO=forced"] + }, + "oi-debug": { + "path": "/usr/local/bin/oci-interceptor", + "runtimeArgs": [ + "--oi-write-debug-output", + "--oi-debug-output-dir", "/tmp/oi-interceptor-debug" + ] + }, + "oi-debug-ro-net": { + "path": "/usr/local/bin/oci-interceptor", + "runtimeArgs": [ + "--oi-readonly-networking-mounts", + "--oi-write-debug-output", + "--oi-debug-output-dir", "/tmp/oi-interceptor-debug" + ] + } + } + } + EOF + sudo systemctl restart docker + # Wait for the daemon to be responsive again before kicking off tests. + for i in $(seq 1 30); do + if docker info > /dev/null 2>&1; then + echo "Docker daemon is ready"; break + fi + sleep 1 + done + docker info --format '{{json .Runtimes}}' + - name: Pre-pull test image + run: docker pull alpine:3.20 + - name: Run integration tests + env: + OCI_INTERCEPTOR_INTEGRATION: "1" + run: cargo test --test integration -- --test-threads=1 --nocapture + - name: Upload debug output on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: oci-interceptor-debug-output + path: /tmp/oi-interceptor-debug/ + if-no-files-found: ignore diff --git a/README.md b/README.md index 2824e68..880ea47 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,19 @@ Alternatively, use `--oi-env-force ` to force an certain value even - Solution for https://stackoverflow.com/questions/33775075/how-to-set-default-docker-environment-variables - Solution for https://stackoverflow.com/questions/50644143/dockerd-set-default-environment-variable-for-all-containers +## Testing + +Unit tests run with `cargo test`. End-to-end integration tests live in `tests/integration.rs` and exercise the wrapper through a real Docker daemon. They are gated by the `OCI_INTERCEPTOR_INTEGRATION` environment variable so the default `cargo test` invocation stays portable. + +To run the integration tests locally on a Linux host with Docker: + +1. Build and install the binary: `cargo build --release && sudo install -m 0755 target/release/oci-interceptor /usr/local/bin/oci-interceptor` +2. Configure `/etc/docker/daemon.json` with the named runtimes listed in the module docs of [tests/integration.rs](tests/integration.rs). The CI workflow (`.github/workflows/CI.yml`, `integration` job) is the canonical reference for the required daemon.json. +3. `sudo systemctl restart docker` +4. `OCI_INTERCEPTOR_INTEGRATION=1 cargo test --test integration -- --test-threads=1` + +CI runs the same suite on every push and pull request, which provides a regression check against the Docker and runc versions shipped on `ubuntu-latest` runners. + ### Debug output Specify the `--oi-write-debug-output` flag to write original, parsed, and modified container configs to the directory specified as `--oi-debug-output-dir` (default `/var/log/oci-interceptor`). diff --git a/src/env_vars.rs b/src/env_vars.rs index 38d7c8f..41453b0 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -68,3 +68,42 @@ pub(crate) fn modify_env_vars(spec: &mut Spec, vars: Vec) { spec.set_process(Some(new_process)); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simple_key_value() { + let v = parse_env_var("FOO=bar").unwrap(); + assert_eq!(v.name, "FOO"); + assert_eq!(v.value, "bar"); + } + + #[test] + fn value_may_contain_equals_signs() { + let v = parse_env_var("FOO=a=b=c").unwrap(); + assert_eq!(v.name, "FOO"); + assert_eq!(v.value, "a=b=c"); + } + + #[test] + fn missing_equals_is_rejected() { + assert!(parse_env_var("FOO").is_err()); + } + + #[test] + fn empty_name_is_rejected() { + assert!(parse_env_var("=bar").is_err()); + } + + #[test] + fn empty_value_is_rejected() { + assert!(parse_env_var("FOO=").is_err()); + } + + #[test] + fn empty_string_is_rejected() { + assert!(parse_env_var("").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index d4a1886..819bbc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,6 +144,7 @@ fn main() -> Result<()> { } if !env_var_overrides.is_empty() { modify_env_vars(&mut spec, env_var_overrides); + spec_modified = true; } // Write the updated config back out to disk @@ -207,3 +208,54 @@ fn call_oci_runtime(runtime_path: &str, options: Vec) -> Result { None => Ok(-1), // child process was killed by a signal } } + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn finds_bundle_short_flag_space_separated() { + let mut o = opts(&["create", "-b", "/tmp/bundle", "cid"]); + assert_eq!(get_bundle_path(&mut o), Some(PathBuf::from("/tmp/bundle"))); + } + + #[test] + fn finds_bundle_short_flag_with_equals() { + let mut o = opts(&["create", "-b=/tmp/bundle", "cid"]); + assert_eq!(get_bundle_path(&mut o), Some(PathBuf::from("/tmp/bundle"))); + } + + #[test] + fn finds_bundle_long_flag_space_separated() { + let mut o = opts(&["create", "--bundle", "/tmp/bundle", "cid"]); + assert_eq!(get_bundle_path(&mut o), Some(PathBuf::from("/tmp/bundle"))); + } + + #[test] + fn finds_bundle_long_flag_with_equals() { + let mut o = opts(&["create", "--bundle=/tmp/bundle", "cid"]); + assert_eq!(get_bundle_path(&mut o), Some(PathBuf::from("/tmp/bundle"))); + } + + #[test] + fn returns_none_when_no_bundle_flag_present() { + let mut o = opts(&["start", "cid"]); + assert_eq!(get_bundle_path(&mut o), None); + } + + #[test] + fn returns_none_for_empty_options() { + let mut o: Vec = Vec::new(); + assert_eq!(get_bundle_path(&mut o), None); + } + + #[test] + fn returns_none_when_short_flag_has_no_following_arg() { + let mut o = opts(&["create", "-b"]); + assert_eq!(get_bundle_path(&mut o), None); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..79fb1a9 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,44 @@ +//! CLI smoke tests for the oci-interceptor binary. +//! +//! These exercise clap-handled flags that short-circuit before any runtime call, +//! so they do not require Docker or runc and always run as part of `cargo test`. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_oci-interceptor"); + +#[test] +fn version_flag_prints_version() { + let out = Command::new(BIN) + .arg("--oi-version") + .output() + .expect("failed to invoke oci-interceptor"); + assert!( + out.status.success(), + "--oi-version exited non-zero: {:?}", + out.status + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains(env!("CARGO_PKG_VERSION")), + "version output missing crate version: {stdout}" + ); +} + +#[test] +fn help_flag_prints_usage() { + let out = Command::new(BIN) + .arg("--oi-help") + .output() + .expect("failed to invoke oci-interceptor"); + assert!( + out.status.success(), + "--oi-help exited non-zero: {:?}", + out.status + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("--oi-runtime-path"), + "help output missing expected flag, got: {stdout}" + ); +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..c0a9c93 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,238 @@ +//! End-to-end integration tests that exercise oci-interceptor through the Docker daemon. +//! +//! These tests are gated by the `OCI_INTERCEPTOR_INTEGRATION` env var so `cargo test` stays +//! portable on developer machines without Docker. They require the Docker daemon to be +//! configured with a specific set of named runtimes; see the `integration` job in +//! `.github/workflows/CI.yml` for the daemon.json contents. +//! +//! Required runtime names: +//! - `oi-default` — interceptor with no flags +//! - `oi-ro-net` — `--oi-readonly-networking-mounts` +//! - `oi-env-foo` — `--oi-env FOO=bar` +//! - `oi-env-force-foo` — `--oi-env-force FOO=forced` +//! - `oi-debug` — `--oi-write-debug-output --oi-debug-output-dir ` +//! - `oi-debug-ro-net` — same as `oi-debug` plus `--oi-readonly-networking-mounts` + +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TEST_IMAGE: &str = "alpine:3.20"; +const DEBUG_DIR: &str = "/tmp/oi-interceptor-debug"; + +fn check_enabled(test_name: &str) -> bool { + if std::env::var("OCI_INTERCEPTOR_INTEGRATION").is_ok() { + return true; + } + eprintln!("skipping {test_name}: set OCI_INTERCEPTOR_INTEGRATION=1 to run"); + false +} + +fn unique_hostname(prefix: &str) -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{prefix}-{nanos}-{n}") +} + +fn docker_run(runtime: &str, extra_args: &[&str], cmd: &[&str]) -> Output { + let mut args: Vec<&str> = vec!["run", "--rm", "--runtime", runtime]; + args.extend(extra_args); + args.push(TEST_IMAGE); + args.extend(cmd); + let output = Command::new("docker") + .args(&args) + .output() + .expect("failed to invoke `docker`; is the daemon running?"); + if !output.status.success() { + eprintln!( + "docker run failed (status {:?})\nargs: {:?}\nstdout: {}\nstderr: {}", + output.status.code(), + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + output +} + +fn mount_options(runtime: &str, target: &str) -> String { + let script = format!(r#"awk '$2 == "{target}" {{ print $4; exit }}' /proc/mounts"#); + let out = docker_run(runtime, &[], &["sh", "-c", &script]); + assert!( + out.status.success(), + "failed to read /proc/mounts for {target}" + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +#[test] +fn passthrough_runs_container() { + if !check_enabled("passthrough_runs_container") { + return; + } + let out = docker_run("oi-default", &[], &["echo", "hello"]); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello"); +} + +#[test] +fn passthrough_propagates_nonzero_exit_code() { + if !check_enabled("passthrough_propagates_nonzero_exit_code") { + return; + } + let out = docker_run("oi-default", &[], &["sh", "-c", "exit 42"]); + assert_eq!(out.status.code(), Some(42)); +} + +#[test] +fn networking_mounts_writable_without_flag() { + if !check_enabled("networking_mounts_writable_without_flag") { + return; + } + for target in ["/etc/hosts", "/etc/hostname", "/etc/resolv.conf"] { + let opts = mount_options("oi-default", target); + let opts: Vec<&str> = opts.split(',').collect(); + assert!( + opts.contains(&"rw"), + "{target} expected to be rw by default, got: {opts:?}" + ); + } +} + +#[test] +fn networking_mounts_readonly_with_flag() { + if !check_enabled("networking_mounts_readonly_with_flag") { + return; + } + for target in ["/etc/hosts", "/etc/hostname", "/etc/resolv.conf"] { + let opts = mount_options("oi-ro-net", target); + let opts: Vec<&str> = opts.split(',').collect(); + assert!( + opts.contains(&"ro"), + "{target} expected to be ro with --oi-readonly-networking-mounts, got: {opts:?}" + ); + } +} + +#[test] +fn networking_mounts_writes_blocked_with_flag() { + if !check_enabled("networking_mounts_writes_blocked_with_flag") { + return; + } + for target in ["/etc/hosts", "/etc/hostname", "/etc/resolv.conf"] { + let script = format!("exec 2>&1; echo x > {target}; echo exit=$?"); + let out = docker_run("oi-ro-net", &[], &["sh", "-c", &script]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.to_lowercase().contains("read-only"), + "writing {target} should have produced a read-only error, got:\n{stdout}" + ); + assert!( + !stdout.contains("exit=0"), + "writing {target} should have failed, got:\n{stdout}" + ); + } +} + +#[test] +fn oi_env_sets_default_when_unset() { + if !check_enabled("oi_env_sets_default_when_unset") { + return; + } + let out = docker_run("oi-env-foo", &[], &["sh", "-c", "echo FOO=$FOO"]); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "FOO=bar"); +} + +#[test] +fn oi_env_does_not_override_user_value() { + if !check_enabled("oi_env_does_not_override_user_value") { + return; + } + let out = docker_run( + "oi-env-foo", + &["-e", "FOO=user"], + &["sh", "-c", "echo FOO=$FOO"], + ); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "FOO=user"); +} + +#[test] +fn oi_env_force_overrides_user_value() { + if !check_enabled("oi_env_force_overrides_user_value") { + return; + } + let out = docker_run( + "oi-env-force-foo", + &["-e", "FOO=user"], + &["sh", "-c", "echo FOO=$FOO"], + ); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "FOO=forced"); +} + +#[test] +fn oi_env_force_sets_when_unset() { + if !check_enabled("oi_env_force_sets_when_unset") { + return; + } + let out = docker_run("oi-env-force-foo", &[], &["sh", "-c", "echo FOO=$FOO"]); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "FOO=forced"); +} + +#[test] +fn debug_output_files_written() { + if !check_enabled("debug_output_files_written") { + return; + } + let hostname = unique_hostname("oi-debug-test"); + let out = docker_run("oi-debug", &["--hostname", &hostname], &["true"]); + assert!(out.status.success()); + + let debug_dir = PathBuf::from(DEBUG_DIR); + let original = debug_dir.join(format!("{hostname}_original.json")); + let parsed = debug_dir.join(format!("{hostname}_parsed.json")); + let calls_log = debug_dir.join("runtime_calls.log"); + + assert!( + original.exists(), + "expected {} to exist", + original.display() + ); + assert!(parsed.exists(), "expected {} to exist", parsed.display()); + assert!( + calls_log.exists(), + "expected {} to exist", + calls_log.display() + ); + + let parsed_contents = std::fs::read_to_string(&parsed).expect("debug parsed file unreadable"); + assert!( + parsed_contents.contains(&hostname), + "parsed config didn't contain hostname {hostname}" + ); +} + +#[test] +fn debug_output_modified_file_written_when_spec_changes() { + if !check_enabled("debug_output_modified_file_written_when_spec_changes") { + return; + } + let hostname = unique_hostname("oi-debug-mod-test"); + let out = docker_run("oi-debug-ro-net", &["--hostname", &hostname], &["true"]); + assert!(out.status.success()); + + let modified = PathBuf::from(DEBUG_DIR).join(format!("{hostname}_modified.json")); + assert!( + modified.exists(), + "expected {} to exist", + modified.display() + ); +}