-
Notifications
You must be signed in to change notification settings - Fork 106
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #996 from mhutter/feat/hetzner
providers/hetzner: add support for Hetzner Cloud
- Loading branch information
Showing
11 changed files
with
335 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.