bindy/reconcilers/dnszone/
primary.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Primary zone instance operations.
5//!
6//! This module handles all operations specific to PRIMARY BIND9 instances,
7//! including:
8//! - Filtering instance references to only primary instances
9//! - Finding primary pods across instances
10//! - Collecting primary pod IPs
11//! - Executing operations on all primary endpoints
12
13use anyhow::{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 PRIMARY 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=Primary
32///
33/// # Errors
34///
35/// Returns an error if Kubernetes API calls fail.
36pub async fn filter_primary_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 primary_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::Primary {
51                    primary_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(primary_refs)
64}
65
66/// Find all PRIMARY pods for a given cluster or cluster provider.
67///
68/// Returns pod information including name, IP, instance name, and namespace
69/// for all running PRIMARY pods in the cluster.
70///
71/// # Arguments
72///
73/// * `client` - Kubernetes API client
74/// * `namespace` - Namespace to search in (if not cluster provider)
75/// * `cluster_name` - Name of the cluster
76/// * `is_cluster_provider` - Whether to search across all namespaces
77///
78/// # Returns
79///
80/// Vector of PodInfo for all running PRIMARY pods
81///
82/// # Errors
83///
84/// Returns an error if Kubernetes API operations fail
85pub async fn find_all_primary_pods(
86    client: &Client,
87    namespace: &str,
88    cluster_name: &str,
89    is_cluster_provider: bool,
90) -> Result<Vec<PodInfo>> {
91    use crate::crd::{Bind9Instance, ServerRole};
92
93    // First, find all Bind9Instance resources that belong to this cluster and have role=primary
94    let instance_api: Api<Bind9Instance> = if is_cluster_provider {
95        Api::all(client.clone())
96    } else {
97        Api::namespaced(client.clone(), namespace)
98    };
99    let instances = instance_api.list(&ListParams::default()).await?;
100
101    // Store tuples of (instance_name, instance_namespace)
102    let mut primary_instances: Vec<(String, String)> = Vec::new();
103    for instance in instances.items {
104        if instance.spec.cluster_ref == cluster_name && instance.spec.role == ServerRole::Primary {
105            if let (Some(name), Some(ns)) = (instance.metadata.name, instance.metadata.namespace) {
106                primary_instances.push((name, ns));
107            }
108        }
109    }
110
111    if primary_instances.is_empty() {
112        let search_scope = if is_cluster_provider {
113            "all namespaces".to_string()
114        } else {
115            format!("namespace {namespace}")
116        };
117        return Err(anyhow!(
118            "No PRIMARY Bind9Instance resources found for cluster {cluster_name} in {search_scope}"
119        ));
120    }
121
122    info!(
123        "Found {} PRIMARY instance(s) for cluster {}: {:?}",
124        primary_instances.len(),
125        cluster_name,
126        primary_instances
127    );
128
129    let mut all_pod_infos = Vec::new();
130
131    for (instance_name, instance_namespace) in &primary_instances {
132        // Now find all pods for this primary instance in its namespace
133        let pod_api: Api<Pod> = Api::namespaced(client.clone(), instance_namespace);
134        // List pods with label selector matching the instance
135        let label_selector = format!("app=bind9,instance={instance_name}");
136        let lp = ListParams::default().labels(&label_selector);
137
138        let pods = pod_api.list(&lp).await?;
139
140        debug!(
141            "Found {} pod(s) for PRIMARY instance {}",
142            pods.items.len(),
143            instance_name
144        );
145
146        for pod in &pods.items {
147            // Skip pods that are not Running or have no IP yet (e.g. Pending
148            // pods that were just scheduled) instead of failing the whole
149            // listing - one new pod must not abort operations on healthy pods.
150            let Some((pod_name, pod_ip)) = super::helpers::running_pod_name_and_ip(pod) else {
151                continue;
152            };
153
154            all_pod_infos.push(PodInfo {
155                name: pod_name.clone(),
156                ip: pod_ip.clone(),
157                instance_name: instance_name.clone(),
158                namespace: instance_namespace.clone(),
159            });
160            debug!(
161                "Found running pod {} with IP {} in namespace {}",
162                pod_name, pod_ip, instance_namespace
163            );
164        }
165    }
166
167    if all_pod_infos.is_empty() {
168        return Err(anyhow!(
169            "No running PRIMARY pods found for cluster {cluster_name} in namespace {namespace}"
170        ));
171    }
172
173    info!(
174        "Found {} running PRIMARY pod(s) across {} instance(s) for cluster {}",
175        all_pod_infos.len(),
176        primary_instances.len(),
177        cluster_name
178    );
179
180    Ok(all_pod_infos)
181}
182
183/// Find primary server IPs from a list of instance references.
184///
185/// This is the NEW instance-based approach that replaces cluster-based lookup.
186/// It filters the instance refs to only PRIMARY instances, then gets their pod IPs.
187///
188/// # Arguments
189///
190/// * `client` - Kubernetes API client
191/// * `instance_refs` - List of instance references to search
192///
193/// # Returns
194///
195/// A vector of IP addresses for all running PRIMARY pods across all primary instances
196///
197/// # Errors
198///
199/// Returns an error if Kubernetes API calls fail or no primary pods are found
200pub async fn find_primary_ips_from_instances(
201    client: &Client,
202    instance_refs: &[crate::crd::InstanceReference],
203) -> Result<Vec<String>> {
204    use crate::crd::{Bind9Instance, ServerRole};
205    use k8s_openapi::api::core::v1::Pod;
206
207    info!(
208        "Finding PRIMARY pod IPs from {} instance reference(s)",
209        instance_refs.len()
210    );
211
212    let mut primary_ips = Vec::new();
213
214    for instance_ref in instance_refs {
215        // Get the Bind9Instance to check its role
216        let instance_api: Api<Bind9Instance> =
217            Api::namespaced(client.clone(), &instance_ref.namespace);
218
219        let instance = match instance_api.get(&instance_ref.name).await {
220            Ok(inst) => inst,
221            Err(e) => {
222                warn!(
223                    "Failed to get instance {}/{}: {}",
224                    instance_ref.namespace, instance_ref.name, e
225                );
226                continue;
227            }
228        };
229
230        // Skip if not a PRIMARY instance
231        if instance.spec.role != ServerRole::Primary {
232            continue;
233        }
234
235        // Get running pod IPs for this primary instance
236        let pod_api: Api<Pod> = Api::namespaced(client.clone(), &instance_ref.namespace);
237        let label_selector = format!("app=bind9,instance={}", instance_ref.name);
238        let lp = ListParams::default().labels(&label_selector);
239
240        match pod_api.list(&lp).await {
241            Ok(pods) => {
242                for pod in pods.items {
243                    if let Some(pod_ip) = pod.status.as_ref().and_then(|s| s.pod_ip.as_ref()) {
244                        // Check if pod is running
245                        let phase = pod
246                            .status
247                            .as_ref()
248                            .and_then(|s| s.phase.as_ref())
249                            .map_or("Unknown", std::string::String::as_str);
250
251                        if phase == "Running" {
252                            primary_ips.push(pod_ip.clone());
253                            debug!(
254                                "Added IP {} from running PRIMARY pod {} (instance {}/{})",
255                                pod_ip,
256                                pod.metadata.name.as_ref().unwrap_or(&"unknown".to_string()),
257                                instance_ref.namespace,
258                                instance_ref.name
259                            );
260                        }
261                    }
262                }
263            }
264            Err(e) => {
265                warn!(
266                    "Failed to list pods for PRIMARY instance {}/{}: {}",
267                    instance_ref.namespace, instance_ref.name, e
268                );
269            }
270        }
271    }
272
273    info!(
274        "Found total of {} PRIMARY pod IP(s) across all instances: {:?}",
275        primary_ips.len(),
276        primary_ips
277    );
278
279    Ok(primary_ips)
280}
281/// Execute an operation on all endpoints of all primary instances in a cluster.
282///
283/// This helper function handles the common pattern of:
284/// 1. Finding all primary pods for a cluster
285/// 2. Collecting unique instance names
286/// 3. Optionally loading RNDC key from each instance
287/// 4. Getting endpoints for each instance
288/// 5. Executing a provided operation on each endpoint
289///
290/// # Arguments
291///
292/// * `client` - Kubernetes API client
293/// * `namespace` - Namespace of the cluster
294/// * `cluster_ref` - Name of the `Bind9Cluster` or `ClusterBind9Provider`
295/// * `is_cluster_provider` - Whether this is a cluster provider (cluster-scoped)
296/// * `with_rndc_key` - Whether to load RNDC key from each instance
297/// * `port_name` - Port name to use for endpoints (e.g., "rndc-api", "dns-tcp")
298/// * `operation` - Async closure to execute for each endpoint
299///   - Arguments: `(pod_endpoint: String, instance_name: String, rndc_key: Option<RndcKeyData>)`
300///   - Returns: `Result<()>`
301///
302/// # Returns
303///
304/// Returns `Ok((first_endpoint, total_count))` where:
305/// - `first_endpoint` - Optional first endpoint encountered (useful for NOTIFY operations)
306/// - `total_count` - Total number of endpoints processed successfully
307///
308/// # Errors
309///
310/// Returns error if:
311/// - No primary pods found for the cluster
312/// - Failed to load RNDC key (if requested)
313/// - Failed to get endpoints for any instance
314/// - The operation closure returns an error for any endpoint
315pub async fn for_each_primary_endpoint<F, Fut>(
316    client: &Client,
317    namespace: &str,
318    cluster_ref: &str,
319    is_cluster_provider: bool,
320    with_rndc_key: bool,
321    port_name: &str,
322    operation: F,
323) -> Result<(Option<String>, usize)>
324where
325    F: Fn(String, String, Option<RndcKeyData>) -> Fut,
326    Fut: std::future::Future<Output = Result<()>>,
327{
328    // Find all PRIMARY pods to get the unique instance names
329    let primary_pods =
330        find_all_primary_pods(client, namespace, cluster_ref, is_cluster_provider).await?;
331
332    info!(
333        "Found {} PRIMARY pod(s) for cluster {}",
334        primary_pods.len(),
335        cluster_ref
336    );
337
338    // Collect unique (instance_name, namespace) tuples from the primary pods
339    // Each instance may have multiple pods (replicas)
340    let mut instance_tuples: Vec<(String, String)> = primary_pods
341        .iter()
342        .map(|pod| (pod.instance_name.clone(), pod.namespace.clone()))
343        .collect();
344    instance_tuples.sort();
345    instance_tuples.dedup();
346
347    info!(
348        "Found {} primary instance(s) for cluster {}: {:?}",
349        instance_tuples.len(),
350        cluster_ref,
351        instance_tuples
352    );
353
354    let mut first_endpoint: Option<String> = None;
355    let mut total_endpoints = 0;
356    let mut errors: Vec<String> = Vec::new();
357
358    // Loop through each primary instance and get its endpoints
359    // Important: With EmptyDir storage (per-pod, non-shared), each primary pod maintains its own
360    // zone files. We need to process ALL pods across ALL instances.
361    for (instance_name, instance_namespace) in &instance_tuples {
362        info!(
363            "Getting endpoints for instance {}/{} in cluster {}",
364            instance_namespace, instance_name, cluster_ref
365        );
366
367        // Load RNDC key for this specific instance if requested
368        // Each instance has its own RNDC secret for security isolation
369        let key_data = if with_rndc_key {
370            Some(load_rndc_key(client, instance_namespace, instance_name).await?)
371        } else {
372            None
373        };
374
375        // Get all endpoints for this instance's service
376        // The Endpoints API gives us pod IPs with their container ports (not service ports)
377        let endpoints = get_endpoint(client, instance_namespace, instance_name, port_name).await?;
378
379        info!(
380            "Found {} endpoint(s) for instance {}",
381            endpoints.len(),
382            instance_name
383        );
384
385        for endpoint in &endpoints {
386            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
387
388            // Save the first endpoint
389            if first_endpoint.is_none() {
390                first_endpoint = Some(pod_endpoint.clone());
391            }
392
393            // Execute the operation on this endpoint with this instance's RNDC key
394            // Continue processing remaining endpoints even if this one fails
395            if let Err(e) = operation(
396                pod_endpoint.clone(),
397                instance_name.clone(),
398                key_data.clone(),
399            )
400            .await
401            {
402                error!(
403                    "Failed operation on endpoint {} (instance {}): {}",
404                    pod_endpoint, instance_name, e
405                );
406                errors.push(format!(
407                    "endpoint {pod_endpoint} (instance {instance_name}): {e}"
408                ));
409            } else {
410                total_endpoints += 1;
411            }
412        }
413    }
414
415    // If any operations failed, return an error with all failures listed
416    if !errors.is_empty() {
417        return Err(anyhow::anyhow!(
418            "Failed to process {} endpoint(s): {}",
419            errors.len(),
420            errors.join("; ")
421        ));
422    }
423
424    Ok((first_endpoint, total_endpoints))
425}
426
427#[cfg(test)]
428#[path = "primary_tests.rs"]
429mod primary_tests;