bindy/reconcilers/dnszone/status_helpers.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Status calculation and finalization helpers for DNSZone reconciliation.
5//!
6//! This module contains functions for calculating expected instance counts
7//! and determining the final Ready/Degraded status of a DNSZone.
8
9use anyhow::Result;
10use kube::Client;
11
12use crate::crd::InstanceReference;
13
14/// Calculate expected instance counts (primary and secondary).
15///
16/// This function filters the instance references to determine how many
17/// primary and secondary instances should be configured.
18///
19/// # Arguments
20///
21/// * `client` - Kubernetes API client
22/// * `instance_refs` - List of instance references assigned to the zone
23///
24/// # Returns
25///
26/// Tuple of `(expected_primary_count, expected_secondary_count)`
27///
28/// # Errors
29///
30/// Returns an error if Kubernetes API calls fail
31pub async fn calculate_expected_instance_counts(
32 client: &Client,
33 instance_refs: &[InstanceReference],
34) -> Result<(usize, usize)> {
35 let expected_primary_count = super::primary::filter_primary_instances(client, instance_refs)
36 .await
37 .map(|refs| refs.len())
38 .unwrap_or(0);
39
40 let expected_secondary_count =
41 super::secondary::filter_secondary_instances(client, instance_refs)
42 .await
43 .map(|refs| refs.len())
44 .unwrap_or(0);
45
46 Ok((expected_primary_count, expected_secondary_count))
47}
48
49/// Set the final zone conditions in memory (no API call).
50///
51/// This function calculates the final Ready/Degraded/Progressing status based on:
52/// - Whether any degraded conditions were set during reconciliation
53/// - Whether all expected INSTANCES were successfully configured (comparing
54/// instance counts with instance counts - never endpoint counts, which would
55/// mask partial pod failures)
56/// - Number of records discovered
57///
58/// The conditions always converge to a consistent triple:
59/// - Success: `Ready=True`, `Degraded=False`, `Progressing=False`
60/// - Failure/partial: `Ready=False`, `Degraded=True`, `Progressing=False`
61///
62/// # Arguments
63///
64/// * `status_updater` - Status updater with accumulated changes
65/// * `zone_name` - DNS zone name (e.g., "example.com")
66/// * `namespace` - Kubernetes namespace of the DNSZone resource
67/// * `name` - Name of the DNSZone resource
68/// * `primary` - Primary configuration outcome (instance + endpoint counts)
69/// * `secondary` - Secondary configuration outcome (instance + endpoint counts)
70/// * `expected_primary_count` - Expected number of primary instances
71/// * `expected_secondary_count` - Expected number of secondary instances
72/// * `records_count` - Number of DNS records discovered
73/// * `generation` - Metadata generation to set as observed
74#[allow(clippy::too_many_arguments)]
75pub fn set_final_zone_conditions(
76 status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
77 zone_name: &str,
78 namespace: &str,
79 name: &str,
80 primary: super::types::ZoneConfigOutcome,
81 secondary: super::types::ZoneConfigOutcome,
82 expected_primary_count: usize,
83 expected_secondary_count: usize,
84 records_count: usize,
85 generation: Option<i64>,
86) {
87 // Set observed generation
88 status_updater.set_observed_generation(generation);
89
90 // Set final Ready/Degraded status based on reconciliation outcome
91 // Only set Ready=True if there were NO degraded conditions during reconciliation
92 // AND all expected instances were successfully configured
93 if status_updater.has_degraded_condition() {
94 // Keep the Degraded condition that was already set, but make the
95 // condition triple consistent: a stale Ready=True from a previous
96 // successful reconciliation must not survive a failure.
97 status_updater.set_condition(
98 "Ready",
99 "False",
100 "ReconcileDegraded",
101 &format!("Zone {zone_name} reconciliation completed with degraded state - see Degraded condition for details"),
102 );
103 tracing::info!(
104 "DNSZone {}/{} reconciliation completed with degraded state - will retry faster",
105 namespace,
106 name
107 );
108 } else if primary.instances_configured < expected_primary_count
109 || secondary.instances_configured < expected_secondary_count
110 {
111 // Not all INSTANCES were configured - set Degraded and Ready=False.
112 // Comparing instance counts (not endpoint counts) ensures an instance
113 // that received the zone on none of its pods is not masked by another
114 // instance with multiple successful pod endpoints.
115 let message = format!(
116 "Zone {} configured on {}/{} primary and {}/{} secondary instance(s) - {} instance(s) pending",
117 zone_name,
118 primary.instances_configured,
119 expected_primary_count,
120 secondary.instances_configured,
121 expected_secondary_count,
122 expected_primary_count.saturating_sub(primary.instances_configured)
123 + expected_secondary_count.saturating_sub(secondary.instances_configured)
124 );
125 status_updater.set_condition("Degraded", "True", "PartialReconciliation", &message);
126 status_updater.set_condition("Ready", "False", "PartialReconciliation", &message);
127 tracing::info!(
128 "DNSZone {}/{} partially configured: {}/{} primaries, {}/{} secondaries",
129 namespace,
130 name,
131 primary.instances_configured,
132 expected_primary_count,
133 secondary.instances_configured,
134 expected_secondary_count
135 );
136 } else {
137 // All reconciliation steps succeeded - set Ready status and clear any stale Degraded condition
138 status_updater.set_condition(
139 "Ready",
140 "True",
141 "ReconcileSucceeded",
142 &format!(
143 "Zone {} configured on {} primary and {} secondary instance(s) ({} endpoint(s)), discovered {} DNS record(s)",
144 zone_name,
145 primary.instances_configured,
146 secondary.instances_configured,
147 primary.endpoints_configured + secondary.endpoints_configured,
148 records_count
149 ),
150 );
151 // Clear any stale Degraded condition from previous failures
152 status_updater.clear_degraded_condition();
153 }
154
155 // The reconciliation attempt has finished either way - resolve the
156 // Progressing condition set at the start of BIND9 configuration so it
157 // does not stay True forever.
158 status_updater.set_condition(
159 "Progressing",
160 "False",
161 "ReconcileComplete",
162 "Reconciliation attempt finished",
163 );
164}
165
166/// Determine final zone status and apply conditions.
167///
168/// Sets the final condition triple via [`set_final_zone_conditions`] and then
169/// applies all accumulated status changes to the API server in a single
170/// atomic operation.
171///
172/// # Arguments
173///
174/// * `status_updater` - Status updater with accumulated changes
175/// * `client` - Kubernetes API client
176/// * `zone_name` - DNS zone name (e.g., "example.com")
177/// * `namespace` - Kubernetes namespace of the DNSZone resource
178/// * `name` - Name of the DNSZone resource
179/// * `primary` - Primary configuration outcome (instance + endpoint counts)
180/// * `secondary` - Secondary configuration outcome (instance + endpoint counts)
181/// * `expected_primary_count` - Expected number of primary instances
182/// * `expected_secondary_count` - Expected number of secondary instances
183/// * `records_count` - Number of DNS records discovered
184/// * `generation` - Metadata generation to set as observed
185///
186/// # Errors
187///
188/// Returns an error if status update fails to apply
189#[allow(clippy::too_many_arguments)]
190pub async fn finalize_zone_status(
191 status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
192 client: &Client,
193 zone_name: &str,
194 namespace: &str,
195 name: &str,
196 primary: super::types::ZoneConfigOutcome,
197 secondary: super::types::ZoneConfigOutcome,
198 expected_primary_count: usize,
199 expected_secondary_count: usize,
200 records_count: usize,
201 generation: Option<i64>,
202) -> Result<()> {
203 set_final_zone_conditions(
204 status_updater,
205 zone_name,
206 namespace,
207 name,
208 primary,
209 secondary,
210 expected_primary_count,
211 expected_secondary_count,
212 records_count,
213 generation,
214 );
215
216 // Apply all status changes in a single atomic operation
217 status_updater.apply(client).await?;
218
219 Ok(())
220}
221
222#[cfg(test)]
223#[path = "status_helpers_tests.rs"]
224mod status_helpers_tests;