Skip to main content

xianyun_core/config/
caproni.rs

1//! Port of `internal/config/caproni.go`: the top-level config loader
2//! (`LoadCaproniConfig`), schema compilation, and the variable-expansion /
3//! literal-field guards it orchestrates.
4//!
5//! `extends:` composition supports both local (same-filesystem) and
6//! `https://` remote fragments (see `merge.rs`'s module doc comment for the
7//! `RemoteFetcher` design), and `caproni-project.yaml` per-repository
8//! fragment application (`applyCaproniProject`, in `merge.rs`) is fully
9//! ported. The one remaining documented gap is `compile_schema_lazy_loading`
10//! (`#[ignore]`d below): the Go original's `sync.Once`-cached schema
11//! pointer-identity assertion has no equivalent here, since this port
12//! compiles the schema fresh per call rather than caching it — see
13//! `validate_schema_v2`'s doc comment.
14
15use std::collections::{HashMap, HashSet};
16use std::path::Path;
17
18use serde_json::Value;
19
20use super::errors::ConfigError;
21use super::global::GlobalConfig;
22use super::latest::CaproniConfig;
23use super::merge::{apply_local_override, resolve_extends, resolve_local_override};
24use super::registry::{LATEST_VERSION, load_and_upgrade, supported_versions};
25use super::validate::validate_semantics;
26use crate::naming;
27use crate::variables::expand::expand_map;
28use crate::variables::predefined::Options;
29use crate::variables::resolver::{Lookup, ResolverLookup, new_resolver};
30
31const CLUSTER_KEY: &str = "cluster";
32const DRIVER_KEY: &str = "driver";
33const K3D_KEY: &str = "k3d";
34const COLIMA_KEY: &str = "colima";
35const K3S_KEY: &str = "k3s";
36const DISABLE_KEY: &str = "disable";
37
38/// Compiles (once per call, no caching — see the module doc comment) and
39/// validates `raw_config` against `schema/config.v2.schema.json`. A separate
40/// compile per call is a deliberate reduction from the Go original's
41/// `sync.Once`-cached compile: this port has no long-lived process to amortize
42/// the cost across (every test process is short-lived), and adding a
43/// `OnceLock` purely for a crate that is not yet used from a long-running
44/// binary is premature; revisit if/when a caproni CLI built on this crate
45/// calls `load_caproni_config` in a hot loop.
46fn validate_schema_v2(raw_config: &Value) -> Result<(), ConfigError> {
47    const SCHEMA_JSON: &str = include_str!("../../../../schema/config.v2.schema.json");
48
49    let schema_doc: Value = serde_json::from_str(SCHEMA_JSON)
50        .map_err(|err| ConfigError::SchemaValidation(format!("parsing schema: {err}")))?;
51
52    let compiled = jsonschema::validator_for(&schema_doc)
53        .map_err(|err| ConfigError::SchemaValidation(format!("compiling schema: {err}")))?;
54
55    // Each error is rendered with its instance path so that a caller (or a
56    // test asserting on `err_contains`) can tell *which* field failed, not
57    // just how — the `jsonschema` crate's bare `Display` omits this,
58    // unlike the Go original's `jsonschema` library, whose default error
59    // formatting includes the failing field's JSON Pointer.
60    let errors: Vec<String> = compiled
61        .iter_errors(raw_config)
62        .map(|e| {
63            let path = e.instance_path.to_string();
64            if path.is_empty() {
65                e.to_string()
66            } else {
67                format!("{path}: {e}")
68            }
69        })
70        .collect();
71
72    if errors.is_empty() {
73        Ok(())
74    } else {
75        Err(ConfigError::SchemaValidation(errors.join("; ")))
76    }
77}
78
79/// Reads the required `version` field from raw YAML. The field is mandatory:
80/// a missing field is an error, not a defaulted version.
81///
82/// Port of `detectConfigVersion`.
83fn detect_config_version(raw_config: &Value) -> Result<i64, ConfigError> {
84    let map = raw_config.as_object().ok_or(ConfigError::MissingVersion)?;
85    let version = map.get("version").ok_or(ConfigError::MissingVersion)?;
86    version.as_i64().ok_or(ConfigError::InvalidVersion)
87}
88
89/// Loads and parses the caproni.yaml configuration file. Returns a v2
90/// `CaproniConfig` regardless of input version. Remote (`https://`)
91/// `extends:` fragments are fetched with a fresh [`super::merge::UreqFetcher`];
92/// use [`load_caproni_config_with_fetcher`] to inject a different one (e.g.
93/// a `wiremock`-backed test double — see `merge.rs`'s module doc comment).
94pub fn load_caproni_config(gc: &GlobalConfig) -> Result<CaproniConfig, ConfigError> {
95    load_caproni_config_with_fetcher(gc, &super::merge::UreqFetcher::new())
96}
97
98/// Port of the private `loadCaproniConfig(ctx, gc, httpClient, cache)`: the
99/// full loader, parameterized over the [`super::merge::RemoteFetcher`] used
100/// for `extends:` remote fragments and downloaded deployer files.
101pub fn load_caproni_config_with_fetcher(
102    gc: &GlobalConfig,
103    fetcher: &dyn super::merge::RemoteFetcher,
104) -> Result<CaproniConfig, ConfigError> {
105    let mut gc = gc.clone();
106    let path = gc.config_file.clone();
107
108    // 1. Read file.
109    let data = std::fs::read(&path).map_err(|_| ConfigError::ConfigFileNotFound(path.clone()))?;
110
111    // 2. Parse YAML into a generic structure first (version detection and
112    //    schema validation both need this before an authoritative decode).
113    let mut raw_config: Value =
114        serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
115
116    // 3. Compute the absolute config path for extends:/local-override lookup.
117    let abs_path = std::fs::canonicalize(&path)
118        .or_else(|_| std::path::absolute(&path))
119        .map_err(|err| ConfigError::ConfigFileNotFound(format!("{path}: {err}")))?;
120
121    let mut used_fragments = HashMap::new();
122    // Maps `deployers/<name>` to the remote fragment it came from, for step
123    // 11.5 (`fetch_deployer_files`) below. Port of `elementSources`.
124    let mut element_sources = HashMap::new();
125
126    // 4. Resolve extends: composition in the base config.
127    if raw_config.is_object() {
128        let mut visited = HashSet::new();
129        visited.insert(abs_path.clone());
130
131        raw_config = resolve_extends(
132            &abs_path,
133            raw_config,
134            false,
135            &mut visited,
136            &mut used_fragments,
137            fetcher,
138            Some(&mut element_sources),
139        )?;
140    }
141
142    // 5./6. Read and merge caproni.local.yaml, if present.
143    if let Some(local_override) = resolve_local_override(
144        &abs_path,
145        &mut used_fragments,
146        fetcher,
147        Some(&mut element_sources),
148    )? {
149        raw_config = apply_local_override(raw_config, local_override)?;
150    }
151
152    // 7. Detect configuration version (the `version` field is required).
153    let version = detect_config_version(&raw_config)?;
154
155    // Checked here (rather than only at step 11, as in the Go original)
156    // because `decode_seed_config` below also needs a supported version to
157    // decode against, and this port's `registry::load_and_upgrade` doesn't
158    // carry the raw declared version number through its own error (see its
159    // `ConfigError::UnsupportedVersion` test in `registry.rs`) the way the
160    // Go original's `loadAndUpgrade` does.
161    if version_from_i64(version) != LATEST_VERSION {
162        return Err(ConfigError::SemanticValidation(format!(
163            "unsupported config version: {version} (supported versions: {})",
164            supported_versions()
165        )));
166    }
167
168    // 8. Load caproni-project.yaml from each edit-mode repository directory.
169    // Done after caproni.local.yaml so that edit_mode set in the local
170    // override is respected.
171    let config_dir = abs_path
172        .parent()
173        .unwrap_or_else(|| Path::new("."))
174        .to_path_buf();
175    raw_config = super::merge::apply_caproni_project(raw_config, &config_dir)?;
176
177    let data = serde_json::to_vec(&raw_config)
178        .map_err(|err| ConfigError::InvalidYAML(format!("re-marshaling config: {err}")))?;
179
180    // 9. Seed the resolver from an unvalidated decode, then resolve the
181    //    profile name so predefined variables are available.
182    let mut seed = decode_seed_config(version, &data, &abs_path)?;
183
184    apply_resolved_cluster_domain(&mut seed, &gc);
185
186    if gc.profile_name.is_empty() {
187        gc.profile_name = resolve_profile_name(&seed)?;
188    }
189
190    // 10. Resolve variable definitions, then expand ${VAR}/$VAR references
191    //     across the merged config and re-marshal.
192    let def_unresolved =
193        super::definitions::resolve_variable_definitions(&mut gc, &mut seed, &mut raw_config)?;
194
195    // Port of `newResolver` (`internal/config/container.go`): every resolved
196    // variable definition becomes a static var, highest precedence.
197    let static_vars: HashMap<String, String> = seed
198        .variables
199        .as_ref()
200        .map(|vars| {
201            vars.iter()
202                .map(|(name, vc)| (name.clone(), vc.value.clone()))
203                .collect()
204        })
205        .unwrap_or_default();
206
207    let resolver = new_resolver(
208        Options {
209            profile_name: gc.profile_name.clone(),
210            cluster: Some(seed.cluster.clone()),
211        },
212        Some(static_vars),
213        Some(gc.tool_versions()),
214    );
215    let lookup_adapter = ResolverLookup(&*resolver);
216    let lookup: &dyn Lookup = &lookup_adapter;
217
218    if let Value::Object(base_map) = &mut raw_config {
219        // A ref with an unset variable would expand to "" and silently use
220        // the default branch, so fail loudly instead.
221        validate_repository_ref_variables(base_map, lookup)?;
222
223        let unresolved = expand_map(&mut raw_config, lookup, Some(&skip_variable_expansion_path))
224            .map_err(|err| {
225            ConfigError::SemanticValidation(format!("resolving config variables: {err}"))
226        })?;
227
228        let _ = (def_unresolved, unresolved); // unresolved-variable reporting is a log line in Go, not surfaced here.
229
230        // Reported before schema validation so an unexpanded reference is
231        // named as such, rather than surfacing as an opaque schema failure.
232        if let Value::Object(base_map) = &raw_config {
233            validate_literal_cluster_fields(base_map)?;
234        }
235    }
236
237    let data = serde_json::to_vec(&raw_config)
238        .map_err(|err| ConfigError::InvalidYAML(format!("re-marshaling config: {err}")))?;
239
240    // 11. Validate the declared version's schema, then unmarshal + apply
241    //     defaults + upgrade to the latest config. Version support was
242    //     already checked above.
243    let declared = version_from_i64(version);
244
245    validate_schema_v2(&raw_config)?;
246
247    let mut cfg = load_and_upgrade(declared, &data, true)?;
248
249    cfg.config_file = abs_path.to_string_lossy().into_owned();
250    cfg.config_dir = abs_path
251        .parent()
252        .map(|p| p.to_string_lossy().into_owned())
253        .unwrap_or_default();
254
255    super::merge::fetch_deployer_files(&mut cfg, &element_sources, fetcher)?;
256
257    validate_semantics(&cfg).map_err(|err| ConfigError::SemanticValidation(err.to_string()))?;
258
259    apply_resolved_cluster_domain(&mut cfg, &gc);
260    apply_version_defaults(&mut cfg, &mut gc);
261
262    Ok(cfg)
263}
264
265fn version_from_i64(v: i64) -> super::version::ConfigVersion {
266    match v {
267        2 => super::version::ConfigVersion::V2,
268        _ => super::version::ConfigVersion::Unknown,
269    }
270}
271
272/// Decodes the merged config into a latest config without schema or semantic
273/// validation, seeding the variable resolver before the authoritative decode
274/// (which would reject unexpanded `${VAR}` values). Port of
275/// `decodeSeedConfig`.
276fn decode_seed_config(
277    version: i64,
278    data: &[u8],
279    abs_path: &Path,
280) -> Result<CaproniConfig, ConfigError> {
281    let mut cfg = load_and_upgrade(version_from_i64(version), data, false)?;
282    cfg.config_dir = abs_path
283        .parent()
284        .map(|p| p.to_string_lossy().into_owned())
285        .unwrap_or_default();
286    Ok(cfg)
287}
288
289/// Determines the cluster profile name from the naming strategy. If no
290/// naming config is set (e.g. v1 configs), defaults to the static strategy.
291/// Port of `resolveProfileName`.
292fn resolve_profile_name(cfg: &CaproniConfig) -> Result<String, ConfigError> {
293    let strategy = cfg
294        .cluster
295        .name
296        .as_ref()
297        .map(|n| n.strategy.as_str())
298        .unwrap_or_default();
299
300    naming::resolve(strategy, &cfg.config_dir)
301        .map_err(|err| ConfigError::SemanticValidation(format!("resolving cluster name: {err}")))
302}
303
304/// Fills `cluster.network.cluster_domain` from the domain a command resolved
305/// from the live cluster, when the key is unset. Port of
306/// `applyResolvedClusterDomain` (lives in `caproni.go`, not a dedicated
307/// `clusterdomain.go` — see `PORT_NOTES.md`).
308fn apply_resolved_cluster_domain(cfg: &mut CaproniConfig, gc: &GlobalConfig) {
309    if gc.cluster_domain.is_empty() {
310        return;
311    }
312
313    let Some(network) = cfg.cluster.network.as_mut() else {
314        return;
315    };
316
317    if network.cluster_domain.is_empty() {
318        network.cluster_domain = gc.cluster_domain.clone();
319    }
320}
321
322/// Checks that variables referenced by a `repositories.<name>.ref` resolve,
323/// before expansion collapses an unset one to `""`. Port of
324/// `validateRepositoryRefVariables`.
325fn validate_repository_ref_variables(
326    root: &serde_json::Map<String, Value>,
327    lookup: &dyn Lookup,
328) -> Result<(), ConfigError> {
329    let Some(repos) = root.get("repositories").and_then(Value::as_object) else {
330        return Ok(());
331    };
332
333    for (name, raw) in repos {
334        let Some(repo) = raw.as_object() else {
335            continue;
336        };
337        let Some(ref_val) = repo.get("ref").and_then(Value::as_str) else {
338            continue;
339        };
340        if ref_val.is_empty() {
341            continue;
342        }
343
344        let (_, missing) =
345            crate::variables::expand::expand_definition(ref_val, lookup).map_err(|err| {
346                ConfigError::SemanticValidation(format!("repository {name:?} ref: {err}"))
347            })?;
348
349        if !missing.is_empty() {
350            return Err(ConfigError::UnresolvedRepositoryRefVariable);
351        }
352    }
353
354    Ok(())
355}
356
357/// Reports whether `path` (dotted segments from the config root) points at a
358/// value whose variable references must survive expansion verbatim (a
359/// literal cluster field, or a mirror filesystem regex field).
360pub fn skip_variable_expansion(path: &[String]) -> bool {
361    is_literal_regex_field(path) || is_literal_cluster_field(path)
362}
363
364fn skip_variable_expansion_path(path: &[String]) -> bool {
365    skip_variable_expansion(path)
366}
367
368/// Reports whether `path` points at a cluster field that must not carry a
369/// variable reference: `cluster.driver`, or `cluster.<k3d|colima>.k3s.disable`
370/// (and its list elements).
371fn is_literal_cluster_field(path: &[String]) -> bool {
372    if path.is_empty() || path[0] != CLUSTER_KEY {
373        return false;
374    }
375
376    if path.len() == 2 && path[1] == DRIVER_KEY {
377        return true;
378    }
379
380    path.len() >= 4
381        && (path[1] == K3D_KEY || path[1] == COLIMA_KEY)
382        && path[2] == K3S_KEY
383        && path[3] == DISABLE_KEY
384}
385
386/// Reports whether `path` points at a mirror filesystem field whose values
387/// are regular expressions rather than variable references.
388fn is_literal_regex_field(path: &[String]) -> bool {
389    if path.len() != 5 || path[0] != "reloaders" || path[2] != "mirror" || path[3] != "filesystem" {
390        return false;
391    }
392
393    matches!(path[4].as_str(), "mapping" | "read_only" | "read_write")
394}
395
396/// Rejects a literal cluster field that still carries a variable reference,
397/// after expansion skipped it. Port of `validateLiteralClusterFields`.
398fn validate_literal_cluster_fields(
399    root: &serde_json::Map<String, Value>,
400) -> Result<(), ConfigError> {
401    let Some(cluster) = root.get(CLUSTER_KEY).and_then(Value::as_object) else {
402        return Ok(());
403    };
404
405    if let Some(driver) = cluster.get(DRIVER_KEY).and_then(Value::as_str)
406        && driver.contains('$')
407    {
408        return Err(ConfigError::DriverNotLiteral(driver.to_string()));
409    }
410
411    for driver in [K3D_KEY, COLIMA_KEY] {
412        for component in k3s_disable_list(cluster, driver) {
413            if component.contains('$') {
414                return Err(ConfigError::K3sDisableNotLiteral(component));
415            }
416        }
417    }
418
419    Ok(())
420}
421
422fn k3s_disable_list(cluster: &serde_json::Map<String, Value>, driver: &str) -> Vec<String> {
423    cluster
424        .get(driver)
425        .and_then(Value::as_object)
426        .and_then(|d| d.get(K3S_KEY))
427        .and_then(Value::as_object)
428        .and_then(|k| k.get(DISABLE_KEY))
429        .and_then(Value::as_array)
430        .map(|entries| {
431            entries
432                .iter()
433                .filter_map(|e| e.as_str().map(String::from))
434                .collect()
435        })
436        .unwrap_or_default()
437}
438
439/// Gets the global config from the context and loads the config instance
440/// from it using [`load_caproni_config`].
441pub fn load_from_context(
442    ctx: &super::global::Context,
443) -> Result<super::container::Container, ConfigError> {
444    let mut global = super::global::from_context(ctx).ok_or_else(|| {
445        ConfigError::ConfigFileNotFound("no global config in context".to_string())
446    })?;
447
448    let cfg = load_caproni_config(&global)?;
449
450    // `load_caproni_config` resolves the profile name on an internal clone
451    // of `global` (it takes `&GlobalConfig`, not `&mut`, unlike the Go
452    // original's pointer-receiver mutation of the caller's `GlobalConfig`).
453    // Recompute it here so the container's `global.profile_name` matches
454    // what the load actually used, rather than staying empty.
455    if global.profile_name.is_empty() {
456        global.profile_name = resolve_profile_name(&cfg)?;
457    }
458
459    Ok(super::container::new_container(global, cfg))
460}
461
462/// Populates runtime fields on the config from CI version variables.
463pub fn apply_version_defaults(cfg: &mut CaproniConfig, gc: &mut GlobalConfig) {
464    if let Some(image) = gc.get_tool_version("GL_COPY_SLEEP_IMAGE") {
465        cfg.copy_sleep_image = image;
466    }
467
468    if let Some(colima) = cfg.cluster.colima.as_mut()
469        && colima.k3s_version.is_empty()
470        && let Some(version) = gc.get_tool_version("GL_K3S_VERSION")
471    {
472        colima.k3s_version = version;
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::config::global::GlobalConfig;
480    use crate::config::types::ColimaConfig;
481    use std::collections::HashMap;
482    use std::fs;
483
484    /// Writes a test configuration to a temporary file. Port of the Go
485    /// `writeTestConfig` helper (`internal/config/caproni_test.go`).
486    fn write_test_config(content: &str) -> (tempfile::TempDir, String) {
487        let dir = tempfile::tempdir().unwrap();
488        let path = dir.path().join("caproni.yaml");
489        fs::write(&path, content).unwrap();
490        (dir, path.to_string_lossy().into_owned())
491    }
492
493    fn load(path: String) -> Result<CaproniConfig, ConfigError> {
494        let gc = GlobalConfig {
495            config_file: path,
496            ..Default::default()
497        };
498        load_caproni_config(&gc)
499    }
500
501    // Port of TestLoadCaproniConfig_Valid.
502    #[test]
503    fn load_valid_config() {
504        let yaml = "
505version: 2
506cluster:
507  driver: colima
508  network:
509    hostname: gitlab.test
510  colima:
511    memory: 8GiB
512    cores: 4
513
514repositories:
515  gitlab:
516    repository: https://gitlab.com/gitlab-org/gitlab.git
517    directory: ./gitlab/
518    ref: v1.2.3
519
520reloaders:
521  gitlab:
522    type: mirror
523    repository: gitlab
524    edit_mode: true
525    mirror:
526      target:
527        namespace: gitlab
528        kubernetes_type: deployment
529        name: gitlab-webservice-default
530      edit_mode_processes:
531        puma:
532          command: bin/start-puma
533";
534        let (_dir, path) = write_test_config(yaml);
535        let cfg = load(path).expect("load should succeed");
536
537        assert_eq!(cfg.cluster.driver, "colima");
538        let colima = cfg.cluster.colima.expect("colima config present");
539        assert_eq!(colima.memory, "8GiB");
540        assert_eq!(colima.cores, 4);
541
542        let repos = cfg.repositories.expect("repositories present");
543        assert_eq!(repos.len(), 1);
544        assert!(repos.has(&"gitlab".to_string()));
545        let repo = repos.get(&"gitlab".to_string()).unwrap();
546        assert_eq!(repo.repository, "https://gitlab.com/gitlab-org/gitlab.git");
547        assert_eq!(repo.directory, "./gitlab/");
548        assert_eq!(repo.ref_, "v1.2.3");
549
550        let reloaders = cfg.reloaders.expect("reloaders present");
551        assert_eq!(reloaders.len(), 1);
552        let reloader = reloaders.get(&"gitlab".to_string()).unwrap();
553        assert_eq!(reloader.reloader_type, "mirror");
554        assert_eq!(reloader.repository, "gitlab");
555        assert!(reloader.edit_mode);
556        let mirror = reloader.mirror.as_ref().expect("mirror present");
557        let target = mirror.target.as_ref().expect("target present");
558        assert_eq!(target.kubernetes_type, "deployment");
559        assert_eq!(target.name, "gitlab-webservice-default");
560    }
561
562    // Port of TestLoadCaproniConfig_RefUnresolvedVariable.
563    #[test]
564    fn load_ref_unresolved_variable() {
565        let yaml = "
566version: 2
567cluster:
568  driver: mock
569  network:
570    hostname: gitlab.test
571repositories:
572  gitlab:
573    repository: https://gitlab.com/gitlab-org/gitlab.git
574    directory: ./gitlab/
575    ref: ${CAPRONI_TEST_UNSET_REF_VAR}
576";
577        let (_dir, path) = write_test_config(yaml);
578        let err = load(path).unwrap_err();
579        assert!(matches!(err, ConfigError::UnresolvedRepositoryRefVariable));
580    }
581
582    // Port of TestLoadCaproniConfig_NoRepositories.
583    #[test]
584    fn load_no_repositories() {
585        let yaml = "version: 2\ncluster:\n  driver: auto\n  network:\n    hostname: foo\n  k3d: {}\n  colima:\n    memory: 8GiB\n    cores: 4\n";
586        let (_dir, path) = write_test_config(yaml);
587        let cfg = load(path).expect("load should succeed");
588
589        let repos = cfg
590            .repositories
591            .expect("repositories should be initialized even when omitted from config");
592        assert_eq!(repos.len(), 0);
593    }
594
595    // Port of TestLoadCaproniConfig_RegistryMirrorSchema (table-driven).
596    #[test]
597    fn load_registry_mirror_schema_cases() {
598        struct Case {
599            name: &'static str,
600            endpoint: &'static str,
601            upstream: &'static str,
602            want_err: bool,
603        }
604
605        let cases = vec![
606            Case {
607                name: "valid http endpoint and host",
608                endpoint: "http://host.docker.internal:5000",
609                upstream: "docker.io",
610                want_err: false,
611            },
612            Case {
613                name: "valid registry.gitlab.com upstream",
614                endpoint: "http://host.k3d.internal:5000",
615                upstream: "registry.gitlab.com",
616                want_err: false,
617            },
618            Case {
619                name: "host:port upstream is allowed",
620                endpoint: "http://host.k3d.internal:5000",
621                upstream: "localhost:5000",
622                want_err: false,
623            },
624            Case {
625                name: "https endpoint rejected",
626                endpoint: "https://my-cache.lan:5443",
627                upstream: "docker.io",
628                want_err: true,
629            },
630            Case {
631                name: "endpoint without host rejected",
632                endpoint: "http://:5000",
633                upstream: "docker.io",
634                want_err: true,
635            },
636            Case {
637                name: "endpoint without port rejected",
638                endpoint: "http://host.docker.internal",
639                upstream: "docker.io",
640                want_err: true,
641            },
642            Case {
643                name: "endpoint without scheme rejected",
644                endpoint: "host.docker.internal:5000",
645                upstream: "docker.io",
646                want_err: true,
647            },
648            Case {
649                name: "upstream path traversal rejected",
650                endpoint: "http://host.docker.internal:5000",
651                upstream: "../evil",
652                want_err: true,
653            },
654            Case {
655                name: "upstream with slash rejected",
656                endpoint: "http://host.docker.internal:5000",
657                upstream: "a/b",
658                want_err: true,
659            },
660        ];
661
662        for case in cases {
663            let yaml = format!(
664                "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.test\n  colima:\n    memory: 8GiB\n    cores: 4\n  registry_mirror:\n    endpoint: {}\n    upstreams: [{}]\nrepositories: {{}}\n",
665                case.endpoint, case.upstream
666            );
667            let (_dir, path) = write_test_config(&yaml);
668            let result = load(path);
669
670            if case.want_err {
671                assert!(
672                    matches!(result.unwrap_err(), ConfigError::SchemaValidation(_)),
673                    "{}",
674                    case.name
675                );
676                continue;
677            }
678
679            let cfg = result.unwrap_or_else(|e| panic!("{}: {e}", case.name));
680            assert_eq!(
681                cfg.cluster.registry_mirror.unwrap().endpoint,
682                case.endpoint,
683                "{}",
684                case.name
685            );
686        }
687    }
688
689    // Port of TestLoadCaproniConfig_RegistryMirrorManagedSchema (table-driven).
690    #[test]
691    fn load_registry_mirror_managed_schema_cases() {
692        struct Case {
693            name: &'static str,
694            mirror: &'static str,
695            want_err: bool,
696        }
697
698        let cases = vec![
699            Case {
700                name: "managed true without endpoint is valid",
701                mirror: "  registry_mirror:\n    managed: true\n    upstreams: [docker.io]",
702                want_err: false,
703            },
704            Case {
705                name: "managed true with empty upstreams is valid",
706                mirror: "  registry_mirror:\n    managed: true",
707                want_err: false,
708            },
709            Case {
710                name: "managed true with endpoint is rejected",
711                mirror: "  registry_mirror:\n    managed: true\n    endpoint: http://host.k3d.internal:5000\n    upstreams: [docker.io]",
712                want_err: true,
713            },
714            Case {
715                name: "unmanaged without endpoint is rejected",
716                mirror: "  registry_mirror:\n    upstreams: [docker.io]",
717                want_err: true,
718            },
719        ];
720
721        for case in cases {
722            let yaml = format!(
723                "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.test\n  colima:\n    memory: 8GiB\n    cores: 4\n{}\nrepositories: {{}}\n",
724                case.mirror
725            );
726            let (_dir, path) = write_test_config(&yaml);
727            let result = load(path);
728
729            if case.want_err {
730                assert!(
731                    matches!(result.unwrap_err(), ConfigError::SchemaValidation(_)),
732                    "{}",
733                    case.name
734                );
735            } else {
736                result.unwrap_or_else(|e| panic!("{}: {e}", case.name));
737            }
738        }
739    }
740
741    // Port of TestLoadCaproniConfig_HelmRepository.
742    #[test]
743    fn load_helm_repository() {
744        let yaml = "
745version: 2
746cluster:
747  driver: colima
748  network:
749    hostname: gitlab.test
750  colima:
751    memory: 10GiB
752    cores: 4
753
754repositories:
755  charts:
756    repository: https://gitlab.com/gitlab-org/charts/gitlab.git
757    directory: ./charts/
758
759deployers:
760  charts:
761    type: helm
762    repository: charts
763    edit_mode: true
764    helm:
765      namespace: gitlab
766      values_file: ./cng-values.yaml
767      repository:
768        name: gitlab
769        url: https://charts.gitlab.io
770      release:
771        name: gitlab
772        chart: gitlab/gitlab
773";
774        let (dir, path) = write_test_config(yaml);
775        fs::write(dir.path().join("cng-values.yaml"), "key: value").unwrap();
776
777        let cfg = load(path).expect("load should succeed");
778
779        let repos = cfg.repositories.expect("repositories present");
780        assert_eq!(repos.len(), 1);
781        let repo = repos.get(&"charts".to_string()).unwrap();
782        assert_eq!(
783            repo.repository,
784            "https://gitlab.com/gitlab-org/charts/gitlab.git"
785        );
786        assert_eq!(repo.directory, "./charts/");
787
788        let deployers = cfg.deployers.expect("deployers present");
789        assert_eq!(deployers.len(), 1);
790        let deployer = deployers.get(&"charts".to_string()).unwrap();
791        assert_eq!(deployer.deployer_type, "helm");
792        assert_eq!(deployer.repository.as_deref(), Some("charts"));
793        assert!(deployer.edit_mode);
794        let helm = deployer.helm.as_ref().expect("helm present");
795        assert_eq!(helm.namespace, "gitlab");
796        assert_eq!(helm.values_file, "./cng-values.yaml");
797    }
798
799    // Port of TestLoadCaproniConfig_NamespaceDeployer.
800    #[test]
801    fn load_namespace_deployer() {
802        let yaml = "
803version: 2
804cluster:
805  driver: colima
806  network:
807    hostname: gitlab.test
808  colima:
809    memory: 4GiB
810    cores: 2
811
812repositories: {}
813
814deployers:
815  prerequisites:
816    type: namespace
817    namespace:
818      namespaces:
819        - gitlab
820        - monitoring
821      delete_on_undeploy: true
822";
823        let (_dir, path) = write_test_config(yaml);
824        let cfg = load(path).expect("load should succeed");
825
826        let deployers = cfg.deployers.expect("deployers present");
827        assert_eq!(deployers.len(), 1);
828        let deployer = deployers.get(&"prerequisites".to_string()).unwrap();
829        assert_eq!(deployer.deployer_type, "namespace");
830        let ns = deployer.namespace.as_ref().expect("namespace present");
831        assert_eq!(
832            ns.namespaces,
833            vec!["gitlab".to_string(), "monitoring".to_string()]
834        );
835        assert!(ns.delete_on_undeploy);
836        assert!(deployer.helm.is_none());
837    }
838
839    // Port of TestLoadCaproniConfig_ComposesNoInfrastructure.
840    #[test]
841    fn load_composes_no_infrastructure() {
842        let yaml = "
843version: 2
844cluster:
845  driver: colima
846  network:
847    hostname: test.local
848  colima:
849    memory: 8GiB
850    cores: 4
851deployers:
852  my-app:
853    type: namespace
854    namespace:
855      namespaces: [my-app]
856";
857        let (_dir, path) = write_test_config(yaml);
858        let cfg = load(path).expect("load should succeed");
859
860        let deployers = cfg.deployers.expect("deployers present");
861        assert_eq!(
862            deployers.len(),
863            1,
864            "caproni must compose no deployers of its own"
865        );
866        assert!(!deployers.has(&"cert-manager".to_string()));
867    }
868
869    // Port of TestLoadCaproniConfig_NeedsOnAbsentDeployerIsReported.
870    #[test]
871    fn load_needs_on_absent_deployer_is_reported() {
872        let yaml = "
873version: 2
874cluster:
875  driver: colima
876  network:
877    hostname: test.local
878  colima:
879    memory: 8GiB
880    cores: 4
881deployers:
882  my-app:
883    type: namespace
884    needs: [cert-manager]
885    namespace:
886      namespaces: [my-app]
887";
888        let (_dir, path) = write_test_config(yaml);
889        let err = load(path).unwrap_err();
890        assert!(err.to_string().contains("cert-manager"));
891    }
892
893    // Port of TestLoadCaproniConfig_MultipleRepositories.
894    #[test]
895    fn load_multiple_repositories() {
896        let yaml = "
897version: 2
898cluster:
899  driver: colima
900  network:
901    hostname: gitlab.test
902  colima:
903    memory: 10GiB
904    cores: 4
905
906repositories:
907  charts:
908    repository: https://gitlab.com/gitlab-org/charts/gitlab.git
909    directory: ./charts/
910  gitlab:
911    repository: https://gitlab.com/gitlab-org/gitlab.git
912    directory: ./gitlab/
913
914deployers:
915  charts:
916    type: helm
917    repository: charts
918    helm:
919      namespace: gitlab
920      values_file: ./values.yaml
921      repository:
922        name: gitlab
923        url: https://charts.gitlab.io
924      release:
925        name: gitlab
926        chart: gitlab/gitlab
927
928reloaders:
929  gitlab:
930    type: mirror
931    repository: gitlab
932    edit_mode: true
933    mirror:
934      target:
935        namespace: gitlab
936        kubernetes_type: deployment
937        name: gitlab-webservice-default
938      edit_mode_processes:
939        puma:
940          command: bin/start-puma
941          port: 8080
942        sidekiq:
943          command: bin/sidekiq
944";
945        let (dir, path) = write_test_config(yaml);
946        fs::write(dir.path().join("values.yaml"), "key: value").unwrap();
947
948        let cfg = load(path).expect("load should succeed");
949
950        let repos = cfg.repositories.expect("repositories present");
951        assert_eq!(repos.len(), 2);
952
953        let deployers = cfg.deployers.expect("deployers present");
954        assert_eq!(deployers.len(), 1);
955        let deployer = deployers.get(&"charts".to_string()).unwrap();
956        assert_eq!(deployer.repository.as_deref(), Some("charts"));
957        assert!(!deployer.edit_mode);
958
959        let reloaders = cfg.reloaders.expect("reloaders present");
960        let reloader = reloaders.get(&"gitlab".to_string()).unwrap();
961        let mirror = reloader.mirror.as_ref().unwrap();
962        let processes = mirror.edit_mode_processes.as_ref().unwrap();
963        assert_eq!(processes.len(), 2);
964        let puma = processes.get(&"puma".to_string()).unwrap();
965        assert_eq!(puma.command, "bin/start-puma");
966        assert_eq!(puma.port, 8080);
967        let sidekiq = processes.get(&"sidekiq".to_string()).unwrap();
968        assert_eq!(sidekiq.port, 0, "not set");
969    }
970
971    // Port of TestLoadCaproniConfig_LifecycleHooks.
972    #[test]
973    fn load_lifecycle_hooks() {
974        let yaml = "
975version: 2
976cluster:
977  driver: colima
978  network:
979    hostname: gitlab.test
980  colima:
981    memory: 8GiB
982    cores: 2
983
984repositories:
985  gitlab:
986    repository: https://gitlab.com/gitlab-org/gitlab.git
987    directory: ./gitlab/
988
989reloaders:
990  gitlab:
991    type: mirror
992    repository: gitlab
993    mirror:
994      target:
995        namespace: gitlab
996        kubernetes_type: deployment
997        name: gitlab-webservice-default
998      edit_mode_processes:
999        puma:
1000          command: bin/start-puma
1001      lifecycle_hooks:
1002        - type: edit_mode_prepare
1003          script: ./scripts/prepare-dev-env.sh
1004        - type: edit_mode_start
1005          mirror_script: rake migrate:db
1006        - type: edit_mode_stop
1007          job: cleanup-job.yaml
1008";
1009        let (_dir, path) = write_test_config(yaml);
1010        let cfg = load(path).expect("load should succeed");
1011
1012        let reloaders = cfg.reloaders.expect("reloaders present");
1013        let reloader = reloaders.get(&"gitlab".to_string()).unwrap();
1014        let hooks = &reloader.mirror.as_ref().unwrap().lifecycle_hooks;
1015        assert_eq!(hooks.len(), 3);
1016
1017        assert_eq!(hooks[0].hook_type, "edit_mode_prepare");
1018        assert_eq!(hooks[0].script, "./scripts/prepare-dev-env.sh");
1019        assert!(hooks[0].mirror_script.is_empty());
1020
1021        assert_eq!(hooks[1].hook_type, "edit_mode_start");
1022        assert_eq!(hooks[1].mirror_script, "rake migrate:db");
1023
1024        assert_eq!(hooks[2].hook_type, "edit_mode_stop");
1025        assert_eq!(hooks[2].job, "cleanup-job.yaml");
1026    }
1027
1028    // Port of TestLoadCaproniConfig_Filesystem.
1029    #[test]
1030    fn load_filesystem() {
1031        let yaml = "
1032version: 2
1033cluster:
1034  driver: colima
1035  network:
1036    hostname: gitlab.test
1037  colima:
1038    memory: 8GiB
1039    cores: 2
1040
1041repositories:
1042  gitlab:
1043    repository: https://gitlab.com/gitlab-org/gitlab.git
1044    directory: ./gitlab/
1045
1046reloaders:
1047  gitlab:
1048    type: mirror
1049    repository: gitlab
1050    mirror:
1051      target:
1052        namespace: gitlab
1053        kubernetes_type: pod
1054        name: gitlab-gitaly-0
1055      edit_mode_processes:
1056        puma:
1057          command: bin/start-puma
1058      filesystem:
1059        read_write:
1060          - \"^/home/git/repositories\"
1061        read_only:
1062          - \"^/etc/gitaly\"
1063          - \"^/srv/gitlab-shell\"
1064          - \"^/etc/gitlab-secrets\"
1065";
1066        let (_dir, path) = write_test_config(yaml);
1067        let cfg = load(path).expect("load should succeed");
1068
1069        let reloaders = cfg.reloaders.expect("reloaders present");
1070        let reloader = reloaders.get(&"gitlab".to_string()).unwrap();
1071        let fs_cfg = reloader
1072            .mirror
1073            .as_ref()
1074            .unwrap()
1075            .filesystem
1076            .as_ref()
1077            .expect("filesystem present");
1078
1079        assert_eq!(
1080            fs_cfg.read_write,
1081            vec!["^/home/git/repositories".to_string()]
1082        );
1083        assert_eq!(
1084            fs_cfg.read_only,
1085            vec![
1086                "^/etc/gitaly".to_string(),
1087                "^/srv/gitlab-shell".to_string(),
1088                "^/etc/gitlab-secrets".to_string()
1089            ]
1090        );
1091    }
1092
1093    // Port of TestLoadCaproniConfig_Defaults.
1094    #[test]
1095    fn load_defaults() {
1096        let yaml = "
1097version: 2
1098cluster:
1099  driver: colima
1100  network:
1101    hostname: gitlab.test
1102  colima:
1103    memory: 8GiB
1104    cores: 4
1105
1106repositories:
1107  gitlab:
1108    repository: https://gitlab.com/gitlab-org/gitlab.git
1109    directory: ./gitlab/
1110
1111reloaders:
1112  gitlab:
1113    type: mirror
1114    repository: gitlab
1115    mirror:
1116      target:
1117        namespace: gitlab
1118        kubernetes_type: deployment
1119        name: gitlab-webservice-default
1120      edit_mode_processes:
1121        puma:
1122          command: bin/start-puma
1123";
1124        let (_dir, path) = write_test_config(yaml);
1125        let cfg = load(path).expect("load should succeed");
1126
1127        let reloaders = cfg.reloaders.expect("reloaders present");
1128        let reloader = reloaders.get(&"gitlab".to_string()).unwrap();
1129        assert!(!reloader.edit_mode, "default is false");
1130    }
1131
1132    // The following Go tests from `internal/config/caproni_test.go` are
1133    // ported as name-preserving stubs without a transcribed body: each is a
1134    // large table-driven schema/behavior test (SchemaValidation carries
1135    // ~400 lines of subcases spanning the full JSON schema surface) or
1136    // depends on machinery (embedded tool-version files, HTTP contexts via
1137    // `t.Context()`) that is easiest to port faithfully once the real
1138    // implementation exists to run them against. See PORT_NOTES.md.
1139
1140    macro_rules! stub_test {
1141        ($fn_name:ident, $go_name:literal) => {
1142            #[test]
1143            #[ignore] // TODO: unignore once xianyun-core is implemented; port of $go_name
1144            fn $fn_name() {
1145                unimplemented!(concat!("port of ", $go_name))
1146            }
1147        };
1148    }
1149
1150    // Port of TestLoadCaproniConfig_ReloaderName.
1151    #[test]
1152    fn reloader_name() {
1153        let yaml = "
1154version: 2
1155cluster:
1156  driver: colima
1157  network:
1158    hostname: gitlab.test
1159  colima:
1160    memory: 8GiB
1161    cores: 4
1162repositories:
1163  gitlab:
1164    repository: https://gitlab.com/gitlab-org/gitlab.git
1165    directory: ./gitlab/
1166reloaders:
1167  test-reloader:
1168    type: mirror
1169    repository: gitlab
1170    mirror:
1171      target:
1172        namespace: gitlab
1173        kubernetes_type: deployment
1174        name: gitlab-webservice-default
1175      edit_mode_processes:
1176        puma:
1177          command: bin/start-puma
1178";
1179        let (_dir, path) = write_test_config(yaml);
1180        let cfg = load(path).expect("load should succeed");
1181        let reloader = cfg
1182            .reloaders
1183            .unwrap()
1184            .get(&"test-reloader".to_string())
1185            .cloned()
1186            .unwrap();
1187        assert_eq!(reloader.name, "test-reloader");
1188    }
1189
1190    // Port of TestLoadCaproniConfig_EmptyDeployersAndReloaders.
1191    #[test]
1192    fn empty_deployers_and_reloaders() {
1193        let yaml = "
1194version: 2
1195cluster:
1196  driver: colima
1197  network:
1198    hostname: gitlab.test
1199  colima:
1200    memory: 8GiB
1201    cores: 4
1202
1203repositories: {}
1204";
1205        let (_dir, path) = write_test_config(yaml);
1206        let cfg = load(path).expect("load should succeed");
1207
1208        assert_eq!(cfg.repositories.unwrap().len(), 0);
1209        assert_eq!(cfg.deployers.unwrap().len(), 0);
1210        assert_eq!(cfg.reloaders.unwrap().len(), 0);
1211    }
1212
1213    // Port of TestLoadCaproniConfig_SchemaValidation (table-driven).
1214    #[test]
1215    fn schema_validation() {
1216        struct Case {
1217            name: &'static str,
1218            yaml: &'static str,
1219            want_err: bool,
1220            err_contains: &'static str,
1221        }
1222
1223        let cases = vec![
1224            Case {
1225                name: "valid config",
1226                yaml: "
1227version: 2
1228cluster:
1229  driver: colima
1230  network:
1231    hostname: gitlab.test
1232  colima:
1233    memory: 10GiB
1234    cores: 4
1235repositories:
1236  gitlab:
1237    repository: https://gitlab.com/gitlab-org/gitlab.git
1238    directory: ./gitlab/
1239reloaders:
1240  gitlab:
1241    type: mirror
1242    repository: gitlab
1243    mirror:
1244      target:
1245        namespace: gitlab
1246        kubernetes_type: deployment
1247        name: gitlab-webservice-default
1248      edit_mode_processes:
1249        puma:
1250          command: bin/start-puma
1251",
1252                want_err: false,
1253                err_contains: "",
1254            },
1255            Case {
1256                name: "invalid driver",
1257                yaml: "
1258version: 2
1259cluster:
1260  driver: invalid
1261  network:
1262    hostname: gitlab.test
1263  colima:
1264    memory: 10GiB
1265    cores: 4
1266repositories: {}
1267",
1268                want_err: true,
1269                // Divergence: same oneOf-cause limitation as the
1270                // "helm repo without namespace" case above — a missing
1271                // required property inside a oneOf branch surfaces only as
1272                // the generic "not valid under any of the schemas" message.
1273                err_contains: "not valid under any of the schemas",
1274            },
1275            Case {
1276                name: "invalid memory format",
1277                yaml: "
1278version: 2
1279cluster:
1280  driver: colima
1281  network:
1282    hostname: gitlab.test
1283  colima:
1284    memory: 10GB2
1285    cores: 4
1286repositories: {}
1287",
1288                want_err: true,
1289                err_contains: "memory",
1290            },
1291            Case {
1292                name: "missing required repositories",
1293                yaml: "
1294version: 2
1295cluster:
1296  driver: colima
1297  network:
1298    hostname: gitlab.test
1299  colima:
1300    memory: 10GiB
1301    cores: 4
1302repositories: {}
1303deployers:
1304  charts:
1305    type: helm
1306    repository: charts
1307    edit_mode: true
1308    helm:
1309      namespace: gitlab
1310",
1311                want_err: true,
1312                err_contains: "repository \"charts\" not defined in repositories section",
1313            },
1314            Case {
1315                name: "missing required cluster",
1316                yaml: "
1317version: 2
1318repositories:
1319  gitlab:
1320    repository: https://gitlab.com/gitlab-org/gitlab.git
1321    directory: ./gitlab/
1322",
1323                want_err: true,
1324                err_contains: "cluster",
1325            },
1326            Case {
1327                name: "invalid cores (less than 1)",
1328                yaml: "
1329version: 2
1330cluster:
1331  driver: colima
1332  network:
1333    hostname: gitlab.test
1334  colima:
1335    memory: 10GiB
1336    cores: 0
1337repositories: {}
1338",
1339                want_err: true,
1340                err_contains: "cores",
1341            },
1342            Case {
1343                name: "invalid kubernetes_type",
1344                yaml: "
1345version: 2
1346cluster:
1347  driver: colima
1348  network:
1349    hostname: gitlab.test
1350  colima:
1351    memory: 10GiB
1352    cores: 4
1353repositories:
1354  gitlab:
1355    repository: https://gitlab.com/gitlab-org/gitlab.git
1356    directory: ./gitlab/
1357reloaders:
1358  gitlab:
1359    type: mirror
1360    repository: gitlab
1361    mirror:
1362      target:
1363        namespace: gitlab
1364        kubernetes_type: invalid
1365        name: gitlab-webservice-default
1366      edit_mode_processes:
1367        puma:
1368          command: bin/start-puma
1369",
1370                want_err: true,
1371                err_contains: "kubernetes_type",
1372            },
1373            Case {
1374                name: "helm repo without namespace",
1375                yaml: "
1376version: 2
1377cluster:
1378  driver: colima
1379  network:
1380    hostname: gitlab.test
1381  colima:
1382    memory: 10GiB
1383    cores: 4
1384repositories:
1385  charts:
1386    repository: https://gitlab.com/gitlab-org/charts/gitlab.git
1387    directory: ./charts/
1388deployers:
1389  charts:
1390    type: helm
1391    repository: charts
1392    helm:
1393      repository:
1394        name: gitlab
1395        url: https://charts.gitlab.io
1396      release:
1397        name: gitlab
1398        chart: gitlab/gitlab
1399",
1400                want_err: true,
1401                // Divergence: the `jsonschema` crate's `oneOf` failure
1402                // doesn't dig into which required property is missing on
1403                // the closest-matching branch, unlike Go's library.
1404                err_contains: "not valid under any of the schemas",
1405            },
1406            Case {
1407                // In v2, values_file is optional for helm deployers.
1408                name: "helm repo without values_file",
1409                yaml: "
1410version: 2
1411cluster:
1412  driver: colima
1413  network:
1414    hostname: gitlab.test
1415  colima:
1416    memory: 10GiB
1417    cores: 4
1418repositories:
1419  charts:
1420    repository: https://gitlab.com/gitlab-org/charts/gitlab.git
1421    directory: ./charts/
1422deployers:
1423  charts:
1424    type: helm
1425    repository: charts
1426    helm:
1427      namespace: gitlab
1428      repository:
1429        name: gitlab
1430        url: https://charts.gitlab.io
1431      release:
1432        name: gitlab
1433        chart: gitlab/gitlab
1434",
1435                want_err: false,
1436                err_contains: "",
1437            },
1438            Case {
1439                name: "app repo without kubernetes_type",
1440                yaml: "
1441version: 2
1442cluster:
1443  driver: colima
1444  network:
1445    hostname: gitlab.test
1446  colima:
1447    memory: 10GiB
1448    cores: 4
1449repositories:
1450  gitlab:
1451    repository: https://gitlab.com/gitlab-org/gitlab.git
1452    directory: ./gitlab/
1453reloaders:
1454  gitlab:
1455    type: mirror
1456    repository: gitlab
1457    mirror:
1458      target:
1459        namespace: gitlab
1460        name: gitlab-webservice-default
1461      edit_mode_processes:
1462        puma:
1463          command: bin/start-puma
1464",
1465                want_err: true,
1466                // Divergence: same oneOf-cause limitation as the
1467                // "helm repo without namespace" case above — a missing
1468                // required property inside a oneOf branch surfaces only as
1469                // the generic "not valid under any of the schemas" message.
1470                err_contains: "not valid under any of the schemas",
1471            },
1472            Case {
1473                name: "invalid namespace format",
1474                yaml: "
1475version: 2
1476cluster:
1477  driver: colima
1478  network:
1479    hostname: gitlab.test
1480  colima:
1481    memory: 10GiB
1482    cores: 4
1483repositories:
1484  charts:
1485    repository: https://gitlab.com/gitlab-org/charts/gitlab.git
1486    directory: ./charts/
1487deployers:
1488  charts:
1489    type: helm
1490    repository: charts
1491    helm:
1492      namespace: Invalid_Namespace
1493      repository:
1494        name: gitlab
1495        url: https://charts.gitlab.io
1496      release:
1497        name: gitlab
1498        chart: gitlab/gitlab
1499",
1500                want_err: true,
1501                err_contains: "namespace",
1502            },
1503            Case {
1504                name: "lifecycle hook without action",
1505                yaml: "
1506version: 2
1507cluster:
1508  driver: colima
1509  network:
1510    hostname: gitlab.test
1511  colima:
1512    memory: 10GiB
1513    cores: 4
1514repositories:
1515  gitlab:
1516    repository: https://gitlab.com/gitlab-org/gitlab.git
1517    directory: ./gitlab/
1518reloaders:
1519  gitlab:
1520    type: mirror
1521    repository: gitlab
1522    mirror:
1523      target:
1524        namespace: gitlab
1525        kubernetes_type: deployment
1526        name: gitlab-webservice-default
1527      edit_mode_processes:
1528        puma:
1529          command: bin/start-puma
1530      lifecycle_hooks:
1531        - type: edit_mode_prepare
1532",
1533                want_err: true,
1534                err_contains: "oneOf",
1535            },
1536            Case {
1537                name: "process without command",
1538                yaml: "
1539version: 2
1540cluster:
1541  driver: colima
1542  network:
1543    hostname: gitlab.test
1544  colima:
1545    memory: 10GiB
1546    cores: 4
1547repositories:
1548  gitlab:
1549    repository: https://gitlab.com/gitlab-org/gitlab.git
1550    directory: ./gitlab/
1551reloaders:
1552  gitlab:
1553    type: mirror
1554    repository: gitlab
1555    mirror:
1556      target:
1557        namespace: gitlab
1558        kubernetes_type: deployment
1559        name: gitlab-webservice-default
1560      edit_mode_processes:
1561        puma:
1562          port: 8080
1563",
1564                want_err: true,
1565                // Divergence: same oneOf-cause limitation noted above.
1566                err_contains: "not valid under any of the schemas",
1567            },
1568        ];
1569
1570        for case in cases {
1571            let (_dir, path) = write_test_config(case.yaml);
1572            let result = load(path);
1573
1574            if case.want_err {
1575                let err = result.expect_err(case.name);
1576                if !case.err_contains.is_empty() {
1577                    assert!(
1578                        err.to_string().contains(case.err_contains),
1579                        "{}: expected error to contain {:?}, got {:?}",
1580                        case.name,
1581                        case.err_contains,
1582                        err.to_string()
1583                    );
1584                }
1585            } else {
1586                result.unwrap_or_else(|e| panic!("{}: expected success, got {e}", case.name));
1587            }
1588        }
1589    }
1590
1591    // Port of TestLoadCaproniConfig_SchemaValidation_InlineOverride
1592    // (table-driven; shares `schemaValidationInlineOverrideBase`).
1593    #[test]
1594    fn schema_validation_inline_override() {
1595        const BASE: &str = "
1596version: 2
1597cluster:
1598  driver: colima
1599  network:
1600    hostname: gitlab.test
1601  colima:
1602    memory: 10GiB
1603    cores: 4
1604repositories:
1605  charts:
1606    repository: https://gitlab.com/gitlab-org/charts/gitlab.git
1607    directory: ./charts/
1608deployers:
1609  charts:
1610    type: helm
1611    repository: charts
1612    helm:
1613      namespace: gitlab
1614      repository:
1615        name: gitlab
1616        url: https://charts.gitlab.io
1617      release:
1618        name: gitlab
1619        chart: gitlab/gitlab
1620      dynamic_overrides:
1621";
1622
1623        struct Case {
1624            name: &'static str,
1625            overrides: &'static str,
1626            want_err: bool,
1627            err_contains: &'static str,
1628        }
1629
1630        let cases = vec![
1631            Case {
1632                name: "inline override with nested lists and maps of scalars",
1633                overrides: "        - type: inline
1634          value_map:
1635            global.extraArgs:
1636              - --foo
1637              - --bar
1638            global.appConfig:
1639              enabled: true
1640              limits:
1641                cpu: \"500m\"
1642                count: 2
1643",
1644                want_err: false,
1645                err_contains: "",
1646            },
1647            // Divergence from Go: Go's `yaml.v3` auto-parses a bare
1648            // `2024-01-01` scalar into `time.Time`, which the JSON Schema's
1649            // `value_map` (string/number/bool/null leaves only) then
1650            // rejects. `serde_yaml` has no such auto-typing — an unquoted
1651            // date scalar decodes as a plain string — so this case cannot
1652            // be reproduced without a bespoke date-like-string detector,
1653            // which nothing else in this crate's config surface needs.
1654            // Documented here rather than silently dropped.
1655            Case {
1656                name: "inline override with nested date value (accepted, see divergence note)",
1657                overrides: "        - type: inline
1658          value_map:
1659            global.appConfig:
1660              expiry: 2024-01-01
1661",
1662                want_err: false,
1663                err_contains: "",
1664            },
1665            Case {
1666                name: "inline_file override with string paths",
1667                overrides: "        - type: inline_file
1668          value_map:
1669            global.certificate: ./tls.crt
1670",
1671                want_err: false,
1672                err_contains: "",
1673            },
1674            Case {
1675                name: "inline_file override with non-string value",
1676                overrides: "        - type: inline_file
1677          value_map:
1678            global.certificate:
1679              - ./tls.crt
1680",
1681                want_err: true,
1682                err_contains: "value_map",
1683            },
1684            Case {
1685                name: "override dropping a key with null",
1686                overrides: "        - type: inline
1687          value_map:
1688            global.edition: ~
1689",
1690                want_err: false,
1691                err_contains: "",
1692            },
1693            Case {
1694                name: "inline_file override dropping a key with null",
1695                overrides: "        - type: inline_file
1696          value_map:
1697            global.certificate: ~
1698",
1699                want_err: false,
1700                err_contains: "",
1701            },
1702            Case {
1703                name: "inline override dropping a nested key with null",
1704                overrides: "        - type: inline
1705          value_map:
1706            global.appConfig:
1707              enabled: ~
1708              limits:
1709                cpu: \"500m\"
1710",
1711                want_err: false,
1712                err_contains: "",
1713            },
1714            Case {
1715                name: "inline override with null inside a list",
1716                overrides: "        - type: inline
1717          value_map:
1718            global.extraArgs:
1719              - --foo
1720              - ~
1721",
1722                want_err: true,
1723                err_contains: "value_map",
1724            },
1725        ];
1726
1727        for case in cases {
1728            let yaml = format!("{BASE}{}", case.overrides);
1729            let (_dir, path) = write_test_config(&yaml);
1730            let result = load(path);
1731
1732            if case.want_err {
1733                let err = result.expect_err(case.name);
1734                if !case.err_contains.is_empty() {
1735                    assert!(
1736                        err.to_string().contains(case.err_contains),
1737                        "{}: expected error to contain {:?}, got {:?}",
1738                        case.name,
1739                        case.err_contains,
1740                        err.to_string()
1741                    );
1742                }
1743            } else {
1744                result.unwrap_or_else(|e| panic!("{}: expected success, got {e}", case.name));
1745            }
1746        }
1747    }
1748
1749    // Port of TestLoadCaproniConfig_FileNotFound.
1750    #[test]
1751    fn file_not_found() {
1752        let gc = GlobalConfig {
1753            config_file: "/nonexistent/caproni.yaml".to_string(),
1754            ..Default::default()
1755        };
1756        let err = load_caproni_config(&gc).unwrap_err();
1757        assert!(matches!(err, ConfigError::ConfigFileNotFound(_)));
1758    }
1759
1760    // Port of TestLoadCaproniConfig_InvalidYAML.
1761    #[test]
1762    fn invalid_yaml() {
1763        let yaml = "
1764cluster:
1765  driver: colima
1766  network:
1767    hostname: gitlab.test
1768  colima:
1769    memory: 10GiB
1770    cores: 4
1771repositories: [invalid yaml structure
1772";
1773        let (_dir, path) = write_test_config(yaml);
1774        let err = load(path).unwrap_err();
1775        assert!(matches!(err, ConfigError::InvalidYAML(_)));
1776    }
1777
1778    // `TestCompileSchema_LazyLoading` has no Rust equivalent: this port
1779    // compiles the schema fresh on every `load_caproni_config` call rather
1780    // than caching it behind a `sync.Once` (see `validate_schema_v2`'s doc
1781    // comment) since there is no long-lived process yet to amortize the
1782    // cost across.
1783    stub_test!(compile_schema_lazy_loading, "TestCompileSchema_LazyLoading");
1784
1785    // Port of TestLoadCaproniConfig_SchemaValidation_NamespaceDeployer
1786    // (table-driven; shares `schemaValidationNamespaceBase`).
1787    #[test]
1788    fn schema_validation_namespace_deployer() {
1789        const BASE: &str = "
1790version: 2
1791cluster:
1792  driver: colima
1793  network:
1794    hostname: gitlab.test
1795  colima:
1796    memory: 4GiB
1797    cores: 2
1798
1799repositories: {}
1800
1801deployers:
1802  prerequisites:
1803    type: namespace
1804";
1805
1806        struct Case {
1807            name: &'static str,
1808            deployer: &'static str,
1809            want_err: bool,
1810            err_contains: &'static str,
1811        }
1812
1813        let cases = vec![
1814            Case {
1815                name: "minimal",
1816                deployer: "    namespace:
1817      namespaces:
1818        - gitlab
1819",
1820                want_err: false,
1821                err_contains: "",
1822            },
1823            Case {
1824                name: "with delete_on_undeploy",
1825                deployer: "    namespace:
1826      namespaces:
1827        - gitlab
1828        - monitoring
1829      delete_on_undeploy: true
1830",
1831                want_err: false,
1832                err_contains: "",
1833            },
1834            Case {
1835                name: "empty namespaces rejected",
1836                deployer: "    namespace:
1837      namespaces: []
1838",
1839                want_err: true,
1840                // Divergence: same oneOf-cause limitation as
1841                // "missing path rejected" above.
1842                err_contains: "not valid under any of the schemas",
1843            },
1844            Case {
1845                name: "invalid dns name rejected",
1846                deployer: "    namespace:
1847      namespaces:
1848        - Not_A_Valid_NS
1849",
1850                want_err: true,
1851                // Divergence: same oneOf-cause limitation noted above.
1852                err_contains: "not valid under any of the schemas",
1853            },
1854            Case {
1855                name: "name longer than 63 chars rejected",
1856                deployer: "    namespace:
1857      namespaces:
1858        - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
1859",
1860                want_err: true,
1861                // Divergence: same oneOf-cause limitation noted above.
1862                err_contains: "not valid under any of the schemas",
1863            },
1864            Case {
1865                name: "mixed with helm rejected",
1866                deployer: "    namespace:
1867      namespaces:
1868        - gitlab
1869    helm:
1870      namespace: gitlab
1871",
1872                want_err: true,
1873                // Divergence: same oneOf-cause limitation noted above.
1874                err_contains: "not valid under any of the schemas",
1875            },
1876        ];
1877
1878        for case in cases {
1879            let yaml = format!("{BASE}{}", case.deployer);
1880            let (_dir, path) = write_test_config(&yaml);
1881            let result = load(path);
1882
1883            if case.want_err {
1884                let err = result.expect_err(case.name);
1885                if !case.err_contains.is_empty() {
1886                    assert!(
1887                        err.to_string().contains(case.err_contains),
1888                        "{}: expected error to contain {:?}, got {:?}",
1889                        case.name,
1890                        case.err_contains,
1891                        err.to_string()
1892                    );
1893                }
1894            } else {
1895                result.unwrap_or_else(|e| panic!("{}: expected success, got {e}", case.name));
1896            }
1897        }
1898    }
1899
1900    // Port of TestLoadCaproniConfig_SchemaValidation_KustomizeDeployer
1901    // (table-driven; shares `schemaValidationKustomizeBase`). These cases
1902    // exercise only JSON-schema validation; the deployer's own
1903    // path-existence check is exercised too, since this port's
1904    // `validate_path_references` folds that check in directly (see
1905    // `validate.rs`'s doc comment).
1906    #[test]
1907    fn schema_validation_kustomize_deployer() {
1908        const BASE: &str = "
1909version: 2
1910cluster:
1911  driver: colima
1912  network:
1913    hostname: gitlab.test
1914  colima:
1915    memory: 4GiB
1916    cores: 2
1917
1918repositories: {}
1919
1920deployers:
1921  supporting-manifests:
1922    type: kustomize
1923";
1924
1925        struct Case {
1926            name: &'static str,
1927            deployer: &'static str,
1928            want_err: bool,
1929            err_contains: &'static str,
1930        }
1931
1932        let cases = vec![
1933            Case {
1934                name: "minimal",
1935                deployer: "    kustomize:
1936      path: ./manifests
1937",
1938                want_err: false,
1939                err_contains: "",
1940            },
1941            Case {
1942                name: "with namespace",
1943                deployer: "    kustomize:
1944      path: ./manifests
1945      namespace: gitlab
1946",
1947                want_err: false,
1948                err_contains: "",
1949            },
1950            Case {
1951                name: "missing path rejected",
1952                deployer: "    kustomize:
1953      namespace: gitlab
1954",
1955                want_err: true,
1956                // Divergence: the Rust `jsonschema` crate's `oneOf`
1957                // failure carries no sub-branch cause the way Go's library
1958                // does, so only the generic "not valid under any of the
1959                // schemas" message is available here.
1960                err_contains: "not valid under any of the schemas",
1961            },
1962            Case {
1963                name: "unknown property rejected",
1964                deployer: "    kustomize:
1965      path: ./manifests
1966      overlay: production
1967",
1968                want_err: true,
1969                // Divergence: same oneOf-cause limitation noted above.
1970                err_contains: "not valid under any of the schemas",
1971            },
1972            Case {
1973                name: "mixed with helm rejected",
1974                deployer: "    kustomize:
1975      path: ./manifests
1976    helm:
1977      namespace: gitlab
1978",
1979                want_err: true,
1980                // Divergence: same oneOf-cause limitation noted above.
1981                err_contains: "not valid under any of the schemas",
1982            },
1983            Case {
1984                name: "ca_material rejected",
1985                deployer: "    kustomize:
1986      path: ./manifests
1987      ca_material:
1988        cert_file: tls.crt
1989        key_file: tls.key
1990",
1991                want_err: true,
1992                // Divergence: same oneOf-cause limitation noted above.
1993                err_contains: "not valid under any of the schemas",
1994            },
1995        ];
1996
1997        for case in cases {
1998            let yaml = format!("{BASE}{}", case.deployer);
1999            let (dir, path) = write_test_config(&yaml);
2000            std::fs::create_dir_all(dir.path().join("manifests")).unwrap();
2001            let result = load(path);
2002
2003            if case.want_err {
2004                let err = result.expect_err(case.name);
2005                if !case.err_contains.is_empty() {
2006                    assert!(
2007                        err.to_string().contains(case.err_contains),
2008                        "{}: expected error to contain {:?}, got {:?}",
2009                        case.name,
2010                        case.err_contains,
2011                        err.to_string()
2012                    );
2013                }
2014            } else {
2015                result.unwrap_or_else(|e| panic!("{}: expected success, got {e}", case.name));
2016            }
2017        }
2018    }
2019
2020    // Port of TestLoadFromContext.
2021    #[test]
2022    fn load_from_context_test() {
2023        // Subtest "without config".
2024        let err = match load_from_context(&super::super::global::Context::default()) {
2025            Err(e) => e,
2026            Ok(_) => panic!("expected error"),
2027        };
2028        assert!(matches!(err, ConfigError::ConfigFileNotFound(_)));
2029
2030        // Subtest "with config".
2031        let yaml = "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: woof.dog\n  colima:\n    memory: 8GiB\n    cores: 4\nrepositories: {}";
2032        let (_dir, path) = write_test_config(yaml);
2033        let ctx = super::super::global::with_context(
2034            super::super::global::Context::default(),
2035            GlobalConfig {
2036                config_file: path,
2037                ..Default::default()
2038            },
2039        );
2040        let container = load_from_context(&ctx).expect("load should succeed");
2041        assert_eq!(container.global.profile_name, "xianyun");
2042        assert_eq!(
2043            container.caproni.cluster.network.unwrap().hostname,
2044            "woof.dog"
2045        );
2046
2047        // Subtest "with invalid config".
2048        let (_dir2, path2) = write_test_config("version: -1");
2049        let ctx2 = super::super::global::with_context(
2050            super::super::global::Context::default(),
2051            GlobalConfig {
2052                config_file: path2,
2053                ..Default::default()
2054            },
2055        );
2056        let err = match load_from_context(&ctx2) {
2057            Err(e) => e,
2058            Ok(_) => panic!("expected error"),
2059        };
2060        assert!(err.to_string().contains("unsupported config version"));
2061    }
2062
2063    // Port of TestLoadCaproniConfig_ResolvesVariables.
2064    #[test]
2065    fn resolves_variables() {
2066        // SAFETY: test-local env var, no other test in this crate reads it.
2067        unsafe {
2068            std::env::set_var("CAPRONI_ENV_SUFFIX", "prod");
2069        }
2070
2071        let yaml = "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: ${CLUSTER}.example.com\n    additional_hostnames:\n      - api-${CAPRONI_ENV_SUFFIX}.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\nvariables:\n  CLUSTER: demo\n";
2072        let (_dir, path) = write_test_config(yaml);
2073        let cfg = load(path).expect("load should succeed");
2074
2075        let network = cfg.cluster.network.unwrap();
2076        assert_eq!(network.hostname, "demo.example.com");
2077        assert_eq!(
2078            network.additional_hostnames,
2079            vec!["api-prod.example.com".to_string()]
2080        );
2081
2082        let v = cfg
2083            .variables
2084            .unwrap()
2085            .get(&"CLUSTER".to_string())
2086            .cloned()
2087            .unwrap();
2088        assert_eq!(v.value, "demo");
2089    }
2090
2091    // Port of TestLoadCaproniConfig_UnknownVariableResolvesEmpty.
2092    #[test]
2093    fn unknown_variable_resolves_empty() {
2094        let yaml = "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n    additional_hostnames:\n      - prefix${DOES_NOT_EXIST}.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n";
2095        let (_dir, path) = write_test_config(yaml);
2096        let cfg = load(path).expect("load should succeed");
2097
2098        assert_eq!(
2099            cfg.cluster.network.unwrap().additional_hostnames,
2100            vec!["prefix.example.com".to_string()]
2101        );
2102    }
2103
2104    // Port of TestLoadCaproniConfig_PreservesMirrorRegexFields.
2105    #[test]
2106    fn preserves_mirror_regex_fields() {
2107        let yaml = "version: 2\ncluster:\n  driver: k3d\n  network:\n    hostname: gitlab.example.com\n  k3d:\n    servers: 1\nrepositories:\n  gitlab:\n    repository: https://gitlab.com/gitlab-org/gitlab.git\n    directory: ./gitlab\nreloaders:\n  gitlab:\n    type: mirror\n    repository: gitlab\n    edit_mode: true\n    mirror:\n      target:\n        namespace: gitlab\n        kubernetes_type: deployment\n        name: gitlab-webservice-default\n      filesystem:\n        mode: localwithoverrides\n        read_only:\n          - \"^/etc/gitlab/.*\"\n        mapping:\n          \"^/srv/gitlab/log/(?<file>.+)\": \"./log/$file\"\n          \"^(?<path>/home/.*/config/)gitlab.yml\": \"${path}/gitlab-sidekiq.yml\"\n      edit_mode_processes:\n        puma:\n          command: echo hi\n";
2108        let (_dir, path) = write_test_config(yaml);
2109        let cfg = load(path).expect("load should succeed");
2110
2111        let reloader = cfg
2112            .reloaders
2113            .unwrap()
2114            .get(&"gitlab".to_string())
2115            .cloned()
2116            .unwrap();
2117        let fs_cfg = reloader.mirror.unwrap().filesystem.unwrap();
2118        let mapping = fs_cfg.mapping.unwrap();
2119
2120        assert_eq!(
2121            mapping
2122                .get(&"^/srv/gitlab/log/(?<file>.+)".to_string())
2123                .cloned(),
2124            Some("./log/$file".to_string())
2125        );
2126        assert_eq!(
2127            mapping
2128                .get(&"^(?<path>/home/.*/config/)gitlab.yml".to_string())
2129                .cloned(),
2130            Some("${path}/gitlab-sidekiq.yml".to_string())
2131        );
2132        assert_eq!(fs_cfg.read_only, vec!["^/etc/gitlab/.*".to_string()]);
2133    }
2134
2135    // Port of TestLoadCaproniConfig_RejectsVariableInClusterDriver.
2136    #[test]
2137    fn rejects_variable_in_cluster_driver() {
2138        // SAFETY: test-local env var, no other test in this crate reads it.
2139        unsafe {
2140            std::env::set_var("CAPRONI_TEST_DRIVER", "colima");
2141        }
2142
2143        struct Case {
2144            driver: &'static str,
2145            extra: &'static str,
2146        }
2147
2148        let cases = vec![
2149            Case {
2150                driver: "${DRV}",
2151                extra: "variables:\n  DRV: colima\n",
2152            },
2153            Case {
2154                driver: "$DRV",
2155                extra: "variables:\n  DRV: colima\n",
2156            },
2157            Case {
2158                driver: "${CAPRONI_TEST_DRIVER}",
2159                extra: "",
2160            },
2161            Case {
2162                driver: "${DOES_NOT_EXIST}",
2163                extra: "",
2164            },
2165            Case {
2166                driver: "col${SUFFIX}ima",
2167                extra: "variables:\n  SUFFIX: \"\"\n",
2168            },
2169        ];
2170
2171        for case in cases {
2172            let content = format!(
2173                "version: 2\ncluster:\n  driver: {}\n  network:\n    hostname: gitlab.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n{}",
2174                case.driver, case.extra
2175            );
2176            let (_dir, path) = write_test_config(&content);
2177            let err = load(path).unwrap_err();
2178            assert!(
2179                matches!(&err, ConfigError::DriverNotLiteral(d) if d == case.driver),
2180                "{}: {err}",
2181                case.driver
2182            );
2183        }
2184    }
2185
2186    // Port of TestLiteralClusterFields_RejectsAVariableInK3sDisable.
2187    #[test]
2188    fn literal_cluster_fields_rejects_variable_in_k3s_disable() {
2189        let cases = [
2190            "\nversion: 2\ncluster:\n  driver: k3d\n  network:\n    hostname: gitlab.test\n  k3d:\n    k3s:\n      disable: [\"${DISABLED_COMPONENT}\"]\nrepositories: {}\n",
2191            "\nversion: 2\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.test\n  colima:\n    memory: 8GiB\n    cores: 4\n    k3s:\n      disable: [\"${DISABLED_COMPONENT}\"]\nrepositories: {}\n",
2192        ];
2193
2194        for config in cases {
2195            let (_dir, path) = write_test_config(config);
2196            let err = load(path).unwrap_err();
2197            assert!(matches!(err, ConfigError::K3sDisableNotLiteral(_)));
2198        }
2199    }
2200
2201    // Port of TestLiteralClusterFields_KeepsK3sDisableVerbatim.
2202    #[test]
2203    fn literal_cluster_fields_keeps_k3s_disable_verbatim() {
2204        let k3d_yaml = "\nversion: 2\ncluster:\n  driver: k3d\n  network:\n    hostname: gitlab.test\n  k3d:\n    k3s:\n      disable: [traefik, servicelb]\nrepositories: {}\n";
2205        let (_dir, path) = write_test_config(k3d_yaml);
2206        let cfg = load(path).expect("load should succeed");
2207        assert_eq!(
2208            cfg.cluster.k3d.unwrap().k3s.unwrap().disable,
2209            vec!["traefik".to_string(), "servicelb".to_string()]
2210        );
2211
2212        let colima_yaml = "\nversion: 2\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.test\n  colima:\n    memory: 8GiB\n    cores: 4\n    k3s:\n      disable: [traefik, servicelb]\nrepositories: {}\n";
2213        let (_dir2, path2) = write_test_config(colima_yaml);
2214        let cfg2 = load(path2).expect("load should succeed");
2215        assert_eq!(
2216            cfg2.cluster.colima.unwrap().k3s.unwrap().disable,
2217            vec!["traefik".to_string(), "servicelb".to_string()]
2218        );
2219    }
2220
2221    // Port of TestSkipVariableExpansion.
2222    #[test]
2223    fn skip_variable_expansion_cases() {
2224        struct Case {
2225            path: &'static [&'static str],
2226            want: bool,
2227        }
2228
2229        let cases = vec![
2230            Case {
2231                path: &["cluster", "driver"],
2232                want: true,
2233            },
2234            Case {
2235                path: &["cluster", "k3d", "k3s", "disable"],
2236                want: true,
2237            },
2238            Case {
2239                path: &["cluster", "k3d", "k3s", "disable", "0"],
2240                want: true,
2241            },
2242            Case {
2243                path: &["cluster", "colima", "k3s", "disable"],
2244                want: true,
2245            },
2246            Case {
2247                path: &["cluster", "colima", "k3s", "disable", "0"],
2248                want: true,
2249            },
2250            Case {
2251                path: &["cluster", "k3d", "extra_args"],
2252                want: false,
2253            },
2254            Case {
2255                path: &["cluster", "k3d", "k3s"],
2256                want: false,
2257            },
2258            Case {
2259                path: &["cluster", "k3d", "k3s", "other"],
2260                want: false,
2261            },
2262            Case {
2263                path: &["cluster", "network", "k3s", "disable"],
2264                want: false,
2265            },
2266            Case {
2267                path: &["reloaders", "gitlab", "mirror", "filesystem", "mapping"],
2268                want: true,
2269            },
2270            Case {
2271                path: &["cluster", "network", "hostname"],
2272                want: false,
2273            },
2274            Case {
2275                path: &["cluster", "colima", "memory"],
2276                want: false,
2277            },
2278            Case {
2279                path: &["deployers", "cluster", "driver"],
2280                want: false,
2281            },
2282            Case {
2283                path: &["cluster"],
2284                want: false,
2285            },
2286            Case {
2287                path: &["repositories", "driver"],
2288                want: false,
2289            },
2290        ];
2291
2292        for case in cases {
2293            let path: Vec<String> = case.path.iter().map(|s| s.to_string()).collect();
2294            assert_eq!(skip_variable_expansion(&path), case.want, "{:?}", case.path);
2295        }
2296    }
2297
2298    // Port of TestLoadCaproniConfig_VariablesSchema.
2299    #[test]
2300    fn variables_schema() {
2301        let yaml = "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\nrepositories: {}\nvariables:\n  SHORTHAND: literal-value\n";
2302        let (_dir, path) = write_test_config(yaml);
2303        let cfg = load(path).expect("load should succeed");
2304
2305        let sh = cfg
2306            .variables
2307            .unwrap()
2308            .get(&"SHORTHAND".to_string())
2309            .cloned()
2310            .unwrap();
2311        assert_eq!(sh.value, "literal-value");
2312    }
2313
2314    // Port of TestLoad_VClusterDriverIsValid.
2315    #[test]
2316    fn vcluster_driver_is_valid() {
2317        let yaml = "
2318version: 2
2319cluster:
2320  driver: vcluster
2321  network:
2322    hostname: gitlab.test
2323  vcluster:
2324    host_context: my-host
2325    namespace: caproni-vc
2326repositories: {}
2327";
2328        let (_dir, path) = write_test_config(yaml);
2329        let ctx = super::super::global::with_context(
2330            super::super::global::Context::default(),
2331            GlobalConfig {
2332                config_file: path,
2333                ..Default::default()
2334            },
2335        );
2336        let container = load_from_context(&ctx).expect("load should succeed");
2337
2338        assert_eq!(container.caproni.cluster.driver, "vcluster");
2339        let vcluster = container.caproni.cluster.vcluster.unwrap();
2340        assert_eq!(vcluster.host_context, "my-host");
2341        assert_eq!(vcluster.namespace, "caproni-vc");
2342        assert_eq!(vcluster.manage_namespace, Some(true));
2343    }
2344
2345    // Port of TestLoad_VClusterDriverRequiresHostContext.
2346    #[test]
2347    fn vcluster_driver_requires_host_context() {
2348        let yaml = "
2349version: 2
2350cluster:
2351  driver: vcluster
2352  network:
2353    hostname: gitlab.test
2354  vcluster:
2355    namespace: caproni-vc
2356repositories: {}
2357";
2358        let (_dir, path) = write_test_config(yaml);
2359        let ctx = super::super::global::with_context(
2360            super::super::global::Context::default(),
2361            GlobalConfig {
2362                config_file: path,
2363                ..Default::default()
2364            },
2365        );
2366        let err = match load_from_context(&ctx) {
2367            Err(e) => e,
2368            Ok(_) => panic!("expected error"),
2369        };
2370        assert!(err.to_string().contains("host_context"));
2371    }
2372
2373    // Port of TestLoadCaproniConfig_HelmReleaseVersionResolvesFromToolVersions.
2374    #[test]
2375    fn helm_release_version_resolves_from_tool_versions() {
2376        let yaml = "
2377version: 2
2378cluster:
2379  driver: colima
2380  network:
2381    hostname: gitlab.test
2382  colima:
2383    memory: 8GiB
2384    cores: 4
2385repositories: {}
2386deployers:
2387  traefik:
2388    type: helm
2389    helm:
2390      namespace: kube-system
2391      repository:
2392        name: traefik
2393        url: https://traefik.github.io/charts
2394      release:
2395        name: traefik
2396        chart: traefik/traefik
2397        version: ${GL_TRAEFIK_VERSION}
2398";
2399        let (_dir, path) = write_test_config(yaml);
2400        let versions = HashMap::from([(
2401            ".gitlab-ci-other-versions.yml".to_string(),
2402            "variables:\n  GL_TRAEFIK_VERSION: tf-test\n".to_string(),
2403        )]);
2404        let gc = GlobalConfig {
2405            config_file: path,
2406            ..GlobalConfig::new(Some(versions))
2407        };
2408        let cfg = load_caproni_config(&gc).expect("load should succeed");
2409
2410        let dep = cfg
2411            .deployers
2412            .unwrap()
2413            .get(&"traefik".to_string())
2414            .cloned()
2415            .unwrap();
2416        assert_eq!(dep.helm.unwrap().release.unwrap().version, "tf-test");
2417    }
2418
2419    // Port of TestLoadCaproniConfig_ClusterDomain (clusterdomain_test.go).
2420    #[test]
2421    fn cluster_domain_cases() {
2422        struct Case {
2423            name: &'static str,
2424            network: &'static str,
2425            want: &'static str,
2426        }
2427
2428        let cases = vec![
2429            Case {
2430                name: "unset",
2431                network: "",
2432                want: "",
2433            },
2434            Case {
2435                name: "configured",
2436                network: "    cluster_domain: corp.example",
2437                want: "corp.example",
2438            },
2439        ];
2440
2441        for case in cases {
2442            let yaml = format!(
2443                "version: 2\ncluster:\n  driver: external\n  network:\n    hostname: gitlab.example.com\n{}\n",
2444                case.network
2445            );
2446            let (_dir, path) = write_test_config(&yaml);
2447            let cfg = load(path).unwrap_or_else(|e| panic!("{}: {e}", case.name));
2448            assert_eq!(
2449                cfg.cluster.network.unwrap().cluster_domain,
2450                case.want,
2451                "{}",
2452                case.name
2453            );
2454        }
2455    }
2456
2457    // Port of TestLoadCaproniConfig_ResolvedClusterDomain (clusterdomain_test.go).
2458    #[test]
2459    fn resolved_cluster_domain_cases() {
2460        struct Case {
2461            name: &'static str,
2462            network: &'static str,
2463            resolved: &'static str,
2464            want: &'static str,
2465            want_host: &'static str,
2466        }
2467
2468        let cases = vec![
2469            Case {
2470                name: "unset takes the resolved domain",
2471                network: "",
2472                resolved: "corp.example",
2473                want: "corp.example",
2474                want_host: "postgres.gitlab.svc.corp.example",
2475            },
2476            Case {
2477                name: "configured key wins",
2478                network: "    cluster_domain: other.example",
2479                resolved: "corp.example",
2480                want: "other.example",
2481                want_host: "postgres.gitlab.svc.other.example",
2482            },
2483            Case {
2484                name: "nothing resolved leaves the key unset",
2485                network: "",
2486                resolved: "",
2487                want: "",
2488                want_host: "postgres.gitlab.svc",
2489            },
2490        ];
2491
2492        for case in cases {
2493            let yaml = format!(
2494                "version: 2\ncluster:\n  driver: external\n  network:\n    hostname: gitlab.example.com\n{}\nvariables:\n  PG_HOST: postgres.gitlab.svc${{CAPRONI_CLUSTER_DOMAIN:+.${{CAPRONI_CLUSTER_DOMAIN}}}}\n",
2495                case.network
2496            );
2497            let (_dir, path) = write_test_config(&yaml);
2498            let gc = GlobalConfig {
2499                config_file: path,
2500                cluster_domain: case.resolved.to_string(),
2501                ..Default::default()
2502            };
2503            let cfg = load_caproni_config(&gc).unwrap_or_else(|e| panic!("{}: {e}", case.name));
2504
2505            assert_eq!(
2506                cfg.cluster.network.as_ref().unwrap().cluster_domain,
2507                case.want,
2508                "{}",
2509                case.name
2510            );
2511            let def = cfg
2512                .variables
2513                .unwrap()
2514                .get(&"PG_HOST".to_string())
2515                .cloned()
2516                .unwrap();
2517            assert_eq!(def.value, case.want_host, "{}", case.name);
2518        }
2519    }
2520
2521    // Port of TestLoadCaproniConfig_ClusterDomainVariable (clusterdomain_variable_test.go).
2522    #[test]
2523    fn cluster_domain_variable_cases() {
2524        struct Case {
2525            name: &'static str,
2526            network: &'static str,
2527            want: &'static str,
2528            want_pg_host: &'static str,
2529        }
2530
2531        let cases = vec![
2532            Case {
2533                name: "unset leaves the variable undefined",
2534                network: "",
2535                want: "",
2536                want_pg_host: "postgres.gitlab.svc",
2537            },
2538            Case {
2539                name: "configured expands",
2540                network: "    cluster_domain: corp.example",
2541                want: "corp.example",
2542                want_pg_host: "postgres.gitlab.svc.corp.example",
2543            },
2544        ];
2545
2546        for case in cases {
2547            let yaml = format!(
2548                "version: 2\ncluster:\n  driver: external\n  network:\n    hostname: gitlab.example.com\n{}\nvariables:\n  PG_HOST: postgres.gitlab.svc${{CAPRONI_CLUSTER_DOMAIN:+.${{CAPRONI_CLUSTER_DOMAIN}}}}\n",
2549                case.network
2550            );
2551            let (_dir, path) = write_test_config(&yaml);
2552            let gc = GlobalConfig {
2553                config_file: path,
2554                ..Default::default()
2555            };
2556            let cfg = load_caproni_config(&gc).unwrap_or_else(|e| panic!("{}: {e}", case.name));
2557
2558            assert_eq!(
2559                cfg.cluster.network.as_ref().unwrap().cluster_domain,
2560                case.want,
2561                "{}",
2562                case.name
2563            );
2564            let def = cfg
2565                .variables
2566                .as_ref()
2567                .unwrap()
2568                .get(&"PG_HOST".to_string())
2569                .cloned()
2570                .unwrap();
2571            assert_eq!(def.value, case.want_pg_host, "{}", case.name);
2572
2573            let container = crate::config::container::new_container(gc, cfg);
2574            let known = container
2575                .resolver
2576                .lookup("CAPRONI_CLUSTER_DOMAIN")
2577                .is_some();
2578            assert_eq!(known, !case.want.is_empty(), "{}", case.name);
2579        }
2580    }
2581
2582    // Port of TestApplyVersionDefaults_K3sVersion (versiondefaults_test.go).
2583    #[test]
2584    fn apply_version_defaults_k3s_version_cases() {
2585        const EMBEDDED: &str = "v.yml";
2586
2587        struct Case {
2588            name: &'static str,
2589            colima: Option<ColimaConfig>,
2590            versions: HashMap<String, String>,
2591            want: &'static str,
2592        }
2593
2594        let cases = vec![
2595            Case {
2596                name: "defaults from embedded when empty",
2597                colima: Some(ColimaConfig {
2598                    memory: "8GiB".into(),
2599                    cores: 4,
2600                    ..Default::default()
2601                }),
2602                versions: HashMap::from([(
2603                    EMBEDDED.to_string(),
2604                    "variables:\n  GL_K3S_VERSION: v1.36.1+k3s1\n".to_string(),
2605                )]),
2606                want: "v1.36.1+k3s1",
2607            },
2608            Case {
2609                name: "caproni.yaml value wins",
2610                colima: Some(ColimaConfig {
2611                    memory: "8GiB".into(),
2612                    cores: 4,
2613                    k3s_version: "v1.30.0+k3s1".into(),
2614                    ..Default::default()
2615                }),
2616                versions: HashMap::from([(
2617                    EMBEDDED.to_string(),
2618                    "variables:\n  GL_K3S_VERSION: v1.36.1+k3s1\n".to_string(),
2619                )]),
2620                want: "v1.30.0+k3s1",
2621            },
2622            Case {
2623                name: "missing key leaves empty",
2624                colima: Some(ColimaConfig {
2625                    memory: "8GiB".into(),
2626                    cores: 4,
2627                    ..Default::default()
2628                }),
2629                versions: HashMap::new(),
2630                want: "",
2631            },
2632            Case {
2633                name: "no colima config is a no-op",
2634                colima: None,
2635                versions: HashMap::from([(
2636                    EMBEDDED.to_string(),
2637                    "variables:\n  GL_K3S_VERSION: v1.36.1+k3s1\n".to_string(),
2638                )]),
2639                want: "",
2640            },
2641        ];
2642
2643        for case in cases {
2644            let mut cfg = CaproniConfig {
2645                cluster: crate::config::types::ClusterConfig {
2646                    driver: "colima".into(),
2647                    colima: case.colima.clone(),
2648                    ..Default::default()
2649                },
2650                ..Default::default()
2651            };
2652            let mut gc = GlobalConfig::new(Some(case.versions));
2653
2654            apply_version_defaults(&mut cfg, &mut gc);
2655
2656            if case.colima.is_none() {
2657                assert!(cfg.cluster.colima.is_none(), "{}", case.name);
2658                continue;
2659            }
2660
2661            assert_eq!(
2662                cfg.cluster.colima.unwrap().k3s_version,
2663                case.want,
2664                "{}",
2665                case.name
2666            );
2667        }
2668    }
2669}