diff --git a/CHANGELOG.md b/CHANGELOG.md index b94b4e96..4e89668c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Support floating tags for product images via the new `spec.image.stackableVersionPolicy` field ([#790]). - Add `/ready` endpoint to the operator Deployment, which reports the CRD installation status ([#795]). +- Nodes now have a default affinity to the OPA Pods when the role mapping from OPA is configured ([#796]). ### Changed @@ -67,6 +68,7 @@ [#790]: https://github.com/stackabletech/superset-operator/pull/790 [#791]: https://github.com/stackabletech/superset-operator/pull/791 [#795]: https://github.com/stackabletech/superset-operator/pull/795 +[#796]: https://github.com/stackabletech/superset-operator/pull/796 ## [26.7.0] - 2026-07-21 diff --git a/docs/modules/superset/pages/usage-guide/operations/pod-placement.adoc b/docs/modules/superset/pages/usage-guide/operations/pod-placement.adoc index 83b6120a..bd606026 100644 --- a/docs/modules/superset/pages/usage-guide/operations/pod-placement.adoc +++ b/docs/modules/superset/pages/usage-guide/operations/pod-placement.adoc @@ -5,3 +5,5 @@ You can configure the Pod placement of the Superset pods as described in xref:co The default affinities created by the operator are: 1. Distribute all the Superset Pods (weight 70) +2. If the role mapping from OPA is configured: co-locate the Superset nodes with the OPA Pods (weight 50). + Workers and beat do not get this affinity. diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index b21ea529..b3fc2e9c 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -240,7 +240,11 @@ fn validate_role_groups( let Some(resolved_role) = superset.get_role(role) else { return Ok(BTreeMap::new()); }; - let default_config = SupersetConfig::default_config(&superset.name_any(), role); + let default_config = SupersetConfig::default_config( + &superset.name_any(), + role, + superset.get_opa_config().map(|opa_config| &opa_config.opa), + ); resolved_role .role_groups diff --git a/rust/operator-binary/src/crd/affinity.rs b/rust/operator-binary/src/crd/affinity.rs index 146f725f..08250e2c 100644 --- a/rust/operator-binary/src/crd/affinity.rs +++ b/rust/operator-binary/src/crd/affinity.rs @@ -1,13 +1,35 @@ use stackable_operator::{ - commons::affinity::{StackableAffinityFragment, affinity_between_role_pods}, - k8s_openapi::api::core::v1::PodAntiAffinity, + commons::{ + affinity::{StackableAffinityFragment, affinity_between_role_pods}, + opa::OpaConfig, + }, + k8s_openapi::api::core::v1::{PodAffinity, PodAntiAffinity}, }; use crate::crd::{APP_NAME, SupersetRole}; -pub fn get_affinity(cluster_name: &str, role: &SupersetRole) -> StackableAffinityFragment { +/// `opa_config` is only passed for roles that send requests to OPA. +pub fn get_affinity( + cluster_name: &str, + role: &SupersetRole, + opa_config: Option<&OpaConfig>, +) -> StackableAffinityFragment { + // With the role mapping from OPA configured, the role sends its requests to OPA, so prefer to + // place it next to the OPA Pods. + let pod_affinity = opa_config.map(|opa_config| PodAffinity { + preferred_during_scheduling_ignored_during_execution: Some(vec![ + affinity_between_role_pods( + "opa", + &opa_config.config_map_name, // The discovery cm has the same name as the OpaCluster itself + "server", + 50, + ), + ]), + required_during_scheduling_ignored_during_execution: None, + }); + StackableAffinityFragment { - pod_affinity: None, + pod_affinity, 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), @@ -23,11 +45,14 @@ pub fn get_affinity(cluster_name: &str, role: &SupersetRole) -> StackableAffinit mod tests { use std::collections::BTreeMap; + use rstest::rstest; use stackable_operator::{ commons::affinity::StackableAffinity, config::fragment, k8s_openapi::{ - api::core::v1::{PodAffinityTerm, PodAntiAffinity, WeightedPodAffinityTerm}, + api::core::v1::{ + PodAffinity, PodAffinityTerm, PodAntiAffinity, WeightedPodAffinityTerm, + }, apimachinery::pkg::apis::meta::v1::LabelSelector, }, kube::ResourceExt, @@ -37,8 +62,11 @@ mod tests { use super::*; use crate::crd::v1alpha1; - #[test] - fn test_affinity_defaults() { + #[rstest] + #[case(SupersetRole::Node)] + #[case(SupersetRole::Worker)] + #[case(SupersetRole::Beat)] + fn test_affinity_defaults(#[case] role: SupersetRole) { let input = r#" apiVersion: superset.stackable.tech/v1alpha1 kind: SupersetCluster @@ -54,6 +82,10 @@ mod tests { host: superset-postgresql database: superset credentialsSecretName: superset-postgresql-credentials + authorization: + roleMappingFromOpa: + configMapName: simple-opa + package: superset nodes: roleGroups: default: @@ -63,15 +95,50 @@ mod tests { yaml_from_str_singleton_map(input).expect("illegal test input"); // The role group carries no resource/affinity overrides, so the merged config is just the // validated default config. - let merged_config: v1alpha1::SupersetConfig = fragment::validate( - v1alpha1::SupersetConfig::default_config(&superset.name_any(), &SupersetRole::Node), - ) - .expect("default config should validate"); + let merged_config: v1alpha1::SupersetConfig = + fragment::validate(v1alpha1::SupersetConfig::default_config( + &superset.name_any(), + &role, + superset.get_opa_config().map(|opa_config| &opa_config.opa), + )) + .expect("default config should validate"); assert_eq!( merged_config.affinity, StackableAffinity { - pod_affinity: None, + pod_affinity: match role { + // Only the web server (node) is known to request the role mapping from OPA. + SupersetRole::Node => Some(PodAffinity { + preferred_during_scheduling_ignored_during_execution: Some(vec![ + WeightedPodAffinityTerm { + pod_affinity_term: PodAffinityTerm { + label_selector: Some(LabelSelector { + match_expressions: None, + 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(), + ), + ])), + }), + topology_key: "kubernetes.io/hostname".to_string(), + ..PodAffinityTerm::default() + }, + weight: 50, + } + ]), + required_during_scheduling_ignored_during_execution: None, + }), + SupersetRole::Worker | SupersetRole::Beat => None, + }, pod_anti_affinity: Some(PodAntiAffinity { preferred_during_scheduling_ignored_during_execution: Some(vec![ WeightedPodAffinityTerm { @@ -89,7 +156,7 @@ mod tests { ), ( "app.kubernetes.io/component".to_string(), - "node".to_string(), + role.to_string(), ) ])) }), diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index e029c466..7043e176 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -510,6 +510,7 @@ impl v1alpha1::SupersetConfig { pub(crate) fn default_config( cluster_name: &str, role: &SupersetRole, + opa_config: Option<&OpaConfig>, ) -> v1alpha1::SupersetConfigFragment { match role { SupersetRole::Node => v1alpha1::SupersetConfigFragment { @@ -525,7 +526,7 @@ impl v1alpha1::SupersetConfig { storage: v1alpha1::SupersetStorageConfigFragment {}, }, logging: product_logging::spec::default_logging(), - affinity: affinity::get_affinity(cluster_name, role), + affinity: affinity::get_affinity(cluster_name, role, opa_config), graceful_shutdown_timeout: Some(DEFAULT_NODE_GRACEFUL_SHUTDOWN_TIMEOUT), row_limit: None, webserver_timeout: None, @@ -543,7 +544,9 @@ impl v1alpha1::SupersetConfig { storage: v1alpha1::SupersetStorageConfigFragment {}, }, logging: product_logging::spec::default_logging(), - affinity: affinity::get_affinity(cluster_name, role), + // Only the web server (node) is known to request the role mapping from OPA, so + // workers and beat get no affinity to the OPA Pods. + affinity: affinity::get_affinity(cluster_name, role, None), graceful_shutdown_timeout: Some(DEFAULT_NODE_GRACEFUL_SHUTDOWN_TIMEOUT), row_limit: None, webserver_timeout: None, @@ -561,7 +564,9 @@ impl v1alpha1::SupersetConfig { storage: v1alpha1::SupersetStorageConfigFragment {}, }, logging: product_logging::spec::default_logging(), - affinity: affinity::get_affinity(cluster_name, role), + // Only the web server (node) is known to request the role mapping from OPA, so + // workers and beat get no affinity to the OPA Pods. + affinity: affinity::get_affinity(cluster_name, role, None), graceful_shutdown_timeout: Some(DEFAULT_NODE_GRACEFUL_SHUTDOWN_TIMEOUT), row_limit: None, webserver_timeout: None,