Skip to main content

xianyun_core/config/
definitions.rs

1//! Port of `internal/config/definitions.go`: resolution of the top-level
2//! `variables:` section (literal and file-backed definitions).
3
4use serde_json::Value;
5
6use super::errors::ConfigError;
7use super::global::GlobalConfig;
8use super::latest::CaproniConfig;
9use crate::variables::expand::Unresolved;
10use crate::variables::resolver::Lookup;
11
12/// Builds the chain a definition resolves against and expands every
13/// `variables:` entry of `raw_config` in place.
14pub fn resolve_variable_definitions(
15    gc: &mut GlobalConfig,
16    seed: &mut CaproniConfig,
17    raw_config: &mut Value,
18) -> Result<Vec<Unresolved>, ConfigError> {
19    use crate::variables::predefined::Options;
20    use crate::variables::resolver::{ResolverLookup, new_resolver};
21
22    if !raw_config.is_object() {
23        return Ok(Vec::new());
24    }
25
26    let chain = new_resolver(
27        Options {
28            profile_name: gc.profile_name.clone(),
29            cluster: Some(seed.cluster.clone()),
30        },
31        None,
32        Some(gc.tool_versions()),
33    );
34    let lookup_adapter = ResolverLookup(&*chain);
35
36    let unresolved =
37        expand_variable_definitions(raw_config, seed, &lookup_adapter).map_err(|err| {
38            ConfigError::SemanticValidation(format!("expanding variable definitions: {err}"))
39        })?;
40
41    Ok(unresolved)
42}
43
44/// Resolves every `variables:` entry in place, in both the raw mapping and
45/// the seed the resolver is built from, so the resolver and the re-decoded
46/// config both serve resolved values. Definitions resolve against
47/// predefined variables, tool versions and the environment; they cannot
48/// reference each other. A definition takes the `${VAR:-default}`,
49/// `${VAR:?message}` and `${VAR:+text}` operators, which config values
50/// reject. A file definition expands its path the same way, then reads the
51/// file's content as its value.
52///
53/// Returns the references that resolved to nothing, named with the
54/// definition each appeared in, so the caller can fold them into the
55/// unresolved-variable warning.
56pub fn expand_variable_definitions(
57    base_map: &mut Value,
58    seed: &mut CaproniConfig,
59    lookup: &dyn Lookup,
60) -> Result<Vec<Unresolved>, ConfigError> {
61    let config_dir = seed.config_dir.clone();
62
63    let Some(variables) = seed.variables.as_mut() else {
64        return Ok(Vec::new());
65    };
66
67    let mut unresolved = Vec::new();
68
69    for name in variables.keys() {
70        let vc = variables
71            .get_mut(&name)
72            .expect("key was just returned by keys()");
73
74        let missing = resolve_definition(&name, vc, &config_dir, base_map, lookup)
75            .map_err(|err| ConfigError::SemanticValidation(format!("variables.{name}: {err}")))?;
76
77        if !missing.is_empty() {
78            unresolved.push(Unresolved {
79                path: format!("variables.{name}"),
80                variables: missing,
81            });
82        }
83    }
84
85    Ok(unresolved)
86}
87
88/// Expands one definition and stores the result in the seed and the raw
89/// mapping. Returns the references that resolved to nothing.
90fn resolve_definition(
91    name: &str,
92    vc: &mut super::latest::VariableConfig,
93    config_dir: &str,
94    base_map: &mut Value,
95    lookup: &dyn Lookup,
96) -> Result<Vec<String>, ConfigError> {
97    match vc.var_type {
98        super::latest::VariableType::Value => {
99            let (value, missing) = crate::variables::expand::expand_definition(&vc.value, lookup)
100                .map_err(|err| {
101                ConfigError::SemanticValidation(format!("expanding value: {err}"))
102            })?;
103
104            vc.value = value.clone();
105            write_raw_variable(base_map, name, &value);
106
107            Ok(missing)
108        }
109        super::latest::VariableType::File => {
110            resolve_file_definition(vc, config_dir, base_map, name, lookup)?;
111            Ok(Vec::new())
112        }
113    }
114}
115
116/// Expands a file definition's path and reads the file. An unresolved
117/// reference is an error rather than the empty-string warning: it leaves a
118/// path naming the wrong file, or the config directory itself.
119fn resolve_file_definition(
120    vc: &mut super::latest::VariableConfig,
121    config_dir: &str,
122    base_map: &mut Value,
123    name: &str,
124    lookup: &dyn Lookup,
125) -> Result<(), ConfigError> {
126    let (path, missing) = crate::variables::expand::expand_definition(&vc.path, lookup)
127        .map_err(|err| ConfigError::SemanticValidation(format!("expanding path: {err}")))?;
128
129    if !missing.is_empty() {
130        return Err(ConfigError::UnresolvedVariablePath(missing.join(", ")));
131    }
132
133    vc.path = path.clone();
134
135    let content = read_file_variable(config_dir, &path)?;
136
137    // Stored as the scalar shorthand, like a resolved `${VAR:-default}`: the
138    // raw tree carries the content, not the file's provenance. Unlike
139    // `write_raw_variable` (used for the literal-value case, which
140    // preserves a mapping form's sibling `type` field), a file definition's
141    // whole entry is replaced outright, mirroring `resolveFileDefinition`'s
142    // unconditional `rawVars[name] = content` in the Go original.
143    vc.value = content.clone();
144    if let Some(vars) = base_map.get_mut("variables").and_then(Value::as_object_mut) {
145        vars.insert(name.to_string(), Value::String(content));
146    }
147
148    Ok(())
149}
150
151/// Reads a file variable's content, trimmed of surrounding whitespace since
152/// version files end in a newline. A relative path resolves against the
153/// config directory.
154fn read_file_variable(config_dir: &str, path: &str) -> Result<String, ConfigError> {
155    let resolved = resolve_relative_to(config_dir, path);
156
157    let content = std::fs::read_to_string(&resolved).map_err(|err| {
158        ConfigError::SemanticValidation(format!("reading file {resolved}: {err}"))
159    })?;
160
161    Ok(content.trim().to_string())
162}
163
164/// Resolves `path` relative to `config_dir`, unless it is already absolute.
165/// Mirrors `CaproniConfig::resolve_path`, duplicated here as a free function
166/// so this module does not need a second, aliasing mutable borrow of `seed`
167/// alongside `seed.variables`.
168fn resolve_relative_to(config_dir: &str, path: &str) -> String {
169    let p = std::path::Path::new(path);
170    if p.is_absolute() {
171        return path.to_string();
172    }
173
174    std::path::Path::new(config_dir)
175        .join(p)
176        .to_string_lossy()
177        .into_owned()
178}
179
180/// Writes a resolved value back into the raw variables mapping, which holds
181/// either the scalar shorthand or the `{type, value}` mapping form of a
182/// definition.
183fn write_raw_variable(base_map: &mut Value, name: &str, value: &str) {
184    let Some(vars) = base_map.get_mut("variables").and_then(Value::as_object_mut) else {
185        return;
186    };
187
188    match vars.get_mut(name) {
189        Some(Value::Object(entry)) => {
190            entry.insert("value".to_string(), Value::String(value.to_string()));
191        }
192        _ => {
193            vars.insert(name.to_string(), Value::String(value.to_string()));
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::config::global::GlobalConfig;
202    use crate::config::latest::{VariableConfig, VariableType};
203    use crate::orderedmap::OrderedMap;
204    use serde_json::json;
205    use std::fs;
206
207    fn write_config(content: &str) -> (tempfile::TempDir, String) {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join("caproni.yaml");
210        fs::write(&path, content).unwrap();
211        (dir, path.to_string_lossy().into_owned())
212    }
213
214    fn load(path: String) -> Result<CaproniConfig, ConfigError> {
215        let gc = GlobalConfig {
216            config_file: path,
217            ..Default::default()
218        };
219        crate::config::caproni::load_caproni_config(&gc)
220    }
221
222    // Port of TestLoadCaproniConfig_DefinitionDefaultReachesUseSite.
223    #[test]
224    fn definition_default_reaches_use_site() {
225        let content = "version: 2\nvariables:\n  HOST_SUFFIX: ${CAPRONI_TEST_UNSET_SUFFIX:-example.com}\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.${HOST_SUFFIX}\n  colima:\n    memory: 8GiB\n    cores: 4\n";
226        let (_dir, path) = write_config(content);
227
228        let cfg = load(path).expect("load should succeed");
229        assert_eq!(cfg.cluster.network.unwrap().hostname, "gitlab.example.com");
230
231        let def = cfg
232            .variables
233            .unwrap()
234            .get(&"HOST_SUFFIX".to_string())
235            .cloned()
236            .unwrap();
237        assert_eq!(def.value, "example.com");
238    }
239
240    // Port of TestLoadCaproniConfig_DefinitionResolvesFromEnvironment.
241    #[test]
242    fn definition_resolves_from_environment() {
243        // SAFETY: test-local env var, no other test in this crate reads it.
244        unsafe {
245            std::env::set_var("CAPRONI_TEST_ENV_SUFFIX", "env.example.com");
246        }
247
248        let content = "version: 2\nvariables:\n  HOST_SUFFIX: ${CAPRONI_TEST_ENV_SUFFIX:-fallback.example.com}\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.${HOST_SUFFIX}\n  colima:\n    memory: 8GiB\n    cores: 4\n";
249        let (_dir, path) = write_config(content);
250
251        let cfg = load(path).expect("load should succeed");
252        assert_eq!(
253            cfg.cluster.network.unwrap().hostname,
254            "gitlab.env.example.com"
255        );
256    }
257
258    // Port of TestLoadCaproniConfig_DefinitionRequiredFailsLoad.
259    #[test]
260    fn definition_required_fails_load() {
261        let content = "version: 2\nvariables:\n  CHART_VERSION: ${CAPRONI_TEST_UNSET_VERSION:?pin the chart version}\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n";
262        let (_dir, path) = write_config(content);
263
264        let err = load(path).unwrap_err();
265        let msg = err.to_string();
266        assert!(msg.contains("expanding variable definitions"));
267        assert!(msg.contains("variables.CHART_VERSION"));
268        assert!(msg.contains("pin the chart version"));
269    }
270
271    // Port of TestLoadCaproniConfig_OperatorInConfigValueFailsLoad.
272    #[test]
273    fn operator_in_config_value_fails_load() {
274        let content = "version: 2\ncluster:\n  driver: colima\n  network:\n    hostname: ${CAPRONI_TEST_UNSET_HOST:-gitlab.example.com}\n  colima:\n    memory: 8GiB\n    cores: 4\n";
275        let (_dir, path) = write_config(content);
276
277        let err = load(path).unwrap_err();
278        let msg = err.to_string();
279        assert!(msg.contains("cluster.network.hostname"));
280        assert!(msg.contains("variable definition"));
281    }
282
283    // Port of TestLoadCaproniConfig_DefinitionEscapedDollarStaysLiteral.
284    #[test]
285    fn definition_escaped_dollar_stays_literal() {
286        let content = "version: 2\nvariables:\n  LITERAL: pre\\$HOST-post\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n    additional_hostnames:\n      - ${LITERAL}.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n";
287        let (_dir, path) = write_config(content);
288
289        let cfg = load(path).expect("load should succeed");
290        let def = cfg
291            .variables
292            .unwrap()
293            .get(&"LITERAL".to_string())
294            .cloned()
295            .unwrap();
296        assert_eq!(def.value, "pre$HOST-post");
297        assert_eq!(
298            cfg.cluster.network.unwrap().additional_hostnames,
299            vec!["pre$HOST-post.example.com".to_string()]
300        );
301    }
302
303    // Port of TestExpandVariableDefinitions_WritesBackBothRawForms.
304    #[test]
305    fn expand_variable_definitions_writes_back_both_raw_forms() {
306        let mut vars = OrderedMap::new();
307        vars.set(
308            "SCALAR".to_string(),
309            VariableConfig {
310                var_type: VariableType::Value,
311                value: "${MISSING:-scalar-resolved}".into(),
312                path: String::new(),
313            },
314        );
315        vars.set(
316            "MAPPING".to_string(),
317            VariableConfig {
318                var_type: VariableType::Value,
319                value: "${MISSING:-mapping-resolved}".into(),
320                path: String::new(),
321            },
322        );
323
324        let mut seed = CaproniConfig {
325            variables: Some(vars),
326            ..Default::default()
327        };
328
329        let mut base_map = json!({
330            "variables": {
331                "SCALAR": "${MISSING:-scalar-resolved}",
332                "MAPPING": {"type": "value", "value": "${MISSING:-mapping-resolved}"},
333            }
334        });
335
336        struct AlwaysMissing;
337        impl Lookup for AlwaysMissing {
338            fn lookup(&self, _name: &str) -> Option<String> {
339                None
340            }
341        }
342
343        let unresolved = expand_variable_definitions(&mut base_map, &mut seed, &AlwaysMissing)
344            .expect("expand should succeed");
345        assert!(unresolved.is_empty());
346
347        assert_eq!(base_map["variables"]["SCALAR"], json!("scalar-resolved"));
348        assert_eq!(
349            base_map["variables"]["MAPPING"]["value"],
350            json!("mapping-resolved")
351        );
352
353        let scalar_def = seed
354            .variables
355            .unwrap()
356            .get(&"SCALAR".to_string())
357            .cloned()
358            .unwrap();
359        assert_eq!(scalar_def.value, "scalar-resolved");
360    }
361
362    // Port of TestLoadCaproniConfig_DefinitionNamesItself.
363    #[test]
364    fn definition_names_itself() {
365        unsafe {
366            std::env::set_var("CAPRONI_TEST_SELF_TAG", "from-env");
367        }
368
369        let content = "version: 2\nvariables:\n  CAPRONI_TEST_SELF_TAG: ${CAPRONI_TEST_SELF_TAG:-dev}\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n    additional_hostnames:\n      - ${CAPRONI_TEST_SELF_TAG}.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n";
370        let (_dir, path) = write_config(content);
371
372        let cfg = load(path).expect("load should succeed");
373        assert_eq!(
374            cfg.cluster.network.unwrap().additional_hostnames,
375            vec!["from-env.example.com".to_string()]
376        );
377    }
378
379    // Port of TestLoadCaproniConfig_DefinitionCannotReferenceAnotherDefinition.
380    #[test]
381    fn definition_cannot_reference_another_definition() {
382        let content = "version: 2\nvariables:\n  CAPRONI_TEST_BASE: example.com\n  CAPRONI_TEST_HOST: gitlab.${CAPRONI_TEST_BASE:-fallback}\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n    additional_hostnames:\n      - ${CAPRONI_TEST_HOST}\n  colima:\n    memory: 8GiB\n    cores: 4\n";
383        let (_dir, path) = write_config(content);
384
385        let cfg = load(path).expect("load should succeed");
386        assert_eq!(
387            cfg.cluster.network.unwrap().additional_hostnames,
388            vec!["gitlab.fallback".to_string()]
389        );
390    }
391
392    fn file_variable_config(path: &str) -> String {
393        format!(
394            "version: 2\nvariables:\n  GITALY_VERSION:\n    type: file\n    path: {path}\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab-${{GITALY_VERSION}}.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n"
395        )
396    }
397
398    // Port of TestLoadCaproniConfig_FileVariable (table-driven).
399    #[test]
400    fn file_variable_cases() {
401        struct Case {
402            name: &'static str,
403            content: &'static str,
404            file_path: fn(&std::path::Path) -> String,
405            want: &'static str,
406        }
407
408        let cases = vec![
409            Case {
410                name: "relative path resolves against the config directory",
411                content: "14.57.1\n",
412                file_path: |_| "./versions/GITALY_SERVER_VERSION".to_string(),
413                want: "14.57.1",
414            },
415            Case {
416                name: "absolute path is used as written",
417                content: "14.57.1\n",
418                file_path: |dir| {
419                    dir.join("versions")
420                        .join("GITALY_SERVER_VERSION")
421                        .to_string_lossy()
422                        .into_owned()
423                },
424                want: "14.57.1",
425            },
426            Case {
427                name: "surrounding whitespace is trimmed",
428                content: "  \n\td2f770f322acd3acd2544fad14878460170ab0f9 \n\n",
429                file_path: |_| "versions/GITALY_SERVER_VERSION".to_string(),
430                want: "d2f770f322acd3acd2544fad14878460170ab0f9",
431            },
432        ];
433
434        for case in cases {
435            let dir = tempfile::tempdir().unwrap();
436            fs::create_dir_all(dir.path().join("versions")).unwrap();
437            fs::write(
438                dir.path().join("versions").join("GITALY_SERVER_VERSION"),
439                case.content,
440            )
441            .unwrap();
442
443            let config_path = dir.path().join("caproni.yaml");
444            fs::write(
445                &config_path,
446                file_variable_config(&(case.file_path)(dir.path())),
447            )
448            .unwrap();
449
450            let cfg = load(config_path.to_string_lossy().into_owned())
451                .unwrap_or_else(|e| panic!("{}: {e}", case.name));
452            assert_eq!(
453                cfg.cluster.network.unwrap().hostname,
454                format!("gitlab-{}.example.com", case.want),
455                "{}",
456                case.name
457            );
458
459            let def = cfg
460                .variables
461                .unwrap()
462                .get(&"GITALY_VERSION".to_string())
463                .cloned()
464                .unwrap();
465            assert_eq!(def.var_type, VariableType::Value, "{}", case.name);
466            assert_eq!(def.value, case.want, "{}", case.name);
467        }
468    }
469
470    // Port of TestLoadCaproniConfig_FileVariablePathIsInterpolated.
471    #[test]
472    fn file_variable_path_is_interpolated() {
473        let dir = tempfile::tempdir().unwrap();
474        fs::write(dir.path().join("GITALY_SERVER_VERSION"), "14.57.1\n").unwrap();
475        unsafe {
476            std::env::set_var("CAPRONI_TEST_PROJECT_DIR", dir.path());
477        }
478
479        let config_path = dir.path().join("caproni.yaml");
480        fs::write(
481            &config_path,
482            file_variable_config("${CAPRONI_TEST_PROJECT_DIR}/GITALY_SERVER_VERSION"),
483        )
484        .unwrap();
485
486        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
487        assert_eq!(
488            cfg.cluster.network.unwrap().hostname,
489            "gitlab-14.57.1.example.com"
490        );
491    }
492
493    // Port of TestLoadCaproniConfig_FileVariableUnresolvedPathFailsLoad.
494    #[test]
495    fn file_variable_unresolved_path_fails_load() {
496        let dir = tempfile::tempdir().unwrap();
497        fs::write(dir.path().join("GITALY_SERVER_VERSION"), "14.57.1\n").unwrap();
498
499        let config_path = dir.path().join("caproni.yaml");
500        fs::write(
501            &config_path,
502            file_variable_config("${CAPRONI_TEST_UNSET_DIR}/GITALY_SERVER_VERSION"),
503        )
504        .unwrap();
505
506        let err = load(config_path.to_string_lossy().into_owned()).unwrap_err();
507        let msg = err.to_string();
508        assert!(msg.contains("variables.GITALY_VERSION"));
509        assert!(msg.contains("CAPRONI_TEST_UNSET_DIR"));
510    }
511
512    // Port of TestLoadCaproniConfig_FileVariableEmptyFileIsEmpty.
513    #[test]
514    fn file_variable_empty_file_is_empty() {
515        let dir = tempfile::tempdir().unwrap();
516        fs::write(dir.path().join("EMPTY"), "\n").unwrap();
517
518        let content = "version: 2\nvariables:\n  EMPTY:\n    type: file\n    path: EMPTY\ncluster:\n  driver: colima\n  network:\n    hostname: gitlab.example.com\n  colima:\n    memory: 8GiB\n    cores: 4\n";
519        let config_path = dir.path().join("caproni.yaml");
520        fs::write(&config_path, content).unwrap();
521
522        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
523        let def = cfg
524            .variables
525            .unwrap()
526            .get(&"EMPTY".to_string())
527            .cloned()
528            .unwrap();
529        assert!(def.value.is_empty());
530    }
531
532    // Port of TestLoadCaproniConfig_FileVariableMissingFileFailsLoad.
533    #[test]
534    fn file_variable_missing_file_fails_load() {
535        let dir = tempfile::tempdir().unwrap();
536        let config_path = dir.path().join("caproni.yaml");
537        fs::write(
538            &config_path,
539            file_variable_config("missing/GITALY_SERVER_VERSION"),
540        )
541        .unwrap();
542
543        let err = load(config_path.to_string_lossy().into_owned()).unwrap_err();
544        let msg = err.to_string();
545        assert!(msg.contains("variables.GITALY_VERSION"));
546        assert!(msg.contains("missing"));
547        assert!(msg.contains("GITALY_SERVER_VERSION"));
548    }
549
550    // Port of TestExpandVariableDefinitions_WritesBackFileVariable.
551    #[test]
552    fn expand_variable_definitions_writes_back_file_variable() {
553        let dir = tempfile::tempdir().unwrap();
554        fs::write(dir.path().join("VERSION"), "1.2.3\n").unwrap();
555
556        let mut vars = OrderedMap::new();
557        vars.set(
558            "VERSION".to_string(),
559            VariableConfig {
560                var_type: VariableType::File,
561                value: String::new(),
562                path: "${MISSING:-VERSION}".into(),
563            },
564        );
565
566        let mut seed = CaproniConfig {
567            config_dir: dir.path().to_string_lossy().into_owned(),
568            variables: Some(vars),
569            ..Default::default()
570        };
571
572        let mut base_map = json!({
573            "variables": {"VERSION": {"type": "file", "path": "${MISSING:-VERSION}"}}
574        });
575
576        struct AlwaysMissing;
577        impl Lookup for AlwaysMissing {
578            fn lookup(&self, _name: &str) -> Option<String> {
579                None
580            }
581        }
582
583        let unresolved = expand_variable_definitions(&mut base_map, &mut seed, &AlwaysMissing)
584            .expect("expand should succeed");
585        assert!(unresolved.is_empty());
586
587        assert_eq!(base_map["variables"]["VERSION"], json!("1.2.3"));
588
589        let def = seed
590            .variables
591            .unwrap()
592            .get(&"VERSION".to_string())
593            .cloned()
594            .unwrap();
595        assert_eq!(def.value, "1.2.3");
596    }
597}