1use 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
12pub 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
44pub 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
88fn 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
116fn 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 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
151fn 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
164fn 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
180fn 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 #[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 #[test]
242 fn definition_resolves_from_environment() {
243 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}