Skip to main content

xianyun_deploy/reloader/mirror/
copy.rs

1//! Prepares and cleans up `-caproni` deployment copies used to run mirror
2//! reloaders without disrupting the original workload.
3//!
4//! Ported from `internal/reloader/mirror/copy.go`.
5
6use std::collections::HashMap;
7use std::time::Duration;
8
9use serde_json::Value;
10use thiserror::Error;
11use xianyun_core::config::latest::ReloaderConfig;
12
13use super::manager::Manager;
14
15/// The suffix appended to the original deployment name to form the name
16/// of the mirror copy managed by caproni.
17pub const COPY_SUFFIX: &str = "-caproni";
18
19/// The number of replicas the original deployment will be scaled up to.
20/// This is intentionally hard-coded to avoid storing state in the old
21/// deployments or within caproni.
22///
23/// See <https://gitlab.com/gitlab-org/gitlab/-/issues/590495#note_3118758329>
24pub const SCALE_UP_REPLICA_COUNT: i64 = 1;
25
26/// Errors from deployment-copy preparation and cleanup.
27#[derive(Debug, Error)]
28pub enum CopyError {
29    /// A deployment JSON structure was invalid in a way that prevented
30    /// modifying it for caproni use.
31    #[error("invalid deployment structure: {0}")]
32    InvalidDeploymentStructure(String),
33    /// A specified container was not found in the deployment.
34    #[error("container not found in deployment: {0}")]
35    ContainerNotFound(String),
36    /// The same container name appeared more than once across multiple
37    /// reloaders targeting the same pod, or within one `files:`-style
38    /// container name list.
39    #[error("duplicate container for target: {0}")]
40    DuplicateContainer(String),
41    /// One reloader targets an entire pod and another targets a
42    /// specific container within the same pod.
43    #[error("cannot mix pod-level and container-level targets for the same resource: {0}")]
44    MixedPodAndContainerTargets(String),
45    /// Validating a reloader's mirror target failed.
46    #[error("validating mirror target for reloader {0:?}: {1}")]
47    ValidatingTarget(String, #[source] Box<CopyError>),
48    /// Fetching, applying, scaling, deleting, or waiting on a resource
49    /// via the kubectl backend failed.
50    #[error("{0}")]
51    Kubectl(String, #[source] xianyun_backends::kubectl::KubectlError),
52    /// Parsing a fetched deployment's JSON failed.
53    #[error("parsing deployment JSON for {0}: {1}")]
54    ParseJson(String, #[source] serde_json::Error),
55    /// Serializing a modified deployment back to JSON failed.
56    #[error("marshaling modified deployment {0}: {1}")]
57    SerializeJson(String, #[source] serde_json::Error),
58}
59
60/// A Kubernetes resource to be mirrored.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Target {
63    pub namespace: String,
64    pub kubernetes_type: String,
65    pub name: String,
66    /// Optional. When non-empty, only the listed containers are
67    /// replaced in the deployment copy. When empty, all containers are
68    /// replaced.
69    pub containers: Vec<String>,
70    /// `true` if one reloader target was specified without a container
71    /// name. It is only an intermediate value; outside consumers will
72    /// find an empty `containers` if this was `true` at some point.
73    has_wildcard: bool,
74}
75
76impl Target {
77    /// Creates a new [`Target`] with no containers set. A convenience
78    /// constructor for callers outside this module, since `has_wildcard`
79    /// is a private intermediate field that struct-update syntax
80    /// (`..Default::default()`) cannot reach across module boundaries.
81    #[must_use]
82    pub fn new(namespace: &str, kubernetes_type: &str, name: &str) -> Self {
83        Self {
84            namespace: namespace.to_string(),
85            kubernetes_type: kubernetes_type.to_string(),
86            name: name.to_string(),
87            containers: Vec::new(),
88            has_wildcard: false,
89        }
90    }
91
92    /// Merges one reloader's container target into this entry,
93    /// validating for conflicts.
94    fn add_container(&mut self, container: &str) -> Result<(), CopyError> {
95        let resource_id = format!("{}/{}/{}", self.namespace, self.kubernetes_type, self.name);
96
97        if container.is_empty() {
98            // Pod-level target: cannot mix with container-level targets.
99            if !self.containers.is_empty() {
100                return Err(CopyError::MixedPodAndContainerTargets(resource_id));
101            }
102
103            self.has_wildcard = true;
104
105            return Ok(());
106        }
107
108        // Container-level target: cannot mix with pod-level targets.
109        if self.has_wildcard {
110            return Err(CopyError::MixedPodAndContainerTargets(resource_id));
111        }
112
113        if self.containers.iter().any(|c| c == container) {
114            return Err(CopyError::DuplicateContainer(format!(
115                "{container} in {resource_id}"
116            )));
117        }
118
119        self.containers.push(container.to_string());
120
121        Ok(())
122    }
123}
124
125/// Holds information about a prepared deployment copy.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CopyInfo {
128    pub resource_type: String,
129    pub name: String,
130    pub namespace: String,
131}
132
133/// Collects mirror targets from all reloaders, merges targets for the
134/// same pod, and validates that there are no conflicts. Returns a
135/// deduplicated, sorted list of targets ready for use with
136/// [`Manager::prepare_copies`].
137///
138/// Validation rules:
139/// - The same container may not appear in more than one reloader for
140///   the same pod.
141/// - A pod-level target (no container specified) cannot be combined
142///   with a container-level target for the same pod.
143///
144/// # Errors
145///
146/// Returns [`CopyError::DuplicateContainer`] or
147/// [`CopyError::MixedPodAndContainerTargets`] (wrapped in
148/// [`CopyError::ValidatingTarget`]) if a reloader's target conflicts
149/// with another's.
150pub fn collect_and_validate_targets(
151    reloaders: &HashMap<String, ReloaderConfig>,
152) -> Result<Vec<Target>, CopyError> {
153    let mut merged: HashMap<String, Target> = HashMap::new();
154    // Preserves first-seen order per key, matching the Go original's
155    // `slices.Sorted(maps.Keys(merged))` -- sorted lexicographically by
156    // the "namespace/type/name" key, which is what determines the
157    // output order regardless of (randomized) map iteration order.
158    let mut order: Vec<String> = Vec::new();
159
160    for (name, reloader_config) in reloaders {
161        if reloader_config.reloader_type != "mirror" {
162            continue;
163        }
164
165        let Some(mirror) = reloader_config.mirror.as_ref() else {
166            continue;
167        };
168        let Some(target) = mirror.target.as_ref() else {
169            continue;
170        };
171
172        let key = format!(
173            "{}/{}/{}",
174            target.namespace, target.kubernetes_type, target.name
175        );
176
177        let entry = merged.entry(key.clone()).or_insert_with(|| {
178            order.push(key.clone());
179            Target {
180                namespace: target.namespace.clone(),
181                kubernetes_type: target.kubernetes_type.clone(),
182                name: target.name.clone(),
183                containers: Vec::new(),
184                has_wildcard: false,
185            }
186        });
187
188        entry
189            .add_container(target.container.trim())
190            .map_err(|e| CopyError::ValidatingTarget(name.clone(), Box::new(e)))?;
191    }
192
193    if merged.is_empty() {
194        return Ok(Vec::new());
195    }
196
197    order.sort();
198
199    let mut targets = Vec::with_capacity(order.len());
200
201    for key in order {
202        let mut entry = merged.remove(&key).expect("key came from merged");
203        entry.containers.sort();
204        entry.has_wildcard = false; // clear intermediate value
205        targets.push(entry);
206    }
207
208    Ok(targets)
209}
210
211/// Modifies a deployment's JSON structure for use as a `-caproni` copy.
212/// Renames the deployment, removes health probes, and replaces
213/// container commands with `sleep infinity`. When `containers` is
214/// non-empty, only those containers are modified; all others are left
215/// unchanged.
216///
217/// # Errors
218///
219/// Returns [`CopyError::InvalidDeploymentStructure`] if the JSON does
220/// not have the expected shape, or [`CopyError::ContainerNotFound`] if
221/// a named container in `containers` does not exist in the deployment.
222pub fn modify_deployment_for_caproni(
223    deployment: &mut Value,
224    target_name: &str,
225    image: &str,
226    containers: &[String],
227) -> Result<(), CopyError> {
228    let metadata = deployment
229        .get_mut("metadata")
230        .and_then(Value::as_object_mut)
231        .ok_or_else(|| {
232            CopyError::InvalidDeploymentStructure("metadata is not an object".to_string())
233        })?;
234
235    metadata.insert(
236        "name".to_string(),
237        Value::String(format!("{target_name}{COPY_SUFFIX}")),
238    );
239
240    let container_set = build_container_set(containers)?;
241
242    let container_list = extract_container_list_mut(deployment)?;
243
244    let mut matched: HashMap<String, bool> = HashMap::new();
245
246    for container in container_list.iter_mut() {
247        let c = container.as_object_mut().ok_or_else(|| {
248            CopyError::InvalidDeploymentStructure("container is not an object".to_string())
249        })?;
250
251        let container_name = c
252            .get("name")
253            .and_then(Value::as_str)
254            .filter(|s| !s.is_empty())
255            .ok_or_else(|| {
256                CopyError::InvalidDeploymentStructure("container has no name".to_string())
257            })?
258            .to_string();
259
260        if !containers.is_empty() && !container_set.contains_key(&container_name) {
261            continue;
262        }
263
264        matched.insert(container_name, true);
265
266        c.remove("livenessProbe");
267        c.remove("readinessProbe");
268        c.remove("startupProbe");
269        c.insert(
270            "command".to_string(),
271            Value::Array(vec![
272                Value::String("sleep".to_string()),
273                Value::String("infinity".to_string()),
274            ]),
275        );
276        c.insert("image".to_string(), Value::String(image.to_string()));
277    }
278
279    for name in containers {
280        if !matched.contains_key(name) {
281            return Err(CopyError::ContainerNotFound(name.clone()));
282        }
283    }
284
285    Ok(())
286}
287
288/// Traverses the deployment JSON structure and returns a mutable
289/// reference to the containers array. Ported from `extractContainerList`,
290/// specialized to the one fixed path this module ever reads (the Go
291/// original's general-purpose `dyn.Access` is not otherwise used by
292/// this crate, so it was not ported wholesale).
293fn extract_container_list_mut(deployment: &mut Value) -> Result<&mut Vec<Value>, CopyError> {
294    let spec = get_or_not_found(deployment, "spec", "spec")?;
295    let template = get_or_not_found(spec, "template", "spec.template")?;
296    let template_spec = get_or_not_found(template, "spec", "spec.template.spec")?;
297
298    template_spec
299        .get_mut("containers")
300        .and_then(Value::as_array_mut)
301        .ok_or_else(|| {
302            CopyError::InvalidDeploymentStructure(
303                "spec.template.spec.containers: not found".to_string(),
304            )
305        })
306}
307
308fn get_or_not_found<'a>(
309    value: &'a mut Value,
310    key: &str,
311    path_so_far: &str,
312) -> Result<&'a mut Value, CopyError> {
313    value
314        .get_mut(key)
315        .ok_or_else(|| CopyError::InvalidDeploymentStructure(format!("{path_so_far}: not found")))
316}
317
318/// Validates that `containers` has no duplicates and returns a set for
319/// O(1) lookup.
320fn build_container_set(containers: &[String]) -> Result<HashMap<String, bool>, CopyError> {
321    let mut set = HashMap::with_capacity(containers.len());
322
323    for name in containers {
324        if set.contains_key(name) {
325            return Err(CopyError::DuplicateContainer(name.clone()));
326        }
327
328        set.insert(name.clone(), true);
329    }
330
331    Ok(set)
332}
333
334impl Manager {
335    /// Scales down original deployments and creates `-caproni` copies
336    /// for the given targets. Skips unsupported resource types.
337    ///
338    /// Callers are responsible for passing a deduplicated, pre-merged
339    /// target list; in practice, callers should use
340    /// [`collect_and_validate_targets`] to produce it. Returns a list
341    /// of [`CopyInfo`] for cleanup operations.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`CopyError`] if scaling down, fetching, modifying, or
346    /// applying a copy fails for any target.
347    pub fn prepare_copies(
348        &self,
349        targets: &[Target],
350        copy_image: &str,
351    ) -> Result<Vec<CopyInfo>, CopyError> {
352        let mut infos = Vec::new();
353        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
354
355        for target in targets {
356            if target.kubernetes_type != "deployment" && target.kubernetes_type != "statefulset" {
357                tracing::warn!(
358                    r#type = %target.kubernetes_type,
359                    name = %target.name,
360                    namespace = %target.namespace,
361                    "skipping copy-creation for target with unsupported resource type"
362                );
363                continue;
364            }
365
366            let key = format!(
367                "{}/{}/{}",
368                target.namespace, target.kubernetes_type, target.name
369            );
370            if !seen.insert(key) {
371                continue;
372            }
373
374            tracing::info!(
375                r#type = %target.kubernetes_type,
376                name = %target.name,
377                namespace = %target.namespace,
378                "preparing deployment copy"
379            );
380
381            self.scale_down(target)?;
382            self.create_caproni_deployment(target, copy_image)?;
383
384            infos.push(CopyInfo {
385                resource_type: target.kubernetes_type.clone(),
386                name: target.name.clone(),
387                namespace: target.namespace.clone(),
388            });
389        }
390
391        Ok(infos)
392    }
393
394    /// Scales the original deployment to 0 replicas.
395    fn scale_down(&self, target: &Target) -> Result<(), CopyError> {
396        tracing::info!(
397            r#type = %target.kubernetes_type,
398            name = %target.name,
399            namespace = %target.namespace,
400            "scaling down original deployment"
401        );
402
403        self.kubectl
404            .scale(&target.kubernetes_type, &target.name, &target.namespace, 0)
405            .map_err(|e| {
406                CopyError::Kubectl(
407                    format!(
408                        "scaling down {}/{}: {e}",
409                        target.kubernetes_type, target.name
410                    ),
411                    e,
412                )
413            })
414    }
415
416    /// Fetches the original deployment, modifies it for caproni use,
417    /// applies it as a `-caproni` deployment, scales it up, and waits
418    /// for it to become available.
419    fn create_caproni_deployment(
420        &self,
421        target: &Target,
422        copy_image: &str,
423    ) -> Result<(), CopyError> {
424        let deployment = self.fetch_and_modify_deployment(target, copy_image)?;
425        self.apply_and_scale_caproni_deployment(target, &deployment)
426    }
427
428    /// Fetches the original deployment JSON and modifies it for
429    /// caproni use.
430    fn fetch_and_modify_deployment(
431        &self,
432        target: &Target,
433        copy_image: &str,
434    ) -> Result<Value, CopyError> {
435        tracing::info!(
436            r#type = %target.kubernetes_type,
437            name = %target.name,
438            namespace = %target.namespace,
439            "fetching deployment JSON"
440        );
441
442        let json_data = self
443            .kubectl
444            .get(&target.kubernetes_type, &target.name, &target.namespace)
445            .map_err(|e| {
446                CopyError::Kubectl(
447                    format!("getting {}/{}: {e}", target.kubernetes_type, target.name),
448                    e,
449                )
450            })?;
451
452        let mut deployment: Value = serde_json::from_str(&json_data).map_err(|e| {
453            CopyError::ParseJson(format!("{}/{}", target.kubernetes_type, target.name), e)
454        })?;
455
456        modify_deployment_for_caproni(
457            &mut deployment,
458            &target.name,
459            copy_image,
460            &target.containers,
461        )?;
462
463        Ok(deployment)
464    }
465
466    /// Applies the modified deployment, scales it up, and waits for
467    /// availability.
468    fn apply_and_scale_caproni_deployment(
469        &self,
470        target: &Target,
471        deployment: &Value,
472    ) -> Result<(), CopyError> {
473        let modified_json = serde_json::to_vec(deployment).map_err(|e| {
474            CopyError::SerializeJson(format!("{}/{}", target.kubernetes_type, target.name), e)
475        })?;
476
477        let copy_name = format!("{}{COPY_SUFFIX}", target.name);
478
479        tracing::info!(
480            r#type = %target.kubernetes_type,
481            name = %copy_name,
482            namespace = %target.namespace,
483            "creating copy deployment"
484        );
485
486        self.kubectl
487            .apply(&modified_json, &target.namespace)
488            .map_err(|e| CopyError::Kubectl(format!("applying copy deployment: {e}"), e))?;
489
490        tracing::info!(
491            r#type = %target.kubernetes_type,
492            name = %copy_name,
493            namespace = %target.namespace,
494            "scaling up copy deployment"
495        );
496
497        self.kubectl
498            .scale(&target.kubernetes_type, &copy_name, &target.namespace, 1)
499            .map_err(|e| CopyError::Kubectl(format!("scaling copy deployment: {e}"), e))?;
500
501        tracing::info!(
502            r#type = %target.kubernetes_type,
503            name = %copy_name,
504            namespace = %target.namespace,
505            "waiting for copy rollout to complete"
506        );
507
508        self.kubectl
509            .wait_for_rollout_status(
510                &target.kubernetes_type,
511                &copy_name,
512                &target.namespace,
513                Duration::from_secs(3 * 60),
514            )
515            .map_err(|e| CopyError::Kubectl(format!("waiting for copy rollout: {e}"), e))
516    }
517
518    /// Deletes a `-caproni` deployment copy and scales the original
519    /// back up.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`CopyError`] if deleting, scaling, or waiting fails.
524    pub fn cleanup_copy(&self, deployment: &CopyInfo) -> Result<(), CopyError> {
525        let copy_name = format!("{}{COPY_SUFFIX}", deployment.name);
526
527        tracing::info!(
528            r#type = %deployment.resource_type,
529            name = %copy_name,
530            namespace = %deployment.namespace,
531            "deleting copy deployment"
532        );
533
534        self.kubectl
535            .delete(&deployment.resource_type, &copy_name, &deployment.namespace)
536            .map_err(|e| {
537                CopyError::Kubectl(
538                    format!(
539                        "deleting {}/{}{COPY_SUFFIX} in {}: {e}",
540                        deployment.resource_type, deployment.name, deployment.namespace
541                    ),
542                    e,
543                )
544            })?;
545
546        tracing::info!(
547            r#type = %deployment.resource_type,
548            name = %deployment.name,
549            namespace = %deployment.namespace,
550            "scaling up original deployment"
551        );
552
553        self.kubectl
554            .scale(
555                &deployment.resource_type,
556                &deployment.name,
557                &deployment.namespace,
558                SCALE_UP_REPLICA_COUNT,
559            )
560            .map_err(|e| {
561                CopyError::Kubectl(
562                    format!(
563                        "scaling up {}/{} in {}: {e}",
564                        deployment.resource_type, deployment.name, deployment.namespace
565                    ),
566                    e,
567                )
568            })?;
569
570        tracing::info!(
571            r#type = %deployment.resource_type,
572            name = %deployment.name,
573            namespace = %deployment.namespace,
574            "waiting for original deployment to be available"
575        );
576
577        self.kubectl
578            .wait(
579                &deployment.resource_type,
580                &deployment.name,
581                &deployment.namespace,
582                "available",
583                Duration::from_secs(60),
584            )
585            .map_err(|e| {
586                CopyError::Kubectl(
587                    format!(
588                        "waiting for {}/{} in {}: {e}",
589                        deployment.resource_type, deployment.name, deployment.namespace
590                    ),
591                    e,
592                )
593            })
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use std::collections::HashMap;
600
601    use serde_json::json;
602    use xianyun_backends::kubectl::KubectlError;
603    use xianyun_core::config::latest::{MirrorConfig, MirrorTargetConfig, ReloaderConfig};
604
605    use super::super::manager::Manager;
606    use super::super::test_support::MockKubectlBackend;
607    use super::*;
608
609    const TEST_COPY_IMAGE: &str = "docker.io/library/alpine:test";
610
611    fn valid_deployment_json(name: &str) -> String {
612        json!({
613            "metadata": {"name": name},
614            "spec": {
615                "selector": {"matchLabels": {"app": name}},
616                "template": {
617                    "spec": {
618                        "containers": [{
619                            "name": "app",
620                            "image": "nginx:latest",
621                            "command": ["nginx"],
622                            "livenessProbe": {"httpGet": {"port": 80}},
623                            "readinessProbe": {"httpGet": {"port": 80}},
624                            "startupProbe": {"httpGet": {"port": 80}}
625                        }]
626                    }
627                }
628            }
629        })
630        .to_string()
631    }
632
633    fn kubectl_err(msg: &str) -> KubectlError {
634        KubectlError::JobFailed(msg.to_string())
635    }
636
637    // Ported from internal/reloader/mirror/copy_test.go:58 (TestModifyDeploymentForCaproni)
638    #[test]
639    fn test_modify_deployment_for_caproni() {
640        struct Case {
641            name: &'static str,
642            input: &'static str,
643            container_names: Vec<&'static str>,
644            expected: Option<serde_json::Value>,
645            want_err: Option<&'static str>,
646        }
647
648        let cases = vec![
649            Case {
650                name: "single container",
651                input: r#"{
652                    "metadata": {"name": "myapp"},
653                    "spec": {"template": {"spec": {"containers": [
654                        {"name": "app", "command": ["start"], "livenessProbe": {}, "readinessProbe": {}}
655                    ]}}}
656                }"#,
657                container_names: vec![],
658                expected: Some(json!({
659                    "metadata": {"name": "myapp-caproni"},
660                    "spec": {"template": {"spec": {"containers": [
661                        {"name": "app", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE}
662                    ]}}}
663                })),
664                want_err: None,
665            },
666            Case {
667                name: "all probes removed including startupProbe",
668                input: r#"{
669                    "metadata": {"name": "myapp"},
670                    "spec": {"template": {"spec": {"containers": [
671                        {"name": "app", "command": ["start"], "livenessProbe": {}, "readinessProbe": {}, "startupProbe": {"httpGet": {"port": 8080}}}
672                    ]}}}
673                }"#,
674                container_names: vec![],
675                expected: Some(json!({
676                    "metadata": {"name": "myapp-caproni"},
677                    "spec": {"template": {"spec": {"containers": [
678                        {"name": "app", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE}
679                    ]}}}
680                })),
681                want_err: None,
682            },
683            Case {
684                name: "multiple containers",
685                input: r#"{
686                    "metadata": {"name": "myapp"},
687                    "spec": {"template": {"spec": {"containers": [
688                        {"name": "app1", "livenessProbe": {}},
689                        {"name": "app2", "readinessProbe": {}}
690                    ]}}}
691                }"#,
692                container_names: vec![],
693                expected: Some(json!({
694                    "metadata": {"name": "myapp-caproni"},
695                    "spec": {"template": {"spec": {"containers": [
696                        {"name": "app1", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE},
697                        {"name": "app2", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE}
698                    ]}}}
699                })),
700                want_err: None,
701            },
702            Case {
703                name: "named container - only target container replaced",
704                input: r#"{
705                    "metadata": {"name": "myapp"},
706                    "spec": {"template": {"spec": {"containers": [
707                        {"name": "app1", "image": "nginx:latest", "livenessProbe": {}},
708                        {"name": "app2", "image": "redis:latest", "readinessProbe": {}}
709                    ]}}}
710                }"#,
711                container_names: vec!["app1"],
712                expected: Some(json!({
713                    "metadata": {"name": "myapp-caproni"},
714                    "spec": {"template": {"spec": {"containers": [
715                        {"name": "app1", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE},
716                        {"name": "app2", "image": "redis:latest", "readinessProbe": {}}
717                    ]}}}
718                })),
719                want_err: None,
720            },
721            Case {
722                name: "multiple named containers - both replaced",
723                input: r#"{
724                    "metadata": {"name": "myapp"},
725                    "spec": {"template": {"spec": {"containers": [
726                        {"name": "app1", "image": "nginx:latest", "livenessProbe": {}},
727                        {"name": "app2", "image": "redis:latest", "readinessProbe": {}}
728                    ]}}}
729                }"#,
730                container_names: vec!["app1", "app2"],
731                expected: Some(json!({
732                    "metadata": {"name": "myapp-caproni"},
733                    "spec": {"template": {"spec": {"containers": [
734                        {"name": "app1", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE},
735                        {"name": "app2", "command": ["sleep", "infinity"], "image": TEST_COPY_IMAGE}
736                    ]}}}
737                })),
738                want_err: None,
739            },
740            Case {
741                name: "named container - not found returns error",
742                input: r#"{
743                    "metadata": {"name": "myapp"},
744                    "spec": {"template": {"spec": {"containers": [
745                        {"name": "app", "image": "nginx:latest"}
746                    ]}}}
747                }"#,
748                container_names: vec!["doesnotexist"],
749                expected: None,
750                want_err: Some("container not found in deployment: doesnotexist"),
751            },
752            Case {
753                name: "one of two named containers not found returns error",
754                input: r#"{
755                    "metadata": {"name": "myapp"},
756                    "spec": {"template": {"spec": {"containers": [
757                        {"name": "app", "image": "nginx:latest"}
758                    ]}}}
759                }"#,
760                container_names: vec!["app", "doesnotexist"],
761                expected: None,
762                want_err: Some("container not found in deployment: doesnotexist"),
763            },
764            Case {
765                name: "missing metadata",
766                input: "{}",
767                container_names: vec![],
768                expected: None,
769                want_err: Some("invalid deployment structure: metadata is not an object"),
770            },
771            Case {
772                name: "missing spec",
773                input: r#"{"metadata": {"name": "myapp"}}"#,
774                container_names: vec![],
775                expected: None,
776                want_err: Some("invalid deployment structure: spec: not found"),
777            },
778            Case {
779                name: "missing template",
780                input: r#"{"metadata": {"name": "myapp"}, "spec": {}}"#,
781                container_names: vec![],
782                expected: None,
783                want_err: Some("invalid deployment structure: spec.template: not found"),
784            },
785            Case {
786                name: "missing template spec",
787                input: r#"{"metadata": {"name": "myapp"}, "spec": {"template": {}}}"#,
788                container_names: vec![],
789                expected: None,
790                want_err: Some("invalid deployment structure: spec.template.spec: not found"),
791            },
792            Case {
793                name: "missing containers",
794                input: r#"{"metadata": {"name": "myapp"}, "spec": {"template": {"spec": {}}}}"#,
795                container_names: vec![],
796                expected: None,
797                want_err: Some(
798                    "invalid deployment structure: spec.template.spec.containers: not found",
799                ),
800            },
801            Case {
802                name: "invalid container",
803                input: r#"{"metadata": {"name": "myapp"}, "spec": {"template": {"spec": {"containers": ["not-a-map"]}}}}"#,
804                container_names: vec![],
805                expected: None,
806                want_err: Some("invalid deployment structure: container is not an object"),
807            },
808            Case {
809                name: "container missing name field returns error",
810                input: r#"{"metadata": {"name": "myapp"}, "spec": {"template": {"spec": {"containers": [{"image": "nginx:latest"}]}}}}"#,
811                container_names: vec![],
812                expected: None,
813                want_err: Some("invalid deployment structure: container has no name"),
814            },
815            Case {
816                name: "duplicate container name in containers slice returns error",
817                input: r#"{
818                    "metadata": {"name": "myapp"},
819                    "spec": {"template": {"spec": {"containers": [
820                        {"name": "app", "image": "nginx:latest"}
821                    ]}}}
822                }"#,
823                container_names: vec!["app", "app"],
824                expected: None,
825                want_err: Some("duplicate container for target: app"),
826            },
827        ];
828
829        for case in cases {
830            let mut deployment: Value = serde_json::from_str(case.input).unwrap();
831            let container_names: Vec<String> =
832                case.container_names.iter().map(|s| s.to_string()).collect();
833
834            let result = modify_deployment_for_caproni(
835                &mut deployment,
836                "myapp",
837                TEST_COPY_IMAGE,
838                &container_names,
839            );
840
841            if let Some(want_err) = case.want_err {
842                let err = result.unwrap_err();
843                assert!(
844                    err.to_string().contains(want_err),
845                    "{}: expected error containing {want_err:?}, got {err}",
846                    case.name
847                );
848                continue;
849            }
850
851            result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
852            assert_eq!(deployment, case.expected.unwrap(), "{}", case.name);
853        }
854    }
855
856    #[derive(Default)]
857    struct CopyMocksData {
858        get_json: Option<String>,
859        scale_err: bool,
860        get_err: bool,
861        apply_err: bool,
862        scale_up_err: bool,
863        wait_err: bool,
864    }
865
866    fn setup_copy_mocks(
867        mut builder: MockKubectlBackend,
868        target: &Target,
869        data: &CopyMocksData,
870    ) -> MockKubectlBackend {
871        let name = target.name.clone();
872        let scale_err = data.scale_err;
873
874        builder = builder.expect_scale(move |_res_type, _name, _ns, replicas| {
875            assert_eq!(replicas, 0);
876            if scale_err {
877                Err(kubectl_err("scale failed"))
878            } else {
879                Ok(())
880            }
881        });
882
883        if data.scale_err {
884            return builder;
885        }
886
887        let get_json = data
888            .get_json
889            .clone()
890            .unwrap_or_else(|| valid_deployment_json(&name));
891        let get_err = data.get_err;
892        let is_valid_json = serde_json::from_str::<Value>(&get_json).is_ok();
893
894        builder = builder.expect_get(move |_res_type, _name, _ns| {
895            if get_err {
896                Err(kubectl_err("get failed"))
897            } else {
898                Ok(get_json.clone())
899            }
900        });
901
902        if data.get_err || !is_valid_json {
903            return builder;
904        }
905
906        let apply_err = data.apply_err;
907        builder = builder.expect_apply(move |_json, _ns| {
908            if apply_err {
909                Err(kubectl_err("apply failed"))
910            } else {
911                Ok(())
912            }
913        });
914
915        if data.apply_err {
916            return builder;
917        }
918
919        let scale_up_err = data.scale_up_err;
920        builder = builder.expect_scale(move |_res_type, _name, _ns, replicas| {
921            assert_eq!(replicas, 1);
922            if scale_up_err {
923                Err(kubectl_err("scale failed"))
924            } else {
925                Ok(())
926            }
927        });
928
929        if data.scale_up_err {
930            return builder;
931        }
932
933        let wait_err = data.wait_err;
934        builder.expect_wait_for_rollout_status(move |_res_type, _name, _ns| {
935            if wait_err {
936                Err(kubectl_err("wait failed"))
937            } else {
938                Ok(())
939            }
940        })
941    }
942
943    fn setup_deduplicated_copy_mocks(
944        mut builder: MockKubectlBackend,
945        targets: &[Target],
946        data: &CopyMocksData,
947    ) -> MockKubectlBackend {
948        let mut seen = std::collections::HashSet::new();
949
950        for target in targets {
951            let key = format!(
952                "{}/{}/{}",
953                target.namespace, target.kubernetes_type, target.name
954            );
955            if !seen.insert(key) {
956                continue;
957            }
958
959            if target.kubernetes_type == "deployment" || target.kubernetes_type == "statefulset" {
960                builder = setup_copy_mocks(builder, target, data);
961            }
962        }
963
964        builder
965    }
966
967    // Ported from internal/reloader/mirror/copy_test.go:309 (TestPrepareCopies)
968    #[test]
969    fn test_prepare_copies() {
970        struct Case {
971            name: &'static str,
972            targets: Vec<Target>,
973            mock_data: CopyMocksData,
974            want_infos: Vec<CopyInfo>,
975            want_err: Option<&'static str>,
976        }
977
978        fn target(namespace: &str, kubernetes_type: &str, name: &str) -> Target {
979            Target {
980                namespace: namespace.to_string(),
981                kubernetes_type: kubernetes_type.to_string(),
982                name: name.to_string(),
983                containers: Vec::new(),
984                ..Default::default()
985            }
986        }
987
988        fn info(resource_type: &str, name: &str, namespace: &str) -> CopyInfo {
989            CopyInfo {
990                resource_type: resource_type.to_string(),
991                name: name.to_string(),
992                namespace: namespace.to_string(),
993            }
994        }
995
996        let cases = vec![
997            Case {
998                name: "success with deployment",
999                targets: vec![target("default", "deployment", "myapp")],
1000                mock_data: CopyMocksData::default(),
1001                want_infos: vec![info("deployment", "myapp", "default")],
1002                want_err: None,
1003            },
1004            Case {
1005                name: "success with statefulset",
1006                targets: vec![target("prod", "statefulset", "mydb")],
1007                mock_data: CopyMocksData::default(),
1008                want_infos: vec![info("statefulset", "mydb", "prod")],
1009                want_err: None,
1010            },
1011            Case {
1012                name: "skips unsupported type",
1013                targets: vec![target("default", "daemonset", "myapp")],
1014                mock_data: CopyMocksData::default(),
1015                want_infos: vec![],
1016                want_err: None,
1017            },
1018            Case {
1019                name: "deduplicates targets",
1020                targets: vec![
1021                    target("default", "deployment", "myapp"),
1022                    target("default", "deployment", "myapp"),
1023                ],
1024                mock_data: CopyMocksData::default(),
1025                want_infos: vec![info("deployment", "myapp", "default")],
1026                want_err: None,
1027            },
1028            Case {
1029                name: "scale down error",
1030                targets: vec![target("default", "deployment", "myapp")],
1031                mock_data: CopyMocksData {
1032                    scale_err: true,
1033                    ..Default::default()
1034                },
1035                want_infos: vec![],
1036                want_err: Some("scale failed"),
1037            },
1038            Case {
1039                name: "get error",
1040                targets: vec![target("default", "deployment", "myapp")],
1041                mock_data: CopyMocksData {
1042                    get_err: true,
1043                    ..Default::default()
1044                },
1045                want_infos: vec![],
1046                want_err: Some("get failed"),
1047            },
1048            Case {
1049                name: "apply error",
1050                targets: vec![target("default", "deployment", "myapp")],
1051                mock_data: CopyMocksData {
1052                    apply_err: true,
1053                    ..Default::default()
1054                },
1055                want_infos: vec![],
1056                want_err: Some("apply failed"),
1057            },
1058            Case {
1059                name: "invalid JSON",
1060                targets: vec![target("default", "deployment", "myapp")],
1061                mock_data: CopyMocksData {
1062                    get_json: Some("not-json".to_string()),
1063                    ..Default::default()
1064                },
1065                want_infos: vec![],
1066                want_err: Some("parsing deployment JSON for deployment/myapp"),
1067            },
1068            Case {
1069                name: "empty targets",
1070                targets: vec![],
1071                mock_data: CopyMocksData::default(),
1072                want_infos: vec![],
1073                want_err: None,
1074            },
1075            Case {
1076                name: "multiple targets",
1077                targets: vec![
1078                    target("ns1", "deployment", "app1"),
1079                    target("ns1", "deployment", "app2"),
1080                    target("ns2", "statefulset", "db1"),
1081                ],
1082                mock_data: CopyMocksData::default(),
1083                want_infos: vec![
1084                    info("deployment", "app1", "ns1"),
1085                    info("deployment", "app2", "ns1"),
1086                    info("statefulset", "db1", "ns2"),
1087                ],
1088                want_err: None,
1089            },
1090        ];
1091
1092        for case in cases {
1093            let builder = setup_deduplicated_copy_mocks(
1094                MockKubectlBackend::new(),
1095                &case.targets,
1096                &case.mock_data,
1097            );
1098
1099            let mgr = Manager::new(Box::new(builder));
1100
1101            let result = mgr.prepare_copies(&case.targets, TEST_COPY_IMAGE);
1102
1103            if let Some(want_err) = case.want_err {
1104                let err = result.unwrap_err();
1105                assert!(
1106                    err.to_string().contains(want_err),
1107                    "{}: expected error containing {want_err:?}, got {err}",
1108                    case.name
1109                );
1110                continue;
1111            }
1112
1113            let infos = result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
1114            assert_eq!(infos, case.want_infos, "{}", case.name);
1115        }
1116    }
1117
1118    fn setup_cleanup_mocks(
1119        info: &CopyInfo,
1120        delete_err: bool,
1121        scale_err: bool,
1122        wait_err: bool,
1123    ) -> MockKubectlBackend {
1124        let mut builder = MockKubectlBackend::new().expect_delete(move |_rt, _name, _ns| {
1125            if delete_err {
1126                Err(kubectl_err("delete failed"))
1127            } else {
1128                Ok(())
1129            }
1130        });
1131
1132        if delete_err {
1133            return builder;
1134        }
1135
1136        builder = builder.expect_scale(move |_rt, _name, _ns, replicas| {
1137            assert_eq!(replicas, 1);
1138            if scale_err {
1139                Err(kubectl_err("scale failed"))
1140            } else {
1141                Ok(())
1142            }
1143        });
1144
1145        if scale_err {
1146            return builder;
1147        }
1148
1149        let _ = info;
1150
1151        builder.expect_wait(move |_rt, _name, _ns, condition| {
1152            assert_eq!(condition, "available");
1153            if wait_err {
1154                Err(kubectl_err("wait failed"))
1155            } else {
1156                Ok(())
1157            }
1158        })
1159    }
1160
1161    // Ported from internal/reloader/mirror/copy_test.go:443 (TestCleanupCopy)
1162    #[test]
1163    fn test_cleanup_copy() {
1164        let copy_info = CopyInfo {
1165            resource_type: "deployment".to_string(),
1166            name: "myapp".to_string(),
1167            namespace: "default".to_string(),
1168        };
1169
1170        struct Case {
1171            name: &'static str,
1172            delete_err: bool,
1173            scale_err: bool,
1174            wait_err: bool,
1175            want_err: Option<&'static str>,
1176        }
1177
1178        let cases = vec![
1179            Case {
1180                name: "success",
1181                delete_err: false,
1182                scale_err: false,
1183                wait_err: false,
1184                want_err: None,
1185            },
1186            Case {
1187                name: "delete error",
1188                delete_err: true,
1189                scale_err: false,
1190                wait_err: false,
1191                want_err: Some("delete failed"),
1192            },
1193            Case {
1194                name: "scale error",
1195                delete_err: false,
1196                scale_err: true,
1197                wait_err: false,
1198                want_err: Some("scale failed"),
1199            },
1200            Case {
1201                name: "wait error",
1202                delete_err: false,
1203                scale_err: false,
1204                wait_err: true,
1205                want_err: Some("wait failed"),
1206            },
1207        ];
1208
1209        for case in cases {
1210            let mock_kubectl =
1211                setup_cleanup_mocks(&copy_info, case.delete_err, case.scale_err, case.wait_err);
1212            let mgr = Manager::new(Box::new(mock_kubectl));
1213
1214            let result = mgr.cleanup_copy(&copy_info);
1215
1216            if let Some(want_err) = case.want_err {
1217                let err = result.unwrap_err();
1218                assert!(
1219                    err.to_string().contains(want_err),
1220                    "{}: expected error containing {want_err:?}, got {err}",
1221                    case.name
1222                );
1223                continue;
1224            }
1225
1226            result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
1227        }
1228    }
1229
1230    // Ported from internal/reloader/mirror/copy_test.go:498 (TestCollectAndValidateTargets)
1231    #[test]
1232    fn test_collect_and_validate_targets() {
1233        fn mirror_reloader(
1234            ns: &str,
1235            res_type: &str,
1236            name: &str,
1237            container: &str,
1238        ) -> ReloaderConfig {
1239            ReloaderConfig {
1240                reloader_type: "mirror".to_string(),
1241                mirror: Some(MirrorConfig {
1242                    target: Some(MirrorTargetConfig {
1243                        namespace: ns.to_string(),
1244                        kubernetes_type: res_type.to_string(),
1245                        name: name.to_string(),
1246                        container: container.to_string(),
1247                    }),
1248                    ..Default::default()
1249                }),
1250                ..Default::default()
1251            }
1252        }
1253
1254        fn target_with(ns: &str, res_type: &str, name: &str, containers: &[&str]) -> Target {
1255            Target {
1256                namespace: ns.to_string(),
1257                kubernetes_type: res_type.to_string(),
1258                name: name.to_string(),
1259                containers: containers.iter().map(|s| s.to_string()).collect(),
1260                ..Default::default()
1261            }
1262        }
1263
1264        struct Case {
1265            name: &'static str,
1266            reloaders: HashMap<String, ReloaderConfig>,
1267            want_targets: Vec<Target>,
1268            want_err: bool,
1269        }
1270
1271        let cases = vec![
1272            Case {
1273                name: "two reloaders same pod different containers merged",
1274                reloaders: HashMap::from([
1275                    (
1276                        "reloader-a".to_string(),
1277                        mirror_reloader("ns", "deployment", "app", "main"),
1278                    ),
1279                    (
1280                        "reloader-b".to_string(),
1281                        mirror_reloader("ns", "deployment", "app", "sidecar"),
1282                    ),
1283                ]),
1284                want_targets: vec![target_with("ns", "deployment", "app", &["main", "sidecar"])],
1285                want_err: false,
1286            },
1287            Case {
1288                name: "reloaders targeting different pods produce separate targets",
1289                reloaders: HashMap::from([
1290                    (
1291                        "a".to_string(),
1292                        mirror_reloader("ns", "deployment", "svc-a", "c1"),
1293                    ),
1294                    (
1295                        "b".to_string(),
1296                        mirror_reloader("ns", "deployment", "svc-b", "c2"),
1297                    ),
1298                ]),
1299                want_targets: vec![
1300                    target_with("ns", "deployment", "svc-a", &["c1"]),
1301                    target_with("ns", "deployment", "svc-b", &["c2"]),
1302                ],
1303                want_err: false,
1304            },
1305            Case {
1306                name: "single container target",
1307                reloaders: HashMap::from([(
1308                    "a".to_string(),
1309                    mirror_reloader("ns", "deployment", "app", "web"),
1310                )]),
1311                want_targets: vec![target_with("ns", "deployment", "app", &["web"])],
1312                want_err: false,
1313            },
1314            Case {
1315                name: "pod-level target produces nil containers",
1316                reloaders: HashMap::from([(
1317                    "a".to_string(),
1318                    mirror_reloader("ns", "deployment", "app", ""),
1319                )]),
1320                want_targets: vec![target_with("ns", "deployment", "app", &[])],
1321                want_err: false,
1322            },
1323            Case {
1324                name: "non-mirror reloaders are ignored",
1325                reloaders: HashMap::from([
1326                    (
1327                        "helm-reloader".to_string(),
1328                        ReloaderConfig {
1329                            reloader_type: "helm".to_string(),
1330                            ..Default::default()
1331                        },
1332                    ),
1333                    (
1334                        "skaffold".to_string(),
1335                        ReloaderConfig {
1336                            reloader_type: "skaffold".to_string(),
1337                            ..Default::default()
1338                        },
1339                    ),
1340                ]),
1341                want_targets: vec![],
1342                want_err: false,
1343            },
1344            Case {
1345                name: "empty reloaders returns nil",
1346                reloaders: HashMap::new(),
1347                want_targets: vec![],
1348                want_err: false,
1349            },
1350            Case {
1351                name: "duplicate container for same pod returns error",
1352                reloaders: HashMap::from([
1353                    (
1354                        "a".to_string(),
1355                        mirror_reloader("ns", "deployment", "app", "web"),
1356                    ),
1357                    (
1358                        "b".to_string(),
1359                        mirror_reloader("ns", "deployment", "app", "web"),
1360                    ),
1361                ]),
1362                want_targets: vec![],
1363                want_err: true,
1364            },
1365            Case {
1366                name: "pod-level target mixed with container target returns error",
1367                reloaders: HashMap::from([
1368                    (
1369                        "a".to_string(),
1370                        mirror_reloader("ns", "deployment", "app", ""),
1371                    ),
1372                    (
1373                        "b".to_string(),
1374                        mirror_reloader("ns", "deployment", "app", "web"),
1375                    ),
1376                ]),
1377                want_targets: vec![],
1378                want_err: true,
1379            },
1380            Case {
1381                name: "container target mixed with pod-level target returns error",
1382                reloaders: HashMap::from([
1383                    (
1384                        "a".to_string(),
1385                        mirror_reloader("ns", "deployment", "app", "web"),
1386                    ),
1387                    (
1388                        "b".to_string(),
1389                        mirror_reloader("ns", "deployment", "app", ""),
1390                    ),
1391                ]),
1392                want_targets: vec![],
1393                want_err: true,
1394            },
1395            Case {
1396                name: "mirror reloader with nil mirror config is skipped",
1397                reloaders: HashMap::from([(
1398                    "a".to_string(),
1399                    ReloaderConfig {
1400                        reloader_type: "mirror".to_string(),
1401                        mirror: None,
1402                        ..Default::default()
1403                    },
1404                )]),
1405                want_targets: vec![],
1406                want_err: false,
1407            },
1408            Case {
1409                name: "whitespace-only container name is treated as pod-level target",
1410                reloaders: HashMap::from([(
1411                    "a".to_string(),
1412                    mirror_reloader("ns", "deployment", "app", "  "),
1413                )]),
1414                want_targets: vec![target_with("ns", "deployment", "app", &[])],
1415                want_err: false,
1416            },
1417        ];
1418
1419        for case in cases {
1420            let result = collect_and_validate_targets(&case.reloaders);
1421
1422            if case.want_err {
1423                assert!(result.is_err(), "{}: expected an error", case.name);
1424                continue;
1425            }
1426
1427            let got = result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
1428            assert_eq!(got, case.want_targets, "{}", case.name);
1429        }
1430    }
1431}