xianyun_backends/kustomize/
mod.rs1pub mod remote;
27
28use std::sync::Arc;
29
30use thiserror::Error;
31use xianyun_executor::exec::{CommandRunner, Options};
32
33pub trait FileSystem: Send + Sync {
47 fn write_file(&self, path: &str, content: &[u8]) -> std::io::Result<()>;
48 fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
49
50 fn walk(&self, dir: &str) -> std::io::Result<Vec<String>> {
56 let _ = dir;
57 Err(std::io::Error::other(
58 "FileSystem::walk is not implemented for this filesystem",
59 ))
60 }
61}
62
63#[derive(Debug, Clone, Copy, Default)]
71pub struct DiskFileSystem;
72
73impl FileSystem for DiskFileSystem {
74 fn write_file(&self, path: &str, content: &[u8]) -> std::io::Result<()> {
75 std::fs::write(path, content)
76 }
77
78 fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
79 std::fs::read(path)
80 }
81
82 fn walk(&self, dir: &str) -> std::io::Result<Vec<String>> {
83 let mut files = Vec::new();
84 let mut pending = vec![std::path::PathBuf::from(dir)];
85
86 while let Some(current) = pending.pop() {
87 for entry in std::fs::read_dir(¤t)? {
88 let entry = entry?;
89 let path = entry.path();
90 if entry.file_type()?.is_dir() {
91 pending.push(path);
92 } else if let Some(path) = path.to_str() {
93 files.push(path.to_string());
94 }
95 }
96 }
97
98 Ok(files)
99 }
100}
101
102#[derive(Debug, Error)]
105pub enum KustomizeError {
106 #[error("building kustomize directory {0:?}: {1}")]
107 BuildFailed(String, String),
108
109 #[error("serializing kustomize output for {0:?}: {1}")]
110 SerializeFailed(String, String),
111
112 #[error("building kustomize directory {0:?}: cancelled")]
113 Cancelled(String),
114}
115
116pub trait Backend {
118 fn build(&self, dir: &str) -> Result<Vec<u8>, KustomizeError>;
121}
122
123pub struct Client {
131 fsys: Arc<dyn FileSystem>,
132 executor: Arc<dyn CommandRunner + Send + Sync>,
133}
134
135impl Client {
136 pub fn new(fsys: Arc<dyn FileSystem>, executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
141 Self::with_fs(fsys, executor)
142 }
143
144 pub fn with_fs(
152 fsys: Arc<dyn FileSystem>,
153 executor: Arc<dyn CommandRunner + Send + Sync>,
154 ) -> Self {
155 Self { fsys, executor }
156 }
157
158 #[must_use]
162 pub fn with_fs_option(
163 fsys: Option<Arc<dyn FileSystem>>,
164 executor: Arc<dyn CommandRunner + Send + Sync>,
165 ) -> Self {
166 match fsys {
167 Some(fsys) => Self { fsys, executor },
168 None => panic!("filesystem cannot be nil"),
169 }
170 }
171
172 pub fn build_cancellable(
193 &self,
194 dir: &str,
195 cancelled: &std::sync::atomic::AtomicBool,
196 ) -> Result<Vec<u8>, KustomizeError> {
197 let worker = Self {
198 fsys: Arc::clone(&self.fsys),
199 executor: Arc::clone(&self.executor),
200 };
201 let dir_owned = dir.to_string();
202 let (tx, rx) = std::sync::mpsc::channel();
203
204 std::thread::spawn(move || {
205 let _ = tx.send(worker.build(&dir_owned));
206 });
207
208 loop {
209 match rx.recv_timeout(std::time::Duration::from_millis(5)) {
210 Ok(result) => return result,
211 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
212 return Err(KustomizeError::Cancelled(dir.to_string()));
213 }
214 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
215 if cancelled.load(std::sync::atomic::Ordering::SeqCst) {
216 return Err(KustomizeError::Cancelled(dir.to_string()));
217 }
218 }
219 }
220 }
221 }
222}
223
224struct StagingDir {
237 path: std::path::PathBuf,
238}
239
240impl StagingDir {
241 fn create() -> std::io::Result<Self> {
242 use std::sync::atomic::{AtomicU64, Ordering};
243
244 static COUNTER: AtomicU64 = AtomicU64::new(0);
245
246 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
247 let nanos = std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .map_or(0, |d| d.as_nanos());
250 let path = std::env::temp_dir().join(format!(
251 "xianyun-kustomize-{}-{unique}-{nanos}",
252 std::process::id(),
253 ));
254
255 std::fs::create_dir_all(&path)?;
256
257 Ok(Self { path })
258 }
259}
260
261impl Drop for StagingDir {
262 fn drop(&mut self) {
263 let _ = std::fs::remove_dir_all(&self.path);
264 }
265}
266
267fn stage_directory(
272 fsys: &dyn FileSystem,
273 dir: &str,
274 staging: &std::path::Path,
275) -> std::io::Result<()> {
276 let dir_with_slash = format!("{}/", dir.trim_end_matches('/'));
277
278 for file_path in fsys.walk(dir)? {
279 let relative = file_path
280 .strip_prefix(&dir_with_slash)
281 .unwrap_or(&file_path)
282 .trim_start_matches('/');
283
284 let dest = staging.join(relative);
285 if let Some(parent) = dest.parent() {
286 std::fs::create_dir_all(parent)?;
287 }
288
289 let content = fsys.read_file(&file_path)?;
290 std::fs::write(&dest, content)?;
291 }
292
293 Ok(())
294}
295
296impl Backend for Client {
297 fn build(&self, dir: &str) -> Result<Vec<u8>, KustomizeError> {
298 let redacted = remote::redact_credentials(dir);
303
304 if remote::is_remote_target(dir) {
310 return self.run_kustomize_build(dir, &redacted);
311 }
312
313 let staging = StagingDir::create().map_err(|err| {
314 KustomizeError::BuildFailed(redacted.clone(), format!("staging build directory: {err}"))
315 })?;
316
317 stage_directory(self.fsys.as_ref(), dir, &staging.path).map_err(|err| {
318 KustomizeError::BuildFailed(redacted.clone(), format!("reading kustomization: {err}"))
319 })?;
320
321 self.run_kustomize_build(&staging.path.to_string_lossy(), &redacted)
322 }
323}
324
325impl Client {
326 fn run_kustomize_build(&self, target: &str, redacted: &str) -> Result<Vec<u8>, KustomizeError> {
331 let result = self.executor.run(
332 "kustomize",
333 &["build".to_string(), target.to_string()],
334 Some(Options {
335 disable_streaming: true,
336 ..Default::default()
337 }),
338 );
339
340 if let Some(error) = result.error {
341 let stderr = result.stderr.join("\n");
342 let detail = if stderr.is_empty() {
343 error.to_string()
344 } else {
345 stderr
346 };
347
348 return Err(KustomizeError::BuildFailed(redacted.to_string(), detail));
349 }
350
351 let manifests = result.stdout.join("\n");
352
353 Ok(manifests.trim().as_bytes().to_vec())
354 }
355}
356
357#[cfg(test)]
358pub(crate) mod fixtures {
359 pub(crate) const NAME_PREFIX_KUSTOMIZATION: &str =
366 "\nnamePrefix: dev-\nresources:\n - configmap.yaml\n";
367
368 pub(crate) const SETTINGS_CONFIG_MAP: &str =
369 "\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: settings\ndata:\n key: value\n";
370
371 pub(crate) fn assert_dev_prefixed_config_map(manifests: &[u8], msg: &str) {
377 let text = String::from_utf8(manifests.to_vec()).expect("manifests must be UTF-8");
378 assert!(text.contains("kind: ConfigMap"), "{msg}");
379 assert!(text.contains("name: dev-settings"), "{msg}");
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::fixtures::{
386 NAME_PREFIX_KUSTOMIZATION, SETTINGS_CONFIG_MAP, assert_dev_prefixed_config_map,
387 };
388 use super::*;
389 use std::collections::HashMap;
390 use std::sync::Mutex;
391
392 #[test]
393 fn disk_file_system_writes_reads_and_walks_real_directories() {
394 let dir = tempfile::tempdir().unwrap();
395 let fsys = DiskFileSystem;
396
397 let file_path = dir.path().join("resources").join("configmap.yaml");
398 std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
399 fsys.write_file(file_path.to_str().unwrap(), SETTINGS_CONFIG_MAP.as_bytes())
400 .unwrap();
401
402 let read_back = fsys.read_file(file_path.to_str().unwrap()).unwrap();
403 assert_eq!(read_back, SETTINGS_CONFIG_MAP.as_bytes());
404
405 let walked = fsys.walk(dir.path().to_str().unwrap()).unwrap();
406 assert_eq!(walked, vec![file_path.to_str().unwrap().to_string()]);
407 }
408
409 #[derive(Default)]
412 struct InMemoryFs {
413 files: Mutex<HashMap<String, Vec<u8>>>,
414 }
415
416 impl InMemoryFs {
417 fn new() -> Self {
418 Self::default()
419 }
420 }
421
422 impl FileSystem for InMemoryFs {
423 fn write_file(&self, path: &str, content: &[u8]) -> std::io::Result<()> {
424 self.files
425 .lock()
426 .unwrap()
427 .insert(path.to_string(), content.to_vec());
428 Ok(())
429 }
430
431 fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
432 self.files
433 .lock()
434 .unwrap()
435 .get(path)
436 .cloned()
437 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
438 }
439
440 fn walk(&self, dir: &str) -> std::io::Result<Vec<String>> {
441 let prefix = format!("{}/", dir.trim_end_matches('/'));
442 Ok(self
443 .files
444 .lock()
445 .unwrap()
446 .keys()
447 .filter(|path| path.starts_with(&prefix))
448 .cloned()
449 .collect())
450 }
451 }
452
453 fn real_executor() -> Arc<dyn CommandRunner + Send + Sync> {
459 Arc::new(xianyun_executor::exec::Client::new())
460 }
461
462 fn client_with(fsys: Arc<dyn FileSystem>) -> Client {
463 Client::with_fs(fsys, real_executor())
464 }
465
466 #[test]
469 fn client_build() {
470 let fsys = Arc::new(InMemoryFs::new());
471 fsys.write_file(
472 "/app/kustomization.yaml",
473 NAME_PREFIX_KUSTOMIZATION.as_bytes(),
474 )
475 .unwrap();
476 fsys.write_file("/app/configmap.yaml", SETTINGS_CONFIG_MAP.as_bytes())
477 .unwrap();
478
479 let client = client_with(fsys);
480
481 let got = client.build("/app").unwrap();
482
483 assert_dev_prefixed_config_map(&got, "the namePrefix transformation must be applied");
484 }
485
486 #[test]
494 fn client_build_renders_secret_generator() {
495 let fsys = Arc::new(InMemoryFs::new());
496 fsys.write_file(
497 "/app/kustomization.yaml",
498 b"\
499generatorOptions:
500 disableNameSuffixHash: true
501secretGenerator:
502 - name: app-credentials
503 literals:
504 - username=admin
505 - password=s3cr3t
506",
507 )
508 .unwrap();
509
510 let client = client_with(fsys);
511
512 let got = client.build("/app").unwrap();
513 let text = String::from_utf8(got).unwrap();
514
515 assert!(text.contains("kind: Secret"), "got: {text}");
516 assert!(text.contains("name: app-credentials"), "got: {text}");
517 assert!(text.contains("username: YWRtaW4="), "got: {text}");
519 }
520
521 #[test]
522 fn client_build_renders_config_map_generator() {
523 let fsys = Arc::new(InMemoryFs::new());
524 fsys.write_file(
525 "/app/kustomization.yaml",
526 b"\
527generatorOptions:
528 disableNameSuffixHash: true
529configMapGenerator:
530 - name: app-settings
531 literals:
532 - log_level=debug
533",
534 )
535 .unwrap();
536
537 let client = client_with(fsys);
538
539 let got = client.build("/app").unwrap();
540 let text = String::from_utf8(got).unwrap();
541
542 assert!(text.contains("kind: ConfigMap"), "got: {text}");
543 assert!(text.contains("name: app-settings"), "got: {text}");
544 assert!(text.contains("log_level: debug"), "got: {text}");
545 }
546
547 #[test]
548 fn client_build_renders_replacements() {
549 let fsys = Arc::new(InMemoryFs::new());
550 fsys.write_file(
551 "/app/kustomization.yaml",
552 b"\
553resources:
554 - configmap.yaml
555 - deployment.yaml
556replacements:
557 - source:
558 kind: ConfigMap
559 name: settings
560 fieldPath: data.image-tag
561 targets:
562 - select:
563 kind: Deployment
564 name: app
565 fieldPaths:
566 - spec.template.spec.containers.0.image
567",
568 )
569 .unwrap();
570 fsys.write_file(
571 "/app/configmap.yaml",
572 b"\
573apiVersion: v1
574kind: ConfigMap
575metadata:
576 name: settings
577data:
578 image-tag: v1.2.3
579",
580 )
581 .unwrap();
582 fsys.write_file(
583 "/app/deployment.yaml",
584 b"\
585apiVersion: apps/v1
586kind: Deployment
587metadata:
588 name: app
589spec:
590 template:
591 spec:
592 containers:
593 - name: app
594 image: PLACEHOLDER
595",
596 )
597 .unwrap();
598
599 let client = client_with(fsys);
600
601 let got = client.build("/app").unwrap();
602 let text = String::from_utf8(got).unwrap();
603
604 assert!(
605 text.contains("image: v1.2.3"),
606 "the replacement must substitute the ConfigMap's image tag into the Deployment: {text}"
607 );
608 }
609
610 struct BuildErrorCase {
613 name: &'static str,
614 dir: &'static str,
615 files: &'static [(&'static str, &'static str)],
616 }
617
618 #[test]
619 fn client_build_errors() {
620 let cases = [
621 BuildErrorCase {
622 name: "missing kustomization",
623 dir: "/does/not/exist",
624 files: &[],
625 },
626 BuildErrorCase {
627 name: "kustomization references missing resource",
628 dir: "/app",
629 files: &[("/app/kustomization.yaml", "resources:\n - missing.yaml\n")],
630 },
631 ];
632
633 for case in cases {
634 let fsys = Arc::new(InMemoryFs::new());
635 for (name, content) in case.files {
636 fsys.write_file(name, content.as_bytes()).unwrap();
637 }
638
639 let client = client_with(fsys);
640
641 let err = client.build(case.dir).unwrap_err();
642 assert!(err.to_string().contains(case.dir), "{}", case.name);
643 }
644 }
645
646 #[test]
649 fn client_build_applies_legacy_resource_order() {
650 let fsys = Arc::new(InMemoryFs::new());
651 fsys.write_file(
652 "/app/kustomization.yaml",
653 b"\nresources:\n - configmap.yaml\n - namespace.yaml\n",
654 )
655 .unwrap();
656 fsys.write_file(
657 "/app/configmap.yaml",
658 b"\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: settings\n namespace: demo\n",
659 )
660 .unwrap();
661 fsys.write_file(
662 "/app/namespace.yaml",
663 b"\napiVersion: v1\nkind: Namespace\nmetadata:\n name: demo\n",
664 )
665 .unwrap();
666
667 let client = client_with(fsys);
668
669 let got = client.build("/app").unwrap();
670 let text = String::from_utf8(got).unwrap();
671 let first_doc = text.split("\n---\n").next().unwrap();
672
673 assert!(
674 first_doc.contains("kind: Namespace"),
675 "the Namespace must be emitted before the resources it contains"
676 );
677 }
678
679 struct BlockingFileSystem {
694 inner: Arc<dyn FileSystem>,
695 start_once: std::sync::Once,
696 started_tx: Mutex<Option<std::sync::mpsc::Sender<()>>>,
697 release_rx: Mutex<std::sync::mpsc::Receiver<()>>,
698 }
699
700 impl FileSystem for BlockingFileSystem {
701 fn write_file(&self, path: &str, content: &[u8]) -> std::io::Result<()> {
702 self.inner.write_file(path, content)
703 }
704
705 fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
706 self.start_once.call_once(|| {
707 if let Some(tx) = self.started_tx.lock().unwrap().take() {
708 let _ = tx.send(());
709 }
710 });
711
712 let _ = self.release_rx.lock().unwrap().recv();
715
716 self.inner.read_file(path)
717 }
718
719 fn walk(&self, dir: &str) -> std::io::Result<Vec<String>> {
724 self.inner.walk(dir)
725 }
726 }
727
728 #[test]
729 fn client_build_cancellation_unblocks_caller() {
730 let fsys = Arc::new(InMemoryFs::new());
731 fsys.write_file("/app/kustomization.yaml", b"resources: []\n")
732 .unwrap();
733
734 let (started_tx, started_rx) = std::sync::mpsc::channel();
735 let (release_tx, release_rx) = std::sync::mpsc::channel();
736
737 let blocking_fs = Arc::new(BlockingFileSystem {
738 inner: fsys,
739 start_once: std::sync::Once::new(),
740 started_tx: Mutex::new(Some(started_tx)),
741 release_rx: Mutex::new(release_rx),
742 });
743
744 let client = client_with(blocking_fs);
745 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
746
747 let (err_tx, err_rx) = std::sync::mpsc::channel();
748 let cancelled_for_thread = std::sync::Arc::clone(&cancelled);
749 std::thread::spawn(move || {
750 let result = client.build_cancellable("/app", &cancelled_for_thread);
751 let _ = err_tx.send(result);
752 });
753
754 started_rx
755 .recv_timeout(std::time::Duration::from_secs(5))
756 .expect("build never started reading the filesystem");
757
758 cancelled.store(true, std::sync::atomic::Ordering::SeqCst);
759
760 let result = err_rx
761 .recv_timeout(std::time::Duration::from_secs(5))
762 .expect("Build did not return after cancellation");
763
764 let err = result.expect_err("a cancelled build must return an error");
765 assert!(matches!(err, KustomizeError::Cancelled(ref dir) if dir == "/app"));
766 assert!(err.to_string().contains("/app"));
767
768 let _ = release_tx.send(());
771 }
772
773 #[test]
776 #[should_panic(expected = "filesystem cannot be nil")]
777 fn new_client_with_fs_nil_filesystem_panics() {
778 let _ = Client::with_fs_option(None, real_executor());
779 }
780}