Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .claude/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,57 @@
## [2026-10-03 16:30] - Config changes reach running BIND pods (operator upgrades included)

**Author:** Erick Bourgeois

### Fixed
- `src/reconcilers/bind9instance/resources.rs`: the instance reconciler now
keeps the ConfigMap its pods mount current by **content**, not by the
`Bind9Cluster` generation. `desired_configmap_for_instance` renders the
cluster's shared `<cluster>-config` (exactly as the cluster reconciler does)
or a standalone instance's own `<name>-config`, and it is written only when
its data differs. Before, the shared ConfigMap was re-rendered only when the
cluster's spec generation changed and that reconcile won a race, so an
operator upgrade that renders the same spec differently (e.g. the
`key-directory` fix) never reached a running cluster; a generation bump and
deleting the ConfigMap did not reliably repair it either.
- `src/bind9_resources.rs` (`CONFIG_HASH_ANNOTATION`,
`configmap_data_hash`, `stamp_config_hash`) and the Deployment update path:
the pod template carries `bindy.firestoned.io/config-hash`, the sha256 of
the mounted ConfigMap's data. A changed hash makes `deployment_needs_update`
true and is patched onto the template, which rolls the pods. BIND reads
`named.conf` only at start, so before, even a rewritten ConfigMap ran only
after an unrelated restart.

- `src/reconcilers/bind9instance/mod.rs`, `resources.rs`
(`config_drifted`): config drift is a reason to run the instance's
resource step. That step was skipped unless the generation changed,
something was missing, labels or the parent generation changed, or a key
rotation was due, so the content-based write above never ran after an
upgrade. Drift is: the mounted ConfigMap's data differs from what this
operator renders, or the Deployment's pods carry another config hash. A
render error counts as drift, so the reconcile runs and reports it.

### Tests
- `reconcilers/bind9instance/resources_tests.rs` (`config_rollout`): drift
from stale content, from a missing or older hash, and none when nothing is
generated; hash
determinism and sensitivity (a byte moving between files counts),
managed instance wants the cluster's shared ConfigMap with the cluster
reconciler's exact data, standalone wants its own, custom `configMapRefs`
leave nothing to write, and a changed hash rolls the Deployment while an
unchanged one does not.

### Known, not changed here
- `deployment_needs_update` still ignores the **bind9** container's image, so
a BIND version change alone does not roll the pods.
- Instances sharing one cluster ConfigMap roll together when it changes.

### Impact
- [ ] Breaking change
- [x] Requires cluster rollout (every BIND pod rolls once, as the annotation
is added)
- [ ] Config change only
- [ ] Documentation only

## [2026-10-03 14:30] - DNSSEC signing actually signs: reserved policy names, inheritance, existing zones, key directory

**Author:** Erick Bourgeois
Expand Down
54 changes: 54 additions & 0 deletions src/bind9_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,60 @@ pub(crate) fn build_dnssec_key_volumes(
(volumes, volume_mounts)
}

/// Pod-template annotation holding [`configmap_data_hash`] of the BIND
/// configuration the pod mounts. BIND reads `named.conf` only at start, so a
/// changed ConfigMap reaches it only through a new pod; changing this
/// annotation is what rolls the Deployment. It covers changes from a spec
/// edit and from an operator upgrade that renders the same spec differently.
pub const CONFIG_HASH_ANNOTATION: &str = "bindy.firestoned.io/config-hash";

/// Lowercase hex sha256 over a ConfigMap's `data`, key and value pairs in key
/// order, each NUL-terminated so a byte moving from one file to the next is a
/// different config.
///
/// # Arguments
///
/// * `configmap` - The ConfigMap whose content is hashed
///
/// # Returns
///
/// The digest, 64 hex characters.
#[must_use]
pub fn configmap_data_hash(configmap: &ConfigMap) -> String {
use sha2::{Digest, Sha256};

let mut hasher = Sha256::new();
for (key, value) in configmap.data.iter().flatten() {
hasher.update(key.as_bytes());
hasher.update([0u8]);
hasher.update(value.as_bytes());
hasher.update([0u8]);
}
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}

/// Set [`CONFIG_HASH_ANNOTATION`] on a Deployment's pod template.
///
/// # Arguments
///
/// * `deployment` - The Deployment to stamp
/// * `hash` - The hash of the ConfigMap its pods mount
pub fn stamp_config_hash(deployment: &mut Deployment, hash: &str) {
let Some(spec) = deployment.spec.as_mut() else {
return;
};
spec.template
.metadata
.get_or_insert_with(Default::default)
.annotations
.get_or_insert_with(Default::default)
.insert(CONFIG_HASH_ANNOTATION.to_string(), hash.to_string());
}

/// Builds standardized Kubernetes labels for BIND9 instance resources.
///
/// Creates labels for resources managed by `Bind9Instance` controller.
Expand Down
41 changes: 34 additions & 7 deletions src/reconcilers/bind9instance/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,19 +392,20 @@ pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance)
}

// Check if ALL required resources actually exist AND match desired state (drift detection)
let (all_resources_exist, deployment_labels_match, rotation_needed) = {
let (all_resources_exist, deployment_labels_match, rotation_needed, config_drifted) = {
let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
let secret_api: Api<Secret> = Api::namespaced(client.clone(), &namespace);

// Fetch deployment to check if it exists AND if OUR labels match
let (deployment_exists, labels_match) = match deployment_api.get(&name).await {
Ok(deployment) => (
let current_deployment = deployment_api.get(&name).await.ok();
let (deployment_exists, labels_match) = match current_deployment.as_ref() {
Some(deployment) => (
true,
deployment_labels_are_current(&deployment, &name, &instance),
deployment_labels_are_current(deployment, &name, &instance),
),
Err(_) => (false, false),
None => (false, false),
};

let service_exists = service_api.get(&name).await.is_ok();
Expand All @@ -415,7 +416,25 @@ pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance)
} else {
format!("{name}-config")
};
let configmap_exists = configmap_api.get(&configmap_name).await.is_ok();
let current_configmap = configmap_api.get(&configmap_name).await.ok();
let configmap_exists = current_configmap.is_some();

// Config drift: the mounted config is behind what this operator renders
// (e.g. after an upgrade), or the pods were never rolled onto it. A
// render error counts as drift so the reconcile runs and reports it.
let config_drifted = match resources::desired_configmap_for_instance(
&name,
&namespace,
&instance,
cluster.as_ref(),
) {
Ok(desired) => resources::config_drifted(
current_configmap.as_ref(),
current_deployment.as_ref(),
desired.as_ref(),
),
Err(_) => true,
};

// Check Secret existence AND rotation status
let secret_name = format!("{name}-rndc-key");
Expand Down Expand Up @@ -445,7 +464,7 @@ pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance)
};

let all_exist = deployment_exists && service_exists && configmap_exists && secret_exists;
(all_exist, labels_match, needs_rotation)
(all_exist, labels_match, needs_rotation, config_drifted)
};
let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());

Expand Down Expand Up @@ -479,6 +498,7 @@ pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance)
&& deployment_labels_match
&& !rotation_needed
&& !parent_config_changed
&& !config_drifted
{
debug!(
"Spec unchanged (generation={:?}), all resources exist, deployment labels match, no rotation needed, and parent config unchanged - skipping resource reconciliation",
Expand Down Expand Up @@ -509,6 +529,13 @@ pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance)
// - Deployment labels don't match desired state (drift), OR
// - RNDC Secret rotation is due, OR
// - Parent cluster configuration has changed
if config_drifted && all_resources_exist {
info!(
"BIND config for {}/{} is behind what this operator renders, updating it and rolling the pods",
namespace, name
);
}

if !deployment_labels_match && all_resources_exist {
info!(
"Deployment labels don't match desired state for {}/{}, triggering reconciliation to update labels",
Expand Down
Loading
Loading