1use std::io::Read as _;
55use std::sync::Mutex;
56
57use serde_json::Value;
58
59use super::errors::ConfigError;
60
61pub(super) const MAX_FRAGMENT_BYTES: u64 = 10 * 1024 * 1024;
64
65const REMOTE_FRAGMENT_SCHEME: &str = "https://";
68
69#[derive(Debug, Clone, Default)]
75pub(super) struct RemoteElement {
76 pub url: String,
77 pub values_file: String,
78 pub kustomize_path: String,
79}
80
81pub(super) fn is_remote_source(source: &str) -> bool {
84 source.starts_with(REMOTE_FRAGMENT_SCHEME)
85}
86
87pub trait RemoteFetcher: Send + Sync {
92 fn fetch(&self, url: &str) -> Result<Vec<u8>, ConfigError>;
95}
96
97#[derive(Default)]
102pub struct UreqFetcher {
103 cache: Mutex<std::collections::HashMap<String, Vec<u8>>>,
104}
105
106impl UreqFetcher {
107 #[must_use]
108 pub fn new() -> Self {
109 Self::default()
110 }
111}
112
113impl RemoteFetcher for UreqFetcher {
114 fn fetch(&self, url: &str) -> Result<Vec<u8>, ConfigError> {
115 if let Some(cached) = self.cache.lock().unwrap().get(url) {
116 return Ok(cached.clone());
117 }
118
119 let body = fetch_remote_file_via_ureq(url)?;
120 self.cache
121 .lock()
122 .unwrap()
123 .insert(url.to_string(), body.clone());
124 Ok(body)
125 }
126}
127
128fn fetch_remote_file_via_ureq(url: &str) -> Result<Vec<u8>, ConfigError> {
129 let mut response = ureq::get(url)
130 .call()
131 .map_err(|err| ConfigError::RemoteFragmentFetch(format!("{url}: {err}")))?;
132
133 if response.status() != 200 {
134 return Err(ConfigError::RemoteFragmentFetch(format!(
135 "{url}: status {}",
136 response.status()
137 )));
138 }
139
140 let mut body = Vec::new();
146 response
147 .body_mut()
148 .as_reader()
149 .take(MAX_FRAGMENT_BYTES + 1)
150 .read_to_end(&mut body)
151 .map_err(|err| ConfigError::RemoteFragmentFetch(format!("{url}: {err}")))?;
152
153 if body.len() as u64 > MAX_FRAGMENT_BYTES {
154 return Err(ConfigError::RemoteFragmentFetch(format!(
155 "{url}: response exceeds {MAX_FRAGMENT_BYTES} bytes"
156 )));
157 }
158
159 Ok(body)
160}
161
162const LIFECYCLE_HOOKS_KEY: &str = "lifecycle_hooks";
163const ADDITIONAL_HOSTNAMES_KEY: &str = "additional_hostnames";
164const DYNAMIC_OVERRIDES_KEY: &str = "dynamic_overrides";
165const LOCAL_OVERRIDE_FILENAME: &str = "xianyun.local.yaml";
168const LOCAL_OVERRIDE_FILENAME_ALIAS: &str = "caproni.local.yaml";
169
170fn resolve_local_override_path(config_path: &std::path::Path) -> std::path::PathBuf {
175 let dir = config_path
176 .parent()
177 .unwrap_or_else(|| std::path::Path::new("."));
178 let primary = dir.join(LOCAL_OVERRIDE_FILENAME);
179 if primary.is_file() {
180 return primary;
181 }
182 let alias = dir.join(LOCAL_OVERRIDE_FILENAME_ALIAS);
183 if alias.is_file() {
184 return alias;
185 }
186 primary
187}
188
189pub fn deep_merge(dst: &Value, src: &Value) -> Value {
196 let (Some(dst_map), Some(src_map)) = (dst.as_object(), src.as_object()) else {
197 return src.clone();
198 };
199
200 let mut result = dst_map.clone();
201
202 for (key, src_val) in src_map {
203 if src_val.is_null() {
205 result.remove(key);
206 continue;
207 }
208
209 let Some(dst_val) = result.get(key) else {
210 result.insert(key.clone(), src_val.clone());
211 continue;
212 };
213
214 if dst_val.is_object() && src_val.is_object() {
215 let merged = deep_merge(dst_val, src_val);
216 result.insert(key.clone(), merged);
217 continue;
218 }
219
220 if matches!(
221 key.as_str(),
222 LIFECYCLE_HOOKS_KEY | ADDITIONAL_HOSTNAMES_KEY | DYNAMIC_OVERRIDES_KEY
223 ) && let (Some(dst_arr), Some(src_arr)) = (dst_val.as_array(), src_val.as_array())
224 {
225 let mut combined = dst_arr.clone();
226 combined.extend(src_arr.clone());
227 result.insert(key.clone(), Value::Array(combined));
228 continue;
229 }
230
231 result.insert(key.clone(), src_val.clone());
232 }
233
234 Value::Object(result)
235}
236
237fn is_valid_fragment_name(path: &str) -> bool {
247 let mut parts: Vec<&str> = path.split('/').collect();
248 let Some(filename) = parts.pop() else {
249 return false;
250 };
251
252 if !is_caproni_fragment_filename(filename, "xianyun")
253 && !is_caproni_fragment_filename(filename, "caproni")
254 {
255 return false;
256 }
257
258 parts
259 .iter()
260 .all(|d| *d == "." || (!d.is_empty() && !d.starts_with('.')))
261}
262
263fn is_caproni_fragment_filename(name: &str, prefix: &str) -> bool {
264 let Some(rest) = name.strip_prefix(prefix) else {
265 return false;
266 };
267
268 if rest == ".yaml" {
269 return true;
270 }
271
272 let Some(mid) = rest.strip_prefix('.') else {
273 return false;
274 };
275
276 mid.strip_suffix(".yaml")
277 .is_some_and(|base| !base.is_empty() && !base.contains('/'))
278}
279
280fn extract_version(
282 cfg: &serde_json::Map<String, Value>,
283 fallback: i64,
284) -> Result<i64, ConfigError> {
285 match cfg.get("version") {
286 None => Ok(fallback),
287 Some(v) => v.as_i64().ok_or(ConfigError::InvalidVersion),
288 }
289}
290
291fn ensure_same_version(
293 base: &serde_json::Map<String, Value>,
294 override_map: &serde_json::Map<String, Value>,
295) -> Result<(), ConfigError> {
296 let base_version = extract_version(base, 2)?;
297 let override_version = extract_version(override_map, base_version)?;
298
299 if base_version != override_version {
300 return Err(ConfigError::OverrideVersionMismatch);
301 }
302
303 Ok(())
304}
305
306fn parse_extends_paths(val: &Value) -> Result<Vec<String>, ConfigError> {
308 let items = val.as_array().ok_or(ConfigError::ExtendsNotList)?;
309
310 items
311 .iter()
312 .map(|item| {
313 item.as_str()
314 .map(String::from)
315 .ok_or(ConfigError::ExtendsItemNotString)
316 })
317 .collect()
318}
319
320fn load_fragment(abs_path: &std::path::Path) -> Result<Value, ConfigError> {
322 let data = std::fs::read(abs_path)
323 .map_err(|_| ConfigError::ConfigFileNotFound(abs_path.display().to_string()))?;
324
325 serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))
326}
327
328#[allow(clippy::too_many_arguments)]
334pub(super) fn resolve_extends(
335 config_path: &std::path::Path,
336 raw_config: Value,
337 is_fragment: bool,
338 visited: &mut std::collections::HashSet<std::path::PathBuf>,
339 used_fragments: &mut std::collections::HashMap<String, String>,
340 fetcher: &dyn RemoteFetcher,
341 mut element_sources: Option<&mut std::collections::HashMap<String, RemoteElement>>,
342) -> Result<Value, ConfigError> {
343 let Value::Object(mut base_map) = raw_config else {
344 return Ok(raw_config);
345 };
346
347 let Some(extends_val) = base_map.get("extends").cloned() else {
348 return Ok(Value::Object(base_map));
349 };
350
351 if is_fragment {
352 return Err(ConfigError::FragmentHasExtends);
353 }
354
355 let extends_paths = parse_extends_paths(&extends_val)?;
356
357 let base_dir = config_path
358 .parent()
359 .unwrap_or_else(|| std::path::Path::new("."));
360
361 let mut merged = Value::Object(serde_json::Map::new());
362
363 for rel_path in &extends_paths {
364 let fragment = if rel_path.starts_with(REMOTE_FRAGMENT_SCHEME) {
365 let url = url_parse(rel_path).ok_or(ConfigError::InvalidFragmentName)?;
366 let basename = url.path.rsplit('/').next().unwrap_or("");
370 if !is_caproni_fragment_filename(basename, "xianyun")
371 && !is_caproni_fragment_filename(basename, "caproni")
372 {
373 return Err(ConfigError::InvalidFragmentName);
374 }
375
376 resolve_remote_fragment(
377 config_path,
378 rel_path,
379 visited,
380 used_fragments,
381 fetcher,
382 element_sources.as_deref_mut(),
383 )?
384 } else if rel_path.contains("://") {
385 return Err(ConfigError::RemoteFragmentFetch(format!(
386 "{rel_path}: only https:// is supported"
387 )));
388 } else {
389 resolve_local_fragment(
390 config_path,
391 base_dir,
392 rel_path,
393 visited,
394 used_fragments,
395 fetcher,
396 )?
397 };
398
399 merged = deep_merge(&merged, &fragment);
400 }
401
402 base_map.remove("extends");
403
404 Ok(deep_merge(&merged, &Value::Object(base_map)))
405}
406
407struct ParsedUrl {
413 path: String,
414}
415
416fn url_parse(url: &str) -> Option<ParsedUrl> {
417 let rest = url.strip_prefix(REMOTE_FRAGMENT_SCHEME)?;
418 let path_start = rest.find('/')?;
419 let path_and_query = &rest[path_start..];
420 let path = path_and_query.split(['?', '#']).next().unwrap_or("");
421 Some(ParsedUrl {
422 path: path.to_string(),
423 })
424}
425
426fn resolve_local_fragment(
428 config_path: &std::path::Path,
429 base_dir: &std::path::Path,
430 rel_path: &str,
431 visited: &mut std::collections::HashSet<std::path::PathBuf>,
432 used_fragments: &mut std::collections::HashMap<String, String>,
433 fetcher: &dyn RemoteFetcher,
434) -> Result<Value, ConfigError> {
435 let abs_frag_path = base_dir.join(rel_path);
436
437 let rel = pathdiff(&abs_frag_path, base_dir);
438 if rel.starts_with("..") {
439 return Err(ConfigError::FragmentOutsideDir);
440 }
441
442 if !is_valid_fragment_name(rel_path) {
443 return Err(ConfigError::InvalidFragmentName);
444 }
445
446 let canonical = abs_frag_path
447 .canonicalize()
448 .unwrap_or_else(|_| abs_frag_path.clone());
449 let canonical_key = canonical.display().to_string();
450
451 if used_fragments.contains_key(&canonical_key) {
452 return Err(ConfigError::DuplicateExtends(rel_path.to_string()));
453 }
454
455 used_fragments.insert(
456 canonical_key,
457 config_path
458 .file_name()
459 .map(|n| n.to_string_lossy().into_owned())
460 .unwrap_or_default(),
461 );
462
463 if visited.contains(&canonical) {
464 return Err(ConfigError::CircularExtends);
465 }
466
467 let frag_map = load_fragment(&abs_frag_path)?;
468
469 resolve_extends(
475 &abs_frag_path,
476 frag_map,
477 true,
478 visited,
479 used_fragments,
480 fetcher,
481 None,
482 )
483}
484
485fn resolve_remote_fragment(
487 config_path: &std::path::Path,
488 url: &str,
489 visited: &mut std::collections::HashSet<std::path::PathBuf>,
490 used_fragments: &mut std::collections::HashMap<String, String>,
491 fetcher: &dyn RemoteFetcher,
492 element_sources: Option<&mut std::collections::HashMap<String, RemoteElement>>,
493) -> Result<Value, ConfigError> {
494 if used_fragments.contains_key(url) {
495 return Err(ConfigError::DuplicateExtends(url.to_string()));
496 }
497
498 used_fragments.insert(
499 url.to_string(),
500 config_path
501 .file_name()
502 .map(|n| n.to_string_lossy().into_owned())
503 .unwrap_or_default(),
504 );
505
506 let data = fetcher.fetch(url)?;
507
508 let frag_map: Value =
509 serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
510
511 let frag_map = resolve_extends(
515 std::path::Path::new(url),
516 frag_map,
517 true,
518 visited,
519 used_fragments,
520 fetcher,
521 None,
522 )?;
523
524 if let Some(sources) = element_sources {
525 record_element_sources(sources, &frag_map, url);
526 }
527
528 Ok(frag_map)
529}
530
531fn record_element_sources(
533 sources: &mut std::collections::HashMap<String, RemoteElement>,
534 frag_map: &Value,
535 url: &str,
536) {
537 let Some(deployers) = frag_map.get("deployers").and_then(Value::as_object) else {
538 return;
539 };
540
541 for (name, deployer) in deployers {
542 let Some(d) = deployer.as_object() else {
543 continue;
544 };
545
546 let values_file = d
547 .get("helm")
548 .and_then(|h| h.get("values_file"))
549 .and_then(Value::as_str)
550 .unwrap_or_default()
551 .to_string();
552 let kustomize_path = d
553 .get("kustomize")
554 .and_then(|k| k.get("path"))
555 .and_then(Value::as_str)
556 .unwrap_or_default()
557 .to_string();
558
559 sources.insert(
560 format!("deployers/{name}"),
561 RemoteElement {
562 url: url.to_string(),
563 values_file,
564 kustomize_path,
565 },
566 );
567 }
568}
569
570fn pathdiff(path: &std::path::Path, base: &std::path::Path) -> std::path::PathBuf {
575 use std::path::Component;
576
577 fn normalize(p: &std::path::Path) -> Vec<Component<'_>> {
578 let mut out: Vec<Component<'_>> = Vec::new();
579 for c in p.components() {
580 match c {
581 Component::CurDir => {}
582 Component::ParentDir => {
583 if matches!(out.last(), Some(Component::Normal(_))) {
584 out.pop();
585 } else {
586 out.push(c);
587 }
588 }
589 other => out.push(other),
590 }
591 }
592 out
593 }
594
595 let path_parts = normalize(path);
596 let base_parts = normalize(base);
597
598 let common = path_parts
599 .iter()
600 .zip(base_parts.iter())
601 .take_while(|(a, b)| a == b)
602 .count();
603
604 let mut result = std::path::PathBuf::new();
605 for _ in common..base_parts.len() {
606 result.push("..");
607 }
608 for part in &path_parts[common..] {
609 result.push(part.as_os_str());
610 }
611
612 if result.as_os_str().is_empty() {
613 result.push(".");
614 }
615
616 result
617}
618
619pub(super) fn load_local_override(
624 config_path: &std::path::Path,
625) -> Result<Option<serde_json::Map<String, Value>>, ConfigError> {
626 let local_path = resolve_local_override_path(config_path);
627
628 let data = match std::fs::read(&local_path) {
629 Ok(d) => d,
630 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
631 Err(err) => {
632 return Err(ConfigError::InvalidYAML(format!(
633 "reading {}: {err}",
634 local_path.display()
635 )));
636 }
637 };
638
639 let value: Value =
640 serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
641
642 match value {
643 Value::Object(map) => Ok(Some(map)),
644 Value::Null => Ok(Some(serde_json::Map::new())),
645 _ => Err(ConfigError::InvalidYAML(format!(
646 "{} must be a YAML mapping",
647 local_path.display()
648 ))),
649 }
650}
651
652pub(super) fn resolve_local_override(
657 abs_path: &std::path::Path,
658 used_fragments: &mut std::collections::HashMap<String, String>,
659 fetcher: &dyn RemoteFetcher,
660 element_sources: Option<&mut std::collections::HashMap<String, RemoteElement>>,
661) -> Result<Option<Value>, ConfigError> {
662 let Some(local_override) = load_local_override(abs_path)? else {
663 return Ok(None);
664 };
665
666 let local_abs_path = resolve_local_override_path(abs_path);
667
668 let mut visited = std::collections::HashSet::new();
669 visited.insert(
670 abs_path
671 .canonicalize()
672 .unwrap_or_else(|_| abs_path.to_path_buf()),
673 );
674 visited.insert(
675 local_abs_path
676 .canonicalize()
677 .unwrap_or_else(|_| local_abs_path.clone()),
678 );
679
680 let resolved = resolve_extends(
681 &local_abs_path,
682 Value::Object(local_override),
683 false,
684 &mut visited,
685 used_fragments,
686 fetcher,
687 element_sources,
688 )?;
689
690 Ok(Some(resolved))
691}
692
693pub(super) fn apply_local_override(
698 raw_config: Value,
699 override_val: Value,
700) -> Result<Value, ConfigError> {
701 let (Value::Object(base_map), Value::Object(override_map)) = (&raw_config, &override_val)
702 else {
703 return Ok(raw_config);
704 };
705
706 ensure_same_version(base_map, override_map)?;
707
708 Ok(deep_merge(&raw_config, &override_val))
709}
710
711const CAPRONI_PROJECT_FILENAME: &str = "xianyun-project.yaml";
715const CAPRONI_PROJECT_FILENAME_ALIAS: &str = "caproni-project.yaml";
716const RELOADERS_KEY: &str = "reloaders";
717
718pub(super) fn validate_project_file_schema(frag: &Value) -> Result<(), ConfigError> {
727 const V2_SCHEMA_JSON: &str = include_str!("../../../../schema/config.v2.schema.json");
728 const PROJECT_SCHEMA_JSON: &str =
729 include_str!("../../../../schema/caproni-project.schema.json");
730
731 const V2_SCHEMA_URL: &str = "https://gitlab.com/schemas/config.v2.schema.json";
732
733 let v2_doc: Value = serde_json::from_str(V2_SCHEMA_JSON)
734 .map_err(|err| ConfigError::SchemaValidation(format!("parsing config schema v2: {err}")))?;
735 let project_doc: Value = serde_json::from_str(PROJECT_SCHEMA_JSON).map_err(|err| {
736 ConfigError::SchemaValidation(format!("parsing caproni-project schema: {err}"))
737 })?;
738
739 let v2_resource = jsonschema::Resource::from_contents(v2_doc).map_err(|err| {
740 ConfigError::SchemaValidation(format!(
741 "adding v2 schema resource for project schema: {err}"
742 ))
743 })?;
744
745 let compiled = jsonschema::options()
746 .with_resource(V2_SCHEMA_URL, v2_resource)
747 .build(&project_doc)
748 .map_err(|err| {
749 ConfigError::SchemaValidation(format!("compiling caproni-project schema: {err}"))
750 })?;
751
752 let errors: Vec<String> = compiled
753 .iter_errors(frag)
754 .map(|e| {
755 let path = e.instance_path.to_string();
756 if path.is_empty() {
757 e.to_string()
758 } else {
759 format!("{path}: {e}")
760 }
761 })
762 .collect();
763
764 if errors.is_empty() {
765 Ok(())
766 } else {
767 Err(ConfigError::SchemaValidation(errors.join("; ")))
768 }
769}
770
771pub(super) fn validate_repo_deployers_fragment(
775 frag: &Value,
776 repo_name: &str,
777) -> Result<(), ConfigError> {
778 let Some(reloaders_val) = frag.get(RELOADERS_KEY) else {
779 return Ok(());
780 };
781
782 let Some(reloaders) = reloaders_val.as_object() else {
783 return Err(ConfigError::RepoFragmentOnlyReloaders);
784 };
785
786 for (name, val) in reloaders {
787 let Some(entry) = val.as_object() else {
788 continue;
789 };
790
791 let repo = entry
792 .get("repository")
793 .and_then(Value::as_str)
794 .unwrap_or("");
795 if repo != repo_name {
796 return Err(ConfigError::RepoFragmentWrongRepository {
797 name: name.clone(),
798 repo: repo.to_string(),
799 expected: repo_name.to_string(),
800 });
801 }
802 }
803
804 Ok(())
805}
806
807fn load_repo_deployers_fragment(dir: &std::path::Path) -> Result<Option<Value>, ConfigError> {
811 let primary_path = dir.join(CAPRONI_PROJECT_FILENAME);
812 let frag_path = if primary_path.is_file() {
813 primary_path
814 } else {
815 dir.join(CAPRONI_PROJECT_FILENAME_ALIAS)
816 };
817
818 let data = match std::fs::read(&frag_path) {
819 Ok(d) => d,
820 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
821 Err(err) => {
822 return Err(ConfigError::InvalidYAML(format!(
823 "reading {}: {err}",
824 frag_path.display()
825 )));
826 }
827 };
828
829 let mut frag: Value =
830 serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
831
832 if let Value::Object(map) = &mut frag {
834 map.remove("version");
835 }
836
837 Ok(Some(frag))
838}
839
840pub(super) fn apply_caproni_project(
850 base_config: Value,
851 config_dir: &std::path::Path,
852) -> Result<Value, ConfigError> {
853 let Some(repos) = base_config.get("repositories").and_then(Value::as_object) else {
854 return Ok(base_config);
855 };
856
857 let mut repo_names: Vec<&String> = repos.keys().collect();
858 repo_names.sort();
859
860 let mut all_reloaders = serde_json::Map::new();
861 let mut repo_hooks: Vec<(String, Vec<Value>)> = Vec::new();
862
863 for repo_name in repo_names {
864 let Some(repo) = repos.get(repo_name).and_then(Value::as_object) else {
865 continue;
866 };
867
868 let edit_mode = repo
869 .get("edit_mode")
870 .and_then(Value::as_bool)
871 .unwrap_or(false);
872 if !edit_mode {
873 continue;
874 }
875
876 let dir_str = repo.get("directory").and_then(Value::as_str).unwrap_or("");
877 if dir_str.is_empty() {
878 continue;
879 }
880
881 let dir_path = std::path::Path::new(dir_str);
882 let dir = if dir_path.is_absolute() {
883 dir_path.to_path_buf()
884 } else {
885 config_dir.join(dir_path)
886 };
887
888 let Some(frag) = load_repo_deployers_fragment(&dir)? else {
889 continue;
890 };
891
892 let is_empty = frag.as_object().is_none_or(serde_json::Map::is_empty);
893 if is_empty {
894 continue;
895 }
896
897 validate_project_file_schema(&frag)?;
898 validate_repo_deployers_fragment(&frag, repo_name)?;
899
900 if let Some(reloaders) = frag.get(RELOADERS_KEY).and_then(Value::as_object) {
901 for (name, val) in reloaders {
902 if all_reloaders.contains_key(name) {
903 return Err(ConfigError::RepoFragmentDuplicateReloader(name.clone()));
904 }
905
906 all_reloaders.insert(name.clone(), val.clone());
907 }
908 }
909
910 if let Some(hooks) = frag.get(LIFECYCLE_HOOKS_KEY).and_then(Value::as_array)
911 && !hooks.is_empty()
912 {
913 repo_hooks.push((repo_name.clone(), hooks.clone()));
914 }
915 }
916
917 if all_reloaders.is_empty() && repo_hooks.is_empty() {
918 return Ok(base_config);
919 }
920
921 let mut merged = base_config;
922
923 if !all_reloaders.is_empty() {
924 let reloaders_overlay = Value::Object(serde_json::Map::from_iter([(
925 RELOADERS_KEY.to_string(),
926 Value::Object(all_reloaders),
927 )]));
928 merged = deep_merge(&reloaders_overlay, &merged);
929 }
930
931 if !repo_hooks.is_empty() {
935 let mut repo_fragment = serde_json::Map::new();
936 for (repo_name, hooks) in repo_hooks {
937 repo_fragment.insert(
938 repo_name,
939 Value::Object(serde_json::Map::from_iter([(
940 LIFECYCLE_HOOKS_KEY.to_string(),
941 Value::Array(hooks),
942 )])),
943 );
944 }
945
946 let hooks_overlay = Value::Object(serde_json::Map::from_iter([(
947 "repositories".to_string(),
948 Value::Object(repo_fragment),
949 )]));
950 merged = deep_merge(&merged, &hooks_overlay);
951 }
952
953 Ok(merged)
954}
955
956const BUILTIN_KUSTOMIZE_SCHEME: &str = "builtin:";
960
961fn is_builtin_kustomize_path(path: &str) -> bool {
962 path.starts_with(BUILTIN_KUSTOMIZE_SCHEME)
963}
964
965fn is_kustomize_remote_target(path: &str) -> bool {
972 let Some(scheme_end) = path.find("://") else {
973 return false;
974 };
975
976 matches!(
977 path[..scheme_end].to_ascii_lowercase().as_str(),
978 "http" | "https" | "ssh" | "file"
979 )
980}
981
982pub(super) fn fetch_deployer_files(
988 cfg: &mut super::latest::CaproniConfig,
989 element_sources: &std::collections::HashMap<String, RemoteElement>,
990 fetcher: &dyn RemoteFetcher,
991) -> Result<(), ConfigError> {
992 let Some(deployers) = cfg.deployers.as_mut() else {
993 return Ok(());
994 };
995
996 let names: Vec<String> = deployers.iter().map(|(k, _)| k.clone()).collect();
997
998 for name in names {
999 let Some(src) = element_sources.get(&format!("deployers/{name}")) else {
1000 continue;
1001 };
1002
1003 if !is_remote_source(&src.url) {
1004 continue;
1005 }
1006
1007 {
1008 let dep = deployers.get(&name).expect("name came from this map");
1009 if let Some(helm) = &dep.helm
1010 && helm.values_file != src.values_file
1011 {
1012 continue;
1013 }
1014 if let Some(kustomize) = &dep.kustomize
1015 && kustomize.path != src.kustomize_path
1016 {
1017 continue;
1018 }
1019 }
1020
1021 let dep = deployers.get_mut(&name).expect("name came from this map");
1022 fetch_deployer_files_from_remote(&name, dep, &src.url, fetcher)?;
1023 }
1024
1025 Ok(())
1026}
1027
1028fn fetch_deployer_files_from_remote(
1032 _name: &str,
1033 deployer: &mut super::latest::DeployerConfig,
1034 frag_url: &str,
1035 fetcher: &dyn RemoteFetcher,
1036) -> Result<(), ConfigError> {
1037 if let Some(kustomize) = &deployer.kustomize
1038 && !is_builtin_kustomize_path(&kustomize.path)
1039 && !is_kustomize_remote_target(&kustomize.path)
1040 {
1041 return Err(ConfigError::RemoteFragmentDirRef);
1042 }
1043
1044 let Some(helm) = &mut deployer.helm else {
1045 return Ok(());
1046 };
1047
1048 if helm.values_file.is_empty() || std::path::Path::new(&helm.values_file).is_absolute() {
1049 return Ok(());
1050 }
1051
1052 helm.values_file = fetch_relative_file(fetcher, frag_url, &helm.values_file)?;
1053
1054 Ok(())
1055}
1056
1057fn resolve_relative_url(base: &str, rel_path: &str) -> Result<String, ConfigError> {
1062 if rel_path.contains("://") {
1063 return Ok(rel_path.to_string());
1064 }
1065
1066 let last_slash = base
1067 .rfind('/')
1068 .ok_or_else(|| ConfigError::RemoteFragmentFetch(base.to_string()))?;
1069
1070 Ok(format!(
1071 "{}/{}",
1072 &base[..last_slash],
1073 rel_path.trim_start_matches("./")
1074 ))
1075}
1076
1077fn cache_file_path(url: &str) -> std::path::PathBuf {
1081 use sha2::{Digest, Sha256};
1082
1083 let mut hasher = Sha256::new();
1084 hasher.update(url.as_bytes());
1085 let hash = hasher.finalize();
1086 let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
1087
1088 std::env::temp_dir()
1089 .join("xianyun-remote-fragments")
1090 .join(hex)
1091}
1092
1093fn fetch_relative_file(
1111 fetcher: &dyn RemoteFetcher,
1112 frag_url: &str,
1113 rel_path: &str,
1114) -> Result<String, ConfigError> {
1115 let file_url = resolve_relative_url(frag_url, rel_path)?;
1116 let path = cache_file_path(&file_url);
1117
1118 if let Ok(data) = fetcher.fetch(&file_url) {
1119 if let Some(parent) = path.parent() {
1120 std::fs::create_dir_all(parent).map_err(|err| {
1121 ConfigError::RemoteFragmentFetch(format!("persisting cache entry: {err}"))
1122 })?;
1123 }
1124 std::fs::write(&path, &data).map_err(|err| {
1125 ConfigError::RemoteFragmentFetch(format!("persisting cache entry: {err}"))
1126 })?;
1127 }
1128
1129 Ok(path.to_string_lossy().into_owned())
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134 use super::*;
1135 use crate::config::global::GlobalConfig;
1136 use serde_json::json;
1137 use std::fs;
1138
1139 fn write_test_config(content: &str) -> (tempfile::TempDir, String) {
1140 let dir = tempfile::tempdir().unwrap();
1141 let path = dir.path().join("caproni.yaml");
1142 fs::write(&path, content).unwrap();
1143 (dir, path.to_string_lossy().into_owned())
1144 }
1145
1146 fn write_local_override(config_path: &str, content: &str) {
1148 let dir = std::path::Path::new(config_path).parent().unwrap();
1149 fs::write(dir.join("caproni.local.yaml"), content).unwrap();
1150 }
1151
1152 fn write_fragment(dir: &std::path::Path, name: &str, content: &str) {
1154 fs::write(dir.join(name), content).unwrap();
1155 }
1156
1157 fn load(path: String) -> Result<super::super::latest::CaproniConfig, ConfigError> {
1158 let gc = GlobalConfig {
1159 config_file: path,
1160 ..Default::default()
1161 };
1162 super::super::caproni::load_caproni_config(&gc)
1163 }
1164
1165 #[test]
1167 fn deep_merge_cases() {
1168 struct Case {
1169 name: &'static str,
1170 dst: Value,
1171 src: Value,
1172 want: Value,
1173 }
1174
1175 let cases = vec![
1176 Case {
1177 name: "scalar override",
1178 dst: json!({"a": "original", "b": "keep"}),
1179 src: json!({"a": "override"}),
1180 want: json!({"a": "override", "b": "keep"}),
1181 },
1182 Case {
1183 name: "new key added",
1184 dst: json!({"a": "original"}),
1185 src: json!({"b": "new"}),
1186 want: json!({"a": "original", "b": "new"}),
1187 },
1188 Case {
1189 name: "nested map merge",
1190 dst: json!({"outer": {"keep": "kept", "override": "original"}}),
1191 src: json!({"outer": {"override": "new", "added": "yes"}}),
1192 want: json!({"outer": {"keep": "kept", "override": "new", "added": "yes"}}),
1193 },
1194 Case {
1195 name: "slice replaces",
1196 dst: json!({"items": ["a", "b"]}),
1197 src: json!({"items": ["c"]}),
1198 want: json!({"items": ["c"]}),
1199 },
1200 Case {
1201 name: "additional_hostnames appended",
1202 dst: json!({"additional_hostnames": ["base.local"]}),
1203 src: json!({"additional_hostnames": ["fragment.local"]}),
1204 want: json!({"additional_hostnames": ["base.local", "fragment.local"]}),
1205 },
1206 Case {
1207 name: "lifecycle_hooks appended",
1208 dst: json!({"lifecycle_hooks": [{"type": "edit_mode_start", "script": "base.sh"}]}),
1209 src: json!({"lifecycle_hooks": [{"type": "edit_mode_start", "script": "override.sh"}]}),
1210 want: json!({"lifecycle_hooks": [
1211 {"type": "edit_mode_start", "script": "base.sh"},
1212 {"type": "edit_mode_start", "script": "override.sh"},
1213 ]}),
1214 },
1215 Case {
1216 name: "dynamic_overrides appended",
1217 dst: json!({"dynamic_overrides": [{"type": "inline", "value_map": {"crds.enabled": true}}]}),
1218 src: json!({"dynamic_overrides": [{"type": "inline", "value_map": {"prometheus.enabled": true}}]}),
1219 want: json!({"dynamic_overrides": [
1220 {"type": "inline", "value_map": {"crds.enabled": true}},
1221 {"type": "inline", "value_map": {"prometheus.enabled": true}},
1222 ]}),
1223 },
1224 ];
1225
1226 for case in cases {
1227 let result = deep_merge(&case.dst, &case.src);
1228 assert_eq!(result, case.want, "{}", case.name);
1229 }
1230 }
1231
1232 #[test]
1234 fn deep_merge_null_deletes_cases() {
1235 struct Case {
1236 name: &'static str,
1237 dst: Value,
1238 src: Value,
1239 want: Value,
1240 }
1241
1242 let cases = vec![
1243 Case {
1244 name: "null overlay deletes existing scalar key",
1245 dst: json!({"a": "original", "b": "keep"}),
1246 src: json!({"a": null}),
1247 want: json!({"b": "keep"}),
1248 },
1249 Case {
1250 name: "null overlay deletes existing map key",
1251 dst: json!({"traefik": {"type": "helm"}, "keep": "kept"}),
1252 src: json!({"traefik": null}),
1253 want: json!({"keep": "kept"}),
1254 },
1255 Case {
1256 name: "null overlay on absent key is a harmless no-op",
1257 dst: json!({"a": "original"}),
1258 src: json!({"b": null}),
1259 want: json!({"a": "original"}),
1260 },
1261 Case {
1262 name: "nested null deletes only the inner key",
1263 dst: json!({"deployers": {"traefik": {"type": "helm"}, "gitlab": {"type": "helm"}}}),
1264 src: json!({"deployers": {"traefik": null}}),
1265 want: json!({"deployers": {"gitlab": {"type": "helm"}}}),
1266 },
1267 Case {
1268 name: "null deletes an append-slice key (lifecycle_hooks)",
1269 dst: json!({"lifecycle_hooks": [{"type": "edit_mode_start"}]}),
1270 src: json!({"lifecycle_hooks": null}),
1271 want: json!({}),
1272 },
1273 Case {
1274 name: "null drops every inherited dynamic_overrides entry",
1275 dst: json!({"dynamic_overrides": [{"type": "inline"}]}),
1276 src: json!({"dynamic_overrides": null}),
1277 want: json!({}),
1278 },
1279 Case {
1280 name: "empty-map overlay on existing map is a no-op",
1281 dst: json!({"traefik": {"type": "helm"}}),
1282 src: json!({"traefik": {}}),
1283 want: json!({"traefik": {"type": "helm"}}),
1284 },
1285 ];
1286
1287 for case in cases {
1288 let result = deep_merge(&case.dst, &case.src);
1289 assert_eq!(result, case.want, "{}", case.name);
1290 }
1291 }
1292
1293 #[test]
1295 fn deep_merge_dst_not_modified() {
1296 let dst = json!({"a": "original"});
1297 let src = json!({"a": "override", "b": "new"});
1298
1299 let _ = deep_merge(&dst, &src);
1300
1301 assert_eq!(dst["a"], json!("original"));
1302 assert!(dst.get("b").is_none());
1303 }
1304
1305 #[test]
1307 fn local_override_null_deployer_removes() {
1308 let base = "
1309version: 2
1310cluster:
1311 driver: colima
1312 network:
1313 hostname: test.local
1314 colima:
1315 memory: 4GiB
1316 cores: 2
1317repositories: {}
1318deployers:
1319 extra-ns:
1320 type: namespace
1321 namespace:
1322 namespaces:
1323 - extra
1324 keep-ns:
1325 type: namespace
1326 namespace:
1327 namespaces:
1328 - keep
1329";
1330 let (_dir, config_path) = write_test_config(base);
1331 write_local_override(&config_path, "deployers:\n extra-ns: null\n");
1332
1333 let cfg = load(config_path).expect("load should succeed");
1334 let deployers = cfg.deployers.unwrap();
1335 assert!(
1336 !deployers.has(&"extra-ns".to_string()),
1337 "null override removes the deployer"
1338 );
1339 assert!(
1340 deployers.has(&"keep-ns".to_string()),
1341 "sibling deployer survives"
1342 );
1343 }
1344
1345 #[test]
1347 fn local_override_deletes_extended_deployer() {
1348 let tmp_dir = tempfile::tempdir().unwrap();
1349
1350 write_fragment(
1351 tmp_dir.path(),
1352 "caproni.infra.yaml",
1353 "
1354deployers:
1355 shared-ns:
1356 type: namespace
1357 namespace:
1358 namespaces:
1359 - shared
1360 other-ns:
1361 type: namespace
1362 namespace:
1363 namespaces:
1364 - other
1365",
1366 );
1367
1368 let config_path = tmp_dir.path().join("caproni.yaml");
1369 fs::write(
1370 &config_path,
1371 "
1372version: 2
1373extends:
1374 - ./caproni.infra.yaml
1375cluster:
1376 driver: colima
1377 network:
1378 hostname: test.local
1379 colima:
1380 memory: 4GiB
1381 cores: 2
1382repositories: {}
1383deployers:
1384 my-app:
1385 type: namespace
1386 namespace:
1387 namespaces:
1388 - my-app
1389",
1390 )
1391 .unwrap();
1392
1393 let config_path = config_path.to_string_lossy().into_owned();
1394 write_local_override(&config_path, "deployers:\n shared-ns: null\n");
1395
1396 let cfg = load(config_path).expect("load should succeed");
1397 let deployers = cfg.deployers.unwrap();
1398 assert!(
1399 !deployers.has(&"shared-ns".to_string()),
1400 "a null in caproni.local.yaml must delete a deployer the extended fragment supplies"
1401 );
1402 assert!(
1403 deployers.has(&"other-ns".to_string()),
1404 "deleting one extended deployer must not affect its siblings"
1405 );
1406 assert!(
1407 deployers.has(&"my-app".to_string()),
1408 "the deployer caproni.yaml declares itself survives the override merge"
1409 );
1410 }
1411
1412 #[test]
1414 fn local_override_empty_map_deployer_is_noop() {
1415 let base = "
1416version: 2
1417cluster:
1418 driver: colima
1419 network:
1420 hostname: test.local
1421 colima:
1422 memory: 4GiB
1423 cores: 2
1424repositories: {}
1425deployers:
1426 extra-ns:
1427 type: namespace
1428 namespace:
1429 namespaces:
1430 - extra
1431";
1432 let (_dir, config_path) = write_test_config(base);
1433 write_local_override(&config_path, "deployers:\n extra-ns: {}\n");
1434
1435 let cfg = load(config_path).expect("load should succeed");
1436 let deployers = cfg.deployers.unwrap();
1437 assert!(deployers.has(&"extra-ns".to_string()));
1438
1439 let dep = deployers.get(&"extra-ns".to_string()).unwrap();
1440 let ns = dep
1441 .namespace
1442 .as_ref()
1443 .expect("empty-map override left the namespace config intact");
1444 assert_eq!(ns.namespaces, vec!["extra".to_string()]);
1445 }
1446
1447 #[test]
1453 fn local_override_absent() {
1454 let base = "
1455version: 2
1456cluster:
1457 driver: colima
1458 network:
1459 hostname: test.local
1460 colima:
1461 memory: 4GiB
1462 cores: 2
1463repositories: {}
1464";
1465 let (_dir, config_path) = write_test_config(base);
1466 let cfg = load(config_path).expect("load should succeed");
1467 assert_eq!(cfg.cluster.driver, "colima");
1468 }
1469
1470 #[test]
1472 fn local_override_scalar() {
1473 let base = "
1474version: 2
1475cluster:
1476 driver: colima
1477 network:
1478 hostname: test.local
1479 colima:
1480 memory: 4GiB
1481 cores: 2
1482repositories: {}
1483";
1484 let (_dir, config_path) = write_test_config(base);
1485 write_local_override(&config_path, "cluster:\n colima:\n memory: 16GiB\n");
1486
1487 let cfg = load(config_path).expect("load should succeed");
1488 assert_eq!(cfg.cluster.colima.as_ref().unwrap().memory, "16GiB");
1489 assert_eq!(
1490 cfg.cluster.colima.as_ref().unwrap().cores,
1491 2,
1492 "cores unchanged by override"
1493 );
1494 }
1495
1496 #[test]
1498 fn local_override_adds_reloader() {
1499 let base = "
1500version: 2
1501cluster:
1502 driver: colima
1503 network:
1504 hostname: test.local
1505 colima:
1506 memory: 4GiB
1507 cores: 2
1508repositories:
1509 app:
1510 repository: https://example.com/app.git
1511 directory: ./app/
1512";
1513 let (_dir, config_path) = write_test_config(base);
1514 write_local_override(
1515 &config_path,
1516 "
1517reloaders:
1518 app:
1519 type: mirror
1520 repository: app
1521 edit_mode: true
1522 mirror:
1523 target:
1524 namespace: default
1525 kubernetes_type: deployment
1526 name: app-deployment
1527 edit_mode_processes:
1528 server:
1529 command: bin/server
1530",
1531 );
1532
1533 let cfg = load(config_path).expect("load should succeed");
1534 let reloaders = cfg.reloaders.unwrap();
1535 assert_eq!(reloaders.len(), 1);
1536 let reloader = reloaders.get(&"app".to_string()).unwrap();
1537 assert!(reloader.edit_mode);
1538 }
1539
1540 #[test]
1542 fn version_mismatch() {
1543 let base = "
1544version: 2
1545cluster:
1546 driver: colima
1547 network:
1548 hostname: test.local
1549 colima:
1550 memory: 4GiB
1551 cores: 2
1552repositories:
1553 app:
1554 repository: https://example.com/app.git
1555 directory: ./app/
1556";
1557 let (_dir, config_path) = write_test_config(base);
1558 write_local_override(&config_path, "version: 1\n");
1559
1560 let err = load(config_path).expect_err("version mismatch should fail");
1561 assert!(matches!(err, ConfigError::OverrideVersionMismatch));
1562 assert_eq!(
1563 err.to_string(),
1564 "tried merging configs with different versions"
1565 );
1566 }
1567
1568 #[test]
1570 fn local_override_lifecycle_hooks_append() {
1571 let base = "
1572version: 2
1573cluster:
1574 driver: colima
1575 network:
1576 hostname: test.local
1577 colima:
1578 memory: 4GiB
1579 cores: 2
1580repositories:
1581 app:
1582 repository: https://example.com/app.git
1583 directory: ./app/
1584 lifecycle_hooks:
1585 - type: edit_mode_start
1586 script: ./base-hook.sh
1587";
1588 let (_dir, config_path) = write_test_config(base);
1589 write_local_override(
1590 &config_path,
1591 "
1592repositories:
1593 app:
1594 lifecycle_hooks:
1595 - type: edit_mode_start
1596 script: ./local-hook.sh
1597",
1598 );
1599
1600 let cfg = load(config_path).expect("load should succeed");
1601 let repos = cfg.repositories.unwrap();
1602 let repo = repos.get(&"app".to_string()).unwrap();
1603 assert_eq!(repo.lifecycle_hooks.len(), 2);
1604 assert_eq!(repo.lifecycle_hooks[0].script, "./base-hook.sh");
1605 assert_eq!(repo.lifecycle_hooks[1].script, "./local-hook.sh");
1606 }
1607
1608 #[test]
1610 fn local_override_invalid_yaml() {
1611 let base = "
1612version: 2
1613cluster:
1614 driver: colima
1615 network:
1616 hostname: test.local
1617 colima:
1618 memory: 4GiB
1619 cores: 2
1620repositories: {}
1621";
1622 let (_dir, config_path) = write_test_config(base);
1623 write_local_override(&config_path, "invalid: [yaml");
1624
1625 let err = load(config_path).expect_err("invalid yaml should fail");
1626 assert!(matches!(err, ConfigError::InvalidYAML(_)));
1627 }
1628
1629 #[test]
1631 fn local_override_non_mapping() {
1632 let base = "
1633version: 2
1634cluster:
1635 driver: colima
1636 network:
1637 hostname: test.local
1638 colima:
1639 memory: 4GiB
1640 cores: 2
1641repositories: {}
1642";
1643 let (_dir, config_path) = write_test_config(base);
1644 write_local_override(&config_path, "- item1\n- item2\n");
1645
1646 let err = load(config_path).expect_err("non-mapping override should fail");
1647 assert!(matches!(err, ConfigError::InvalidYAML(_)));
1648 }
1649
1650 #[test]
1652 fn extends_adds_repository() {
1653 let tmp_dir = tempfile::tempdir().unwrap();
1654 write_fragment(
1655 tmp_dir.path(),
1656 "caproni.extra.yaml",
1657 "
1658repositories:
1659 extra:
1660 repository: https://example.com/extra.git
1661 directory: ./extra/
1662",
1663 );
1664
1665 let config_path = tmp_dir.path().join("caproni.yaml");
1666 fs::write(
1667 &config_path,
1668 "
1669version: 2
1670extends:
1671 - ./caproni.extra.yaml
1672cluster:
1673 driver: colima
1674 network:
1675 hostname: test.local
1676 colima:
1677 memory: 4GiB
1678 cores: 2
1679repositories:
1680 main:
1681 repository: https://example.com/main.git
1682 directory: ./main/
1683",
1684 )
1685 .unwrap();
1686
1687 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
1688 let repos = cfg.repositories.unwrap();
1689 assert_eq!(repos.len(), 2);
1690 assert!(repos.has(&"main".to_string()));
1691 assert!(repos.has(&"extra".to_string()));
1692 }
1693
1694 #[test]
1696 fn extends_base_wins_on_conflict() {
1697 let tmp_dir = tempfile::tempdir().unwrap();
1698 write_fragment(
1699 tmp_dir.path(),
1700 "caproni.frag.yaml",
1701 "
1702cluster:
1703 colima:
1704 memory: 32GiB
1705 cores: 8
1706",
1707 );
1708
1709 let config_path = tmp_dir.path().join("caproni.yaml");
1710 fs::write(
1711 &config_path,
1712 "
1713version: 2
1714extends:
1715 - ./caproni.frag.yaml
1716cluster:
1717 driver: colima
1718 network:
1719 hostname: test.local
1720 colima:
1721 memory: 4GiB
1722 cores: 2
1723repositories: {}
1724",
1725 )
1726 .unwrap();
1727
1728 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
1729 assert_eq!(cfg.cluster.colima.as_ref().unwrap().memory, "4GiB");
1730 assert_eq!(cfg.cluster.colima.as_ref().unwrap().cores, 2);
1731 }
1732
1733 #[test]
1735 fn extends_multiple_fragments_left_to_right() {
1736 let tmp_dir = tempfile::tempdir().unwrap();
1737 write_fragment(
1738 tmp_dir.path(),
1739 "caproni.a.yaml",
1740 "
1741repositories:
1742 a:
1743 repository: https://example.com/a.git
1744 directory: ./a/
1745 shared:
1746 repository: https://example.com/shared-from-a.git
1747 directory: ./shared/
1748",
1749 );
1750 write_fragment(
1751 tmp_dir.path(),
1752 "caproni.b.yaml",
1753 "
1754repositories:
1755 b:
1756 repository: https://example.com/b.git
1757 directory: ./b/
1758 shared:
1759 repository: https://example.com/shared-from-b.git
1760 directory: ./shared/
1761",
1762 );
1763
1764 let config_path = tmp_dir.path().join("caproni.yaml");
1765 fs::write(
1766 &config_path,
1767 "
1768version: 2
1769extends:
1770 - ./caproni.a.yaml
1771 - ./caproni.b.yaml
1772cluster:
1773 driver: colima
1774 network:
1775 hostname: test.local
1776 colima:
1777 memory: 4GiB
1778 cores: 2
1779repositories: {}
1780",
1781 )
1782 .unwrap();
1783
1784 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
1785 let repos = cfg.repositories.unwrap();
1786 assert!(repos.has(&"a".to_string()));
1787 assert!(repos.has(&"b".to_string()));
1788 let shared = repos.get(&"shared".to_string()).unwrap();
1789 assert_eq!(shared.repository, "https://example.com/shared-from-b.git");
1790 }
1791
1792 #[test]
1794 fn extends_fragment_has_extends_fails() {
1795 let tmp_dir = tempfile::tempdir().unwrap();
1796 write_fragment(
1797 tmp_dir.path(),
1798 "caproni.frag.yaml",
1799 "
1800extends:
1801 - ./caproni.other.yaml
1802repositories:
1803 frag:
1804 repository: https://example.com/frag.git
1805 directory: ./frag/
1806",
1807 );
1808
1809 let config_path = tmp_dir.path().join("caproni.yaml");
1810 fs::write(
1811 &config_path,
1812 "
1813version: 2
1814extends:
1815 - ./caproni.frag.yaml
1816cluster:
1817 driver: colima
1818 network:
1819 hostname: test.local
1820 colima:
1821 memory: 4GiB
1822 cores: 2
1823repositories: {}
1824",
1825 )
1826 .unwrap();
1827
1828 let err = load(config_path.to_string_lossy().into_owned())
1829 .expect_err("fragment with extends should fail");
1830 assert!(matches!(err, ConfigError::FragmentHasExtends));
1831 }
1832
1833 #[test]
1835 fn extends_main_config_fails() {
1836 let tmp_dir = tempfile::tempdir().unwrap();
1837 let config_path = tmp_dir.path().join("caproni.yaml");
1838 fs::write(
1839 &config_path,
1840 "
1841version: 2
1842extends:
1843 - ./caproni.yaml
1844cluster:
1845 driver: colima
1846 network:
1847 hostname: test.local
1848 colima:
1849 memory: 4GiB
1850 cores: 2
1851repositories: {}
1852",
1853 )
1854 .unwrap();
1855
1856 let err =
1857 load(config_path.to_string_lossy().into_owned()).expect_err("self-extends should fail");
1858 assert!(matches!(err, ConfigError::CircularExtends));
1859 }
1860
1861 #[test]
1863 fn extends_local_override_fails() {
1864 let tmp_dir = tempfile::tempdir().unwrap();
1865 let config_path = tmp_dir.path().join("caproni.yaml");
1866 fs::write(
1867 &config_path,
1868 "
1869version: 2
1870cluster:
1871 driver: colima
1872 network:
1873 hostname: test.local
1874 colima:
1875 memory: 4GiB
1876 cores: 2
1877repositories: {}
1878",
1879 )
1880 .unwrap();
1881
1882 write_local_override(
1883 &config_path.to_string_lossy(),
1884 "
1885extends:
1886 - ./caproni.local.yaml
1887",
1888 );
1889
1890 let err = load(config_path.to_string_lossy().into_owned())
1891 .expect_err("local override extending itself should fail");
1892 assert!(matches!(err, ConfigError::CircularExtends));
1893 }
1894
1895 #[test]
1897 fn extends_missing_fragment_fails() {
1898 let tmp_dir = tempfile::tempdir().unwrap();
1899 let config_path = tmp_dir.path().join("caproni.yaml");
1900 fs::write(
1901 &config_path,
1902 "
1903version: 2
1904extends:
1905 - ./caproni.nonexistent.yaml
1906cluster:
1907 driver: colima
1908 network:
1909 hostname: test.local
1910 colima:
1911 memory: 4GiB
1912 cores: 2
1913repositories: {}
1914",
1915 )
1916 .unwrap();
1917
1918 let err = load(config_path.to_string_lossy().into_owned())
1919 .expect_err("missing fragment should fail");
1920 assert!(matches!(err, ConfigError::ConfigFileNotFound(_)));
1921 }
1922
1923 #[test]
1925 fn extends_fragment_invalid_yaml_fails() {
1926 let tmp_dir = tempfile::tempdir().unwrap();
1927 write_fragment(tmp_dir.path(), "caproni.bad.yaml", "invalid: [yaml");
1928
1929 let config_path = tmp_dir.path().join("caproni.yaml");
1930 fs::write(
1931 &config_path,
1932 "
1933version: 2
1934extends:
1935 - ./caproni.bad.yaml
1936cluster:
1937 driver: colima
1938 network:
1939 hostname: test.local
1940 colima:
1941 memory: 4GiB
1942 cores: 2
1943repositories: {}
1944",
1945 )
1946 .unwrap();
1947
1948 let err = load(config_path.to_string_lossy().into_owned())
1949 .expect_err("invalid fragment yaml should fail");
1950 assert!(matches!(err, ConfigError::InvalidYAML(_)));
1951 }
1952
1953 #[test]
1955 fn extends_with_local_override() {
1956 let tmp_dir = tempfile::tempdir().unwrap();
1957 write_fragment(
1958 tmp_dir.path(),
1959 "caproni.frag.yaml",
1960 "
1961reloaders:
1962 app:
1963 type: mirror
1964 repository: app
1965 mirror:
1966 target:
1967 namespace: default
1968 kubernetes_type: deployment
1969 name: app
1970 edit_mode_processes:
1971 server:
1972 command: bin/server
1973",
1974 );
1975
1976 let config_path = tmp_dir.path().join("caproni.yaml");
1977 fs::write(
1978 &config_path,
1979 "
1980version: 2
1981extends:
1982 - ./caproni.frag.yaml
1983cluster:
1984 driver: colima
1985 network:
1986 hostname: test.local
1987 colima:
1988 memory: 4GiB
1989 cores: 2
1990repositories:
1991 app:
1992 repository: https://example.com/app.git
1993 directory: ./app/
1994",
1995 )
1996 .unwrap();
1997
1998 write_local_override(
1999 &config_path.to_string_lossy(),
2000 "
2001reloaders:
2002 app:
2003 edit_mode: true
2004",
2005 );
2006
2007 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2008 let reloaders = cfg.reloaders.unwrap();
2009 let reloader = reloaders.get(&"app".to_string()).unwrap();
2010 assert!(
2011 reloader.edit_mode,
2012 "local override should set edit_mode=true"
2013 );
2014 }
2015
2016 #[test]
2018 fn local_override_extends_fragment() {
2019 let tmp_dir = tempfile::tempdir().unwrap();
2020 write_fragment(
2021 tmp_dir.path(),
2022 "caproni.local-extra.yaml",
2023 "
2024reloaders:
2025 app:
2026 type: mirror
2027 repository: app
2028 edit_mode: true
2029 mirror:
2030 target:
2031 namespace: default
2032 kubernetes_type: deployment
2033 name: app
2034 edit_mode_processes:
2035 server:
2036 command: bin/server
2037",
2038 );
2039
2040 let config_path = tmp_dir.path().join("caproni.yaml");
2041 fs::write(
2042 &config_path,
2043 "
2044version: 2
2045cluster:
2046 driver: colima
2047 network:
2048 hostname: test.local
2049 colima:
2050 memory: 4GiB
2051 cores: 2
2052repositories:
2053 app:
2054 repository: https://example.com/app.git
2055 directory: ./app/
2056",
2057 )
2058 .unwrap();
2059
2060 write_local_override(
2061 &config_path.to_string_lossy(),
2062 "
2063extends:
2064 - ./caproni.local-extra.yaml
2065",
2066 );
2067
2068 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2069 let reloaders = cfg.reloaders.unwrap();
2070 let reloader = reloaders.get(&"app".to_string()).unwrap();
2071 assert!(reloader.edit_mode);
2072 }
2073
2074 #[test]
2076 fn same_fragment_in_base_and_local_fails() {
2077 let tmp_dir = tempfile::tempdir().unwrap();
2078 write_fragment(
2079 tmp_dir.path(),
2080 "caproni.frag.yaml",
2081 "
2082repositories:
2083 extra:
2084 repository: https://example.com/extra.git
2085 directory: ./extra/
2086",
2087 );
2088
2089 let config_path = tmp_dir.path().join("caproni.yaml");
2090 fs::write(
2091 &config_path,
2092 "
2093version: 2
2094extends:
2095 - ./caproni.frag.yaml
2096cluster:
2097 driver: colima
2098 network:
2099 hostname: test.local
2100 colima:
2101 memory: 4GiB
2102 cores: 2
2103repositories: {}
2104",
2105 )
2106 .unwrap();
2107
2108 write_local_override(
2109 &config_path.to_string_lossy(),
2110 "
2111extends:
2112 - ./caproni.frag.yaml
2113",
2114 );
2115
2116 let err = load(config_path.to_string_lossy().into_owned())
2117 .expect_err("using the same fragment twice should fail");
2118 assert!(matches!(err, ConfigError::DuplicateExtends(_)));
2119 }
2120
2121 #[test]
2123 fn local_override_empty() {
2124 let base = "
2125version: 2
2126cluster:
2127 driver: colima
2128 network:
2129 hostname: test.local
2130 colima:
2131 memory: 4GiB
2132 cores: 2
2133repositories: {}
2134";
2135 let (_dir, config_path) = write_test_config(base);
2136 write_local_override(&config_path, "");
2137
2138 let cfg = load(config_path).expect("load should succeed");
2139 assert_eq!(cfg.cluster.driver, "colima");
2140 }
2141
2142 #[test]
2144 fn extends_lifecycle_hooks_append() {
2145 let tmp_dir = tempfile::tempdir().unwrap();
2146 write_fragment(
2147 tmp_dir.path(),
2148 "caproni.hooks.yaml",
2149 "
2150repositories:
2151 app:
2152 repository: https://example.com/app.git
2153 directory: ./app/
2154 lifecycle_hooks:
2155 - type: edit_mode_start
2156 script: ./fragment-hook.sh
2157",
2158 );
2159
2160 let config_path = tmp_dir.path().join("caproni.yaml");
2161 fs::write(
2162 &config_path,
2163 "
2164version: 2
2165extends:
2166 - ./caproni.hooks.yaml
2167cluster:
2168 driver: colima
2169 network:
2170 hostname: test.local
2171 colima:
2172 memory: 4GiB
2173 cores: 2
2174repositories:
2175 app:
2176 repository: https://example.com/app.git
2177 directory: ./app/
2178 lifecycle_hooks:
2179 - type: edit_mode_start
2180 script: ./base-hook.sh
2181",
2182 )
2183 .unwrap();
2184
2185 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2186 let repos = cfg.repositories.unwrap();
2187 let repo = repos.get(&"app".to_string()).unwrap();
2188 assert_eq!(repo.lifecycle_hooks.len(), 2);
2189 assert_eq!(repo.lifecycle_hooks[0].script, "./fragment-hook.sh");
2190 assert_eq!(repo.lifecycle_hooks[1].script, "./base-hook.sh");
2191 }
2192
2193 #[test]
2195 fn local_override_additional_hostnames_append() {
2196 let base = "
2197version: 2
2198cluster:
2199 driver: colima
2200 network:
2201 hostname: test.local
2202 additional_hostnames:
2203 - base.local
2204 colima:
2205 memory: 4GiB
2206 cores: 2
2207repositories:
2208 app:
2209 repository: https://example.com/app.git
2210 directory: ./app/
2211";
2212 let (_dir, config_path) = write_test_config(base);
2213 write_local_override(
2214 &config_path,
2215 "
2216cluster:
2217 network:
2218 additional_hostnames:
2219 - local-override.local
2220",
2221 );
2222
2223 let cfg = load(config_path).expect("load should succeed");
2224 let network = cfg.cluster.network.as_ref().unwrap();
2225 assert_eq!(
2226 network.additional_hostnames,
2227 vec!["base.local".to_string(), "local-override.local".to_string()]
2228 );
2229 }
2230
2231 #[test]
2233 fn extends_additional_hostnames_append() {
2234 let tmp_dir = tempfile::tempdir().unwrap();
2235 write_fragment(
2236 tmp_dir.path(),
2237 "caproni.hostnames.yaml",
2238 "
2239cluster:
2240 network:
2241 additional_hostnames:
2242 - fragment.local
2243",
2244 );
2245
2246 let config_path = tmp_dir.path().join("caproni.yaml");
2247 fs::write(
2248 &config_path,
2249 "
2250version: 2
2251extends:
2252 - ./caproni.hostnames.yaml
2253cluster:
2254 driver: colima
2255 network:
2256 hostname: test.local
2257 additional_hostnames:
2258 - base.local
2259 colima:
2260 memory: 4GiB
2261 cores: 2
2262repositories:
2263 app:
2264 repository: https://example.com/app.git
2265 directory: ./app/
2266",
2267 )
2268 .unwrap();
2269
2270 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2271 let network = cfg.cluster.network.as_ref().unwrap();
2272 assert_eq!(
2273 network.additional_hostnames,
2274 vec!["fragment.local".to_string(), "base.local".to_string()]
2275 );
2276 }
2277
2278 #[test]
2280 fn extends_path_traversal_fails() {
2281 let tmp_dir = tempfile::tempdir().unwrap();
2282 let config_path = tmp_dir.path().join("caproni.yaml");
2283 fs::write(
2284 &config_path,
2285 "
2286version: 2
2287extends:
2288 - ../caproni.evil.yaml
2289cluster:
2290 driver: colima
2291 network:
2292 hostname: test.local
2293 colima:
2294 memory: 4GiB
2295 cores: 2
2296repositories: {}
2297",
2298 )
2299 .unwrap();
2300
2301 let err = load(config_path.to_string_lossy().into_owned())
2302 .expect_err("path traversal should fail");
2303 assert!(matches!(err, ConfigError::FragmentOutsideDir));
2304 }
2305
2306 #[test]
2308 fn extends_invalid_fragment_name_fails() {
2309 let tmp_dir = tempfile::tempdir().unwrap();
2310 let config_path = tmp_dir.path().join("caproni.yaml");
2311 fs::write(
2312 &config_path,
2313 "
2314version: 2
2315extends:
2316 - ./my-arbitrary-config.yaml
2317cluster:
2318 driver: colima
2319 network:
2320 hostname: test.local
2321 colima:
2322 memory: 4GiB
2323 cores: 2
2324repositories: {}
2325",
2326 )
2327 .unwrap();
2328
2329 let err = load(config_path.to_string_lossy().into_owned())
2330 .expect_err("invalid fragment name should fail");
2331 assert!(matches!(err, ConfigError::InvalidFragmentName));
2332 }
2333
2334 #[test]
2337 fn extends_xianyun_prefixed_fragment_name_is_valid() {
2338 let tmp_dir = tempfile::tempdir().unwrap();
2339 write_fragment(tmp_dir.path(), "xianyun.infra.yaml", "\ndeployers: {}\n");
2340
2341 let config_path = tmp_dir.path().join("caproni.yaml");
2342 fs::write(
2343 &config_path,
2344 "
2345version: 2
2346extends:
2347 - ./xianyun.infra.yaml
2348cluster:
2349 driver: colima
2350 network:
2351 hostname: test.local
2352 colima:
2353 memory: 4GiB
2354 cores: 2
2355repositories: {}
2356",
2357 )
2358 .unwrap();
2359
2360 load(config_path.to_string_lossy().into_owned())
2361 .expect("xianyun.<name>.yaml fragment name must be accepted");
2362 }
2363
2364 #[test]
2366 fn extends_duplicate_fragment_fails() {
2367 let tmp_dir = tempfile::tempdir().unwrap();
2368 write_fragment(
2369 tmp_dir.path(),
2370 "caproni.frag.yaml",
2371 "
2372repositories:
2373 extra:
2374 repository: https://example.com/extra.git
2375 directory: ./extra/
2376",
2377 );
2378
2379 let config_path = tmp_dir.path().join("caproni.yaml");
2380 fs::write(
2381 &config_path,
2382 "
2383version: 2
2384extends:
2385 - ./caproni.frag.yaml
2386 - ./caproni.frag.yaml
2387cluster:
2388 driver: colima
2389 network:
2390 hostname: test.local
2391 colima:
2392 memory: 4GiB
2393 cores: 2
2394repositories: {}
2395",
2396 )
2397 .unwrap();
2398
2399 let err = load(config_path.to_string_lossy().into_owned())
2400 .expect_err("duplicate fragment should fail");
2401 assert!(matches!(err, ConfigError::DuplicateExtends(_)));
2402 }
2403
2404 #[test]
2406 fn extends_local_plain_caproni_yaml_in_subdir() {
2407 let tmp_dir = tempfile::tempdir().unwrap();
2408 let sub_dir = tmp_dir.path().join("sub");
2409 fs::create_dir(&sub_dir).unwrap();
2410 write_fragment(
2411 &sub_dir,
2412 "caproni.yaml",
2413 "
2414repositories:
2415 local:
2416 repository: https://example.com/local.git
2417 directory: ./local/
2418",
2419 );
2420
2421 let config_path = tmp_dir.path().join("caproni.yaml");
2422 fs::write(
2423 &config_path,
2424 "
2425version: 2
2426extends:
2427 - ./sub/caproni.yaml
2428cluster:
2429 driver: colima
2430 network:
2431 hostname: test.local
2432 colima:
2433 memory: 4GiB
2434 cores: 2
2435repositories: {}
2436",
2437 )
2438 .unwrap();
2439
2440 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2441 assert!(cfg.repositories.unwrap().has(&"local".to_string()));
2442 }
2443
2444 #[test]
2448 fn extends_local_fragment_relative_paths_helm_values_file() {
2449 let tmp_dir = tempfile::tempdir().unwrap();
2450 fs::write(tmp_dir.path().join("values.yaml"), "replicaCount: 3\n").unwrap();
2451
2452 let sub_dir = tmp_dir.path().join("sub");
2453 fs::create_dir(&sub_dir).unwrap();
2454 write_fragment(
2455 &sub_dir,
2456 "caproni.gitlab.yaml",
2457 "
2458deployers:
2459 gitlab:
2460 type: helm
2461 helm:
2462 namespace: gitlab
2463 values_file: values.yaml
2464 release:
2465 name: gitlab
2466 chart: gitlab/gitlab
2467",
2468 );
2469
2470 let config_path = tmp_dir.path().join("caproni.yaml");
2471 fs::write(
2472 &config_path,
2473 "
2474version: 2
2475extends:
2476 - ./sub/caproni.gitlab.yaml
2477cluster:
2478 driver: colima
2479 network:
2480 hostname: test.local
2481 colima:
2482 memory: 4GiB
2483 cores: 2
2484repositories: {}
2485",
2486 )
2487 .unwrap();
2488
2489 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2490 let deployers = cfg.deployers.unwrap();
2491 let dep = deployers.get(&"gitlab".to_string()).unwrap();
2492 let got = &dep.helm.as_ref().unwrap().values_file;
2493 assert_eq!(got, "values.yaml");
2494 assert!(!std::path::Path::new(got).is_absolute());
2495 }
2496
2497 #[test]
2499 fn extends_local_fragment_relative_paths_kustomize_path() {
2500 let tmp_dir = tempfile::tempdir().unwrap();
2501 fs::create_dir(tmp_dir.path().join("manifests")).unwrap();
2502
2503 let sub_dir = tmp_dir.path().join("sub");
2504 fs::create_dir(&sub_dir).unwrap();
2505 write_fragment(
2506 &sub_dir,
2507 "caproni.gitlab.yaml",
2508 "
2509deployers:
2510 gitlab:
2511 type: kustomize
2512 kustomize:
2513 path: manifests
2514",
2515 );
2516
2517 let config_path = tmp_dir.path().join("caproni.yaml");
2518 fs::write(
2519 &config_path,
2520 "
2521version: 2
2522extends:
2523 - ./sub/caproni.gitlab.yaml
2524cluster:
2525 driver: colima
2526 network:
2527 hostname: test.local
2528 colima:
2529 memory: 4GiB
2530 cores: 2
2531repositories: {}
2532",
2533 )
2534 .unwrap();
2535
2536 let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2537 let deployers = cfg.deployers.unwrap();
2538 let dep = deployers.get(&"gitlab".to_string()).unwrap();
2539 let got = &dep.kustomize.as_ref().unwrap().path;
2540 assert_eq!(got, "manifests");
2541 assert!(!std::path::Path::new(got).is_absolute());
2542 }
2543
2544 #[derive(Default)]
2561 struct FakeFetcher {
2562 responses: std::collections::HashMap<String, Vec<u8>>,
2563 cache: Mutex<std::collections::HashMap<String, Vec<u8>>>,
2569 hits: std::sync::atomic::AtomicU32,
2570 }
2571
2572 impl FakeFetcher {
2573 fn new(pairs: &[(&str, &str)]) -> Self {
2574 Self {
2575 responses: pairs
2576 .iter()
2577 .map(|(k, v)| ((*k).to_string(), v.as_bytes().to_vec()))
2578 .collect(),
2579 cache: Mutex::new(std::collections::HashMap::new()),
2580 hits: std::sync::atomic::AtomicU32::new(0),
2581 }
2582 }
2583
2584 fn hit_count(&self) -> u32 {
2585 self.hits.load(std::sync::atomic::Ordering::SeqCst)
2586 }
2587 }
2588
2589 impl RemoteFetcher for FakeFetcher {
2590 fn fetch(&self, url: &str) -> Result<Vec<u8>, ConfigError> {
2591 if let Some(cached) = self.cache.lock().unwrap().get(url) {
2592 return Ok(cached.clone());
2593 }
2594
2595 self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2596
2597 let body = self
2598 .responses
2599 .get(url)
2600 .ok_or_else(|| ConfigError::RemoteFragmentFetch(format!("{url}: not found")))?;
2601
2602 if body.len() as u64 > MAX_FRAGMENT_BYTES {
2603 return Err(ConfigError::RemoteFragmentFetch(format!(
2604 "{url}: response exceeds {MAX_FRAGMENT_BYTES} bytes"
2605 )));
2606 }
2607
2608 self.cache
2609 .lock()
2610 .unwrap()
2611 .insert(url.to_string(), body.clone());
2612
2613 Ok(body.clone())
2614 }
2615 }
2616
2617 const FAKE_SERVER: &str = "https://fake-remote.test";
2620
2621 fn load_with_fetcher(
2622 path: String,
2623 fetcher: &dyn RemoteFetcher,
2624 ) -> Result<super::super::latest::CaproniConfig, ConfigError> {
2625 let gc = GlobalConfig {
2626 config_file: path,
2627 ..Default::default()
2628 };
2629 super::super::caproni::load_caproni_config_with_fetcher(&gc, fetcher)
2630 }
2631
2632 fn base_config_with_extends(extends_url: &str) -> String {
2634 format!(
2635 "\nversion: 2\nextends:\n - {extends_url}\ncluster:\n driver: colima\n network:\n hostname: test.local\n colima:\n memory: 4GiB\n cores: 2\nrepositories: {{}}\n"
2636 )
2637 }
2638
2639 #[test]
2643 fn extends_remote_fragment() {
2644 let frag_url = format!("{FAKE_SERVER}/caproni.extended.yaml");
2645 let fetcher = FakeFetcher::new(&[(
2646 frag_url.as_str(),
2647 "\nrepositories:\n extended:\n repository: https://example.com/extended.git\n directory: ./extended/\n",
2648 )]);
2649
2650 let base = format!(
2651 "\nversion: 2\nextends:\n - {frag_url}\ncluster:\n driver: colima\n network:\n hostname: test.local\n colima:\n memory: 4GiB\n cores: 2\nrepositories:\n base:\n repository: https://example.com/base.git\n directory: ./base/\n"
2652 );
2653 let (_dir, config_path) = write_test_config(&base);
2654
2655 let cfg = load_with_fetcher(config_path.clone(), &fetcher).expect("load should succeed");
2656 let repos = cfg.repositories.as_ref().unwrap();
2657 assert_eq!(repos.len(), 2);
2658
2659 let base_repo = repos.get(&"base".to_string()).unwrap();
2660 assert_eq!(base_repo.repository, "https://example.com/base.git");
2661 assert_eq!(base_repo.directory, "./base/");
2662
2663 let extended_repo = repos.get(&"extended".to_string()).unwrap();
2664 assert_eq!(extended_repo.repository, "https://example.com/extended.git");
2665 assert_eq!(extended_repo.directory, "./extended/");
2666
2667 assert_eq!(fetcher.hit_count(), 1);
2668 let cfg2 = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
2669 assert_eq!(
2670 fetcher.hit_count(),
2671 1,
2672 "expected request count to stay at 1"
2673 );
2674 assert_eq!(
2675 serde_json::to_value(&cfg).unwrap(),
2676 serde_json::to_value(&cfg2).unwrap(),
2677 "second loaded config should be identical"
2678 );
2679 }
2680
2681 #[test]
2683 fn extends_remote_plain_caproni_yaml() {
2684 let frag_url = format!("{FAKE_SERVER}/caproni.yaml");
2685 let fetcher = FakeFetcher::new(&[(
2686 frag_url.as_str(),
2687 "\nrepositories:\n extended:\n repository: https://example.com/extended.git\n directory: ./extended/\n",
2688 )]);
2689
2690 let base = format!(
2691 "\nversion: 2\nextends:\n - {frag_url}\ncluster:\n driver: colima\n network:\n hostname: test.local\n colima:\n memory: 4GiB\n cores: 2\nrepositories: {{}}\n"
2692 );
2693 let (_dir, config_path) = write_test_config(&base);
2694
2695 let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
2696 assert!(cfg.repositories.unwrap().has(&"extended".to_string()));
2697 }
2698
2699 #[test]
2701 fn extends_local_and_remote() {
2702 let frag_url = format!("{FAKE_SERVER}/caproni.remote.yaml");
2703 let fetcher = FakeFetcher::new(&[(
2704 frag_url.as_str(),
2705 "\nrepositories:\n remote:\n repository: https://example.com/remote.git\n directory: ./remote/\n",
2706 )]);
2707
2708 let dir = tempfile::tempdir().unwrap();
2709 write_fragment(
2710 dir.path(),
2711 "caproni.frag.yaml",
2712 "\nrepositories:\n local:\n repository: https://example.com/local.git\n directory: ./local/\n",
2713 );
2714
2715 let config_path = dir.path().join("caproni.yaml");
2716 fs::write(
2717 &config_path,
2718 format!(
2719 "\nversion: 2\nextends:\n - ./caproni.frag.yaml\n - {frag_url}\ncluster:\n driver: colima\n network:\n hostname: test.local\n colima:\n memory: 4GiB\n cores: 2\nrepositories: {{}}\n"
2720 ),
2721 )
2722 .unwrap();
2723
2724 let cfg = load_with_fetcher(config_path.to_string_lossy().into_owned(), &fetcher)
2725 .expect("load should succeed");
2726 let repos = cfg.repositories.unwrap();
2727 assert_eq!(repos.len(), 2);
2728 assert!(repos.has(&"local".to_string()));
2729 assert!(repos.has(&"remote".to_string()));
2730 }
2731
2732 #[test]
2734 fn extends_remote_missing_fails() {
2735 let fetcher = FakeFetcher::new(&[]);
2736
2737 let base = base_config_with_extends(&format!("{FAKE_SERVER}/caproni.missing.yaml"));
2738 let (_dir, config_path) = write_test_config(&base);
2739
2740 let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
2741 assert!(matches!(err, ConfigError::RemoteFragmentFetch(_)));
2742 }
2743
2744 #[test]
2746 fn extends_remote_too_large_fails() {
2747 let oversized = "a".repeat((MAX_FRAGMENT_BYTES + 1) as usize);
2748 let frag_url = format!("{FAKE_SERVER}/caproni.big.yaml");
2749 let fetcher = FakeFetcher::new(&[(frag_url.as_str(), oversized.as_str())]);
2750
2751 let base = base_config_with_extends(&frag_url);
2752 let (_dir, config_path) = write_test_config(&base);
2753
2754 let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
2755 assert!(matches!(err, ConfigError::RemoteFragmentFetch(_)));
2756 }
2757
2758 #[test]
2763 fn extends_remote_http_fails() {
2764 let base = "
2765version: 2
2766extends:
2767 - http://example.com/caproni.frag.yaml
2768cluster:
2769 driver: colima
2770 network:
2771 hostname: test.local
2772 colima:
2773 memory: 4GiB
2774 cores: 2
2775repositories: {}
2776";
2777 let (_dir, config_path) = write_test_config(base);
2778
2779 let gc = GlobalConfig {
2780 config_file: config_path,
2781 ..Default::default()
2782 };
2783 let err = super::super::caproni::load_caproni_config(&gc).expect_err("load should fail");
2784 assert!(matches!(err, ConfigError::RemoteFragmentFetch(_)));
2785 }
2786
2787 #[test]
2789 fn extends_remote_invalid_name_fails() {
2790 let base = "
2791version: 2
2792extends:
2793 - https://example.com/some-path/gitlab-dev-stack-values.yaml
2794cluster:
2795 driver: colima
2796 network:
2797 hostname: test.local
2798 colima:
2799 memory: 4GiB
2800 cores: 2
2801repositories: {}
2802";
2803 let (_dir, config_path) = write_test_config(base);
2804
2805 let gc = GlobalConfig {
2806 config_file: config_path,
2807 ..Default::default()
2808 };
2809 let err = super::super::caproni::load_caproni_config(&gc).expect_err("load should fail");
2810 assert!(matches!(err, ConfigError::InvalidFragmentName));
2811 }
2812
2813 #[test]
2815 fn extends_remote_has_extends_fails() {
2816 let frag_url = format!("{FAKE_SERVER}/caproni.extended.yaml");
2817 let fetcher = FakeFetcher::new(&[(
2818 frag_url.as_str(),
2819 "\nextends:\n - ./caproni.nested.yaml\nrepositories: {}\n",
2820 )]);
2821
2822 let base = base_config_with_extends(&frag_url);
2823 let (_dir, config_path) = write_test_config(&base);
2824
2825 let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
2826 assert!(matches!(err, ConfigError::FragmentHasExtends));
2827 }
2828
2829 #[test]
2831 fn validate_project_file_schema_cases() {
2832 struct Case {
2833 name: &'static str,
2834 frag: Value,
2835 want_err: bool,
2836 }
2837
2838 let cases = vec![
2839 Case {
2840 name: "valid reloaders only",
2841 frag: json!({
2842 "reloaders": {
2843 "web": {
2844 "type": "mirror",
2845 "repository": "myrepo",
2846 "mirror": {
2847 "target": {
2848 "namespace": "ns",
2849 "kubernetes_type": "deployment",
2850 "name": "deploy",
2851 },
2852 "edit_mode_processes": {
2853 "web": {"command": "bin/server"},
2854 },
2855 },
2856 },
2857 },
2858 }),
2859 want_err: false,
2860 },
2861 Case {
2862 name: "valid lifecycle_hooks only",
2863 frag: json!({
2864 "lifecycle_hooks": [
2865 {"type": "edit_mode_start", "script": "./hook.sh"},
2866 ],
2867 }),
2868 want_err: false,
2869 },
2870 Case {
2871 name: "disallowed top-level key",
2872 frag: json!({"cluster": {}}),
2873 want_err: true,
2874 },
2875 Case {
2876 name: "lifecycle_hook missing type",
2877 frag: json!({
2878 "lifecycle_hooks": [{"script": "./hook.sh"}],
2879 }),
2880 want_err: true,
2881 },
2882 Case {
2883 name: "lifecycle_hook missing action",
2884 frag: json!({
2885 "lifecycle_hooks": [{"type": "edit_mode_start"}],
2886 }),
2887 want_err: true,
2888 },
2889 Case {
2890 name: "lifecycle_hook invalid type value",
2891 frag: json!({
2892 "lifecycle_hooks": [
2893 {"type": "not_a_valid_type", "script": "./hook.sh"},
2894 ],
2895 }),
2896 want_err: true,
2897 },
2898 ];
2899
2900 for case in cases {
2901 let result = validate_project_file_schema(&case.frag);
2902 if case.want_err {
2903 assert!(result.is_err(), "case {:?} should have failed", case.name);
2904 assert!(
2905 matches!(result.unwrap_err(), ConfigError::SchemaValidation(_)),
2906 "case {:?} should fail with SchemaValidation",
2907 case.name
2908 );
2909 } else {
2910 assert!(
2911 result.is_ok(),
2912 "case {:?} should have passed: {:?}",
2913 case.name,
2914 result.unwrap_err()
2915 );
2916 }
2917 }
2918 }
2919
2920 #[test]
2922 fn validate_repo_deployers_fragment_cases() {
2923 struct Case {
2924 name: &'static str,
2925 frag: Value,
2926 repo_name: &'static str,
2927 want_err: Option<fn(&ConfigError) -> bool>,
2928 err_substr: &'static str,
2929 }
2930
2931 let cases = vec![
2932 Case {
2933 name: "valid fragment",
2934 repo_name: "myrepo",
2935 frag: json!({
2936 "reloaders": {"web": {"repository": "myrepo"}},
2937 }),
2938 want_err: None,
2939 err_substr: "",
2940 },
2941 Case {
2942 name: "empty fragment",
2943 repo_name: "myrepo",
2944 frag: json!({}),
2945 want_err: None,
2946 err_substr: "",
2947 },
2948 Case {
2949 name: "reloader references wrong repository",
2950 repo_name: "myrepo",
2951 frag: json!({
2952 "reloaders": {"web": {"repository": "otherrepo"}},
2953 }),
2954 want_err: Some(|e| matches!(e, ConfigError::RepoFragmentWrongRepository { .. })),
2955 err_substr: "\"otherrepo\"",
2956 },
2957 Case {
2958 name: "reloader missing repository field",
2959 repo_name: "myrepo",
2960 frag: json!({
2961 "reloaders": {"web": {"type": "mirror"}},
2962 }),
2963 want_err: Some(|e| matches!(e, ConfigError::RepoFragmentWrongRepository { .. })),
2964 err_substr: "\"\"",
2965 },
2966 ];
2967
2968 for case in cases {
2969 let result = validate_repo_deployers_fragment(&case.frag, case.repo_name);
2970
2971 match case.want_err {
2972 None => assert!(result.is_ok(), "case {:?} should have passed", case.name),
2973 Some(check) => {
2974 let err = result.unwrap_err();
2975 assert!(
2976 check(&err),
2977 "case {:?}: unexpected error {err:?}",
2978 case.name
2979 );
2980 if !case.err_substr.is_empty() {
2981 assert!(
2982 err.to_string().contains(case.err_substr),
2983 "case {:?}: error {err} should contain {:?}",
2984 case.name,
2985 case.err_substr
2986 );
2987 }
2988 }
2989 }
2990 }
2991 }
2992
2993 fn minimal_reloader_yaml(reloader_name: &str, repo_name: &str) -> String {
2994 format!(
2995 "
2996reloaders:
2997 {reloader_name}:
2998 type: mirror
2999 repository: {repo_name}
3000 mirror:
3001 target:
3002 namespace: ns
3003 kubernetes_type: deployment
3004 name: deploy
3005 edit_mode_processes:
3006 web:
3007 command: bin/server
3008"
3009 )
3010 }
3011
3012 fn write_deployers_fragment(dir: &std::path::Path, content: &str) {
3013 std::fs::write(dir.join("caproni-project.yaml"), content)
3014 .expect("writing caproni-project.yaml should succeed");
3015 }
3016
3017 #[test]
3021 fn apply_repo_deployers_fragments_prefers_xianyun_project_yaml_over_caproni_alias() {
3022 let repo_dir = tempfile::tempdir().unwrap();
3023 std::fs::write(
3024 repo_dir.path().join("xianyun-project.yaml"),
3025 minimal_reloader_yaml("primary-reloader", "myrepo"),
3026 )
3027 .unwrap();
3028 std::fs::write(
3029 repo_dir.path().join("caproni-project.yaml"),
3030 minimal_reloader_yaml("alias-reloader", "myrepo"),
3031 )
3032 .unwrap();
3033
3034 let base = json!({
3035 "repositories": {
3036 "myrepo": {
3037 "repository": "https://example.com/myrepo.git",
3038 "directory": repo_dir.path().to_string_lossy(),
3039 "edit_mode": true,
3040 },
3041 },
3042 });
3043
3044 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3045 let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3046 assert!(
3047 reloaders.contains_key("primary-reloader"),
3048 "xianyun-project.yaml must win over caproni-project.yaml when both exist"
3049 );
3050 assert!(!reloaders.contains_key("alias-reloader"));
3051 }
3052
3053 #[test]
3054 fn apply_repo_deployers_fragments_falls_back_to_caproni_project_yaml_alias() {
3055 let repo_dir = tempfile::tempdir().unwrap();
3056 write_deployers_fragment(
3057 repo_dir.path(),
3058 &minimal_reloader_yaml("alias-only-reloader", "myrepo"),
3059 );
3060
3061 let base = json!({
3062 "repositories": {
3063 "myrepo": {
3064 "repository": "https://example.com/myrepo.git",
3065 "directory": repo_dir.path().to_string_lossy(),
3066 "edit_mode": true,
3067 },
3068 },
3069 });
3070
3071 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3072 let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3073 assert!(
3074 reloaders.contains_key("alias-only-reloader"),
3075 "caproni-project.yaml must still be found when xianyun-project.yaml is absent"
3076 );
3077 }
3078
3079 #[test]
3081 fn apply_repo_deployers_fragments_loads_reloaders_from_edit_mode_repo() {
3082 let repo_dir = tempfile::tempdir().unwrap();
3083 write_deployers_fragment(
3084 repo_dir.path(),
3085 &minimal_reloader_yaml("my-reloader", "myrepo"),
3086 );
3087
3088 let base = json!({
3089 "repositories": {
3090 "myrepo": {
3091 "repository": "https://example.com/myrepo.git",
3092 "directory": repo_dir.path().to_string_lossy(),
3093 "edit_mode": true,
3094 },
3095 },
3096 });
3097
3098 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3099 let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3100 assert!(reloaders.contains_key("my-reloader"));
3101 }
3102
3103 #[test]
3104 fn apply_repo_deployers_fragments_main_config_wins_over_repo_fragment() {
3105 let repo_dir = tempfile::tempdir().unwrap();
3106 write_deployers_fragment(
3107 repo_dir.path(),
3108 "
3109reloaders:
3110 shared:
3111 type: mirror
3112 repository: myrepo
3113 mirror:
3114 target:
3115 namespace: from-fragment
3116 kubernetes_type: deployment
3117 name: deploy
3118 edit_mode_processes:
3119 web:
3120 command: bin/server
3121",
3122 );
3123
3124 let base = json!({
3125 "repositories": {
3126 "myrepo": {
3127 "repository": "https://example.com/myrepo.git",
3128 "directory": repo_dir.path().to_string_lossy(),
3129 "edit_mode": true,
3130 },
3131 },
3132 "reloaders": {
3133 "shared": {
3134 "type": "mirror",
3135 "repository": "myrepo",
3136 "mirror": {
3137 "target": {
3138 "namespace": "from-base",
3139 "kubernetes_type": "deployment",
3140 "name": "deploy",
3141 },
3142 "edit_mode_processes": {
3143 "web": {"command": "bin/server"},
3144 },
3145 },
3146 },
3147 },
3148 });
3149
3150 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3151 let namespace = &result["reloaders"]["shared"]["mirror"]["target"]["namespace"];
3152 assert_eq!(namespace, "from-base", "base config must win");
3153 }
3154
3155 #[test]
3156 fn apply_repo_deployers_fragments_skips_repos_without_edit_mode() {
3157 let repo_dir = tempfile::tempdir().unwrap();
3158 write_deployers_fragment(
3159 repo_dir.path(),
3160 &minimal_reloader_yaml("unwanted", "myrepo"),
3161 );
3162
3163 let base = json!({
3164 "repositories": {
3165 "myrepo": {
3166 "repository": "https://example.com/myrepo.git",
3167 "directory": repo_dir.path().to_string_lossy(),
3168 },
3169 },
3170 });
3171
3172 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3173 assert!(result.get("reloaders").is_none());
3174 }
3175
3176 #[test]
3177 fn apply_repo_deployers_fragments_skips_repos_with_missing_directory() {
3178 let base = json!({
3179 "repositories": {
3180 "myrepo": {
3181 "repository": "https://example.com/myrepo.git",
3182 "directory": "/nonexistent/path/that/does/not/exist",
3183 "edit_mode": true,
3184 },
3185 },
3186 });
3187
3188 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3189 assert!(result.get("reloaders").is_none());
3190 }
3191
3192 #[test]
3193 fn apply_repo_deployers_fragments_noop_when_project_file_absent() {
3194 let repo_dir = tempfile::tempdir().unwrap();
3195
3196 let base = json!({
3197 "repositories": {
3198 "myrepo": {
3199 "repository": "https://example.com/myrepo.git",
3200 "directory": repo_dir.path().to_string_lossy(),
3201 "edit_mode": true,
3202 },
3203 },
3204 });
3205
3206 let result =
3207 apply_caproni_project(base.clone(), std::path::Path::new("/irrelevant")).unwrap();
3208 assert_eq!(result, base);
3209 }
3210
3211 #[test]
3212 fn apply_repo_deployers_fragments_resolves_relative_directory_against_config_dir() {
3213 let config_dir = tempfile::tempdir().unwrap();
3214 let repo_dir = config_dir.path().join("myrepo");
3215 std::fs::create_dir_all(&repo_dir).unwrap();
3216 write_deployers_fragment(&repo_dir, &minimal_reloader_yaml("rel-reloader", "myrepo"));
3217
3218 let base = json!({
3219 "repositories": {
3220 "myrepo": {
3221 "repository": "https://example.com/myrepo.git",
3222 "directory": "./myrepo",
3223 "edit_mode": true,
3224 },
3225 },
3226 });
3227
3228 let result = apply_caproni_project(base, config_dir.path()).unwrap();
3229 let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3230 assert!(reloaders.contains_key("rel-reloader"));
3231 }
3232
3233 #[test]
3234 fn apply_repo_deployers_fragments_disallowed_top_level_key_errors() {
3235 let repo_dir = tempfile::tempdir().unwrap();
3236 write_deployers_fragment(
3237 repo_dir.path(),
3238 "
3239deployers:
3240 something:
3241 type: namespace
3242",
3243 );
3244
3245 let base = json!({
3246 "repositories": {
3247 "myrepo": {
3248 "repository": "https://example.com/myrepo.git",
3249 "directory": repo_dir.path().to_string_lossy(),
3250 "edit_mode": true,
3251 },
3252 },
3253 });
3254
3255 let err = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap_err();
3256 assert!(matches!(err, ConfigError::SchemaValidation(_)));
3257 }
3258
3259 #[test]
3260 fn apply_repo_deployers_fragments_wrong_repository_errors() {
3261 let repo_dir = tempfile::tempdir().unwrap();
3262 write_deployers_fragment(repo_dir.path(), &minimal_reloader_yaml("web", "otherrepo"));
3263
3264 let base = json!({
3265 "repositories": {
3266 "myrepo": {
3267 "repository": "https://example.com/myrepo.git",
3268 "directory": repo_dir.path().to_string_lossy(),
3269 "edit_mode": true,
3270 },
3271 },
3272 });
3273
3274 let err = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap_err();
3275 assert!(matches!(
3276 err,
3277 ConfigError::RepoFragmentWrongRepository { .. }
3278 ));
3279 }
3280
3281 #[test]
3282 fn apply_repo_deployers_fragments_duplicate_reloader_across_repos_errors() {
3283 let dir_a = tempfile::tempdir().unwrap();
3284 let dir_b = tempfile::tempdir().unwrap();
3285 write_deployers_fragment(dir_a.path(), &minimal_reloader_yaml("web", "repo-a"));
3286 write_deployers_fragment(dir_b.path(), &minimal_reloader_yaml("web", "repo-b"));
3287
3288 let base = json!({
3289 "repositories": {
3290 "repo-a": {
3291 "repository": "https://example.com/a.git",
3292 "directory": dir_a.path().to_string_lossy(),
3293 "edit_mode": true,
3294 },
3295 "repo-b": {
3296 "repository": "https://example.com/b.git",
3297 "directory": dir_b.path().to_string_lossy(),
3298 "edit_mode": true,
3299 },
3300 },
3301 });
3302
3303 let err = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap_err();
3304 assert!(matches!(err, ConfigError::RepoFragmentDuplicateReloader(_)));
3305 assert!(err.to_string().contains("\"web\""));
3306 }
3307
3308 #[test]
3309 fn apply_repo_deployers_fragments_lifecycle_hooks_appended_to_repo_hooks() {
3310 let repo_dir = tempfile::tempdir().unwrap();
3311 write_deployers_fragment(
3312 repo_dir.path(),
3313 "
3314lifecycle_hooks:
3315 - type: edit_mode_start
3316 script: ./project-hook.sh
3317",
3318 );
3319
3320 let base = json!({
3321 "repositories": {
3322 "myrepo": {
3323 "repository": "https://example.com/myrepo.git",
3324 "directory": repo_dir.path().to_string_lossy(),
3325 "edit_mode": true,
3326 "lifecycle_hooks": [
3327 {"type": "edit_mode_start", "script": "./base-hook.sh"},
3328 ],
3329 },
3330 },
3331 });
3332
3333 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3334 let hooks = result["repositories"]["myrepo"]["lifecycle_hooks"]
3335 .as_array()
3336 .unwrap();
3337 assert_eq!(hooks.len(), 2);
3338 assert_eq!(
3339 hooks[0]["script"], "./base-hook.sh",
3340 "base hook must be first"
3341 );
3342 assert_eq!(
3343 hooks[1]["script"], "./project-hook.sh",
3344 "project hook must be appended"
3345 );
3346 }
3347
3348 #[test]
3349 fn apply_repo_deployers_fragments_lifecycle_hooks_added_when_repo_has_none() {
3350 let repo_dir = tempfile::tempdir().unwrap();
3351 write_deployers_fragment(
3352 repo_dir.path(),
3353 "
3354lifecycle_hooks:
3355 - type: edit_mode_start
3356 script: ./project-hook.sh
3357",
3358 );
3359
3360 let base = json!({
3361 "repositories": {
3362 "myrepo": {
3363 "repository": "https://example.com/myrepo.git",
3364 "directory": repo_dir.path().to_string_lossy(),
3365 "edit_mode": true,
3366 },
3367 },
3368 });
3369
3370 let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3371 let hooks = result["repositories"]["myrepo"]["lifecycle_hooks"]
3372 .as_array()
3373 .unwrap();
3374 assert_eq!(hooks.len(), 1);
3375 assert_eq!(hooks[0]["script"], "./project-hook.sh");
3376 }
3377
3378 #[test]
3380 fn extends_duplicate_remote_fragment_fails() {
3381 let frag_url = format!("{FAKE_SERVER}/caproni.extended.yaml");
3382 let fetcher = FakeFetcher::new(&[(
3383 frag_url.as_str(),
3384 "\nrepositories:\n extended:\n repository: https://example.com/extended.git\n directory: ./extended/\n",
3385 )]);
3386
3387 let base = format!(
3388 "\nversion: 2\nextends:\n - {frag_url}\n - {frag_url}\ncluster:\n driver: colima\n network:\n hostname: test.local\nrepositories: {{}}\n"
3389 );
3390 let (_dir, config_path) = write_test_config(&base);
3391
3392 let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
3393 assert!(matches!(err, ConfigError::DuplicateExtends(_)));
3394 }
3395
3396 #[test]
3398 fn extends_remote_fragment_downloads_values_file() {
3399 const VALUES_CONTENT: &str = "replicaCount: 3\n";
3400
3401 let frag_url = format!("{FAKE_SERVER}/download/caproni.frag.yaml");
3402 let values_url = format!("{FAKE_SERVER}/download/values.yaml");
3403 let fetcher = FakeFetcher::new(&[
3404 (
3405 frag_url.as_str(),
3406 "\ndeployers:\n gitlab:\n type: helm\n helm:\n namespace: gitlab\n values_file: values.yaml\n release:\n name: gitlab\n chart: gitlab/gitlab\n",
3407 ),
3408 (values_url.as_str(), VALUES_CONTENT),
3409 ]);
3410
3411 let base = base_config_with_extends(&frag_url);
3412 let (_dir, config_path) = write_test_config(&base);
3413
3414 let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
3415 let deployers = cfg.deployers.unwrap();
3416 let dep = deployers.get(&"gitlab".to_string()).unwrap();
3417 let helm = dep.helm.as_ref().unwrap();
3418 assert_ne!(
3419 helm.values_file, "values.yaml",
3420 "values_file is rewritten to a local path"
3421 );
3422 assert!(std::path::Path::new(&helm.values_file).is_absolute());
3423
3424 let got = fs::read_to_string(&helm.values_file).unwrap();
3425 assert_eq!(got, VALUES_CONTENT);
3426 }
3427
3428 #[test]
3430 fn extends_remote_fragment_local_override() {
3431 let frag_url = format!("{FAKE_SERVER}/local-override/caproni.frag.yaml");
3432 let values_url = format!("{FAKE_SERVER}/local-override/values.yaml");
3433 let fetcher = FakeFetcher::new(&[
3434 (
3435 frag_url.as_str(),
3436 "\ndeployers:\n gitlab:\n type: helm\n helm:\n namespace: gitlab\n values_file: values.yaml\n release:\n name: gitlab\n chart: gitlab/gitlab\n",
3437 ),
3438 (values_url.as_str(), "replicaCount: 3\n"),
3439 ]);
3440
3441 let dir = tempfile::tempdir().unwrap();
3442 let local_values = dir.path().join("local-values.yaml");
3443 fs::write(&local_values, "replicaCount: 5\n").unwrap();
3444
3445 let config_path = dir.path().join("caproni.yaml");
3446 fs::write(
3447 &config_path,
3448 format!(
3449 "\nversion: 2\nextends:\n - {frag_url}\ndeployers:\n gitlab:\n type: helm\n helm:\n namespace: gitlab\n values_file: {}\n release:\n name: gitlab\n chart: gitlab/gitlab\ncluster:\n driver: colima\n network:\n hostname: test.local\n colima:\n memory: 4GiB\n cores: 2\nrepositories: {{}}\n",
3450 local_values.to_string_lossy()
3451 ),
3452 )
3453 .unwrap();
3454
3455 let cfg = load_with_fetcher(config_path.to_string_lossy().into_owned(), &fetcher)
3456 .expect("load should succeed");
3457 let deployers = cfg.deployers.unwrap();
3458 let dep = deployers.get(&"gitlab".to_string()).unwrap();
3459 let helm = dep.helm.as_ref().unwrap();
3460 assert_eq!(
3461 helm.values_file,
3462 local_values.to_string_lossy(),
3463 "local override should be preserved"
3464 );
3465
3466 let got = fs::read_to_string(&helm.values_file).unwrap();
3467 assert_eq!(got, "replicaCount: 5\n");
3468 }
3469
3470 #[test]
3472 fn extends_remote_fragment_kustomize_dir_fails() {
3473 let frag_url = format!("{FAKE_SERVER}/caproni.frag.yaml");
3474 let fetcher = FakeFetcher::new(&[(
3475 frag_url.as_str(),
3476 "\ndeployers:\n gitlab:\n type: kustomize\n kustomize:\n path: ./gitlab\n",
3477 )]);
3478
3479 let base = base_config_with_extends(&frag_url);
3480 let (_dir, config_path) = write_test_config(&base);
3481
3482 let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
3483 assert!(matches!(err, ConfigError::RemoteFragmentDirRef), "{err:?}");
3484 }
3485
3486 #[test]
3489 fn extends_remote_fragment_kustomize_path_override() {
3490 const FRAGMENT_PATH: &str = "./capronix";
3491 const LOCAL_OVERLAY: &str = "./local-overlay";
3492 const REMOTE_TARGET: &str = "https://gitlab.com/org/infra.git//overlays/dev?ref=v1.2.0";
3493
3494 fn override_for(path: &str) -> String {
3495 format!(
3496 "\ndeployers:\n nix-builder:\n type: kustomize\n kustomize:\n path: {path}\n"
3497 )
3498 }
3499
3500 struct Case {
3501 name: &'static str,
3502 base_override: String,
3503 fragment_override: String,
3504 local_override: String,
3505 want_path: &'static str,
3506 want_err: bool,
3507 }
3508
3509 let cases = vec![
3510 Case {
3511 name: "base override to local dir",
3512 base_override: override_for(LOCAL_OVERLAY),
3513 fragment_override: String::new(),
3514 local_override: String::new(),
3515 want_path: LOCAL_OVERLAY,
3516 want_err: false,
3517 },
3518 Case {
3519 name: "local fragment override to local dir",
3520 base_override: String::new(),
3521 fragment_override: override_for(LOCAL_OVERLAY),
3522 local_override: String::new(),
3523 want_path: LOCAL_OVERLAY,
3524 want_err: false,
3525 },
3526 Case {
3527 name: "caproni.local.yaml override to local dir",
3528 base_override: String::new(),
3529 fragment_override: String::new(),
3530 local_override: override_for(LOCAL_OVERLAY),
3531 want_path: LOCAL_OVERLAY,
3532 want_err: false,
3533 },
3534 Case {
3535 name: "no override keeps rejecting the fragment's local path",
3536 base_override: String::new(),
3537 fragment_override: String::new(),
3538 local_override: String::new(),
3539 want_path: "",
3540 want_err: true,
3541 },
3542 Case {
3543 name: "base override to remote target",
3544 base_override: override_for(REMOTE_TARGET),
3545 fragment_override: String::new(),
3546 local_override: String::new(),
3547 want_path: REMOTE_TARGET,
3548 want_err: false,
3549 },
3550 ];
3551
3552 for case in cases {
3553 let frag_url = format!("{FAKE_SERVER}/caproni.nix-builder.yaml");
3554 let fragment_body = override_for(FRAGMENT_PATH);
3555 let fetcher = FakeFetcher::new(&[(frag_url.as_str(), fragment_body.as_str())]);
3556
3557 let dir = tempfile::tempdir().unwrap();
3558 fs::create_dir(dir.path().join("local-overlay")).unwrap();
3559
3560 let mut extends = vec![frag_url.clone()];
3561
3562 if !case.fragment_override.is_empty() {
3563 let override_path = dir.path().join("caproni.override.yaml");
3564 fs::write(&override_path, &case.fragment_override).unwrap();
3565 extends.push("./caproni.override.yaml".to_string());
3566 }
3567
3568 let config_path = dir.path().join("caproni.yaml");
3569 let base = format!(
3570 "\nversion: 2\nextends:\n - {}\ncluster:\n driver: colima\n network:\n hostname: test.local\n colima:\n memory: 4GiB\n cores: 2\nrepositories: {{}}\n{}",
3571 extends.join("\n - "),
3572 case.base_override
3573 );
3574 fs::write(&config_path, &base).unwrap();
3575
3576 if !case.local_override.is_empty() {
3577 fs::write(
3578 dir.path().join("caproni.local.yaml"),
3579 format!("version: 2\n{}", case.local_override),
3580 )
3581 .unwrap();
3582 }
3583
3584 let result = load_with_fetcher(config_path.to_string_lossy().into_owned(), &fetcher);
3585
3586 if case.want_err {
3587 let err = result.expect_err(case.name);
3588 assert!(
3589 matches!(err, ConfigError::RemoteFragmentDirRef),
3590 "{}: {err:?}",
3591 case.name
3592 );
3593 continue;
3594 }
3595
3596 let cfg = result.unwrap_or_else(|err| panic!("{}: {err:?}", case.name));
3597 let deployers = cfg.deployers.unwrap();
3598 let dep = deployers.get(&"nix-builder".to_string()).unwrap();
3599 let kustomize = dep.kustomize.as_ref().unwrap();
3600 assert_eq!(kustomize.path, case.want_path, "{}", case.name);
3601 }
3602 }
3603
3604 #[test]
3606 fn extends_remote_fragment_kustomize_builtin_succeeds() {
3607 let frag_url = format!("{FAKE_SERVER}/caproni.frag.yaml");
3608 let fetcher = FakeFetcher::new(&[(
3609 frag_url.as_str(),
3610 "\ndeployers:\n caproni-dev-ca:\n type: kustomize\n kustomize:\n path: builtin:dev-ca\n",
3611 )]);
3612
3613 let base = base_config_with_extends(&frag_url);
3614 let (_dir, config_path) = write_test_config(&base);
3615
3616 let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
3617 let deployers = cfg.deployers.unwrap();
3618 let dep = deployers.get(&"caproni-dev-ca".to_string()).unwrap();
3619 assert_eq!(
3620 dep.kustomize.as_ref().unwrap().path,
3621 "builtin:dev-ca",
3622 "a builtin: path must survive untouched"
3623 );
3624 }
3625
3626 #[test]
3628 fn extends_remote_fragment_kustomize_remote_target_succeeds() {
3629 let frag_url = format!("{FAKE_SERVER}/caproni.frag.yaml");
3630 let fetcher = FakeFetcher::new(&[(
3631 frag_url.as_str(),
3632 "\ndeployers:\n shared-overlay:\n type: kustomize\n kustomize:\n path: https://gitlab.com/org/infra.git//overlays/dev?ref=v1.2.0\n",
3633 )]);
3634
3635 let base = base_config_with_extends(&frag_url);
3636 let (_dir, config_path) = write_test_config(&base);
3637
3638 let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
3639 let deployers = cfg.deployers.unwrap();
3640 let dep = deployers.get(&"shared-overlay".to_string()).unwrap();
3641 assert_eq!(
3642 dep.kustomize.as_ref().unwrap().path,
3643 "https://gitlab.com/org/infra.git//overlays/dev?ref=v1.2.0",
3644 "a remote target must survive untouched"
3645 );
3646 }
3647
3648 #[test]
3650 fn extends_remote_fragment_missing_values_file_fails() {
3651 let frag_url = format!("{FAKE_SERVER}/missing-values/caproni.frag.yaml");
3652 let fetcher = FakeFetcher::new(&[(
3653 frag_url.as_str(),
3654 "\ndeployers:\n gitlab:\n type: helm\n helm:\n namespace: gitlab\n values_file: values.yaml\n release:\n name: gitlab\n chart: gitlab/gitlab\n",
3655 )]);
3656
3657 let base = base_config_with_extends(&frag_url);
3658 let (_dir, config_path) = write_test_config(&base);
3659
3660 let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
3661 assert!(
3662 err.to_string().contains("values file not found"),
3663 "unexpected error: {err}"
3664 );
3665 }
3666
3667 #[test]
3673 fn load_local_override_prefers_xianyun_local_yaml_over_caproni_local_yaml_alias() {
3674 let dir = tempfile::tempdir().unwrap();
3675 let config_path = dir.path().join("xianyun.yaml");
3676 fs::write(dir.path().join("xianyun.local.yaml"), "primary: true\n").unwrap();
3677 fs::write(dir.path().join("caproni.local.yaml"), "primary: false\n").unwrap();
3678
3679 let result = load_local_override(&config_path).unwrap().unwrap();
3680 assert_eq!(result.get("primary"), Some(&json!(true)));
3681 }
3682
3683 #[test]
3684 fn load_local_override_falls_back_to_caproni_local_yaml_alias() {
3685 let dir = tempfile::tempdir().unwrap();
3686 let config_path = dir.path().join("xianyun.yaml");
3687 fs::write(dir.path().join("caproni.local.yaml"), "alias: true\n").unwrap();
3688
3689 let result = load_local_override(&config_path).unwrap().unwrap();
3690 assert_eq!(result.get("alias"), Some(&json!(true)));
3691 }
3692
3693 #[test]
3694 fn load_local_override_returns_none_when_neither_exists() {
3695 let dir = tempfile::tempdir().unwrap();
3696 let config_path = dir.path().join("xianyun.yaml");
3697
3698 assert!(load_local_override(&config_path).unwrap().is_none());
3699 }
3700}