1use thiserror::Error;
6
7use super::errors::ConfigError;
8use super::latest::CaproniConfig;
9
10#[derive(Debug, Error)]
12#[error("{}", render_validation_errors(.0))]
13pub struct ValidationError(pub Vec<ConfigError>);
14
15fn render_validation_errors(errors: &[ConfigError]) -> String {
16 match errors.len() {
17 0 => "validation failed".to_string(),
18 1 => errors[0].to_string(),
19 _ => {
20 let mut out = String::from("multiple validation errors:\n");
21 for (i, err) in errors.iter().enumerate() {
22 out.push_str(&format!(" {}. {err}\n", i + 1));
23 }
24 out
25 }
26 }
27}
28
29pub type ConfigValidator = fn(&CaproniConfig) -> Result<(), ValidationError>;
34
35pub fn register_validator(_v: ConfigValidator) {}
46
47pub fn validate_semantics(cfg: &CaproniConfig) -> Result<(), ValidationError> {
49 let mut errors = Vec::new();
50
51 if let Err(err) = validate_deployer_references(cfg) {
52 errors.extend(err.0);
53 }
54 if let Err(err) = validate_reloader_references(cfg) {
55 errors.extend(err.0);
56 }
57 if let Err(err) = validate_path_references(cfg) {
58 errors.extend(err.0);
59 }
60 if let Err(err) = validate_registry_mirror(cfg) {
61 errors.push(err);
62 }
63 if let Err(err) = validate_repositories(cfg) {
64 errors.extend(err.0);
65 }
66
67 if errors.is_empty() {
68 Ok(())
69 } else {
70 Err(ValidationError(errors))
71 }
72}
73
74fn validate_repositories(cfg: &CaproniConfig) -> Result<(), ValidationError> {
77 let mut errs = Vec::new();
78
79 if let Some(repos) = &cfg.repositories {
80 for (_name, repo) in repos.iter() {
81 if repo.ref_.is_empty() {
82 continue;
83 }
84 if repo.ref_.trim().is_empty() || repo.ref_.chars().any(char::is_whitespace) {
85 errs.push(ConfigError::InvalidRepositoryRef);
86 }
87 }
88 }
89
90 if errs.is_empty() {
91 Ok(())
92 } else {
93 Err(ValidationError(errs))
94 }
95}
96
97fn validate_registry_mirror(cfg: &CaproniConfig) -> Result<(), ConfigError> {
101 let Some(mirror) = &cfg.cluster.registry_mirror else {
102 return Ok(());
103 };
104
105 if mirror.managed {
106 if !mirror.endpoint.is_empty() {
107 return Err(ConfigError::RegistryMirrorManagedEndpoint);
108 }
109 return Ok(());
110 }
111
112 let (host, port) = parse_host_port(&mirror.endpoint);
113
114 if host.is_empty() {
115 return Err(ConfigError::RegistryMirrorEndpointMissingHost);
116 }
117 if port.is_empty() {
118 return Err(ConfigError::RegistryMirrorEndpointMissingPort);
119 }
120
121 Ok(())
122}
123
124fn parse_host_port(endpoint: &str) -> (String, String) {
128 let authority = match endpoint.split_once("://") {
129 Some((_, rest)) => rest,
130 None => endpoint,
131 };
132 let authority = authority.split(['/', '?', '#']).next().unwrap_or_default();
133
134 match authority.rsplit_once(':') {
135 Some((host, port)) => (host.to_string(), port.to_string()),
136 None => (authority.to_string(), String::new()),
137 }
138}
139
140fn validate_deployer_references(cfg: &CaproniConfig) -> Result<(), ValidationError> {
143 let mut errs = Vec::new();
144
145 if let Some(deployers) = &cfg.deployers {
146 for (name, deployer) in deployers.iter() {
147 if let Some(repo_name) = &deployer.repository {
148 let exists = cfg.repositories.as_ref().is_some_and(|r| r.has(repo_name));
149 if !exists {
150 errs.push(ConfigError::SemanticValidation(format!(
151 "deployer \"{name}\": repository \"{repo_name}\" not defined in repositories section"
152 )));
153 }
154 }
155
156 for need in &deployer.needs {
157 if !deployers.has(need) {
158 errs.push(ConfigError::SemanticValidation(format!(
159 "deployer \"{name}\": dependent deployer \"{need}\" not defined in deployers section"
160 )));
161 }
162 }
163 }
164 }
165
166 if errs.is_empty() {
167 Ok(())
168 } else {
169 Err(ValidationError(errs))
170 }
171}
172
173fn validate_reloader_references(cfg: &CaproniConfig) -> Result<(), ValidationError> {
176 let mut errs = Vec::new();
177
178 if let Some(reloaders) = &cfg.reloaders {
179 for (name, reloader) in reloaders.iter() {
180 let exists = cfg
181 .repositories
182 .as_ref()
183 .is_some_and(|r| r.has(&reloader.repository));
184 if !exists {
185 let repo_name = &reloader.repository;
186 errs.push(ConfigError::SemanticValidation(format!(
187 "reloader \"{name}\": repository \"{repo_name}\" not defined in repositories section"
188 )));
189 }
190 }
191 }
192
193 if errs.is_empty() {
194 Ok(())
195 } else {
196 Err(ValidationError(errs))
197 }
198}
199
200fn validate_path_references(cfg: &CaproniConfig) -> Result<(), ValidationError> {
205 let mut errs = Vec::new();
206
207 if let Some(deployers) = &cfg.deployers {
208 for (name, deployer) in deployers.iter() {
209 if let Err(err) = validate_deployer_paths(cfg, deployer) {
210 errs.push(ConfigError::SemanticValidation(format!(
211 "deployer \"{name}\": {err}"
212 )));
213 }
214 }
215 }
216
217 if let Some(reloaders) = &cfg.reloaders {
218 for (name, reloader) in reloaders.iter() {
219 if let Err(err) = validate_reloader_paths(cfg, reloader) {
220 errs.push(ConfigError::SemanticValidation(format!(
221 "reloader \"{name}\": {err}"
222 )));
223 }
224 }
225 }
226
227 if errs.is_empty() {
228 Ok(())
229 } else {
230 Err(ValidationError(errs))
231 }
232}
233
234fn validate_deployer_paths(
240 cfg: &CaproniConfig,
241 deployer: &super::latest::DeployerConfig,
242) -> Result<(), String> {
243 match deployer.deployer_type.as_str() {
244 "helm" => {
245 let Some(helm) = &deployer.helm else {
246 return Ok(());
247 };
248
249 if !(deployer.repository.is_some() && deployer.edit_mode) && helm.release.is_none() {
250 return Err(
251 "helm.release is required when not using edit mode with a repository"
252 .to_string(),
253 );
254 }
255
256 if !helm.values_file.is_empty() {
257 let path = cfg.resolve_path(&helm.values_file);
258 if !std::path::Path::new(&path).exists() {
259 return Err(format!("values file not found: {path}"));
260 }
261 }
262
263 Ok(())
264 }
265 "namespace" => {
266 let empty = deployer
267 .namespace
268 .as_ref()
269 .is_none_or(|ns| ns.namespaces.is_empty());
270 if empty {
271 return Err("namespaces list must not be empty".to_string());
272 }
273 Ok(())
274 }
275 _ => Ok(()),
276 }
277}
278
279fn validate_reloader_paths(
283 cfg: &CaproniConfig,
284 reloader: &super::latest::ReloaderConfig,
285) -> Result<(), String> {
286 if reloader.reloader_type != "skaffold" {
287 return Ok(());
288 }
289
290 let Some(skaffold) = &reloader.skaffold else {
291 return Err("skaffold configuration is missing".to_string());
292 };
293
294 if skaffold.config_file.is_empty() {
295 return Err("config_file not configured".to_string());
296 }
297
298 let Some(repo) = cfg
299 .repositories
300 .as_ref()
301 .and_then(|r| r.get(&reloader.repository))
302 else {
303 return Err(format!("repository not found: {}", reloader.repository));
304 };
305
306 let repo_dir = cfg.resolve_path(&repo.directory);
307 if !std::path::Path::new(&repo_dir).exists() {
308 return Ok(());
310 }
311
312 let config_file_path = std::path::Path::new(&repo_dir).join(&skaffold.config_file);
313 if !config_file_path.exists() {
314 return Err(format!(
315 "skaffold config file not found: {}",
316 config_file_path.display()
317 ));
318 }
319
320 Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::config::latest::{
327 DeployerConfig, HelmConfig, HelmReleaseConfig, MirrorConfig, MirrorTargetConfig,
328 NamespaceConfig, ReloaderConfig, RepositoryConfig, SkaffoldConfig,
329 };
330 use crate::config::types::{ClusterConfig, RegistryMirrorConfig};
331 use crate::orderedmap::OrderedMap;
332 use std::fs;
333
334 fn repos(entries: &[(&str, &str, &str)]) -> OrderedMap<String, RepositoryConfig> {
335 let mut m = OrderedMap::new();
336 for (name, repository, directory) in entries {
337 m.set(
338 name.to_string(),
339 RepositoryConfig {
340 repository: repository.to_string(),
341 directory: directory.to_string(),
342 ..Default::default()
343 },
344 );
345 }
346 m
347 }
348
349 #[test]
351 fn validate_semantics_valid_config() {
352 let mut deployers = OrderedMap::new();
353 deployers.set(
354 "deployer1".to_string(),
355 DeployerConfig {
356 deployer_type: "helm".into(),
357 repository: Some("repo1".into()),
358 helm: Some(HelmConfig {
359 namespace: "default".into(),
360 release: Some(HelmReleaseConfig {
361 name: "my-app".into(),
362 chart: "repo/chart".into(),
363 ..Default::default()
364 }),
365 ..Default::default()
366 }),
367 ..Default::default()
368 },
369 );
370
371 let mut reloaders = OrderedMap::new();
372 reloaders.set(
373 "reloader1".to_string(),
374 ReloaderConfig {
375 reloader_type: "mirror".into(),
376 repository: "repo2".into(),
377 mirror: Some(MirrorConfig {
378 target: Some(MirrorTargetConfig {
379 namespace: "default".into(),
380 kubernetes_type: "deployment".into(),
381 name: "test".into(),
382 ..Default::default()
383 }),
384 ..Default::default()
385 }),
386 ..Default::default()
387 },
388 );
389
390 let cfg = CaproniConfig {
391 version: 2,
392 cluster: ClusterConfig {
393 driver: "mock".into(),
394 ..Default::default()
395 },
396 repositories: Some(repos(&[
397 ("repo1", "https://example.com/repo1.git", "./repo1"),
398 ("repo2", "https://example.com/repo2.git", "./repo2"),
399 ])),
400 deployers: Some(deployers),
401 reloaders: Some(reloaders),
402 ..Default::default()
403 };
404
405 assert!(validate_semantics(&cfg).is_ok());
406 }
407
408 #[test]
410 fn validate_semantics_invalid_repository_ref() {
411 let mut repositories = OrderedMap::new();
412 repositories.set(
413 "repo1".to_string(),
414 RepositoryConfig {
415 repository: "https://example.com/repo1.git".into(),
416 directory: "./repo1".into(),
417 ref_: "not a ref".into(),
418 ..Default::default()
419 },
420 );
421
422 let cfg = CaproniConfig {
423 version: 2,
424 cluster: ClusterConfig {
425 driver: "mock".into(),
426 ..Default::default()
427 },
428 repositories: Some(repositories),
429 deployers: Some(OrderedMap::new()),
430 reloaders: Some(OrderedMap::new()),
431 ..Default::default()
432 };
433
434 let err = validate_semantics(&cfg).unwrap_err();
435 assert!(
436 err.0
437 .iter()
438 .any(|e| matches!(e, ConfigError::InvalidRepositoryRef))
439 );
440 }
441
442 #[test]
444 fn validate_semantics_undefined_deployer_repository() {
445 let mut deployers = OrderedMap::new();
446 deployers.set(
447 "deployer1".to_string(),
448 DeployerConfig {
449 deployer_type: "helm".into(),
450 repository: Some("undefined-repo".into()),
451 ..Default::default()
452 },
453 );
454
455 let cfg = CaproniConfig {
456 version: 2,
457 cluster: ClusterConfig {
458 driver: "mock".into(),
459 ..Default::default()
460 },
461 repositories: Some(repos(&[(
462 "repo1",
463 "https://example.com/repo1.git",
464 "./repo1",
465 )])),
466 deployers: Some(deployers),
467 ..Default::default()
468 };
469
470 let err = validate_semantics(&cfg).unwrap_err();
471 let msg = err.to_string();
472 assert!(msg.contains("deployer \"deployer1\""));
473 assert!(msg.contains("repository \"undefined-repo\" not defined"));
474 }
475
476 #[test]
478 fn validate_semantics_undefined_reloader_repository() {
479 let mut reloaders = OrderedMap::new();
480 reloaders.set(
481 "reloader1".to_string(),
482 ReloaderConfig {
483 reloader_type: "mirror".into(),
484 repository: "undefined-repo".into(),
485 ..Default::default()
486 },
487 );
488
489 let cfg = CaproniConfig {
490 version: 2,
491 cluster: ClusterConfig {
492 driver: "mock".into(),
493 ..Default::default()
494 },
495 repositories: Some(repos(&[(
496 "repo1",
497 "https://example.com/repo1.git",
498 "./repo1",
499 )])),
500 reloaders: Some(reloaders),
501 ..Default::default()
502 };
503
504 let err = validate_semantics(&cfg).unwrap_err();
505 let msg = err.to_string();
506 assert!(msg.contains("reloader \"reloader1\""));
507 assert!(msg.contains("repository \"undefined-repo\" not defined"));
508 }
509
510 #[test]
512 fn validate_semantics_multiple_errors() {
513 let mut deployers = OrderedMap::new();
514 deployers.set(
515 "deployer1".to_string(),
516 DeployerConfig {
517 deployer_type: "helm".into(),
518 repository: Some("undefined-repo1".into()),
519 ..Default::default()
520 },
521 );
522
523 let mut reloaders = OrderedMap::new();
524 reloaders.set(
525 "reloader1".to_string(),
526 ReloaderConfig {
527 reloader_type: "mirror".into(),
528 repository: "undefined-repo2".into(),
529 ..Default::default()
530 },
531 );
532
533 let cfg = CaproniConfig {
534 version: 2,
535 cluster: ClusterConfig {
536 driver: "mock".into(),
537 ..Default::default()
538 },
539 repositories: Some(OrderedMap::new()),
540 deployers: Some(deployers),
541 reloaders: Some(reloaders),
542 ..Default::default()
543 };
544
545 let err = validate_semantics(&cfg).unwrap_err();
546 let msg = err.to_string();
547 assert!(msg.contains("deployer \"deployer1\""));
548 assert!(msg.contains("reloader \"reloader1\""));
549 assert!(msg.contains("undefined-repo1"));
550 assert!(msg.contains("undefined-repo2"));
551 }
552
553 #[test]
555 fn validate_semantics_missing_values_file() {
556 let tmp_dir = tempfile::tempdir().unwrap();
557
558 let mut deployers = OrderedMap::new();
559 deployers.set(
560 "deployer1".to_string(),
561 DeployerConfig {
562 deployer_type: "helm".into(),
563 repository: Some("repo1".into()),
564 helm: Some(HelmConfig {
565 namespace: "default".into(),
566 values_file: "nonexistent-values.yaml".into(),
567 release: Some(HelmReleaseConfig {
568 name: "my-app".into(),
569 chart: "repo/chart".into(),
570 ..Default::default()
571 }),
572 ..Default::default()
573 }),
574 ..Default::default()
575 },
576 );
577
578 let cfg = CaproniConfig {
579 version: 2,
580 cluster: ClusterConfig {
581 driver: "mock".into(),
582 ..Default::default()
583 },
584 repositories: Some(repos(&[(
585 "repo1",
586 "https://example.com/repo1.git",
587 "./repo1",
588 )])),
589 deployers: Some(deployers),
590 config_dir: tmp_dir.path().to_string_lossy().into_owned(),
591 ..Default::default()
592 };
593
594 let err = validate_semantics(&cfg).unwrap_err();
595 let msg = err.to_string();
596 assert!(msg.contains("deployer \"deployer1\""));
597 assert!(msg.contains("values file not found"));
598 assert!(msg.contains("nonexistent-values.yaml"));
599 }
600
601 #[test]
603 fn validate_semantics_existing_values_file() {
604 let tmp_dir = tempfile::tempdir().unwrap();
605 fs::write(tmp_dir.path().join("values.yaml"), "key: value").unwrap();
606
607 let mut deployers = OrderedMap::new();
608 deployers.set(
609 "deployer1".to_string(),
610 DeployerConfig {
611 deployer_type: "helm".into(),
612 repository: Some("repo1".into()),
613 helm: Some(HelmConfig {
614 namespace: "default".into(),
615 values_file: "values.yaml".into(),
616 release: Some(HelmReleaseConfig {
617 name: "my-app".into(),
618 chart: "repo/chart".into(),
619 ..Default::default()
620 }),
621 ..Default::default()
622 }),
623 ..Default::default()
624 },
625 );
626
627 let cfg = CaproniConfig {
628 version: 2,
629 cluster: ClusterConfig {
630 driver: "mock".into(),
631 ..Default::default()
632 },
633 repositories: Some(repos(&[(
634 "repo1",
635 "https://example.com/repo1.git",
636 "./repo1",
637 )])),
638 deployers: Some(deployers),
639 config_dir: tmp_dir.path().to_string_lossy().into_owned(),
640 ..Default::default()
641 };
642
643 assert!(validate_semantics(&cfg).is_ok());
644 }
645
646 #[test]
648 fn validate_semantics_missing_skaffold_config_file() {
649 let tmp_dir = tempfile::tempdir().unwrap();
650 fs::create_dir_all(tmp_dir.path().join("repo1")).unwrap();
651
652 let mut reloaders = OrderedMap::new();
653 reloaders.set(
654 "reloader1".to_string(),
655 ReloaderConfig {
656 reloader_type: "skaffold".into(),
657 repository: "repo1".into(),
658 skaffold: Some(SkaffoldConfig {
659 namespace: "default".into(),
660 config_file: "nonexistent-skaffold.yaml".into(),
661 }),
662 ..Default::default()
663 },
664 );
665
666 let cfg = CaproniConfig {
667 version: 2,
668 cluster: ClusterConfig {
669 driver: "mock".into(),
670 ..Default::default()
671 },
672 repositories: Some(repos(&[(
673 "repo1",
674 "https://example.com/repo1.git",
675 "./repo1",
676 )])),
677 reloaders: Some(reloaders),
678 config_dir: tmp_dir.path().to_string_lossy().into_owned(),
679 ..Default::default()
680 };
681
682 let err = validate_semantics(&cfg).unwrap_err();
683 let msg = err.to_string();
684 assert!(msg.contains("reloader \"reloader1\""));
685 assert!(msg.contains("skaffold config file not found"));
686 assert!(msg.contains("nonexistent-skaffold.yaml"));
687 }
688
689 #[test]
691 fn validate_semantics_existing_skaffold_config_file() {
692 let tmp_dir = tempfile::tempdir().unwrap();
693 let repo_dir = tmp_dir.path().join("repo1");
694 fs::create_dir_all(&repo_dir).unwrap();
695 fs::write(repo_dir.join("skaffold.yaml"), "apiVersion: skaffold/v2").unwrap();
696
697 let mut reloaders = OrderedMap::new();
698 reloaders.set(
699 "reloader1".to_string(),
700 ReloaderConfig {
701 reloader_type: "skaffold".into(),
702 repository: "repo1".into(),
703 skaffold: Some(SkaffoldConfig {
704 namespace: "default".into(),
705 config_file: "skaffold.yaml".into(),
706 }),
707 ..Default::default()
708 },
709 );
710
711 let cfg = CaproniConfig {
712 version: 2,
713 cluster: ClusterConfig {
714 driver: "mock".into(),
715 ..Default::default()
716 },
717 repositories: Some(repos(&[(
718 "repo1",
719 "https://example.com/repo1.git",
720 "./repo1",
721 )])),
722 reloaders: Some(reloaders),
723 config_dir: tmp_dir.path().to_string_lossy().into_owned(),
724 ..Default::default()
725 };
726
727 assert!(validate_semantics(&cfg).is_ok());
728 }
729
730 #[test]
732 fn validate_semantics_direct_chart_reference() {
733 for chart in [
734 "oci://registry.example.com/charts/my-app",
735 "oci://registry.example.com/charts/my-app:1.2.3",
736 "./dist/my-app-1.0.0.tgz",
737 ] {
738 let mut deployers = OrderedMap::new();
739 deployers.set(
740 "deployer1".to_string(),
741 DeployerConfig {
742 deployer_type: "helm".into(),
743 repository: None,
744 helm: Some(HelmConfig {
745 namespace: "default".into(),
746 release: Some(HelmReleaseConfig {
747 name: "my-app".into(),
748 chart: chart.into(),
749 ..Default::default()
750 }),
751 ..Default::default()
752 }),
753 ..Default::default()
754 },
755 );
756
757 let cfg = CaproniConfig {
758 version: 2,
759 cluster: ClusterConfig {
760 driver: "mock".into(),
761 ..Default::default()
762 },
763 repositories: Some(OrderedMap::new()),
764 deployers: Some(deployers),
765 ..Default::default()
766 };
767
768 assert!(validate_semantics(&cfg).is_ok(), "{chart}");
769 }
770 }
771
772 #[test]
774 fn validate_semantics_namespace_deployer() {
775 let mut deployers = OrderedMap::new();
776 deployers.set(
777 "prerequisites".to_string(),
778 DeployerConfig {
779 deployer_type: "namespace".into(),
780 namespace: Some(NamespaceConfig {
781 namespaces: vec!["gitlab".into()],
782 ..Default::default()
783 }),
784 ..Default::default()
785 },
786 );
787
788 let cfg = CaproniConfig {
789 repositories: Some(OrderedMap::new()),
790 reloaders: Some(OrderedMap::new()),
791 deployers: Some(deployers),
792 ..Default::default()
793 };
794
795 assert!(validate_semantics(&cfg).is_ok());
796 }
797
798 #[test]
800 fn validate_semantics_unknown_needs_deployer() {
801 let mut deployers = OrderedMap::new();
802 deployers.set(
803 "prerequisites".to_string(),
804 DeployerConfig {
805 deployer_type: "namespace".into(),
806 namespace: Some(NamespaceConfig {
807 namespaces: vec!["gitlab".into()],
808 ..Default::default()
809 }),
810 needs: vec!["bark".into()],
811 ..Default::default()
812 },
813 );
814
815 let cfg = CaproniConfig {
816 repositories: Some(OrderedMap::new()),
817 reloaders: Some(OrderedMap::new()),
818 deployers: Some(deployers),
819 ..Default::default()
820 };
821
822 let err = validate_semantics(&cfg).unwrap_err();
823 assert!(err.to_string().contains(r#"deployer "prerequisites": dependent deployer "bark" not defined in deployers section"#));
824 }
825
826 #[test]
828 fn validate_semantics_namespace_deployer_empty() {
829 let mut deployers = OrderedMap::new();
830 deployers.set(
831 "prerequisites".to_string(),
832 DeployerConfig {
833 deployer_type: "namespace".into(),
834 ..Default::default()
835 },
836 );
837
838 let cfg = CaproniConfig {
839 repositories: Some(OrderedMap::new()),
840 reloaders: Some(OrderedMap::new()),
841 deployers: Some(deployers),
842 ..Default::default()
843 };
844
845 let err = validate_semantics(&cfg).unwrap_err();
846 let msg = err.to_string();
847 assert!(msg.contains("deployer \"prerequisites\""));
848 assert!(msg.contains("namespaces list must not be empty"));
849 }
850
851 #[test]
853 fn validate_semantics_registry_mirror_cases() {
854 struct Case {
855 name: &'static str,
856 mirror: Option<RegistryMirrorConfig>,
857 want_err: Option<ConfigError>,
858 }
859
860 let cases = vec![
861 Case {
862 name: "nil mirror is valid",
863 mirror: None,
864 want_err: None,
865 },
866 Case {
867 name: "valid endpoint with host and port",
868 mirror: Some(RegistryMirrorConfig {
869 endpoint: "http://host.k3d.internal:5000".into(),
870 upstreams: vec!["docker.io".into()],
871 ..Default::default()
872 }),
873 want_err: None,
874 },
875 Case {
876 name: "endpoint without host is rejected",
877 mirror: Some(RegistryMirrorConfig {
878 endpoint: "http://:5000".into(),
879 upstreams: vec!["docker.io".into()],
880 ..Default::default()
881 }),
882 want_err: Some(ConfigError::RegistryMirrorEndpointMissingHost),
883 },
884 Case {
885 name: "endpoint without port is rejected",
886 mirror: Some(RegistryMirrorConfig {
887 endpoint: "http://host.k3d.internal".into(),
888 upstreams: vec!["docker.io".into()],
889 ..Default::default()
890 }),
891 want_err: Some(ConfigError::RegistryMirrorEndpointMissingPort),
892 },
893 Case {
894 name: "managed without endpoint is valid",
895 mirror: Some(RegistryMirrorConfig {
896 managed: true,
897 upstreams: vec!["docker.io".into()],
898 ..Default::default()
899 }),
900 want_err: None,
901 },
902 Case {
903 name: "managed with endpoint is rejected",
904 mirror: Some(RegistryMirrorConfig {
905 managed: true,
906 endpoint: "http://host.k3d.internal:5000".into(),
907 upstreams: vec!["docker.io".into()],
908 }),
909 want_err: Some(ConfigError::RegistryMirrorManagedEndpoint),
910 },
911 ];
912
913 for case in cases {
914 let cfg = CaproniConfig {
915 version: 2,
916 cluster: ClusterConfig {
917 driver: "mock".into(),
918 registry_mirror: case.mirror,
919 ..Default::default()
920 },
921 repositories: Some(OrderedMap::new()),
922 ..Default::default()
923 };
924
925 let result = validate_semantics(&cfg);
926 match case.want_err {
927 None => assert!(result.is_ok(), "{}", case.name),
928 Some(want) => {
929 let err = result.unwrap_err();
930 assert!(err.0.contains(&want), "{}", case.name);
931 }
932 }
933 }
934 }
935}