xianyun_deploy/deployer/kustomize/
files.rs1use 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#[derive(Debug, Error)]
16pub enum FilesError {
17 #[error("kustomize.files requires a local kustomization path: {0:?}")]
19 NotSupported(String),
20 #[error("kustomize.files names must be bare file names: {0:?}")]
22 InvalidFileName(String),
23 #[error("creating temporary kustomization directory: {0}")]
25 CreateTempDir(#[source] std::io::Error),
26 #[error("copying kustomization {0:?}: {1}")]
29 Copy(String, #[source] std::io::Error),
30 #[error("writing generated files into kustomization copy: {0}")]
32 Write(#[source] std::io::Error),
33}
34
35pub 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
64fn 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
73pub 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
108fn 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
124fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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}