Skip to content

Commit 37f03d7

Browse files
authored
fix: Re-emit existing discovery CM if listener is not ready (#1105)
* re-emit existing config map if listener addresses are not ready * drop unused Default * add discovery_config_map_name() helper * invalid port test * warn on port-not-found * only re-emit explicit keys, not overrides * add owner check via uid * while paused, skip a discovery ConfigMap that does not exist yet * updated doc comments * add test step to check role listener recreation * changelog * move config map check from build to dereference * use struct for config map fields
1 parent ecc294c commit 37f03d7

12 files changed

Lines changed: 728 additions & 182 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ All notable changes to this project will be documented in this file.
2828
Previously a ZookeeperCluster without it was accepted by the API server but failed reconciliation ([#1060]).
2929
- The reconciler now applies resources and derives the cluster status in discrete
3030
apply and update_status steps for the `zk_controller` and `znode_controller` ([#1069]).
31-
- The discovery ConfigMap is now always written, with empty `ZOOKEEPER` and `ZOOKEEPER_HOSTS` while
32-
the listener publishes no addresses ([#1069]).
3331
- All product containers now run with `securityContext.runAsNonRoot` set to `true` to improve security ([#1070]).
3432
- Remove the `app.kubernetes.io/component` and `app.kubernetes.io/role-group` labels from the
3533
resources they don't apply to (previously set to `none` or the placeholder value `discovery`)
@@ -43,6 +41,8 @@ All notable changes to this project will be documented in this file.
4341
- All pods now set `securityContext.seccompProfile.type` to `RuntimeDefault` ([#1097]).
4442
- Replace the unmaintained `serde_yaml` with `yaml_serde` ([#1097]).
4543
- The operator Deployment runs with a restricted security context and a read-only root filesystem, so it can be deployed into namespaces that enforce the `restricted` Pod Security Standard ([#1101]).
44+
- An existing discovery ConfigMap is kept unchanged while the role listener has no address and a new one is only created once the address is available.
45+
Previously, reconciliation failed while the address was missing ([#1069], [#1105]).
4646

4747
### Fixed
4848

@@ -74,6 +74,7 @@ All notable changes to this project will be documented in this file.
7474
[#1100]: https://github.com/stackabletech/zookeeper-operator/pull/1100
7575
[#1101]: https://github.com/stackabletech/zookeeper-operator/pull/1101
7676
[#1102]: https://github.com/stackabletech/zookeeper-operator/pull/1102
77+
[#1105]: https://github.com/stackabletech/zookeeper-operator/pull/1105
7778
[#1106]: https://github.com/stackabletech/zookeeper-operator/pull/1106
7879

7980
## [26.7.0] - 2026-07-21

‎rust/operator-binary/src/discovery.rs‎

Lines changed: 390 additions & 99 deletions
Large diffs are not rendered by default.

‎rust/operator-binary/src/listener_addresses.rs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ type Result<T, E = Error> = std::result::Result<T, E>;
3030
///
3131
/// An address is a hostname or IP address of a node, a cluster IP or an external load balancer,
3232
/// depending on the Service type behind the Listener.
33-
///
34-
/// The [`Default`] value is empty, which renders as an empty connection string, see
35-
/// [`build_discovery_configmap`](crate::discovery::build_discovery_configmap).
36-
#[derive(Clone, Debug, Default, PartialEq, Eq)]
33+
#[derive(Clone, Debug, PartialEq, Eq)]
3734
pub struct ListenerAddresses(BTreeSet<(String, u16)>);
3835

3936
impl ListenerAddresses {
@@ -50,8 +47,9 @@ impl ListenerAddresses {
5047

5148
/// Reads the addresses that `listener` publishes for `port_name`.
5249
///
53-
/// Returns `Ok(None)` while the Listener carries no ingress addresses at all, which is the normal
54-
/// state between creating the Listener and the listener operator publishing its addresses. A
50+
/// Returns `Ok(None)` while the Listener carries no ingress addresses at all
51+
/// (no status yet, or an empty address list), which is the normal state between
52+
/// creating the Listener and the listener operator publishing its addresses. A
5553
/// Listener that does publish addresses, but none for `port_name`, is an error.
5654
// TODO (@NickLarsenNZ): Move this to stackable-operator, so it can be used as
5755
// listener.addresses_for_port(port_name)
@@ -67,6 +65,12 @@ pub fn listener_addresses(
6765
return Ok(None);
6866
};
6967

68+
// The listener operator publishes an empty list until it knows an address, e.g. before the
69+
// Pods behind a NodePort Listener are scheduled. That is "not ready yet", not a missing port.
70+
if ingress_addresses.is_empty() {
71+
return Ok(None);
72+
}
73+
7074
let address_port_pairs = ingress_addresses
7175
.iter()
7276
// Filter the addresses that have the port we are interested in (they likely all have it though)
@@ -163,6 +167,15 @@ mod tests {
163167
);
164168
}
165169

170+
#[test]
171+
fn listener_addresses_with_empty_ingress_list_is_not_ready_yet() {
172+
assert_eq!(
173+
listener_addresses(&role_listener(Some(vec![])), ZOOKEEPER_SERVER_PORT_NAME)
174+
.expect("addresses"),
175+
None
176+
);
177+
}
178+
166179
#[test]
167180
fn listener_addresses_missing_port_name_is_error() {
168181
let listener = role_listener(Some(vec![ingress(2181)]));

‎rust/operator-binary/src/zk_controller.rs‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ pub struct KubernetesResources<T> {
115115
pub listeners: Vec<Listener>,
116116
pub config_maps: Vec<ConfigMap>,
117117
/// The discovery `ConfigMap`, kept apart from the role group `config_maps` because the cluster
118-
/// status carries a hash of it.
119-
pub discovery_config_map: ConfigMap,
118+
/// status carries a hash of it. `None` while none can be built or re-emitted.
119+
pub discovery_config_map: Option<ConfigMap>,
120120
pub pod_disruption_budgets: Vec<PodDisruptionBudget>,
121121
pub service_accounts: Vec<ServiceAccount>,
122122
pub role_bindings: Vec<RoleBinding>,
@@ -190,7 +190,7 @@ pub fn error_policy(
190190
pub(crate) mod test_support {
191191
use stackable_operator::{
192192
cli::OperatorEnvironmentOptions, commons::networking::DomainName, crd::listener,
193-
utils::cluster_info::KubernetesClusterInfo,
193+
k8s_openapi::api::core::v1::ConfigMap, utils::cluster_info::KubernetesClusterInfo,
194194
};
195195

196196
use crate::{
@@ -201,6 +201,9 @@ pub(crate) mod test_support {
201201
},
202202
};
203203

204+
/// The UID that `minimal_zk` gives the fixture cluster.
205+
pub const CLUSTER_UID: &str = "c27b3971-ca72-42c1-80a4-abdfc1db0ddd";
206+
204207
/// The expected `app.kubernetes.io/version` label value for the given product version.
205208
///
206209
/// The `-stackable` suffix carries the operator's own version, which is `0.0.0-dev` on main
@@ -225,7 +228,7 @@ pub(crate) mod test_support {
225228
.get_or_insert_with(|| "default".to_owned());
226229
zk.metadata
227230
.uid
228-
.get_or_insert_with(|| "c27b3971-ca72-42c1-80a4-abdfc1db0ddd".to_owned());
231+
.get_or_insert_with(|| CLUSTER_UID.to_owned());
229232
zk
230233
}
231234

@@ -248,20 +251,22 @@ pub(crate) mod test_support {
248251
pub fn try_validate(
249252
zk: &v1alpha1::ZookeeperCluster,
250253
) -> Result<ValidatedCluster, super::validate::Error> {
251-
try_validate_with_role_listener(zk, None)
254+
try_validate_with_role_listener(zk, None, None)
252255
}
253256

254257
/// Runs the real validate step against a minimal (auth-free) fixture and the given role
255258
/// Listener, returning the result so tests can assert on validation errors.
256259
pub fn try_validate_with_role_listener(
257260
zk: &v1alpha1::ZookeeperCluster,
258261
maybe_role_listener: Option<listener::v1alpha1::Listener>,
262+
existing_discovery_config_map: Option<ConfigMap>,
259263
) -> Result<ValidatedCluster, super::validate::Error> {
260264
validate(
261265
zk,
262266
&DereferencedObjects {
263267
authentication_classes: DereferencedAuthenticationClasses::new_for_tests(),
264268
maybe_role_listener,
269+
existing_discovery_config_map,
265270
},
266271
&operator_environment(),
267272
)

‎rust/operator-binary/src/zk_controller/apply.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ impl<'a> Applier<'a> {
9595
let services = self.add_resources(services).await?;
9696
let listeners = self.add_resources(listeners).await?;
9797
let config_maps = self.add_resources(config_maps).await?;
98-
let discovery_config_map = self.add_resource(discovery_config_map).await?;
98+
let discovery_config_map = match discovery_config_map {
99+
Some(config_map) => Some(self.add_resource(config_map).await?),
100+
None => None,
101+
};
99102
let pod_disruption_budgets = self.add_resources(pod_disruption_budgets).await?;
100103
let stateful_sets = self.add_resources(stateful_sets).await?;
101104

‎rust/operator-binary/src/zk_controller/build.rs‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ pub enum Error {
8181
/// [`Listener`](stackable_operator::crd::listener::v1alpha1::Listener) that were dereferenced and
8282
/// validated into
8383
/// [`ValidatedCluster::discovery_addresses`](ValidatedCluster#structfield.discovery_addresses); see
84-
/// [`build_discovery_configmap`](discovery::build_discovery_configmap) for how its content depends
85-
/// on them.
84+
/// [`build_discovery_configmap`](discovery::build_discovery_configmap) for when it is built fresh,
85+
/// re-emitted or skipped.
8686
pub fn build(
8787
cluster: &ValidatedCluster,
8888
cluster_info: &KubernetesClusterInfo,
@@ -139,8 +139,7 @@ pub fn build(
139139
let listeners = vec![build_role_listener(cluster, &zk_role)];
140140

141141
let discovery_config_map =
142-
discovery::build_discovery_configmap(cluster, &zk_role, &cluster.discovery_addresses)
143-
.context(DiscoveryConfigMapSnafu)?;
142+
discovery::build_discovery_configmap(cluster, &zk_role).context(DiscoveryConfigMapSnafu)?;
144143

145144
Ok(KubernetesResources {
146145
stateful_sets,
@@ -321,12 +320,9 @@ mod tests {
321320
"simple-zookeeper-server-secondary",
322321
]
323322
);
324-
// The discovery ConfigMap is named after the cluster, and written even though the fixture
325-
// has no role Listener publishing addresses yet (see `build()`).
326-
assert_eq!(
327-
resources.discovery_config_map.meta().name.as_deref(),
328-
Some("simple-zookeeper")
329-
);
323+
// The fixture's role Listener does not exist and no discovery
324+
// ConfigMap is stored, so none is built yet.
325+
assert!(resources.discovery_config_map.is_none());
330326
// The single role-level Listener for the one ZooKeeper role (`server`).
331327
assert_eq!(
332328
sorted_names(&resources.listeners),

‎rust/operator-binary/src/zk_controller/build/resource/rbac.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ mod tests {
3333
use serde_json::json;
3434

3535
use super::*;
36-
use crate::zk_controller::test_support::{app_version_label, minimal_zk, validated_cluster};
36+
use crate::zk_controller::test_support::{
37+
CLUSTER_UID, app_version_label, minimal_zk, validated_cluster,
38+
};
3739

3840
// `simple-zookeeper` vs `zookeeper`: see the swap-guard note on `minimal_zk`.
3941
fn cluster() -> ValidatedCluster {
@@ -81,7 +83,7 @@ mod tests {
8183
"controller": true,
8284
"kind": "ZookeeperCluster",
8385
"name": "simple-zookeeper",
84-
"uid": "c27b3971-ca72-42c1-80a4-abdfc1db0ddd"
86+
"uid": CLUSTER_UID.to_owned()
8587
}
8688
]
8789
}
@@ -114,7 +116,7 @@ mod tests {
114116
"controller": true,
115117
"kind": "ZookeeperCluster",
116118
"name": "simple-zookeeper",
117-
"uid": "c27b3971-ca72-42c1-80a4-abdfc1db0ddd"
119+
"uid": CLUSTER_UID.to_owned()
118120
}
119121
]
120122
},

‎rust/operator-binary/src/zk_controller/build/resource/service.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ mod tests {
129129
use super::*;
130130
use crate::{
131131
crd::ZookeeperRole,
132-
zk_controller::test_support::{app_version_label, minimal_zk, validated_cluster},
132+
zk_controller::test_support::{
133+
CLUSTER_UID, app_version_label, minimal_zk, validated_cluster,
134+
},
133135
};
134136

135137
/// Every metrics Service must carry the Prometheus scrape label and the
@@ -193,7 +195,7 @@ mod tests {
193195
"controller": true,
194196
"kind": "ZookeeperCluster",
195197
"name": "simple-zookeeper",
196-
"uid": "c27b3971-ca72-42c1-80a4-abdfc1db0ddd"
198+
"uid": CLUSTER_UID.to_owned()
197199
}
198200
]
199201
},

‎rust/operator-binary/src/zk_controller/dereference.rs‎

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,23 @@ use snafu::{ResultExt, Snafu};
99
use stackable_operator::{
1010
client::Client,
1111
crd::listener,
12+
k8s_openapi::api::core::v1::ConfigMap,
1213
v2::{
13-
controller_utils::{get_cluster_name, get_namespace},
14-
types::{kubernetes::NamespaceName, operator::ClusterName},
14+
controller_utils::{get_cluster_name, get_namespace, get_uid},
15+
types::{
16+
kubernetes::{NamespaceName, Uid},
17+
operator::ClusterName,
18+
},
1519
},
1620
};
1721

18-
use crate::crd::{
19-
ZookeeperRole,
20-
authentication::{self, DereferencedAuthenticationClasses},
21-
role_listener_name, v1alpha1,
22+
use crate::{
23+
crd::{
24+
ZookeeperRole,
25+
authentication::{self, DereferencedAuthenticationClasses},
26+
role_listener_name, v1alpha1,
27+
},
28+
discovery::discovery_config_map_name,
2229
};
2330

2431
#[derive(Snafu, Debug)]
@@ -40,6 +47,17 @@ pub enum Error {
4047
FetchRoleListener {
4148
source: stackable_operator::client::Error,
4249
},
50+
51+
#[snafu(display("failed to fetch the discovery ConfigMap {config_map}"))]
52+
FetchExistingDiscoveryConfigMap {
53+
source: stackable_operator::client::Error,
54+
config_map: String,
55+
},
56+
57+
#[snafu(display("failed to get the cluster UID"))]
58+
GetUid {
59+
source: stackable_operator::v2::controller_utils::Error,
60+
},
4361
}
4462

4563
type Result<T, E = Error> = std::result::Result<T, E>;
@@ -55,9 +73,15 @@ pub struct DereferencedObjects {
5573
/// this object, so it can only be built once the Listener exists and carries them. The
5674
/// controller watches Listeners, so a reconciliation is triggered as soon as that happens.
5775
pub maybe_role_listener: Option<listener::v1alpha1::Listener>,
76+
77+
/// The discovery ConfigMap as stored by an earlier reconciliation (named by
78+
/// [`discovery_config_map_name`]), or `None` while none owned by this cluster is stored.
79+
/// The build step re-emits it while the role Listener has no usable address.
80+
pub existing_discovery_config_map: Option<ConfigMap>,
5881
}
5982

60-
/// Fetches all Kubernetes objects referenced from the [`v1alpha1::ZookeeperCluster`] spec.
83+
/// Fetches all Kubernetes objects referenced from the [`v1alpha1::ZookeeperCluster`] spec
84+
/// (only considering a ConfigMap owned by this cluster).
6185
pub async fn dereference(
6286
client: &Client,
6387
zk: &v1alpha1::ZookeeperCluster,
@@ -73,10 +97,21 @@ pub async fn dereference(
7397
.context(FetchAuthenticationClassesSnafu)?;
7498

7599
let maybe_role_listener = fetch_role_listener(client, &cluster_name, &namespace).await?;
100+
let uid = get_uid(zk).context(GetUidSnafu)?;
101+
102+
let discovery_config_map_name = discovery_config_map_name(&cluster_name);
103+
let existing_discovery_config_map = client
104+
.get_opt::<ConfigMap>(discovery_config_map_name.as_ref(), namespace.as_ref())
105+
.await
106+
.context(FetchExistingDiscoveryConfigMapSnafu {
107+
config_map: discovery_config_map_name.as_ref(),
108+
})?
109+
.filter(|config_map| is_owned_by(config_map, &uid));
76110

77111
Ok(DereferencedObjects {
78112
authentication_classes,
79113
maybe_role_listener,
114+
existing_discovery_config_map,
80115
})
81116
}
82117

@@ -92,3 +127,72 @@ async fn fetch_role_listener(
92127
.await
93128
.context(FetchRoleListenerSnafu)
94129
}
130+
131+
/// Whether `config_map` is owned by the object with the given UID.
132+
///
133+
/// A ConfigMap of the same name left behind by a deleted cluster (e.g. after
134+
/// `kubectl delete --cascade=orphan`) must not be re-emitted for a new cluster.
135+
fn is_owned_by(config_map: &ConfigMap, uid: &Uid) -> bool {
136+
config_map
137+
.metadata
138+
.owner_references
139+
.iter()
140+
.flatten()
141+
.any(|owner_reference| owner_reference.uid == uid.to_string())
142+
}
143+
144+
#[cfg(test)]
145+
mod tests {
146+
use std::str::FromStr;
147+
148+
use stackable_operator::k8s_openapi::apimachinery::pkg::apis::meta::v1::{
149+
ObjectMeta, OwnerReference,
150+
};
151+
152+
use super::*;
153+
use crate::zk_controller::test_support::CLUSTER_UID;
154+
155+
fn config_map_owned_by(owner_uids: &[&str]) -> ConfigMap {
156+
ConfigMap {
157+
metadata: ObjectMeta {
158+
owner_references: Some(
159+
owner_uids
160+
.iter()
161+
.map(|uid| OwnerReference {
162+
uid: (*uid).to_owned(),
163+
..OwnerReference::default()
164+
})
165+
.collect(),
166+
),
167+
..ObjectMeta::default()
168+
},
169+
..ConfigMap::default()
170+
}
171+
}
172+
173+
fn cluster_uid() -> Uid {
174+
Uid::from_str(CLUSTER_UID).expect("valid UID")
175+
}
176+
177+
#[test]
178+
fn config_map_owned_by_this_cluster_is_considered() {
179+
assert!(is_owned_by(
180+
&config_map_owned_by(&[CLUSTER_UID]),
181+
&cluster_uid()
182+
));
183+
}
184+
185+
/// E.g. left behind by a deleted cluster of the same name.
186+
#[test]
187+
fn config_map_owned_by_another_object_is_ignored() {
188+
assert!(!is_owned_by(
189+
&config_map_owned_by(&["00000000-0000-0000-0000-000000000000"]),
190+
&cluster_uid()
191+
));
192+
}
193+
194+
#[test]
195+
fn config_map_without_owner_references_is_ignored() {
196+
assert!(!is_owned_by(&ConfigMap::default(), &cluster_uid()));
197+
}
198+
}

0 commit comments

Comments
 (0)