Skip to main content

xianyun_deploy/deployer/kustomize/
manager.rs

1//! A generic kustomize deployer implementation.
2//!
3//! Ported from `internal/deployer/kustomize/manager.go`. It builds a
4//! kustomization directory with the kustomize backend and applies the
5//! rendered manifests with the kubectl backend, making arbitrary
6//! non-chart manifests declarable in caproni.yaml like any other
7//! deployer.
8
9use std::path::PathBuf;
10
11use thiserror::Error;
12use xianyun_backends::kubectl::{self, Backend as KubectlBackend};
13use xianyun_backends::kustomize::{self as kustomizebackend, Backend as KustomizeBackend};
14use xianyun_core::config::container::Container;
15use xianyun_core::config::latest::{CaproniConfig, DeployerConfig, KustomizeConfig};
16
17use crate::deployer::{State, Status};
18
19use super::Cleanup;
20use super::builtin::{self, BuiltinError};
21use super::camaterial::{self, CaMaterial, CaMaterialError, CaProvider, PkiCaProvider};
22use super::files::{self, FilesError};
23use super::hostname::{self, HostnameError};
24
25/// The deployer type identifier registered for kustomize.
26pub const DEPLOYER_TYPE: &str = "kustomize";
27
28/// Errors from the kustomize deployer manager.
29#[derive(Debug, Error)]
30pub enum KustomizeError {
31    /// A manager method received a deployer config whose type is not
32    /// `"kustomize"`.
33    #[error("deployer is not a kustomize deployer")]
34    NotKustomizeDeployer,
35    /// A kustomize deployer has no path set.
36    #[error("kustomize.path is required")]
37    MissingPath,
38    /// The configured kustomization path does not exist on disk.
39    #[error("kustomization path not found: {0}")]
40    PathNotFound(String),
41    /// A kustomize path carries a URL scheme that kustomize cannot
42    /// resolve as a remote target.
43    #[error("unsupported url scheme in kustomize path: {0:?} uses scheme {1:?}")]
44    UnsupportedScheme(String, String),
45    /// A builtin overlay path error.
46    #[error(transparent)]
47    Builtin(#[from] BuiltinError),
48    /// A `files:` validation or materialization error.
49    #[error(transparent)]
50    Files(#[from] FilesError),
51    /// A hostname-injection error.
52    #[error(transparent)]
53    Hostname(#[from] HostnameError),
54    /// Loading the runtime CA material failed.
55    #[error("loading CA material: {0}")]
56    CaMaterial(#[source] CaMaterialError),
57    /// Checking the kustomization path failed for a reason other than
58    /// non-existence.
59    #[error("checking kustomization path {0}: {1}")]
60    Stat(String, #[source] std::io::Error),
61    /// The kustomize backend failed to build the kustomization.
62    #[error("building kustomization {0:?}: {1}")]
63    Build(String, #[source] kustomizebackend::KustomizeError),
64    /// The kubectl backend failed to apply the rendered manifests.
65    #[error("applying kustomize manifests from {0:?}: {1}")]
66    Apply(String, #[source] kubectl::KubectlError),
67    /// The kubectl backend failed to delete the rendered manifests.
68    #[error("deleting kustomize manifests from {0:?}: {1}")]
69    Delete(String, #[source] kubectl::KubectlError),
70}
71
72/// Implements the kustomize deployer.
73pub struct Manager {
74    kustomize: Box<dyn KustomizeBackend + Send + Sync>,
75    kubectl: Box<dyn KubectlBackend + Send + Sync>,
76    ca_provider: Box<dyn CaProvider>,
77}
78
79impl Manager {
80    /// Creates a new kustomize deployer manager using the default
81    /// pki-backed CA provider.
82    #[must_use]
83    pub fn new(
84        kustomize_backend: Box<dyn KustomizeBackend + Send + Sync>,
85        kubectl_backend: Box<dyn KubectlBackend + Send + Sync>,
86    ) -> Self {
87        Self::with_ca_provider(kustomize_backend, kubectl_backend, Box::new(PkiCaProvider))
88    }
89
90    /// Creates a kustomize deployer manager with an explicit CA
91    /// provider. Tests inject a fake provider to avoid touching the
92    /// filesystem.
93    #[must_use]
94    pub fn with_ca_provider(
95        kustomize_backend: Box<dyn KustomizeBackend + Send + Sync>,
96        kubectl_backend: Box<dyn KubectlBackend + Send + Sync>,
97        ca_provider: Box<dyn CaProvider>,
98    ) -> Self {
99        Self {
100            kustomize: kustomize_backend,
101            kubectl: kubectl_backend,
102            ca_provider,
103        }
104    }
105
106    /// Creates a new kustomize deployer manager from optional backends,
107    /// panicking with the same messages as the Go original when either
108    /// is absent. Exists only so the "nil backend" Go test has a direct
109    /// port; production code should call [`Manager::new`] with concrete
110    /// backends instead, since `Box<dyn Trait>` has no "nil" state of
111    /// its own.
112    #[must_use]
113    pub fn new_from_options(
114        kustomize_backend: Option<Box<dyn KustomizeBackend + Send + Sync>>,
115        kubectl_backend: Option<Box<dyn KubectlBackend + Send + Sync>>,
116    ) -> Self {
117        let kustomize_backend =
118            kustomize_backend.unwrap_or_else(|| panic!("kustomize backend cannot be nil"));
119        let kubectl_backend =
120            kubectl_backend.unwrap_or_else(|| panic!("kubectl backend cannot be nil"));
121
122        Self::new(kustomize_backend, kubectl_backend)
123    }
124
125    /// Returns the deployer type that this manager handles
126    /// (`"kustomize"`).
127    #[must_use]
128    pub fn deployer_type(&self) -> &'static str {
129        DEPLOYER_TYPE
130    }
131
132    /// Validates the kustomize deployer configuration and referenced
133    /// path. The configured path must exist and be a directory at
134    /// validation time; a missing path returns
135    /// [`KustomizeError::PathNotFound`].
136    ///
137    /// # Errors
138    ///
139    /// Returns [`KustomizeError`] if the config is not a kustomize
140    /// deployer, the path is missing or unresolvable, or `files:` is
141    /// invalid.
142    pub fn validate(
143        &self,
144        deployer_config: &DeployerConfig,
145        caproni_config: &CaproniConfig,
146    ) -> Result<(), KustomizeError> {
147        self.validate_config(deployer_config)?;
148
149        // Presence was already checked by validate_config.
150        let kc = deployer_config
151            .kustomize
152            .as_ref()
153            .expect("kustomize config present");
154
155        // builtin: paths reference an overlay embedded in caproni itself; they
156        // are always present, so they are validated by name rather than by
157        // stat-ing disk.
158        if builtin::is_builtin_path(&kc.path) {
159            return builtin::validate_builtin_path(&kc.path).map_err(KustomizeError::from);
160        }
161
162        // Remote targets are resolved by kustomize at build time, so there is
163        // nothing on disk to check. An unpinned target still builds, but
164        // tracks the repository's default branch, so it gets a
165        // reproducibility warning.
166        if kustomizebackend::remote::is_remote_target(&kc.path) {
167            if !kustomizebackend::remote::has_pinned_ref(&kc.path) {
168                tracing::warn!(
169                    path = %kustomizebackend::remote::redact_credentials(&kc.path),
170                    "remote kustomize target has no ?ref= pin; the build tracks the repository's default branch and is not reproducible"
171                );
172            }
173
174            return Ok(());
175        }
176
177        // A path that carries a URL scheme but is not a remote target (s3://,
178        // git::, ...) can neither be resolved by kustomize nor exist on disk,
179        // so it gets a clear error instead of a confusing path-not-found one.
180        let scheme = kustomizebackend::remote::url_scheme(&kc.path);
181        if !scheme.is_empty() {
182            return Err(KustomizeError::UnsupportedScheme(
183                kustomizebackend::remote::redact_credentials(&kc.path),
184                scheme,
185            ));
186        }
187
188        let path = caproni_config.resolve_path(&kc.path);
189
190        match std::fs::metadata(&path) {
191            Ok(info) => {
192                if info.is_dir() {
193                    Ok(())
194                } else {
195                    Err(KustomizeError::PathNotFound(path))
196                }
197            }
198            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
199                Err(KustomizeError::PathNotFound(path))
200            }
201            Err(e) => Err(KustomizeError::Stat(path, e)),
202        }
203    }
204
205    /// Builds the kustomization and applies the rendered manifests.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`KustomizeError`] if the config is invalid, loading CA
210    /// material fails, the build fails, or applying the manifests
211    /// fails.
212    pub fn deploy(
213        &self,
214        deployer_config: &DeployerConfig,
215        cfg: &Container,
216    ) -> Result<(), KustomizeError> {
217        self.validate_config(deployer_config)?;
218
219        let kc = deployer_config
220            .kustomize
221            .as_ref()
222            .expect("kustomize config present");
223
224        // Deploy is the only path with access to the cluster profile, so it
225        // supplies the runtime CA material when the target is a builtin
226        // overlay that needs it.
227        let material = self.load_ca_material(&kc.path, &cfg.global.profile_name)?;
228
229        let (manifests, path, namespace) =
230            self.build_manifests(deployer_config, &cfg.caproni, material.as_ref())?;
231
232        tracing::info!(path = %path, namespace = %namespace, "applying kustomize manifests");
233
234        self.kubectl
235            .apply(&manifests, &namespace)
236            .map_err(|e| KustomizeError::Apply(path.clone(), e))?;
237
238        tracing::info!(path = %path, "kustomize manifests applied successfully");
239
240        Ok(())
241    }
242
243    /// Fetches the runtime CA cert/key for the profile when the
244    /// configured path is a builtin overlay that consumes it. Returns
245    /// `None` otherwise, which callers treat as "no CA to write".
246    fn load_ca_material(
247        &self,
248        path: &str,
249        profile_name: &str,
250    ) -> Result<Option<CaMaterial>, KustomizeError> {
251        if !builtin::overlay_needs_ca(path) {
252            return Ok(None);
253        }
254
255        let (cert_pem, key_pem) = self
256            .ca_provider
257            .load(profile_name)
258            .map_err(KustomizeError::CaMaterial)?;
259
260        Ok(Some(CaMaterial { cert_pem, key_pem }))
261    }
262
263    /// Builds the kustomization and deletes the rendered manifests.
264    /// Missing objects are ignored, so undeploying twice is a no-op.
265    ///
266    /// # Errors
267    ///
268    /// Returns [`KustomizeError`] if the config is invalid, the build
269    /// fails, or deleting the manifests fails.
270    pub fn undeploy(
271        &self,
272        deployer_config: &DeployerConfig,
273        caproni_config: &CaproniConfig,
274    ) -> Result<(), KustomizeError> {
275        // Undeploy lacks the cluster profile, so it builds with no CA
276        // material: a builtin overlay that needs CA gets placeholder files,
277        // so the manifests render and the objects (identified by name) can
278        // be deleted.
279        let (manifests, path, namespace) =
280            self.build_manifests(deployer_config, caproni_config, None)?;
281
282        tracing::info!(path = %path, namespace = %namespace, "deleting kustomize manifests");
283
284        self.kubectl
285            .delete_manifest(&manifests, &namespace)
286            .map_err(|e| KustomizeError::Delete(path.clone(), e))?;
287
288        Ok(())
289    }
290
291    /// Reports the deployment state of a kustomize deployer.
292    ///
293    /// kustomize applies an arbitrary, unlabelled set of manifests, so
294    /// there is no reliable application-agnostic signal to query for
295    /// "is this still deployed". Rather than guess, `status` reports
296    /// [`State::Deployed`] whenever the kustomization builds
297    /// successfully (the manifests are available to apply) and
298    /// [`State::NotDeployed`] when the build fails. Deploy is
299    /// idempotent, so a redundant apply is harmless.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`KustomizeError`] if the config is invalid or resolving
304    /// the build source fails.
305    pub fn status(
306        &self,
307        deployer_config: &DeployerConfig,
308        caproni_config: &CaproniConfig,
309    ) -> Result<Status, KustomizeError> {
310        self.validate_config(deployer_config)?;
311
312        let kc = deployer_config
313            .kustomize
314            .as_ref()
315            .expect("kustomize config present");
316
317        // Building a remote target means cloning its repository, so
318        // answering a status query would cost network I/O -- and a
319        // transient network failure would spuriously report NotDeployed.
320        // Report StateDeployed instead, consistent with the build-succeeds
321        // proxy below; Deploy is idempotent, so a redundant apply is
322        // harmless.
323        if kustomizebackend::remote::is_remote_target(&kc.path) {
324            return Ok(Status {
325                state: State::Deployed,
326                deployer_type: DEPLOYER_TYPE.to_string(),
327            });
328        }
329
330        // Status only checks that the kustomization builds; builtin overlays
331        // needing CA get placeholder files (material is None here).
332        let (build_dir, cleanup) = resolve_build_source(kc, caproni_config, None)?;
333
334        let build_result = self.kustomize.build(build_dir.to_string_lossy().as_ref());
335
336        cleanup();
337
338        match build_result {
339            Ok(_) => Ok(Status {
340                state: State::Deployed,
341                deployer_type: DEPLOYER_TYPE.to_string(),
342            }),
343            Err(e) => {
344                tracing::debug!(path = %kc.path, error = %e, "kustomize build failed, treating as not deployed");
345
346                Ok(Status {
347                    state: State::NotDeployed,
348                    deployer_type: DEPLOYER_TYPE.to_string(),
349                })
350            }
351        }
352    }
353
354    /// Checks that the deployer config is a kustomize deployer with a
355    /// non-empty path.
356    fn validate_config(&self, deployer_config: &DeployerConfig) -> Result<(), KustomizeError> {
357        if deployer_config.deployer_type != DEPLOYER_TYPE || deployer_config.kustomize.is_none() {
358            return Err(KustomizeError::NotKustomizeDeployer);
359        }
360
361        let kc = deployer_config.kustomize.as_ref().expect("checked above");
362
363        if kc.path.is_empty() {
364            return Err(KustomizeError::MissingPath);
365        }
366
367        files::validate_files(kc)?;
368
369        Ok(())
370    }
371
372    /// Validates the config, resolves the kustomization build source,
373    /// and renders it, returning the rendered manifests along with the
374    /// configured path and target namespace used by `deploy` and
375    /// `undeploy`. The returned path is the configured path (for
376    /// example a builtin: reference), so it stays meaningful in log
377    /// messages.
378    fn build_manifests(
379        &self,
380        deployer_config: &DeployerConfig,
381        caproni_config: &CaproniConfig,
382        material: Option<&CaMaterial>,
383    ) -> Result<(Vec<u8>, String, String), KustomizeError> {
384        self.validate_config(deployer_config)?;
385
386        let kc = deployer_config.kustomize.as_ref().expect("checked above");
387
388        let namespace = kc.namespace.clone();
389
390        // The returned path is only used in logs and error messages, so
391        // embedded git credentials are stripped from it.
392        let path = kustomizebackend::remote::redact_credentials(&kc.path);
393
394        let (build_dir, cleanup) = resolve_build_source(kc, caproni_config, material)?;
395
396        let build_result = self.kustomize.build(build_dir.to_string_lossy().as_ref());
397
398        cleanup();
399
400        let manifests = build_result.map_err(|e| KustomizeError::Build(path.clone(), e))?;
401
402        Ok((manifests, path, namespace))
403    }
404}
405
406/// Returns the on-disk kustomization directory to build from.
407///
408/// A builtin: path is materialized from an embedded overlay into a temp
409/// directory; a local path is resolved against the config directory, or
410/// copied into a temp directory when `files:` are declared. A builtin
411/// overlay needing CA material gets the runtime cert/key (empty
412/// placeholders when `material` is `None`). The returned cleanup is
413/// always safe to call.
414fn resolve_build_source(
415    kc: &KustomizeConfig,
416    caproni_config: &CaproniConfig,
417    material: Option<&CaMaterial>,
418) -> Result<(PathBuf, Cleanup), KustomizeError> {
419    let path = &kc.path;
420
421    // A remote target is resolved by kustomize itself (shallow git clone
422    // into a temporary directory), so it must not be joined against the
423    // config directory.
424    if kustomizebackend::remote::is_remote_target(path) {
425        return Ok((PathBuf::from(path), no_cleanup()));
426    }
427
428    if !builtin::is_builtin_path(path) {
429        let src = caproni_config.resolve_path(path);
430
431        if !kc.files.is_empty() {
432            let (dir, cleanup) = files::materialize_with_files(&src, &kc.files)?;
433            return Ok((dir, cleanup));
434        }
435
436        return Ok((PathBuf::from(src), no_cleanup()));
437    }
438
439    let (dir, cleanup) = builtin::materialize_builtin_overlay(path)?;
440
441    // Each overlay declares what it consumes; both writes target the same
442    // materialized directory kustomize builds.
443    if builtin::overlay_needs_ca(path)
444        && let Err(e) = camaterial::write_ca_material(&dir, material)
445    {
446        cleanup();
447        return Err(KustomizeError::CaMaterial(e));
448    }
449
450    if builtin::overlay_needs_hostname(path) {
451        let primary_hostname = hostname::primary_hostname(caproni_config);
452        if let Err(e) = hostname::write_overlay_hostname(&dir, &primary_hostname) {
453            cleanup();
454            return Err(KustomizeError::from(e));
455        }
456    }
457
458    Ok((dir, cleanup))
459}
460
461fn no_cleanup() -> Cleanup {
462    Box::new(|| {})
463}
464
465/// Lets the orchestrator drive a kustomize [`Manager`] through the registry.
466impl crate::deployer::Manager for Manager {
467    fn deployer_type(&self) -> String {
468        DEPLOYER_TYPE.to_string()
469    }
470
471    fn deploy(
472        &self,
473        deployer_config: &DeployerConfig,
474        cfg: Option<&Container>,
475    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
476        let cfg = cfg.ok_or("kustomize deploy needs the resolved configuration")?;
477
478        Manager::deploy(self, deployer_config, cfg).map_err(Into::into)
479    }
480
481    fn undeploy(
482        &self,
483        deployer_config: &DeployerConfig,
484        caproni_config: &CaproniConfig,
485    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
486        Manager::undeploy(self, deployer_config, caproni_config).map_err(Into::into)
487    }
488
489    fn status(
490        &self,
491        deployer_config: &DeployerConfig,
492        caproni_config: &CaproniConfig,
493    ) -> Result<Status, Box<dyn std::error::Error + Send + Sync>> {
494        Manager::status(self, deployer_config, caproni_config).map_err(Into::into)
495    }
496
497    fn validate(
498        &self,
499        deployer_config: &DeployerConfig,
500        caproni_config: &CaproniConfig,
501    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
502        Manager::validate(self, deployer_config, caproni_config).map_err(Into::into)
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use std::path::Path;
509    use std::sync::{Arc, Mutex};
510
511    use xianyun_backends::kubectl::KubectlError;
512    use xianyun_backends::kustomize::KustomizeError as BackendKustomizeError;
513    use xianyun_core::config::container::{Container, new_container};
514    use xianyun_core::config::global::GlobalConfig;
515    use xianyun_core::config::latest::{CaproniConfig, DeployerConfig, KustomizeConfig};
516    use xianyun_core::config::types::NetworkConfig;
517
518    use super::*;
519    use crate::deployer::kustomize::camaterial::CaMaterialError;
520    use crate::deployer::kustomize::test_support::{MockKubectlBackend, MockKustomizeBackend};
521
522    const RENDERED_MANIFESTS: &[u8] =
523        b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: example\n";
524
525    #[derive(Default)]
526    struct FakeCaProvider {
527        cert_pem: Vec<u8>,
528        key_pem: Vec<u8>,
529        err: Option<String>,
530    }
531
532    impl CaProvider for FakeCaProvider {
533        fn load(&self, _profile_name: &str) -> Result<(Vec<u8>, Vec<u8>), CaMaterialError> {
534            if let Some(msg) = &self.err {
535                return Err(CaMaterialError::Write(std::io::Error::other(msg.clone())));
536            }
537
538            Ok((self.cert_pem.clone(), self.key_pem.clone()))
539        }
540    }
541
542    fn backend_test_error() -> BackendKustomizeError {
543        BackendKustomizeError::BuildFailed("test".to_string(), "backend failed".to_string())
544    }
545
546    fn kubectl_test_error() -> KubectlError {
547        KubectlError::JobFailed("backend failed".to_string())
548    }
549
550    fn new_config(config_dir: &str) -> CaproniConfig {
551        CaproniConfig {
552            config_dir: config_dir.to_string(),
553            cluster: xianyun_core::config::types::ClusterConfig {
554                network: Some(NetworkConfig {
555                    hostname: "gitlab.test".to_string(),
556                    additional_hostnames: Vec::new(),
557                    cluster_domain: String::new(),
558                }),
559                ..Default::default()
560            },
561            ..Default::default()
562        }
563    }
564
565    fn new_test_container(config_dir: &str) -> Container {
566        new_container(GlobalConfig::default(), new_config(config_dir))
567    }
568
569    fn kustomize_deployer(path: &str, namespace: &str) -> DeployerConfig {
570        DeployerConfig {
571            deployer_type: DEPLOYER_TYPE.to_string(),
572            kustomize: Some(KustomizeConfig {
573                path: path.to_string(),
574                namespace: namespace.to_string(),
575                ..Default::default()
576            }),
577            ..Default::default()
578        }
579    }
580
581    fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
582        if let Some(s) = payload.downcast_ref::<&str>() {
583            (*s).to_string()
584        } else if let Some(s) = payload.downcast_ref::<String>() {
585            s.clone()
586        } else {
587            "unknown panic payload".to_string()
588        }
589    }
590
591    // Ported from internal/deployer/kustomize/manager_test.go:60 (TestType)
592    #[test]
593    fn test_type() {
594        let mgr = Manager::new(
595            Box::new(MockKustomizeBackend::new()),
596            Box::new(MockKubectlBackend::new()),
597        );
598        assert_eq!(mgr.deployer_type(), "kustomize");
599    }
600
601    // Ported from internal/deployer/kustomize/manager_test.go:67 (TestNewManager_NilBackends)
602    #[test]
603    fn test_new_manager_nil_backends() {
604        let result = std::panic::catch_unwind(|| {
605            let _ = Manager::new_from_options(None, Some(Box::new(MockKubectlBackend::new())));
606        });
607        let payload = result.expect_err("expected a panic for a nil kustomize backend");
608        assert_eq!(panic_message(&*payload), "kustomize backend cannot be nil");
609
610        let result = std::panic::catch_unwind(|| {
611            let _ = Manager::new_from_options(Some(Box::new(MockKustomizeBackend::new())), None);
612        });
613        let payload = result.expect_err("expected a panic for a nil kubectl backend");
614        assert_eq!(panic_message(&*payload), "kubectl backend cannot be nil");
615    }
616
617    // Ported from internal/deployer/kustomize/manager_test.go:79 (TestValidate)
618    #[test]
619    fn test_validate() {
620        let tmp = tempfile::tempdir().unwrap();
621        let dir = tmp.path();
622
623        let not_a_dir = dir.join("kustomization.yaml");
624        std::fs::write(&not_a_dir, "kind: Kustomization\n").unwrap();
625
626        struct Case {
627            name: &'static str,
628            cfg: DeployerConfig,
629            want_err: Option<&'static str>,
630        }
631
632        let cases = vec![
633            Case {
634                name: "valid existing directory",
635                cfg: kustomize_deployer(dir.to_str().unwrap(), ""),
636                want_err: None,
637            },
638            Case {
639                name: "wrong type",
640                cfg: DeployerConfig {
641                    deployer_type: "helm".to_string(),
642                    kustomize: Some(KustomizeConfig {
643                        path: dir.to_str().unwrap().to_string(),
644                        ..Default::default()
645                    }),
646                    ..Default::default()
647                },
648                want_err: Some("not_kustomize"),
649            },
650            Case {
651                name: "missing kustomize block",
652                cfg: DeployerConfig {
653                    deployer_type: DEPLOYER_TYPE.to_string(),
654                    ..Default::default()
655                },
656                want_err: Some("not_kustomize"),
657            },
658            Case {
659                name: "empty path",
660                cfg: kustomize_deployer("", ""),
661                want_err: Some("missing_path"),
662            },
663            Case {
664                name: "path does not exist",
665                cfg: kustomize_deployer(dir.join("missing").to_str().unwrap(), ""),
666                want_err: Some("path_not_found"),
667            },
668            Case {
669                name: "path is a file",
670                cfg: kustomize_deployer(not_a_dir.to_str().unwrap(), ""),
671                want_err: Some("path_not_found"),
672            },
673            Case {
674                name: "remote target with pinned ref",
675                cfg: kustomize_deployer(
676                    "https://gitlab.com/org/repo.git//overlays/dev?ref=v1.2.0",
677                    "",
678                ),
679                want_err: None,
680            },
681            Case {
682                name: "remote target with version synonym",
683                cfg: kustomize_deployer(
684                    "https://github.com/owner/repo//overlays/dev?version=v2.0.0",
685                    "",
686                ),
687                want_err: None,
688            },
689            Case {
690                name: "remote target without ref is allowed but unreproducible",
691                cfg: kustomize_deployer("https://gitlab.com/org/repo.git//overlays/dev", ""),
692                want_err: None,
693            },
694            Case {
695                name: "unsupported url scheme",
696                cfg: kustomize_deployer("s3://bucket/overlays/dev", ""),
697                want_err: Some("unsupported_scheme"),
698            },
699            Case {
700                name: "relative path with colon is a local path",
701                cfg: kustomize_deployer("overlays:v2/dev", ""),
702                want_err: Some("path_not_found"),
703            },
704            Case {
705                name: "go-getter git prefix is an unsupported scheme",
706                cfg: kustomize_deployer("git::https://example.com/org/repo", ""),
707                want_err: Some("unsupported_scheme"),
708            },
709            Case {
710                name: "remote target with empty ref is allowed but unreproducible",
711                cfg: kustomize_deployer("https://github.com/owner/repo?ref=", ""),
712                want_err: None,
713            },
714        ];
715
716        for case in cases {
717            let mgr = Manager::new(
718                Box::new(MockKustomizeBackend::new()),
719                Box::new(MockKubectlBackend::new()),
720            );
721
722            let result = mgr.validate(&case.cfg, &new_config(""));
723
724            match case.want_err {
725                None => {
726                    result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
727                }
728                Some("not_kustomize") => assert!(
729                    matches!(result, Err(KustomizeError::NotKustomizeDeployer)),
730                    "{}: expected NotKustomizeDeployer, got {result:?}",
731                    case.name
732                ),
733                Some("missing_path") => assert!(
734                    matches!(result, Err(KustomizeError::MissingPath)),
735                    "{}: expected MissingPath, got {result:?}",
736                    case.name
737                ),
738                Some("path_not_found") => assert!(
739                    matches!(result, Err(KustomizeError::PathNotFound(_))),
740                    "{}: expected PathNotFound, got {result:?}",
741                    case.name
742                ),
743                Some("unsupported_scheme") => assert!(
744                    matches!(result, Err(KustomizeError::UnsupportedScheme(_, _))),
745                    "{}: expected UnsupportedScheme, got {result:?}",
746                    case.name
747                ),
748                Some(other) => unreachable!("unknown want_err case: {other}"),
749            }
750        }
751    }
752
753    // Ported from internal/deployer/kustomize/manager_test.go:177 (TestDeploy)
754    #[test]
755    fn test_deploy() {
756        // builds and applies manifests
757        {
758            let path = Path::new("/repo").join("manifests");
759            let path_str = path.to_str().unwrap().to_string();
760            let expected_path = path_str.clone();
761
762            let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
763                assert_eq!(dir, expected_path);
764                Ok(RENDERED_MANIFESTS.to_vec())
765            });
766            let kctl = MockKubectlBackend::new().expect_apply(|manifests, namespace| {
767                assert_eq!(manifests, RENDERED_MANIFESTS);
768                assert_eq!(namespace, "gitlab");
769                Ok(())
770            });
771
772            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
773
774            mgr.deploy(
775                &kustomize_deployer(&path_str, "gitlab"),
776                &new_test_container(""),
777            )
778            .unwrap();
779        }
780
781        // passes a remote target to the backend verbatim
782        {
783            let target = "https://gitlab.com/org/repo.git//overlays/dev?ref=v1.2.0";
784
785            let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
786                assert_eq!(dir, target);
787                Ok(RENDERED_MANIFESTS.to_vec())
788            });
789            let kctl = MockKubectlBackend::new().expect_apply(|manifests, namespace| {
790                assert_eq!(manifests, RENDERED_MANIFESTS);
791                assert_eq!(namespace, "gitlab");
792                Ok(())
793            });
794
795            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
796
797            mgr.deploy(
798                &kustomize_deployer(target, "gitlab"),
799                &new_test_container("/some/config/dir"),
800            )
801            .unwrap();
802        }
803
804        // resolves relative path against config dir
805        {
806            let resolved = Path::new("/conf").join("infra");
807            let resolved_str = resolved.to_str().unwrap().to_string();
808            let expected = resolved_str.clone();
809
810            let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
811                assert_eq!(dir, expected);
812                Ok(RENDERED_MANIFESTS.to_vec())
813            });
814            let kctl = MockKubectlBackend::new().expect_apply(|manifests, namespace| {
815                assert_eq!(manifests, RENDERED_MANIFESTS);
816                assert_eq!(namespace, "");
817                Ok(())
818            });
819
820            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
821
822            mgr.deploy(
823                &kustomize_deployer("infra", ""),
824                &new_test_container("/conf"),
825            )
826            .unwrap();
827        }
828
829        // wrong type
830        {
831            let mgr = Manager::new(
832                Box::new(MockKustomizeBackend::new()),
833                Box::new(MockKubectlBackend::new()),
834            );
835
836            let cfg = DeployerConfig {
837                deployer_type: "helm".to_string(),
838                kustomize: Some(KustomizeConfig {
839                    path: "x".to_string(),
840                    ..Default::default()
841                }),
842                ..Default::default()
843            };
844
845            let err = mgr.deploy(&cfg, &new_test_container("")).unwrap_err();
846            assert!(matches!(err, KustomizeError::NotKustomizeDeployer));
847        }
848
849        // build failure propagates
850        {
851            let kbackend =
852                MockKustomizeBackend::new().expect_build(|_dir| Err(backend_test_error()));
853            let kctl = MockKubectlBackend::new();
854
855            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
856
857            let err = mgr
858                .deploy(&kustomize_deployer("/repo", ""), &new_test_container(""))
859                .unwrap_err();
860            assert!(matches!(err, KustomizeError::Build(_, _)));
861        }
862
863        // apply failure propagates
864        {
865            let kbackend =
866                MockKustomizeBackend::new().expect_build(|_dir| Ok(RENDERED_MANIFESTS.to_vec()));
867            let kctl = MockKubectlBackend::new()
868                .expect_apply(|_manifests, _namespace| Err(kubectl_test_error()));
869
870            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
871
872            let err = mgr
873                .deploy(&kustomize_deployer("/repo", ""), &new_test_container(""))
874                .unwrap_err();
875            assert!(matches!(err, KustomizeError::Apply(_, _)));
876        }
877    }
878
879    // Ported from internal/deployer/kustomize/manager_test.go:274 (TestDeploy_BuiltinDevCA)
880    #[test]
881    fn test_deploy_builtin_dev_ca() {
882        let cert = b"test-cert-pem".to_vec();
883        let key = b"test-key-pem".to_vec();
884
885        // writes runtime CA material into the build directory
886        {
887            let got: Arc<Mutex<(Vec<u8>, Vec<u8>)>> =
888                Arc::new(Mutex::new((Vec::new(), Vec::new())));
889            let got_cap = got.clone();
890
891            let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
892                let cert = std::fs::read(Path::new(dir).join("tls.crt")).unwrap_or_default();
893                let key = std::fs::read(Path::new(dir).join("tls.key")).unwrap_or_default();
894                *got_cap.lock().unwrap() = (cert, key);
895
896                Ok(RENDERED_MANIFESTS.to_vec())
897            });
898            let kctl = MockKubectlBackend::new().expect_apply(|manifests, namespace| {
899                assert_eq!(manifests, RENDERED_MANIFESTS);
900                assert_eq!(namespace, "cert-manager");
901                Ok(())
902            });
903
904            let ca_provider = FakeCaProvider {
905                cert_pem: cert.clone(),
906                key_pem: key.clone(),
907                err: None,
908            };
909
910            let mgr = Manager::with_ca_provider(
911                Box::new(kbackend),
912                Box::new(kctl),
913                Box::new(ca_provider),
914            );
915
916            mgr.deploy(
917                &kustomize_deployer("builtin:dev-ca", "cert-manager"),
918                &new_test_container(""),
919            )
920            .unwrap();
921
922            let (got_cert, got_key) = got.lock().unwrap().clone();
923            assert_eq!(got_cert, cert, "tls.crt must contain the runtime CA cert");
924            assert_eq!(got_key, key, "tls.key must contain the runtime CA key");
925        }
926
927        // CA provider failure aborts deploy
928        {
929            let ca_provider = FakeCaProvider {
930                cert_pem: Vec::new(),
931                key_pem: Vec::new(),
932                err: Some("backend failed".to_string()),
933            };
934
935            let mgr = Manager::with_ca_provider(
936                Box::new(MockKustomizeBackend::new()),
937                Box::new(MockKubectlBackend::new()),
938                Box::new(ca_provider),
939            );
940
941            let err = mgr
942                .deploy(
943                    &kustomize_deployer("builtin:dev-ca", "cert-manager"),
944                    &new_test_container(""),
945                )
946                .unwrap_err();
947            assert!(matches!(err, KustomizeError::CaMaterial(_)));
948        }
949    }
950
951    // Ported from internal/deployer/kustomize/manager_test.go:327 (TestUndeploy_BuiltinDevCAWritesPlaceholders)
952    #[test]
953    fn test_undeploy_builtin_dev_ca_writes_placeholders() {
954        let present: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
955        let present_cap = present.clone();
956
957        // Undeploy has no profile, so placeholder files must still let the
958        // build run.
959        let kbackend = MockKustomizeBackend::new().expect_build(move |dir| {
960            *present_cap.lock().unwrap() = Path::new(dir).join("tls.crt").is_file();
961            Ok(RENDERED_MANIFESTS.to_vec())
962        });
963        let kctl = MockKubectlBackend::new().expect_delete_manifest(|manifests, namespace| {
964            assert_eq!(manifests, RENDERED_MANIFESTS);
965            assert_eq!(namespace, "cert-manager");
966            Ok(())
967        });
968
969        let mgr = Manager::with_ca_provider(
970            Box::new(kbackend),
971            Box::new(kctl),
972            Box::new(FakeCaProvider::default()),
973        );
974
975        mgr.undeploy(
976            &kustomize_deployer("builtin:dev-ca", "cert-manager"),
977            &new_config(""),
978        )
979        .unwrap();
980
981        assert!(
982            *present.lock().unwrap(),
983            "placeholder tls.crt must exist so kustomize can build during undeploy"
984        );
985    }
986
987    // Ported from internal/deployer/kustomize/manager_test.go:353 (TestUndeploy)
988    #[test]
989    fn test_undeploy() {
990        // builds and deletes manifests
991        {
992            let kbackend = MockKustomizeBackend::new().expect_build(|dir| {
993                assert_eq!(dir, "/repo");
994                Ok(RENDERED_MANIFESTS.to_vec())
995            });
996            let kctl = MockKubectlBackend::new().expect_delete_manifest(|manifests, namespace| {
997                assert_eq!(manifests, RENDERED_MANIFESTS);
998                assert_eq!(namespace, "gitlab");
999                Ok(())
1000            });
1001
1002            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
1003
1004            mgr.undeploy(&kustomize_deployer("/repo", "gitlab"), &new_config(""))
1005                .unwrap();
1006        }
1007
1008        // wrong type
1009        {
1010            let mgr = Manager::new(
1011                Box::new(MockKustomizeBackend::new()),
1012                Box::new(MockKubectlBackend::new()),
1013            );
1014
1015            let cfg = DeployerConfig {
1016                deployer_type: "helm".to_string(),
1017                kustomize: Some(KustomizeConfig {
1018                    path: "x".to_string(),
1019                    ..Default::default()
1020                }),
1021                ..Default::default()
1022            };
1023
1024            let err = mgr.undeploy(&cfg, &new_config("")).unwrap_err();
1025            assert!(matches!(err, KustomizeError::NotKustomizeDeployer));
1026        }
1027
1028        // delete failure propagates
1029        {
1030            let kbackend =
1031                MockKustomizeBackend::new().expect_build(|_dir| Ok(RENDERED_MANIFESTS.to_vec()));
1032            let kctl = MockKubectlBackend::new()
1033                .expect_delete_manifest(|_manifests, _namespace| Err(kubectl_test_error()));
1034
1035            let mgr = Manager::new(Box::new(kbackend), Box::new(kctl));
1036
1037            let err = mgr
1038                .undeploy(&kustomize_deployer("/repo", ""), &new_config(""))
1039                .unwrap_err();
1040            assert!(matches!(err, KustomizeError::Delete(_, _)));
1041        }
1042    }
1043
1044    // Ported from internal/deployer/kustomize/manager_test.go:399 (TestStatus)
1045    #[test]
1046    fn test_status() {
1047        // deployed when build succeeds
1048        {
1049            let kbackend = MockKustomizeBackend::new().expect_build(|dir| {
1050                assert_eq!(dir, "/repo");
1051                Ok(RENDERED_MANIFESTS.to_vec())
1052            });
1053
1054            let mgr = Manager::new(Box::new(kbackend), Box::new(MockKubectlBackend::new()));
1055
1056            let status = mgr
1057                .status(&kustomize_deployer("/repo", ""), &new_config(""))
1058                .unwrap();
1059            assert_eq!(status.state, State::Deployed);
1060            assert_eq!(status.deployer_type, "kustomize");
1061        }
1062
1063        // not deployed when build fails
1064        {
1065            let kbackend =
1066                MockKustomizeBackend::new().expect_build(|_dir| Err(backend_test_error()));
1067
1068            let mgr = Manager::new(Box::new(kbackend), Box::new(MockKubectlBackend::new()));
1069
1070            let status = mgr
1071                .status(&kustomize_deployer("/repo", ""), &new_config(""))
1072                .unwrap();
1073            assert_eq!(status.state, State::NotDeployed);
1074        }
1075
1076        // remote target reports deployed without building
1077        {
1078            // No Build expectation: fetching a remote repository to answer a
1079            // status query is wasted network I/O, and a transient network
1080            // failure would spuriously report NotDeployed.
1081            let mgr = Manager::new(
1082                Box::new(MockKustomizeBackend::new()),
1083                Box::new(MockKubectlBackend::new()),
1084            );
1085
1086            let target = "https://gitlab.com/org/repo.git//overlays/dev?ref=v1.2.0";
1087
1088            let status = mgr
1089                .status(&kustomize_deployer(target, ""), &new_config(""))
1090                .unwrap();
1091            assert_eq!(status.state, State::Deployed);
1092            assert_eq!(status.deployer_type, "kustomize");
1093        }
1094
1095        // wrong type
1096        {
1097            let mgr = Manager::new(
1098                Box::new(MockKustomizeBackend::new()),
1099                Box::new(MockKubectlBackend::new()),
1100            );
1101
1102            let cfg = DeployerConfig {
1103                deployer_type: "helm".to_string(),
1104                kustomize: Some(KustomizeConfig {
1105                    path: "x".to_string(),
1106                    ..Default::default()
1107                }),
1108                ..Default::default()
1109            };
1110
1111            let err = mgr.status(&cfg, &new_config("")).unwrap_err();
1112            assert!(matches!(err, KustomizeError::NotKustomizeDeployer));
1113        }
1114    }
1115}