Skip to main content

xianyun_deploy/deployer/helm/
overrider.rs

1//! Ported from `internal/deployer/helm/overrider.go`.
2//!
3//! **Divergence:** Go's `DynamicOverrideConfig.ValueMap` decodes YAML into
4//! `map[string]any`, so a scalar such as a YAML timestamp decodes into a
5//! `time.Time` -- a type `assertOverrideValue`/`assertNoNulls` reject as an
6//! "unsupported inline value type". This crate's
7//! `DynamicOverrideConfig::value_map` is `OrderedMap<String,
8//! serde_json::Value>` (see `xianyun_core::config::latest`); every
9//! `serde_json::Value` variant (`Null`/`Bool`/`Number`/`String`/`Array`/
10//! `Object`) is one `assertOverrideValue` already allows, so that rejection
11//! path is unreachable here -- there is no Rust equivalent of a decoded
12//! value that is neither a scalar, a list, nor a map. The Go tests exercising
13//! it (`TestBuildDynamicOverrides_InlineRejectsNonScalarLeaves`,
14//! `TestBuildDynamicOverrides_InlineUnsupportedType`) are ported as
15//! `#[ignore]`d stubs in `manager.rs` documenting exactly this, rather than
16//! silently dropped.
17
18use 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
27/// Ported from `DynamicOverrider`. Resolves a dynamic override into a tree
28/// of Helm values (a JSON object), or `None` when the override contributes
29/// nothing (for example, `gitlab_repo_version_files` whose repository path
30/// does not exist yet).
31pub(super) trait DynamicOverrider {
32    fn get_values(
33        &self,
34        override_cfg: &DynamicOverrideConfig,
35        caproni_config: &CaproniConfig,
36    ) -> Result<Option<Value>, HelmError>;
37}
38
39/// Ported from `newDynamicOverrider`.
40pub(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/// Ported from `versionMapConfig`.
57#[derive(Debug, Default, Deserialize)]
58struct VersionMapConfig {
59    #[serde(default)]
60    version_map: OrderedMap<String, String>,
61}
62
63/// Ported from `gitLabRepoVersionFilesOverrider`.
64struct 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        // Skip the override when the repo is missing (for example, before
76        // edit mode clones it), so the deploy can fall back to chart
77        // defaults.
78        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
124/// Ported from `inlineOverrider`.
125struct 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
148/// Ported from `assertOverrideValue`.
149fn 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
167/// Ported from `assertNoNulls`.
168fn 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
190/// Ported from `inlineFileOverrider`.
191struct 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            // A null drops whatever an earlier override set, as it does for
207            // inline.
208            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
244/// Ported from `setValue`.
245pub(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    // Seeded with what is already in `dst`, so a later key sharing a prefix
253    // with an earlier one (e.g. "global.a" then "global.b") builds onto the
254    // same subtree rather than replacing it.
255    let container = Value::Object(std::mem::take(dst));
256
257    let updated = set_path(Some(container), &steps, value, key)?;
258
259    // A key always starts with a map segment, so the root stays a map.
260    let Value::Object(root) = updated else {
261        return Err(HelmError::InvalidHelmKey(key.to_string()));
262    };
263
264    *dst = root;
265
266    Ok(())
267}
268
269/// Ported from `setPath`.
270fn 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            // Helm pads a gap with nulls rather than rejecting a sparse
289            // index.
290            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
316/// Small helper so `set_path`'s list branch can hand the existing element
317/// (which may be `Value::Null`, meaning "nothing here yet") down without an
318/// extra allocation. `Value::Null` and "no container" are equivalent inputs
319/// to `set_path`.
320trait 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
330/// Ported from `mergeValues`.
331pub(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        // Recurse even into a fresh map, so nulls nested in src are applied
344        // rather than copied in wholesale.
345        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
356/// Ported from `pruneEmpty`.
357pub(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            // Not a map: put back exactly what was there.
365            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    // setValue/mergeValues/pruneEmpty/assertOverrideValue have no direct Go
382    // unit tests of their own (see the module doc comment); they are
383    // exercised end-to-end via `Manager::build_dynamic_overrides`'s ported
384    // `TestBuildDynamicOverrides_*` tests in `manager.rs`. A couple of
385    // sanity checks here guard the plumbing this file owns directly.
386
387    #[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    // --- Ported from internal/deployer/helm/overrider_integration_test.go ---
408    //
409    // Go build-tags this file `_integration_test.go`, but (per
410    // `PORT_NOTES.md`) none of its three tests actually touch a live
411    // `helm`/`kustomize` binary or a cluster: they only exercise
412    // `InlineOverrider::get_values` against in-memory `DynamicOverrideConfig`
413    // values and assert on the resulting `serde_json::Value` tree. Ported
414    // as plain unit tests rather than left `#[ignore]`d.
415
416    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    // Ported from internal/deployer/helm/overrider_integration_test.go:99
430    // (TestInlineOverrider_PreservesPunctuationInValues)
431    #[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    // Ported from internal/deployer/helm/overrider_integration_test.go:122
462    // (TestInlineOverrider_EscapedDotAddressesOneKey)
463    #[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        // The escaped dot keeps "cert-manager.io/cluster-issuer" as one map
480        // key nested under "annotations", rather than splitting it into a
481        // separate "cert-manager" -> "io/cluster-issuer" level.
482        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    // Ported from internal/deployer/helm/overrider_integration_test.go:148
493    // (TestInlineOverrider_RendersListsAndMapsAsYAML)
494    #[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        // A chart's `helm install -f values.yaml` reads whatever this
518        // override eventually gets serialized into, so what matters is
519        // that lists/maps round-trip through YAML, not just JSON.
520        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}