Skip to content

Commit

Permalink
Merge pull request #996 from mhutter/feat/hetzner
Browse files Browse the repository at this point in the history
providers/hetzner: add support for Hetzner Cloud
  • Loading branch information
prestist authored Sep 21, 2023
2 parents fda1e1a + ad26a97 commit 296d08d
Show file tree
Hide file tree
Showing 11 changed files with 335 additions and 5 deletions.
4 changes: 4 additions & 0 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 6 additions & 0 deletions docs/usage/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions dracut/30afterburn/afterburn-hostname.service
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,6 +57,7 @@ pub fn fetch_metadata(provider: &str) -> Result<Box<dyn providers::MetadataProvi
"digitalocean" => 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.
Expand Down
149 changes: 149 additions & 0 deletions src/providers/hetzner/mock_tests.rs
Original file line number Diff line number Diff line change
@@ -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);
}
151 changes: 151 additions & 0 deletions src/providers/hetzner/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<std::collections::HashMap<String, String>> {
let meta: HetznerMetadata = self
.client
.get(retry::Yaml, HETZNER_METADATA_BASE_URL.to_string())
.send()?
.unwrap();

Ok(meta.into())
}

fn hostname(&self) -> Result<Option<String>> {
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<Vec<PublicKey>> {
let keys: Vec<String> = self
.client
.get(retry::Json, Self::endpoint_for("public-keys"))
.send()?
.unwrap_or_default();

let keys = keys
.iter()
.map(|s| PublicKey::parse(s))
.collect::<Result<_, _>>()?;

Ok(keys)
}
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct HetznerMetadata {
hostname: Option<String>,
instance_id: Option<i64>,
public_ipv4: Option<String>,
availability_zone: Option<String>,
region: Option<String>,
}

impl From<HetznerMetadata> for HashMap<String, String> {
fn from(meta: HetznerMetadata) -> Self {
let mut out = HashMap::with_capacity(5);

let add_value = |map: &mut HashMap<_, _>, key: &str, value: Option<String>| {
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");
}
}
1 change: 1 addition & 0 deletions src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit 296d08d

Please sign in to comment.