bindy/reconcilers/bind9cluster/
status_helpers.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Status calculation and update helpers for `Bind9Cluster` resources.
5//!
6//! This module handles computing cluster status from instance health and
7//! patching the cluster status in Kubernetes.
8
9#[allow(clippy::wildcard_imports)]
10use super::types::*;
11
12/// Calculate cluster status from instance health.
13///
14/// Analyzes the list of instances to determine cluster readiness.
15/// Creates both an encompassing `Ready` condition and individual conditions
16/// for each instance.
17///
18/// # Arguments
19///
20/// * `instances` - List of `Bind9Instance` resources for the cluster
21/// * `namespace` - Cluster namespace (for logging)
22/// * `name` - Cluster name (for logging)
23///
24/// # Returns
25///
26/// Tuple of:
27/// - `instance_count` - Total number of instances
28/// - `ready_instances` - Number of ready instances
29/// - `instance_names` - Names of all instances
30/// - `conditions` - Vector of status conditions (Ready + per-instance)
31#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
32pub fn calculate_cluster_status(
33    instances: &[Bind9Instance],
34    namespace: &str,
35    name: &str,
36) -> (i32, i32, Vec<String>, Vec<Condition>) {
37    // Count total instances and ready instances
38    let instance_count = instances.len() as i32;
39    let instance_names: Vec<String> = instances.iter().map(ResourceExt::name_any).collect();
40
41    let ready_instances = instances
42        .iter()
43        .filter(|instance| {
44            instance
45                .status
46                .as_ref()
47                .and_then(|status| status.conditions.first())
48                .is_some_and(|condition| condition.r#type == "Ready" && condition.status == "True")
49        })
50        .count() as i32;
51
52    info!(
53        "Bind9Cluster {}/{} has {} instances, {} ready",
54        namespace, name, instance_count, ready_instances
55    );
56
57    // Create instance-level conditions
58    let mut instance_conditions = Vec::new();
59    for (index, instance) in instances.iter().enumerate() {
60        let instance_name = instance.name_any();
61        let is_instance_ready = instance
62            .status
63            .as_ref()
64            .and_then(|status| status.conditions.first())
65            .is_some_and(|condition| condition.r#type == "Ready" && condition.status == "True");
66
67        let (status, reason, message) = if is_instance_ready {
68            (
69                "True",
70                REASON_READY,
71                format!("Instance {instance_name} is ready"),
72            )
73        } else {
74            (
75                "False",
76                REASON_NOT_READY,
77                format!("Instance {instance_name} is not ready"),
78            )
79        };
80
81        instance_conditions.push(Condition {
82            r#type: bind9_instance_condition_type(index),
83            status: status.to_string(),
84            reason: Some(reason.to_string()),
85            message: Some(message),
86            last_transition_time: Some(Utc::now().to_rfc3339()),
87        });
88    }
89
90    // Create encompassing Ready condition
91    let (encompassing_status, encompassing_reason, encompassing_message) = if instance_count == 0 {
92        debug!("No instances found for cluster");
93        (
94            "False",
95            REASON_NO_CHILDREN,
96            "No instances found for this cluster".to_string(),
97        )
98    } else if ready_instances == instance_count {
99        debug!("All instances ready");
100        (
101            "True",
102            REASON_ALL_READY,
103            format!("All {instance_count} instances are ready"),
104        )
105    } else if ready_instances > 0 {
106        debug!(ready_instances, instance_count, "Cluster progressing");
107        (
108            "False",
109            REASON_PARTIALLY_READY,
110            format!("{ready_instances}/{instance_count} instances are ready"),
111        )
112    } else {
113        debug!("Waiting for instances to become ready");
114        (
115            "False",
116            REASON_NOT_READY,
117            "No instances are ready".to_string(),
118        )
119    };
120
121    let encompassing_condition = Condition {
122        r#type: CONDITION_TYPE_READY.to_string(),
123        status: encompassing_status.to_string(),
124        reason: Some(encompassing_reason.to_string()),
125        message: Some(encompassing_message.clone()),
126        last_transition_time: Some(Utc::now().to_rfc3339()),
127    };
128
129    // Combine encompassing condition + instance-level conditions
130    let mut all_conditions = vec![encompassing_condition];
131    all_conditions.extend(instance_conditions);
132
133    debug!(
134        status = %encompassing_status,
135        message = %encompassing_message,
136        num_conditions = all_conditions.len(),
137        "Determined cluster status"
138    );
139
140    (
141        instance_count,
142        ready_instances,
143        instance_names,
144        all_conditions,
145    )
146}
147
148/// Determines whether the `Bind9Cluster` status patch is needed.
149///
150/// Compares the current status against the values about to be written. The
151/// patch is needed if any of the following changed:
152/// - `instance_count`, `ready_instances`, or the instance name list
153/// - `observed_generation` (so spec edits that don't change counts/conditions
154///   still advance `observedGeneration` and stop perpetual re-reconciliation)
155/// - Any condition's type, status, reason, or message
156///
157/// # Arguments
158///
159/// * `current` - The status currently stored on the resource (if any)
160/// * `conditions` - New conditions about to be written
161/// * `instance_count` - New total instance count
162/// * `ready_instances` - New ready instance count
163/// * `instances` - New instance name list
164/// * `generation` - The `metadata.generation` about to be written as `observed_generation`
165///
166/// # Returns
167///
168/// `true` if the status patch should be applied, `false` if it can be skipped.
169#[must_use]
170pub fn cluster_status_changed(
171    current: Option<&Bind9ClusterStatus>,
172    conditions: &[Condition],
173    instance_count: i32,
174    ready_instances: i32,
175    instances: &[String],
176    generation: Option<i64>,
177) -> bool {
178    let Some(current) = current else {
179        // No status exists, need to update
180        return true;
181    };
182
183    // Check if counts changed
184    if current.instance_count != Some(instance_count)
185        || current.ready_instances != Some(ready_instances)
186        || current.instances != instances
187    {
188        return true;
189    }
190
191    // Check if the observed generation is behind the generation about to be
192    // written. Without this, a spec edit that does not change counts or
193    // conditions never advances observedGeneration, causing should_reconcile()
194    // to return true on every requeue forever.
195    if current.observed_generation != generation {
196        return true;
197    }
198
199    // Check if any condition changed
200    if current.conditions.len() != conditions.len() {
201        return true;
202    }
203
204    current
205        .conditions
206        .iter()
207        .zip(conditions.iter())
208        .any(|(current_cond, new_cond)| {
209            current_cond.r#type != new_cond.r#type
210                || current_cond.status != new_cond.status
211                || current_cond.message != new_cond.message
212                || current_cond.reason != new_cond.reason
213        })
214}
215
216/// Update the status of a `Bind9Cluster` with multiple conditions.
217///
218/// Patches the cluster status in Kubernetes if it has changed.
219/// Performs a comparison to avoid unnecessary API calls when status is unchanged.
220///
221/// # Arguments
222///
223/// * `client` - Kubernetes API client
224/// * `cluster` - The `Bind9Cluster` to update
225/// * `conditions` - Vector of status conditions to set
226/// * `instance_count` - Total number of instances
227/// * `ready_instances` - Number of ready instances
228/// * `instances` - Names of all instances
229///
230/// # Errors
231///
232/// Returns an error if status patching fails.
233pub(super) async fn update_status(
234    client: &Client,
235    cluster: &Bind9Cluster,
236    conditions: Vec<Condition>,
237    instance_count: i32,
238    ready_instances: i32,
239    instances: Vec<String>,
240) -> Result<()> {
241    let api: Api<Bind9Cluster> =
242        Api::namespaced(client.clone(), &cluster.namespace().unwrap_or_default());
243
244    // Check if status has actually changed (including observed_generation)
245    let status_changed = cluster_status_changed(
246        cluster.status.as_ref(),
247        &conditions,
248        instance_count,
249        ready_instances,
250        &instances,
251        cluster.metadata.generation,
252    );
253
254    // Only update if status has changed
255    if !status_changed {
256        debug!(
257            namespace = %cluster.namespace().unwrap_or_default(),
258            name = %cluster.name_any(),
259            "Status unchanged, skipping update"
260        );
261        info!(
262            "Bind9Cluster {}/{} status unchanged, skipping update",
263            cluster.namespace().unwrap_or_default(),
264            cluster.name_any()
265        );
266        return Ok(());
267    }
268
269    debug!(
270        instance_count,
271        ready_instances,
272        instances_count = instances.len(),
273        num_conditions = conditions.len(),
274        "Preparing status update"
275    );
276
277    let new_status = Bind9ClusterStatus {
278        conditions,
279        observed_generation: cluster.metadata.generation,
280        instance_count: Some(instance_count),
281        ready_instances: Some(ready_instances),
282        instances,
283    };
284
285    info!(
286        "Updating Bind9Cluster {}/{} status: {} instances, {} ready",
287        cluster.namespace().unwrap_or_default(),
288        cluster.name_any(),
289        instance_count,
290        ready_instances
291    );
292
293    let patch = json!({ "status": new_status });
294    api.patch_status(
295        &cluster.name_any(),
296        &PatchParams::apply("bindy-controller"),
297        &Patch::Merge(&patch),
298    )
299    .await?;
300
301    Ok(())
302}
303
304#[cfg(test)]
305#[path = "status_helpers_tests.rs"]
306mod status_helpers_tests;