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, RecordReferenceWithTimestamp};
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///
333///     // Single atomic update at the end
334///     status_updater.apply(&client).await?;
335///     Ok(())
336/// }
337/// ```
338pub struct DNSZoneStatusUpdater {
339    namespace: String,
340    name: String,
341    current_status: Option<DNSZoneStatus>,
342    new_status: DNSZoneStatus,
343    has_changes: bool,
344    degraded_set_this_reconciliation: 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            degraded_set_this_reconciliation: false,
363        }
364    }
365
366    /// Update or add a condition (in-memory only, no API call).
367    ///
368    /// Marks the status as changed if the condition differs from the current state.
369    pub fn set_condition(
370        &mut self,
371        condition_type: &str,
372        status: &str,
373        reason: &str,
374        message: &str,
375    ) {
376        // Track if we're setting Degraded=True during this reconciliation
377        if condition_type == "Degraded" && status == "True" {
378            self.degraded_set_this_reconciliation = true;
379        }
380
381        update_condition_in_memory(
382            &mut self.new_status.conditions,
383            condition_type,
384            status,
385            reason,
386            message,
387        );
388        self.has_changes = true;
389    }
390
391    /// Set the discovered DNS records list (in-memory only, no API call).
392    pub fn set_records(&mut self, records: &[RecordReferenceWithTimestamp]) {
393        records.clone_into(&mut self.new_status.records);
394        // Update records_count whenever records changes
395        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
396        {
397            self.new_status.records_count =
398                i32::try_from(self.new_status.records.len()).unwrap_or(0);
399        }
400        self.has_changes = true;
401    }
402
403    /// Set the observed generation to match the current generation.
404    pub fn set_observed_generation(&mut self, generation: Option<i64>) {
405        self.new_status.observed_generation = generation;
406        self.has_changes = true;
407    }
408
409    /// Update instance status (in-memory only, no API call).
410    ///
411    /// Updates the status of a specific instance in the `status.bind9Instances` list.
412    /// Creates a new entry if the instance doesn't exist.
413    ///
414    /// # Arguments
415    ///
416    /// * `name` - Instance name
417    /// * `namespace` - Instance namespace
418    /// * `status` - New status (Claimed, Configured, Failed, Unclaimed)
419    /// * `message` - Optional status message (error details, etc.)
420    pub fn update_instance_status(
421        &mut self,
422        name: &str,
423        namespace: &str,
424        status: crate::crd::InstanceStatus,
425        message: Option<String>,
426    ) {
427        use chrono::Utc;
428        let now = Utc::now().to_rfc3339();
429
430        // Find existing instance or create new one
431        if let Some(instance) = self
432            .new_status
433            .bind9_instances
434            .iter_mut()
435            .find(|i| i.namespace == namespace && i.name == name)
436        {
437            // Update existing instance
438            instance.status = status;
439            instance.last_reconciled_at = Some(now);
440            instance.message = message;
441        } else {
442            // Add new instance
443            self.new_status
444                .bind9_instances
445                .push(crate::crd::InstanceReferenceWithStatus {
446                    api_version: crate::constants::API_GROUP_VERSION.to_string(),
447                    kind: crate::constants::KIND_BIND9_INSTANCE.to_string(),
448                    name: name.to_string(),
449                    namespace: namespace.to_string(),
450                    status,
451                    last_reconciled_at: Some(now),
452                    message,
453                });
454        }
455        // Update bind9_instances_count whenever bind9_instances changes
456        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
457        {
458            self.new_status.bind9_instances_count =
459                i32::try_from(self.new_status.bind9_instances.len()).ok();
460        }
461        self.has_changes = true;
462    }
463
464    /// Remove instance from the instances list (in-memory only, no API call).
465    ///
466    /// Removes an instance from `status.bind9Instances` when it no longer claims the zone
467    /// or has been deleted.
468    ///
469    /// # Arguments
470    ///
471    /// * `name` - Instance name
472    /// * `namespace` - Instance namespace
473    pub fn remove_instance(&mut self, name: &str, namespace: &str) {
474        let initial_len = self.new_status.bind9_instances.len();
475        self.new_status
476            .bind9_instances
477            .retain(|i| !(i.namespace == namespace && i.name == name));
478
479        if self.new_status.bind9_instances.len() != initial_len {
480            // Update bind9_instances_count whenever bind9_instances changes
481            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
482            {
483                self.new_status.bind9_instances_count =
484                    i32::try_from(self.new_status.bind9_instances.len()).ok();
485            }
486            self.has_changes = true;
487        }
488    }
489
490    /// Check if the status has actually changed compared to the current status.
491    ///
492    /// Returns `true` if there are semantic changes that warrant an API update.
493    ///
494    /// **CRITICAL**: The comparison uses `InstanceReferenceWithStatus::eq()` which excludes
495    /// `last_reconciled_at` timestamps. Without this, nanosecond precision differences would
496    /// cause infinite reconciliation loops.
497    #[must_use]
498    pub fn has_changes(&self) -> bool {
499        if !self.has_changes {
500            return false;
501        }
502
503        match &self.current_status {
504            None => true, // First status update
505            Some(current) => {
506                current.records != self.new_status.records
507                    || current.observed_generation != self.new_status.observed_generation
508                    || !conditions_equal(&current.conditions, &self.new_status.conditions)
509                    || current.bind9_instances != self.new_status.bind9_instances
510                    || current.bind9_instances_count != self.new_status.bind9_instances_count
511            }
512        }
513    }
514
515    /// Check if a Degraded condition was set during **this** reconciliation.
516    ///
517    /// Returns `true` only if `set_condition("Degraded", "True", ...)` was called
518    /// during this reconciliation, not if a Degraded condition existed from a previous reconciliation.
519    #[must_use]
520    pub fn has_degraded_condition(&self) -> bool {
521        self.degraded_set_this_reconciliation
522    }
523
524    /// Clear any Degraded condition by setting it to False (in-memory only, no API call).
525    ///
526    /// This method should be called when reconciliation succeeds to ensure stale
527    /// Degraded conditions from previous failures are cleared.
528    ///
529    /// If no Degraded condition exists, this method does nothing.
530    pub fn clear_degraded_condition(&mut self) {
531        self.set_condition("Degraded", "False", "ReconcileSucceeded", "");
532        // Reset the tracking flag since we're explicitly clearing the condition
533        self.degraded_set_this_reconciliation = false;
534    }
535
536    /// Set the Ready condition to False with `DuplicateZone` reason (in-memory only, no API call).
537    ///
538    /// This method should be called when a duplicate zone is detected to signal that
539    /// this zone cannot be reconciled because another zone already claims the same zone name.
540    ///
541    /// # Arguments
542    ///
543    /// * `zone_name` - The zone name that has a conflict
544    /// * `conflicting_zones` - List of conflicting zone identifiers (namespace/name)
545    pub fn set_duplicate_zone_condition(&mut self, zone_name: &str, conflicting_zones: &[String]) {
546        let message = format!(
547            "A zone with this name '{}' has already been declared in these BIND9 Instances: {}",
548            zone_name,
549            conflicting_zones.join(", ")
550        );
551        self.set_condition("Ready", "False", "DuplicateZone", &message);
552    }
553
554    /// Get a reference to the conditions list (for testing).
555    ///
556    /// # Returns
557    ///
558    /// A reference to the conditions vector in the new status.
559    #[cfg(test)]
560    #[must_use]
561    pub fn conditions(&self) -> &Vec<Condition> {
562        &self.new_status.conditions
563    }
564
565    /// Apply the collected status changes to Kubernetes (single atomic API call).
566    ///
567    /// Only makes the API call if there are actual changes. Skips the update if
568    /// the status is semantically unchanged, preventing unnecessary reconciliation loops.
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the Kubernetes API call fails.
573    pub async fn apply(&self, client: &Client) -> Result<()> {
574        if !self.has_changes() {
575            debug!(
576                "DNSZone {}/{} status unchanged, skipping update",
577                self.namespace, self.name
578            );
579            return Ok(());
580        }
581
582        let api: Api<DNSZone> = Api::namespaced(client.clone(), &self.namespace);
583
584        let patch = json!({
585            "status": self.new_status
586        });
587
588        api.patch_status(&self.name, &PatchParams::default(), &Patch::Merge(&patch))
589            .await?;
590
591        debug!(
592            "Updated DNSZone {}/{} status: {} condition(s), {} record(s)",
593            self.namespace,
594            self.name,
595            self.new_status.conditions.len(),
596            self.new_status.records.len()
597        );
598
599        Ok(())
600    }
601}