1use std::path::Path;
6
7use thiserror::Error;
8use xianyun_backends::kubectl::Backend as KubectlBackend;
9use xianyun_core::config::latest::{CaproniConfig, ReloaderConfig};
10use xianyun_executor::exec::CommandRunner;
11use xianyun_executor::exec::streamed::ExecStreamedOpts;
12
13use super::copy::{self, CopyError, CopyInfo};
14
15pub const RELOADER_TYPE: &str = "mirror";
17
18const MIRRORD_AGENT_LABEL: &str = "app=mirrord";
21
22#[derive(Debug, Error)]
24pub enum MirrorError {
25 #[error("reloader type is not 'mirror'")]
27 InvalidReloaderType,
28 #[error("mirror configuration is missing")]
30 MirrorConfigMissing,
31 #[error("target configuration is missing")]
33 TargetConfigMissing,
34 #[error("process {0:?}: empty command for process")]
36 EmptyCommand(String),
37 #[error("repository not found: {0}")]
39 RepositoryNotFound(String),
40 #[error(
42 "caproni deployments detected: {kubernetes_type}/{copy_name} in namespace {namespace:?}. Is another `caproni run` running? Run `caproni kubectl delete {kubernetes_type}/{copy_name} -n {namespace}` to delete it"
43 )]
44 CaproniDeploymentsExist {
45 kubernetes_type: String,
46 copy_name: String,
47 namespace: String,
48 },
49 #[error("mirrord version too old: {1}. Please upgrade mirrord")]
51 MirrordVersionTooOld(String, #[source] xianyun_update::version::VersionError),
52 #[error("validating mirror targets: {0}")]
54 ValidatingTargets(#[source] CopyError),
55 #[error(transparent)]
57 Copy(#[from] CopyError),
58 #[error("getting executable path: {0}")]
60 ExecutablePath(#[source] std::io::Error),
61 #[error("creating logs directory: {0}")]
63 CreateLogsDir(#[source] std::io::Error),
64 #[error("finding mirrord executable: {0}")]
66 ResolveMirrordPath(#[source] xianyun_executor::exec::ExecError),
67 #[error("generating mirrord config: {0}")]
69 GenerateMirrordConfig(String),
70 #[error("running supervisor: {0}")]
72 Supervisor(#[source] xianyun_executor::exec::ExecError),
73}
74
75pub struct Manager {
77 pub(super) kubectl: Box<dyn KubectlBackend + Send + Sync>,
78}
79
80struct SupervisedProcessContext<'a> {
84 repo_dir: &'a str,
85 caproni_path: &'a str,
86 shared_args: &'a [String],
87 kubeconfig_path: &'a str,
88}
89
90impl Manager {
91 #[must_use]
93 pub fn new(kubectl_backend: Box<dyn KubectlBackend + Send + Sync>) -> Self {
94 Self {
95 kubectl: kubectl_backend,
96 }
97 }
98
99 #[must_use]
101 pub fn reloader_type(&self) -> &'static str {
102 RELOADER_TYPE
103 }
104
105 pub fn check_preconditions(
123 &self,
124 executor: &dyn CommandRunner,
125 reloader_config: &ReloaderConfig,
126 _caproni_config: &CaproniConfig,
127 get_tool_version: &dyn Fn(&str) -> Option<String>,
128 ) -> Result<(), MirrorError> {
129 self.check_mirrord_version(executor, get_tool_version)?;
130
131 let Some(mirror) = reloader_config.mirror.as_ref() else {
132 return Ok(());
133 };
134 let Some(target) = mirror.target.as_ref() else {
135 return Ok(());
136 };
137
138 let copy_name = format!("{}{}", target.name, copy::COPY_SUFFIX);
139
140 let output = match self
141 .kubectl
142 .get(&target.kubernetes_type, ©_name, &target.namespace)
143 {
144 Err(_) => return Ok(()),
147 Ok(output) => output,
148 };
149
150 if output.is_empty() {
151 return Ok(());
152 }
153
154 Err(MirrorError::CaproniDeploymentsExist {
155 kubernetes_type: target.kubernetes_type.clone(),
156 copy_name,
157 namespace: target.namespace.clone(),
158 })
159 }
160
161 fn check_mirrord_version(
162 &self,
163 executor: &dyn CommandRunner,
164 get_tool_version: &dyn Fn(&str) -> Option<String>,
165 ) -> Result<(), MirrorError> {
166 let Some(required_version) = get_tool_version("GL_ASDF_MIRRORD_VERSION") else {
167 return Ok(());
168 };
169
170 let Ok(installed_version) = executor.read_stdout("mirrord", &["--version".to_string()])
173 else {
174 return Ok(());
175 };
176
177 xianyun_update::version::check_minimum_version(&installed_version, &required_version)
178 .map_err(|e| MirrorError::MirrordVersionTooOld(installed_version.clone(), e))
179 }
180
181 pub fn run(
204 &self,
205 executor: &dyn CommandRunner,
206 reloader_config: &ReloaderConfig,
207 caproni_config: &CaproniConfig,
208 kubeconfig_path: &str,
209 ) -> Result<(), MirrorError> {
210 if reloader_config.reloader_type != RELOADER_TYPE {
211 return Err(MirrorError::InvalidReloaderType);
212 }
213
214 let Some(mirror) = reloader_config.mirror.as_ref() else {
215 return Err(MirrorError::MirrorConfigMissing);
216 };
217
218 let repo_config = caproni_config
219 .repositories
220 .as_ref()
221 .and_then(|repos| repos.get(&reloader_config.repository))
222 .ok_or_else(|| MirrorError::RepositoryNotFound(reloader_config.repository.clone()))?;
223
224 let Some(procs) = mirror.edit_mode_processes.as_ref() else {
226 return Ok(());
227 };
228 if procs.is_empty() {
229 return Ok(());
230 }
231
232 let caproni_path = std::env::current_exe().map_err(MirrorError::ExecutablePath)?;
233 let caproni_path = caproni_path.to_string_lossy().into_owned();
234
235 let logs_dir = Path::new(&caproni_config.config_file)
236 .parent()
237 .map(|p| p.join(xianyun_executor::editmodelogger::DIR_NAME))
238 .unwrap_or_else(|| Path::new(xianyun_executor::editmodelogger::DIR_NAME).to_path_buf());
239
240 std::fs::create_dir_all(&logs_dir).map_err(MirrorError::CreateLogsDir)?;
241
242 let shared_args = vec![
243 "--config-file".to_string(),
244 caproni_config.config_file.clone(),
245 "edit-mode-process-supervisor".to_string(),
246 "--reloader".to_string(),
247 reloader_config.name.clone(),
248 "--logs-dir".to_string(),
249 logs_dir.to_string_lossy().into_owned(),
250 ];
251
252 let repo_dir = caproni_config.resolve_path(&repo_config.directory);
253
254 self.supervise_processes(
255 executor,
256 reloader_config,
257 mirror,
258 SupervisedProcessContext {
259 repo_dir: &repo_dir,
260 caproni_path: &caproni_path,
261 shared_args: &shared_args,
262 kubeconfig_path,
263 },
264 )
265 }
266
267 fn supervise_processes(
281 &self,
282 executor: &dyn CommandRunner,
283 reloader_config: &ReloaderConfig,
284 mirror: &xianyun_core::config::latest::MirrorConfig,
285 ctx: SupervisedProcessContext<'_>,
286 ) -> Result<(), MirrorError> {
287 let SupervisedProcessContext {
288 repo_dir,
289 caproni_path,
290 shared_args,
291 kubeconfig_path,
292 } = ctx;
293 let procs = mirror
294 .edit_mode_processes
295 .as_ref()
296 .expect("checked non-empty by run()");
297
298 let mut has_mirrord = false;
299 let mut has_local = false;
300
301 for proc_config in procs.values() {
302 if proc_config.context == xianyun_core::config::latest::PROCESS_CONTEXT_LOCAL {
303 has_local = true;
304 } else {
305 has_mirrord = true;
306 }
307 }
308
309 if has_mirrord {
310 let tmp_mirrord_config = self.generate_mirrord_config(reloader_config)?;
311 let mirrord_path = self.resolve_mirrord_path(executor)?;
312
313 let mut mirrord_args = vec![
314 "exec".to_string(),
315 "--config-file".to_string(),
316 tmp_mirrord_config.name().to_string_lossy().into_owned(),
317 "--".to_string(),
318 caproni_path.to_string(),
319 ];
320 mirrord_args.extend_from_slice(shared_args);
321
322 let opts = ExecStreamedOpts {
327 process_descriptive_name: Some(reloader_config.name.clone()),
328 env: kubeconfig_env(kubeconfig_path),
329 ..Default::default()
330 };
331
332 executor
333 .run_in_repository(repo_dir, &mirrord_path, &mirrord_args, Some(opts))
334 .map_err(MirrorError::Supervisor)?;
335 }
336
337 if has_local {
338 let mut local_args = shared_args.to_vec();
339 local_args.push(format!(
340 "--context={}",
341 xianyun_core::config::latest::PROCESS_CONTEXT_LOCAL
342 ));
343
344 let opts = ExecStreamedOpts {
345 process_descriptive_name: Some(format!("{} (local)", reloader_config.name)),
346 ..Default::default()
347 };
348
349 executor
350 .run_in_repository(repo_dir, caproni_path, &local_args, Some(opts))
351 .map_err(MirrorError::Supervisor)?;
352 }
353
354 Ok(())
355 }
356
357 pub fn prepare_from_reloader_configs(
366 &self,
367 reloaders: &std::collections::HashMap<String, ReloaderConfig>,
368 copy_sleep_image: &str,
369 ) -> Result<Vec<CopyInfo>, MirrorError> {
370 let targets = copy::collect_and_validate_targets(reloaders)
371 .map_err(MirrorError::ValidatingTargets)?;
372
373 if targets.is_empty() {
374 return Ok(Vec::new());
375 }
376
377 self.prepare_copies(&targets, copy_sleep_image)
378 .map_err(MirrorError::from)
379 }
380
381 pub fn cleanup_copy_deployments(&self, deployments: &[CopyInfo]) {
385 tracing::info!(
386 "reverting cluster to production mode, deleting caproni copies and restoring originals"
387 );
388
389 for dep in deployments {
390 if let Err(e) = self.cleanup_copy(dep) {
391 tracing::error!(
392 name = %dep.name,
393 namespace = %dep.namespace,
394 error = %e,
395 "failed to clean up deployment copy"
396 );
397 }
398 }
399 }
400
401 pub fn status(
408 &self,
409 reloader_config: &ReloaderConfig,
410 _caproni_config: &CaproniConfig,
411 ) -> Result<crate::reloader::Status, MirrorError> {
412 if reloader_config.reloader_type != RELOADER_TYPE {
413 return Err(MirrorError::InvalidReloaderType);
414 }
415
416 let Some(mirror) = reloader_config.mirror.as_ref() else {
417 return Err(MirrorError::MirrorConfigMissing);
418 };
419
420 let Some(target) = mirror.target.as_ref() else {
421 return Err(MirrorError::TargetConfigMissing);
422 };
423
424 let exists = matches!(
425 self.kubectl.get(&target.kubernetes_type, &target.name, &target.namespace),
426 Ok(output) if !output.is_empty()
427 );
428
429 if !exists {
430 return Ok(crate::reloader::Status {
431 state: crate::reloader::State::NotDeployed,
432 reloader_type: RELOADER_TYPE.to_string(),
433 });
434 }
435
436 let copy_name = format!("{}{}", target.name, copy::COPY_SUFFIX);
437 let editing = matches!(
438 self.kubectl.get(&target.kubernetes_type, ©_name, &target.namespace),
439 Ok(output) if !output.is_empty()
440 );
441
442 Ok(crate::reloader::Status {
443 state: if editing {
444 crate::reloader::State::Editing
445 } else {
446 crate::reloader::State::Ready
447 },
448 reloader_type: RELOADER_TYPE.to_string(),
449 })
450 }
451
452 pub fn cleanup_stale_mirrord_agents(&self, namespace: &str) {
459 match self
460 .kubectl
461 .delete_pods_by_label(namespace, MIRRORD_AGENT_LABEL)
462 {
463 Ok(deleted) => {
464 if deleted > 0 {
465 tracing::info!(
466 namespace = %namespace,
467 count = deleted,
468 "cleaned up stale mirrord agent pods"
469 );
470 }
471 }
472 Err(e) => {
473 tracing::warn!(
474 namespace = %namespace,
475 error = %e,
476 "failed to clean up stale mirrord agents (non-fatal)"
477 );
478 }
479 }
480 }
481
482 fn resolve_mirrord_path(&self, executor: &dyn CommandRunner) -> Result<String, MirrorError> {
488 if let Ok(path) =
489 executor.read_stdout("mise", &["which".to_string(), "mirrord".to_string()])
490 {
491 return Ok(path.trim().to_string());
492 }
493
494 executor
495 .look_path("mirrord")
496 .map(|p| p.to_string_lossy().into_owned())
497 .map_err(MirrorError::ResolveMirrordPath)
498 }
499
500 fn generate_mirrord_config(
504 &self,
505 reloader_config: &ReloaderConfig,
506 ) -> Result<xianyun_executor::tempfile::Tempfile, MirrorError> {
507 let tmp_file = xianyun_executor::tempfile::Tempfile::new("", "mirrord-config-*.json")
508 .map_err(|e| MirrorError::GenerateMirrordConfig(format!("creating temp file: {e}")))?;
509
510 crate::mirrord::generate_config(reloader_config, &tmp_file.name().to_string_lossy())
511 .map_err(|e| MirrorError::GenerateMirrordConfig(e.to_string()))?;
512
513 Ok(tmp_file)
514 }
515
516 pub fn validate(
526 &self,
527 reloader_config: &ReloaderConfig,
528 caproni_config: &CaproniConfig,
529 ) -> Result<(), MirrorError> {
530 if reloader_config.reloader_type != RELOADER_TYPE {
531 return Err(MirrorError::InvalidReloaderType);
532 }
533
534 let Some(mirror) = reloader_config.mirror.as_ref() else {
535 return Err(MirrorError::MirrorConfigMissing);
536 };
537
538 if mirror.target.is_none() {
539 return Err(MirrorError::TargetConfigMissing);
540 }
541
542 caproni_config
543 .repositories
544 .as_ref()
545 .and_then(|repos| repos.get(&reloader_config.repository))
546 .ok_or_else(|| MirrorError::RepositoryNotFound(reloader_config.repository.clone()))?;
547
548 if let Some(procs) = mirror.edit_mode_processes.as_ref() {
549 for (name, proc_config) in procs.iter() {
550 if proc_config.command.is_empty() {
551 return Err(MirrorError::EmptyCommand(name.clone()));
552 }
553 }
554 }
555
556 Ok(())
557 }
558}
559
560fn kubeconfig_env(kubeconfig_path: &str) -> Option<Vec<String>> {
564 if kubeconfig_path.is_empty() {
565 return None;
566 }
567
568 let mut env: Vec<String> = std::env::vars().map(|(k, v)| format!("{k}={v}")).collect();
569 env.push(format!("KUBECONFIG={kubeconfig_path}"));
570
571 Some(env)
572}
573
574#[cfg(test)]
575mod tests {
576 use std::collections::HashMap;
577
578 use xianyun_backends::kubectl::KubectlError;
579 use xianyun_core::config::latest;
580 use xianyun_core::config::latest::{
581 CaproniConfig, MirrorConfig, MirrorTargetConfig, ProcessConfig, ReloaderConfig,
582 RepositoryConfig,
583 };
584 use xianyun_core::orderedmap::OrderedMap;
585 use xianyun_testutil::MockCommandRunner;
586
587 use super::super::test_support::MockKubectlBackend;
588 use super::*;
589
590 #[test]
592 fn test_cleanup_stale_mirrord_agents_deletes_stale_agents() {
593 let mock_kubectl =
594 MockKubectlBackend::new().expect_delete_pods_by_label(|namespace, label| {
595 assert_eq!(namespace, "gitlab");
596 assert_eq!(label, MIRRORD_AGENT_LABEL);
597 Ok(2)
598 });
599
600 let mgr = Manager::new(Box::new(mock_kubectl));
601 mgr.cleanup_stale_mirrord_agents("gitlab");
602 }
603
604 #[test]
606 fn test_cleanup_stale_mirrord_agents_no_stale_agents() {
607 let mock_kubectl =
608 MockKubectlBackend::new().expect_delete_pods_by_label(|_namespace, _label| Ok(0));
609
610 let mgr = Manager::new(Box::new(mock_kubectl));
611 mgr.cleanup_stale_mirrord_agents("gitlab");
612 }
613
614 #[test]
616 fn test_cleanup_stale_mirrord_agents_error_is_non_fatal() {
617 let mock_kubectl = MockKubectlBackend::new().expect_delete_pods_by_label(|_, _| {
618 Err(KubectlError::JobFailed("deadline exceeded".to_string()))
619 });
620
621 let mgr = Manager::new(Box::new(mock_kubectl));
622 mgr.cleanup_stale_mirrord_agents("gitlab");
624 }
625
626 #[test]
628 fn test_cleanup_stale_mirrord_agents_called_externally() {
629 let mock_kubectl = MockKubectlBackend::new().expect_delete_pods_by_label(|_, _| Ok(1));
630
631 let mgr = Manager::new(Box::new(mock_kubectl));
632 mgr.cleanup_stale_mirrord_agents("gitlab");
633 }
634
635 #[test]
637 fn test_check_preconditions() {
638 struct Case {
639 name: &'static str,
640 target_type: &'static str,
641 target_name: &'static str,
642 get_output: &'static str,
643 get_err: bool,
644 want_stale: bool,
645 }
646
647 let cases = vec![
648 Case {
649 name: "no stale copy exists",
650 target_type: "deployment",
651 target_name: "gitlab-webservice",
652 get_output: "",
653 get_err: true,
654 want_stale: false,
655 },
656 Case {
657 name: "stale copy exists",
658 target_type: "deployment",
659 target_name: "gitlab-webservice",
660 get_output: r#"{"kind":"Deployment"}"#,
661 get_err: false,
662 want_stale: true,
663 },
664 Case {
665 name: "stale statefulset copy exists",
666 target_type: "statefulset",
667 target_name: "gitlab-gitaly",
668 get_output: r#"{"kind":"StatefulSet"}"#,
669 get_err: false,
670 want_stale: true,
671 },
672 ];
673
674 for case in cases {
675 let get_output = case.get_output.to_string();
676 let get_err = case.get_err;
677 let target_type = case.target_type.to_string();
678 let target_name = case.target_name.to_string();
679
680 let mock_kubectl =
681 MockKubectlBackend::new().expect_get(move |resource_type, name, namespace| {
682 assert_eq!(resource_type, target_type);
683 assert_eq!(name, format!("{target_name}-caproni"));
684 assert_eq!(namespace, "gitlab");
685
686 if get_err {
687 Err(KubectlError::JobFailed("command failed".to_string()))
688 } else {
689 Ok(get_output.clone())
690 }
691 });
692
693 let mgr = Manager::new(Box::new(mock_kubectl));
694 let mock_exec = MockCommandRunner::new();
695
696 let reloader_config = ReloaderConfig {
697 mirror: Some(MirrorConfig {
698 target: Some(MirrorTargetConfig {
699 namespace: "gitlab".to_string(),
700 kubernetes_type: case.target_type.to_string(),
701 name: case.target_name.to_string(),
702 container: String::new(),
703 }),
704 ..Default::default()
705 }),
706 ..Default::default()
707 };
708
709 let result = mgr.check_preconditions(
710 &mock_exec,
711 &reloader_config,
712 &CaproniConfig::default(),
713 &|_| None,
714 );
715
716 if case.want_stale {
717 let err = result.unwrap_err();
718 assert!(
719 matches!(err, MirrorError::CaproniDeploymentsExist { .. }),
720 "{}: expected CaproniDeploymentsExist, got {err:?}",
721 case.name
722 );
723 assert!(
724 err.to_string().contains(&format!(
725 "{}/{}-caproni",
726 case.target_type, case.target_name
727 )),
728 "{}: {err}",
729 case.name
730 );
731 } else {
732 result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
733 }
734 }
735 }
736
737 #[test]
739 fn test_check_preconditions_mirrord_version_check() {
740 struct Case {
741 name: &'static str,
742 required_version: &'static str,
743 installed_output: &'static str,
744 installed_err: bool,
745 want_too_old: bool,
746 want_err_contains: &'static str,
747 }
748
749 let cases = vec![
750 Case {
751 name: "version meets minimum",
752 required_version: "3.187.0",
753 installed_output: "mirrord 3.187.0",
754 installed_err: false,
755 want_too_old: false,
756 want_err_contains: "",
757 },
758 Case {
759 name: "version exceeds minimum",
760 required_version: "3.187.0",
761 installed_output: "mirrord 3.200.0",
762 installed_err: false,
763 want_too_old: false,
764 want_err_contains: "",
765 },
766 Case {
767 name: "version too old",
768 required_version: "3.187.0",
769 installed_output: "mirrord 2.100.0",
770 installed_err: false,
771 want_too_old: true,
772 want_err_contains: "mirrord 2.100.0",
773 },
774 Case {
775 name: "minor version too old",
776 required_version: "3.187.0",
777 installed_output: "mirrord 3.100.0",
778 installed_err: false,
779 want_too_old: true,
780 want_err_contains: "minimum required v3.187",
781 },
782 Case {
783 name: "mirrord not installed",
784 required_version: "3.187.0",
785 installed_output: "",
786 installed_err: true,
787 want_too_old: false,
788 want_err_contains: "",
789 },
790 Case {
791 name: "version key not configured",
792 required_version: "",
793 installed_output: "",
794 installed_err: false,
795 want_too_old: false,
796 want_err_contains: "",
797 },
798 ];
799
800 for case in cases {
801 let mock_exec = MockCommandRunner::new();
802
803 if !case.required_version.is_empty() {
804 if case.installed_err {
805 mock_exec.expect_read_stdout(
806 "mirrord",
807 &["--version"],
808 Err(xianyun_executor::exec::ExecError::CommandFailed(1)),
809 );
810 } else {
811 mock_exec.expect_read_stdout(
812 "mirrord",
813 &["--version"],
814 Ok(case.installed_output.to_string()),
815 );
816 }
817 }
818
819 let get_tool_version = move |_: &str| {
820 if case.required_version.is_empty() {
821 None
822 } else {
823 Some(case.required_version.to_string())
824 }
825 };
826
827 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
828
829 let result = mgr.check_preconditions(
830 &mock_exec,
831 &ReloaderConfig::default(),
832 &CaproniConfig::default(),
833 &get_tool_version,
834 );
835
836 if case.want_too_old {
837 let err = result.unwrap_err();
838 assert!(
839 matches!(err, MirrorError::MirrordVersionTooOld(_, _)),
840 "{}: expected MirrordVersionTooOld, got {err:?}",
841 case.name
842 );
843 assert!(
844 err.to_string().contains(case.want_err_contains),
845 "{}: {err}",
846 case.name
847 );
848 } else {
849 result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
850 }
851 }
852 }
853
854 #[test]
856 fn test_run_invalid_reloader_type() {
857 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
858 let mock_exec = MockCommandRunner::new();
859
860 let reloader_config = ReloaderConfig {
861 reloader_type: "helm".to_string(),
862 ..Default::default()
863 };
864
865 let err = mgr
866 .run(&mock_exec, &reloader_config, &CaproniConfig::default(), "")
867 .unwrap_err();
868 assert!(matches!(err, MirrorError::InvalidReloaderType));
869 }
870
871 #[test]
873 fn test_run_missing_mirror_config() {
874 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
875 let mock_exec = MockCommandRunner::new();
876
877 let reloader_config = ReloaderConfig {
878 reloader_type: RELOADER_TYPE.to_string(),
879 mirror: None,
880 ..Default::default()
881 };
882
883 let err = mgr
884 .run(&mock_exec, &reloader_config, &CaproniConfig::default(), "")
885 .unwrap_err();
886 assert!(matches!(err, MirrorError::MirrorConfigMissing));
887 }
888
889 #[test]
891 fn test_run_no_processes_is_noop() {
892 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
893 let mock_exec = MockCommandRunner::new();
894
895 let mut repos = OrderedMap::new();
896 repos.set(
897 "openbao".to_string(),
898 RepositoryConfig {
899 repository: "https://github.com/openbao/openbao.git".to_string(),
900 directory: "./openbao".to_string(),
901 ..Default::default()
902 },
903 );
904
905 let reloader_config = ReloaderConfig {
906 reloader_type: RELOADER_TYPE.to_string(),
907 repository: "openbao".to_string(),
908 mirror: Some(MirrorConfig {
909 target: Some(MirrorTargetConfig {
910 namespace: "gitlab".to_string(),
911 kubernetes_type: "deployment".to_string(),
912 name: "gitlab-openbao".to_string(),
913 container: String::new(),
914 }),
915 ..Default::default()
916 }),
917 ..Default::default()
918 };
919
920 let caproni_config = CaproniConfig {
921 repositories: Some(repos),
922 ..Default::default()
923 };
924
925 mgr.run(&mock_exec, &reloader_config, &caproni_config, "")
926 .unwrap();
927 }
928
929 #[test]
931 fn test_run_missing_repository() {
932 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
933 let mock_exec = MockCommandRunner::new();
934
935 let reloader_config = ReloaderConfig {
936 reloader_type: RELOADER_TYPE.to_string(),
937 repository: "nonexistent".to_string(),
938 mirror: Some(MirrorConfig {
939 target: Some(MirrorTargetConfig {
940 namespace: "gitlab".to_string(),
941 kubernetes_type: "deployment".to_string(),
942 name: "gitlab-openbao".to_string(),
943 container: String::new(),
944 }),
945 ..Default::default()
946 }),
947 ..Default::default()
948 };
949
950 let caproni_config = CaproniConfig {
951 repositories: Some(OrderedMap::new()),
952 ..Default::default()
953 };
954
955 let err = mgr
956 .run(&mock_exec, &reloader_config, &caproni_config, "")
957 .unwrap_err();
958 assert!(matches!(err, MirrorError::RepositoryNotFound(_)));
959 }
960
961 fn new_mirrord_manager() -> (Manager, MockCommandRunner) {
966 let mock_exec = MockCommandRunner::new();
967 mock_exec.expect_read_stdout(
968 "mise",
969 &["which", "mirrord"],
970 Ok("/usr/local/bin/mirrord".to_string()),
971 );
972
973 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
974
975 (mgr, mock_exec)
976 }
977
978 fn run_gitlab_mirror(
982 mgr: &Manager,
983 executor: &dyn CommandRunner,
984 procs: OrderedMap<String, ProcessConfig>,
985 kubeconfig_path: &str,
986 ) -> Result<(), MirrorError> {
987 let mut repos = OrderedMap::new();
988 repos.set(
989 "gitlab".to_string(),
990 RepositoryConfig {
991 repository: "https://example.com/gitlab.git".to_string(),
992 directory: "./gitlab".to_string(),
993 ..Default::default()
994 },
995 );
996
997 let tmp_dir = tempfile::tempdir().expect("creating temp dir");
998
999 let reloader_config = ReloaderConfig {
1000 name: "gitlab".to_string(),
1001 reloader_type: RELOADER_TYPE.to_string(),
1002 repository: "gitlab".to_string(),
1003 mirror: Some(MirrorConfig {
1004 target: Some(MirrorTargetConfig {
1005 namespace: "gitlab".to_string(),
1006 kubernetes_type: "deployment".to_string(),
1007 name: "gitlab-webservice-default".to_string(),
1008 container: String::new(),
1009 }),
1010 edit_mode_processes: Some(procs),
1011 ..Default::default()
1012 }),
1013 ..Default::default()
1014 };
1015
1016 let caproni_config = CaproniConfig {
1017 repositories: Some(repos),
1018 config_file: tmp_dir
1019 .path()
1020 .join("caproni.yaml")
1021 .to_string_lossy()
1022 .into_owned(),
1023 ..Default::default()
1024 };
1025
1026 mgr.run(executor, &reloader_config, &caproni_config, kubeconfig_path)
1027 }
1028
1029 #[test]
1031 fn test_run_does_not_cleanup_agents() {
1032 let mock_kubectl = MockKubectlBackend::new();
1036
1037 let mut repos = OrderedMap::new();
1038 let openbao_repo = RepositoryConfig {
1039 repository: "https://github.com/openbao/openbao.git".to_string(),
1040 directory: "./openbao".to_string(),
1041 ..Default::default()
1042 };
1043 repos.set("openbao".to_string(), openbao_repo.clone());
1044
1045 let tmp_dir = tempfile::tempdir().expect("creating temp dir");
1046 let caproni_config = CaproniConfig {
1047 repositories: Some(repos),
1048 config_file: tmp_dir
1049 .path()
1050 .join("caproni.yaml")
1051 .to_string_lossy()
1052 .into_owned(),
1053 ..Default::default()
1054 };
1055 let repo_dir = caproni_config.resolve_path(&openbao_repo.directory);
1056
1057 let mock_exec = MockCommandRunner::new();
1058 mock_exec.expect_read_stdout(
1059 "mise",
1060 &["which", "mirrord"],
1061 Ok("/bin/mirrord".to_string()),
1062 );
1063 mock_exec.expect_run_in_repository_matching(
1064 "/bin/mirrord",
1065 move |args| args.first().map(String::as_str) == Some(&repo_dir),
1066 Ok(()),
1067 );
1068
1069 let mgr = Manager::new(Box::new(mock_kubectl));
1070
1071 let mut procs = OrderedMap::new();
1072 procs.set(
1073 "bao".to_string(),
1074 ProcessConfig {
1075 command: "bin/bao server".to_string(),
1076 port: 8200,
1077 ..Default::default()
1078 },
1079 );
1080
1081 let reloader_config = ReloaderConfig {
1082 reloader_type: RELOADER_TYPE.to_string(),
1083 repository: "openbao".to_string(),
1084 mirror: Some(MirrorConfig {
1085 target: Some(MirrorTargetConfig {
1086 namespace: "gitlab".to_string(),
1087 kubernetes_type: "deployment".to_string(),
1088 name: "gitlab-openbao".to_string(),
1089 container: String::new(),
1090 }),
1091 edit_mode_processes: Some(procs),
1092 ..Default::default()
1093 }),
1094 ..Default::default()
1095 };
1096
1097 let _ = mgr.run(&mock_exec, &reloader_config, &caproni_config, "");
1098
1099 }
1103
1104 #[test]
1106 fn test_run_wraps_processes_except_local_context() {
1107 let (mgr, mock_exec) = new_mirrord_manager();
1108
1109 let caproni_path = std::env::current_exe()
1110 .expect("getting test binary path")
1111 .to_string_lossy()
1112 .into_owned();
1113
1114 mock_exec.expect_run_in_repository_matching(
1115 "/usr/local/bin/mirrord",
1116 |args| args.iter().any(|a| a == "exec"),
1117 Ok(()),
1118 );
1119 mock_exec.expect_run_in_repository_matching(
1120 &caproni_path,
1121 |args| {
1122 args.iter()
1123 .any(|a| a == format!("--context={}", latest::PROCESS_CONTEXT_LOCAL).as_str())
1124 },
1125 Ok(()),
1126 );
1127
1128 let mut procs = OrderedMap::new();
1129 procs.set(
1130 "puma".to_string(),
1131 ProcessConfig {
1132 command: "bin/puma".to_string(),
1133 ..Default::default()
1134 },
1135 );
1136 procs.set(
1137 "vite".to_string(),
1138 ProcessConfig {
1139 command: "bin/vite".to_string(),
1140 context: latest::PROCESS_CONTEXT_LOCAL.to_string(),
1141 ..Default::default()
1142 },
1143 );
1144
1145 run_gitlab_mirror(&mgr, &mock_exec, procs, "").unwrap();
1146
1147 let calls = mock_exec.recorded_run_in_repository_calls();
1148 assert_eq!(calls.len(), 2);
1149 assert!(
1150 calls
1151 .iter()
1152 .any(|c| c.name == "/usr/local/bin/mirrord" && c.args.contains(&"exec".to_string()))
1153 );
1154 assert!(calls.iter().any(|c| {
1155 c.args
1156 .contains(&format!("--context={}", latest::PROCESS_CONTEXT_LOCAL))
1157 }));
1158 }
1159
1160 #[test]
1162 fn test_run_pins_mirrord_to_kubeconfig_via_env() {
1163 const KUBECONFIG_PATH: &str = "/tmp/caproni-kubeconfig-123";
1164
1165 let (mgr, mock_exec) = new_mirrord_manager();
1166
1167 mock_exec.expect_run_in_repository_matching("/usr/local/bin/mirrord", |_| true, Ok(()));
1168
1169 let mut procs = OrderedMap::new();
1170 procs.set(
1171 "puma".to_string(),
1172 ProcessConfig {
1173 command: "bin/puma".to_string(),
1174 ..Default::default()
1175 },
1176 );
1177
1178 run_gitlab_mirror(&mgr, &mock_exec, procs, KUBECONFIG_PATH).unwrap();
1179
1180 let calls = mock_exec.recorded_run_in_repository_calls();
1181 let mirrord_call = calls
1182 .iter()
1183 .find(|c| c.name == "/usr/local/bin/mirrord")
1184 .expect("expected a recorded call to mirrord");
1185
1186 let env = mirrord_call
1187 .opts
1188 .as_ref()
1189 .and_then(|opts| opts.env.as_ref())
1190 .expect("expected mirrord call to carry env");
1191
1192 assert!(
1193 env.contains(&format!("KUBECONFIG={KUBECONFIG_PATH}")),
1194 "expected env to contain KUBECONFIG={KUBECONFIG_PATH}, got {env:?}"
1195 );
1196 }
1197
1198 #[test]
1200 fn test_prepare_from_reloader_configs_no_mirror_reloaders() {
1201 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
1202
1203 let reloaders = HashMap::from([(
1204 "skaffold-app".to_string(),
1205 ReloaderConfig {
1206 reloader_type: "skaffold".to_string(),
1207 ..Default::default()
1208 },
1209 )]);
1210
1211 let copies = mgr
1212 .prepare_from_reloader_configs(&reloaders, "busybox")
1213 .unwrap();
1214 assert!(copies.is_empty());
1215 }
1216
1217 #[test]
1219 fn test_prepare_from_reloader_configs_validation_error() {
1220 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
1221
1222 let make_reloader = || ReloaderConfig {
1223 reloader_type: "mirror".to_string(),
1224 mirror: Some(MirrorConfig {
1225 target: Some(MirrorTargetConfig {
1226 namespace: "default".to_string(),
1227 kubernetes_type: "deployment".to_string(),
1228 name: "my-app".to_string(),
1229 container: "web".to_string(),
1230 }),
1231 ..Default::default()
1232 }),
1233 ..Default::default()
1234 };
1235
1236 let reloaders = HashMap::from([
1238 ("app-a".to_string(), make_reloader()),
1239 ("app-b".to_string(), make_reloader()),
1240 ]);
1241
1242 let err = mgr
1243 .prepare_from_reloader_configs(&reloaders, "busybox")
1244 .unwrap_err();
1245 assert!(matches!(
1246 err,
1247 MirrorError::ValidatingTargets(
1248 crate::reloader::mirror::copy::CopyError::ValidatingTarget(_, _)
1249 )
1250 ));
1251 }
1252
1253 #[test]
1255 fn test_validate_valid_open_bao_config() {
1256 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
1257
1258 let mut repos = OrderedMap::new();
1259 repos.set(
1260 "openbao".to_string(),
1261 RepositoryConfig {
1262 repository: "https://github.com/openbao/openbao.git".to_string(),
1263 directory: "./openbao".to_string(),
1264 ..Default::default()
1265 },
1266 );
1267
1268 let mut procs = OrderedMap::new();
1269 procs.set(
1270 "bao".to_string(),
1271 ProcessConfig {
1272 command: "bin/bao server -config /srv/openbao/config/".to_string(),
1273 port: 8200,
1274 ..Default::default()
1275 },
1276 );
1277
1278 let reloader_config = ReloaderConfig {
1279 reloader_type: RELOADER_TYPE.to_string(),
1280 repository: "openbao".to_string(),
1281 edit_mode: true,
1282 mirror: Some(MirrorConfig {
1283 target: Some(MirrorTargetConfig {
1284 namespace: "gitlab".to_string(),
1285 kubernetes_type: "deployment".to_string(),
1286 name: "gitlab-openbao".to_string(),
1287 container: String::new(),
1288 }),
1289 filesystem: Some(xianyun_core::config::latest::FilesystemConfig {
1290 read_only: vec![
1291 "^/srv/openbao/config".to_string(),
1292 "^/srv/openbao/keys".to_string(),
1293 ],
1294 ..Default::default()
1295 }),
1296 edit_mode_processes: Some(procs),
1297 ..Default::default()
1298 }),
1299 ..Default::default()
1300 };
1301
1302 let caproni_config = CaproniConfig {
1303 repositories: Some(repos),
1304 ..Default::default()
1305 };
1306
1307 mgr.validate(&reloader_config, &caproni_config).unwrap();
1308 }
1309
1310 #[test]
1312 fn test_validate_empty_command() {
1313 let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
1314
1315 let mut repos = OrderedMap::new();
1316 repos.set(
1317 "openbao".to_string(),
1318 RepositoryConfig {
1319 repository: "https://github.com/openbao/openbao.git".to_string(),
1320 directory: "./openbao".to_string(),
1321 ..Default::default()
1322 },
1323 );
1324
1325 let mut procs = OrderedMap::new();
1326 procs.set("bao".to_string(), ProcessConfig::default());
1327
1328 let reloader_config = ReloaderConfig {
1329 reloader_type: RELOADER_TYPE.to_string(),
1330 repository: "openbao".to_string(),
1331 mirror: Some(MirrorConfig {
1332 target: Some(MirrorTargetConfig {
1333 namespace: "gitlab".to_string(),
1334 kubernetes_type: "deployment".to_string(),
1335 name: "gitlab-openbao".to_string(),
1336 container: String::new(),
1337 }),
1338 edit_mode_processes: Some(procs),
1339 ..Default::default()
1340 }),
1341 ..Default::default()
1342 };
1343
1344 let caproni_config = CaproniConfig {
1345 repositories: Some(repos),
1346 ..Default::default()
1347 };
1348
1349 let err = mgr.validate(&reloader_config, &caproni_config).unwrap_err();
1350 assert!(matches!(err, MirrorError::EmptyCommand(_)));
1351 }
1352}