bindy/reconcilers/
status.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Status condition helpers for Kubernetes resources.
5//!
6//! This module provides utility functions for creating and managing Kubernetes
7//! status conditions following the standard conventions.
8//!
9//! # Condition Format
10//!
11//! Kubernetes conditions follow a standard format:
12//! - `type`: The aspect of the resource being reported (e.g., "Ready", "Progressing")
13//! - `status`: "True", "False", or "Unknown"
14//! - `reason`: A programmatic identifier (CamelCase)
15//! - `message`: A human-readable explanation
16//! - `lastTransitionTime`: RFC3339 timestamp when the condition changed
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! use bindy::reconcilers::status::create_condition;
22//! use bindy::crd::Condition;
23//!
24//! let condition = create_condition(
25//!     "Ready",
26//!     "True",
27//!     "DeploymentReady",
28//!     "All replicas are running"
29//! );
30//! ```
31
32use crate::crd::{Condition, DNSZone, DNSZoneStatus, RecordReference};
33use anyhow::Result;
34use chrono::Utc;
35use kube::api::Patch;
36use kube::{api::PatchParams, Api, Client, ResourceExt};
37use serde_json::json;
38use tracing::debug;
39
40/// Create a new Kubernetes condition with the current timestamp.
41///
42/// This is a convenience function for creating conditions that follow Kubernetes
43/// conventions. The `lastTransitionTime` is automatically set to the current time.
44///
45/// # Arguments
46///
47/// * `condition_type` - The type of condition (e.g., "Ready", "Progressing")
48/// * `status` - The status: "True", "False", or "Unknown"
49/// * `reason` - A programmatic identifier in `CamelCase` (e.g., "`DeploymentReady`")
50/// * `message` - A human-readable explanation
51///
52/// # Returns
53///
54/// A new `Condition` with the current timestamp.
55///
56/// # Example
57///
58/// ```rust,no_run
59/// # use bindy::reconcilers::status::create_condition;
60/// let condition = create_condition(
61///     "Ready",
62///     "True",
63///     "AllPodsRunning",
64///     "All 3 pods are running and ready"
65/// );
66/// assert_eq!(condition.r#type, "Ready");
67/// assert_eq!(condition.status, "True");
68/// ```
69#[must_use]
70pub fn create_condition(
71    condition_type: &str,
72    status: &str,
73    reason: &str,
74    message: &str,
75) -> Condition {
76    Condition {
77        r#type: condition_type.to_string(),
78        status: status.to_string(),
79        reason: Some(reason.to_string()),
80        message: Some(message.to_string()),
81        last_transition_time: Some(Utc::now().to_rfc3339()),
82    }
83}
84
85/// Check if a condition has changed compared to the existing status.
86///
87/// This function compares a new condition against an existing condition from the
88/// resource's status. It returns `true` if the condition has changed and should
89/// be updated, or `false` if it's unchanged.
90///
91/// A condition is considered changed if:
92/// - The condition type is different
93/// - The status value is different ("True" vs "False")
94/// - The message is different
95///
96/// The `reason` and `lastTransitionTime` are not compared, as these typically
97/// change with the condition itself.
98///
99/// # Arguments
100///
101/// * `existing` - The existing condition from the resource's status (if any)
102/// * `new_condition` - The new condition to compare against
103///
104/// # Returns
105///
106/// * `true` - The condition has changed and should be updated
107/// * `false` - The condition is unchanged, skip the update
108///
109/// # Example
110///
111/// ```rust,no_run
112/// # use bindy::reconcilers::status::{create_condition, condition_changed};
113/// # use bindy::crd::Condition;
114/// let existing = Some(create_condition("Ready", "False", "Pending", "Waiting"));
115/// let new_cond = create_condition("Ready", "True", "Running", "All pods ready");
116///
117/// if condition_changed(&existing, &new_cond) {
118///     // Update the status
119/// }
120/// ```
121#[must_use]
122pub fn condition_changed(existing: &Option<Condition>, new_condition: &Condition) -> bool {
123    if let Some(current) = existing {
124        current.r#type != new_condition.r#type
125            || current.status != new_condition.status
126            || current.message != new_condition.message
127    } else {
128        // No existing condition, so it has changed
129        true
130    }
131}
132
133/// Get the last transition time from an existing condition, or current time if none exists.
134///
135/// When updating a condition, we want to preserve the `lastTransitionTime` if the
136/// condition status hasn't actually changed. This function retrieves the existing
137/// timestamp if available, or returns the current time for new conditions.
138///
139/// This is useful for preserving transition times when only the message changes
140/// but the overall status remains the same.
141///
142/// # Arguments
143///
144/// * `existing_conditions` - The existing conditions from the resource's status
145/// * `condition_type` - The type of condition to look for
146///
147/// # Returns
148///
149/// The existing `lastTransitionTime` if found, otherwise the current time as RFC3339.
150///
151/// # Example
152///
153/// ```rust,no_run
154/// # use bindy::reconcilers::status::get_last_transition_time;
155/// # use bindy::crd::Condition;
156/// let existing_conditions = vec![]; // From resource status
157/// let time = get_last_transition_time(&existing_conditions, "Ready");
158/// ```
159#[must_use]
160pub fn get_last_transition_time(existing_conditions: &[Condition], condition_type: &str) -> String {
161    existing_conditions
162        .iter()
163        .find(|c| c.r#type == condition_type)
164        .and_then(|c| c.last_transition_time.as_ref())
165        .map_or_else(|| Utc::now().to_rfc3339(), std::string::ToString::to_string)
166}
167
168/// Find a condition by type in a list of conditions.
169///
170/// This is a convenience function for finding a specific condition type
171/// in a resource's status conditions.
172///
173/// # Arguments
174///
175/// * `conditions` - The list of conditions to search
176/// * `condition_type` - The type of condition to find (e.g., "Ready")
177///
178/// # Returns
179///
180/// The matching condition if found, otherwise `None`.
181///
182/// # Example
183///
184/// ```rust,no_run
185/// # use bindy::reconcilers::status::find_condition;
186/// # use bindy::crd::Condition;
187/// let conditions = vec![]; // From resource status
188/// if let Some(ready_condition) = find_condition(&conditions, "Ready") {
189///     println!("Ready status: {}", ready_condition.status);
190/// }
191/// ```
192#[must_use]
193pub fn find_condition<'a>(
194    conditions: &'a [Condition],
195    condition_type: &str,
196) -> Option<&'a Condition> {
197    conditions.iter().find(|c| c.r#type == condition_type)
198}
199
200/// Update or add a condition in a mutable conditions list (in-memory, no API call).
201///
202/// This function modifies the conditions list in-place by either updating an existing
203/// condition or adding a new one. It preserves the `lastTransitionTime` if the status
204/// hasn't changed, or sets a new timestamp if it has.
205///
206/// **Important:** This function does NOT make any Kubernetes API calls. It only modifies
207/// the in-memory conditions list. You must call `patch_status()` separately to persist
208/// the changes.
209///
210/// # Arguments
211///
212/// * `conditions` - Mutable reference to the conditions list
213/// * `condition_type` - The type of condition (e.g., "Ready", "Progressing")
214/// * `status` - The status: "True", "False", or "Unknown"
215/// * `reason` - A programmatic identifier in `CamelCase`
216/// * `message` - A human-readable explanation
217///
218/// # Example
219///
220/// ```rust,ignore
221/// use bindy::reconcilers::status::update_condition_in_memory;
222/// use bindy::crd::DNSZoneStatus;
223///
224/// let mut status = DNSZoneStatus::default();
225/// update_condition_in_memory(
226///     &mut status.conditions,
227///     "Ready",
228///     "True",
229///     "ZoneConfigured",
230///     "Zone configured on 3 servers"
231/// );
232/// ```
233pub fn update_condition_in_memory(
234    conditions: &mut Vec<Condition>,
235    condition_type: &str,
236    status: &str,
237    reason: &str,
238    message: &str,
239) {
240    // Find existing condition
241    if let Some(existing) = conditions.iter_mut().find(|c| c.r#type == condition_type) {
242        // Preserve lastTransitionTime if status hasn't changed
243        let last_transition_time = if existing.status == status {
244            existing
245                .last_transition_time
246                .clone()
247                .unwrap_or_else(|| Utc::now().to_rfc3339())
248        } else {
249            Utc::now().to_rfc3339()
250        };
251
252        existing.status = status.to_string();
253        existing.reason = Some(reason.to_string());
254        existing.message = Some(message.to_string());
255        existing.last_transition_time = Some(last_transition_time);
256    } else {
257        // Create new condition
258        conditions.push(create_condition(condition_type, status, reason, message));
259    }
260}
261
262/// Compare two condition lists to check if they are semantically equal.
263///
264/// This function compares two lists of conditions to determine if they represent
265/// the same state. It ignores `lastTransitionTime` differences and only compares
266/// the semantic content (type, status, reason, message).
267///
268/// # Arguments
269///
270/// * `current` - The current conditions list
271/// * `new` - The new conditions list to compare
272///
273/// # Returns
274///
275/// * `true` - The conditions are semantically equal (no update needed)
276/// * `false` - The conditions differ (update needed)
277///
278/// # Example
279///
280/// ```rust,ignore
281/// use bindy::reconcilers::status::conditions_equal;
282///
283/// let current_conditions = vec![/* ... */];
284/// let new_conditions = vec![/* ... */];
285///
286/// if !conditions_equal(&current_conditions, &new_conditions) {
287///     // Conditions changed, update status
288/// }
289/// ```
290#[must_use]
291pub fn conditions_equal(current: &[Condition], new: &[Condition]) -> bool {
292    if current.len() != new.len() {
293        return false;
294    }
295
296    for new_cond in new {
297        match current.iter().find(|c| c.r#type == new_cond.r#type) {
298            None => return false,
299            Some(curr_cond) => {
300                if curr_cond.status != new_cond.status
301                    || curr_cond.reason != new_cond.reason
302                    || curr_cond.message != new_cond.message
303                {
304                    return false;
305                }
306            }
307        }
308    }
309
310    true
311}
312
313/// Centralized status updater for `DNSZone` resources.
314///
315/// This struct collects all status changes during reconciliation and applies them
316/// atomically in a single Kubernetes API call. This prevents the tight reconciliation
317/// loop caused by multiple status updates triggering multiple "object updated" events.
318///
319/// **Pattern aligns with kube-condition project for future migration.**
320///
321/// # Example
322///
323/// ```rust,ignore
324/// use bindy::reconcilers::status::DNSZoneStatusUpdater;
325///
326/// async fn reconcile(client: Client, zone: DNSZone) -> Result<()> {
327///     let mut status_updater = DNSZoneStatusUpdater::new(&zone);
328///
329///     // Collect status changes in memory
330///     status_updater.set_condition("Progressing", "True", "Configuring", "Setting up zone");
331///     status_updater.set_records(vec![/* discovered records */]);
332///     status_updater.set_secondary_ips(vec!["10.0.0.1".to_string()]);
333///
334///     // Single atomic update at the end
335///     status_updater.apply(&client).await?;
336///     Ok(())
337/// }
338/// ```
339pub struct DNSZoneStatusUpdater {
340    namespace: String,
341    name: String,
342    current_status: Option<DNSZoneStatus>,
343    new_status: DNSZoneStatus,
344    has_changes: bool,
345}
346
347impl DNSZoneStatusUpdater {
348    /// Create a new status updater for a `DNSZone`.
349    ///
350    /// Initializes with the current status from the zone, or creates a new empty status.
351    #[must_use]
352    pub fn new(dnszone: &DNSZone) -> Self {
353        let current_status = dnszone.status.clone();
354        let new_status = current_status.clone().unwrap_or_default();
355
356        Self {
357            namespace: dnszone.namespace().unwrap_or_default(),
358            name: dnszone.name_any(),
359            current_status,
360            new_status,
361            has_changes: false,
362        }
363    }
364
365    /// Update or add a condition (in-memory only, no API call).
366    ///
367    /// Marks the status as changed if the condition differs from the current state.
368    pub fn set_condition(
369        &mut self,
370        condition_type: &str,
371        status: &str,
372        reason: &str,
373        message: &str,
374    ) {
375        update_condition_in_memory(
376            &mut self.new_status.conditions,
377            condition_type,
378            status,
379            reason,
380            message,
381        );
382        self.has_changes = true;
383    }
384
385    /// Set the discovered DNS records list (in-memory only, no API call).
386    pub fn set_records(&mut self, records: Vec<RecordReference>) {
387        self.new_status.records = records;
388        self.new_status.record_count = i32::try_from(self.new_status.records.len()).ok();
389        self.has_changes = true;
390    }
391
392    /// Set the secondary server IPs (in-memory only, no API call).
393    pub fn set_secondary_ips(&mut self, ips: Vec<String>) {
394        self.new_status.secondary_ips = Some(ips);
395        self.has_changes = true;
396    }
397
398    /// Set the observed generation to match the current generation.
399    pub fn set_observed_generation(&mut self, generation: Option<i64>) {
400        self.new_status.observed_generation = generation;
401        self.has_changes = true;
402    }
403
404    /// Check if the status has actually changed compared to the current status.
405    ///
406    /// Returns `true` if there are semantic changes that warrant an API update.
407    #[must_use]
408    pub fn has_changes(&self) -> bool {
409        if !self.has_changes {
410            return false;
411        }
412
413        match &self.current_status {
414            None => true, // First status update
415            Some(current) => {
416                current.records != self.new_status.records
417                    || current.record_count != self.new_status.record_count
418                    || current.secondary_ips != self.new_status.secondary_ips
419                    || current.observed_generation != self.new_status.observed_generation
420                    || !conditions_equal(&current.conditions, &self.new_status.conditions)
421            }
422        }
423    }
424
425    /// Apply the collected status changes to Kubernetes (single atomic API call).
426    ///
427    /// Only makes the API call if there are actual changes. Skips the update if
428    /// the status is semantically unchanged, preventing unnecessary reconciliation loops.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if the Kubernetes API call fails.
433    pub async fn apply(&self, client: &Client) -> Result<()> {
434        if !self.has_changes() {
435            debug!(
436                "DNSZone {}/{} status unchanged, skipping update",
437                self.namespace, self.name
438            );
439            return Ok(());
440        }
441
442        let api: Api<DNSZone> = Api::namespaced(client.clone(), &self.namespace);
443
444        let patch = json!({
445            "status": self.new_status
446        });
447
448        api.patch_status(&self.name, &PatchParams::default(), &Patch::Merge(&patch))
449            .await?;
450
451        debug!(
452            "Updated DNSZone {}/{} status: {} condition(s), {} record(s)",
453            self.namespace,
454            self.name,
455            self.new_status.conditions.len(),
456            self.new_status.record_count.unwrap_or(0)
457        );
458
459        Ok(())
460    }
461}