Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
File renamed without changes.
21 changes: 20 additions & 1 deletion src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

// ============================================================================
Expand Down
14 changes: 13 additions & 1 deletion src/commands/setup.rs
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 110 additions & 41 deletions src/setup/rootfs.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::{bail, Context, Result};
use directories::ProjectDirs;
use nix::fcntl::{Flock, FlockArg};
use serde::Deserialize;
use sha2::{Digest, Sha256};
Expand All @@ -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";
Expand Down Expand Up @@ -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<PathBuf> {
// 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<PathBuf> {
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<PathBuf> {
// 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::<Vec<_>>()
)
"No rootfs config found.\n\n\
Searched:\n \
~/.config/fcvm/{}\n \
/etc/fcvm/{}\n \
<binary-dir>/{}\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
Expand Down
30 changes: 30 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down
Loading