routes: Add kubeadm kubeconfig resource

The *GET /kubeadm/kubeconfig/<instance-id>* operation returns a
configuration document for `kubeadm` to add the node to the cluster as a
worker.  The document is derived from the kubeconfig stored in the
`cluster-info` ConfigMap, which includes the external URL of the
Kubernetes API server and the root CA certificate used in the cluster.
The bootstrap token assigned to the specified instance is added to the
document for `kubeadm` to use for authentication.  The kubeconfig is
stored in the ConfigMap as a string, so extracting data from it requires
deserializing the YAML document first.

In order to access the cluster information ConfigMap, the service
account bound to the pod running the provisioner service must have the
appropriate permissions.
master
Dustin 2022-10-02 17:48:18 -05:00
parent 25524d5290
commit df39fe46eb
8 changed files with 337 additions and 3 deletions

22
Cargo.lock generated
View File

@ -450,6 +450,7 @@ dependencies = [
"rsa",
"serde",
"serde_json",
"serde_yaml 0.9.13",
"sha1",
"x509-parser",
]
@ -957,7 +958,7 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"serde_yaml",
"serde_yaml 0.8.26",
"thiserror",
"tokio",
"tokio-util",
@ -1863,6 +1864,19 @@ dependencies = [
"yaml-rust",
]
[[package]]
name = "serde_yaml"
version = "0.9.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8613d593412a0deb7bbd8de9d908efff5a0cb9ccd8f62c641e7b2ed2f57291d1"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sha1"
version = "0.10.2"
@ -2361,6 +2375,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e5fa573d8ac5f1a856f8d7be41d390ee973daf97c806b2c1a465e4e1406e68"
[[package]]
name = "url"
version = "2.2.2"

View File

@ -15,6 +15,7 @@ rocket = { version = "0.5.0-rc.2", features = ["json"] }
rsa = "0.6.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.85"
serde_yaml = "0.9.13"
sha1 = "0.10.2"
x509-parser = "0.14.0"

View File

@ -3,13 +3,15 @@ use std::collections::btree_map::BTreeMap;
use chrono::offset::Utc;
use chrono::{DateTime, Duration};
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::api::core::v1::{ConfigMap, Secret};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::core::params::{ListParams, Patch, PatchParams, PostParams};
use kube::{Api, Client};
use log::{debug, error, info};
use log::{debug, error, info, warn};
use rand::seq::SliceRandom;
use crate::model::k8s::*;
/// The set of characters allowed to appear in bootstrap tokens
const TOKEN_CHARS: [char; 36] = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
@ -312,9 +314,138 @@ pub async fn create_bootstrap_token<I: AsRef<str>>(
Ok(())
}
/// Get the `kubeadm join` configuration for the specified EC2 instance
///
/// This function creates a kubeconfig file that can be passed to `kubeadm
/// join` to add the specified EC2 instance to the Kubernetes cluster as a
/// worker node. The cluster configuration is read from the `cluster-info`
/// ConfigMap in the *kube-public* namespace. The bootstrap token assigned to
/// the instance is included for client authentication.
pub async fn get_kubeconfig<I: AsRef<str>>(
instance_id: I,
) -> Result<Option<KubeConfig>, kube::Error> {
let instance_id = instance_id.as_ref();
let token = match get_bootstrap_token(&instance_id).await {
Ok(Some(t)) => t,
Ok(None) => {
warn!("No bootstrap token assigned to instance {}", &instance_id);
return Ok(None);
}
Err(e) => {
error!(
"Could not get bootstrap token for instance {}: {}",
&instance_id, e
);
return Ok(None);
}
};
match get_cluster_info().await? {
Some(config) => {
let cluster = Cluster {
name: "kubernetes".into(),
cluster: config.clusters[0].cluster.clone(),
};
let context = Context {
name: "kubeadm".into(),
context: ContextInfo {
cluster: "kubernetes".into(),
user: "kubeadm".into(),
},
};
let user = User {
name: "kubeadm".into(),
user: UserInfo { token: token },
};
let mut kubeconfig = KubeConfig::default();
kubeconfig.clusters = vec![cluster];
kubeconfig.contexts = Some(vec![context]);
kubeconfig.current_context = "kubeadm".into();
kubeconfig.users = Some(vec![user]);
Ok(Some(kubeconfig))
}
None => {
warn!("No kubeconfig loaded from cluster-info");
Ok(None)
}
}
}
/// Retrieve the bootstrap token assigned to an EC2 instance
async fn get_bootstrap_token<I: AsRef<str>>(
instance_id: I,
) -> Result<Option<String>, kube::Error> {
let instance_id = instance_id.as_ref();
let client = Client::try_default().await?;
let secrets: Api<Secret> = Api::namespaced(client, "kube-system");
let lp = ListParams::default()
.fields("type=bootstrap.kubernetes.io/token")
.labels(&format!(
"dynk8s.du5t1n.me/ec2-instance-id={}",
&instance_id
));
for s in secrets.list(&lp).await? {
match token_string(&s) {
Ok(t) => return Ok(Some(t)),
Err(e) => {
error!("Invalid bootstrap token: {}", e);
}
}
}
Ok(None)
}
/// Get cluster information from the ConfigMap
async fn get_cluster_info() -> Result<Option<KubeConfig>, kube::Error> {
let client = Client::try_default().await?;
let configmaps: Api<ConfigMap> = Api::namespaced(client, "kube-public");
let cluster_info = configmaps.get("cluster-info").await?;
if let Some(data) = cluster_info.data {
if let Some(config) = data.get("kubeconfig") {
match serde_yaml::from_str::<KubeConfig>(config) {
Ok(c) => return Ok(Some(c)),
Err(e) => {
error!(
"Could not load kubeconfig from cluster-info: {}",
e
);
}
};
} else {
error!("No kubeconfig property found in cluster-info ConfigMap");
}
} else {
error!("No data property found in cluster-info ConfigMap");
}
Ok(None)
}
/// Get the string representation of a bootstrap token from a Secret
fn token_string(secret: &Secret) -> Result<String, String> {
let data = match &secret.data {
Some(d) => d,
None => return Err("Missing data property".into()),
};
let token_id = match data.get("token-id") {
Some(s) => match String::from_utf8(s.0.clone()) {
Ok(s) => s,
Err(e) => return Err(e.to_string()),
},
None => return Err("Missing token-id".into()),
};
let secret = match data.get("token-secret") {
Some(s) => match String::from_utf8(s.0.clone()) {
Ok(s) => s,
Err(e) => return Err(e.to_string()),
},
None => return Err("Missing token-secret".into()),
};
Ok(format!("{}.{}", token_id, secret))
}
#[cfg(test)]
mod test {
use super::*;
use k8s_openapi::ByteString;
use regex::Regex;
#[test]
@ -377,4 +508,17 @@ mod test {
"i-0a1b2c3d4e5f6f7f8"
);
}
#[test]
fn test_token_string() {
let token = BootstrapToken::new();
let mut data = BTreeMap::new();
data.insert("token-id".into(), ByteString(token.token_id().into()));
data.insert("token-secret".into(), ByteString(token.secret().into()));
let secret = Secret {
data: Some(data),
..Default::default()
};
assert_eq!(token.token(), token_string(&secret).unwrap());
}
}

View File

@ -12,6 +12,10 @@ fn rocket() -> _ {
"/",
rocket::routes![
routes::health::get_health,
routes::kubeadm::get_node_kubeconfig,
routes::kubeadm::post_node_kubeconfig,
routes::kubeadm::patch_node_kubeconfig,
routes::kubeadm::put_node_kubeconfig,
routes::wireguard::get_node_wireguard,
routes::sns::post_sns_notify,
routes::sns::get_sns_notify,

91
src/model/k8s.rs Normal file
View File

@ -0,0 +1,91 @@
//! kubeadm configuration data types
//!
//! The Kubernetes API reference does not include a specification for the
//! `Config` resource, and as such there is no model for it in [`k8s_openapi`].
//! Since *dynk8s* needs to read and write objects of this type, to provide
//! configuration for `kubeadm` on dynamic nodes, a subset of the required
//! model is defined here.
use serde::{Deserialize, Serialize};
/// Cluster information
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct ClusterInfo {
/// X.509 certificate of the Kubernetes certificate authority
pub certificate_authority_data: String,
/// URL of the Kubernetes API server
pub server: String,
}
/// Cluster definition
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Cluster {
/// Cluster information
pub cluster: ClusterInfo,
/// Cluster name
pub name: String,
}
/// kubeconfig context information
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ContextInfo {
/// The cluster to use
pub cluster: String,
/// The user to use
pub user: String,
}
/// kubeconfig context definition
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Context {
/// Context information
pub context: ContextInfo,
/// Context name
pub name: String,
}
/// User information
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserInfo {
/// Bootstrap token for authentication
pub token: String,
}
/// User definition
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct User {
/// User name
pub name: String,
/// User information
pub user: UserInfo,
}
/// kubeconfig
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct KubeConfig {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
/// List of defined clusters
pub clusters: Vec<Cluster>,
/// List of defined contexts (usercluster associations)
pub contexts: Option<Vec<Context>>,
#[serde(rename = "current-context")]
/// Current context
pub current_context: String,
/// List of defined users
pub users: Option<Vec<User>>,
}
impl Default for KubeConfig {
fn default() -> Self {
Self {
api_version: "v1".into(),
kind: "Config".into(),
clusters: vec![],
contexts: None,
current_context: "".into(),
users: None,
}
}
}

View File

@ -1,3 +1,4 @@
//! The dynk8s provisioner data model
pub mod events;
pub mod k8s;
pub mod sns;

72
src/routes/kubeadm.rs Normal file
View File

@ -0,0 +1,72 @@
use rocket::http::Status;
use crate::k8s::get_kubeconfig;
#[rocket::get("/kubeadm/kubeconfig/<instance_id>")]
pub async fn get_node_kubeconfig(instance_id: String) -> Option<String> {
if let Ok(Some(kubeconfig)) = get_kubeconfig(&instance_id).await {
Some(serde_yaml::to_string(&kubeconfig).unwrap())
} else {
None
}
}
#[rocket::post("/kubeadm/kubeconfig/<_instance_id>")]
pub async fn post_node_kubeconfig(_instance_id: String) -> Status {
Status::MethodNotAllowed
}
#[rocket::patch("/kubeadm/kubeconfig/<_instance_id>")]
pub async fn patch_node_kubeconfig(_instance_id: String) -> Status {
Status::MethodNotAllowed
}
#[rocket::put("/kubeadm/kubeconfig/<_instance_id>")]
pub async fn put_node_kubeconfig(_instance_id: String) -> Status {
Status::MethodNotAllowed
}
#[cfg(test)]
mod test {
use super::*;
use crate::rocket;
use rocket::local::blocking::Client;
use rocket::uri;
#[test]
fn test_get_node_token_404() {
let client = Client::tracked(rocket()).unwrap();
let res = client
.get(uri!(get_node_kubeconfig(
instance_id = "i-0a1b2c3d4e5f6f7f8"
)))
.dispatch();
assert_eq!(res.status(), Status::NotFound);
}
#[test]
fn test_kubeconfig_msg_wrong_method() {
let client = Client::tracked(rocket()).unwrap();
let res = client
.post(uri!(get_node_kubeconfig(
instance_id = "i-0a1b2c3d4e5f6f7f8"
)))
.dispatch();
assert_eq!(res.status(), Status::MethodNotAllowed);
let res = client
.patch(uri!(get_node_kubeconfig(
instance_id = "i-0a1b2c3d4e5f6f7f8"
)))
.dispatch();
assert_eq!(res.status(), Status::MethodNotAllowed);
let res = client
.put(uri!(get_node_kubeconfig(
instance_id = "i-0a1b2c3d4e5f6f7f8"
)))
.dispatch();
assert_eq!(res.status(), Status::MethodNotAllowed);
}
}

View File

@ -1,4 +1,5 @@
//! Rocket route handlers
pub mod health;
pub mod kubeadm;
pub mod sns;
pub mod wireguard;