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/// Update the status of a `Bind9Cluster` with multiple conditions.
149///
150/// Patches the cluster status in Kubernetes if it has changed.
151/// Performs a comparison to avoid unnecessary API calls when status is unchanged.
152///
153/// # Arguments
154///
155/// * `client` - Kubernetes API client
156/// * `cluster` - The `Bind9Cluster` to update
157/// * `conditions` - Vector of status conditions to set
158/// * `instance_count` - Total number of instances
159/// * `ready_instances` - Number of ready instances
160/// * `instances` - Names of all instances
161///
162/// # Errors
163///
164/// Returns an error if status patching fails.
165pub(super) async fn update_status(
166    client: &Client,
167    cluster: &Bind9Cluster,
168    conditions: Vec<Condition>,
169    instance_count: i32,
170    ready_instances: i32,
171    instances: Vec<String>,
172) -> Result<()> {
173    let api: Api<Bind9Cluster> =
174        Api::namespaced(client.clone(), &cluster.namespace().unwrap_or_default());
175
176    // Check if status has actually changed
177    let current_status = &cluster.status;
178    let status_changed =
179        if let Some(current) = current_status {
180            // Check if counts changed
181            if current.instance_count != Some(instance_count)
182                || current.ready_instances != Some(ready_instances)
183                || current.instances != instances
184            {
185                true
186            } else {
187                // Check if any condition changed
188                if current.conditions.len() == conditions.len() {
189                    // Compare each condition
190                    current.conditions.iter().zip(conditions.iter()).any(
191                        |(current_cond, new_cond)| {
192                            current_cond.r#type != new_cond.r#type
193                                || current_cond.status != new_cond.status
194                                || current_cond.message != new_cond.message
195                                || current_cond.reason != new_cond.reason
196                        },
197                    )
198                } else {
199                    true
200                }
201            }
202        } else {
203            // No status exists, need to update
204            true
205        };
206
207    // Only update if status has changed
208    if !status_changed {
209        debug!(
210            namespace = %cluster.namespace().unwrap_or_default(),
211            name = %cluster.name_any(),
212            "Status unchanged, skipping update"
213        );
214        info!(
215            "Bind9Cluster {}/{} status unchanged, skipping update",
216            cluster.namespace().unwrap_or_default(),
217            cluster.name_any()
218        );
219        return Ok(());
220    }
221
222    debug!(
223        instance_count,
224        ready_instances,
225        instances_count = instances.len(),
226        num_conditions = conditions.len(),
227        "Preparing status update"
228    );
229
230    let new_status = Bind9ClusterStatus {
231        conditions,
232        observed_generation: cluster.metadata.generation,
233        instance_count: Some(instance_count),
234        ready_instances: Some(ready_instances),
235        instances,
236    };
237
238    info!(
239        "Updating Bind9Cluster {}/{} status: {} instances, {} ready",
240        cluster.namespace().unwrap_or_default(),
241        cluster.name_any(),
242        instance_count,
243        ready_instances
244    );
245
246    let patch = json!({ "status": new_status });
247    api.patch_status(
248        &cluster.name_any(),
249        &PatchParams::apply("bindy-controller"),
250        &Patch::Merge(&patch),
251    )
252    .await?;
253
254    Ok(())
255}
256
257#[cfg(test)]
258#[path = "status_helpers_tests.rs"]
259mod status_helpers_tests;