diff --git a/docs/platforms.md b/docs/platforms.md index 37220d20..aedda881 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -51,6 +51,9 @@ The following platforms are supported, with a different set of features availabl - Attributes - First-boot check-in - SSH Keys +* powervs + - Attributes + - SSH keys * vmware - Custom network command-line arguments * vultr diff --git a/docs/usage/attributes.md b/docs/usage/attributes.md index f46314d5..01704328 100644 --- a/docs/usage/attributes.md +++ b/docs/usage/attributes.md @@ -105,6 +105,9 @@ Cloud providers with supported metadata endpoints and their respective attribute - AFTERBURN_PACKET_IPV4_PRIVATE_GATEWAY_0 - AFTERBURN_PACKET_IPV6_PUBLIC_0 - AFTERBURN_PACKET_IPV6_PUBLIC_GATEWAY_0 +* powervs + - AFTERBURN_POWERVS_INSTANCE_ID + - AFTERBURN_POWERVS_LOCAL_HOSTNAME * vultr - AFTERBURN_VULTR_HOSTNAME - AFTERBURN_VULTR_INSTANCE_ID diff --git a/src/metadata.rs b/src/metadata.rs index 4c31a7d8..758bb1ed 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -29,6 +29,7 @@ use crate::providers::microsoft::azurestack::AzureStack; use crate::providers::openstack; use crate::providers::openstack::network::OpenstackProviderNetwork; use crate::providers::packet::PacketProvider; +use crate::providers::powervs::PowerVSProvider; use crate::providers::vmware::VmwareProvider; use crate::providers::vultr::VultrProvider; @@ -61,6 +62,7 @@ pub fn fetch_metadata(provider: &str) -> Result openstack::try_config_drive_else_network(), "openstack-metadata" => box_result!(OpenstackProviderNetwork::try_new()?), "packet" => box_result!(PacketProvider::try_new()?), + "powervs" => box_result!(PowerVSProvider::try_new()?), "vmware" => box_result!(VmwareProvider::try_new()?), "vultr" => box_result!(VultrProvider::try_new()?), _ => bail!("unknown provider '{}'", provider), diff --git a/src/providers/mod.rs b/src/providers/mod.rs index a300677f..aaaccb06 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -34,6 +34,7 @@ pub mod ibmcloud_classic; pub mod microsoft; pub mod openstack; pub mod packet; +pub mod powervs; pub mod vmware; pub mod vultr; diff --git a/src/providers/powervs/mod.rs b/src/providers/powervs/mod.rs new file mode 100644 index 00000000..6ab6a34f --- /dev/null +++ b/src/providers/powervs/mod.rs @@ -0,0 +1,239 @@ +//! Metadata fetcher for PowerVS instances. +//! +//! This provider supports the Power Virtual Server infrastructure type on IBMCloud. +//! It provides a config-drive as the only metadata source, whose layout +//! follows the `cloud-init ConfigDrive v2` [datasource][configdrive], with +//! the following details: +//! - disk filesystem label is `config-2` (lowercase) +//! - filesystem is `iso9660` +//! - drive contains a single directory at `/openstack/latest/` +//! - content is exposed as JSON files called `meta_data.json`. +//! +//! configdrive: https://cloudinit.readthedocs.io/en/latest/topics/datasources/configdrive.html + +use anyhow::{bail, Context, Result}; +use openssh_keys::PublicKey; +use serde::Deserialize; +use slog_scope::warn; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +use crate::network; +use crate::providers::MetadataProvider; + +// Filesystem label for the Config Drive. +static CONFIG_DRIVE_FS_LABEL: &str = "config-2"; + +// Filesystem type for the Config Drive. +static CONFIG_DRIVE_FS_TYPE: &str = "iso9660"; + +///PowerVS provider. +#[derive(Debug)] +pub struct PowerVSProvider { + /// Path to the top directory of the mounted config-drive. + drive_path: PathBuf, + /// Temporary directory for own mountpoint. + temp_dir: TempDir, +} + +/// Partial object for `meta_data.json` +#[derive(Debug, Deserialize)] +pub struct MetaDataJSON { + /// Fully-Qualified Domain Name (FQDN). + #[serde(rename = "hostname")] + pub fqdn: String, + /// Local hostname. + #[serde(rename = "name")] + pub local_hostname: String, + /// Instance ID (UUID). + #[serde(rename = "uuid")] + pub instance_id: String, + /// SSH public keys. + pub public_keys: Option>, +} + +impl PowerVSProvider { + /// Try to build a new provider client. + /// + /// This internally tries to mount (and own) the config-drive. + pub fn try_new() -> Result { + let target = tempfile::Builder::new() + .prefix("afterburn-") + .tempdir() + .context("failed to create temporary directory")?; + crate::util::mount_ro( + &Path::new("/dev/disk/by-label/").join(CONFIG_DRIVE_FS_LABEL), + target.path(), + CONFIG_DRIVE_FS_TYPE, + 3, // maximum retries + )?; + + let provider = Self { + drive_path: target.path().to_owned(), + temp_dir: target, + }; + Ok(provider) + } + + /// Return the path to the metadata directory. + fn metadata_dir(&self) -> PathBuf { + let drive = self.drive_path.clone(); + drive.join("openstack").join("latest") + } + + /// Read and parse metadata file. + fn read_metadata(&self) -> Result { + let filename = self.metadata_dir().join("meta_data.json"); + let file = File::open(&filename) + .with_context(|| format!("failed to open file '{:?}'", filename))?; + let bufrd = BufReader::new(file); + Self::parse_metadata(bufrd) + } + + /// Parse metadata attributes. + /// + /// Metadata file contains a JSON object, corresponding to `MetaDataJSON`. + fn parse_metadata(input: BufReader) -> Result { + serde_json::from_reader(input).context("failed to parse JSON metadata") + } + + /// Extract supported metadata values and convert to Afterburn attributes. + /// + /// The `AFTERBURN_` prefix is added later on, so it is not part of the + /// key-labels here. + fn known_attributes(metadata: MetaDataJSON) -> Result> { + if metadata.instance_id.is_empty() { + bail!("empty instance ID"); + } + + if metadata.local_hostname.is_empty() { + bail!("empty local hostname"); + } + + let attrs = maplit::hashmap! { + "POWERVS_INSTANCE_ID".to_string() => metadata.instance_id, + "POWERVS_LOCAL_HOSTNAME".to_string() => metadata.local_hostname, + + }; + Ok(attrs) + } + + /// The public key is stored as key:value pair in openstack/latest/meta_data.json file + fn public_keys(metadata: MetaDataJSON) -> Result> { + let public_keys_map = metadata.public_keys.unwrap_or_default(); + let public_keys_vec: Vec<&std::string::String> = public_keys_map.values().collect(); + let mut out = vec![]; + for key in public_keys_vec { + let key = PublicKey::parse(key)?; + out.push(key); + } + Ok(out) + } +} + +impl MetadataProvider for PowerVSProvider { + fn attributes(&self) -> Result> { + let metadata = self.read_metadata()?; + Self::known_attributes(metadata) + } + + fn hostname(&self) -> Result> { + let metadata = self.read_metadata()?; + let hostname = if metadata.local_hostname.is_empty() { + None + } else { + Some(metadata.local_hostname) + }; + Ok(hostname) + } + + fn ssh_keys(&self) -> Result> { + let metadata = self.read_metadata()?; + Self::public_keys(metadata) + } + + fn networks(&self) -> Result> { + warn!("network interfaces metadata requested, but not supported on this platform"); + Ok(vec![]) + } + + fn virtual_network_devices(&self) -> Result> { + warn!("virtual network devices metadata requested, but not supported on this platform"); + Ok(vec![]) + } + + fn boot_checkin(&self) -> Result<()> { + warn!("boot check-in requested, but not supported on this platform"); + Ok(()) + } +} + +impl Drop for PowerVSProvider { + fn drop(&mut self) { + if let Err(e) = crate::util::unmount( + self.temp_dir.path(), + 3, // maximum retries + ) { + slog_scope::error!("failed to unmount powervs config-drive: {}", e); + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_powervs_basic_attributes() { + let metadata = r#" +{ + "hostname": "test_instance-powervs.foo.cloud", + "name": "test_instance-powervs", + "uuid": "41b4fb82-ca29-11eb-b8bc-0242ac130003" +} +"#; + + let bufrd = BufReader::new(Cursor::new(metadata)); + let parsed = PowerVSProvider::parse_metadata(bufrd).unwrap(); + assert_eq!(parsed.instance_id, "41b4fb82-ca29-11eb-b8bc-0242ac130003",); + assert_eq!(parsed.local_hostname, "test_instance-powervs",); + + let attrs = PowerVSProvider::known_attributes(parsed).unwrap(); + assert_eq!(attrs.len(), 2); + assert_eq!( + attrs.get("POWERVS_INSTANCE_ID"), + Some(&"41b4fb82-ca29-11eb-b8bc-0242ac130003".to_string()) + ); + assert_eq!( + attrs.get("POWERVS_LOCAL_HOSTNAME"), + Some(&"test_instance-powervs".to_string()) + ); + } + + #[test] + fn test_powervs_parse_metadata_json() { + let fixture = File::open("./tests/fixtures/powervs/meta_data.json").unwrap(); + let bufrd = BufReader::new(fixture); + let parsed = PowerVSProvider::parse_metadata(bufrd).unwrap(); + + assert!(!parsed.instance_id.is_empty()); + assert!(!parsed.local_hostname.is_empty()); + assert!(!parsed.public_keys.is_none()); + } + + #[test] + fn test_powervs_ssh_keys() { + let fixture = File::open("./tests/fixtures/powervs/meta_data.json").unwrap(); + let bufrd = BufReader::new(fixture); + let parsed = PowerVSProvider::parse_metadata(bufrd).unwrap(); + let keys = PowerVSProvider::public_keys(parsed).unwrap(); + let expect = PublicKey::parse("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmMuiypdqqftqhrQeBTjOhcgyARvylZMLiH+6nCvi5Lv5M7evAnvvG3Hz4rbjbbqoVgSCIdAEb4PuttiCdwE6UyAl0TYAydOVPx7l87BlaucTEqDFbXkQB+yyUmzodllCpWAMUmxwvJB/ntFrC6rP0K0kKxx4SESvozutwM2X5oH3LNHcYI1xgKIMF9VMJLkkM0rLo8Fmj6mWF5KtbU7vS7JJPvLTCRhW5TYrqvhHKuIS6KBtj3GJqvRt+it8AsIb6/RUaji68Mt7W41UrmFSPt8bxJMdE/xKGMcFQjamURPSCHx7z8/pr2/pv2QbQF76FO7lRdPH3f542uAkOOpO1 user1@user1-MacBook-Pro-2.local").unwrap(); + + assert_eq!(keys.len(), 1); + assert_eq!(keys[0], expect); + } +} diff --git a/tests/fixtures/powervs/meta_data.json b/tests/fixtures/powervs/meta_data.json new file mode 100644 index 00000000..d865fa05 --- /dev/null +++ b/tests/fixtures/powervs/meta_data.json @@ -0,0 +1,32 @@ +{ + "admin_pass": "oSfw8JEKHvBo", + "random_seed": "YUQzy5FVwllOEHrRfvB9rims2ZtotnhJz/f7EVXIknrhh2htbSMxYt1DKxnTzLzWm+tfRjcvWyjie4aU4lIVFrwFgsFmDoAcBIjBID20QVObzKaN0rfrGtdwojyiu+7uGWuQlojoBR/m5pzO1HFcdexEdOSIE+EpU7WIHnS6K948rpH8kERslIz7W6kJixjTpsNanidsvV4DRiSoYdI3wtxyVxBt7ZSzSyuzUIPkbRQ5D/XE/DLs+B8ChRGe9apGCcPvpDRPvot3UUvmwBvtZtaNfH99poKp+j7GOjINT5tc+Wypvgls2EoI/klekzrIOqLmRx/q6UpvvmpoP2PhKT687WMoWNg4uS4d0VbxS2UkydSZoUVCSQmO9O95RdD/jYqXY6q5o7qdr3TZZFf4s1gdoXltb70oQzi7wo4q5Z4QsOsdZdNz5BZ5vtQ06+7neS8ppP5cD6OkQXm+d9bBBWElG5yTeN/zyURpgGdsQlocZzkVlhdVRW9wyeGeN6TGiIn3EkzOJdb6ypZ50iNAnnTN5+zQpt0Y3f1up2/Ppy+nBNzjGdGAHDl4cc+2ri1IiNAIiG2Ta/kcNghNx3+XqUuYsK97e9grW+qXDa3gkeHqclOu9753GJKXeBR5gcH6O9PpPWRQ6T6slltsOPUnW3K3E54Il87fbxd73WSPN/8=", + "uuid": "1c0c0bb6-8dcd-4966-ad80-946b871814f1", + "availability_zone": "s922", + "keys": [ + { + "data": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmMuiypdqqftqhrQeBTjOhcgyARvylZMLiH+6nCvi5Lv5M7evAnvvG3Hz4rbjbbqoVgSCIdAEb4PuttiCdwE6UyAl0TYAydOVPx7l87BlaucTEqDFbXkQB+yyUmzodllCpWAMUmxwvJB/ntFrC6rP0K0kKxx4SESvozutwM2X5oH3LNHcYI1xgKIMF9VMJLkkM0rLo8Fmj6mWF5KtbU7vS7JJPvLTCRhW5TYrqvhHKuIS6KBtj3GJqvRt+it8AsIb6/RUaji68Mt7W41UrmFSPt8bxJMdE/xKGMcFQjamURPSCHx7z8/pr2/pv2QbQF76FO7lRdPH3f542uAkOOpO1 user1@user1-MacBook-Pro-2.local", + "type": "ssh", + "name": "65b64c1f1c29460e8c2e4bbfbd893c2c_7c6cd5d4-a7aa-4d07-bb89-94f274bfeed0_user1-pub-key" + } + ], + "hostname": "test-tf-pvm.power-iaas.cloud.ibm.com", + "launch_index": 0, + "devices": [], + "meta": { + "ibmiDBQ": "false", + "ibmiCSS": "false", + "storage_pool": "Tier1-Flash-2", + "ibmiRDS": "false", + "ibmiPHA": "false" + }, + "public_keys": { + "65b64c1f1c29460e8c2e4bbfbd893c2c_7c6cd5d4-a7aa-4d07-bb89-94f274bfeed0_user1-pub-key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmMuiypdqqftqhrQeBTjOhcgyARvylZMLiH+6nCvi5Lv5M7evAnvvG3Hz4rbjbbqoVgSCIdAEb4PuttiCdwE6UyAl0TYAydOVPx7l87BlaucTEqDFbXkQB+yyUmzodllCpWAMUmxwvJB/ntFrC6rP0K0kKxx4SESvozutwM2X5oH3LNHcYI1xgKIMF9VMJLkkM0rLo8Fmj6mWF5KtbU7vS7JJPvLTCRhW5TYrqvhHKuIS6KBtj3GJqvRt+it8AsIb6/RUaji68Mt7W41UrmFSPt8bxJMdE/xKGMcFQjamURPSCHx7z8/pr2/pv2QbQF76FO7lRdPH3f542uAkOOpO1 user1@user1-MacBook-Pro-2.local" + }, + "project_id": "15ea5428420d4d47be42e19332645a18", + "network_config": { + "content_path": "/content/0000", + "name": "network_config" + }, + "name": "test-tf-pVM" + } \ No newline at end of file