Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add stacked KUBECONFIG support #411

Merged
merged 3 commits into from
Feb 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 172 additions & 6 deletions kube/src/config/file_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
/// This type (and its children) are exposed for convenience only.
/// Please load a [`Config`][crate::Config] object for use with a [`Client`][crate::Client]
/// which will read and parse the kubeconfig file
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct Kubeconfig {
pub kind: Option<String>,
#[serde(rename = "apiVersion")]
Expand All @@ -23,7 +23,7 @@ pub struct Kubeconfig {
pub auth_infos: Vec<NamedAuthInfo>,
pub contexts: Vec<NamedContext>,
#[serde(rename = "current-context")]
pub current_context: String,
pub current_context: Option<String>,
pub extensions: Option<Vec<NamedExtension>>,
}

Expand Down Expand Up @@ -132,6 +132,8 @@ pub struct Context {
pub extensions: Option<Vec<NamedExtension>>,
}

const KUBECONFIG: &str = "KUBECONFIG";

/// Some helpers on the raw Config object are exposed for people needing to parse it
impl Kubeconfig {
/// Read a Config from an arbitrary location
Expand All @@ -140,14 +142,126 @@ impl Kubeconfig {
path: path.as_ref().into(),
source,
})?;
let config = serde_yaml::from_reader(f).map_err(ConfigError::ParseYaml)?;
let mut config: Kubeconfig = serde_yaml::from_reader(f).map_err(ConfigError::ParseYaml)?;

// Remap all files we read to absolute paths.
if let Some(dir) = path.as_ref().parent() {
for named in config.clusters.iter_mut() {
if let Some(path) = &named.cluster.certificate_authority {
if let Some(abs_path) = to_absolute(dir, path) {
named.cluster.certificate_authority = Some(abs_path);
}
}
}
for named in config.auth_infos.iter_mut() {
if let Some(path) = &named.auth_info.client_certificate {
if let Some(abs_path) = to_absolute(dir, path) {
named.auth_info.client_certificate = Some(abs_path);
}
}
if let Some(path) = &named.auth_info.client_key {
if let Some(abs_path) = to_absolute(dir, path) {
named.auth_info.client_key = Some(abs_path);
}
}
if let Some(path) = &named.auth_info.token_file {
if let Some(abs_path) = to_absolute(dir, path) {
named.auth_info.token_file = Some(abs_path);
}
}
}
}
Ok(config)
}

/// Read a Config from the default location
/// Read a Config from `KUBECONFIG` or the the default location.
pub fn read() -> Result<Kubeconfig> {
let path = utils::find_kubeconfig()?;
Self::read_from(path)
match Self::from_env()? {
Some(config) => Ok(config),
None => {
let path = utils::default_kube_path().ok_or(ConfigError::NoKubeconfigPath)?;
Self::read_from(path)
}
}
}

/// Create `Kubeconfig` from `KUBECONFIG` environment variable.
/// Supports list of files to be merged.
///
/// # Panics
///
/// Panics if `KUBECONFIG` value contains the NUL character.
pub fn from_env() -> Result<Option<Self>> {
match std::env::var_os(KUBECONFIG) {
Some(value) => {
let paths = std::env::split_paths(&value)
.filter(|p| !p.as_os_str().is_empty())
.collect::<Vec<_>>();
if paths.is_empty() {
return Ok(None);
}

let merged = paths.iter().try_fold(Kubeconfig::default(), |m, p| {
Kubeconfig::read_from(p).and_then(|c| m.merge(c))
})?;
Ok(Some(merged))
}

None => Ok(None),
}
}

/// Merge kubeconfig file according to the rules described in
/// <https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files>
///
/// > Merge the files listed in the `KUBECONFIG` environment variable according to these rules:
/// >
/// > - Ignore empty filenames.
/// > - Produce errors for files with content that cannot be deserialized.
/// > - The first file to set a particular value or map key wins.
/// > - Never change the value or map key.
/// > Example: Preserve the context of the first file to set `current-context`.
/// > Example: If two files specify a `red-user`, use only values from the first file's `red-user`.
/// > Even if the second file has non-conflicting entries under `red-user`, discard them.
fn merge(mut self, next: Kubeconfig) -> Result<Self> {
if self.kind.is_some() && next.kind.is_some() && self.kind != next.kind {
return Err(ConfigError::KindMismatch.into());
}
if self.api_version.is_some() && next.api_version.is_some() && self.api_version != next.api_version {
return Err(ConfigError::ApiVersionMismatch.into());
}

self.kind = self.kind.or(next.kind);
self.api_version = self.api_version.or(next.api_version);
self.preferences = self.preferences.or(next.preferences);
append_new_named(&mut self.clusters, next.clusters, |x| &x.name);
append_new_named(&mut self.auth_infos, next.auth_infos, |x| &x.name);
append_new_named(&mut self.contexts, next.contexts, |x| &x.name);
self.current_context = self.current_context.or(next.current_context);
self.extensions = self.extensions.or(next.extensions);
Ok(self)
}
}

fn append_new_named<T, F>(base: &mut Vec<T>, next: Vec<T>, f: F)
where
F: Fn(&T) -> &String,
{
use std::collections::HashSet;
base.extend({
let existing = base.iter().map(|x| f(x)).collect::<HashSet<_>>();
next.into_iter()
.filter(|x| !existing.contains(f(x)))
.collect::<Vec<_>>()
});
}

fn to_absolute(dir: &Path, file: &str) -> Option<String> {
let path = Path::new(&file);
if path.is_relative() {
dir.join(path).to_str().map(str::to_owned)
} else {
None
}
}

Expand All @@ -171,3 +285,55 @@ impl AuthInfo {
utils::data_or_file_with_base64(&self.client_key_data, &self.client_key)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn kubeconfig_merge() {
let kubeconfig1 = Kubeconfig {
current_context: Some("default".into()),
auth_infos: vec![NamedAuthInfo {
name: "red-user".into(),
auth_info: AuthInfo {
token: Some("first-token".into()),
..Default::default()
},
}],
..Default::default()
};
let kubeconfig2 = Kubeconfig {
current_context: Some("dev".into()),
auth_infos: vec![
NamedAuthInfo {
name: "red-user".into(),
auth_info: AuthInfo {
token: Some("second-token".into()),
username: Some("red-user".into()),
..Default::default()
},
},
NamedAuthInfo {
name: "green-user".into(),
auth_info: AuthInfo {
token: Some("new-token".into()),
..Default::default()
},
},
],
..Default::default()
};

let merged = kubeconfig1.merge(kubeconfig2).unwrap();
// Preserves first `current_context`
assert_eq!(merged.current_context, Some("default".into()));
// Auth info with the same name does not overwrite
assert_eq!(merged.auth_infos[0].name, "red-user".to_owned());
assert_eq!(merged.auth_infos[0].auth_info.token, Some("first-token".into()));
// Even if it's not conflicting
assert_eq!(merged.auth_infos[0].auth_info.username, None);
// New named auth info is appended
assert_eq!(merged.auth_infos[1].name, "green-user".to_owned());
}
}
14 changes: 8 additions & 6 deletions kube/src/config/file_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,7 @@ pub struct ConfigLoader {
impl ConfigLoader {
/// Returns a config loader based on the cluster information from the kubeconfig file.
pub async fn new_from_options(options: &KubeConfigOptions) -> Result<Self> {
let kubeconfig_path = utils::find_kubeconfig()
.map_err(Box::new)
.map_err(ConfigError::LoadConfigFile)?;

let config = Kubeconfig::read_from(kubeconfig_path)?;
let config = Kubeconfig::read()?;
let loader = Self::load(
config,
options.context.as_ref(),
Expand Down Expand Up @@ -61,7 +57,13 @@ impl ConfigLoader {
cluster: Option<&String>,
user: Option<&String>,
) -> Result<Self> {
let context_name = context.unwrap_or(&config.current_context);
let context_name = if let Some(name) = context {
name
} else if let Some(name) = &config.current_context {
name
} else {
return Err(ConfigError::CurrentContextNotSet.into());
};
let current_context = config
.contexts
.iter()
Expand Down
7 changes: 3 additions & 4 deletions kube/src/config/incluster_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,17 @@ fn kube_port() -> Option<String> {

/// Returns token from specified path in cluster.
pub fn load_token() -> Result<String> {
utils::data_or_file(&None, &Some(SERVICE_TOKENFILE))
utils::read_file_to_string(SERVICE_TOKENFILE)
}

/// Returns certification from specified path in cluster.
pub fn load_cert() -> Result<Vec<Vec<u8>>> {
let ca = utils::data_or_file_with_base64(&None, &Some(SERVICE_CERTFILE))?;
Ok(utils::certs(&ca))
Ok(utils::certs(&utils::read_file(SERVICE_CERTFILE)?))
}

/// Returns the default namespace from specified path in cluster.
pub fn load_default_ns() -> Result<String> {
utils::data_or_file(&None, &Some(SERVICE_DEFAULT_NS))
utils::read_file_to_string(SERVICE_DEFAULT_NS)
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion kube/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mod utils;
use crate::{error::ConfigError, Result};
use file_loader::ConfigLoader;
pub use file_loader::KubeConfigOptions;
pub(crate) use utils::data_or_file;
pub(crate) use utils::read_file_to_string;

use http::header::HeaderMap;

Expand Down
99 changes: 20 additions & 79 deletions kube/src/config/utils.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
use std::{
env, fs,
fs,
path::{Path, PathBuf},
};

use crate::{error::ConfigError, Error, Result};
use dirs::home_dir;

const KUBECONFIG: &str = "KUBECONFIG";

/// Search the kubeconfig file
pub fn find_kubeconfig() -> Result<PathBuf> {
kubeconfig_path()
.or_else(default_kube_path)
.ok_or_else(|| ConfigError::NoKubeconfigPath.into())
}

/// Returns kubeconfig path from specified environment variable.
pub fn kubeconfig_path() -> Option<PathBuf> {
env::var_os(KUBECONFIG).map(PathBuf::from)
}

/// Returns kubeconfig path from `$HOME/.kube/config`.
pub fn default_kube_path() -> Option<PathBuf> {
home_dir().map(|h| h.join(".kube").join("config"))
Expand All @@ -30,42 +16,29 @@ pub fn data_or_file_with_base64<P: AsRef<Path>>(data: &Option<String>, file: &Op
(Some(d), _) => base64::decode(&d)
.map_err(ConfigError::Base64Decode)
.map_err(Error::Kubeconfig),
(_, Some(f)) => {
let f = (*f).as_ref();
let abs_file = if f.is_absolute() {
f.to_path_buf()
} else {
find_kubeconfig().and_then(|cfg| {
cfg.parent()
.map(|kubedir| kubedir.join(f))
.ok_or_else(|| ConfigError::NoAbsolutePath { path: f.into() }.into())
})?
};
// dbg!(&abs_file);
fs::read(&abs_file).map_err(|source| {
ConfigError::ReadFile {
path: abs_file,
source,
}
.into()
})
}
(_, Some(f)) => read_file(f),
_ => Err(ConfigError::NoBase64FileOrData.into()),
}
}

pub fn data_or_file<P: AsRef<Path>>(data: &Option<String>, file: &Option<P>) -> Result<String> {
match (data, file) {
(Some(d), _) => Ok(d.to_string()),
(_, Some(f)) => fs::read_to_string(f).map_err(|source| {
ConfigError::ReadFile {
path: f.as_ref().into(),
source,
}
.into()
}),
_ => Err(ConfigError::NoFileOrData.into()),
}
pub fn read_file<P: AsRef<Path>>(file: P) -> Result<Vec<u8>> {
fs::read(&file).map_err(|source| {
ConfigError::ReadFile {
path: file.as_ref().into(),
source,
}
.into()
})
}

pub fn read_file_to_string<P: AsRef<Path>>(file: P) -> Result<String> {
fs::read_to_string(&file).map_err(|source| {
ConfigError::ReadFile {
path: file.as_ref().into(),
source,
}
.into()
})
}

pub fn certs(data: &[u8]) -> Vec<Vec<u8>> {
Expand All @@ -80,35 +53,3 @@ pub fn certs(data: &[u8]) -> Vec<Vec<u8>> {
})
.collect::<Vec<_>>()
}

#[cfg(test)]
mod tests {
extern crate tempfile;
use super::*;
use crate::config::utils;
use std::io::Write;

#[test]
fn test_kubeconfig_path() {
let expect_str = "/fake/.kube/config";
env::set_var(KUBECONFIG, expect_str);
assert_eq!(PathBuf::from(expect_str), kubeconfig_path().unwrap());
}

#[test]
fn test_data_or_file() {
let data = "fake_data";
let file = "fake_file";
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
write!(tmpfile, "{}", file).unwrap();

let actual = utils::data_or_file(&Some(data.to_string()), &Some(tmpfile.path()));
assert_eq!(actual.ok().unwrap(), data.to_string());

let actual = utils::data_or_file(&None, &Some(tmpfile.path()));
assert_eq!(actual.ok().unwrap(), file.to_string());

let actual = utils::data_or_file(&None, &None::<String>);
assert!(actual.is_err());
}
}
Loading