From a8239bf2461b27de85961b16577323b66e705396 Mon Sep 17 00:00:00 2001 From: Kai Lueke Date: Fri, 10 Mar 2023 15:10:46 +0900 Subject: [PATCH] providers/vmware: Process guestinfo.metadata netplan configuration The network environment can be dynamic and thus needs to be provided as VM metadata. Since the format should not depend on whether the VM runs uses Ignition and Afterburn or Cloud-Init, the idea is to also support the guestinfo.metadata variable as used by Cloud-Init which contains Netplan YAML/JSON network configuration. Add a new command to write out netplan configs to a given directory, similar as we do with networkd units. While this is currently just used for VMware, other providers could also construct the netplan data type to provide netplan configurations if the OS rather wants to use NetworkManager than systemd-networkd. For backwards compatibility and to not need netplan it would be nice to keep the systemd-networkd support as long as its used. References: https://cloudinit.readthedocs.io/en/latest/reference/datasources/vmware.html#walkthrough-of-guestinfo-keys-transport https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html https://netplan.io/reference/ https://linux-on-z.blogspot.com/p/using-netplan-on-ibm-z.html --- Cargo.lock | 53 +++++++++++ Cargo.toml | 3 + src/cli/multi.rs | 9 ++ src/providers/mod.rs | 21 ++++ src/providers/vmware/amd64.rs | 174 +++++++++++++++++++++++++++++++++- src/providers/vmware/mod.rs | 6 ++ 6 files changed, 264 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d50c8e3..dbe641d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,27 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "afterburn" version = "5.4.3" dependencies = [ "anyhow", + "base64 0.21.2", "cfg-if", "clap", "ipnetwork", + "libflate", "libsystemd", "mailparse", "maplit", "mockito", + "netplan-types", "nix 0.27.1", "openssh-keys", "openssl", @@ -389,6 +398,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.8" @@ -939,6 +957,26 @@ version = "0.2.147" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" +[[package]] +name = "libflate" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97822bf791bd4d5b403713886a5fbe8bf49520fe78e323b0dc480ca1a03e50b0" +dependencies = [ + "adler32", + "crc32fast", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a52d3a8bfc85f250440e4424db7d857e241a3aebbbe301f3eb606ab15c39acbf" +dependencies = [ + "rle-decode-fast", +] + [[package]] name = "libsystemd" version = "0.6.0" @@ -1087,6 +1125,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "netplan-types" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb821b15b1ee694bd9f6192c832ab7f355c85e8f325a0e96bc3361a46890825" +dependencies = [ + "serde", +] + [[package]] name = "nix" version = "0.26.3" @@ -1472,6 +1519,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rustix" version = "0.37.19" diff --git a/Cargo.toml b/Cargo.toml index 5c1c8e03..f73a720e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,12 +35,15 @@ debug = true [dependencies] anyhow = "1.0" +base64 = "0.21" cfg-if = "1.0" clap = { version = "4", "default_features" = false, "features" = ["std", "cargo", "derive", "error-context", "help", "suggestions", "usage", "wrap_help"] } ipnetwork = ">= 0.17, < 0.21" +libflate = "1.3" libsystemd = ">= 0.2.1, < 0.7.0" mailparse = ">= 0.13, < 0.15" maplit = "1.0" +netplan-types = "0.3" nix = { version = ">= 0.19, < 0.28", "default_features" = false, "features" = [ "mount", "user"] } openssh-keys = ">= 0.5, < 0.7" openssl = ">= 0.10.46, < 0.11" diff --git a/src/cli/multi.rs b/src/cli/multi.rs index 9e32ef03..5aad9ffd 100644 --- a/src/cli/multi.rs +++ b/src/cli/multi.rs @@ -26,6 +26,9 @@ pub struct CliMulti { /// The directory into which network units are written #[arg(long = "network-units", value_name = "path")] network_units_dir: Option, + /// The directory into which netplan configs are written + #[arg(long = "netplan-configs", value_name = "path")] + netplan_config_dir: Option, /// Update SSH keys for the given user #[arg(long = "ssh-keys", value_name = "username")] ssh_keys_user: Option, @@ -41,6 +44,7 @@ impl CliMulti { if self.attributes_file.is_none() && self.network_units_dir.is_none() + && self.netplan_config_dir.is_none() && !self.check_in && self.ssh_keys_user.is_none() && self.hostname_file.is_none() @@ -72,6 +76,11 @@ impl CliMulti { .map_or(Ok(()), |x| metadata.write_network_units(x)) .context("writing network units")?; + // write netplan configs if configured to do so + self.netplan_config_dir + .map_or(Ok(()), |x| metadata.write_netplan_configs(x)) + .context("writing network units")?; + // perform boot check-in. if self.check_in { metadata diff --git a/src/providers/mod.rs b/src/providers/mod.rs index ad5365aa..59e46854 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -200,6 +200,10 @@ pub trait MetadataProvider { Ok(vec![]) } + fn netplan_config(&self) -> Result> { + Ok(None) + } + fn boot_checkin(&self) -> Result<()> { warn!("boot check-in requested, but not supported on this platform"); Ok(()) @@ -295,6 +299,23 @@ pub trait MetadataProvider { } Ok(()) } + + fn write_netplan_configs(&self, netplan_configs_dir: String) -> Result<()> { + let dir_path = Path::new(&netplan_configs_dir); + fs::create_dir_all(dir_path) + .with_context(|| format!("failed to create directory {dir_path:?}"))?; + + // Write a single afterburn `.yaml` netplan config. + if let Some(netplan_config) = &self.netplan_config()? { + let netplan_yaml = serde_yaml::to_string(&netplan_config)?; + let file_path = dir_path.join("afterburn.yaml"); + let mut config_file = File::create(&file_path) + .with_context(|| format!("failed to create file {file_path:?}"))?; + write!(&mut config_file, "{netplan_yaml}") + .with_context(|| format!("failed to write netplan config file {config_file:?}"))?; + } + Ok(()) + } } #[cfg(test)] diff --git a/src/providers/vmware/amd64.rs b/src/providers/vmware/amd64.rs index eabac33c..6355dee8 100644 --- a/src/providers/vmware/amd64.rs +++ b/src/providers/vmware/amd64.rs @@ -4,9 +4,14 @@ use super::VmwareProvider; use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use libflate::gzip::Decoder; +use std::io::Read; /// Guestinfo key for network kargs. static INITRD_NET_KARGS: &str = "guestinfo.afterburn.initrd.network-kargs"; +static METADATA: &str = "guestinfo.metadata"; +static METADATA_ENCODING: &str = "guestinfo.metadata.encoding"; impl VmwareProvider { /// Build the VMware provider, fetching and caching guestinfo entries. @@ -24,11 +29,24 @@ impl VmwareProvider { vmw_backdoor::probe_backdoor() })?; - let mut erpc = backdoor.open_enhanced_chan()?; + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; let guestinfo_net_kargs = Self::fetch_guestinfo(&mut erpc, INITRD_NET_KARGS)?; + drop(erpc); + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + + let guestinfo_metadata_raw = Self::fetch_guestinfo(&mut erpc, METADATA)?; + + drop(erpc); + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + + let guestinfo_metadata_encoding = Self::fetch_guestinfo(&mut erpc, METADATA_ENCODING)?; + let guestinfo_metadata = + parse_metadata(guestinfo_metadata_encoding, guestinfo_metadata_raw)?; + let provider = Self { guestinfo_net_kargs, + guestinfo_metadata, }; slog_scope::trace!("cached vmware provider: {:?}", provider); @@ -39,8 +57,160 @@ impl VmwareProvider { fn fetch_guestinfo(erpc: &mut vmw_backdoor::EnhancedChan, key: &str) -> Result> { let guestinfo = erpc .get_guestinfo(key.as_bytes()) - .context("failed to retrieve network kargs")? + .context(format!("failed to retrieve guestinfo for {}", key))? .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()); Ok(guestinfo) } + + pub fn parse_netplan_config(&self) -> Result> { + if let Some(metadata) = &self.guestinfo_metadata { + // To provide at least some sanity checking, parse the netplan config. + // First check if we have JSON userdata. + let netplan_config: netplan_types::NetplanConfig = + if let Ok(netplan_json_config) = serde_json::from_str(metadata) { + netplan_json_config + } else { + // The JSON parsing error is discarded, + // and if YAML parsing fails as well, we only return this error. + serde_yaml::from_str(metadata)? + }; + // Also check the version to make it easier for the OS to say that + // this mechanism is supported or not. + if netplan_config.network.version != 2 { + bail!("only netplan version 2 supported"); + } + Ok(Some(netplan_config)) + } else { + Ok(None) + } + } + + #[cfg(test)] + pub fn new_from_metadata(metadata: String) -> Result { + Ok(Self { + guestinfo_net_kargs: None, + guestinfo_metadata: Some(metadata), + }) + } +} + +fn parse_metadata( + guestinfo_metadata_encoding: Option, + guestinfo_metadata_raw: Option, +) -> Result> { + let guestinfo_metadata = + if let Some(guestinfo_metadata_encoding_val) = guestinfo_metadata_encoding { + match ( + guestinfo_metadata_encoding_val.as_str(), + guestinfo_metadata_raw, + ) { + ("base64" | "b64", Some(guestinfo_metadata_raw_val)) => { + let decoded = + general_purpose::STANDARD.decode(guestinfo_metadata_raw_val.as_bytes())?; + Some(String::from_utf8(decoded)?) + } + ("gzip+base64" | "gz+b64", Some(guestinfo_metadata_raw_val)) => { + let decoded = + general_purpose::STANDARD.decode(guestinfo_metadata_raw_val.as_bytes())?; + let mut decompressor = Decoder::new(decoded.as_slice())?; + let mut uncompressed = Vec::new(); + decompressor.read_to_end(&mut uncompressed)?; + Some(String::from_utf8(uncompressed)?) + } + ("", guestinfo_metadata_raw) => guestinfo_metadata_raw, + (&_, _) => bail!("unknown guestinfo.metadata.encoding"), + } + } else { + guestinfo_metadata_raw + }; + Ok(guestinfo_metadata) +} + +#[test] +fn test_netplan_json() { + let metadata = r#"{ + "network": { + "version": 2, + "ethernets": { + "nics": { + "match": { + "name": "ens*" + }, + "dhcp4": true + } + } + } + }"#; + let provider = VmwareProvider::new_from_metadata(metadata.to_owned()).unwrap(); + let netplan_config = provider.parse_netplan_config().unwrap(); + let netplan_yaml = serde_yaml::to_string(&netplan_config).unwrap(); + let expected = r#"network: + version: 2 + ethernets: + nics: + match: + name: ens* + dhcp4: true +"#; + assert_eq!(netplan_yaml, expected); +} + +#[test] +fn test_netplan_dhcp() { + let metadata = r#"network: + version: 2 + ethernets: + nics: + match: + name: ens* + dhcp4: true +"#; + let provider = VmwareProvider::new_from_metadata(metadata.to_owned()).unwrap(); + let netplan_config = provider.parse_netplan_config().unwrap(); + let netplan_yaml = serde_yaml::to_string(&netplan_config).unwrap(); + assert_eq!(metadata, netplan_yaml); +} + +#[test] +fn test_metadata_plain_1() { + let guestinfo_metadata_raw = Some("hello".to_owned()); + let parsed = parse_metadata(None, guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); +} + +#[test] +fn test_metadata_plain_2() { + let guestinfo_metadata_raw = Some("hello".to_owned()); + let parsed = parse_metadata(Some("".into()), guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); +} + +#[test] +fn test_metadata_base64() { + let guestinfo_metadata_raw = Some("aGVsbG8=".to_owned()); + let parsed = parse_metadata(Some("base64".into()), guestinfo_metadata_raw.clone()) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); + let parsed_b64 = parse_metadata(Some("b64".into()), guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed_b64, "hello"); +} + +#[test] +fn test_metadata_gzip_base64() { + let guestinfo_metadata_raw = Some("H4sIAAAAAAACA8tIzcnJBwCGphA2BQAAAA==".to_owned()); + let parsed = parse_metadata(Some("gzip+base64".into()), guestinfo_metadata_raw.clone()) + .unwrap() + .unwrap(); + assert_eq!(parsed, "hello"); + let parsed_b64 = parse_metadata(Some("gz+b64".into()), guestinfo_metadata_raw) + .unwrap() + .unwrap(); + assert_eq!(parsed_b64, "hello"); } diff --git a/src/providers/vmware/mod.rs b/src/providers/vmware/mod.rs index be4015d4..3443c411 100644 --- a/src/providers/vmware/mod.rs +++ b/src/providers/vmware/mod.rs @@ -11,6 +11,8 @@ use crate::providers::MetadataProvider; pub struct VmwareProvider { /// External network kargs for initrd. guestinfo_net_kargs: Option, + /// Cloud-Init metadata for netplan YAML + guestinfo_metadata: Option, } // Architecture-specific implementation. @@ -30,4 +32,8 @@ impl MetadataProvider for VmwareProvider { fn rd_network_kargs(&self) -> Result> { Ok(self.guestinfo_net_kargs.clone()) } + + fn netplan_config(&self) -> Result> { + self.parse_netplan_config() + } }