1use serde::{Deserialize, Deserializer, Serialize};
6use thiserror::Error;
7
8use super::types::ClusterConfig;
9use super::versions::Upgrader;
10use crate::orderedmap::OrderedMap;
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum LatestError {
14 #[error("unsupported variable type: {0:?}")]
15 UnsupportedVariableType(String),
16 #[error("file variable requires a path")]
17 MissingVariablePath,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct CaproniConfig {
23 pub version: i32,
24 pub cluster: ClusterConfig,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub repositories: Option<OrderedMap<String, RepositoryConfig>>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub deployers: Option<OrderedMap<String, DeployerConfig>>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub reloaders: Option<OrderedMap<String, ReloaderConfig>>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub variables: Option<OrderedMap<String, VariableConfig>>,
33 #[serde(skip)]
34 pub config_file: String,
35 #[serde(skip)]
36 pub config_dir: String,
37 #[serde(skip)]
38 pub copy_sleep_image: String,
39}
40
41impl CaproniConfig {
42 pub fn apply_defaults(&mut self) -> Result<(), LatestError> {
44 if self.cluster.driver == "k3d"
45 && let Some(k3d) = self.cluster.k3d.as_mut()
46 && k3d.servers == 0
47 {
48 k3d.servers = 1;
49 }
50
51 if self.cluster.driver == "vcluster"
52 && let Some(vcluster) = self.cluster.vcluster.as_mut()
53 && vcluster.manage_namespace.is_none()
54 {
55 vcluster.manage_namespace = Some(true);
56 }
57
58 if self.cluster.name.is_none() {
59 self.cluster.name = Some(super::types::ClusterNameConfig {
60 strategy: "static".into(),
61 });
62 }
63
64 if let Some(network) = self.cluster.network.as_mut()
65 && network.additional_hostnames.is_empty()
66 {
67 network.additional_hostnames = Vec::new();
68 }
69
70 if self.repositories.is_none() {
71 self.repositories = Some(OrderedMap::new());
72 }
73 if self.deployers.is_none() {
74 self.deployers = Some(OrderedMap::new());
75 }
76 if self.reloaders.is_none() {
77 self.reloaders = Some(OrderedMap::new());
78 }
79 if self.variables.is_none() {
80 self.variables = Some(OrderedMap::new());
81 }
82
83 if let Some(reloaders) = self.reloaders.as_mut() {
84 let names: Vec<String> = reloaders.keys();
85 for name in names {
86 if let Some(rc) = reloaders.get_mut(&name) {
87 rc.name = name;
88 }
89 }
90 }
91
92 Ok(())
93 }
94
95 pub fn resolve_path(&self, path: &str) -> String {
98 let p = std::path::Path::new(path);
99 if p.is_absolute() {
100 return path.to_string();
101 }
102
103 std::path::Path::new(&self.config_dir)
104 .join(p)
105 .to_string_lossy()
106 .into_owned()
107 }
108}
109
110impl Upgrader for CaproniConfig {
111 fn upgrade(&self) -> Result<Box<dyn Upgrader>, super::versions::UpgraderError> {
117 Ok(Box::new(self.clone()))
118 }
119
120 fn is_terminal(&self) -> bool {
124 true
125 }
126
127 fn as_any(&self) -> &dyn std::any::Any {
128 self
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum VariableType {
134 #[default]
135 Value,
136 File,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
140pub struct VariableConfig {
141 #[serde(rename = "type")]
142 pub var_type: VariableType,
143 #[serde(skip_serializing_if = "String::is_empty")]
144 pub value: String,
145 #[serde(skip_serializing_if = "String::is_empty")]
146 pub path: String,
147}
148
149impl Serialize for VariableType {
150 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
151 where
152 S: serde::Serializer,
153 {
154 match self {
155 VariableType::Value => serializer.serialize_str("value"),
156 VariableType::File => serializer.serialize_str("file"),
157 }
158 }
159}
160
161impl<'de> Deserialize<'de> for VariableConfig {
169 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170 where
171 D: Deserializer<'de>,
172 {
173 struct VariableConfigVisitor;
174
175 impl<'de> serde::de::Visitor<'de> for VariableConfigVisitor {
176 type Value = VariableConfig;
177
178 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 f.write_str("a scalar value or a variable mapping")
180 }
181
182 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
183 where
184 E: serde::de::Error,
185 {
186 Ok(VariableConfig {
187 var_type: VariableType::Value,
188 value: v.to_string(),
189 path: String::new(),
190 })
191 }
192
193 fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
194 where
195 E: serde::de::Error,
196 {
197 Ok(VariableConfig {
198 var_type: VariableType::Value,
199 value: v,
200 path: String::new(),
201 })
202 }
203
204 fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
205 where
206 A: serde::de::MapAccess<'de>,
207 {
208 #[derive(Deserialize, Default)]
209 #[serde(default)]
210 struct RawVariableConfig {
211 r#type: String,
212 value: String,
213 path: String,
214 }
215
216 let raw: RawVariableConfig =
217 Deserialize::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
218
219 let var_type = match raw.r#type.as_str() {
220 "" | "value" => VariableType::Value,
221 "file" => VariableType::File,
222 other => {
223 return Err(serde::de::Error::custom(format!(
224 "unsupported variable type: {other:?}"
225 )));
226 }
227 };
228
229 if var_type == VariableType::File && raw.path.is_empty() {
230 return Err(serde::de::Error::custom("file variable requires a path"));
231 }
232
233 Ok(VariableConfig {
234 var_type,
235 value: raw.value,
236 path: raw.path,
237 })
238 }
239 }
240
241 deserializer.deserialize_any(VariableConfigVisitor)
242 }
243}
244
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
246#[serde(default)]
247pub struct RepositoryConfig {
248 pub repository: String,
249 pub directory: String,
250 #[serde(rename = "ref")]
251 pub ref_: String,
252 pub edit_mode: bool,
253 pub lifecycle_hooks: Vec<LifecycleHookConfig>,
254}
255
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257#[serde(default)]
258pub struct HelmConfig {
259 pub namespace: String,
260 pub wait: bool,
261 pub timeout: String,
262 pub values_file: String,
263 pub dynamic_overrides: Vec<DynamicOverrideConfig>,
264 pub repository: Option<HelmRepositoryConfig>,
265 pub release: Option<HelmReleaseConfig>,
266}
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269#[serde(default)]
270pub struct HelmRepositoryConfig {
271 pub name: String,
272 pub url: String,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276#[serde(default)]
277pub struct HelmReleaseConfig {
278 pub name: String,
279 pub chart: String,
280 pub version: String,
281}
282
283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
284#[serde(default)]
285pub struct DynamicOverrideConfig {
286 #[serde(rename = "type")]
287 pub override_type: String,
288 pub repository: String,
289 pub version_map: String,
290 pub value_map: Option<OrderedMap<String, serde_json::Value>>,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
294#[serde(default)]
295pub struct MirrorConfig {
296 pub target: Option<MirrorTargetConfig>,
297 pub filesystem: Option<FilesystemConfig>,
298 pub network: Option<MirrorNetworkConfig>,
299 pub edit_mode_processes: Option<OrderedMap<String, ProcessConfig>>,
300 pub lifecycle_hooks: Vec<LifecycleHookConfig>,
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304#[serde(default)]
305pub struct MirrorNetworkConfig {
306 pub incoming: Option<MirrorIncomingConfig>,
307 pub outgoing: Option<MirrorOutgoingConfig>,
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(default)]
312pub struct MirrorIncomingConfig {
313 pub mode: String,
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317#[serde(default)]
318pub struct MirrorOutgoingConfig {
319 pub ignore_localhost: bool,
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323#[serde(default)]
324pub struct MirrorTargetConfig {
325 pub namespace: String,
326 pub kubernetes_type: String,
327 pub name: String,
328 pub container: String,
329}
330
331#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332#[serde(default)]
333pub struct FilesystemConfig {
334 pub mode: String,
335 pub read_write: Vec<String>,
336 pub read_only: Vec<String>,
337 pub mapping: Option<OrderedMap<String, String>>,
338}
339
340pub const PROCESS_CONTEXT_MIRRORD: &str = "mirrord";
341pub const PROCESS_CONTEXT_LOCAL: &str = "local";
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize)]
344#[serde(default)]
345pub struct ProcessConfig {
346 pub command: String,
347 pub port: i32,
348 pub context: String,
349}
350
351#[derive(Debug, Clone, Default, Serialize, Deserialize)]
352#[serde(default)]
353pub struct LifecycleHookConfig {
354 #[serde(rename = "type")]
355 pub hook_type: String,
356 pub script: String,
357 pub mirror_script: String,
358 pub job: String,
359}
360
361#[derive(Debug, Clone, Default, Serialize, Deserialize)]
362#[serde(default)]
363pub struct SkaffoldConfig {
364 pub namespace: String,
365 pub config_file: String,
366}
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
369#[serde(default)]
370pub struct DeployerConfig {
371 #[serde(rename = "type")]
372 pub deployer_type: String,
373 pub repository: Option<String>,
374 pub needs: Vec<String>,
375 pub edit_mode: bool,
376 pub helm: Option<HelmConfig>,
377 pub namespace: Option<NamespaceConfig>,
378 pub kustomize: Option<KustomizeConfig>,
379}
380
381#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382#[serde(default)]
383pub struct NamespaceConfig {
384 pub namespaces: Vec<String>,
385 pub delete_on_undeploy: bool,
386}
387
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
389#[serde(default)]
390pub struct KustomizeConfig {
391 pub path: String,
392 pub namespace: String,
393 pub files: std::collections::HashMap<String, String>,
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
397#[serde(default)]
398pub struct ReloaderConfig {
399 #[serde(skip)]
400 pub name: String,
401 #[serde(rename = "type")]
402 pub reloader_type: String,
403 pub repository: String,
404 pub edit_mode: bool,
405 pub mirror: Option<MirrorConfig>,
406 pub skaffold: Option<SkaffoldConfig>,
407}
408
409impl ReloaderConfig {
410 pub fn pretty_resource_name(&self) -> String {
415 if let Some(mirror) = &self.mirror
416 && let Some(target) = &mirror.target
417 {
418 return [
419 target.namespace.as_str(),
420 target.kubernetes_type.as_str(),
421 target.name.as_str(),
422 target.container.as_str(),
423 ]
424 .into_iter()
425 .filter(|s| !s.is_empty())
426 .collect::<Vec<_>>()
427 .join("/");
428 }
429
430 if let Some(skaffold) = &self.skaffold {
431 return skaffold.namespace.clone();
432 }
433
434 String::new()
435 }
436}
437
438pub fn unmarshal_variable_config(yaml: &str) -> Result<VariableConfig, LatestError> {
443 let value: serde_yaml::Value =
444 serde_yaml::from_str(yaml).map_err(|_| LatestError::MissingVariablePath)?;
445
446 if let serde_yaml::Value::String(s) = &value {
447 return Ok(VariableConfig {
448 var_type: VariableType::Value,
449 value: s.clone(),
450 path: String::new(),
451 });
452 }
453
454 #[derive(serde::Deserialize, Default)]
455 struct RawVariableConfig {
456 #[serde(default)]
457 r#type: String,
458 #[serde(default)]
459 value: String,
460 #[serde(default)]
461 path: String,
462 }
463
464 let raw: RawVariableConfig =
465 serde_yaml::from_value(value).map_err(|_| LatestError::MissingVariablePath)?;
466
467 let var_type = match raw.r#type.as_str() {
468 "" | "value" => VariableType::Value,
469 "file" => VariableType::File,
470 other => return Err(LatestError::UnsupportedVariableType(other.to_string())),
471 };
472
473 if var_type == VariableType::File && raw.path.is_empty() {
474 return Err(LatestError::MissingVariablePath);
475 }
476
477 Ok(VariableConfig {
478 var_type,
479 value: raw.value,
480 path: raw.path,
481 })
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
490 fn variable_config_unmarshal_yaml_cases() {
491 struct Case {
492 name: &'static str,
493 yaml: &'static str,
494 want: Option<VariableConfig>,
495 want_err: Option<&'static str>,
496 }
497
498 let cases = vec![
499 Case {
500 name: "scalar shorthand becomes literal value",
501 yaml: "foo.bar.gitlab.net",
502 want: Some(VariableConfig {
503 var_type: VariableType::Value,
504 value: "foo.bar.gitlab.net".into(),
505 path: String::new(),
506 }),
507 want_err: None,
508 },
509 Case {
510 name: "explicit literal type",
511 yaml: "type: value\nvalue: foo.bar.gitlab.net",
512 want: Some(VariableConfig {
513 var_type: VariableType::Value,
514 value: "foo.bar.gitlab.net".into(),
515 path: String::new(),
516 }),
517 want_err: None,
518 },
519 Case {
520 name: "mapping without type defaults to literal value",
521 yaml: "value: foo.bar.gitlab.net",
522 want: Some(VariableConfig {
523 var_type: VariableType::Value,
524 value: "foo.bar.gitlab.net".into(),
525 path: String::new(),
526 }),
527 want_err: None,
528 },
529 Case {
530 name: "file type keeps the path",
531 yaml: "type: file\npath: ../GITALY_SERVER_VERSION",
532 want: Some(VariableConfig {
533 var_type: VariableType::File,
534 value: String::new(),
535 path: "../GITALY_SERVER_VERSION".into(),
536 }),
537 want_err: None,
538 },
539 Case {
540 name: "file type without a path is rejected",
541 yaml: "type: file",
542 want: None,
543 want_err: Some("file variable requires a path"),
544 },
545 Case {
546 name: "unsupported type is rejected",
547 yaml: "type: env\nname: CI_REGISTRY",
548 want: None,
549 want_err: Some("unsupported variable type: \"env\""),
550 },
551 ];
552
553 for case in cases {
554 let got = unmarshal_variable_config(case.yaml);
555
556 if let Some(expected_err) = case.want_err {
557 let err = got.unwrap_err();
558 assert_eq!(err.to_string(), expected_err, "{}", case.name);
559 continue;
560 }
561
562 assert_eq!(got.unwrap(), case.want.unwrap(), "{}", case.name);
563 }
564 }
565
566 #[test]
573 fn caproni_config_variables_preserve_insertion_order() {
574 let mut vars: OrderedMap<String, VariableConfig> = OrderedMap::new();
575 vars.set(
576 "HOSTNAME".into(),
577 VariableConfig {
578 var_type: VariableType::Value,
579 value: "foo.bar.gitlab.net".into(),
580 path: String::new(),
581 },
582 );
583 vars.set(
584 "PRIMARY".into(),
585 VariableConfig {
586 var_type: VariableType::Value,
587 value: "primary.example.com".into(),
588 path: String::new(),
589 },
590 );
591
592 let cfg = CaproniConfig {
593 version: 2,
594 variables: Some(vars),
595 ..Default::default()
596 };
597 let vars = cfg.variables.expect("variables present");
598 assert_eq!(vars.len(), 2);
599
600 let hostname = vars.get(&"HOSTNAME".to_string()).expect("HOSTNAME present");
601 assert_eq!(hostname.value, "foo.bar.gitlab.net");
602
603 assert_eq!(
604 vars.keys(),
605 vec!["HOSTNAME".to_string(), "PRIMARY".to_string()]
606 );
607 }
608
609 #[test]
615 fn caproni_config_deserializes_from_full_yaml_document() {
616 let yaml = r#"
617version: 2
618cluster:
619 driver: k3d
620 k3d:
621 servers: 2
622 network:
623 hostname: gitlab.example.com
624 additional_hostnames:
625 - registry.example.com
626repositories:
627 gitlab:
628 repository: https://gitlab.com/gitlab-org/gitlab.git
629 directory: gitlab
630 ref: main
631 edit_mode: true
632deployers:
633 gitlab-helm:
634 type: helm
635 repository: gitlab
636 needs: []
637 helm:
638 namespace: gitlab
639 release:
640 name: gitlab
641 chart: gitlab/gitlab
642 gitlab-ns:
643 type: namespace
644 namespace:
645 namespaces:
646 - gitlab
647reloaders:
648 gitlab-mirror:
649 type: mirror
650 repository: gitlab
651 mirror:
652 target:
653 namespace: gitlab
654 kubernetes_type: deployment
655 name: gitlab
656 edit_mode_processes:
657 web:
658 command: run.sh
659variables:
660 HOSTNAME: gitlab.example.com
661 GITALY_VERSION:
662 type: file
663 path: ../GITALY_SERVER_VERSION
664"#;
665
666 let cfg: CaproniConfig = serde_yaml::from_str(yaml).expect("full document should decode");
667
668 assert_eq!(cfg.version, 2);
669 assert_eq!(cfg.cluster.driver, "k3d");
670 assert_eq!(cfg.cluster.k3d.as_ref().unwrap().servers, 2);
671 assert_eq!(
672 cfg.cluster.network.as_ref().unwrap().hostname,
673 "gitlab.example.com"
674 );
675
676 let repos = cfg.repositories.expect("repositories present");
677 let gitlab_repo = repos.get(&"gitlab".to_string()).expect("gitlab repo");
678 assert_eq!(
679 gitlab_repo.repository,
680 "https://gitlab.com/gitlab-org/gitlab.git"
681 );
682 assert_eq!(gitlab_repo.ref_, "main");
683 assert!(gitlab_repo.edit_mode);
684
685 let deployers = cfg.deployers.expect("deployers present");
686 let helm_deployer = deployers
687 .get(&"gitlab-helm".to_string())
688 .expect("helm deployer");
689 assert_eq!(helm_deployer.deployer_type, "helm");
690 assert_eq!(helm_deployer.repository.as_deref(), Some("gitlab"));
691 let helm = helm_deployer.helm.as_ref().expect("helm block present");
692 assert_eq!(helm.namespace, "gitlab");
693 assert_eq!(helm.release.as_ref().unwrap().chart, "gitlab/gitlab");
694
695 let ns_deployer = deployers
696 .get(&"gitlab-ns".to_string())
697 .expect("namespace deployer");
698 assert_eq!(ns_deployer.deployer_type, "namespace");
699 assert_eq!(
700 ns_deployer.namespace.as_ref().unwrap().namespaces,
701 vec!["gitlab".to_string()]
702 );
703
704 let reloaders = cfg.reloaders.expect("reloaders present");
705 let mirror_reloader = reloaders
706 .get(&"gitlab-mirror".to_string())
707 .expect("mirror reloader");
708 assert_eq!(mirror_reloader.reloader_type, "mirror");
709 let mirror = mirror_reloader
710 .mirror
711 .as_ref()
712 .expect("mirror block present");
713 let target = mirror.target.as_ref().expect("target present");
714 assert_eq!(target.kubernetes_type, "deployment");
715 let processes = mirror
716 .edit_mode_processes
717 .as_ref()
718 .expect("edit_mode_processes present");
719 assert_eq!(processes.get(&"web".to_string()).unwrap().command, "run.sh");
720
721 let variables = cfg.variables.expect("variables present");
722 let hostname_var = variables.get(&"HOSTNAME".to_string()).unwrap();
723 assert_eq!(hostname_var.var_type, VariableType::Value);
724 assert_eq!(hostname_var.value, "gitlab.example.com");
725
726 let gitaly_var = variables.get(&"GITALY_VERSION".to_string()).unwrap();
727 assert_eq!(gitaly_var.var_type, VariableType::File);
728 assert_eq!(gitaly_var.path, "../GITALY_SERVER_VERSION");
729 }
730
731 #[test]
733 fn latest_upgrade_is_terminal_noop() {
734 let cfg = CaproniConfig {
735 version: 2,
736 ..Default::default()
737 };
738 let next = cfg.upgrade().expect("upgrade must not error");
739 let _ = next;
744 }
745}