Skip to main content

xianyun_backends/kustomize/
mod.rs

1//! Kustomize build operations.
2//!
3//! Ported from `internal/backends/kustomize/{interface,client}.go`. Go
4//! builds *in process* against the `kustomize` Go library (`krusty`),
5//! never shelling out to a `kustomize` binary -- there is no Rust
6//! equivalent of `krusty` to link against. This port instead shells out
7//! to the real `kustomize build` binary via [`xianyun_executor::exec::
8//! CommandRunner`] (the same mechanism every other backend in this
9//! crate uses), which gives generator (`secretGenerator`/
10//! `configMapGenerator`) and `replacements` support -- and kustomize's
11//! own remote-target resolution -- for free, rather than hand-rolling a
12//! partial reimplementation of kustomize's YAML processing semantics.
13//! See `PORT_NOTES.md` for the full design rationale.
14//!
15//! [`FileSystem`] (this crate's own invention, not a port of `sigs.k8s.
16//! io/kustomize/kyaml/filesys.FileSystem`) still exists so unit tests can
17//! stay hermetic: `Client::build` walks and reads the kustomize
18//! directory through this trait, stages a copy on a real temporary
19//! directory (the real `kustomize` binary can only read from disk), and
20//! then invokes the real binary against that staging copy. A *remote*
21//! target (`file://...//overlay?ref=...`, `https://...`, ...) bypasses
22//! `FileSystem` and staging entirely: kustomize resolves those itself
23//! (shallow git clone included), so the target string is passed straight
24//! through to the binary.
25
26pub mod remote;
27
28use std::sync::Arc;
29
30use thiserror::Error;
31use xianyun_executor::exec::{CommandRunner, Options};
32
33/// Ported from the subset of `sigs.k8s.io/kustomize/kyaml/filesys.
34/// FileSystem` that `Client::build` needs: `WriteFile`/`ReadFile` (for
35/// test fixtures to populate a hermetic directory tree) plus [`walk`],
36/// which this port adds so `Client::build` can enumerate every file
37/// under a directory to stage it onto real disk before invoking the
38/// real `kustomize` binary. [`walk`] defaults to "unsupported" so
39/// existing `FileSystem` implementors elsewhere in the workspace (which
40/// predate this method) keep compiling without needing to implement it
41/// themselves; an implementor that wants its fixtures to exercise real
42/// generator/replacement builds through `Client::build` must override
43/// it.
44///
45/// [`walk`]: FileSystem::walk
46pub 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    /// Returns every file path under `dir`, recursively. The default
51    /// implementation reports "unsupported": a `FileSystem` without a
52    /// real `walk` cannot be staged onto disk, so any `Client::build`
53    /// call against it fails with [`KustomizeError::BuildFailed`]
54    /// rather than silently building an empty directory.
55    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/// The real on-disk [`FileSystem`]: Go's zero-argument `NewClient`
64/// defaults to `filesys.MakeFsOnDisk()`, and production callers of this
65/// port's [`Client::new`] need the equivalent -- every `FileSystem`
66/// implementor elsewhere in the workspace is test-only (an in-memory
67/// map), since nothing needed a real-disk one until a production caller
68/// (the `deployer::kustomize` manager) had an on-disk kustomization
69/// directory, built by `builtin::materialize_builtin_overlay`, to build.
70#[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(&current)? {
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/// Ported from the error `Client::Build` wraps: `kustomizer.Run` /
103/// `resources.AsYaml` failures, and context cancellation.
104#[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
116/// Ported from the `Backend` interface in `interface.go`.
117pub trait Backend {
118    /// Renders a kustomize directory and returns the resulting YAML
119    /// bytes.
120    fn build(&self, dir: &str) -> Result<Vec<u8>, KustomizeError>;
121}
122
123/// Ported from `Client` in `client.go`.
124///
125/// Holds both a [`FileSystem`] (for staging a hermetic test directory
126/// onto disk) and an [`xianyun_executor::exec::CommandRunner`] (to
127/// invoke the real `kustomize` binary), matching the dependency-
128/// injection convention every other backend in this crate already
129/// follows (`docker::Client`, `git::Client`, `kubectl::Client`, ...).
130pub struct Client {
131    fsys: Arc<dyn FileSystem>,
132    executor: Arc<dyn CommandRunner + Send + Sync>,
133}
134
135impl Client {
136    /// Ported from `NewClient`. Go's zero-argument constructor defaults
137    /// to the real on-disk filesystem; this port takes the filesystem
138    /// and executor explicitly, matching `docker`/`git`/`kubectl::
139    /// Client`'s constructor shape.
140    pub fn new(fsys: Arc<dyn FileSystem>, executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
141        Self::with_fs(fsys, executor)
142    }
143
144    /// Ported from `NewClientWithFS`. Panics if `fsys` construction
145    /// yields a null pointer -- Go's `panic("filesystem cannot be nil")`
146    /// has no direct Rust analogue since `Arc<dyn FileSystem>` cannot be
147    /// null; callers wanting to exercise the "nil filesystem" case pass
148    /// an `Option<Arc<dyn FileSystem>>` to [`Client::with_fs_option`]
149    /// instead. See the ported `new_client_with_fs_nil_filesystem_panics`
150    /// test.
151    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    /// Ported from `NewClientWithFS`'s nil-check, for the one ported
159    /// test that exercises it. Production code should call
160    /// [`Client::with_fs`] directly with a non-optional `Arc`.
161    #[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    /// A cancellation-aware variant of [`Backend::build`]. Go's
173    /// `Client.Build` takes a `context.Context` and can abandon a build
174    /// genuinely in flight (a blocked `FileSystem.ReadFile` call) as
175    /// soon as the context is cancelled, without waiting for that call
176    /// to return -- `TestClient_Build_cancellationUnblocksCaller` proves
177    /// exactly this. `Backend::build`'s plain synchronous signature has
178    /// no way to express that.
179    ///
180    /// Rather than make `Backend::build` `async` (a cross-crate
181    /// breaking change `PORT_NOTES.md` already deferred) or add a new
182    /// dependency for a cancellation-token type, this runs the real
183    /// build on a background thread and polls a channel for its result
184    /// while checking `cancelled` -- a plain `AtomicBool` is enough of
185    /// a "cancellation token" here, and both are already in `std`. If
186    /// `cancelled` flips to `true` before the background thread
187    /// finishes, this returns [`KustomizeError::Cancelled`] immediately
188    /// and *does not* wait for (or kill) the background thread, mirroring
189    /// Go's abandoned-goroutine behaviour: the blocked `FileSystem` call
190    /// keeps running until it unblocks on its own, but nothing is left
191    /// waiting on it.
192    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
224/// A real, uniquely-named directory under the OS temp directory,
225/// removed when dropped. Used to stage a [`FileSystem`]'s contents onto
226/// disk so the real `kustomize` binary (which only reads from disk) can
227/// build them.
228///
229/// Not built on the `tempfile` crate: `tempfile` is already a
230/// `[dev-dependencies]` entry for this crate's tests (see `git::state`'s
231/// integration tests), but promoting it to a runtime dependency here
232/// would change this crate's resolved dependency graph in the root
233/// `Cargo.lock` -- out of scope for this change. A process-id-plus-
234/// counter-plus-timestamp suffix is enough entropy for a same-host temp
235/// directory name.
236struct 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
267/// Stages every file [`FileSystem::walk`] finds under `dir` onto
268/// `staging`, preserving each file's path relative to `dir`. The result
269/// is a real on-disk mirror of whatever `dir` names in `fsys` -- the
270/// real `kustomize` binary can then build `staging` directly.
271fn 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        // Error messages carry the directory for context; a remote
299        // target may embed git credentials, so they are stripped before
300        // the path reaches any error -- ported from `client.go`'s
301        // `redacted := RedactCredentials(dir)`.
302        let redacted = remote::redact_credentials(dir);
303
304        // A remote target (`file://...//overlay?ref=...`, `https://...`,
305        // ...) is resolved by kustomize itself (shallow git clone
306        // included): pass it straight through rather than routing it
307        // through `FileSystem`/staging, which only understands local
308        // paths.
309        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    /// Invokes the real `kustomize build <target>` binary and returns
327    /// its trimmed stdout, or a [`KustomizeError::BuildFailed`] carrying
328    /// kustomize's own stderr diagnostics (not just an exit code) when
329    /// the build fails.
330    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    //! Ported from `fixtures_test.go`. `namePrefixKustomization` and
360    //! `settings_config_map` form a minimal kustomization whose render
361    //! proves a transformation ran: the emitted ConfigMap carries the
362    //! `dev-` prefix only if kustomize actually built it. Shared by
363    //! `client_test.go` and `remote_integration_test.go`'s Rust ports.
364
365    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    /// Ported from `assertDevPrefixedConfigMap`. Go's version parses the
372    /// rendered manifest as YAML and asserts on the decoded `kind`/
373    /// `metadata.name` fields; no YAML crate is a dependency of this
374    /// crate, so this checks the same facts as substrings of the
375    /// rendered text instead.
376    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    /// A minimal in-memory [`FileSystem`], standing in for
410    /// `filesys.MakeFsInMemory()`.
411    #[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    /// A real (not mocked) [`CommandRunner`], since these tests exercise
454    /// generator/replacement support that can only be validated against
455    /// the actual `kustomize` binary's own YAML processing -- a
456    /// `MockCommandRunner` would need to assert on a randomly-generated
457    /// staging directory path it has no way to predict.
458    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    // --- Ported from client_test.go: TestClient_Build ---
467
468    #[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    // --- New: secretGenerator/configMapGenerator/replacements support.
487    // These were unreachable under the hand-rolled line-oriented
488    // `Kustomization`/`Manifest` readers this `Client::build` used to
489    // have (see git history) -- they only understood `namePrefix:` and
490    // a flat `resources:` list. Shelling out to the real `kustomize`
491    // binary handles them with no extra code in this crate at all. ---
492
493    #[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        // Secret data is base64-encoded by kustomize; "admin" -> "YWRtaW4=".
518        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    // --- Ported from client_test.go: TestClient_Build_errors ---
611
612    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    // --- Ported from client_test.go: TestClient_Build_appliesLegacyResourceOrder ---
647
648    #[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    // --- Ported from client_test.go: TestClient_Build_cancellationUnblocksCaller ---
680    //
681    // Go's `blockingFS` wraps a filesystem so `ReadFile` blocks on a
682    // channel until released, letting the test synchronize on the build
683    // being genuinely in flight before cancelling a `context.Context`.
684    // Ported against `Client::build_cancellable`'s `&AtomicBool` token
685    // (see its doc comment) instead of a `context.Context`; the same
686    // "block until released, prove cancellation returns promptly without
687    // waiting for the blocked call" shape survives the port.
688
689    /// Ported from `blockingFS`: wraps a [`FileSystem`] so `read_file`
690    /// signals `started_tx` on its first call (via `start_once`, the
691    /// equivalent of Go's `sync.Once`), then blocks until a value is
692    /// received on `release_rx`.
693    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            // Blocks until the test releases it, same as Go's
713            // `<-b.release`.
714            let _ = self.release_rx.lock().unwrap().recv();
715
716            self.inner.read_file(path)
717        }
718
719        // Enumeration itself doesn't block -- only `read_file` does, so
720        // the "build genuinely in flight" signal still fires on the
721        // first content read, same as before this `walk` passthrough
722        // was added.
723        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        // Unblock the abandoned background thread so it doesn't leak
769        // past the test.
770        let _ = release_tx.send(());
771    }
772
773    // --- Ported from client_test.go: TestNewClientWithFS_nilFilesystemPanics ---
774
775    #[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}