diff --git a/Cargo.lock b/Cargo.lock index 5079a099..342ded26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,14 +2,22 @@ # 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", @@ -389,6 +397,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 +956,26 @@ version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +[[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" @@ -1472,6 +1509,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.25" diff --git a/Cargo.toml b/Cargo.toml index 5c1c8e03..e916c5aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,9 +35,11 @@ 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" diff --git a/docs/development/distro.md b/docs/development/distro.md index 8cd47290..2ceda13f 100644 --- a/docs/development/distro.md +++ b/docs/development/distro.md @@ -14,3 +14,16 @@ Alternatively, sshd can be configured to read the fragment file directly: ``` AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys.d/afterburn ``` + +## VMware Netplan guestinfo metadata + +The `guestinfo.metadata` and `guestinfo.metadata.encoding` fields can contain a Netplan configuration provided by the VM provisioning logic. +Netplan is required on the OS to render the Netplan format to either NetworkManager or systemd-networkd configuration files. By default, Netplan generates systemd-networkd units. Since the renderer backend is defined in the Netplan config itself, requiring NetworkManager in the config would rule out support for systems that don't use it (unless they would ship a drop-in file with later lexicographical ordering to force it to `networkd`). As systemd-networkd can work in parallel with NetworkManager, it's expected that the renderer field is left to its default but systems can also add a default drop-in file with early lexicographical ordering to prefer NetworkManager. + +The Afterburn invocation is as follows, where `FOLDER` could be `/run/netplan/`: + +``` +afterburn multi --netplan-configs FOLDER --provider vmware +``` + +Afterwards, `netplan generate` can be used to render the config files. If that is done before `systemd-networkd` runs, this is enough, but if the network already is up, `netplan apply` should be used instead. diff --git a/docs/index.md b/docs/index.md index 1d32bc07..9cafbc75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ It comprises several modules which may run at different times during the lifecyc Depending on the specific platform, the following services may run in the [initramfs](https://github.com/coreos/afterburn/tree/main/dracut/30afterburn) on first boot: * setting local hostname * injecting [network command-line arguments](usage/initrd-network-cmdline.md) + * configuring the network with [Netplan guestinfo metadata on VMware](usage/vmware-netplan-guestinfo-metadata.md) The following features are conditionally available on some platforms as [systemd service units](https://github.com/coreos/afterburn/tree/main/systemd): * installing public SSH keys for local system users diff --git a/docs/release-notes.md b/docs/release-notes.md index adbaa8a3..8f64ffc9 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -9,6 +9,7 @@ nav_order: 8 Major changes: - Add support for Scaleway +- Add Netplan guestinfo support on VMware Minor changes: diff --git a/docs/usage.md b/docs/usage.md index ef108f10..e7c9cc03 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,6 +10,10 @@ has_toc: false See [Initrd first-boot network arguments](usage/initrd-network-cmdline.md). +## VMware Netplan guestinfo metadata + +See [VMware Netplan guestinfo metadata](usage/vmware-netplan-guestinfo-metadata.md). + ## Metadata attributes See [Metadata attributes](usage/attributes.md). diff --git a/docs/usage/vmware-netplan-guestinfo-metadata.md b/docs/usage/vmware-netplan-guestinfo-metadata.md new file mode 100644 index 00000000..51f915f7 --- /dev/null +++ b/docs/usage/vmware-netplan-guestinfo-metadata.md @@ -0,0 +1,27 @@ +--- +nav_order: 2 +parent: Usage +--- + +# VMware Netplan guestinfo metadata + +The network environment can vary between VMware servers and instead of leaking these requirements into userdata snippets, a well-known guestinfo metadata field can be used. +The guestinfo metadata field is OS-independent and supported by cloud-init (spec [here](https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html), example [here](https://cloudinit.readthedocs.io/en/latest/reference/datasources/vmware.html#walkthrough-of-guestinfo-keys-transport)) and Afterburn. When the OS supports this mechanism the user can provide Netplan configs which the OS renders using the backend of choice. + +## Specifying the guestinfo metadata + +The guestinfo keys are named `guestinfo.metadata` for the content and `guestinfo.metadata.encoding` to specify the encoding of the content. +The value of the encoding field can be empty to indicate raw string data, or one of `base64` or `b64` to indicate an base64 encoding, or one of `gzip+base64` or `gz+b64` to indicate base64-encoded gzip data. + +An example for raw string data is the following: +``` +network: + version: 2 + ethernets: + nics: + match: + name: ens* + dhcp4: yes +``` + +The supported config format with examples can be found in the [Netplan specification](https://netplan.readthedocs.io/en/latest/netplan-yaml/). diff --git a/src/cli/multi.rs b/src/cli/multi.rs index 9e32ef03..5ac4b641 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 a netplan config is written + #[arg(long = "netplan-config", 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 config if configured to do so + self.netplan_config_dir + .map_or(Ok(()), |x| metadata.write_netplan_config(x)) + .context("writing netplan config")?; + // perform boot check-in. if self.check_in { metadata diff --git a/src/providers/mod.rs b/src/providers/mod.rs index ad5365aa..0fb01f4a 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,22 @@ pub trait MetadataProvider { } Ok(()) } + + fn write_netplan_config(&self, netplan_config_dir: String) -> Result<()> { + let dir_path = Path::new(&netplan_config_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 file_path = dir_path.join("50-afterburn.yaml"); + let mut config_file = File::create(&file_path) + .with_context(|| format!("failed to create file {file_path:?}"))?; + write!(&mut config_file, "{netplan_config}") + .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..9a0765c7 100644 --- a/src/providers/vmware/amd64.rs +++ b/src/providers/vmware/amd64.rs @@ -4,9 +4,15 @@ use super::VmwareProvider; use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose, Engine as _}; +use libflate::gzip::Decoder; +use serde_json::json; +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 +30,28 @@ impl VmwareProvider { vmw_backdoor::probe_backdoor() })?; - let mut erpc = backdoor.open_enhanced_chan()?; - let guestinfo_net_kargs = Self::fetch_guestinfo(&mut erpc, INITRD_NET_KARGS)?; + let guestinfo_net_kargs = { + // Use a block, otherwise we would have to drop(erpc) manually + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + Self::fetch_guestinfo(&mut erpc, INITRD_NET_KARGS)? + }; + + let guestinfo_metadata_raw = { + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + Self::fetch_guestinfo(&mut erpc, METADATA)? + }; + + let guestinfo_metadata_encoding = { + let mut erpc = vmw_backdoor::EnhancedChan::open(&mut backdoor)?; + 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 +62,144 @@ 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")? + .with_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 { + // We need to parse the netplan config to remove the non-Netplan keys. + // The data can either be JSON or YAML, but since JSON is a subset of + // YAML we don't need to try serde_json::from_str here. + let netplan_config_unfiltered: serde_json::Value = + serde_yaml::from_str(metadata).context("invalid YAML/JSON metadata")?; + // Only the "network" key is allowed to be present. + // We use the json! macro but this is only about creating a serde value::Value, + // even though its name sounds like it would create JSON. + let netplan_config_filtered = json!({ + "network": netplan_config_unfiltered.get("network").context("no 'network' key found")? + }); + Ok(Some(serde_yaml::to_string(&netplan_config_filtered)?)) + } 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> { + match ( + guestinfo_metadata_encoding.as_deref(), + guestinfo_metadata_raw, + ) { + (Some("base64" | "b64"), Some(guestinfo_metadata_raw_val)) => { + let decoded = + general_purpose::STANDARD.decode(guestinfo_metadata_raw_val.as_bytes())?; + Ok(Some(String::from_utf8(decoded)?)) + } + (Some("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)?; + Ok(Some(String::from_utf8(uncompressed)?)) + } + (Some(""), guestinfo_metadata_raw) => Ok(guestinfo_metadata_raw), + (Some(encoding), _) => bail!("unknown guestinfo.metadata.encoding '{}'", encoding), + (None, guestinfo_metadata_raw) => Ok(guestinfo_metadata_raw), + } +} + +#[test] +fn test_netplan_json() { + let metadata = r#"{ + "network": { + "ethernets": { + "nics": { + "match": { + "name": "ens*" + } + } + } + }, + "ExcludeNonNetplanField": 0 + }"#; + let provider = VmwareProvider::new_from_metadata(metadata.to_owned()).unwrap(); + let netplan_config = provider.parse_netplan_config().unwrap().unwrap(); + let expected = r#"network: + ethernets: + nics: + match: + name: ens* +"#; + assert_eq!(netplan_config, expected); +} + +#[test] +fn test_netplan_dhcp() { + let metadata = r#"network: + ethernets: + nics: + match: + name: ens* +"#; + let provider = VmwareProvider::new_from_metadata(metadata.to_owned()).unwrap(); + let netplan_config = provider.parse_netplan_config().unwrap().unwrap(); + assert_eq!(netplan_config, metadata); +} + +#[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..7d3fa696 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() + } }