diff --git a/Makefile b/Makefile index d3b6c113..89e73072 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,14 @@ SHELL := /bin/bash +# Find Rust toolchain bin directory and set PATH +# Prefer stable (has musl target), fall back to any toolchain +RUST_BIN := $(shell command -v cargo >/dev/null 2>&1 && dirname $$(command -v cargo) || \ + (test -x $(HOME)/.cargo/bin/cargo && echo $(HOME)/.cargo/bin) || \ + (ls -d $(HOME)/.rustup/toolchains/stable-*/bin 2>/dev/null | head -1) || \ + (ls -d $(HOME)/.rustup/toolchains/*/bin 2>/dev/null | head -1)) +export PATH := $(RUST_BIN):$(PATH) +CARGO := cargo + # Brief notes (see .claude/CLAUDE.md for details): # FILTER=x STREAM=1 - filter tests, stream output # Assets are content-addressed (kernel by URL SHA, rootfs by script SHA, initrd by binary SHA) @@ -57,7 +66,7 @@ endif # Base test command export CARGO_TARGET_DIR := target -NEXTEST := cargo nextest $(NEXTEST_CMD) --release +NEXTEST := $(CARGO) nextest $(NEXTEST_CMD) --release # Optional cargo cache directory (for CI caching) CARGO_CACHE_DIR ?= @@ -175,8 +184,8 @@ clean-test-data: build: @echo "==> Building..." - CARGO_TARGET_DIR=target cargo build --release -p fcvm - CARGO_TARGET_DIR=target cargo build --release -p fc-agent --target $(MUSL_TARGET) + CARGO_TARGET_DIR=target $(CARGO) build --release -p fcvm + CARGO_TARGET_DIR=target $(CARGO) build --release -p fc-agent --target $(MUSL_TARGET) @mkdir -p target/release && cp target/$(MUSL_TARGET)/release/fc-agent target/release/fc-agent @# Sync embedded config to user config dir (config is embedded at compile time) @./target/release/fcvm setup --generate-config --force 2>/dev/null || true @@ -309,9 +318,9 @@ _setup-fcvm: bench: build @echo "==> Running benchmarks..." - sudo cargo bench -p fuse-pipe --bench throughput - sudo cargo bench -p fuse-pipe --bench operations - cargo bench -p fuse-pipe --bench protocol + sudo $(CARGO) bench -p fuse-pipe --bench throughput + sudo $(CARGO) bench -p fuse-pipe --bench operations + $(CARGO) bench -p fuse-pipe --bench protocol # Container benchmark target (used by nightly CI) container-bench: container-build @@ -320,18 +329,18 @@ container-bench: container-build _bench: @echo "==> Running benchmarks..." - cargo bench -p fuse-pipe --bench throughput - cargo bench -p fuse-pipe --bench operations - cargo bench -p fuse-pipe --bench protocol + $(CARGO) bench -p fuse-pipe --bench throughput + $(CARGO) bench -p fuse-pipe --bench operations + $(CARGO) bench -p fuse-pipe --bench protocol lint: - cargo fmt -p fcvm -p fuse-pipe -p fc-agent --check - cargo clippy --all-targets -- -D warnings - cargo audit - cargo deny check + $(CARGO) fmt -p fcvm -p fuse-pipe -p fc-agent --check + $(CARGO) clippy --all-targets -- -D warnings + $(CARGO) audit + $(CARGO) deny check fmt: - cargo fmt + $(CARGO) fmt # SSH to jumpbox (IP from terraform: cd ~/src/aws && terraform output jumpbox_ssh_command) JUMPBOX_IP := 54.193.62.221 diff --git a/README.md b/README.md index db1a1379..702bac34 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A Rust implementation that launches Firecracker microVMs to run Podman container - For AWS: c6g.metal (ARM64) or c5.metal (x86_64) - NOT regular instances **Runtime Dependencies** -- Rust 1.83+ with cargo (nightly for fuser crate) +- Rust 1.83+ with cargo and musl target ([rustup.rs](https://rustup.rs), then `rustup target add $(uname -m)-unknown-linux-musl`) - Firecracker binary in PATH - For bridged networking: sudo, iptables, iproute2 - For rootless networking: slirp4netns @@ -102,95 +102,109 @@ sudo iptables -P FORWARD ACCEPT ## Quick Start +fcvm runs containers inside Firecracker microVMs: + +``` +You → fcvm → Firecracker VM → Podman → Container +``` + +Each `podman run` boots a VM (~50ms), pulls the image, and starts the container with full VM isolation. + ```bash +# Install Rust (if not already installed) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source ~/.cargo/env + +# Install musl toolchain (for static linking fc-agent binary) +sudo apt install musl-tools +rustup target add $(uname -m)-unknown-linux-musl + +# Clone and build fcvm + fc-agent binaries (~2 min) git clone https://github.com/ejc3/fcvm cd fcvm make build +# → "Finished release profile [optimized] target(s)" -# Create a short repo-local entrypoint +# Create symlink for convenience (works with sudo) ln -sf target/release/fcvm ./fcvm -# First-time setup (downloads kernel + builds rootfs, ~5 min) -# Use sudo if fcvm needs to create/mount /mnt/fcvm-btrfs +# Download kernel + build rootfs (~5 min first time, then cached) sudo ./fcvm setup +# → "Setup complete" -# Rootless (default network, no sudo) -./fcvm podman run --name test-rootless nginx:alpine -./fcvm exec test-rootless -- cat /etc/os-release +# One-shot command (runs, prints output, exits) +./fcvm podman run --name hello alpine:latest -- echo "Hello from microVM" +# → Hello from microVM -# One-shot command (runs command then exits) -./fcvm podman run --name oneshot alpine:latest -- echo "Hello from microVM" +# Run a long-lived service (stays in foreground, or add & to background) +./fcvm podman run --name web nginx:alpine +# → Logs show VM booting, then "healthy" when nginx is ready -# Bridged networking (requires sudo) -sudo ./fcvm podman run --name test-bridged --network bridged nginx:alpine -``` +# In another terminal: +./fcvm ls +# → Shows "web" with PID, health status, network info -### Run a Container -```bash -# Run a one-shot command (clean output, just like docker run) -fcvm podman run --name test alpine:latest echo "hello world" -# Output: hello world +./fcvm exec --name web -- cat /etc/os-release +# → Shows Alpine Linux info -# Run a service (uses rootless mode by default, no sudo needed) -fcvm podman run --name web1 public.ecr.aws/nginx/nginx:alpine +# Bridged networking (for full network access, requires sudo) +sudo ./fcvm podman run --name web-bridged --network bridged nginx:alpine +``` + +### More Options -# With port forwarding (8080 on host -> 80 in guest) -# Note: In rootless mode, use the assigned loopback IP (e.g., curl 127.0.0.2:8080) -# In bridged mode, use the veth host IP (see fcvm ls --json for the IP) -fcvm podman run --name web1 --publish 8080:80 public.ecr.aws/nginx/nginx:alpine +```bash +# Port forwarding (8080 on host -> 80 in container) +./fcvm podman run --name web --publish 8080:80 nginx:alpine +# In rootless: curl the assigned loopback IP (e.g., curl 127.0.0.2:8080) +# In bridged: curl the veth host IP (see ./fcvm ls --json) -# With host directory mapping (via fuse-pipe) -fcvm podman run --name web1 --map /host/data:/data public.ecr.aws/nginx/nginx:alpine +# Mount host directory into container +./fcvm podman run --name app --map /host/data:/data alpine:latest -# Custom resources -fcvm podman run --name web1 --cpu 4 --mem 4096 public.ecr.aws/nginx/nginx:alpine +# Custom CPU/memory +./fcvm podman run --name big --cpu 4 --mem 4096 alpine:latest -# Bridged mode (requires sudo, uses iptables) -sudo fcvm podman run --name web1 --network bridged public.ecr.aws/nginx/nginx:alpine +# Interactive shell (-it like docker/podman) +./fcvm podman run --name shell -it alpine:latest sh -# Interactive container (-it like docker/podman) -fcvm podman run --name shell -it alpine:latest sh # Interactive shell -fcvm podman run --name vim -it alpine:latest vi # Run vim (full TTY support) +# JSON output for scripting +./fcvm ls --json +./fcvm ls --pid 12345 # Filter by PID -# List running VMs (sudo needed to read VM state files) -sudo fcvm ls -sudo fcvm ls --json # JSON output -sudo fcvm ls --pid 12345 # Filter by PID +# Execute in guest VM instead of container +./fcvm exec --name web --vm -- hostname -# Execute commands (mirrors podman exec, sudo needed) -sudo fcvm exec --name web1 -- cat /etc/os-release # Run in container (default) -sudo fcvm exec --name web1 --vm -- hostname # Run in VM -sudo fcvm exec --pid 12345 -- hostname # By PID (from fcvm ls) +# Interactive shell in container +./fcvm exec --name web -it -- sh -# Interactive shell/TTY (like docker/podman -it) -sudo fcvm exec --name web1 -it -- sh # Interactive shell in container -sudo fcvm exec --name web1 -it --vm -- bash # Interactive shell in VM -sudo fcvm exec --name web1 -t -- ls -la --color=always # TTY for colors, no stdin +# TTY for colors (no stdin) +./fcvm exec --name web -t -- ls -la --color=always ``` ### Snapshot & Clone Workflow ```bash # 1. Start baseline VM (using bridged, or omit --network for rootless) -sudo fcvm podman run --name baseline --network bridged public.ecr.aws/nginx/nginx:alpine +sudo ./fcvm podman run --name baseline --network bridged public.ecr.aws/nginx/nginx:alpine # 2. Create snapshot (pauses VM briefly) -sudo fcvm snapshot create baseline --tag nginx-warm +sudo ./fcvm snapshot create baseline --tag nginx-warm # 3. Start UFFD memory server (serves pages on-demand) -sudo fcvm snapshot serve nginx-warm +sudo ./fcvm snapshot serve nginx-warm # 4. Clone from snapshot (~3ms startup) -sudo fcvm snapshot run --pid --name clone1 --network bridged -sudo fcvm snapshot run --pid --name clone2 --network bridged +sudo ./fcvm snapshot run --pid --name clone1 --network bridged +sudo ./fcvm snapshot run --pid --name clone2 --network bridged # 5. Clone with port forwarding (each clone can have unique ports) -sudo fcvm snapshot run --pid --name web1 --network bridged --publish 8081:80 -sudo fcvm snapshot run --pid --name web2 --network bridged --publish 8082:80 +sudo ./fcvm snapshot run --pid --name web1 --network bridged --publish 8081:80 +sudo ./fcvm snapshot run --pid --name web2 --network bridged --publish 8082:80 # Get the host IP from fcvm ls --json, then curl it: -# curl $(sudo fcvm ls --json | jq -r '.[] | select(.name=="web1") | .config.network.host_ip'):8081 +# curl $(./fcvm ls --json | jq -r '.[] | select(.name=="web1") | .config.network.host_ip'):8081 # 6. Clone and execute command (auto-cleans up after) -sudo fcvm snapshot run --pid --network bridged --exec "curl localhost" +sudo ./fcvm snapshot run --pid --network bridged --exec "curl localhost" ``` --- @@ -211,13 +225,13 @@ sudo fcvm snapshot run --pid --network bridged --exec "curl localhos Demonstrate instant VM cloning from a warmed snapshot: ```bash -# Setup: Create baseline and snapshot -sudo fcvm podman run --name baseline public.ecr.aws/nginx/nginx:alpine -sudo fcvm snapshot create baseline --tag nginx-warm -sudo fcvm snapshot serve nginx-warm # Note the serve PID +# Setup: Create baseline and snapshot (rootless mode) +./fcvm podman run --name baseline nginx:alpine +./fcvm snapshot create baseline --tag nginx-warm +./fcvm snapshot serve nginx-warm # Note the serve PID # Time a clone startup (includes exec and cleanup) -time sudo fcvm snapshot run --pid --exec "echo ready" +time ./fcvm snapshot run --pid --exec "echo ready" # real 0m0.003s ← 3ms! ``` @@ -231,7 +245,7 @@ free -m | grep Mem # Start 10 clones from same snapshot for i in {1..10}; do - sudo fcvm snapshot run --pid --name clone$i & + ./fcvm snapshot run --pid --name clone$i & done wait @@ -244,24 +258,24 @@ free -m | grep Mem Spin up a fleet of web servers instantly: ```bash -# Create warm nginx snapshot (one-time) -sudo fcvm podman run --name baseline --publish 8080:80 public.ecr.aws/nginx/nginx:alpine -# Wait for healthy, then snapshot -sudo fcvm snapshot create baseline --tag nginx-warm -sudo fcvm snapshot serve nginx-warm # Note serve PID +# Create warm nginx snapshot (one-time, in another terminal) +./fcvm podman run --name baseline --publish 8080:80 nginx:alpine +# Once healthy, in another terminal: +./fcvm snapshot create baseline --tag nginx-warm +./fcvm snapshot serve nginx-warm # Note serve PID # Spin up 50 nginx instances in parallel time for i in {1..50}; do - sudo fcvm snapshot run --pid --name web$i --publish $((8080+i)):80 & + ./fcvm snapshot run --pid --name web$i --publish $((8080+i)):80 & done wait # real 0m0.150s ← 50 VMs in 150ms! # Verify all running -sudo fcvm ls | wc -l # 51 (50 clones + 1 baseline) +./fcvm ls | wc -l # 51 (50 clones + 1 baseline) -# Test a random clone -curl -s localhost:8090 | head -5 +# Test a clone (use loopback IP from ./fcvm ls --json) +curl -s 127.0.0.10:8090 | head -5 ``` ### Privileged Container (Device Access) @@ -270,7 +284,7 @@ Run containers that need mknod or device access: ```bash # Privileged mode allows mknod, /dev access, etc. -sudo fcvm podman run --name dev --privileged \ +sudo ./fcvm podman run --name dev --privileged \ --cmd "sh -c 'mknod /dev/null2 c 1 3 && ls -la /dev/null2'" \ public.ecr.aws/docker/library/alpine:latest # Output: crw-r--r-- 1 root root 1,3 /dev/null2 @@ -282,21 +296,21 @@ Expose multiple ports and mount multiple volumes in one command: ```bash # Multiple port mappings (comma-separated) -sudo fcvm podman run --name multi-port \ +./fcvm podman run --name multi-port \ --publish 8080:80,8443:443 \ - public.ecr.aws/nginx/nginx:alpine + nginx:alpine # Multiple volume mappings (comma-separated, with read-only) -sudo fcvm podman run --name multi-vol \ +./fcvm podman run --name multi-vol \ --map /tmp/logs:/logs,/tmp/data:/data:ro \ - public.ecr.aws/nginx/nginx:alpine + nginx:alpine # Combined -sudo fcvm podman run --name full \ +./fcvm podman run --name full \ --publish 8080:80,8443:443 \ --map /tmp/html:/usr/share/nginx/html:ro \ --env NGINX_HOST=localhost,NGINX_PORT=80 \ - public.ecr.aws/nginx/nginx:alpine + nginx:alpine ``` --- @@ -315,16 +329,16 @@ fcvm supports full interactive terminal sessions, matching docker/podman's `-i` ```bash # Run interactive shell in container -fcvm podman run --name shell -it alpine:latest sh +./fcvm podman run --name shell -it alpine:latest sh # Run vim (full TTY - arrow keys, escape sequences work) -fcvm podman run --name editor -it alpine:latest vi /tmp/test.txt +./fcvm podman run --name editor -it alpine:latest vi /tmp/test.txt # Run shell in existing VM -sudo fcvm exec --name web1 -it -- sh +./fcvm exec --name web1 -it -- sh # Pipe data (use -i without -t) -echo "hello" | fcvm podman run --name pipe -i alpine:latest cat +echo "hello" | ./fcvm podman run --name pipe -i alpine:latest cat ``` ### How It Works @@ -397,7 +411,7 @@ cargo install fcvm # Download nested kernel profile and install as host kernel # This also configures GRUB with kvm-arm.mode=nested -sudo fcvm setup --kernel-profile nested --install-host-kernel +sudo ./fcvm setup --kernel-profile nested --install-host-kernel # Reboot into the new kernel sudo reboot @@ -457,14 +471,14 @@ sudo reboot ```bash # Download pre-built kernel from GitHub releases (~20MB) -fcvm setup --kernel-profile nested +./fcvm setup --kernel-profile nested # Kernel will be at /mnt/fcvm-btrfs/kernels/vmlinux-nested-6.18-aarch64-*.bin ``` Or build locally (takes 10-20 minutes): ```bash -fcvm setup --kernel-profile nested --build-kernels +./fcvm setup --kernel-profile nested --build-kernels ``` The nested kernel (6.18) includes: @@ -479,7 +493,7 @@ The nested kernel (6.18) includes: **Step 1: Start outer VM with nested kernel profile** ```bash # Uses nested kernel profile from rootfs-config.toml -sudo fcvm podman run \ +sudo ./fcvm podman run \ --name outer-vm \ --network bridged \ --kernel-profile nested \ @@ -492,11 +506,11 @@ sudo fcvm podman run \ **Step 2: Verify nested KVM works** ```bash # Check guest sees HYP mode -fcvm exec --pid --vm -- dmesg | grep -i kvm +./fcvm exec --pid --vm -- dmesg | grep -i kvm # Should show: "kvm [1]: VHE mode initialized successfully" # Verify /dev/kvm is accessible -fcvm exec --pid --vm -- ls -la /dev/kvm +./fcvm exec --pid --vm -- ls -la /dev/kvm ``` **Step 3: Run inner VM** @@ -649,9 +663,9 @@ See [DESIGN.md](DESIGN.md#cli-interface) for architecture and design decisions. **`fcvm exec`** - Execute in VM/container: ```bash -sudo fcvm exec --name my-vm -- cat /etc/os-release # In container -sudo fcvm exec --name my-vm --vm -- curl -s ifconfig.me # In guest OS -sudo fcvm exec --name my-vm -it -- bash # Interactive shell +./fcvm exec --name my-vm -- cat /etc/os-release # In container +./fcvm exec --name my-vm --vm -- curl -s ifconfig.me # In guest OS +./fcvm exec --name my-vm -it -- bash # Interactive shell ``` --- @@ -783,7 +797,7 @@ RUST_LOG="passthrough=debug,fuse_pipe=info" sudo -E cargo test ... Check running VMs: ```bash -sudo fcvm ls +./fcvm ls ``` Manual cleanup: @@ -858,10 +872,10 @@ For advanced use cases (like nested virtualization), fcvm supports **kernel prof Usage: ```bash # Download/build kernel for profile -fcvm setup --kernel-profile nested +./fcvm setup --kernel-profile nested # Run VM with profile -sudo fcvm podman run --name vm1 --kernel-profile nested --privileged nginx:alpine +sudo ./fcvm podman run --name vm1 --kernel-profile nested --privileged nginx:alpine ``` ### Adding a New Kernel Profile @@ -950,7 +964,7 @@ After changing the config, run `fcvm setup` to rebuild the rootfs with the new S - Or set PATH: `export PATH=$PATH:./target/release` ### "timeout waiting for VM to become healthy" -- Check VM logs: `sudo fcvm ls --json` +- Check VM logs: `./fcvm ls --json` - Verify kernel and rootfs exist: `ls -la /mnt/fcvm-btrfs/` - Check networking: VMs use host DNS servers directly (no dnsmasq needed) diff --git a/src/commands/podman.rs b/src/commands/podman.rs index 2d07af4c..42c33502 100644 --- a/src/commands/podman.rs +++ b/src/commands/podman.rs @@ -701,9 +701,10 @@ async fn cmd_podman_run(args: RunArgs) -> Result<()> { let socket_path = data_dir.join("firecracker.sock"); // Create VM state + // Note: env vars are NOT stored in state (they may contain secrets and state is world-readable) + // Instead, env is passed directly to MMDS at VM start time let mut vm_state = VmState::new(vm_id.clone(), args.image.clone(), args.cpu, args.mem); vm_state.name = Some(vm_name.clone()); - vm_state.config.env = args.env.clone(); vm_state.config.volumes = args.map.clone(); vm_state.config.health_check_url = args.health_check.clone(); diff --git a/src/state/manager.rs b/src/state/manager.rs index 9daac87e..0df6b66d 100644 --- a/src/state/manager.rs +++ b/src/state/manager.rs @@ -105,11 +105,11 @@ impl StateManager { .await .context("writing temp state file")?; - // Set file permissions to 0600 (owner read/write only) for security + // Set file permissions to 0644 (world-readable) so non-root can list VMs #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let permissions = std::fs::Permissions::from_mode(0o600); + let permissions = std::fs::Permissions::from_mode(0o644); tokio::fs::set_permissions(&temp_file, permissions) .await .context("setting file permissions on state file")?; @@ -454,11 +454,11 @@ impl StateManager { .await .context("writing temp state file for health update")?; - // Set permissions + // Set permissions (world-readable so non-root can list VMs) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let permissions = std::fs::Permissions::from_mode(0o600); + let permissions = std::fs::Permissions::from_mode(0o644); tokio::fs::set_permissions(&temp_file, permissions) .await .context("setting file permissions on state file")?; diff --git a/src/state/types.rs b/src/state/types.rs index d313bbf6..6570e97b 100644 --- a/src/state/types.rs +++ b/src/state/types.rs @@ -93,7 +93,8 @@ pub struct VmConfig { pub memory_mib: u32, pub network: NetworkConfig, pub volumes: Vec, - pub env: Vec, + // Note: env vars intentionally NOT stored here - they may contain secrets + // and state files are world-readable. Env is passed directly to MMDS. /// Extra block devices (paths to raw disk images) #[serde(default)] pub extra_disks: Vec, @@ -130,7 +131,6 @@ impl VmState { memory_mib, network: NetworkConfig::default(), volumes: Vec::new(), - env: Vec::new(), extra_disks: Vec::new(), nfs_shares: Vec::new(), health_check_url: None, @@ -221,7 +221,6 @@ mod tests { "host_veth": null }, "volumes": [], - "env": [], "health_check_url": null, "snapshot_name": null, "process_type": "serve", diff --git a/tests/test_ctrlc.rs b/tests/test_ctrlc.rs new file mode 100644 index 00000000..b2bb6b0f --- /dev/null +++ b/tests/test_ctrlc.rs @@ -0,0 +1,257 @@ +//! Test Ctrl+C (SIGINT) handling when sent via terminal +//! +//! This test verifies that pressing Ctrl+C in a terminal properly sends SIGINT +//! to fcvm and the signal is handled correctly. +//! +//! The key difference from test_exec.rs is that we send the interrupt character +//! (^C, 0x03) through the PTY master, which causes the kernel to send SIGINT +//! to the foreground process group - exactly like pressing Ctrl+C in a real terminal. + +#![cfg(feature = "integration-fast")] + +mod common; + +use anyhow::{Context, Result}; +use nix::pty::openpty; +use nix::sys::signal::{kill, Signal}; +use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; +use nix::unistd::{close, dup2, fork, ForkResult}; +use std::io::{Read, Write}; +use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd}; +use std::time::Duration; + +/// Test that Ctrl+C (sent via PTY) reaches fcvm and triggers signal handling +#[tokio::test] +async fn test_ctrlc_via_terminal() -> Result<()> { + println!("\nTest: Ctrl+C via terminal (PTY)"); + println!("================================"); + + let fcvm_path = common::find_fcvm_binary()?; + let (vm_name, _, _, _) = common::unique_names("ctrlc-term"); + + // Build command args - use rootless mode (no sudo required) + let args = [ + fcvm_path.to_str().unwrap(), + "podman", + "run", + "--name", + &vm_name, + "--network", + "rootless", + "alpine:latest", + "sleep", + "120", // Long sleep so we can interrupt it + ]; + + println!("Running: {} {}", args[0], args[1..].join(" ")); + + // Create PTY pair + let pty = openpty(None, None).context("opening PTY")?; + + // Set up terminal attributes on slave BEFORE fork + // Enable ISIG so Ctrl+C generates SIGINT + unsafe { + let slave_fd = pty.slave.as_raw_fd(); + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(slave_fd, &mut termios) == 0 { + // Enable ISIG so interrupt character generates SIGINT + termios.c_lflag |= libc::ISIG; + // Set VINTR to ^C (0x03) - this is the default but be explicit + termios.c_cc[libc::VINTR] = 0x03; + libc::tcsetattr(slave_fd, libc::TCSANOW, &termios); + } + } + + let master_fd = pty.master.into_raw_fd(); + let slave_fd = pty.slave.into_raw_fd(); + + // Fork: child runs fcvm, parent sends Ctrl+C + let child_pid = match unsafe { fork() }.context("forking")? { + ForkResult::Child => { + // Child: set up PTY as controlling terminal and run fcvm + unsafe { + // Create new session - this makes us the session leader + libc::setsid(); + + // Set the PTY slave as our controlling terminal + // TIOCSCTTY with arg 0 means "make this my controlling terminal" + libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0); + + // Make ourselves the foreground process group + libc::tcsetpgrp(slave_fd, libc::getpid()); + + // Redirect stdio to PTY slave + dup2(slave_fd, 0).ok(); + dup2(slave_fd, 1).ok(); + dup2(slave_fd, 2).ok(); + + // Close original fds + if slave_fd > 2 { + close(slave_fd).ok(); + } + close(master_fd).ok(); + } + + // Exec fcvm + use std::ffi::CString; + let prog = CString::new(args[0]).unwrap(); + let c_args: Vec = args.iter().map(|s| CString::new(*s).unwrap()).collect(); + // execvp replaces the process - this line is only reached on error + let _ = nix::unistd::execvp(&prog, &c_args); + std::process::exit(1); + } + ForkResult::Parent { child } => { + // Close slave in parent + close(slave_fd).ok(); + child + } + }; + + // Parent: wait for VM to start, then send Ctrl+C via PTY + let mut master = unsafe { std::fs::File::from_raw_fd(master_fd) }; + + // Set non-blocking for reads + unsafe { + let flags = libc::fcntl(master_fd, libc::F_GETFL); + libc::fcntl(master_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + + // CRITICAL: Enable ISIG on the master AFTER child has set up + // The child's setsid() + TIOCSCTTY may affect terminal settings. + // Without ISIG, writing 0x03 to PTY won't generate SIGINT. + std::thread::sleep(Duration::from_millis(500)); // Let child set up + unsafe { + let mut termios: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(master_fd, &mut termios) == 0 { + termios.c_lflag |= libc::ISIG; + termios.c_cc[libc::VINTR] = 0x03; + libc::tcsetattr(master_fd, libc::TCSANOW, &termios); + println!(" Enabled ISIG on PTY master"); + } + } + + // Wait for "Container ready" in PTY output + println!("Waiting for container to be ready..."); + let mut output = Vec::new(); + let mut buf = [0u8; 4096]; + let deadline = std::time::Instant::now() + Duration::from_secs(90); + let mut vm_started = false; + + let mut last_print = std::time::Instant::now(); + while std::time::Instant::now() < deadline { + // Drain PTY output (non-blocking) + match master.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + output.extend_from_slice(&buf[..n]); + let output_str = String::from_utf8_lossy(&output); + // "Container ready notification received" appears when container is up + if output_str.contains("Container ready") { + vm_started = true; + println!(" Container ready!"); + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => {} + } + + // Print progress every 10 seconds + if last_print.elapsed() > Duration::from_secs(10) { + println!(" ... waiting ({} bytes output)", output.len()); + last_print = std::time::Instant::now(); + } + + // Check if child exited early + match waitpid(child_pid, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(_, code)) => { + println!(" Child exited early with code {}", code); + let output_str = String::from_utf8_lossy(&output); + println!(" Output so far: {}", output_str); + anyhow::bail!("fcvm exited before VM started"); + } + Ok(WaitStatus::Signaled(_, sig, _)) => { + anyhow::bail!("fcvm killed by signal {:?} before VM started", sig); + } + _ => {} + } + + std::thread::sleep(Duration::from_millis(100)); + } + + if !vm_started { + // Kill child and fail + kill(child_pid, Signal::SIGKILL).ok(); + let output_str = String::from_utf8_lossy(&output); + println!("Output so far:\n{}", output_str); + anyhow::bail!("Timeout waiting for VM to start"); + } + + // Give it a moment to fully initialize + std::thread::sleep(Duration::from_secs(2)); + + // Send Ctrl+C (0x03) through the PTY + // This is exactly what happens when you press Ctrl+C in a real terminal + println!("Sending Ctrl+C (0x03) via PTY..."); + master.write_all(&[0x03]).context("writing Ctrl+C")?; + master.flush().context("flushing")?; + + // Wait for fcvm to handle the signal and exit + println!("Waiting for fcvm to handle signal and exit..."); + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(30); + let mut exit_status = None; + + loop { + // Continue reading output + match master.read(&mut buf) { + Ok(0) => break, + Ok(n) => output.extend_from_slice(&buf[..n]), + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => break, + } + + // Check if child exited + match waitpid(child_pid, Some(WaitPidFlag::WNOHANG)) { + Ok(WaitStatus::Exited(_, code)) => { + println!(" fcvm exited with code {}", code); + exit_status = Some(WaitStatus::Exited(child_pid, code)); + break; + } + Ok(WaitStatus::Signaled(_, sig, core)) => { + println!(" fcvm killed by signal {:?}", sig); + exit_status = Some(WaitStatus::Signaled(child_pid, sig, core)); + break; + } + _ => {} + } + + if start.elapsed() > timeout { + println!("TIMEOUT: fcvm didn't exit after Ctrl+C"); + kill(child_pid, Signal::SIGKILL).ok(); + anyhow::bail!("Timeout waiting for fcvm to handle Ctrl+C"); + } + + std::thread::sleep(Duration::from_millis(100)); + } + + let output_str = String::from_utf8_lossy(&output); + println!("\n=== Output ===\n{}", output_str); + + // Check exit status - fcvm should exit cleanly (code 0) after Ctrl+C + let exited_cleanly = matches!(exit_status, Some(WaitStatus::Exited(_, 0))); + + println!("\n=== Results ==="); + println!("Exit status: {:?}", exit_status); + println!("Clean exit after Ctrl+C: {}", exited_cleanly); + + if exited_cleanly { + println!("✓ SUCCESS: Ctrl+C via PTY caused clean exit!"); + Ok(()) + } else { + anyhow::bail!( + "FAILURE: fcvm did not exit cleanly after Ctrl+C. Status: {:?}", + exit_status + ) + } +} diff --git a/tests/test_health_monitor.rs b/tests/test_health_monitor.rs index fc6dce21..02249f59 100644 --- a/tests/test_health_monitor.rs +++ b/tests/test_health_monitor.rs @@ -70,7 +70,6 @@ async fn test_health_monitor_behaviors() { volumes: vec![], extra_disks: vec![], nfs_shares: vec![], - env: vec![], health_check_url: Some("http://localhost/health".to_string()), snapshot_name: None, process_type: Some(ProcessType::Vm), diff --git a/tests/test_state_manager.rs b/tests/test_state_manager.rs index b5c7747c..a167f0cb 100644 --- a/tests/test_state_manager.rs +++ b/tests/test_state_manager.rs @@ -43,7 +43,6 @@ async fn test_state_persistence() { volumes: vec![], extra_disks: vec![], nfs_shares: vec![], - env: vec![], health_check_url: None, snapshot_name: None, process_type: Some(ProcessType::Vm), @@ -61,15 +60,16 @@ async fn test_state_persistence() { // Note: VmStatus doesn't derive PartialEq, so we can't compare directly assert!(matches!(loaded.status, VmStatus::Running)); - // Verify file permissions are restrictive (Unix only) + // Verify file permissions are world-readable (Unix only) + // State files use 0o644 so non-root users can list VMs #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let state_file = temp_dir.path().join("test-vm-1.json"); let metadata = std::fs::metadata(&state_file).unwrap(); let permissions = metadata.permissions(); - // Check that only owner can read/write (0o600) - assert_eq!(permissions.mode() & 0o777, 0o600); + // Check permissions are world-readable (0o644) so non-root can list VMs + assert_eq!(permissions.mode() & 0o777, 0o644); } // Delete state @@ -108,7 +108,6 @@ async fn test_list_vms() { volumes: vec![], extra_disks: vec![], nfs_shares: vec![], - env: vec![], health_check_url: None, snapshot_name: None, process_type: Some(ProcessType::Vm),