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}