Skip to main content

xianyun_deploy/deployer/kustomize/
camaterial.rs

1//! Supplies caproni's runtime-generated CA material to builtin overlays
2//! that need it at build time.
3//!
4//! Ported from `internal/deployer/kustomize/camaterial.go`.
5
6use std::path::Path;
7
8use thiserror::Error;
9
10/// The conventional filenames the builtin dev-CA overlay's
11/// secretGenerator reads. caproni writes its runtime CA material into
12/// these names inside the materialized overlay before `kustomize build`
13/// runs.
14const CA_CERT_FILE: &str = "tls.crt";
15const CA_KEY_FILE: &str = "tls.key";
16
17/// Errors from [`write_ca_material`] and [`CaProvider::load`]
18/// implementations.
19#[derive(Debug, Error)]
20pub enum CaMaterialError {
21    /// Writing the CA cert/key files into the overlay directory failed.
22    #[error("writing CA material into overlay: {0}")]
23    Write(#[source] std::io::Error),
24    /// Resolving the profile's PKI directory failed.
25    #[error("resolving PKI directory: {0}")]
26    ResolvePkiDir(#[source] std::io::Error),
27    /// Loading the runtime CA material for a profile failed.
28    #[error("loading CA material for profile {0:?}: {1}")]
29    LoadCa(String, #[source] xianyun_pki::ca::PkiError),
30    /// The `HOME` environment variable is unset, so the profile's PKI
31    /// directory cannot be resolved.
32    #[error("resolving home directory: HOME is not set")]
33    NoHomeDir,
34}
35
36/// Supplies caproni's runtime-generated CA material to deployers that
37/// need it at build time.
38///
39/// The dev-CA kustomize overlay declares a secretGenerator whose
40/// `tls.crt`/`tls.key` inputs are not shipped in the repository; they
41/// are caproni runtime artifacts. A `CaProvider` hands those PEM bytes
42/// to the deployer so they can be written into the kustomization
43/// directory before `kustomize build` runs.
44///
45/// The interface is deliberately application-agnostic: it knows
46/// nothing about the application being deployed, only about caproni's
47/// own CA. Tests inject a fake provider; production uses the
48/// pki-backed implementation.
49pub trait CaProvider: Send + Sync {
50    /// Returns the PEM-encoded CA certificate and private key for the
51    /// given cluster profile. Implementations must not generate a new
52    /// CA -- they read the material caproni already produced during
53    /// `caproni up`.
54    fn load(&self, profile_name: &str) -> Result<(Vec<u8>, Vec<u8>), CaMaterialError>;
55}
56
57/// Loads CA material from caproni's per-profile PKI directory, where the
58/// `up` flow's `pki::load_or_generate` persists it during cluster
59/// startup.
60pub struct PkiCaProvider;
61
62impl CaProvider for PkiCaProvider {
63    /// Reads the runtime CA cert/key from the profile's PKI directory.
64    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        // `load_or_generate` returns the existing CA when present. During
73        // `caproni up` the CA is generated before any deployer runs, so this
74        // is a pure load. An empty permitted-DNS slice only matters when
75        // generating; it is correct for the load path.
76        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
83/// Holds the PEM-encoded CA cert/key to write into a kustomization.
84pub struct CaMaterial {
85    pub cert_pem: Vec<u8>,
86    pub key_pem: Vec<u8>,
87}
88
89/// Writes caproni's CA cert/key into `dir` under the conventional
90/// `tls.crt`/`tls.key` filenames the builtin dev-CA overlay references.
91///
92/// When `material` is `None` -- Undeploy and Status lack the cluster
93/// profile and so cannot load the CA -- empty placeholder files are
94/// written instead. The secretGenerator inputs must exist for
95/// `kustomize build` to succeed, but their contents are irrelevant to a
96/// delete-by-name (Undeploy) or a build check (Status).
97///
98/// # Errors
99///
100/// Returns [`CaMaterialError::Write`] if writing either file fails.
101pub 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
110/// Writes the given name/content pairs into `dir`.
111fn 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    /// A fixed-material [`CaProvider`], standing in for the Go test's
135    /// `staticCAProvider`.
136    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    /// Finds a YAML document in a multi-document manifest stream by
148    /// `kind`/`metadata.name`, failing the test if none matches. Ported
149    /// from `findResource`.
150    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    // Ported from internal/deployer/kustomize/camaterial_integration_test.go
171    // (TestDeploy_DevCAOverlayProducesCASecret). Drives the builtin:dev-ca
172    // overlay through the real kustomize backend (now that it shells out
173    // to a real `kustomize` binary with generator/replacements support),
174    // asserting it produces the xianyun-ca Secret carrying the runtime CA
175    // material and a wildcard Certificate covering the injected hostname.
176    #[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        // kustomize/YAML emitters may line-wrap long base64 values as a
293        // block scalar; strip all whitespace before decoding.
294        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}