1use std::path::PathBuf;
2use std::process::Command;
3
4use serde::Deserialize;
5use tracing::{instrument, warn};
6
7mod error;
8pub use crate::error::HelmError;
9use fluvio_command::CommandExt;
10
11#[derive(Debug)]
13pub struct InstallArg {
14 pub name: String,
15 pub chart: String,
16 pub version: Option<String>,
17 pub namespace: Option<String>,
18 pub opts: Vec<(String, String)>,
19 pub values: Vec<PathBuf>,
20 pub develop: bool,
21}
22
23impl InstallArg {
24 pub fn new<N: Into<String>, C: Into<String>>(name: N, chart: C) -> Self {
25 Self {
26 name: name.into(),
27 chart: chart.into(),
28 version: None,
29 namespace: None,
30 opts: vec![],
31 values: vec![],
32 develop: false,
33 }
34 }
35
36 pub fn version<S: Into<String>>(mut self, version: S) -> Self {
38 self.version = Some(version.into());
39 self
40 }
41
42 pub fn namespace<S: Into<String>>(mut self, ns: S) -> Self {
44 self.namespace = Some(ns.into());
45 self
46 }
47
48 pub fn opts(mut self, options: Vec<(String, String)>) -> Self {
50 self.opts = options;
51 self
52 }
53
54 pub fn opt<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
56 self.opts.push((key.into(), value.into()));
57 self
58 }
59
60 pub fn develop(mut self) -> Self {
62 self.develop = true;
63 self
64 }
65
66 pub fn values(mut self, values: Vec<PathBuf>) -> Self {
68 self.values = values;
69 self
70 }
71
72 pub fn value(&mut self, value: PathBuf) -> &mut Self {
74 self.values.push(value);
75 self
76 }
77
78 pub fn install(&self) -> Command {
79 let mut command = Command::new("helm");
80 command.args(&["install", &self.name, &self.chart]);
81 self.apply_args(&mut command);
82 command
83 }
84
85 pub fn upgrade(&self) -> Command {
86 let mut command = Command::new("helm");
87 command.args(&["upgrade", "--install", &self.name, &self.chart]);
88 self.apply_args(&mut command);
89 command
90 }
91
92 fn apply_args(&self, command: &mut Command) {
93 if let Some(namespace) = &self.namespace {
94 command.args(&["--namespace", namespace]);
95 }
96
97 if self.develop {
98 command.arg("--devel");
99 }
100
101 if let Some(version) = &self.version {
102 command.args(&["--version", version]);
103 }
104
105 for value_path in &self.values {
106 command.arg("--values").arg(value_path);
107 }
108
109 for (key, val) in &self.opts {
110 command.arg("--set").arg(format!("{}={}", key, val));
111 }
112 }
113}
114
115impl From<InstallArg> for Command {
116 fn from(arg: InstallArg) -> Self {
117 let mut command = Command::new("helm");
118 command.args(&["install", &arg.name, &arg.chart]);
119
120 if let Some(namespace) = &arg.namespace {
121 command.args(&["--namespace", namespace]);
122 }
123
124 if arg.develop {
125 command.arg("--devel");
126 }
127
128 if let Some(version) = &arg.version {
129 command.args(&["--version", version]);
130 }
131
132 for value_path in &arg.values {
133 command.arg("--values").arg(value_path);
134 }
135
136 for (key, val) in &arg.opts {
137 command.arg("--set").arg(format!("{}={}", key, val));
138 }
139
140 command
141 }
142}
143
144#[derive(Debug)]
146pub struct UninstallArg {
147 pub release: String,
148 pub namespace: Option<String>,
149 pub ignore_not_found: bool,
150 pub dry_run: bool,
151 pub timeout: Option<String>,
152}
153
154impl UninstallArg {
155 pub fn new(release: String) -> Self {
156 Self {
157 release,
158 namespace: None,
159 ignore_not_found: false,
160 dry_run: false,
161 timeout: None,
162 }
163 }
164
165 pub fn namespace(mut self, ns: String) -> Self {
167 self.namespace = Some(ns);
168 self
169 }
170
171 pub fn ignore_not_found(mut self) -> Self {
173 self.ignore_not_found = true;
174 self
175 }
176
177 pub fn dry_run(mut self) -> Self {
179 self.dry_run = true;
180 self
181 }
182
183 pub fn timeout(mut self, timeout: String) -> Self {
185 self.timeout = Some(timeout);
186 self
187 }
188}
189
190impl From<UninstallArg> for Command {
191 fn from(arg: UninstallArg) -> Self {
192 let mut command = Command::new("helm");
193 command.args(&["uninstall", &arg.release]);
194
195 if let Some(namespace) = &arg.namespace {
196 command.args(&["--namespace", namespace]);
197 }
198
199 if arg.dry_run {
200 command.arg("--dry-run");
201 }
202
203 for timeout in &arg.timeout {
204 command.arg("--timeout").arg(timeout);
205 }
206
207 command
208 }
209}
210
211#[derive(Debug)]
213#[non_exhaustive]
214pub struct HelmClient {}
215
216impl HelmClient {
217 pub fn new() -> Result<Self, HelmError> {
221 let output = Command::new("helm").arg("version").result()?;
222
223 let out_str = String::from_utf8(output.stdout).map_err(HelmError::Utf8Error)?;
225
226 if !out_str.contains("version") {
230 return Err(HelmError::HelmVersionNotFound(out_str));
231 }
232
233 Ok(Self {})
235 }
236
237 #[instrument(skip(self))]
240 pub fn install(&self, args: &InstallArg) -> Result<(), HelmError> {
241 let mut command = args.install();
242 command.result()?;
243 Ok(())
244 }
245
246 #[instrument(skip(self))]
248 pub fn upgrade(&self, args: &InstallArg) -> Result<(), HelmError> {
249 let mut command = args.upgrade();
250 command.result()?;
251 Ok(())
252 }
253
254 pub fn uninstall(&self, uninstall: UninstallArg) -> Result<(), HelmError> {
256 if uninstall.ignore_not_found {
257 let app_charts = self
258 .get_installed_chart_by_name(&uninstall.release, uninstall.namespace.as_deref())?;
259 if app_charts.is_empty() {
260 warn!("Chart does not exists, {}", &uninstall.release);
261 return Ok(());
262 }
263 }
264 let mut command: Command = uninstall.into();
265 command.result()?;
266 Ok(())
267 }
268
269 #[instrument(skip(self))]
271 pub fn repo_add(&self, chart: &str, location: &str) -> Result<(), HelmError> {
272 Command::new("helm")
273 .args(&["repo", "add", chart, location])
274 .result()?;
275 Ok(())
276 }
277
278 #[instrument(skip(self))]
280 pub fn repo_update(&self) -> Result<(), HelmError> {
281 Command::new("helm").args(&["repo", "update"]).result()?;
282 Ok(())
283 }
284
285 #[instrument(skip(self))]
287 pub fn search_repo(&self, chart: &str, version: &str) -> Result<Vec<Chart>, HelmError> {
288 let mut command = Command::new("helm");
289 command
290 .args(&["search", "repo", chart])
291 .args(&["--version", version])
292 .args(&["--output", "json"]);
293
294 let output = command.result()?;
295
296 check_helm_stderr(output.stderr)?;
297 serde_json::from_slice(&output.stdout).map_err(HelmError::Serde)
298 }
299
300 #[instrument(skip(self))]
302 pub fn versions(&self, chart: &str) -> Result<Vec<Chart>, HelmError> {
303 let mut command = Command::new("helm");
304 command
305 .args(&["search", "repo"])
306 .args(&["--versions", chart])
307 .args(&["--output", "json", "--devel"]);
308 let output = command.result()?;
309
310 check_helm_stderr(output.stderr)?;
311 serde_json::from_slice(&output.stdout).map_err(HelmError::Serde)
312 }
313
314 #[instrument(skip(self))]
316 pub fn chart_version_exists(&self, name: &str, version: &str) -> Result<bool, HelmError> {
317 let versions = self.search_repo(name, version)?;
318 let count = versions
319 .iter()
320 .filter(|chart| chart.name == name && chart.version == version)
321 .count();
322 Ok(count > 0)
323 }
324
325 #[instrument(skip(self))]
327 pub fn get_installed_chart_by_name(
328 &self,
329 name: &str,
330 namespace: Option<&str>,
331 ) -> Result<Vec<InstalledChart>, HelmError> {
332 let exact_match = format!("^{}$", name);
333 let mut command = Command::new("helm");
334 command
335 .arg("list")
336 .arg("--filter")
337 .arg(exact_match)
338 .arg("--output")
339 .arg("json");
340
341 match namespace {
342 Some(ns) => {
343 command.args(&["--namespace", ns]);
344 }
345 None => {
346 command.args(&["-A"]);
348 }
349 }
350
351 let output = command.result()?;
352 check_helm_stderr(output.stderr)?;
353 serde_json::from_slice(&output.stdout).map_err(HelmError::Serde)
354 }
355
356 #[instrument(skip(self))]
358 pub fn get_helm_version(&self) -> Result<String, HelmError> {
359 let helm_version = Command::new("helm")
360 .arg("version")
361 .arg("--short")
362 .output()
363 .map_err(HelmError::HelmNotInstalled)?;
364 let version_text = String::from_utf8(helm_version.stdout).map_err(HelmError::Utf8Error)?;
365 Ok(version_text[1..].trim().to_string())
366 }
367}
368
369fn check_helm_stderr(stderr: Vec<u8>) -> Result<(), HelmError> {
373 if !stderr.is_empty() {
374 let stderr = String::from_utf8(stderr)?;
375 if stderr.contains("Kubernetes cluster unreachable") {
376 return Err(HelmError::FailedToConnect);
377 }
378 }
379
380 Ok(())
381}
382
383#[derive(Debug, Deserialize)]
385pub struct Chart {
386 name: String,
388 version: String,
390}
391
392impl Chart {
393 pub fn version(&self) -> &str {
394 &self.version
395 }
396 pub fn name(&self) -> &str {
397 &self.name
398 }
399}
400
401#[derive(Debug, Deserialize)]
403pub struct InstalledChart {
404 pub name: String,
406 pub app_version: String,
408 pub revision: String,
410 pub updated: String,
412 pub status: String,
414 pub chart: String,
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_parse_get_installed_charts() {
424 const JSON_RESPONSE: &str = r#"[{"name":"test_chart","namespace":"default","revision":"50","updated":"2021-03-17 08:42:54.546347741 +0000 UTC","status":"deployed","chart":"test_chart-1.2.32-rc2","app_version":"1.2.32-rc2"}]"#;
425 let installed_charts: Vec<InstalledChart> =
426 serde_json::from_slice(JSON_RESPONSE.as_bytes()).expect("can not parse json");
427 assert_eq!(installed_charts.len(), 1);
428 let test_chart = installed_charts
429 .get(0)
430 .expect("can not grab the first result");
431 assert_eq!(test_chart.name, "test_chart");
432 assert_eq!(test_chart.chart, "test_chart-1.2.32-rc2");
433 }
434}