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