Skip to main content

xianyun_deploy/deployer/kustomize/
builtin.rs

1//! Registry of kustomizations embedded in caproni itself.
2//!
3//! Ported from `internal/deployer/kustomize/builtin.go`. The Go original
4//! walks an embedded `io/fs.FS` per overlay (`//go:embed *.yaml`); this
5//! port instead reuses the flat `(name, content)` file lists already
6//! ported to `xianyun_infra::{dev_ca, traefik_tlsstore}::FILES`, since
7//! both builtin overlays here are flat (no nested directories).
8
9use std::path::{Path, PathBuf};
10
11use thiserror::Error;
12
13use super::Cleanup;
14
15/// Prefixes a kustomize path that references an overlay embedded in
16/// caproni itself rather than a directory on disk. For example
17/// `"builtin:dev-ca"` resolves to the embedded dev-CA supporting-manifests
18/// overlay.
19const BUILTIN_SCHEME: &str = "builtin:";
20
21/// Errors from builtin overlay resolution and materialization.
22#[derive(Debug, Error)]
23pub enum BuiltinError {
24    /// A kustomize path used the `builtin:` scheme with a name that is
25    /// not registered.
26    #[error("unknown builtin kustomize overlay: {0}")]
27    UnknownOverlay(String),
28    /// Creating the temporary directory to materialize an overlay into
29    /// failed.
30    #[error("creating temp overlay directory: {0}")]
31    CreateTempDir(#[source] std::io::Error),
32    /// Writing an overlay's embedded files into its materialized
33    /// directory failed.
34    #[error("materializing builtin overlay {0:?}: {1}")]
35    Materialize(String, #[source] std::io::Error),
36}
37
38/// Describes a kustomization embedded in caproni itself.
39///
40/// What caproni writes into the materialized directory before building
41/// is declared per overlay rather than assumed: an overlay that wants
42/// the runtime CA material or the cluster hostname must say so, and the
43/// injection then fails loudly if the overlay does not carry the
44/// generator it needs. Injecting unconditionally and skipping when a
45/// generator is absent would turn a broken dev-CA kustomization into a
46/// silent fallback to its placeholder hostname literal.
47struct BuiltinOverlay {
48    /// The overlay's embedded files, as `(name, content)` pairs.
49    files: &'static [(&'static str, &'static str)],
50    /// Marks an overlay whose secretGenerator references the runtime CA
51    /// cert/key files, which are written into the materialized
52    /// directory.
53    needs_ca: bool,
54    /// Marks an overlay whose configMapGenerator carries the cluster
55    /// hostname literals, which are rewritten in the materialized
56    /// kustomization.
57    needs_hostname: bool,
58}
59
60/// Looks up a builtin overlay by its bare name (without the `builtin:`
61/// prefix). Adding a new builtin overlay only requires adding a case
62/// here.
63fn lookup(name: &str) -> Option<BuiltinOverlay> {
64    match name {
65        // The dev-CA supporting manifests: CA Secret, ClusterIssuer, trust
66        // Bundle, and a wildcard Certificate for the cluster hostname.
67        "dev-ca" => Some(BuiltinOverlay {
68            files: xianyun_infra::dev_ca::FILES,
69            needs_ca: true,
70            needs_hostname: true,
71        }),
72        // The TLSStore making that wildcard certificate Traefik's default. It
73        // names the certificate Secret and nothing else, so it takes no
74        // injection.
75        "traefik-tlsstore" => Some(BuiltinOverlay {
76            files: xianyun_infra::traefik_tlsstore::FILES,
77            needs_ca: false,
78            needs_hostname: false,
79        }),
80        _ => None,
81    }
82}
83
84/// Reports whether `path` uses the `builtin:` scheme.
85#[must_use]
86pub fn is_builtin_path(path: &str) -> bool {
87    path.starts_with(BUILTIN_SCHEME)
88}
89
90/// Extracts the overlay name from a `builtin:` path.
91#[must_use]
92pub fn builtin_name(path: &str) -> &str {
93    path.strip_prefix(BUILTIN_SCHEME).unwrap_or(path)
94}
95
96/// Reports whether `path` names a registered builtin overlay that
97/// consumes caproni's runtime CA material.
98#[must_use]
99pub fn overlay_needs_ca(path: &str) -> bool {
100    registered_builtin(path).is_some_and(|overlay| overlay.needs_ca)
101}
102
103/// Reports whether `path` names a registered builtin overlay that
104/// carries the cluster hostname literals.
105#[must_use]
106pub fn overlay_needs_hostname(path: &str) -> bool {
107    registered_builtin(path).is_some_and(|overlay| overlay.needs_hostname)
108}
109
110/// Returns the overlay a `builtin:` path names, if it is registered.
111fn registered_builtin(path: &str) -> Option<BuiltinOverlay> {
112    if !is_builtin_path(path) {
113        return None;
114    }
115
116    lookup(builtin_name(path))
117}
118
119/// Returns `Ok(())` when `path` names a registered builtin overlay.
120///
121/// # Errors
122///
123/// Returns [`BuiltinError::UnknownOverlay`] if `path` names an
124/// unregistered overlay.
125pub fn validate_builtin_path(path: &str) -> Result<(), BuiltinError> {
126    if lookup(builtin_name(path)).is_none() {
127        return Err(BuiltinError::UnknownOverlay(builtin_name(path).to_string()));
128    }
129
130    Ok(())
131}
132
133/// Copies the embedded overlay named by a `builtin:` path into a fresh
134/// temporary directory and returns it -- kustomize builds it directly,
135/// and caproni writes the CA material and hostname into the same
136/// directory. The caller owns the directory and must remove it via the
137/// returned cleanup function, which is always safe to call.
138///
139/// # Errors
140///
141/// Returns [`BuiltinError::UnknownOverlay`] if `path` names an
142/// unregistered overlay, or [`BuiltinError::CreateTempDir`] /
143/// [`BuiltinError::Materialize`] if materializing the overlay's files
144/// fails.
145pub fn materialize_builtin_overlay(path: &str) -> Result<(PathBuf, Cleanup), BuiltinError> {
146    let name = builtin_name(path);
147    let overlay = lookup(name).ok_or_else(|| BuiltinError::UnknownOverlay(name.to_string()))?;
148
149    let tmp_dir =
150        super::mkdtemp("caproni-builtin-kustomize-").map_err(BuiltinError::CreateTempDir)?;
151
152    if let Err(e) = write_overlay_files(&tmp_dir, overlay.files) {
153        let _ = std::fs::remove_dir_all(&tmp_dir);
154        return Err(BuiltinError::Materialize(name.to_string(), e));
155    }
156
157    let cleanup_dir = tmp_dir.clone();
158    let cleanup: Cleanup = Box::new(move || {
159        let _ = std::fs::remove_dir_all(&cleanup_dir);
160    });
161
162    Ok((tmp_dir, cleanup))
163}
164
165/// Writes an overlay's embedded `(name, content)` files into `dir`,
166/// which must already exist.
167fn write_overlay_files(dir: &Path, files: &[(&str, &str)]) -> std::io::Result<()> {
168    for (name, content) in files {
169        std::fs::write(dir.join(name), content)?;
170    }
171
172    Ok(())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::deployer::kustomize::manager::{DEPLOYER_TYPE, Manager};
179    use crate::deployer::kustomize::test_support::{MockKubectlBackend, MockKustomizeBackend};
180    use xianyun_core::config::latest::{CaproniConfig, DeployerConfig, KustomizeConfig};
181
182    // Ported from internal/deployer/kustomize/builtin_test.go:15 (TestValidate_BuiltinDevCAPath)
183    #[test]
184    fn test_validate_builtin_dev_ca_path() {
185        let mgr = Manager::new(
186            Box::new(MockKustomizeBackend::new()),
187            Box::new(MockKubectlBackend::new()),
188        );
189
190        let deployer_cfg = DeployerConfig {
191            deployer_type: DEPLOYER_TYPE.to_string(),
192            kustomize: Some(KustomizeConfig {
193                path: "builtin:dev-ca".to_string(),
194                namespace: "cert-manager".to_string(),
195                ..Default::default()
196            }),
197            ..Default::default()
198        };
199
200        // builtin: paths are validated by name, not by stat-ing disk, so an
201        // empty ConfigDir (no on-disk path) must still validate.
202        mgr.validate(&deployer_cfg, &CaproniConfig::default())
203            .unwrap();
204    }
205
206    // Ported from internal/deployer/kustomize/builtin_test.go:34 (TestValidate_UnknownBuiltinPath)
207    #[test]
208    fn test_validate_unknown_builtin_path() {
209        let mgr = Manager::new(
210            Box::new(MockKustomizeBackend::new()),
211            Box::new(MockKubectlBackend::new()),
212        );
213
214        let deployer_cfg = DeployerConfig {
215            deployer_type: DEPLOYER_TYPE.to_string(),
216            kustomize: Some(KustomizeConfig {
217                path: "builtin:does-not-exist".to_string(),
218                ..Default::default()
219            }),
220            ..Default::default()
221        };
222
223        let err = mgr
224            .validate(&deployer_cfg, &CaproniConfig::default())
225            .unwrap_err();
226        assert!(matches!(
227            err,
228            crate::deployer::kustomize::manager::KustomizeError::Builtin(
229                BuiltinError::UnknownOverlay(_)
230            )
231        ));
232    }
233
234    // Ported from internal/deployer/kustomize/builtin_test.go:50 (TestMaterializeBuiltinOverlay_DevCA)
235    #[test]
236    fn test_materialize_builtin_overlay_dev_ca() {
237        let (dir, cleanup) = materialize_builtin_overlay("builtin:dev-ca").unwrap();
238
239        // The flat overlay ships every manifest in one directory: the base CA
240        // resources plus the wildcard Certificate. The Traefik TLSStore is a
241        // separate overlay, since it needs the Traefik CRDs rather than
242        // cert-manager's.
243        for name in [
244            "kustomization.yaml",
245            "namespace.yaml",
246            "clusterissuer.yaml",
247            "bundle.yaml",
248            "certificate.yaml",
249        ] {
250            assert!(
251                dir.join(name).is_file(),
252                "expected materialized file {name:?}"
253            );
254        }
255
256        cleanup();
257    }
258
259    // Ported from internal/deployer/kustomize/builtin_test.go:70 (TestMaterializeBuiltinOverlay_Unknown)
260    #[test]
261    fn test_materialize_builtin_overlay_unknown() {
262        match materialize_builtin_overlay("builtin:nope") {
263            Ok(_) => panic!("expected an UnknownOverlay error"),
264            Err(err) => assert!(matches!(err, BuiltinError::UnknownOverlay(_))),
265        }
266    }
267
268    // Ported from internal/deployer/kustomize/builtin_test.go:83 (TestBuiltinOverlayCapabilities)
269    //
270    // Pins what caproni injects into each builtin overlay. The dev-CA overlay
271    // carries a CA secretGenerator and the hostname literals; the TLSStore
272    // overlay carries neither, and injecting into it would fail on the
273    // missing generators.
274    #[test]
275    fn test_builtin_overlay_capabilities() {
276        struct Case {
277            name: &'static str,
278            path: &'static str,
279            want_ca: bool,
280            want_hostname: bool,
281        }
282
283        let cases = [
284            Case {
285                name: "dev-ca takes CA material and hostname",
286                path: "builtin:dev-ca",
287                want_ca: true,
288                want_hostname: true,
289            },
290            Case {
291                name: "traefik-tlsstore takes neither",
292                path: "builtin:traefik-tlsstore",
293                want_ca: false,
294                want_hostname: false,
295            },
296            Case {
297                name: "unknown builtin",
298                path: "builtin:nope",
299                want_ca: false,
300                want_hostname: false,
301            },
302            Case {
303                name: "ordinary path",
304                path: "./manifests",
305                want_ca: false,
306                want_hostname: false,
307            },
308        ];
309
310        for case in cases {
311            assert_eq!(
312                overlay_needs_ca(case.path),
313                case.want_ca,
314                "{}: needs CA material",
315                case.name
316            );
317            assert_eq!(
318                overlay_needs_hostname(case.path),
319                case.want_hostname,
320                "{}: needs hostname",
321                case.name
322            );
323        }
324    }
325}