Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Masters and regionservers now have a default affinity to OPA server Pods when OPA authorization is configured ([#814]).
- Support floating tags for product images via the new `spec.image.stackableVersionPolicy` field
([#809]).
- Add `/ready` endpoint to the operator Deployment, which reports the CRD installation status ([#812]).
Expand Down Expand Up @@ -56,6 +57,7 @@
[#803]: https://github.com/stackabletech/hbase-operator/pull/803
[#809]: https://github.com/stackabletech/hbase-operator/pull/809
[#812]: https://github.com/stackabletech/hbase-operator/pull/812
[#814]: https://github.com/stackabletech/hbase-operator/pull/814

## [26.7.0] - 2026-07-21

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ The default affinities created by the operator are:
2. Co-locate HBase regionservers with the underlying HDFS datanodes (weight 50)
3. Distribute all Pods within the same role across nodes so multiple instances don't end up on the same Kubernetes node (masters, regionservers, rest servers) (weight 70)

When OPA authorization is configured, the operator also prefers to co-locate masters and regionservers with OPA server Pods (weight 50).
The OPA coprocessors run in these roles; REST servers do not receive this affinity.
The `configMapName` in `clusterConfig.authorization.opa` is used as the OPA cluster name for Pod selection, because the OPA discovery ConfigMap has the same name as its OpaCluster.
The OPA cluster must be in the same namespace for this affinity to match its Pods.
This is a scheduling preference, not a placement guarantee or local service routing: HBase still uses the OPA endpoint from the discovery ConfigMap.
It has little effect when OPA server Pods run as a DaemonSet, but can help when they run as a Deployment.

NOTE: All default affinities are only preferred and not enforced, as we can not expect all setups to have multiple Kubernetes nodes.
If you want to have them enforced, you need to specify you own `requiredDuringSchedulingIgnoredDuringExecution` affinities.

Expand Down
10 changes: 10 additions & 0 deletions rust/operator-binary/src/controller/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ pub fn validate_cluster(

let hdfs_discovery_cm_name = hbase.spec.cluster_config.hdfs_config_map_name.as_ref();
let cluster_name = hbase.name_any();
let opa_config = hbase
.spec
.cluster_config
.authorization
.as_ref()
.and_then(|authorization| authorization.opa.as_ref());

// The Vector aggregator discovery ConfigMap name. It is validated already at deserialize time
// (it is a `ConfigMapName`), and only required when the Vector agent is enabled for a role
Expand All @@ -147,6 +153,7 @@ pub fn validate_cluster(
&hbase_role,
&cluster_name,
hdfs_discovery_cm_name,
opa_config,
),
AnyServiceConfig::Master,
&vector_aggregator_config_map_name,
Expand All @@ -157,6 +164,7 @@ pub fn validate_cluster(
&hbase_role,
&cluster_name,
hdfs_discovery_cm_name,
opa_config,
),
AnyServiceConfig::RegionServer,
&vector_aggregator_config_map_name,
Expand All @@ -167,6 +175,7 @@ pub fn validate_cluster(
&hbase_role,
&cluster_name,
hdfs_discovery_cm_name,
opa_config,
),
AnyServiceConfig::RestServer,
&vector_aggregator_config_map_name,
Expand Down Expand Up @@ -357,6 +366,7 @@ spec:
&HbaseRole::Master,
&hbase.name_any(),
hbase.spec.cluster_config.hdfs_config_map_name.as_ref(),
None,
);

let validated = with_validated_config::<
Expand Down
161 changes: 100 additions & 61 deletions rust/operator-binary/src/crd/affinity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use stackable_operator::{
commons::affinity::{
StackableAffinityFragment, affinity_between_cluster_pods, affinity_between_role_pods,
},
commons::opa::OpaConfig,
k8s_openapi::api::core::v1::{PodAffinity, PodAntiAffinity},
};

Expand All @@ -11,67 +12,47 @@ pub fn get_affinity(
cluster_name: &str,
role: &HbaseRole,
hdfs_discovery_cm_name: &str,
opa_config: Option<&OpaConfig>,
) -> StackableAffinityFragment {
let affinity_between_cluster_pods = affinity_between_cluster_pods(APP_NAME, cluster_name, 20);
match role {
HbaseRole::Master => StackableAffinityFragment {
pod_affinity: Some(PodAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_cluster_pods,
// We would like a affinity to the Zookeeper Pods, but the hbase CRD only contains a ZNode reference.
// We could look up the ZNode and extract the zk cluster from it but that causes network calls
// See https://github.com/stackabletech/zookeeper-operator/issues/644
// Watch out: The zk can be in a different namespace, so the namespaceSelector must be used
]),
required_during_scheduling_ignored_during_execution: None,
}),
pod_anti_affinity: Some(PodAntiAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_role_pods(APP_NAME, cluster_name, &role.to_string(), 70),
]),
required_during_scheduling_ignored_during_execution: None,
}),
node_affinity: None,
node_selector: None,
},
HbaseRole::RegionServer => StackableAffinityFragment {
pod_affinity: Some(PodAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_cluster_pods,
affinity_between_role_pods(
"hdfs",
hdfs_discovery_cm_name, // The discovery cm has the same name as the HdfsCluster itself
"datanode",
50,
),
]),
required_during_scheduling_ignored_during_execution: None,
}),
pod_anti_affinity: Some(PodAntiAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_role_pods(APP_NAME, cluster_name, &role.to_string(), 70),
]),
required_during_scheduling_ignored_during_execution: None,
}),
node_affinity: None,
node_selector: None,
},
HbaseRole::RestServer => StackableAffinityFragment {
pod_affinity: Some(PodAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_cluster_pods,
]),
required_during_scheduling_ignored_during_execution: None,
}),
pod_anti_affinity: Some(PodAntiAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_role_pods(APP_NAME, cluster_name, &role.to_string(), 70),
]),
required_during_scheduling_ignored_during_execution: None,
}),
node_affinity: None,
node_selector: None,
},
let mut affinities = vec![affinity_between_cluster_pods(APP_NAME, cluster_name, 20)];
if role == &HbaseRole::RegionServer {
affinities.push(affinity_between_role_pods(
"hdfs",
hdfs_discovery_cm_name, // The discovery cm has the same name as the HdfsCluster itself
"datanode",
50,
));
}
// We would like an affinity to the ZooKeeper Pods, but the HBase CRD only contains a ZNode
// reference. Looking up its cluster would require a network call, and it may be in another
// namespace (which would require namespaceSelector).
// See https://github.com/stackabletech/zookeeper-operator/issues/644

// The OPA coprocessors run in masters and regionservers, not in REST servers.
if let Some(opa_config) = opa_config
&& role != &HbaseRole::RestServer
{
affinities.push(affinity_between_role_pods(
"opa",
&opa_config.config_map_name, // The discovery ConfigMap has the same name as the OpaCluster.
"server",
50,
));
}

StackableAffinityFragment {
pod_affinity: Some(PodAffinity {
preferred_during_scheduling_ignored_during_execution: Some(affinities),
required_during_scheduling_ignored_during_execution: None,
}),
pod_anti_affinity: Some(PodAntiAffinity {
preferred_during_scheduling_ignored_during_execution: Some(vec![
affinity_between_role_pods(APP_NAME, cluster_name, &role.to_string(), 70),
]),
required_during_scheduling_ignored_during_execution: None,
}),
node_affinity: None,
node_selector: None,
}
}

Expand All @@ -91,7 +72,7 @@ mod tests {
};

use super::*;
use crate::crd::v1alpha1;
use crate::crd::{security::AuthorizationConfig, v1alpha1};

#[rstest]
#[case(HbaseRole::Master)]
Expand Down Expand Up @@ -224,4 +205,62 @@ mod tests {
}
);
}

#[rstest]
#[case(HbaseRole::Master)]
#[case(HbaseRole::RegionServer)]
#[case(HbaseRole::RestServer)]
fn test_opa_affinity(#[case] role: HbaseRole) {
let mut hbase = crate::test_utils::minimal_hbase();
let without_opa = crate::test_utils::validated_cluster_from(&hbase);
let default_affinity = crate::test_utils::merged_config(&without_opa, &role)
.affinity()
.clone();

hbase.spec.cluster_config.authorization = Some(AuthorizationConfig {
opa: Some(
serde_yaml::from_str("configMapName: simple-opa\npackage: hbase")
.expect("valid OPA configuration"),
),
});
let with_opa = crate::test_utils::validated_cluster_from(&hbase);
let affinity = crate::test_utils::merged_config(&with_opa, &role)
.affinity()
.clone();

if role == HbaseRole::RestServer {
assert_eq!(affinity, default_affinity);
} else {
let mut expected = default_affinity;
expected
.pod_affinity
.as_mut()
.unwrap()
.preferred_during_scheduling_ignored_during_execution
.as_mut()
.unwrap()
.push(WeightedPodAffinityTerm {
pod_affinity_term: PodAffinityTerm {
label_selector: Some(LabelSelector {
match_labels: Some(BTreeMap::from([
("app.kubernetes.io/name".to_string(), "opa".to_string()),
(
"app.kubernetes.io/instance".to_string(),
"simple-opa".to_string(),
),
(
"app.kubernetes.io/component".to_string(),
"server".to_string(),
),
])),
..LabelSelector::default()
}),
topology_key: "kubernetes.io/hostname".to_string(),
..PodAffinityTerm::default()
},
weight: 50,
});
assert_eq!(affinity, expected);
}
}
}
6 changes: 4 additions & 2 deletions rust/operator-binary/src/crd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ impl HbaseConfigFragment {
role: &HbaseRole,
cluster_name: &str,
hdfs_discovery_cm_name: &str,
opa_config: Option<&stackable_operator::commons::opa::OpaConfig>,
) -> Self {
let graceful_shutdown_timeout = match role {
HbaseRole::Master => HbaseRole::DEFAULT_MASTER_GRACEFUL_SHUTDOWN_TIMEOUT,
Expand All @@ -330,7 +331,7 @@ impl HbaseConfigFragment {
hbase_rootdir: Some(default_hbase_rootdir()),
resources: default_resources(role),
logging: product_logging::spec::default_logging(),
affinity: get_affinity(cluster_name, role, hdfs_discovery_cm_name),
affinity: get_affinity(cluster_name, role, hdfs_discovery_cm_name, opa_config),
graceful_shutdown_timeout: Some(graceful_shutdown_timeout),
requested_secret_lifetime: Some(requested_secret_lifetime),
listener_class: Some(
Expand All @@ -347,12 +348,13 @@ impl RegionServerConfigFragment {
role: &HbaseRole,
cluster_name: &str,
hdfs_discovery_cm_name: &str,
opa_config: Option<&stackable_operator::commons::opa::OpaConfig>,
) -> Self {
RegionServerConfigFragment {
hbase_rootdir: Some(default_hbase_rootdir()),
resources: default_resources(role),
logging: product_logging::spec::default_logging(),
affinity: get_affinity(cluster_name, role, hdfs_discovery_cm_name),
affinity: get_affinity(cluster_name, role, hdfs_discovery_cm_name, opa_config),
graceful_shutdown_timeout: Some(
HbaseRole::DEFAULT_REGION_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT,
),
Expand Down