1use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5
6use serde::Deserialize;
7use sha2::{Digest, Sha256};
8
9use super::HelmError;
10
11pub(super) const CHART_METADATA_FILE: &str = "Chart.yaml";
14
15pub(super) const LOCK_FILE: &str = "Chart.lock";
17
18const 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#[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#[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
68pub(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#[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
116pub(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#[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 #[test]
165 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 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 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 fn test_locate_unpacked_chart() {
325 {
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 {
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 {
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 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 assert_eq!(name, chart_repo_name("https://charts.jetstack.io/"));
389
390 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 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 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 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 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 #[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 let chart_dir = write_parent_chart(workdir.path(), &dep_repo_url, false);
503
504 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 #[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 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 #[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 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 #[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 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}