diff --git a/Cargo.lock b/Cargo.lock index b59505b..ae68e6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -100,6 +121,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -192,6 +224,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -235,6 +273,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fc-api" version = "0.2.2" @@ -254,8 +298,12 @@ dependencies = [ name = "fc-cli" version = "0.2.2" dependencies = [ + "assert_cmd", "clap", "firecracker", + "libc", + "predicates", + "tempfile", "tokio", ] @@ -281,6 +329,21 @@ dependencies = [ "sha2", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -395,6 +458,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -403,7 +488,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -577,6 +662,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -605,7 +696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -648,12 +739,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -692,6 +795,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-traits" version = "0.2.19" @@ -774,6 +883,36 @@ dependencies = [ "zerovec", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -868,6 +1007,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -912,7 +1057,7 @@ version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "memchr", ] @@ -951,6 +1096,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1182,6 +1340,25 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -1382,6 +1559,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1424,6 +1607,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" @@ -1439,6 +1631,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1498,6 +1708,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -1511,6 +1743,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -1610,6 +1854,94 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/fc-cli/Cargo.toml b/fc-cli/Cargo.toml index e4c4d65..c38a67f 100644 --- a/fc-cli/Cargo.toml +++ b/fc-cli/Cargo.toml @@ -11,3 +11,9 @@ repository.workspace = true clap.workspace = true firecracker = { workspace = true, features = ["bundled-runtime"] } tokio.workspace = true + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" +libc = "0.2" diff --git a/fc-cli/TESTING.md b/fc-cli/TESTING.md new file mode 100644 index 0000000..483a51a --- /dev/null +++ b/fc-cli/TESTING.md @@ -0,0 +1,302 @@ +# fc-cli Testing Guide + +This document describes the testing strategy and how to run tests for `fc-cli`. + +## Test Structure + +``` +fc-cli/ +├── src/ +│ └── main.rs # Unit tests (bottom of file) +└── tests/ + ├── cli_integration.rs # Integration tests + └── e2e_linux.rs # End-to-end tests (Linux only) +``` + +## Test Categories + +| Category | Location | Count | Requires | +|----------|----------|-------|----------| +| Unit Tests | `src/main.rs` | 15 | Nothing | +| Integration Tests | `tests/cli_integration.rs` | 20 | Nothing | +| E2E Tests | `tests/e2e_linux.rs` | 9 | Linux + Firecracker | + +## Running Tests + +### Run All Tests (Recommended) + +```bash +cargo test -p fc-cli +``` + +This runs unit tests and integration tests, skipping E2E tests. + +### Run Specific Test Categories + +```bash +# Unit tests only +cargo test -p fc-cli --lib + +# Integration tests only +cargo test -p fc-cli --test cli_integration + +# E2E tests only (requires Linux + Firecracker) +cargo test -p fc-cli --test e2e_linux -- --ignored +``` + +### Run a Single Test + +```bash +cargo test -p fc-cli test_parse_start_minimal_args +``` + +### Run Tests with Output + +```bash +cargo test -p fc-cli -- --nocapture +``` + +## Unit Tests + +Unit tests verify internal functions and argument parsing without external dependencies. + +### Argument Parsing Tests + +| Test | Description | +|------|-------------| +| `test_parse_start_minimal_args` | Verify minimal required arguments are parsed correctly | +| `test_parse_start_with_all_vm_options` | Verify all VM configuration options | +| `test_parse_jailer_backend_options` | Verify jailer backend specific options | +| `test_parse_resolve_command` | Verify resolve command parsing | +| `test_parse_platform_command` | Verify platform command parsing | + +### Input Validation Tests + +| Test | Description | +|------|-------------| +| `test_missing_required_kernel_arg` | Missing --kernel should fail | +| `test_missing_required_rootfs_arg` | Missing --rootfs should fail | +| `test_invalid_backend_value` | Invalid backend value should fail | + +### Helper Function Tests + +| Test | Description | +|------|-------------| +| `test_chroot_root_from_socket_valid` | Parse chroot root from valid socket path | +| `test_chroot_root_from_socket_minimal_path` | Parse chroot root from minimal path | +| `test_chroot_root_from_socket_too_short` | Too short path should fail | +| `test_path_to_string` | Path to string conversion | +| `test_backend_as_str` | Backend enum to string conversion | +| `test_to_bundled_mode` | ResolveMode to BundledMode conversion | + +### Default Values Tests + +| Test | Description | +|------|-------------| +| `test_default_values` | Verify all default values are set correctly | + +## Integration Tests + +Integration tests run the actual compiled binary and verify its behavior. + +### Platform Command Tests + +| Test | Description | +|------|-------------| +| `test_platform_command_output` | Output contains os=, arch=, bundled_release_supported= | +| `test_platform_command_format` | Output is in key=value format | + +### Start Command Validation Tests + +| Test | Description | +|------|-------------| +| `test_start_missing_kernel_error` | Missing --kernel shows helpful error | +| `test_start_missing_rootfs_error` | Missing --rootfs shows helpful error | +| `test_start_jailer_missing_uid_error` | Jailer without --uid fails | +| `test_start_jailer_missing_gid_error` | Jailer without --gid fails | +| `test_start_vcpu_count_zero_error` | --vcpu-count=0 fails | +| `test_start_mem_size_zero_error` | --mem-size-mib=0 fails | +| `test_start_jailer_daemonize_without_detach_error` | --daemonize requires --detach | +| `test_start_socket_path_with_jailer_error` | --socket-path not allowed with jailer | + +### Resolve Command Tests + +| Test | Description | +|------|-------------| +| `test_resolve_system_only_not_found` | system-only fails when binary not in PATH | +| `test_resolve_bundled_only_without_bundle_root` | bundled-only without bundle root fails | + +### Help and Version Tests + +| Test | Description | +|------|-------------| +| `test_help_output` | --help shows usage information | +| `test_version_output` | --version shows version | +| `test_start_help` | start --help shows all options | +| `test_resolve_help` | resolve --help shows options | + +### Invalid Input Tests + +| Test | Description | +|------|-------------| +| `test_invalid_subcommand` | Invalid subcommand fails | +| `test_invalid_backend` | Invalid backend value fails | +| `test_invalid_resolve_target` | Invalid resolve target fails | +| `test_negative_mem_size` | Negative mem-size-mib fails | + +## E2E Tests + +E2E tests require Linux and actual Firecracker binaries. They are marked with `#[ignore]` and skipped by default. + +### Prerequisites + +1. Linux operating system (x86_64 or aarch64) +2. Firecracker binary available (auto-installed if missing) +3. Valid kernel image (vmlinux) +4. Valid rootfs image (rootfs.ext4) + +### Automatic Firecracker Installation + +E2E tests will **automatically install Firecracker** using [arcbox.sh](https://arcbox.sh) if not found: + +```bash +# This happens automatically when running E2E tests +curl -fsSL https://arcbox.sh/firecracker.sh | bash +``` + +To disable automatic installation: + +```bash +export SKIP_FIRECRACKER_INSTALL=1 +``` + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `TEST_KERNEL_PATH` | Path to vmlinux kernel image | +| `TEST_ROOTFS_PATH` | Path to rootfs.ext4 image | +| `SKIP_FIRECRACKER_INSTALL` | Set to `1` to disable auto-install | + +### Running E2E Tests + +```bash +# Firecracker will be auto-installed if not found +export TEST_KERNEL_PATH=/path/to/vmlinux +export TEST_ROOTFS_PATH=/path/to/rootfs.ext4 +cargo test -p fc-cli --test e2e_linux -- --ignored +``` + +### Getting Test Assets + +You can download test kernel and rootfs from Firecracker's quickstart: + +```bash +# Download kernel +ARCH=$(uname -m) +curl -fsSL -o /tmp/vmlinux \ + https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${ARCH}/kernels/vmlinux.bin + +# Download rootfs +curl -fsSL -o /tmp/rootfs.ext4 \ + https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/${ARCH}/rootfs/bionic.rootfs.ext4 + +# Run tests +export TEST_KERNEL_PATH=/tmp/vmlinux +export TEST_ROOTFS_PATH=/tmp/rootfs.ext4 +cargo test -p fc-cli --test e2e_linux -- --ignored +``` + +### E2E Test Cases + +| Test | Description | +|------|-------------| +| `test_start_vm_detached_firecracker` | Start VM in detached mode with firecracker backend | +| `test_start_vm_with_boot_args` | Start VM with custom boot arguments | +| `test_start_vm_readonly_rootfs` | Start VM with read-only rootfs | +| `test_start_nonexistent_kernel` | Non-existent kernel path fails gracefully | +| `test_start_nonexistent_rootfs` | Non-existent rootfs path fails gracefully | +| `test_jailer_requires_root` | Jailer backend requires root privileges | +| `test_resolve_bundled_firecracker` | Resolve bundled firecracker binary | +| `test_resolve_bundled_jailer` | Resolve bundled jailer binary | +| `test_resolve_all_binaries` | Resolve all binaries at once | + +## CI Integration + +Tests are automatically run in CI via GitHub Actions: + +```yaml +# .github/workflows/ci.yml +- name: Test + run: cargo test --workspace --all-features +``` + +### CI Test Matrix + +| Stage | Tests Run | Environment | +|-------|-----------|-------------| +| Every push | Unit + Integration | ubuntu-latest | +| Every PR | Unit + Integration | ubuntu-latest | +| Release | Unit + Integration + E2E | ubuntu-latest with Firecracker | + +## Adding New Tests + +### Adding a Unit Test + +Add to `src/main.rs` inside the `#[cfg(test)] mod tests` block: + +```rust +#[test] +fn test_your_new_test() { + // Your test code here + assert!(true); +} +``` + +### Adding an Integration Test + +Add to `tests/cli_integration.rs`: + +```rust +#[test] +fn test_your_integration_test() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["your", "args"]) + .assert() + .success(); +} +``` + +### Adding an E2E Test + +Add to `tests/e2e_linux.rs`: + +```rust +#[test] +#[ignore] // Important: mark as ignored +fn test_your_e2e_test() { + require_linux!(); + + // Check for required resources + let kernel = match get_kernel_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_KERNEL_PATH not set"); + return; + } + }; + + // Your test code here +} +``` + +## Test Dependencies + +```toml +[dev-dependencies] +assert_cmd = "2" # CLI testing utilities +predicates = "3" # Assertion matchers +tempfile = "3" # Temporary file/directory creation +libc = "0.2" # Low-level system calls (E2E tests) +``` diff --git a/fc-cli/src/main.rs b/fc-cli/src/main.rs index f931c07..6a3b31a 100644 --- a/fc-cli/src/main.rs +++ b/fc-cli/src/main.rs @@ -589,3 +589,272 @@ fn to_bundled_mode(mode: ResolveMode) -> BundledMode { ResolveMode::SystemThenBundled => BundledMode::SystemThenBundled, } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + // ==================== Argument Parsing Tests ==================== + + /// Verify minimal required arguments are parsed correctly + #[test] + fn test_parse_start_minimal_args() { + let cli = Cli::try_parse_from([ + "fc-cli", + "start", + "--kernel", + "/path/to/vmlinux", + "--rootfs", + "/path/to/rootfs.ext4", + ]) + .unwrap(); + + match cli.command { + Commands::Start(args) => { + assert_eq!(args.kernel, PathBuf::from("/path/to/vmlinux")); + assert_eq!(args.rootfs, PathBuf::from("/path/to/rootfs.ext4")); + // Verify default values + assert_eq!(args.vcpu_count, 1); + assert_eq!(args.mem_size_mib, 256); + assert!(!args.detach); + assert!(!args.rootfs_read_only); + } + _ => panic!("expected Start command"), + } + } + + /// Verify all VM configuration options are parsed correctly + #[test] + fn test_parse_start_with_all_vm_options() { + let cli = Cli::try_parse_from([ + "fc-cli", + "start", + "--kernel", + "/vmlinux", + "--rootfs", + "/rootfs.ext4", + "--vcpu-count", + "4", + "--mem-size-mib", + "1024", + "--smt", + "--track-dirty-pages", + "--boot-args", + "console=ttyS0", + "--rootfs-read-only", + ]) + .unwrap(); + + match cli.command { + Commands::Start(args) => { + assert_eq!(args.vcpu_count, 4); + assert_eq!(args.mem_size_mib, 1024); + assert!(args.smt); + assert!(args.track_dirty_pages); + assert_eq!(args.boot_args, Some("console=ttyS0".to_string())); + assert!(args.rootfs_read_only); + } + _ => panic!("expected Start command"), + } + } + + /// Verify jailer backend options are parsed correctly + #[test] + fn test_parse_jailer_backend_options() { + let cli = Cli::try_parse_from([ + "fc-cli", + "start", + "--backend", + "jailer", + "--uid", + "1000", + "--gid", + "1000", + "--kernel", + "/vmlinux", + "--rootfs", + "/rootfs.ext4", + "--id", + "my-vm", + "--netns", + "/var/run/netns/my-ns", + "--daemonize", + "--detach", + "--new-pid-ns", + "--cgroup", + "cpu.shares=512", + "--cgroup", + "memory.limit_in_bytes=536870912", + ]) + .unwrap(); + + match cli.command { + Commands::Start(args) => { + assert!(matches!(args.backend, StartBackend::Jailer)); + assert_eq!(args.uid, Some(1000)); + assert_eq!(args.gid, Some(1000)); + assert_eq!(args.id, Some("my-vm".to_string())); + assert_eq!(args.netns, Some("/var/run/netns/my-ns".to_string())); + assert!(args.daemonize); + assert!(args.detach); + assert!(args.new_pid_ns); + assert_eq!(args.cgroups.len(), 2); + } + _ => panic!("expected Start command"), + } + } + + /// Verify resolve command parsing + #[test] + fn test_parse_resolve_command() { + let cli = + Cli::try_parse_from(["fc-cli", "resolve", "firecracker", "--mode", "system-only"]) + .unwrap(); + + match cli.command { + Commands::Resolve(args) => { + assert!(matches!(args.target, ResolveTarget::Firecracker)); + assert!(matches!(args.runtime.mode, ResolveMode::SystemOnly)); + } + _ => panic!("expected Resolve command"), + } + } + + /// Verify platform command parsing + #[test] + fn test_parse_platform_command() { + let cli = Cli::try_parse_from(["fc-cli", "platform"]).unwrap(); + assert!(matches!(cli.command, Commands::Platform)); + } + + // ==================== Input Validation Tests ==================== + + /// Missing kernel argument should fail + #[test] + fn test_missing_required_kernel_arg() { + let result = Cli::try_parse_from(["fc-cli", "start", "--rootfs", "/rootfs.ext4"]); + assert!(result.is_err()); + } + + /// Missing rootfs argument should fail + #[test] + fn test_missing_required_rootfs_arg() { + let result = Cli::try_parse_from(["fc-cli", "start", "--kernel", "/vmlinux"]); + assert!(result.is_err()); + } + + /// Invalid backend value should fail + #[test] + fn test_invalid_backend_value() { + let result = Cli::try_parse_from([ + "fc-cli", + "start", + "--backend", + "invalid", + "--kernel", + "/vmlinux", + "--rootfs", + "/rootfs.ext4", + ]); + assert!(result.is_err()); + } + + // ==================== Helper Function Tests ==================== + + /// Parse chroot root from socket path - valid path + #[test] + fn test_chroot_root_from_socket_valid() { + let socket = Path::new("/srv/jailer/firecracker/test-vm/root/run/firecracker.socket"); + let result = chroot_root_from_socket(socket).unwrap(); + assert_eq!( + result, + PathBuf::from("/srv/jailer/firecracker/test-vm/root") + ); + } + + /// Parse chroot root from socket path - minimal valid path + #[test] + fn test_chroot_root_from_socket_minimal_path() { + let socket = Path::new("/root/run/firecracker.socket"); + let result = chroot_root_from_socket(socket).unwrap(); + assert_eq!(result, PathBuf::from("/root")); + } + + /// Parse chroot root from socket path - too short should fail + #[test] + fn test_chroot_root_from_socket_too_short() { + let socket = Path::new("firecracker.socket"); + let result = chroot_root_from_socket(socket); + assert!(result.is_err()); + } + + /// Path to string conversion + #[test] + fn test_path_to_string() { + let path = Path::new("/path/to/file.ext"); + assert_eq!(path_to_string(path), "/path/to/file.ext"); + } + + /// Backend enum to string conversion + #[test] + fn test_backend_as_str() { + assert_eq!(backend_as_str(StartBackend::Firecracker), "firecracker"); + assert_eq!(backend_as_str(StartBackend::Jailer), "jailer"); + } + + /// ResolveMode to BundledMode conversion + #[test] + fn test_to_bundled_mode() { + assert!(matches!( + to_bundled_mode(ResolveMode::BundledOnly), + BundledMode::BundledOnly + )); + assert!(matches!( + to_bundled_mode(ResolveMode::SystemOnly), + BundledMode::SystemOnly + )); + assert!(matches!( + to_bundled_mode(ResolveMode::BundledThenSystem), + BundledMode::BundledThenSystem + )); + assert!(matches!( + to_bundled_mode(ResolveMode::SystemThenBundled), + BundledMode::SystemThenBundled + )); + } + + // ==================== Default Values Tests ==================== + + /// Verify all default values are set correctly + #[test] + fn test_default_values() { + let cli = Cli::try_parse_from([ + "fc-cli", + "start", + "--kernel", + "/vmlinux", + "--rootfs", + "/rootfs.ext4", + ]) + .unwrap(); + + match cli.command { + Commands::Start(args) => { + assert_eq!(args.rootfs_id, "rootfs"); + assert!(!args.rootfs_read_only); + assert_eq!(args.socket_path, PathBuf::from("/tmp/firecracker.socket")); + assert_eq!(args.socket_timeout_secs, 5); + assert_eq!(args.socket_poll_interval_ms, 50); + assert!(!args.no_seccomp); + assert!(!args.smt); + assert!(!args.track_dirty_pages); + assert!(matches!(args.backend, StartBackend::Firecracker)); + assert!(args.boot_args.is_none()); + assert!(args.initrd.is_none()); + assert!(args.id.is_none()); + } + _ => panic!("expected Start command"), + } + } +} diff --git a/fc-cli/tests/cli_integration.rs b/fc-cli/tests/cli_integration.rs new file mode 100644 index 0000000..32bf1f3 --- /dev/null +++ b/fc-cli/tests/cli_integration.rs @@ -0,0 +1,326 @@ +//! Integration tests for fc-cli binary. +//! +//! These tests run the actual compiled binary and verify its behavior, +//! including command output, error messages, and exit codes. + +use assert_cmd::Command; +use predicates::prelude::*; + +// ==================== Platform Command Tests ==================== + +/// Platform command should output os, arch, and bundled support info +#[test] +fn test_platform_command_output() { + Command::cargo_bin("fc-cli") + .unwrap() + .arg("platform") + .assert() + .success() + .stdout(predicate::str::contains("os=")) + .stdout(predicate::str::contains("arch=")) + .stdout(predicate::str::contains("bundled_release_supported=")); +} + +/// Platform command output should be in key=value format +#[test] +fn test_platform_command_format() { + let output = Command::cargo_bin("fc-cli") + .unwrap() + .arg("platform") + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + + for line in stdout.lines() { + assert!( + line.contains('='), + "Each line should be key=value format, got: {}", + line + ); + } +} + +// ==================== Start Command Validation Tests ==================== + +/// Start command without --kernel should fail with helpful error +#[test] +fn test_start_missing_kernel_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["start", "--rootfs", "/tmp/rootfs.ext4"]) + .assert() + .failure() + .stderr(predicate::str::contains("--kernel")); +} + +/// Start command without --rootfs should fail with helpful error +#[test] +fn test_start_missing_rootfs_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["start", "--kernel", "/tmp/vmlinux"]) + .assert() + .failure() + .stderr(predicate::str::contains("--rootfs")); +} + +/// Jailer backend without --uid should fail +#[test] +fn test_start_jailer_missing_uid_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--backend", + "jailer", + "--gid", + "1000", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("--uid")); +} + +/// Jailer backend without --gid should fail +#[test] +fn test_start_jailer_missing_gid_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--backend", + "jailer", + "--uid", + "1000", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("--gid")); +} + +/// --vcpu-count=0 should fail validation +#[test] +fn test_start_vcpu_count_zero_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + "--vcpu-count", + "0", + ]) + .assert() + .failure(); +} + +/// --mem-size-mib=0 should fail validation +#[test] +fn test_start_mem_size_zero_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + "--mem-size-mib", + "0", + ]) + .assert() + .failure(); +} + +/// --daemonize without --detach should fail for jailer backend +#[test] +fn test_start_jailer_daemonize_without_detach_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--backend", + "jailer", + "--uid", + "1000", + "--gid", + "1000", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + "--daemonize", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("--detach")); +} + +/// --socket-path is not allowed with jailer backend +#[test] +fn test_start_socket_path_with_jailer_error() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--backend", + "jailer", + "--uid", + "1000", + "--gid", + "1000", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + "--socket-path", + "/custom/socket.sock", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("--socket-path")); +} + +// ==================== Resolve Command Tests ==================== + +/// Resolve with system-only mode should fail when binary not in PATH +#[test] +fn test_resolve_system_only_not_found() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "firecracker", "--mode", "system-only"]) + .env("PATH", "/nonexistent") + .assert() + .failure(); +} + +/// Resolve with bundled-only mode without bundle root should fail +#[test] +fn test_resolve_bundled_only_without_bundle_root() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "firecracker", "--mode", "bundled-only"]) + .assert() + .failure(); +} + +// ==================== Help and Version Tests ==================== + +/// --help should show usage information +#[test] +fn test_help_output() { + Command::cargo_bin("fc-cli") + .unwrap() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("CLI utilities for Firecracker")); +} + +/// --version should show version +#[test] +fn test_version_output() { + Command::cargo_bin("fc-cli") + .unwrap() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("fc-cli")); +} + +/// start --help should show all start options +#[test] +fn test_start_help() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["start", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--kernel")) + .stdout(predicate::str::contains("--rootfs")) + .stdout(predicate::str::contains("--vcpu-count")) + .stdout(predicate::str::contains("--mem-size-mib")) + .stdout(predicate::str::contains("--backend")); +} + +/// resolve --help should show resolve options +#[test] +fn test_resolve_help() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("firecracker")) + .stdout(predicate::str::contains("jailer")); +} + +// ==================== Invalid Input Tests ==================== + +/// Invalid subcommand should fail +#[test] +fn test_invalid_subcommand() { + Command::cargo_bin("fc-cli") + .unwrap() + .arg("invalid-command") + .assert() + .failure(); +} + +/// Invalid backend value should fail +#[test] +fn test_invalid_backend() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--backend", + "invalid", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid")); +} + +/// Invalid resolve target should fail +#[test] +fn test_invalid_resolve_target() { + Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "invalid-target"]) + .assert() + .failure(); +} + +/// Negative mem-size-mib should fail +#[test] +fn test_negative_mem_size() { + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + "/tmp/vmlinux", + "--rootfs", + "/tmp/rootfs.ext4", + "--mem-size-mib", + "-100", + ]) + .assert() + .failure(); +} diff --git a/fc-cli/tests/e2e_linux.rs b/fc-cli/tests/e2e_linux.rs new file mode 100644 index 0000000..a96c8e1 --- /dev/null +++ b/fc-cli/tests/e2e_linux.rs @@ -0,0 +1,547 @@ +//! End-to-end tests that require Linux and actual Firecracker binaries. +//! +//! These tests are marked with #[ignore] and skipped by default. +//! Run with: cargo test -p fc-cli --test e2e_linux -- --ignored +//! +//! Required environment variables: +//! - TEST_KERNEL_PATH: Path to vmlinux kernel image +//! - TEST_ROOTFS_PATH: Path to rootfs.ext4 image +//! +//! Firecracker Installation: +//! - Tests will automatically install Firecracker using arcbox.sh if not found +//! - Set SKIP_FIRECRACKER_INSTALL=1 to skip automatic installation + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; +use std::process::Command as StdCommand; +use std::sync::Once; +use std::time::Duration; +use tempfile::TempDir; + +static FIRECRACKER_INIT: Once = Once::new(); + +/// Skip test if not running on Linux +macro_rules! require_linux { + () => { + if std::env::consts::OS != "linux" { + eprintln!("Skipping: test requires Linux"); + return; + } + }; +} + +/// Check if firecracker is available in PATH +fn is_firecracker_installed() -> bool { + StdCommand::new("which") + .arg("firecracker") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Install Firecracker using arcbox.sh script +fn install_firecracker() -> Result<(), String> { + eprintln!("Installing Firecracker via arcbox.sh..."); + + let status = StdCommand::new("bash") + .args(["-c", "curl -fsSL https://arcbox.sh/firecracker.sh | bash"]) + .status() + .map_err(|e| format!("Failed to run install script: {}", e))?; + + if status.success() { + eprintln!("Firecracker installed successfully"); + Ok(()) + } else { + Err(format!( + "Firecracker installation failed with exit code: {:?}", + status.code() + )) + } +} + +/// Ensure Firecracker is installed before running tests +/// Uses Once to ensure installation only happens once per test run +fn ensure_firecracker_installed() -> bool { + // Check if auto-install is disabled + if std::env::var("SKIP_FIRECRACKER_INSTALL").is_ok() { + return is_firecracker_installed(); + } + + let mut installed = is_firecracker_installed(); + + if !installed { + FIRECRACKER_INIT.call_once(|| { + if let Err(e) = install_firecracker() { + eprintln!("Warning: {}", e); + } + }); + installed = is_firecracker_installed(); + } + + installed +} + +/// Require Firecracker to be installed, install if necessary +macro_rules! require_firecracker { + () => { + if !ensure_firecracker_installed() { + eprintln!("Skipping: Firecracker not installed and auto-install failed"); + eprintln!("Install manually: curl -fsSL https://arcbox.sh/firecracker.sh | bash"); + return; + } + }; +} + +// ==================== Firecracker Installation Tests ==================== + +/// Test that Firecracker can be detected or installed automatically +#[test] +#[ignore] +fn test_firecracker_installation() { + require_linux!(); + + // This test verifies the auto-install mechanism works + let installed = ensure_firecracker_installed(); + + if installed { + // Verify firecracker binary is actually executable + let output = StdCommand::new("firecracker").arg("--version").output(); + + match output { + Ok(o) => { + assert!(o.status.success(), "firecracker --version should succeed"); + let version = String::from_utf8_lossy(&o.stdout); + eprintln!("Firecracker version: {}", version.trim()); + } + Err(e) => { + panic!("Failed to run firecracker --version: {}", e); + } + } + + // Verify jailer is also installed + let jailer_output = StdCommand::new("jailer").arg("--version").output(); + + match jailer_output { + Ok(o) => { + assert!(o.status.success(), "jailer --version should succeed"); + let version = String::from_utf8_lossy(&o.stdout); + eprintln!("Jailer version: {}", version.trim()); + } + Err(e) => { + panic!("Failed to run jailer --version: {}", e); + } + } + } else { + eprintln!("Firecracker not installed and auto-install was skipped or failed"); + eprintln!("This is expected if SKIP_FIRECRACKER_INSTALL=1 is set"); + } +} + +/// Get test kernel path from environment, or skip test +fn get_kernel_path() -> Option { + std::env::var("TEST_KERNEL_PATH") + .ok() + .map(PathBuf::from) + .filter(|p| p.exists()) +} + +/// Get test rootfs path from environment, or skip test +fn get_rootfs_path() -> Option { + std::env::var("TEST_ROOTFS_PATH") + .ok() + .map(PathBuf::from) + .filter(|p| p.exists()) +} + +// ==================== VM Lifecycle Tests ==================== + +/// Test starting a VM in detached mode with firecracker backend +#[test] +#[ignore] +fn test_start_vm_detached_firecracker() { + require_linux!(); + require_firecracker!(); + + let kernel = match get_kernel_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_KERNEL_PATH not set or file not found"); + return; + } + }; + + let rootfs = match get_rootfs_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_ROOTFS_PATH not set or file not found"); + return; + } + }; + + let temp_dir = TempDir::new().unwrap(); + let socket_path = temp_dir.path().join("firecracker.socket"); + + let assert = Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + kernel.to_str().unwrap(), + "--rootfs", + rootfs.to_str().unwrap(), + "--socket-path", + socket_path.to_str().unwrap(), + "--vcpu-count", + "1", + "--mem-size-mib", + "128", + "--detach", + ]) + .timeout(Duration::from_secs(30)) + .assert(); + + assert + .success() + .stdout(predicate::str::contains("vm_started=true")) + .stdout(predicate::str::contains("detached=true")) + .stdout(predicate::str::contains("socket=")) + .stdout(predicate::str::contains("pid=")); + + // Verify socket file was created + assert!( + socket_path.exists(), + "Socket file should exist after VM start" + ); + + // Cleanup: kill the VM process + // Parse PID from output and terminate + let output = Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + kernel.to_str().unwrap(), + "--rootfs", + rootfs.to_str().unwrap(), + "--socket-path", + socket_path.to_str().unwrap(), + "--detach", + ]) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(pid_str) = line.strip_prefix("pid=") { + if let Ok(pid) = pid_str.parse::() { + unsafe { + libc::kill(pid, libc::SIGTERM); + } + } + } + } +} + +/// Test starting a VM with custom boot arguments +#[test] +#[ignore] +fn test_start_vm_with_boot_args() { + require_linux!(); + require_firecracker!(); + + let kernel = match get_kernel_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_KERNEL_PATH not set"); + return; + } + }; + + let rootfs = match get_rootfs_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_ROOTFS_PATH not set"); + return; + } + }; + + let temp_dir = TempDir::new().unwrap(); + let socket_path = temp_dir.path().join("firecracker.socket"); + + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + kernel.to_str().unwrap(), + "--rootfs", + rootfs.to_str().unwrap(), + "--socket-path", + socket_path.to_str().unwrap(), + "--boot-args", + "console=ttyS0 reboot=k panic=1", + "--detach", + ]) + .timeout(Duration::from_secs(30)) + .assert() + .success() + .stdout(predicate::str::contains("vm_started=true")); +} + +/// Test starting a VM with read-only rootfs +#[test] +#[ignore] +fn test_start_vm_readonly_rootfs() { + require_linux!(); + require_firecracker!(); + + let kernel = match get_kernel_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_KERNEL_PATH not set"); + return; + } + }; + + let rootfs = match get_rootfs_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_ROOTFS_PATH not set"); + return; + } + }; + + let temp_dir = TempDir::new().unwrap(); + let socket_path = temp_dir.path().join("firecracker.socket"); + + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + kernel.to_str().unwrap(), + "--rootfs", + rootfs.to_str().unwrap(), + "--socket-path", + socket_path.to_str().unwrap(), + "--rootfs-read-only", + "--detach", + ]) + .timeout(Duration::from_secs(30)) + .assert() + .success() + .stdout(predicate::str::contains("vm_started=true")); +} + +// ==================== Error Handling Tests ==================== + +/// Test that non-existent kernel path fails gracefully +#[test] +#[ignore] +fn test_start_nonexistent_kernel() { + require_linux!(); + require_firecracker!(); + + let rootfs = match get_rootfs_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_ROOTFS_PATH not set"); + return; + } + }; + + let temp_dir = TempDir::new().unwrap(); + let socket_path = temp_dir.path().join("firecracker.socket"); + + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + "/nonexistent/path/to/vmlinux", + "--rootfs", + rootfs.to_str().unwrap(), + "--socket-path", + socket_path.to_str().unwrap(), + ]) + .timeout(Duration::from_secs(10)) + .assert() + .failure(); +} + +/// Test that non-existent rootfs path fails gracefully +#[test] +#[ignore] +fn test_start_nonexistent_rootfs() { + require_linux!(); + require_firecracker!(); + + let kernel = match get_kernel_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_KERNEL_PATH not set"); + return; + } + }; + + let temp_dir = TempDir::new().unwrap(); + let socket_path = temp_dir.path().join("firecracker.socket"); + + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--kernel", + kernel.to_str().unwrap(), + "--rootfs", + "/nonexistent/path/to/rootfs.ext4", + "--socket-path", + socket_path.to_str().unwrap(), + ]) + .timeout(Duration::from_secs(10)) + .assert() + .failure(); +} + +// ==================== Jailer Backend Tests ==================== + +/// Test jailer backend requires root privileges +#[test] +#[ignore] +fn test_jailer_requires_root() { + require_linux!(); + require_firecracker!(); + + // Skip if running as root (test is for non-root behavior) + if unsafe { libc::geteuid() } == 0 { + eprintln!("Skipping: test is for non-root users"); + return; + } + + let kernel = match get_kernel_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_KERNEL_PATH not set"); + return; + } + }; + + let rootfs = match get_rootfs_path() { + Some(p) => p, + None => { + eprintln!("Skipping: TEST_ROOTFS_PATH not set"); + return; + } + }; + + // Jailer typically requires root to set up chroot + Command::cargo_bin("fc-cli") + .unwrap() + .args([ + "start", + "--backend", + "jailer", + "--uid", + "1000", + "--gid", + "1000", + "--kernel", + kernel.to_str().unwrap(), + "--rootfs", + rootfs.to_str().unwrap(), + "--detach", + ]) + .timeout(Duration::from_secs(10)) + .assert() + .failure(); +} + +// ==================== Resolve Command Tests ==================== + +/// Test resolve command can find bundled firecracker on Linux +#[test] +#[ignore] +fn test_resolve_bundled_firecracker() { + require_linux!(); + + // This test assumes bundled binaries are available + let result = Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "firecracker", "--mode", "bundled-then-system"]) + .output(); + + match result { + Ok(output) => { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("firecracker="), + "Output should contain firecracker path" + ); + } + // If it fails, that's okay - bundled might not be available + } + Err(_) => { + // Command execution failed, skip + } + } +} + +/// Test resolve command for jailer binary +#[test] +#[ignore] +fn test_resolve_bundled_jailer() { + require_linux!(); + + let result = Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "jailer", "--mode", "bundled-then-system"]) + .output(); + + match result { + Ok(output) => { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("jailer="), + "Output should contain jailer path" + ); + } + } + Err(_) => { + // Command execution failed, skip + } + } +} + +/// Test resolve all binaries at once +#[test] +#[ignore] +fn test_resolve_all_binaries() { + require_linux!(); + + let result = Command::cargo_bin("fc-cli") + .unwrap() + .args(["resolve", "all", "--mode", "bundled-then-system"]) + .output(); + + match result { + Ok(output) => { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("firecracker="), + "Output should contain firecracker path" + ); + assert!( + stdout.contains("jailer="), + "Output should contain jailer path" + ); + } + } + Err(_) => { + // Command execution failed, skip + } + } +}