From 5a683590d85d6fd801f557078fb751cec140b672 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Fri, 10 Jul 2026 16:46:39 +0200 Subject: [PATCH 01/14] refactor: Source cluster domain from ValidatedCluster --- rust/operator-binary/src/controller/build/properties.rs | 3 ++- .../src/controller/build/resource/statefulset.rs | 8 +++----- rust/operator-binary/src/controller/dereference.rs | 5 +++++ rust/operator-binary/src/controller/mod.rs | 6 ++++++ rust/operator-binary/src/controller/validate.rs | 2 ++ rust/operator-binary/src/nifi_controller.rs | 2 -- 6 files changed, 18 insertions(+), 8 deletions(-) diff --git a/rust/operator-binary/src/controller/build/properties.rs b/rust/operator-binary/src/controller/build/properties.rs index a03c6c5b..58c4ba0c 100644 --- a/rust/operator-binary/src/controller/build/properties.rs +++ b/rust/operator-binary/src/controller/build/properties.rs @@ -81,7 +81,7 @@ pub(crate) mod test_support { use std::str::FromStr as _; use stackable_operator::{ - commons::product_image_selection::ResolvedProductImage, + commons::{networking::DomainName, product_image_selection::ResolvedProductImage}, crd::authentication::r#static::v1alpha1::{ AuthenticationProvider as StaticAuthProvider, UserCredentialsSecretRef, }, @@ -168,6 +168,7 @@ pub(crate) mod test_support { ValidatedCluster::new( name, namespace, + DomainName::from_str("cluster.local").expect("valid cluster domain"), uid, image, product_version, diff --git a/rust/operator-binary/src/controller/build/resource/statefulset.rs b/rust/operator-binary/src/controller/build/resource/statefulset.rs index 64bd3b37..8c01c35f 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -32,7 +32,7 @@ use stackable_operator::{ self, framework::{create_vector_shutdown_file_command, remove_vector_shutdown_file_command}, }, - utils::{COMMON_BASH_TRAP_FUNCTIONS, cluster_info::KubernetesClusterInfo}, + utils::COMMON_BASH_TRAP_FUNCTIONS, v2::{ builder::pod::container::{EnvVarSet, new_container_builder}, product_logging::framework::{ @@ -164,10 +164,8 @@ pub(crate) const LISTENER_DEFAULT_PORT_HTTPS_ENV: &str = "LISTENER_DEFAULT_PORT_ /// /// The [`Pod`](`stackable_operator::k8s_openapi::api::core::v1::Pod`)s are accessible through the /// corresponding [`stackable_operator::k8s_openapi::api::core::v1::Service`] (from `build_rolegroup_headless_service`). -#[allow(clippy::too_many_arguments)] -pub(crate) async fn build_node_rolegroup_statefulset( +pub(crate) fn build_node_rolegroup_statefulset( cluster: &ValidatedCluster, - cluster_info: &KubernetesClusterInfo, role_group_name: &RoleGroupName, rg: &NifiRoleGroupConfig, effective_replicas: Option, @@ -251,7 +249,7 @@ pub(crate) async fn build_node_rolegroup_statefulset( "$POD_NAME.{service_name}.{namespace}.svc.{cluster_domain}", service_name = resource_names.headless_service_name(), namespace = cluster.namespace, - cluster_domain = cluster_info.cluster_domain, + cluster_domain = cluster.cluster_domain, ); let sensitive_key_secret = &cluster.cluster_config.sensitive_properties.key_secret; diff --git a/rust/operator-binary/src/controller/dereference.rs b/rust/operator-binary/src/controller/dereference.rs index a5d31cb6..5eadb6ed 100644 --- a/rust/operator-binary/src/controller/dereference.rs +++ b/rust/operator-binary/src/controller/dereference.rs @@ -6,6 +6,7 @@ use snafu::{ResultExt, Snafu}; use stackable_operator::{ client::Client, + commons::networking::DomainName, v2::{ controller_utils::{self, get_namespace}, types::kubernetes::NamespaceName, @@ -38,6 +39,9 @@ type Result = std::result::Result; pub struct DereferencedObjects { /// The namespace of the [`v1alpha1::NifiCluster`], parsed once here and reused everywhere. pub namespace: NamespaceName, + /// The Kubernetes cluster domain, captured from the client here so the build step needs no + /// client to assemble in-cluster DNS names. + pub cluster_domain: DomainName, pub authentication_classes: DereferencedAuthenticationClasses, pub authorization: DereferencedAuthorization, } @@ -63,6 +67,7 @@ pub async fn dereference( Ok(DereferencedObjects { namespace, + cluster_domain: client.kubernetes_cluster_info.cluster_domain.clone(), authentication_classes, authorization, }) diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index f9106741..8871acd6 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -7,6 +7,7 @@ use std::{collections::BTreeMap, str::FromStr as _}; use stackable_operator::{ commons::{ affinity::StackableAffinity, + networking::DomainName, product_image_selection::ResolvedProductImage, resources::{NoRuntimeLimits, Resources}, }, @@ -140,6 +141,9 @@ pub struct ValidatedCluster { pub name: ClusterName, /// The namespace of the NifiCluster, parsed once in the dereference step and reused everywhere. pub namespace: NamespaceName, + /// The Kubernetes cluster domain, captured from the client in the dereference step so the + /// build step needs no client to assemble in-cluster DNS names. + pub cluster_domain: DomainName, /// The UID of the NifiCluster, used to build OwnerReferences downstream. pub uid: Uid, /// The product image. @@ -198,6 +202,7 @@ impl ValidatedCluster { pub fn new( name: ClusterName, namespace: NamespaceName, + cluster_domain: DomainName, uid: Uid, image: ResolvedProductImage, product_version: ProductVersion, @@ -216,6 +221,7 @@ impl ValidatedCluster { metadata, name, namespace, + cluster_domain, uid, image, product_version, diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index a29ed629..c5d7950a 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -156,6 +156,7 @@ pub fn validate( .context(NoNodesDefinedSnafu)?; let namespace = dereferenced_objects.namespace.clone(); + let cluster_domain = dereferenced_objects.cluster_domain.clone(); let uid = get_uid(nifi).context(GetUidSnafu)?; // `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( Ok(ValidatedCluster::new( name, namespace, + cluster_domain, uid, image, product_version, diff --git a/rust/operator-binary/src/nifi_controller.rs b/rust/operator-binary/src/nifi_controller.rs index b10e9013..eb34d62f 100644 --- a/rust/operator-binary/src/nifi_controller.rs +++ b/rust/operator-binary/src/nifi_controller.rs @@ -259,13 +259,11 @@ pub async fn reconcile_nifi( let rg_statefulset = build_node_rolegroup_statefulset( &validated_cluster, - &client.kubernetes_cluster_info, role_group_name, rg, effective_replicas, &rbac_sa.name_any(), ) - .await .with_context(|_| BuildStatefulSetSnafu { rolegroup: role_group_name.clone(), })?; From 75728c52de1f9a1ab911c83688bc5e0d5a1ce405 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Fri, 10 Jul 2026 16:53:11 +0200 Subject: [PATCH 02/14] refactor: Introduce build aggregator --- rust/operator-binary/src/controller/build.rs | 143 ++++++++++++++ rust/operator-binary/src/controller/mod.rs | 20 +- rust/operator-binary/src/nifi_controller.rs | 187 ++++--------------- 3 files changed, 202 insertions(+), 148 deletions(-) diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 8c713a32..7ba9d7c9 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -4,8 +4,23 @@ use std::str::FromStr; +use snafu::{OptionExt, ResultExt, Snafu}; use stackable_operator::v2::types::{common::Port, operator::RoleGroupName}; +use crate::{ + controller::{ + KubernetesResources, ValidatedCluster, + build::resource::{ + config_map::build_rolegroup_config_map, + listener::{build_group_listener, group_listener_name}, + pdb::build_pdb, + service::{build_rolegroup_headless_service, build_rolegroup_metrics_service}, + statefulset::build_node_rolegroup_statefulset, + }, + }, + crd::NifiRole, +}; + pub mod git_sync; pub mod graceful_shutdown; pub mod jvm; @@ -27,3 +42,131 @@ pub const BALANCE_PORT: Port = Port(6243); // Filesystem paths shared by multiple builders. Single-consumer paths live in their builder. pub const NIFI_CONFIG_DIRECTORY: &str = "/stackable/nifi/conf"; pub const NIFI_PYTHON_WORKING_DIRECTORY: &str = "/nifi-python-working-directory"; + +#[derive(Snafu, Debug)] +pub enum Error { + #[snafu(display("NifiCluster has no nodes role defined"))] + NoNodesDefined, + + #[snafu(display("failed to build ConfigMap for role group {role_group}"))] + ConfigMap { + source: resource::config_map::Error, + role_group: RoleGroupName, + }, + + #[snafu(display("failed to build StatefulSet for role group {role_group}"))] + StatefulSet { + source: resource::statefulset::Error, + role_group: RoleGroupName, + }, +} + +/// Builds every Kubernetes resource for the given validated cluster. +/// +/// Does not need a Kubernetes client: every reference to another Kubernetes resource is already +/// dereferenced and validated by this point, so the errors returned here are resource-assembly +/// failures only. +/// +/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under +/// (RBAC resources are built and applied separately, in the reconcile step). +pub fn build( + cluster: &ValidatedCluster, + service_account_name: &str, +) -> Result { + let mut stateful_sets = vec![]; + let mut services = vec![]; + let mut listeners = vec![]; + let mut config_maps = vec![]; + let mut pod_disruption_budgets = vec![]; + + // NiFi has a single role (`node`), which must always be present. + let nifi_role = NifiRole::Node; + let node_role_group_configs = cluster + .role_group_configs + .get(&nifi_role) + .context(NoNodesDefinedSnafu)?; + + // Role-level resources (one per role): the PodDisruptionBudget and the group Listener. + let role_config = &cluster.role_config; + if let Some(pdb) = build_pdb(&role_config.pdb, cluster, &nifi_role) { + pod_disruption_budgets.push(pdb); + } + listeners.push(build_group_listener( + cluster, + role_config.listener_class.clone(), + group_listener_name(cluster, &nifi_role.to_string()), + )); + + for (role_group_name, rg) in node_role_group_configs { + services.push(build_rolegroup_headless_service(cluster, role_group_name)); + services.push(build_rolegroup_metrics_service(cluster, role_group_name)); + + config_maps.push( + build_rolegroup_config_map(cluster, role_group_name, rg).context(ConfigMapSnafu { + role_group: role_group_name.clone(), + })?, + ); + + let effective_replicas = rg.replicas.map(i32::from); + stateful_sets.push( + build_node_rolegroup_statefulset( + cluster, + role_group_name, + rg, + effective_replicas, + service_account_name, + ) + .context(StatefulSetSnafu { + role_group: role_group_name.clone(), + })?, + ); + } + + Ok(KubernetesResources { + stateful_sets, + services, + listeners, + config_maps, + pod_disruption_budgets, + }) +} + +#[cfg(test)] +mod tests { + use stackable_operator::kube::Resource; + + use super::{build, properties::test_support::minimal_validated_cluster}; + + fn sorted_names(resources: &[impl Resource]) -> Vec<&str> { + let mut names: Vec<&str> = resources + .iter() + .filter_map(|resource| resource.meta().name.as_deref()) + .collect(); + names.sort(); + names + } + + #[test] + fn build_produces_expected_resources() { + let cluster = minimal_validated_cluster(); + let resources = build(&cluster, "simple-nifi-serviceaccount").expect("build succeeds"); + + // The minimal fixture has a single `default` role group for the `node` role. + assert_eq!( + sorted_names(&resources.stateful_sets), + ["simple-nifi-node-default"] + ); + assert_eq!( + sorted_names(&resources.config_maps), + ["simple-nifi-node-default"] + ); + // One headless and one metrics Service per role group. + assert_eq!(resources.services.len(), 2); + // One group Listener and one PDB for the single `node` role. + assert_eq!(sorted_names(&resources.listeners), ["simple-nifi-node"]); + assert_eq!( + sorted_names(&resources.pod_disruption_budgets), + ["simple-nifi-node"] + ); + } +} diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 8871acd6..4098ad17 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -11,8 +11,15 @@ use stackable_operator::{ product_image_selection::ResolvedProductImage, resources::{NoRuntimeLimits, Resources}, }, - crd::git_sync, - k8s_openapi::{api::core::v1::Volume, apimachinery::pkg::apis::meta::v1::ObjectMeta}, + crd::{git_sync, listener}, + k8s_openapi::{ + api::{ + apps::v1::StatefulSet, + core::v1::{ConfigMap, Service, Volume}, + policy::v1::PodDisruptionBudget, + }, + apimachinery::pkg::apis::meta::v1::ObjectMeta, + }, kube::Resource, kvp::Labels, shared::time::Duration, @@ -48,6 +55,15 @@ pub(crate) mod build; pub(crate) mod dereference; pub(crate) mod validate; +/// Every Kubernetes resource produced by the [`build`] step. +pub struct KubernetesResources { + pub stateful_sets: Vec, + pub services: Vec, + pub listeners: Vec, + pub config_maps: Vec, + pub pod_disruption_budgets: Vec, +} + /// A validated, merged (default <- role <- role-group) NiFi rolegroup config. pub type NifiRoleGroupConfig = RoleGroupConfig; diff --git a/rust/operator-binary/src/nifi_controller.rs b/rust/operator-binary/src/nifi_controller.rs index eb34d62f..0cee34c5 100644 --- a/rust/operator-binary/src/nifi_controller.rs +++ b/rust/operator-binary/src/nifi_controller.rs @@ -3,7 +3,7 @@ use std::{str::FromStr, sync::Arc}; use const_format::concatcp; -use snafu::{OptionExt, ResultExt, Snafu}; +use snafu::{ResultExt, Snafu}; use stackable_operator::{ cli::OperatorEnvironmentOptions, client::Client, @@ -20,27 +20,14 @@ use stackable_operator::{ compute_conditions, operations::ClusterOperationsConditionBuilder, statefulset::StatefulSetConditionBuilder, }, - v2::{ - cluster_resources::cluster_resources_new, - types::operator::{ProductVersion, RoleGroupName}, - }, + v2::{cluster_resources::cluster_resources_new, types::operator::ProductVersion}, }; use strum::{EnumDiscriminants, IntoStaticStr}; -use tracing::Instrument; use crate::{ OPERATOR_NAME, - controller::{ - build, - build::resource::{ - listener::{build_group_listener, group_listener_name}, - pdb::build_pdb, - service::{build_rolegroup_headless_service, build_rolegroup_metrics_service}, - statefulset::build_node_rolegroup_statefulset, - }, - controller_name, dereference, operator_name, product_name, validate, - }, - crd::{APP_NAME, NifiRole, NifiStatus, v1alpha1}, + controller::{build, controller_name, dereference, operator_name, product_name, validate}, + crd::{APP_NAME, NifiStatus, v1alpha1}, security::{ authentication::NifiAuthenticationConfig, check_or_generate_oidc_admin_password, check_or_generate_sensitive_key, @@ -80,37 +67,12 @@ pub enum Error { source: stackable_operator::client::Error, }, - #[snafu(display("failed to apply Service for {}", rolegroup))] - ApplyRoleGroupService { - source: stackable_operator::cluster_resources::Error, - rolegroup: RoleGroupName, - }, - - #[snafu(display("failed to build rolegroup ConfigMap for {}", rolegroup))] - BuildRoleGroupConfigMap { - source: build::resource::config_map::Error, - rolegroup: RoleGroupName, - }, - - #[snafu(display("failed to build StatefulSet for {}", rolegroup))] - BuildStatefulSet { - source: crate::controller::build::resource::statefulset::Error, - rolegroup: RoleGroupName, - }, - - #[snafu(display("object has no nodes defined"))] - NoNodesDefined, - - #[snafu(display("failed to apply ConfigMap for {}", rolegroup))] - ApplyRoleGroupConfig { - source: stackable_operator::cluster_resources::Error, - rolegroup: RoleGroupName, - }, + #[snafu(display("failed to build the Kubernetes resources"))] + BuildResources { source: build::Error }, - #[snafu(display("failed to apply StatefulSet for {}", rolegroup))] - ApplyRoleGroupStatefulSet { + #[snafu(display("failed to apply Kubernetes resource"))] + ApplyResource { source: stackable_operator::cluster_resources::Error, - rolegroup: RoleGroupName, }, #[snafu(display("failed to patch service account"))] @@ -128,11 +90,6 @@ pub enum Error { source: stackable_operator::commons::rbac::Error, }, - #[snafu(display("failed to apply PodDisruptionBudget"))] - ApplyPdb { - source: stackable_operator::cluster_resources::Error, - }, - #[snafu(display("failed to get required labels"))] GetRequiredLabels { source: @@ -141,11 +98,6 @@ pub enum Error { #[snafu(display("security failure"))] Security { source: crate::security::Error }, - - #[snafu(display("failed to apply group listener"))] - ApplyGroupListener { - source: stackable_operator::cluster_resources::Error, - }, } type Result = std::result::Result; @@ -231,103 +183,46 @@ pub async fn reconcile_nifi( .await .context(ApplyRoleBindingSnafu)?; - let mut ss_cond_builder = StatefulSetConditionBuilder::default(); - - let nifi_role = NifiRole::Node; - let node_role_group_configs = validated_cluster - .role_group_configs - .get(&nifi_role) - .context(NoNodesDefinedSnafu)?; - for (role_group_name, rg) in node_role_group_configs.iter() { - let rg_span = tracing::info_span!("rolegroup_span", rolegroup = role_group_name.as_ref()); - async { - tracing::debug!("Processing rolegroup {role_group_name}"); - - let rg_headless_service = - build_rolegroup_headless_service(&validated_cluster, role_group_name); - - let rg_configmap = build::resource::config_map::build_rolegroup_config_map( - &validated_cluster, - role_group_name, - rg, - ) - .context(BuildRoleGroupConfigMapSnafu { - rolegroup: role_group_name.clone(), - })?; - - let effective_replicas = rg.replicas.map(i32::from); - - let rg_statefulset = build_node_rolegroup_statefulset( - &validated_cluster, - role_group_name, - rg, - effective_replicas, - &rbac_sa.name_any(), - ) - .with_context(|_| BuildStatefulSetSnafu { - rolegroup: role_group_name.clone(), - })?; - - let rg_metrics_service = - build_rolegroup_metrics_service(&validated_cluster, role_group_name); - - cluster_resources - .add(client, rg_metrics_service) - .await - .with_context(|_| ApplyRoleGroupServiceSnafu { - rolegroup: role_group_name.clone(), - })?; + let resources = + build::build(&validated_cluster, &rbac_sa.name_any()).context(BuildResourcesSnafu)?; - cluster_resources - .add(client, rg_headless_service) - .await - .with_context(|_| ApplyRoleGroupServiceSnafu { - rolegroup: role_group_name.clone(), - })?; + let mut ss_cond_builder = StatefulSetConditionBuilder::default(); - cluster_resources - .add(client, rg_configmap) - .await - .with_context(|_| ApplyRoleGroupConfigSnafu { - rolegroup: role_group_name.clone(), - })?; - - // Note: The StatefulSet needs to be applied after all ConfigMaps and Secrets it mounts - // to prevent unnecessary Pod restarts. - // See https://github.com/stackabletech/commons-operator/issues/111 for details. - ss_cond_builder.add( - cluster_resources - .add(client, rg_statefulset) - .await - .with_context(|_| ApplyRoleGroupStatefulSetSnafu { - rolegroup: role_group_name.clone(), - })?, - ); - - Ok(()) - } - .instrument(rg_span) - .await? + // Apply order: everything before StatefulSets, StatefulSets last. A StatefulSet must be applied + // after all ConfigMaps and Secrets it mounts, otherwise the Pods restart unnecessarily. + // See https://github.com/stackabletech/commons-operator/issues/111 for details. + for service in resources.services { + cluster_resources + .add(client, service) + .await + .context(ApplyResourceSnafu)?; } - - let role_config = &validated_cluster.role_config; - if let Some(pdb) = build_pdb(&role_config.pdb, &validated_cluster, &nifi_role) { + for listener in resources.listeners { + cluster_resources + .add(client, listener) + .await + .context(ApplyResourceSnafu)?; + } + for config_map in resources.config_maps { + cluster_resources + .add(client, config_map) + .await + .context(ApplyResourceSnafu)?; + } + for pdb in resources.pod_disruption_budgets { cluster_resources .add(client, pdb) .await - .context(ApplyPdbSnafu)?; + .context(ApplyResourceSnafu)?; + } + for stateful_set in resources.stateful_sets { + ss_cond_builder.add( + cluster_resources + .add(client, stateful_set) + .await + .context(ApplyResourceSnafu)?, + ); } - - let role_group_listener = build_group_listener( - &validated_cluster, - role_config.listener_class.clone(), - group_listener_name(&validated_cluster, &nifi_role.to_string()), - ); - - cluster_resources - .add(client, role_group_listener) - .await - .context(ApplyGroupListenerSnafu)?; // Remove any orphaned resources that still exist in k8s, but have not been added to // the cluster resources during the reconciliation From 20fb778ba3dc94af24e9c0ebfa42721097aeb434 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 20 Jul 2026 12:54:40 +0200 Subject: [PATCH 03/14] factor: add infallible rbac functions --- Cargo.lock | 9 --- Cargo.toml | 2 +- rust/operator-binary/src/controller/build.rs | 79 +++++++++++++++---- .../src/controller/build/resource/mod.rs | 1 + .../src/controller/build/resource/rbac.rs | 42 ++++++++++ .../controller/build/resource/statefulset.rs | 10 ++- rust/operator-binary/src/controller/mod.rs | 63 ++++++++++----- rust/operator-binary/src/nifi_controller.rs | 59 ++++---------- 8 files changed, 173 insertions(+), 92 deletions(-) create mode 100644 rust/operator-binary/src/controller/build/resource/rbac.rs diff --git a/Cargo.lock b/Cargo.lock index e8c3cdfd..d20b1d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,7 +1634,6 @@ dependencies = [ [[package]] name = "k8s-version" version = "0.1.3" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "darling", "regex", @@ -3069,7 +3068,6 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackable-certs" version = "0.4.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "const-oid", "ecdsa", @@ -3120,7 +3118,6 @@ dependencies = [ [[package]] name = "stackable-operator" version = "0.113.4" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "base64", "clap", @@ -3165,7 +3162,6 @@ dependencies = [ [[package]] name = "stackable-operator-derive" version = "0.3.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "darling", "proc-macro2", @@ -3176,7 +3172,6 @@ dependencies = [ [[package]] name = "stackable-shared" version = "0.1.2" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "jiff", "k8s-openapi", @@ -3193,7 +3188,6 @@ dependencies = [ [[package]] name = "stackable-telemetry" version = "0.6.5" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "axum", "clap", @@ -3217,7 +3211,6 @@ dependencies = [ [[package]] name = "stackable-versioned" version = "0.11.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "kube", "schemars", @@ -3231,7 +3224,6 @@ dependencies = [ [[package]] name = "stackable-versioned-macros" version = "0.11.1" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "convert_case", "convert_case_extras", @@ -3249,7 +3241,6 @@ dependencies = [ [[package]] name = "stackable-webhook" version = "0.9.2" -source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.113.4#300422ce451e01865debe0cc9da2d75feca14070" dependencies = [ "arc-swap", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 89c1c08f..f723a999 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,4 @@ url = { version = "2.5.7" } [patch."https://github.com/stackabletech/operator-rs.git"] # stackable-operator = { git = "https://github.com/stackabletech//operator-rs.git", branch = "main" } -# stackable-operator = { path = "../operator-rs/crates/stackable-operator" } +stackable-operator = { path = "../operator-rs/crates/stackable-operator" } diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 7ba9d7c9..83479946 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -14,6 +14,7 @@ use crate::{ config_map::build_rolegroup_config_map, listener::{build_group_listener, group_listener_name}, pdb::build_pdb, + rbac::{build_role_binding, build_service_account}, service::{build_rolegroup_headless_service, build_rolegroup_metrics_service}, statefulset::build_node_rolegroup_statefulset, }, @@ -69,10 +70,7 @@ pub enum Error { /// /// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under /// (RBAC resources are built and applied separately, in the reconcile step). -pub fn build( - cluster: &ValidatedCluster, - service_account_name: &str, -) -> Result { +pub fn build(cluster: &ValidatedCluster) -> Result { let mut stateful_sets = vec![]; let mut services = vec![]; let mut listeners = vec![]; @@ -109,16 +107,10 @@ pub fn build( let effective_replicas = rg.replicas.map(i32::from); stateful_sets.push( - build_node_rolegroup_statefulset( - cluster, - role_group_name, - rg, - effective_replicas, - service_account_name, - ) - .context(StatefulSetSnafu { - role_group: role_group_name.clone(), - })?, + build_node_rolegroup_statefulset(cluster, role_group_name, rg, effective_replicas) + .context(StatefulSetSnafu { + role_group: role_group_name.clone(), + })?, ); } @@ -128,11 +120,15 @@ pub fn build( listeners, config_maps, pod_disruption_budgets, + service_accounts: vec![build_service_account(cluster)], + role_bindings: vec![build_role_binding(cluster)], }) } #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use stackable_operator::kube::Resource; use super::{build, properties::test_support::minimal_validated_cluster}; @@ -149,7 +145,7 @@ mod tests { #[test] fn build_produces_expected_resources() { let cluster = minimal_validated_cluster(); - let resources = build(&cluster, "simple-nifi-serviceaccount").expect("build succeeds"); + let resources = build(&cluster).expect("build succeeds"); // The minimal fixture has a single `default` role group for the `node` role. assert_eq!( @@ -169,4 +165,57 @@ mod tests { ["simple-nifi-node"] ); } + + /// Locks the RBAC resource names, the roleRef, and the recommended label set against + /// accidental drift. The fixture's cluster name deliberately differs from the product name so + /// that swapped `name`/`instance` label values cannot pass unnoticed. + /// + /// The version label is the bare `2.9.0` because the fixture hand-builds its + /// [`ResolvedProductImage`](stackable_operator::commons::product_image_selection::ResolvedProductImage) + /// instead of resolving it (which would append the `-stackable…` suffix). + #[test] + fn build_produces_rbac() { + let cluster = minimal_validated_cluster(); + let resources = build(&cluster).expect("build succeeds"); + + assert_eq!( + sorted_names(&resources.service_accounts), + ["simple-nifi-serviceaccount"] + ); + assert_eq!( + sorted_names(&resources.role_bindings), + ["simple-nifi-rolebinding"] + ); + + let expected_labels = BTreeMap::from( + [ + ("app.kubernetes.io/component", "none"), + ("app.kubernetes.io/instance", "simple-nifi"), + ( + "app.kubernetes.io/managed-by", + "nifi.stackable.tech_nificluster", + ), + ("app.kubernetes.io/name", "nifi"), + ("app.kubernetes.io/role-group", "none"), + ("app.kubernetes.io/version", "2.9.0"), + ("stackable.tech/vendor", "Stackable"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())), + ); + let service_account = resources + .service_accounts + .first() + .expect("a ServiceAccount is built"); + assert_eq!( + service_account.metadata.labels, + Some(expected_labels.clone()) + ); + + let role_binding = resources + .role_bindings + .first() + .expect("a RoleBinding is built"); + assert_eq!(role_binding.metadata.labels, Some(expected_labels)); + assert_eq!(role_binding.role_ref.name, "nifi-clusterrole"); + } } diff --git a/rust/operator-binary/src/controller/build/resource/mod.rs b/rust/operator-binary/src/controller/build/resource/mod.rs index d040825b..9598a324 100644 --- a/rust/operator-binary/src/controller/build/resource/mod.rs +++ b/rust/operator-binary/src/controller/build/resource/mod.rs @@ -5,5 +5,6 @@ pub mod config_map; pub mod listener; pub mod pdb; +pub mod rbac; pub mod service; pub mod statefulset; diff --git a/rust/operator-binary/src/controller/build/resource/rbac.rs b/rust/operator-binary/src/controller/build/resource/rbac.rs new file mode 100644 index 00000000..8d52854b --- /dev/null +++ b/rust/operator-binary/src/controller/build/resource/rbac.rs @@ -0,0 +1,42 @@ +//! Builds the RBAC resources (ServiceAccount + RoleBinding) shared by all role groups. + +use std::str::FromStr; + +use stackable_operator::{ + k8s_openapi::api::{core::v1::ServiceAccount, rbac::v1::RoleBinding}, + kvp::Labels, + v2::{ + rbac, + types::operator::{RoleGroupName, RoleName}, + }, +}; + +use crate::controller::ValidatedCluster; + +stackable_operator::constant!(NONE_ROLE_NAME: RoleName = "none"); +stackable_operator::constant!(NONE_ROLE_GROUP_NAME: RoleGroupName = "none"); + +/// Builds the [`ServiceAccount`] that the role-group Pods run under. +pub fn build_service_account(cluster: &ValidatedCluster) -> ServiceAccount { + rbac::build_service_account( + cluster, + &cluster.rbac_resource_names(), + rbac_labels(cluster), + ) +} + +/// Builds the [`RoleBinding`] that binds the [`ServiceAccount`] from [`build_service_account`] to +/// the operator-deployed ClusterRole. +pub fn build_role_binding(cluster: &ValidatedCluster) -> RoleBinding { + rbac::build_role_binding( + cluster, + &cluster.rbac_resource_names(), + rbac_labels(cluster), + ) +} + +/// Both resources are shared by the whole cluster rather than tied to a role or role group, so +/// the recommended labels carry `none` for both values. +fn rbac_labels(cluster: &ValidatedCluster) -> Labels { + cluster.recommended_labels_for(&NONE_ROLE_NAME, &NONE_ROLE_GROUP_NAME) +} diff --git a/rust/operator-binary/src/controller/build/resource/statefulset.rs b/rust/operator-binary/src/controller/build/resource/statefulset.rs index 8c01c35f..c1077b02 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -169,7 +169,6 @@ pub(crate) fn build_node_rolegroup_statefulset( role_group_name: &RoleGroupName, rg: &NifiRoleGroupConfig, effective_replicas: Option, - service_account_name: &str, ) -> Result { tracing::debug!("Building statefulset"); @@ -642,7 +641,12 @@ pub(crate) fn build_node_rolegroup_statefulset( ..Volume::default() }) .context(AddVolumeSnafu)? - .service_account_name(service_account_name) + .service_account_name( + cluster + .rbac_resource_names() + .service_account_name() + .to_string(), + ) .security_context(PodSecurityContextBuilder::new().fs_group(1000).build()); let mut pod_template = pod_builder.build_template(); @@ -709,7 +713,7 @@ fn get_volume_claim_templates( // Used for PVC templates that cannot be modified once they are deployed, so the version label // is set to the placeholder `none` to keep the labels stable across version upgrades. - let unversioned_recommended_labels = cluster.recommended_labels_unversioned(role_group_name); + let unversioned_recommended_labels = cluster.unversioned_recommended_labels(role_group_name); // listener endpoints will use persistent volumes // so that load balancers can hard-code the target addresses and diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 4098ad17..7916738e 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -15,8 +15,9 @@ use stackable_operator::{ k8s_openapi::{ api::{ apps::v1::StatefulSet, - core::v1::{ConfigMap, Service, Volume}, + core::v1::{ConfigMap, Service, ServiceAccount, Volume}, policy::v1::PodDisruptionBudget, + rbac::v1::RoleBinding, }, apimachinery::pkg::apis::meta::v1::ObjectMeta, }, @@ -28,7 +29,7 @@ use stackable_operator::{ kvp::label::{recommended_labels, role_group_selector}, product_logging::framework::{ValidatedContainerLogConfigChoice, VectorContainerLogConfig}, role_group_utils::ResourceNames, - role_utils::{JavaCommonConfig, RoleGroupConfig}, + role_utils::{self, JavaCommonConfig, RoleGroupConfig}, types::{ kubernetes::{ListenerClassName, NamespaceName, SecretClassName, SecretName, Uid}, operator::{ @@ -55,6 +56,9 @@ pub(crate) mod build; pub(crate) mod dereference; pub(crate) mod validate; +// Placeholder version label value for resources whose labels must not change after deployment. +stackable_operator::constant!(UNVERSIONED_PRODUCT_VERSION: ProductVersion = "none"); + /// Every Kubernetes resource produced by the [`build`] step. pub struct KubernetesResources { pub stateful_sets: Vec, @@ -62,6 +66,8 @@ pub struct KubernetesResources { pub listeners: Vec, pub config_maps: Vec, pub pod_disruption_budgets: Vec, + pub service_accounts: Vec, + pub role_bindings: Vec, } /// A validated, merged (default <- role <- role-group) NiFi rolegroup config. @@ -253,6 +259,15 @@ impl ValidatedCluster { .expect("the node role name is a valid role name") } + /// Type-safe names for the per-cluster RBAC resources: the ServiceAccount shared by all + /// Pods, its (namespaced) RoleBinding, and the operator-deployed ClusterRole it binds. + pub fn rbac_resource_names(&self) -> role_utils::ResourceNames { + role_utils::ResourceNames { + cluster_name: self.name.clone(), + product_name: product_name(), + } + } + /// Type-safe names for the resources of a given role group. pub(crate) fn resource_names(&self, role_group_name: &RoleGroupName) -> ResourceNames { ResourceNames { @@ -262,10 +277,34 @@ impl ValidatedCluster { } } - /// Recommended labels for a role-group resource, using the given product version. - fn recommended_labels_for( + /// Recommended labels for a role-group resource. + pub fn recommended_labels(&self, role_group_name: &RoleGroupName) -> Labels { + self.recommended_labels_for(&Self::role_name(), role_group_name) + } + + /// Recommended labels for a resource that is not tied to a concrete role, using a free-form role/role-group label value. + pub fn recommended_labels_for( + &self, + role_name: &RoleName, + role_group_name: &RoleGroupName, + ) -> Labels { + self.recommended_labels_with(&self.product_version, role_name, role_group_name) + } + + /// Recommended labels with the constant [`UNVERSIONED_PRODUCT_VERSION`], for PVC templates + /// that cannot be modified after deployment (keeps the labels stable across version upgrades). + pub fn unversioned_recommended_labels(&self, role_group_name: &RoleGroupName) -> Labels { + self.recommended_labels_with( + &UNVERSIONED_PRODUCT_VERSION, + &Self::role_name(), + role_group_name, + ) + } + + fn recommended_labels_with( &self, product_version: &ProductVersion, + role_name: &RoleName, role_group_name: &RoleGroupName, ) -> Labels { recommended_labels( @@ -274,25 +313,11 @@ impl ValidatedCluster { product_version, &operator_name(), &controller_name(), - &Self::role_name(), + role_name, role_group_name, ) } - /// Recommended labels for a role-group resource. - pub fn recommended_labels(&self, role_group_name: &RoleGroupName) -> Labels { - self.recommended_labels_for(&self.product_version, role_group_name) - } - - /// Recommended labels for resources whose labels must stay stable across version upgrades - /// (e.g. PVC templates, which are immutable once created), using the placeholder version - /// `none` for `app.kubernetes.io/version`. - pub fn recommended_labels_unversioned(&self, role_group_name: &RoleGroupName) -> Labels { - let unversioned = ProductVersion::from_str("none") - .expect("'none' is a valid product version label value"); - self.recommended_labels_for(&unversioned, role_group_name) - } - /// Selector labels matching the pods of a role group. pub fn role_group_selector(&self, role_group_name: &RoleGroupName) -> Labels { role_group_selector(self, &product_name(), &Self::role_name(), role_group_name) diff --git a/rust/operator-binary/src/nifi_controller.rs b/rust/operator-binary/src/nifi_controller.rs index 0cee34c5..88c59a0d 100644 --- a/rust/operator-binary/src/nifi_controller.rs +++ b/rust/operator-binary/src/nifi_controller.rs @@ -8,9 +8,7 @@ use stackable_operator::{ cli::OperatorEnvironmentOptions, client::Client, cluster_resources::ClusterResourceApplyStrategy, - commons::rbac::build_rbac_resources, kube::{ - ResourceExt, core::{DeserializeGuard, error_boundary}, runtime::controller::Action, }, @@ -27,7 +25,7 @@ use strum::{EnumDiscriminants, IntoStaticStr}; use crate::{ OPERATOR_NAME, controller::{build, controller_name, dereference, operator_name, product_name, validate}, - crd::{APP_NAME, NifiStatus, v1alpha1}, + crd::{NifiStatus, v1alpha1}, security::{ authentication::NifiAuthenticationConfig, check_or_generate_oidc_admin_password, check_or_generate_sensitive_key, @@ -75,27 +73,6 @@ pub enum Error { source: stackable_operator::cluster_resources::Error, }, - #[snafu(display("failed to patch service account"))] - ApplyServiceAccount { - source: stackable_operator::cluster_resources::Error, - }, - - #[snafu(display("failed to patch role binding"))] - ApplyRoleBinding { - source: stackable_operator::cluster_resources::Error, - }, - - #[snafu(display("failed to build RBAC resources"))] - BuildRbacResources { - source: stackable_operator::commons::rbac::Error, - }, - - #[snafu(display("failed to get required labels"))] - GetRequiredLabels { - source: - stackable_operator::kvp::KeyValuePairError, - }, - #[snafu(display("security failure"))] Security { source: crate::security::Error }, } @@ -164,33 +141,25 @@ pub async fn reconcile_nifi( .context(SecuritySnafu)?; } - let (rbac_sa, rbac_rolebinding) = build_rbac_resources( - nifi, - APP_NAME, - cluster_resources - .get_required_labels() - .context(GetRequiredLabelsSnafu)?, - ) - .context(BuildRbacResourcesSnafu)?; - - let rbac_sa = cluster_resources - .add(client, rbac_sa) - .await - .context(ApplyServiceAccountSnafu)?; - - cluster_resources - .add(client, rbac_rolebinding) - .await - .context(ApplyRoleBindingSnafu)?; - - let resources = - build::build(&validated_cluster, &rbac_sa.name_any()).context(BuildResourcesSnafu)?; + let resources = build::build(&validated_cluster).context(BuildResourcesSnafu)?; let mut ss_cond_builder = StatefulSetConditionBuilder::default(); // Apply order: everything before StatefulSets, StatefulSets last. A StatefulSet must be applied // after all ConfigMaps and Secrets it mounts, otherwise the Pods restart unnecessarily. // See https://github.com/stackabletech/commons-operator/issues/111 for details. + for service_account in resources.service_accounts { + cluster_resources + .add(client, service_account) + .await + .context(ApplyResourceSnafu)?; + } + for role_binding in resources.role_bindings { + cluster_resources + .add(client, role_binding) + .await + .context(ApplyResourceSnafu)?; + } for service in resources.services { cluster_resources .add(client, service) From 04b80158c92537d8b19048547de523287c632187 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Thu, 23 Jul 2026 11:46:06 +0200 Subject: [PATCH 04/14] rename resource name functions --- Cargo.lock | 64 ------------------- .../controller/build/resource/config_map.rs | 2 +- .../src/controller/build/resource/rbac.rs | 4 +- .../src/controller/build/resource/service.rs | 4 +- .../controller/build/resource/statefulset.rs | 6 +- rust/operator-binary/src/controller/mod.rs | 7 +- rust/operator-binary/src/main.rs | 1 - 7 files changed, 13 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d20b1d81..f791f699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -265,21 +265,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bitflags" version = "1.3.2" @@ -861,17 +846,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fastrand" version = "2.4.1" @@ -2302,22 +2276,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "product-config" -version = "0.8.0" -source = "git+https://github.com/stackabletech/product-config.git?tag=0.8.0#678fb7cf30af7d7b516c9a46698a1b661120d54a" -dependencies = [ - "fancy-regex", - "java-properties", - "schemars", - "semver", - "serde", - "serde_json", - "serde_yaml", - "snafu 0.8.9", - "xml", -] - [[package]] name = "prost" version = "0.14.4" @@ -2980,15 +2938,6 @@ dependencies = [ "snafu-derive 0.6.10", ] -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive 0.8.9", -] - [[package]] name = "snafu" version = "0.9.1" @@ -3009,18 +2958,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "snafu-derive" version = "0.9.1" @@ -3134,7 +3071,6 @@ dependencies = [ "json-patch", "k8s-openapi", "kube", - "product-config", "rand 0.9.4", "regex", "schemars", diff --git a/rust/operator-binary/src/controller/build/resource/config_map.rs b/rust/operator-binary/src/controller/build/resource/config_map.rs index d402b96a..56a91c37 100644 --- a/rust/operator-binary/src/controller/build/resource/config_map.rs +++ b/rust/operator-binary/src/controller/build/resource/config_map.rs @@ -69,7 +69,7 @@ pub fn build_rolegroup_config_map( cluster .object_meta( cluster - .resource_names(role_group_name) + .role_group_resource_names(role_group_name) .role_group_config_map() .to_string(), role_group_name, diff --git a/rust/operator-binary/src/controller/build/resource/rbac.rs b/rust/operator-binary/src/controller/build/resource/rbac.rs index 8d52854b..e59a8818 100644 --- a/rust/operator-binary/src/controller/build/resource/rbac.rs +++ b/rust/operator-binary/src/controller/build/resource/rbac.rs @@ -20,7 +20,7 @@ stackable_operator::constant!(NONE_ROLE_GROUP_NAME: RoleGroupName = "none"); pub fn build_service_account(cluster: &ValidatedCluster) -> ServiceAccount { rbac::build_service_account( cluster, - &cluster.rbac_resource_names(), + &cluster.cluster_resource_names(), rbac_labels(cluster), ) } @@ -30,7 +30,7 @@ pub fn build_service_account(cluster: &ValidatedCluster) -> ServiceAccount { pub fn build_role_binding(cluster: &ValidatedCluster) -> RoleBinding { rbac::build_role_binding( cluster, - &cluster.rbac_resource_names(), + &cluster.cluster_resource_names(), rbac_labels(cluster), ) } diff --git a/rust/operator-binary/src/controller/build/resource/service.rs b/rust/operator-binary/src/controller/build/resource/service.rs index e2c6947e..52154992 100644 --- a/rust/operator-binary/src/controller/build/resource/service.rs +++ b/rust/operator-binary/src/controller/build/resource/service.rs @@ -22,7 +22,7 @@ pub fn build_rolegroup_headless_service( metadata: cluster .object_meta( cluster - .resource_names(role_group_name) + .role_group_resource_names(role_group_name) .headless_service_name() .to_string(), role_group_name, @@ -50,7 +50,7 @@ pub fn build_rolegroup_metrics_service( metadata: cluster .object_meta( cluster - .resource_names(role_group_name) + .role_group_resource_names(role_group_name) .metrics_service_name() .to_string(), role_group_name, diff --git a/rust/operator-binary/src/controller/build/resource/statefulset.rs b/rust/operator-binary/src/controller/build/resource/statefulset.rs index c1077b02..0f20eb15 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -180,7 +180,7 @@ pub(crate) fn build_node_rolegroup_statefulset( let git_sync_resources = &rg.config.git_sync_resources; // Type-safe names for this role group's resources (StatefulSet, ConfigMap, headless Service). - let resource_names = cluster.resource_names(role_group_name); + let resource_names = cluster.role_group_resource_names(role_group_name); // The validated, merged `NifiConfig` is the single source of truth; the ConfigMap builder // sources the same `rg.config`. @@ -603,7 +603,7 @@ pub(crate) fn build_node_rolegroup_statefulset( &cluster.cluster_config.server_tls_secret_class, &KEYSTORE_VOLUME_NAME, [cluster - .resource_names(role_group_name) + .role_group_resource_names(role_group_name) .metrics_service_name() .to_string()], SecretFormat::TlsPkcs12, @@ -643,7 +643,7 @@ pub(crate) fn build_node_rolegroup_statefulset( .context(AddVolumeSnafu)? .service_account_name( cluster - .rbac_resource_names() + .cluster_resource_names() .service_account_name() .to_string(), ) diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 7916738e..cc3d6d54 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -261,7 +261,7 @@ impl ValidatedCluster { /// Type-safe names for the per-cluster RBAC resources: the ServiceAccount shared by all /// Pods, its (namespaced) RoleBinding, and the operator-deployed ClusterRole it binds. - pub fn rbac_resource_names(&self) -> role_utils::ResourceNames { + pub fn cluster_resource_names(&self) -> role_utils::ResourceNames { role_utils::ResourceNames { cluster_name: self.name.clone(), product_name: product_name(), @@ -269,7 +269,10 @@ impl ValidatedCluster { } /// Type-safe names for the resources of a given role group. - pub(crate) fn resource_names(&self, role_group_name: &RoleGroupName) -> ResourceNames { + pub(crate) fn role_group_resource_names( + &self, + role_group_name: &RoleGroupName, + ) -> ResourceNames { ResourceNames { cluster_name: self.name.clone(), role_name: Self::role_name(), diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index e5fae7ee..8b3c1b4c 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -67,7 +67,6 @@ async fn main() -> anyhow::Result<()> { Command::Run(RunArguments { operator_environment, watch_namespace, - product_config: _, maintenance, common, }) => { From 12135a86c4bcf8ce2e2a47d8382589c2c583577a Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Thu, 23 Jul 2026 11:56:04 +0200 Subject: [PATCH 05/14] update lock file --- Cargo.lock | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ef708113..1c16c8db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1608,6 +1608,7 @@ dependencies = [ [[package]] name = "k8s-version" version = "0.1.3" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "darling", "regex", @@ -3005,6 +3006,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stackable-certs" version = "0.4.1" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "const-oid", "ecdsa", @@ -3054,7 +3056,8 @@ dependencies = [ [[package]] name = "stackable-operator" -version = "0.113.4" +version = "0.114.0" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "base64", "clap", @@ -3098,6 +3101,7 @@ dependencies = [ [[package]] name = "stackable-operator-derive" version = "0.3.1" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "darling", "proc-macro2", @@ -3108,6 +3112,7 @@ dependencies = [ [[package]] name = "stackable-shared" version = "0.1.2" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "jiff", "k8s-openapi", @@ -3124,6 +3129,7 @@ dependencies = [ [[package]] name = "stackable-telemetry" version = "0.6.5" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "axum", "clap", @@ -3147,6 +3153,7 @@ dependencies = [ [[package]] name = "stackable-versioned" version = "0.11.1" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "kube", "schemars", @@ -3160,6 +3167,7 @@ dependencies = [ [[package]] name = "stackable-versioned-macros" version = "0.11.1" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "convert_case", "convert_case_extras", @@ -3177,6 +3185,7 @@ dependencies = [ [[package]] name = "stackable-webhook" version = "0.9.2" +source = "git+https://github.com/stackabletech/operator-rs.git?tag=stackable-operator-0.114.0#ee66b8d66fe10c216acfed0f81070b362604b392" dependencies = [ "arc-swap", "async-trait", From a9d21db19fd65139170fdd504489831d2aa13965 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Thu, 23 Jul 2026 12:36:07 +0200 Subject: [PATCH 06/14] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 984ee009..5cc26e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,11 @@ All notable changes to this project will be documented in this file. - Internal operator refactoring: introduce a build() step in the reconciler that assembles all relevant Kubernetes resources before anything is applied ([#961]). - Bump stackable-operator to 0.114.0 ([#970]) +- The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac` + functions and carry the full set of recommended labels ([#966]). [#961]: https://github.com/stackabletech/nifi-operator/pull/961 +[#966]: https://github.com/stackabletech/nifi-operator/pull/966 [#970]: https://github.com/stackabletech/nifi-operator/pull/970 ## [26.7.0] - 2026-07-21 From e7e39b04a4a4de7e7d3ed4f0d70bf241c3d29576 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Fri, 24 Jul 2026 13:05:26 +0200 Subject: [PATCH 07/14] added role/From impl for single nifi role plus parsing test --- rust/operator-binary/src/controller/build.rs | 3 -- .../src/controller/build/resource/pdb.rs | 2 +- rust/operator-binary/src/controller/mod.rs | 37 ++++++++++++++----- rust/operator-binary/src/crd/mod.rs | 26 ++++++++++++- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 83479946..9231f305 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -67,9 +67,6 @@ pub enum Error { /// Does not need a Kubernetes client: every reference to another Kubernetes resource is already /// dereferenced and validated by this point, so the errors returned here are resource-assembly /// failures only. -/// -/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under -/// (RBAC resources are built and applied separately, in the reconcile step). pub fn build(cluster: &ValidatedCluster) -> Result { let mut stateful_sets = vec![]; let mut services = vec![]; diff --git a/rust/operator-binary/src/controller/build/resource/pdb.rs b/rust/operator-binary/src/controller/build/resource/pdb.rs index 9c8be14a..9f44c7f2 100644 --- a/rust/operator-binary/src/controller/build/resource/pdb.rs +++ b/rust/operator-binary/src/controller/build/resource/pdb.rs @@ -23,7 +23,7 @@ pub fn build_pdb( let pdb = pod_disruption_budget_builder_with_role( cluster, &product_name(), - &ValidatedCluster::role_name(), + &NifiRole::Node.into(), &operator_name(), &controller_name(), ) diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index cc3d6d54..2eebacb2 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -253,12 +253,6 @@ impl ValidatedCluster { } } - /// The single NiFi role name (`node`). - pub fn role_name() -> RoleName { - RoleName::from_str(&NifiRole::Node.to_string()) - .expect("the node role name is a valid role name") - } - /// Type-safe names for the per-cluster RBAC resources: the ServiceAccount shared by all /// Pods, its (namespaced) RoleBinding, and the operator-deployed ClusterRole it binds. pub fn cluster_resource_names(&self) -> role_utils::ResourceNames { @@ -275,14 +269,14 @@ impl ValidatedCluster { ) -> ResourceNames { ResourceNames { cluster_name: self.name.clone(), - role_name: Self::role_name(), + role_name: NifiRole::Node.into(), role_group_name: role_group_name.clone(), } } /// Recommended labels for a role-group resource. pub fn recommended_labels(&self, role_group_name: &RoleGroupName) -> Labels { - self.recommended_labels_for(&Self::role_name(), role_group_name) + self.recommended_labels_for(&NifiRole::Node.into(), role_group_name) } /// Recommended labels for a resource that is not tied to a concrete role, using a free-form role/role-group label value. @@ -299,7 +293,7 @@ impl ValidatedCluster { pub fn unversioned_recommended_labels(&self, role_group_name: &RoleGroupName) -> Labels { self.recommended_labels_with( &UNVERSIONED_PRODUCT_VERSION, - &Self::role_name(), + &NifiRole::Node.into(), role_group_name, ) } @@ -323,7 +317,12 @@ impl ValidatedCluster { /// Selector labels matching the pods of a role group. pub fn role_group_selector(&self, role_group_name: &RoleGroupName) -> Labels { - role_group_selector(self, &product_name(), &Self::role_name(), role_group_name) + role_group_selector( + self, + &product_name(), + &NifiRole::Node.into(), + role_group_name, + ) } /// Returns an [`ObjectMetaBuilder`](stackable_operator::builder::meta::ObjectMetaBuilder) @@ -418,3 +417,21 @@ impl Resource for ValidatedCluster { &mut self.metadata } } + +#[cfg(test)] +mod tests { + use stackable_operator::v2::types::operator::RoleName; + use strum::IntoEnumIterator; + + use crate::crd::NifiRole; + + /// Locks the invariant behind the `expect` in the `From for RoleName` impls: + /// every `NifiRole` variant (present and future) must serialise to a valid `RoleName`. + #[test] + fn every_nifi_role_serialises_to_a_valid_role_name() { + for role in NifiRole::iter() { + let _: RoleName = (&role).into(); + let _: RoleName = role.into(); + } + } +} diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 1d1acf67..dcc4b2cb 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -36,7 +36,7 @@ use stackable_operator::{ role_utils::JavaCommonConfig, types::{ kubernetes::{ConfigMapName, ListenerClassName, SecretClassName}, - operator::ProductVersion, + operator::{ProductVersion, RoleName}, }, }, versioned::versioned, @@ -224,7 +224,17 @@ pub fn default_allow_all() -> bool { } #[derive( - Clone, Debug, Deserialize, Eq, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, strum::Display, + Clone, + Debug, + Deserialize, + Eq, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + strum::Display, + strum::EnumIter, )] #[serde(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] @@ -233,6 +243,18 @@ pub enum NifiRole { Node, } +impl From for RoleName { + fn from(value: NifiRole) -> Self { + RoleName::from_str(&value.to_string()).expect("a NifiRole is a valid role name") + } +} + +impl From<&NifiRole> for RoleName { + fn from(value: &NifiRole) -> Self { + RoleName::from_str(&value.to_string()).expect("a NifiRole is a valid role name") + } +} + #[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] pub struct NifiStatus { pub deployed_version: Option, From 0a9f3f88cc5d31d95b291d74894d0837d21dca90 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:05:44 +0200 Subject: [PATCH 08/14] use the role parameter via its From impl in build_pdb --- rust/operator-binary/src/controller/build/resource/pdb.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/operator-binary/src/controller/build/resource/pdb.rs b/rust/operator-binary/src/controller/build/resource/pdb.rs index 9f44c7f2..ec44cd3c 100644 --- a/rust/operator-binary/src/controller/build/resource/pdb.rs +++ b/rust/operator-binary/src/controller/build/resource/pdb.rs @@ -23,7 +23,7 @@ pub fn build_pdb( let pdb = pod_disruption_budget_builder_with_role( cluster, &product_name(), - &NifiRole::Node.into(), + &role.into(), &operator_name(), &controller_name(), ) From 5a4918750b19e9e17cfec6f27ee0854c5662393f Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:12:20 +0200 Subject: [PATCH 09/14] moved tests and made them more thorough --- rust/operator-binary/src/controller/build.rs | 48 +--------- .../src/controller/build/properties.rs | 18 +++- .../src/controller/build/resource/rbac.rs | 96 +++++++++++++++++++ .../src/controller/build/resource/service.rs | 71 +++++++++++++- 4 files changed, 183 insertions(+), 50 deletions(-) diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 9231f305..20482f1f 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -124,8 +124,6 @@ pub fn build(cluster: &ValidatedCluster) -> Result { #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use stackable_operator::kube::Resource; use super::{build, properties::test_support::minimal_validated_cluster}; @@ -161,20 +159,7 @@ mod tests { sorted_names(&resources.pod_disruption_budgets), ["simple-nifi-node"] ); - } - - /// Locks the RBAC resource names, the roleRef, and the recommended label set against - /// accidental drift. The fixture's cluster name deliberately differs from the product name so - /// that swapped `name`/`instance` label values cannot pass unnoticed. - /// - /// The version label is the bare `2.9.0` because the fixture hand-builds its - /// [`ResolvedProductImage`](stackable_operator::commons::product_image_selection::ResolvedProductImage) - /// instead of resolving it (which would append the `-stackable…` suffix). - #[test] - fn build_produces_rbac() { - let cluster = minimal_validated_cluster(); - let resources = build(&cluster).expect("build succeeds"); - + // The cluster-shared RBAC pair. assert_eq!( sorted_names(&resources.service_accounts), ["simple-nifi-serviceaccount"] @@ -183,36 +168,5 @@ mod tests { sorted_names(&resources.role_bindings), ["simple-nifi-rolebinding"] ); - - let expected_labels = BTreeMap::from( - [ - ("app.kubernetes.io/component", "none"), - ("app.kubernetes.io/instance", "simple-nifi"), - ( - "app.kubernetes.io/managed-by", - "nifi.stackable.tech_nificluster", - ), - ("app.kubernetes.io/name", "nifi"), - ("app.kubernetes.io/role-group", "none"), - ("app.kubernetes.io/version", "2.9.0"), - ("stackable.tech/vendor", "Stackable"), - ] - .map(|(key, value)| (key.to_string(), value.to_string())), - ); - let service_account = resources - .service_accounts - .first() - .expect("a ServiceAccount is built"); - assert_eq!( - service_account.metadata.labels, - Some(expected_labels.clone()) - ); - - let role_binding = resources - .role_bindings - .first() - .expect("a RoleBinding is built"); - assert_eq!(role_binding.metadata.labels, Some(expected_labels)); - assert_eq!(role_binding.role_ref.name, "nifi-clusterrole"); } } diff --git a/rust/operator-binary/src/controller/build/properties.rs b/rust/operator-binary/src/controller/build/properties.rs index 58c4ba0c..aab20fe5 100644 --- a/rust/operator-binary/src/controller/build/properties.rs +++ b/rust/operator-binary/src/controller/build/properties.rs @@ -104,6 +104,18 @@ pub(crate) mod test_support { }, }; + /// The expected `app.kubernetes.io/version` label value for the given product version. + /// + /// The `-stackable` suffix carries the operator's own version, which is `0.0.0-dev` on main + /// but rewritten by the release process — so tests must derive it rather than hardcode it, + /// or they fail on release branches. + pub fn app_version_label(product_version: &str) -> String { + format!( + "{product_version}-stackable{}", + crate::built_info::PKG_VERSION + ) + } + /// A minimal NiFi cluster YAML. Mirrors the fixture used by bootstrap_conf tests, /// stripped down to the mandatory fields only (Kubernetes clustering backend, SingleUser auth). pub const MINIMAL_NIFI_YAML: &str = r#" @@ -140,10 +152,12 @@ pub(crate) mod test_support { let nifi: v1alpha1::NifiCluster = serde_yaml::from_str(MINIMAL_NIFI_YAML).expect("invalid test YAML"); + // Mirrors what `image.resolve()` produces in production, so label-asserting tests see + // realistic values. let image = ResolvedProductImage { product_version: "2.9.0".to_string(), - app_version_label_value: "2.9.0".parse::().unwrap(), - image: "oci.stackable.tech/sdp/nifi:2.9.0-stackable0.0.0-dev".to_string(), + app_version_label_value: app_version_label("2.9.0").parse::().unwrap(), + image: format!("oci.stackable.tech/sdp/nifi:{}", app_version_label("2.9.0")), image_pull_policy: "IfNotPresent".to_string(), pull_secrets: None, }; diff --git a/rust/operator-binary/src/controller/build/resource/rbac.rs b/rust/operator-binary/src/controller/build/resource/rbac.rs index e59a8818..b9a98678 100644 --- a/rust/operator-binary/src/controller/build/resource/rbac.rs +++ b/rust/operator-binary/src/controller/build/resource/rbac.rs @@ -40,3 +40,99 @@ pub fn build_role_binding(cluster: &ValidatedCluster) -> RoleBinding { fn rbac_labels(cluster: &ValidatedCluster) -> Labels { cluster.recommended_labels_for(&NONE_ROLE_NAME, &NONE_ROLE_GROUP_NAME) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::controller::build::properties::test_support::{ + app_version_label, minimal_validated_cluster, + }; + + // The fixture's cluster name (`simple-nifi`) deliberately differs from the product name + // (`nifi`), so swapped `name`/`instance` label values cannot pass unnoticed. + + #[test] + fn test_service_account() { + let service_account = build_service_account(&minimal_validated_cluster()); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + // The RBAC resources are cluster-shared, so role and role group are `none`. + "labels": { + "app.kubernetes.io/component": "none", + "app.kubernetes.io/instance": "simple-nifi", + "app.kubernetes.io/managed-by": "nifi.stackable.tech_nificluster", + "app.kubernetes.io/name": "nifi", + "app.kubernetes.io/role-group": "none", + "app.kubernetes.io/version": app_version_label("2.9.0"), + "stackable.tech/vendor": "Stackable" + }, + "name": "simple-nifi-serviceaccount", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "nifi.stackable.tech/v1alpha1", + "controller": true, + "kind": "NifiCluster", + "name": "simple-nifi", + "uid": "e6ac237d-a6d4-43a1-8135-f36506110912" + } + ] + } + }), + serde_json::to_value(service_account).expect("must be serializable") + ); + } + + #[test] + fn test_role_binding() { + let role_binding = build_role_binding(&minimal_validated_cluster()); + + assert_eq!( + json!({ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "labels": { + "app.kubernetes.io/component": "none", + "app.kubernetes.io/instance": "simple-nifi", + "app.kubernetes.io/managed-by": "nifi.stackable.tech_nificluster", + "app.kubernetes.io/name": "nifi", + "app.kubernetes.io/role-group": "none", + "app.kubernetes.io/version": app_version_label("2.9.0"), + "stackable.tech/vendor": "Stackable" + }, + "name": "simple-nifi-rolebinding", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "nifi.stackable.tech/v1alpha1", + "controller": true, + "kind": "NifiCluster", + "name": "simple-nifi", + "uid": "e6ac237d-a6d4-43a1-8135-f36506110912" + } + ] + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "nifi-clusterrole" + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": "simple-nifi-serviceaccount", + "namespace": "default" + } + ] + }), + serde_json::to_value(role_binding).expect("must be serializable") + ); + } +} diff --git a/rust/operator-binary/src/controller/build/resource/service.rs b/rust/operator-binary/src/controller/build/resource/service.rs index 52154992..f6ff69bd 100644 --- a/rust/operator-binary/src/controller/build/resource/service.rs +++ b/rust/operator-binary/src/controller/build/resource/service.rs @@ -103,9 +103,12 @@ mod tests { use std::str::FromStr as _; use pretty_assertions::assert_eq; + use serde_json::json; use super::*; - use crate::controller::build::properties::test_support::minimal_validated_cluster; + use crate::controller::build::properties::test_support::{ + app_version_label, minimal_validated_cluster, + }; #[test] fn headless_service_is_cluster_ip_none_with_https_port() { @@ -124,4 +127,70 @@ mod tests { assert_eq!(Some(HTTPS_PORT_NAME.to_string()), ports[0].name); assert_eq!(i32::from(HTTPS_PORT), ports[0].port); } + + /// Every metrics Service must carry the Prometheus scrape label and the + /// `prometheus.io/path|port|scheme|scrape` annotations, or Prometheus stops discovering the + /// endpoints. + #[test] + fn test_rolegroup_metrics_service() { + let cluster = minimal_validated_cluster(); + let role_group_name = RoleGroupName::from_str("default").expect("valid role-group name"); + + let service = build_rolegroup_metrics_service(&cluster, &role_group_name); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "prometheus.io/path": "/nifi-api/flow/metrics/prometheus", + "prometheus.io/port": "8443", + "prometheus.io/scheme": "https", + "prometheus.io/scrape": "true" + }, + "labels": { + "app.kubernetes.io/component": "node", + "app.kubernetes.io/instance": "simple-nifi", + "app.kubernetes.io/managed-by": "nifi.stackable.tech_nificluster", + "app.kubernetes.io/name": "nifi", + "app.kubernetes.io/role-group": "default", + "app.kubernetes.io/version": app_version_label("2.9.0"), + "prometheus.io/scrape": "true", + "stackable.tech/vendor": "Stackable" + }, + "name": "simple-nifi-node-default-metrics", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "nifi.stackable.tech/v1alpha1", + "controller": true, + "kind": "NifiCluster", + "name": "simple-nifi", + "uid": "e6ac237d-a6d4-43a1-8135-f36506110912" + } + ] + }, + "spec": { + "clusterIP": "None", + "ports": [ + { + "name": "https", + "port": 8443, + "protocol": "TCP" + } + ], + "publishNotReadyAddresses": true, + "selector": { + "app.kubernetes.io/component": "node", + "app.kubernetes.io/instance": "simple-nifi", + "app.kubernetes.io/name": "nifi", + "app.kubernetes.io/role-group": "default" + }, + "type": "ClusterIP" + } + }), + serde_json::to_value(service).expect("must be serializable") + ); + } } From d03518a9d55b0da27e742c018b788a1576a77d40 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:16:08 +0200 Subject: [PATCH 10/14] move object_meta to build step --- rust/operator-binary/src/controller/build.rs | 30 ++++++++++++- .../controller/build/resource/config_map.rs | 19 +++++---- .../src/controller/build/resource/listener.rs | 14 +++---- .../src/controller/build/resource/service.rs | 42 +++++++++---------- .../controller/build/resource/statefulset.rs | 15 +++---- rust/operator-binary/src/controller/mod.rs | 29 ------------- 6 files changed, 75 insertions(+), 74 deletions(-) diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 20482f1f..084f73ac 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -5,7 +5,13 @@ use std::str::FromStr; use snafu::{OptionExt, ResultExt, Snafu}; -use stackable_operator::v2::types::{common::Port, operator::RoleGroupName}; +use stackable_operator::{ + builder::meta::ObjectMetaBuilder, + v2::{ + builder::meta::ownerreference_from_resource, + types::{common::Port, operator::RoleGroupName}, + }, +}; use crate::{ controller::{ @@ -122,6 +128,28 @@ pub fn build(cluster: &ValidatedCluster) -> Result { }) } +/// Returns an [`ObjectMetaBuilder`] pre-filled with the namespace, an owner reference back to +/// the cluster, and the recommended labels for a resource named `name` in `role_group_name`. +/// +/// Consolidates the metadata chain repeated by the child-resource builders. Call sites that +/// need extra labels/annotations chain them onto the returned builder. Role-level resources +/// (e.g. the per-role [`Listener`](stackable_operator::crd::listener::v1alpha1::Listener)) pass +/// the placeholder role-group `none`, preserving the historical +/// `app.kubernetes.io/role-group: none` label. +pub(crate) fn object_meta( + cluster: &ValidatedCluster, + name: impl Into, + role_group_name: &RoleGroupName, +) -> ObjectMetaBuilder { + let mut builder = ObjectMetaBuilder::new(); + builder + .name_and_namespace(cluster) + .name(name) + .ownerreference(ownerreference_from_resource(cluster, None, Some(true))) + .with_labels(cluster.recommended_labels(role_group_name)); + builder +} + #[cfg(test)] mod tests { use stackable_operator::kube::Resource; diff --git a/rust/operator-binary/src/controller/build/resource/config_map.rs b/rust/operator-binary/src/controller/build/resource/config_map.rs index 56a91c37..7f4cc896 100644 --- a/rust/operator-binary/src/controller/build/resource/config_map.rs +++ b/rust/operator-binary/src/controller/build/resource/config_map.rs @@ -9,6 +9,7 @@ use stackable_operator::{ use crate::controller::{ NifiRoleGroupConfig, ValidatedCluster, build::{ + object_meta, properties::{ ConfigFileName, authorizers, bootstrap_conf, login_identity_providers, nifi_properties, product_logging, security_properties, state_management_xml, @@ -66,15 +67,15 @@ pub fn build_rolegroup_config_map( cm_builder .metadata( - cluster - .object_meta( - cluster - .role_group_resource_names(role_group_name) - .role_group_config_map() - .to_string(), - role_group_name, - ) - .build(), + object_meta( + cluster, + cluster + .role_group_resource_names(role_group_name) + .role_group_config_map() + .to_string(), + role_group_name, + ) + .build(), ) .add_data( ConfigFileName::BootstrapConf.to_string(), diff --git a/rust/operator-binary/src/controller/build/resource/listener.rs b/rust/operator-binary/src/controller/build/resource/listener.rs index fe979328..11151b7c 100644 --- a/rust/operator-binary/src/controller/build/resource/listener.rs +++ b/rust/operator-binary/src/controller/build/resource/listener.rs @@ -14,7 +14,7 @@ use stackable_operator::{ use crate::controller::{ ValidatedCluster, - build::{HTTPS_PORT, HTTPS_PORT_NAME, PLACEHOLDER_LISTENER_ROLE_GROUP}, + build::{HTTPS_PORT, HTTPS_PORT_NAME, PLACEHOLDER_LISTENER_ROLE_GROUP, object_meta}, }; pub const LISTENER_VOLUME_NAME: &str = "listener"; @@ -29,12 +29,12 @@ pub fn build_group_listener( listener_group_name: ListenerName, ) -> Listener { Listener { - metadata: cluster - .object_meta( - listener_group_name.to_string(), - &PLACEHOLDER_LISTENER_ROLE_GROUP, - ) - .build(), + metadata: object_meta( + cluster, + listener_group_name.to_string(), + &PLACEHOLDER_LISTENER_ROLE_GROUP, + ) + .build(), spec: ListenerSpec { class_name: Some(listener_class.to_string()), ports: Some(vec![ListenerPort { diff --git a/rust/operator-binary/src/controller/build/resource/service.rs b/rust/operator-binary/src/controller/build/resource/service.rs index f6ff69bd..0aeab6d9 100644 --- a/rust/operator-binary/src/controller/build/resource/service.rs +++ b/rust/operator-binary/src/controller/build/resource/service.rs @@ -9,7 +9,7 @@ use stackable_operator::{ use crate::controller::{ ValidatedCluster, - build::{HTTPS_PORT, HTTPS_PORT_NAME}, + build::{HTTPS_PORT, HTTPS_PORT_NAME, object_meta}, }; /// The rolegroup headless [`Service`] is a service that allows direct access to the instances of a certain rolegroup @@ -19,15 +19,15 @@ pub fn build_rolegroup_headless_service( role_group_name: &RoleGroupName, ) -> Service { Service { - metadata: cluster - .object_meta( - cluster - .role_group_resource_names(role_group_name) - .headless_service_name() - .to_string(), - role_group_name, - ) - .build(), + metadata: object_meta( + cluster, + cluster + .role_group_resource_names(role_group_name) + .headless_service_name() + .to_string(), + role_group_name, + ) + .build(), spec: Some(ServiceSpec { // Internal communication does not need to be exposed type_: Some("ClusterIP".to_string()), @@ -47,17 +47,17 @@ pub fn build_rolegroup_metrics_service( role_group_name: &RoleGroupName, ) -> Service { Service { - metadata: cluster - .object_meta( - cluster - .role_group_resource_names(role_group_name) - .metrics_service_name() - .to_string(), - role_group_name, - ) - .with_labels(service::prometheus_labels(&Scraping::Enabled)) - .with_annotations(prometheus_annotations()) - .build(), + metadata: object_meta( + cluster, + cluster + .role_group_resource_names(role_group_name) + .metrics_service_name() + .to_string(), + role_group_name, + ) + .with_labels(service::prometheus_labels(&Scraping::Enabled)) + .with_annotations(prometheus_annotations()) + .build(), spec: Some(ServiceSpec { // Internal communication does not need to be exposed type_: Some("ClusterIP".to_string()), diff --git a/rust/operator-binary/src/controller/build/resource/statefulset.rs b/rust/operator-binary/src/controller/build/resource/statefulset.rs index 0f20eb15..98df6735 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -52,6 +52,7 @@ use crate::{ BALANCE_PORT, BALANCE_PORT_NAME, HTTPS_PORT, HTTPS_PORT_NAME, NIFI_CONFIG_DIRECTORY, NIFI_PYTHON_WORKING_DIRECTORY, PROTOCOL_PORT, PROTOCOL_PORT_NAME, graceful_shutdown::add_graceful_shutdown_config, + object_meta, properties::ConfigFileName, resource::listener::{ LISTENER_VOLUME_DIR, LISTENER_VOLUME_NAME, build_group_listener_pvc, @@ -654,13 +655,13 @@ pub(crate) fn build_node_rolegroup_statefulset( pod_template.merge_from(rg.pod_overrides.clone()); Ok(StatefulSet { - metadata: cluster - .object_meta( - resource_names.stateful_set_name().to_string(), - role_group_name, - ) - .with_label(RESTART_CONTROLLER_ENABLED_LABEL.to_owned()) - .build(), + metadata: object_meta( + cluster, + resource_names.stateful_set_name().to_string(), + role_group_name, + ) + .with_label(RESTART_CONTROLLER_ENABLED_LABEL.to_owned()) + .build(), spec: Some(StatefulSetSpec { pod_management_policy: Some("Parallel".to_string()), replicas: effective_replicas, diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 2eebacb2..7efc6aa4 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -324,35 +324,6 @@ impl ValidatedCluster { role_group_name, ) } - - /// Returns an [`ObjectMetaBuilder`](stackable_operator::builder::meta::ObjectMetaBuilder) - /// pre-filled with the namespace, an owner reference back to this cluster, and the recommended - /// labels for a resource named `name` in `role_group_name`. - /// - /// Consolidates the metadata chain repeated by the child-resource builders. Call sites that - /// need extra labels/annotations chain them onto the returned builder. Role-level resources - /// (e.g. the per-role [`Listener`](stackable_operator::crd::listener::v1alpha1::Listener)) pass - /// the placeholder role-group `none`, preserving the historical - /// `app.kubernetes.io/role-group: none` label. - pub(crate) fn object_meta( - &self, - name: impl Into, - role_group_name: &RoleGroupName, - ) -> stackable_operator::builder::meta::ObjectMetaBuilder { - let mut builder = stackable_operator::builder::meta::ObjectMetaBuilder::new(); - builder - .name_and_namespace(self) - .name(name) - .ownerreference( - stackable_operator::v2::builder::meta::ownerreference_from_resource( - self, - None, - Some(true), - ), - ) - .with_labels(self.recommended_labels(role_group_name)); - builder - } } /// The product name (`nifi`) as a type-safe label value. From b2886c19660f3be8da563bd7a6a697b520b20e83 Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:21:33 +0200 Subject: [PATCH 11/14] make the nodes role required in the CRD --- CHANGELOG.md | 2 ++ extra/crds.yaml | 2 +- rust/operator-binary/src/controller/build.rs | 7 ++----- .../src/controller/build/properties.rs | 12 +++++------ .../src/controller/validate.rs | 21 +++++++------------ rust/operator-binary/src/crd/mod.rs | 7 +++---- 6 files changed, 21 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cc26e13..3814242e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. - Bump stackable-operator to 0.114.0 ([#970]) - The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac` functions and carry the full set of recommended labels ([#966]). +- BREAKING: The `nodes` role is now required by the CRD; a NifiCluster without it was + previously accepted by the API server but failed reconciliation ([#966]). [#961]: https://github.com/stackabletech/nifi-operator/pull/961 [#966]: https://github.com/stackabletech/nifi-operator/pull/966 diff --git a/extra/crds.yaml b/extra/crds.yaml index 73720bae..8d9b8e7e 100644 --- a/extra/crds.yaml +++ b/extra/crds.yaml @@ -489,7 +489,6 @@ spec: at role level, the `roleConfig`. You can learn more about this in the [Roles and role group concept documentation](https://docs.stackable.tech/home/nightly/concepts/roles-and-role-groups). - nullable: true properties: cliOverrides: additionalProperties: @@ -2209,6 +2208,7 @@ spec: required: - clusterConfig - image + - nodes type: object status: nullable: true diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 084f73ac..6376f9df 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -4,7 +4,7 @@ use std::str::FromStr; -use snafu::{OptionExt, ResultExt, Snafu}; +use snafu::{ResultExt, Snafu}; use stackable_operator::{ builder::meta::ObjectMetaBuilder, v2::{ @@ -52,9 +52,6 @@ pub const NIFI_PYTHON_WORKING_DIRECTORY: &str = "/nifi-python-working-directory" #[derive(Snafu, Debug)] pub enum Error { - #[snafu(display("NifiCluster has no nodes role defined"))] - NoNodesDefined, - #[snafu(display("failed to build ConfigMap for role group {role_group}"))] ConfigMap { source: resource::config_map::Error, @@ -85,7 +82,7 @@ pub fn build(cluster: &ValidatedCluster) -> Result { let node_role_group_configs = cluster .role_group_configs .get(&nifi_role) - .context(NoNodesDefinedSnafu)?; + .expect("the nodes role is required by the CRD and validate always inserts it"); // Role-level resources (one per role): the PodDisruptionBudget and the group Listener. let role_config = &cluster.role_config; diff --git a/rust/operator-binary/src/controller/build/properties.rs b/rust/operator-binary/src/controller/build/properties.rs index aab20fe5..a91935be 100644 --- a/rust/operator-binary/src/controller/build/properties.rs +++ b/rust/operator-binary/src/controller/build/properties.rs @@ -165,13 +165,11 @@ pub(crate) mod test_support { let role_group_configs = build_role_group_configs(&nifi, &image, &None) .expect("role group configs should merge for minimal fixture"); - let role_config = nifi - .role_config(&NifiRole::Node) - .map(|role_config| ValidatedRoleConfig { - pdb: role_config.common.pod_disruption_budget.clone(), - listener_class: role_config.listener_class.clone(), - }) - .expect("the minimal fixture defines the nodes role"); + let node_role_config = nifi.role_config(&NifiRole::Node); + let role_config = ValidatedRoleConfig { + pdb: node_role_config.common.pod_disruption_budget.clone(), + listener_class: node_role_config.listener_class.clone(), + }; let name = ClusterName::from_str("simple-nifi").expect("valid cluster name"); let namespace = NamespaceName::from_str("default").expect("valid namespace"); diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index c5d7950a..545a8eb5 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -53,9 +53,6 @@ pub enum Error { source: product_image_selection::Error, }, - #[snafu(display("object has no nodes defined"))] - NoNodesDefined, - #[snafu(display("failed to get the cluster name"))] GetClusterName { source: controller_utils::Error }, @@ -145,15 +142,13 @@ pub fn validate( build_role_group_configs(nifi, &image, &vector_aggregator_config_map_name)?; // Per-role config (PDB + listener class), extracted here so downstream builders source it from - // the `ValidatedCluster` rather than the raw `NifiCluster`. The `nodes` role is mandatory - // (already enforced by `build_role_group_configs` above), so this is always present. - let role_config = nifi - .role_config(&NifiRole::Node) - .map(|role_config| ValidatedRoleConfig { - pdb: role_config.common.pod_disruption_budget.clone(), - listener_class: role_config.listener_class.clone(), - }) - .context(NoNodesDefinedSnafu)?; + // the `ValidatedCluster` rather than the raw `NifiCluster`. The `nodes` role is required by + // the CRD, so this is always present. + let node_role_config = nifi.role_config(&NifiRole::Node); + let role_config = ValidatedRoleConfig { + pdb: node_role_config.common.pod_disruption_budget.clone(), + listener_class: node_role_config.listener_class.clone(), + }; let namespace = dereferenced_objects.namespace.clone(); let cluster_domain = dereferenced_objects.cluster_domain.clone(); @@ -199,7 +194,7 @@ pub(crate) fn build_role_group_configs( image: &product_image_selection::ResolvedProductImage, vector_aggregator_config_map_name: &Option, ) -> Result>> { - let role = nifi.spec.nodes.as_ref().context(NoNodesDefinedSnafu)?; + let role = &nifi.spec.nodes; let default_config = NifiConfig::default_config(&nifi.name_any(), &NifiRole::Node); let mut groups: BTreeMap = BTreeMap::new(); diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index dcc4b2cb..e573d8a8 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -79,8 +79,7 @@ pub mod versioned { pub cluster_config: v1alpha1::NifiClusterConfig, // no doc - docs in Role struct. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nodes: Option, + pub nodes: NifiRoleType, // no doc - docs in ProductImage struct. pub image: ProductImage, @@ -186,9 +185,9 @@ impl HasStatusCondition for v1alpha1::NifiCluster { } impl v1alpha1::NifiCluster { - pub fn role_config(&self, role: &NifiRole) -> Option<&NifiNodeRoleConfig> { + pub fn role_config(&self, role: &NifiRole) -> &NifiNodeRoleConfig { match role { - NifiRole::Node => self.spec.nodes.as_ref().map(|n| &n.role_config), + NifiRole::Node => &self.spec.nodes.role_config, } } From 144b73bcbe6f189f2d71c6ba75a0acf12166fc6e Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:29:32 +0200 Subject: [PATCH 12/14] add validate happy-path test covering all derived values --- .../src/controller/validate.rs | 139 +++++++++++++++++- .../src/security/authentication.rs | 12 ++ .../src/security/authorization.rs | 9 ++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index 545a8eb5..498db02e 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -311,9 +311,146 @@ pub(crate) fn test_resolved_product_image() -> product_image_selection::Resolved #[cfg(test)] mod tests { use pretty_assertions::assert_eq; - use stackable_operator::v2::types::kubernetes::ConfigMapName; + use stackable_operator::{ + commons::networking::DomainName, crd::authentication::core as auth_core, + v2::types::kubernetes::ConfigMapName, + }; use super::*; + use crate::{ + controller::build::properties::test_support::app_version_label, + security::{ + authentication::DereferencedAuthenticationClasses, + authorization::DereferencedAuthorization, + }, + }; + + /// Locks every value the validate step itself derives from the minimal fixture — so a + /// validation regression fails here, with a validate-shaped message, instead of surfacing as + /// a confusing build-test failure downstream. + /// + /// The merged per-role-group config (resources, affinity, logging defaults, …) is produced by + /// `with_validated_config` and the config defaults, whose contracts are tested in operator-rs; + /// only the values this module derives on top are re-asserted here. + #[test] + fn validate_ok_derives_expected_values() { + let yaml = r#" + apiVersion: nifi.stackable.tech/v1alpha1 + kind: NifiCluster + metadata: + name: simple-nifi + namespace: default + uid: e6ac237d-a6d4-43a1-8135-f36506110912 + spec: + image: + productVersion: 2.9.0 + clusterConfig: + authentication: + - authenticationClass: nifi-admin-credentials-simple + sensitiveProperties: + keySecret: simple-nifi-sensitive-property-key + autoGenerate: true + nodes: + roleGroups: + default: + replicas: 1 + "#; + let nifi: v1alpha1::NifiCluster = serde_yaml::from_str(yaml).expect("valid test YAML"); + + let auth_class: auth_core::v1alpha1::AuthenticationClass = serde_yaml::from_str( + r#" + metadata: + name: nifi-admin-credentials-simple + spec: + provider: !static + userCredentialsSecret: + name: nifi-admin-credentials-simple + "#, + ) + .expect("valid static AuthenticationClass"); + let auth_entry: auth_core::v1alpha1::ClientAuthenticationDetails = + serde_yaml::from_str("authenticationClass: nifi-admin-credentials-simple") + .expect("valid authentication entry"); + + let dereferenced_objects = DereferencedObjects { + namespace: "default".parse().expect("valid namespace"), + cluster_domain: DomainName::from_str("cluster.local").expect("valid cluster domain"), + authentication_classes: DereferencedAuthenticationClasses::from_entries(vec![( + auth_entry, auth_class, + )]), + authorization: DereferencedAuthorization::without_opa(), + }; + let operator_environment = OperatorEnvironmentOptions { + operator_namespace: "stackable-operators".to_owned(), + operator_service_name: "nifi-operator".to_owned(), + image_repository: "oci.example.org".to_owned(), + }; + + let cluster = validate(&nifi, &dereferenced_objects, &operator_environment) + .expect("the minimal fixture validates"); + + assert_eq!(cluster.name.to_string(), "simple-nifi"); + assert_eq!(cluster.namespace.to_string(), "default"); + assert_eq!( + cluster.uid.to_string(), + "e6ac237d-a6d4-43a1-8135-f36506110912" + ); + assert_eq!(cluster.cluster_domain.to_string(), "cluster.local"); + assert_eq!( + cluster.image.image, + format!("oci.example.org/nifi:{}", app_version_label("2.9.0")) + ); + assert_eq!(cluster.image.product_version, "2.9.0"); + assert_eq!( + cluster.product_version.to_string(), + app_version_label("2.9.0") + ); + + // The role config falls back to its defaults: PDBs enabled, cluster-internal listener. + assert!(cluster.role_config.pdb.enabled); + assert_eq!(cluster.role_config.pdb.max_unavailable, None); + assert_eq!( + cluster.role_config.listener_class.to_string(), + "cluster-internal" + ); + + // SingleUser authentication and authorization, the default (Kubernetes) clustering + // backend, and the default `tls` server SecretClass. + let cluster_config = &cluster.cluster_config; + assert!(matches!( + &cluster_config.authentication, + NifiAuthenticationConfig::SingleUser { provider } + if provider.user_credentials_secret.name == "nifi-admin-credentials-simple" + )); + assert!(matches!( + cluster_config.authorization, + ResolvedNifiAuthorizationConfig::SingleUser + )); + assert!(matches!( + cluster_config.clustering_backend, + v1alpha1::NifiClusteringBackend::Kubernetes {} + )); + assert_eq!(cluster_config.server_tls_secret_class.to_string(), "tls"); + assert_eq!( + cluster_config.sensitive_properties.key_secret.to_string(), + "simple-nifi-sensitive-property-key" + ); + assert!(cluster_config.sensitive_properties.auto_generate); + assert!(cluster_config.extra_volumes.is_empty()); + + // A single `node` role with the single `default` role group; the Vector agent is off. + assert_eq!(cluster.role_group_configs.len(), 1); + let role_groups = &cluster.role_group_configs[&NifiRole::Node]; + let role_group_names: Vec = role_groups.keys().map(ToString::to_string).collect(); + assert_eq!(role_group_names, ["default"]); + let role_group = role_groups + .values() + .next() + .expect("the default role group exists"); + assert_eq!(role_group.replicas, Some(1)); + assert!(!role_group.config.logging.enable_vector_agent); + assert_eq!(role_group.config.logging.vector_container, None); + } /// A NiFi cluster with the Vector agent enabled at the Node role level. const NIFI_VECTOR_ENABLED_YAML: &str = r#" diff --git a/rust/operator-binary/src/security/authentication.rs b/rust/operator-binary/src/security/authentication.rs index bc9eeb18..0d561c03 100644 --- a/rust/operator-binary/src/security/authentication.rs +++ b/rust/operator-binary/src/security/authentication.rs @@ -114,6 +114,18 @@ impl DereferencedAuthenticationClasses { Ok(DereferencedAuthenticationClasses { entries }) } + + /// Builds the struct directly from already-fetched entries, for tests that cannot use the + /// client-based [`Self::dereference`]. + #[cfg(test)] + pub(crate) fn from_entries( + entries: Vec<( + auth_core::v1alpha1::ClientAuthenticationDetails, + auth_core::v1alpha1::AuthenticationClass, + )>, + ) -> Self { + Self { entries } + } } #[derive(Clone)] diff --git a/rust/operator-binary/src/security/authorization.rs b/rust/operator-binary/src/security/authorization.rs index 14c6e679..4789a95a 100644 --- a/rust/operator-binary/src/security/authorization.rs +++ b/rust/operator-binary/src/security/authorization.rs @@ -60,6 +60,15 @@ pub struct DereferencedAuthorization { } impl DereferencedAuthorization { + /// A dereferenced authorization with no OPA ConfigMap, for tests that cannot use the + /// client-based [`Self::dereference`]. + #[cfg(test)] + pub(crate) fn without_opa() -> Self { + Self { + opa_config_map: None, + } + } + /// Fetch the OPA ConfigMap referenced by the authorization spec, if applicable. pub async fn dereference( nifi_authorization: &NifiAuthorization, From 5ecb72aeb78f3eb8b00558c8e83a6ed14520f9ac Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:39:37 +0200 Subject: [PATCH 13/14] improved comment --- rust/operator-binary/src/controller/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index 7efc6aa4..dc8a01c5 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -174,7 +174,7 @@ pub struct ValidatedCluster { /// label on built resources. pub product_version: ProductVersion, /// Per-role configuration (PodDisruptionBudget and listener class). The `nodes` role is - /// mandatory, so this is always present. + /// required by the CRD, so this is always present. pub role_config: ValidatedRoleConfig, /// Cluster wide settings. pub cluster_config: ValidatedClusterConfig, From 4ec257a9be0b4ddcbe208b2ae5c007736452d57d Mon Sep 17 00:00:00 2001 From: Andrew Kenworthy Date: Mon, 27 Jul 2026 12:51:08 +0200 Subject: [PATCH 14/14] move swap-guard comment to the fixture that defines the cluster name --- rust/operator-binary/src/controller/build/properties.rs | 3 +++ rust/operator-binary/src/controller/build/resource/rbac.rs | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/operator-binary/src/controller/build/properties.rs b/rust/operator-binary/src/controller/build/properties.rs index a91935be..760fe651 100644 --- a/rust/operator-binary/src/controller/build/properties.rs +++ b/rust/operator-binary/src/controller/build/properties.rs @@ -118,6 +118,9 @@ pub(crate) mod test_support { /// A minimal NiFi cluster YAML. Mirrors the fixture used by bootstrap_conf tests, /// stripped down to the mandatory fields only (Kubernetes clustering backend, SingleUser auth). + /// + /// The cluster name (`simple-nifi`) deliberately differs from the product name (`nifi`), so + /// tests asserting recommended labels catch swapped `name`/`instance` values. pub const MINIMAL_NIFI_YAML: &str = r#" apiVersion: nifi.stackable.tech/v1alpha1 kind: NifiCluster diff --git a/rust/operator-binary/src/controller/build/resource/rbac.rs b/rust/operator-binary/src/controller/build/resource/rbac.rs index b9a98678..acb29d8d 100644 --- a/rust/operator-binary/src/controller/build/resource/rbac.rs +++ b/rust/operator-binary/src/controller/build/resource/rbac.rs @@ -50,8 +50,7 @@ mod tests { app_version_label, minimal_validated_cluster, }; - // The fixture's cluster name (`simple-nifi`) deliberately differs from the product name - // (`nifi`), so swapped `name`/`instance` label values cannot pass unnoticed. + // `simple-nifi` vs `nifi`: see the swap-guard note on `MINIMAL_NIFI_YAML`. #[test] fn test_service_account() {