1#[allow(clippy::wildcard_imports)]
10use super::types::*;
11use crate::constants::{API_GROUP_VERSION, KIND_BIND9_CLUSTER, KIND_BIND9_INSTANCE};
12use crate::reconcilers::pagination::list_all_paginated;
13
14#[allow(clippy::too_many_lines)]
30pub(super) async fn reconcile_managed_instances(
31 ctx: &Context,
32 cluster: &Bind9Cluster,
33) -> Result<()> {
34 let client = ctx.client.clone();
35 let namespace = cluster.namespace().unwrap_or_default();
36 let cluster_name = cluster.name_any();
37
38 info!(
39 "Reconciling managed instances for cluster {}/{}",
40 namespace, cluster_name
41 );
42
43 let primary_replicas = cluster
45 .spec
46 .common
47 .primary
48 .as_ref()
49 .and_then(|p| p.replicas)
50 .unwrap_or(0);
51
52 let secondary_replicas = cluster
53 .spec
54 .common
55 .secondary
56 .as_ref()
57 .and_then(|s| s.replicas)
58 .unwrap_or(0);
59
60 debug!(
61 "Desired replicas: {} primary, {} secondary",
62 primary_replicas, secondary_replicas
63 );
64
65 if primary_replicas == 0 && secondary_replicas == 0 {
66 debug!(
67 "No instances requested for cluster {}/{}",
68 namespace, cluster_name
69 );
70 return Ok(());
71 }
72
73 let api: Api<Bind9Instance> = Api::namespaced(client.clone(), &namespace);
75 let instances = list_all_paginated(&api, ListParams::default()).await?;
76
77 let managed_instances: Vec<_> = instances
79 .into_iter()
80 .filter(|instance| {
81 instance.metadata.labels.as_ref().is_some_and(|labels| {
83 labels.get(BINDY_MANAGED_BY_LABEL) == Some(&MANAGED_BY_BIND9_CLUSTER.to_string())
84 && labels.get(BINDY_CLUSTER_LABEL) == Some(&cluster_name)
85 })
86 })
87 .collect();
88
89 debug!(
90 "Found {} managed instances for cluster {}/{}",
91 managed_instances.len(),
92 namespace,
93 cluster_name
94 );
95
96 let existing_primary: Vec<_> = managed_instances
98 .iter()
99 .filter(|i| i.spec.role == ServerRole::Primary)
100 .collect();
101
102 let existing_secondary: Vec<_> = managed_instances
103 .iter()
104 .filter(|i| i.spec.role == ServerRole::Secondary)
105 .collect();
106
107 debug!(
108 "Existing instances: {} primary, {} secondary",
109 existing_primary.len(),
110 existing_secondary.len()
111 );
112
113 let owner_ref = k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference {
115 api_version: API_GROUP_VERSION.to_string(),
116 kind: KIND_BIND9_CLUSTER.to_string(),
117 name: cluster_name.clone(),
118 uid: cluster.metadata.uid.clone().unwrap_or_default(),
119 controller: Some(true),
120 block_owner_deletion: Some(true),
121 };
122
123 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
127 let mut primaries_to_create = 0;
128 {
129 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
131 let desired_primary_names: std::collections::HashSet<String> = (0..(primary_replicas
132 as usize))
133 .map(|i| format!("{cluster_name}-primary-{i}"))
134 .collect();
135
136 let existing_primary_names: std::collections::HashSet<String> = existing_primary
138 .iter()
139 .map(|instance| instance.name_any())
140 .collect();
141
142 let missing_primaries: Vec<_> = desired_primary_names
144 .difference(&existing_primary_names)
145 .collect();
146
147 for instance_name in missing_primaries {
149 let index = instance_name
151 .rsplit('-')
152 .next()
153 .and_then(|s| s.parse::<usize>().ok())
154 .unwrap_or(0);
155
156 create_managed_instance_with_owner(
157 &client,
158 &namespace,
159 &cluster_name,
160 ServerRole::Primary,
161 index,
162 &cluster.spec.common,
163 Some(owner_ref.clone()),
164 )
165 .await?;
166 primaries_to_create += 1;
167 }
168 }
169
170 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
172 let primaries_to_delete = existing_primary
173 .len()
174 .saturating_sub(primary_replicas as usize);
175 if primaries_to_delete > 0 {
176 let mut sorted_primary: Vec<_> = existing_primary.iter().collect();
178 sorted_primary.sort_by_key(|instance| {
179 instance
180 .metadata
181 .annotations
182 .as_ref()
183 .and_then(|a| a.get(BINDY_INSTANCE_INDEX_ANNOTATION))
184 .and_then(|idx| idx.parse::<usize>().ok())
185 .unwrap_or(0)
186 });
187 sorted_primary.reverse();
188
189 for instance in sorted_primary.iter().take(primaries_to_delete) {
190 let instance_name = instance.name_any();
191 delete_managed_instance(&client, &namespace, &instance_name).await?;
192 }
193 }
194
195 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
199 let mut secondaries_to_create = 0;
200 {
201 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
203 let desired_secondary_names: std::collections::HashSet<String> = (0..(secondary_replicas
204 as usize))
205 .map(|i| format!("{cluster_name}-secondary-{i}"))
206 .collect();
207
208 let existing_secondary_names: std::collections::HashSet<String> = existing_secondary
210 .iter()
211 .map(|instance| instance.name_any())
212 .collect();
213
214 let missing_secondaries: Vec<_> = desired_secondary_names
216 .difference(&existing_secondary_names)
217 .collect();
218
219 for instance_name in missing_secondaries {
221 let index = instance_name
223 .rsplit('-')
224 .next()
225 .and_then(|s| s.parse::<usize>().ok())
226 .unwrap_or(0);
227
228 create_managed_instance_with_owner(
229 &client,
230 &namespace,
231 &cluster_name,
232 ServerRole::Secondary,
233 index,
234 &cluster.spec.common,
235 Some(owner_ref.clone()),
236 )
237 .await?;
238 secondaries_to_create += 1;
239 }
240 }
241
242 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
244 let secondaries_to_delete = existing_secondary
245 .len()
246 .saturating_sub(secondary_replicas as usize);
247 if secondaries_to_delete > 0 {
248 let mut sorted_secondary: Vec<_> = existing_secondary.iter().collect();
250 sorted_secondary.sort_by_key(|instance| {
251 instance
252 .metadata
253 .annotations
254 .as_ref()
255 .and_then(|a| a.get(BINDY_INSTANCE_INDEX_ANNOTATION))
256 .and_then(|idx| idx.parse::<usize>().ok())
257 .unwrap_or(0)
258 });
259 sorted_secondary.reverse();
260
261 for instance in sorted_secondary.iter().take(secondaries_to_delete) {
262 let instance_name = instance.name_any();
263 delete_managed_instance(&client, &namespace, &instance_name).await?;
264 }
265 }
266
267 if primaries_to_create > 0
268 || secondaries_to_create > 0
269 || primaries_to_delete > 0
270 || secondaries_to_delete > 0
271 {
272 info!(
273 "Scaled cluster {}/{}: created {} primary, {} secondary; deleted {} primary, {} secondary",
274 namespace,
275 cluster_name,
276 primaries_to_create,
277 secondaries_to_create,
278 primaries_to_delete,
279 secondaries_to_delete
280 );
281 } else {
282 debug!(
283 "Cluster {}/{} already at desired scale",
284 namespace, cluster_name
285 );
286 }
287
288 update_existing_managed_instances(
290 &client,
291 &namespace,
292 &cluster_name,
293 &cluster.spec.common,
294 &managed_instances,
295 )
296 .await?;
297
298 ensure_managed_instance_resources(&client, cluster, &managed_instances).await?;
300
301 Ok(())
302}
303
304pub(super) async fn update_existing_managed_instances(
324 client: &Client,
325 namespace: &str,
326 cluster_name: &str,
327 common_spec: &crate::crd::Bind9ClusterCommonSpec,
328 managed_instances: &[Bind9Instance],
329) -> Result<()> {
330 if managed_instances.is_empty() {
331 return Ok(());
332 }
333
334 let instance_api: Api<Bind9Instance> = Api::namespaced(client.clone(), namespace);
335 let mut updated_count = 0;
336
337 for instance in managed_instances {
338 let instance_name = instance.name_any();
339
340 let desired_bindcar_config = common_spec
342 .global
343 .as_ref()
344 .and_then(|g| g.bindcar_config.clone());
345
346 let needs_update = instance.spec.version != common_spec.version
348 || instance.spec.image != common_spec.image
349 || instance.spec.config_map_refs != common_spec.config_map_refs
350 || instance.spec.volumes != common_spec.volumes
351 || instance.spec.volume_mounts != common_spec.volume_mounts
352 || instance.spec.bindcar_config != desired_bindcar_config;
353
354 if needs_update {
355 debug!(
356 "Instance {}/{} spec differs from cluster spec, updating",
357 namespace, instance_name
358 );
359
360 #[allow(deprecated)]
362 let updated_spec = Bind9InstanceSpec {
364 cluster_ref: instance.spec.cluster_ref.clone(),
365 role: instance.spec.role,
366 replicas: instance.spec.replicas, version: common_spec.version.clone(),
368 image: common_spec.image.clone(),
369 config_map_refs: common_spec.config_map_refs.clone(),
370 config: None, primary_servers: instance.spec.primary_servers.clone(), volumes: common_spec.volumes.clone(),
373 volume_mounts: common_spec.volume_mounts.clone(),
374 rndc_secret_ref: instance.spec.rndc_secret_ref.clone(), rndc_key: instance.spec.rndc_key.clone(), storage: instance.spec.storage.clone(), placement: instance.spec.placement.clone(),
384 bindcar_config: desired_bindcar_config,
385 };
386
387 let patch = serde_json::json!({
389 "apiVersion": API_GROUP_VERSION,
390 "kind": KIND_BIND9_INSTANCE,
391 "metadata": {
392 "name": instance_name,
393 "namespace": namespace,
394 },
395 "spec": updated_spec,
396 });
397
398 match instance_api
399 .patch(
400 &instance_name,
401 &PatchParams::apply("bindy-controller").force(),
402 &Patch::Apply(&patch),
403 )
404 .await
405 {
406 Ok(_) => {
407 info!(
408 "Updated managed instance {}/{} to match cluster spec",
409 namespace, instance_name
410 );
411 updated_count += 1;
412 }
413 Err(e) => {
414 error!(
415 "Failed to update managed instance {}/{}: {}",
416 namespace, instance_name, e
417 );
418 return Err(e.into());
419 }
420 }
421 } else {
422 debug!(
423 "Instance {}/{} spec matches cluster spec, no update needed",
424 namespace, instance_name
425 );
426 }
427 }
428
429 if updated_count > 0 {
430 info!(
431 "Updated {} managed instances in cluster {}/{} to match current spec",
432 updated_count, namespace, cluster_name
433 );
434 }
435
436 Ok(())
437}
438
439pub(super) async fn ensure_managed_instance_resources(
455 client: &Client,
456 cluster: &Bind9Cluster,
457 managed_instances: &[Bind9Instance],
458) -> Result<()> {
459 let namespace = cluster.namespace().unwrap_or_default();
460 let cluster_name = cluster.name_any();
461
462 if managed_instances.is_empty() {
463 return Ok(());
464 }
465
466 debug!(
467 "Ensuring child resources exist for {} managed instances in cluster {}/{}",
468 managed_instances.len(),
469 namespace,
470 cluster_name
471 );
472
473 let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
474 let secret_api: Api<Secret> = Api::namespaced(client.clone(), &namespace);
475 let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
476 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
477 let instance_api: Api<Bind9Instance> = Api::namespaced(client.clone(), &namespace);
478
479 let cluster_configmap_name = format!("{cluster_name}-config");
481
482 for instance in managed_instances {
483 let instance_name = instance.name_any();
484 let mut missing_resources = Vec::new();
485
486 if configmap_api.get(&cluster_configmap_name).await.is_err() {
488 missing_resources.push("ConfigMap");
489 }
490
491 let secret_name = format!("{instance_name}-rndc-key");
493 if secret_api.get(&secret_name).await.is_err() {
494 missing_resources.push("Secret");
495 }
496
497 if service_api.get(&instance_name).await.is_err() {
499 missing_resources.push("Service");
500 }
501
502 if deployment_api.get(&instance_name).await.is_err() {
504 missing_resources.push("Deployment");
505 }
506
507 if missing_resources.is_empty() {
509 debug!(
510 "All child resources exist for managed instance {}/{}",
511 namespace, instance_name
512 );
513 } else {
514 warn!(
515 "Missing resources for managed instance {}/{}: {}. Triggering reconciliation.",
516 namespace,
517 instance_name,
518 missing_resources.join(", ")
519 );
520
521 let patch = json!({
523 "metadata": {
524 "annotations": {
525 BINDY_RECONCILE_TRIGGER_ANNOTATION: Utc::now().to_rfc3339()
526 }
527 }
528 });
529
530 instance_api
531 .patch(
532 &instance_name,
533 &PatchParams::apply("bindy-cluster-controller"),
534 &Patch::Merge(&patch),
535 )
536 .await?;
537
538 info!(
539 "Triggered reconciliation for instance {}/{} to recreate: {}",
540 namespace,
541 instance_name,
542 missing_resources.join(", ")
543 );
544 }
545 }
546
547 Ok(())
548}
549
550#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
568pub async fn create_managed_instance(
569 client: &Client,
570 namespace: &str,
571 cluster_name: &str,
572 role: ServerRole,
573 index: usize,
574 common_spec: &crate::crd::Bind9ClusterCommonSpec,
575 _is_global: bool,
576) -> Result<()> {
577 create_managed_instance_with_owner(
578 client,
579 namespace,
580 cluster_name,
581 role,
582 index,
583 common_spec,
584 None, )
586 .await
587}
588
589#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
598async fn create_managed_instance_with_owner(
599 client: &Client,
600 namespace: &str,
601 cluster_name: &str,
602 role: ServerRole,
603 index: usize,
604 common_spec: &crate::crd::Bind9ClusterCommonSpec,
605 owner_ref: Option<k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference>,
606) -> Result<()> {
607 let role_str = match role {
608 ServerRole::Primary => ROLE_PRIMARY,
609 ServerRole::Secondary => ROLE_SECONDARY,
610 };
611
612 let instance_name = format!("{cluster_name}-{role_str}-{index}");
613
614 info!(
615 "Creating managed instance {}/{} for cluster {} (role: {:?}, index: {})",
616 namespace, instance_name, cluster_name, role, index
617 );
618
619 let mut labels = BTreeMap::new();
621 labels.insert(
622 BINDY_MANAGED_BY_LABEL.to_string(),
623 MANAGED_BY_BIND9_CLUSTER.to_string(),
624 );
625 labels.insert(BINDY_CLUSTER_LABEL.to_string(), cluster_name.to_string());
626 labels.insert(BINDY_ROLE_LABEL.to_string(), role_str.to_string());
627 labels.insert(K8S_PART_OF.to_string(), PART_OF_BINDY.to_string());
628
629 match role {
631 ServerRole::Primary => {
632 if let Some(primary_config) = &common_spec.primary {
633 if let Some(custom_labels) = &primary_config.labels {
634 for (key, value) in custom_labels {
635 labels.insert(key.clone(), value.clone());
636 }
637 }
638 }
639 }
640 ServerRole::Secondary => {
641 if let Some(secondary_config) = &common_spec.secondary {
642 if let Some(custom_labels) = &secondary_config.labels {
643 for (key, value) in custom_labels {
644 labels.insert(key.clone(), value.clone());
645 }
646 }
647 }
648 }
649 }
650
651 let mut annotations = BTreeMap::new();
653 annotations.insert(
654 BINDY_INSTANCE_INDEX_ANNOTATION.to_string(),
655 index.to_string(),
656 );
657
658 #[allow(deprecated)] let instance_spec = Bind9InstanceSpec {
661 cluster_ref: cluster_name.to_string(),
662 role,
663 replicas: Some(1), version: common_spec.version.clone(),
665 image: common_spec.image.clone(),
666 config_map_refs: common_spec.config_map_refs.clone(),
667 config: None, primary_servers: None, volumes: common_spec.volumes.clone(),
670 volume_mounts: common_spec.volume_mounts.clone(),
671 rndc_secret_ref: None, rndc_key: None, storage: None, placement: None, bindcar_config: common_spec
676 .global
677 .as_ref()
678 .and_then(|g| g.bindcar_config.clone()),
679 };
680
681 let instance = Bind9Instance {
682 metadata: ObjectMeta {
683 name: Some(instance_name.clone()),
684 namespace: Some(namespace.to_string()),
685 labels: Some(labels.clone()),
686 annotations: Some(annotations),
687 owner_references: owner_ref.map(|r| vec![r]),
688 ..Default::default()
689 },
690 spec: instance_spec,
691 status: None,
692 };
693
694 let api: Api<Bind9Instance> = Api::namespaced(client.clone(), namespace);
695
696 match api.create(&PostParams::default(), &instance).await {
697 Ok(_) => {
698 info!(
699 "Successfully created managed instance {}/{}",
700 namespace, instance_name
701 );
702 Ok(())
703 }
704 Err(e) => {
705 if e.to_string().contains("AlreadyExists") {
707 debug!(
708 "Managed instance {}/{} already exists, patching with updated spec",
709 namespace, instance_name
710 );
711
712 let labels_json: serde_json::Map<String, serde_json::Value> = labels
715 .iter()
716 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
717 .collect();
718
719 let patch = serde_json::json!({
720 "apiVersion": API_GROUP_VERSION,
721 "kind": KIND_BIND9_INSTANCE,
722 "metadata": {
723 "name": instance_name,
724 "namespace": namespace,
725 "labels": labels_json,
726 "annotations": {
727 BINDY_INSTANCE_INDEX_ANNOTATION: index.to_string(),
728 },
729 "ownerReferences": instance.metadata.owner_references,
730 },
731 "spec": instance.spec,
732 });
733
734 match api
736 .patch(
737 &instance_name,
738 &PatchParams::apply("bindy-controller").force(),
739 &Patch::Apply(&patch),
740 )
741 .await
742 {
743 Ok(_) => {
744 info!(
745 "Successfully patched managed instance {}/{} with updated spec",
746 namespace, instance_name
747 );
748 Ok(())
749 }
750 Err(patch_err) => {
751 error!(
752 "Failed to patch managed instance {}/{}: {}",
753 namespace, instance_name, patch_err
754 );
755 Err(patch_err.into())
756 }
757 }
758 } else {
759 error!(
760 "Failed to create managed instance {}/{}: {}",
761 namespace, instance_name, e
762 );
763 Err(e.into())
764 }
765 }
766 }
767}
768
769pub async fn delete_managed_instance(
783 client: &Client,
784 namespace: &str,
785 instance_name: &str,
786) -> Result<()> {
787 let api: Api<Bind9Instance> = Api::namespaced(client.clone(), namespace);
788
789 match api.delete(instance_name, &DeleteParams::default()).await {
790 Ok(_) => {
791 info!(
792 "Successfully deleted managed instance {}/{}",
793 namespace, instance_name
794 );
795 Ok(())
796 }
797 Err(e) if e.to_string().contains("NotFound") => {
798 debug!(
799 "Managed instance {}/{} already deleted",
800 namespace, instance_name
801 );
802 Ok(())
803 }
804 Err(e) => {
805 error!(
806 "Failed to delete managed instance {}/{}: {}",
807 namespace, instance_name, e
808 );
809 Err(e.into())
810 }
811 }
812}
813
814pub(super) async fn delete_cluster_instances(
828 client: &Client,
829 namespace: &str,
830 cluster_name: &str,
831) -> Result<()> {
832 let api: Api<Bind9Instance> = Api::namespaced(client.clone(), namespace);
833
834 info!(
835 "Finding all Bind9Instance resources for cluster {}/{}",
836 namespace, cluster_name
837 );
838
839 let instances = list_all_paginated(&api, ListParams::default()).await?;
841
842 let cluster_instances: Vec<_> = instances
844 .into_iter()
845 .filter(|instance| instance.spec.cluster_ref == cluster_name)
846 .collect();
847
848 if cluster_instances.is_empty() {
849 info!(
850 "No Bind9Instance resources found for cluster {}/{}",
851 namespace, cluster_name
852 );
853 return Ok(());
854 }
855
856 info!(
857 "Found {} Bind9Instance resources for cluster {}/{}, deleting...",
858 cluster_instances.len(),
859 namespace,
860 cluster_name
861 );
862
863 for instance in cluster_instances {
865 let instance_name = instance.name_any();
866 info!(
867 "Deleting Bind9Instance {}/{} (clusterRef: {})",
868 namespace, instance_name, cluster_name
869 );
870
871 match api.delete(&instance_name, &DeleteParams::default()).await {
872 Ok(_) => {
873 info!(
874 "Successfully deleted Bind9Instance {}/{}",
875 namespace, instance_name
876 );
877 }
878 Err(e) => {
879 if e.to_string().contains("NotFound") {
881 warn!(
882 "Bind9Instance {}/{} already deleted",
883 namespace, instance_name
884 );
885 } else {
886 error!(
887 "Failed to delete Bind9Instance {}/{}: {}",
888 namespace, instance_name, e
889 );
890 return Err(e.into());
891 }
892 }
893 }
894 }
895
896 info!(
897 "Successfully deleted all Bind9Instance resources for cluster {}/{}",
898 namespace, cluster_name
899 );
900
901 Ok(())
902}
903
904pub async fn delete_bind9cluster(_client: Client, _cluster: Bind9Cluster) -> Result<()> {
913 Ok(())
915}
916
917#[cfg(test)]
918#[path = "instances_tests.rs"]
919mod instances_tests;