1pub mod chartref;
8
9use std::sync::Arc;
10
11use xianyun_executor::exec::{CommandRunner, ExecError};
12
13pub trait Backend {
15 fn install(&self, release_name: &str, chart_path: &str, args: &[&str])
18 -> Result<(), ExecError>;
19
20 fn uninstall(&self, release_name: &str, namespace: &str) -> Result<(), ExecError>;
22
23 fn upgrade(&self, release_name: &str, chart_path: &str, args: &[&str])
26 -> Result<(), ExecError>;
27
28 fn list(&self, namespace: &str, filter: &str) -> Result<String, ExecError>;
31
32 fn repo_list(&self) -> Result<String, ExecError>;
35
36 fn repo_add(&self, name: &str, url: &str, force_update: bool) -> Result<(), ExecError>;
38
39 fn repo_update(&self, name: &str) -> Result<(), ExecError>;
41
42 fn pull(&self, chart_ref: &str, untar_dir: &str) -> Result<(), ExecError>;
45
46 fn dependency_list(&self, chart_dir: &str) -> Result<String, ExecError>;
49
50 fn dependency_build(&self, chart_dir: &str, skip_refresh: bool) -> Result<(), ExecError>;
53}
54
55pub struct Client {
75 kubeconfig: String,
76 executor: Arc<dyn CommandRunner + Send + Sync>,
77}
78
79struct 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 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 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 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 #[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 #[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}