xianyun_deploy/deployer/helm/
overrider.rs1use serde::Deserialize;
19use serde_json::{Map, Value};
20
21use xianyun_core::config::latest::{CaproniConfig, DynamicOverrideConfig};
22use xianyun_core::dyn_access::{HelmStep, parse_helm_path};
23use xianyun_core::orderedmap::OrderedMap;
24
25use super::HelmError;
26
27pub(super) trait DynamicOverrider {
32 fn get_values(
33 &self,
34 override_cfg: &DynamicOverrideConfig,
35 caproni_config: &CaproniConfig,
36 ) -> Result<Option<Value>, HelmError>;
37}
38
39pub(super) fn new_dynamic_overrider(
41 override_type: &str,
42) -> Result<Box<dyn DynamicOverrider>, HelmError> {
43 match override_type {
44 "gitlab_repo_version_files" => Ok(Box::new(GitLabRepoVersionFilesOverrider)),
45 "inline" => Ok(Box::new(InlineOverrider)),
46 "inline_file" => Ok(Box::new(InlineFileOverrider)),
47 "release_manifest" => Err(HelmError::DynamicOverridesNotImplemented(
48 override_type.to_string(),
49 )),
50 _ => Err(HelmError::UnknownDynamicOverrideType(
51 override_type.to_string(),
52 )),
53 }
54}
55
56#[derive(Debug, Default, Deserialize)]
58struct VersionMapConfig {
59 #[serde(default)]
60 version_map: OrderedMap<String, String>,
61}
62
63struct GitLabRepoVersionFilesOverrider;
65
66impl DynamicOverrider for GitLabRepoVersionFilesOverrider {
67 fn get_values(
68 &self,
69 override_cfg: &DynamicOverrideConfig,
70 caproni_config: &CaproniConfig,
71 ) -> Result<Option<Value>, HelmError> {
72 let source_repo_path = caproni_config.resolve_path(&override_cfg.repository);
73 let version_map_path = caproni_config.resolve_path(&override_cfg.version_map);
74
75 match std::fs::metadata(&source_repo_path) {
79 Ok(_) => {}
80 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81 tracing::warn!(
82 repository = %source_repo_path,
83 "repository path unavailable, skipping gitlab_repo_version_files override"
84 );
85 return Ok(None);
86 }
87 Err(e) => {
88 return Err(HelmError::Io(format!(
89 "checking repository path {source_repo_path}: {e}"
90 )));
91 }
92 }
93
94 let version_map_data = std::fs::read(&version_map_path)
95 .map_err(|e| HelmError::Io(format!("reading version map file: {e}")))?;
96
97 let version_map: VersionMapConfig = serde_yaml::from_slice(&version_map_data)
98 .map_err(|e| HelmError::Io(format!("parsing version map: {e}")))?;
99
100 if version_map.version_map.is_empty() {
101 return Err(HelmError::EmptyVersionMap(version_map_path));
102 }
103
104 let mut values = Map::new();
105
106 for (helm_key, version_file_name) in version_map.version_map.iter() {
107 let version_file_path = std::path::Path::new(&source_repo_path).join(version_file_name);
108
109 let version_value = std::fs::read_to_string(&version_file_path).map_err(|e| {
110 HelmError::Io(format!("reading version file {version_file_name}: {e}"))
111 })?;
112
113 set_value(
114 &mut values,
115 helm_key,
116 Value::String(version_value.trim().to_string()),
117 )?;
118 }
119
120 Ok(Some(Value::Object(values)))
121 }
122}
123
124struct InlineOverrider;
126
127impl DynamicOverrider for InlineOverrider {
128 fn get_values(
129 &self,
130 override_cfg: &DynamicOverrideConfig,
131 _caproni_config: &CaproniConfig,
132 ) -> Result<Option<Value>, HelmError> {
133 let Some(value_map) = &override_cfg.value_map else {
134 return Ok(None);
135 };
136
137 let mut values = Map::new();
138
139 for (helm_key, value) in value_map.iter() {
140 assert_override_value(helm_key, value)?;
141 set_value(&mut values, helm_key, value.clone())?;
142 }
143
144 Ok(Some(Value::Object(values)))
145 }
146}
147
148fn assert_override_value(helm_key: &str, value: &Value) -> Result<(), HelmError> {
150 match value {
151 Value::Null | Value::String(_) | Value::Bool(_) | Value::Number(_) => Ok(()),
152 Value::Array(items) => {
153 for item in items {
154 assert_no_nulls(helm_key, item)?;
155 }
156 Ok(())
157 }
158 Value::Object(map) => {
159 for item in map.values() {
160 assert_override_value(helm_key, item)?;
161 }
162 Ok(())
163 }
164 }
165}
166
167fn assert_no_nulls(helm_key: &str, value: &Value) -> Result<(), HelmError> {
169 match value {
170 Value::String(_) | Value::Bool(_) | Value::Number(_) => Ok(()),
171 Value::Array(items) => {
172 for item in items {
173 assert_no_nulls(helm_key, item)?;
174 }
175 Ok(())
176 }
177 Value::Object(map) => {
178 for item in map.values() {
179 assert_no_nulls(helm_key, item)?;
180 }
181 Ok(())
182 }
183 Value::Null => Err(HelmError::UnsupportedInlineValueType(
184 helm_key.to_string(),
185 "null (inside a list)",
186 )),
187 }
188}
189
190struct InlineFileOverrider;
192
193impl DynamicOverrider for InlineFileOverrider {
194 fn get_values(
195 &self,
196 override_cfg: &DynamicOverrideConfig,
197 _caproni_config: &CaproniConfig,
198 ) -> Result<Option<Value>, HelmError> {
199 let Some(value_map) = &override_cfg.value_map else {
200 return Ok(None);
201 };
202
203 let mut values = Map::new();
204
205 for (helm_key, value) in value_map.iter() {
206 if value.is_null() {
209 set_value(&mut values, helm_key, Value::Null)?;
210 continue;
211 }
212
213 let Value::String(path) = value else {
214 return Err(HelmError::UnsupportedInlineFileValueType(
215 helm_key.clone(),
216 json_type_name(value),
217 ));
218 };
219
220 let content = std::fs::read_to_string(path).map_err(|e| {
221 HelmError::Io(format!(
222 "reading value file for key {helm_key:?}: {path}: {e}"
223 ))
224 })?;
225
226 set_value(&mut values, helm_key, Value::String(content))?;
227 }
228
229 Ok(Some(Value::Object(values)))
230 }
231}
232
233fn json_type_name(value: &Value) -> &'static str {
234 match value {
235 Value::Null => "null",
236 Value::Bool(_) => "bool",
237 Value::Number(_) => "number",
238 Value::String(_) => "string",
239 Value::Array(_) => "array",
240 Value::Object(_) => "object",
241 }
242}
243
244pub(super) fn set_value(
246 dst: &mut Map<String, Value>,
247 key: &str,
248 value: Value,
249) -> Result<(), HelmError> {
250 let steps = parse_helm_path(key).map_err(|_| HelmError::InvalidHelmKey(key.to_string()))?;
251
252 let container = Value::Object(std::mem::take(dst));
256
257 let updated = set_path(Some(container), &steps, value, key)?;
258
259 let Value::Object(root) = updated else {
261 return Err(HelmError::InvalidHelmKey(key.to_string()));
262 };
263
264 *dst = root;
265
266 Ok(())
267}
268
269fn set_path(
271 container: Option<Value>,
272 steps: &[HelmStep],
273 value: Value,
274 key: &str,
275) -> Result<Value, HelmError> {
276 let Some((step, rest)) = steps.split_first() else {
277 return Ok(value);
278 };
279
280 match step {
281 HelmStep::Index(index) => {
282 let mut list = match container {
283 None => Vec::new(),
284 Some(Value::Array(items)) => items,
285 Some(_) => return Err(HelmError::ConflictingHelmKey(key.to_string())),
286 };
287
288 while list.len() <= *index {
291 list.push(Value::Null);
292 }
293
294 let existing = std::mem::replace(&mut list[*index], Value::Null);
295 let child = set_path(existing.take_or_none(), rest, value, key)?;
296 list[*index] = child;
297
298 Ok(Value::Array(list))
299 }
300 HelmStep::MapKey(map_key) => {
301 let mut map = match container {
302 None => Map::new(),
303 Some(Value::Object(map)) => map,
304 Some(_) => return Err(HelmError::ConflictingHelmKey(key.to_string())),
305 };
306
307 let existing = map.remove(map_key);
308 let next = set_path(existing, rest, value, key)?;
309 map.insert(map_key.clone(), next);
310
311 Ok(Value::Object(map))
312 }
313 }
314}
315
316trait TakeOrNone {
321 fn take_or_none(self) -> Option<Value>;
322}
323
324impl TakeOrNone for Value {
325 fn take_or_none(self) -> Option<Value> {
326 if self.is_null() { None } else { Some(self) }
327 }
328}
329
330pub(super) fn merge_values(dst: &mut Map<String, Value>, src: Map<String, Value>) {
332 for (key, value) in src {
333 if value.is_null() {
334 dst.remove(&key);
335 continue;
336 }
337
338 let Value::Object(src_child) = value else {
339 dst.insert(key, value);
340 continue;
341 };
342
343 let dst_child = match dst.remove(&key) {
346 Some(Value::Object(m)) => m,
347 _ => Map::new(),
348 };
349
350 let mut dst_child = dst_child;
351 merge_values(&mut dst_child, src_child);
352 dst.insert(key, Value::Object(dst_child));
353 }
354}
355
356pub(super) fn prune_empty(values: &mut Map<String, Value>) {
358 let keys: Vec<String> = values.keys().cloned().collect();
359
360 for key in keys {
361 let value = values.remove(&key).expect("key was just listed");
362
363 let Value::Object(mut child) = value else {
364 values.insert(key, value);
366 continue;
367 };
368
369 prune_empty(&mut child);
370
371 if !child.is_empty() {
372 values.insert(key, Value::Object(child));
373 }
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
388 fn set_value_builds_nested_maps() {
389 let mut dst = Map::new();
390 set_value(&mut dst, "global.hosts.domain", Value::String("x".into())).unwrap();
391 assert_eq!(
392 Value::Object(dst),
393 serde_json::json!({"global": {"hosts": {"domain": "x"}}})
394 );
395 }
396
397 #[test]
398 fn merge_values_drops_null_keys() {
399 let mut dst = Map::new();
400 dst.insert("a".to_string(), Value::from(1));
401 let mut src = Map::new();
402 src.insert("a".to_string(), Value::Null);
403 merge_values(&mut dst, src);
404 assert!(dst.is_empty());
405 }
406
407 fn inline_override(value_map: Map<String, Value>) -> DynamicOverrideConfig {
417 let mut ordered = OrderedMap::new();
418 for (key, value) in value_map {
419 ordered.set(key, value);
420 }
421
422 DynamicOverrideConfig {
423 override_type: "inline".to_string(),
424 value_map: Some(ordered),
425 ..Default::default()
426 }
427 }
428
429 #[test]
432 fn test_inline_overrider_preserves_punctuation_in_values() {
433 let mut value_map = Map::new();
434 value_map.insert(
435 "global.hosts.domain".to_string(),
436 Value::String("gitlab.example.com,registry.example.com".to_string()),
437 );
438 value_map.insert(
439 "ingress.annotations.nginx\\.ingress\\.kubernetes\\.io/proxy-body-size".to_string(),
440 Value::String("0=unlimited; see docs".to_string()),
441 );
442
443 let cfg = inline_override(value_map);
444 let caproni_config = CaproniConfig::default();
445
446 let values = InlineOverrider
447 .get_values(&cfg, &caproni_config)
448 .unwrap()
449 .expect("inline overrides with a value_map always produce values");
450
451 assert_eq!(
452 values["global"]["hosts"]["domain"],
453 Value::String("gitlab.example.com,registry.example.com".to_string())
454 );
455 assert_eq!(
456 values["ingress"]["annotations"]["nginx.ingress.kubernetes.io/proxy-body-size"],
457 Value::String("0=unlimited; see docs".to_string())
458 );
459 }
460
461 #[test]
464 fn test_inline_overrider_escaped_dot_addresses_one_key() {
465 let mut value_map = Map::new();
466 value_map.insert(
467 "annotations.cert-manager\\.io/cluster-issuer".to_string(),
468 Value::String("letsencrypt-prod".to_string()),
469 );
470
471 let cfg = inline_override(value_map);
472 let caproni_config = CaproniConfig::default();
473
474 let values = InlineOverrider
475 .get_values(&cfg, &caproni_config)
476 .unwrap()
477 .expect("inline overrides with a value_map always produce values");
478
479 let annotations = values["annotations"]
483 .as_object()
484 .expect("annotations is a single nested map");
485 assert_eq!(annotations.len(), 1);
486 assert_eq!(
487 annotations["cert-manager.io/cluster-issuer"],
488 Value::String("letsencrypt-prod".to_string())
489 );
490 }
491
492 #[test]
495 fn test_inline_overrider_renders_lists_and_maps_as_yaml() {
496 let mut value_map = Map::new();
497 value_map.insert(
498 "ingress.hosts".to_string(),
499 Value::Array(vec![
500 Value::String("gitlab.example.com".to_string()),
501 Value::String("registry.example.com".to_string()),
502 ]),
503 );
504 value_map.insert(
505 "global.extraEnv".to_string(),
506 serde_json::json!({"FOO": "bar", "BAZ": "qux"}),
507 );
508
509 let cfg = inline_override(value_map);
510 let caproni_config = CaproniConfig::default();
511
512 let values = InlineOverrider
513 .get_values(&cfg, &caproni_config)
514 .unwrap()
515 .expect("inline overrides with a value_map always produce values");
516
517 let yaml = serde_yaml::to_string(&values).unwrap();
521 let reparsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
522
523 assert_eq!(
524 reparsed["ingress"]["hosts"][0].as_str(),
525 Some("gitlab.example.com")
526 );
527 assert_eq!(
528 reparsed["ingress"]["hosts"][1].as_str(),
529 Some("registry.example.com")
530 );
531 assert_eq!(reparsed["global"]["extraEnv"]["FOO"].as_str(), Some("bar"));
532 assert_eq!(reparsed["global"]["extraEnv"]["BAZ"].as_str(), Some("qux"));
533 }
534}