Skip to main content

xianyun_deploy/reloader/skaffold/
manager.rs

1//! Implements the reloader manager for skaffold reloaders.
2//!
3//! Ported from `internal/reloader/skaffold/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::streamed::ExecStreamedOpts;
11use xianyun_executor::exec::{CommandRunner, ExecError};
12
13use crate::reloader::{State, Status};
14
15/// The reloader type identifier registered for skaffold.
16pub const RELOADER_TYPE: &str = "skaffold";
17
18/// Errors from the skaffold reloader manager.
19#[derive(Debug, Error)]
20pub enum SkaffoldError {
21    /// The reloader type is not `"skaffold"`.
22    #[error("reloader type is not 'skaffold'")]
23    InvalidReloaderType,
24    /// The skaffold configuration is missing.
25    #[error("skaffold configuration is missing")]
26    SkaffoldConfigMissing,
27    /// `config_file` is not configured.
28    #[error("config_file not configured")]
29    ConfigFileNotConfigured,
30    /// The referenced repository doesn't exist.
31    #[error("repository not found: {0}")]
32    RepositoryNotFound(String),
33    /// The skaffold config file doesn't exist.
34    #[error("skaffold config file not found: {0}")]
35    ConfigFileNotFound(String),
36    /// The installed skaffold version is below the minimum required.
37    #[error("skaffold version too old: {1}. Please upgrade skaffold")]
38    SkaffoldVersionTooOld(String, #[source] xianyun_update::version::VersionError),
39    /// Checking the repository directory failed for a reason other
40    /// than non-existence.
41    #[error("checking repository directory: {0}")]
42    CheckRepositoryDir(#[source] std::io::Error),
43    /// Running `skaffold dev` failed.
44    #[error("running skaffold dev: {0}")]
45    RunSkaffoldDev(#[source] ExecError),
46}
47
48/// Implements the skaffold reloader.
49pub struct Manager {
50    #[allow(dead_code)]
51    kubectl: Box<dyn KubectlBackend + Send + Sync>,
52}
53
54impl Manager {
55    /// Creates a new skaffold reloader manager.
56    #[must_use]
57    pub fn new(kubectl_backend: Box<dyn KubectlBackend + Send + Sync>) -> Self {
58        Self {
59            kubectl: kubectl_backend,
60        }
61    }
62
63    /// Returns the reloader type identifier (`"skaffold"`).
64    #[must_use]
65    pub fn reloader_type(&self) -> &'static str {
66        RELOADER_TYPE
67    }
68
69    /// Verifies skaffold version meets minimum requirements.
70    ///
71    /// `executor` is threaded explicitly rather than read off an
72    /// ambient context; see the equivalent note on
73    /// `reloader::mirror::manager::Manager::check_preconditions`.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`SkaffoldError::SkaffoldVersionTooOld`] if the
78    /// installed skaffold version is below the configured minimum.
79    pub fn check_preconditions(
80        &self,
81        executor: &dyn CommandRunner,
82        _reloader_config: &ReloaderConfig,
83        _caproni_config: &CaproniConfig,
84        get_tool_version: &dyn Fn(&str) -> Option<String>,
85    ) -> Result<(), SkaffoldError> {
86        self.check_skaffold_version(executor, get_tool_version)
87    }
88
89    fn check_skaffold_version(
90        &self,
91        executor: &dyn CommandRunner,
92        get_tool_version: &dyn Fn(&str) -> Option<String>,
93    ) -> Result<(), SkaffoldError> {
94        let Some(required_version) = get_tool_version("GL_ASDF_SKAFFOLD_VERSION") else {
95            return Ok(());
96        };
97
98        // Tool not found is not a precondition failure; run() will fail with
99        // a clearer error.
100        let Ok(installed_version) = executor.read_stdout("skaffold", &["version".to_string()])
101        else {
102            return Ok(());
103        };
104
105        xianyun_update::version::check_minimum_version(&installed_version, &required_version)
106            .map_err(|e| SkaffoldError::SkaffoldVersionTooOld(installed_version.clone(), e))
107    }
108
109    /// Runs the reloader, blocking until the process exits. Runs
110    /// `skaffold dev` in the repository directory.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`SkaffoldError`] if the config is invalid, no
115    /// repository is configured, or `skaffold dev` fails for a reason
116    /// other than cancellation.
117    pub fn run(
118        &self,
119        executor: &dyn CommandRunner,
120        reloader_config: &ReloaderConfig,
121        caproni_config: &CaproniConfig,
122        kubeconfig_path: &str,
123    ) -> Result<(), SkaffoldError> {
124        if reloader_config.reloader_type != RELOADER_TYPE {
125            return Err(SkaffoldError::InvalidReloaderType);
126        }
127
128        let Some(skaffold) = reloader_config.skaffold.as_ref() else {
129            return Err(SkaffoldError::SkaffoldConfigMissing);
130        };
131
132        let repo_config = caproni_config
133            .repositories
134            .as_ref()
135            .and_then(|repos| repos.get(&reloader_config.repository))
136            .ok_or_else(|| SkaffoldError::RepositoryNotFound(reloader_config.repository.clone()))?;
137
138        if skaffold.config_file.is_empty() {
139            return Err(SkaffoldError::ConfigFileNotConfigured);
140        }
141
142        let mut dev_args = vec![
143            "dev".to_string(),
144            "--filename".to_string(),
145            skaffold.config_file.clone(),
146        ];
147        dev_args.extend(self.build_skaffold_args(reloader_config, kubeconfig_path));
148
149        tracing::info!(
150            reloader = %reloader_config.repository,
151            namespace = %skaffold.namespace,
152            config_file = %skaffold.config_file,
153            repository = %repo_config.directory,
154            "starting skaffold dev"
155        );
156
157        let repo_dir = caproni_config.resolve_path(&repo_config.directory);
158
159        let opts = ExecStreamedOpts {
160            use_scrolling_area: false,
161            process_descriptive_name: Some("skaffold".to_string()),
162            ..Default::default()
163        };
164
165        match executor.run_in_repository(&repo_dir, "skaffold", &dev_args, Some(opts)) {
166            Ok(()) => Ok(()),
167            // Context cancellation is graceful shutdown, matching the Go
168            // original's `if ctx.Err() != nil { return nil }` check (this
169            // port has no ambient context to re-check independently of the
170            // error `run_in_repository` itself returned).
171            Err(ExecError::Cancelled) => {
172                tracing::info!(
173                    reloader = %reloader_config.repository,
174                    "skaffold dev stopped due to context cancellation"
175                );
176                Ok(())
177            }
178            Err(e) => Err(SkaffoldError::RunSkaffoldDev(e)),
179        }
180    }
181
182    /// Returns the current status of the reloader.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`SkaffoldError`] if the config is invalid, or
187    /// [`SkaffoldError::RepositoryNotFound`] if the referenced
188    /// repository doesn't exist (a [`Status`] with
189    /// [`State::NotReady`] is still returned alongside the error, as
190    /// in the Go original).
191    pub fn status(
192        &self,
193        reloader_config: &ReloaderConfig,
194        caproni_config: &CaproniConfig,
195    ) -> Result<Status, SkaffoldError> {
196        if reloader_config.reloader_type != RELOADER_TYPE {
197            return Err(SkaffoldError::InvalidReloaderType);
198        }
199
200        if reloader_config.skaffold.is_none() {
201            return Err(SkaffoldError::SkaffoldConfigMissing);
202        }
203
204        // For skaffold, check if the repository exists and is cloned.
205        let Some(repo_config) = caproni_config
206            .repositories
207            .as_ref()
208            .and_then(|repos| repos.get(&reloader_config.repository))
209        else {
210            return Err(SkaffoldError::RepositoryNotFound(
211                reloader_config.repository.clone(),
212            ));
213        };
214
215        let repo_dir = caproni_config.resolve_path(&repo_config.directory);
216
217        match std::fs::metadata(&repo_dir) {
218            Ok(_) => Ok(Status {
219                state: State::Ready,
220                reloader_type: RELOADER_TYPE.to_string(),
221            }),
222            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Status {
223                state: State::NotReady,
224                reloader_type: RELOADER_TYPE.to_string(),
225            }),
226            Err(e) => Err(SkaffoldError::CheckRepositoryDir(e)),
227        }
228    }
229
230    /// Builds the command-line arguments for `skaffold dev`. When
231    /// `kubeconfig_path` is non-empty, pins skaffold to that kubeconfig
232    /// via the `--kubeconfig` flag (skaffold's highest-priority
233    /// selector) so it targets the caproni-selected cluster instead of
234    /// the user's global current-context (see #175).
235    fn build_skaffold_args(
236        &self,
237        reloader_config: &ReloaderConfig,
238        kubeconfig_path: &str,
239    ) -> Vec<String> {
240        let skaffold = reloader_config
241            .skaffold
242            .as_ref()
243            .expect("caller has already checked skaffold config is present");
244
245        let mut args = vec!["--namespace".to_string(), skaffold.namespace.clone()];
246
247        if !kubeconfig_path.is_empty() {
248            args.push("--kubeconfig".to_string());
249            args.push(kubeconfig_path.to_string());
250        }
251
252        args
253    }
254
255    /// Validates the skaffold reloader configuration and referenced
256    /// files.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`SkaffoldError`] if the config is invalid, no
261    /// repository is configured, or the configured config file does
262    /// not exist within a cloned repository.
263    pub fn validate(
264        &self,
265        reloader_config: &ReloaderConfig,
266        caproni_config: &CaproniConfig,
267    ) -> Result<(), SkaffoldError> {
268        if reloader_config.reloader_type != RELOADER_TYPE {
269            return Err(SkaffoldError::InvalidReloaderType);
270        }
271
272        let Some(skaffold) = reloader_config.skaffold.as_ref() else {
273            return Err(SkaffoldError::SkaffoldConfigMissing);
274        };
275
276        if skaffold.config_file.is_empty() {
277            return Err(SkaffoldError::ConfigFileNotConfigured);
278        }
279
280        let repo_config = caproni_config
281            .repositories
282            .as_ref()
283            .and_then(|repos| repos.get(&reloader_config.repository))
284            .ok_or_else(|| SkaffoldError::RepositoryNotFound(reloader_config.repository.clone()))?;
285
286        let repo_dir = caproni_config.resolve_path(&repo_config.directory);
287
288        // Validation should pass if the repository directory doesn't exist
289        // yet -- an uncloned repository is not an error until Run needs it.
290        if std::fs::metadata(&repo_dir).is_err() {
291            return Ok(());
292        }
293
294        let config_file_path = Path::new(&repo_dir).join(&skaffold.config_file);
295
296        if std::fs::metadata(&config_file_path).is_err() {
297            return Err(SkaffoldError::ConfigFileNotFound(
298                config_file_path.to_string_lossy().into_owned(),
299            ));
300        }
301
302        Ok(())
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use xianyun_core::config::latest::{
309        CaproniConfig, ReloaderConfig, RepositoryConfig, SkaffoldConfig,
310    };
311    use xianyun_core::orderedmap::OrderedMap;
312    use xianyun_executor::exec::ExecError;
313    use xianyun_testutil::MockCommandRunner;
314
315    use super::*;
316    use crate::reloader::mirror::test_support::MockKubectlBackend;
317
318    // Ported from internal/reloader/skaffold/manager_test.go:22 (TestType)
319    #[test]
320    fn test_type() {
321        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
322        assert_eq!(mgr.reloader_type(), "skaffold");
323    }
324
325    // Ported from internal/reloader/skaffold/manager_test.go:31 (TestCheckPreconditions_SkaffoldVersionCheck)
326    #[test]
327    fn test_check_preconditions_skaffold_version_check() {
328        struct Case {
329            name: &'static str,
330            required_version: &'static str,
331            installed_output: &'static str,
332            installed_err: bool,
333            want_too_old: bool,
334            want_err_contains: &'static str,
335        }
336
337        let cases = vec![
338            Case {
339                name: "version meets minimum",
340                required_version: "2.17.3",
341                installed_output: "v2.17.3",
342                installed_err: false,
343                want_too_old: false,
344                want_err_contains: "",
345            },
346            Case {
347                name: "version exceeds minimum",
348                required_version: "2.17.3",
349                installed_output: "v2.20.0",
350                installed_err: false,
351                want_too_old: false,
352                want_err_contains: "",
353            },
354            Case {
355                name: "version too old",
356                required_version: "2.17.3",
357                installed_output: "v1.39.0",
358                installed_err: false,
359                want_too_old: true,
360                want_err_contains: "v1.39.0",
361            },
362            Case {
363                name: "minor version too old",
364                required_version: "2.17.3",
365                installed_output: "v2.10.0",
366                installed_err: false,
367                want_too_old: true,
368                want_err_contains: "minimum required v2.17",
369            },
370            Case {
371                name: "skaffold not installed",
372                required_version: "2.17.3",
373                installed_output: "",
374                installed_err: true,
375                want_too_old: false,
376                want_err_contains: "",
377            },
378            Case {
379                name: "version key not configured",
380                required_version: "",
381                installed_output: "",
382                installed_err: false,
383                want_too_old: false,
384                want_err_contains: "",
385            },
386        ];
387
388        for case in cases {
389            let mock_exec = MockCommandRunner::new();
390
391            if !case.required_version.is_empty() {
392                if case.installed_err {
393                    mock_exec.expect_read_stdout(
394                        "skaffold",
395                        &["version"],
396                        Err(ExecError::CommandFailed(1)),
397                    );
398                } else {
399                    mock_exec.expect_read_stdout(
400                        "skaffold",
401                        &["version"],
402                        Ok(case.installed_output.to_string()),
403                    );
404                }
405            }
406
407            let get_tool_version = move |_: &str| {
408                if case.required_version.is_empty() {
409                    None
410                } else {
411                    Some(case.required_version.to_string())
412                }
413            };
414
415            let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
416
417            let result = mgr.check_preconditions(
418                &mock_exec,
419                &ReloaderConfig::default(),
420                &CaproniConfig::default(),
421                &get_tool_version,
422            );
423
424            if case.want_too_old {
425                let err = result.unwrap_err();
426                assert!(
427                    matches!(err, SkaffoldError::SkaffoldVersionTooOld(_, _)),
428                    "{}: expected SkaffoldVersionTooOld, got {err:?}",
429                    case.name
430                );
431                assert!(
432                    err.to_string().contains(case.want_err_contains),
433                    "{}: {err}",
434                    case.name
435                );
436            } else {
437                result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
438            }
439        }
440    }
441
442    // Ported from internal/reloader/skaffold/manager_test.go:109 (TestRun_InvalidReloaderType)
443    #[test]
444    fn test_run_invalid_reloader_type() {
445        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
446        let mock_exec = MockCommandRunner::new();
447
448        let reloader_config = ReloaderConfig {
449            reloader_type: "mirror".to_string(),
450            ..Default::default()
451        };
452
453        let err = mgr
454            .run(&mock_exec, &reloader_config, &CaproniConfig::default(), "")
455            .unwrap_err();
456        assert!(matches!(err, SkaffoldError::InvalidReloaderType));
457    }
458
459    // Ported from internal/reloader/skaffold/manager_test.go:122 (TestRun_MissingSkaffoldConfig)
460    #[test]
461    fn test_run_missing_skaffold_config() {
462        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
463        let mock_exec = MockCommandRunner::new();
464
465        let reloader_config = ReloaderConfig {
466            reloader_type: RELOADER_TYPE.to_string(),
467            skaffold: None,
468            ..Default::default()
469        };
470
471        let err = mgr
472            .run(&mock_exec, &reloader_config, &CaproniConfig::default(), "")
473            .unwrap_err();
474        assert!(matches!(err, SkaffoldError::SkaffoldConfigMissing));
475    }
476
477    // Ported from internal/reloader/skaffold/manager_test.go:136 (TestRun_MissingRepository)
478    #[test]
479    fn test_run_missing_repository() {
480        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
481        let mock_exec = MockCommandRunner::new();
482
483        let reloader_config = ReloaderConfig {
484            reloader_type: RELOADER_TYPE.to_string(),
485            repository: "nonexistent".to_string(),
486            skaffold: Some(SkaffoldConfig {
487                namespace: "gitlab".to_string(),
488                config_file: "skaffold.yaml".to_string(),
489            }),
490            ..Default::default()
491        };
492
493        let caproni_config = CaproniConfig {
494            repositories: Some(OrderedMap::new()),
495            ..Default::default()
496        };
497
498        let err = mgr
499            .run(&mock_exec, &reloader_config, &caproni_config, "")
500            .unwrap_err();
501        assert!(matches!(err, SkaffoldError::RepositoryNotFound(_)));
502    }
503
504    // Ported from internal/reloader/skaffold/manager_test.go:156 (TestRun_ConfigFileNotConfigured)
505    #[test]
506    fn test_run_config_file_not_configured() {
507        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
508        let mock_exec = MockCommandRunner::new();
509
510        let mut repos = OrderedMap::new();
511        repos.set(
512            "gitlab".to_string(),
513            RepositoryConfig {
514                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
515                directory: "./gitlab".to_string(),
516                ..Default::default()
517            },
518        );
519
520        let reloader_config = ReloaderConfig {
521            reloader_type: RELOADER_TYPE.to_string(),
522            repository: "gitlab".to_string(),
523            skaffold: Some(SkaffoldConfig {
524                namespace: "gitlab".to_string(),
525                config_file: String::new(),
526            }),
527            ..Default::default()
528        };
529
530        let caproni_config = CaproniConfig {
531            repositories: Some(repos),
532            ..Default::default()
533        };
534
535        let err = mgr
536            .run(&mock_exec, &reloader_config, &caproni_config, "")
537            .unwrap_err();
538        assert!(matches!(err, SkaffoldError::ConfigFileNotConfigured));
539    }
540
541    // Ported from internal/reloader/skaffold/manager_test.go:182 (TestRun_Success)
542    #[test]
543    fn test_run_success() {
544        let mock_exec = MockCommandRunner::new();
545        mock_exec.expect_run_in_repository_matching(
546            "skaffold",
547            |args| {
548                args.len() == 6
549                    && args[1..].iter().map(String::as_str).collect::<Vec<_>>()
550                        == [
551                            "dev",
552                            "--filename",
553                            "skaffold.yaml",
554                            "--namespace",
555                            "gitlab",
556                        ]
557            },
558            Ok(()),
559        );
560
561        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
562
563        let mut repos = OrderedMap::new();
564        repos.set(
565            "gitlab".to_string(),
566            RepositoryConfig {
567                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
568                directory: "./gitlab".to_string(),
569                ..Default::default()
570            },
571        );
572
573        let reloader_config = ReloaderConfig {
574            reloader_type: RELOADER_TYPE.to_string(),
575            repository: "gitlab".to_string(),
576            skaffold: Some(SkaffoldConfig {
577                namespace: "gitlab".to_string(),
578                config_file: "skaffold.yaml".to_string(),
579            }),
580            ..Default::default()
581        };
582
583        let caproni_config = CaproniConfig {
584            repositories: Some(repos),
585            config_dir: "/tmp".to_string(),
586            ..Default::default()
587        };
588
589        mgr.run(&mock_exec, &reloader_config, &caproni_config, "")
590            .unwrap();
591    }
592
593    // Ported from internal/reloader/skaffold/manager_test.go:227 (TestRun_PinsKubeconfigViaFlag)
594    #[test]
595    fn test_run_pins_kubeconfig_via_flag() {
596        let mock_exec = MockCommandRunner::new();
597        mock_exec.expect_run_in_repository_matching(
598            "skaffold",
599            |args| {
600                args.len() == 8
601                    && args[1..].iter().map(String::as_str).collect::<Vec<_>>()
602                        == [
603                            "dev",
604                            "--filename",
605                            "skaffold.yaml",
606                            "--namespace",
607                            "gitlab",
608                            "--kubeconfig",
609                            "/tmp/caproni-kubeconfig-123",
610                        ]
611            },
612            Ok(()),
613        );
614
615        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
616
617        let mut repos = OrderedMap::new();
618        repos.set(
619            "gitlab".to_string(),
620            RepositoryConfig {
621                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
622                directory: "./gitlab".to_string(),
623                ..Default::default()
624            },
625        );
626
627        let reloader_config = ReloaderConfig {
628            reloader_type: RELOADER_TYPE.to_string(),
629            repository: "gitlab".to_string(),
630            skaffold: Some(SkaffoldConfig {
631                namespace: "gitlab".to_string(),
632                config_file: "skaffold.yaml".to_string(),
633            }),
634            ..Default::default()
635        };
636
637        let caproni_config = CaproniConfig {
638            repositories: Some(repos),
639            config_dir: "/tmp".to_string(),
640            ..Default::default()
641        };
642
643        mgr.run(
644            &mock_exec,
645            &reloader_config,
646            &caproni_config,
647            "/tmp/caproni-kubeconfig-123",
648        )
649        .unwrap();
650    }
651
652    // Ported from internal/reloader/skaffold/manager_test.go:263 (TestRun_ContextCancellation)
653    #[test]
654    fn test_run_context_cancellation() {
655        let mock_exec = MockCommandRunner::new();
656        mock_exec.expect_run_in_repository_matching(
657            "skaffold",
658            |_args| true,
659            Err(ExecError::Cancelled),
660        );
661
662        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
663
664        let mut repos = OrderedMap::new();
665        repos.set(
666            "gitlab".to_string(),
667            RepositoryConfig {
668                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
669                directory: "./gitlab".to_string(),
670                ..Default::default()
671            },
672        );
673
674        let reloader_config = ReloaderConfig {
675            reloader_type: RELOADER_TYPE.to_string(),
676            repository: "gitlab".to_string(),
677            skaffold: Some(SkaffoldConfig {
678                namespace: "gitlab".to_string(),
679                config_file: "skaffold.yaml".to_string(),
680            }),
681            ..Default::default()
682        };
683
684        let caproni_config = CaproniConfig {
685            repositories: Some(repos),
686            config_dir: "/tmp".to_string(),
687            ..Default::default()
688        };
689
690        // Context cancellation should be treated as graceful shutdown (no error).
691        mgr.run(&mock_exec, &reloader_config, &caproni_config, "")
692            .unwrap();
693    }
694
695    // Ported from internal/reloader/skaffold/manager_test.go:302 (TestStatus_InvalidReloaderType)
696    #[test]
697    fn test_status_invalid_reloader_type() {
698        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
699
700        let reloader_config = ReloaderConfig {
701            reloader_type: "mirror".to_string(),
702            ..Default::default()
703        };
704
705        let err = mgr
706            .status(&reloader_config, &CaproniConfig::default())
707            .unwrap_err();
708        assert!(matches!(err, SkaffoldError::InvalidReloaderType));
709    }
710
711    // Ported from internal/reloader/skaffold/manager_test.go:316 (TestStatus_MissingSkaffoldConfig)
712    #[test]
713    fn test_status_missing_skaffold_config() {
714        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
715
716        let reloader_config = ReloaderConfig {
717            reloader_type: RELOADER_TYPE.to_string(),
718            skaffold: None,
719            ..Default::default()
720        };
721
722        let err = mgr
723            .status(&reloader_config, &CaproniConfig::default())
724            .unwrap_err();
725        assert!(matches!(err, SkaffoldError::SkaffoldConfigMissing));
726    }
727
728    // Ported from internal/reloader/skaffold/manager_test.go:331 (TestStatus_RepositoryNotFound)
729    #[test]
730    fn test_status_repository_not_found() {
731        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
732
733        let reloader_config = ReloaderConfig {
734            reloader_type: RELOADER_TYPE.to_string(),
735            repository: "nonexistent".to_string(),
736            skaffold: Some(SkaffoldConfig {
737                namespace: "gitlab".to_string(),
738                config_file: "skaffold.yaml".to_string(),
739            }),
740            ..Default::default()
741        };
742
743        let caproni_config = CaproniConfig {
744            repositories: Some(OrderedMap::new()),
745            ..Default::default()
746        };
747
748        let err = mgr.status(&reloader_config, &caproni_config).unwrap_err();
749        assert!(matches!(err, SkaffoldError::RepositoryNotFound(_)));
750    }
751
752    // Ported from internal/reloader/skaffold/manager_test.go:353 (TestStatus_RepositoryNotCloned)
753    #[test]
754    fn test_status_repository_not_cloned() {
755        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
756
757        let mut repos = OrderedMap::new();
758        repos.set(
759            "gitlab".to_string(),
760            RepositoryConfig {
761                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
762                directory: "./nonexistent-repo".to_string(),
763                ..Default::default()
764            },
765        );
766
767        let reloader_config = ReloaderConfig {
768            reloader_type: RELOADER_TYPE.to_string(),
769            repository: "gitlab".to_string(),
770            skaffold: Some(SkaffoldConfig {
771                namespace: "gitlab".to_string(),
772                config_file: "skaffold.yaml".to_string(),
773            }),
774            ..Default::default()
775        };
776
777        let caproni_config = CaproniConfig {
778            repositories: Some(repos),
779            config_dir: "/tmp".to_string(),
780            ..Default::default()
781        };
782
783        let status = mgr.status(&reloader_config, &caproni_config).unwrap();
784        assert_eq!(status.state, State::NotReady);
785        assert_eq!(status.reloader_type, "skaffold");
786    }
787
788    // Ported from internal/reloader/skaffold/manager_test.go:383 (TestStatus_Ready)
789    #[test]
790    fn test_status_ready() {
791        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
792
793        let tmp_dir = tempfile::tempdir().unwrap();
794        let tmp_dir_str = tmp_dir.path().to_str().unwrap().to_string();
795
796        let mut repos = OrderedMap::new();
797        repos.set(
798            "gitlab".to_string(),
799            RepositoryConfig {
800                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
801                directory: tmp_dir_str.clone(),
802                ..Default::default()
803            },
804        );
805
806        let reloader_config = ReloaderConfig {
807            reloader_type: RELOADER_TYPE.to_string(),
808            repository: "gitlab".to_string(),
809            skaffold: Some(SkaffoldConfig {
810                namespace: "gitlab".to_string(),
811                config_file: "skaffold.yaml".to_string(),
812            }),
813            ..Default::default()
814        };
815
816        let caproni_config = CaproniConfig {
817            repositories: Some(repos),
818            config_dir: tmp_dir_str,
819            ..Default::default()
820        };
821
822        let status = mgr.status(&reloader_config, &caproni_config).unwrap();
823        assert_eq!(status.state, State::Ready);
824        assert_eq!(status.reloader_type, "skaffold");
825    }
826
827    // Ported from internal/reloader/skaffold/manager_test.go:416 (TestValidate_InvalidReloaderType)
828    #[test]
829    fn test_validate_invalid_reloader_type() {
830        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
831
832        let reloader_config = ReloaderConfig {
833            reloader_type: "mirror".to_string(),
834            ..Default::default()
835        };
836
837        let err = mgr
838            .validate(&reloader_config, &CaproniConfig::default())
839            .unwrap_err();
840        assert!(matches!(err, SkaffoldError::InvalidReloaderType));
841    }
842
843    // Ported from internal/reloader/skaffold/manager_test.go:429 (TestValidate_MissingSkaffoldConfig)
844    #[test]
845    fn test_validate_missing_skaffold_config() {
846        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
847
848        let reloader_config = ReloaderConfig {
849            reloader_type: RELOADER_TYPE.to_string(),
850            skaffold: None,
851            ..Default::default()
852        };
853
854        let err = mgr
855            .validate(&reloader_config, &CaproniConfig::default())
856            .unwrap_err();
857        assert!(matches!(err, SkaffoldError::SkaffoldConfigMissing));
858    }
859
860    // Ported from internal/reloader/skaffold/manager_test.go:443 (TestValidate_ConfigFileNotConfigured)
861    #[test]
862    fn test_validate_config_file_not_configured() {
863        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
864
865        let reloader_config = ReloaderConfig {
866            reloader_type: RELOADER_TYPE.to_string(),
867            skaffold: Some(SkaffoldConfig {
868                namespace: "gitlab".to_string(),
869                config_file: String::new(),
870            }),
871            ..Default::default()
872        };
873
874        let err = mgr
875            .validate(&reloader_config, &CaproniConfig::default())
876            .unwrap_err();
877        assert!(matches!(err, SkaffoldError::ConfigFileNotConfigured));
878    }
879
880    // Ported from internal/reloader/skaffold/manager_test.go:460 (TestValidate_RepositoryNotFound)
881    #[test]
882    fn test_validate_repository_not_found() {
883        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
884
885        let reloader_config = ReloaderConfig {
886            reloader_type: RELOADER_TYPE.to_string(),
887            repository: "nonexistent".to_string(),
888            skaffold: Some(SkaffoldConfig {
889                namespace: "gitlab".to_string(),
890                config_file: "skaffold.yaml".to_string(),
891            }),
892            ..Default::default()
893        };
894
895        let caproni_config = CaproniConfig {
896            repositories: Some(OrderedMap::new()),
897            ..Default::default()
898        };
899
900        let err = mgr.validate(&reloader_config, &caproni_config).unwrap_err();
901        assert!(matches!(err, SkaffoldError::RepositoryNotFound(_)));
902    }
903
904    // Ported from internal/reloader/skaffold/manager_test.go:480 (TestValidate_RepositoryNotCloned)
905    #[test]
906    fn test_validate_repository_not_cloned() {
907        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
908
909        let mut repos = OrderedMap::new();
910        repos.set(
911            "gitlab".to_string(),
912            RepositoryConfig {
913                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
914                directory: "./nonexistent-repo".to_string(),
915                ..Default::default()
916            },
917        );
918
919        let reloader_config = ReloaderConfig {
920            reloader_type: RELOADER_TYPE.to_string(),
921            repository: "gitlab".to_string(),
922            skaffold: Some(SkaffoldConfig {
923                namespace: "gitlab".to_string(),
924                config_file: "skaffold.yaml".to_string(),
925            }),
926            ..Default::default()
927        };
928
929        let caproni_config = CaproniConfig {
930            repositories: Some(repos),
931            config_dir: "/tmp".to_string(),
932            ..Default::default()
933        };
934
935        // Validation should pass if the repository directory doesn't exist yet.
936        mgr.validate(&reloader_config, &caproni_config).unwrap();
937    }
938
939    // Ported from internal/reloader/skaffold/manager_test.go:508 (TestValidate_ConfigFileNotFound)
940    #[test]
941    fn test_validate_config_file_not_found() {
942        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
943
944        let tmp_dir = tempfile::tempdir().unwrap();
945        let tmp_dir_str = tmp_dir.path().to_str().unwrap().to_string();
946
947        let mut repos = OrderedMap::new();
948        repos.set(
949            "gitlab".to_string(),
950            RepositoryConfig {
951                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
952                directory: tmp_dir_str.clone(),
953                ..Default::default()
954            },
955        );
956
957        let reloader_config = ReloaderConfig {
958            reloader_type: RELOADER_TYPE.to_string(),
959            repository: "gitlab".to_string(),
960            skaffold: Some(SkaffoldConfig {
961                namespace: "gitlab".to_string(),
962                config_file: "nonexistent-skaffold.yaml".to_string(),
963            }),
964            ..Default::default()
965        };
966
967        let caproni_config = CaproniConfig {
968            repositories: Some(repos),
969            config_dir: tmp_dir_str,
970            ..Default::default()
971        };
972
973        let err = mgr.validate(&reloader_config, &caproni_config).unwrap_err();
974        assert!(matches!(err, SkaffoldError::ConfigFileNotFound(_)));
975    }
976
977    // Ported from internal/reloader/skaffold/manager_test.go:537 (TestValidate_Success)
978    #[test]
979    fn test_validate_success() {
980        let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
981
982        let tmp_dir = tempfile::tempdir().unwrap();
983        let tmp_dir_str = tmp_dir.path().to_str().unwrap().to_string();
984
985        std::fs::write(
986            tmp_dir.path().join("skaffold.yaml"),
987            "apiVersion: skaffold/v2beta28\nkind: Config\n",
988        )
989        .unwrap();
990
991        let mut repos = OrderedMap::new();
992        repos.set(
993            "gitlab".to_string(),
994            RepositoryConfig {
995                repository: "https://gitlab.com/gitlab-org/gitlab.git".to_string(),
996                directory: tmp_dir_str.clone(),
997                ..Default::default()
998            },
999        );
1000
1001        let reloader_config = ReloaderConfig {
1002            reloader_type: RELOADER_TYPE.to_string(),
1003            repository: "gitlab".to_string(),
1004            skaffold: Some(SkaffoldConfig {
1005                namespace: "gitlab".to_string(),
1006                config_file: "skaffold.yaml".to_string(),
1007            }),
1008            ..Default::default()
1009        };
1010
1011        let caproni_config = CaproniConfig {
1012            repositories: Some(repos),
1013            config_dir: tmp_dir_str,
1014            ..Default::default()
1015        };
1016
1017        mgr.validate(&reloader_config, &caproni_config).unwrap();
1018    }
1019
1020    // Ported from internal/reloader/skaffold/manager_test.go:571 (TestBuildSkaffoldArgs)
1021    #[test]
1022    fn test_build_skaffold_args() {
1023        struct Case {
1024            name: &'static str,
1025            kubeconfig_path: &'static str,
1026            want: Vec<&'static str>,
1027        }
1028
1029        let cases = vec![
1030            Case {
1031                name: "without kubeconfig",
1032                kubeconfig_path: "",
1033                want: vec!["--namespace", "test-namespace"],
1034            },
1035            Case {
1036                name: "with kubeconfig",
1037                kubeconfig_path: "/tmp/caproni-kubeconfig-123",
1038                want: vec![
1039                    "--namespace",
1040                    "test-namespace",
1041                    "--kubeconfig",
1042                    "/tmp/caproni-kubeconfig-123",
1043                ],
1044            },
1045        ];
1046
1047        for case in cases {
1048            let mgr = Manager::new(Box::new(MockKubectlBackend::new()));
1049
1050            let reloader_config = ReloaderConfig {
1051                skaffold: Some(SkaffoldConfig {
1052                    namespace: "test-namespace".to_string(),
1053                    config_file: String::new(),
1054                }),
1055                ..Default::default()
1056            };
1057
1058            let args = mgr.build_skaffold_args(&reloader_config, case.kubeconfig_path);
1059            let want: Vec<String> = case.want.into_iter().map(str::to_string).collect();
1060            assert_eq!(args, want, "{}", case.name);
1061        }
1062    }
1063}