diff --git a/illumos-utils/src/zfs.rs b/illumos-utils/src/zfs.rs index 21de2a50da..7dfd574c97 100644 --- a/illumos-utils/src/zfs.rs +++ b/illumos-utils/src/zfs.rs @@ -5,9 +5,12 @@ //! Utilities for poking at ZFS. use crate::{execute, PFEXEC}; +use anyhow::Context; use camino::{Utf8Path, Utf8PathBuf}; use omicron_common::disk::DiskIdentity; +use omicron_uuid_kinds::DatasetUuid; use std::fmt; +use std::str::FromStr; // These locations in the ramdisk must only be used by the switch zone. // @@ -207,9 +210,71 @@ pub struct SizeDetails { pub compression: Option, } +#[derive(Debug)] +pub struct DatasetProperties { + /// The Uuid of the dataset + pub id: Option, + /// The full name of the dataset. + pub name: String, + /// Remaining space in the dataset and descendents. + pub avail: u64, + /// Space used by dataset and descendents. + pub used: u64, + /// Maximum space usable by dataset and descendents. + pub quota: Option, + /// Minimum space guaranteed to dataset and descendents. + pub reservation: Option, + /// The compression algorithm used for this dataset. + pub compression: String, +} + +impl FromStr for DatasetProperties { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let mut iter = s.split_whitespace(); + + let id = match iter.next().context("Missing UUID")? { + "-" => None, + anything_else => Some(anything_else.parse::()?), + }; + + let name = iter.next().context("Missing 'name'")?.to_string(); + let avail = iter.next().context("Missing 'avail'")?.parse::()?; + let used = iter.next().context("Missing 'used'")?.parse::()?; + let quota = + match iter.next().context("Missing 'quota'")?.parse::()? { + 0 => None, + q => Some(q), + }; + let reservation = match iter + .next() + .context("Missing 'reservation'")? + .parse::()? + { + 0 => None, + r => Some(r), + }; + let compression = + iter.next().context("Missing 'compression'")?.to_string(); + + Ok(DatasetProperties { + id, + name, + avail, + used, + quota, + reservation, + compression, + }) + } +} + #[cfg_attr(any(test, feature = "testing"), mockall::automock, allow(dead_code))] impl Zfs { /// Lists all datasets within a pool or existing dataset. + /// + /// Strips the input `name` from the output dataset names. pub fn list_datasets(name: &str) -> Result, ListDatasetsError> { let mut command = std::process::Command::new(ZFS); let cmd = command.args(&["list", "-d", "1", "-rHpo", "name", name]); @@ -228,6 +293,38 @@ impl Zfs { Ok(filesystems) } + /// Get information about datasets within a list of zpools / datasets. + /// + /// This function is similar to [Zfs::list_datasets], but provides a more + /// substantial results about the datasets found. + /// + /// Sorts results and de-duplicates them by name. + pub fn get_dataset_properties( + datasets: &[String], + ) -> Result, anyhow::Error> { + let mut command = std::process::Command::new(ZFS); + let cmd = command.args(&["list", "-d", "1", "-rHpo"]); + + // Note: this is tightly coupled with the layout of DatasetProperties + cmd.arg("oxide:uuid,name,avail,used,quota,reservation,compression"); + cmd.args(datasets); + + let output = execute(cmd).with_context(|| { + format!("Failed to get dataset properties for {datasets:?}") + })?; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut datasets = stdout + .trim() + .split('\n') + .map(|row| row.parse::()) + .collect::, _>>()?; + + datasets.sort_by(|d1, d2| d1.name.partial_cmp(&d2.name).unwrap()); + datasets.dedup_by(|d1, d2| d1.name.eq(&d2.name)); + + Ok(datasets) + } + /// Return the name of a dataset for a ZFS object. /// /// The object can either be a dataset name, or a path, in which case it @@ -679,3 +776,120 @@ pub fn get_all_omicron_datasets_for_delete() -> anyhow::Result> { Ok(datasets) } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn parse_dataset_props() { + let input = + "- dataset_name 1234 5678 0 0 off"; + let props = DatasetProperties::from_str(&input) + .expect("Should have parsed data"); + + assert_eq!(props.id, None); + assert_eq!(props.name, "dataset_name"); + assert_eq!(props.avail, 1234); + assert_eq!(props.used, 5678); + assert_eq!(props.quota, None); + assert_eq!(props.reservation, None); + assert_eq!(props.compression, "off"); + } + + #[test] + fn parse_dataset_props_with_optionals() { + let input = "d4e1e554-7b98-4413-809e-4a42561c3d0c dataset_name 1234 5678 111 222 off"; + let props = DatasetProperties::from_str(&input) + .expect("Should have parsed data"); + + assert_eq!( + props.id, + Some("d4e1e554-7b98-4413-809e-4a42561c3d0c".parse().unwrap()) + ); + assert_eq!(props.name, "dataset_name"); + assert_eq!(props.avail, 1234); + assert_eq!(props.used, 5678); + assert_eq!(props.quota, Some(111)); + assert_eq!(props.reservation, Some(222)); + assert_eq!(props.compression, "off"); + } + + #[test] + fn parse_dataset_bad_uuid() { + let input = "bad dataset_name 1234 5678 111 222 off"; + let err = DatasetProperties::from_str(&input) + .expect_err("Should have failed to parse"); + assert!( + err.to_string().contains("error parsing UUID (dataset)"), + "{err}" + ); + } + + #[test] + fn parse_dataset_bad_avail() { + let input = "- dataset_name BADAVAIL 5678 111 222 off"; + let err = DatasetProperties::from_str(&input) + .expect_err("Should have failed to parse"); + assert!( + err.to_string().contains("invalid digit found in string"), + "{err}" + ); + } + + #[test] + fn parse_dataset_bad_usage() { + let input = "- dataset_name 1234 BADUSAGE 111 222 off"; + let err = DatasetProperties::from_str(&input) + .expect_err("Should have failed to parse"); + assert!( + err.to_string().contains("invalid digit found in string"), + "{err}" + ); + } + + #[test] + fn parse_dataset_bad_quota() { + let input = "- dataset_name 1234 5678 BADQUOTA 222 off"; + let err = DatasetProperties::from_str(&input) + .expect_err("Should have failed to parse"); + assert!( + err.to_string().contains("invalid digit found in string"), + "{err}" + ); + } + + #[test] + fn parse_dataset_bad_reservation() { + let input = "- dataset_name 1234 5678 111 BADRES off"; + let err = DatasetProperties::from_str(&input) + .expect_err("Should have failed to parse"); + assert!( + err.to_string().contains("invalid digit found in string"), + "{err}" + ); + } + + #[test] + fn parse_dataset_missing_fields() { + let expect_missing = |input: &str, what: &str| { + let err = DatasetProperties::from_str(input) + .expect_err("Should have failed to parse"); + assert!(err.to_string().contains(&format!("Missing {what}"))); + }; + + expect_missing( + "- dataset_name 1234 5678 111 222", + "'compression'", + ); + expect_missing( + "- dataset_name 1234 5678 111", + "'reservation'", + ); + expect_missing("- dataset_name 1234 5678", "'quota'"); + expect_missing("- dataset_name 1234", "'used'"); + expect_missing("- dataset_name", "'avail'"); + expect_missing("-", "'name'"); + expect_missing("", "UUID"); + } +} diff --git a/sled-agent/src/params.rs b/sled-agent/src/params.rs index a421bda3a6..e0b74e11b0 100644 --- a/sled-agent/src/params.rs +++ b/sled-agent/src/params.rs @@ -19,6 +19,7 @@ use omicron_common::api::internal::nexus::{ use omicron_common::api::internal::shared::{ NetworkInterface, SourceNatConfig, }; +use omicron_uuid_kinds::DatasetUuid; use omicron_uuid_kinds::PropolisUuid; use omicron_uuid_kinds::ZpoolUuid; use schemars::JsonSchema; @@ -763,6 +764,49 @@ pub struct InventoryZpool { pub total_size: ByteCount, } +/// Identifies information about datasets within Oxide-managed zpools +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct InventoryDataset { + /// Although datasets mandated by the control plane will have UUIDs, + /// datasets can be created (and have been created) without UUIDs. + pub id: Option, + + /// This name is the full path of the dataset. + // This is akin to [sled_storage::dataset::DatasetName::full_name], + // and it's also what you'd see when running "zfs list". + pub name: String, + + /// The amount of remaining space usable by the dataset (and children) + /// assuming there is no other activity within the pool. + pub available: u64, + + /// The amount of space consumed by this dataset and descendents. + pub used: u64, + + /// The maximum amount of space usable by a dataset and all descendents. + pub quota: Option, + + /// The minimum amount of space guaranteed to a dataset and descendents. + pub reservation: Option, + + /// The compression algorithm used for this dataset, if any. + pub compression: String, +} + +impl From for InventoryDataset { + fn from(props: illumos_utils::zfs::DatasetProperties) -> Self { + Self { + id: props.id, + name: props.name, + available: props.avail, + used: props.used, + quota: props.quota, + reservation: props.reservation, + compression: props.compression, + } + } +} + /// Identity and basic status information about this sled agent #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct Inventory { @@ -775,6 +819,7 @@ pub struct Inventory { pub reservoir_size: ByteCount, pub disks: Vec, pub zpools: Vec, + pub datasets: Vec, } #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] diff --git a/sled-agent/src/sim/sled_agent.rs b/sled-agent/src/sim/sled_agent.rs index f23b14c377..43d4fd310f 100644 --- a/sled-agent/src/sim/sled_agent.rs +++ b/sled-agent/src/sim/sled_agent.rs @@ -866,6 +866,8 @@ impl SledAgent { }) }) .collect::, anyhow::Error>>()?, + // TODO: Make this more real? + datasets: vec![], }) } diff --git a/sled-agent/src/sled_agent.rs b/sled-agent/src/sled_agent.rs index 6b212c96ce..6669e8e4ca 100644 --- a/sled-agent/src/sled_agent.rs +++ b/sled-agent/src/sled_agent.rs @@ -66,6 +66,7 @@ use sled_agent_types::early_networking::EarlyNetworkConfig; use sled_hardware::{underlay, HardwareManager}; use sled_hardware_types::underlay::BootstrapInterface; use sled_hardware_types::Baseboard; +use sled_storage::dataset::{CRYPT_DATASET, ZONE_DATASET}; use sled_storage::manager::StorageHandle; use sled_storage::resources::DatasetsManagementResult; use sled_storage::resources::DisksManagementResult; @@ -1250,6 +1251,7 @@ impl SledAgent { let mut disks = vec![]; let mut zpools = vec![]; + let mut datasets = vec![]; let all_disks = self.storage().get_latest_disks().await; for (identity, variant, slot, _firmware) in all_disks.iter_all() { disks.push(crate::params::InventoryDisk { @@ -1278,6 +1280,47 @@ impl SledAgent { id: zpool.id(), total_size: ByteCount::try_from(info.size())?, }); + + // We do care about the total space usage within zpools, but mapping + // the layering back to "datasets we care about" is a little + // awkward. + // + // We could query for all datasets within a pool, but the sled agent + // doesn't really care about the children of datasets that it + // allocates. As an example: Sled Agent might provision a "crucible" + // dataset, but how region allocation occurs within that dataset + // is a detail for Crucible to care about, not the Sled Agent. + // + // To balance this effort, we ask for information about datasets + // that the Sled Agent is directly resopnsible for managing. + let datasets_of_interest = [ + // We care about the zpool itself, and all direct children. + zpool.to_string(), + // Likewise, we care about the encrypted dataset, and all + // direct children. + format!("{zpool}/{CRYPT_DATASET}"), + // The zone dataset gives us additional context on "what zones + // have datasets provisioned". + format!("{zpool}/{ZONE_DATASET}"), + ]; + let inv_props = + match illumos_utils::zfs::Zfs::get_dataset_properties( + datasets_of_interest.as_slice(), + ) { + Ok(props) => props.into_iter().map(|prop| { + crate::params::InventoryDataset::from(prop) + }), + Err(err) => { + warn!( + self.log, + "Failed to access dataset info within zpool"; + "zpool" => %zpool, + "err" => %err + ); + continue; + } + }; + datasets.extend(inv_props); } Ok(Inventory { @@ -1290,6 +1333,7 @@ impl SledAgent { reservoir_size, disks, zpools, + datasets, }) } }