Skip to main content

xianyun_deploy/deployer/helm/
chartsource.rs

1//! Ported from `internal/deployer/helm/chartsource.go`.
2
3use tracing::info;
4
5use xianyun_backends::helm::{Backend as HelmBackend, chartref::redact_chart_ref};
6
7use super::HelmError;
8use super::manager::Manager;
9
10/// The chart root helm can install from, and a cleanup function the caller
11/// MUST call once it is done with the directory.
12type MaterializedChart = (std::path::PathBuf, Box<dyn FnOnce()>);
13
14impl<B: HelmBackend> Manager<B> {
15    /// Ported from `materializeURLChart`.
16    ///
17    /// Downloads the chart served by `chart_ref`, resolves whatever it
18    /// declares and does not carry, and returns the directory helm can
19    /// install from along with a function that removes it.
20    ///
21    /// The archive is unpacked whether or not it needs completing, because
22    /// a packaged chart and a repository's source archive can only be told
23    /// apart once it is. Each download gets a directory of its own, because
24    /// helm's collision check cannot see a chart unpacked from a different
25    /// reference and would merge the two.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error if the download fails, the archive holds no chart
30    /// (or more than one), or resolving the chart's own dependencies fails.
31    pub(super) fn materialize_url_chart(
32        &self,
33        chart_ref: &str,
34    ) -> Result<MaterializedChart, HelmError> {
35        let dir = tempfile::Builder::new()
36            .prefix("caproni-chart-")
37            .tempdir()
38            .map_err(|e| HelmError::Io(format!("creating chart unpack directory: {e}")))?;
39
40        info!(chart = %redact_chart_ref(chart_ref), "fetching helm chart");
41
42        let dir_path = dir.path().to_path_buf();
43
44        self.backend
45            .pull(chart_ref, &dir_path.display().to_string())
46            .map_err(|e| HelmError::Backend {
47                context: format!("fetching chart {}", redact_chart_ref(chart_ref)),
48                source: e,
49            })?;
50
51        let root = super::chartdeps::locate_unpacked_chart(&dir_path)?;
52
53        self.resolve_chart_dependencies(&root)?;
54
55        Ok((root, Box::new(move || drop(dir))))
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use std::sync::{Arc, Mutex};
62
63    use xianyun_executor::exec::ExecError;
64
65    use super::super::manager::Manager;
66    use super::super::test_support::MockHelmBackend;
67
68    const TEST_CHART_URL: &str = "https://gitlab.com/g/p/-/archive/abc123/p-abc123.tar.gz";
69    const TEST_REPO_URL: &str = "https://charts.jetstack.io";
70
71    const SATISFIED_LIST: &str =
72        "NAME\tVERSION\tREPOSITORY\tSTATUS\ncert-manager\t1.16.2\thttps://charts.jetstack.io\tok\n";
73    const UNSATISFIED_LIST: &str = "NAME\tVERSION\tREPOSITORY\tSTATUS\ncert-manager\t1.16.2\thttps://charts.jetstack.io\tmissing\n";
74
75    /// Stands in for helm, writing the chart directory plus the empty one
76    /// helm leaves beside it, and recording where it unpacked. Ported from
77    /// `stubPull`/`stubPullFor`.
78    fn stub_pull_for(chart_ref: &'static str, dirs: Arc<Mutex<Vec<String>>>) -> MockHelmBackend {
79        stub_pull_for_calls(chart_ref, dirs, 1)
80    }
81
82    fn stub_pull_for_calls(
83        chart_ref: &'static str,
84        dirs: Arc<Mutex<Vec<String>>>,
85        calls: usize,
86    ) -> MockHelmBackend {
87        let mut backend = MockHelmBackend::new();
88        for _ in 0..calls {
89            backend = stub_pull_once(backend, chart_ref, dirs.clone());
90        }
91        backend
92    }
93
94    fn stub_pull_once(
95        backend: MockHelmBackend,
96        chart_ref: &'static str,
97        dirs: Arc<Mutex<Vec<String>>>,
98    ) -> MockHelmBackend {
99        backend.expect_pull(move |got_chart_ref, untar_dir| {
100            assert_eq!(got_chart_ref, chart_ref);
101
102            let entries: Vec<_> = std::fs::read_dir(untar_dir).unwrap().collect();
103            assert!(
104                entries.is_empty(),
105                "helm must never unpack into a directory that already holds a chart"
106            );
107
108            dirs.lock().unwrap().push(untar_dir.to_string());
109
110            std::fs::create_dir_all(format!("{untar_dir}/p-abc123.tar.gz")).unwrap();
111            std::fs::create_dir_all(format!("{untar_dir}/gitlab")).unwrap();
112            std::fs::write(format!("{untar_dir}/gitlab/Chart.yaml"), "name: gitlab\n").unwrap();
113            std::fs::write(
114                format!("{untar_dir}/gitlab/Chart.lock"),
115                format!(
116                    "dependencies:\n- name: cert-manager\n  repository: {TEST_REPO_URL}\n  version: 1.16.2\n"
117                ),
118            )
119            .unwrap();
120
121            Ok(())
122        })
123    }
124
125    // Ported from internal/deployer/helm/chartsource_test.go
126    #[test]
127    // Ported from internal/deployer/helm/chartsource_test.go:53 (TestMaterializeURLChart_UnpacksTheChartAndCleansUpAfterIt)
128    fn test_materialize_url_chart_unpacks_the_chart_and_cleans_up_after_it() {
129        let dirs = Arc::new(Mutex::new(Vec::new()));
130        let backend = stub_pull_for(TEST_CHART_URL, dirs.clone())
131            .expect_dependency_list(|_| Ok(SATISFIED_LIST.to_string()));
132
133        let mgr = Manager::new(backend);
134
135        let (root, cleanup) = mgr.materialize_url_chart(TEST_CHART_URL).unwrap();
136
137        let dirs = dirs.lock().unwrap().clone();
138        assert_eq!(dirs.len(), 1);
139        assert_eq!(
140            root.display().to_string(),
141            format!("{}/gitlab", dirs[0]),
142            "the chart root, not the directory helm unpacked into"
143        );
144        assert!(root.join("Chart.yaml").is_file());
145
146        cleanup();
147
148        assert!(
149            !std::path::Path::new(&dirs[0]).exists(),
150            "cleanup should remove the unpack directory"
151        );
152    }
153
154    #[test]
155    // Ported from internal/deployer/helm/chartsource_test.go:75 (TestMaterializeURLChart_ResolvesAChartThatDoesNotVendorItsDependencies)
156    fn test_materialize_url_chart_resolves_a_chart_that_does_not_vendor_its_dependencies() {
157        let dirs = Arc::new(Mutex::new(Vec::new()));
158        let backend = stub_pull_for(TEST_CHART_URL, dirs)
159            .expect_dependency_list(|_| Ok(UNSATISFIED_LIST.to_string()))
160            .expect_repo_list(|| Ok("[]".to_string()))
161            .expect_repo_add(|_, url, force_update| {
162                assert_eq!(url, TEST_REPO_URL);
163                assert!(force_update);
164                Ok(())
165            })
166            .expect_dependency_build(|_, skip_refresh| {
167                assert!(skip_refresh);
168                Ok(())
169            });
170
171        let mgr = Manager::new(backend);
172
173        let (_root, cleanup) = mgr.materialize_url_chart(TEST_CHART_URL).unwrap();
174        cleanup();
175    }
176
177    #[test]
178    // Ported from internal/deployer/helm/chartsource_test.go:95 (TestMaterializeURLChart_ReportsAFailedPull)
179    fn test_materialize_url_chart_reports_a_failed_pull() {
180        let backend = MockHelmBackend::new().expect_pull(|got_chart_ref, _| {
181            assert_eq!(got_chart_ref, TEST_CHART_URL);
182            Err(ExecError::RunFailed(
183                "failed to fetch the chart".to_string(),
184            ))
185        });
186
187        let mgr = Manager::new(backend);
188
189        let result = mgr.materialize_url_chart(TEST_CHART_URL);
190        let err = match result {
191            Ok(_) => panic!("expected an error"),
192            Err(e) => e,
193        };
194
195        assert!(err.to_string().contains("failed to fetch the chart"));
196    }
197
198    #[test]
199    // Ported from internal/deployer/helm/chartsource_test.go:107 (TestMaterializeURLChart_ReportsAnArchiveWithNoChartInIt)
200    fn test_materialize_url_chart_reports_an_archive_with_no_chart_in_it() {
201        // Reports success without writing a chart.
202        let backend = MockHelmBackend::new().expect_pull(|got_chart_ref, _| {
203            assert_eq!(got_chart_ref, TEST_CHART_URL);
204            Ok(())
205        });
206
207        let mgr = Manager::new(backend);
208
209        let result = mgr.materialize_url_chart(TEST_CHART_URL);
210        let err = match result {
211            Ok(_) => panic!("expected an error"),
212            Err(e) => e,
213        };
214
215        assert!(matches!(err, super::HelmError::ChartNotUnpacked(_)));
216    }
217
218    #[test]
219    // Ported from internal/deployer/helm/chartsource_test.go:120 (TestMaterializeURLChart_EachCallUnpacksIntoAFreshDirectory)
220    fn test_materialize_url_chart_each_call_unpacks_into_a_fresh_directory() {
221        let dirs = Arc::new(Mutex::new(Vec::new()));
222        let backend = stub_pull_for_calls(TEST_CHART_URL, dirs.clone(), 2)
223            .expect_dependency_list(|_| Ok(SATISFIED_LIST.to_string()))
224            .expect_dependency_list(|_| Ok(SATISFIED_LIST.to_string()));
225
226        let mgr = Manager::new(backend);
227
228        for _ in 0..2 {
229            let (_root, cleanup) = mgr.materialize_url_chart(TEST_CHART_URL).unwrap();
230            cleanup();
231        }
232
233        let dirs = dirs.lock().unwrap().clone();
234        assert_eq!(dirs.len(), 2);
235        assert_ne!(
236            dirs[0], dirs[1],
237            "a reused directory would let helm merge two charts"
238        );
239    }
240}