Skip to main content

xianyun_deploy/reloader/mirror/
manager.rs

1//! Implements the reloader manager for mirror reloaders.
2//!
3//! Ported from `internal/reloader/mirror/manager.go`.
4
5use 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
15/// The reloader type identifier registered for mirror.
16pub const RELOADER_TYPE: &str = "mirror";
17
18/// The mirrord agent pod label, used to clean up leftover agents that
19/// may cause "Detected dirty iptables" errors on subsequent runs.
20const MIRRORD_AGENT_LABEL: &str = "app=mirrord";
21
22/// Errors from the mirror reloader manager.
23#[derive(Debug, Error)]
24pub enum MirrorError {
25    /// The reloader type is not `"mirror"`.
26    #[error("reloader type is not 'mirror'")]
27    InvalidReloaderType,
28    /// The mirror configuration is missing.
29    #[error("mirror configuration is missing")]
30    MirrorConfigMissing,
31    /// The target configuration is missing.
32    #[error("target configuration is missing")]
33    TargetConfigMissing,
34    /// A process has an empty command.
35    #[error("process {0:?}: empty command for process")]
36    EmptyCommand(String),
37    /// The referenced repository doesn't exist.
38    #[error("repository not found: {0}")]
39    RepositoryNotFound(String),
40    /// Stale `*-caproni` deployments were found.
41    #[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    /// The installed mirrord version is below the minimum required.
50    #[error("mirrord version too old: {1}. Please upgrade mirrord")]
51    MirrordVersionTooOld(String, #[source] xianyun_update::version::VersionError),
52    /// Validating mirror targets failed.
53    #[error("validating mirror targets: {0}")]
54    ValidatingTargets(#[source] CopyError),
55    /// Preparing or cleaning up deployment copies failed.
56    #[error(transparent)]
57    Copy(#[from] CopyError),
58    /// Getting the current executable's path failed.
59    #[error("getting executable path: {0}")]
60    ExecutablePath(#[source] std::io::Error),
61    /// Creating the edit-mode logs directory failed.
62    #[error("creating logs directory: {0}")]
63    CreateLogsDir(#[source] std::io::Error),
64    /// Resolving the mirrord executable's path failed.
65    #[error("finding mirrord executable: {0}")]
66    ResolveMirrordPath(#[source] xianyun_executor::exec::ExecError),
67    /// Generating the mirrord configuration file failed.
68    #[error("generating mirrord config: {0}")]
69    GenerateMirrordConfig(String),
70    /// Running the process supervisor failed.
71    #[error("running supervisor: {0}")]
72    Supervisor(#[source] xianyun_executor::exec::ExecError),
73}
74
75/// Implements the mirror reloader.
76pub struct Manager {
77    pub(super) kubectl: Box<dyn KubectlBackend + Send + Sync>,
78}
79
80/// Groups [`Manager::supervise_processes`]'s leaf parameters, cutting it
81/// from 8 arguments to 4 (clippy's `too_many_arguments` default
82/// threshold is 7) without changing any behavior.
83struct 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    /// Creates a new mirror reloader manager.
92    #[must_use]
93    pub fn new(kubectl_backend: Box<dyn KubectlBackend + Send + Sync>) -> Self {
94        Self {
95            kubectl: kubectl_backend,
96        }
97    }
98
99    /// Returns the reloader type identifier (`"mirror"`).
100    #[must_use]
101    pub fn reloader_type(&self) -> &'static str {
102        RELOADER_TYPE
103    }
104
105    /// Verifies mirrord version meets minimum requirements and that no
106    /// stale `-caproni` copy exists for this reloader's target.
107    ///
108    /// `executor` is threaded explicitly rather than read off an
109    /// ambient context, unlike the Go original's `executor.FromContext`:
110    /// `xianyun_executor::exec::context::{from_context, with_context}`
111    /// are themselves unimplemented stubs in a sibling crate outside
112    /// this task's scope, so this port takes the `CommandRunner`
113    /// directly as a parameter instead of threading it through a
114    /// context object. See `PORT_NOTES.md`.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`MirrorError::MirrordVersionTooOld`] if the installed
119    /// mirrord version is below the configured minimum, or
120    /// [`MirrorError::CaproniDeploymentsExist`] if a stale `-caproni`
121    /// copy of the target already exists.
122    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, &copy_name, &target.namespace)
143        {
144            // A kubectl error means the resource doesn't exist, which is the
145            // expected state.
146            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        // Tool not found is not a precondition failure; Run will fail with a
171        // clearer error.
172        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    /// Runs the mirror reloader, blocking until the context is
182    /// cancelled. Deployment copies must be prepared by the caller
183    /// before `run` is invoked (see
184    /// [`Manager::cleanup_copy_deployments`]).
185    ///
186    /// This method:
187    /// - Generates a mirrord configuration file
188    /// - Starts `caproni edit-mode-process-supervisor` wrapped in
189    ///   mirrord
190    /// - Runs processes tagged `context: local` outside mirrord
191    ///
192    /// `kubeconfig_path` is the path to a temporary kubeconfig for the
193    /// caproni-selected cluster (created and owned by the caller).
194    /// Implementations that launch external tooling pin `KUBECONFIG` to
195    /// it rather than relying on the user's global current-context. It
196    /// may be empty.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`MirrorError`] if the config is invalid, no repository
201    /// is configured, the mirrord config cannot be generated, or the
202    /// underlying process supervision fails.
203    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        // If no processes configured, this is a no-op.
225        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    /// Runs mirrord-wrapped processes and locally-run processes,
268    /// blocking until they exit or fail. Processes tagged `context:
269    /// local` run outside mirrord; every other process shares a single
270    /// mirrord session.
271    ///
272    /// The Go original runs both groups concurrently via `errgroup`,
273    /// waiting for whichever exits or fails first. This port runs them
274    /// sequentially instead: `run_in_repository` is expected to block
275    /// until its process exits, exactly like the Go original, so real
276    /// concurrency would require spawning OS threads for a benefit this
277    /// crate's tests (mocked, immediate-return `run_in_repository`
278    /// calls) do not exercise. Revisit if a real concurrent supervisor
279    /// is needed.
280    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            // Pin mirrord to the caproni-selected kubeconfig via KUBECONFIG, the
323            // same way the kubectl and helm backends do. Without this, mirrord
324            // falls back to the global current-context and can target a
325            // different cluster than the hooks and kubectl backend (see #175).
326            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    /// Collects and validates mirror targets from the given reloader
358    /// configs, then prepares a coordinated set of deployment copies.
359    /// Returns an empty list if there are no mirror targets.
360    ///
361    /// # Errors
362    ///
363    /// Returns [`MirrorError::ValidatingTargets`] if the targets
364    /// conflict, or [`MirrorError::Copy`] if preparing a copy fails.
365    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    /// Deletes all `-caproni` deployment copies and scales the
382    /// originals back up. Cleanup failures are logged, not returned:
383    /// the caller is typically shutting down and cannot act on them.
384    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    /// Returns the current status of the reloader.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`MirrorError`] if the config is not a mirror reloader
406    /// or has no target configured.
407    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, &copy_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    /// Removes leftover mirrord agent pods that may cause "Detected
453    /// dirty iptables" errors on subsequent runs. This must be called
454    /// once before any reloaders are started, not per-reloader, to
455    /// avoid killing a live agent belonging to a concurrently running
456    /// reloader. Failures are logged, not returned: this cleanup is
457    /// best-effort.
458    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    /// Returns the absolute path to the mirrord executable. Tries `mise
483    /// which mirrord` first to get the real installed path, avoiding
484    /// mise shim symlinks that break when executed outside the
485    /// repository directory. Falls back to `look_path` for non-mise
486    /// installations of mirrord.
487    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    /// Generates a mirrord configuration file for the reloader. The
501    /// returned [`Tempfile`](xianyun_executor::tempfile::Tempfile) is
502    /// removed when dropped.
503    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    /// Validates the mirror reloader configuration. Mirror reloaders
517    /// don't have file path dependencies, so this primarily validates
518    /// the configuration structure.
519    ///
520    /// # Errors
521    ///
522    /// Returns [`MirrorError`] if the config is not a mirror reloader,
523    /// has no target or referenced repository configured, or any
524    /// edit-mode process has an empty command.
525    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
560/// Returns the process environment with `KUBECONFIG` pointing at the
561/// caproni-selected kubeconfig. When `kubeconfig_path` is empty, returns
562/// `None`, so the process inherits the ambient environment unchanged.
563fn 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    // Ported from internal/reloader/mirror/manager_test.go:19 (TestCleanupStaleMirrordAgents_DeletesStaleAgents)
591    #[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    // Ported from internal/reloader/mirror/manager_test.go:31 (TestCleanupStaleMirrordAgents_NoStaleAgents)
605    #[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    // Ported from internal/reloader/mirror/manager_test.go:43 (TestCleanupStaleMirrordAgents_ErrorIsNonFatal)
615    #[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        // Should not panic or return error -- cleanup failures are non-fatal.
623        mgr.cleanup_stale_mirrord_agents("gitlab");
624    }
625
626    // Ported from internal/reloader/mirror/manager_test.go:282 (TestCleanupStaleMirrordAgents_CalledExternally)
627    #[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    // Ported from internal/reloader/mirror/manager_test.go:56 (TestCheckPreconditions)
636    #[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    // Ported from internal/reloader/mirror/manager_test.go:125 (TestCheckPreconditions_MirrordVersionCheck)
738    #[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    // Ported from internal/reloader/mirror/manager_test.go:203 (TestRun_InvalidReloaderType)
855    #[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    // Ported from internal/reloader/mirror/manager_test.go:216 (TestRun_MissingMirrorConfig)
872    #[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    // Ported from internal/reloader/mirror/manager_test.go:230 (TestRun_NoProcessesIsNoop)
890    #[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    // Ported from internal/reloader/mirror/manager_test.go:259 (TestRun_MissingRepository)
930    #[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    /// Builds a [`Manager`] with no kubectl expectations (unused by
962    /// `run`) plus a mock executor primed so `resolve_mirrord_path`
963    /// resolves via `mise which mirrord` -- mirroring the Go test
964    /// helper `newMirrordManager`.
965    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    /// Runs `mgr` against a standard single-repo gitlab mirror reloader
979    /// with the given processes and kubeconfig path, mirroring the Go
980    /// test helper `runGitlabMirror`.
981    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    // Ported from internal/reloader/mirror/manager_test.go:296 (TestRun_DoesNotCleanupAgents)
1030    #[test]
1031    fn test_run_does_not_cleanup_agents() {
1032        // Run() should NOT call delete_pods_by_label -- cleanup is the
1033        // caller's responsibility. MockKubectlBackend::new() queues no
1034        // expectation for delete_pods_by_label, so any call would panic.
1035        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        // mock_kubectl has no expectations for delete_pods_by_label --
1100        // MockKubectlBackend panics on an unexpected call, so if Run()
1101        // ever called it, we would have already panicked above.
1102    }
1103
1104    // Ported from internal/reloader/mirror/manager_test.go:388 (TestRun_WrapsProcessesExceptLocalContext)
1105    #[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    // Ported from internal/reloader/mirror/manager_test.go:416 (TestRun_PinsMirrordToKubeconfigViaEnv)
1161    #[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    // Ported from internal/reloader/mirror/manager_test.go:438 (TestPrepareFromReloaderConfigs_NoMirrorReloaders)
1199    #[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    // Ported from internal/reloader/mirror/manager_test.go:453 (TestPrepareFromReloaderConfigs_ValidationError)
1218    #[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        // Two reloaders targeting the same container -- should fail validation.
1237        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    // Ported from internal/reloader/mirror/manager_test.go:485 (TestValidate_ValidOpenBaoConfig)
1254    #[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    // Ported from internal/reloader/mirror/manager_test.go:525 (TestValidate_EmptyCommand)
1311    #[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}