bindy/reconcilers/dnszone/
secondary.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Secondary zone instance operations.
5//!
6//! This module handles all operations specific to SECONDARY BIND9 instances,
7//! including:
8//! - Filtering instance references to only secondary instances
9//! - Finding secondary pods across instances
10//! - Collecting secondary pod IPs
11//! - Executing operations on all secondary endpoints
12
13use anyhow::Result;
14use k8s_openapi::api::core::v1::Pod;
15use kube::{api::ListParams, Api, Client};
16use tracing::{debug, error, info, warn};
17
18use super::helpers::{get_endpoint, load_rndc_key};
19use super::types::PodInfo;
20use crate::bind9::RndcKeyData;
21
22/// Filters a list of instance references to only SECONDARY instances.
23///
24/// # Arguments
25///
26/// * `client` - Kubernetes API client
27/// * `instance_refs` - Instance references to filter
28///
29/// # Returns
30///
31/// Vector of instance references that have role=Secondary
32///
33/// # Errors
34///
35/// Returns an error if Kubernetes API calls fail.
36pub async fn filter_secondary_instances(
37    client: &Client,
38    instance_refs: &[crate::crd::InstanceReference],
39) -> Result<Vec<crate::crd::InstanceReference>> {
40    use crate::crd::{Bind9Instance, ServerRole};
41
42    let mut secondary_refs = Vec::new();
43
44    for instance_ref in instance_refs {
45        let instance_api: Api<Bind9Instance> =
46            Api::namespaced(client.clone(), &instance_ref.namespace);
47
48        match instance_api.get(&instance_ref.name).await {
49            Ok(instance) => {
50                if instance.spec.role == ServerRole::Secondary {
51                    secondary_refs.push(instance_ref.clone());
52                }
53            }
54            Err(e) => {
55                warn!(
56                    "Failed to get instance {}/{}: {}. Skipping.",
57                    instance_ref.namespace, instance_ref.name, e
58                );
59            }
60        }
61    }
62
63    Ok(secondary_refs)
64}
65
66/// Finds all pod IPs from a list of instance references, filtering by role.
67///
68/// Queries each `Bind9Instance` resource to determine its role, then collects
69/// pod IPs only from secondary instances. This is event-driven as it reacts
70/// to the current state of `Bind9Instance` resources rather than caching.
71///
72/// # Arguments
73///
74/// * `client` - Kubernetes API client
75/// * `instance_refs` - Instance references to query
76///
77/// # Returns
78///
79/// Vector of pod IP addresses from secondary instances only
80///
81/// # Errors
82///
83/// Returns an error if Kubernetes API calls fail.
84pub async fn find_secondary_pod_ips_from_instances(
85    client: &Client,
86    instance_refs: &[crate::crd::InstanceReference],
87) -> Result<Vec<String>> {
88    use crate::crd::{Bind9Instance, ServerRole};
89    use k8s_openapi::api::core::v1::Pod;
90
91    let mut secondary_ips = Vec::new();
92
93    for instance_ref in instance_refs {
94        // Query the Bind9Instance resource to check its role
95        let instance_api: Api<Bind9Instance> =
96            Api::namespaced(client.clone(), &instance_ref.namespace);
97
98        let instance = match instance_api.get(&instance_ref.name).await {
99            Ok(inst) => inst,
100            Err(e) => {
101                warn!(
102                    "Failed to get Bind9Instance {}/{}: {}. Skipping.",
103                    instance_ref.namespace, instance_ref.name, e
104                );
105                continue;
106            }
107        };
108
109        // Only collect IPs from secondary instances
110        if instance.spec.role != ServerRole::Secondary {
111            debug!(
112                "Skipping instance {}/{} - role is {:?}, not Secondary",
113                instance_ref.namespace, instance_ref.name, instance.spec.role
114            );
115            continue;
116        }
117
118        // Find pods for this secondary instance
119        let pod_api: Api<Pod> = Api::namespaced(client.clone(), &instance_ref.namespace);
120        let label_selector = format!("app=bind9,instance={}", instance_ref.name);
121        let lp = ListParams::default().labels(&label_selector);
122
123        match pod_api.list(&lp).await {
124            Ok(pods) => {
125                for pod in pods.items {
126                    if let Some(pod_ip) = pod.status.as_ref().and_then(|s| s.pod_ip.as_ref()) {
127                        // Check if pod is running
128                        let phase = pod
129                            .status
130                            .as_ref()
131                            .and_then(|s| s.phase.as_ref())
132                            .map_or("Unknown", std::string::String::as_str);
133
134                        if phase == "Running" {
135                            secondary_ips.push(pod_ip.clone());
136                        } else {
137                            debug!(
138                                "Skipping pod {} in phase {} for instance {}/{}",
139                                pod.metadata.name.as_ref().unwrap_or(&"unknown".to_string()),
140                                phase,
141                                instance_ref.namespace,
142                                instance_ref.name
143                            );
144                        }
145                    }
146                }
147            }
148            Err(e) => {
149                warn!(
150                    "Failed to list pods for instance {}/{}: {}. Skipping.",
151                    instance_ref.namespace, instance_ref.name, e
152                );
153            }
154        }
155    }
156
157    Ok(secondary_ips)
158}
159
160async fn find_all_secondary_pods(
161    client: &Client,
162    namespace: &str,
163    cluster_name: &str,
164    is_cluster_provider: bool,
165) -> Result<Vec<PodInfo>> {
166    use crate::crd::{Bind9Instance, ServerRole};
167
168    // Find all Bind9Instance resources with role=SECONDARY for this cluster
169    let instance_api: Api<Bind9Instance> = if is_cluster_provider {
170        Api::all(client.clone())
171    } else {
172        Api::namespaced(client.clone(), namespace)
173    };
174    let instances = instance_api.list(&ListParams::default()).await?;
175
176    // Store tuples of (instance_name, instance_namespace)
177    let mut secondary_instances: Vec<(String, String)> = Vec::new();
178    for instance in instances.items {
179        if instance.spec.cluster_ref == cluster_name && instance.spec.role == ServerRole::Secondary
180        {
181            if let (Some(name), Some(ns)) = (instance.metadata.name, instance.metadata.namespace) {
182                secondary_instances.push((name, ns));
183            }
184        }
185    }
186
187    if secondary_instances.is_empty() {
188        info!("No SECONDARY instances found for cluster {cluster_name}");
189        return Ok(Vec::new());
190    }
191
192    info!(
193        "Found {} SECONDARY instance(s) for cluster {}: {:?}",
194        secondary_instances.len(),
195        cluster_name,
196        secondary_instances
197    );
198
199    let mut all_pod_infos = Vec::new();
200
201    for (instance_name, instance_namespace) in &secondary_instances {
202        // Find all pods for this secondary instance in its namespace
203        let pod_api: Api<Pod> = Api::namespaced(client.clone(), instance_namespace);
204        let label_selector = format!("app=bind9,instance={instance_name}");
205        let lp = ListParams::default().labels(&label_selector);
206
207        let pods = pod_api.list(&lp).await?;
208
209        debug!(
210            "Found {} pod(s) for SECONDARY instance {}",
211            pods.items.len(),
212            instance_name
213        );
214
215        for pod in &pods.items {
216            // Skip pods that are not Running or have no IP yet (e.g. Pending
217            // pods that were just scheduled) instead of failing the whole
218            // listing - one new pod must not abort operations on healthy pods.
219            let Some((pod_name, pod_ip)) = super::helpers::running_pod_name_and_ip(pod) else {
220                continue;
221            };
222
223            all_pod_infos.push(PodInfo {
224                name: pod_name.clone(),
225                ip: pod_ip.clone(),
226                instance_name: instance_name.clone(),
227                namespace: instance_namespace.clone(),
228            });
229            debug!(
230                "Found running secondary pod {} with IP {} in namespace {}",
231                pod_name, pod_ip, instance_namespace
232            );
233        }
234    }
235
236    info!(
237        "Found {} running SECONDARY pod(s) across {} instance(s) for cluster {}",
238        all_pod_infos.len(),
239        secondary_instances.len(),
240        cluster_name
241    );
242
243    Ok(all_pod_infos)
244}
245
246/// Update `lastReconciledAt` timestamp for a zone in `Bind9Instance.status.selectedZones[]`.
247///
248/// This function implements the critical Phase 2 completion step: after successfully
249/// configuring a zone on an instance, we update the instance's status to signal that
250/// the zone is now reconciled and doesn't need reconfiguration on future reconciliations.
251///
252/// This prevents infinite reconciliation loops by ensuring the `DNSZone` watch mapper
253/// only triggers reconciliation when `lastReconciledAt == None`.
254///
255/// # Arguments
256///
257/// * `client` - Kubernetes API client
258/// * `instance_name` - Name of the `Bind9Instance`
259/// * `instance_namespace` - Namespace of the `Bind9Instance`
260/// * `zone_name` - Name of the `DNSZone` resource
261/// * `zone_namespace` - Namespace of the `DNSZone` resource
262///
263/// Execute an operation on all SECONDARY endpoints for a cluster.
264///
265/// Similar to `for_each_primary_endpoint`, but operates on SECONDARY instances.
266/// Useful for triggering zone transfers or other secondary-specific operations.
267///
268/// # Arguments
269///
270/// * `client` - Kubernetes API client
271/// * `namespace` - Namespace to search for instances
272/// * `cluster_ref` - Cluster reference name
273/// * `is_cluster_provider` - Whether this is a cluster provider (cluster-scoped)
274/// * `with_rndc_key` - Whether to load and pass RNDC keys for each instance
275/// * `port_name` - Port name to use for endpoints (e.g., "rndc-api", "dns-tcp")
276/// * `operation` - Async closure to execute for each endpoint
277///
278/// # Returns
279///
280/// * `Ok((first_endpoint, total_endpoints))` - First endpoint found and total count
281///
282/// # Errors
283///
284/// Returns an error if:
285/// - Failed to find secondary pods
286/// - Failed to load RNDC keys
287/// - Failed to get service endpoints
288/// - The operation closure returns an error for any endpoint
289pub async fn for_each_secondary_endpoint<F, Fut>(
290    client: &Client,
291    namespace: &str,
292    cluster_ref: &str,
293    is_cluster_provider: bool,
294    with_rndc_key: bool,
295    port_name: &str,
296    operation: F,
297) -> Result<(Option<String>, usize)>
298where
299    F: Fn(String, String, Option<RndcKeyData>) -> Fut,
300    Fut: std::future::Future<Output = Result<()>>,
301{
302    // Find all SECONDARY pods to get the unique instance names
303    let secondary_pods =
304        find_all_secondary_pods(client, namespace, cluster_ref, is_cluster_provider).await?;
305
306    info!(
307        "Found {} SECONDARY pod(s) for cluster {}",
308        secondary_pods.len(),
309        cluster_ref
310    );
311
312    // Collect unique (instance_name, namespace) tuples from the secondary pods
313    // Each instance may have multiple pods (replicas)
314    let mut instance_tuples: Vec<(String, String)> = secondary_pods
315        .iter()
316        .map(|pod| (pod.instance_name.clone(), pod.namespace.clone()))
317        .collect();
318    instance_tuples.sort();
319    instance_tuples.dedup();
320
321    info!(
322        "Found {} secondary instance(s) for cluster {}: {:?}",
323        instance_tuples.len(),
324        cluster_ref,
325        instance_tuples
326    );
327
328    let mut first_endpoint: Option<String> = None;
329    let mut total_endpoints = 0;
330    let mut errors: Vec<String> = Vec::new();
331
332    // Loop through each secondary instance and get its endpoints
333    for (instance_name, instance_namespace) in &instance_tuples {
334        info!(
335            "Getting endpoints for secondary instance {}/{} in cluster {}",
336            instance_namespace, instance_name, cluster_ref
337        );
338
339        // Load RNDC key for this specific instance if requested
340        // Each instance has its own RNDC secret for security isolation
341        let key_data = if with_rndc_key {
342            Some(load_rndc_key(client, instance_namespace, instance_name).await?)
343        } else {
344            None
345        };
346
347        // Get all endpoints for this instance's service
348        // The Endpoints API gives us pod IPs with their container ports (not service ports)
349        let endpoints = get_endpoint(client, instance_namespace, instance_name, port_name).await?;
350
351        info!(
352            "Found {} endpoint(s) for secondary instance {}",
353            endpoints.len(),
354            instance_name
355        );
356
357        for endpoint in &endpoints {
358            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
359
360            // Save the first endpoint
361            if first_endpoint.is_none() {
362                first_endpoint = Some(pod_endpoint.clone());
363            }
364
365            // Execute the operation on this endpoint with this instance's RNDC key
366            // Continue processing remaining endpoints even if this one fails
367            if let Err(e) = operation(
368                pod_endpoint.clone(),
369                instance_name.clone(),
370                key_data.clone(),
371            )
372            .await
373            {
374                error!(
375                    "Failed operation on secondary endpoint {} (instance {}): {}",
376                    pod_endpoint, instance_name, e
377                );
378                errors.push(format!(
379                    "endpoint {pod_endpoint} (instance {instance_name}): {e}"
380                ));
381            } else {
382                total_endpoints += 1;
383            }
384        }
385    }
386
387    // If any operations failed, return an error with all failures listed
388    if !errors.is_empty() {
389        return Err(anyhow::anyhow!(
390            "Failed to process {} secondary endpoint(s): {}",
391            errors.len(),
392            errors.join("; ")
393        ));
394    }
395
396    Ok((first_endpoint, total_endpoints))
397}
398
399#[cfg(test)]
400#[path = "secondary_tests.rs"]
401mod secondary_tests;