diff --git a/completion/flake-ctl b/completion/flake-ctl index 24a3079..2973a2e 100755 --- a/completion/flake-ctl +++ b/completion/flake-ctl @@ -53,6 +53,14 @@ __flake_ctl_firecracker_network() { __comp_reply "help add init remove" } +__flake_ctl_firecracker_volume() { + if [ "${COMP_WORDS[3]}" = "export" ] || [ "${COMP_WORDS[3]}" = "release" ];then + __comp_reply_unused "--path --help" + return 0 + fi + __comp_reply "help export release" +} + __flake_ctl_show() { declare prev="${prev}" if [ "${prev}" = "--format" ];then @@ -166,12 +174,13 @@ __flake_ctl_main() { firecracker_network| \ firecracker_pull| \ firecracker_show| \ - firecracker_register) + firecracker_register| \ + firecracker_volume) __flake_ctl_complete_command "${comp}" && return 0 ;; firecracker_*) command="firecracker" && __comp_reply " - help network pull register remove show + help network pull register remove show volume " && return 0 ;; podman_*) diff --git a/doc/flake-ctl-firecracker-volume-export.rst b/doc/flake-ctl-firecracker-volume-export.rst new file mode 100644 index 0000000..cf6395d --- /dev/null +++ b/doc/flake-ctl-firecracker-volume-export.rst @@ -0,0 +1,68 @@ +FLAKE-CTL-FIRECRACKER-VOLUME-EXPORT(8) +====================================== + +NAME +---- + +**flake-ctl firecracker volume export** - Export a local path through NFS for firecracker guests + +SYNOPSIS +-------- + +.. code:: bash + + USAGE: + flake-ctl firecracker volume export --path + + OPTIONS: + --path + --help + +DESCRIPTION +----------- + +Export the given absolute host path through NFS for firecracker guest +access. The command writes a flake-pilot managed entry to +``/etc/exports`` for the private firecracker network ``172.16.0.0/24``. + +If the ``nfs-server`` systemd service is not running yet, it is +started. If it is already running, the export table is reloaded so the +new export becomes effective without waiting for a service restart. + +The command expects an existing directory path. As it modifies a +system-wide NFS configuration, the required privileged operations are +executed through **sudo** when needed. + +OPTIONS +------- + +--path + + Absolute directory path on the host to export through NFS + +FILES +----- + +* /etc/exports + +EXAMPLE +------- + +.. code:: bash + + $ flake-ctl firecracker volume export --path /some/local/path + +SEE ALSO +-------- + +flake-ctl-firecracker-volume-release(8), flake-ctl-firecracker-network-init(8), firecracker-pilot(8) + +AUTHOR +------ + +Marcus Schäfer + +COPYRIGHT +--------- + +(c) 2026, Marcus Schäfer diff --git a/doc/flake-ctl-firecracker-volume-release.rst b/doc/flake-ctl-firecracker-volume-release.rst new file mode 100644 index 0000000..cd4a8ff --- /dev/null +++ b/doc/flake-ctl-firecracker-volume-release.rst @@ -0,0 +1,65 @@ +FLAKE-CTL-FIRECRACKER-VOLUME-RELEASE(8) +======================================= + +NAME +---- + +**flake-ctl firecracker volume release** - Remove a local path from the firecracker NFS exports + +SYNOPSIS +-------- + +.. code:: bash + + USAGE: + flake-ctl firecracker volume release --path + + OPTIONS: + --path + --help + +DESCRIPTION +----------- + +Remove the flake-pilot managed NFS export entry for the given absolute +host path from ``/etc/exports`` and restart the ``nfs-server`` systemd +service so the updated export table becomes effective. + +Only entries previously managed by flake-pilot for the given path are +removed. As the command changes a system-wide NFS configuration, the +required privileged operations are executed through **sudo** when +needed. + +OPTIONS +------- + +--path + + Absolute host path to remove from the flake-pilot managed NFS exports + +FILES +----- + +* /etc/exports + +EXAMPLE +------- + +.. code:: bash + + $ flake-ctl firecracker volume release --path /some/local/path + +SEE ALSO +-------- + +flake-ctl-firecracker-volume-export(8), flake-ctl-firecracker-network-init(8), firecracker-pilot(8) + +AUTHOR +------ + +Marcus Schäfer + +COPYRIGHT +--------- + +(c) 2026, Marcus Schäfer diff --git a/doc/flake-ctl.rst b/doc/flake-ctl.rst index c0d25da..c820a99 100644 --- a/doc/flake-ctl.rst +++ b/doc/flake-ctl.rst @@ -58,7 +58,7 @@ is no option to select the mode: SEE ALSO -------- -podman-pilot(8), flake-ctl-init(8), flake-ctl-list(8), flake-ctl-podman-load(8), flake-ctl-podman-register(8), flake-ctl-podman-remove(8), flake-ctl-podman-show(8), firecracker-pilot(8), flake-ctl-firecracker-load(8), flake-ctl-firecracker-register(8), flake-ctl-firecracker-remove(8), flake-ctl-firecracker-show(8), flake-ctl-firecracker-network-init(8), flake-ctl-firecracker-network-add(8), flake-ctl-firecracker-network-remove(8) +podman-pilot(8), flake-ctl-init(8), flake-ctl-list(8), flake-ctl-podman-load(8), flake-ctl-podman-register(8), flake-ctl-podman-remove(8), flake-ctl-podman-show(8), firecracker-pilot(8), flake-ctl-firecracker-load(8), flake-ctl-firecracker-register(8), flake-ctl-firecracker-remove(8), flake-ctl-firecracker-show(8), flake-ctl-firecracker-network-init(8), flake-ctl-firecracker-network-add(8), flake-ctl-firecracker-network-remove(8), flake-ctl-firecracker-volume-export(8), flake-ctl-firecracker-volume-release(8) AUTHOR ------ diff --git a/flake-ctl/src/cli.rs b/flake-ctl/src/cli.rs index 7010eee..e4b7fcc 100644 --- a/flake-ctl/src/cli.rs +++ b/flake-ctl/src/cli.rs @@ -224,6 +224,11 @@ pub enum Firecracker { #[clap(subcommand)] command: Network, }, + /// Manage NFS exports for firecracker volumes + Volume { + #[clap(subcommand)] + command: Volume, + }, /// Remove application registration or entire VM #[clap(group( ArgGroup::new("remove").required(true).args(&["vm", "app"]), @@ -289,6 +294,22 @@ pub enum Network { }, } +#[derive(Subcommand)] +pub enum Volume { + /// Export a local path through NFS for firecracker guests + Export { + /// An absolute path on the host to export via NFS + #[clap(long)] + path: String, + }, + /// Remove an exported local path from the NFS configuration + Release { + /// An absolute path on the host to remove from the NFS exports + #[clap(long)] + path: String, + }, +} + #[derive(Subcommand)] pub enum Podman { /// Pull container diff --git a/flake-ctl/src/defaults.rs b/flake-ctl/src/defaults.rs index c2a85ef..0846a89 100644 --- a/flake-ctl/src/defaults.rs +++ b/flake-ctl/src/defaults.rs @@ -108,6 +108,16 @@ pub const IPTABLES_TOOL:&str = "iptables"; pub const IP_TOOL:&str = "ip"; +pub const NFS_EXPORTFS_TOOL:&str = + "exportfs"; +pub const NFS_EXPORTS_FILE:&str = + "/etc/exports"; +pub const NFS_SERVER_SERVICE:&str = + "nfs-server"; +pub const NFS_CLIENT_NETWORK:&str = + "172.16.0.0/24"; +pub const NFS_EXPORT_OPTIONS:&str = + "rw,sync,no_subtree_check,no_root_squash,insecure"; // Kernel switch to turn the host into a router. Required to // forward the traffic of a VM from its TUN/TAP device to the // outgoing interface of the host diff --git a/flake-ctl/src/firecracker.rs b/flake-ctl/src/firecracker.rs index 573eb4e..40b2697 100644 --- a/flake-ctl/src/firecracker.rs +++ b/flake-ctl/src/firecracker.rs @@ -24,10 +24,10 @@ // use flakes::config::get_flakes_dir; use std::ffi::OsStr; -use std::io::{self, Read}; +use std::io::{self, Read, Write}; use std::os::unix::fs::PermissionsExt; use std::process::{Command, Stdio}; -use tempfile::{Builder, TempDir}; +use tempfile::{Builder, NamedTempFile, TempDir}; use std::path::Path; use std::borrow::Cow; use std::fs; @@ -38,6 +38,9 @@ use crate::{app, app_config}; use crate::fetch::{fetch_file, send_request}; +const FLAKE_PILOT_NFS_EXPORT_MARKER: &str = + "# flake-pilot firecracker volume"; + pub fn get_registry_dir(usermode: bool) -> String { /*! Provide the toplevel firecracker registry directory @@ -695,6 +698,249 @@ pub fn run_as(program: &str, user: &str) -> Command { call } +pub fn export_volume(path: &str) -> bool { + /*! + Export the given host path via NFS for firecracker guests + !*/ + if ! validate_volume_export_path(path, true) { + return false + } + if ! update_nfs_exports(path, true) { + return false + } + if nfs_server_is_running() { + reload_nfs_exports() + } else { + start_nfs_server() + } +} + +pub fn release_volume(path: &str) -> bool { + /*! + Remove the given host path from the NFS exports + !*/ + if ! validate_volume_export_path(path, false) { + return false + } + if ! update_nfs_exports(path, false) { + return false + } + restart_nfs_server() +} + +fn validate_volume_export_path(path: &str, must_exist: bool) -> bool { + /*! + Validate the path used for an NFS volume export operation + !*/ + if ! path.starts_with('/') { + error!("Path {path:?} must be specified with an absolute path"); + return false + } + if path.contains('\n') || path.contains('\r') { + error!("Path {path:?} contains unsupported control characters"); + return false + } + if must_exist { + let volume_path = Path::new(path); + if ! volume_path.exists() { + error!("Volume path {path:?} does not exist"); + return false + } + if ! volume_path.is_dir() { + error!("Volume path {path:?} is not a directory"); + return false + } + } + true +} + +fn update_nfs_exports(path: &str, present: bool) -> bool { + /*! + Add or remove the flake-pilot managed NFS export entry + !*/ + let exports = match read_nfs_exports() { + Some(exports) => exports, + None => return false + }; + let desired_entry = nfs_export_entry(path); + let managed_entries: Vec<&str> = exports.lines().filter( + |line| is_flake_pilot_nfs_export(line, path) + ).collect(); + if present && managed_entries.len() == 1 + && managed_entries[0].trim() == desired_entry + { + info!("Keeping existing NFS export for {path}"); + return true + } + if ! present && managed_entries.is_empty() { + info!("No flake-pilot NFS export entry found for {path}"); + return true + } + + let mut updated_lines: Vec = exports.lines().filter( + |line| ! is_flake_pilot_nfs_export(line, path) + ).map(ToOwned::to_owned).collect(); + if present { + info!("Exporting {path} through NFS..."); + updated_lines.push(desired_entry); + } else { + info!("Releasing NFS export for {path}..."); + } + + let mut updated_exports = updated_lines.join("\n"); + if ! updated_exports.is_empty() { + updated_exports.push('\n'); + } + write_nfs_exports(&updated_exports) +} + +fn read_nfs_exports() -> Option { + /*! + Read the /etc/exports file + !*/ + match fs::read_to_string(defaults::NFS_EXPORTS_FILE) { + Ok(exports) => Some(exports), + Err(error) if error.kind() == io::ErrorKind::NotFound => Some(String::new()), + Err(error) => { + error!( + "Failed to read {}: {error}", + defaults::NFS_EXPORTS_FILE + ); + None + } + } +} + +fn write_nfs_exports(exports: &str) -> bool { + /*! + Write the /etc/exports file through a temporary copy + !*/ + let mut temp = match NamedTempFile::new_in(defaults::TEMP_DIR) { + Ok(temp) => temp, + Err(error) => { + error!( + "Failed to create temp file in {}: {error}", + defaults::TEMP_DIR + ); + return false + } + }; + if let Err(error) = temp.write_all(exports.as_bytes()) { + error!("Failed to write temp exports file: {error}"); + return false + } + if let Err(error) = temp.flush() { + error!("Failed to flush temp exports file: {error}"); + return false + } + let mut call = run_as("install", "root"); + call.arg("-m") + .arg("644") + .arg(temp.path()) + .arg(defaults::NFS_EXPORTS_FILE); + run_ok(&mut call, &format!("install {}", defaults::NFS_EXPORTS_FILE)) +} + +fn nfs_export_entry(path: &str) -> String { + /*! + Provide the managed NFS export line for the given path + !*/ + format!( + "{} {}({}) {}", + escape_nfs_export_path(path), + defaults::NFS_CLIENT_NETWORK, + defaults::NFS_EXPORT_OPTIONS, + FLAKE_PILOT_NFS_EXPORT_MARKER + ) +} + +fn escape_nfs_export_path(path: &str) -> String { + /*! + Escape whitespace in the path for /etc/exports + !*/ + path.replace('\\', "\\\\") + .replace(' ', "\\040") + .replace('\t', "\\011") +} + +fn is_flake_pilot_nfs_export(line: &str, path: &str) -> bool { + /*! + Check if the line is the managed flake-pilot export for path + !*/ + let trimmed = line.trim(); + trimmed.starts_with(&format!("{} ", escape_nfs_export_path(path))) + && trimmed.ends_with(FLAKE_PILOT_NFS_EXPORT_MARKER) +} + +fn nfs_server_is_running() -> bool { + /*! + Check if the NFS server systemd service is active + !*/ + let mut call = run_as("systemctl", "root"); + call.arg("is-active") + .arg("--quiet") + .arg(defaults::NFS_SERVER_SERVICE); + match call.status() { + Ok(status) => status.success(), + Err(error) => { + error!( + "Failed to query {}: {error:?}", + defaults::NFS_SERVER_SERVICE + ); + false + } + } +} + +fn start_nfs_server() -> bool { + /*! + Start the NFS server service + !*/ + info!("Starting {}...", defaults::NFS_SERVER_SERVICE); + let mut call = run_as("systemctl", "root"); + call.arg("start").arg(defaults::NFS_SERVER_SERVICE); + run_ok(&mut call, &format!("start {}", defaults::NFS_SERVER_SERVICE)) +} + +fn restart_nfs_server() -> bool { + /*! + Restart the NFS server service + !*/ + info!("Restarting {}...", defaults::NFS_SERVER_SERVICE); + let mut call = run_as("systemctl", "root"); + call.arg("restart").arg(defaults::NFS_SERVER_SERVICE); + run_ok(&mut call, &format!("restart {}", defaults::NFS_SERVER_SERVICE)) +} + +fn reload_nfs_exports() -> bool { + /*! + Reload the NFS exports of the running server + !*/ + info!("Reloading NFS exports..."); + let mut call = run_as(defaults::NFS_EXPORTFS_TOOL, "root"); + call.arg("-ra"); + run_ok(&mut call, "reload NFS exports") +} + +fn run_ok(call: &mut Command, action: &str) -> bool { + /*! + Run the given call and tell whether it succeeded + !*/ + match call.status() { + Ok(status) => { + if ! status.success() { + error!("Failed to {action}: {status}"); + return false + } + true + }, + Err(error) => { + error!("Failed to {action}: {error:?}"); + false + } + } +} + pub fn mkdir(dirname: &String, user: &str) -> bool { /*! Make directory diff --git a/flake-ctl/src/main.rs b/flake-ctl/src/main.rs index 778080b..d655c67 100644 --- a/flake-ctl/src/main.rs +++ b/flake-ctl/src/main.rs @@ -172,6 +172,21 @@ async fn main() -> Result> { } } }, + // volume + cli::Firecracker::Volume { command } => { + match &command { + cli::Volume::Export { path } => { + if ! firecracker::export_volume(path) { + return Ok(ExitCode::FAILURE) + } + }, + cli::Volume::Release { path } => { + if ! firecracker::release_volume(path) { + return Ok(ExitCode::FAILURE) + } + } + } + }, // remove cli::Firecracker::Remove { vm, app, force } => { if ! app.is_none() && ! app::remove( diff --git a/package/flake-pilot.spec b/package/flake-pilot.spec index 20bc76c..9a7bc92 100644 --- a/package/flake-pilot.spec +++ b/package/flake-pilot.spec @@ -90,6 +90,7 @@ Requires: rsync Requires: firecracker %endif Requires: e2fsprogs +Requires: nfs-kernel-server Requires: sudo Requires: xz @@ -267,6 +268,8 @@ fi %doc /usr/share/man/man8/flake-ctl-firecracker-network-init.8.gz %doc /usr/share/man/man8/flake-ctl-firecracker-network-add.8.gz %doc /usr/share/man/man8/flake-ctl-firecracker-network-remove.8.gz +%doc /usr/share/man/man8/flake-ctl-firecracker-volume-export.8.gz +%doc /usr/share/man/man8/flake-ctl-firecracker-volume-release.8.gz /usr/bin/firecracker-pilot %doc /usr/share/man/man8/firecracker-pilot.8.gz /usr/lib/flake-pilot/sci