Skip to main content

xianyun_core/config/
validate.rs

1//! Port of `internal/config/validate.go`: semantic validation on a v2
2//! configuration (referential integrity, file paths, and other constraints
3//! beyond what the JSON schema enforces).
4
5use thiserror::Error;
6
7use super::errors::ConfigError;
8use super::latest::CaproniConfig;
9
10/// Aggregates multiple validation errors.
11#[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
29/// A `ConfigValidator` performs additional semantic validation on a
30/// `CaproniConfig`, registered via [`register_validator`] the way the Go
31/// original lets `deployer`/`reloader` packages extend validation without an
32/// import cycle.
33pub type ConfigValidator = fn(&CaproniConfig) -> Result<(), ValidationError>;
34
35/// Registers an additional semantic validator to be called during
36/// [`validate_semantics`]. This port has no plugin-registration seam (no
37/// `deployer`/`reloader` crates registering their own validators yet, unlike
38/// the Go original's `init()`-time registration) — the checks those packages
39/// perform (`internal/deployer/{helm,namespace}`,
40/// `internal/reloader/{mirror,skaffold}`) are folded directly into
41/// [`validate_semantics`] below (`validate_helm_paths`,
42/// `validate_namespace_deployer`, `validate_skaffold_paths`) instead. Kept as
43/// a no-op so call sites that mirror the Go original's `init()` pattern
44/// still compile.
45pub fn register_validator(_v: ConfigValidator) {}
46
47/// Performs semantic validation on a v2 configuration.
48pub 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
74/// Port of `validateRepositories`: a present `ref` must be a single
75/// whitespace-free token; an omitted ref is valid.
76fn 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
97/// Port of `validateRegistryMirror`: a managed mirror must not carry an
98/// endpoint, and a bring-your-own mirror must have an endpoint with both a
99/// host and an explicit port.
100fn 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
124/// A minimal `scheme://host:port` authority parser, scoped to what
125/// `validate_registry_mirror` needs (no `url` crate dependency: the schema
126/// already constrains the endpoint's overall shape upstream of this call).
127fn 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
140/// Port of `validateDeployerReferences`: every deployer's `repository` and
141/// `needs` entries must name a defined repository/deployer.
142fn 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
173/// Port of `validateReloaderReferences`: every reloader's `repository` must
174/// name a defined repository.
175fn 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
200/// Port of `validatePathReferences`: invokes the deployer- and
201/// reloader-path validators that, in the Go original, are registered by the
202/// `deployer`/`reloader` packages via `RegisterValidator`. This port folds
203/// those checks in directly (see [`register_validator`]'s doc comment).
204fn 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
234/// Folds `internal/deployer/helm.Manager.Validate` and
235/// `internal/deployer/namespace.Manager.Validate`'s file/shape checks.
236/// `kustomize` deployers have no check exercised by this crate's tests and
237/// are left unvalidated here, matching the "structural blocker" note in
238/// `PORT_NOTES.md` about the deployer backend crates not existing yet.
239fn 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
279/// Folds `internal/reloader/skaffold.Manager.Validate`'s file check.
280/// `mirror` reloaders have no check exercised by this crate's tests and are
281/// left unvalidated here, for the same reason as `kustomize` above.
282fn 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        // A repository not yet checked out has nothing to validate against.
309        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    // Port of TestValidateSemantics_ValidConfig.
350    #[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    // Port of TestValidateSemantics_InvalidRepositoryRef.
409    #[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    // Port of TestValidateSemantics_UndefinedDeployerRepository.
443    #[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    // Port of TestValidateSemantics_UndefinedReloaderRepository.
477    #[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    // Port of TestValidateSemantics_MultipleErrors.
511    #[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    // Port of TestValidateSemantics_MissingValuesFile.
554    #[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    // Port of TestValidateSemantics_ExistingValuesFile.
602    #[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    // Port of TestValidateSemantics_MissingSkaffoldConfigFile.
647    #[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    // Port of TestValidateSemantics_ExistingSkaffoldConfigFile.
690    #[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    // Port of TestValidateSemantics_DirectChartReference.
731    #[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    // Port of TestValidateSemantics_NamespaceDeployer.
773    #[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    // Port of TestValidateSemantics_UnknownNeedsDeployer.
799    #[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    // Port of TestValidateSemantics_NamespaceDeployerEmpty.
827    #[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    // Port of TestValidateSemantics_RegistryMirror (table-driven).
852    #[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}