diff --git a/docs/platforms.md b/docs/platforms.md index 2d25d124..63e3abd8 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -36,6 +36,10 @@ The following platforms are supported, with a different set of features availabl * gcp - Attributes - SSH Keys +* hetzner + - Attributes + - Hostname + - SSH Keys * ibmcloud - Attributes - SSH Keys diff --git a/docs/release-notes.md b/docs/release-notes.md index 3f513d44..a2f9aa7c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -13,6 +13,7 @@ Minor changes: - openstack: Add `OPENSTACK_INSTANCE_UUID` attribute - openstack-metadata: Add `OPENSTACK_INSTANCE_UUID` attribute +- providers: Add Hetzner Cloud Packaging changes: diff --git a/docs/usage/attributes.md b/docs/usage/attributes.md index d477f440..f7f5df8d 100644 --- a/docs/usage/attributes.md +++ b/docs/usage/attributes.md @@ -79,6 +79,12 @@ Cloud providers with supported metadata endpoints and their respective attribute - AFTERBURN_GCP_IP_EXTERNAL_0 - AFTERBURN_GCP_IP_LOCAL_0 - AFTERBURN_GCP_MACHINE_TYPE +* hetzner + - AFTERBURN_HETZNER_AVAILABILITY_ZONE + - AFTERBURN_HETZNER_HOSTNAME + - AFTERBURN_HETZNER_INSTANCE_ID + - AFTERBURN_HETZNER_PUBLIC_IPV4 + - AFTERBURN_HETZNER_REGION * ibmcloud - AFTERBURN_IBMCLOUD_INSTANCE_ID - AFTERBURN_IBMCLOUD_LOCAL_HOSTNAME diff --git a/dracut/30afterburn/afterburn-hostname.service b/dracut/30afterburn/afterburn-hostname.service index f14b5965..478c2285 100644 --- a/dracut/30afterburn/afterburn-hostname.service +++ b/dracut/30afterburn/afterburn-hostname.service @@ -9,6 +9,7 @@ ConditionKernelCommandLine=|ignition.platform.id=azure ConditionKernelCommandLine=|ignition.platform.id=azurestack ConditionKernelCommandLine=|ignition.platform.id=digitalocean ConditionKernelCommandLine=|ignition.platform.id=exoscale +ConditionKernelCommandLine=|ignition.platform.id=hetzner ConditionKernelCommandLine=|ignition.platform.id=ibmcloud ConditionKernelCommandLine=|ignition.platform.id=vultr diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3cd85321..4055187d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -210,7 +210,7 @@ mod tests { .map(ToString::to_string) .collect(); - for args in vec![t1, t2] { + for args in [t1, t2] { let input = format!("{args:?}"); parse_args(args).expect_err(&input); } diff --git a/src/metadata.rs b/src/metadata.rs index ececef15..e6482c06 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -22,6 +22,7 @@ use crate::providers::cloudstack::network::CloudstackNetwork; use crate::providers::digitalocean::DigitalOceanProvider; use crate::providers::exoscale::ExoscaleProvider; use crate::providers::gcp::GcpProvider; +use crate::providers::hetzner::HetznerProvider; use crate::providers::ibmcloud::IBMGen2Provider; use crate::providers::ibmcloud_classic::IBMClassicProvider; use crate::providers::kubevirt::KubeVirtProvider; @@ -56,6 +57,7 @@ pub fn fetch_metadata(provider: &str) -> Result box_result!(DigitalOceanProvider::try_new()?), "exoscale" => box_result!(ExoscaleProvider::try_new()?), "gcp" => box_result!(GcpProvider::try_new()?), + "hetzner" => box_result!(HetznerProvider::try_new()?), // IBM Cloud - VPC Generation 2. "ibmcloud" => box_result!(IBMGen2Provider::try_new()?), // IBM Cloud - Classic infrastructure. diff --git a/src/providers/hetzner/mock_tests.rs b/src/providers/hetzner/mock_tests.rs new file mode 100644 index 00000000..94d52a6f --- /dev/null +++ b/src/providers/hetzner/mock_tests.rs @@ -0,0 +1,149 @@ +use mockito; +use openssh_keys::Data; + +use crate::providers::MetadataProvider; + +use super::HetznerProvider; + +fn setup() -> (mockito::ServerGuard, HetznerProvider) { + let server = mockito::Server::new(); + let mut provider = HetznerProvider::try_new().expect("create provider under test"); + provider.client = provider.client.max_retries(0).mock_base_url(server.url()); + (server, provider) +} + +#[test] +fn test_attributes() { + let endpoint = "/hetzner/v1/metadata"; + let (mut server, provider) = setup(); + + let availability_zone = "fsn1-dc14"; + let hostname = "some-hostname"; + let instance_id = "12345678"; + let public_ipv4 = "192.0.2.10"; + let region = "eu-central"; + + let body = format!( + r#"availability-zone: {availability_zone} +hostname: {hostname} +instance-id: {instance_id} +public-ipv4: {public_ipv4} +region: {region} +local-ipv4: '' +public-keys: [] +vendor_data: "blah blah blah""# + ); + + let expected = maplit::hashmap! { + "AFTERBURN_HETZNER_AVAILABILITY_ZONE".to_string() => availability_zone.to_string(), + "AFTERBURN_HETZNER_HOSTNAME".to_string() => hostname.to_string(), + "AFTERBURN_HETZNER_INSTANCE_ID".to_string() => instance_id.to_string(), + "AFTERBURN_HETZNER_PUBLIC_IPV4".to_string() => public_ipv4.to_string(), + "AFTERBURN_HETZNER_REGION".to_string() => region.to_string(), + }; + + // Fail on not found + provider.attributes().unwrap_err(); + + // Fail on internal server errors + let mock = server.mock("GET", endpoint).with_status(503).create(); + provider.attributes().unwrap_err(); + mock.assert(); + + // Fetch metadata + let mock = server + .mock("GET", endpoint) + .with_status(200) + .with_body(body) + .create(); + let actual = provider.attributes().unwrap(); + mock.assert(); + assert_eq!(actual, expected); +} + +#[test] +fn test_hostname() { + let endpoint = "/hetzner/v1/metadata/hostname"; + let hostname = "some-hostname"; + + let (mut server, provider) = setup(); + + // Fail on not found + provider.hostname().unwrap_err(); + + // Fail on internal server errors + server.mock("GET", endpoint).with_status(503).create(); + provider.hostname().unwrap_err(); + + // Return hostname on success + server + .mock("GET", endpoint) + .with_status(200) + .with_body(hostname) + .create(); + assert_eq!(provider.hostname().unwrap(), Some(hostname.to_string())); + + // Return `None` if response is empty + server + .mock("GET", endpoint) + .with_status(200) + .with_body("") + .create(); + assert_eq!(provider.hostname().unwrap(), None); +} + +#[test] +fn test_pubkeys() { + let endpoint = "/hetzner/v1/metadata/public-keys"; + let pubkey1 = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBjYTHGYkNK7DZ4Gn0NGN1sjFUVapus4GXybEYg/ylcA some-key"; + let pubkey2 = + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOPAmN/ccWtKFlCPOwjAMXxrbKBE4cxypTLKgARZF8W1 some-other-key"; + + let (mut server, provider) = setup(); + + // Fail on not found + provider.ssh_keys().unwrap_err(); + + // Fail on internal server errors + server.mock("GET", endpoint).with_status(503).create(); + provider.ssh_keys().unwrap_err(); + + // No keys + server + .mock("GET", endpoint) + .with_status(200) + .with_body("[]") + .create(); + let keys = provider.ssh_keys().unwrap(); + assert!(keys.is_empty()); + + // Fetch single key + server + .mock("GET", endpoint) + .with_status(200) + .with_body(serde_json::to_string(&[pubkey1]).unwrap()) + .create(); + let keys = provider.ssh_keys().unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].comment, Some("some-key".to_string())); + assert_eq!( + keys[0].data, + Data::Ed25519 { + key: vec![ + 24, 216, 76, 113, 152, 144, 210, 187, 13, 158, 6, 159, 67, 70, 55, 91, 35, 21, 69, + 90, 166, 235, 56, 25, 124, 155, 17, 136, 63, 202, 87, 0 + ] + } + ); + assert_eq!(keys[0].options, None); + + // Fetch multiple keys + server + .mock("GET", endpoint) + .with_status(200) + .with_body(serde_json::to_string(&[pubkey1, pubkey2]).unwrap()) + .create(); + let keys = provider.ssh_keys().unwrap(); + assert_eq!(keys.len(), 2); +} diff --git a/src/providers/hetzner/mod.rs b/src/providers/hetzner/mod.rs new file mode 100644 index 00000000..5a575f05 --- /dev/null +++ b/src/providers/hetzner/mod.rs @@ -0,0 +1,151 @@ +// Copyright 2023 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Metadata fetcher for the hetzner provider +//! https://docs.hetzner.cloud/#server-metadata + +use std::collections::HashMap; + +use anyhow::Result; +use openssh_keys::PublicKey; +use serde::Deserialize; + +use crate::retry; + +use super::MetadataProvider; + +#[cfg(test)] +mod mock_tests; + +const HETZNER_METADATA_BASE_URL: &str = "http://169.254.169.254/hetzner/v1/metadata"; + +/// Metadata provider for Hetzner Cloud +/// +/// See: https://docs.hetzner.cloud/#server-metadata +#[derive(Clone, Debug)] +pub struct HetznerProvider { + client: retry::Client, +} + +impl HetznerProvider { + pub fn try_new() -> Result { + let client = retry::Client::try_new()?; + Ok(Self { client }) + } + + fn endpoint_for(key: &str) -> String { + format!("{HETZNER_METADATA_BASE_URL}/{key}") + } +} + +impl MetadataProvider for HetznerProvider { + fn attributes(&self) -> Result> { + let meta: HetznerMetadata = self + .client + .get(retry::Yaml, HETZNER_METADATA_BASE_URL.to_string()) + .send()? + .unwrap(); + + Ok(meta.into()) + } + + fn hostname(&self) -> Result> { + let hostname: String = self + .client + .get(retry::Raw, Self::endpoint_for("hostname")) + .send()? + .unwrap_or_default(); + + if hostname.is_empty() { + return Ok(None); + } + + Ok(Some(hostname)) + } + + fn ssh_keys(&self) -> Result> { + let keys: Vec = self + .client + .get(retry::Json, Self::endpoint_for("public-keys")) + .send()? + .unwrap_or_default(); + + let keys = keys + .iter() + .map(|s| PublicKey::parse(s)) + .collect::>()?; + + Ok(keys) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct HetznerMetadata { + hostname: Option, + instance_id: Option, + public_ipv4: Option, + availability_zone: Option, + region: Option, +} + +impl From for HashMap { + fn from(meta: HetznerMetadata) -> Self { + let mut out = HashMap::with_capacity(5); + + let add_value = |map: &mut HashMap<_, _>, key: &str, value: Option| { + if let Some(value) = value { + map.insert(key.to_string(), value); + } + }; + + add_value( + &mut out, + "AFTERBURN_HETZNER_AVAILABILITY_ZONE", + meta.availability_zone, + ); + add_value(&mut out, "AFTERBURN_HETZNER_HOSTNAME", meta.hostname); + add_value( + &mut out, + "AFTERBURN_HETZNER_INSTANCE_ID", + meta.instance_id.map(|i| i.to_string()), + ); + add_value(&mut out, "AFTERBURN_HETZNER_PUBLIC_IPV4", meta.public_ipv4); + add_value(&mut out, "AFTERBURN_HETZNER_REGION", meta.region); + + out + } +} + +#[cfg(test)] +mod tests { + use super::HetznerMetadata; + + #[test] + fn test_metadata_deserialize() { + let body = r#"availability-zone: hel1-dc2 +hostname: my-server +instance-id: 42 +public-ipv4: 1.2.3.4 +region: eu-central +public-keys: []"#; + + let meta: HetznerMetadata = serde_yaml::from_str(body).unwrap(); + + assert_eq!(meta.availability_zone.unwrap(), "hel1-dc2"); + assert_eq!(meta.hostname.unwrap(), "my-server"); + assert_eq!(meta.instance_id.unwrap(), 42); + assert_eq!(meta.public_ipv4.unwrap(), "1.2.3.4"); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index b2b30c9e..7f136323 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -29,6 +29,7 @@ pub mod cloudstack; pub mod digitalocean; pub mod exoscale; pub mod gcp; +pub mod hetzner; pub mod ibmcloud; pub mod ibmcloud_classic; pub mod kubevirt; diff --git a/src/retry/client.rs b/src/retry/client.rs index 954ed710..8370a9e0 100644 --- a/src/retry/client.rs +++ b/src/retry/client.rs @@ -72,6 +72,22 @@ impl Deserializer for Json { } } +#[derive(Debug, Clone, Copy)] +pub struct Yaml; + +impl Deserializer for Yaml { + fn deserialize(&self, r: R) -> Result + where + T: serde::de::DeserializeOwned, + R: Read, + { + serde_yaml::from_reader(r).context("failed yaml deserialization") + } + fn content_type(&self) -> header::HeaderValue { + header::HeaderValue::from_static("application/x-yaml; charset=utf-8") + } +} + #[derive(Debug, Clone, Copy)] pub struct Raw; @@ -233,7 +249,7 @@ where { let url = self.parse_url()?; let mut req = blocking::Request::new(Method::GET, url); - req.headers_mut().extend(self.headers.clone().into_iter()); + req.headers_mut().extend(self.headers.clone()); self.retry.clone().retry(|attempt| { info!("Fetching {}: Attempt #{}", req.url(), attempt + 1); @@ -353,8 +369,6 @@ where /// so we have to do it here. fn clone_request(req: &blocking::Request) -> blocking::Request { let mut newreq = blocking::Request::new(req.method().clone(), req.url().clone()); - newreq - .headers_mut() - .extend(req.headers().clone().into_iter()); + newreq.headers_mut().extend(req.headers().clone()); newreq } diff --git a/systemd/afterburn-sshkeys@.service.in b/systemd/afterburn-sshkeys@.service.in index 8966af8c..fd5a0b28 100644 --- a/systemd/afterburn-sshkeys@.service.in +++ b/systemd/afterburn-sshkeys@.service.in @@ -12,6 +12,7 @@ ConditionKernelCommandLine=|ignition.platform.id=azurestack ConditionKernelCommandLine=|ignition.platform.id=digitalocean ConditionKernelCommandLine=|ignition.platform.id=exoscale ConditionKernelCommandLine=|ignition.platform.id=gcp +ConditionKernelCommandLine=|ignition.platform.id=hetzner ConditionKernelCommandLine=|ignition.platform.id=ibmcloud ConditionKernelCommandLine=|ignition.platform.id=openstack ConditionKernelCommandLine=|ignition.platform.id=packet