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 fn list(
12 &self,
13 namespace: Option<&NonBlankString>,
14 ) -> Result<Vec<HelmListItem>, HelmWrapperError>;
15
16 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 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 pub fn new() -> Self {
55 Self("helm".to_string(), None, 15, false, false)
56 }
57
58 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}