1use serde::Deserialize;
8use serde_json::{Map, Value};
9
10use xianyun_backends::helm::Backend as HelmBackend;
11use xianyun_core::config::latest::{CaproniConfig, DeployerConfig};
12
13use super::HelmError;
14use super::overrider::{merge_values, new_dynamic_overrider, prune_empty};
15
16type PathWithCleanup = (String, Box<dyn FnOnce()>);
19
20type ValuesArgsWithCleanup = (Vec<String>, Box<dyn FnOnce()>);
24
25pub struct Manager<B: HelmBackend> {
27 pub(super) backend: B,
28}
29
30impl<B: HelmBackend> Manager<B> {
31 pub fn new(backend: B) -> Self {
35 Self { backend }
36 }
37
38 #[must_use]
40 pub fn deployer_type(&self) -> &'static str {
41 "helm"
42 }
43}
44
45#[derive(Debug, Clone, Deserialize)]
48pub(super) struct HelmRepoEntry {
49 pub name: String,
50 pub url: String,
51}
52
53impl<B: HelmBackend> Manager<B> {
54 pub(super) fn get_existing_helm_repos(&self) -> Result<Vec<HelmRepoEntry>, HelmError> {
61 let Ok(list_output) = self.backend.repo_list() else {
62 return Ok(Vec::new());
63 };
64
65 if list_output.is_empty() {
66 return Ok(Vec::new());
67 }
68
69 serde_json::from_str(&list_output)
70 .map_err(|e| HelmError::Io(format!("parsing helm repo list output: {e}")))
71 }
72
73 pub(super) fn find_helm_repo_by_url<'a>(
76 &self,
77 repos: &'a [HelmRepoEntry],
78 repo_url: &str,
79 ) -> Option<&'a HelmRepoEntry> {
80 let want = repo_url.trim_end_matches('/');
81
82 repos.iter().find(|r| r.url.trim_end_matches('/') == want)
83 }
84
85 pub fn build_dynamic_overrides(
108 &self,
109 deployer_config: &DeployerConfig,
110 caproni_config: &CaproniConfig,
111 ) -> Result<Option<Value>, HelmError> {
112 let mut values = Map::new();
113
114 let helm_config = deployer_config
115 .helm
116 .as_ref()
117 .expect("deployer_config.helm must be set for a helm deployer");
118
119 for override_cfg in &helm_config.dynamic_overrides {
120 let overrider = new_dynamic_overrider(&override_cfg.override_type)?;
121
122 let Some(override_values) = overrider.get_values(override_cfg, caproni_config)? else {
123 continue;
124 };
125
126 let Value::Object(override_map) = override_values else {
127 continue;
128 };
129
130 merge_values(&mut values, override_map);
131 }
132
133 prune_empty(&mut values);
134
135 if values.is_empty() {
136 return Ok(None);
137 }
138
139 Ok(Some(Value::Object(values)))
140 }
141}
142
143struct DeferGuard(Option<Box<dyn FnOnce()>>);
148
149impl Drop for DeferGuard {
150 fn drop(&mut self) {
151 if let Some(f) = self.0.take() {
152 f();
153 }
154 }
155}
156
157impl<B: HelmBackend> Manager<B> {
158 pub fn deploy(
174 &self,
175 deployer_config: &DeployerConfig,
176 cfg: &xianyun_core::config::container::Container,
177 ) -> Result<(), HelmError> {
178 if deployer_config.deployer_type != "helm" {
179 return Err(HelmError::NotHelmDeployer);
180 }
181
182 let (chart_path, chart_cleanup) =
183 self.prepare_chart_source(deployer_config, &cfg.caproni)?;
184 let _chart_guard = DeferGuard(Some(chart_cleanup));
185
186 let status = self.status(deployer_config, &cfg.caproni)?;
187 let is_deployed = status.state != crate::deployer::State::NotDeployed;
188
189 let (values_args, values_cleanup) = self.build_values_args(deployer_config, cfg)?;
190 let _values_guard = DeferGuard(Some(values_cleanup));
191
192 let release_name = self.get_release_name(deployer_config);
193 let helm_config = deployer_config
194 .helm
195 .as_ref()
196 .expect("deployer_config.helm must be set for a helm deployer");
197 let namespace = helm_config.namespace.clone();
198 let mut args = self.build_helm_args(deployer_config, &namespace, &values_args);
199
200 if is_deployed {
201 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
202
203 self.backend
204 .upgrade(&release_name, &chart_path, &arg_refs)
205 .map_err(|e| HelmError::Backend {
206 context: "upgrading helm chart".to_string(),
207 source: e,
208 })
209 } else {
210 args.insert(0, "--create-namespace".to_string());
211 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
212
213 self.backend
214 .install(&release_name, &chart_path, &arg_refs)
215 .map_err(|e| HelmError::Backend {
216 context: "installing helm chart".to_string(),
217 source: e,
218 })
219 }
220 }
221
222 pub fn status(
238 &self,
239 deployer_config: &DeployerConfig,
240 _caproni_config: &CaproniConfig,
241 ) -> Result<crate::deployer::Status, HelmError> {
242 let release_name = self.get_release_name(deployer_config);
243 let helm_config = deployer_config
244 .helm
245 .as_ref()
246 .expect("deployer_config.helm must be set for a helm deployer");
247
248 let filter = format!("^{release_name}$");
249
250 let state = match self.backend.list(&helm_config.namespace, &filter) {
251 Ok(output) if !output.is_empty() && output != "[]" => crate::deployer::State::Deployed,
252 _ => crate::deployer::State::NotDeployed,
253 };
254
255 Ok(crate::deployer::Status {
256 state,
257 deployer_type: "helm".to_string(),
258 })
259 }
260
261 pub fn undeploy(
270 &self,
271 deployer_config: &DeployerConfig,
272 caproni_config: &CaproniConfig,
273 ) -> Result<(), HelmError> {
274 if deployer_config.deployer_type != "helm" || deployer_config.helm.is_none() {
275 return Err(HelmError::NotHelmDeployer);
276 }
277
278 let status = self.status(deployer_config, caproni_config)?;
279 if status.state == crate::deployer::State::NotDeployed {
280 return Ok(());
281 }
282
283 let release_name = self.get_release_name(deployer_config);
284 let namespace = deployer_config
285 .helm
286 .as_ref()
287 .map(|h| h.namespace.clone())
288 .unwrap_or_default();
289
290 tracing::info!(release = %release_name, namespace = %namespace, "removing helm chart");
291
292 self.backend
293 .uninstall(&release_name, &namespace)
294 .map_err(|e| HelmError::Backend {
295 context: "removing helm chart".to_string(),
296 source: e,
297 })
298 }
299
300 fn build_values_args(
305 &self,
306 deployer_config: &DeployerConfig,
307 cfg: &xianyun_core::config::container::Container,
308 ) -> Result<ValuesArgsWithCleanup, HelmError> {
309 let noop: Box<dyn FnOnce()> = Box::new(|| {});
310
311 let helm_config = deployer_config
312 .helm
313 .as_ref()
314 .expect("deployer_config.helm must be set for a helm deployer");
315
316 let mut values_args = Vec::new();
317
318 if !helm_config.values_file.is_empty() {
319 values_args.push("--values".to_string());
320 values_args.push(cfg.caproni.resolve_path(&helm_config.values_file));
321 }
322
323 let override_values = self.build_dynamic_overrides(deployer_config, &cfg.caproni)?;
324
325 let Some(override_values) = override_values else {
326 return Ok((values_args, noop));
327 };
328
329 let (override_path, cleanup) = write_values_file(&override_values)?;
330
331 values_args.push("--values".to_string());
332 values_args.push(override_path);
333
334 Ok((values_args, cleanup))
335 }
336
337 fn build_helm_args(
340 &self,
341 deployer_config: &DeployerConfig,
342 namespace: &str,
343 values_args: &[String],
344 ) -> Vec<String> {
345 let helm_config = deployer_config
346 .helm
347 .as_ref()
348 .expect("deployer_config.helm must be set for a helm deployer");
349
350 let mut args = vec!["--namespace".to_string(), namespace.to_string()];
351 args.extend(values_args.iter().cloned());
352
353 if helm_config.wait {
354 args.push("--wait".to_string());
355 }
356
357 if !helm_config.timeout.is_empty() {
358 args.push("--timeout".to_string());
359 args.push(helm_config.timeout.clone());
360 }
361
362 if let Some(release) = helm_config.release.as_ref()
365 && !release.version.is_empty()
366 {
367 args.push("--version".to_string());
368 args.push(release.version.clone());
369 }
370
371 if deployer_config.edit_mode {
372 args.push("--set".to_string());
373 args.push("editMode=true".to_string());
374 }
375
376 args
377 }
378
379 fn get_release_name(&self, deployer_config: &DeployerConfig) -> String {
382 let helm_config = deployer_config
383 .helm
384 .as_ref()
385 .expect("deployer_config.helm must be set for a helm deployer");
386
387 if let Some(release) = helm_config.release.as_ref()
388 && !release.name.is_empty()
389 {
390 return release.name.clone();
391 }
392
393 helm_config.namespace.clone()
394 }
395
396 pub fn prepare_chart_source(
422 &self,
423 deployer_config: &DeployerConfig,
424 caproni_config: &CaproniConfig,
425 ) -> Result<PathWithCleanup, HelmError> {
426 let noop: Box<dyn FnOnce()> = Box::new(|| {});
427
428 let Some(helm_config) = deployer_config.helm.as_ref() else {
429 return Err(HelmError::NotHelmDeployer);
430 };
431
432 if let Some(repo_name) = deployer_config.repository.as_ref()
434 && deployer_config.edit_mode
435 {
436 let repo_config = caproni_config
437 .repositories
438 .as_ref()
439 .and_then(|repos| repos.get(repo_name));
440
441 let Some(repo_config) = repo_config else {
442 return Err(HelmError::RepositoryNotFound(repo_name.clone()));
443 };
444
445 let clone = caproni_config.resolve_path(&repo_config.directory);
446
447 self.resolve_chart_dependencies(std::path::Path::new(&clone))?;
448
449 return Ok((clone, noop));
450 }
451
452 let Some(release) = helm_config.release.as_ref() else {
454 return Err(HelmError::MissingRelease);
455 };
456
457 if let Some(repo) = helm_config.repository.as_ref() {
460 self.ensure_helm_repository(repo)?;
461 return Ok((release.chart.clone(), noop));
462 }
463
464 if xianyun_backends::helm::chartref::is_url_chart_ref(&release.chart) {
467 let (path, cleanup) = self.materialize_url_chart(&release.chart)?;
468 return Ok((path.display().to_string(), cleanup));
469 }
470
471 if super::chartdeps::is_chart_directory(&release.chart) {
474 self.resolve_chart_dependencies(std::path::Path::new(&release.chart))?;
475 }
476
477 Ok((release.chart.clone(), noop))
478 }
479
480 fn ensure_helm_repository(
487 &self,
488 repo: &xianyun_core::config::latest::HelmRepositoryConfig,
489 ) -> Result<(), HelmError> {
490 let existing_repos = self.get_existing_helm_repos()?;
491 let existing_repo = existing_repos.iter().find(|r| r.name == repo.name);
492
493 match existing_repo {
494 Some(existing) if existing.url == repo.url => {}
495 Some(_) => self.add_or_update_helm_repo(&repo.name, &repo.url, true)?,
496 None => self.add_or_update_helm_repo(&repo.name, &repo.url, false)?,
497 }
498
499 self.update_helm_repo(&repo.name)
500 }
501
502 fn add_or_update_helm_repo(
504 &self,
505 name: &str,
506 url: &str,
507 force_update: bool,
508 ) -> Result<(), HelmError> {
509 self.backend
510 .repo_add(name, url, force_update)
511 .map_err(|e| HelmError::Backend {
512 context: "adding/updating helm repository".to_string(),
513 source: e,
514 })
515 }
516
517 fn update_helm_repo(&self, name: &str) -> Result<(), HelmError> {
519 self.backend
520 .repo_update(name)
521 .map_err(|e| HelmError::Backend {
522 context: "updating helm repository".to_string(),
523 source: e,
524 })
525 }
526
527 pub fn validate(
543 &self,
544 deployer_config: &DeployerConfig,
545 caproni_config: &CaproniConfig,
546 ) -> Result<(), HelmError> {
547 if deployer_config.deployer_type != "helm" || deployer_config.helm.is_none() {
548 return Err(HelmError::NotHelmDeployer);
549 }
550
551 let helm_config = deployer_config
552 .helm
553 .as_ref()
554 .expect("checked deployer_config.helm.is_none() above");
555
556 let edit_mode_with_repository =
557 deployer_config.repository.is_some() && deployer_config.edit_mode;
558
559 if !edit_mode_with_repository && helm_config.release.is_none() {
560 return Err(HelmError::MissingRelease);
561 }
562
563 if let Some(release) = helm_config.release.as_ref()
564 && !release.version.is_empty()
565 && helm_config.repository.is_none()
566 {
567 return Err(HelmError::VersionRequiresRepository);
568 }
569
570 validate_dynamic_overrides(deployer_config, caproni_config)?;
571
572 validate_helm_paths(deployer_config, caproni_config)
573 }
574}
575
576fn validate_dynamic_overrides(
578 deployer_config: &DeployerConfig,
579 caproni_config: &CaproniConfig,
580) -> Result<(), HelmError> {
581 let helm_config = deployer_config
582 .helm
583 .as_ref()
584 .expect("checked deployer_config.helm.is_none() by Validate");
585
586 for override_cfg in &helm_config.dynamic_overrides {
587 match override_cfg.override_type.as_str() {
588 "gitlab_repo_version_files" => {
589 validate_gitlab_repo_version_files(override_cfg, caproni_config)?;
590 }
591 "inline" | "inline_file" | "release_manifest" => {
592 }
598 other => {
599 return Err(HelmError::UnknownDynamicOverrideType(other.to_string()));
600 }
601 }
602 }
603
604 Ok(())
605}
606
607fn validate_gitlab_repo_version_files(
609 override_cfg: &xianyun_core::config::latest::DynamicOverrideConfig,
610 caproni_config: &CaproniConfig,
611) -> Result<(), HelmError> {
612 if override_cfg.repository.is_empty() {
613 return Err(HelmError::MissingRepository);
614 }
615
616 if override_cfg.version_map.is_empty() {
617 return Err(HelmError::MissingVersionMap(
618 "gitlab_repo_version_files".to_string(),
619 ));
620 }
621
622 let version_map_path = caproni_config.resolve_path(&override_cfg.version_map);
623
624 match std::fs::metadata(&version_map_path) {
625 Ok(_) => Ok(()),
626 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
627 Err(HelmError::MissingVersionMap(version_map_path))
628 }
629 Err(e) => Err(HelmError::Io(format!(
630 "checking version map path {version_map_path}: {e}"
631 ))),
632 }
633}
634
635fn validate_helm_paths(
637 deployer_config: &DeployerConfig,
638 caproni_config: &CaproniConfig,
639) -> Result<(), HelmError> {
640 let helm_config = deployer_config
641 .helm
642 .as_ref()
643 .expect("checked deployer_config.helm.is_none() by Validate");
644
645 if !helm_config.values_file.is_empty() {
646 let path = caproni_config.resolve_path(&helm_config.values_file);
647
648 if !std::path::Path::new(&path).exists() {
649 return Err(HelmError::ValuesFileNotFound(path));
650 }
651 }
652
653 Ok(())
654}
655
656fn write_values_file(values: &Value) -> Result<PathWithCleanup, HelmError> {
666 use std::io::Write as _;
667
668 let encoded = serde_yaml::to_string(values)
669 .map_err(|e| HelmError::Io(format!("encoding helm override values: {e}")))?;
670
671 let mut file = tempfile::Builder::new()
672 .prefix("caproni-helm-values-")
673 .suffix(".yaml")
674 .tempfile()
675 .map_err(|e| HelmError::Io(format!("creating helm override values file: {e}")))?;
676
677 file.write_all(encoded.as_bytes())
678 .map_err(|e| HelmError::Io(format!("writing helm override values file: {e}")))?;
679
680 let path = file.path().display().to_string();
681 let temp_path = file.into_temp_path();
682
683 Ok((path, Box::new(move || drop(temp_path))))
684}
685
686impl<B: HelmBackend> crate::deployer::Manager for Manager<B> {
688 fn deployer_type(&self) -> String {
689 "helm".to_string()
690 }
691
692 fn deploy(
693 &self,
694 deployer_config: &DeployerConfig,
695 cfg: Option<&xianyun_core::config::container::Container>,
696 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
697 require_helm_block(deployer_config)?;
698 let cfg = cfg.ok_or("helm deploy needs the resolved configuration")?;
699
700 Manager::<B>::deploy(self, deployer_config, cfg).map_err(Into::into)
701 }
702
703 fn undeploy(
704 &self,
705 deployer_config: &DeployerConfig,
706 caproni_config: &CaproniConfig,
707 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
708 Manager::<B>::undeploy(self, deployer_config, caproni_config).map_err(Into::into)
709 }
710
711 fn status(
712 &self,
713 deployer_config: &DeployerConfig,
714 caproni_config: &CaproniConfig,
715 ) -> Result<crate::deployer::Status, Box<dyn std::error::Error + Send + Sync>> {
716 require_helm_block(deployer_config)?;
717
718 Manager::<B>::status(self, deployer_config, caproni_config).map_err(Into::into)
719 }
720
721 fn validate(
722 &self,
723 deployer_config: &DeployerConfig,
724 caproni_config: &CaproniConfig,
725 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
726 Manager::<B>::validate(self, deployer_config, caproni_config).map_err(Into::into)
727 }
728}
729
730fn require_helm_block(deployer_config: &DeployerConfig) -> Result<(), HelmError> {
733 if deployer_config.deployer_type != "helm" || deployer_config.helm.is_none() {
734 return Err(HelmError::NotHelmDeployer);
735 }
736
737 Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742 use serde_json::{Value, json};
743
744 use xianyun_core::config::container::{Container, new_container};
745 use xianyun_core::config::global::GlobalConfig;
746 use xianyun_core::config::latest::{
747 CaproniConfig, DeployerConfig, DynamicOverrideConfig, HelmConfig,
748 };
749 use xianyun_core::orderedmap::OrderedMap;
750
751 use super::super::test_support::MockHelmBackend;
752 use super::Manager;
753
754 const TEST_OCI_CHART: &str = "oci://registry.example.com/charts/my-app";
755
756 const SATISFIED_LIST: &str =
757 "NAME\tVERSION\tREPOSITORY\tSTATUS\ncert-manager\t1.16.2\thttps://charts.jetstack.io\tok\n";
758 const UNSATISFIED_LIST: &str = "NAME\tVERSION\tREPOSITORY\tSTATUS\ncert-manager\t1.16.2\thttps://charts.jetstack.io\tmissing\n";
759
760 fn new_test_config() -> Container {
764 new_container(
765 GlobalConfig::default(),
766 CaproniConfig {
767 cluster: xianyun_core::config::types::ClusterConfig {
768 network: Some(xianyun_core::config::types::NetworkConfig {
769 hostname: "test.example.com".to_string(),
770 ..Default::default()
771 }),
772 ..Default::default()
773 },
774 ..Default::default()
775 },
776 )
777 }
778
779 fn helm_deployer_config(overrides: Vec<DynamicOverrideConfig>) -> DeployerConfig {
780 DeployerConfig {
781 deployer_type: "helm".to_string(),
782 helm: Some(HelmConfig {
783 namespace: "default".to_string(),
784 dynamic_overrides: overrides,
785 ..Default::default()
786 }),
787 ..Default::default()
788 }
789 }
790
791 fn inline_override(value_map: Vec<(&str, Value)>) -> DynamicOverrideConfig {
792 let mut map = OrderedMap::new();
793 for (k, v) in value_map {
794 map.set(k.to_string(), v);
795 }
796
797 DynamicOverrideConfig {
798 override_type: "inline".to_string(),
799 value_map: Some(map),
800 ..Default::default()
801 }
802 }
803
804 #[test]
806 fn test_type() {
807 let mgr = Manager::new(MockHelmBackend::new());
808 assert_eq!(mgr.deployer_type(), "helm");
809 }
810
811 #[test]
813 fn test_build_dynamic_overrides_git_lab_repo_version_files() {
814 let mgr = Manager::new(MockHelmBackend::new());
815
816 let manifest_dir = env!("CARGO_MANIFEST_DIR");
817 let deployer_config = helm_deployer_config(vec![DynamicOverrideConfig {
818 override_type: "gitlab_repo_version_files".to_string(),
819 repository: format!("{manifest_dir}/testdata/gitlab/"),
820 version_map: format!("{manifest_dir}/testdata/version_map.yaml"),
821 ..Default::default()
822 }]);
823
824 let caproni_config = CaproniConfig::default();
825
826 let values = mgr
827 .build_dynamic_overrides(&deployer_config, &caproni_config)
828 .unwrap();
829
830 assert_eq!(
831 values,
832 Some(json!({
833 "gitlab": {"gitaly": {"image": {"tag": "e7e3eae790ac47ed59a7eb82240eb9a74a9421c2"}}},
834 "openbao": {"image": {"tag": "v2.4.4-gitlab2"}},
835 }))
836 );
837 }
838
839 #[test]
841 fn test_build_dynamic_overrides_git_lab_repo_version_files_missing_repository() {
842 let mgr = Manager::new(MockHelmBackend::new());
843
844 let manifest_dir = env!("CARGO_MANIFEST_DIR");
845 let deployer_config = helm_deployer_config(vec![DynamicOverrideConfig {
846 override_type: "gitlab_repo_version_files".to_string(),
847 repository: format!("{manifest_dir}/testdata/does-not-exist/"),
848 version_map: format!("{manifest_dir}/testdata/version_map.yaml"),
849 ..Default::default()
850 }]);
851
852 let values = mgr
853 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
854 .unwrap();
855
856 assert_eq!(values, None);
857 }
858
859 #[test]
861 fn test_build_dynamic_overrides_inline() {
862 let mgr = Manager::new(MockHelmBackend::new());
863
864 let deployer_config = helm_deployer_config(vec![inline_override(vec![
865 ("global.hosts.domain", json!("${CAPRONI_TEST_DOMAIN}")),
866 ("global.edition", json!("ce")),
867 ("global.tag", json!("v1.2.3")),
868 ])]);
869
870 let values = mgr
871 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
872 .unwrap();
873
874 assert_eq!(
877 values,
878 Some(json!({
879 "global": {
880 "edition": "ce",
881 "hosts": {"domain": "${CAPRONI_TEST_DOMAIN}"},
882 "tag": "v1.2.3",
883 }
884 }))
885 );
886 }
887
888 #[test]
890 fn test_build_dynamic_overrides_inline_typed_values() {
891 let mgr = Manager::new(MockHelmBackend::new());
892
893 let deployer_config = helm_deployer_config(vec![inline_override(vec![
894 ("global.hosts.gitlab.name", json!("gitlab.example.test")),
895 ("global.hosts.gitlab.port", json!(5580)),
896 ("global.shell.port", json!(5522)),
897 ("global.ingress.tls", json!(false)),
898 ("global.replicas", json!(3)),
899 ("global.weight", json!(1.5)),
900 ])]);
901
902 let values = mgr
903 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
904 .unwrap();
905
906 assert_eq!(
907 values,
908 Some(json!({
909 "global": {
910 "hosts": {"gitlab": {"name": "gitlab.example.test", "port": 5580}},
911 "ingress": {"tls": false},
912 "replicas": 3,
913 "shell": {"port": 5522},
914 "weight": 1.5,
915 }
916 }))
917 );
918 }
919
920 #[test]
922 fn test_build_dynamic_overrides_inline_lists_and_maps() {
923 let mgr = Manager::new(MockHelmBackend::new());
924
925 let deployer_config = helm_deployer_config(vec![inline_override(vec![
926 ("global.appConfig", json!({"enabled": true, "count": 3})),
927 ("global.gitlab.extraArgs", json!(["--foo", "--bar"])),
928 ("global.tag", json!("v1.2.3")),
929 ])]);
930
931 let values = mgr
932 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
933 .unwrap();
934
935 assert_eq!(
936 values,
937 Some(json!({
938 "global": {
939 "appConfig": {"enabled": true, "count": 3},
940 "gitlab": {"extraArgs": ["--foo", "--bar"]},
941 "tag": "v1.2.3",
942 }
943 }))
944 );
945 }
946
947 #[test]
956 #[ignore = "no Rust equivalent: DynamicOverrideConfig::value_map is serde_json::Value, which has no variant analogous to Go's decoded time.Time (see overrider.rs doc comment)"]
957 fn test_build_dynamic_overrides_inline_rejects_non_scalar_leaves() {
958 unimplemented!(
959 "port of Go TestBuildDynamicOverrides_InlineRejectsNonScalarLeaves from internal/deployer/helm/manager_test.go:246 -- see #[ignore] reason"
960 )
961 }
962
963 #[test]
968 #[ignore = "no Rust equivalent: DynamicOverrideConfig::value_map is serde_json::Value, which has no variant analogous to Go's decoded time.Time (see overrider.rs doc comment)"]
969 fn test_build_dynamic_overrides_inline_unsupported_type() {
970 unimplemented!(
971 "port of Go TestBuildDynamicOverrides_InlineUnsupportedType from internal/deployer/helm/manager_test.go:296 -- see #[ignore] reason"
972 )
973 }
974
975 #[test]
977 fn test_build_dynamic_overrides_inline_file() {
978 let dir = tempfile::tempdir().unwrap();
979 let secret_path = dir.path().join("rails-secret");
980 std::fs::write(&secret_path, "s3cret\n").unwrap();
981
982 let mgr = Manager::new(MockHelmBackend::new());
983
984 let deployer_config = helm_deployer_config(vec![DynamicOverrideConfig {
985 override_type: "inline_file".to_string(),
986 value_map: Some({
987 let mut m = OrderedMap::new();
988 m.set(
989 "global.rails.secret".to_string(),
990 json!(secret_path.display().to_string()),
991 );
992 m
993 }),
994 ..Default::default()
995 }]);
996
997 let values = mgr
998 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
999 .unwrap();
1000
1001 assert_eq!(
1002 values,
1003 Some(json!({"global": {"rails": {"secret": "s3cret\n"}}}))
1004 );
1005 }
1006
1007 #[test]
1009 fn test_build_dynamic_overrides_inline_file_missing() {
1010 let mgr = Manager::new(MockHelmBackend::new());
1011
1012 let deployer_config = helm_deployer_config(vec![DynamicOverrideConfig {
1013 override_type: "inline_file".to_string(),
1014 value_map: Some({
1015 let mut m = OrderedMap::new();
1016 m.set("global.literal".to_string(), json!("$CAPRONI_TEST_FILE"));
1017 m
1018 }),
1019 ..Default::default()
1020 }]);
1021
1022 let err = mgr
1023 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1024 .unwrap_err();
1025
1026 assert!(err.to_string().contains("$CAPRONI_TEST_FILE"));
1027 }
1028
1029 #[test]
1031 fn test_build_dynamic_overrides_merges_overrides_in_order() {
1032 let mgr = Manager::new(MockHelmBackend::new());
1033
1034 let deployer_config = helm_deployer_config(vec![
1035 inline_override(vec![
1036 ("global.hosts.domain", json!("example.test")),
1037 ("global.edition", json!("ce")),
1038 ]),
1039 inline_override(vec![
1040 ("global.hosts.gitlab.name", json!("gitlab.example.test")),
1041 ("global.edition", json!("ee")),
1042 ]),
1043 ]);
1044
1045 let values = mgr
1046 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1047 .unwrap();
1048
1049 assert_eq!(
1050 values,
1051 Some(json!({
1052 "global": {
1053 "edition": "ee",
1054 "hosts": {
1055 "domain": "example.test",
1056 "gitlab": {"name": "gitlab.example.test"},
1057 },
1058 }
1059 }))
1060 );
1061 }
1062
1063 #[test]
1065 fn test_build_dynamic_overrides_later_override_replaces_shape() {
1066 let mgr = Manager::new(MockHelmBackend::new());
1067
1068 {
1070 let deployer_config = helm_deployer_config(vec![
1071 inline_override(vec![("global.tag", json!("v1.2.3"))]),
1072 inline_override(vec![("global.tag.suffix", json!("rc1"))]),
1073 ]);
1074
1075 let values = mgr
1076 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1077 .unwrap();
1078
1079 assert_eq!(values, Some(json!({"global": {"tag": {"suffix": "rc1"}}})));
1080 }
1081
1082 {
1084 let deployer_config = helm_deployer_config(vec![
1085 inline_override(vec![("global.tag.suffix", json!("rc1"))]),
1086 inline_override(vec![("global.tag", json!("v1.2.3"))]),
1087 ]);
1088
1089 let values = mgr
1090 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1091 .unwrap();
1092
1093 assert_eq!(values, Some(json!({"global": {"tag": "v1.2.3"}})));
1094 }
1095 }
1096
1097 #[test]
1099 fn test_build_dynamic_overrides_null_drops_earlier_value() {
1100 let mgr = Manager::new(MockHelmBackend::new());
1101
1102 let deployer_config = helm_deployer_config(vec![
1103 inline_override(vec![
1104 ("crds.enabled", json!(true)),
1105 ("prometheus.enabled", json!(false)),
1106 ]),
1107 inline_override(vec![
1108 ("crds.enabled", Value::Null),
1109 ("never.set", Value::Null),
1110 ("extra.enabled", json!(true)),
1111 ]),
1112 ]);
1113
1114 let values = mgr
1115 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1116 .unwrap();
1117
1118 assert_eq!(
1119 values,
1120 Some(json!({
1121 "prometheus": {"enabled": false},
1122 "extra": {"enabled": true},
1123 }))
1124 );
1125 }
1126
1127 #[test]
1129 fn test_build_dynamic_overrides_nested_null_drops_key() {
1130 let mgr = Manager::new(MockHelmBackend::new());
1131
1132 let deployer_config = helm_deployer_config(vec![
1133 inline_override(vec![(
1134 "global.appConfig",
1135 json!({"enabled": true, "debug": true}),
1136 )]),
1137 inline_override(vec![(
1138 "global.appConfig",
1139 json!({"debug": null, "limits": {"cpu": "500m"}}),
1140 )]),
1141 ]);
1142
1143 let values = mgr
1144 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1145 .unwrap();
1146
1147 assert_eq!(
1148 values,
1149 Some(json!({
1150 "global": {
1151 "appConfig": {"enabled": true, "limits": {"cpu": "500m"}},
1152 }
1153 }))
1154 );
1155 }
1156
1157 #[test]
1159 fn test_build_dynamic_overrides_rejects_null_in_list() {
1160 let mgr = Manager::new(MockHelmBackend::new());
1161
1162 {
1164 let deployer_config = helm_deployer_config(vec![inline_override(vec![(
1165 "global.extraArgs",
1166 json!(["--foo", null]),
1167 )])]);
1168
1169 let err = mgr
1170 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1171 .unwrap_err();
1172 assert!(matches!(
1173 err,
1174 super::HelmError::UnsupportedInlineValueType(..)
1175 ));
1176 }
1177
1178 {
1180 let deployer_config = helm_deployer_config(vec![inline_override(vec![(
1181 "global.servers",
1182 json!([{"name": null}]),
1183 )])]);
1184
1185 let err = mgr
1186 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1187 .unwrap_err();
1188 assert!(matches!(
1189 err,
1190 super::HelmError::UnsupportedInlineValueType(..)
1191 ));
1192 }
1193 }
1194
1195 #[test]
1197 fn test_build_dynamic_overrides_all_values_nulled() {
1198 let mgr = Manager::new(MockHelmBackend::new());
1199
1200 let deployer_config = helm_deployer_config(vec![
1201 inline_override(vec![("crds.enabled", json!(true))]),
1202 inline_override(vec![("crds.enabled", Value::Null)]),
1203 ]);
1204
1205 let values = mgr
1206 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1207 .unwrap();
1208
1209 assert_eq!(values, None);
1210 }
1211
1212 #[test]
1214 fn test_build_dynamic_overrides_later_override_wins_across_types() {
1215 let mgr = Manager::new(MockHelmBackend::new());
1216
1217 let deployer_config = helm_deployer_config(vec![
1218 inline_override(vec![("global.port", json!("5580"))]),
1219 inline_override(vec![("global.port", json!(5580))]),
1220 ]);
1221
1222 let values = mgr
1223 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1224 .unwrap();
1225
1226 assert_eq!(values, Some(json!({"global": {"port": 5580}})));
1227 }
1228
1229 #[test]
1231 fn test_build_dynamic_overrides_map_values_deep_merge() {
1232 let mgr = Manager::new(MockHelmBackend::new());
1233
1234 let deployer_config = helm_deployer_config(vec![
1235 inline_override(vec![("global.cfg", json!({"a": 1, "shared": "base"}))]),
1236 inline_override(vec![("global.cfg", json!({"b": 2, "shared": "overlay"}))]),
1237 inline_override(vec![("global.cfg.c", json!(3))]),
1239 ]);
1240
1241 let values = mgr
1242 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1243 .unwrap();
1244
1245 assert_eq!(
1246 values,
1247 Some(json!({
1248 "global": {"cfg": {"a": 1, "b": 2, "c": 3, "shared": "overlay"}}
1249 }))
1250 );
1251 }
1252
1253 #[test]
1255 fn test_build_dynamic_overrides_indexed_keys() {
1256 let mgr = Manager::new(MockHelmBackend::new());
1257
1258 {
1260 let deployer_config = helm_deployer_config(vec![inline_override(vec![(
1261 "ingress.hosts[0]",
1262 json!("grafana.example.test"),
1263 )])]);
1264 let values = mgr
1265 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1266 .unwrap();
1267 assert_eq!(
1268 values,
1269 Some(json!({"ingress": {"hosts": ["grafana.example.test"]}}))
1270 );
1271 }
1272
1273 {
1275 let deployer_config = helm_deployer_config(vec![inline_override(vec![(
1276 "ingress.hosts[0].name",
1277 json!("grafana.example.test"),
1278 )])]);
1279 let values = mgr
1280 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1281 .unwrap();
1282 assert_eq!(
1283 values,
1284 Some(json!({"ingress": {"hosts": [{"name": "grafana.example.test"}]}}))
1285 );
1286 }
1287
1288 {
1290 let deployer_config = helm_deployer_config(vec![inline_override(vec![
1291 ("ingress.hosts[0]", json!("a.example.test")),
1292 ("ingress.hosts[1]", json!("b.example.test")),
1293 ])]);
1294 let values = mgr
1295 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1296 .unwrap();
1297 assert_eq!(
1298 values,
1299 Some(json!({"ingress": {"hosts": ["a.example.test", "b.example.test"]}}))
1300 );
1301 }
1302
1303 {
1305 let deployer_config = helm_deployer_config(vec![inline_override(vec![(
1306 "ingress.hosts[2]",
1307 json!("c.example.test"),
1308 )])]);
1309 let values = mgr
1310 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1311 .unwrap();
1312 assert_eq!(
1313 values,
1314 Some(json!({"ingress": {"hosts": [null, null, "c.example.test"]}}))
1315 );
1316 }
1317 }
1318
1319 #[test]
1321 fn test_build_dynamic_overrides_index_into_scalar() {
1322 let mgr = Manager::new(MockHelmBackend::new());
1323
1324 let deployer_config = helm_deployer_config(vec![inline_override(vec![
1326 ("ingress.hosts", json!("a.example.test")),
1327 ("ingress.hosts[0]", json!("b.example.test")),
1328 ])]);
1329
1330 let err = mgr
1331 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1332 .unwrap_err();
1333
1334 assert!(matches!(err, super::HelmError::ConflictingHelmKey(_)));
1335 }
1336
1337 #[test]
1339 fn test_build_dynamic_overrides_conflicting_key_path() {
1340 let mgr = Manager::new(MockHelmBackend::new());
1341
1342 let deployer_config = helm_deployer_config(vec![inline_override(vec![
1344 ("global.tag", json!("v1.2.3")),
1345 ("global.tag.suffix", json!("rc1")),
1346 ])]);
1347
1348 let err = mgr
1349 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1350 .unwrap_err();
1351
1352 assert!(matches!(err, super::HelmError::ConflictingHelmKey(_)));
1353 }
1354
1355 #[test]
1357 fn test_build_dynamic_overrides_unknown_type() {
1358 let mgr = Manager::new(MockHelmBackend::new());
1359
1360 let deployer_config = helm_deployer_config(vec![DynamicOverrideConfig {
1361 override_type: "unknown_type".to_string(),
1362 ..Default::default()
1363 }]);
1364
1365 let err = mgr
1366 .build_dynamic_overrides(&deployer_config, &CaproniConfig::default())
1367 .unwrap_err();
1368
1369 assert!(matches!(
1370 err,
1371 super::HelmError::UnknownDynamicOverrideType(_)
1372 ));
1373 assert!(err.to_string().contains("unknown_type"));
1374 }
1375
1376 #[test]
1378 fn test_validate_no_dynamic_overrides() {
1379 let mgr = Manager::new(MockHelmBackend::new());
1380
1381 let deployer_config = DeployerConfig {
1382 deployer_type: "helm".to_string(),
1383 helm: Some(HelmConfig {
1384 namespace: "default".to_string(),
1385 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1386 name: "my-app".to_string(),
1387 chart: "repo/chart".to_string(),
1388 ..Default::default()
1389 }),
1390 ..Default::default()
1391 }),
1392 ..Default::default()
1393 };
1394
1395 mgr.validate(&deployer_config, &CaproniConfig::default())
1396 .unwrap();
1397 }
1398
1399 #[test]
1401 fn test_validate_dynamic_overrides_missing_repository() {
1402 let mgr = Manager::new(MockHelmBackend::new());
1403
1404 let deployer_config = DeployerConfig {
1405 deployer_type: "helm".to_string(),
1406 helm: Some(HelmConfig {
1407 namespace: "default".to_string(),
1408 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1409 name: "my-app".to_string(),
1410 chart: "repo/chart".to_string(),
1411 ..Default::default()
1412 }),
1413 dynamic_overrides: vec![DynamicOverrideConfig {
1414 override_type: "gitlab_repo_version_files".to_string(),
1415 ..Default::default()
1416 }],
1417 ..Default::default()
1418 }),
1419 ..Default::default()
1420 };
1421
1422 let err = mgr
1423 .validate(&deployer_config, &CaproniConfig::default())
1424 .unwrap_err();
1425
1426 assert!(matches!(err, super::HelmError::MissingRepository));
1427 }
1428
1429 #[test]
1431 fn test_validate_dynamic_overrides_missing_version_map() {
1432 let mgr = Manager::new(MockHelmBackend::new());
1433
1434 let deployer_config = DeployerConfig {
1435 deployer_type: "helm".to_string(),
1436 helm: Some(HelmConfig {
1437 namespace: "default".to_string(),
1438 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1439 name: "my-app".to_string(),
1440 chart: "repo/chart".to_string(),
1441 ..Default::default()
1442 }),
1443 dynamic_overrides: vec![DynamicOverrideConfig {
1444 override_type: "gitlab_repo_version_files".to_string(),
1445 repository: "./testdata/gitlab/".to_string(),
1446 ..Default::default()
1447 }],
1448 ..Default::default()
1449 }),
1450 ..Default::default()
1451 };
1452
1453 let err = mgr
1454 .validate(&deployer_config, &CaproniConfig::default())
1455 .unwrap_err();
1456
1457 assert!(matches!(err, super::HelmError::MissingVersionMap(_)));
1458 }
1459
1460 #[test]
1462 fn test_validate_invalid_deployer_type() {
1463 let mgr = Manager::new(MockHelmBackend::new());
1464
1465 let deployer_config = DeployerConfig {
1466 deployer_type: "skaffold".to_string(),
1467 ..Default::default()
1468 };
1469
1470 let err = mgr
1471 .validate(&deployer_config, &CaproniConfig::default())
1472 .unwrap_err();
1473
1474 assert!(matches!(err, super::HelmError::NotHelmDeployer));
1475 }
1476
1477 #[test]
1479 fn test_prepare_chart_source_oci_chart() {
1480 let mgr = Manager::new(MockHelmBackend::new());
1481
1482 let deployer_config = DeployerConfig {
1483 deployer_type: "helm".to_string(),
1484 helm: Some(HelmConfig {
1485 namespace: "default".to_string(),
1486 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1487 name: "my-app".to_string(),
1488 chart: TEST_OCI_CHART.to_string(),
1489 ..Default::default()
1490 }),
1491 ..Default::default()
1492 }),
1493 ..Default::default()
1494 };
1495
1496 let (chart_path, _cleanup) = mgr
1497 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
1498 .unwrap();
1499
1500 assert_eq!(chart_path, TEST_OCI_CHART);
1501 }
1502
1503 #[test]
1505 fn test_prepare_chart_source_oci_chart_with_tag() {
1506 let mgr = Manager::new(MockHelmBackend::new());
1507
1508 let oci_chart = "oci://registry.example.com/charts/my-app:1.2.3";
1509 let deployer_config = DeployerConfig {
1510 deployer_type: "helm".to_string(),
1511 helm: Some(HelmConfig {
1512 namespace: "default".to_string(),
1513 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1514 name: "my-app".to_string(),
1515 chart: oci_chart.to_string(),
1516 ..Default::default()
1517 }),
1518 ..Default::default()
1519 }),
1520 ..Default::default()
1521 };
1522
1523 let (chart_path, _cleanup) = mgr
1524 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
1525 .unwrap();
1526
1527 assert_eq!(chart_path, oci_chart);
1528 }
1529
1530 #[test]
1532 fn test_prepare_chart_source_repo_chart() {
1533 let backend = MockHelmBackend::new()
1534 .expect_repo_list(|| Ok("[]".to_string()))
1535 .expect_repo_add(|name, url, force_update| {
1536 assert_eq!(name, "gitlab");
1537 assert_eq!(url, "https://charts.gitlab.io");
1538 assert!(!force_update);
1539 Ok(())
1540 })
1541 .expect_repo_update(|name| {
1542 assert_eq!(name, "gitlab");
1543 Ok(())
1544 });
1545 let mgr = Manager::new(backend);
1546
1547 let deployer_config = DeployerConfig {
1548 deployer_type: "helm".to_string(),
1549 helm: Some(HelmConfig {
1550 namespace: "default".to_string(),
1551 repository: Some(xianyun_core::config::latest::HelmRepositoryConfig {
1552 name: "gitlab".to_string(),
1553 url: "https://charts.gitlab.io".to_string(),
1554 }),
1555 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1556 name: "gitlab".to_string(),
1557 chart: "gitlab/gitlab".to_string(),
1558 ..Default::default()
1559 }),
1560 ..Default::default()
1561 }),
1562 ..Default::default()
1563 };
1564
1565 let (chart_path, _cleanup) = mgr
1566 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
1567 .unwrap();
1568
1569 assert_eq!(chart_path, "gitlab/gitlab");
1570 }
1571
1572 #[test]
1574 fn test_prepare_chart_source_missing_release() {
1575 let mgr = Manager::new(MockHelmBackend::new());
1576
1577 let deployer_config = DeployerConfig {
1578 deployer_type: "helm".to_string(),
1579 helm: Some(HelmConfig {
1580 namespace: "default".to_string(),
1581 ..Default::default()
1582 }),
1583 ..Default::default()
1584 };
1585
1586 let err = match mgr.prepare_chart_source(&deployer_config, &CaproniConfig::default()) {
1587 Err(e) => e,
1588 Ok(_) => panic!("expected an error"),
1589 };
1590
1591 assert!(matches!(err, super::HelmError::MissingRelease));
1592 }
1593
1594 #[test]
1596 fn test_validate_missing_release() {
1597 let mgr = Manager::new(MockHelmBackend::new());
1598
1599 let deployer_config = DeployerConfig {
1600 deployer_type: "helm".to_string(),
1601 helm: Some(HelmConfig {
1602 namespace: "default".to_string(),
1603 ..Default::default()
1604 }),
1605 ..Default::default()
1606 };
1607
1608 let err = mgr
1609 .validate(&deployer_config, &CaproniConfig::default())
1610 .unwrap_err();
1611
1612 assert!(matches!(err, super::HelmError::MissingRelease));
1613 }
1614
1615 #[test]
1617 fn test_deploy_helm_cli_flags() {
1618 const RELEASE_NAME: &str = "my-app";
1619 const NAMESPACE: &str = "default";
1620
1621 struct Case {
1622 name: &'static str,
1623 helm: HelmConfig,
1624 want_args: Vec<&'static str>,
1625 }
1626
1627 let cases = vec![
1628 Case {
1629 name: "defaults omit wait and timeout",
1630 helm: HelmConfig {
1631 namespace: NAMESPACE.to_string(),
1632 ..Default::default()
1633 },
1634 want_args: vec!["--create-namespace", "--namespace", NAMESPACE],
1635 },
1636 Case {
1637 name: "wait true passes --wait",
1638 helm: HelmConfig {
1639 namespace: NAMESPACE.to_string(),
1640 wait: true,
1641 ..Default::default()
1642 },
1643 want_args: vec!["--create-namespace", "--namespace", NAMESPACE, "--wait"],
1644 },
1645 Case {
1646 name: "timeout set passes --timeout",
1647 helm: HelmConfig {
1648 namespace: NAMESPACE.to_string(),
1649 timeout: "10m".to_string(),
1650 ..Default::default()
1651 },
1652 want_args: vec![
1653 "--create-namespace",
1654 "--namespace",
1655 NAMESPACE,
1656 "--timeout",
1657 "10m",
1658 ],
1659 },
1660 Case {
1661 name: "wait and timeout combined",
1662 helm: HelmConfig {
1663 namespace: NAMESPACE.to_string(),
1664 wait: true,
1665 timeout: "1h30m".to_string(),
1666 ..Default::default()
1667 },
1668 want_args: vec![
1669 "--create-namespace",
1670 "--namespace",
1671 NAMESPACE,
1672 "--wait",
1673 "--timeout",
1674 "1h30m",
1675 ],
1676 },
1677 ];
1678
1679 for case in cases {
1680 let mut helm = case.helm;
1681 helm.release = Some(xianyun_core::config::latest::HelmReleaseConfig {
1682 name: RELEASE_NAME.to_string(),
1683 chart: TEST_OCI_CHART.to_string(),
1684 ..Default::default()
1685 });
1686
1687 let deployer_config = DeployerConfig {
1688 deployer_type: "helm".to_string(),
1689 helm: Some(helm),
1690 ..Default::default()
1691 };
1692
1693 let want_args: Vec<String> = case.want_args.iter().map(|s| s.to_string()).collect();
1694 let want_args_for_closure = want_args.clone();
1695
1696 let backend = MockHelmBackend::new()
1697 .expect_list(move |namespace, filter| {
1698 assert_eq!(namespace, NAMESPACE);
1699 assert_eq!(filter, format!("^{RELEASE_NAME}$"));
1700 Ok("[]".to_string())
1701 })
1702 .expect_install(move |release_name, chart_path, args| {
1703 assert_eq!(release_name, RELEASE_NAME);
1704 assert_eq!(chart_path, TEST_OCI_CHART);
1705 assert_eq!(args, want_args_for_closure, "{}", case.name);
1706 Ok(())
1707 });
1708
1709 let mgr = Manager::new(backend);
1710
1711 mgr.deploy(&deployer_config, &new_test_config())
1712 .unwrap_or_else(|e| panic!("{}: {e}", case.name));
1713 }
1714 }
1715
1716 #[test]
1718 fn test_deploy_version_pinning() {
1719 const RELEASE_NAME: &str = "gitlab";
1720 const NAMESPACE: &str = "gitlab";
1721 const CHART_REF: &str = "gitlab/gitlab";
1722 const VERSION: &str = "8.9.1";
1723 const REPO_NAME: &str = "gitlab";
1724 const REPO_URL: &str = "https://charts.gitlab.io";
1725
1726 let want_args = vec![
1727 "--namespace".to_string(),
1728 NAMESPACE.to_string(),
1729 "--version".to_string(),
1730 VERSION.to_string(),
1731 ];
1732
1733 let deployer_config = DeployerConfig {
1734 deployer_type: "helm".to_string(),
1735 helm: Some(HelmConfig {
1736 namespace: NAMESPACE.to_string(),
1737 repository: Some(xianyun_core::config::latest::HelmRepositoryConfig {
1738 name: REPO_NAME.to_string(),
1739 url: REPO_URL.to_string(),
1740 }),
1741 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1742 name: RELEASE_NAME.to_string(),
1743 chart: CHART_REF.to_string(),
1744 version: VERSION.to_string(),
1745 }),
1746 ..Default::default()
1747 }),
1748 ..Default::default()
1749 };
1750
1751 {
1753 let mut install_args = vec!["--create-namespace".to_string()];
1754 install_args.extend(want_args.clone());
1755
1756 let backend = MockHelmBackend::new()
1757 .expect_repo_list(|| Ok("[]".to_string()))
1758 .expect_repo_add(|name, url, force_update| {
1759 assert_eq!(name, REPO_NAME);
1760 assert_eq!(url, REPO_URL);
1761 assert!(!force_update);
1762 Ok(())
1763 })
1764 .expect_repo_update(|name| {
1765 assert_eq!(name, REPO_NAME);
1766 Ok(())
1767 })
1768 .expect_list(|namespace, filter| {
1769 assert_eq!(namespace, NAMESPACE);
1770 assert_eq!(filter, format!("^{RELEASE_NAME}$"));
1771 Ok("[]".to_string())
1772 })
1773 .expect_install(move |release_name, chart_path, args| {
1774 assert_eq!(release_name, RELEASE_NAME);
1775 assert_eq!(chart_path, CHART_REF);
1776 assert_eq!(args, install_args);
1777 Ok(())
1778 });
1779
1780 let mgr = Manager::new(backend);
1781 mgr.deploy(&deployer_config, &new_test_config()).unwrap();
1782 }
1783
1784 {
1786 let upgrade_args = want_args.clone();
1787
1788 let backend = MockHelmBackend::new()
1789 .expect_repo_list(|| {
1790 Ok(r#"[{"name":"gitlab","url":"https://charts.gitlab.io"}]"#.to_string())
1791 })
1792 .expect_repo_update(|name| {
1793 assert_eq!(name, REPO_NAME);
1794 Ok(())
1795 })
1796 .expect_list(|namespace, filter| {
1797 assert_eq!(namespace, NAMESPACE);
1798 assert_eq!(filter, format!("^{RELEASE_NAME}$"));
1799 Ok(r#"[{"name":"gitlab"}]"#.to_string())
1800 })
1801 .expect_upgrade(move |release_name, chart_path, args| {
1802 assert_eq!(release_name, RELEASE_NAME);
1803 assert_eq!(chart_path, CHART_REF);
1804 assert_eq!(args, upgrade_args);
1805 Ok(())
1806 });
1807
1808 let mgr = Manager::new(backend);
1809 mgr.deploy(&deployer_config, &new_test_config()).unwrap();
1810 }
1811 }
1812
1813 fn last_values_arg(args: &[String]) -> String {
1816 for i in (0..args.len().saturating_sub(1)).rev() {
1817 if args[i] == "--values" {
1818 return args[i + 1].clone();
1819 }
1820 }
1821
1822 panic!("no --values flag in {args:?}");
1823 }
1824
1825 #[test]
1827 fn test_deploy_dynamic_overrides_values_file() {
1828 const RELEASE_NAME: &str = "my-app";
1829 const NAMESPACE: &str = "default";
1830
1831 let temp_dir = tempfile::tempdir().unwrap();
1832 let configured_values = temp_dir.path().join("values.yaml");
1833 std::fs::write(&configured_values, "{}\n").unwrap();
1834 let configured_values = configured_values.display().to_string();
1835
1836 let mut value_map = OrderedMap::new();
1837 value_map.set("global.hosts.domain".to_string(), json!("example.test"));
1838
1839 let deployer_config = DeployerConfig {
1840 deployer_type: "helm".to_string(),
1841 helm: Some(HelmConfig {
1842 namespace: NAMESPACE.to_string(),
1843 values_file: configured_values.clone(),
1844 wait: true,
1845 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1846 name: RELEASE_NAME.to_string(),
1847 chart: TEST_OCI_CHART.to_string(),
1848 ..Default::default()
1849 }),
1850 dynamic_overrides: vec![DynamicOverrideConfig {
1851 override_type: "inline".to_string(),
1852 value_map: Some(value_map),
1853 ..Default::default()
1854 }],
1855 ..Default::default()
1856 }),
1857 ..Default::default()
1858 };
1859
1860 let got_args: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
1861 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1862 let override_yaml: std::sync::Arc<std::sync::Mutex<Vec<u8>>> =
1863 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1864
1865 let got_args_clone = got_args.clone();
1866 let override_yaml_clone = override_yaml.clone();
1867
1868 let backend = MockHelmBackend::new()
1869 .expect_list(|namespace, filter| {
1870 assert_eq!(namespace, NAMESPACE);
1871 assert_eq!(filter, format!("^{RELEASE_NAME}$"));
1872 Ok("[]".to_string())
1873 })
1874 .expect_install(move |release_name, chart_path, args| {
1875 assert_eq!(release_name, RELEASE_NAME);
1876 assert_eq!(chart_path, TEST_OCI_CHART);
1877
1878 *got_args_clone.lock().unwrap() = args.to_vec();
1879
1880 let override_path = last_values_arg(args);
1881 *override_yaml_clone.lock().unwrap() =
1882 std::fs::read(&override_path).expect("reading override values file");
1883
1884 Ok(())
1885 });
1886
1887 let mgr = Manager::new(backend);
1888 mgr.deploy(&deployer_config, &new_test_config()).unwrap();
1889
1890 let got_args = got_args.lock().unwrap().clone();
1891 let override_path = last_values_arg(&got_args);
1892
1893 assert_eq!(
1894 got_args,
1895 vec![
1896 "--create-namespace".to_string(),
1897 "--namespace".to_string(),
1898 NAMESPACE.to_string(),
1899 "--values".to_string(),
1900 configured_values,
1901 "--values".to_string(),
1902 override_path.clone(),
1903 "--wait".to_string(),
1904 ]
1905 );
1906
1907 let decoded: Value = serde_yaml::from_slice(&override_yaml.lock().unwrap()).unwrap();
1908 assert_eq!(
1909 decoded,
1910 json!({
1911 "global": {
1912 "hosts": {"domain": "example.test"},
1913 },
1914 })
1915 );
1916
1917 assert!(
1918 !std::path::Path::new(&override_path).exists(),
1919 "the override file should be removed once the deploy finishes"
1920 );
1921 }
1922
1923 #[test]
1925 fn test_write_values_file_is_not_world_readable() {
1926 let (path, cleanup) = super::write_values_file(&json!({"secret": "s3cret"})).unwrap();
1927
1928 let metadata = std::fs::metadata(&path).unwrap();
1929
1930 #[cfg(unix)]
1931 {
1932 use std::os::unix::fs::PermissionsExt as _;
1933 assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1934 }
1935
1936 cleanup();
1937 }
1938
1939 #[test]
1941 fn test_validate_version_without_repository() {
1942 let mgr = Manager::new(MockHelmBackend::new());
1943
1944 let deployer_config = DeployerConfig {
1945 deployer_type: "helm".to_string(),
1946 helm: Some(HelmConfig {
1947 namespace: "default".to_string(),
1948 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
1949 name: "my-app".to_string(),
1950 chart: "oci://registry.example.com/charts/my-app".to_string(),
1951 version: "1.2.3".to_string(),
1952 }),
1953 ..Default::default()
1954 }),
1955 ..Default::default()
1956 };
1957
1958 let err = mgr
1959 .validate(&deployer_config, &CaproniConfig::default())
1960 .unwrap_err();
1961
1962 assert!(matches!(err, super::HelmError::VersionRequiresRepository));
1963 }
1964
1965 #[test]
1967 fn test_validate_missing_release_edit_mode_ok() {
1968 let mgr = Manager::new(MockHelmBackend::new());
1969
1970 let deployer_config = DeployerConfig {
1971 deployer_type: "helm".to_string(),
1972 repository: Some("my-chart".to_string()),
1973 edit_mode: true,
1974 helm: Some(HelmConfig {
1975 namespace: "default".to_string(),
1976 ..Default::default()
1977 }),
1978 ..Default::default()
1979 };
1980
1981 mgr.validate(&deployer_config, &CaproniConfig::default())
1982 .unwrap();
1983 }
1984
1985 fn stub_pull_for(
1989 chart_ref: &'static str,
1990 dirs: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1991 ) -> MockHelmBackend {
1992 MockHelmBackend::new().expect_pull(move |got_chart_ref, untar_dir| {
1993 assert_eq!(got_chart_ref, chart_ref);
1994
1995 dirs.lock().unwrap().push(untar_dir.to_string());
1996
1997 std::fs::create_dir_all(format!("{untar_dir}/gitlab")).unwrap();
1998 std::fs::write(format!("{untar_dir}/gitlab/Chart.yaml"), "name: gitlab\n").unwrap();
1999
2000 Ok(())
2001 })
2002 }
2003
2004 #[test]
2006 fn test_prepare_chart_source_url_chart_is_fetched() {
2007 const TEST_CHART_URL: &str = "https://gitlab.com/g/p/-/archive/abc123/p-abc123.tar.gz";
2008
2009 let dirs = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2010 let backend = stub_pull_for(TEST_CHART_URL, dirs)
2011 .expect_dependency_list(|_| Ok(SATISFIED_LIST.to_string()));
2012
2013 let deployer_config = DeployerConfig {
2014 deployer_type: "helm".to_string(),
2015 helm: Some(HelmConfig {
2016 namespace: "default".to_string(),
2017 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
2018 name: "gitlab".to_string(),
2019 chart: TEST_CHART_URL.to_string(),
2020 ..Default::default()
2021 }),
2022 ..Default::default()
2023 }),
2024 ..Default::default()
2025 };
2026
2027 let mgr = Manager::new(backend);
2028 let (chart_path, cleanup) = mgr
2029 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
2030 .unwrap();
2031
2032 assert_ne!(
2033 chart_path, TEST_CHART_URL,
2034 "helm should be given the fetched chart, not the URL"
2035 );
2036 assert!(
2037 std::path::Path::new(&chart_path)
2038 .join("Chart.yaml")
2039 .is_file()
2040 );
2041
2042 cleanup();
2043 }
2044
2045 #[test]
2047 fn test_prepare_chart_source_url_chart_with_an_uppercase_scheme() {
2048 const CHART: &str = "HTTPS://gitlab.com/g/p/-/archive/abc123/p-abc123.tar.gz";
2049
2050 let dirs = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2051 let backend =
2052 stub_pull_for(CHART, dirs).expect_dependency_list(|_| Ok(SATISFIED_LIST.to_string()));
2053
2054 let deployer_config = DeployerConfig {
2055 deployer_type: "helm".to_string(),
2056 helm: Some(HelmConfig {
2057 namespace: "default".to_string(),
2058 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
2059 name: "gitlab".to_string(),
2060 chart: CHART.to_string(),
2061 ..Default::default()
2062 }),
2063 ..Default::default()
2064 }),
2065 ..Default::default()
2066 };
2067
2068 let mgr = Manager::new(backend);
2069 let (chart_path, cleanup) = mgr
2070 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
2071 .unwrap();
2072
2073 assert!(
2074 std::path::Path::new(&chart_path)
2075 .join("Chart.yaml")
2076 .is_file()
2077 );
2078
2079 cleanup();
2080 }
2081
2082 #[test]
2084 fn test_prepare_chart_source_configured_repository_wins_over_aurl_chart() {
2085 const TEST_CHART_URL: &str = "https://gitlab.com/g/p/-/archive/abc123/p-abc123.tar.gz";
2086
2087 let backend = MockHelmBackend::new()
2090 .expect_repo_list(|| Ok("[]".to_string()))
2091 .expect_repo_add(|name, url, force_update| {
2092 assert_eq!(name, "gitlab");
2093 assert_eq!(url, "https://charts.gitlab.io");
2094 assert!(!force_update);
2095 Ok(())
2096 })
2097 .expect_repo_update(|name| {
2098 assert_eq!(name, "gitlab");
2099 Ok(())
2100 });
2101
2102 let deployer_config = DeployerConfig {
2103 deployer_type: "helm".to_string(),
2104 helm: Some(HelmConfig {
2105 namespace: "default".to_string(),
2106 repository: Some(xianyun_core::config::latest::HelmRepositoryConfig {
2107 name: "gitlab".to_string(),
2108 url: "https://charts.gitlab.io".to_string(),
2109 }),
2110 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
2111 name: "gitlab".to_string(),
2112 chart: TEST_CHART_URL.to_string(),
2113 ..Default::default()
2114 }),
2115 ..Default::default()
2116 }),
2117 ..Default::default()
2118 };
2119
2120 let mgr = Manager::new(backend);
2121 let (chart_path, _cleanup) = mgr
2122 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
2123 .unwrap();
2124
2125 assert_eq!(chart_path, TEST_CHART_URL);
2126 }
2127
2128 #[test]
2130 fn test_prepare_chart_source_edit_mode_resolves_the_clones_dependencies() {
2131 const TEST_REPO_URL: &str = "https://charts.jetstack.io";
2132
2133 let clone_dir = tempfile::tempdir().unwrap();
2136 let clone = clone_dir.path().display().to_string();
2137 std::fs::write(clone_dir.path().join("Chart.yaml"), "name: gitlab\n").unwrap();
2138 std::fs::write(
2139 clone_dir.path().join("Chart.lock"),
2140 format!(
2141 "dependencies:\n- name: cert-manager\n repository: {TEST_REPO_URL}\n version: 1.16.2\n"
2142 ),
2143 )
2144 .unwrap();
2145
2146 let repo_name_for_chart = super::super::chartdeps::chart_repo_name(TEST_REPO_URL);
2147
2148 let clone_for_dep_list = clone.clone();
2149 let clone_for_dep_build = clone.clone();
2150
2151 let backend = MockHelmBackend::new()
2152 .expect_dependency_list(move |chart_dir| {
2153 assert_eq!(chart_dir, clone_for_dep_list);
2154 Ok(UNSATISFIED_LIST.to_string())
2155 })
2156 .expect_repo_list(|| Ok("[]".to_string()))
2157 .expect_repo_add(move |name, url, force_update| {
2158 assert_eq!(name, repo_name_for_chart);
2159 assert_eq!(url, TEST_REPO_URL);
2160 assert!(force_update);
2161 Ok(())
2162 })
2163 .expect_dependency_build(move |chart_dir, skip_refresh| {
2164 assert_eq!(chart_dir, clone_for_dep_build);
2165 assert!(skip_refresh);
2166 Ok(())
2167 });
2168
2169 let repo_name = "gitlab-chart".to_string();
2170 let deployer_config = DeployerConfig {
2171 deployer_type: "helm".to_string(),
2172 repository: Some(repo_name.clone()),
2173 edit_mode: true,
2174 helm: Some(HelmConfig {
2175 namespace: "default".to_string(),
2176 ..Default::default()
2177 }),
2178 ..Default::default()
2179 };
2180
2181 let mut repositories = OrderedMap::new();
2182 repositories.set(
2183 repo_name,
2184 xianyun_core::config::latest::RepositoryConfig {
2185 repository: "https://gitlab.com/gitlab-org/charts/gitlab.git".to_string(),
2186 directory: clone.clone(),
2187 ..Default::default()
2188 },
2189 );
2190 let caproni_config = CaproniConfig {
2191 repositories: Some(repositories),
2192 ..Default::default()
2193 };
2194
2195 let mgr = Manager::new(backend);
2196 let (chart_path, _cleanup) = mgr
2197 .prepare_chart_source(&deployer_config, &caproni_config)
2198 .unwrap();
2199
2200 assert_eq!(chart_path, clone);
2201 }
2202
2203 #[test]
2205 fn test_prepare_chart_source_local_chart_directory_is_resolved_in_place() {
2206 let chart_dir = tempfile::tempdir().unwrap();
2207 std::fs::write(chart_dir.path().join("Chart.yaml"), "name: my-app\n").unwrap();
2208 let chart_dir_path = chart_dir.path().display().to_string();
2209
2210 let chart_dir_for_dep_list = chart_dir_path.clone();
2211 let chart_dir_for_dep_build = chart_dir_path.clone();
2212
2213 let backend = MockHelmBackend::new()
2214 .expect_dependency_list(move |dir| {
2215 assert_eq!(dir, chart_dir_for_dep_list);
2216 Ok(UNSATISFIED_LIST.to_string())
2217 })
2218 .expect_repo_list(|| Ok("[]".to_string()))
2219 .expect_repo_add(|_, _, _| Ok(()))
2220 .expect_dependency_build(move |dir, skip_refresh| {
2221 assert_eq!(dir, chart_dir_for_dep_build);
2222 assert!(skip_refresh);
2223 Ok(())
2224 });
2225
2226 let deployer_config = DeployerConfig {
2227 deployer_type: "helm".to_string(),
2228 helm: Some(HelmConfig {
2229 namespace: "default".to_string(),
2230 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
2231 name: "my-app".to_string(),
2232 chart: chart_dir_path.clone(),
2233 ..Default::default()
2234 }),
2235 ..Default::default()
2236 }),
2237 ..Default::default()
2238 };
2239
2240 let mgr = Manager::new(backend);
2241 let (chart_path, _cleanup) = mgr
2242 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
2243 .unwrap();
2244
2245 assert_eq!(chart_path, chart_dir_path);
2246 }
2247
2248 #[test]
2250 fn test_prepare_chart_source_local_packaged_chart_is_passed_through() {
2251 let temp_dir = tempfile::tempdir().unwrap();
2252 let packaged = temp_dir.path().join("my-app-1.0.0.tgz");
2253 std::fs::write(&packaged, "packaged chart").unwrap();
2254 let packaged = packaged.display().to_string();
2255
2256 let backend = MockHelmBackend::new();
2259
2260 let deployer_config = DeployerConfig {
2261 deployer_type: "helm".to_string(),
2262 helm: Some(HelmConfig {
2263 namespace: "default".to_string(),
2264 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
2265 name: "my-app".to_string(),
2266 chart: packaged.clone(),
2267 ..Default::default()
2268 }),
2269 ..Default::default()
2270 }),
2271 ..Default::default()
2272 };
2273
2274 let mgr = Manager::new(backend);
2275 let (chart_path, _cleanup) = mgr
2276 .prepare_chart_source(&deployer_config, &CaproniConfig::default())
2277 .unwrap();
2278
2279 assert_eq!(chart_path, packaged);
2280 }
2281
2282 fn undeploy_config(release: &str) -> DeployerConfig {
2285 DeployerConfig {
2286 deployer_type: "helm".to_string(),
2287 helm: Some(HelmConfig {
2288 namespace: "apps".to_string(),
2289 release: Some(xianyun_core::config::latest::HelmReleaseConfig {
2290 name: release.to_string(),
2291 chart: "oci://registry.example.com/charts/my-app".to_string(),
2292 ..Default::default()
2293 }),
2294 ..Default::default()
2295 }),
2296 ..Default::default()
2297 }
2298 }
2299
2300 #[test]
2301 fn test_undeploy_uninstalls_a_deployed_release() {
2302 let uninstalled = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2303 let seen = uninstalled.clone();
2304 let backend = MockHelmBackend::new()
2305 .expect_list(|namespace, filter| {
2306 assert_eq!(namespace, "apps");
2307 assert_eq!(filter, "^my-app$");
2308 Ok("[{\"name\":\"my-app\"}]".to_string())
2309 })
2310 .expect_uninstall(move |release, namespace| {
2311 seen.lock()
2312 .unwrap()
2313 .push((release.to_string(), namespace.to_string()));
2314 Ok(())
2315 });
2316
2317 Manager::new(backend)
2318 .undeploy(&undeploy_config("my-app"), &CaproniConfig::default())
2319 .unwrap();
2320
2321 assert_eq!(
2322 *uninstalled.lock().unwrap(),
2323 vec![("my-app".to_string(), "apps".to_string())]
2324 );
2325 }
2326
2327 #[test]
2328 fn test_undeploy_is_a_no_op_when_the_release_is_not_deployed() {
2329 let backend = MockHelmBackend::new().expect_list(|_, _| Ok("[]".to_string()));
2331
2332 Manager::new(backend)
2333 .undeploy(&undeploy_config("my-app"), &CaproniConfig::default())
2334 .unwrap();
2335 }
2336
2337 #[test]
2338 fn test_undeploy_reports_an_uninstall_failure() {
2339 let backend = MockHelmBackend::new()
2340 .expect_list(|_, _| Ok("[{\"name\":\"my-app\"}]".to_string()))
2341 .expect_uninstall(|_, _| Err(xianyun_executor::exec::ExecError::CommandFailed(1)));
2342
2343 let err = Manager::new(backend)
2344 .undeploy(&undeploy_config("my-app"), &CaproniConfig::default())
2345 .unwrap_err();
2346
2347 assert!(
2348 err.to_string().contains("removing helm chart"),
2349 "error should say what failed: {err}"
2350 );
2351 }
2352
2353 #[test]
2354 fn test_undeploy_rejects_a_non_helm_deployer() {
2355 let err = Manager::new(MockHelmBackend::new())
2356 .undeploy(
2357 &DeployerConfig {
2358 deployer_type: "kustomize".to_string(),
2359 ..Default::default()
2360 },
2361 &CaproniConfig::default(),
2362 )
2363 .unwrap_err();
2364
2365 assert!(matches!(err, super::super::HelmError::NotHelmDeployer));
2366 }
2367}