Skip to main content

xianyun_deploy/deployer/kustomize/
hostname.rs

1//! Injects the cluster hostname into the with-hostname builtin overlay's
2//! `configMapGenerator` literals.
3//!
4//! Ported from `internal/deployer/kustomize/hostname.go`.
5
6use std::path::Path;
7
8use serde_yaml::Value;
9use thiserror::Error;
10use xianyun_core::config::latest::CaproniConfig;
11
12/// The `configMapGenerator` the with-hostname dev-CA overlay sources its
13/// host/wildcardHost replacement literals from.
14const HOST_CONFIG_MAP_NAME: &str = "xianyun-dev-ca-host";
15
16/// Errors from [`write_overlay_hostname`].
17#[derive(Debug, Error)]
18pub enum HostnameError {
19    /// The hostname-bearing overlay does not carry the expected
20    /// `configMapGenerator` the injected literals target.
21    #[error("overlay hostname configMapGenerator not found: {0}")]
22    OverlayHostname(String),
23    /// The with-hostname overlay was built without a cluster hostname.
24    #[error("with-hostname overlay requires a non-empty cluster hostname")]
25    EmptyHostname,
26    /// Reading the overlay's `kustomization.yaml` failed.
27    #[error("reading overlay kustomization: {0}")]
28    Read(#[source] std::io::Error),
29    /// Parsing the overlay's `kustomization.yaml` failed.
30    #[error("parsing overlay kustomization: {0}")]
31    Parse(#[source] serde_yaml::Error),
32    /// Serializing the rewritten `kustomization.yaml` failed.
33    #[error("serializing overlay kustomization: {0}")]
34    Serialize(#[source] serde_yaml::Error),
35    /// Writing the rewritten `kustomization.yaml` failed.
36    #[error("writing overlay kustomization: {0}")]
37    Write(#[source] std::io::Error),
38}
39
40/// Returns the cluster's primary hostname, or `""` when no network
41/// configuration is present. A schema-validated config always carries
42/// `cluster.network.hostname`.
43#[must_use]
44pub fn primary_hostname(caproni_config: &CaproniConfig) -> String {
45    caproni_config
46        .cluster
47        .network
48        .as_ref()
49        .map(|network| network.hostname.clone())
50        .unwrap_or_default()
51}
52
53/// Rewrites the host/wildcardHost literals in the overlay's
54/// `kustomization.yaml` so the wildcard Certificate covers the cluster
55/// hostname rather than the embedded placeholder. An empty hostname
56/// returns [`HostnameError::EmptyHostname`] rather than silently shipping
57/// the placeholder cert.
58///
59/// # Errors
60///
61/// Returns [`HostnameError`] if `hostname` is empty, the overlay's
62/// `kustomization.yaml` cannot be read, parsed, rewritten, or written
63/// back, or the overlay does not carry the expected
64/// `configMapGenerator`.
65pub fn write_overlay_hostname(overlay_dir: &Path, hostname: &str) -> Result<(), HostnameError> {
66    if hostname.is_empty() {
67        return Err(HostnameError::EmptyHostname);
68    }
69
70    let kustomization_path = overlay_dir.join("kustomization.yaml");
71
72    let data = std::fs::read(&kustomization_path).map_err(HostnameError::Read)?;
73
74    let mut doc: Value = serde_yaml::from_slice(&data).map_err(HostnameError::Parse)?;
75
76    set_host_literals(&mut doc, hostname)?;
77
78    let out = serde_yaml::to_string(&doc).map_err(HostnameError::Serialize)?;
79
80    std::fs::write(&kustomization_path, out).map_err(HostnameError::Write)?;
81
82    Ok(())
83}
84
85/// Replaces the literals of the host `configMapGenerator` with the given
86/// hostname and its wildcard form.
87fn set_host_literals(doc: &mut Value, hostname: &str) -> Result<(), HostnameError> {
88    let generators = doc
89        .get_mut("configMapGenerator")
90        .and_then(Value::as_sequence_mut)
91        .ok_or_else(|| HostnameError::OverlayHostname("no configMapGenerator".to_string()))?;
92
93    for entry in generators.iter_mut() {
94        let is_target = entry.get("name").and_then(Value::as_str) == Some(HOST_CONFIG_MAP_NAME);
95        if !is_target {
96            continue;
97        }
98
99        let Some(map) = entry.as_mapping_mut() else {
100            continue;
101        };
102
103        map.insert(
104            Value::String("literals".to_string()),
105            Value::Sequence(vec![
106                Value::String(format!("host={hostname}")),
107                Value::String(format!("wildcardHost=*.{hostname}")),
108            ]),
109        );
110
111        return Ok(());
112    }
113
114    Err(HostnameError::OverlayHostname(format!(
115        "{HOST_CONFIG_MAP_NAME:?}"
116    )))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::deployer::kustomize::builtin::materialize_builtin_overlay;
123    use xianyun_core::config::types::{ClusterConfig, NetworkConfig};
124
125    // Ported from internal/deployer/kustomize/hostname_test.go:17 (TestPrimaryHostname)
126    #[test]
127    fn test_primary_hostname() {
128        struct Case {
129            name: &'static str,
130            cluster: ClusterConfig,
131            want: &'static str,
132        }
133
134        let cases = vec![
135            Case {
136                name: "network hostname",
137                cluster: ClusterConfig {
138                    network: Some(NetworkConfig {
139                        hostname: "gitlab.test".to_string(),
140                        additional_hostnames: Vec::new(),
141                        cluster_domain: String::new(),
142                    }),
143                    ..Default::default()
144                },
145                want: "gitlab.test",
146            },
147            Case {
148                name: "no network yields empty hostname",
149                cluster: ClusterConfig::default(),
150                want: "",
151            },
152        ];
153
154        for case in cases {
155            let cfg = CaproniConfig {
156                cluster: case.cluster,
157                ..Default::default()
158            };
159            assert_eq!(primary_hostname(&cfg), case.want, "{}", case.name);
160        }
161    }
162
163    fn read_host_literals(overlay_dir: &Path) -> Vec<String> {
164        let data = std::fs::read(overlay_dir.join("kustomization.yaml")).unwrap();
165        let doc: Value = serde_yaml::from_slice(&data).unwrap();
166
167        let generators = doc
168            .get("configMapGenerator")
169            .and_then(Value::as_sequence)
170            .expect("configMapGenerator must be present");
171
172        for entry in generators {
173            if entry.get("name").and_then(Value::as_str) == Some(HOST_CONFIG_MAP_NAME) {
174                let literals = entry
175                    .get("literals")
176                    .and_then(Value::as_sequence)
177                    .expect("literals must be a sequence");
178
179                return literals
180                    .iter()
181                    .map(|v| v.as_str().unwrap().to_string())
182                    .collect();
183            }
184        }
185
186        panic!("configMapGenerator {HOST_CONFIG_MAP_NAME:?} not found");
187    }
188
189    // Ported from internal/deployer/kustomize/hostname_test.go:46 (TestWriteOverlayHostname_InjectsLiterals)
190    #[test]
191    fn test_write_overlay_hostname_injects_literals() {
192        let (build_dir, cleanup) = materialize_builtin_overlay("builtin:dev-ca").unwrap();
193
194        write_overlay_hostname(&build_dir, "gitlab.example.test").unwrap();
195
196        let literals = read_host_literals(&build_dir);
197        assert_eq!(
198            literals,
199            vec![
200                "host=gitlab.example.test".to_string(),
201                "wildcardHost=*.gitlab.example.test".to_string(),
202            ]
203        );
204
205        cleanup();
206    }
207
208    // Ported from internal/deployer/kustomize/hostname_test.go:61 (TestWriteOverlayHostname_EmptyErrors)
209    #[test]
210    fn test_write_overlay_hostname_empty_errors() {
211        let (build_dir, cleanup) = materialize_builtin_overlay("builtin:dev-ca").unwrap();
212
213        let err = write_overlay_hostname(&build_dir, "").unwrap_err();
214        assert!(
215            matches!(err, HostnameError::EmptyHostname),
216            "empty hostname must fail loudly, not ship the placeholder: {err}"
217        );
218
219        cleanup();
220    }
221}