xianyun_deploy/deployer/kustomize/
camaterial.rs1use std::path::Path;
7
8use thiserror::Error;
9
10const CA_CERT_FILE: &str = "tls.crt";
15const CA_KEY_FILE: &str = "tls.key";
16
17#[derive(Debug, Error)]
20pub enum CaMaterialError {
21 #[error("writing CA material into overlay: {0}")]
23 Write(#[source] std::io::Error),
24 #[error("resolving PKI directory: {0}")]
26 ResolvePkiDir(#[source] std::io::Error),
27 #[error("loading CA material for profile {0:?}: {1}")]
29 LoadCa(String, #[source] xianyun_pki::ca::PkiError),
30 #[error("resolving home directory: HOME is not set")]
33 NoHomeDir,
34}
35
36pub trait CaProvider: Send + Sync {
50 fn load(&self, profile_name: &str) -> Result<(Vec<u8>, Vec<u8>), CaMaterialError>;
55}
56
57pub struct PkiCaProvider;
61
62impl CaProvider for PkiCaProvider {
63 fn load(&self, profile_name: &str) -> Result<(Vec<u8>, Vec<u8>), CaMaterialError> {
65 let home = std::env::var_os("HOME")
66 .map(std::path::PathBuf::from)
67 .ok_or(CaMaterialError::NoHomeDir)?;
68
69 let pki_dir = xianyun_executor::datadir::pki_dir_in(&home, profile_name)
70 .map_err(CaMaterialError::ResolvePkiDir)?;
71
72 let ca = xianyun_pki::ca::load_or_generate(&pki_dir, &[])
77 .map_err(|e| CaMaterialError::LoadCa(profile_name.to_string(), e))?;
78
79 Ok((ca.cert_pem, ca.key_pem))
80 }
81}
82
83pub struct CaMaterial {
85 pub cert_pem: Vec<u8>,
86 pub key_pem: Vec<u8>,
87}
88
89pub fn write_ca_material(dir: &Path, material: Option<&CaMaterial>) -> Result<(), CaMaterialError> {
102 let (cert, key): (&[u8], &[u8]) = match material {
103 Some(m) => (&m.cert_pem, &m.key_pem),
104 None => (&[], &[]),
105 };
106
107 write_files(dir, &[(CA_CERT_FILE, cert), (CA_KEY_FILE, key)])
108}
109
110fn write_files(dir: &Path, files: &[(&str, &[u8])]) -> Result<(), CaMaterialError> {
112 for (name, content) in files {
113 std::fs::write(dir.join(name), content).map_err(CaMaterialError::Write)?;
114 }
115
116 Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121 use std::sync::{Arc, Mutex};
122
123 use xianyun_backends::kustomize::{Client as KustomizeClient, DiskFileSystem};
124 use xianyun_core::config::container::new_container;
125 use xianyun_core::config::global::GlobalConfig;
126 use xianyun_core::config::latest::{CaproniConfig, DeployerConfig, KustomizeConfig};
127 use xianyun_core::config::types::NetworkConfig;
128 use xianyun_executor::exec::Client as ExecClient;
129
130 use super::*;
131 use crate::deployer::kustomize::manager::{DEPLOYER_TYPE, Manager};
132 use crate::deployer::kustomize::test_support::MockKubectlBackend;
133
134 struct StaticCaProvider {
137 cert_pem: Vec<u8>,
138 key_pem: Vec<u8>,
139 }
140
141 impl CaProvider for StaticCaProvider {
142 fn load(&self, _profile_name: &str) -> Result<(Vec<u8>, Vec<u8>), CaMaterialError> {
143 Ok((self.cert_pem.clone(), self.key_pem.clone()))
144 }
145 }
146
147 fn find_resource(manifests: &[u8], kind: &str, name: &str) -> serde_yaml::Mapping {
151 use serde::Deserialize as _;
152 for doc in serde_yaml::Deserializer::from_slice(manifests) {
153 let value = serde_yaml::Value::deserialize(doc).expect("valid YAML document");
154 let Some(mapping) = value.as_mapping() else {
155 continue;
156 };
157 let doc_kind = mapping.get("kind").and_then(serde_yaml::Value::as_str);
158 let doc_name = mapping
159 .get("metadata")
160 .and_then(serde_yaml::Value::as_mapping)
161 .and_then(|m| m.get("name"))
162 .and_then(serde_yaml::Value::as_str);
163 if doc_kind == Some(kind) && doc_name == Some(name) {
164 return mapping.clone();
165 }
166 }
167 panic!("no {kind} named {name:?} found in manifests");
168 }
169
170 #[test]
177 fn test_deploy_dev_ca_overlay_produces_ca_secret() {
178 let pki_dir = tempfile::tempdir().unwrap();
179 let ca = xianyun_pki::ca::ensure_current(pki_dir.path(), &["gitlab.test".to_string()])
180 .unwrap()
181 .0;
182 let cert_pem = ca.cert_pem.clone();
183 let key_pem = ca.key_pem.clone();
184
185 let applied: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
186 let applied_for_closure = Arc::clone(&applied);
187 let kubectl = MockKubectlBackend::new().expect_apply(move |manifests, namespace| {
188 assert_eq!(namespace, "cert-manager");
189 *applied_for_closure.lock().unwrap() = Some(manifests.to_vec());
190 Ok(())
191 });
192
193 let kustomize_backend =
194 KustomizeClient::new(Arc::new(DiskFileSystem), Arc::new(ExecClient));
195
196 let mgr = Manager::with_ca_provider(
197 Box::new(kustomize_backend),
198 Box::new(kubectl),
199 Box::new(StaticCaProvider {
200 cert_pem: cert_pem.clone(),
201 key_pem: key_pem.clone(),
202 }),
203 );
204
205 let dep = DeployerConfig {
206 deployer_type: DEPLOYER_TYPE.to_string(),
207 kustomize: Some(KustomizeConfig {
208 path: "builtin:dev-ca".to_string(),
209 namespace: "cert-manager".to_string(),
210 ..Default::default()
211 }),
212 ..Default::default()
213 };
214
215 let caproni_cfg = CaproniConfig {
216 cluster: xianyun_core::config::types::ClusterConfig {
217 network: Some(NetworkConfig {
218 hostname: "gitlab.test".to_string(),
219 additional_hostnames: Vec::new(),
220 cluster_domain: String::new(),
221 }),
222 ..Default::default()
223 },
224 ..Default::default()
225 };
226 let container = new_container(GlobalConfig::default(), caproni_cfg);
227
228 mgr.deploy(&dep, &container).expect("deploy must succeed");
229
230 let manifests = applied
231 .lock()
232 .unwrap()
233 .take()
234 .expect("apply must be called");
235
236 let secret = find_resource(&manifests, "Secret", "xianyun-ca");
237 assert_eq!(
238 secret.get("type").and_then(serde_yaml::Value::as_str),
239 Some("kubernetes.io/tls"),
240 "Secret name must be preserved with no suffix hash"
241 );
242 let data = secret
243 .get("data")
244 .and_then(serde_yaml::Value::as_mapping)
245 .expect("Secret must carry data");
246 let decoded_cert = base64_decode(
247 data.get("tls.crt")
248 .and_then(serde_yaml::Value::as_str)
249 .expect("tls.crt present"),
250 );
251 assert_eq!(
252 decoded_cert, cert_pem,
253 "Secret tls.crt must hold the runtime CA cert"
254 );
255 let decoded_key = base64_decode(
256 data.get("tls.key")
257 .and_then(serde_yaml::Value::as_str)
258 .expect("tls.key present"),
259 );
260 assert_eq!(
261 decoded_key, key_pem,
262 "Secret tls.key must hold the runtime CA key"
263 );
264
265 let wildcard = find_resource(&manifests, "Certificate", "xianyun-wildcard");
266 let spec = wildcard
267 .get("spec")
268 .and_then(serde_yaml::Value::as_mapping)
269 .expect("wildcard Certificate spec must be a map");
270 let dns_names: Vec<String> = spec
271 .get("dnsNames")
272 .and_then(serde_yaml::Value::as_sequence)
273 .expect("dnsNames present")
274 .iter()
275 .map(|v| v.as_str().unwrap().to_string())
276 .collect();
277 assert_eq!(
278 dns_names,
279 vec!["gitlab.test".to_string(), "*.gitlab.test".to_string()],
280 "wildcard Certificate must cover the injected cluster hostname"
281 );
282
283 let manifests_text = String::from_utf8(manifests).unwrap();
284 assert!(
285 !manifests_text.contains("kind: TLSStore"),
286 "the dev-CA overlay must not emit the Traefik TLSStore"
287 );
288 }
289
290 fn base64_decode(s: &str) -> Vec<u8> {
291 use base64::Engine as _;
292 let compact: String = s.chars().filter(|c| !c.is_whitespace()).collect();
295 base64::engine::general_purpose::STANDARD
296 .decode(&compact)
297 .expect("valid base64")
298 }
299}