Skip to main content

kube_eks_config/
lib.rs

1//! # kube-eks-config
2//!
3//! Helpers for building a [`kube_client::Client`] (or [`kube_client::Config`])
4//! directly from an [Amazon EKS](https://aws.amazon.com/eks/) cluster, without
5//! manually managing a kubeconfig file on disk.
6//!
7//! ## How it works
8//!
9//! The crate calls the AWS EKS `DescribeCluster` API to retrieve the cluster's
10//! HTTPS endpoint and certificate-authority data, then converts those values
11//! into the configuration structs used by `kube_client`.  Authentication
12//! (bearer tokens) is intentionally omitted: EKS uses short-lived tokens
13//! obtained via `aws eks get-token`, IRSA, or EKS Pod Identity — none of which
14//! belong in a static config.
15//!
16//! ## Quick start
17//!
18//! ```rust,no_run
19//! use kube_eks_config::{TryEksClusterExt, default_aws_client};
20//!
21//! #[tokio::main]
22//! async fn main() -> kube_client::Result<()> {
23//!     // Credentials are loaded from the environment (see [`default_aws_client`])
24//!     let aws = default_aws_client().await;
25//!
26//!     // One call produces a ready-to-use Kubernetes client
27//!     let client = aws.try_eks_kube_client("my-cluster").await?;
28//!     let _ = client;
29//!     Ok(())
30//! }
31//! ```
32//!
33//! ## AWS credentials
34//!
35//! [`default_aws_client`] resolves credentials via the standard AWS provider
36//! chain (highest priority first):
37//!
38//! 1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, …)
39//! 2. AWS shared credentials / config files (`~/.aws/credentials`)
40//! 3. Web identity / IRSA (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`)
41//! 4. Amazon EC2 / ECS instance metadata (IMDSv2)
42//!
43//! Any custom [`aws_sdk_eks::Client`] can also be used directly with the
44//! [`TryEksClusterExt`] methods.
45//!
46//! ## Traits at a glance
47//!
48//! | Trait | Input | Output |
49//! |---|---|---|
50//! | [`TryEksClusterExt`] | `eks::Client` + cluster name | cluster / config / client |
51//! | [`ToKubeConfig`] | `eks::types::Cluster` | `kube_client::Config` |
52//! | [`IntoKubeconfig`] | `eks::types::Cluster` | `kube_client::config::Kubeconfig` |
53
54use aws_sdk_eks as eks;
55use kube_client::Error as KubeError;
56use kube_client::config as kubeconfig;
57
58/// Extension trait that adds EKS-aware helpers to [`aws_sdk_eks::Client`].
59///
60/// The three methods form a convenience ladder — use the one that returns
61/// exactly what you need without paying for extra AWS API calls:
62///
63/// | Method | Returns |
64/// |---|---|
65/// | [`try_eks_cluster`](Self::try_eks_cluster) | Raw [`eks::types::Cluster`] from the AWS API |
66/// | [`try_eks_kube_config`](Self::try_eks_kube_config) | [`kube_client::Config`] ready for `Client::try_from` |
67/// | [`try_eks_kube_client`](Self::try_eks_kube_client) | Fully initialised [`kube_client::Client`] |
68///
69/// Most callers only need [`try_eks_kube_client`](Self::try_eks_kube_client).
70/// The lower-level methods are exposed so that intermediate values can be
71/// inspected or reused without making additional AWS API calls.
72///
73/// # Example
74///
75/// ```rust,no_run
76/// use kube_eks_config::{TryEksClusterExt, default_aws_client};
77///
78/// # #[tokio::main]
79/// # async fn main() -> kube_client::Result<()> {
80/// let aws = default_aws_client().await;
81/// let client = aws.try_eks_kube_client("my-cluster").await?;
82/// let _ = client;
83/// # Ok(())
84/// # }
85/// ```
86// `async fn` in traits is stable since Rust 1.75 but still triggers a lint;
87// `#[expect]` silences it and documents the intent.
88#[expect(async_fn_in_trait)]
89pub trait TryEksClusterExt {
90    /// Fetches the EKS cluster descriptor from the AWS API.
91    ///
92    /// Returns the raw [`eks::types::Cluster`] struct, which contains the
93    /// HTTPS endpoint URL, certificate-authority data, cluster status,
94    /// Kubernetes version, tags, and other metadata.
95    ///
96    /// # Errors
97    ///
98    /// - [`eks::Error::NotFoundException`] if no cluster with the given name
99    ///   exists in the caller's AWS account and region.
100    /// - Any other [`eks::Error`] variant on API or network failures.
101    ///
102    /// # Example
103    ///
104    /// ```rust,no_run
105    /// # use kube_eks_config::{TryEksClusterExt, default_aws_client};
106    /// # #[tokio::main]
107    /// # async fn main() -> Result<(), aws_sdk_eks::Error> {
108    /// let aws = default_aws_client().await;
109    /// let cluster = aws.try_eks_cluster("my-cluster").await?;
110    /// println!("Kubernetes version: {:?}", cluster.version);
111    /// # Ok(())
112    /// # }
113    /// ```
114    async fn try_eks_cluster(
115        &self,
116        cluster: impl Into<String>,
117    ) -> Result<eks::types::Cluster, eks::Error>;
118
119    /// Builds a [`kube_client::Config`] for the named EKS cluster.
120    ///
121    /// This is a provided method: it calls [`try_eks_cluster`](Self::try_eks_cluster)
122    /// and converts the result via [`ToKubeConfig::into_kube_config`].
123    ///
124    /// The resulting `Config` holds the cluster's HTTPS endpoint and
125    /// certificate-authority data but does **not** contain authentication
126    /// credentials — EKS authentication is handled via short-lived tokens
127    /// (IRSA, EKS Pod Identity, `aws eks get-token`).
128    ///
129    /// # Errors
130    ///
131    /// - [`kube_client::Error::Service`] wrapping an [`eks::Error`] on AWS failures.
132    /// - [`kube_client::Error::InferKubeconfig`] if the endpoint URL is absent
133    ///   or cannot be parsed.
134    async fn try_eks_kube_config(
135        &self,
136        cluster: impl Into<String>,
137    ) -> Result<kube_client::Config, KubeError> {
138        self.try_eks_cluster(cluster)
139            .await
140            .map_err(|err| KubeError::Service(Box::new(err)))?
141            .into_kube_config()
142            .map_err(KubeError::InferKubeconfig)
143    }
144
145    /// Creates a [`kube_client::Client`] connected to the named EKS cluster.
146    ///
147    /// This is the primary convenience method. It combines
148    /// [`try_eks_kube_config`](Self::try_eks_kube_config) and
149    /// [`kube_client::Client::try_from`] into a single call.
150    ///
151    /// # Errors
152    ///
153    /// Propagates all errors from
154    /// [`try_eks_kube_config`](Self::try_eks_kube_config) plus any TLS or HTTP
155    /// client initialisation errors from `kube_client`.
156    ///
157    /// # Example
158    ///
159    /// ```rust,no_run
160    /// use kube_eks_config::{TryEksClusterExt, default_aws_client};
161    ///
162    /// #[tokio::main]
163    /// async fn main() -> kube_client::Result<()> {
164    ///     let aws = default_aws_client().await;
165    ///     let client = aws.try_eks_kube_client("my-cluster").await?;
166    ///     let _ = client; // use with kube::Api for Kubernetes operations
167    ///     Ok(())
168    /// }
169    /// ```
170    async fn try_eks_kube_client(
171        &self,
172        cluster: impl Into<String>,
173    ) -> Result<kube_client::Client, KubeError> {
174        let config = self.try_eks_kube_config(cluster).await?;
175        kube_client::Client::try_from(config)
176    }
177}
178
179impl TryEksClusterExt for eks::Client {
180    async fn try_eks_cluster(
181        &self,
182        cluster: impl Into<String>,
183    ) -> Result<eks::types::Cluster, eks::Error> {
184        let cluster = cluster.into();
185        self.describe_cluster()
186            .name(&cluster)
187            .send()
188            .await?
189            .cluster
190            .ok_or_else(|| cluster_not_found(&cluster))
191    }
192}
193
194/// Converts an [`eks::types::Cluster`] into a [`kube_client::Config`].
195///
196/// This lower-level conversion is used internally by
197/// [`TryEksClusterExt::try_eks_kube_config`]. It is also useful when you
198/// already hold a `Cluster` value and want a runtime `Config` without making
199/// an additional AWS API call.
200///
201/// See also [`IntoKubeconfig`] for converting to the serialisable
202/// [`kube_client::config::Kubeconfig`] format (the equivalent of a kubeconfig
203/// YAML file).
204pub trait ToKubeConfig {
205    /// Converts `self` into a [`kube_client::Config`].
206    ///
207    /// Extracts the cluster's `endpoint` (required) and
208    /// `certificate_authority.data` (optional). No authentication credentials
209    /// are included — EKS uses short-lived bearer tokens.
210    ///
211    /// # Errors
212    ///
213    /// - [`kube_client::config::KubeconfigError::MissingClusterUrl`] if the
214    ///   cluster's `endpoint` field is `None`.
215    /// - [`kube_client::config::KubeconfigError::ParseClusterUrl`] if the
216    ///   endpoint string is not a valid URL.
217    fn into_kube_config(self) -> Result<kubeconfig::Config, kubeconfig::KubeconfigError>;
218}
219
220impl ToKubeConfig for eks::types::Cluster {
221    fn into_kube_config(self) -> Result<kubeconfig::Config, kubeconfig::KubeconfigError> {
222        let client_certificate_data = self.certificate_authority.and_then(|cert| cert.data);
223        let auth_info = kubeconfig::AuthInfo {
224            client_certificate_data,
225            ..kubeconfig::AuthInfo::default()
226        };
227        let cluster_url = self
228            .endpoint
229            .ok_or(kubeconfig::KubeconfigError::MissingClusterUrl)?
230            .try_into()
231            .map_err(kubeconfig::KubeconfigError::ParseClusterUrl)?;
232        let config = kubeconfig::Config {
233            auth_info,
234            ..kubeconfig::Config::new(cluster_url)
235        };
236
237        Ok(config)
238    }
239}
240
241/// Converts an [`eks::types::Cluster`] into a
242/// [`kube_client::config::Kubeconfig`].
243///
244/// Unlike [`ToKubeConfig`] (which produces a `kube_client::Config` runtime
245/// struct), this trait produces the serialisable
246/// [`kube_client::config::Kubeconfig`] structure — the in-memory equivalent of
247/// a `~/.kube/config` file — with named cluster, context, and
248/// `current-context` entries.
249///
250/// This is useful when you need to:
251///
252/// - Serialise the kubeconfig to YAML and write it to disk.
253/// - Merge the EKS cluster entry into an existing kubeconfig.
254/// - Pass a structured kubeconfig to tooling that expects the full format.
255///
256/// # Authentication
257///
258/// The produced `Kubeconfig` contains **no `auth_infos` entries**. EKS
259/// authentication relies on short-lived bearer tokens obtained outside of
260/// static kubeconfig credentials (e.g. via IRSA, EKS Pod Identity, or
261/// `aws eks get-token`). Callers are responsible for supplying an
262/// `exec`-based `AuthInfo` if they need a fully self-contained kubeconfig.
263///
264/// See also [`ToKubeConfig`] for a direct runtime `Config` conversion.
265pub trait IntoKubeconfig {
266    /// Converts `self` into a [`kube_client::config::Kubeconfig`].
267    ///
268    /// The cluster name (falls back to `"eks-cluster"` if absent) is used as
269    /// the `clusters[0].name`, `contexts[0].name`,
270    /// `contexts[0].context.cluster`, and `current-context`.
271    ///
272    /// # Errors
273    ///
274    /// Currently infallible in practice, but returns a `Result` for forward
275    /// compatibility.
276    fn into_kubeconfig(self) -> Result<kubeconfig::Kubeconfig, kubeconfig::KubeconfigError>;
277}
278
279impl IntoKubeconfig for eks::types::Cluster {
280    fn into_kubeconfig(self) -> Result<kubeconfig::Kubeconfig, kubeconfig::KubeconfigError> {
281        let eks::types::Cluster {
282            name,
283            endpoint,
284            certificate_authority,
285            // arn,
286            // created_at,
287            // version,
288            // role_arn,
289            // resources_vpc_config,
290            // kubernetes_network_config,
291            // logging,
292            // identity,
293            // status,
294            // client_request_token,
295            // platform_version,
296            // tags,
297            // encryption_config,
298            // connector_config,
299            // id,
300            // health,
301            // outpost_config,
302            // access_config,
303            // upgrade_policy,
304            // zonal_shift_config,
305            // remote_network_config,
306            // compute_config,
307            // storage_config,
308            // deletion_protection,
309            ..
310        } = self;
311        let name = name.unwrap_or_else(|| "eks-cluster".to_string());
312        let certificate_authority_data = certificate_authority.and_then(|cert| cert.data);
313
314        let cluster = kubeconfig::Cluster {
315            server: endpoint,
316            insecure_skip_tls_verify: None,
317            certificate_authority: None,
318            certificate_authority_data,
319            proxy_url: None,
320            disable_compression: None,
321            tls_server_name: None,
322            extensions: None,
323        };
324
325        let named_cluster = kubeconfig::NamedCluster {
326            name: name.clone(),
327            cluster: Some(cluster),
328        };
329
330        let context = kubeconfig::Context {
331            cluster: name.clone(),
332            user: None,
333            namespace: None,
334            extensions: None,
335        };
336
337        let named_context = kubeconfig::NamedContext {
338            name: name.clone(),
339            context: Some(context),
340        };
341
342        let config = kubeconfig::Kubeconfig {
343            clusters: vec![named_cluster],
344            contexts: vec![named_context],
345            current_context: Some(name),
346            // auth_infos: vec![],
347            ..kubeconfig::Kubeconfig::default()
348        };
349
350        Ok(config)
351    }
352}
353
354// Constructs a `NotFoundException` carrying a human-readable message that
355// names the missing cluster. Used when `DescribeCluster` returns `None`.
356fn cluster_not_found(cluster: &str) -> eks::Error {
357    let exception = eks::types::error::NotFoundException::builder()
358        .message(format!("EKS cluster {cluster} not found"))
359        .build();
360    eks::Error::NotFoundException(exception)
361}
362
363/// Creates an [`aws_sdk_eks::Client`] from the default AWS credential chain.
364///
365/// Credentials and the AWS region are resolved in the following order
366/// (highest priority first):
367///
368/// 1. **Environment variables** — `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
369///    `AWS_SESSION_TOKEN`, `AWS_REGION` / `AWS_DEFAULT_REGION`.
370/// 2. **AWS shared files** — `~/.aws/credentials` and `~/.aws/config`.
371/// 3. **Web identity / IRSA** — `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`
372///    (used in Kubernetes pods with IAM Roles for Service Accounts).
373/// 4. **Instance metadata** — EC2 instance profile or ECS task role via the
374///    IMDSv2 endpoint.
375///
376/// This is a thin convenience wrapper around [`aws_config::load_from_env`].
377/// For fine-grained control over credentials, region, or endpoint
378/// configuration, construct an [`aws_sdk_eks::Client`] directly and use
379/// [`TryEksClusterExt`] on it.
380///
381/// # Example
382///
383/// ```rust,no_run
384/// use kube_eks_config::{TryEksClusterExt, default_aws_client};
385///
386/// # #[tokio::main]
387/// # async fn main() -> kube_client::Result<()> {
388/// let aws = default_aws_client().await;
389/// let client = aws.try_eks_kube_client("my-cluster").await?;
390/// let _ = client;
391/// # Ok(())
392/// # }
393/// ```
394pub async fn default_aws_client() -> eks::Client {
395    let config = aws_config::load_from_env().await;
396    eks::Client::new(&config)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{IntoKubeconfig, ToKubeConfig};
402    use aws_sdk_eks as eks;
403    use kube_client::config as kubeconfig;
404
405    /// Constructs an `eks::types::Cluster` from optional parts without hitting AWS.
406    fn make_cluster(
407        name: Option<&str>,
408        endpoint: Option<&str>,
409        cert_data: Option<&str>,
410    ) -> eks::types::Cluster {
411        let mut builder = eks::types::Cluster::builder();
412        if let Some(n) = name {
413            builder = builder.name(n);
414        }
415        if let Some(e) = endpoint {
416            builder = builder.endpoint(e);
417        }
418        if let Some(d) = cert_data {
419            builder =
420                builder.certificate_authority(eks::types::Certificate::builder().data(d).build());
421        }
422        builder.build()
423    }
424
425    #[test]
426    fn cluster_not_found_error_contains_name() {
427        let err = super::cluster_not_found("my-cluster");
428        let eks::Error::NotFoundException(ref ex) = err else {
429            panic!("expected NotFoundException, got {err:?}");
430        };
431        assert!(
432            ex.message().unwrap_or("").contains("my-cluster"),
433            "error message should contain the cluster name"
434        );
435    }
436
437    #[test]
438    fn to_kube_config_extracts_endpoint_and_cert() {
439        let cluster = make_cluster(
440            Some("test"),
441            Some("https://abc123.gr7.us-east-1.eks.amazonaws.com"),
442            Some("base64certdata=="),
443        );
444        let config = cluster.into_kube_config().expect("should build config");
445        assert_eq!(
446            config.cluster_url.host(),
447            Some("abc123.gr7.us-east-1.eks.amazonaws.com")
448        );
449        assert_eq!(
450            config.auth_info.client_certificate_data.as_deref(),
451            Some("base64certdata==")
452        );
453    }
454
455    #[test]
456    fn to_kube_config_missing_endpoint_returns_error() {
457        let cluster = make_cluster(Some("test"), None, None);
458        let err = cluster.into_kube_config().unwrap_err();
459        assert!(
460            matches!(err, kubeconfig::KubeconfigError::MissingClusterUrl),
461            "expected MissingClusterUrl, got {err:?}"
462        );
463    }
464
465    #[test]
466    fn into_kubeconfig_uses_cluster_name() {
467        let cluster = make_cluster(Some("my-cluster"), Some("https://example.k8s.io"), None);
468        let kc = cluster.into_kubeconfig().expect("should build kubeconfig");
469        assert_eq!(kc.current_context.as_deref(), Some("my-cluster"));
470        assert_eq!(kc.clusters[0].name, "my-cluster");
471        assert_eq!(kc.contexts[0].name, "my-cluster");
472        assert_eq!(
473            kc.contexts[0].context.as_ref().map(|c| c.cluster.as_str()),
474            Some("my-cluster")
475        );
476    }
477
478    #[test]
479    fn into_kubeconfig_falls_back_to_eks_cluster_name() {
480        let cluster = make_cluster(None, Some("https://example.k8s.io"), None);
481        let kc = cluster.into_kubeconfig().expect("should build kubeconfig");
482        assert_eq!(kc.current_context.as_deref(), Some("eks-cluster"));
483        assert_eq!(kc.clusters[0].name, "eks-cluster");
484    }
485
486    #[test]
487    fn into_kubeconfig_propagates_cert_authority_data() {
488        let cluster = make_cluster(Some("test"), None, Some("dGVzdA=="));
489        let kc = cluster.into_kubeconfig().expect("should build kubeconfig");
490        let cert = kc.clusters[0]
491            .cluster
492            .as_ref()
493            .and_then(|c| c.certificate_authority_data.as_deref());
494        assert_eq!(cert, Some("dGVzdA=="));
495    }
496}