bindy/reconcilers/bind9cluster/
instances.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Instance lifecycle management for `Bind9Cluster` resources.
5//!
6//! This module handles creating, updating, and deleting `Bind9Instance`
7//! resources that are managed by a `Bind9Cluster`.
8
9#[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/// Reconcile managed `Bind9Instance` resources for a cluster
15///
16/// This function ensures the correct number of primary and secondary instances exist
17/// based on the cluster spec. It creates missing instances and adds management labels.
18///
19/// # Arguments
20///
21/// * `client` - Kubernetes API client
22/// * `cluster` - The `Bind9Cluster` resource
23///
24/// # Errors
25///
26/// Returns an error if:
27/// - Failed to list existing instances
28/// - Failed to create new instances
29#[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    // Get desired replica counts from spec
44    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    // List existing managed instances
74    let api: Api<Bind9Instance> = Api::namespaced(client.clone(), &namespace);
75    let instances = list_all_paginated(&api, ListParams::default()).await?;
76
77    // Filter for managed instances of this cluster
78    let managed_instances: Vec<_> = instances
79        .into_iter()
80        .filter(|instance| {
81            // Check if instance has management labels
82            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    // Separate by role
97    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    // Create ownerReference to the Bind9Cluster
114    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    // Handle scale-up: Create missing primary instances
124    // CRITICAL: Compare desired vs current state to find missing instances
125    // Build set of desired instance names, compare with existing, create the difference
126    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
127    let mut primaries_to_create = 0;
128    {
129        // Build set of desired primary instance names based on replica count
130        #[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        // Build set of existing primary instance names
137        let existing_primary_names: std::collections::HashSet<String> = existing_primary
138            .iter()
139            .map(|instance| instance.name_any())
140            .collect();
141
142        // Find missing instances (desired - existing)
143        let missing_primaries: Vec<_> = desired_primary_names
144            .difference(&existing_primary_names)
145            .collect();
146
147        // Create each missing instance
148        for instance_name in missing_primaries {
149            // Extract index from name (e.g., "production-dns-primary-0" -> 0)
150            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    // Handle scale-down: Delete excess primary instances
171    #[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        // Sort by index descending to delete highest-indexed instances first
177        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    // Handle scale-up: Create missing secondary instances
196    // CRITICAL: Compare desired vs current state to find missing instances
197    // Build set of desired instance names, compare with existing, create the difference
198    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
199    let mut secondaries_to_create = 0;
200    {
201        // Build set of desired secondary instance names based on replica count
202        #[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        // Build set of existing secondary instance names
209        let existing_secondary_names: std::collections::HashSet<String> = existing_secondary
210            .iter()
211            .map(|instance| instance.name_any())
212            .collect();
213
214        // Find missing instances (desired - existing)
215        let missing_secondaries: Vec<_> = desired_secondary_names
216            .difference(&existing_secondary_names)
217            .collect();
218
219        // Create each missing instance
220        for instance_name in missing_secondaries {
221            // Extract index from name (e.g., "production-dns-secondary-0" -> 0)
222            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    // Handle scale-down: Delete excess secondary instances
243    #[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        // Sort by index descending to delete highest-indexed instances first
249        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 to match cluster spec (declarative reconciliation)
289    update_existing_managed_instances(
290        &client,
291        &namespace,
292        &cluster_name,
293        &cluster.spec.common,
294        &managed_instances,
295    )
296    .await?;
297
298    // Ensure child resources (ConfigMaps, Secrets, Services, Deployments) exist for all managed instances
299    ensure_managed_instance_resources(&client, cluster, &managed_instances).await?;
300
301    Ok(())
302}
303
304/// Update existing managed instances to match the cluster's current spec.
305///
306/// This implements true declarative reconciliation - comparing the desired state (from cluster spec)
307/// with the actual state (existing instance specs) and updating any instances that have drifted.
308///
309/// This ensures that when the cluster's `spec.common` changes (e.g., bindcar version, volumes,
310/// config references), all managed instances are updated to reflect the new configuration.
311///
312/// # Arguments
313///
314/// * `client` - Kubernetes API client
315/// * `namespace` - Namespace containing the instances
316/// * `cluster_name` - Name of the parent cluster
317/// * `common_spec` - The cluster's common spec (source of truth)
318/// * `managed_instances` - List of existing managed instances to check
319///
320/// # Errors
321///
322/// Returns an error if patching instances fails
323pub(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        // Build the desired spec based on current cluster configuration
341        let desired_bindcar_config = common_spec
342            .global
343            .as_ref()
344            .and_then(|g| g.bindcar_config.clone());
345
346        // Check if instance spec needs updating by comparing key fields
347        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            // Build updated instance spec - preserve instance-specific fields, update cluster-inherited fields
361            #[allow(deprecated)]
362            // Backward compatibility: preserve deprecated rndc_secret_ref if set
363            let updated_spec = Bind9InstanceSpec {
364                cluster_ref: instance.spec.cluster_ref.clone(),
365                role: instance.spec.role,
366                replicas: instance.spec.replicas, // Preserve instance replicas (always 1 for managed)
367                version: common_spec.version.clone(),
368                image: common_spec.image.clone(),
369                config_map_refs: common_spec.config_map_refs.clone(),
370                config: None, // Managed instances inherit from cluster
371                primary_servers: instance.spec.primary_servers.clone(), // Preserve if set
372                volumes: common_spec.volumes.clone(),
373                volume_mounts: common_spec.volume_mounts.clone(),
374                rndc_secret_ref: instance.spec.rndc_secret_ref.clone(), // Preserve if set (deprecated)
375                rndc_key: instance.spec.rndc_key.clone(),               // Preserve if set
376                storage: instance.spec.storage.clone(),                 // Preserve if set
377                // Preserve any instance-level placement the user set directly.
378                // Cluster- and role-level placement is NOT copied down here:
379                // it is resolved against the live cluster when the Deployment
380                // is built (see `crate::placement::resolve_placement`), so a
381                // cluster-level change converges without rewriting every
382                // managed instance spec.
383                placement: instance.spec.placement.clone(),
384                bindcar_config: desired_bindcar_config,
385            };
386
387            // Use server-side apply to update the instance spec
388            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
439/// Ensure child resources exist for all managed instances
440///
441/// This function verifies that all Kubernetes resources (`ConfigMap`, `Secret`, `Service`, `Deployment`)
442/// exist for each managed instance. If any resource is missing, it triggers reconciliation
443/// by updating the instance's annotations to force the `Bind9Instance` controller to recreate them.
444///
445/// # Arguments
446///
447/// * `client` - Kubernetes API client
448/// * `cluster` - The parent `Bind9Cluster`
449/// * `managed_instances` - List of managed `Bind9Instance` resources
450///
451/// # Errors
452///
453/// Returns an error if resource checking or instance update fails
454pub(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    // Managed instances share the cluster ConfigMap, not instance-specific ones
480    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        // Check ConfigMap - managed instances use the shared cluster ConfigMap
487        if configmap_api.get(&cluster_configmap_name).await.is_err() {
488            missing_resources.push("ConfigMap");
489        }
490
491        // Check RNDC Secret
492        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        // Check Service
498        if service_api.get(&instance_name).await.is_err() {
499            missing_resources.push("Service");
500        }
501
502        // Check Deployment
503        if deployment_api.get(&instance_name).await.is_err() {
504            missing_resources.push("Deployment");
505        }
506
507        // If any resources are missing, trigger instance reconciliation
508        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            // Force reconciliation by updating an annotation
522            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/// Create a managed `Bind9Instance` resource
551///
552/// This function is public to allow reuse by `ClusterBind9Provider` reconciler.
553///
554/// # Arguments
555///
556/// * `client` - Kubernetes API client
557/// * `namespace` - Namespace for the instance
558/// * `cluster_name` - Name of the cluster (namespace-scoped or global)
559/// * `role` - Role of the instance (Primary or Secondary)
560/// * `index` - Index of this instance within its role
561/// * `common_spec` - The cluster's common specification
562/// * `is_global` - Whether this is for a global cluster
563///
564/// # Errors
565///
566/// Returns an error if instance creation fails
567#[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, // No owner reference - for backward compatibility
585    )
586    .await
587}
588
589/// Create a managed `Bind9Instance` with optional ownerReference.
590///
591/// This is the internal implementation that supports setting ownerReferences.
592/// Use `create_managed_instance()` for backward compatibility without ownerReferences.
593///
594/// # Arguments
595///
596/// * `owner_ref` - Optional ownerReference to the parent `Bind9Cluster`
597#[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    // Create labels
620    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    // Propagate custom labels from cluster spec based on role
630    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    // Create annotations
652    let mut annotations = BTreeMap::new();
653    annotations.insert(
654        BINDY_INSTANCE_INDEX_ANNOTATION.to_string(),
655        index.to_string(),
656    );
657
658    // Build instance spec - copy configuration from cluster
659    #[allow(deprecated)] // Backward compatibility: include deprecated rndc_secret_ref field
660    let instance_spec = Bind9InstanceSpec {
661        cluster_ref: cluster_name.to_string(),
662        role,
663        replicas: Some(1), // Each managed instance has 1 replica
664        version: common_spec.version.clone(),
665        image: common_spec.image.clone(),
666        config_map_refs: common_spec.config_map_refs.clone(),
667        config: None,          // Inherit from cluster
668        primary_servers: None, // TODO: Could populate for secondaries
669        volumes: common_spec.volumes.clone(),
670        volume_mounts: common_spec.volume_mounts.clone(),
671        rndc_secret_ref: None, // Inherit from cluster/role config (deprecated)
672        rndc_key: None,        // Inherit from cluster/role config
673        storage: None,         // Use default (emptyDir)
674        placement: None,       // Inherit from role / cluster level at build time
675        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 already exists, patch it to ensure spec is up to date
706            if e.to_string().contains("AlreadyExists") {
707                debug!(
708                    "Managed instance {}/{} already exists, patching with updated spec",
709                    namespace, instance_name
710                );
711
712                // Build a complete patch object for server-side apply
713                // Convert BTreeMap labels to serde_json::Value for patch
714                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                // Apply the patch to update the spec, labels, annotations, and owner references
735                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
769/// Delete a single managed `Bind9Instance` resource.
770///
771/// This function is public to allow reuse by `ClusterBind9Provider` reconciler.
772///
773/// # Arguments
774///
775/// * `client` - Kubernetes API client
776/// * `namespace` - Namespace of the instance
777/// * `instance_name` - Name of the instance to delete
778///
779/// # Errors
780///
781/// Returns an error if deletion fails (except for `NotFound` errors, which are treated as success)
782pub 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
814/// Delete all `Bind9Instance` resources that reference the given cluster
815///
816/// # Arguments
817///
818/// * `client` - Kubernetes API client
819/// * `namespace` - Namespace containing the instances
820/// * `cluster_name` - Name of the cluster being deleted
821///
822/// # Errors
823///
824/// Returns an error if:
825/// - Failed to list `Bind9Instance` resources
826/// - Failed to delete any `Bind9Instance` resource
827pub(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    // List all instances in the namespace
840    let instances = list_all_paginated(&api, ListParams::default()).await?;
841
842    // Filter instances that reference this cluster
843    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    // Delete each instance
864    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 the resource is already deleted, treat it as success
880                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
904/// Delete handler for `Bind9Cluster` resources (cleanup logic)
905///
906/// This function is no longer used as deletion is handled by the finalizer in `reconcile_bind9cluster`.
907/// Kept for backward compatibility.
908///
909/// # Errors
910///
911/// This function currently never returns an error, but returns `Result` for API consistency.
912pub async fn delete_bind9cluster(_client: Client, _cluster: Bind9Cluster) -> Result<()> {
913    // Deletion is now handled by the finalizer in reconcile_bind9cluster
914    Ok(())
915}
916
917#[cfg(test)]
918#[path = "instances_tests.rs"]
919mod instances_tests;