Skip to content

Commit 055838f

Browse files
maltesandersiegfriedweberadwk67
authored
refactor: Introduce build aggregator (#961)
* refactor: Source cluster domain from ValidatedCluster * refactor: Introduce build aggregator * changelog --------- Co-authored-by: Siegfried Weber <mail@siegfriedweber.net> Co-authored-by: Andrew Kenworthy <andrew.kenworthy@stackable.tech>
1 parent b2e8788 commit 055838f

8 files changed

Lines changed: 226 additions & 155 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 ([#961]).
11+
12+
[#961]: https://github.com/stackabletech/nifi-operator/pull/961
13+
714
## [26.7.0] - 2026-07-21
815

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

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,23 @@
44
55
use std::str::FromStr;
66

7+
use snafu::{OptionExt, ResultExt, Snafu};
78
use stackable_operator::v2::types::{common::Port, operator::RoleGroupName};
89

10+
use crate::{
11+
controller::{
12+
KubernetesResources, ValidatedCluster,
13+
build::resource::{
14+
config_map::build_rolegroup_config_map,
15+
listener::{build_group_listener, group_listener_name},
16+
pdb::build_pdb,
17+
service::{build_rolegroup_headless_service, build_rolegroup_metrics_service},
18+
statefulset::build_node_rolegroup_statefulset,
19+
},
20+
},
21+
crd::NifiRole,
22+
};
23+
924
pub mod git_sync;
1025
pub mod graceful_shutdown;
1126
pub mod jvm;
@@ -27,3 +42,131 @@ pub const BALANCE_PORT: Port = Port(6243);
2742
// Filesystem paths shared by multiple builders. Single-consumer paths live in their builder.
2843
pub const NIFI_CONFIG_DIRECTORY: &str = "/stackable/nifi/conf";
2944
pub const NIFI_PYTHON_WORKING_DIRECTORY: &str = "/nifi-python-working-directory";
45+
46+
#[derive(Snafu, Debug)]
47+
pub enum Error {
48+
#[snafu(display("NifiCluster has no nodes role defined"))]
49+
NoNodesDefined,
50+
51+
#[snafu(display("failed to build ConfigMap for role group {role_group}"))]
52+
ConfigMap {
53+
source: resource::config_map::Error,
54+
role_group: RoleGroupName,
55+
},
56+
57+
#[snafu(display("failed to build StatefulSet for role group {role_group}"))]
58+
StatefulSet {
59+
source: resource::statefulset::Error,
60+
role_group: RoleGroupName,
61+
},
62+
}
63+
64+
/// Builds every Kubernetes resource for the given validated cluster.
65+
///
66+
/// Does not need a Kubernetes client: every reference to another Kubernetes resource is already
67+
/// dereferenced and validated by this point, so the errors returned here are resource-assembly
68+
/// failures only.
69+
///
70+
/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under
71+
/// (RBAC resources are built and applied separately, in the reconcile step).
72+
pub fn build(
73+
cluster: &ValidatedCluster,
74+
service_account_name: &str,
75+
) -> Result<KubernetesResources, Error> {
76+
let mut stateful_sets = vec![];
77+
let mut services = vec![];
78+
let mut listeners = vec![];
79+
let mut config_maps = vec![];
80+
let mut pod_disruption_budgets = vec![];
81+
82+
// NiFi has a single role (`node`), which must always be present.
83+
let nifi_role = NifiRole::Node;
84+
let node_role_group_configs = cluster
85+
.role_group_configs
86+
.get(&nifi_role)
87+
.context(NoNodesDefinedSnafu)?;
88+
89+
// Role-level resources (one per role): the PodDisruptionBudget and the group Listener.
90+
let role_config = &cluster.role_config;
91+
if let Some(pdb) = build_pdb(&role_config.pdb, cluster, &nifi_role) {
92+
pod_disruption_budgets.push(pdb);
93+
}
94+
listeners.push(build_group_listener(
95+
cluster,
96+
role_config.listener_class.clone(),
97+
group_listener_name(cluster, &nifi_role.to_string()),
98+
));
99+
100+
for (role_group_name, rg) in node_role_group_configs {
101+
services.push(build_rolegroup_headless_service(cluster, role_group_name));
102+
services.push(build_rolegroup_metrics_service(cluster, role_group_name));
103+
104+
config_maps.push(
105+
build_rolegroup_config_map(cluster, role_group_name, rg).context(ConfigMapSnafu {
106+
role_group: role_group_name.clone(),
107+
})?,
108+
);
109+
110+
let effective_replicas = rg.replicas.map(i32::from);
111+
stateful_sets.push(
112+
build_node_rolegroup_statefulset(
113+
cluster,
114+
role_group_name,
115+
rg,
116+
effective_replicas,
117+
service_account_name,
118+
)
119+
.context(StatefulSetSnafu {
120+
role_group: role_group_name.clone(),
121+
})?,
122+
);
123+
}
124+
125+
Ok(KubernetesResources {
126+
stateful_sets,
127+
services,
128+
listeners,
129+
config_maps,
130+
pod_disruption_budgets,
131+
})
132+
}
133+
134+
#[cfg(test)]
135+
mod tests {
136+
use stackable_operator::kube::Resource;
137+
138+
use super::{build, properties::test_support::minimal_validated_cluster};
139+
140+
fn sorted_names(resources: &[impl Resource]) -> Vec<&str> {
141+
let mut names: Vec<&str> = resources
142+
.iter()
143+
.filter_map(|resource| resource.meta().name.as_deref())
144+
.collect();
145+
names.sort();
146+
names
147+
}
148+
149+
#[test]
150+
fn build_produces_expected_resources() {
151+
let cluster = minimal_validated_cluster();
152+
let resources = build(&cluster, "simple-nifi-serviceaccount").expect("build succeeds");
153+
154+
// The minimal fixture has a single `default` role group for the `node` role.
155+
assert_eq!(
156+
sorted_names(&resources.stateful_sets),
157+
["simple-nifi-node-default"]
158+
);
159+
assert_eq!(
160+
sorted_names(&resources.config_maps),
161+
["simple-nifi-node-default"]
162+
);
163+
// One headless and one metrics Service per role group.
164+
assert_eq!(resources.services.len(), 2);
165+
// One group Listener and one PDB for the single `node` role.
166+
assert_eq!(sorted_names(&resources.listeners), ["simple-nifi-node"]);
167+
assert_eq!(
168+
sorted_names(&resources.pod_disruption_budgets),
169+
["simple-nifi-node"]
170+
);
171+
}
172+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ pub(crate) mod test_support {
8181
use std::str::FromStr as _;
8282

8383
use stackable_operator::{
84-
commons::product_image_selection::ResolvedProductImage,
84+
commons::{networking::DomainName, product_image_selection::ResolvedProductImage},
8585
crd::authentication::r#static::v1alpha1::{
8686
AuthenticationProvider as StaticAuthProvider, UserCredentialsSecretRef,
8787
},
@@ -168,6 +168,7 @@ pub(crate) mod test_support {
168168
ValidatedCluster::new(
169169
name,
170170
namespace,
171+
DomainName::from_str("cluster.local").expect("valid cluster domain"),
171172
uid,
172173
image,
173174
product_version,

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ use stackable_operator::{
3232
self,
3333
framework::{create_vector_shutdown_file_command, remove_vector_shutdown_file_command},
3434
},
35-
utils::{COMMON_BASH_TRAP_FUNCTIONS, cluster_info::KubernetesClusterInfo},
35+
utils::COMMON_BASH_TRAP_FUNCTIONS,
3636
v2::{
3737
builder::pod::container::{EnvVarSet, new_container_builder},
3838
product_logging::framework::{
@@ -164,10 +164,8 @@ pub(crate) const LISTENER_DEFAULT_PORT_HTTPS_ENV: &str = "LISTENER_DEFAULT_PORT_
164164
///
165165
/// The [`Pod`](`stackable_operator::k8s_openapi::api::core::v1::Pod`)s are accessible through the
166166
/// corresponding [`stackable_operator::k8s_openapi::api::core::v1::Service`] (from `build_rolegroup_headless_service`).
167-
#[allow(clippy::too_many_arguments)]
168-
pub(crate) async fn build_node_rolegroup_statefulset(
167+
pub(crate) fn build_node_rolegroup_statefulset(
169168
cluster: &ValidatedCluster,
170-
cluster_info: &KubernetesClusterInfo,
171169
role_group_name: &RoleGroupName,
172170
rg: &NifiRoleGroupConfig,
173171
effective_replicas: Option<i32>,
@@ -251,7 +249,7 @@ pub(crate) async fn build_node_rolegroup_statefulset(
251249
"$POD_NAME.{service_name}.{namespace}.svc.{cluster_domain}",
252250
service_name = resource_names.headless_service_name(),
253251
namespace = cluster.namespace,
254-
cluster_domain = cluster_info.cluster_domain,
252+
cluster_domain = cluster.cluster_domain,
255253
);
256254

257255
let sensitive_key_secret = &cluster.cluster_config.sensitive_properties.key_secret;

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use snafu::{ResultExt, Snafu};
77
use stackable_operator::{
88
client::Client,
9+
commons::networking::DomainName,
910
v2::{
1011
controller_utils::{self, get_namespace},
1112
types::kubernetes::NamespaceName,
@@ -38,6 +39,9 @@ type Result<T, E = Error> = std::result::Result<T, E>;
3839
pub struct DereferencedObjects {
3940
/// The namespace of the [`v1alpha1::NifiCluster`], parsed once here and reused everywhere.
4041
pub namespace: NamespaceName,
42+
/// The Kubernetes cluster domain, captured from the client here so the build step needs no
43+
/// client to assemble in-cluster DNS names.
44+
pub cluster_domain: DomainName,
4145
pub authentication_classes: DereferencedAuthenticationClasses,
4246
pub authorization: DereferencedAuthorization,
4347
}
@@ -63,6 +67,7 @@ pub async fn dereference(
6367

6468
Ok(DereferencedObjects {
6569
namespace,
70+
cluster_domain: client.kubernetes_cluster_info.cluster_domain.clone(),
6671
authentication_classes,
6772
authorization,
6873
})

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@ use std::{collections::BTreeMap, str::FromStr as _};
77
use stackable_operator::{
88
commons::{
99
affinity::StackableAffinity,
10+
networking::DomainName,
1011
product_image_selection::ResolvedProductImage,
1112
resources::{NoRuntimeLimits, Resources},
1213
},
13-
crd::git_sync,
14-
k8s_openapi::{api::core::v1::Volume, apimachinery::pkg::apis::meta::v1::ObjectMeta},
14+
crd::{git_sync, listener},
15+
k8s_openapi::{
16+
api::{
17+
apps::v1::StatefulSet,
18+
core::v1::{ConfigMap, Service, Volume},
19+
policy::v1::PodDisruptionBudget,
20+
},
21+
apimachinery::pkg::apis::meta::v1::ObjectMeta,
22+
},
1523
kube::Resource,
1624
kvp::Labels,
1725
shared::time::Duration,
@@ -47,6 +55,15 @@ pub(crate) mod build;
4755
pub(crate) mod dereference;
4856
pub(crate) mod validate;
4957

58+
/// Every Kubernetes resource produced by the [`build`] step.
59+
pub struct KubernetesResources {
60+
pub stateful_sets: Vec<StatefulSet>,
61+
pub services: Vec<Service>,
62+
pub listeners: Vec<listener::v1alpha1::Listener>,
63+
pub config_maps: Vec<ConfigMap>,
64+
pub pod_disruption_budgets: Vec<PodDisruptionBudget>,
65+
}
66+
5067
/// A validated, merged (default <- role <- role-group) NiFi rolegroup config.
5168
pub type NifiRoleGroupConfig =
5269
RoleGroupConfig<ValidatedNifiConfig, JavaCommonConfig, v1alpha1::NifiConfigOverrides>;
@@ -140,6 +157,9 @@ pub struct ValidatedCluster {
140157
pub name: ClusterName,
141158
/// The namespace of the NifiCluster, parsed once in the dereference step and reused everywhere.
142159
pub namespace: NamespaceName,
160+
/// The Kubernetes cluster domain, captured from the client in the dereference step so the
161+
/// build step needs no client to assemble in-cluster DNS names.
162+
pub cluster_domain: DomainName,
143163
/// The UID of the NifiCluster, used to build OwnerReferences downstream.
144164
pub uid: Uid,
145165
/// The product image.
@@ -198,6 +218,7 @@ impl ValidatedCluster {
198218
pub fn new(
199219
name: ClusterName,
200220
namespace: NamespaceName,
221+
cluster_domain: DomainName,
201222
uid: Uid,
202223
image: ResolvedProductImage,
203224
product_version: ProductVersion,
@@ -216,6 +237,7 @@ impl ValidatedCluster {
216237
metadata,
217238
name,
218239
namespace,
240+
cluster_domain,
219241
uid,
220242
image,
221243
product_version,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ pub fn validate(
156156
.context(NoNodesDefinedSnafu)?;
157157

158158
let namespace = dereferenced_objects.namespace.clone();
159+
let cluster_domain = dereferenced_objects.cluster_domain.clone();
159160
let uid = get_uid(nifi).context(GetUidSnafu)?;
160161

161162
// `app_version_label_value` is constructed to be a valid label value, so it is always a valid
@@ -166,6 +167,7 @@ pub fn validate(
166167
Ok(ValidatedCluster::new(
167168
name,
168169
namespace,
170+
cluster_domain,
169171
uid,
170172
image,
171173
product_version,

0 commit comments

Comments
 (0)