diff --git a/Cargo.lock b/Cargo.lock index 5789987a7..a0e09ddff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1210,6 +1210,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764" +[[package]] +name = "fatfs" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05669f8e7e2d7badc545c513710f0eba09c2fbef683eb859fd79c46c355048e0" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "chrono", + "log", +] + [[package]] name = "filetime" version = "0.2.22" @@ -3213,6 +3225,7 @@ dependencies = [ "crucible-client-types", "ctrlc", "erased-serde", + "fatfs", "futures", "libc", "propolis", diff --git a/Cargo.toml b/Cargo.toml index 5148750ee..78179351c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,7 @@ dropshot = { git = "https://github.com/oxidecomputer/dropshot", branch = "main" erased-serde = "0.3" errno = "0.2.8" expectorate = "1.0.5" +fatfs = "0.3.6" futures = "0.3" hex = "0.4.3" http = "0.2.9" diff --git a/bin/propolis-standalone/Cargo.toml b/bin/propolis-standalone/Cargo.toml index 2c8b3b994..b406bae5c 100644 --- a/bin/propolis-standalone/Cargo.toml +++ b/bin/propolis-standalone/Cargo.toml @@ -3,6 +3,7 @@ name = "propolis-standalone" version = "0.1.0" license = "MPL-2.0" edition = "2021" +rust-version = "1.73" [[bin]] name = "propolis-standalone" @@ -16,6 +17,7 @@ atty.workspace = true bhyve_api.workspace = true clap = { workspace = true, features = ["derive"] } ctrlc.workspace = true +fatfs.workspace = true futures.workspace = true libc.workspace = true toml.workspace = true diff --git a/bin/propolis-standalone/README.md b/bin/propolis-standalone/README.md index a82d7173e..fa6048bde 100644 --- a/bin/propolis-standalone/README.md +++ b/bin/propolis-standalone/README.md @@ -251,3 +251,46 @@ Certain fields in `cpuid` data depend on aspects specific to the host (such as vCPU count) or the vCPU they are associated with (such as APIC ID). Propolis will "specialize" the data provided in the `cpuid` profile with logic appropriate for the specific leafs involved. + +## Configuring Cloud-Init + +Propolis is able to assemble a disk image formatted in the +[NoCloud](https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html) +fashion to be consumed by `cloud-init` inside the guest. An example of such configuration is as follows: +```toml +# ... other configuration bits + +# Define a disk device to bear the cloud-init data +[dev.cloudinit] +driver = "pci-virtio-block" +pci-path = "0.16.0" +block_dev = "cloudinit_be" + +# Define the backend to that disk as the cloudinit type +[block_dev.cloudinit_be] +type = "cloudinit" + +# Data from this cloudinit section will be used to populate the above block_dev +[cloudinit] +user-data = ''' +#cloud-config +users: +- default +- name: test + sudo: 'ALL=(ALL) NOPASSWD:ALL' + lock_passwd: false + hashed_passwd: '$6$rounds=4096$MBW/3OrwWLifnv30$QM.oCQ3pzV7X4EToX9IyZmplvaTgpZ6YJ50MhQrwlryj1soqBW5zvraVttYwfyWdxigHpZHTjY9kT.029UOEn1' +''' +# Instead of specifying string data like above, a path to a file can be used too: +# user-data-path = "path/to/file" + +# Instance metadata is configured the same way: +# meta-data = "..." +# or +# meta-data-path = "path/to/file" + +# Same with network configuration: +# network-config = "..." +# or +# network-config-path = "path/to/file" +``` diff --git a/bin/propolis-standalone/src/cidata.rs b/bin/propolis-standalone/src/cidata.rs new file mode 100644 index 000000000..b4723e3c5 --- /dev/null +++ b/bin/propolis-standalone/src/cidata.rs @@ -0,0 +1,103 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::io::{Cursor, Write}; +use std::sync::Arc; + +use anyhow::{bail, Context}; +use fatfs::{FileSystem, FormatVolumeOptions, FsOptions}; + +use propolis::block; +use propolis_standalone_config::Config; + +const SECTOR_SZ: usize = 512; +const VOLUME_LABEL: [u8; 11] = *b"cidata "; + +pub(crate) fn build_cidata_be( + config: &Config, +) -> anyhow::Result> { + let cidata = &config + .cloudinit + .as_ref() + .ok_or_else(|| anyhow::anyhow!("missing [cloudinit] config section"))?; + + let fields = [ + ("user-data", &cidata.user_data, &cidata.user_data_path), + ("meta-data", &cidata.meta_data, &cidata.meta_data_path), + ("network-config", &cidata.network_config, &cidata.network_config_path), + ]; + let all_data = fields + .into_iter() + .map(|(name, str_data, path_data)| { + Ok(( + name, + match (str_data, path_data) { + (None, None) => vec![], + (None, Some(path)) => std::fs::read(path).context( + format!("unable to read {name} from {path}"), + )?, + (Some(data), None) => data.clone().into(), + (Some(_), Some(_)) => { + bail!("cannot provide path and string for {name}"); + } + }, + )) + }) + .collect::, _>>()?; + + let file_sectors: usize = + all_data.iter().map(|(_, data)| data.len().div_ceil(SECTOR_SZ)).sum(); + // vfat can hold more data than this, but we don't expect to ever need that + // for cloud-init purposes. + if file_sectors > 512 { + bail!("too much vfat data: {file_sectors} > 512 sectors"); + } + + // Copying the match already done for this in Omicron: + // + // if we're storing < 341 KiB of clusters, the overhead is 37. With a limit + // of 512 sectors (error check above), we can assume an overhead of 37. + // Additionally, fatfs refuses to format a disk that is smaller than 42 + // sectors. + let sectors = 42.max(file_sectors + 37); + + // Some tools also require that the number of sectors is a multiple of the + // sectors-per-track. fatfs uses a default of 32 which won't evenly divide + // sectors as we compute above generally. To fix that we simply set it to + // match the number of sectors to make it trivially true. + let sectors_per_track = sectors.try_into().unwrap(); + + let mut disk = Cursor::new(vec![0; sectors * SECTOR_SZ]); + fatfs::format_volume( + &mut disk, + FormatVolumeOptions::new() + .bytes_per_cluster(512) + .sectors_per_track(sectors_per_track) + .fat_type(fatfs::FatType::Fat12) + .volume_label(VOLUME_LABEL), + ) + .context("error formatting FAT volume")?; + + let fs = FileSystem::new(&mut disk, FsOptions::new())?; + let root_dir = fs.root_dir(); + for (name, data) in all_data.iter() { + if *name == "network-config" && data.is_empty() { + // Skip creating an empty network interfaces if nothing is provided. + // It is not required, unlike the other files + } + root_dir.create_file(name)?.write_all(data)?; + } + drop(root_dir); + drop(fs); + + block::InMemoryBackend::create( + disk.into_inner(), + block::BackendOpts { + block_size: Some(SECTOR_SZ as u32), + read_only: Some(true), + ..Default::default() + }, + ) + .context("could not create block backend") +} diff --git a/bin/propolis-standalone/src/config.rs b/bin/propolis-standalone/src/config.rs index 45cd7465b..a4246cfd9 100644 --- a/bin/propolis-standalone/src/config.rs +++ b/bin/propolis-standalone/src/config.rs @@ -15,6 +15,7 @@ use propolis::cpuid; use propolis::hw::pci::Bdf; use propolis::inventory::ChildRegister; +use crate::cidata::build_cidata_be; pub use propolis_standalone_config::{Config, SnapshotTag}; use propolis_standalone_config::{CpuVendor, CpuidEntry, Device}; @@ -88,6 +89,11 @@ pub fn block_backend( let creg = ChildRegister::new(&be, None); (be, creg) } + "cloudinit" => { + let be = build_cidata_be(config).unwrap(); + let creg = ChildRegister::new(&be, None); + (be, creg) + } _ => { panic!("unrecognized block dev type {}!", be.bdtype); } diff --git a/bin/propolis-standalone/src/main.rs b/bin/propolis-standalone/src/main.rs index f98f8b2c9..0716665d5 100644 --- a/bin/propolis-standalone/src/main.rs +++ b/bin/propolis-standalone/src/main.rs @@ -28,6 +28,7 @@ use propolis::vcpu::Vcpu; use propolis::vmm::{Builder, Machine}; use propolis::*; +mod cidata; mod config; mod snapshot; diff --git a/crates/propolis-standalone-config/src/lib.rs b/crates/propolis-standalone-config/src/lib.rs index ce45e5456..ded94903a 100644 --- a/crates/propolis-standalone-config/src/lib.rs +++ b/crates/propolis-standalone-config/src/lib.rs @@ -31,6 +31,8 @@ pub struct Config { #[serde(default, rename = "cpuid")] pub cpuid_profiles: BTreeMap, + + pub cloudinit: Option, } impl Config { pub fn cpuid_profile(&self) -> Option<&CpuidProfile> { @@ -79,3 +81,16 @@ pub struct BlockDevice { #[serde(flatten, default)] pub options: BTreeMap, } + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct CloudInit { + pub user_data: Option, + pub meta_data: Option, + pub network_config: Option, + + // allow path-style contents as well + pub user_data_path: Option, + pub meta_data_path: Option, + pub network_config_path: Option, +} diff --git a/lib/propolis/src/util/regmap.rs b/lib/propolis/src/util/regmap.rs index 7a69d4f9e..eddd0b8e8 100644 --- a/lib/propolis/src/util/regmap.rs +++ b/lib/propolis/src/util/regmap.rs @@ -126,8 +126,7 @@ impl RegMap { debug_assert!(copy_op.offset() == 0); f(®.id, RWOp::Read(copy_op)); } else { - let mut scratch = Vec::new(); - scratch.resize(reg_len, 0); + let mut scratch = vec![0; reg_len]; let mut sro = ReadOp::from_buf(0, &mut scratch); f(®.id, RWOp::Read(&mut sro)); @@ -154,10 +153,8 @@ impl RegMap { debug_assert!(copy_op.offset() == 0); f(®.id, RWOp::Write(copy_op)); } else { - let mut scratch = Vec::new(); - scratch.resize(reg_len, 0); + let mut scratch = vec![0; reg_len]; - debug_assert!(scratch.len() == reg_len); if !reg.flags.contains(Flags::NO_READ_MOD_WRITE) { let mut sro = ReadOp::from_buf(0, &mut scratch); f(®.id, RWOp::Read(&mut sro));