diff --git a/CHANGELOG.md b/CHANGELOG.md index 95731a59..365bf054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,12 @@ - Internal operator refactoring: introduce a build() step in the reconciler that assembles all relevant Kubernetes resources before anything is applied ([#814]). +- The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac` + functions and carry the full set of recommended labels ([#821]). - Bump stackable-operator to 0.114.0 ([#827]). [#814]: https://github.com/stackabletech/airflow-operator/pull/814 +[#821]: https://github.com/stackabletech/airflow-operator/pull/821 [#827]: https://github.com/stackabletech/airflow-operator/pull/827 ## [26.7.0] - 2026-07-21 diff --git a/rust/operator-binary/src/airflow_controller.rs b/rust/operator-binary/src/airflow_controller.rs index 7d2bbbd3..615e18fe 100644 --- a/rust/operator-binary/src/airflow_controller.rs +++ b/rust/operator-binary/src/airflow_controller.rs @@ -9,14 +9,12 @@ use snafu::{ResultExt, Snafu}; use stackable_operator::{ cli::OperatorEnvironmentOptions, cluster_resources::ClusterResourceApplyStrategy, - commons::{random_secret_creation, rbac::build_rbac_resources}, + commons::random_secret_creation, k8s_openapi::api::core::v1::EnvVar, kube::{ - ResourceExt, core::{DeserializeGuard, error_boundary}, runtime::controller::Action, }, - kvp::LabelError, logging::controller::ReconcilerError, shared::time::Duration, status::condition::{ @@ -30,7 +28,7 @@ use strum::{EnumDiscriminants, IntoStaticStr}; use crate::{ controller::{ValidatedCluster, build, controller_name, operator_name, product_name}, crd::{ - APP_NAME, AirflowClusterStatus, OPERATOR_NAME, + AirflowClusterStatus, OPERATOR_NAME, internal_secret::{ FERNET_KEY_SECRET_KEY, INTERNAL_SECRET_SECRET_KEY, JWT_SECRET_SECRET_KEY, }, @@ -58,21 +56,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: {source}"))] - ApplyRoleBinding { - source: stackable_operator::cluster_resources::Error, - }, - - #[snafu(display("failed to build RBAC objects"))] - BuildRBACObjects { - source: stackable_operator::commons::rbac::Error, - }, - #[snafu(display("failed to build the Kubernetes resources"))] BuildResources { source: build::Error }, @@ -101,9 +84,6 @@ pub enum Error { source: crate::controller::validate::Error, }, - #[snafu(display("failed to build label"))] - BuildLabel { source: LabelError }, - #[snafu(display("AirflowCluster object is invalid"))] InvalidAirflowCluster { source: error_boundary::InvalidObject, @@ -159,33 +139,25 @@ pub async fn reconcile_airflow( &airflow.spec.object_overrides, ); - let required_labels = cluster_resources - .get_required_labels() - .context(BuildLabelSnafu)?; - - let (rbac_sa, rbac_rolebinding) = - build_rbac_resources(airflow, APP_NAME, required_labels).context(BuildRBACObjectsSnafu)?; - - // The ServiceAccount name is deterministic on the built object, so the build step does not - // depend on the applied ServiceAccount. - let service_account_name = rbac_sa.name_any(); - - cluster_resources - .add(client, rbac_sa) - .await - .context(ApplyServiceAccountSnafu)?; - cluster_resources - .add(client, rbac_rolebinding) - .await - .context(ApplyRoleBindingSnafu)?; - - let resources = - build::build(&validated_cluster, &service_account_name).context(BuildResourcesSnafu)?; + let resources = build::build(&validated_cluster).context(BuildResourcesSnafu)?; let mut ss_cond_builder = StatefulSetConditionBuilder::default(); // Apply order is: StatefulSets last (a changed mounted ConfigMap/Secret - // must exist first, else Pods restart -- commons-operator#111). + // must exist first, else Pods restart -- commons-operator#111). The ServiceAccount comes + // first because the Pods reference it at creation time. + 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) diff --git a/rust/operator-binary/src/controller/build/mod.rs b/rust/operator-binary/src/controller/build/mod.rs index f148f167..65d51626 100644 --- a/rust/operator-binary/src/controller/build/mod.rs +++ b/rust/operator-binary/src/controller/build/mod.rs @@ -11,6 +11,7 @@ use crate::{ executor::build_executor_template_config_map, listener::build_group_listener, pdb::build_pdb, + rbac::{build_role_binding, build_service_account}, service::{build_rolegroup_headless_service, build_rolegroup_metrics_service}, statefulset::build_server_rolegroup_statefulset, }, @@ -47,14 +48,7 @@ pub enum Error { /// Does not need a Kubernetes client: every reference to another Kubernetes resource is already /// dereferenced and validated by this point. Cluster configuration is likewise already validated, /// so the errors returned here are resource-assembly failures only. -/// -/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods and the -/// Kubernetes-executor pod template 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![]; @@ -81,7 +75,6 @@ pub fn build( let executor_template_config_map = build_executor_template_config_map( cluster, - service_account_name, &executor_template.config, &executor_template.env_overrides, &executor_template.pod_overrides, @@ -123,7 +116,7 @@ pub fn build( config_maps.push( config_map::build_rolegroup_config_map( cluster, - &role.role_name(), + &ValidatedCluster::role_name(role), role_group_name, &rg_config.config_overrides, logging, @@ -140,7 +133,6 @@ pub fn build( role_group_name, rg_config, logging, - service_account_name, ) .context(StatefulSetSnafu { role_group: role_group_name.clone(), @@ -155,11 +147,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; @@ -184,7 +180,7 @@ mod tests { apiVersion: airflow.stackable.tech/v1alpha2 kind: AirflowCluster metadata: - name: airflow + name: my-airflow namespace: default uid: e6ac237d-a6d4-43a1-8135-f36506110912 spec: @@ -263,25 +259,84 @@ mod tests { #[test] fn build_produces_expected_resource_names() { let cluster = celery_executor_cluster(); - let resources = build(&cluster, "airflow-serviceaccount").expect("build succeeds"); + let resources = build(&cluster).expect("build succeeds"); assert_eq!( sorted_names(&resources.stateful_sets), - ["airflow-scheduler-default", "airflow-webserver-default"] + [ + "my-airflow-scheduler-default", + "my-airflow-webserver-default" + ] ); // One headless and one metrics Service per role group. assert_eq!(resources.services.len(), 4); assert_eq!( sorted_names(&resources.config_maps), - ["airflow-scheduler-default", "airflow-webserver-default"] + [ + "my-airflow-scheduler-default", + "my-airflow-webserver-default" + ] ); // The webserver is the only role with a group Listener. - assert_eq!(sorted_names(&resources.listeners), ["airflow-webserver"]); + assert_eq!(sorted_names(&resources.listeners), ["my-airflow-webserver"]); // A default PDB per role (the Celery worker included). assert_eq!( sorted_names(&resources.pod_disruption_budgets), - ["airflow-scheduler", "airflow-webserver", "airflow-worker"] + [ + "my-airflow-scheduler", + "my-airflow-webserver", + "my-airflow-worker" + ] + ); + } + + /// 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. + #[test] + fn build_produces_rbac() { + let cluster = celery_executor_cluster(); + let resources = build(&cluster).expect("build succeeds"); + + assert_eq!( + sorted_names(&resources.service_accounts), + ["my-airflow-serviceaccount"] + ); + assert_eq!( + sorted_names(&resources.role_bindings), + ["my-airflow-rolebinding"] + ); + + let expected_labels = BTreeMap::from( + [ + ("app.kubernetes.io/component", "none"), + ("app.kubernetes.io/instance", "my-airflow"), + ( + "app.kubernetes.io/managed-by", + "airflow.stackable.tech_airflowcluster", + ), + ("app.kubernetes.io/name", "airflow"), + ("app.kubernetes.io/role-group", "none"), + ("app.kubernetes.io/version", "3.1.6-stackable0.0.0-dev"), + ("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, "airflow-clusterrole"); } /// The Kubernetes-executor branch of `build()` (moved here from `reconcile`) additionally emits @@ -290,15 +345,15 @@ mod tests { #[test] fn build_kubernetes_executor_adds_pod_template_config_maps() { let cluster = kubernetes_executor_cluster(); - let resources = build(&cluster, "airflow-serviceaccount").expect("build succeeds"); + let resources = build(&cluster).expect("build succeeds"); assert_eq!( sorted_names(&resources.config_maps), [ - "airflow-executor-kubernetes", - "airflow-executor-pod-template", - "airflow-scheduler-default", - "airflow-webserver-default", + "my-airflow-executor-kubernetes", + "my-airflow-executor-pod-template", + "my-airflow-scheduler-default", + "my-airflow-webserver-default", ] ); } 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 23bb2dbd..d4d10958 100644 --- a/rust/operator-binary/src/controller/build/resource/config_map.rs +++ b/rust/operator-binary/src/controller/build/resource/config_map.rs @@ -66,7 +66,7 @@ pub fn build_rolegroup_config_map( validated_cluster .object_meta( validated_cluster - .resource_names(role_name, role_group_name) + .role_group_resource_names(role_name, role_group_name) .role_group_config_map() .to_string(), validated_cluster.recommended_labels_for(role_name, role_group_name), diff --git a/rust/operator-binary/src/controller/build/resource/executor.rs b/rust/operator-binary/src/controller/build/resource/executor.rs index b4b6830d..3bcc320a 100644 --- a/rust/operator-binary/src/controller/build/resource/executor.rs +++ b/rust/operator-binary/src/controller/build/resource/executor.rs @@ -76,7 +76,6 @@ type Result = std::result::Result; pub fn build_executor_template_config_map( cluster: &ValidatedCluster, - sa_name: &str, executor_config: &ValidatedAirflowConfig, env_overrides: &HashMap, pod_overrides: &PodTemplateSpec, @@ -99,7 +98,12 @@ pub fn build_executor_template_config_map( pb.metadata(pb_metadata) .image_pull_secrets_from_product_image(resolved_product_image) .affinity(&executor_config.affinity) - .service_account_name(sa_name) + .service_account_name( + cluster + .cluster_resource_names() + .service_account_name() + .to_string(), + ) .restart_policy("Never") .security_context(PodSecurityContextBuilder::new().fs_group(1000).build()); @@ -152,7 +156,7 @@ pub fn build_executor_template_config_map( .context(AddVolumeSnafu)?; pb.add_volumes(volumes::create_volumes( cluster - .resource_names(&executor_role_name(), &executor_role_group_name()) + .role_group_resource_names(&executor_role_name(), &executor_role_group_name()) .role_group_config_map() .as_ref(), &executor_config.logging.product_container, @@ -163,7 +167,10 @@ pub fn build_executor_template_config_map( pb.add_container(build_logging_container( resolved_product_image, vector_log_config, - &cluster.resource_names(&executor_role_name(), &executor_template_role_group_name()), + &cluster.role_group_resource_names( + &executor_role_name(), + &executor_template_role_group_name(), + ), )); } diff --git a/rust/operator-binary/src/controller/build/resource/listener.rs b/rust/operator-binary/src/controller/build/resource/listener.rs index 2083f259..e42d3b25 100644 --- a/rust/operator-binary/src/controller/build/resource/listener.rs +++ b/rust/operator-binary/src/controller/build/resource/listener.rs @@ -1,6 +1,11 @@ +use std::str::FromStr; + use stackable_operator::{ crd::listener, - v2::types::kubernetes::{ListenerClassName, ListenerName}, + v2::types::{ + kubernetes::{ListenerClassName, ListenerName}, + operator::RoleGroupName, + }, }; use crate::{ @@ -8,6 +13,10 @@ use crate::{ crd::{AirflowRole, HTTP_PORT, HTTP_PORT_NAME}, }; +// The group listener is a role-level object, so a constant `none` role-group is used as the +// role-group label value. +stackable_operator::constant!(NONE_ROLE_GROUP_NAME: RoleGroupName = "none"); + pub fn build_group_listener( cluster: &ValidatedCluster, role: &AirflowRole, @@ -18,11 +27,9 @@ pub fn build_group_listener( metadata: cluster .object_meta( listener_group_name, - // The group listener is a role-level object, so a constant `none` role-group is - // used as the role-group label value. cluster.recommended_labels_for( - &role.role_name(), - &"none".parse().expect("'none' is a valid role group name"), + &ValidatedCluster::role_name(role), + &NONE_ROLE_GROUP_NAME, ), ) .build(), diff --git a/rust/operator-binary/src/controller/build/resource/mod.rs b/rust/operator-binary/src/controller/build/resource/mod.rs index dd7798af..8f65cc3f 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 executor; pub mod listener; pub mod pdb; pub mod pod; +pub mod rbac; pub mod service; pub mod statefulset; diff --git a/rust/operator-binary/src/controller/build/resource/pdb.rs b/rust/operator-binary/src/controller/build/resource/pdb.rs index a7f5b338..22d82556 100644 --- a/rust/operator-binary/src/controller/build/resource/pdb.rs +++ b/rust/operator-binary/src/controller/build/resource/pdb.rs @@ -38,7 +38,7 @@ pub fn build_pdb( let pdb = pod_disruption_budget_builder_with_role( cluster, &product_name(), - &role.role_name(), + &ValidatedCluster::role_name(role), &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 new file mode 100644 index 00000000..7bbb3aa7 --- /dev/null +++ b/rust/operator-binary/src/controller/build/resource/rbac.rs @@ -0,0 +1,43 @@ +//! 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 and the Kubernetes-executor 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 [`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), + ) +} + +/// 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/service.rs b/rust/operator-binary/src/controller/build/resource/service.rs index d1481845..4bba1114 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.role_name(), role_group_name) + .role_group_resource_names(&ValidatedCluster::role_name(role), role_group_name) .headless_service_name() .to_string(), cluster.recommended_labels(role, role_group_name), @@ -51,7 +51,7 @@ pub fn build_rolegroup_metrics_service( metadata: cluster .object_meta( cluster - .resource_names(&role.role_name(), role_group_name) + .role_group_resource_names(&ValidatedCluster::role_name(role), role_group_name) .metrics_service_name() .to_string(), cluster.recommended_labels(role, role_group_name), @@ -85,7 +85,7 @@ pub fn stateful_set_service_name( ) -> Option { Some( cluster - .resource_names(&role.role_name(), role_group_name) + .role_group_resource_names(&ValidatedCluster::role_name(role), role_group_name) .headless_service_name() .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 b357b93a..5e2aa8d8 100644 --- a/rust/operator-binary/src/controller/build/resource/statefulset.rs +++ b/rust/operator-binary/src/controller/build/resource/statefulset.rs @@ -103,7 +103,6 @@ pub fn build_server_rolegroup_statefulset( role_group_name: &RoleGroupName, validated_rg_config: &AirflowRoleGroupConfig, logging: &ValidatedLogging, - service_account_name: &str, ) -> Result { let merged_airflow_config = &validated_rg_config.config; let env_overrides = &validated_rg_config.env_overrides; @@ -116,8 +115,8 @@ pub fn build_server_rolegroup_statefulset( let executor = &validated_cluster.cluster_config.executor; let mut pb = PodBuilder::new(); - let resource_names = - validated_cluster.resource_names(&airflow_role.role_name(), role_group_name); + let resource_names = validated_cluster + .role_group_resource_names(&ValidatedCluster::role_name(airflow_role), role_group_name); let recommended_object_labels = validated_cluster.recommended_labels(airflow_role, role_group_name); @@ -140,7 +139,12 @@ pub fn build_server_rolegroup_statefulset( pb.metadata(pb_metadata) .image_pull_secrets_from_product_image(resolved_product_image) .affinity(&merged_airflow_config.affinity) - .service_account_name(service_account_name) + .service_account_name( + validated_cluster + .cluster_resource_names() + .service_account_name() + .to_string(), + ) .security_context(PodSecurityContextBuilder::new().fs_group(1000).build()); let mut airflow_container = new_container_builder(&Container::Airflow.to_container_name()); diff --git a/rust/operator-binary/src/controller/mod.rs b/rust/operator-binary/src/controller/mod.rs index d59e7b39..923ae89b 100644 --- a/rust/operator-binary/src/controller/mod.rs +++ b/rust/operator-binary/src/controller/mod.rs @@ -20,8 +20,9 @@ use stackable_operator::{ }, k8s_openapi::api::{ apps::v1::StatefulSet, - core::v1::{ConfigMap, PodTemplateSpec, Service, Volume, VolumeMount}, + core::v1::{ConfigMap, PodTemplateSpec, Service, ServiceAccount, Volume, VolumeMount}, policy::v1::PodDisruptionBudget, + rbac::v1::RoleBinding, }, kube::{Resource, ResourceExt, api::ObjectMeta}, kvp::Labels, @@ -33,6 +34,7 @@ use stackable_operator::{ kvp::label::{recommended_labels, role_group_selector}, product_logging::framework::{ValidatedContainerLogConfigChoice, VectorContainerLogConfig}, role_group_utils::ResourceNames, + role_utils, types::{ kubernetes::{ ConfigMapName, ListenerClassName, ListenerName, NamespaceName, SecretName, Uid, @@ -63,6 +65,9 @@ pub mod build; pub mod dereference; pub 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, @@ -70,6 +75,8 @@ pub struct KubernetesResources { pub listeners: Vec, pub config_maps: Vec, pub pod_disruption_budgets: Vec, + pub service_accounts: Vec, + pub role_bindings: Vec, } /// Per-role configuration extracted during validation. @@ -319,8 +326,17 @@ 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 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 role group. - pub fn resource_names( + pub fn role_group_resource_names( &self, role_name: &RoleName, role_group_name: &RoleGroupName, @@ -333,12 +349,21 @@ impl ValidatedCluster { } /// Recommended labels for a role-group resource. + /// The type-safe role name for an Airflow role. + /// + /// Infallible: every `AirflowRole` serialises to a short, valid role name. + pub fn role_name(role: &AirflowRole) -> RoleName { + role.to_string() + .parse() + .expect("an AirflowRole serialises to a valid RoleName") + } + pub fn recommended_labels( &self, role: &AirflowRole, role_group_name: &RoleGroupName, ) -> Labels { - self.recommended_labels_for(&role.role_name(), role_group_name) + self.recommended_labels_for(&Self::role_name(role), role_group_name) } /// Recommended labels for a resource that is not tied to a concrete [`AirflowRole`] (e.g. the @@ -351,16 +376,16 @@ impl ValidatedCluster { self.recommended_labels_with(&self.product_version, role_name, role_group_name) } - /// Recommended labels with a constant `none` version, for PVC templates that cannot be modified - /// after deployment (keeps the labels stable across version upgrades). + /// 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: &AirflowRole, role_group_name: &RoleGroupName, ) -> Labels { self.recommended_labels_with( - &ProductVersion::from_str("none").expect("'none' is a valid product version"), - &role.role_name(), + &UNVERSIONED_PRODUCT_VERSION, + &Self::role_name(role), role_group_name, ) } @@ -388,7 +413,12 @@ impl ValidatedCluster { role: &AirflowRole, role_group_name: &RoleGroupName, ) -> Labels { - role_group_selector(self, &product_name(), &role.role_name(), role_group_name) + role_group_selector( + self, + &product_name(), + &Self::role_name(role), + role_group_name, + ) } /// Returns an [`ObjectMetaBuilder`] pre-filled with the namespace, the resource `name`, an owner @@ -496,3 +526,20 @@ impl HasUid for ValidatedCluster { self.uid.clone() } } + +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use super::ValidatedCluster; + use crate::crd::AirflowRole; + + /// Locks the invariant behind the `expect` in [`ValidatedCluster::role_name`]: every + /// `AirflowRole` variant (present and future) must serialise to a valid `RoleName`. + #[test] + fn every_airflow_role_serialises_to_a_valid_role_name() { + for role in AirflowRole::iter() { + ValidatedCluster::role_name(&role); + } + } +} diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 8da1ef1a..961c073d 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -731,15 +731,6 @@ impl AirflowRole { } } - /// The role name as a type-safe label/resource-name value. - /// - /// Infallible: every `AirflowRole` serialises to a short, valid role name. - pub fn role_name(&self) -> stackable_operator::v2::types::operator::RoleName { - self.to_string() - .parse() - .expect("an AirflowRole serialises to a valid RoleName") - } - pub fn listener_class_name( &self, airflow: &v1alpha2::AirflowCluster,