helm_wrapper_rs/
blocking.rs

1use std::{collections::HashMap, path::Path, process::Command};
2
3use log::{debug, error, info};
4use non_blank_string_rs::NonBlankString;
5
6use crate::{error::HelmWrapperError, HelmDeployStatus, HelmListItem, HelmUpgradeResponse};
7
8pub trait HelmExecutor {
9    /// List installed helm charts
10    /// - `namespace` - namespace (optional)
11    fn list(
12        &self,
13        namespace: Option<&NonBlankString>,
14    ) -> Result<Vec<HelmListItem>, HelmWrapperError>;
15
16    /// Install or upgrade helm chart in such way:
17    /// helm upgrade --install <RELEASE-NAME> <CHART-NAME> [-v CHART-VERSION] [-f VALUES-FILE] [--set <OVERRIDE_A>=<OVERRIDE_A_VALUE>]
18    /// - `namespace` - target namespace
19    /// - `release_name` - release name. For example: myapp
20    /// - `chart_name` - helm chart name. For example: cowboysysop/whoami
21    /// - `chart_version` - helm chart version. For example: 1.2.3 (optional)
22    /// - `values_overrides` - values overrides, pass to helm as --set NAME=VALUE (optional)
23    /// - `values-file` - path to values file (optional)
24    /// - `helm_options` - any other options for helm. for example '--dry-run' (optional)
25    fn install_or_upgrade(
26        &self,
27        namespace: &NonBlankString,
28        release_name: &NonBlankString,
29        chart_name: &NonBlankString,
30        chart_version: Option<&NonBlankString>,
31        values_overrides: Option<&HashMap<NonBlankString, String>>,
32        values_file: Option<&Path>,
33        helm_options: Option<&Vec<NonBlankString>>,
34    ) -> Result<HelmDeployStatus, HelmWrapperError>;
35
36    /// - `helm_options` - any other options for helm. for example '--dry-run' (optional)
37    fn uninstall(
38        &self,
39        namespace: &NonBlankString,
40        release_name: &NonBlankString,
41    ) -> Result<(), HelmWrapperError>;
42}
43
44#[derive(Clone, Debug)]
45pub struct DefaultHelmExecutor(String, Option<String>, u16, bool, bool);
46
47impl DefaultHelmExecutor {
48    /// Create executor instance with predefined option values:
49    /// - Helm path: helm
50    /// - kubeconfig path: None
51    /// - Timeout: 15 (secs)
52    /// - Debug: false
53    /// - unsafe_mode: false - print overridden values to log
54    pub fn new() -> Self {
55        Self("helm".to_string(), None, 15, false, false)
56    }
57
58    /// Create execute with options:
59    /// - `helm_path` - path to helm executable
60    /// - `kubeconfig_path` - path to kubeconfig file (optional)
61    /// - `timeout` - timeout for helm command execution (seconds)
62    /// - `debug` - debug mode, more verbose output from helm
63    /// - `unsafe_mode` - print overridden values to log
64    pub fn new_with_opts(
65        helm_path: &NonBlankString,
66        kubeconfig_path: Option<String>,
67        timeout: u16,
68        debug: bool,
69        unsafe_mode: bool,
70    ) -> Self {
71        Self(
72            helm_path.to_string(),
73            kubeconfig_path,
74            timeout,
75            debug,
76            unsafe_mode,
77        )
78    }
79
80    pub fn get_helm_path(&self) -> &str {
81        &self.0
82    }
83
84    pub fn get_kubeconfig_path(&self) -> &Option<String> {
85        &self.1
86    }
87
88    pub fn get_timeout(&self) -> u16 {
89        self.2
90    }
91
92    pub fn get_debug(&self) -> bool {
93        self.3
94    }
95
96    pub fn get_unsafe_mode(&self) -> bool {
97        self.4
98    }
99
100    fn remove_double_spaces_and_trim(&self, input: &str) -> String {
101        let result = input.replace("  ", " ");
102        result.trim().to_string()
103    }
104}
105
106impl HelmExecutor for DefaultHelmExecutor {
107    fn list(
108        &self,
109        namespace: Option<&NonBlankString>,
110    ) -> Result<Vec<HelmListItem>, HelmWrapperError> {
111        info!("get list of installed helm charts..");
112
113        debug!("helm executable path '{}'", self.get_helm_path());
114        debug!("kubeconfig file path '{:?}'", self.get_kubeconfig_path());
115        debug!("timeout {}s", self.get_timeout());
116
117        let mut command_args = format!("ls");
118
119        match &self.1 {
120            Some(kubeconfig_path) => {
121                info!("- kubeconfig path '{}'", kubeconfig_path);
122                command_args.push_str(&format!(" --kubeconfig={} ", kubeconfig_path));
123            }
124            None => {
125                debug!("no kubeconfig path provided");
126            }
127        }
128
129        if let Some(namespace) = namespace {
130            info!("- namespace '{namespace}'");
131            command_args.push_str(&format!(" -n {} -o json ", namespace));
132        }
133
134        if self.get_debug() {
135            command_args.push_str(" --debug ");
136        }
137
138        command_args = self.remove_double_spaces_and_trim(&command_args);
139
140        let command_args: Vec<&str> = command_args.split(" ").collect();
141
142        match Command::new(&self.get_helm_path())
143            .args(command_args)
144            .output()
145        {
146            Ok(output) => {
147                if output.status.success() {
148                    let stdout = String::from_utf8(output.stdout)?;
149
150                    if self.get_unsafe_mode() {
151                        debug!("<stdout>");
152                        debug!("{}", stdout);
153                        debug!("</stdout>");
154                    }
155
156                    let helm_response: Vec<HelmListItem> = serde_json::from_str(&stdout)?;
157
158                    info!("response: {:?}", helm_response);
159
160                    Ok(helm_response)
161                } else {
162                    error!("helm command execution error");
163                    let stderr = String::from_utf8_lossy(&output.stderr);
164
165                    error!("<stderr>");
166                    error!("{}", stderr);
167                    error!("</stderr>");
168
169                    Err(HelmWrapperError::Error)
170                }
171            }
172            Err(e) => {
173                error!("helm execution error: {}", e);
174                Err(HelmWrapperError::ExecutionError(e))
175            }
176        }
177    }
178
179    fn install_or_upgrade(
180        &self,
181        namespace: &NonBlankString,
182        release_name: &NonBlankString,
183        chart_name: &NonBlankString,
184        chart_version: Option<&NonBlankString>,
185        values_overrides: Option<&HashMap<NonBlankString, String>>,
186        values_file: Option<&Path>,
187        helm_options: Option<&Vec<NonBlankString>>,
188    ) -> Result<HelmDeployStatus, HelmWrapperError> {
189        info!(
190            "installing helm chart '{}' with release name '{}' to namespace '{}'..",
191            chart_name, release_name, namespace
192        );
193
194        debug!("helm executable path '{}'", self.get_helm_path());
195        debug!("kubeconfig file path '{:?}'", self.get_kubeconfig_path());
196        debug!("timeout {}s", self.get_timeout());
197
198        let mut command_args = format!(
199            "upgrade --install -n {} {} {}",
200            namespace, release_name, chart_name
201        );
202
203        match &self.1 {
204            Some(kubeconfig_path) => {
205                info!("- kubeconfig path '{}'", kubeconfig_path);
206                command_args.push_str(&format!(" --kubeconfig={} ", kubeconfig_path));
207            }
208            None => {
209                debug!("no kubeconfig path provided");
210            }
211        }
212
213        if let Some(chart_version) = chart_version {
214            info!("- chart version '{chart_version}'");
215            command_args.push_str(&format!(" --version {} ", chart_version));
216        }
217
218        if let Some(values_file) = values_file {
219            info!("- values file '{}'", values_file.display());
220            command_args.push_str(&format!(" -f {} ", values_file.display()));
221        }
222
223        if let Some(overrides) = values_overrides {
224            if !self.get_unsafe_mode() {
225                info!("overriden chart values won't be mentioned in log because of safe mode");
226            }
227
228            for (k, v) in overrides.iter() {
229                if self.get_unsafe_mode() {
230                    info!("- value override '{}': '{}'", k, v);
231                }
232                command_args.push_str(&format!(" --set {}={} ", k, v));
233            }
234        }
235
236        if let Some(helm_options) = helm_options {
237            for helm_option in helm_options {
238                info!("- helm option '{helm_option}'");
239                command_args.push_str(&format!(" {helm_option} "));
240            }
241        }
242
243        if self.get_debug() {
244            command_args.push_str(" --debug ");
245        }
246
247        command_args.push_str(&format!(" -o json --timeout={}s ", self.get_timeout()));
248
249        let command_args = command_args.replace("  ", " ");
250        let command_args = command_args.trim();
251
252        if self.get_unsafe_mode() {
253            debug!("command args: '{command_args}'")
254        }
255
256        let command_args: Vec<&str> = command_args.split(" ").collect();
257
258        match Command::new(&self.get_helm_path())
259            .args(command_args)
260            .output()
261        {
262            Ok(output) => {
263                if output.status.success() {
264                    let stdout = String::from_utf8(output.stdout)?;
265
266                    if self.get_unsafe_mode() {
267                        debug!("<stdout>");
268                        debug!("{}", stdout);
269                        debug!("</stdout>");
270                    }
271
272                    let helm_response: HelmUpgradeResponse = serde_json::from_str(&stdout)?;
273
274                    info!("response: {:?}", helm_response);
275
276                    Ok(helm_response.info.status)
277                } else {
278                    error!("helm command execution error");
279                    let stderr = String::from_utf8_lossy(&output.stderr);
280
281                    error!("<stderr>");
282                    error!("{}", stderr);
283                    error!("</stderr>");
284
285                    Err(HelmWrapperError::Error)
286                }
287            }
288            Err(e) => {
289                error!("helm execution error: {}", e);
290                Err(HelmWrapperError::ExecutionError(e))
291            }
292        }
293    }
294
295    fn uninstall(
296        &self,
297        namespace: &NonBlankString,
298        release_name: &NonBlankString,
299    ) -> Result<(), HelmWrapperError> {
300        info!(
301            "uninstalling helm release '{}', namespace '{}'..",
302            release_name, namespace
303        );
304
305        let mut command_args = format!(
306            "uninstall {} -n {} --timeout={}s --wait",
307            release_name,
308            namespace,
309            self.get_timeout()
310        );
311
312        if self.get_debug() {
313            command_args.push_str(" --debug ");
314        }
315
316        match &self.1 {
317            Some(kubeconfig_path) => {
318                info!("- kubeconfig path '{}'", kubeconfig_path);
319                command_args.push_str(&format!(" --kubeconfig={} ", kubeconfig_path));
320            }
321            None => {
322                debug!("no kubeconfig path provided");
323            }
324        }
325
326        if self.get_unsafe_mode() {
327            debug!("command args: '{command_args}'")
328        }
329
330        let command_args: Vec<&str> = command_args.trim().split(" ").collect();
331
332        match Command::new(&self.get_helm_path())
333            .args(command_args)
334            .output()
335        {
336            Ok(output) => {
337                if output.status.success() {
338                    let stdout = String::from_utf8(output.stdout)?;
339
340                    if self.get_unsafe_mode() {
341                        debug!("<stdout>");
342                        debug!("{}", stdout);
343                        debug!("</stdout>");
344                    }
345
346                    info!("helm release '{}' uninstalled successfully", release_name);
347
348                    Ok(())
349                } else {
350                    error!("helmcommand execution error");
351                    let stderr = String::from_utf8_lossy(&output.stderr);
352
353                    error!("<stderr>");
354                    error!("{}", stderr);
355                    error!("</stderr>");
356
357                    Err(HelmWrapperError::Error)
358                }
359            }
360            Err(e) => {
361                error!("helm execution error: {}", e);
362                Err(HelmWrapperError::ExecutionError(e))
363            }
364        }
365    }
366}
367
368#[cfg(test)]
369mod blocking_helm_command_tests {
370    use std::{collections::HashMap, path::Path};
371
372    use non_blank_string_rs::NonBlankString;
373
374    use crate::{
375        blocking::{DefaultHelmExecutor, HelmExecutor},
376        tests::{
377            get_test_chart_name, get_test_helm_options, get_test_namespace, get_test_release_name,
378            init_logging,
379        },
380        HelmDeployStatus,
381    };
382
383    #[test]
384    fn install_or_upgrade_helm_chart_with_invalid_syntax_values() {
385        init_logging();
386
387        let executor =
388            DefaultHelmExecutor::new_with_opts(&"helm".parse().unwrap(), None, 15, true, true);
389
390        let helm_options: Vec<NonBlankString> = get_test_helm_options();
391
392        let namespace: NonBlankString = get_test_namespace();
393        let release_name: NonBlankString = get_test_release_name();
394        let chart_name: NonBlankString = get_test_chart_name();
395
396        let values_file = Path::new("test-data").join("whoami-invalid-syntax.yml");
397
398        assert!(executor
399            .install_or_upgrade(
400                &namespace,
401                &release_name,
402                &chart_name,
403                None,
404                None,
405                Some(&values_file),
406                Some(&helm_options),
407            )
408            .is_err());
409    }
410
411    #[test]
412    fn install_or_upgrade_helm_chart() {
413        init_logging();
414
415        let executor =
416            DefaultHelmExecutor::new_with_opts(&"helm".parse().unwrap(), None, 15, true, true);
417
418        let helm_options: Vec<NonBlankString> = get_test_helm_options();
419
420        let namespace: NonBlankString = get_test_namespace();
421        let release_name: NonBlankString = get_test_release_name();
422        let chart_name: NonBlankString = get_test_chart_name();
423
424        let mut values_overrides: HashMap<NonBlankString, String> = HashMap::new();
425
426        values_overrides.insert("startupProbe.enabled".parse().unwrap(), "false".to_string());
427        values_overrides.insert("replicaCount".parse().unwrap(), "2".to_string());
428
429        let values_file = Path::new("test-data").join("whoami-values.yml");
430
431        let result = executor
432            .install_or_upgrade(
433                &namespace,
434                &release_name,
435                &chart_name,
436                Some(&"5.2.0".parse().unwrap()),
437                Some(&values_overrides),
438                Some(&values_file),
439                Some(&helm_options),
440            )
441            .unwrap();
442
443        assert_eq!(HelmDeployStatus::Deployed, result);
444
445        let releases = executor.list(Some(&namespace)).unwrap();
446
447        assert!(!releases.is_empty());
448
449        let release = releases.first().unwrap();
450
451        assert_eq!(release.app_version, "1.10.3");
452        assert_eq!(release.namespace, namespace.to_string());
453        assert_eq!(release.name, release_name.to_string());
454        assert_eq!(release.status, HelmDeployStatus::Deployed);
455
456        assert!(executor.uninstall(&namespace, &release_name).is_ok());
457
458        let releases = executor.list(Some(&namespace)).unwrap();
459
460        assert!(releases.is_empty());
461    }
462}