Skip to main content

husako_openapi/
kubeconfig.rs

1use crate::OpenApiError;
2
3/// Credentials resolved from a kubeconfig file.
4#[derive(Debug, Clone)]
5pub struct ClusterCredentials {
6    pub server: String,
7    pub bearer_token: String,
8}
9
10/// Resolve credentials for a Kubernetes API server from kubeconfig files.
11///
12/// Searches `~/.kube/` by default. Returns the bearer token for the first
13/// cluster matching `server_url`.
14pub fn resolve_credentials(server_url: &str) -> Result<ClusterCredentials, OpenApiError> {
15    let kube_dir = dirs_kube();
16    resolve_credentials_from_dir(&kube_dir, server_url)
17}
18
19/// Resolve credentials from a specific directory of kubeconfig files.
20pub fn resolve_credentials_from_dir(
21    kube_dir: &std::path::Path,
22    server_url: &str,
23) -> Result<ClusterCredentials, OpenApiError> {
24    let entries = std::fs::read_dir(kube_dir).map_err(|e| {
25        OpenApiError::Kubeconfig(format!("cannot read {}: {e}", kube_dir.display()))
26    })?;
27
28    let normalized_target = normalize_url(server_url);
29
30    for entry in entries {
31        let entry = entry.map_err(|e| OpenApiError::Kubeconfig(format!("read entry: {e}")))?;
32        let path = entry.path();
33
34        // Only process regular files (no subdirectory traversal)
35        if !path.is_file() {
36            continue;
37        }
38
39        let content = match std::fs::read_to_string(&path) {
40            Ok(c) => c,
41            Err(_) => continue, // silently skip unreadable files
42        };
43
44        let config: KubeConfig = match serde_yaml_ng::from_str(&content) {
45            Ok(c) => c,
46            Err(_) => continue, // silently skip non-kubeconfig files
47        };
48
49        if let Some(creds) = find_credentials(&config, &normalized_target) {
50            return Ok(creds);
51        }
52    }
53
54    Err(OpenApiError::Kubeconfig(format!(
55        "no kubeconfig found for server '{server_url}' in {}",
56        kube_dir.display()
57    )))
58}
59
60fn find_credentials(config: &KubeConfig, normalized_target: &str) -> Option<ClusterCredentials> {
61    // Find the cluster entry matching the target server URL
62    let cluster_entry = config.clusters.iter().find(|c| {
63        let normalized = normalize_url(&c.cluster.server);
64        normalized == normalized_target
65    })?;
66
67    let cluster_name = &cluster_entry.name;
68
69    // Find a context referencing this cluster
70    let context_entry = config
71        .contexts
72        .iter()
73        .find(|ctx| &ctx.context.cluster == cluster_name)?;
74
75    let user_name = &context_entry.context.user;
76
77    // Find the user entry
78    let user_entry = config.users.iter().find(|u| &u.name == user_name)?;
79
80    // Extract bearer token
81    let token = user_entry.user.token.as_ref()?;
82
83    Some(ClusterCredentials {
84        server: cluster_entry.cluster.server.clone(),
85        bearer_token: token.clone(),
86    })
87}
88
89/// Normalize a server URL by stripping trailing slashes.
90fn normalize_url(url: &str) -> String {
91    url.trim_end_matches('/').to_string()
92}
93
94fn dirs_kube() -> std::path::PathBuf {
95    dirs_home().join(".kube")
96}
97
98fn dirs_home() -> std::path::PathBuf {
99    std::env::var("HOME")
100        .map(std::path::PathBuf::from)
101        .unwrap_or_else(|_| std::path::PathBuf::from("/root"))
102}
103
104// Minimal kubeconfig structs — just enough for credential extraction.
105
106#[derive(Debug, serde::Deserialize)]
107struct KubeConfig {
108    #[serde(default)]
109    clusters: Vec<NamedCluster>,
110    #[serde(default)]
111    contexts: Vec<NamedContext>,
112    #[serde(default)]
113    users: Vec<NamedUser>,
114}
115
116#[derive(Debug, serde::Deserialize)]
117struct NamedCluster {
118    name: String,
119    cluster: ClusterInfo,
120}
121
122#[derive(Debug, serde::Deserialize)]
123struct ClusterInfo {
124    server: String,
125}
126
127#[derive(Debug, serde::Deserialize)]
128struct NamedContext {
129    #[allow(dead_code)]
130    name: String,
131    context: ContextInfo,
132}
133
134#[derive(Debug, serde::Deserialize)]
135struct ContextInfo {
136    cluster: String,
137    user: String,
138}
139
140#[derive(Debug, serde::Deserialize)]
141struct NamedUser {
142    name: String,
143    user: UserInfo,
144}
145
146#[derive(Debug, serde::Deserialize)]
147struct UserInfo {
148    #[serde(default)]
149    token: Option<String>,
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn write_kubeconfig(dir: &std::path::Path, filename: &str, content: &str) {
157        std::fs::write(dir.join(filename), content).unwrap();
158    }
159
160    const STANDARD_KUBECONFIG: &str = r#"
161apiVersion: v1
162kind: Config
163clusters:
164  - name: my-cluster
165    cluster:
166      server: https://10.0.0.1:6443
167contexts:
168  - name: my-context
169    context:
170      cluster: my-cluster
171      user: my-user
172users:
173  - name: my-user
174    user:
175      token: my-bearer-token-123
176"#;
177
178    #[test]
179    fn resolve_standard_bearer_token() {
180        let tmp = tempfile::tempdir().unwrap();
181        write_kubeconfig(tmp.path(), "config", STANDARD_KUBECONFIG);
182
183        let creds = resolve_credentials_from_dir(tmp.path(), "https://10.0.0.1:6443").unwrap();
184        assert_eq!(creds.bearer_token, "my-bearer-token-123");
185        assert_eq!(creds.server, "https://10.0.0.1:6443");
186    }
187
188    #[test]
189    fn url_normalization_trailing_slash() {
190        let tmp = tempfile::tempdir().unwrap();
191        write_kubeconfig(tmp.path(), "config", STANDARD_KUBECONFIG);
192
193        // Query with trailing slash should still match
194        let creds = resolve_credentials_from_dir(tmp.path(), "https://10.0.0.1:6443/").unwrap();
195        assert_eq!(creds.bearer_token, "my-bearer-token-123");
196    }
197
198    #[test]
199    fn no_match_returns_error() {
200        let tmp = tempfile::tempdir().unwrap();
201        write_kubeconfig(tmp.path(), "config", STANDARD_KUBECONFIG);
202
203        let err =
204            resolve_credentials_from_dir(tmp.path(), "https://other-server:6443").unwrap_err();
205        assert!(err.to_string().contains("no kubeconfig found"));
206    }
207
208    #[test]
209    fn skip_non_yaml_files() {
210        let tmp = tempfile::tempdir().unwrap();
211        write_kubeconfig(tmp.path(), "config", STANDARD_KUBECONFIG);
212        std::fs::write(tmp.path().join("binary.dat"), [0xFF, 0xFE, 0x00]).unwrap();
213        std::fs::write(tmp.path().join("readme.txt"), "not yaml").unwrap();
214
215        let creds = resolve_credentials_from_dir(tmp.path(), "https://10.0.0.1:6443").unwrap();
216        assert_eq!(creds.bearer_token, "my-bearer-token-123");
217    }
218
219    #[test]
220    fn multiple_configs_first_match_wins() {
221        let tmp = tempfile::tempdir().unwrap();
222
223        write_kubeconfig(
224            tmp.path(),
225            "config-a",
226            r#"
227apiVersion: v1
228kind: Config
229clusters:
230  - name: cluster-a
231    cluster:
232      server: https://a:6443
233contexts:
234  - name: ctx-a
235    context:
236      cluster: cluster-a
237      user: user-a
238users:
239  - name: user-a
240    user:
241      token: token-a
242"#,
243        );
244
245        write_kubeconfig(
246            tmp.path(),
247            "config-b",
248            r#"
249apiVersion: v1
250kind: Config
251clusters:
252  - name: cluster-b
253    cluster:
254      server: https://b:6443
255contexts:
256  - name: ctx-b
257    context:
258      cluster: cluster-b
259      user: user-b
260users:
261  - name: user-b
262    user:
263      token: token-b
264"#,
265        );
266
267        let creds = resolve_credentials_from_dir(tmp.path(), "https://b:6443").unwrap();
268        assert_eq!(creds.bearer_token, "token-b");
269    }
270
271    #[test]
272    fn no_token_user_skipped() {
273        let tmp = tempfile::tempdir().unwrap();
274        write_kubeconfig(
275            tmp.path(),
276            "config",
277            r#"
278apiVersion: v1
279kind: Config
280clusters:
281  - name: cl
282    cluster:
283      server: https://10.0.0.1:6443
284contexts:
285  - name: ctx
286    context:
287      cluster: cl
288      user: usr
289users:
290  - name: usr
291    user: {}
292"#,
293        );
294
295        let err = resolve_credentials_from_dir(tmp.path(), "https://10.0.0.1:6443").unwrap_err();
296        assert!(err.to_string().contains("no kubeconfig found"));
297    }
298}