bindy/reconcilers/bind9instance/
status_helpers.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Status calculation and update helpers for `Bind9Instance` resources.
5//!
6//! This module handles computing instance status from deployment/pod health and
7//! patching the instance status in Kubernetes.
8
9#[allow(clippy::wildcard_imports)]
10use super::types::*;
11use crate::reconcilers::pagination::list_all_paginated;
12
13/// Update instance status from deployment pod health.
14///
15/// Queries the Deployment and its Pods to determine readiness, then updates
16/// the instance status with detailed per-pod conditions.
17///
18/// # Arguments
19///
20/// * `client` - Kubernetes API client
21/// * `namespace` - Instance namespace
22/// * `name` - Instance name
23/// * `instance` - The `Bind9Instance` resource
24/// * `cluster_ref` - Optional cluster reference to include in status
25/// * `observed_parent_generation` - Generation of the referenced parent cluster
26///   (`Bind9Cluster`/`ClusterBind9Provider`) observed during this reconciliation
27///
28/// # Errors
29///
30/// Returns an error if Kubernetes API operations fail or status patching fails.
31#[allow(clippy::too_many_lines)]
32pub(super) async fn update_status_from_deployment(
33    client: &Client,
34    namespace: &str,
35    name: &str,
36    instance: &Bind9Instance,
37    cluster_ref: Option<ClusterReference>,
38    observed_parent_generation: Option<i64>,
39) -> Result<()> {
40    let deploy_api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
41    let pod_api: Api<Pod> = Api::namespaced(client.clone(), namespace);
42
43    match deploy_api.get(name).await {
44        Ok(deployment) => {
45            let actual_replicas = deployment
46                .spec
47                .as_ref()
48                .and_then(|spec| spec.replicas)
49                .unwrap_or(0);
50
51            // List pods for this deployment using label selector
52            // Use the standard Kubernetes label for instance matching
53            let label_selector = format!("{}={}", crate::labels::K8S_INSTANCE, name);
54            let list_params = ListParams::default().labels(&label_selector);
55            let all_pods = list_all_paginated(&pod_api, list_params).await?;
56
57            // Filter to only non-terminating pods (exclude pods with deletionTimestamp)
58            // This prevents counting old pods during rollouts
59            let pods: Vec<_> = all_pods
60                .into_iter()
61                .filter(|pod| pod.metadata.deletion_timestamp.is_none())
62                .collect();
63
64            // Create pod-level conditions
65            let mut pod_conditions = Vec::new();
66            let mut ready_pod_count = 0;
67
68            for (index, pod) in pods.iter().enumerate() {
69                let pod_name = pod.metadata.name.as_deref().unwrap_or("unknown");
70                // Using map_or for explicit false default on None - more readable than is_some_and
71                #[allow(clippy::unnecessary_map_or)]
72                let is_pod_ready = pod
73                    .status
74                    .as_ref()
75                    .and_then(|status| status.conditions.as_ref())
76                    .map_or(false, |conditions| {
77                        conditions
78                            .iter()
79                            .any(|c| c.type_ == "Ready" && c.status == "True")
80                    });
81
82                if is_pod_ready {
83                    ready_pod_count += 1;
84                }
85
86                let (status, reason, message) = if is_pod_ready {
87                    ("True", REASON_READY, format!("Pod {pod_name} is ready"))
88                } else {
89                    (
90                        "False",
91                        REASON_NOT_READY,
92                        format!("Pod {pod_name} is not ready"),
93                    )
94                };
95
96                pod_conditions.push(Condition {
97                    r#type: pod_condition_type(index),
98                    status: status.to_string(),
99                    reason: Some(reason.to_string()),
100                    message: Some(message),
101                    last_transition_time: Some(Utc::now().to_rfc3339()),
102                });
103            }
104
105            // Create encompassing Ready condition
106            let (encompassing_status, encompassing_reason, encompassing_message) =
107                if ready_pod_count == 0 && actual_replicas > 0 {
108                    (
109                        "False",
110                        REASON_NOT_READY,
111                        "Waiting for pods to become ready".to_string(),
112                    )
113                } else if ready_pod_count == actual_replicas && actual_replicas > 0 {
114                    (
115                        "True",
116                        REASON_ALL_READY,
117                        format!("All {ready_pod_count} pods are ready"),
118                    )
119                } else if ready_pod_count > 0 {
120                    (
121                        "False",
122                        REASON_PARTIALLY_READY,
123                        format!("{ready_pod_count}/{actual_replicas} pods are ready"),
124                    )
125                } else {
126                    ("False", REASON_NOT_READY, "No pods are ready".to_string())
127                };
128
129            let encompassing_condition = Condition {
130                r#type: CONDITION_TYPE_READY.to_string(),
131                status: encompassing_status.to_string(),
132                reason: Some(encompassing_reason.to_string()),
133                message: Some(encompassing_message),
134                last_transition_time: Some(Utc::now().to_rfc3339()),
135            };
136
137            // Combine encompassing condition + pod-level conditions
138            let mut all_conditions = vec![encompassing_condition];
139            all_conditions.extend(pod_conditions);
140
141            // Update status with all conditions
142            update_status(
143                client,
144                instance,
145                all_conditions,
146                cluster_ref,
147                observed_parent_generation,
148            )
149            .await?;
150        }
151        Err(e) => {
152            warn!(
153                "Failed to get Deployment status for {}/{}: {}",
154                namespace, name, e
155            );
156            // Set status as unknown if we can't check deployment
157            let unknown_condition = Condition {
158                r#type: CONDITION_TYPE_READY.to_string(),
159                status: "Unknown".to_string(),
160                reason: Some(REASON_NOT_READY.to_string()),
161                message: Some("Unable to determine deployment status".to_string()),
162                last_transition_time: Some(Utc::now().to_rfc3339()),
163            };
164            update_status(
165                client,
166                instance,
167                vec![unknown_condition],
168                cluster_ref,
169                observed_parent_generation,
170            )
171            .await?;
172        }
173    }
174
175    Ok(())
176}
177
178/// Determines whether the `Bind9Instance` status patch is needed.
179///
180/// Compares the current status against the values about to be written. The
181/// patch is needed if any of the following changed:
182/// - `cluster_ref` or the zones list
183/// - `observed_generation` (so spec edits that don't change conditions still
184///   advance `observedGeneration` and stop perpetual re-reconciliation)
185/// - `observed_parent_generation` (so parent cluster config changes are
186///   recorded once applied)
187/// - Any condition's type, status, reason, or message
188///
189/// # Arguments
190///
191/// * `current` - The status currently stored on the resource (if any)
192/// * `conditions` - New conditions about to be written
193/// * `cluster_ref` - New cluster reference about to be written
194/// * `zones` - Zones list about to be written (preserved from current status)
195/// * `generation` - The `metadata.generation` about to be written as `observed_generation`
196/// * `observed_parent_generation` - The parent cluster generation about to be written
197///
198/// # Returns
199///
200/// `true` if the status patch should be applied, `false` if it can be skipped.
201#[must_use]
202pub fn instance_status_changed(
203    current: Option<&Bind9InstanceStatus>,
204    conditions: &[Condition],
205    cluster_ref: Option<&ClusterReference>,
206    zones: &[crate::crd::ZoneReference],
207    generation: Option<i64>,
208    observed_parent_generation: Option<i64>,
209) -> bool {
210    let Some(current) = current else {
211        // No status exists, need to update
212        return true;
213    };
214
215    // Check if cluster_ref or zones changed
216    if current.cluster_ref.as_ref() != cluster_ref || current.zones != zones {
217        return true;
218    }
219
220    // Check if the observed generations are behind the values about to be
221    // written. Without this, a spec (or parent spec) edit that does not change
222    // conditions never advances the observed generations, causing perpetual
223    // re-reconciliation on every requeue.
224    if current.observed_generation != generation
225        || current.observed_parent_generation != observed_parent_generation
226    {
227        return true;
228    }
229
230    // Check if any condition changed
231    if current.conditions.len() != conditions.len() {
232        return true;
233    }
234
235    current
236        .conditions
237        .iter()
238        .zip(conditions.iter())
239        .any(|(current_cond, new_cond)| {
240            current_cond.r#type != new_cond.r#type
241                || current_cond.status != new_cond.status
242                || current_cond.message != new_cond.message
243                || current_cond.reason != new_cond.reason
244        })
245}
246
247/// Update the status of a `Bind9Instance` with multiple conditions.
248///
249/// NOTE: This function does NOT update `status.zones`. Zone reconciliation is handled
250/// separately by `reconcile_instance_zones()` which is called:
251/// 1. From the main reconcile loop after deployment changes
252/// 2. From the `DNSZone` watcher when zone selections change
253///
254/// # Arguments
255///
256/// * `client` - Kubernetes API client
257/// * `instance` - The instance to update
258/// * `conditions` - Vector of status conditions to set
259/// * `cluster_ref` - Optional cluster reference
260/// * `observed_parent_generation` - Generation of the referenced parent cluster
261///   (`Bind9Cluster`/`ClusterBind9Provider`) observed during this reconciliation
262///
263/// # Errors
264///
265/// Returns an error if status patching fails.
266pub(super) async fn update_status(
267    client: &Client,
268    instance: &Bind9Instance,
269    conditions: Vec<Condition>,
270    cluster_ref: Option<ClusterReference>,
271    observed_parent_generation: Option<i64>,
272) -> Result<()> {
273    let api: Api<Bind9Instance> =
274        Api::namespaced(client.clone(), &instance.namespace().unwrap_or_default());
275
276    // Preserve existing zones - zone reconciliation is handled separately
277    let zones = instance
278        .status
279        .as_ref()
280        .map(|s| s.zones.clone())
281        .unwrap_or_default();
282
283    // Compute zones_count from zones length
284    let zones_count = i32::try_from(zones.len()).ok();
285
286    // Check if status has actually changed (including generation tracking)
287    let status_changed = instance_status_changed(
288        instance.status.as_ref(),
289        &conditions,
290        cluster_ref.as_ref(),
291        &zones,
292        instance.metadata.generation,
293        observed_parent_generation,
294    );
295
296    // Only update if status has changed
297    if !status_changed {
298        debug!(
299            "Status unchanged for Bind9Instance {}/{}, skipping patch",
300            instance.namespace().unwrap_or_default(),
301            instance.name_any()
302        );
303        return Ok(());
304    }
305
306    let new_status = Bind9InstanceStatus {
307        conditions,
308        observed_generation: instance.metadata.generation,
309        observed_parent_generation,
310        service_address: None, // Will be populated when service is ready
311        cluster_ref,
312        zones,
313        zones_count,
314        rndc_key_rotation: None, // Will be populated by rotation reconciler
315    };
316
317    let patch = json!({ "status": new_status });
318    api.patch_status(
319        &instance.name_any(),
320        &PatchParams::default(),
321        &Patch::Merge(patch),
322    )
323    .await?;
324
325    Ok(())
326}
327
328#[cfg(test)]
329#[path = "status_helpers_tests.rs"]
330mod status_helpers_tests;