xianyun_deploy/deployer/kustomize/
hostname.rs1use std::path::Path;
7
8use serde_yaml::Value;
9use thiserror::Error;
10use xianyun_core::config::latest::CaproniConfig;
11
12const HOST_CONFIG_MAP_NAME: &str = "xianyun-dev-ca-host";
15
16#[derive(Debug, Error)]
18pub enum HostnameError {
19 #[error("overlay hostname configMapGenerator not found: {0}")]
22 OverlayHostname(String),
23 #[error("with-hostname overlay requires a non-empty cluster hostname")]
25 EmptyHostname,
26 #[error("reading overlay kustomization: {0}")]
28 Read(#[source] std::io::Error),
29 #[error("parsing overlay kustomization: {0}")]
31 Parse(#[source] serde_yaml::Error),
32 #[error("serializing overlay kustomization: {0}")]
34 Serialize(#[source] serde_yaml::Error),
35 #[error("writing overlay kustomization: {0}")]
37 Write(#[source] std::io::Error),
38}
39
40#[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
53pub 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
85fn 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 #[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 #[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 #[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}