Skip to main content

xianyun_deploy/deployer/kustomize/
files.rs

1//! Materializes a local kustomization plus caller-declared generated
2//! files into a private temporary copy before `kustomize build` runs.
3//!
4//! Ported from `internal/deployer/kustomize/files.go`.
5
6use std::path::{Path, PathBuf};
7
8use thiserror::Error;
9use xianyun_core::config::latest::KustomizeConfig;
10
11use super::Cleanup;
12use super::builtin::is_builtin_path;
13
14/// Errors from [`validate_files`] and [`materialize_with_files`].
15#[derive(Debug, Error)]
16pub enum FilesError {
17    /// `files:` is set on a builtin or remote path.
18    #[error("kustomize.files requires a local kustomization path: {0:?}")]
19    NotSupported(String),
20    /// A `files:` key is not a bare file name.
21    #[error("kustomize.files names must be bare file names: {0:?}")]
22    InvalidFileName(String),
23    /// Creating the temporary kustomization copy failed.
24    #[error("creating temporary kustomization directory: {0}")]
25    CreateTempDir(#[source] std::io::Error),
26    /// Copying the source kustomization into the temporary directory
27    /// failed.
28    #[error("copying kustomization {0:?}: {1}")]
29    Copy(String, #[source] std::io::Error),
30    /// Writing a generated file into the temporary copy failed.
31    #[error("writing generated files into kustomization copy: {0}")]
32    Write(#[source] std::io::Error),
33}
34
35/// Rejects `files:` on non-local paths and keys that leave the
36/// kustomization directory.
37///
38/// # Errors
39///
40/// Returns [`FilesError::NotSupported`] if `path` is a builtin or
41/// remote target, or [`FilesError::InvalidFileName`] if a key is not a
42/// bare file name.
43pub fn validate_files(kc: &KustomizeConfig) -> Result<(), FilesError> {
44    if kc.files.is_empty() {
45        return Ok(());
46    }
47
48    if is_builtin_path(&kc.path) || xianyun_backends::kustomize::remote::is_remote_target(&kc.path)
49    {
50        return Err(FilesError::NotSupported(
51            xianyun_backends::kustomize::remote::redact_credentials(&kc.path),
52        ));
53    }
54
55    for name in kc.files.keys() {
56        if !is_bare_file_name(name) {
57            return Err(FilesError::InvalidFileName(name.clone()));
58        }
59    }
60
61    Ok(())
62}
63
64/// Reports whether `name` is a single, non-dot path element.
65fn is_bare_file_name(name: &str) -> bool {
66    if name.is_empty() || name == "." || name == ".." {
67        return false;
68    }
69
70    !name.contains('/') && !name.contains('\\')
71}
72
73/// Copies `src` into a fresh `0700` temp directory and writes `files`
74/// into it (`0600`), so nothing lands in the user's repository.
75/// `cleanup` is always safe to call.
76///
77/// # Errors
78///
79/// Returns [`FilesError::CreateTempDir`] if creating the temp directory
80/// fails, [`FilesError::Copy`] if copying `src` fails, or
81/// [`FilesError::Write`] if writing a generated file fails.
82pub fn materialize_with_files(
83    src: &str,
84    files: &std::collections::HashMap<String, String>,
85) -> Result<(PathBuf, Cleanup), FilesError> {
86    let tmp_dir = super::mkdtemp("caproni-kustomize-").map_err(FilesError::CreateTempDir)?;
87
88    if let Err(e) = copy_dir(Path::new(src), &tmp_dir) {
89        let _ = std::fs::remove_dir_all(&tmp_dir);
90        return Err(FilesError::Copy(src.to_string(), e));
91    }
92
93    for (name, body) in files {
94        if let Err(e) = write_file_0600(&tmp_dir.join(name), body.as_bytes()) {
95            let _ = std::fs::remove_dir_all(&tmp_dir);
96            return Err(FilesError::Write(e));
97        }
98    }
99
100    let cleanup_dir = tmp_dir.clone();
101    let cleanup: Cleanup = Box::new(move || {
102        let _ = std::fs::remove_dir_all(&cleanup_dir);
103    });
104
105    Ok((tmp_dir, cleanup))
106}
107
108/// Writes `content` to `path` with mode `0600`, matching the Go
109/// original's `os.WriteFile(..., 0o600)`.
110fn write_file_0600(path: &Path, content: &[u8]) -> std::io::Result<()> {
111    use std::io::Write;
112    use std::os::unix::fs::OpenOptionsExt;
113
114    let mut file = std::fs::OpenOptions::new()
115        .write(true)
116        .create(true)
117        .truncate(true)
118        .mode(0o600)
119        .open(path)?;
120
121    file.write_all(content)
122}
123
124/// Recursively copies the contents of `src` into `dst`, which must
125/// already exist. Ported from the Go original's `os.CopyFS(tmpDir,
126/// os.DirFS(src))`.
127fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
128    for entry in std::fs::read_dir(src)? {
129        let entry = entry?;
130        let file_type = entry.file_type()?;
131        let target = dst.join(entry.file_name());
132
133        if file_type.is_dir() {
134            std::fs::create_dir_all(&target)?;
135            copy_dir(&entry.path(), &target)?;
136        } else {
137            std::fs::copy(entry.path(), target)?;
138        }
139    }
140
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use std::collections::HashMap;
147    use std::sync::{Arc, Mutex};
148
149    use xianyun_core::config::latest::{CaproniConfig, DeployerConfig, KustomizeConfig};
150
151    use super::*;
152    use crate::deployer::kustomize::manager::Manager;
153    use crate::deployer::kustomize::test_support::{MockKubectlBackend, MockKustomizeBackend};
154
155    fn kustomize_deployer_with_files(path: &str, files: HashMap<String, String>) -> DeployerConfig {
156        DeployerConfig {
157            deployer_type: "kustomize".to_string(),
158            kustomize: Some(KustomizeConfig {
159                path: path.to_string(),
160                files,
161                ..Default::default()
162            }),
163            ..Default::default()
164        }
165    }
166
167    /// Creates a minimal kustomization under `dir` and returns its path.
168    fn write_kustomization(dir: &Path) -> PathBuf {
169        let src = dir.join("overlay");
170        std::fs::create_dir(&src).unwrap();
171        std::fs::write(src.join("kustomization.yaml"), "kind: Kustomization\n").unwrap();
172        src
173    }
174
175    fn new_container(config_dir: &str) -> xianyun_core::config::container::Container {
176        let caproni_config = CaproniConfig {
177            config_dir: config_dir.to_string(),
178            ..Default::default()
179        };
180        xianyun_core::config::container::new_container(
181            xianyun_core::config::global::GlobalConfig::default(),
182            caproni_config,
183        )
184    }
185
186    const RENDERED_MANIFESTS: &[u8] =
187        b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: example\n";
188
189    /// Captures what the build callback observed inside the materialized
190    /// directory, mirroring the Go test's captured outer variables.
191    #[derive(Default)]
192    struct Captured {
193        build_dir: Option<PathBuf>,
194        got_file: Vec<u8>,
195        got_kustomization: Vec<u8>,
196        file_mode: Option<u32>,
197        dir_mode: Option<u32>,
198        stat_file_ok: bool,
199    }
200
201    // Ported from internal/deployer/kustomize/files_test.go:35 (TestDeploy_FilesAreWrittenIntoACopyOfTheKustomization)
202    #[test]
203    fn test_deploy_files_are_written_into_a_copy_of_the_kustomization() {
204        use std::os::unix::fs::PermissionsExt;
205
206        let tmp = tempfile::tempdir().unwrap();
207        let src = write_kustomization(tmp.path());
208
209        let captured: Arc<Mutex<Captured>> = Arc::new(Mutex::new(Captured::default()));
210        let captured_cap = captured.clone();
211
212        let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
213            let mut c = captured_cap.lock().unwrap();
214            c.build_dir = Some(PathBuf::from(dir));
215            c.got_file = std::fs::read(Path::new(dir).join("provider.yaml")).unwrap_or_default();
216            c.got_kustomization =
217                std::fs::read(Path::new(dir).join("kustomization.yaml")).unwrap_or_default();
218
219            if let Ok(meta) = std::fs::metadata(Path::new(dir).join("provider.yaml")) {
220                c.stat_file_ok = true;
221                c.file_mode = Some(meta.permissions().mode() & 0o777);
222            }
223            if let Ok(meta) = std::fs::metadata(dir) {
224                c.dir_mode = Some(meta.permissions().mode() & 0o777);
225            }
226
227            Ok(RENDERED_MANIFESTS.to_vec())
228        });
229        let kctl = MockKubectlBackend::new().expect_apply(|manifests, namespace| {
230            assert_eq!(manifests, RENDERED_MANIFESTS);
231            assert_eq!(namespace, "gitlab");
232            Ok(())
233        });
234
235        let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
236
237        let mut deployer_config = kustomize_deployer_with_files(
238            src.to_str().unwrap(),
239            HashMap::from([("provider.yaml".to_string(), "app_id: '123'\n".to_string())]),
240        );
241        deployer_config.kustomize.as_mut().unwrap().namespace = "gitlab".to_string();
242
243        mgr.deploy(&deployer_config, &new_container("")).unwrap();
244
245        let c = captured.lock().unwrap();
246        let build_dir = c.build_dir.clone().unwrap();
247        assert_ne!(
248            build_dir, src,
249            "build must run in a copy, not the user's directory"
250        );
251        assert!(c.stat_file_ok);
252        assert_eq!(String::from_utf8_lossy(&c.got_file), "app_id: '123'\n");
253        assert_eq!(
254            String::from_utf8_lossy(&c.got_kustomization),
255            "kind: Kustomization\n",
256            "the kustomization directory is copied alongside"
257        );
258        assert_eq!(c.file_mode, Some(0o600));
259        assert_eq!(c.dir_mode, Some(0o700));
260
261        assert!(
262            std::fs::metadata(src.join("provider.yaml")).is_err(),
263            "nothing is written into the user's directory"
264        );
265        assert!(
266            !build_dir.exists(),
267            "the temporary copy is removed after the build"
268        );
269    }
270
271    // Ported from internal/deployer/kustomize/files_test.go:93 (TestDeploy_FilesCopyFailurePropagates)
272    #[test]
273    fn test_deploy_files_copy_failure_propagates() {
274        let mgr = Manager::new(
275            Box::new(MockKustomizeBackend::new()),
276            Box::new(MockKubectlBackend::new()),
277        );
278
279        let tmp = tempfile::tempdir().unwrap();
280        let missing = tmp.path().join("missing");
281        let deployer_config = kustomize_deployer_with_files(
282            missing.to_str().unwrap(),
283            HashMap::from([("a".to_string(), "b".to_string())]),
284        );
285
286        let err = mgr
287            .deploy(&deployer_config, &new_container(""))
288            .unwrap_err();
289        assert!(
290            matches!(
291                &err,
292                crate::deployer::kustomize::manager::KustomizeError::Files(FilesError::Copy(_, io_err))
293                    if io_err.kind() == std::io::ErrorKind::NotFound
294            ),
295            "expected a not-found copy error, got {err}"
296        );
297    }
298
299    // Ported from internal/deployer/kustomize/files_test.go:103 (TestUndeploy_FilesAreWrittenIntoTheBuildDirectory)
300    #[test]
301    fn test_undeploy_files_are_written_into_the_build_directory() {
302        let tmp = tempfile::tempdir().unwrap();
303        let src = write_kustomization(tmp.path());
304
305        let present: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
306        let present_cap = present.clone();
307
308        let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
309            *present_cap.lock().unwrap() = Path::new(dir).join("provider.yaml").is_file();
310            Ok(RENDERED_MANIFESTS.to_vec())
311        });
312        let kctl = MockKubectlBackend::new().expect_delete_manifest(|manifests, namespace| {
313            assert_eq!(manifests, RENDERED_MANIFESTS);
314            assert_eq!(namespace, "");
315            Ok(())
316        });
317
318        let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
319
320        let deployer_config = kustomize_deployer_with_files(
321            src.to_str().unwrap(),
322            HashMap::from([("provider.yaml".to_string(), "x".to_string())]),
323        );
324
325        mgr.undeploy(&deployer_config, &CaproniConfig::default())
326            .unwrap();
327
328        assert!(
329            *present.lock().unwrap(),
330            "generated files must exist so kustomize can build during undeploy"
331        );
332    }
333
334    // Ported from internal/deployer/kustomize/files_test.go:129 (TestValidate_Files)
335    #[test]
336    fn test_validate_files() {
337        let tmp = tempfile::tempdir().unwrap();
338        let src = write_kustomization(tmp.path());
339        let src = src.to_str().unwrap().to_string();
340
341        let mgr = Manager::new(
342            Box::new(MockKustomizeBackend::new()),
343            Box::new(MockKubectlBackend::new()),
344        );
345
346        struct Case {
347            name: &'static str,
348            path: String,
349            files: HashMap<String, String>,
350            want_err: Option<&'static str>,
351        }
352
353        let cases = vec![
354            Case {
355                name: "bare file name on a local path",
356                path: src.clone(),
357                files: HashMap::from([("provider.yaml".to_string(), "x".to_string())]),
358                want_err: None,
359            },
360            Case {
361                name: "builtin overlay cannot take files",
362                path: "builtin:dev-ca".to_string(),
363                files: HashMap::from([("provider.yaml".to_string(), "x".to_string())]),
364                want_err: Some("not_supported"),
365            },
366            Case {
367                name: "remote target cannot take files",
368                path: "https://gitlab.com/org/repo.git//overlays/dev?ref=v1".to_string(),
369                files: HashMap::from([("provider.yaml".to_string(), "x".to_string())]),
370                want_err: Some("not_supported"),
371            },
372            Case {
373                name: "name with a path separator",
374                path: src.clone(),
375                files: HashMap::from([("sub/provider.yaml".to_string(), "x".to_string())]),
376                want_err: Some("invalid_name"),
377            },
378            Case {
379                name: "parent directory name",
380                path: src.clone(),
381                files: HashMap::from([("..".to_string(), "x".to_string())]),
382                want_err: Some("invalid_name"),
383            },
384            Case {
385                name: "empty name",
386                path: src.clone(),
387                files: HashMap::from([(String::new(), "x".to_string())]),
388                want_err: Some("invalid_name"),
389            },
390        ];
391
392        for case in cases {
393            let deployer_config = kustomize_deployer_with_files(&case.path, case.files);
394
395            let result = mgr.validate(&deployer_config, &CaproniConfig::default());
396
397            match case.want_err {
398                None => {
399                    result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
400                }
401                Some("not_supported") => match result {
402                    Err(crate::deployer::kustomize::manager::KustomizeError::Files(
403                        FilesError::NotSupported(_),
404                    )) => {}
405                    other => panic!("{}: expected NotSupported, got {other:?}", case.name),
406                },
407                Some("invalid_name") => match result {
408                    Err(crate::deployer::kustomize::manager::KustomizeError::Files(
409                        FilesError::InvalidFileName(_),
410                    )) => {}
411                    other => panic!("{}: expected InvalidFileName, got {other:?}", case.name),
412                },
413                Some(other) => unreachable!("unknown want_err case: {other}"),
414            }
415        }
416    }
417
418    // Ported from internal/deployer/kustomize/files_integration_test.go
419    // (TestDeploy_FilesFeedASecretGenerator). Builds examples/kustomize-secret
420    // with the real kustomize backend, declaring provider.yaml via `files:`
421    // rather than committing it, and asserts the generated Secret carries it
422    // without the source file leaking into the example directory.
423    #[test]
424    fn test_deploy_files_feed_a_secret_generator() {
425        use base64::Engine as _;
426        use xianyun_backends::kustomize::{Client as KustomizeClient, DiskFileSystem};
427        use xianyun_core::config::container::new_container;
428        use xianyun_core::config::global::GlobalConfig;
429        use xianyun_core::config::latest::{CaproniConfig, DeployerConfig};
430        use xianyun_executor::exec::Client as ExecClient;
431
432        use crate::deployer::kustomize::manager::{DEPLOYER_TYPE, Manager};
433        use crate::deployer::kustomize::test_support::MockKubectlBackend;
434
435        let src = std::env::current_dir()
436            .unwrap()
437            .join("../../examples/kustomize-secret");
438        let src = src.canonicalize().expect(
439            "examples/kustomize-secret must exist relative to the workspace root (crates/xianyun-deploy)",
440        );
441
442        let provider = "name: 'github'\napp_id: '123'\napp_secret: 'hunter2'\n";
443
444        let applied: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
445        let applied_for_closure = Arc::clone(&applied);
446        let kubectl = MockKubectlBackend::new().expect_apply(move |manifests, namespace| {
447            assert_eq!(namespace, "app");
448            *applied_for_closure.lock().unwrap() = Some(manifests.to_vec());
449            Ok(())
450        });
451
452        let kustomize_backend =
453            KustomizeClient::new(Arc::new(DiskFileSystem), Arc::new(ExecClient));
454        let mgr = Manager::new(Box::new(kustomize_backend), Box::new(kubectl));
455
456        let mut files = std::collections::HashMap::new();
457        files.insert("provider.yaml".to_string(), provider.to_string());
458
459        let dep = DeployerConfig {
460            deployer_type: DEPLOYER_TYPE.to_string(),
461            kustomize: Some(KustomizeConfig {
462                path: src.to_string_lossy().to_string(),
463                namespace: "app".to_string(),
464                files,
465            }),
466            ..Default::default()
467        };
468
469        let container = new_container(GlobalConfig::default(), CaproniConfig::default());
470
471        mgr.deploy(&dep, &container).expect("deploy must succeed");
472
473        let manifests = applied
474            .lock()
475            .unwrap()
476            .take()
477            .expect("apply must be called");
478        let manifests_text = String::from_utf8(manifests).unwrap();
479
480        let doc: serde_yaml::Value = serde_yaml::from_str(&manifests_text)
481            .expect("the kustomize-secret example must render a single resource");
482        let data = doc
483            .get("metadata")
484            .and_then(|m| m.get("name"))
485            .and_then(serde_yaml::Value::as_str);
486        assert_eq!(
487            data,
488            Some("oauth-provider"),
489            "disableNameSuffixHash keeps the name stable"
490        );
491
492        let encoded = doc
493            .get("data")
494            .and_then(|d| d.get("provider.yaml"))
495            .and_then(serde_yaml::Value::as_str)
496            .expect("provider.yaml key present in Secret data");
497        let compact: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
498        let decoded = base64::engine::general_purpose::STANDARD
499            .decode(&compact)
500            .expect("valid base64");
501        assert_eq!(String::from_utf8(decoded).unwrap(), provider);
502
503        assert!(
504            !src.join("provider.yaml").exists(),
505            "the generated file must not land in the example directory"
506        );
507    }
508}