diff --git a/Cargo.lock b/Cargo.lock index 44ff6036..3e222f23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,13 +463,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -480,7 +501,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -562,6 +583,7 @@ dependencies = [ "chrono", "clap", "criterion", + "directories", "fs2", "fuse-pipe", "hex", @@ -1796,6 +1818,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index b9a664ad..8403970e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ url = "2" tokio-util = "0.7" regex = "1.12.2" fs2 = "0.4.3" +directories = "5" [features] # Default: all integration tests that work without sudo (rootless networking) diff --git a/rootfs-plan.toml b/rootfs-config.toml similarity index 100% rename from rootfs-plan.toml rename to rootfs-config.toml diff --git a/src/cli/args.rs b/src/cli/args.rs index ad0fb456..6df09c96 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -32,7 +32,26 @@ pub enum Commands { /// Execute a command in a running VM Exec(ExecArgs), /// Setup kernel and rootfs (kernel ~15MB download, rootfs ~10GB creation, takes 5-10 minutes) - Setup, + Setup(SetupArgs), +} + +// ============================================================================ +// Setup Command +// ============================================================================ + +#[derive(Args, Debug)] +pub struct SetupArgs { + /// Generate default config file at ~/.config/fcvm/rootfs-config.toml and exit + #[arg(long)] + pub generate_config: bool, + + /// Overwrite existing config when using --generate-config + #[arg(long, requires = "generate_config")] + pub force: bool, + + /// Path to custom rootfs config file + #[arg(long)] + pub config: Option, } // ============================================================================ diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 7d3ecc66..b22f8b3e 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,10 +1,22 @@ use anyhow::{Context, Result}; +use crate::cli::args::SetupArgs; +use crate::setup::rootfs::generate_config; + /// Run setup to download kernel and create rootfs. /// /// This downloads the Kata kernel (~15MB) and creates the Layer 2 rootfs (~10GB). /// The rootfs creation downloads Ubuntu cloud image and installs podman, taking 5-10 minutes. -pub async fn cmd_setup() -> Result<()> { +pub async fn cmd_setup(args: SetupArgs) -> Result<()> { + // Handle --generate-config: write default config and exit + if args.generate_config { + let config_path = generate_config(args.force)?; + println!("Generated config at: {}", config_path.display()); + println!("\nCustomize the config file, then run:"); + println!(" fcvm setup"); + return Ok(()); + } + println!("Setting up fcvm (this may take 5-10 minutes on first run)..."); // Ensure kernel exists (downloads Kata kernel if missing) diff --git a/src/main.rs b/src/main.rs index 4b8ee8fd..376dd56f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,7 +73,7 @@ async fn main() -> Result<()> { Commands::Snapshot(args) => commands::cmd_snapshot(args).await, Commands::Snapshots => commands::cmd_snapshots().await, Commands::Exec(args) => commands::cmd_exec(args).await, - Commands::Setup => commands::cmd_setup().await, + Commands::Setup(args) => commands::cmd_setup(args).await, }; // Handle errors diff --git a/src/setup/rootfs.rs b/src/setup/rootfs.rs index c9550970..2cfffaa0 100644 --- a/src/setup/rootfs.rs +++ b/src/setup/rootfs.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +use directories::ProjectDirs; use nix::fcntl::{Flock, FlockArg}; use serde::Deserialize; use sha2::{Digest, Sha256}; @@ -9,8 +10,11 @@ use tracing::{debug, info, warn}; use crate::paths; -/// Plan file location (relative to workspace root) -const PLAN_FILE: &str = "rootfs-plan.toml"; +/// Config file name +const CONFIG_FILE: &str = "rootfs-config.toml"; + +/// Embedded default config (used by --generate-config) +const EMBEDDED_CONFIG: &str = include_str!("../../rootfs-config.toml"); /// Size of the Layer 2 disk image const LAYER2_SIZE: &str = "10G"; @@ -448,64 +452,129 @@ pub fn generate_setup_script(plan: &Plan) -> String { } // ============================================================================ -// Plan Loading and SHA256 +// Config File Loading // ============================================================================ -/// Find the plan file in the workspace -fn find_plan_file() -> Result { - // Try relative to current exe (for installed binary) - let exe_path = std::env::current_exe().context("getting current executable path")?; - let exe_dir = exe_path.parent().context("getting executable directory")?; +/// Generate default config file at XDG config directory. +/// +/// Writes the embedded default config to ~/.config/fcvm/rootfs-config.toml +pub fn generate_config(force: bool) -> Result { + let proj_dirs = ProjectDirs::from("", "", "fcvm") + .context("Could not determine config directory")?; + let config_dir = proj_dirs.config_dir(); + let config_path = config_dir.join(CONFIG_FILE); + + if config_path.exists() && !force { + bail!( + "Config file already exists at {}\n\n\ + Use --force to overwrite, or edit the existing file.", + config_path.display() + ); + } + + std::fs::create_dir_all(config_dir) + .with_context(|| format!("creating config directory: {}", config_dir.display()))?; + std::fs::write(&config_path, EMBEDDED_CONFIG) + .with_context(|| format!("writing config file: {}", config_path.display()))?; + + info!("Generated config at {}", config_path.display()); + Ok(config_path) +} + +/// Find the config file using the lookup chain. +/// +/// Lookup order: +/// 1. Explicit path (--config flag) +/// 2. XDG user config (~/.config/fcvm/rootfs-config.toml) +/// 3. System config (/etc/fcvm/rootfs-config.toml) +/// 4. Next to binary (development) +/// 5. ERROR (no embedded fallback) +pub fn find_config_file(explicit_path: Option<&str>) -> Result { + // 1. Explicit --config + if let Some(path) = explicit_path { + let p = PathBuf::from(path); + if !p.exists() { + bail!("Config file not found: {}", path); + } + return Ok(p); + } + + // 2. XDG user config + if let Some(proj_dirs) = ProjectDirs::from("", "", "fcvm") { + let p = proj_dirs.config_dir().join(CONFIG_FILE); + if p.exists() { + return Ok(p); + } + } - // Check various locations - let candidates = [ - exe_dir.join(PLAN_FILE), - exe_dir.join("..").join(PLAN_FILE), - exe_dir.join("../..").join(PLAN_FILE), - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(PLAN_FILE), - ]; - - for path in &candidates { - if path.exists() { - return path.canonicalize().context("canonicalizing plan file path"); + // 3. System config + let system = Path::new("/etc/fcvm").join(CONFIG_FILE); + if system.exists() { + return Ok(system); + } + + // 4. Next to binary (development) + if let Ok(exe) = std::env::current_exe() { + if let Some(exe_dir) = exe.parent() { + // Check next to binary + let p = exe_dir.join(CONFIG_FILE); + if p.exists() { + return Ok(p); + } + // Check parent directories (for development) + for parent in &[".", "..", "../.."] { + let p = exe_dir.join(parent).join(CONFIG_FILE); + if p.exists() { + return p.canonicalize().context("canonicalizing config path"); + } + } } } - // Fallback to CARGO_MANIFEST_DIR for development - let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(PLAN_FILE); + // 5. Check CARGO_MANIFEST_DIR for development builds + let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(CONFIG_FILE); if manifest_path.exists() { return Ok(manifest_path); } + // 5. Error with helpful message bail!( - "rootfs-plan.toml not found. Checked: {:?}", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - ) + "No rootfs config found.\n\n\ + Searched:\n \ + ~/.config/fcvm/{}\n \ + /etc/fcvm/{}\n \ + /{}\n\n\ + Generate the default config with:\n \ + fcvm setup --generate-config", + CONFIG_FILE, CONFIG_FILE, CONFIG_FILE + ); } -/// Load and parse the plan file -pub fn load_plan() -> Result<(Plan, String, String)> { - let plan_path = find_plan_file()?; - let plan_content = std::fs::read_to_string(&plan_path) - .with_context(|| format!("reading plan file: {}", plan_path.display()))?; +/// Load and parse the config file +pub fn load_config(explicit_path: Option<&str>) -> Result<(Plan, String, String)> { + let config_path = find_config_file(explicit_path)?; + let config_content = std::fs::read_to_string(&config_path) + .with_context(|| format!("reading config file: {}", config_path.display()))?; - // Compute SHA256 of plan content (first 12 chars for image naming) - let plan_sha = compute_sha256(plan_content.as_bytes()); - let plan_sha_short = plan_sha[..12].to_string(); + // Compute SHA256 of config content (first 12 chars for image naming) + let config_sha = compute_sha256(config_content.as_bytes()); + let config_sha_short = config_sha[..12].to_string(); - let plan: Plan = toml::from_str(&plan_content) - .with_context(|| format!("parsing plan file: {}", plan_path.display()))?; + let config: Plan = toml::from_str(&config_content) + .with_context(|| format!("parsing config file: {}", config_path.display()))?; info!( - plan_file = %plan_path.display(), - plan_sha = %plan_sha_short, - "loaded rootfs plan" + config_file = %config_path.display(), + config_sha = %config_sha_short, + "loaded rootfs config" ); - Ok((plan, plan_sha, plan_sha_short)) + Ok((config, config_sha, config_sha_short)) +} + +/// Legacy alias for load_config (for backward compatibility during migration) +pub fn load_plan() -> Result<(Plan, String, String)> { + load_config(None) } /// Compute SHA256 of bytes, return hex string diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 24b814b8..a9cdec83 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -147,12 +147,39 @@ impl Clone for TestLogger { pub const POLL_INTERVAL: Duration = Duration::from_millis(100); use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Once; use std::time::Duration; use tokio::time::sleep; /// Global counter for unique test IDs static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); +/// Ensure config is generated once per test run +static CONFIG_INIT: Once = Once::new(); + +/// Ensure the fcvm config file exists. +/// +/// Runs `fcvm setup --generate-config --force` once per test process to ensure +/// the config file exists at ~/.config/fcvm/rootfs-config.toml. +/// Uses std::sync::Once to ensure this runs only once even with parallel tests. +fn ensure_config_exists() { + CONFIG_INIT.call_once(|| { + let fcvm_path = find_fcvm_binary().expect("fcvm binary not found"); + let output = Command::new(&fcvm_path) + .args(["setup", "--generate-config", "--force"]) + .output() + .expect("failed to run fcvm setup --generate-config"); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("Failed to generate config: {}", stderr); + } + eprintln!( + ">>> Generated config at ~/.config/fcvm/rootfs-config.toml" + ); + }); +} + /// Check if we're running inside a container. /// /// Containers create marker files that we can use to detect containerized environments. @@ -349,6 +376,9 @@ pub async fn spawn_fcvm_with_logs( args: &[&str], name: &str, ) -> anyhow::Result<(tokio::process::Child, u32)> { + // Ensure config exists (runs once per test process) + ensure_config_exists(); + let fcvm_path = find_fcvm_binary()?; let final_args = maybe_add_test_flags(args);