Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ 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 ([#841]).
- The RBAC ServiceAccount and RoleBinding are now built with the operator-rs `v2::rbac`
functions and carry the full set of recommended labels ([#846]).
- Bump stackable-operator to 0.114.0 ([#855]).

[#841]: https://github.com/stackabletech/druid-operator/pull/841
[#846]: https://github.com/stackabletech/druid-operator/pull/846
[#855]: https://github.com/stackabletech/druid-operator/pull/855

## [26.7.0] - 2026-07-21
Expand Down
67 changes: 19 additions & 48 deletions rust/operator-binary/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ use snafu::{ResultExt, Snafu};
use stackable_operator::{
cli::OperatorEnvironmentOptions,
cluster_resources::ClusterResourceApplyStrategy,
commons::rbac::build_rbac_resources,
crd::listener::v1alpha1::Listener,
k8s_openapi::api::{
apps::v1::StatefulSet,
core::v1::{ConfigMap, Service},
core::v1::{ConfigMap, Service, ServiceAccount},
policy::v1::PodDisruptionBudget,
rbac::v1::RoleBinding,
},
kube::{
ResourceExt,
core::{DeserializeGuard, error_boundary},
runtime::controller::Action,
},
kvp::{KeyValuePairError, LabelValueError},
logging::controller::ReconcilerError,
shared::time::Duration,
status::condition::{
Expand Down Expand Up @@ -83,6 +81,8 @@ pub struct KubernetesResources {
pub listeners: Vec<Listener>,
pub config_maps: Vec<ConfigMap>,
pub pod_disruption_budgets: Vec<PodDisruptionBudget>,
pub service_accounts: Vec<ServiceAccount>,
pub role_bindings: Vec<RoleBinding>,
}

#[derive(Snafu, Debug, EnumDiscriminants)]
Expand Down Expand Up @@ -122,26 +122,6 @@ pub enum Error {
source: crate::internal_secret::Error,
},

#[snafu(display("failed to create RBAC service account"))]
ApplyServiceAccount {
source: stackable_operator::cluster_resources::Error,
},

#[snafu(display("failed to create RBAC 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: KeyValuePairError<LabelValueError>,
},

#[snafu(display("DruidCluster object is invalid"))]
InvalidDruidCluster {
source: error_boundary::InvalidObject,
Expand Down Expand Up @@ -196,39 +176,30 @@ pub async fn reconcile_druid(
&druid.spec.object_overrides,
);

let (rbac_sa, rbac_rolebinding) = build_rbac_resources(
druid,
APP_NAME,
cluster_resources
.get_required_labels()
.context(GetRequiredLabelsSnafu)?,
)
.context(BuildRbacResourcesSnafu)?;

// The ServiceAccount name is deterministic on the built object, so the StatefulSet builder only
// needs the name and 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)?;

// The internal secret is shared across all roles and role groups, so it only needs to be
// created once per reconcile rather than inside the role loop below.
create_shared_internal_secret(&validated_cluster, client, DRUID_CONTROLLER_NAME)
.await
.context(FailedInternalSecretCreationSnafu)?;

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();

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)?;
}

// Apply order: everything a Pod mounts (ConfigMaps) must exist before the StatefulSets, so the
// StatefulSets are applied last to prevent unnecessary Pod restarts.
// See https://github.com/stackabletech/commons-operator/issues/111 for details.
Expand Down
85 changes: 64 additions & 21 deletions rust/operator-binary/src/controller/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use std::str::FromStr;

use snafu::{ResultExt, Snafu};
use stackable_operator::v2::types::operator::{ProductVersion, RoleGroupName};
use stackable_operator::v2::types::operator::RoleGroupName;

use crate::{
controller::{
Expand All @@ -12,6 +12,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_rolegroup_statefulset,
},
Expand All @@ -26,11 +27,7 @@ stackable_operator::constant!(pub(crate) PLACEHOLDER_DISCOVERY_ROLE_GROUP: RoleG

// Placeholder role-group name used for the recommended labels of the role-level `Listener`
// (which is not tied to a single role group).
stackable_operator::constant!(pub(crate) PLACEHOLDER_LISTENER_ROLE_GROUP: RoleGroupName = "none");

// Placeholder product version used for labels on PVC templates, which cannot be modified once
// deployed. A constant value keeps the labels stable across version upgrades.
stackable_operator::constant!(pub(crate) UNVERSIONED_PRODUCT_VERSION: ProductVersion = "none");
stackable_operator::constant!(pub(crate) NONE_ROLE_GROUP_NAME: RoleGroupName = "none");

pub mod authentication;
pub mod graceful_shutdown;
Expand Down Expand Up @@ -66,10 +63,7 @@ pub enum Error {
/// The Router group `Listener` and the discovery `ConfigMap`s are not built here: the discovery
/// `ConfigMap` derives from the *applied* Router listener's ingress address, so both are built and
/// applied in the reconcile step instead.
pub fn build(
cluster: &ValidatedCluster,
service_account_name: &str,
) -> Result<KubernetesResources, Error> {
pub fn build(cluster: &ValidatedCluster) -> Result<KubernetesResources, Error> {
let mut stateful_sets = vec![];
let mut services = vec![];
let mut listeners = vec![];
Expand Down Expand Up @@ -116,16 +110,11 @@ pub fn build(
)?,
);
stateful_sets.push(
build_rolegroup_statefulset(
cluster,
druid_role,
role_group_name,
rg,
service_account_name,
)
.context(StatefulSetSnafu {
role_group: role_group_name.clone(),
})?,
build_rolegroup_statefulset(cluster, druid_role, role_group_name, rg).context(
StatefulSetSnafu {
role_group: role_group_name.clone(),
},
)?,
);
}
}
Expand All @@ -136,11 +125,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;
Expand All @@ -161,7 +154,7 @@ mod tests {
fn build_produces_expected_resource_names() {
let druid = druid_from_yaml(MINIMAL_DRUID_YAML);
let cluster = validated_cluster(&druid);
let resources = build(&cluster, "simple-druid-serviceaccount").expect("build succeeds");
let resources = build(&cluster).expect("build succeeds");

// One StatefulSet and one ConfigMap per role group (one role group per role).
let expected_role_group_names = [
Expand All @@ -188,4 +181,54 @@ mod tests {
["simple-druid-broker", "simple-druid-coordinator"]
);
}

/// 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 druid = druid_from_yaml(MINIMAL_DRUID_YAML);
let cluster = validated_cluster(&druid);
let resources = build(&cluster).expect("build succeeds");

assert_eq!(
sorted_names(&resources.service_accounts),
["simple-druid-serviceaccount"]
);
assert_eq!(
sorted_names(&resources.role_bindings),
["simple-druid-rolebinding"]
);

let expected_labels = BTreeMap::from(
[
("app.kubernetes.io/component", "none"),
("app.kubernetes.io/instance", "simple-druid"),
(
"app.kubernetes.io/managed-by",
"druid.stackable.tech_druidcluster",
),
("app.kubernetes.io/name", "druid"),
("app.kubernetes.io/role-group", "none"),
("app.kubernetes.io/version", "30.0.0-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, "druid-clusterrole");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ pub fn build_rolegroup_config_map(
role_group_name: &RoleGroupName,
rg: &DruidRoleGroupConfig,
) -> Result<ConfigMap> {
let resource_names = cluster.resource_names(role, role_group_name);
let resource_names = cluster.role_group_resource_names(role, role_group_name);
let cluster_config = &cluster.cluster_config;
let druid_tls_security = &cluster_config.druid_tls_security;
let druid_auth_config = &cluster_config.druid_auth_config;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use stackable_operator::{

use crate::{
controller::{
build::{PLACEHOLDER_LISTENER_ROLE_GROUP, security::listener_ports},
build::{NONE_ROLE_GROUP_NAME, security::listener_ports},
validate::ValidatedCluster,
},
crd::{
Expand Down Expand Up @@ -52,7 +52,7 @@ pub fn build_group_listener(
.object_meta(
listener_group_name.to_string(),
druid_role,
&PLACEHOLDER_LISTENER_ROLE_GROUP,
&NONE_ROLE_GROUP_NAME,
)
.build(),
spec: listener::v1alpha1::ListenerSpec {
Expand Down
1 change: 1 addition & 0 deletions rust/operator-binary/src/controller/build/resource/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ pub mod config_map;
pub mod discovery;
pub mod listener;
pub mod pdb;
pub mod rbac;
pub mod service;
pub mod statefulset;
2 changes: 1 addition & 1 deletion rust/operator-binary/src/controller/build/resource/pdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub fn build_pdb(
let pdb = pod_disruption_budget_builder_with_role(
cluster,
&product_name(),
&role.to_role_name(),
&ValidatedCluster::role_name(role),
&operator_name(),
&controller_name(),
)
Expand Down
42 changes: 42 additions & 0 deletions rust/operator-binary/src/controller/build/resource/rbac.rs
Original file line number Diff line number Diff line change
@@ -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::validate::ValidatedCluster;

stackable_operator::constant!(NONE_ROLE_NAME: RoleName = "none");
stackable_operator::constant!(NONE_ROLE_GROUP_NAME: RoleGroupName = "none");

/// Builds the [`ServiceAccount`] that all 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 [`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)
}
4 changes: 2 additions & 2 deletions rust/operator-binary/src/controller/build/resource/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub fn build_rolegroup_headless_service(
metadata: cluster
.object_meta(
cluster
.resource_names(druid_role, role_group_name)
.role_group_resource_names(druid_role, role_group_name)
.headless_service_name()
.to_string(),
druid_role,
Expand Down Expand Up @@ -59,7 +59,7 @@ pub fn build_rolegroup_metrics_service(
metadata: cluster
.object_meta(
cluster
.resource_names(druid_role, role_group_name)
.role_group_resource_names(druid_role, role_group_name)
.metrics_service_name()
.to_string(),
druid_role,
Expand Down
Loading
Loading