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;