Skip to main content

xianyun_deploy/deployer/helm/
chartdeps.rs

1//! Ported from `internal/deployer/helm/chartdeps.go`.
2
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5
6use serde::Deserialize;
7use sha2::{Digest, Sha256};
8
9use super::HelmError;
10
11/// Ported from `chartMetadataFile`: the file that marks a directory as a
12/// chart.
13pub(super) const CHART_METADATA_FILE: &str = "Chart.yaml";
14
15/// Ported from `lockFile`: where a chart pins its resolved dependencies.
16pub(super) const LOCK_FILE: &str = "Chart.lock";
17
18/// Ported from `satisfiedStatuses`: the `helm dependency list` `STATUS`
19/// values that need no fetching.
20const SATISFIED_STATUSES: &[&str] = &["ok", "unpacked"];
21
22static REPO_NAME_UNSAFE: LazyLock<regex::Regex> =
23    LazyLock::new(|| regex::Regex::new(r"[^a-z0-9]+").unwrap());
24
25/// Ported from `chartLock`: the part of `Chart.lock` this needs.
26#[derive(Debug, Default, Deserialize)]
27struct ChartLockDependency {
28    #[serde(default)]
29    repository: String,
30}
31
32#[derive(Debug, Default, Deserialize)]
33struct ChartLock {
34    #[serde(default)]
35    dependencies: Vec<ChartLockDependency>,
36}
37
38/// Ported from `dependenciesSatisfied`.
39#[must_use]
40pub(super) fn dependencies_satisfied(list_output: &str) -> bool {
41    for line in list_output.lines() {
42        let row = line.trim();
43
44        if row.is_empty() {
45            continue;
46        }
47
48        if row.to_uppercase().starts_with("WARNING:") {
49            continue;
50        }
51
52        if row.starts_with("NAME") {
53            continue;
54        }
55
56        let satisfied = SATISFIED_STATUSES
57            .iter()
58            .any(|status| row.ends_with(status));
59
60        if !satisfied {
61            return false;
62        }
63    }
64
65    true
66}
67
68/// Ported from `lockedRepositories`.
69pub(super) fn locked_repositories(chart_dir: &Path) -> Result<Vec<String>, HelmError> {
70    let path = chart_dir.join(LOCK_FILE);
71
72    let data = match std::fs::read(&path) {
73        Ok(data) => data,
74        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
75        Err(e) => return Err(HelmError::Io(format!("reading {LOCK_FILE}: {e}"))),
76    };
77
78    let lock: ChartLock = serde_yaml::from_slice(&data).map_err(|e| {
79        HelmError::Io(format!(
80            "parsing chart lock in {}: {e}",
81            chart_dir.display()
82        ))
83    })?;
84
85    let mut seen = std::collections::HashSet::new();
86    let mut repos = Vec::new();
87
88    for dep in lock.dependencies {
89        let repo = dep.repository.trim();
90
91        if repo.is_empty() || repo.starts_with("file://") || repo.starts_with("oci://") {
92            continue;
93        }
94
95        if !seen.insert(repo.to_string()) {
96            continue;
97        }
98
99        repos.push(repo.to_string());
100    }
101
102    Ok(repos)
103}
104
105/// Ported from `isChartDirectory`. A relative reference is resolved against
106/// caproni's current working directory, as helm resolves it too.
107#[must_use]
108pub(super) fn is_chart_directory(chart: &str) -> bool {
109    if chart.is_empty() {
110        return false;
111    }
112
113    std::fs::metadata(chart).is_ok_and(|info| info.is_dir())
114}
115
116/// Ported from `locateUnpackedChart`.
117pub(super) fn locate_unpacked_chart(dir: &Path) -> Result<PathBuf, HelmError> {
118    let mut matches = Vec::new();
119
120    if let Ok(entries) = std::fs::read_dir(dir) {
121        for entry in entries.flatten() {
122            let candidate = entry.path();
123            if candidate.is_dir() && candidate.join(CHART_METADATA_FILE).is_file() {
124                matches.push(candidate);
125            }
126        }
127    }
128
129    match matches.len() {
130        0 => Err(HelmError::ChartNotUnpacked(dir.display().to_string())),
131        1 => Ok(matches.remove(0)),
132        _ => Err(HelmError::AmbiguousUnpackedChart(dir.display().to_string())),
133    }
134}
135
136/// Ported from `chartRepoName`.
137#[must_use]
138pub(super) fn chart_repo_name(repo_url: &str) -> String {
139    let trimmed = repo_url.trim().trim_end_matches('/');
140
141    let mut hasher = Sha256::new();
142    hasher.update(trimmed.as_bytes());
143    let digest = hex::encode(hasher.finalize())[..8].to_string();
144
145    let host = url::Url::parse(trimmed)
146        .ok()
147        .and_then(|u| u.host_str().map(str::to_string))
148        .filter(|h| !h.is_empty())
149        .unwrap_or_else(|| trimmed.to_string());
150
151    let slug = REPO_NAME_UNSAFE
152        .replace_all(&host.to_lowercase(), "-")
153        .trim_matches('-')
154        .to_string();
155
156    format!("caproni-{slug}-{digest}")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    // Ported from internal/deployer/helm/chartdeps_test.go
164    #[test]
165    // Ported from internal/deployer/helm/chartdeps_test.go:14 (TestDependenciesSatisfied)
166    fn test_dependencies_satisfied() {
167        struct Case {
168            name: &'static str,
169            output: &'static str,
170            want: bool,
171        }
172
173        let cases = [
174            Case {
175                name: "every dependency is ok",
176                output: "NAME         \tVERSION\tREPOSITORY                    \tSTATUS\ncert-manager \t1.16.2 \thttps://charts.jetstack.io    \tok\nprometheus   \t25.8.0 \thttps://prometheus-community.github.io/helm-charts\tok\n",
177                want: true,
178            },
179            Case {
180                name: "every dependency is unpacked",
181                output: "NAME     \tVERSION\tREPOSITORY               \tSTATUS\nregistry \t1.0.0  \t                         \tunpacked\ngitlab   \t10.3.1 \t                         \tunpacked\n",
182                want: true,
183            },
184            Case {
185                name: "one dependency is missing",
186                output: "NAME         \tVERSION\tREPOSITORY                \tSTATUS\nregistry     \t1.0.0  \t                          \tunpacked\ncert-manager \t1.16.2 \thttps://charts.jetstack.io\tmissing\n",
187                want: false,
188            },
189            Case {
190                name: "a dependency is at the wrong version",
191                output: "NAME         \tVERSION\tREPOSITORY                \tSTATUS\ncert-manager \t1.16.2 \thttps://charts.jetstack.io\twrong version\n",
192                want: false,
193            },
194            Case {
195                name: "a status containing a space is not mistaken for ok",
196                output: "NAME         \tVERSION\tREPOSITORY                \tSTATUS\ncert-manager \t1.16.2 \thttps://charts.jetstack.io\ttoo many matches\n",
197                want: false,
198            },
199            Case {
200                name: "a truncated repository column does not hide the status",
201                output: "NAME         \tVERSION\tREPOSITORY          \tSTATUS\ncert-manager \t1.16.2 \thttps://charts.jets...\tok\n",
202                want: true,
203            },
204            Case {
205                name: "a chart with no dependencies is satisfied",
206                output: "WARNING: no dependencies at /tmp/chart/charts\n",
207                want: true,
208            },
209            Case {
210                name: "warnings about unexpected charts are ignored",
211                output: "NAME         \tVERSION\tREPOSITORY                \tSTATUS\ncert-manager \t1.16.2 \thttps://charts.jetstack.io\tok\n\nWARNING: \"/tmp/chart/charts/stray\" is not in Chart.yaml.\nWarning: stat /tmp/chart/charts/x: no such file or directory\n",
212                want: true,
213            },
214            Case {
215                name: "a header with no rows is satisfied",
216                output: "NAME\tVERSION\tREPOSITORY\tSTATUS\n",
217                want: true,
218            },
219            Case {
220                name: "an unreadable row is treated as unsatisfied",
221                output: "NAME\tVERSION\tREPOSITORY\tSTATUS\nsomething unexpected\n",
222                want: false,
223            },
224        ];
225
226        for case in cases {
227            assert_eq!(
228                dependencies_satisfied(case.output),
229                case.want,
230                "{}",
231                case.name
232            );
233        }
234    }
235
236    #[test]
237    // Ported from internal/deployer/helm/chartdeps_test.go:103 (TestLockedRepositories)
238    fn test_locked_repositories() {
239        struct Case {
240            name: &'static str,
241            file: bool,
242            lock: &'static str,
243            want: Vec<&'static str>,
244            want_err: bool,
245        }
246
247        let cases = [
248            Case {
249                name: "the http repositories a Chart.lock names",
250                file: true,
251                lock: "dependencies:\n- name: cert-manager\n  repository: https://charts.jetstack.io\n  version: 1.16.2\n- name: prometheus\n  repository: https://prometheus-community.github.io/helm-charts\n  version: 25.8.0\ndigest: sha256:abc\n",
252                want: vec![
253                    "https://charts.jetstack.io",
254                    "https://prometheus-community.github.io/helm-charts",
255                ],
256                want_err: false,
257            },
258            Case {
259                name: "only the repositories helm needs registered, once each",
260                file: true,
261                lock: "dependencies:\n- name: in-tree\n  repository: \"\"\n  version: 1.0.0\n- name: vendored\n  repository: file://./charts/vendored\n  version: 1.0.0\n- name: from-registry\n  repository: oci://registry.example.com/charts\n  version: 1.0.0\n- name: cert-manager\n  repository: https://charts.jetstack.io\n  version: 1.16.2\n- name: cert-manager-again\n  repository: https://charts.jetstack.io\n  version: 1.16.2\n",
262                want: vec!["https://charts.jetstack.io"],
263                want_err: false,
264            },
265            Case {
266                name: "a chart without a lock has nothing to register",
267                file: false,
268                lock: "",
269                want: vec![],
270                want_err: false,
271            },
272            Case {
273                name: "a lock that cannot be read",
274                file: true,
275                lock: "dependencies: [oh dear\n",
276                want: vec![],
277                want_err: true,
278            },
279        ];
280
281        for case in cases {
282            let dir = tempfile::tempdir().unwrap();
283            if case.file {
284                std::fs::write(dir.path().join("Chart.lock"), case.lock).unwrap();
285            }
286
287            let result = locked_repositories(dir.path());
288
289            if case.want_err {
290                assert!(result.is_err(), "{}", case.name);
291                continue;
292            }
293
294            let repos = result.unwrap_or_else(|e| panic!("{}: {e}", case.name));
295            assert_eq!(repos, case.want, "{}", case.name);
296        }
297    }
298
299    #[test]
300    // Ported from internal/deployer/helm/chartdeps_test.go:186 (TestIsChartDirectory)
301    fn test_is_chart_directory() {
302        let dir = tempfile::tempdir().unwrap();
303        let file = dir.path().join("my-app-1.0.0.tgz");
304        std::fs::write(&file, "x").unwrap();
305
306        assert!(is_chart_directory(dir.path().to_str().unwrap()));
307        assert!(
308            !is_chart_directory(file.to_str().unwrap()),
309            "a packaged chart is not a directory"
310        );
311        assert!(
312            !is_chart_directory(dir.path().join("missing").to_str().unwrap()),
313            "a path that does not resolve"
314        );
315        assert!(
316            !is_chart_directory("gitlab/gitlab"),
317            "a repository reference"
318        );
319        assert!(!is_chart_directory(""), "an empty reference");
320    }
321
322    #[test]
323    // Ported from internal/deployer/helm/chartdeps_test.go:200 (TestLocateUnpackedChart)
324    fn test_locate_unpacked_chart() {
325        // finds the chart helm unpacked beside its stray directory
326        {
327            let dir = tempfile::tempdir().unwrap();
328            std::fs::create_dir_all(dir.path().join("gitlab-abc123.tar.gz")).unwrap();
329            std::fs::create_dir_all(dir.path().join("gitlab")).unwrap();
330            std::fs::write(
331                dir.path().join("gitlab").join("Chart.yaml"),
332                "name: gitlab\n",
333            )
334            .unwrap();
335
336            let root = locate_unpacked_chart(dir.path()).unwrap();
337
338            assert_eq!(root, dir.path().join("gitlab"));
339        }
340
341        // reports a directory holding no chart
342        {
343            let dir = tempfile::tempdir().unwrap();
344            let err = locate_unpacked_chart(dir.path()).unwrap_err();
345            assert!(matches!(err, HelmError::ChartNotUnpacked(_)));
346        }
347
348        // reports a directory holding more than one chart
349        {
350            let dir = tempfile::tempdir().unwrap();
351            for name in ["one", "two"] {
352                std::fs::create_dir_all(dir.path().join(name)).unwrap();
353                std::fs::write(
354                    dir.path().join(name).join("Chart.yaml"),
355                    format!("name: {name}\n"),
356                )
357                .unwrap();
358            }
359
360            let err = locate_unpacked_chart(dir.path()).unwrap_err();
361            assert!(matches!(err, HelmError::AmbiguousUnpackedChart(_)));
362        }
363    }
364
365    #[test]
366    // Ported from internal/deployer/helm/chartdeps_test.go:242 (TestChartRepoName)
367    fn test_chart_repo_name() {
368        let name = chart_repo_name("https://charts.jetstack.io");
369
370        assert!(
371            name.starts_with("caproni-"),
372            "should say what created it: {name}"
373        );
374        assert!(
375            name.contains("charts-jetstack-io"),
376            "should say which repository it is"
377        );
378
379        let odd = chart_repo_name("https://user:pw@example.com:8443/a/b?x=1");
380        assert!(
381            odd.chars()
382                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
383            "should hold only characters a helm repository name can: {odd}"
384        );
385
386        // Stable, so helm reuses the index it cached last time, and unaffected
387        // by a trailing slash, as helm's own matching is.
388        assert_eq!(name, chart_repo_name("https://charts.jetstack.io/"));
389
390        // A collision would make RepoAdd clobber the entry the other URL needs.
391        assert_ne!(
392            chart_repo_name("https://gitlab.com/api/v4/projects/1/packages/helm/stable"),
393            chart_repo_name("https://gitlab.com/api/v4/projects/2/packages/helm/stable"),
394        );
395    }
396
397    // --- Ported from internal/deployer/helm/chartdeps_integration_test.go ---
398    //
399    // A real `helm` binary is available in this environment, so these run
400    // against it for real rather than staying `unimplemented!()` stubs.
401    // There is no live network access, so "a repository" here means a
402    // `StaticFileServer` (`httpfixture.rs`) serving a `helm repo index`'d
403    // directory on `127.0.0.1` -- exactly what `helm repo add`/`helm
404    // dependency build` need, without reaching the internet. Go
405    // build-tags this whole file `_integration_test.go` and marks every
406    // test `//nolint:paralleltest // sets HELM_* in the environment`;
407    // `HelmEnvGuard` is this port's equivalent serialization.
408
409    use std::sync::Arc;
410
411    use super::super::httpfixture::{
412        HelmEnvGuard, StaticFileServer, index_repo, package_chart, tar_chart,
413    };
414    use super::super::manager::Manager;
415    use xianyun_backends::helm::Client as HelmClient;
416    use xianyun_core::config::latest::{DeployerConfig, HelmConfig, HelmReleaseConfig};
417
418    fn real_manager() -> Manager<HelmClient> {
419        Manager::new(HelmClient::new(
420            "",
421            Arc::new(xianyun_executor::exec::Client::new()),
422        ))
423    }
424
425    /// Packages, serves, and indexes a one-chart repository holding
426    /// `dep-chart` 0.1.0. Returns the server (keep it alive for the
427    /// duration of the test) and its base URL.
428    fn serve_dep_chart_repo(workdir: &Path) -> (StaticFileServer, String) {
429        let dep_chart_dir = workdir.join("dep-chart");
430        std::fs::create_dir_all(dep_chart_dir.join("templates")).unwrap();
431        std::fs::write(
432            dep_chart_dir.join("Chart.yaml"),
433            "apiVersion: v2\nname: dep-chart\nversion: 0.1.0\n",
434        )
435        .unwrap();
436        std::fs::write(
437            dep_chart_dir.join("templates").join("configmap.yaml"),
438            "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: dep-chart-cm\n",
439        )
440        .unwrap();
441
442        let repo_dir = workdir.join("repo");
443        std::fs::create_dir_all(&repo_dir).unwrap();
444        package_chart(&dep_chart_dir, &repo_dir);
445
446        // The server needs to be listening before `helm repo index` writes
447        // its URL into index.yaml, since the URL is baked in up front.
448        let server = StaticFileServer::start(repo_dir.clone());
449        let url = server.url();
450        index_repo(&repo_dir, &url);
451
452        (server, url)
453    }
454
455    /// Writes `parent-chart`, declaring `dep-chart` as a dependency served
456    /// at `dep_repo_url`, and runs a real `helm dependency update` so a
457    /// genuine `Chart.lock` (and, unless `strip_charts` is set, a populated
458    /// `charts/` directory) exists before the test's own call to
459    /// `resolve_chart_dependencies`/`prepare_chart_source`.
460    fn write_parent_chart(workdir: &Path, dep_repo_url: &str, strip_charts: bool) -> PathBuf {
461        let chart_dir = workdir.join("parent-chart");
462        std::fs::create_dir_all(chart_dir.join("templates")).unwrap();
463        std::fs::write(
464            chart_dir.join("Chart.yaml"),
465            format!(
466                "apiVersion: v2\nname: parent-chart\nversion: 0.1.0\ndependencies:\n- name: dep-chart\n  version: 0.1.0\n  repository: {dep_repo_url}\n"
467            ),
468        )
469        .unwrap();
470
471        let output = std::process::Command::new("helm")
472            .arg("dependency")
473            .arg("update")
474            .arg(&chart_dir)
475            .output()
476            .expect("helm binary must be on PATH");
477        assert!(
478            output.status.success(),
479            "helm dependency update failed: {}",
480            String::from_utf8_lossy(&output.stderr)
481        );
482
483        if strip_charts {
484            std::fs::remove_dir_all(chart_dir.join("charts")).unwrap();
485        }
486
487        chart_dir
488    }
489
490    // Ported from internal/deployer/helm/chartdeps_integration_test.go:293
491    // (TestChartDependencies_SatisfiedChartIsNotResolved)
492    #[test]
493    fn test_chart_dependencies_satisfied_chart_is_not_resolved() {
494        let workdir = tempfile::tempdir().unwrap();
495        let home = tempfile::tempdir().unwrap();
496        let _env = HelmEnvGuard::install(home.path());
497
498        let (server, dep_repo_url) = serve_dep_chart_repo(workdir.path());
499        // `strip_charts: false` so the dependency is already vendored --
500        // `helm dependency list` reports "ok" without touching the
501        // repository at all.
502        let chart_dir = write_parent_chart(workdir.path(), &dep_repo_url, false);
503
504        // The repository is gone by the time resolution runs: a satisfied
505        // chart must not be resolved (i.e. must not need the repository),
506        // not merely "happens to succeed because the repository was still
507        // reachable".
508        drop(server);
509
510        real_manager()
511            .resolve_chart_dependencies(&chart_dir)
512            .expect("a satisfied chart's dependencies resolve without reaching the repository");
513
514        assert!(
515            chart_dir
516                .join("charts")
517                .join("dep-chart-0.1.0.tgz")
518                .is_file(),
519            "the vendored dependency must be left untouched"
520        );
521    }
522
523    // Ported from internal/deployer/helm/chartdeps_integration_test.go:239
524    // (TestChartDependencies_ChartDirectoryIsResolvedInPlace)
525    #[test]
526    fn test_chart_dependencies_chart_directory_is_resolved_in_place() {
527        let workdir = tempfile::tempdir().unwrap();
528        let home = tempfile::tempdir().unwrap();
529        let _env = HelmEnvGuard::install(home.path());
530
531        let (_server, dep_repo_url) = serve_dep_chart_repo(workdir.path());
532        let chart_dir = write_parent_chart(workdir.path(), &dep_repo_url, true);
533
534        let deployer_config = DeployerConfig {
535            deployer_type: "helm".to_string(),
536            helm: Some(HelmConfig {
537                release: Some(HelmReleaseConfig {
538                    name: "parent".to_string(),
539                    chart: chart_dir.display().to_string(),
540                    ..Default::default()
541                }),
542                ..Default::default()
543            }),
544            ..Default::default()
545        };
546        let caproni_config = xianyun_core::config::latest::CaproniConfig::default();
547
548        let (resolved_chart, cleanup) = real_manager()
549            .prepare_chart_source(&deployer_config, &caproni_config)
550            .expect("resolving a local chart directory's dependencies");
551        cleanup();
552
553        // "In place": the directory handed to helm is exactly the one
554        // named by the config, not a copy.
555        assert_eq!(resolved_chart, chart_dir.display().to_string());
556        assert!(
557            chart_dir
558                .join("charts")
559                .join("dep-chart-0.1.0.tgz")
560                .is_file(),
561            "dependency build must have populated the chart's own charts/ directory"
562        );
563    }
564
565    // Ported from internal/deployer/helm/chartdeps_integration_test.go:199
566    // (TestChartDependencies_URLArchiveIsResolvedFromItsLock)
567    #[test]
568    fn test_chart_dependencies_url_archive_is_resolved_from_its_lock() {
569        let workdir = tempfile::tempdir().unwrap();
570        let home = tempfile::tempdir().unwrap();
571        let _env = HelmEnvGuard::install(home.path());
572
573        let (_dep_server, dep_repo_url) = serve_dep_chart_repo(workdir.path());
574        // The chart that gets downloaded carries a Chart.lock pointing at
575        // `dep_repo_url` but no vendored `charts/` of its own -- resolving
576        // it from that lock, after unpacking, is exactly what this test
577        // name describes.
578        let chart_dir = write_parent_chart(workdir.path(), &dep_repo_url, true);
579
580        let archive_dir = workdir.path().join("archive");
581        std::fs::create_dir_all(&archive_dir).unwrap();
582        let archive_path = tar_chart(&chart_dir, &archive_dir);
583
584        let archive_server = StaticFileServer::start(archive_dir.clone());
585        let archive_url = format!(
586            "{}/{}",
587            archive_server.url(),
588            archive_path.file_name().unwrap().to_str().unwrap()
589        );
590
591        let (resolved_chart, cleanup) = real_manager()
592            .materialize_url_chart(&archive_url)
593            .expect("fetching and resolving a URL-referenced chart archive");
594
595        assert!(
596            resolved_chart
597                .join("charts")
598                .join("dep-chart-0.1.0.tgz")
599                .is_file(),
600            "the unpacked archive's own dependency must be resolved from its Chart.lock"
601        );
602
603        cleanup();
604    }
605
606    // Ported from internal/deployer/helm/chartdeps_integration_test.go:268
607    // (TestChartDependencies_UsesARepositoryTheUserAlreadyRegistered)
608    #[test]
609    fn test_chart_dependencies_uses_a_repository_the_user_already_registered() {
610        let workdir = tempfile::tempdir().unwrap();
611        let home = tempfile::tempdir().unwrap();
612        let _env = HelmEnvGuard::install(home.path());
613
614        let (_server, dep_repo_url) = serve_dep_chart_repo(workdir.path());
615        let chart_dir = write_parent_chart(workdir.path(), &dep_repo_url, true);
616
617        let manager = real_manager();
618
619        // The user already has this exact URL registered, under a name of
620        // their own choosing.
621        let output = std::process::Command::new("helm")
622            .args(["repo", "add", "my-own-jetstack", &dep_repo_url])
623            .output()
624            .expect("helm binary must be on PATH");
625        assert!(
626            output.status.success(),
627            "helm repo add failed: {}",
628            String::from_utf8_lossy(&output.stderr)
629        );
630
631        manager
632            .resolve_chart_dependencies(&chart_dir)
633            .expect("resolving dependencies against an already-registered repository");
634
635        let repos = manager.get_existing_helm_repos().unwrap();
636        assert_eq!(
637            repos.len(),
638            1,
639            "no second (auto-named) repository should have been registered: {repos:?}"
640        );
641        assert_eq!(repos[0].name, "my-own-jetstack");
642
643        assert!(
644            chart_dir
645                .join("charts")
646                .join("dep-chart-0.1.0.tgz")
647                .is_file()
648        );
649    }
650}