fluvio_helm/
lib.rs

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/// Installer Argument
12#[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    /// set chart version
37    pub fn version<S: Into<String>>(mut self, version: S) -> Self {
38        self.version = Some(version.into());
39        self
40    }
41
42    /// set namepsace
43    pub fn namespace<S: Into<String>>(mut self, ns: S) -> Self {
44        self.namespace = Some(ns.into());
45        self
46    }
47
48    /// reset array of options
49    pub fn opts(mut self, options: Vec<(String, String)>) -> Self {
50        self.opts = options;
51        self
52    }
53
54    /// set a single option
55    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    /// set to use develop
61    pub fn develop(mut self) -> Self {
62        self.develop = true;
63        self
64    }
65
66    /// set list of values
67    pub fn values(mut self, values: Vec<PathBuf>) -> Self {
68        self.values = values;
69        self
70    }
71
72    /// set one value
73    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/// Uninstaller Argument
145#[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    /// set namepsace
166    pub fn namespace(mut self, ns: String) -> Self {
167        self.namespace = Some(ns);
168        self
169    }
170
171    /// set ignore not found
172    pub fn ignore_not_found(mut self) -> Self {
173        self.ignore_not_found = true;
174        self
175    }
176
177    /// set dry tun
178    pub fn dry_run(mut self) -> Self {
179        self.dry_run = true;
180        self
181    }
182
183    /// set timeout
184    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/// Client to manage helm operations
212#[derive(Debug)]
213#[non_exhaustive]
214pub struct HelmClient {}
215
216impl HelmClient {
217    /// Creates a Rust client to manage our helm needs.
218    ///
219    /// This only succeeds if the helm command can be found.
220    pub fn new() -> Result<Self, HelmError> {
221        let output = Command::new("helm").arg("version").result()?;
222
223        // Convert command output into a string
224        let out_str = String::from_utf8(output.stdout).map_err(HelmError::Utf8Error)?;
225
226        // Check that the version command gives a version.
227        // In the future, we can parse the version string and check
228        // for compatible CLI client version.
229        if !out_str.contains("version") {
230            return Err(HelmError::HelmVersionNotFound(out_str));
231        }
232
233        // If checks succeed, create Helm client
234        Ok(Self {})
235    }
236
237    /// Installs the given chart under the given name.
238    ///
239    #[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    /// Upgrades the given chart
247    #[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    /// Uninstalls specified chart library
255    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    /// Adds a new helm repo with the given chart name and chart location
270    #[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    /// Updates the local helm repository
279    #[instrument(skip(self))]
280    pub fn repo_update(&self) -> Result<(), HelmError> {
281        Command::new("helm").args(&["repo", "update"]).result()?;
282        Ok(())
283    }
284
285    /// Searches the repo for the named helm chart
286    #[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    /// Get all the available versions
301    #[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    /// Checks that a given version of a given chart exists in the repo.
315    #[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    /// Returns the list of installed charts by name
326    #[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                // Search all namespaces
347                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    /// get helm package version
357    #[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
369/// Check for errors in Helm's stderr output
370///
371/// Returns `Ok(())` if everything is fine, or `HelmError` if something is wrong
372fn 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/// A representation of a chart definition in a repo.
384#[derive(Debug, Deserialize)]
385pub struct Chart {
386    /// The chart name
387    name: String,
388    /// The chart version
389    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/// A representation of an installed chart.
402#[derive(Debug, Deserialize)]
403pub struct InstalledChart {
404    /// The chart name
405    pub name: String,
406    /// The version of the app this chart installed
407    pub app_version: String,
408    /// The chart revision
409    pub revision: String,
410    /// Date/time when the chart was last updated
411    pub updated: String,
412    /// Status of the installed chart
413    pub status: String,
414    /// The ID of the chart that is installed
415    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}