Skip to content

Commit abb40b2

Browse files
maltesandersiegfriedweberadwk67
authored
refactor: Introduce build aggregator (#801)
* refactor: Decouple StatefulSet builder from applied ServiceAccount * refactor: Introduce build aggregator * changelog --------- Co-authored-by: Siegfried Weber <mail@siegfriedweber.net> Co-authored-by: Andrew Kenworthy <andrew.kenworthy@stackable.tech>
1 parent 595b846 commit abb40b2

5 files changed

Lines changed: 263 additions & 123 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
- Internal operator refactoring: introduce a build() step in the reconciler that
10+
assembles all relevant Kubernetes resources before anything is applied ([#801]).
11+
12+
[#801]: https://github.com/stackabletech/hdfs-operator/pull/801
13+
714
## [26.7.0] - 2026-07-21
815

916
## [26.7.0-rc1] - 2026-07-16

rust/operator-binary/src/controller/build/mod.rs

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use std::{collections::HashMap, str::FromStr};
22

3+
use snafu::{ResultExt, Snafu};
34
use stackable_operator::{
45
builder::meta::ObjectMetaBuilder,
56
kvp::{LabelError, Labels},
7+
utils::cluster_info::KubernetesClusterInfo,
68
v2::{
79
builder::meta::ownerreference_from_resource,
810
types::{common::Port, kubernetes::ServiceName, operator::RoleGroupName},
@@ -11,7 +13,7 @@ use stackable_operator::{
1113

1214
use crate::{
1315
build_recommended_labels,
14-
controller::ValidatedCluster,
16+
controller::{KubernetesResources, ValidatedCluster},
1517
crd::{
1618
HdfsNodeRole, HdfsPodRef,
1719
constants::{
@@ -41,6 +43,111 @@ pub mod opa;
4143
pub mod properties;
4244
pub mod resource;
4345

46+
#[derive(Snafu, Debug)]
47+
pub enum Error {
48+
#[snafu(display("failed to build Service for role {role} role group {role_group}"))]
49+
Service {
50+
source: resource::service::Error,
51+
role: HdfsNodeRole,
52+
role_group: RoleGroupName,
53+
},
54+
55+
#[snafu(display("failed to build ConfigMap for role {role} role group {role_group}"))]
56+
ConfigMap {
57+
source: resource::config_map::Error,
58+
role: HdfsNodeRole,
59+
role_group: RoleGroupName,
60+
},
61+
62+
#[snafu(display("failed to build StatefulSet for role {role} role group {role_group}"))]
63+
StatefulSet {
64+
source: resource::statefulset::Error,
65+
role: HdfsNodeRole,
66+
role_group: RoleGroupName,
67+
},
68+
}
69+
70+
/// Builds every Kubernetes resource for the given validated cluster.
71+
///
72+
/// Does not need a Kubernetes client: every external reference is already dereferenced and
73+
/// validated by this point, so the errors returned here are resource-assembly failures only.
74+
/// `cluster_info` carries static cluster information resolved at operator startup (e.g. the
75+
/// cluster domain used to build Kerberos principals), not a live client.
76+
///
77+
/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under.
78+
/// The RBAC resources are built and applied separately in the reconcile step.
79+
///
80+
/// The resources are returned as flat, unordered collections. The reconcile step re-groups the
81+
/// StatefulSets by role to preserve HDFS's ordered, rollout-gated deployment during upgrades. The
82+
/// discovery `ConfigMap` is deliberately not built here: it needs a live client to resolve
83+
/// listener addresses and is therefore handled in the reconcile step.
84+
pub fn build(
85+
cluster: &ValidatedCluster,
86+
cluster_info: &KubernetesClusterInfo,
87+
service_account_name: &str,
88+
) -> Result<KubernetesResources, Error> {
89+
let mut services = vec![];
90+
let mut config_maps = vec![];
91+
let mut stateful_sets = vec![];
92+
let mut pod_disruption_budgets = vec![];
93+
94+
for (role, role_group_configs) in &cluster.role_groups {
95+
for (role_group_name, rg_config) in role_group_configs {
96+
services.push(
97+
resource::service::rolegroup_headless_service(cluster, role, role_group_name)
98+
.context(ServiceSnafu {
99+
role: *role,
100+
role_group: role_group_name.clone(),
101+
})?,
102+
);
103+
services.push(
104+
resource::service::rolegroup_metrics_service(cluster, role, role_group_name)
105+
.context(ServiceSnafu {
106+
role: *role,
107+
role_group: role_group_name.clone(),
108+
})?,
109+
);
110+
config_maps.push(
111+
resource::config_map::build_rolegroup_config_map(
112+
cluster,
113+
cluster_info,
114+
role,
115+
role_group_name,
116+
)
117+
.context(ConfigMapSnafu {
118+
role: *role,
119+
role_group: role_group_name.clone(),
120+
})?,
121+
);
122+
stateful_sets.push(
123+
resource::statefulset::build_rolegroup_statefulset(
124+
cluster,
125+
cluster_info,
126+
role,
127+
role_group_name,
128+
rg_config,
129+
service_account_name,
130+
)
131+
.context(StatefulSetSnafu {
132+
role: *role,
133+
role_group: role_group_name.clone(),
134+
})?,
135+
);
136+
}
137+
138+
if let Some(pdb) = resource::pdb::build_pdb(cluster, role) {
139+
pod_disruption_budgets.push(pdb);
140+
}
141+
}
142+
143+
Ok(KubernetesResources {
144+
services,
145+
config_maps,
146+
pod_disruption_budgets,
147+
stateful_sets,
148+
})
149+
}
150+
44151
/// Builds the [`HdfsPodRef`]s expected for every pod of the given `role`, across all
45152
/// of its role groups.
46153
///
@@ -263,3 +370,55 @@ fn role_data_ports(role: &HdfsNodeRole, https_enabled: bool) -> Vec<(String, Por
263370
],
264371
}
265372
}
373+
374+
#[cfg(test)]
375+
mod tests {
376+
use stackable_operator::kube::Resource;
377+
378+
use super::build;
379+
use crate::controller::build::properties::test_support::{cluster_info, validated_cluster};
380+
381+
/// The sorted `metadata.name`s of a resource collection.
382+
fn sorted_names(resources: &[impl Resource]) -> Vec<String> {
383+
let mut names: Vec<String> = resources
384+
.iter()
385+
.filter_map(|resource| resource.meta().name.clone())
386+
.collect();
387+
names.sort();
388+
names
389+
}
390+
391+
/// The aggregator emits, for the minimal three-role cluster (one `default` role group each):
392+
/// one StatefulSet and one ConfigMap per role group, one headless plus one metrics Service per
393+
/// role group, and one default PDB per role.
394+
#[test]
395+
fn build_produces_expected_resource_names() {
396+
let cluster = validated_cluster();
397+
let resources =
398+
build(&cluster, &cluster_info(), "hdfs-serviceaccount").expect("build succeeds");
399+
400+
assert_eq!(
401+
sorted_names(&resources.stateful_sets),
402+
[
403+
"hdfs-datanode-default",
404+
"hdfs-journalnode-default",
405+
"hdfs-namenode-default",
406+
]
407+
);
408+
// One headless and one metrics Service per role group.
409+
assert_eq!(resources.services.len(), 6);
410+
assert_eq!(
411+
sorted_names(&resources.config_maps),
412+
[
413+
"hdfs-datanode-default",
414+
"hdfs-journalnode-default",
415+
"hdfs-namenode-default",
416+
]
417+
);
418+
// A default PDB per role.
419+
assert_eq!(
420+
sorted_names(&resources.pod_disruption_budgets),
421+
["hdfs-datanode", "hdfs-journalnode", "hdfs-namenode"]
422+
);
423+
}
424+
}

rust/operator-binary/src/controller/build/resource/statefulset.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,10 @@ use stackable_operator::{
55
builder::pod::{PodBuilder, security::PodSecurityContextBuilder},
66
k8s_openapi::{
77
DeepMerge,
8-
api::{
9-
apps::v1::{StatefulSet, StatefulSetSpec},
10-
core::v1::ServiceAccount,
11-
},
8+
api::apps::v1::{StatefulSet, StatefulSetSpec},
129
apimachinery::pkg::apis::meta::v1::LabelSelector,
1310
},
14-
kube::{ResourceExt, api::ObjectMeta},
11+
kube::api::ObjectMeta,
1512
kvp::{LabelError, Labels},
1613
utils::cluster_info::KubernetesClusterInfo,
1714
v2::types::operator::RoleGroupName,
@@ -50,7 +47,7 @@ pub(crate) fn build_rolegroup_statefulset(
5047
role: &HdfsNodeRole,
5148
role_group_name: &RoleGroupName,
5249
rolegroup_config: &ValidatedRoleGroupConfig,
53-
service_account: &ServiceAccount,
50+
service_account_name: &str,
5451
) -> Result<StatefulSet, Error> {
5552
tracing::info!("Setting up StatefulSet for role {role} role group {role_group_name}");
5653

@@ -72,7 +69,7 @@ pub(crate) fn build_rolegroup_statefulset(
7269
pb.metadata(pb_metadata)
7370
.image_pull_secrets_from_product_image(image)
7471
.affinity(&merged_config.affinity)
75-
.service_account_name(service_account.name_any())
72+
.service_account_name(service_account_name)
7673
.security_context(PodSecurityContextBuilder::new().fs_group(1000).build());
7774

7875
// Adds all containers and volumes to the pod builder

rust/operator-binary/src/controller/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ use std::{collections::BTreeMap, str::FromStr};
22

33
use stackable_operator::{
44
commons::product_image_selection::ResolvedProductImage,
5+
k8s_openapi::api::{
6+
apps::v1::StatefulSet,
7+
core::v1::{ConfigMap, Service},
8+
policy::v1::PodDisruptionBudget,
9+
},
510
kube::{Resource, api::ObjectMeta},
611
v2::{
712
HasName, HasUid, NameIsValidLabelValue,
@@ -30,6 +35,20 @@ pub mod build;
3035
pub mod dereference;
3136
pub mod validate;
3237

38+
/// Every Kubernetes resource produced by the build step.
39+
///
40+
/// The resources are flat, unordered collections. The reconcile step re-groups the
41+
/// StatefulSets by role to preserve HDFS's ordered, rollout-gated deployment during
42+
/// upgrades. The discovery `ConfigMap` is not part of this set: it depends on a live
43+
/// Kubernetes client (to resolve listener addresses) and is therefore built and applied
44+
/// separately in the reconcile step.
45+
pub struct KubernetesResources {
46+
pub services: Vec<Service>,
47+
pub config_maps: Vec<ConfigMap>,
48+
pub pod_disruption_budgets: Vec<PodDisruptionBudget>,
49+
pub stateful_sets: Vec<StatefulSet>,
50+
}
51+
3352
/// The [`RoleGroupConfig`] specialised for HDFS: the validated config is the
3453
/// per-role [`AnyNodeConfig`],
3554
pub type ValidatedRoleGroupConfig = RoleGroupConfig<

0 commit comments

Comments
 (0)