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
parent
25524d5290
commit
df39fe46eb
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
148
src/k8s.rs
148
src/k8s.rs
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (user–cluster 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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
//! The dynk8s provisioner data model
|
||||
pub mod events;
|
||||
pub mod k8s;
|
||||
pub mod sns;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
//! Rocket route handlers
|
||||
pub mod health;
|
||||
pub mod kubeadm;
|
||||
pub mod sns;
|
||||
pub mod wireguard;
|
||||
|
|
Loading…
Reference in New Issue