diff --git a/docs/platforms.md b/docs/platforms.md index d2795735..d86a4219 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -8,6 +8,9 @@ By default Afterburn uses the Ignition platform ID to detect the environment whe The following platforms are supported, with a different set of features available on each: +* akamai + - Attributes + - SSH Keys * aliyun - Attributes - SSH Keys diff --git a/docs/release-notes.md b/docs/release-notes.md index b488bfcd..2c09e9b2 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -8,6 +8,8 @@ nav_order: 8 Major changes: +- Add support for Akamai Connected Cloud (Linode) + Minor changes: Packaging changes: diff --git a/docs/usage/attributes.md b/docs/usage/attributes.md index f3868f92..8baaab4f 100644 --- a/docs/usage/attributes.md +++ b/docs/usage/attributes.md @@ -13,6 +13,20 @@ which wants to make use of Afterburn metadata must explicitly pull it in using e Cloud providers with supported metadata endpoints and their respective attributes are listed below. +* akamai + - AFTERBURN_AKAMAI_INSTANCE_HOST_UUID + - AFTERBURN_AKAMAI_INSTANCE_ID + - AFTERBURN_AKAMAI_INSTANCE_LABEL + - AFTERBURN_AKAMAI_INSTANCE_REGION + - AFTERBURN_AKAMAI_INSTANCE_TAGS + - AFTERBURN_AKAMAI_INSTANCE_TYPE + - AFTERBURN_AKAMAI_IPV6_LINK_LOCAL + - AFTERBURN_AKAMAI_IPV6_RANGE_0 + - AFTERBURN_AKAMAI_IPV6_SHARED_RANGE_0 + - AFTERBURN_AKAMAI_IPV6_SLAAC + - AFTERBURN_AKAMAI_PRIVATE_IPV4_0 + - AFTERBURN_AKAMAI_PUBLIC_IPV4_0 + - AFTERBURN_AKAMAI_SHARED_IPV4_0 * aliyun - AFTERBURN_ALIYUN_EIPV4 - AFTERBURN_ALIYUN_HOSTNAME diff --git a/src/metadata.rs b/src/metadata.rs index f27dc7e4..b89bf5b3 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -15,6 +15,7 @@ use anyhow::{bail, Result}; use crate::providers; +use crate::providers::akamai::AkamaiProvider; use crate::providers::aliyun::AliyunProvider; use crate::providers::aws::AwsProvider; use crate::providers::cloudstack::configdrive::ConfigDrive; @@ -49,6 +50,7 @@ macro_rules! box_result { /// to the provider-specific fetch logic. pub fn fetch_metadata(provider: &str) -> Result> { match provider { + "akamai" => box_result!(AkamaiProvider::try_new()?), "aliyun" => box_result!(AliyunProvider::try_new()?), "aws" => box_result!(AwsProvider::try_new()?), "azure" => box_result!(Azure::try_new()?), diff --git a/src/providers/akamai/mock_tests.rs b/src/providers/akamai/mock_tests.rs new file mode 100644 index 00000000..e6ac21a7 --- /dev/null +++ b/src/providers/akamai/mock_tests.rs @@ -0,0 +1,104 @@ +use crate::providers::akamai::{AkamaiProvider, TOKEN_TTL}; +use crate::providers::MetadataProvider; +use mockito::{self}; + +#[test] +fn test_attributes() { + let mut server = mockito::Server::new(); + let token = "deadbeefcafebabe"; + + // Mock the PUT /v1/token endpoint. + let put_v1_token = server + .mock("PUT", "/v1/token") + .match_header("metadata-token-expiry-seconds", TOKEN_TTL) + .with_body(token) + .expect_at_least(1) + .create(); + + // Mock the GET /v1/instance endpoint. + let instance_metadata = r#"{ + "id": 12345678, + "label": "my-linode", + "region": "us-ord", + "type": "g6-nanode-1", + "specs": { + "vcpus": 1, + "memory": 1024, + "gpus": 0, + "transfer": 1000, + "disk": 25600 + }, + "backups": { + "enabled": false, + "status": null + }, + "host_uuid": "a631b16d14534d84e2830da16d1b28e1d08d24df", + "tags": ["foo", "bar", "baz"] + }"#; + + let get_v1_instance = server + .mock("GET", "/v1/instance") + .match_header("Accept", "application/json") + .match_header("metadata-token", token) + .with_body(instance_metadata) + .create(); + + // Mock the /v1/network endpoint. + let network_metadata = r#"{ + "interfaces": [ + { + "id": 12345678, + "purpose": "public", + "label": null, + "ipam_address": null + } + ], + "ipv4": { + "public": [ + "1.2.3.4/32" + ], + "private": [ + "192.168.1.1/32" + ], + "shared": [] + }, + "ipv6": { + "slaac": "2600:3c06::f03c:94ff:fecb:c10b/128", + "ranges": [], + "link_local": "fe80::f03c:94ff:fecb:c10b/128", + "shared_ranges": [] + } + }"#; + + let get_v1_network = server + .mock("GET", "/v1/network") + .match_header("Accept", "application/json") + .match_header("metadata-token", token) + .with_body(network_metadata) + .create(); + + let provider = AkamaiProvider::with_base_url(server.url()).unwrap(); + let attrs = provider.attributes(); + + // Assert that our endpoints were called. + put_v1_token.assert(); + get_v1_instance.assert(); + get_v1_network.assert(); + + let actual = attrs.unwrap(); + let expected = maplit::hashmap! { + "AKAMAI_INSTANCE_ID".to_string() => "12345678".to_string(), + "AKAMAI_INSTANCE_HOST_UUID".to_string() => "a631b16d14534d84e2830da16d1b28e1d08d24df".to_string(), + "AKAMAI_INSTANCE_LABEL".to_string() => "my-linode".to_string(), + "AKAMAI_INSTANCE_REGION".to_string() => "us-ord".to_string(), + "AKAMAI_INSTANCE_TYPE".to_string() => "g6-nanode-1".to_string(), + "AKAMAI_INSTANCE_TAGS".to_string() => "foo:bar:baz".to_string(), + "AKAMAI_PUBLIC_IPV4_0".to_string() => "1.2.3.4/32".to_string(), + "AKAMAI_PRIVATE_IPV4_0".to_string() => "192.168.1.1/32".to_string(), + "AKAMAI_IPV6_SLAAC".to_string() => "2600:3c06::f03c:94ff:fecb:c10b/128".to_string(), + "AKAMAI_IPV6_LINK_LOCAL".to_string() => "fe80::f03c:94ff:fecb:c10b/128".to_string(), + }; + assert_eq!(expected, actual); + + server.reset(); +} diff --git a/src/providers/akamai/mod.rs b/src/providers/akamai/mod.rs new file mode 100644 index 00000000..e13a59af --- /dev/null +++ b/src/providers/akamai/mod.rs @@ -0,0 +1,284 @@ +// Copyright 2024 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 Akamai Connected Cloud (Linode). +//! +//! The Metadata Service's API specification is described in [Guides - Overview of the Metadata +//! Service](https://www.linode.com/docs/products/compute/compute-instances/guides/metadata/). + +#[cfg(test)] +mod mock_tests; + +use anyhow::{Context, Result}; +use openssh_keys::PublicKey; +use reqwest::header::{HeaderName, HeaderValue}; +use serde::Deserialize; +use std::collections::HashMap; + +use crate::providers::MetadataProvider; +use crate::retry; + +/// Default TTL for the metadata token, in seconds. +static TOKEN_TTL: &str = "300"; + +pub struct AkamaiProvider { + client: retry::Client, +} + +impl AkamaiProvider { + /// Instantiate a new `AkamaiProvider`. + pub fn try_new() -> Result { + // Get a metadata token. + let client = retry::Client::try_new()?; + let token = get_token(client)?; + + // Create the new client with the token pre-loaded into a header. + // All of the other endpoints accept "text/plain" and "application/json". + // Let's prefer JSON. + let client = retry::Client::try_new()? + .header( + HeaderName::from_static("metadata-token"), + HeaderValue::from_str(&token)?, + ) + .header( + HeaderName::from_static("accept"), + HeaderValue::from_static("application/json"), + ) + .return_on_404(true); + Ok(Self { client }) + } + + /// Instantiate a new `AkamaiProvider` with a specific client. + /// + /// NOTE: This method solely exists for testing. + #[cfg(test)] + pub fn with_base_url(url: String) -> Result { + let client = retry::Client::try_new()? + .mock_base_url(url.clone()) + .return_on_404(true) + .max_retries(0); + let token = get_token(client)?; + + let client = retry::Client::try_new()? + .header( + HeaderName::from_static("metadata-token"), + HeaderValue::from_str(&token)?, + ) + .header( + HeaderName::from_static("accept"), + HeaderValue::from_static("application/json"), + ) + .mock_base_url(url) + .return_on_404(true) + .max_retries(0); + Ok(Self { client }) + } + + fn endpoint_for(key: &str) -> String { + const URL: &str = "http://169.254.169.254/v1"; + format!("{URL}/{key}") + } + + /// Fetch the instance metadata. + fn fetch_instance_metadata(&self) -> Result { + let instance: Instance = self + .client + .get(retry::Json, AkamaiProvider::endpoint_for("instance")) + .send()? + .context("get instance metadata")?; + Ok(instance) + } + + /// Fetch the network metadata. + fn fetch_network_metadata(&self) -> Result { + let network: Network = self + .client + .get(retry::Json, AkamaiProvider::endpoint_for("network")) + .send()? + .context("get network metadata")?; + Ok(network) + } + + /// Fetch the SSH keys. + /// The returned [HashMap] is a mapping of usernames, to SSH public keys. + fn fetch_ssh_keys(&self) -> Result>> { + let ssh_keys: SshKeys = self + .client + .get(retry::Json, AkamaiProvider::endpoint_for("ssh-keys")) + .send()? + .context("get ssh keys")?; + Ok(ssh_keys.users) + } + + /// Convert instance and network metadata into environment variables. + fn parse_attrs(&self) -> Result> { + // Instance metadata. + let data = self.fetch_instance_metadata()?; + let mut attrs = vec![ + ("AKAMAI_INSTANCE_ID".to_string(), data.id.to_string()), + ( + "AKAMAI_INSTANCE_HOST_UUID".to_string(), + data.host_uuid.clone(), + ), + ("AKAMAI_INSTANCE_LABEL".to_string(), data.label.clone()), + ("AKAMAI_INSTANCE_REGION".to_string(), data.region.clone()), + ("AKAMAI_INSTANCE_TYPE".to_string(), data.r#type.clone()), + ("AKAMAI_INSTANCE_TAGS".to_string(), data.tags.join(":")), + ]; + + // Network metadata. + let data = self.fetch_network_metadata()?; + + // IPv4 + for (i, addr) in data.ipv4.public.iter().enumerate() { + attrs.push((format!("AKAMAI_PUBLIC_IPV4_{i}"), addr.to_string())); + } + + for (i, addr) in data.ipv4.private.iter().enumerate() { + attrs.push((format!("AKAMAI_PRIVATE_IPV4_{i}"), addr.to_string())); + } + + for (i, addr) in data.ipv4.shared.iter().enumerate() { + attrs.push((format!("AKAMAI_SHARED_IPV4_{i}"), addr.to_string())); + } + + // IPv6 + attrs.push(("AKAMAI_IPV6_SLAAC".to_string(), data.ipv6.slaac.clone())); + attrs.push(( + "AKAMAI_IPV6_LINK_LOCAL".to_string(), + data.ipv6.link_local.clone(), + )); + for (i, v) in data.ipv6.ranges.iter().enumerate() { + attrs.push((format!("AKAMAI_IPV6_RANGE_{i}"), v.to_string())); + } + for (i, v) in data.ipv6.shared_ranges.iter().enumerate() { + attrs.push((format!("AKAMAI_IPV6_SHARED_RANGE_{i}"), v.to_string())); + } + + Ok(attrs) + } +} + +// Retrieve a token we can use to authenticate future requests to the Linode Metadata Service. +fn get_token(client: retry::Client) -> Result { + let token: String = client + .header( + HeaderName::from_static("metadata-token-expiry-seconds"), + HeaderValue::from_static(TOKEN_TTL), + ) + .put(retry::Raw, AkamaiProvider::endpoint_for("token"), None) + .dispatch_put()? + .context("get metadata token")?; + Ok(token) +} + +impl MetadataProvider for AkamaiProvider { + fn attributes(&self) -> Result> { + let attrs = self.parse_attrs()?; + Ok(attrs.into_iter().collect()) + } + + fn ssh_keys(&self) -> Result> { + Ok(self + .fetch_ssh_keys()? + .values() + .flatten() + .map(|k| PublicKey::parse(k)) + .collect::>()?) + } +} + +#[derive(Clone, Deserialize)] +struct Instance { + id: i64, + host_uuid: String, + label: String, + region: String, + r#type: String, + tags: Vec, + #[allow(dead_code)] + specs: Specs, + #[allow(dead_code)] + backups: Backups, +} + +#[allow(dead_code)] +#[derive(Clone, Deserialize)] +struct Specs { + // Total number of virtual CPU cores on the instance. + // Currently, the largest offering is 64 vCPUs on a `g6-dedicated-64` instance type. + vcpus: u8, + + // Total amount of instance memory, in MB (not MiB). + memory: u64, + + // Total amount of local disk, in MB. + // + // NOTE: This is a strange number. For example, an instance with 25GB of disk has a reported + // size of `25600`. + disk: u64, + + // The monthly network transfer limit for the instance, in GB (not GiB). + // For a 1TB monthly transfer limit, this value would be `1000`. + transfer: u64, + + // Total number of available GPUs. + gpus: u8, +} + +#[allow(dead_code)] +#[derive(Clone, Deserialize)] +struct Backups { + enabled: bool, + status: Option, // pending, running, complete +} + +#[derive(Clone, Deserialize)] +struct Network { + #[allow(dead_code)] + interfaces: Vec, + ipv4: Ipv4, + ipv6: Ipv6, +} + +#[allow(dead_code)] +#[derive(Clone, Deserialize)] +struct NetworkInterface { + id: u64, + purpose: Option, // public, vlan + label: Option, + ipam_address: Option, +} + +#[derive(Clone, Deserialize)] +struct Ipv4 { + public: Vec, + private: Vec, + shared: Vec, +} + +#[derive(Clone, Deserialize)] +struct Ipv6 { + slaac: String, // undocumented + ranges: Vec, // ??? + link_local: String, // snake_case is correct, documentation is wrong + shared_ranges: Vec, // undocumented, might be "elastic-ranges" in the doc +} + +/// Used for deserializing a JSON response from the /v1/ssh-keys endpoint. +#[derive(Clone, Deserialize)] +struct SshKeys { + // Mapping of user names, to a list of public keys. + users: HashMap>, +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 0fb01f4a..ab9698a7 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -23,6 +23,7 @@ //! function to fetch the metadata, and then add a match line in the top-level //! `fetch_metadata()` function in metadata.rs. +pub mod akamai; pub mod aliyun; pub mod aws; pub mod cloudstack; diff --git a/systemd/afterburn-sshkeys@.service.in b/systemd/afterburn-sshkeys@.service.in index 9e889fb9..5709131e 100644 --- a/systemd/afterburn-sshkeys@.service.in +++ b/systemd/afterburn-sshkeys@.service.in @@ -5,6 +5,7 @@ Description=Afterburn (SSH Keys) # (e.g. via optional platform components); those platforms need a user-provided # dropin, adding an appropriate triggering condition and setting the value of # `AFTERBURN_OPT_PROVIDER` as needed. +ConditionKernelCommandLine=|ignition.platform.id=akamai ConditionKernelCommandLine=|ignition.platform.id=aliyun ConditionKernelCommandLine=|ignition.platform.id=aws ConditionKernelCommandLine=|ignition.platform.id=azure