bindy/reconcilers/records/
status_helpers.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Status management and event creation for DNS record resources.
5
6#[allow(clippy::wildcard_imports)]
7use super::types::*;
8
9pub(super) async fn create_event<T>(
10    client: &Client,
11    record: &T,
12    event_type: &str,
13    reason: &str,
14    message: &str,
15) -> Result<()>
16where
17    T: Resource<DynamicType = ()> + ResourceExt,
18{
19    let namespace = record.namespace().unwrap_or_default();
20    let name = record.name_any();
21    let event_api: Api<Event> = Api::namespaced(client.clone(), &namespace);
22
23    let now = Time(k8s_openapi::jiff::Timestamp::now());
24    let event = Event {
25        metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
26            generate_name: Some(format!("{name}-")),
27            namespace: Some(namespace.clone()),
28            ..Default::default()
29        },
30        involved_object: ObjectReference {
31            api_version: Some(T::api_version(&()).to_string()),
32            kind: Some(T::kind(&()).to_string()),
33            name: Some(name.clone()),
34            namespace: Some(namespace),
35            uid: record.meta().uid.clone(),
36            ..Default::default()
37        },
38        reason: Some(reason.to_string()),
39        message: Some(message.to_string()),
40        type_: Some(event_type.to_string()),
41        first_timestamp: Some(now.clone()),
42        last_timestamp: Some(now),
43        count: Some(1),
44        ..Default::default()
45    };
46
47    match event_api.create(&PostParams::default(), &event).await {
48        Ok(_) => Ok(()),
49        Err(e) => {
50            warn!("Failed to create event for {}: {}", name, e);
51            Ok(()) // Don't fail reconciliation if event creation fails
52        }
53    }
54}
55
56/// Updates the status of a DNS record resource.
57///
58/// Updates the status subresource with appropriate conditions following
59/// Kubernetes conventions. Also creates a Kubernetes Event for visibility.
60///
61/// # Arguments
62///
63/// * `client` - Kubernetes API client
64/// * `record` - The DNS record resource to update
65/// * `condition_type` - Type of condition (e.g., "Ready", "Failed")
66/// * `status` - Status value (e.g., "True", "False", "Unknown")
67/// * `reason` - Short reason code (e.g., "`ReconcileSucceeded`", "`ZoneNotFound`")
68/// * `message` - Human-readable message describing the status
69/// * `observed_generation` - Optional generation to set in status (defaults to record's current generation)
70/// * `record_hash` - Optional hash of the record spec for change detection
71/// * `last_updated` - Optional timestamp of last update
72/// * `addresses` - Optional display addresses; `None` preserves any existing value
73/// * `published_name` - DNS name just published to BIND9 (used for rename
74///   detection); `None` preserves any existing value
75///
76/// # Errors
77///
78/// Returns an error if the status update fails.
79#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
80pub(super) async fn update_record_status<T>(
81    client: &Client,
82    record: &T,
83    condition_type: &str,
84    status: &str,
85    reason: &str,
86    message: &str,
87    observed_generation: Option<i64>,
88    record_hash: Option<String>,
89    last_updated: Option<String>,
90    addresses: Option<String>,
91    published_name: Option<String>,
92) -> Result<()>
93where
94    T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
95        + ResourceExt
96        + Clone
97        + std::fmt::Debug
98        + serde::Serialize
99        + for<'de> serde::Deserialize<'de>,
100{
101    let namespace = record.namespace().unwrap_or_default();
102    let name = record.name_any();
103    let api: Api<T> = Api::namespaced(client.clone(), &namespace);
104
105    // Fetch current resource to check existing status
106    let current = api
107        .get(&name)
108        .await
109        .context("Failed to fetch current resource")?;
110
111    // Check if we need to update
112    // Extract status from the current resource using json
113    let current_json = serde_json::to_value(&current)?;
114    let needs_update = if let Some(current_status) = current_json.get("status") {
115        if let Some(observed_gen) = current_status.get("observedGeneration") {
116            // If observed generation matches current generation and condition hasn't changed, skip update
117            if observed_gen == &json!(record.meta().generation) {
118                if let Some(conditions) =
119                    current_status.get("conditions").and_then(|c| c.as_array())
120                {
121                    // Find the condition with matching type (not just first condition)
122                    let matching_condition = conditions.iter().find(|cond| {
123                        cond.get("type").and_then(|t| t.as_str()) == Some(condition_type)
124                    });
125
126                    if let Some(cond) = matching_condition {
127                        let status_matches =
128                            cond.get("status").and_then(|s| s.as_str()) == Some(status);
129                        let reason_matches =
130                            cond.get("reason").and_then(|r| r.as_str()) == Some(reason);
131                        let message_matches =
132                            cond.get("message").and_then(|m| m.as_str()) == Some(message);
133                        // Only update if any field has changed
134                        !(status_matches && reason_matches && message_matches)
135                    } else {
136                        true // Condition type not found, need to add it
137                    }
138                } else {
139                    true // No conditions array, need to update
140                }
141            } else {
142                true // Generation changed, need to update
143            }
144        } else {
145            true // No observed generation, need to update
146        }
147    } else {
148        true // No status, need to update
149    };
150
151    if !needs_update {
152        // Status is already correct, skip update to avoid reconciliation loop
153        return Ok(());
154    }
155
156    // Determine last_transition_time
157    let last_transition_time = if let Some(current_status) = current_json.get("status") {
158        if let Some(conditions) = current_status.get("conditions").and_then(|c| c.as_array()) {
159            // Find the condition with matching type (same as above)
160            let matching_condition = conditions
161                .iter()
162                .find(|cond| cond.get("type").and_then(|t| t.as_str()) == Some(condition_type));
163
164            if let Some(cond) = matching_condition {
165                let status_changed = cond.get("status").and_then(|s| s.as_str()) != Some(status);
166                if status_changed {
167                    // Status changed, use current time
168                    Utc::now().to_rfc3339()
169                } else {
170                    // Status unchanged, preserve existing timestamp
171                    cond.get("lastTransitionTime")
172                        .and_then(|t| t.as_str())
173                        .unwrap_or(&Utc::now().to_rfc3339())
174                        .to_string()
175                }
176            } else {
177                // Condition type not found, use current time
178                Utc::now().to_rfc3339()
179            }
180        } else {
181            Utc::now().to_rfc3339()
182        }
183    } else {
184        Utc::now().to_rfc3339()
185    };
186
187    let condition = Condition {
188        r#type: condition_type.to_string(),
189        status: status.to_string(),
190        reason: Some(reason.to_string()),
191        message: Some(message.to_string()),
192        last_transition_time: Some(last_transition_time),
193    };
194
195    // Preserve existing zone field if it exists (set by DNSZone controller)
196    let zone = current_json
197        .get("status")
198        .and_then(|s| s.get("zone"))
199        .and_then(|z| z.as_str())
200        .map(ToString::to_string);
201
202    // Preserve existing zone_ref field if it exists (set by DNSZone controller)
203    let zone_ref = current_json
204        .get("status")
205        .and_then(|s| s.get("zoneRef"))
206        .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z.clone()).ok());
207
208    // Use provided addresses if available, otherwise preserve existing
209    let status_addresses = addresses.or_else(|| {
210        current_json
211            .get("status")
212            .and_then(|s| s.get("addresses"))
213            .and_then(|a| a.as_str())
214            .map(ToString::to_string)
215    });
216
217    // Use provided published name if available, otherwise preserve existing
218    let status_published_name = published_name.or_else(|| {
219        current_json
220            .get("status")
221            .and_then(|s| s.get("publishedName"))
222            .and_then(|p| p.as_str())
223            .map(ToString::to_string)
224    });
225
226    #[allow(deprecated)] // Maintain backward compatibility with deprecated zone field
227    let record_status = RecordStatus {
228        conditions: vec![condition],
229        observed_generation: observed_generation.or(record.meta().generation),
230        zone,
231        zone_ref, // Preserved from existing status (set by DNSZone controller)
232        record_hash,
233        last_updated,
234        addresses: status_addresses, // Set by A/AAAA record reconcilers or preserved from existing
235        published_name: status_published_name, // Set on success by reconcile_record or preserved
236    };
237
238    let status_patch = json!({
239        "status": record_status
240    });
241
242    api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch))
243        .await
244        .context("Failed to update record status")?;
245
246    info!(
247        "Updated status for {}/{}: {} = {}",
248        namespace, name, condition_type, status
249    );
250
251    // Create event for visibility
252    let event_type = if status == "True" {
253        "Normal"
254    } else {
255        "Warning"
256    };
257    create_event(client, record, event_type, reason, message).await?;
258
259    Ok(())
260}