diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..2a54eab38e --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +rust/target diff --git a/rust/src/cli/ncl.rs b/rust/src/cli/ncl.rs index 16f38631c9..42ce18749c 100644 --- a/rust/src/cli/ncl.rs +++ b/rust/src/cli/ncl.rs @@ -9,6 +9,8 @@ mod format; #[cfg(feature = "gen_conf")] mod gen_conf; #[cfg(feature = "query_apply")] +pub(crate) mod persist_nic; +#[cfg(feature = "query_apply")] mod policy; #[cfg(feature = "query_apply")] mod query; @@ -49,6 +51,7 @@ const SUB_CMD_EDIT: &str = "edit"; const SUB_CMD_VERSION: &str = "version"; const SUB_CMD_AUTOCONF: &str = "autoconf"; const SUB_CMD_SERVICE: &str = "service"; +const SUB_CMD_PERSIST_NIC_NAMES: &str = "persist-nic-names"; const SUB_CMD_POLICY: &str = "policy"; const SUB_CMD_FORMAT: &str = "format"; @@ -61,7 +64,7 @@ fn main() { print_result_and_exit(autoconf(&argv[1..])); } - let matches = clap::Command::new(APP_NAME) + let mut app = clap::Command::new(APP_NAME) .version(clap::crate_version!()) .author("Gris Ge ") .about("Command line of nmstate") @@ -317,7 +320,40 @@ fn main() { .subcommand( clap::Command::new(SUB_CMD_VERSION) .about("Show version") - ).get_matches(); + ); + if cfg!(feature = "query_apply") { + app = app.subcommand( + clap::Command::new(SUB_CMD_PERSIST_NIC_NAMES) + .about("Generate .link files which persist active network interfaces to their current names") + .arg( + clap::Arg::new("DRY_RUN") + .long("dry-run") + .takes_value(false) + .help( + "Only output changes that would be made", + ), + ) + .arg( + clap::Arg::new("INSPECT") + .long("inspect") + .takes_value(false) + .help( + "Print the state of any persisted nics", + ), + ) + .arg( + clap::Arg::new("ROOT") + .long("root") + .short('r') + .required(false) + .takes_value(true) + .default_value("/") + .help("Target root filesystem for writing state"), + ) + // We don't want to expose this outside of OCP yet + .hide(true)); + }; + let matches = app.get_matches(); let (log_module_filters, log_level) = match matches.occurrences_of("verbose") { 0 => (vec!["nmstate", "nm_dbus"], LevelFilter::Info), @@ -387,6 +423,26 @@ fn main() { APP_NAME, clap::crate_version!() ))); + } else { + // Conditionally-built commands + #[cfg(feature = "query_apply")] + if let Some(matches) = + matches.subcommand_matches(SUB_CMD_PERSIST_NIC_NAMES) + { + let action = + if matches.try_contains_id("DRY_RUN").unwrap_or_default() { + persist_nic::PersistAction::DryRun + } else if matches.try_contains_id("INSPECT").unwrap_or_default() + { + persist_nic::PersistAction::Inspect + } else { + persist_nic::PersistAction::Save + }; + print_result_and_exit(crate::persist_nic::run_persist_immediately( + matches.value_of("ROOT").unwrap(), + action, + )); + } } } diff --git a/rust/src/cli/persist_nic.rs b/rust/src/cli/persist_nic.rs new file mode 100644 index 0000000000..e138c3d54e --- /dev/null +++ b/rust/src/cli/persist_nic.rs @@ -0,0 +1,275 @@ +//! # Handling writing .link files for NICs +//! +//! This module implements logic for generating systemd [`.link`] files +//! based on active networking state. +//! +//! The logic currently is: +//! +//! - Iterate over all active NICs +//! - Skip any that don't have a MAC address (hence are likely non-physical) +//! - Skip any that are virtual (by looking for devices/virtual) +//! - Persist any that have a static IP address assigned (no DHCP) +//! - Persist any that are subordinate (part of a bridge/bond) +//! - Otherwise, skip +//! +//! ## Known broken cases +//! +//! - bond devices over DHCP +//! +//! [`.link`]: https://www.freedesktop.org/software/systemd/man/systemd.link.html +use std::path::Path; + +use nmstate::{Interface, InterfaceType, NetworkState}; + +use crate::error::CliError; + +/// Comment added into our generated link files +const PERSIST_GENERATED_BY: &str = "# Generated by nmstate"; +/// The file prefix for our generated persisted NIC names. +/// 98 here is important as it should be invoked after others but before +/// 99-default.link +const PERSIST_FILE_PREFIX: &str = "98-nmstate"; +/// See https://www.freedesktop.org/software/systemd/man/systemd.link.html +const SYSTEMD_NETWORK_LINK_FOLDER: &str = "etc/systemd/network"; +/// File which if present signals that we have already performed NIC name persistence. +const NMSTATE_PERSIST_STAMP: &str = ".nmstate-persist.stamp"; + +/// The action to take +pub(crate) enum PersistAction { + /// Persist NIC name state + Save, + /// Print what we would do in Save mode + DryRun, + /// Output any persisted state + Inspect, +} + +fn gather_state() -> Result { + let mut state = NetworkState::new(); + state.set_kernel_only(true); + state.set_running_config_only(true); + state.retrieve()?; + Ok(state) +} + +fn device_is_virtual(iface: &Interface) -> bool { + let name = iface.name(); + let p = format!("/sys/class/net/{name}"); + match std::fs::read_link(&p) { + Ok(r) => match r.to_str() { + Some(s) => s.contains("devices/virtual"), + None => { + log::warn!("Invalid link with non-UTF8 {p}"); + false + } + }, + Err(e) => { + log::warn!("Failed to readlink {p}: {e}"); + false + } + } +} + +enum PersistenceReason { + StaticAddressing, + Subordinate, +} + +fn should_persist_nic_for(iface: &Interface) -> Option { + let base_iface = iface.base_iface(); + let name = iface.name(); + + if device_is_virtual(iface) { + log::debug!("Skipping virtual device {name}"); + return None; + } + + let ipv4_manual = base_iface + .ipv4 + .as_ref() + .map(|ip| ip.is_static()) + .unwrap_or_default(); + let ipv6_manual = base_iface + .ipv6 + .as_ref() + .map(|ip| ip.is_static()) + .unwrap_or_default(); + if ipv4_manual || ipv6_manual { + return Some(PersistenceReason::StaticAddressing); + } + + // Unconditionally persist NICs that are subordinate e.g. + // - Bonds over DHCP + // - OpenShift cases where the primary NIC is moved into `ovs-system` + // Because there is likely external configuration referencing the NIC name + if let Some(controller) = base_iface.controller.as_deref() { + log::debug!("Device {name} is subordinate to controller {controller}"); + return Some(PersistenceReason::Subordinate); + } + + None +} + +fn process_interfaces(state: &NetworkState, mut f: F) -> Result<(), CliError> +where + F: FnMut(&nmstate::Interface, &str) -> Result<(), CliError>, +{ + for iface in state + .interfaces + .iter() + .filter(|i| i.iface_type() == InterfaceType::Ethernet) + { + let name = iface.name(); + let iface_name = iface.name(); + let mac = match iface.base_iface().mac_address.as_ref() { + Some(c) => c, + None => continue, + }; + if let Some(reason) = should_persist_nic_for(iface) { + match reason { + PersistenceReason::StaticAddressing => { + log::info!("Device {name} has static address"); + } + PersistenceReason::Subordinate => { + log::info!("Device {name} is subordinate"); + } + } + } else { + log::info!("Skipping interface {iface_name}"); + continue; + } + + f(iface, mac.as_str())?; + } + Ok(()) +} + +/// For all active interfaces, write a systemd .link file which persists the currently +/// active name. +pub(crate) fn run_persist_immediately( + root: &str, + action: PersistAction, +) -> Result { + let dry_run = match action { + PersistAction::Save => false, + PersistAction::DryRun => true, + PersistAction::Inspect => return inspect(root), + }; + + let stamp_path = Path::new(root) + .join(SYSTEMD_NETWORK_LINK_FOLDER) + .join(NMSTATE_PERSIST_STAMP); + if stamp_path.exists() { + log::info!("{} exists; nothing to do", stamp_path.display()); + return Ok("".to_string()); + } + + let state = gather_state()?; + let mut changed = false; + process_interfaces(&state, |iface, mac| { + let iface_name = iface.name(); + let action = if dry_run { + "Would persist" + } else { + "Persisting" + }; + log::info!( + "{action} the interface with MAC {mac} to \ + interface name {iface_name}" + ); + if !dry_run { + changed |= + persist_iface_name_via_systemd_link(root, mac, iface.name())?; + } + Ok(()) + })?; + + if !changed { + log::info!("No changes."); + } + + if !dry_run { + std::fs::write(stamp_path, b"")?; + } + + Ok("".to_string()) +} + +pub(crate) fn inspect(root: &str) -> Result { + let netdir = Path::new(root).join(SYSTEMD_NETWORK_LINK_FOLDER); + let stamp_path = netdir.join(NMSTATE_PERSIST_STAMP); + if !stamp_path.exists() { + log::info!( + "{} does not exist, no prior persisted state", + stamp_path.display() + ); + return Ok("".to_string()); + } + + let mut n = 0; + for e in netdir.read_dir()? { + let e = e?; + let name = e.file_name(); + let name = if let Some(n) = name.to_str() { + n + } else { + continue; + }; + if !name.ends_with(".link") { + continue; + } + if !name.starts_with(PERSIST_FILE_PREFIX) { + continue; + } + log::info!("Found persisted NIC file: {name}"); + n += 1; + } + if n == 0 { + log::info!("No persisted NICs found"); + } + + let state = gather_state()?; + process_interfaces(&state, |iface, mac| { + let iface_name = iface.name(); + log::info!( + "NOTE: would persist the interface with MAC {mac} to interface name {iface_name}" + ); + Ok(()) + })?; + + Ok("".to_string()) +} + +fn persist_iface_name_via_systemd_link( + root: &str, + mac: &str, + iface_name: &str, +) -> Result { + let link_dir = Path::new(root).join(SYSTEMD_NETWORK_LINK_FOLDER); + + let file_path = + link_dir.join(format!("{PERSIST_FILE_PREFIX}-{iface_name}.link")); + if file_path.exists() { + log::info!("Network link file {} already exists", file_path.display()); + return Ok(false); + } + + if !link_dir.exists() { + std::fs::create_dir(&link_dir)?; + } + + let content = + format!("{PERSIST_GENERATED_BY}\n[Match]\nMACAddress={mac}\n\n[Link]\nName={iface_name}\n"); + + std::fs::write(&file_path, content.as_bytes()).map_err(|e| { + CliError::from(format!( + "Failed to store captured states to file {}: {e}", + file_path.display() + )) + })?; + log::info!( + "Systemd network link file created at {}", + file_path.display() + ); + Ok(true) +} diff --git a/rust/src/cli/service.rs b/rust/src/cli/service.rs index 1ddf00914e..1b2e208576 100644 --- a/rust/src/cli/service.rs +++ b/rust/src/cli/service.rs @@ -89,7 +89,7 @@ fn get_config_files(folder: &str) -> Result, CliError> { } // rename file by adding a suffix `.applied`. -fn relocate_file(file_path: &Path) -> Result<(), CliError> { +pub(crate) fn relocate_file(file_path: &Path) -> Result<(), CliError> { let new_path = file_path.with_extension(RELOCATE_FILE_EXTENTION); std::fs::rename(file_path, &new_path)?; log::info!( diff --git a/rust/src/clib/Cargo.toml b/rust/src/clib/Cargo.toml index b7756acc03..b64ac722f0 100644 --- a/rust/src/clib/Cargo.toml +++ b/rust/src/clib/Cargo.toml @@ -11,6 +11,7 @@ rust-version = "1.58" name = "nmstate" path = "lib.rs" crate-type = ["cdylib", "staticlib"] +doc = false [dependencies] nmstate = { path = "../lib", default-features = false } diff --git a/rust/src/lib/ip.rs b/rust/src/lib/ip.rs index 247acc5235..8bb99b33a3 100644 --- a/rust/src/lib/ip.rs +++ b/rust/src/lib/ip.rs @@ -196,7 +196,7 @@ impl InterfaceIpv4 { self.enabled && self.dhcp == Some(true) } - pub(crate) fn is_static(&self) -> bool { + pub fn is_static(&self) -> bool { self.enabled && !self.is_auto() && !self.addresses.as_deref().unwrap_or_default().is_empty() @@ -519,7 +519,7 @@ impl InterfaceIpv6 { self.enabled && (self.dhcp == Some(true) || self.autoconf == Some(true)) } - pub(crate) fn is_static(&self) -> bool { + pub fn is_static(&self) -> bool { self.enabled && !self.is_auto() && !self.addresses.as_deref().unwrap_or_default().is_empty()