1use 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
15pub const COPY_SUFFIX: &str = "-caproni";
18
19pub const SCALE_UP_REPLICA_COUNT: i64 = 1;
25
26#[derive(Debug, Error)]
28pub enum CopyError {
29 #[error("invalid deployment structure: {0}")]
32 InvalidDeploymentStructure(String),
33 #[error("container not found in deployment: {0}")]
35 ContainerNotFound(String),
36 #[error("duplicate container for target: {0}")]
40 DuplicateContainer(String),
41 #[error("cannot mix pod-level and container-level targets for the same resource: {0}")]
44 MixedPodAndContainerTargets(String),
45 #[error("validating mirror target for reloader {0:?}: {1}")]
47 ValidatingTarget(String, #[source] Box<CopyError>),
48 #[error("{0}")]
51 Kubectl(String, #[source] xianyun_backends::kubectl::KubectlError),
52 #[error("parsing deployment JSON for {0}: {1}")]
54 ParseJson(String, #[source] serde_json::Error),
55 #[error("marshaling modified deployment {0}: {1}")]
57 SerializeJson(String, #[source] serde_json::Error),
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Target {
63 pub namespace: String,
64 pub kubernetes_type: String,
65 pub name: String,
66 pub containers: Vec<String>,
70 has_wildcard: bool,
74}
75
76impl Target {
77 #[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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CopyInfo {
128 pub resource_type: String,
129 pub name: String,
130 pub namespace: String,
131}
132
133pub 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 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; targets.push(entry);
206 }
207
208 Ok(targets)
209}
210
211pub 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
288fn 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
318fn 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 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 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 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 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 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, ©_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 ©_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 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, ©_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 #[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 #[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 #[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(©_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(©_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 #[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}