Skip to main content

xianyun_backends/helm/
mod.rs

1//! Helm operations.
2//!
3//! Ported from `internal/backends/helm/{interface,client}.go`.
4//! `chartref.rs` carries the chart-reference helpers from
5//! `chartref.go`.
6
7pub mod chartref;
8
9use std::sync::Arc;
10
11use xianyun_executor::exec::{CommandRunner, ExecError};
12
13/// Ported from the `Backend` interface in `interface.go`.
14pub trait Backend {
15    /// Installs a Helm chart. `args` should include all flags and values
16    /// for the install command.
17    fn install(&self, release_name: &str, chart_path: &str, args: &[&str])
18    -> Result<(), ExecError>;
19
20    /// Uninstalls a Helm release.
21    fn uninstall(&self, release_name: &str, namespace: &str) -> Result<(), ExecError>;
22
23    /// Upgrades a Helm release. `args` should include all flags and
24    /// values for the upgrade command.
25    fn upgrade(&self, release_name: &str, chart_path: &str, args: &[&str])
26    -> Result<(), ExecError>;
27
28    /// Lists Helm releases in a namespace. Returns JSON output from
29    /// `helm list`.
30    fn list(&self, namespace: &str, filter: &str) -> Result<String, ExecError>;
31
32    /// Lists configured Helm repositories. Returns JSON output from
33    /// `helm repo list`.
34    fn repo_list(&self) -> Result<String, ExecError>;
35
36    /// Adds a Helm repository, or updates it if `force_update` is true.
37    fn repo_add(&self, name: &str, url: &str, force_update: bool) -> Result<(), ExecError>;
38
39    /// Updates a specific Helm repository to fetch the latest charts.
40    fn repo_update(&self, name: &str) -> Result<(), ExecError>;
41
42    /// Downloads the chart and unpacks it into `untar_dir`, which must be
43    /// freshly created.
44    fn pull(&self, chart_ref: &str, untar_dir: &str) -> Result<(), ExecError>;
45
46    /// Returns helm's `dependency list` table. It exits 0 whatever it
47    /// finds, so the caller must read the table rather than the error.
48    fn dependency_list(&self, chart_dir: &str) -> Result<String, ExecError>;
49
50    /// Fetches the dependencies `Chart.lock` pins, leaving the lock
51    /// untouched.
52    fn dependency_build(&self, chart_dir: &str, skip_refresh: bool) -> Result<(), ExecError>;
53}
54
55/// Ported from `Client` in `client.go`.
56///
57/// Unlike Go's `NewClient(kubeconfig string)`, which reads the
58/// `CommandRunner` from an ambient `context.Context` on every call, this
59/// takes the executor explicitly at construction, matching
60/// `docker::Client`/`git::Client`/`kubectl::Client`.
61///
62/// `kubeconfig`'s materialization into a temp file + `KUBECONFIG` env var
63/// (Go's `kubeconfigEnv`, backed by `internal/kubeconfig`) mirrors
64/// `kubectl::Client::kubeconfig_env`, which itself mirrors
65/// `k3d::Driver`'s `TempKubeconfigGuard` -- all three exist because
66/// `xianyun up`'s k3d driver uses `--kubeconfig-update-default=false`, so
67/// nothing writes the cluster's kubeconfig to `~/.kube/config`; every
68/// client that talks to the cluster has to point itself at the real
69/// kubeconfig explicitly or it silently falls back to
70/// `localhost:8080`/the ambient default. Closed alongside `kubectl`'s own
71/// fix (see `PORT_NOTES.md`): an empty `kubeconfig` (Go's no-op path)
72/// means `kubeconfig_env` returns `None` and every call behaves exactly
73/// as before.
74pub struct Client {
75    kubeconfig: String,
76    executor: Arc<dyn CommandRunner + Send + Sync>,
77}
78
79/// A kubeconfig materialized to a throwaway temp file so it can be
80/// pointed at via a `KUBECONFIG` env var -- `helm` reads the file path,
81/// not an in-memory string. Deletes the file on drop. Mirrors
82/// `kubectl::Client`'s identically-named, identically-purposed type.
83struct KubeconfigFile(std::path::PathBuf);
84
85impl Drop for KubeconfigFile {
86    fn drop(&mut self) {
87        let _ = std::fs::remove_file(&self.0);
88    }
89}
90
91impl Client {
92    /// Ported from `NewClient`.
93    pub fn new(kubeconfig: &str, executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
94        Self {
95            kubeconfig: kubeconfig.to_string(),
96            executor,
97        }
98    }
99
100    /// Ported from `Client.kubeconfigEnv`. See [`Client`]'s doc comment.
101    fn kubeconfig_env(&self) -> Result<Option<(Vec<String>, KubeconfigFile)>, ExecError> {
102        if self.kubeconfig.is_empty() {
103            return Ok(None);
104        }
105
106        let pid = std::process::id();
107        let nanos = std::time::SystemTime::now()
108            .duration_since(std::time::UNIX_EPOCH)
109            .map(|d| d.as_nanos())
110            .unwrap_or_default();
111        let path = std::env::temp_dir().join(format!("xianyun-helm-kubeconfig-{pid}-{nanos}"));
112
113        std::fs::write(&path, &self.kubeconfig)
114            .map_err(|err| ExecError::RunFailed(format!("writing temp kubeconfig: {err}")))?;
115
116        let mut env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
117        env.push(format!("KUBECONFIG={}", path.display()));
118
119        Ok(Some((env, KubeconfigFile(path))))
120    }
121
122    fn exec_streamed_command(&self, args: &[String]) -> Result<(), ExecError> {
123        let env_guard = self.kubeconfig_env()?;
124        let opts =
125            env_guard.as_ref().map(
126                |(env, _)| xianyun_executor::exec::streamed::ExecStreamedOpts {
127                    env: Some(env.clone()),
128                    ..Default::default()
129                },
130            );
131
132        self.executor.exec_streamed_command(opts, "helm", args)
133    }
134
135    fn read_stdout_joined(&self, args: &[String]) -> Result<String, ExecError> {
136        let env_guard = self.kubeconfig_env()?;
137        let opts = env_guard
138            .as_ref()
139            .map(|(env, _)| xianyun_executor::exec::Options {
140                env: Some(env.clone()),
141                ..Default::default()
142            });
143
144        let result = self.executor.run("helm", args, opts);
145        if let Some(err) = result.error {
146            return Err(err);
147        }
148
149        Ok(result.stdout.join("\n"))
150    }
151}
152
153impl Backend for Client {
154    fn install(
155        &self,
156        release_name: &str,
157        chart_path: &str,
158        args: &[&str],
159    ) -> Result<(), ExecError> {
160        let mut install_args = vec![
161            "install".to_string(),
162            release_name.to_string(),
163            chart_path.to_string(),
164        ];
165        install_args.extend(args.iter().map(std::string::ToString::to_string));
166
167        self.exec_streamed_command(&install_args)
168    }
169
170    fn uninstall(&self, release_name: &str, namespace: &str) -> Result<(), ExecError> {
171        self.exec_streamed_command(&[
172            "uninstall".to_string(),
173            release_name.to_string(),
174            "--namespace".to_string(),
175            namespace.to_string(),
176        ])
177    }
178
179    fn upgrade(
180        &self,
181        release_name: &str,
182        chart_path: &str,
183        args: &[&str],
184    ) -> Result<(), ExecError> {
185        let mut upgrade_args = vec![
186            "upgrade".to_string(),
187            release_name.to_string(),
188            chart_path.to_string(),
189        ];
190        upgrade_args.extend(args.iter().map(std::string::ToString::to_string));
191
192        self.exec_streamed_command(&upgrade_args)
193    }
194
195    fn list(&self, namespace: &str, filter: &str) -> Result<String, ExecError> {
196        self.read_stdout_joined(&[
197            "list".to_string(),
198            "--namespace".to_string(),
199            namespace.to_string(),
200            "--filter".to_string(),
201            filter.to_string(),
202            "--output".to_string(),
203            "json".to_string(),
204        ])
205    }
206
207    fn repo_list(&self) -> Result<String, ExecError> {
208        self.read_stdout_joined(&[
209            "repo".to_string(),
210            "list".to_string(),
211            "-o".to_string(),
212            "json".to_string(),
213        ])
214    }
215
216    fn repo_add(&self, name: &str, url: &str, force_update: bool) -> Result<(), ExecError> {
217        let mut args = vec![
218            "repo".to_string(),
219            "add".to_string(),
220            name.to_string(),
221            url.to_string(),
222        ];
223        if force_update {
224            args.push("--force-update".to_string());
225        }
226
227        self.exec_streamed_command(&args)
228    }
229
230    fn repo_update(&self, name: &str) -> Result<(), ExecError> {
231        self.exec_streamed_command(&["repo".to_string(), "update".to_string(), name.to_string()])
232    }
233
234    fn pull(&self, chart_ref: &str, untar_dir: &str) -> Result<(), ExecError> {
235        self.exec_streamed_command(&[
236            "pull".to_string(),
237            chart_ref.to_string(),
238            "--untar".to_string(),
239            "--untardir".to_string(),
240            untar_dir.to_string(),
241        ])
242    }
243
244    fn dependency_list(&self, chart_dir: &str) -> Result<String, ExecError> {
245        self.read_stdout_joined(&[
246            "dependency".to_string(),
247            "list".to_string(),
248            chart_dir.to_string(),
249        ])
250    }
251
252    fn dependency_build(&self, chart_dir: &str, skip_refresh: bool) -> Result<(), ExecError> {
253        let mut args = vec![
254            "dependency".to_string(),
255            "build".to_string(),
256            chart_dir.to_string(),
257        ];
258        if skip_refresh {
259            args.push("--skip-refresh".to_string());
260        }
261
262        self.exec_streamed_command(&args)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use xianyun_executor::exec::ExecResult;
270    use xianyun_testutil::MockCommandRunner;
271
272    // --- Ported from client_test.go: TestClient ---
273    //
274    // `helm::Client` now takes its executor explicitly at construction
275    // (see the doc comment on `Client`), so each case builds its own
276    // mock, constructs a `Client` against it directly, and calls
277    // through -- no ambient-context carrier needed.
278
279    struct ClientCase {
280        name: &'static str,
281        want_args: &'static [&'static str],
282        streamed: bool,
283        stdout: Option<&'static [&'static str]>,
284        call: fn(&Client) -> Result<(), ExecError>,
285    }
286
287    #[test]
288    fn client_cases() {
289        let cases: Vec<ClientCase> = vec![
290            ClientCase {
291                name: "Install",
292                want_args: &["install", "my-release", "./chart", "--namespace", "default"],
293                streamed: true,
294                stdout: None,
295                call: |c| c.install("my-release", "./chart", &["--namespace", "default"]),
296            },
297            ClientCase {
298                name: "Uninstall",
299                want_args: &["uninstall", "my-release", "--namespace", "uwubernetes"],
300                streamed: true,
301                stdout: None,
302                call: |c| c.uninstall("my-release", "uwubernetes"),
303            },
304            ClientCase {
305                name: "Upgrade",
306                want_args: &[
307                    "upgrade",
308                    "my-release",
309                    "./chart",
310                    "--namespace",
311                    "default",
312                    "--values",
313                    "values.yaml",
314                ],
315                streamed: true,
316                stdout: None,
317                call: |c| {
318                    c.upgrade(
319                        "my-release",
320                        "./chart",
321                        &["--namespace", "default", "--values", "values.yaml"],
322                    )
323                },
324            },
325            ClientCase {
326                name: "RepoAdd",
327                want_args: &["repo", "add", "example", "https://charts.example.com"],
328                streamed: true,
329                stdout: None,
330                call: |c| c.repo_add("example", "https://charts.example.com", false),
331            },
332            ClientCase {
333                name: "RepoAdd with force-update",
334                want_args: &[
335                    "repo",
336                    "add",
337                    "example",
338                    "https://charts.example.com",
339                    "--force-update",
340                ],
341                streamed: true,
342                stdout: None,
343                call: |c| c.repo_add("example", "https://charts.example.com", true),
344            },
345            ClientCase {
346                name: "RepoUpdate",
347                want_args: &["repo", "update", "example"],
348                streamed: true,
349                stdout: None,
350                call: |c| c.repo_update("example"),
351            },
352            ClientCase {
353                name: "List",
354                want_args: &[
355                    "list",
356                    "--namespace",
357                    "uwubernetes",
358                    "--filter",
359                    "my-release",
360                    "--output",
361                    "json",
362                ],
363                streamed: false,
364                stdout: Some(&["[]"]),
365                call: |c| c.list("uwubernetes", "my-release").map(|_| ()),
366            },
367            ClientCase {
368                name: "RepoList",
369                want_args: &["repo", "list", "-o", "json"],
370                streamed: false,
371                stdout: Some(&["[]"]),
372                call: |c| c.repo_list().map(|_| ()),
373            },
374            ClientCase {
375                name: "Pull",
376                want_args: &[
377                    "pull",
378                    "https://example.com/chart.tar.gz",
379                    "--untar",
380                    "--untardir",
381                    "/tmp/unpack",
382                ],
383                streamed: true,
384                stdout: None,
385                call: |c| c.pull("https://example.com/chart.tar.gz", "/tmp/unpack"),
386            },
387            ClientCase {
388                name: "DependencyList",
389                want_args: &["dependency", "list", "/tmp/chart"],
390                streamed: false,
391                stdout: Some(&["[]"]),
392                call: |c| c.dependency_list("/tmp/chart").map(|_| ()),
393            },
394            ClientCase {
395                name: "DependencyBuild",
396                want_args: &["dependency", "build", "/tmp/chart"],
397                streamed: true,
398                stdout: None,
399                call: |c| c.dependency_build("/tmp/chart", false),
400            },
401            ClientCase {
402                name: "DependencyBuild with skip-refresh",
403                want_args: &["dependency", "build", "/tmp/chart", "--skip-refresh"],
404                streamed: true,
405                stdout: None,
406                call: |c| c.dependency_build("/tmp/chart", true),
407            },
408        ];
409
410        for case in cases {
411            let mock = MockCommandRunner::new();
412            let stdout: Vec<&str> = case.stdout.unwrap_or(&["[]"]).to_vec();
413
414            if case.streamed {
415                mock.expect_exec_streamed_command("helm", case.want_args, Ok(()));
416            } else {
417                mock.expect_run(
418                    "helm",
419                    case.want_args,
420                    ExecResult {
421                        stdout: stdout.iter().map(|s| s.to_string()).collect(),
422                        ..Default::default()
423                    },
424                );
425            }
426
427            let executor: Arc<dyn CommandRunner + Send + Sync> = Arc::new(mock);
428            let client = Client::new("", executor);
429
430            let result = (case.call)(&client);
431            assert!(result.is_ok(), "{}", case.name);
432        }
433    }
434
435    /// New coverage (no direct Go test to port -- Go's `kubeconfigEnv`
436    /// has no dedicated unit test either, only integration coverage):
437    /// proves a non-empty `kubeconfig` actually reaches `helm` via
438    /// `KUBECONFIG`, for both the streamed and buffered call paths. This
439    /// is exactly the bug class `kubectl::Client` had (see
440    /// `PORT_NOTES.md`'s "kubectl kubeconfig materialization" entry): a
441    /// `kubeconfig` field carried but never wired into the executed
442    /// command's environment, invisible to every test because
443    /// `MockCommandRunner::run`/`exec_streamed_command`'s matching never
444    /// inspected `opts` -- now it can, via `recorded_run_calls`/
445    /// `recorded_exec_streamed_command_calls`.
446    #[test]
447    fn kubeconfig_is_wired_into_both_call_paths() {
448        let mock = Arc::new(MockCommandRunner::new());
449        mock.expect_exec_streamed_command_repeating(
450            "helm",
451            &["install", "my-release", "./chart"],
452            || Ok(()),
453        );
454        mock.expect_run_repeating("helm", &["repo", "list", "-o", "json"], ExecResult::default);
455
456        let client = Client::new("apiVersion: v1\nkind: Config\n", mock.clone());
457
458        client.install("my-release", "./chart", &[]).unwrap();
459        client.repo_list().unwrap();
460
461        let streamed_calls = mock.recorded_exec_streamed_command_calls();
462        assert_eq!(streamed_calls.len(), 1);
463        let streamed_env = streamed_calls[0]
464            .opts
465            .as_ref()
466            .and_then(|o| o.env.clone())
467            .expect("install should set opts.env");
468        assert!(
469            streamed_env.iter().any(|kv| kv.starts_with("KUBECONFIG=")),
470            "expected a KUBECONFIG=... entry in {streamed_env:?}"
471        );
472
473        let run_calls = mock.recorded_run_calls();
474        assert_eq!(run_calls.len(), 1);
475        let run_env = run_calls[0]
476            .env
477            .as_ref()
478            .expect("repo_list should set opts.env");
479        assert!(
480            run_env.iter().any(|kv| kv.starts_with("KUBECONFIG=")),
481            "expected a KUBECONFIG=... entry in {run_env:?}"
482        );
483    }
484
485    /// An empty `kubeconfig` (the common case before a cluster exists, or
486    /// Go's no-op path) must not set `KUBECONFIG` at all, so `helm` falls
487    /// back to its own default resolution exactly as before this fix.
488    #[test]
489    fn empty_kubeconfig_sets_no_env_at_all() {
490        let mock = Arc::new(MockCommandRunner::new());
491        mock.expect_run_repeating("helm", &["repo", "list", "-o", "json"], ExecResult::default);
492
493        let client = Client::new("", mock.clone());
494
495        client.repo_list().unwrap();
496
497        let run_calls = mock.recorded_run_calls();
498        assert_eq!(run_calls.len(), 1);
499        assert!(run_calls[0].env.is_none());
500    }
501}