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}