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(¤t_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(¤t.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}