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;