Skip to content

Commit ad3e633

Browse files
authored
refactor: Build RBAC with recommended labels (#846)
* factor: add infallible rbac functions * reference op-rs remote branch not local one * bump op-rs to 0.114.0 * rename resource names functions * review feedback * remove inaccurate statement from comment
1 parent f486e43 commit ad3e633

12 files changed

Lines changed: 207 additions & 110 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ All notable changes to this project will be documented in this file.
88

99
- Internal operator refactoring: introduce a build() step in the reconciler that
1010
assembles all relevant Kubernetes resources before anything is applied ([#841]).
11+
- The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac`
12+
functions and carry the full set of recommended labels ([#846]).
1113
- Bump stackable-operator to 0.114.0 ([#855]).
1214

1315
[#841]: https://github.com/stackabletech/druid-operator/pull/841
16+
[#846]: https://github.com/stackabletech/druid-operator/pull/846
1417
[#855]: https://github.com/stackabletech/druid-operator/pull/855
1518

1619
## [26.7.0] - 2026-07-21

rust/operator-binary/src/controller.rs

Lines changed: 19 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,17 @@ use snafu::{ResultExt, Snafu};
88
use stackable_operator::{
99
cli::OperatorEnvironmentOptions,
1010
cluster_resources::ClusterResourceApplyStrategy,
11-
commons::rbac::build_rbac_resources,
1211
crd::listener::v1alpha1::Listener,
1312
k8s_openapi::api::{
1413
apps::v1::StatefulSet,
15-
core::v1::{ConfigMap, Service},
14+
core::v1::{ConfigMap, Service, ServiceAccount},
1615
policy::v1::PodDisruptionBudget,
16+
rbac::v1::RoleBinding,
1717
},
1818
kube::{
19-
ResourceExt,
2019
core::{DeserializeGuard, error_boundary},
2120
runtime::controller::Action,
2221
},
23-
kvp::{KeyValuePairError, LabelValueError},
2422
logging::controller::ReconcilerError,
2523
shared::time::Duration,
2624
status::condition::{
@@ -83,6 +81,8 @@ pub struct KubernetesResources {
8381
pub listeners: Vec<Listener>,
8482
pub config_maps: Vec<ConfigMap>,
8583
pub pod_disruption_budgets: Vec<PodDisruptionBudget>,
84+
pub service_accounts: Vec<ServiceAccount>,
85+
pub role_bindings: Vec<RoleBinding>,
8686
}
8787

8888
#[derive(Snafu, Debug, EnumDiscriminants)]
@@ -122,26 +122,6 @@ pub enum Error {
122122
source: crate::internal_secret::Error,
123123
},
124124

125-
#[snafu(display("failed to create RBAC service account"))]
126-
ApplyServiceAccount {
127-
source: stackable_operator::cluster_resources::Error,
128-
},
129-
130-
#[snafu(display("failed to create RBAC role binding"))]
131-
ApplyRoleBinding {
132-
source: stackable_operator::cluster_resources::Error,
133-
},
134-
135-
#[snafu(display("failed to build RBAC resources"))]
136-
BuildRbacResources {
137-
source: stackable_operator::commons::rbac::Error,
138-
},
139-
140-
#[snafu(display("failed to get required labels"))]
141-
GetRequiredLabels {
142-
source: KeyValuePairError<LabelValueError>,
143-
},
144-
145125
#[snafu(display("DruidCluster object is invalid"))]
146126
InvalidDruidCluster {
147127
source: error_boundary::InvalidObject,
@@ -196,39 +176,30 @@ pub async fn reconcile_druid(
196176
&druid.spec.object_overrides,
197177
);
198178

199-
let (rbac_sa, rbac_rolebinding) = build_rbac_resources(
200-
druid,
201-
APP_NAME,
202-
cluster_resources
203-
.get_required_labels()
204-
.context(GetRequiredLabelsSnafu)?,
205-
)
206-
.context(BuildRbacResourcesSnafu)?;
207-
208-
// The ServiceAccount name is deterministic on the built object, so the StatefulSet builder only
209-
// needs the name and does not depend on the applied ServiceAccount.
210-
let service_account_name = rbac_sa.name_any();
211-
212-
cluster_resources
213-
.add(client, rbac_sa)
214-
.await
215-
.context(ApplyServiceAccountSnafu)?;
216-
cluster_resources
217-
.add(client, rbac_rolebinding)
218-
.await
219-
.context(ApplyRoleBindingSnafu)?;
220-
221179
// The internal secret is shared across all roles and role groups, so it only needs to be
222180
// created once per reconcile rather than inside the role loop below.
223181
create_shared_internal_secret(&validated_cluster, client, DRUID_CONTROLLER_NAME)
224182
.await
225183
.context(FailedInternalSecretCreationSnafu)?;
226184

227-
let resources =
228-
build::build(&validated_cluster, &service_account_name).context(BuildResourcesSnafu)?;
185+
let resources = build::build(&validated_cluster).context(BuildResourcesSnafu)?;
229186

230187
let mut ss_cond_builder = StatefulSetConditionBuilder::default();
231188

189+
for service_account in resources.service_accounts {
190+
cluster_resources
191+
.add(client, service_account)
192+
.await
193+
.context(ApplyResourceSnafu)?;
194+
}
195+
196+
for role_binding in resources.role_bindings {
197+
cluster_resources
198+
.add(client, role_binding)
199+
.await
200+
.context(ApplyResourceSnafu)?;
201+
}
202+
232203
// Apply order: everything a Pod mounts (ConfigMaps) must exist before the StatefulSets, so the
233204
// StatefulSets are applied last to prevent unnecessary Pod restarts.
234205
// See https://github.com/stackabletech/commons-operator/issues/111 for details.

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

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use std::str::FromStr;
44

55
use snafu::{ResultExt, Snafu};
6-
use stackable_operator::v2::types::operator::{ProductVersion, RoleGroupName};
6+
use stackable_operator::v2::types::operator::RoleGroupName;
77

88
use crate::{
99
controller::{
@@ -12,6 +12,7 @@ use crate::{
1212
config_map::build_rolegroup_config_map,
1313
listener::{build_group_listener, group_listener_name},
1414
pdb::build_pdb,
15+
rbac::{build_role_binding, build_service_account},
1516
service::{build_rolegroup_headless_service, build_rolegroup_metrics_service},
1617
statefulset::build_rolegroup_statefulset,
1718
},
@@ -26,11 +27,7 @@ stackable_operator::constant!(pub(crate) PLACEHOLDER_DISCOVERY_ROLE_GROUP: RoleG
2627

2728
// Placeholder role-group name used for the recommended labels of the role-level `Listener`
2829
// (which is not tied to a single role group).
29-
stackable_operator::constant!(pub(crate) PLACEHOLDER_LISTENER_ROLE_GROUP: RoleGroupName = "none");
30-
31-
// Placeholder product version used for labels on PVC templates, which cannot be modified once
32-
// deployed. A constant value keeps the labels stable across version upgrades.
33-
stackable_operator::constant!(pub(crate) UNVERSIONED_PRODUCT_VERSION: ProductVersion = "none");
30+
stackable_operator::constant!(pub(crate) NONE_ROLE_GROUP_NAME: RoleGroupName = "none");
3431

3532
pub mod authentication;
3633
pub mod graceful_shutdown;
@@ -57,19 +54,13 @@ pub enum Error {
5754
/// Builds the Kubernetes resources for the given validated cluster.
5855
///
5956
/// Does not need a Kubernetes client: every reference to another Kubernetes resource is already
60-
/// dereferenced and validated by this point. The remaining errors are resource-assembly failures
61-
/// only.
62-
///
63-
/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under
64-
/// (RBAC resources are built and applied separately, in the reconcile step).
57+
/// dereferenced and validated by this point.
58+
/// The remaining errors are resource-assembly failures only.
6559
///
6660
/// The Router group `Listener` and the discovery `ConfigMap`s are not built here: the discovery
6761
/// `ConfigMap` derives from the *applied* Router listener's ingress address, so both are built and
6862
/// applied in the reconcile step instead.
69-
pub fn build(
70-
cluster: &ValidatedCluster,
71-
service_account_name: &str,
72-
) -> Result<KubernetesResources, Error> {
63+
pub fn build(cluster: &ValidatedCluster) -> Result<KubernetesResources, Error> {
7364
let mut stateful_sets = vec![];
7465
let mut services = vec![];
7566
let mut listeners = vec![];
@@ -116,16 +107,11 @@ pub fn build(
116107
)?,
117108
);
118109
stateful_sets.push(
119-
build_rolegroup_statefulset(
120-
cluster,
121-
druid_role,
122-
role_group_name,
123-
rg,
124-
service_account_name,
125-
)
126-
.context(StatefulSetSnafu {
127-
role_group: role_group_name.clone(),
128-
})?,
110+
build_rolegroup_statefulset(cluster, druid_role, role_group_name, rg).context(
111+
StatefulSetSnafu {
112+
role_group: role_group_name.clone(),
113+
},
114+
)?,
129115
);
130116
}
131117
}
@@ -136,11 +122,15 @@ pub fn build(
136122
listeners,
137123
config_maps,
138124
pod_disruption_budgets,
125+
service_accounts: vec![build_service_account(cluster)],
126+
role_bindings: vec![build_role_binding(cluster)],
139127
})
140128
}
141129

142130
#[cfg(test)]
143131
mod tests {
132+
use std::collections::BTreeMap;
133+
144134
use stackable_operator::kube::Resource;
145135

146136
use super::build;
@@ -161,7 +151,7 @@ mod tests {
161151
fn build_produces_expected_resource_names() {
162152
let druid = druid_from_yaml(MINIMAL_DRUID_YAML);
163153
let cluster = validated_cluster(&druid);
164-
let resources = build(&cluster, "simple-druid-serviceaccount").expect("build succeeds");
154+
let resources = build(&cluster).expect("build succeeds");
165155

166156
// One StatefulSet and one ConfigMap per role group (one role group per role).
167157
let expected_role_group_names = [
@@ -188,4 +178,54 @@ mod tests {
188178
["simple-druid-broker", "simple-druid-coordinator"]
189179
);
190180
}
181+
182+
/// Locks the RBAC resource names, the roleRef, and the recommended label set against
183+
/// accidental drift. The fixture's cluster name deliberately differs from the product name so
184+
/// that swapped `name`/`instance` label values cannot pass unnoticed.
185+
#[test]
186+
fn build_produces_rbac() {
187+
let druid = druid_from_yaml(MINIMAL_DRUID_YAML);
188+
let cluster = validated_cluster(&druid);
189+
let resources = build(&cluster).expect("build succeeds");
190+
191+
assert_eq!(
192+
sorted_names(&resources.service_accounts),
193+
["simple-druid-serviceaccount"]
194+
);
195+
assert_eq!(
196+
sorted_names(&resources.role_bindings),
197+
["simple-druid-rolebinding"]
198+
);
199+
200+
let expected_labels = BTreeMap::from(
201+
[
202+
("app.kubernetes.io/component", "none"),
203+
("app.kubernetes.io/instance", "simple-druid"),
204+
(
205+
"app.kubernetes.io/managed-by",
206+
"druid.stackable.tech_druidcluster",
207+
),
208+
("app.kubernetes.io/name", "druid"),
209+
("app.kubernetes.io/role-group", "none"),
210+
("app.kubernetes.io/version", "30.0.0-stackable0.0.0-dev"),
211+
("stackable.tech/vendor", "Stackable"),
212+
]
213+
.map(|(key, value)| (key.to_string(), value.to_string())),
214+
);
215+
let service_account = resources
216+
.service_accounts
217+
.first()
218+
.expect("a ServiceAccount is built");
219+
assert_eq!(
220+
service_account.metadata.labels,
221+
Some(expected_labels.clone())
222+
);
223+
224+
let role_binding = resources
225+
.role_bindings
226+
.first()
227+
.expect("a RoleBinding is built");
228+
assert_eq!(role_binding.metadata.labels, Some(expected_labels));
229+
assert_eq!(role_binding.role_ref.name, "druid-clusterrole");
230+
}
191231
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ pub fn build_rolegroup_config_map(
151151
role_group_name: &RoleGroupName,
152152
rg: &DruidRoleGroupConfig,
153153
) -> Result<ConfigMap> {
154-
let resource_names = cluster.resource_names(role, role_group_name);
154+
let resource_names = cluster.role_group_resource_names(role, role_group_name);
155155
let cluster_config = &cluster.cluster_config;
156156
let druid_tls_security = &cluster_config.druid_tls_security;
157157
let druid_auth_config = &cluster_config.druid_auth_config;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use stackable_operator::{
1515

1616
use crate::{
1717
controller::{
18-
build::{PLACEHOLDER_LISTENER_ROLE_GROUP, security::listener_ports},
18+
build::{NONE_ROLE_GROUP_NAME, security::listener_ports},
1919
validate::ValidatedCluster,
2020
},
2121
crd::{
@@ -52,7 +52,7 @@ pub fn build_group_listener(
5252
.object_meta(
5353
listener_group_name.to_string(),
5454
druid_role,
55-
&PLACEHOLDER_LISTENER_ROLE_GROUP,
55+
&NONE_ROLE_GROUP_NAME,
5656
)
5757
.build(),
5858
spec: listener::v1alpha1::ListenerSpec {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ pub mod config_map;
44
pub mod discovery;
55
pub mod listener;
66
pub mod pdb;
7+
pub mod rbac;
78
pub mod service;
89
pub mod statefulset;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub fn build_pdb(
2727
let pdb = pod_disruption_budget_builder_with_role(
2828
cluster,
2929
&product_name(),
30-
&role.to_role_name(),
30+
&role.into(),
3131
&operator_name(),
3232
&controller_name(),
3333
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! Builds the RBAC resources (ServiceAccount + RoleBinding) shared by all role groups.
2+
3+
use std::str::FromStr;
4+
5+
use stackable_operator::{
6+
k8s_openapi::api::{core::v1::ServiceAccount, rbac::v1::RoleBinding},
7+
kvp::Labels,
8+
v2::{
9+
rbac,
10+
types::operator::{RoleGroupName, RoleName},
11+
},
12+
};
13+
14+
use crate::controller::validate::ValidatedCluster;
15+
16+
stackable_operator::constant!(NONE_ROLE_NAME: RoleName = "none");
17+
stackable_operator::constant!(NONE_ROLE_GROUP_NAME: RoleGroupName = "none");
18+
19+
/// Builds the [`ServiceAccount`] that all role-group Pods run under.
20+
pub fn build_service_account(cluster: &ValidatedCluster) -> ServiceAccount {
21+
rbac::build_service_account(
22+
cluster,
23+
&cluster.cluster_resource_names(),
24+
rbac_labels(cluster),
25+
)
26+
}
27+
28+
/// Builds the [`RoleBinding`] that binds the [`ServiceAccount`] from [`build_service_account`] to
29+
/// the operator-deployed ClusterRole.
30+
pub fn build_role_binding(cluster: &ValidatedCluster) -> RoleBinding {
31+
rbac::build_role_binding(
32+
cluster,
33+
&cluster.cluster_resource_names(),
34+
rbac_labels(cluster),
35+
)
36+
}
37+
38+
/// Both resources are shared by the whole cluster rather than tied to a role or role group, so
39+
/// the recommended labels carry `none` for both values.
40+
fn rbac_labels(cluster: &ValidatedCluster) -> Labels {
41+
cluster.recommended_labels_for(&NONE_ROLE_NAME, &NONE_ROLE_GROUP_NAME)
42+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub fn build_rolegroup_headless_service(
2222
metadata: cluster
2323
.object_meta(
2424
cluster
25-
.resource_names(druid_role, role_group_name)
25+
.role_group_resource_names(druid_role, role_group_name)
2626
.headless_service_name()
2727
.to_string(),
2828
druid_role,
@@ -59,7 +59,7 @@ pub fn build_rolegroup_metrics_service(
5959
metadata: cluster
6060
.object_meta(
6161
cluster
62-
.resource_names(druid_role, role_group_name)
62+
.role_group_resource_names(druid_role, role_group_name)
6363
.metrics_service_name()
6464
.to_string(),
6565
druid_role,

0 commit comments

Comments
 (0)