kube_client/config/
file_config.rs

1use std::{
2    collections::HashMap,
3    fs, io,
4    path::{Path, PathBuf},
5};
6
7use secrecy::{ExposeSecret, SecretString};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10use super::{KubeconfigError, LoadDataError};
11
12/// [`CLUSTER_EXTENSION_KEY`] is reserved in the cluster extensions list for exec plugin config.
13const CLUSTER_EXTENSION_KEY: &str = "client.authentication.k8s.io/exec";
14
15/// [`Kubeconfig`] represents information on how to connect to a remote Kubernetes cluster
16///
17/// Stored in `~/.kube/config` by default, but can be distributed across multiple paths in passed through `KUBECONFIG`.
18/// An analogue of the [config type from client-go](https://github.com/kubernetes/client-go/blob/7697067af71046b18e03dbda04e01a5bb17f9809/tools/clientcmd/api/types.go).
19///
20/// This type (and its children) are exposed primarily for convenience.
21///
22/// [`Config`][crate::Config] is the __intended__ developer interface to help create a [`Client`][crate::Client],
23/// and this will handle the difference between in-cluster deployment and local development.
24#[derive(Clone, Debug, Serialize, Deserialize, Default)]
25#[cfg_attr(test, derive(PartialEq))]
26pub struct Kubeconfig {
27    /// General information to be use for cli interactions
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub preferences: Option<Preferences>,
30    /// Referencable names to cluster configs
31    #[serde(default, deserialize_with = "deserialize_null_as_default")]
32    pub clusters: Vec<NamedCluster>,
33    /// Referencable names to user configs
34    #[serde(rename = "users")]
35    #[serde(default, deserialize_with = "deserialize_null_as_default")]
36    pub auth_infos: Vec<NamedAuthInfo>,
37    /// Referencable names to context configs
38    #[serde(default, deserialize_with = "deserialize_null_as_default")]
39    pub contexts: Vec<NamedContext>,
40    /// The name of the context that you would like to use by default
41    #[serde(rename = "current-context")]
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub current_context: Option<String>,
44    /// Additional information for extenders so that reads and writes don't clobber unknown fields.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub extensions: Option<Vec<NamedExtension>>,
47
48    // legacy fields TODO: remove
49    /// Legacy field from TypeMeta
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub kind: Option<String>,
52    /// Legacy field from TypeMeta
53    #[serde(rename = "apiVersion")]
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub api_version: Option<String>,
56}
57
58/// Preferences stores extensions for cli.
59#[derive(Clone, Debug, Serialize, Deserialize)]
60#[cfg_attr(test, derive(PartialEq, Eq))]
61pub struct Preferences {
62    /// Enable colors
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub colors: Option<bool>,
65    /// Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub extensions: Option<Vec<NamedExtension>>,
68}
69
70/// NamedExtention associates name with extension.
71#[derive(Clone, Debug, Serialize, Deserialize)]
72#[cfg_attr(test, derive(PartialEq, Eq))]
73pub struct NamedExtension {
74    /// Name of extension
75    pub name: String,
76    /// Additional information for extenders so that reads and writes don't clobber unknown fields
77    pub extension: serde_json::Value,
78}
79
80/// NamedCluster associates name with cluster.
81#[derive(Clone, Debug, Serialize, Deserialize, Default)]
82#[cfg_attr(test, derive(PartialEq, Eq))]
83pub struct NamedCluster {
84    /// Name of cluster
85    pub name: String,
86    /// Information about how to communicate with a kubernetes cluster
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub cluster: Option<Cluster>,
89}
90
91/// Cluster stores information to connect Kubernetes cluster.
92#[derive(Clone, Debug, Serialize, Deserialize, Default)]
93#[cfg_attr(test, derive(PartialEq, Eq))]
94pub struct Cluster {
95    /// The address of the kubernetes cluster (https://hostname:port).
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub server: Option<String>,
98    /// Skips the validity check for the server's certificate. This will make your HTTPS connections insecure.
99    #[serde(rename = "insecure-skip-tls-verify")]
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub insecure_skip_tls_verify: Option<bool>,
102    /// The path to a cert file for the certificate authority.
103    #[serde(rename = "certificate-authority")]
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub certificate_authority: Option<String>,
106    /// PEM-encoded certificate authority certificates. Overrides `certificate_authority`
107    #[serde(rename = "certificate-authority-data")]
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub certificate_authority_data: Option<String>,
110    /// URL to the proxy to be used for all requests.
111    #[serde(rename = "proxy-url")]
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub proxy_url: Option<String>,
114    /// Compression is enabled by default with the `gzip` feature.
115    /// `disable_compression` allows client to opt-out of response compression for all requests to the server.
116    /// This is useful to speed up requests (specifically lists) when client-server network bandwidth is ample,
117    /// by saving time on compression (server-side) and decompression (client-side):
118    /// https://github.com/kubernetes/kubernetes/issues/112296
119    #[serde(rename = "disable-compression")]
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub disable_compression: Option<bool>,
122    /// Name used to check server certificate.
123    ///
124    /// If `tls_server_name` is `None`, the hostname used to contact the server is used.
125    #[serde(rename = "tls-server-name")]
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub tls_server_name: Option<String>,
128    /// Additional information for extenders so that reads and writes don't clobber unknown fields
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub extensions: Option<Vec<NamedExtension>>,
131}
132
133/// NamedAuthInfo associates name with authentication.
134#[derive(Clone, Debug, Serialize, Deserialize, Default)]
135#[cfg_attr(test, derive(PartialEq))]
136pub struct NamedAuthInfo {
137    /// Name of the user
138    pub name: String,
139    /// Information that describes identity of the user
140    #[serde(rename = "user")]
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub auth_info: Option<AuthInfo>,
143}
144
145fn serialize_secretstring<S>(pw: &Option<SecretString>, serializer: S) -> Result<S::Ok, S::Error>
146where
147    S: Serializer,
148{
149    match pw {
150        Some(secret) => serializer.serialize_str(secret.expose_secret()),
151        None => serializer.serialize_none(),
152    }
153}
154
155fn deserialize_secretstring<'de, D>(deserializer: D) -> Result<Option<SecretString>, D::Error>
156where
157    D: Deserializer<'de>,
158{
159    match Option::<String>::deserialize(deserializer) {
160        Ok(Some(secret)) => Ok(Some(SecretString::new(secret.into()))),
161        Ok(None) => Ok(None),
162        Err(e) => Err(e),
163    }
164}
165
166fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
167where
168    T: Default + Deserialize<'de>,
169    D: Deserializer<'de>,
170{
171    let opt = Option::deserialize(deserializer)?;
172    Ok(opt.unwrap_or_default())
173}
174
175/// AuthInfo stores information to tell cluster who you are.
176#[derive(Clone, Debug, Serialize, Deserialize, Default)]
177pub struct AuthInfo {
178    /// The username for basic authentication to the kubernetes cluster.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub username: Option<String>,
181    /// The password for basic authentication to the kubernetes cluster.
182    #[serde(skip_serializing_if = "Option::is_none", default)]
183    #[serde(
184        serialize_with = "serialize_secretstring",
185        deserialize_with = "deserialize_secretstring"
186    )]
187    pub password: Option<SecretString>,
188
189    /// The bearer token for authentication to the kubernetes cluster.
190    #[serde(skip_serializing_if = "Option::is_none", default)]
191    #[serde(
192        serialize_with = "serialize_secretstring",
193        deserialize_with = "deserialize_secretstring"
194    )]
195    pub token: Option<SecretString>,
196    /// Pointer to a file that contains a bearer token (as described above). If both `token` and token_file` are present, `token` takes precedence.
197    #[serde(rename = "tokenFile")]
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub token_file: Option<String>,
200
201    /// Path to a client cert file for TLS.
202    #[serde(rename = "client-certificate")]
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub client_certificate: Option<String>,
205    /// PEM-encoded data from a client cert file for TLS. Overrides `client_certificate`
206    /// this key should be base64 encoded instead of the decode string data
207    #[serde(rename = "client-certificate-data")]
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub client_certificate_data: Option<String>,
210
211    /// Path to a client key file for TLS.
212    #[serde(rename = "client-key")]
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub client_key: Option<String>,
215    /// PEM-encoded data from a client key file for TLS. Overrides `client_key`
216    /// this key should be base64 encoded instead of the decode string data
217    #[serde(rename = "client-key-data")]
218    #[serde(skip_serializing_if = "Option::is_none", default)]
219    #[serde(
220        serialize_with = "serialize_secretstring",
221        deserialize_with = "deserialize_secretstring"
222    )]
223    pub client_key_data: Option<SecretString>,
224
225    /// The username to act-as.
226    #[serde(rename = "as")]
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub impersonate: Option<String>,
229    /// The groups to imperonate.
230    #[serde(rename = "as-groups")]
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub impersonate_groups: Option<Vec<String>>,
233
234    /// Specifies a custom authentication plugin for the kubernetes cluster.
235    #[serde(rename = "auth-provider")]
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub auth_provider: Option<AuthProviderConfig>,
238
239    /// Specifies a custom exec-based authentication plugin for the kubernetes cluster.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub exec: Option<ExecConfig>,
242}
243
244#[cfg(test)]
245impl PartialEq for AuthInfo {
246    fn eq(&self, other: &Self) -> bool {
247        serde_json::to_value(self).unwrap() == serde_json::to_value(other).unwrap()
248    }
249}
250
251/// AuthProviderConfig stores auth for specified cloud provider.
252#[derive(Clone, Debug, Serialize, Deserialize)]
253#[cfg_attr(test, derive(PartialEq, Eq))]
254pub struct AuthProviderConfig {
255    /// Name of the auth provider
256    pub name: String,
257    /// Auth provider configuration
258    #[serde(default)]
259    pub config: HashMap<String, String>,
260}
261
262/// ExecConfig stores credential-plugin configuration.
263#[derive(Clone, Debug, Serialize, Deserialize)]
264#[cfg_attr(test, derive(PartialEq, Eq))]
265pub struct ExecConfig {
266    /// Preferred input version of the ExecInfo.
267    ///
268    /// The returned ExecCredentials MUST use the same encoding version as the input.
269    #[serde(rename = "apiVersion")]
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub api_version: Option<String>,
272    /// Command to execute.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub command: Option<String>,
275    /// Arguments to pass to the command when executing it.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub args: Option<Vec<String>>,
278    /// Env defines additional environment variables to expose to the process.
279    ///
280    /// TODO: These are unioned with the host's environment, as well as variables client-go uses to pass argument to the plugin.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub env: Option<Vec<HashMap<String, String>>>,
283    /// Specifies which environment variables the host should avoid passing to the auth plugin.
284    ///
285    /// This does currently not exist upstream and cannot be specified on disk.
286    /// It has been suggested in client-go via <https://github.com/kubernetes/client-go/issues/1177>
287    #[serde(skip)]
288    pub drop_env: Option<Vec<String>>,
289
290    /// Interative mode of the auth plugins
291    #[serde(rename = "interactiveMode")]
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub interactive_mode: Option<ExecInteractiveMode>,
294
295    /// ProvideClusterInfo determines whether or not to provide cluster information,
296    /// which could potentially contain very large CA data, to this exec plugin as a
297    /// part of the KUBERNETES_EXEC_INFO environment variable. By default, it is set
298    /// to false. Package k8s.io/client-go/tools/auth/exec provides helper methods for
299    /// reading this environment variable.
300    #[serde(default, rename = "provideClusterInfo")]
301    pub provide_cluster_info: bool,
302
303    /// Cluster information to pass to the plugin.
304    /// Should be used only when `provide_cluster_info` is True.
305    #[serde(skip)]
306    pub cluster: Option<ExecAuthCluster>,
307}
308
309/// ExecInteractiveMode define the interactity of the child process
310#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
311#[cfg_attr(test, derive(Eq))]
312pub enum ExecInteractiveMode {
313    /// Never get interactive
314    Never,
315    /// If available et interactive
316    IfAvailable,
317    /// Alwayes get interactive
318    Always,
319}
320
321/// NamedContext associates name with context.
322#[derive(Clone, Debug, Serialize, Deserialize, Default)]
323#[cfg_attr(test, derive(PartialEq, Eq))]
324pub struct NamedContext {
325    /// Name of the context
326    pub name: String,
327    /// Associations for the context
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub context: Option<Context>,
330}
331
332/// Context stores tuple of cluster and user information.
333#[derive(Clone, Debug, Serialize, Deserialize, Default)]
334#[cfg_attr(test, derive(PartialEq, Eq))]
335pub struct Context {
336    /// Name of the cluster for this context
337    pub cluster: String,
338    /// Name of the `AuthInfo` for this context
339    pub user: Option<String>,
340    /// The default namespace to use on unspecified requests
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub namespace: Option<String>,
343    /// Additional information for extenders so that reads and writes don't clobber unknown fields
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub extensions: Option<Vec<NamedExtension>>,
346}
347
348const KUBECONFIG: &str = "KUBECONFIG";
349
350/// Some helpers on the raw Config object are exposed for people needing to parse it
351impl Kubeconfig {
352    /// Read a Config from an arbitrary location
353    pub fn read_from<P: AsRef<Path>>(path: P) -> Result<Kubeconfig, KubeconfigError> {
354        let data =
355            read_path(&path).map_err(|source| KubeconfigError::ReadConfig(source, path.as_ref().into()))?;
356
357        // Remap all files we read to absolute paths.
358        let mut merged_docs = None;
359        for mut config in kubeconfig_from_yaml(&data)? {
360            if let Some(dir) = path.as_ref().parent() {
361                for named in config.clusters.iter_mut() {
362                    if let Some(cluster) = &mut named.cluster {
363                        if let Some(path) = &cluster.certificate_authority {
364                            if let Some(abs_path) = to_absolute(dir, path) {
365                                cluster.certificate_authority = Some(abs_path);
366                            }
367                        }
368                    }
369                }
370                for named in config.auth_infos.iter_mut() {
371                    if let Some(auth_info) = &mut named.auth_info {
372                        if let Some(path) = &auth_info.client_certificate {
373                            if let Some(abs_path) = to_absolute(dir, path) {
374                                auth_info.client_certificate = Some(abs_path);
375                            }
376                        }
377                        if let Some(path) = &auth_info.client_key {
378                            if let Some(abs_path) = to_absolute(dir, path) {
379                                auth_info.client_key = Some(abs_path);
380                            }
381                        }
382                        if let Some(path) = &auth_info.token_file {
383                            if let Some(abs_path) = to_absolute(dir, path) {
384                                auth_info.token_file = Some(abs_path);
385                            }
386                        }
387                    }
388                }
389            }
390            if let Some(c) = merged_docs {
391                merged_docs = Some(Kubeconfig::merge(c, config)?);
392            } else {
393                merged_docs = Some(config);
394            }
395        }
396        // Empty file defaults to an empty Kubeconfig
397        Ok(merged_docs.unwrap_or_default())
398    }
399
400    /// Read a Config from an arbitrary YAML string
401    ///
402    /// This is preferable to using serde_yaml::from_str() because it will correctly
403    /// parse multi-document YAML text and merge them into a single `Kubeconfig`
404    pub fn from_yaml(text: &str) -> Result<Kubeconfig, KubeconfigError> {
405        kubeconfig_from_yaml(text)?
406            .into_iter()
407            .try_fold(Kubeconfig::default(), Kubeconfig::merge)
408    }
409
410    /// Read a Config from `KUBECONFIG` or the the default location.
411    pub fn read() -> Result<Kubeconfig, KubeconfigError> {
412        match Self::from_env()? {
413            Some(config) => Ok(config),
414            None => Self::read_from(default_kube_path().ok_or(KubeconfigError::FindPath)?),
415        }
416    }
417
418    /// Create `Kubeconfig` from `KUBECONFIG` environment variable.
419    /// Supports list of files to be merged.
420    ///
421    /// # Panics
422    ///
423    /// Panics if `KUBECONFIG` value contains the NUL character.
424    pub fn from_env() -> Result<Option<Self>, KubeconfigError> {
425        match std::env::var_os(KUBECONFIG) {
426            Some(value) => {
427                let paths = std::env::split_paths(&value)
428                    .filter(|p| !p.as_os_str().is_empty())
429                    .collect::<Vec<_>>();
430                if paths.is_empty() {
431                    return Ok(None);
432                }
433
434                let merged = paths.iter().try_fold(Kubeconfig::default(), |m, p| {
435                    Kubeconfig::read_from(p).and_then(|c| m.merge(c))
436                })?;
437                Ok(Some(merged))
438            }
439
440            None => Ok(None),
441        }
442    }
443
444    /// Merge kubeconfig file according to the rules described in
445    /// <https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files>
446    ///
447    /// > Merge the files listed in the `KUBECONFIG` environment variable according to these rules:
448    /// >
449    /// > - Ignore empty filenames.
450    /// > - Produce errors for files with content that cannot be deserialized.
451    /// > - The first file to set a particular value or map key wins.
452    /// > - Never change the value or map key.
453    /// >   Example: Preserve the context of the first file to set `current-context`.
454    /// >   Example: If two files specify a `red-user`, use only values from the first file's `red-user`.
455    /// >            Even if the second file has non-conflicting entries under `red-user`, discard them.
456    pub fn merge(mut self, next: Kubeconfig) -> Result<Self, KubeconfigError> {
457        if self.kind.is_some() && next.kind.is_some() && self.kind != next.kind {
458            return Err(KubeconfigError::KindMismatch);
459        }
460        if self.api_version.is_some() && next.api_version.is_some() && self.api_version != next.api_version {
461            return Err(KubeconfigError::ApiVersionMismatch);
462        }
463
464        self.kind = self.kind.or(next.kind);
465        self.api_version = self.api_version.or(next.api_version);
466        self.preferences = self.preferences.or(next.preferences);
467        append_new_named(&mut self.clusters, next.clusters, |x| &x.name);
468        append_new_named(&mut self.auth_infos, next.auth_infos, |x| &x.name);
469        append_new_named(&mut self.contexts, next.contexts, |x| &x.name);
470        self.current_context = self.current_context.or(next.current_context);
471        self.extensions = self.extensions.or(next.extensions);
472        Ok(self)
473    }
474}
475
476fn kubeconfig_from_yaml(text: &str) -> Result<Vec<Kubeconfig>, KubeconfigError> {
477    let mut documents = vec![];
478    for doc in serde_yaml::Deserializer::from_str(text) {
479        let value = serde_yaml::Value::deserialize(doc).map_err(KubeconfigError::Parse)?;
480        let kubeconfig = serde_yaml::from_value(value).map_err(KubeconfigError::InvalidStructure)?;
481        documents.push(kubeconfig);
482    }
483    Ok(documents)
484}
485
486#[allow(clippy::redundant_closure)]
487fn append_new_named<T, F>(base: &mut Vec<T>, next: Vec<T>, f: F)
488where
489    F: Fn(&T) -> &String,
490{
491    use std::collections::HashSet;
492    base.extend({
493        let existing = base.iter().map(|x| f(x)).collect::<HashSet<_>>();
494        next.into_iter()
495            .filter(|x| !existing.contains(f(x)))
496            .collect::<Vec<_>>()
497    });
498}
499
500fn read_path<P: AsRef<Path>>(path: P) -> io::Result<String> {
501    let bytes = fs::read(&path)?;
502    match bytes.as_slice() {
503        [0xFF, 0xFE, ..] => {
504            let utf16_data: Vec<u16> = bytes[2..]
505                .chunks(2)
506                .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
507                .collect();
508            String::from_utf16(&utf16_data)
509                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-16 LE"))
510        }
511        [0xFE, 0xFF, ..] => {
512            let utf16_data: Vec<u16> = bytes[2..]
513                .chunks(2)
514                .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
515                .collect();
516            String::from_utf16(&utf16_data)
517                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-16 BE"))
518        }
519        [0xEF, 0xBB, 0xBF, ..] => String::from_utf8(bytes[3..].to_vec())
520            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8 BOM")),
521        _ => {
522            String::from_utf8(bytes).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))
523        }
524    }
525}
526
527fn to_absolute(dir: &Path, file: &str) -> Option<String> {
528    let path = Path::new(&file);
529    if path.is_relative() {
530        dir.join(path).to_str().map(str::to_owned)
531    } else {
532        None
533    }
534}
535
536impl Cluster {
537    pub(crate) fn load_certificate_authority(&self) -> Result<Option<Vec<u8>>, KubeconfigError> {
538        if self.certificate_authority.is_none() && self.certificate_authority_data.is_none() {
539            return Ok(None);
540        }
541
542        let ca = load_from_base64_or_file(
543            &self.certificate_authority_data.as_deref(),
544            &self.certificate_authority,
545        )
546        .map_err(KubeconfigError::LoadCertificateAuthority)?;
547        Ok(Some(ca))
548    }
549}
550
551impl AuthInfo {
552    pub(crate) fn identity_pem(&self) -> Result<Vec<u8>, KubeconfigError> {
553        let client_cert = &self.load_client_certificate()?;
554        let client_key = &self.load_client_key()?;
555        let mut buffer = client_key.clone();
556        buffer.extend_from_slice(client_cert);
557        Ok(buffer)
558    }
559
560    pub(crate) fn load_client_certificate(&self) -> Result<Vec<u8>, KubeconfigError> {
561        // TODO Shouldn't error when `self.client_certificate_data.is_none() && self.client_certificate.is_none()`
562
563        load_from_base64_or_file(&self.client_certificate_data.as_deref(), &self.client_certificate)
564            .map_err(KubeconfigError::LoadClientCertificate)
565    }
566
567    pub(crate) fn load_client_key(&self) -> Result<Vec<u8>, KubeconfigError> {
568        // TODO Shouldn't error when `self.client_key_data.is_none() && self.client_key.is_none()`
569
570        load_from_base64_or_file(
571            &self.client_key_data.as_ref().map(|secret| secret.expose_secret()),
572            &self.client_key,
573        )
574        .map_err(KubeconfigError::LoadClientKey)
575    }
576}
577
578/// Connection information for auth plugins that have `provideClusterInfo` enabled.
579///
580/// This is a copy of [`kube::config::Cluster`] with certificate_authority passed as bytes without the path.
581/// Taken from [clientauthentication/types.go#Cluster](https://github.com/kubernetes/client-go/blob/477cb782cf024bc70b7239f0dca91e5774811950/pkg/apis/clientauthentication/types.go#L73-L129)
582#[derive(Clone, Debug, Serialize, Deserialize, Default)]
583#[serde(rename_all = "kebab-case")]
584#[cfg_attr(test, derive(PartialEq, Eq))]
585pub struct ExecAuthCluster {
586    /// The address of the kubernetes cluster (https://hostname:port).
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub server: Option<String>,
589    /// Skips the validity check for the server's certificate. This will make your HTTPS connections insecure.
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub insecure_skip_tls_verify: Option<bool>,
592    /// PEM-encoded certificate authority certificates. Overrides `certificate_authority`
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    #[serde(with = "base64serde")]
595    pub certificate_authority_data: Option<Vec<u8>>,
596    /// URL to the proxy to be used for all requests.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub proxy_url: Option<String>,
599    /// Name used to check server certificate.
600    ///
601    /// If `tls_server_name` is `None`, the hostname used to contact the server is used.
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub tls_server_name: Option<String>,
604    /// This can be anything
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub config: Option<serde_json::Value>,
607}
608
609impl TryFrom<&Cluster> for ExecAuthCluster {
610    type Error = KubeconfigError;
611
612    fn try_from(cluster: &crate::config::Cluster) -> Result<Self, KubeconfigError> {
613        let certificate_authority_data = cluster.load_certificate_authority()?;
614        Ok(Self {
615            server: cluster.server.clone(),
616            insecure_skip_tls_verify: cluster.insecure_skip_tls_verify,
617            certificate_authority_data,
618            proxy_url: cluster.proxy_url.clone(),
619            tls_server_name: cluster.tls_server_name.clone(),
620            config: cluster.extensions.as_ref().and_then(|extensions| {
621                extensions
622                    .iter()
623                    .find(|extension| extension.name == CLUSTER_EXTENSION_KEY)
624                    .map(|extension| extension.extension.clone())
625            }),
626        })
627    }
628}
629
630fn load_from_base64_or_file<P: AsRef<Path>>(
631    value: &Option<&str>,
632    file: &Option<P>,
633) -> Result<Vec<u8>, LoadDataError> {
634    let data = value
635        .map(load_from_base64)
636        .or_else(|| file.as_ref().map(load_from_file))
637        .unwrap_or_else(|| Err(LoadDataError::NoBase64DataOrFile))?;
638    Ok(ensure_trailing_newline(data))
639}
640
641fn load_from_base64(value: &str) -> Result<Vec<u8>, LoadDataError> {
642    use base64::Engine;
643    base64::engine::general_purpose::STANDARD
644        .decode(value)
645        .map_err(LoadDataError::DecodeBase64)
646}
647
648fn load_from_file<P: AsRef<Path>>(file: &P) -> Result<Vec<u8>, LoadDataError> {
649    fs::read(file).map_err(|source| LoadDataError::ReadFile(source, file.as_ref().into()))
650}
651
652// Ensure there is a trailing newline in the blob
653// Don't bother if the blob is empty
654fn ensure_trailing_newline(mut data: Vec<u8>) -> Vec<u8> {
655    if data.last().map(|end| *end != b'\n').unwrap_or(false) {
656        data.push(b'\n');
657    }
658    data
659}
660
661/// Returns kubeconfig path from `$HOME/.kube/config`.
662fn default_kube_path() -> Option<PathBuf> {
663    home::home_dir().map(|h| h.join(".kube").join("config"))
664}
665
666mod base64serde {
667    use base64::Engine;
668    use serde::{Deserialize, Deserializer, Serialize, Serializer};
669
670    pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
671        match v {
672            Some(v) => {
673                let encoded = base64::engine::general_purpose::STANDARD.encode(v);
674                String::serialize(&encoded, s)
675            }
676            None => <Option<String>>::serialize(&None, s),
677        }
678    }
679
680    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
681        let data = <Option<String>>::deserialize(d)?;
682        match data {
683            Some(data) => Ok(Some(
684                base64::engine::general_purpose::STANDARD
685                    .decode(data.as_bytes())
686                    .map_err(serde::de::Error::custom)?,
687            )),
688            None => Ok(None),
689        }
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use crate::config::file_loader::ConfigLoader;
696
697    use super::*;
698    use serde_json::{json, Value};
699
700    #[test]
701    fn kubeconfig_merge() {
702        let kubeconfig1 = Kubeconfig {
703            current_context: Some("default".into()),
704            auth_infos: vec![NamedAuthInfo {
705                name: "red-user".into(),
706                auth_info: Some(AuthInfo {
707                    token: Some(SecretString::new("first-token".into())),
708                    ..Default::default()
709                }),
710            }],
711            ..Default::default()
712        };
713        let kubeconfig2 = Kubeconfig {
714            current_context: Some("dev".into()),
715            auth_infos: vec![
716                NamedAuthInfo {
717                    name: "red-user".into(),
718                    auth_info: Some(AuthInfo {
719                        token: Some(SecretString::new("second-token".into())),
720                        username: Some("red-user".into()),
721                        ..Default::default()
722                    }),
723                },
724                NamedAuthInfo {
725                    name: "green-user".into(),
726                    auth_info: Some(AuthInfo {
727                        token: Some(SecretString::new("new-token".into())),
728                        ..Default::default()
729                    }),
730                },
731            ],
732            ..Default::default()
733        };
734
735        let merged = kubeconfig1.merge(kubeconfig2).unwrap();
736        // Preserves first `current_context`
737        assert_eq!(merged.current_context, Some("default".into()));
738        // Auth info with the same name does not overwrite
739        assert_eq!(merged.auth_infos[0].name, "red-user");
740        assert_eq!(
741            merged.auth_infos[0]
742                .auth_info
743                .as_ref()
744                .unwrap()
745                .token
746                .as_ref()
747                .map(|t| t.expose_secret()),
748            Some("first-token")
749        );
750        // Even if it's not conflicting
751        assert_eq!(merged.auth_infos[0].auth_info.as_ref().unwrap().username, None);
752        // New named auth info is appended
753        assert_eq!(merged.auth_infos[1].name, "green-user");
754    }
755
756    #[test]
757    fn kubeconfig_deserialize() {
758        let config_yaml = "apiVersion: v1
759clusters:
760- cluster:
761    certificate-authority-data: LS0t<SNIP>LS0tLQo=
762    server: https://ABCDEF0123456789.gr7.us-west-2.eks.amazonaws.com
763  name: eks
764- cluster:
765    certificate-authority: /home/kevin/.minikube/ca.crt
766    extensions:
767    - extension:
768        last-update: Thu, 18 Feb 2021 16:59:26 PST
769        provider: minikube.sigs.k8s.io
770        version: v1.17.1
771      name: cluster_info
772    server: https://192.168.49.2:8443
773  name: minikube
774contexts:
775- context:
776    cluster: minikube
777    extensions:
778    - extension:
779        last-update: Thu, 18 Feb 2021 16:59:26 PST
780        provider: minikube.sigs.k8s.io
781        version: v1.17.1
782      name: context_info
783    namespace: default
784    user: minikube
785  name: minikube
786- context:
787    cluster: arn:aws:eks:us-west-2:012345678912:cluster/eks
788    user: arn:aws:eks:us-west-2:012345678912:cluster/eks
789  name: eks
790current-context: minikube
791kind: Config
792preferences: {}
793users:
794- name: arn:aws:eks:us-west-2:012345678912:cluster/eks
795  user:
796    exec:
797      apiVersion: client.authentication.k8s.io/v1alpha1
798      args:
799      - --region
800      - us-west-2
801      - eks
802      - get-token
803      - --cluster-name
804      - eks
805      command: aws
806      env: null
807      provideClusterInfo: false
808- name: minikube
809  user:
810    client-certificate: /home/kevin/.minikube/profiles/minikube/client.crt
811    client-key: /home/kevin/.minikube/profiles/minikube/client.key";
812
813        let config = Kubeconfig::from_yaml(config_yaml).unwrap();
814
815        assert_eq!(config.clusters[0].name, "eks");
816        assert_eq!(config.clusters[1].name, "minikube");
817
818        let cluster1 = config.clusters[1].cluster.as_ref().unwrap();
819        assert_eq!(
820            cluster1.extensions.as_ref().unwrap()[0].extension.get("provider"),
821            Some(&Value::String("minikube.sigs.k8s.io".to_owned()))
822        );
823    }
824
825    #[test]
826    fn kubeconfig_multi_document_merge() -> Result<(), KubeconfigError> {
827        let config_yaml = r#"---
828apiVersion: v1
829clusters:
830- cluster:
831    certificate-authority-data: aGVsbG8K
832    server: https://0.0.0.0:6443
833  name: k3d-promstack
834contexts:
835- context:
836    cluster: k3d-promstack
837    user: admin@k3d-promstack
838  name: k3d-promstack
839current-context: k3d-promstack
840kind: Config
841preferences: {}
842users:
843- name: admin@k3d-promstack
844  user:
845    client-certificate-data: aGVsbG8K
846    client-key-data: aGVsbG8K
847---
848apiVersion: v1
849clusters:
850- cluster:
851    certificate-authority-data: aGVsbG8K
852    server: https://0.0.0.0:6443
853  name: k3d-k3s-default
854contexts:
855- context:
856    cluster: k3d-k3s-default
857    user: admin@k3d-k3s-default
858  name: k3d-k3s-default
859current-context: k3d-k3s-default
860kind: Config
861preferences: {}
862users:
863- name: admin@k3d-k3s-default
864  user:
865    client-certificate-data: aGVsbG8K
866    client-key-data: aGVsbG8K
867"#;
868        let cfg = Kubeconfig::from_yaml(config_yaml)?;
869
870        // Ensure we have data from both documents:
871        assert_eq!(cfg.clusters[0].name, "k3d-promstack");
872        assert_eq!(cfg.clusters[1].name, "k3d-k3s-default");
873
874        Ok(())
875    }
876
877    #[test]
878    fn kubeconfig_split_sections_merge() -> Result<(), KubeconfigError> {
879        let config1 = r#"
880apiVersion: v1
881clusters:
882- cluster:
883    certificate-authority-data: aGVsbG8K
884    server: https://0.0.0.0:6443
885  name: k3d-promstack
886contexts:
887- context:
888    cluster: k3d-promstack
889    user: admin@k3d-promstack
890  name: k3d-promstack
891current-context: k3d-promstack
892kind: Config
893preferences: {}
894"#;
895
896        let config2 = r#"
897users:
898- name: admin@k3d-k3s-default
899  user:
900    client-certificate-data: aGVsbG8K
901    client-key-data: aGVsbG8K
902"#;
903
904        let kubeconfig1 = Kubeconfig::from_yaml(config1)?;
905        let kubeconfig2 = Kubeconfig::from_yaml(config2)?;
906        let merged = kubeconfig1.merge(kubeconfig2).unwrap();
907
908        // Ensure we have data from both files:
909        assert_eq!(merged.clusters[0].name, "k3d-promstack");
910        assert_eq!(merged.contexts[0].name, "k3d-promstack");
911        assert_eq!(merged.auth_infos[0].name, "admin@k3d-k3s-default");
912
913        Ok(())
914    }
915
916    #[test]
917    fn kubeconfig_from_empty_string() {
918        let cfg = Kubeconfig::from_yaml("").unwrap();
919
920        assert_eq!(cfg, Kubeconfig::default());
921    }
922
923    #[test]
924    fn authinfo_deserialize_null_secret() {
925        let authinfo_yaml = r#"
926username: user
927password: 
928"#;
929        let authinfo: AuthInfo = serde_yaml::from_str(authinfo_yaml).unwrap();
930        assert_eq!(authinfo.username, Some("user".to_string()));
931        assert!(authinfo.password.is_none());
932    }
933
934    #[test]
935    fn authinfo_debug_does_not_output_password() {
936        let authinfo_yaml = r#"
937username: user
938password: kube_rs
939"#;
940        let authinfo: AuthInfo = serde_yaml::from_str(authinfo_yaml).unwrap();
941        let authinfo_debug_output = format!("{authinfo:?}");
942        let expected_output = "AuthInfo { \
943        username: Some(\"user\"), \
944        password: Some(SecretBox<str>([REDACTED])), \
945        token: None, token_file: None, client_certificate: None, \
946        client_certificate_data: None, client_key: None, \
947        client_key_data: None, impersonate: None, \
948        impersonate_groups: None, \
949        auth_provider: None, \
950        exec: None \
951        }";
952
953        assert_eq!(authinfo_debug_output, expected_output)
954    }
955
956    #[tokio::test]
957    async fn authinfo_exec_provide_cluster_info() {
958        let config = r#"
959apiVersion: v1
960clusters:
961- cluster:
962    server: https://localhost:8080
963    extensions:
964    - name: client.authentication.k8s.io/exec
965      extension:
966        audience: foo
967        other: bar
968  name: foo-cluster
969contexts:
970- context:
971    cluster: foo-cluster
972    user: foo-user
973    namespace: bar
974  name: foo-context
975current-context: foo-context
976kind: Config
977users:
978- name: foo-user
979  user:
980    exec:
981      apiVersion: client.authentication.k8s.io/v1alpha1
982      args:
983      - arg-1
984      - arg-2
985      command: foo-command
986      provideClusterInfo: true
987"#;
988        let kube_config = Kubeconfig::from_yaml(config).unwrap();
989        let config_loader = ConfigLoader::load(kube_config, None, None, None).await.unwrap();
990        let auth_info = config_loader.user;
991        let exec = auth_info.exec.unwrap();
992        assert!(exec.provide_cluster_info);
993        let cluster = exec.cluster.unwrap();
994        assert_eq!(
995            cluster.config.unwrap(),
996            json!({"audience": "foo", "other": "bar"})
997        );
998    }
999
1000    #[tokio::test]
1001    async fn parse_kubeconfig_encodings() {
1002        let files = vec![
1003            "kubeconfig_utf8.yaml",
1004            "kubeconfig_utf16le.yaml",
1005            "kubeconfig_utf16be.yaml",
1006        ];
1007
1008        for file_name in files {
1009            let path = PathBuf::from(format!(
1010                "{}/src/config/test_data/{}",
1011                env!("CARGO_MANIFEST_DIR"),
1012                file_name
1013            ));
1014            let cfg = Kubeconfig::read_from(path).unwrap();
1015            assert_eq!(cfg.clusters[0].name, "k3d-promstack");
1016            assert_eq!(cfg.contexts[0].name, "k3d-promstack");
1017            assert_eq!(cfg.auth_infos[0].name, "admin@k3d-k3s-default");
1018        }
1019    }
1020}