diff --git a/CHANGELOG.md b/CHANGELOG.md index 4696be96..ef1f1c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,13 @@ 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 ([#985]). - Bump stackable-operator to 0.114.0 ([#994]). +- The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac` + functions and carry the full set of recommended labels ([#990]). +- BREAKING: The `brokers` role is now required by the CRD; a KafkaCluster without it was + previously accepted by the API server but failed reconciliation ([#990]). [#985]: https://github.com/stackabletech/kafka-operator/pull/985 +[#990]: https://github.com/stackabletech/kafka-operator/pull/990 [#994]: https://github.com/stackabletech/kafka-operator/pull/994 ## [26.7.0] - 2026-07-21 diff --git a/extra/crds.yaml b/extra/crds.yaml index 97b6e0a5..7bd2d740 100644 --- a/extra/crds.yaml +++ b/extra/crds.yaml @@ -35,7 +35,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: @@ -2260,6 +2259,7 @@ spec: x-kubernetes-preserve-unknown-fields: true type: array required: + - brokers - image type: object status: diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index 4162801d..5b3cea97 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -21,15 +21,17 @@ use stackable_operator::{ crd::listener, k8s_openapi::api::{ apps::v1::StatefulSet, - core::v1::{ConfigMap, Service}, + core::v1::{ConfigMap, Service, ServiceAccount}, policy::v1::PodDisruptionBudget, + rbac::v1::RoleBinding, }, kube::{ - Resource, ResourceExt, + Resource, api::{DynamicObject, ObjectMeta}, core::{DeserializeGuard, error_boundary}, runtime::{controller::Action, reflector::ObjectRef}, }, + kvp::Labels, logging::controller::ReconcilerError, shared::time::Duration, status::condition::{ @@ -39,7 +41,9 @@ use stackable_operator::{ v2::{ HasName, HasUid, NameIsValidLabelValue, cluster_resources::cluster_resources_new, + kvp::label::{recommended_labels, role_group_selector}, role_group_utils::ResourceNames, + role_utils, types::{ kubernetes::{ConfigMapName, ListenerName, NamespaceName, Uid}, operator::{ClusterName, ControllerName, OperatorName, ProductName, ProductVersion}, @@ -59,11 +63,7 @@ pub(crate) mod validate; pub use stackable_operator::v2::types::operator::{RoleGroupName, RoleName}; use crate::{ - controller::{ - build::resource::rbac::{build_rbac_role_binding, build_rbac_service_account}, - node_id_hasher::node_id_hash32_offset, - security::ValidatedKafkaSecurity, - }, + controller::{node_id_hasher::node_id_hash32_offset, security::ValidatedKafkaSecurity}, crd::{ APP_NAME, KafkaClusterStatus, KafkaPodDescriptor, MetadataManager, OPERATOR_NAME, authorization::KafkaAuthorizationConfig, @@ -75,6 +75,9 @@ use crate::{ pub const KAFKA_CONTROLLER_NAME: &str = "kafkacluster"; pub const KAFKA_FULL_CONTROLLER_NAME: &str = concatcp!(KAFKA_CONTROLLER_NAME, '.', OPERATOR_NAME); +// Placeholder version label value for resources whose labels must not change after deployment. +stackable_operator::constant!(UNVERSIONED_PRODUCT_VERSION: ProductVersion = "none"); + #[derive(Snafu, Debug)] pub enum PodDescriptorsError { #[snafu(display( @@ -99,6 +102,8 @@ pub struct KubernetesResources { pub listeners: Vec, pub config_maps: Vec, pub pod_disruption_budgets: Vec, + pub service_accounts: Vec, + pub role_bindings: Vec, } /// The validated cluster. Carries everything the build steps need, resolved once @@ -169,8 +174,7 @@ impl ValidatedCluster { /// node-id hash offsets must be unique across the whole cluster, so collisions are detected /// across all role groups regardless of `requested_kafka_role`. /// - /// Resource names reuse [`Self::resource_names`] (the canonical - /// `--` naming) so they stay in sync with the StatefulSet and + /// Resource names reuse resource names so they stay in sync with the StatefulSet and /// headless Service this descriptor refers to. pub fn pod_descriptors( &self, @@ -199,7 +203,7 @@ impl ValidatedCluster { seen_hashes.insert(node_id_hash_offset, (role.clone(), role_group_name.clone())); if requested_kafka_role.is_none() || Some(role) == requested_kafka_role { - let resource_names = self.resource_names(role, role_group_name); + let resource_names = self.role_group_resource_names(role, role_group_name); let role_group_statefulset_name = resource_names.stateful_set_name(); let role_group_service_name = resource_names.headless_service_name(); // Pods must be predicted from a concrete count (e.g. for KRaft quorum @@ -224,23 +228,74 @@ impl ValidatedCluster { } /// The given [`KafkaRole`] as a type-safe [`RoleName`]. - pub fn role_name(role: &KafkaRole) -> RoleName { - RoleName::from_str(&role.to_string()).expect("a KafkaRole 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 { + 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( + pub(crate) fn role_group_resource_names( &self, role: &KafkaRole, role_group_name: &RoleGroupName, ) -> ResourceNames { ResourceNames { cluster_name: self.name.clone(), - role_name: Self::role_name(role), + role_name: role.into(), role_group_name: role_group_name.clone(), } } + /// Recommended labels for a role-group resource. + pub fn recommended_labels(&self, role: &KafkaRole, role_group_name: &RoleGroupName) -> Labels { + self.recommended_labels_for(&role.into(), role_group_name) + } + + /// Recommended labels for a resource that is not tied to a concrete [`KafkaRole`], 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: &KafkaRole, + role_group_name: &RoleGroupName, + ) -> Labels { + self.recommended_labels_with(&UNVERSIONED_PRODUCT_VERSION, &role.into(), role_group_name) + } + + fn recommended_labels_with( + &self, + product_version: &ProductVersion, + role_name: &RoleName, + role_group_name: &RoleGroupName, + ) -> Labels { + recommended_labels( + self, + &product_name(), + product_version, + &operator_name(), + &controller_name(), + role_name, + role_group_name, + ) + } + + /// Selector labels matching the pods of a role group. + pub fn role_group_selector(&self, role: &KafkaRole, role_group_name: &RoleGroupName) -> Labels { + role_group_selector(self, &product_name(), &role.into(), role_group_name) + } + /// The name of the broker rolegroup's bootstrap [`Listener`](stackable_operator::crd::listener), /// `---bootstrap`. pub fn bootstrap_listener_name( @@ -250,7 +305,7 @@ impl ValidatedCluster { ) -> ListenerName { ListenerName::from_str(&format!( "{}-bootstrap", - self.resource_names(role, role_group_name) + self.role_group_resource_names(role, role_group_name) .stateful_set_name() )) .expect("the bootstrap listener name is a valid Listener name") @@ -423,27 +478,11 @@ 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 update status"))] ApplyStatus { source: stackable_operator::client::Error, }, - #[snafu(display("failed to get required Labels"))] - GetRequiredLabels { - source: - stackable_operator::kvp::KeyValuePairError, - }, - #[snafu(display("KafkaCluster object is invalid"))] InvalidKafkaCluster { source: error_boundary::InvalidObject, @@ -465,10 +504,7 @@ impl ReconcilerError for Error { Error::BuildDiscoveryConfig { .. } => None, Error::ApplyDiscoveryConfig { .. } => None, Error::DeleteOrphans { .. } => None, - Error::ApplyServiceAccount { .. } => None, - Error::ApplyRoleBinding { .. } => None, Error::ApplyStatus { .. } => None, - Error::GetRequiredLabels { .. } => None, Error::InvalidKafkaCluster { .. } => None, } } @@ -519,34 +555,27 @@ pub async fn reconcile_kafka( let mut ss_cond_builder = StatefulSetConditionBuilder::default(); - let required_labels = cluster_resources - .get_required_labels() - .context(GetRequiredLabelsSnafu)?; - let rbac_sa = build_rbac_service_account(&validated_cluster, required_labels.clone()); - let rbac_rolebinding = build_rbac_role_binding(&validated_cluster, required_labels); - - let rbac_sa = cluster_resources - .add(client, rbac_sa.clone()) - .await - .context(ApplyServiceAccountSnafu)?; - // The ServiceAccount name is deterministic, so the statefulset builders only need the name, - // not the applied object. - let service_account_name = rbac_sa.name_any(); - cluster_resources - .add(client, rbac_rolebinding) - .await - .context(ApplyRoleBindingSnafu)?; - // Build every Kubernetes resource up front (client-free). The discovery ConfigMap is not part // of this, as it depends on the applied bootstrap Listeners' status (see below). - let resources = - build::build(&validated_cluster, &service_account_name).context(BuildResourcesSnafu)?; + let resources = build::build(&validated_cluster).context(BuildResourcesSnafu)?; // Apply order: Services, then Listeners (collecting the applied bootstrap Listeners for the // discovery ConfigMap), then ConfigMaps, then PodDisruptionBudgets, and finally the // StatefulSets. The StatefulSets must be applied after all ConfigMaps and Secrets they mount to // prevent unnecessary Pod restarts. // 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) @@ -643,6 +672,18 @@ pub(crate) mod test_support { use super::{ValidatedCluster, dereference::DereferencedObjects, validate::validate}; use crate::crd::{authentication::ResolvedAuthenticationClasses, v1alpha1}; + /// 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 + ) + } + pub fn minimal_kafka(yaml: &str) -> v1alpha1::KafkaCluster { yaml_from_str_singleton_map(yaml).expect("invalid test KafkaCluster YAML") } @@ -680,6 +721,9 @@ pub(crate) mod test_support { mod tests { use std::collections::BTreeSet; + use stackable_operator::v2::types::operator::RoleName; + use strum::IntoEnumIterator; + use super::{ PodDescriptorsError, test_support::{minimal_kafka, validated_cluster}, @@ -763,4 +807,14 @@ mod tests { let node_ids: BTreeSet = descriptors.iter().map(|d| d.node_id).collect(); assert_eq!(node_ids.len(), 3, "node ids must be unique: {node_ids:?}"); } + + /// Locks the invariant behind the `expect` in the `From for RoleName` impls: + /// every `KafkaRole` variant (present and future) must serialise to a valid `RoleName`. + #[test] + fn every_kafka_role_serialises_to_a_valid_role_name() { + for role in KafkaRole::iter() { + let _: RoleName = (&role).into(); + let _: RoleName = role.into(); + } + } } diff --git a/rust/operator-binary/src/controller/build/labels.rs b/rust/operator-binary/src/controller/build/labels.rs deleted file mode 100644 index db86b3cf..00000000 --- a/rust/operator-binary/src/controller/build/labels.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Recommended-label and selector construction for the KafkaCluster build step. -//! -//! These build Kubernetes labels/selectors from a [`ValidatedCluster`]. They live here rather -//! than as methods on [`ValidatedCluster`] so the validated model only exposes views on its -//! properties, not build-step helpers. - -use std::str::FromStr; - -use stackable_operator::{ - kvp::Labels, - v2::{kvp::label, types::operator::ProductVersion}, -}; - -use crate::{ - controller::{RoleGroupName, ValidatedCluster, controller_name, operator_name, product_name}, - crd::role::KafkaRole, -}; - -/// Recommended labels for a role-group resource, using the given product version. -fn recommended_labels_for( - cluster: &ValidatedCluster, - product_version: &ProductVersion, - role: &KafkaRole, - role_group_name: &RoleGroupName, -) -> Labels { - label::recommended_labels( - cluster, - &product_name(), - product_version, - &operator_name(), - &controller_name(), - &ValidatedCluster::role_name(role), - role_group_name, - ) -} - -/// Recommended labels for a role-group resource. -pub fn recommended_labels( - cluster: &ValidatedCluster, - role: &KafkaRole, - role_group_name: &RoleGroupName, -) -> Labels { - recommended_labels_for(cluster, &cluster.product_version, role, role_group_name) -} - -/// Recommended labels without a version, for PVC templates that cannot be modified once -/// deployed. -pub fn unversioned_recommended_labels( - cluster: &ValidatedCluster, - role: &KafkaRole, - role_group_name: &RoleGroupName, -) -> Labels { - // A version value is required, and we do want to use the "recommended" format for the - // other desired labels. - let none_version = ProductVersion::from_str("none").expect("'none' is a valid product version"); - recommended_labels_for(cluster, &none_version, role, role_group_name) -} - -/// Selector labels matching the pods of a role group. -pub fn role_group_selector( - cluster: &ValidatedCluster, - role: &KafkaRole, - role_group_name: &RoleGroupName, -) -> Labels { - label::role_group_selector( - cluster, - &product_name(), - &ValidatedCluster::role_name(role), - role_group_name, - ) -} diff --git a/rust/operator-binary/src/controller/build/mod.rs b/rust/operator-binary/src/controller/build/mod.rs index f33b2f30..93e7996c 100644 --- a/rust/operator-binary/src/controller/build/mod.rs +++ b/rust/operator-binary/src/controller/build/mod.rs @@ -13,6 +13,7 @@ use crate::{ config_map::build_rolegroup_config_map, listener::build_broker_rolegroup_bootstrap_listener, pdb::build_pdb, + rbac::{build_role_binding, build_service_account}, service::{build_rolegroup_headless_service, build_rolegroup_metrics_service}, statefulset::{ build_broker_rolegroup_statefulset, build_controller_rolegroup_statefulset, @@ -27,7 +28,6 @@ pub mod command; pub mod graceful_shutdown; pub mod jvm; pub mod kerberos; -pub mod labels; pub mod properties; pub mod resource; pub mod security; @@ -55,14 +55,7 @@ pub enum Error { /// The discovery `ConfigMap` is intentionally excluded: it reports the applied bootstrap /// `Listener`s' ingress addresses (populated by the Listener operator only after apply), so it is /// built in the reconcile step once those `Listener`s exist. -/// -/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under. -/// The RBAC resources are built and applied separately, in the reconcile step; the name is -/// deterministic, so the build step does not depend on the applied `ServiceAccount`. -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![]; @@ -118,19 +111,14 @@ pub fn build( ); let stateful_set = match role { - KafkaRole::Broker => build_broker_rolegroup_statefulset( - role, - role_group_name, - cluster, - validated_rg, - service_account_name, - ), + KafkaRole::Broker => { + build_broker_rolegroup_statefulset(role, role_group_name, cluster, validated_rg) + } KafkaRole::Controller => build_controller_rolegroup_statefulset( role, role_group_name, cluster, validated_rg, - service_account_name, ), } .context(StatefulSetSnafu { @@ -156,6 +144,8 @@ pub fn build( listeners, config_maps, pod_disruption_budgets, + service_accounts: vec![build_service_account(cluster)], + role_bindings: vec![build_role_binding(cluster)], }) } @@ -236,7 +226,7 @@ mod tests { #[test] fn build_produces_expected_resource_names() { let cluster = kraft_cluster(); - let resources = build(&cluster, "simple-kafka-serviceaccount").expect("build succeeds"); + let resources = build(&cluster).expect("build succeeds"); // One StatefulSet per role group. assert_eq!( @@ -274,6 +264,15 @@ mod tests { sorted_names(&resources.pod_disruption_budgets), ["simple-kafka-broker", "simple-kafka-controller"] ); + // The cluster-shared RBAC pair. + assert_eq!( + sorted_names(&resources.service_accounts), + ["simple-kafka-serviceaccount"] + ); + assert_eq!( + sorted_names(&resources.role_bindings), + ["simple-kafka-rolebinding"] + ); } /// ZooKeeper mode has no `controller` role, so `build()` emits no controller resources while @@ -281,7 +280,7 @@ mod tests { #[test] fn build_zookeeper_mode_has_no_controller_resources() { let cluster = zookeeper_cluster(); - let resources = build(&cluster, "simple-kafka-serviceaccount").expect("build succeeds"); + let resources = build(&cluster).expect("build succeeds"); assert_eq!( sorted_names(&resources.stateful_sets), diff --git a/rust/operator-binary/src/controller/build/properties/listener.rs b/rust/operator-binary/src/controller/build/properties/listener.rs index d58cba74..3ba733bf 100644 --- a/rust/operator-binary/src/controller/build/properties/listener.rs +++ b/rust/operator-binary/src/controller/build/properties/listener.rs @@ -27,7 +27,7 @@ pub fn get_kafka_listener_config( role_group_name: &RoleGroupName, ) -> KafkaListenerConfig { let headless_service_name = validated_cluster - .resource_names(role, role_group_name) + .role_group_resource_names(role, role_group_name) .headless_service_name(); let pod_fqdn = pod_fqdn( &validated_cluster.namespace, @@ -241,7 +241,7 @@ mod tests { internal_host = pod_fqdn( &validated.namespace, validated - .resource_names(&KafkaRole::Broker, &role_group_name) + .role_group_resource_names(&KafkaRole::Broker, &role_group_name) .headless_service_name() .as_ref(), &cluster_info.cluster_domain @@ -303,7 +303,7 @@ mod tests { internal_host = pod_fqdn( &validated.namespace, validated - .resource_names(&KafkaRole::Broker, &role_group_name) + .role_group_resource_names(&KafkaRole::Broker, &role_group_name) .headless_service_name() .as_ref(), &cluster_info.cluster_domain @@ -366,7 +366,7 @@ mod tests { internal_host = pod_fqdn( &validated.namespace, validated - .resource_names(&KafkaRole::Broker, &role_group_name) + .role_group_resource_names(&KafkaRole::Broker, &role_group_name) .headless_service_name() .as_ref(), &cluster_info.cluster_domain @@ -466,7 +466,7 @@ mod tests { internal_host = pod_fqdn( &validated.namespace, validated - .resource_names(&KafkaRole::Broker, &role_group_name) + .role_group_resource_names(&KafkaRole::Broker, &role_group_name) .headless_service_name() .as_ref(), &cluster_info.cluster_domain 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 6df8f2db..284a281b 100644 --- a/rust/operator-binary/src/controller/build/resource/config_map.rs +++ b/rust/operator-binary/src/controller/build/resource/config_map.rs @@ -14,7 +14,6 @@ use crate::{ controller::{ RoleGroupName, ValidatedCluster, ValidatedRoleGroupConfig, build::{ - labels, properties::{ ConfigFileName, config_file_name, product_logging::role_group_config_map_data, }, @@ -125,7 +124,7 @@ pub fn build_rolegroup_config_map( .name_and_namespace(validated_cluster) .name( validated_cluster - .resource_names(&role, role_group_name) + .role_group_resource_names(&role, role_group_name) .role_group_config_map() .to_string(), ) @@ -134,11 +133,7 @@ pub fn build_rolegroup_config_map( None, Some(true), )) - .with_labels(labels::recommended_labels( - validated_cluster, - &role, - role_group_name, - )) + .with_labels(validated_cluster.recommended_labels(&role, role_group_name)) .build(), ) .add_data( diff --git a/rust/operator-binary/src/controller/build/resource/discovery.rs b/rust/operator-binary/src/controller/build/resource/discovery.rs index 6a9a9c07..de25fd3a 100644 --- a/rust/operator-binary/src/controller/build/resource/discovery.rs +++ b/rust/operator-binary/src/controller/build/resource/discovery.rs @@ -9,7 +9,7 @@ use stackable_operator::{ }; use crate::{ - controller::{RoleGroupName, ValidatedCluster, build::labels}, + controller::{RoleGroupName, ValidatedCluster}, crd::role::KafkaRole, }; @@ -57,12 +57,13 @@ pub fn build_discovery_configmap( None, Some(true), )) - .with_labels(labels::recommended_labels( - validated_cluster, - &KafkaRole::Broker, - &RoleGroupName::from_str("discovery") - .expect("'discovery' is a valid role group name"), - )) + .with_labels( + validated_cluster.recommended_labels( + &KafkaRole::Broker, + &RoleGroupName::from_str("discovery") + .expect("'discovery' is a valid role group name"), + ), + ) .build(), ) .add_data("KAFKA", bootstrap_servers) diff --git a/rust/operator-binary/src/controller/build/resource/listener.rs b/rust/operator-binary/src/controller/build/resource/listener.rs index c8497d31..6e5adc88 100644 --- a/rust/operator-binary/src/controller/build/resource/listener.rs +++ b/rust/operator-binary/src/controller/build/resource/listener.rs @@ -4,9 +4,7 @@ use stackable_operator::{ }; use crate::{ - controller::{ - RoleGroupName, ValidatedCluster, build::labels, security::ValidatedKafkaSecurity, - }, + controller::{RoleGroupName, ValidatedCluster, security::ValidatedKafkaSecurity}, crd::role::{KafkaRole, broker::BrokerConfig}, }; @@ -30,11 +28,7 @@ pub fn build_broker_rolegroup_bootstrap_listener( None, Some(true), )) - .with_labels(labels::recommended_labels( - validated_cluster, - role, - role_group_name, - )) + .with_labels(validated_cluster.recommended_labels(role, role_group_name)) .build(), spec: listener::v1alpha1::ListenerSpec { class_name: Some(merged_config.bootstrap_listener_class.to_string()), diff --git a/rust/operator-binary/src/controller/build/resource/pdb.rs b/rust/operator-binary/src/controller/build/resource/pdb.rs index 29bd473e..f649e1df 100644 --- a/rust/operator-binary/src/controller/build/resource/pdb.rs +++ b/rust/operator-binary/src/controller/build/resource/pdb.rs @@ -24,7 +24,7 @@ pub fn build_pdb( let pdb = pod_disruption_budget_builder_with_role( validated_cluster, &product_name(), - &ValidatedCluster::role_name(role), + &role.into(), &operator_name(), &controller_name(), ) diff --git a/rust/operator-binary/src/controller/build/resource/rbac.rs b/rust/operator-binary/src/controller/build/resource/rbac.rs index dfe0e057..a26d067d 100644 --- a/rust/operator-binary/src/controller/build/resource/rbac.rs +++ b/rust/operator-binary/src/controller/build/resource/rbac.rs @@ -1,81 +1,159 @@ -//! Builds the cluster-wide RBAC resources (`ServiceAccount` and `RoleBinding`). -//! -//! The names come from [`ResourceNames`] -//! and are identical to the previously used `commons::rbac::build_rbac_resources` -//! (`-serviceaccount`, `-rolebinding`, `-clusterrole`), so switching to -//! this builder does not rename any RBAC objects. +//! Builds the RBAC resources (ServiceAccount + RoleBinding) shared by all role groups. + +use std::str::FromStr; use stackable_operator::{ - builder::meta::ObjectMetaBuilder, - k8s_openapi::api::{ - core::v1::ServiceAccount, - rbac::v1::{RoleBinding, RoleRef, Subject}, - }, + k8s_openapi::api::{core::v1::ServiceAccount, rbac::v1::RoleBinding}, kvp::Labels, - v2::{builder::meta::ownerreference_from_resource, role_utils::ResourceNames}, + v2::{ + rbac, + types::operator::{RoleGroupName, RoleName}, + }, }; -use crate::controller::{ValidatedCluster, product_name}; +use crate::controller::ValidatedCluster; -/// Type-safe RBAC resource names for this cluster. -fn rbac_resource_names(validated_cluster: &ValidatedCluster) -> ResourceNames { - ResourceNames { - cluster_name: validated_cluster.name.clone(), - product_name: product_name(), - } +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.cluster_resource_names(), + rbac_labels(cluster), + ) } -/// Builds the [`ServiceAccount`] shared by all role groups, named `-serviceaccount`. -pub fn build_rbac_service_account( - validated_cluster: &ValidatedCluster, - labels: Labels, -) -> ServiceAccount { - let resource_names = rbac_resource_names(validated_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.cluster_resource_names(), + rbac_labels(cluster), + ) +} - ServiceAccount { - metadata: ObjectMetaBuilder::new() - .name_and_namespace(validated_cluster) - .name(resource_names.service_account_name().to_string()) - .ownerreference(ownerreference_from_resource( - validated_cluster, - None, - Some(true), - )) - .with_labels(labels) - .build(), - ..ServiceAccount::default() - } +/// 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) } -/// Builds the [`RoleBinding`] (named `-rolebinding`) that binds the -/// [`ServiceAccount`] to the `-clusterrole` `ClusterRole`. -pub fn build_rbac_role_binding( - validated_cluster: &ValidatedCluster, - labels: Labels, -) -> RoleBinding { - let resource_names = rbac_resource_names(validated_cluster); +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::controller::test_support::{app_version_label, minimal_kafka, validated_cluster}; + + // The fixture's cluster name (`simple-kafka`) deliberately differs from the product name + // (`kafka`), so swapped `name`/`instance` label values cannot pass unnoticed. The RBAC pair + // is mode-independent, so the minimal ZooKeeper-mode cluster suffices. + fn cluster() -> ValidatedCluster { + let kafka = minimal_kafka( + r#" + apiVersion: kafka.stackable.tech/v1alpha1 + kind: KafkaCluster + metadata: + name: simple-kafka + namespace: default + uid: 12345678-1234-1234-1234-123456789012 + spec: + image: + productVersion: 3.9.2 + clusterConfig: + zookeeperConfigMapName: xyz + brokers: + roleGroups: + default: + replicas: 1 + "#, + ); + validated_cluster(&kafka) + } + + #[test] + fn test_service_account() { + let service_account = build_service_account(&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-kafka", + "app.kubernetes.io/managed-by": "kafka.stackable.tech_kafkacluster", + "app.kubernetes.io/name": "kafka", + "app.kubernetes.io/role-group": "none", + "app.kubernetes.io/version": app_version_label("3.9.2"), + "stackable.tech/vendor": "Stackable" + }, + "name": "simple-kafka-serviceaccount", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "kafka.stackable.tech/v1alpha1", + "controller": true, + "kind": "KafkaCluster", + "name": "simple-kafka", + "uid": "12345678-1234-1234-1234-123456789012" + } + ] + } + }), + serde_json::to_value(service_account).expect("must be serializable") + ); + } + + #[test] + fn test_role_binding() { + let role_binding = build_role_binding(&cluster()); - RoleBinding { - metadata: ObjectMetaBuilder::new() - .name_and_namespace(validated_cluster) - .name(resource_names.role_binding_name().to_string()) - .ownerreference(ownerreference_from_resource( - validated_cluster, - None, - Some(true), - )) - .with_labels(labels) - .build(), - role_ref: RoleRef { - api_group: Some("rbac.authorization.k8s.io".to_owned()), - kind: "ClusterRole".to_string(), - name: resource_names.cluster_role_name().to_string(), - }, - subjects: Some(vec![Subject { - kind: "ServiceAccount".to_string(), - name: resource_names.service_account_name().to_string(), - namespace: Some(validated_cluster.namespace.to_string()), - ..Subject::default() - }]), + assert_eq!( + json!({ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "labels": { + "app.kubernetes.io/component": "none", + "app.kubernetes.io/instance": "simple-kafka", + "app.kubernetes.io/managed-by": "kafka.stackable.tech_kafkacluster", + "app.kubernetes.io/name": "kafka", + "app.kubernetes.io/role-group": "none", + "app.kubernetes.io/version": app_version_label("3.9.2"), + "stackable.tech/vendor": "Stackable" + }, + "name": "simple-kafka-rolebinding", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "kafka.stackable.tech/v1alpha1", + "controller": true, + "kind": "KafkaCluster", + "name": "simple-kafka", + "uid": "12345678-1234-1234-1234-123456789012" + } + ] + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "kafka-clusterrole" + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": "simple-kafka-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 07703051..be9801ed 100644 --- a/rust/operator-binary/src/controller/build/resource/service.rs +++ b/rust/operator-binary/src/controller/build/resource/service.rs @@ -8,9 +8,7 @@ use stackable_operator::{ }; use crate::{ - controller::{ - RoleGroupName, ValidatedCluster, build::labels, security::ValidatedKafkaSecurity, - }, + controller::{RoleGroupName, ValidatedCluster, security::ValidatedKafkaSecurity}, crd::{METRICS_PORT, METRICS_PORT_NAME, role::KafkaRole}, }; @@ -28,7 +26,7 @@ pub fn build_rolegroup_headless_service( .name_and_namespace(validated_cluster) .name( validated_cluster - .resource_names(role, role_group_name) + .role_group_resource_names(role, role_group_name) .headless_service_name() .to_string(), ) @@ -37,17 +35,15 @@ pub fn build_rolegroup_headless_service( None, Some(true), )) - .with_labels(labels::recommended_labels( - validated_cluster, - role, - role_group_name, - )) + .with_labels(validated_cluster.recommended_labels(role, role_group_name)) .build(), spec: Some(ServiceSpec { cluster_ip: Some("None".to_string()), ports: Some(headless_ports(kafka_security)), selector: Some( - labels::role_group_selector(validated_cluster, role, role_group_name).into(), + validated_cluster + .role_group_selector(role, role_group_name) + .into(), ), publish_not_ready_addresses: Some(true), ..ServiceSpec::default() @@ -67,7 +63,7 @@ pub fn build_rolegroup_metrics_service( .name_and_namespace(validated_cluster) .name( validated_cluster - .resource_names(role, role_group_name) + .role_group_resource_names(role, role_group_name) .metrics_service_name() .to_string(), ) @@ -76,11 +72,7 @@ pub fn build_rolegroup_metrics_service( None, Some(true), )) - .with_labels(labels::recommended_labels( - validated_cluster, - role, - role_group_name, - )) + .with_labels(validated_cluster.recommended_labels(role, role_group_name)) .with_labels(prometheus_labels(&Scraping::Enabled)) .with_annotations(prometheus_annotations( &Scraping::Enabled, @@ -95,7 +87,9 @@ pub fn build_rolegroup_metrics_service( cluster_ip: Some("None".to_string()), ports: Some(metrics_ports()), selector: Some( - labels::role_group_selector(validated_cluster, role, role_group_name).into(), + validated_cluster + .role_group_selector(role, role_group_name) + .into(), ), publish_not_ready_addresses: Some(true), ..ServiceSpec::default() @@ -121,3 +115,97 @@ fn headless_ports(kafka_security: &ValidatedKafkaSecurity) -> Vec { ..ServicePort::default() }] } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::controller::test_support::{app_version_label, minimal_kafka, validated_cluster}; + + /// 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 kafka = minimal_kafka( + r#" + apiVersion: kafka.stackable.tech/v1alpha1 + kind: KafkaCluster + metadata: + name: simple-kafka + namespace: default + uid: 12345678-1234-1234-1234-123456789012 + spec: + image: + productVersion: 3.9.2 + clusterConfig: + zookeeperConfigMapName: xyz + brokers: + roleGroups: + default: + replicas: 1 + "#, + ); + let cluster = validated_cluster(&kafka); + let role_group_name: RoleGroupName = "default".parse().expect("valid role group name"); + + let service = + build_rolegroup_metrics_service(&cluster, &KafkaRole::Broker, &role_group_name); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "prometheus.io/path": "/metrics", + "prometheus.io/port": "9606", + "prometheus.io/scheme": "http", + "prometheus.io/scrape": "true" + }, + "labels": { + "app.kubernetes.io/component": "broker", + "app.kubernetes.io/instance": "simple-kafka", + "app.kubernetes.io/managed-by": "kafka.stackable.tech_kafkacluster", + "app.kubernetes.io/name": "kafka", + "app.kubernetes.io/role-group": "default", + "app.kubernetes.io/version": app_version_label("3.9.2"), + "prometheus.io/scrape": "true", + "stackable.tech/vendor": "Stackable" + }, + "name": "simple-kafka-broker-default-metrics", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "kafka.stackable.tech/v1alpha1", + "controller": true, + "kind": "KafkaCluster", + "name": "simple-kafka", + "uid": "12345678-1234-1234-1234-123456789012" + } + ] + }, + "spec": { + "clusterIP": "None", + "ports": [ + { + "name": "metrics", + "port": 9606, + "protocol": "TCP" + } + ], + "publishNotReadyAddresses": true, + "selector": { + "app.kubernetes.io/component": "broker", + "app.kubernetes.io/instance": "simple-kafka", + "app.kubernetes.io/name": "kafka", + "app.kubernetes.io/role-group": "default" + }, + "type": "ClusterIP" + } + }), + serde_json::to_value(service).expect("must be serializable") + ); + } +} diff --git a/rust/operator-binary/src/controller/build/resource/statefulset.rs b/rust/operator-binary/src/controller/build/resource/statefulset.rs index 70997d08..57104c50 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -53,7 +53,6 @@ use crate::{ }, graceful_shutdown::add_graceful_shutdown_config, kerberos::add_kerberos_pod_config, - labels, properties::product_logging::MAX_KAFKA_LOG_FILES_SIZE, security::{ add_broker_volume_and_volume_mounts, add_controller_volume_and_volume_mounts, @@ -188,17 +187,15 @@ pub fn build_broker_rolegroup_statefulset( role_group_name: &RoleGroupName, validated_cluster: &ValidatedCluster, validated_rg: &ValidatedRoleGroupConfig, - service_account_name: &str, ) -> Result { let kafka_security = &validated_cluster.cluster_config.kafka_security; let resolved_product_image = &validated_cluster.image; let merged_config = &validated_rg.config.config; - let resource_names = validated_cluster.resource_names(kafka_role, role_group_name); - let recommended_labels = - labels::recommended_labels(validated_cluster, kafka_role, role_group_name); + let resource_names = validated_cluster.role_group_resource_names(kafka_role, role_group_name); + let recommended_labels = validated_cluster.recommended_labels(kafka_role, role_group_name); // Used for PVC templates that cannot be modified once they are deployed let unversioned_recommended_labels = - labels::unversioned_recommended_labels(validated_cluster, kafka_role, role_group_name); + validated_cluster.unversioned_recommended_labels(kafka_role, role_group_name); let kcat_prober_container_name = BrokerContainer::KcatProber.to_string(); let mut cb_kcat_prober = @@ -394,7 +391,14 @@ pub fn build_broker_rolegroup_statefulset( .add_container(cb_kcat_prober.build()) .affinity(&merged_config.affinity); - add_common_pod_config(&mut pod_builder, &resource_names, service_account_name)?; + add_common_pod_config( + &mut pod_builder, + &resource_names, + validated_cluster + .cluster_resource_names() + .service_account_name() + .as_ref(), + )?; add_vector_container( &mut pod_builder, @@ -432,7 +436,8 @@ pub fn build_broker_rolegroup_statefulset( replicas: validated_rg.replicas.map(i32::from), selector: LabelSelector { match_labels: Some( - labels::role_group_selector(validated_cluster, kafka_role, role_group_name) + validated_cluster + .role_group_selector(kafka_role, role_group_name) .into(), ), ..LabelSelector::default() @@ -452,14 +457,12 @@ pub fn build_controller_rolegroup_statefulset( role_group_name: &RoleGroupName, validated_cluster: &ValidatedCluster, validated_rg: &ValidatedRoleGroupConfig, - service_account_name: &str, ) -> Result { let kafka_security = &validated_cluster.cluster_config.kafka_security; let resolved_product_image = &validated_cluster.image; let merged_config = &validated_rg.config.config; - let resource_names = validated_cluster.resource_names(kafka_role, role_group_name); - let recommended_labels = - labels::recommended_labels(validated_cluster, kafka_role, role_group_name); + let resource_names = validated_cluster.role_group_resource_names(kafka_role, role_group_name); + let recommended_labels = validated_cluster.recommended_labels(kafka_role, role_group_name); let kafka_container_name = ControllerContainer::Kafka.to_string(); let mut cb_kafka = @@ -577,7 +580,14 @@ pub fn build_controller_rolegroup_statefulset( .add_container(kafka_container) .affinity(&merged_config.affinity); - add_common_pod_config(&mut pod_builder, &resource_names, service_account_name)?; + add_common_pod_config( + &mut pod_builder, + &resource_names, + validated_cluster + .cluster_resource_names() + .service_account_name() + .as_ref(), + )?; add_vector_container( &mut pod_builder, @@ -615,7 +625,8 @@ pub fn build_controller_rolegroup_statefulset( replicas: validated_rg.replicas.map(i32::from), selector: LabelSelector { match_labels: Some( - labels::role_group_selector(validated_cluster, kafka_role, role_group_name) + validated_cluster + .role_group_selector(kafka_role, role_group_name) .into(), ), ..LabelSelector::default() diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index 53fe8b76..29a1c2dc 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -35,7 +35,7 @@ use crate::{ security::{self, ValidatedKafkaSecurity}, }, crd::{ - self, CONTAINER_IMAGE_BASE_NAME, + CONTAINER_IMAGE_BASE_NAME, authentication::{self}, role::{ AnyConfig, AnyConfigOverrides, KafkaRole, @@ -62,9 +62,6 @@ pub enum Error { #[snafu(display("failed to validate authentication method"))] FailedToValidateAuthenticationMethod { source: security::Error }, - #[snafu(display("cluster object defines no '{role}' role"))] - MissingKafkaRole { source: crd::Error, role: KafkaRole }, - #[snafu(display("failed to merge and validate the role group config"))] ValidateRoleGroupConfig { source: stackable_operator::config::fragment::ValidationError, @@ -249,10 +246,8 @@ pub fn validate( BTreeMap, > = BTreeMap::new(); - // Brokers always exist. - let broker_role = kafka.broker_role().context(MissingKafkaRoleSnafu { - role: KafkaRole::Broker, - })?; + // The broker role is required by the CRD. + let broker_role = &kafka.spec.brokers; let broker_groups = validate_role_group_configs( broker_role, BrokerConfig::default_config(&kafka.name_any(), &KafkaRole::Broker.to_string()), @@ -419,15 +414,110 @@ fn inject_cluster_id(env_overrides: EnvVarSet, cluster_id: Option<&str>) -> Resu mod tests { use std::str::FromStr; - use stackable_operator::v2::builder::pod::container::{EnvVarName, EnvVarSet}; + use stackable_operator::v2::{ + builder::pod::container::{EnvVarName, EnvVarSet}, + types::operator::RoleGroupName, + }; use super::{KAFKA_CLUSTER_ID_ENV, inject_cluster_id}; + use crate::{ + controller::test_support::{app_version_label, minimal_kafka, validated_cluster}, + crd::role::KafkaRole, + }; fn cluster_id_value(env: &EnvVarSet) -> Option { let name = EnvVarName::from_str(KAFKA_CLUSTER_ID_ENV).unwrap(); env.get(&name).and_then(|var| var.value.clone()) } + /// Locks every value the validate step itself derives from the minimal KRaft 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 kafka = minimal_kafka( + r#" + apiVersion: kafka.stackable.tech/v1alpha1 + kind: KafkaCluster + metadata: + name: simple-kafka + namespace: default + uid: 12345678-1234-1234-1234-123456789012 + spec: + image: + productVersion: 3.9.2 + clusterConfig: + metadataManager: kraft + controllers: + roleGroups: + default: + replicas: 3 + brokers: + roleGroups: + default: + replicas: 3 + "#, + ); + let cluster = validated_cluster(&kafka); + + assert_eq!(cluster.name.to_string(), "simple-kafka"); + assert_eq!(cluster.namespace.to_string(), "default"); + assert_eq!( + cluster.uid.to_string(), + "12345678-1234-1234-1234-123456789012" + ); + assert_eq!(cluster.cluster_domain.to_string(), "cluster.local"); + assert_eq!( + cluster.image.image, + format!("oci.example.org/kafka:{}", app_version_label("3.9.2")) + ); + assert_eq!(cluster.image.product_version, "3.9.2"); + assert_eq!( + cluster.product_version.to_string(), + app_version_label("3.9.2") + ); + + // KRaft mode: no ZooKeeper ConfigMap; no user-supplied broker-id map or authorization. + let cluster_config = &cluster.cluster_config; + assert!(cluster_config.is_kraft_mode()); + assert_eq!(cluster_config.zookeeper_config_map_name, None); + assert_eq!(cluster_config.broker_id_pod_config_map_name, None); + assert!(cluster_config.authorization_config.is_none()); + + // TLS defaults: server and internal both use the `tls` SecretClass; no Kerberos or OPA. + let security = &cluster_config.kafka_security; + assert!(security.tls_enabled()); + assert_eq!(security.tls_server_secret_class(), Some("tls")); + assert_eq!(security.tls_internal_secret_class(), "tls"); + assert!(!security.has_kerberos_enabled()); + assert_eq!(security.opa_secret_class(), None); + + // Both roles are present, with default (enabled) PDB configs. + let roles: Vec<_> = cluster.role_configs.keys().collect(); + assert_eq!(roles, [&KafkaRole::Broker, &KafkaRole::Controller]); + for role_config in cluster.role_configs.values() { + assert!(role_config.pdb.enabled); + assert_eq!(role_config.pdb.max_unavailable, None); + } + + // One `default` role group per role. The KRaft cluster id (derived from the cluster + // name) is injected into every role group's env overrides. + let default_rg = RoleGroupName::from_str("default").expect("valid role group name"); + for role in [KafkaRole::Broker, KafkaRole::Controller] { + let role_group = &cluster.role_group_configs[&role][&default_rg]; + assert_eq!(role_group.replicas, Some(3)); + assert_eq!( + cluster_id_value(&role_group.env_overrides), + Some("simple-kafka".to_string()) + ); + assert_eq!(role_group.config.logging.vector_container, None); + } + } + #[test] fn injects_cluster_id_when_absent() { let env = inject_cluster_id(EnvVarSet::new(), Some("my-id")).unwrap(); diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 503ff159..487a478c 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -7,7 +7,7 @@ pub mod tls; use authentication::KafkaAuthentication; use serde::{Deserialize, Serialize}; -use snafu::{OptionExt, Snafu}; +use snafu::Snafu; use stackable_operator::{ commons::{ cluster_operation::ClusterOperation, networking::DomainName, @@ -73,9 +73,6 @@ pub enum Error { ))] Kafka4RequiresKraftMetadataManager, - #[snafu(display("The Kafka role [{role}] is missing from spec"))] - MissingRole { role: String }, - #[snafu(display( "Kafka version 4 and higher requires a Kraft controller (configured via `spec.controller`)" ))] @@ -130,7 +127,7 @@ pub mod versioned { pub image: ProductImage, // no doc - docs in Role struct. - pub brokers: Option, + pub brokers: BrokerRole, // no doc - docs in Role struct. pub controllers: Option, @@ -320,12 +317,6 @@ impl v1alpha1::KafkaCluster { _ => None, }) } - - pub fn broker_role(&self) -> Result<&BrokerRole, Error> { - self.spec.brokers.as_ref().context(MissingRoleSnafu { - role: KafkaRole::Broker.to_string(), - }) - } } /// Reference to a single `Pod` that is a component of a [`KafkaCluster`] @@ -444,6 +435,10 @@ mod tests { spec: image: productVersion: 3.9.2 + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: zookeeperConfigMapName: xyz "#; @@ -463,6 +458,10 @@ mod tests { spec: image: productVersion: 3.9.2 + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: tls: serverSecretClass: simple-kafka-server-tls @@ -488,6 +487,10 @@ mod tests { spec: image: productVersion: 3.9.2 + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: tls: serverSecretClass: null @@ -509,7 +512,10 @@ mod tests { spec: image: productVersion: 3.9.2 - zookeeperConfigMapName: xyz + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: tls: internalSecretClass: simple-kafka-internal-tls @@ -534,6 +540,10 @@ mod tests { spec: image: productVersion: 3.9.2 + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: zookeeperConfigMapName: xyz "#; @@ -553,6 +563,10 @@ mod tests { spec: image: productVersion: 3.9.2 + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: tls: internalSecretClass: simple-kafka-internal-tls @@ -574,6 +588,10 @@ mod tests { spec: image: productVersion: 3.9.2 + brokers: + roleGroups: + default: + replicas: 1 clusterConfig: tls: serverSecretClass: simple-kafka-server-tls diff --git a/rust/operator-binary/src/crd/role/mod.rs b/rust/operator-binary/src/crd/role/mod.rs index 3c75848b..972d3d56 100644 --- a/rust/operator-binary/src/crd/role/mod.rs +++ b/rust/operator-binary/src/crd/role/mod.rs @@ -2,14 +2,17 @@ pub mod broker; pub mod commons; pub mod controller; -use std::{borrow::Cow, ops::Deref}; +use std::{borrow::Cow, ops::Deref, str::FromStr}; use serde::{Deserialize, Serialize}; use stackable_operator::{ commons::resources::{NoRuntimeLimits, Resources}, product_logging::spec::ContainerLogConfig, schemars::{self, JsonSchema}, - v2::{config_overrides::KeyValueConfigOverrides, types::kubernetes::ListenerClassName}, + v2::{ + config_overrides::KeyValueConfigOverrides, + types::{kubernetes::ListenerClassName, operator::RoleName}, + }, }; use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; @@ -85,6 +88,18 @@ pub enum KafkaRole { Controller, } +impl From for RoleName { + fn from(value: KafkaRole) -> Self { + RoleName::from_str(&value.to_string()).expect("a KafkaRole is a valid role name") + } +} + +impl From<&KafkaRole> for RoleName { + fn from(value: &KafkaRole) -> Self { + RoleName::from_str(&value.to_string()).expect("a KafkaRole is a valid role name") + } +} + impl KafkaRole { /// Return all available roles pub fn roles() -> Vec {