From a5d2163f6c47c09e9a8154f23dab3a5e8a5b9f2d Mon Sep 17 00:00:00 2001 From: Marc-Merino <35137847+Marc-Merino@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:49:30 -0500 Subject: [PATCH 1/2] feat: Add default affinity to OPA server Pods Knit-Group: kg_20260928_53b541 Knit-Bundle: opa-client-pod-affinity --- CHANGELOG.md | 2 + .../usage-guide/operations/pod-placement.adoc | 7 + .../src/controller/validate.rs | 10 ++ rust/operator-binary/src/crd/affinity.rs | 161 +++++++++++------- rust/operator-binary/src/crd/mod.rs | 6 +- 5 files changed, 123 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6e6441d..eb72f9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Masters and regionservers now have a default affinity to OPA server Pods when OPA authorization is configured ([#XXX]). - 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]). @@ -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 +[#XXX]: https://github.com/stackabletech/hbase-operator/pull/XXX ## [26.7.0] - 2026-07-21 diff --git a/docs/modules/hbase/pages/usage-guide/operations/pod-placement.adoc b/docs/modules/hbase/pages/usage-guide/operations/pod-placement.adoc index 3401afa1..a9a34912 100644 --- a/docs/modules/hbase/pages/usage-guide/operations/pod-placement.adoc +++ b/docs/modules/hbase/pages/usage-guide/operations/pod-placement.adoc @@ -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. diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index 73cf44a3..21ad78d7 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -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 @@ -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, @@ -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, @@ -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, @@ -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::< diff --git a/rust/operator-binary/src/crd/affinity.rs b/rust/operator-binary/src/crd/affinity.rs index 540bee68..8af06128 100644 --- a/rust/operator-binary/src/crd/affinity.rs +++ b/rust/operator-binary/src/crd/affinity.rs @@ -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}, }; @@ -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, } } @@ -91,7 +72,7 @@ mod tests { }; use super::*; - use crate::crd::v1alpha1; + use crate::crd::{security::AuthorizationConfig, v1alpha1}; #[rstest] #[case(HbaseRole::Master)] @@ -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); + } + } } diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index ff9d983e..8847a60a 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -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, @@ -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( @@ -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, ), From c4f5d958ce7ad381a8f86ead8ed5f4fc5873ed87 Mon Sep 17 00:00:00 2001 From: Marc-Merino <35137847+Marc-Merino@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:06:34 -0500 Subject: [PATCH 2/2] docs: Link the pull request in the changelog Knit-Group: kg_20260928_ef2936 Knit-Bundle: opa-client-pod-affinity --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb72f9a3..5aad223d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Masters and regionservers now have a default affinity to OPA server Pods when OPA authorization is configured ([#XXX]). +- 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]). @@ -57,7 +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 -[#XXX]: https://github.com/stackabletech/hbase-operator/pull/XXX +[#814]: https://github.com/stackabletech/hbase-operator/pull/814 ## [26.7.0] - 2026-07-21