Skip to main content

xianyun_deploy/deployer/helm/
manager.rs

1//! Helm chart deployer manager.
2//!
3//! Ported from `internal/deployer/helm/manager.go`. The `deployer::Manager`
4//! trait impl at the bottom is what lets the orchestrator drive it through
5//! the registry.
6
7use 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
16/// A path together with a cleanup function the caller MUST call
17/// once done, e.g. removing a temporary directory or file.
18type PathWithCleanup = (String, Box<dyn FnOnce()>);
19
20/// The `--values` CLI arguments together with a cleanup function
21/// the caller MUST call once done, e.g. removing a temporary
22/// values file.
23type ValuesArgsWithCleanup = (Vec<String>, Box<dyn FnOnce()>);
24
25/// Ported from `Manager` in `manager.go`.
26pub struct Manager<B: HelmBackend> {
27    pub(super) backend: B,
28}
29
30impl<B: HelmBackend> Manager<B> {
31    /// Ported from `NewManager`. The Go original panics if `backend` is
32    /// nil; a Rust `B: HelmBackend` is never null, so that check has no
33    /// equivalent here.
34    pub fn new(backend: B) -> Self {
35        Self { backend }
36    }
37
38    /// Ported from `Type`.
39    #[must_use]
40    pub fn deployer_type(&self) -> &'static str {
41        "helm"
42    }
43}
44
45/// Ported from `helmRepoEntry`: one repository in the output of
46/// `helm repo list -o json`.
47#[derive(Debug, Clone, Deserialize)]
48pub(super) struct HelmRepoEntry {
49    pub name: String,
50    pub url: String,
51}
52
53impl<B: HelmBackend> Manager<B> {
54    /// Ported from `getExistingHelmRepos`. `helm repo list` failing (most
55    /// commonly because no repositories are configured yet) is treated as
56    /// an empty list rather than propagated, matching the Go original's
57    /// `//nolint:nilerr` comment. A response that fails to parse as JSON
58    /// *is* propagated -- the caller (`registerLockedRepositories`) is the
59    /// one that logs and swallows it.
60    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    /// Ported from `findHelmRepoByURL`. helm matches by URL and ignores a
74    /// trailing slash (`helm pkg/downloader/manager.go`, `hasAllRepos`).
75    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    /// Ported from `buildDynamicOverrides`. Merges every configured dynamic
86    /// override into a single tree of Helm values. Later overrides win over
87    /// earlier ones. Returns `None` when no override contributes a value.
88    ///
89    /// `ctx` (Go's `context.Context`, used there only to scope a
90    /// `slog.DebugContext` log line) has no parameter here; see
91    /// `PORT_NOTES.md`'s note on this crate's explicit-dependency-injection
92    /// convention for context-carried values that this session's ported
93    /// submodules do not otherwise need.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if an override names an unknown or not-yet-supported
98    /// type, or if resolving one fails (an unreadable file, an invalid Helm
99    /// key path, or a conflicting key path).
100    ///
101    /// # Panics
102    ///
103    /// Panics if `deployer_config.helm` is `None`; callers are expected to
104    /// have already checked `deployer_config.deployer_type == "helm"`
105    /// (ported from `Manager::Validate`/`Deploy`'s own type check, not yet
106    /// wired up here -- see `PORT_NOTES.md`).
107    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
143/// A cleanup callback the caller MUST run once it is done with the
144/// value it came with, e.g. removing a temporary directory or file.
145/// [`DeferGuard`] runs it automatically when it goes out of scope,
146/// mirroring Go's `defer cleanup()`.
147struct 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    /// Ported from `Deploy`. Deploys the Helm chart, upgrading it if
159    /// already deployed.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if `deployer_config` is not a helm deployer,
164    /// preparing the chart source fails, building the values arguments
165    /// fails, or the underlying `helm install`/`helm upgrade` call
166    /// fails.
167    ///
168    /// # Panics
169    ///
170    /// Panics if `deployer_config.helm` is `None`; callers are
171    /// expected to have already checked `deployer_config.deployer_type
172    /// == "helm"` (this method's own first check).
173    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    /// Ported from `Status`. `helm list` failing (missing namespace,
223    /// helm unavailable, ...) is treated as "not deployed" for
224    /// idempotency, matching the Go original's `//nolint:nilerr`
225    /// comment -- this method never actually returns `Err`, but keeps
226    /// a `Result` return type for parity with the rest of this
227    /// module's fallible methods.
228    ///
229    /// # Errors
230    ///
231    /// Never returns an error; the signature is `Result` for parity
232    /// with the rest of this module.
233    ///
234    /// # Panics
235    ///
236    /// Panics if `deployer_config.helm` is `None`.
237    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    /// Ported from `Undeploy`. Uninstalls the release if it's deployed,
262    /// and does nothing if it isn't, so removing twice is safe.
263    ///
264    /// # Errors
265    ///
266    /// Returns [`HelmError::NotHelmDeployer`] if `deployer_config` is not
267    /// a helm deployer or has no `helm` block, and a
268    /// [`HelmError::Backend`] if `helm uninstall` fails.
269    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    /// Ported from `buildValuesArgs`. Assembles the `--values` flags for
301    /// `helm install`/`helm upgrade`: the configured values file first,
302    /// then a temporary file holding the resolved dynamic overrides, so
303    /// overrides win. Returns a cleanup function the caller MUST call.
304    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    /// Ported from `buildHelmArgs`. Assembles the common CLI arguments
338    /// for `helm install`/`helm upgrade`.
339    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        // Schema validation ensures Version is only set for repo-style
363        // charts (requires helm.repository).
364        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    /// Ported from `getReleaseName`. Uses the configured release name if
380    /// present, else falls back to the namespace.
381    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    /// Ported from `prepareChartSource`. Determines the chart reference
397    /// to pass to `helm install`/`helm upgrade`.
398    ///
399    /// Four chart source modes:
400    /// 1. Edit mode with repository — uses the local cloned directory
401    ///    path.
402    /// 2. Repository-based chart — runs `helm repo add`/`update`,
403    ///    returns a `repo/chart` reference.
404    /// 3. Direct chart reference (OCI, local path, etc.) — returns the
405    ///    chart string as-is.
406    /// 4. Absolute http(s) URL — fetches the chart and returns a local
407    ///    packaged copy.
408    ///
409    /// Every mode that ends at a chart directory has its dependencies
410    /// resolved first, since a chart source tree vendors only its
411    /// in-tree subcharts. Modes ending at a repository, a registry, or
412    /// an archive are left alone.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if `deployer_config` has no `helm` block, the
417    /// repository named by edit mode is not found, `helm.release` is
418    /// missing when required, adding/updating the configured helm
419    /// repository fails, fetching a URL chart fails, or resolving a
420    /// chart's dependencies fails.
421    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        // Mode 1: edit mode with repository reference → local directory
433        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        // Modes 2 and 3 require helm.release
453        let Some(release) = helm_config.release.as_ref() else {
454            return Err(HelmError::MissingRelease);
455        };
456
457        // Mode 2: helm repository configured → ensure repo is added,
458        // return chart reference
459        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        // Mode 4: absolute http(s) URL → fetch it, resolve it, install
465        // the copy
466        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        // Mode 3: direct chart reference (OCI URI, local path, etc.) →
472        // pass through
473        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    /// Ported from `ensureHelmRepository`. Idempotent:
481    /// - if the repository exists with the correct URL, it is only
482    ///   refreshed,
483    /// - if it exists with the wrong URL, it is updated then
484    ///   refreshed,
485    /// - if it doesn't exist, it is added then refreshed.
486    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    /// Ported from `addOrUpdateHelmRepo`.
503    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    /// Ported from `updateHelmRepo`.
518    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    /// Ported from `Validate`. Validates the helm deployer
528    /// configuration and referenced files.
529    ///
530    /// # Errors
531    ///
532    /// Returns an error if `deployer_config` is not a helm deployer,
533    /// `helm.release` is missing when required, `release.version` is
534    /// set without `helm.repository`, a dynamic override is invalid,
535    /// or a referenced file doesn't exist.
536    ///
537    /// # Panics
538    ///
539    /// Never panics: the `deployer_config.helm.is_none()` case is
540    /// checked and returned as an error before the `.expect()` below
541    /// runs.
542    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
576/// Ported from `validateDynamicOverrides`.
577fn 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                // No validation needed: missing/empty value_map is a
593                // noop, and unset environment variable references are
594                // skipped at deploy time. release_manifest has no
595                // validation of its own yet either (ported as a TODO
596                // in the Go original).
597            }
598            other => {
599                return Err(HelmError::UnknownDynamicOverrideType(other.to_string()));
600            }
601        }
602    }
603
604    Ok(())
605}
606
607/// Ported from `validateGitLabRepoVersionFiles`.
608fn 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
635/// Ported from `validateHelmPaths`.
636fn 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
656/// Ported from `writeValuesFile`. Marshals `values` into a temporary
657/// values file for helm, and returns its path with a cleanup function
658/// the caller MUST call, including on the error paths (the tempfile
659/// crate's `TempPath` handles this: it removes the file on drop
660/// regardless of how the closure below is invoked).
661///
662/// The file is created with `0600` permissions (owner read/write
663/// only), which the contents require: `inline_file` overrides may
664/// carry secret file contents.
665fn 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
686/// Lets the orchestrator drive a helm [`Manager`] through the registry.
687impl<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
730/// `deploy` and `status` index into the `helm` block and would panic
731/// without one, so the trait entry points check for it first.
732fn 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    /// Ported from `newTestConfig`: a minimal [`Container`] suitable for
761    /// use in tests. Provides a valid `Cluster.Network` so the
762    /// variables resolver can compute `CAPRONI_PRIMARY_HOSTNAME`.
763    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    // Ported from internal/deployer/helm/manager_test.go:39 (TestType)
805    #[test]
806    fn test_type() {
807        let mgr = Manager::new(MockHelmBackend::new());
808        assert_eq!(mgr.deployer_type(), "helm");
809    }
810
811    // Ported from internal/deployer/helm/manager_test.go:50 (TestBuildDynamicOverrides_GitLabRepoVersionFiles)
812    #[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    // Ported from internal/deployer/helm/manager_test.go:89 (TestBuildDynamicOverrides_GitLabRepoVersionFiles_MissingRepository)
840    #[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    // Ported from internal/deployer/helm/manager_test.go:117 (TestBuildDynamicOverrides_Inline)
860    #[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        // Values are passed through unchanged; ${VAR} resolution happens at
875        // config load time, not at override-build time.
876        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    // Ported from internal/deployer/helm/manager_test.go:159 (TestBuildDynamicOverrides_InlineTypedValues)
889    #[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    // Ported from internal/deployer/helm/manager_test.go:205 (TestBuildDynamicOverrides_InlineListsAndMaps)
921    #[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    // Ported from internal/deployer/helm/manager_test.go:246 (TestBuildDynamicOverrides_InlineRejectsNonScalarLeaves)
948    //
949    // **Divergence, not ported (see overrider.rs's module doc comment):**
950    // this test exercises a decoded YAML `time.Time` value, which has no
951    // equivalent in this crate's `serde_json::Value`-based
952    // `DynamicOverrideConfig::value_map` -- every `Value` variant is one
953    // `assertOverrideValue`'s Rust port already allows, so the error path
954    // this test targets is unreachable here.
955    #[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    // Ported from internal/deployer/helm/manager_test.go:296 (TestBuildDynamicOverrides_InlineUnsupportedType)
964    //
965    // **Divergence, not ported:** same reasoning as
966    // `test_build_dynamic_overrides_inline_rejects_non_scalar_leaves` above.
967    #[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    // Ported from internal/deployer/helm/manager_test.go:326 (TestBuildDynamicOverrides_InlineFile)
976    #[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    // Ported from internal/deployer/helm/manager_test.go:365 (TestBuildDynamicOverrides_InlineFileMissing)
1008    #[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    // Ported from internal/deployer/helm/manager_test.go:394 (TestBuildDynamicOverrides_MergesOverridesInOrder)
1030    #[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    // Ported from internal/deployer/helm/manager_test.go:440 (TestBuildDynamicOverrides_LaterOverrideReplacesShape)
1064    #[test]
1065    fn test_build_dynamic_overrides_later_override_replaces_shape() {
1066        let mgr = Manager::new(MockHelmBackend::new());
1067
1068        // subtree replaces scalar
1069        {
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        // scalar replaces subtree
1083        {
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    // Ported from internal/deployer/helm/manager_test.go:496 (TestBuildDynamicOverrides_NullDropsEarlierValue)
1098    #[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    // Ported from internal/deployer/helm/manager_test.go:536 (TestBuildDynamicOverrides_NestedNullDropsKey)
1128    #[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    // Ported from internal/deployer/helm/manager_test.go:580 (TestBuildDynamicOverrides_RejectsNullInList)
1158    #[test]
1159    fn test_build_dynamic_overrides_rejects_null_in_list() {
1160        let mgr = Manager::new(MockHelmBackend::new());
1161
1162        // null list item
1163        {
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        // null nested in a map inside a list
1179        {
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    // Ported from internal/deployer/helm/manager_test.go:622 (TestBuildDynamicOverrides_AllValuesNulled)
1196    #[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    // Ported from internal/deployer/helm/manager_test.go:648 (TestBuildDynamicOverrides_LaterOverrideWinsAcrossTypes)
1213    #[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    // Ported from internal/deployer/helm/manager_test.go:675 (TestBuildDynamicOverrides_MapValuesDeepMerge)
1230    #[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            // The dotted form reaches into the same map.
1238            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    // Ported from internal/deployer/helm/manager_test.go:718 (TestBuildDynamicOverrides_IndexedKeys)
1254    #[test]
1255    fn test_build_dynamic_overrides_indexed_keys() {
1256        let mgr = Manager::new(MockHelmBackend::new());
1257
1258        // index builds a list
1259        {
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        // index followed by a key builds a list of maps
1274        {
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        // sibling indices grow one list
1289        {
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        // a gap is padded with null, as helm does
1304        {
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    // Ported from internal/deployer/helm/manager_test.go:787 (TestBuildDynamicOverrides_IndexIntoScalar)
1320    #[test]
1321    fn test_build_dynamic_overrides_index_into_scalar() {
1322        let mgr = Manager::new(MockHelmBackend::new());
1323
1324        // FromMap sorts keys, so "ingress.hosts" is set first.
1325        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    // Ported from internal/deployer/helm/manager_test.go:816 (TestBuildDynamicOverrides_ConflictingKeyPath)
1338    #[test]
1339    fn test_build_dynamic_overrides_conflicting_key_path() {
1340        let mgr = Manager::new(MockHelmBackend::new());
1341
1342        // FromMap sorts keys, so "global.tag" is set before it is traversed.
1343        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    // Ported from internal/deployer/helm/manager_test.go:845 (TestBuildDynamicOverrides_UnknownType)
1356    #[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    // Ported from internal/deployer/helm/manager_test.go:874 (TestValidate_NoDynamicOverrides)
1377    #[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    // Ported from internal/deployer/helm/manager_test.go:900 (TestValidate_DynamicOverrides_MissingRepository)
1400    #[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    // Ported from internal/deployer/helm/manager_test.go:931 (TestValidate_DynamicOverrides_MissingVersionMap)
1430    #[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    // Ported from internal/deployer/helm/manager_test.go:963 (TestValidate_InvalidDeployerType)
1461    #[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    // Ported from internal/deployer/helm/manager_test.go:980 (TestPrepareChartSource_OCIChart)
1478    #[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    // Ported from internal/deployer/helm/manager_test.go:1004 (TestPrepareChartSource_OCIChartWithTag)
1504    #[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    // Ported from internal/deployer/helm/manager_test.go:1029 (TestPrepareChartSource_RepoChart)
1531    #[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    // Ported from internal/deployer/helm/manager_test.go:1061 (TestPrepareChartSource_MissingRelease)
1573    #[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    // Ported from internal/deployer/helm/manager_test.go:1080 (TestValidate_MissingRelease)
1595    #[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    // Ported from internal/deployer/helm/manager_test.go:1102 (TestDeploy_HelmCLIFlags)
1616    #[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    // Ported from internal/deployer/helm/manager_test.go:1181 (TestDeploy_VersionPinning)
1717    #[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        // install path
1752        {
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        // upgrade path
1785        {
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    /// Ported from `lastValuesArg`: returns the path given to the final
1814    /// `--values` flag in `args`.
1815    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    // Ported from internal/deployer/helm/manager_test.go:1262 (TestDeploy_DynamicOverridesValuesFile)
1826    #[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    // Ported from internal/deployer/helm/manager_test.go:1343 (TestWriteValuesFile_IsNotWorldReadable)
1924    #[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    // Ported from internal/deployer/helm/manager_test.go:1358 (TestValidate_VersionWithoutRepository)
1940    #[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    // Ported from internal/deployer/helm/manager_test.go:1380 (TestValidate_MissingRelease_EditModeOK)
1966    #[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    /// Stands in for helm, writing the chart directory plus the empty
1986    /// one helm leaves beside it, and recording where it unpacked.
1987    /// Ported from `stubPull`/`stubPullFor`.
1988    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    // Ported from internal/deployer/helm/manager_test.go:1402 (TestPrepareChartSource_URLChartIsFetched)
2005    #[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    // Ported from internal/deployer/helm/manager_test.go:1428 (TestPrepareChartSource_URLChartWithAnUppercaseScheme)
2046    #[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    // Ported from internal/deployer/helm/manager_test.go:1455 (TestPrepareChartSource_ConfiguredRepositoryWinsOverAURLChart)
2083    #[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        // helm.repository means the chart is a repo/chart reference,
2088        // not a fetch.
2089        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    // Ported from internal/deployer/helm/manager_test.go:1483 (TestPrepareChartSource_EditModeResolvesTheClonesDependencies)
2129    #[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        // A source clone carries its in-tree subcharts and nothing
2134        // else.
2135        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    // Ported from internal/deployer/helm/manager_test.go:1524 (TestPrepareChartSource_LocalChartDirectoryIsResolvedInPlace)
2204    #[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    // Ported from internal/deployer/helm/manager_test.go:1549 (TestPrepareChartSource_LocalPackagedChartIsPassedThrough)
2249    #[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        // helm cannot resolve dependencies into an archive, so nothing
2257        // to probe.
2258        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    // Ported from internal/deployer/helm/manager.go `Undeploy`, which the
2283    // Go suite covered only indirectly.
2284    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        // No expect_uninstall: the mock panics if uninstall is called.
2330        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}