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 /// Record whether this zone still owes BIND9 a full record replay
404 /// (in-memory only, no API call).
405 ///
406 /// Set to `true` as soon as the controller creates the zone on any server
407 /// endpoint - a created zone holds only SOA and NS records, so every record
408 /// CR is missing from it. Set to `false` only once every record has been
409 /// pushed to every primary endpoint successfully.
410 ///
411 /// # Arguments
412 ///
413 /// * `pending` - Whether a full record replay is still outstanding
414 pub fn set_records_resync_pending(&mut self, pending: bool) {
415 if self.new_status.records_resync_pending == pending {
416 return;
417 }
418 self.new_status.records_resync_pending = pending;
419 self.has_changes = true;
420 }
421
422 /// Whether a full record replay is currently marked as outstanding.
423 #[must_use]
424 pub fn records_resync_pending(&self) -> bool {
425 self.new_status.records_resync_pending
426 }
427
428 /// Set the observed generation to match the current generation.
429 pub fn set_observed_generation(&mut self, generation: Option<i64>) {
430 self.new_status.observed_generation = generation;
431 self.has_changes = true;
432 }
433
434 /// Update instance status (in-memory only, no API call).
435 ///
436 /// Updates the status of a specific instance in the `status.bind9Instances` list.
437 /// Creates a new entry if the instance doesn't exist.
438 ///
439 /// # Arguments
440 ///
441 /// * `name` - Instance name
442 /// * `namespace` - Instance namespace
443 /// * `status` - New status (Claimed, Configured, Failed, Unclaimed)
444 /// * `message` - Optional status message (error details, etc.)
445 pub fn update_instance_status(
446 &mut self,
447 name: &str,
448 namespace: &str,
449 status: crate::crd::InstanceStatus,
450 message: Option<String>,
451 ) {
452 use chrono::Utc;
453 let now = Utc::now().to_rfc3339();
454
455 // Find existing instance or create new one
456 if let Some(instance) = self
457 .new_status
458 .bind9_instances
459 .iter_mut()
460 .find(|i| i.namespace == namespace && i.name == name)
461 {
462 // Update existing instance
463 instance.status = status;
464 instance.last_reconciled_at = Some(now);
465 instance.message = message;
466 } else {
467 // Add new instance
468 self.new_status
469 .bind9_instances
470 .push(crate::crd::InstanceReferenceWithStatus {
471 api_version: crate::constants::API_GROUP_VERSION.to_string(),
472 kind: crate::constants::KIND_BIND9_INSTANCE.to_string(),
473 name: name.to_string(),
474 namespace: namespace.to_string(),
475 status,
476 last_reconciled_at: Some(now),
477 message,
478 });
479 }
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 /// Remove instance from the instances list (in-memory only, no API call).
490 ///
491 /// Removes an instance from `status.bind9Instances` when it no longer claims the zone
492 /// or has been deleted.
493 ///
494 /// # Arguments
495 ///
496 /// * `name` - Instance name
497 /// * `namespace` - Instance namespace
498 pub fn remove_instance(&mut self, name: &str, namespace: &str) {
499 let initial_len = self.new_status.bind9_instances.len();
500 self.new_status
501 .bind9_instances
502 .retain(|i| !(i.namespace == namespace && i.name == name));
503
504 if self.new_status.bind9_instances.len() != initial_len {
505 // Update bind9_instances_count whenever bind9_instances changes
506 #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
507 {
508 self.new_status.bind9_instances_count =
509 i32::try_from(self.new_status.bind9_instances.len()).ok();
510 }
511 self.has_changes = true;
512 }
513 }
514
515 /// Check if the status has actually changed compared to the current status.
516 ///
517 /// Returns `true` if there are semantic changes that warrant an API update.
518 ///
519 /// **CRITICAL**: The comparison uses `InstanceReferenceWithStatus::eq()` which excludes
520 /// `last_reconciled_at` timestamps. Without this, nanosecond precision differences would
521 /// cause infinite reconciliation loops.
522 #[must_use]
523 pub fn has_changes(&self) -> bool {
524 if !self.has_changes {
525 return false;
526 }
527
528 match &self.current_status {
529 None => true, // First status update
530 Some(current) => {
531 current.records != self.new_status.records
532 || current.observed_generation != self.new_status.observed_generation
533 || !conditions_equal(¤t.conditions, &self.new_status.conditions)
534 || current.bind9_instances != self.new_status.bind9_instances
535 || current.bind9_instances_count != self.new_status.bind9_instances_count
536 || current.records_resync_pending != self.new_status.records_resync_pending
537 }
538 }
539 }
540
541 /// Check if a Degraded condition was set during **this** reconciliation.
542 ///
543 /// Returns `true` only if `set_condition("Degraded", "True", ...)` was called
544 /// during this reconciliation, not if a Degraded condition existed from a previous reconciliation.
545 #[must_use]
546 pub fn has_degraded_condition(&self) -> bool {
547 self.degraded_set_this_reconciliation
548 }
549
550 /// Clear any Degraded condition by setting it to False (in-memory only, no API call).
551 ///
552 /// This method should be called when reconciliation succeeds to ensure stale
553 /// Degraded conditions from previous failures are cleared.
554 ///
555 /// If no Degraded condition exists, this method does nothing.
556 pub fn clear_degraded_condition(&mut self) {
557 self.set_condition("Degraded", "False", "ReconcileSucceeded", "");
558 // Reset the tracking flag since we're explicitly clearing the condition
559 self.degraded_set_this_reconciliation = false;
560 }
561
562 /// Set the Ready condition to False with `DuplicateZone` reason (in-memory only, no API call).
563 ///
564 /// This method should be called when a duplicate zone is detected to signal that
565 /// this zone cannot be reconciled because another zone already claims the same zone name.
566 ///
567 /// # Arguments
568 ///
569 /// * `zone_name` - The zone name that has a conflict
570 /// * `conflicting_zones` - List of conflicting zone identifiers (namespace/name)
571 pub fn set_duplicate_zone_condition(&mut self, zone_name: &str, conflicting_zones: &[String]) {
572 let message = format!(
573 "A zone with this name '{}' has already been declared in these BIND9 Instances: {}",
574 zone_name,
575 conflicting_zones.join(", ")
576 );
577 self.set_condition("Ready", "False", "DuplicateZone", &message);
578 }
579
580 /// Get a reference to the conditions list (for testing).
581 ///
582 /// # Returns
583 ///
584 /// A reference to the conditions vector in the new status.
585 #[cfg(test)]
586 #[must_use]
587 pub fn conditions(&self) -> &Vec<Condition> {
588 &self.new_status.conditions
589 }
590
591 /// Apply the collected status changes to Kubernetes (single atomic API call).
592 ///
593 /// Only makes the API call if there are actual changes. Skips the update if
594 /// the status is semantically unchanged, preventing unnecessary reconciliation loops.
595 ///
596 /// # Errors
597 ///
598 /// Returns an error if the Kubernetes API call fails.
599 pub async fn apply(&self, client: &Client) -> Result<()> {
600 if !self.has_changes() {
601 debug!(
602 "DNSZone {}/{} status unchanged, skipping update",
603 self.namespace, self.name
604 );
605 return Ok(());
606 }
607
608 let api: Api<DNSZone> = Api::namespaced(client.clone(), &self.namespace);
609
610 let patch = json!({
611 "status": self.new_status
612 });
613
614 api.patch_status(&self.name, &PatchParams::default(), &Patch::Merge(&patch))
615 .await?;
616
617 debug!(
618 "Updated DNSZone {}/{} status: {} condition(s), {} record(s)",
619 self.namespace,
620 self.name,
621 self.new_status.conditions.len(),
622 self.new_status.records.len()
623 );
624
625 Ok(())
626 }
627}