From 28b1f2757f2072bc353d726cb216d0bb2e40cf4b Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 27 Mar 2026 16:17:06 +0100 Subject: [PATCH 1/8] feat: Support hot-reloading for security configuration files --- rust/operator-binary/src/controller/build.rs | 5 +- .../src/controller/build/role_builder.rs | 153 ++++++++++++-- .../controller/build/role_group_builder.rs | 138 ++++++------- .../src/controller/build/scripts/test.sh | 14 ++ .../build/scripts/update-security-config.sh | 188 ++++++++++++++---- .../src/framework/builder/meta.rs | 21 +- .../security-config/10-security-config.yaml | 4 + .../kuttl/security-config/11-assert.yaml | 2 +- .../kuttl/security-config/21-assert.yaml | 2 +- 9 files changed, 385 insertions(+), 142 deletions(-) create mode 100755 rust/operator-binary/src/controller/build/scripts/test.sh diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 5be50f3..948b498 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -33,9 +33,12 @@ pub fn build(names: &ContextNames, cluster: ValidatedCluster) -> KubernetesResou listeners.push(role_group_builder.build_listener()); } - if let Some(discovery_config_map) = role_builder.build_discovery_config_map() { + if let Some(discovery_config_map) = role_builder.build_maybe_discovery_config_map() { config_maps.push(discovery_config_map); } + if let Some(security_config_map) = role_builder.build_maybe_security_config_map() { + config_maps.push(security_config_map); + } services.push(role_builder.build_seed_nodes_service()); listeners.push(role_builder.build_discovery_service_listener()); diff --git a/rust/operator-binary/src/controller/build/role_builder.rs b/rust/operator-binary/src/controller/build/role_builder.rs index 385e0fc..93de96d 100644 --- a/rust/operator-binary/src/controller/build/role_builder.rs +++ b/rust/operator-binary/src/controller/build/role_builder.rs @@ -1,6 +1,6 @@ //! Builder for role resources -use std::str::FromStr; +use std::{collections::BTreeMap, str::FromStr}; use stackable_operator::{ builder::meta::ObjectMetaBuilder, @@ -13,7 +13,6 @@ use stackable_operator::{ rbac::v1::{ClusterRole, RoleBinding, RoleRef, Subject}, }, }, - kube::api::ObjectMeta, kvp::{ Label, Labels, consts::{STACKABLE_VENDOR_KEY, STACKABLE_VENDOR_VALUE}, @@ -23,12 +22,14 @@ use stackable_operator::{ use crate::{ controller::{ ContextNames, HTTP_PORT, HTTP_PORT_NAME, TRANSPORT_PORT, TRANSPORT_PORT_NAME, - ValidatedCluster, build::role_group_builder::RoleGroupBuilder, + ValidatedCluster, ValidatedSecurity, build::role_group_builder::RoleGroupBuilder, }, + crd::v1alpha1, framework::{ NameIsValidLabelValue, builder::{ - meta::ownerreference_from_resource, pdb::pod_disruption_budget_builder_with_role, + meta::{annotation_ignore_restarter, ownerreference_from_resource}, + pdb::pod_disruption_budget_builder_with_role, }, role_utils::ResourceNames, types::{ @@ -80,7 +81,9 @@ impl<'a> RoleBuilder<'a> { /// Builds a ServiceAccount used by all role-groups pub fn build_service_account(&self) -> ServiceAccount { - let metadata = self.common_metadata(self.resource_names.service_account_name()); + let metadata = self + .common_metadata(self.resource_names.service_account_name()) + .build(); ServiceAccount { metadata, @@ -90,7 +93,9 @@ impl<'a> RoleBuilder<'a> { /// Builds a RoleBinding used by all role-groups pub fn build_role_binding(&self) -> RoleBinding { - let metadata = self.common_metadata(self.resource_names.role_binding_name()); + let metadata = self + .common_metadata(self.resource_names.role_binding_name()) + .build(); RoleBinding { metadata, @@ -116,7 +121,9 @@ impl<'a> RoleBuilder<'a> { ..ServicePort::default() }]; - let metadata = self.common_metadata(seed_nodes_service_name(&self.cluster.name)); + let metadata = self + .common_metadata(seed_nodes_service_name(&self.cluster.name)) + .build(); let service_selector = RoleGroupBuilder::cluster_manager_labels(&self.cluster, self.context_names); @@ -140,7 +147,9 @@ impl<'a> RoleBuilder<'a> { /// Builds a Listener whose status is used to populate the discovery ConfigMap. pub fn build_discovery_service_listener(&self) -> listener::v1alpha1::Listener { - let metadata = self.common_metadata(discovery_service_listener_name(&self.cluster.name)); + let metadata = self + .common_metadata(discovery_service_listener_name(&self.cluster.name)) + .build(); let listener_class = &self.cluster.role_config.discovery_service_listener_class; @@ -166,10 +175,12 @@ impl<'a> RoleBuilder<'a> { /// The discovery endpoint is derived from the status of the discovery service Listener. If the /// status is not set yet, the reconciliation process will occur again once the Listener status /// is updated, leading to the eventual creation of the discovery ConfigMap. - pub fn build_discovery_config_map(&self) -> Option { + pub fn build_maybe_discovery_config_map(&self) -> Option { let discovery_endpoint = self.cluster.discovery_endpoint.as_ref()?; - let metadata = self.common_metadata(discovery_config_map_name(&self.cluster.name)); + let metadata = self + .common_metadata(discovery_config_map_name(&self.cluster.name)) + .build(); let protocol = if self.cluster.is_server_tls_enabled() { "https" @@ -204,6 +215,43 @@ impl<'a> RoleBuilder<'a> { }) } + /// Builds the [`ConfigMap`] containing the security configuration files that were defined by + /// value. + /// + /// Returns `None` if the security plugin is disabled or all configuration files are + /// references. + pub fn build_maybe_security_config_map(&self) -> Option { + let metadata = self + .common_metadata(security_config_map_name(&self.cluster.name)) + .with_annotation(annotation_ignore_restarter()) + .build(); + + let mut data = BTreeMap::new(); + + if let ValidatedSecurity::ManagedByApi { settings, .. } + | ValidatedSecurity::ManagedByOperator { settings, .. } = &self.cluster.security + { + for file_type in settings { + if let v1alpha1::SecuritySettingsFileTypeContent::Value( + v1alpha1::SecuritySettingsFileTypeContentValue { value }, + ) = &file_type.content + { + data.insert(file_type.filename.to_owned(), value.to_string()); + } + } + } + + if data.is_empty() { + None + } else { + Some(ConfigMap { + metadata, + data: Some(data), + ..ConfigMap::default() + }) + } + } + /// Builds a [`PodDisruptionBudget`] used by all role-groups pub fn build_pdb(&self) -> Option { let pdb_config = &self.cluster.role_config.common.pod_disruption_budget; @@ -229,8 +277,10 @@ impl<'a> RoleBuilder<'a> { } /// Common metadata for role resources - fn common_metadata(&self, resource_name: impl Into) -> ObjectMeta { - ObjectMetaBuilder::new() + fn common_metadata(&self, resource_name: impl Into) -> ObjectMetaBuilder { + let mut builder = ObjectMetaBuilder::new(); + + builder .name(resource_name) .namespace(&self.cluster.namespace) .ownerreference(ownerreference_from_resource( @@ -238,8 +288,9 @@ impl<'a> RoleBuilder<'a> { None, Some(true), )) - .with_labels(self.labels()) - .build() + .with_labels(self.labels()); + + builder } /// Common labels for role resources @@ -297,6 +348,20 @@ fn discovery_config_map_name(cluster_name: &ClusterName) -> ConfigMapName { ConfigMapName::from_str(cluster_name.as_ref()).expect("should be a valid ConfigMap name") } +pub fn security_config_map_name(cluster_name: &ClusterName) -> ConfigMapName { + const SUFFIX: &str = "-security-config"; + + // compile-time checks + const _: () = assert!( + ClusterName::MAX_LENGTH + SUFFIX.len() <= ConfigMapName::MAX_LENGTH, + "The string `-security-config` must not exceed the limit of ConfigMap names." + ); + let _ = ClusterName::IS_RFC_1123_SUBDOMAIN_NAME; + + ConfigMapName::from_str(&format!("{}{SUFFIX}", cluster_name.as_ref())) + .expect("should be a valid ConfigMap name") +} + pub fn discovery_service_listener_name(cluster_name: &ClusterName) -> ListenerName { // compile-time checks const _: () = assert!( @@ -640,12 +705,13 @@ mod tests { } #[test] - fn test_build_discovery_config_map() { + fn test_build_maybe_discovery_config_map() { let context_names = context_names(); let role_builder = role_builder(&context_names); - let discovery_config_map = serde_json::to_value(role_builder.build_discovery_config_map()) - .expect("should be serializable"); + let discovery_config_map = + serde_json::to_value(role_builder.build_maybe_discovery_config_map()) + .expect("should be serializable"); assert_eq!( json!({ @@ -683,6 +749,59 @@ mod tests { ); } + #[test] + fn test_build_maybe_security_config_map() { + let context_names = context_names(); + let role_builder = role_builder(&context_names); + + let security_config_map = + serde_json::to_value(role_builder.build_maybe_security_config_map()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "annotations": { + "restarter.stackable.tech/ignore": "true" + }, + "labels": { + "app.kubernetes.io/component": "nodes", + "app.kubernetes.io/instance": "my-opensearch-cluster", + "app.kubernetes.io/managed-by": "opensearch.stackable.tech_opensearchcluster", + "app.kubernetes.io/name": "opensearch", + "app.kubernetes.io/version": "3.4.0", + "stackable.tech/vendor": "Stackable", + }, + "name": "my-opensearch-cluster-security-config", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b", + }, + ], + }, + "data": { + "action_groups.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"actiongroups\"}}", + "allowlist.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"allowlist\"},\"config\":{\"enabled\":false}}", + "audit.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"audit\"},\"config\":{\"enabled\":false}}", + "config.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"config\"},\"config\":{\"dynamic\":{\"authc\":{},\"authz\":{},\"http\":{}}}}", + "internal_users.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"internalusers\"}}", + "nodes_dn.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"nodesdn\"}}", + "roles.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"roles\"}}", + "roles_mapping.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"rolesmapping\"}}", + "tenants.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"tenants\"}}", + }, + }), + security_config_map + ); + } + #[test] fn test_build_pdb() { let context_names = context_names(); diff --git a/rust/operator-binary/src/controller/build/role_group_builder.rs b/rust/operator-binary/src/controller/build/role_group_builder.rs index 975efcd..4c5a81c 100644 --- a/rust/operator-binary/src/controller/build/role_group_builder.rs +++ b/rust/operator-binary/src/controller/build/role_group_builder.rs @@ -47,8 +47,11 @@ use crate::{ controller::{ ContextNames, HTTP_PORT, HTTP_PORT_NAME, OpenSearchRoleGroupConfig, TRANSPORT_PORT, TRANSPORT_PORT_NAME, ValidatedCluster, ValidatedNodeRole, ValidatedSecurity, - build::product_logging::config::{ - MAX_OPENSEARCH_SERVER_LOG_FILES_SIZE, vector_config_file_extra_env_vars, + build::{ + product_logging::config::{ + MAX_OPENSEARCH_SERVER_LOG_FILES_SIZE, vector_config_file_extra_env_vars, + }, + role_builder::security_config_map_name, }, }, crd::{ExtendedSecuritySettingsFileType, v1alpha1}, @@ -294,19 +297,6 @@ impl<'a> RoleGroupBuilder<'a> { data.insert(VECTOR_CONFIG_FILE.to_owned(), vector_config_file_content()); } - if let RoleGroupSecurityMode::Initializing { settings, .. } - | RoleGroupSecurityMode::Managing { settings, .. } = &self.security_mode - { - for file_type in settings { - if let v1alpha1::SecuritySettingsFileTypeContent::Value( - v1alpha1::SecuritySettingsFileTypeContentValue { value }, - ) = &file_type.content - { - data.insert(file_type.filename.to_owned(), value.to_string()); - } - } - } - ConfigMap { metadata, data: Some(data), @@ -725,7 +715,7 @@ impl<'a> RoleGroupBuilder<'a> { }; if let RoleGroupSecurityMode::Initializing { settings, .. } = &self.security_mode { - volume_mounts.extend(self.security_config_volume_mounts(settings)); + volume_mounts.extend(self.security_config_volume_mounts(settings, true)); }; if !self.cluster.keystores.is_empty() { @@ -791,20 +781,35 @@ impl<'a> RoleGroupBuilder<'a> { fn security_config_volume_mounts( &self, settings: &v1alpha1::SecuritySettings, + use_sub_path: bool, ) -> Vec { let mut volume_mounts = vec![]; let opensearch_path_conf = self.node_config.opensearch_path_conf(); for file_type in settings { - volume_mounts.push(VolumeMount { - mount_path: format!( + let mount_path; + let sub_path; + + if use_sub_path { + mount_path = format!( "{opensearch_path_conf}/opensearch-security/{filename}", filename = file_type.filename.to_owned() - ), + ); + sub_path = Some(file_type.filename.to_owned()); + } else { + mount_path = format!( + "{opensearch_path_conf}/opensearch-security/{file_type}", + file_type = file_type.id + ); + sub_path = None; + } + + volume_mounts.push(VolumeMount { + mount_path, name: Self::security_settings_file_type_volume_name(&file_type).to_string(), read_only: Some(true), - sub_path: Some(file_type.filename.to_owned()), + sub_path, ..VolumeMount::default() }); } @@ -877,7 +882,7 @@ impl<'a> RoleGroupBuilder<'a> { ..VolumeMount::default() }, ]; - volume_mounts.extend(self.security_config_volume_mounts(settings)); + volume_mounts.extend(self.security_config_volume_mounts(settings, false)); let mut env_vars = EnvVarSet::new() .with_value( @@ -1125,7 +1130,7 @@ impl<'a> RoleGroupBuilder<'a> { mode: Some(0o660), path: file_type.filename.to_owned(), }]), - name: self.resource_names.role_group_config_map().to_string(), + name: security_config_map_name(&self.cluster.name).to_string(), ..Default::default() }), ..Volume::default() @@ -1628,12 +1633,8 @@ mod tests { } #[rstest] - #[case::security_mode_initializing(TestSecurityMode::Initializing)] - #[case::security_mode_managing(TestSecurityMode::Managing)] - #[case::security_mode_participating(TestSecurityMode::Participating)] - #[case::security_mode_disabled(TestSecurityMode::Disabled)] - fn test_build_config_map(#[case] security_mode: TestSecurityMode) { - let cluster = validated_cluster(security_mode); + fn test_build_config_map() { + let cluster = validated_cluster(TestSecurityMode::Disabled); let context_names = context_names(); let role_group_builder = role_group_builder(&cluster, &context_names); @@ -1649,26 +1650,6 @@ mod tests { // vector.yaml is a static file and does not have to be repeated here. config_map["data"]["vector.yaml"].take(); - let expected_data = match security_mode { - TestSecurityMode::Initializing | TestSecurityMode::Managing => json!({ - "action_groups.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"actiongroups\"}}", - "allowlist.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"allowlist\"},\"config\":{\"enabled\":false}}", - "audit.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"audit\"},\"config\":{\"enabled\":false}}", - "config.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"config\"},\"config\":{\"dynamic\":{\"authc\":{},\"authz\":{},\"http\":{}}}}", - "log4j2.properties": null, - "nodes_dn.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"nodesdn\"}}", - "opensearch.yml": null, - "roles_mapping.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"rolesmapping\"}}", - "tenants.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"tenants\"}}", - "vector.yaml": null - }), - TestSecurityMode::Participating | TestSecurityMode::Disabled => json!({ - "log4j2.properties": null, - "opensearch.yml": null, - "vector.yaml": null - }), - }; - assert_eq!( json!({ "apiVersion": "v1", @@ -1695,7 +1676,11 @@ mod tests { } ] }, - "data": expected_data + "data": { + "log4j2.properties": null, + "opensearch.yml": null, + "vector.yaml": null + } }), config_map ); @@ -2297,58 +2282,49 @@ mod tests { "name": "log", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/action_groups.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/actiongroups", "name": "security-config-file-actiongroups", "readOnly": true, - "subPath": "action_groups.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/allowlist.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/allowlist", "name": "security-config-file-allowlist", "readOnly": true, - "subPath": "allowlist.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/audit.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/audit", "name": "security-config-file-audit", "readOnly": true, - "subPath": "audit.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/config.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/config", "name": "security-config-file-config", "readOnly": true, - "subPath": "config.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/internal_users.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/internalusers", "name": "security-config-file-internalusers", "readOnly": true, - "subPath": "internal_users.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/nodes_dn.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/nodesdn", "name": "security-config-file-nodesdn", "readOnly": true, - "subPath": "nodes_dn.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/roles.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/roles", "name": "security-config-file-roles", "readOnly": true, - "subPath": "roles.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/roles_mapping.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/rolesmapping", "name": "security-config-file-rolesmapping", "readOnly": true, - "subPath": "roles_mapping.yml", }, { - "mountPath": "/stackable/opensearch/config/opensearch-security/tenants.yml", + "mountPath": "/stackable/opensearch/config/opensearch-security/tenants", "name": "security-config-file-tenants", "readOnly": true, - "subPath": "tenants.yml", }, ], }); @@ -2546,7 +2522,7 @@ mod tests { "path": "action_groups.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-actiongroups" }, @@ -2559,7 +2535,7 @@ mod tests { "path": "allowlist.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-allowlist" }, @@ -2572,7 +2548,7 @@ mod tests { "path": "audit.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-audit" }, @@ -2585,7 +2561,7 @@ mod tests { "path": "config.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-config" }, @@ -2611,7 +2587,7 @@ mod tests { "path": "nodes_dn.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-nodesdn" }, @@ -2637,7 +2613,7 @@ mod tests { "path": "roles_mapping.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-rolesmapping" }, @@ -2650,7 +2626,7 @@ mod tests { "path": "tenants.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-tenants" }, @@ -2756,7 +2732,7 @@ mod tests { "path": "action_groups.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-actiongroups" }, @@ -2769,7 +2745,7 @@ mod tests { "path": "allowlist.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-allowlist" }, @@ -2782,7 +2758,7 @@ mod tests { "path": "audit.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-audit" }, @@ -2795,7 +2771,7 @@ mod tests { "path": "config.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-config" }, @@ -2821,7 +2797,7 @@ mod tests { "path": "nodes_dn.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-nodesdn" }, @@ -2847,7 +2823,7 @@ mod tests { "path": "roles_mapping.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-rolesmapping" }, @@ -2860,7 +2836,7 @@ mod tests { "path": "tenants.yml" } ], - "name": "my-opensearch-cluster-nodes-default" + "name": "my-opensearch-cluster-security-config" }, "name": "security-config-file-tenants" }, diff --git a/rust/operator-binary/src/controller/build/scripts/test.sh b/rust/operator-binary/src/controller/build/scripts/test.sh new file mode 100755 index 0000000..a2cd9ca --- /dev/null +++ b/rust/operator-binary/src/controller/build/scripts/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +OPENSEARCH_PATH_CONF="$(pwd)/test-config" \ + POD_NAME="security-config-0" \ + MANAGE_ACTIONGROUPS="true" \ + MANAGE_ALLOWLIST="true" \ + MANAGE_AUDIT="false" \ + MANAGE_CONFIG="true" \ + MANAGE_INTERNALUSERS="true" \ + MANAGE_NODESDN="false" \ + MANAGE_ROLES="true" \ + MANAGE_ROLESMAPPING="true" \ + MANAGE_TENANTS="true" \ + sh ./update-security-config.sh diff --git a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh index d84a550..9afeb5b 100644 --- a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh +++ b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh @@ -1,7 +1,53 @@ #!/usr/bin/env bash +# +# Expected environment variables: +# - OPENSEARCH_PATH_CONF +# - POD_NAME +# - MANAGE_ACTIONGROUPS +# - MANAGE_ALLOWLIST +# - MANAGE_AUDIT +# - MANAGE_CONFIG +# - MANAGE_INTERNALUSERS +# - MANAGE_NODESDN +# - MANAGE_ROLES +# - MANAGE_ROLESMAPPING +# - MANAGE_TENANTS + +# TODO config_files vs. configuration_files set -u -o pipefail +VECTOR_CONTROL_DIR=/stackable/log/_vector +SECURITY_CONFIG_DIR="$OPENSEARCH_PATH_CONF/opensearch-security" + +declare -a CONFIG_FILETYPES=( + actiongroups + allowlist + audit + config + internalusers + nodesdn + roles + rolesmapping + tenants +) + +declare -A CONFIG_FILENAME=( + [actiongroups]=action_groups.yml + [allowlist]=allowlist.yml + [audit]=audit.yml + [config]=config.yml + [internalusers]=internal_users.yml + [nodesdn]=nodes_dn.yml + [roles]=roles.yml + [rolesmapping]=roles_mapping.yml + [tenants]=tenants.yml +) + +declare -a managed_filetypes + +last_applied_config_hashes="" + function log () { level="$1" message="$2" @@ -10,6 +56,12 @@ function log () { echo "$timestamp [$level] $message" } +function debug () { + message="$*" + + log DEBUG "$message" +} + function info () { message="$*" @@ -22,33 +74,93 @@ function warn () { log WARN "$message" } -function wait_seconds () { +function config_file () { + filetype="$1" + + echo "$SECURITY_CONFIG_DIR/${CONFIG_FILENAME[$filetype]}" +} + +function symlink_config_files () { + for filetype in "${CONFIG_FILETYPES[@]}" + do + ln --force --symbolic \ + "$SECURITY_CONFIG_DIR/$filetype/${CONFIG_FILENAME[$filetype]}" \ + "$(config_file $filetype)" + done +} + +function initialize_managed_configuration_filetypes () { + for filetype in "${CONFIG_FILETYPES[@]}" + do + envvar="MANAGE_${filetype^^}" + if test "${!envvar}" = "true" + then + info "Watch managed configuration type \"$filetype\"." + + managed_filetypes+=("$filetype") + else + info "Skip unmanaged configuration type \"$filetype\"." + fi + done +} + +function calculate_config_hashes () { + for filetype in "${managed_filetypes[@]}" + do + file=$(config_file "$filetype") + sha256sum "$file" + done +} + +function wait_seconds_or_shutdown () { seconds="$1" - if test "$seconds" = 0 - then - info "Wait until pod is restarted..." - else - info "Wait for $seconds seconds..." - fi + debug "Wait for $seconds seconds..." - if test ! -e /stackable/log/_vector/shutdown + if test ! -e "$VECTOR_CONTROL_DIR/shutdown" then - mkdir --parents /stackable/log/_vector inotifywait \ --quiet --quiet \ --timeout "$seconds" \ --event create \ - /stackable/log/_vector + "$VECTOR_CONTROL_DIR" fi - if test -e /stackable/log/_vector/shutdown + # Only the file named "shutdown" should be created in + # VECTOR_CONTROL_DIR. If another file is created instead, this + # function will return early; this is acceptable and has no adverse + # effects. + if test -e "$VECTOR_CONTROL_DIR/shutdown" then info "Shut down" exit 0 fi } +function wait_for_configuration_changes_or_shutdown () { + info "Wait for security configuration changes..." + + while test "$(calculate_config_hashes)" = "$last_applied_config_hashes" + do + wait_seconds_or_shutdown 10 + done + + info "Configuration change detected" +} + +function wait_for_shutdown () { + until test ! -e "$VECTOR_CONTROL_DIR/shutdown" + do + inotifywait \ + --quiet --quiet \ + --event create \ + "$VECTOR_CONTROL_DIR" + done + + info "Shut down" + exit 0 +} + function check_pod () { POD_INDEX="${POD_NAME##*-}" @@ -62,34 +174,34 @@ function check_pod () { "configuration. The security configuration is managed by" \ "the pod \"$MANAGING_POD\"." - wait_seconds 0 + wait_for_shutdown fi } function initialize_security_index() { info "Initialize the security index." + last_applied_config_hashes=$(calculate_config_hashes) + until plugins/opensearch-security/tools/securityadmin.sh \ - --configdir "$OPENSEARCH_PATH_CONF/opensearch-security" \ + --configdir "$SECURITY_CONFIG_DIR" \ --disable-host-name-verification \ -cacert "$OPENSEARCH_PATH_CONF/tls/ca.crt" \ -cert "$OPENSEARCH_PATH_CONF/tls/tls.crt" \ -key "$OPENSEARCH_PATH_CONF/tls/tls.key" do warn "Initializing the security index failed." - wait_seconds 10 + wait_seconds_or_shutdown 10 done } function update_config () { - filetype="$1" - filename="$2" + last_applied_config_hashes=$(calculate_config_hashes) - file="$OPENSEARCH_PATH_CONF/opensearch-security/$filename" + for filetype in "${managed_filetypes[@]}" + do + file=$(config_file "$filetype") - envvar="MANAGE_${filetype^^}" - if test "${!envvar}" = "true" - then info "Update managed configuration type \"$filetype\"." until plugins/opensearch-security/tools/securityadmin.sh \ @@ -101,11 +213,9 @@ function update_config () { -key "$OPENSEARCH_PATH_CONF/tls/tls.key" do warn "Updating \"$filetype\" in the security index failed." - wait_seconds 10 + wait_seconds_or_shutdown 10 done - else - info "Skip unmanaged configuration type \"$filetype\"." - fi + done } function update_security_index() { @@ -123,29 +233,27 @@ function update_security_index() { then info "The security index is already initialized." - update_config actiongroups action_groups.yml - update_config allowlist allowlist.yml - update_config audit audit.yml - update_config config config.yml - update_config internalusers internal_users.yml - update_config nodesdn nodes_dn.yml - update_config roles roles.yml - update_config rolesmapping roles_mapping.yml - update_config tenants tenants.yml + update_config elif test "$STATUS_CODE" = "404" then initialize_security_index else warn "Checking the security index failed." - wait_seconds 10 - check_security_index + wait_seconds_or_shutdown 10 + update_security_index fi } -check_pod +# Ensure that VECTOR_CONTROL_DIR exists, so that calls to inotifywait do not +# fail. +mkdir --parents "$VECTOR_CONTROL_DIR" -update_security_index +check_pod +symlink_config_files +initialize_managed_configuration_filetypes -info "Wait for security configuration changes..." -# Wait until the pod is restarted due to a change of the Secret. -wait_seconds 0 +while true +do + update_security_index + wait_for_configuration_changes_or_shutdown +done diff --git a/rust/operator-binary/src/framework/builder/meta.rs b/rust/operator-binary/src/framework/builder/meta.rs index 5034004..b1b8a3e 100644 --- a/rust/operator-binary/src/framework/builder/meta.rs +++ b/rust/operator-binary/src/framework/builder/meta.rs @@ -1,6 +1,7 @@ use stackable_operator::{ builder::meta::OwnerReferenceBuilder, k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference, kube::Resource, + kvp::Annotation, }; use crate::framework::{HasName, HasUid}; @@ -28,6 +29,15 @@ pub fn ownerreference_from_resource( ) } +/// Annotation which signals the restarter to ignore this resource. +pub fn annotation_ignore_restarter() -> Annotation { + Annotation::try_from(( + "restarter.stackable.tech/ignore".to_owned(), + "true".to_owned(), + )) + .expect("should be a valid annotation") +} + #[cfg(test)] mod tests { use std::borrow::Cow; @@ -37,7 +47,10 @@ mod tests { kube::Resource, }; - use crate::framework::{HasName, HasUid, Uid, builder::meta::ownerreference_from_resource}; + use crate::framework::{ + HasName, HasUid, Uid, + builder::meta::{annotation_ignore_restarter, ownerreference_from_resource}, + }; struct Cluster { object_meta: ObjectMeta, @@ -121,4 +134,10 @@ mod tests { assert_eq!(expected_owner_reference, actual_owner_reference); } + + #[test] + fn test_annotation_ignore_restarter() { + // Test that the functions do not panic + annotation_ignore_restarter(); + } } diff --git a/tests/templates/kuttl/security-config/10-security-config.yaml b/tests/templates/kuttl/security-config/10-security-config.yaml index a411ee8..91f5e63 100644 --- a/tests/templates/kuttl/security-config/10-security-config.yaml +++ b/tests/templates/kuttl/security-config/10-security-config.yaml @@ -3,6 +3,8 @@ apiVersion: v1 kind: Secret metadata: name: security-config-file-internal-users + annotations: + restarter.stackable.tech/ignore: "true" stringData: internal_users.yml: | --- @@ -21,6 +23,8 @@ apiVersion: v1 kind: ConfigMap metadata: name: security-config + annotations: + restarter.stackable.tech/ignore: "true" data: roles.yml: | --- diff --git a/tests/templates/kuttl/security-config/11-assert.yaml b/tests/templates/kuttl/security-config/11-assert.yaml index 11166f9..9f3d565 100644 --- a/tests/templates/kuttl/security-config/11-assert.yaml +++ b/tests/templates/kuttl/security-config/11-assert.yaml @@ -30,7 +30,7 @@ status: apiVersion: v1 kind: ConfigMap metadata: - name: opensearch-nodes-security-config + name: opensearch-security-config data: action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' diff --git a/tests/templates/kuttl/security-config/21-assert.yaml b/tests/templates/kuttl/security-config/21-assert.yaml index bffbbbc..9cab8a4 100644 --- a/tests/templates/kuttl/security-config/21-assert.yaml +++ b/tests/templates/kuttl/security-config/21-assert.yaml @@ -6,7 +6,7 @@ timeout: 120 apiVersion: v1 kind: ConfigMap metadata: - name: opensearch-nodes-security-config + name: opensearch-security-config data: action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' From e5d2574bbb50f954a295404ed7ec61a04b6564a4 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 09:16:32 +0200 Subject: [PATCH 2/8] Fix shellcheck warnings --- .../src/controller/build/scripts/update-security-config.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh index 9afeb5b..bfbce98 100644 --- a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh +++ b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh @@ -85,7 +85,7 @@ function symlink_config_files () { do ln --force --symbolic \ "$SECURITY_CONFIG_DIR/$filetype/${CONFIG_FILENAME[$filetype]}" \ - "$(config_file $filetype)" + "$(config_file "$filetype")" done } From 12f20b137c65ee1f51a7ed76e363bad2bc089d8a Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 11:32:38 +0200 Subject: [PATCH 3/8] test(smoke): Fix assertions --- .../kuttl/security-config/11-assert.yaml | 2 + tests/templates/kuttl/smoke/10-assert.yaml.j2 | 75 +++++++++++-------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/tests/templates/kuttl/security-config/11-assert.yaml b/tests/templates/kuttl/security-config/11-assert.yaml index 9f3d565..8084b58 100644 --- a/tests/templates/kuttl/security-config/11-assert.yaml +++ b/tests/templates/kuttl/security-config/11-assert.yaml @@ -31,6 +31,8 @@ apiVersion: v1 kind: ConfigMap metadata: name: opensearch-security-config + annotations: + restarter.stackable.tech/ignore: "true" data: action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' diff --git a/tests/templates/kuttl/smoke/10-assert.yaml.j2 b/tests/templates/kuttl/smoke/10-assert.yaml.j2 index ec38871..b2e3124 100644 --- a/tests/templates/kuttl/smoke/10-assert.yaml.j2 +++ b/tests/templates/kuttl/smoke/10-assert.yaml.j2 @@ -358,7 +358,7 @@ spec: - key: action_groups.yml mode: 432 path: action_groups.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-actiongroups - configMap: defaultMode: 420 @@ -366,7 +366,7 @@ spec: - key: allowlist.yml mode: 432 path: allowlist.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-allowlist - configMap: defaultMode: 420 @@ -374,7 +374,7 @@ spec: - key: audit.yml mode: 432 path: audit.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-audit - configMap: defaultMode: 420 @@ -382,7 +382,7 @@ spec: - key: config.yml mode: 432 path: config.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-config - configMap: defaultMode: 420 @@ -390,7 +390,7 @@ spec: - key: internal_users.yml mode: 432 path: internal_users.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-internalusers - configMap: defaultMode: 420 @@ -398,7 +398,7 @@ spec: - key: nodes_dn.yml mode: 432 path: nodes_dn.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-nodesdn - configMap: defaultMode: 420 @@ -406,7 +406,7 @@ spec: - key: roles.yml mode: 432 path: roles.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-roles - configMap: defaultMode: 420 @@ -414,7 +414,7 @@ spec: - key: roles_mapping.yml mode: 432 path: roles_mapping.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-rolesmapping - configMap: defaultMode: 420 @@ -422,7 +422,7 @@ spec: - key: tenants.yml mode: 432 path: tenants.yml - name: opensearch-nodes-cluster-manager + name: opensearch-security-config name: security-config-file-tenants volumeClaimTemplates: - apiVersion: v1 @@ -834,7 +834,7 @@ spec: - key: action_groups.yml mode: 432 path: action_groups.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-actiongroups - configMap: defaultMode: 420 @@ -842,7 +842,7 @@ spec: - key: allowlist.yml mode: 432 path: allowlist.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-allowlist - configMap: defaultMode: 420 @@ -850,7 +850,7 @@ spec: - key: audit.yml mode: 432 path: audit.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-audit - configMap: defaultMode: 420 @@ -858,7 +858,7 @@ spec: - key: config.yml mode: 432 path: config.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-config - configMap: defaultMode: 420 @@ -866,7 +866,7 @@ spec: - key: internal_users.yml mode: 432 path: internal_users.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-internalusers - configMap: defaultMode: 420 @@ -874,7 +874,7 @@ spec: - key: nodes_dn.yml mode: 432 path: nodes_dn.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-nodesdn - configMap: defaultMode: 420 @@ -882,7 +882,7 @@ spec: - key: roles.yml mode: 432 path: roles.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-roles - configMap: defaultMode: 420 @@ -890,7 +890,7 @@ spec: - key: roles_mapping.yml mode: 432 path: roles_mapping.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-rolesmapping - configMap: defaultMode: 420 @@ -898,7 +898,7 @@ spec: - key: tenants.yml mode: 432 path: tenants.yml - name: opensearch-nodes-data + name: opensearch-security-config name: security-config-file-tenants volumeClaimTemplates: - apiVersion: v1 @@ -941,15 +941,16 @@ status: apiVersion: v1 kind: ConfigMap metadata: + annotations: + restarter.stackable.tech/ignore: "true" labels: app.kubernetes.io/component: nodes app.kubernetes.io/instance: opensearch app.kubernetes.io/managed-by: opensearch.stackable.tech_opensearchcluster app.kubernetes.io/name: opensearch - app.kubernetes.io/role-group: cluster-manager app.kubernetes.io/version: {{ test_scenario['values']['opensearch'].split(',')[0] }} stackable.tech/vendor: Stackable - name: opensearch-nodes-cluster-manager + name: opensearch-security-config ownerReferences: - apiVersion: opensearch.stackable.tech/v1alpha1 controller: true @@ -962,6 +963,28 @@ data: config.yml: '{"_meta":{"config_version":2,"type":"config"},"config":{"dynamic":{"authc":{"basic_internal_auth_domain":{"authentication_backend":{"type":"intern"},"description":"Authenticate via HTTP Basic against internal users database","http_authenticator":{"challenge":true,"type":"basic"},"http_enabled":true,"order":1,"transport_enabled":true}},"authz":{}}}}' internal_users.yml: '{"_meta":{"config_version":2,"type":"internalusers"},"admin":{"backend_roles":["admin"],"description":"OpenSearch admin user","hash":"$2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e","reserved":true}}' nodes_dn.yml: '{"_meta":{"config_version":2,"type":"nodesdn"}}' + roles.yml: '{"_meta":{"config_version":2,"type":"roles"}}' + roles_mapping.yml: '{"_meta":{"config_version":2,"type":"rolesmapping"},"all_access":{"backend_roles":["admin"],"reserved":false}}' + tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/component: nodes + app.kubernetes.io/instance: opensearch + app.kubernetes.io/managed-by: opensearch.stackable.tech_opensearchcluster + app.kubernetes.io/name: opensearch + app.kubernetes.io/role-group: cluster-manager + app.kubernetes.io/version: {{ test_scenario['values']['opensearch'].split(',')[0] }} + stackable.tech/vendor: Stackable + name: opensearch-nodes-cluster-manager + ownerReferences: + - apiVersion: opensearch.stackable.tech/v1alpha1 + controller: true + kind: OpenSearchCluster + name: opensearch +data: opensearch.yml: |- cluster.name: "opensearch" cluster.routing.allocation.disk.threshold_enabled: "false" @@ -984,9 +1007,6 @@ data: plugins.security.ssl.transport.pemcert_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.crt" plugins.security.ssl.transport.pemkey_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.key" plugins.security.ssl.transport.pemtrustedcas_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/ca.crt" - roles.yml: '{"_meta":{"config_version":2,"type":"roles"}}' - roles_mapping.yml: '{"_meta":{"config_version":2,"type":"rolesmapping"},"all_access":{"backend_roles":["admin"],"reserved":false}}' - tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' --- apiVersion: v1 kind: ConfigMap @@ -1006,12 +1026,6 @@ metadata: kind: OpenSearchCluster name: opensearch data: - action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' - allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' - audit.yml: '{"_meta":{"config_version":2,"type":"audit"},"config":{"enabled":false}}' - config.yml: '{"_meta":{"config_version":2,"type":"config"},"config":{"dynamic":{"authc":{"basic_internal_auth_domain":{"authentication_backend":{"type":"intern"},"description":"Authenticate via HTTP Basic against internal users database","http_authenticator":{"challenge":true,"type":"basic"},"http_enabled":true,"order":1,"transport_enabled":true}},"authz":{}}}}' - internal_users.yml: '{"_meta":{"config_version":2,"type":"internalusers"},"admin":{"backend_roles":["admin"],"description":"OpenSearch admin user","hash":"$2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e","reserved":true}}' - nodes_dn.yml: '{"_meta":{"config_version":2,"type":"nodesdn"}}' opensearch.yml: |- cluster.name: "opensearch" cluster.routing.allocation.disk.threshold_enabled: "false" @@ -1034,9 +1048,6 @@ data: plugins.security.ssl.transport.pemcert_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.crt" plugins.security.ssl.transport.pemkey_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.key" plugins.security.ssl.transport.pemtrustedcas_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/ca.crt" - roles.yml: '{"_meta":{"config_version":2,"type":"roles"}}' - roles_mapping.yml: '{"_meta":{"config_version":2,"type":"rolesmapping"},"all_access":{"backend_roles":["admin"],"reserved":false}}' - tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' --- apiVersion: v1 kind: Service From 86eb8558a5c52104ca65ca0a774812d2c070d0c6 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 11:34:12 +0200 Subject: [PATCH 4/8] fix: Remove broken test script --- .../src/controller/build/scripts/test.sh | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100755 rust/operator-binary/src/controller/build/scripts/test.sh diff --git a/rust/operator-binary/src/controller/build/scripts/test.sh b/rust/operator-binary/src/controller/build/scripts/test.sh deleted file mode 100755 index a2cd9ca..0000000 --- a/rust/operator-binary/src/controller/build/scripts/test.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env sh - -OPENSEARCH_PATH_CONF="$(pwd)/test-config" \ - POD_NAME="security-config-0" \ - MANAGE_ACTIONGROUPS="true" \ - MANAGE_ALLOWLIST="true" \ - MANAGE_AUDIT="false" \ - MANAGE_CONFIG="true" \ - MANAGE_INTERNALUSERS="true" \ - MANAGE_NODESDN="false" \ - MANAGE_ROLES="true" \ - MANAGE_ROLESMAPPING="true" \ - MANAGE_TENANTS="true" \ - sh ./update-security-config.sh From e7fa7be7a44bd568b52a46bdc66884388b8cd0b8 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 13:41:13 +0200 Subject: [PATCH 5/8] chore: Improve comments and unit tests --- rust/operator-binary/src/controller/build.rs | 11 ++++++++--- .../src/controller/build/role_group_builder.rs | 13 +++++++++++++ .../build/scripts/update-security-config.sh | 10 ++++------ rust/operator-binary/src/framework/builder/meta.rs | 2 +- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 948b498..479decc 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -93,7 +93,7 @@ mod tests { role_utils::GenericProductSpecificCommonConfig, types::{ common::Port, - kubernetes::{Hostname, ListenerClassName, NamespaceName}, + kubernetes::{Hostname, ListenerClassName, NamespaceName, SecretClassName}, operator::{ ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, @@ -137,7 +137,8 @@ mod tests { "my-opensearch", "my-opensearch-nodes-cluster-manager", "my-opensearch-nodes-coordinating", - "my-opensearch-nodes-data" + "my-opensearch-nodes-data", + "my-opensearch-security-config" ], extract_resource_names(&resources.config_maps) ); @@ -212,7 +213,11 @@ mod tests { ), ] .into(), - ValidatedSecurity::Disabled, + ValidatedSecurity::ManagedByApi { + settings: v1alpha1::SecuritySettings::default(), + tls_server_secret_class: None, + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls"), + }, vec![], Some(ValidatedDiscoveryEndpoint { hostname: Hostname::from_str_unsafe("1.2.3.4"), diff --git a/rust/operator-binary/src/controller/build/role_group_builder.rs b/rust/operator-binary/src/controller/build/role_group_builder.rs index 4c5a81c..f82634b 100644 --- a/rust/operator-binary/src/controller/build/role_group_builder.rs +++ b/rust/operator-binary/src/controller/build/role_group_builder.rs @@ -715,6 +715,9 @@ impl<'a> RoleGroupBuilder<'a> { }; if let RoleGroupSecurityMode::Initializing { settings, .. } = &self.security_mode { + // Mount the security configuration files using `subPath`, because the configuration + // files are only used for initializing the security index and hot-reloading is not + // required. volume_mounts.extend(self.security_config_volume_mounts(settings, true)); }; @@ -778,6 +781,13 @@ impl<'a> RoleGroupBuilder<'a> { /// Builds the security settings volume mounts for the [`v1alpha1::Container::OpenSearch`] /// container or the [`v1alpha1::Container::UpdateSecurityConfig`] container + /// + /// If `use_sub_path` is set to `true`, then the configuration files are directly mounted via + /// `subPath` into the opensearch-security configuration directory. If it is set to `false`, + /// then they are mounted into sub directories of the opensearch-security configuration + /// directory without using `subPath`. Files mounted via `subPath` are not updated on changes + /// in the ConfigMap or Secret volume. Therefore, hot-reloading works only without `subPath`, + /// but links from the configuration directory into the sub directories are required. fn security_config_volume_mounts( &self, settings: &v1alpha1::SecuritySettings, @@ -882,6 +892,9 @@ impl<'a> RoleGroupBuilder<'a> { ..VolumeMount::default() }, ]; + + // Mount the security configuration files without using `subPath`, so that hot-reloading + // works. volume_mounts.extend(self.security_config_volume_mounts(settings, false)); let mut env_vars = EnvVarSet::new() diff --git a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh index bfbce98..c9238ab 100644 --- a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh +++ b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Expected environment variables: +# Required environment variables: # - OPENSEARCH_PATH_CONF # - POD_NAME # - MANAGE_ACTIONGROUPS @@ -13,8 +13,6 @@ # - MANAGE_ROLESMAPPING # - MANAGE_TENANTS -# TODO config_files vs. configuration_files - set -u -o pipefail VECTOR_CONTROL_DIR=/stackable/log/_vector @@ -80,6 +78,7 @@ function config_file () { echo "$SECURITY_CONFIG_DIR/${CONFIG_FILENAME[$filetype]}" } +# Create link for every configuration file in SECURITY_CONFIG_DIR function symlink_config_files () { for filetype in "${CONFIG_FILETYPES[@]}" do @@ -89,14 +88,13 @@ function symlink_config_files () { done } -function initialize_managed_configuration_filetypes () { +function initialize_managed_config_filetypes () { for filetype in "${CONFIG_FILETYPES[@]}" do envvar="MANAGE_${filetype^^}" if test "${!envvar}" = "true" then info "Watch managed configuration type \"$filetype\"." - managed_filetypes+=("$filetype") else info "Skip unmanaged configuration type \"$filetype\"." @@ -250,7 +248,7 @@ mkdir --parents "$VECTOR_CONTROL_DIR" check_pod symlink_config_files -initialize_managed_configuration_filetypes +initialize_managed_config_filetypes while true do diff --git a/rust/operator-binary/src/framework/builder/meta.rs b/rust/operator-binary/src/framework/builder/meta.rs index b1b8a3e..45d85c3 100644 --- a/rust/operator-binary/src/framework/builder/meta.rs +++ b/rust/operator-binary/src/framework/builder/meta.rs @@ -137,7 +137,7 @@ mod tests { #[test] fn test_annotation_ignore_restarter() { - // Test that the functions do not panic + // Test that the function does not panic annotation_ignore_restarter(); } } From c730d83d6abffda23da68d43d294e803cb8bdfaf Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 13:45:50 +0200 Subject: [PATCH 6/8] chore: Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a2301..8ae6aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Support hot-reloading of security configuration files ([#130]). + +[#130]: https://github.com/stackabletech/opensearch-operator/pull/130 + ## [26.3.0] - 2026-03-16 ## [26.3.0-rc1] - 2026-03-16 From 8000c6d0bba310dc25398926c8716fccb87084a8 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 14:51:12 +0200 Subject: [PATCH 7/8] doc: Document hot-reloading of security settings --- .../opensearch-security-config.yaml | 87 ------------------- .../pages/usage-guide/monitoring.adoc | 8 +- .../pages/usage-guide/security.adoc | 25 ++++++ .../opensearch/pages/usage-guide/upgrade.adoc | 29 +++++++ 4 files changed, 59 insertions(+), 90 deletions(-) delete mode 100644 docs/modules/opensearch/examples/getting_started/opensearch-security-config.yaml diff --git a/docs/modules/opensearch/examples/getting_started/opensearch-security-config.yaml b/docs/modules/opensearch/examples/getting_started/opensearch-security-config.yaml deleted file mode 100644 index dde39f1..0000000 --- a/docs/modules/opensearch/examples/getting_started/opensearch-security-config.yaml +++ /dev/null @@ -1,87 +0,0 @@ ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-security-config -stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} - internal_users.yml: | - --- - _meta: - type: internalusers - config_version: 2 - admin: - hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e - reserved: true - backend_roles: - - admin - description: OpenSearch admin user - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - all_access: - reserved: false - backend_roles: - - admin - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/docs/modules/opensearch/pages/usage-guide/monitoring.adoc b/docs/modules/opensearch/pages/usage-guide/monitoring.adoc index c628c44..2e7889a 100644 --- a/docs/modules/opensearch/pages/usage-guide/monitoring.adoc +++ b/docs/modules/opensearch/pages/usage-guide/monitoring.adoc @@ -29,10 +29,12 @@ To make the metrics accessible for all users, especially Prometheus, anonymous a ---- --- apiVersion: v1 -kind: Secret +kind: ConfigMap metadata: - name: opensearch-security-config -stringData: + name: custom-opensearch-security-config + annotations: + restarter.stackable.tech/ignore: "true" +data: config.yml: | --- _meta: diff --git a/docs/modules/opensearch/pages/usage-guide/security.adoc b/docs/modules/opensearch/pages/usage-guide/security.adoc index ab8076e..4dff514 100644 --- a/docs/modules/opensearch/pages/usage-guide/security.adoc +++ b/docs/modules/opensearch/pages/usage-guide/security.adoc @@ -144,6 +144,31 @@ spec: If this role group is not defined, it will be created by the operator. +[IMPORTANT] +==== +Settings managed by the operator are hot-reloaded when changed, i.e. without pod restarts. +However, if those settings are provided via ConfigMap or Secret, updates will normally trigger a restart. +To prevent that restart, add the following annotation: + +[source,yaml] +---- +--- +apiVersion: v1 +kind: Secret +metadata: + name: security-config + annotations: + restarter.stackable.tech/ignore: "true" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: security-config + annotations: + restarter.stackable.tech/ignore: "true" +---- +==== + == TLS TLS is also managed by the OpenSearch security plugin, therefore TLS is only available if the security plugin was not disabled. diff --git a/docs/modules/opensearch/pages/usage-guide/upgrade.adoc b/docs/modules/opensearch/pages/usage-guide/upgrade.adoc index 2b8485f..e3b6815 100644 --- a/docs/modules/opensearch/pages/usage-guide/upgrade.adoc +++ b/docs/modules/opensearch/pages/usage-guide/upgrade.adoc @@ -1,6 +1,35 @@ = SDP upgrade notes :description: Instructions for upgrading the SDP versions. +== Upgrade from SDP 26.3 to 26.7 + +=== Hot-reloading of security settings + +The security settings defined in the cluster specification are now stored in a separate ConfigMap named `-security-config`. +If you used this name for your custom security configuration, then you must rename it. +Otherwise the operator will override it. + +The operator now supports hot-reloading of security settings. +If those settings are provided via ConfigMap or Secret, then the annotation `restarter.stackable.tech/ignore: "true"` should be added to avoid restarts triggered by the restart controller: + +[source,yaml] +---- +--- +apiVersion: v1 +kind: Secret +metadata: + name: security-config + annotations: + restarter.stackable.tech/ignore: "true" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: security-config + annotations: + restarter.stackable.tech/ignore: "true" +---- + == Upgrade from SDP 25.11 to 26.3 When upgrading the OpenSearch operator from SDP 25.11 to 26.3, you may encounter several warnings and errors in the operator logs. From a7dfe3c7364cc40f4c3c56ce83c88a7b4cb9b0f6 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 31 Mar 2026 16:24:19 +0200 Subject: [PATCH 8/8] chore: Improve comments --- .../build/scripts/update-security-config.sh | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh index c9238ab..926433f 100644 --- a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh +++ b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh @@ -72,13 +72,14 @@ function warn () { log WARN "$message" } +# Return the configuration file in SECURITY_CONFIG_DIR for the given file type function config_file () { filetype="$1" echo "$SECURITY_CONFIG_DIR/${CONFIG_FILENAME[$filetype]}" } -# Create link for every configuration file in SECURITY_CONFIG_DIR +# Create a link for every configuration file in SECURITY_CONFIG_DIR function symlink_config_files () { for filetype in "${CONFIG_FILETYPES[@]}" do @@ -88,6 +89,7 @@ function symlink_config_files () { done } +# Initialize the variable managed_filetypes function initialize_managed_config_filetypes () { for filetype in "${CONFIG_FILETYPES[@]}" do @@ -102,6 +104,7 @@ function initialize_managed_config_filetypes () { done } +# Calculate the hashes of the managed configuration files function calculate_config_hashes () { for filetype in "${managed_filetypes[@]}" do @@ -124,9 +127,8 @@ function wait_seconds_or_shutdown () { "$VECTOR_CONTROL_DIR" fi - # Only the file named "shutdown" should be created in - # VECTOR_CONTROL_DIR. If another file is created instead, this - # function will return early; this is acceptable and has no adverse + # Only the file named "shutdown" should be created in VECTOR_CONTROL_DIR. If another file is + # created instead, this function will return early; this is acceptable and has no adverse # effects. if test -e "$VECTOR_CONTROL_DIR/shutdown" then @@ -159,6 +161,7 @@ function wait_for_shutdown () { exit 0 } +# Return if this pod is responsible for managing the security configuration or wait for shutdown function check_pod () { POD_INDEX="${POD_NAME##*-}" @@ -176,6 +179,7 @@ function check_pod () { fi } +# Initialize the security index with all (managed and unmanaged) configuration files function initialize_security_index() { info "Initialize the security index." @@ -193,7 +197,8 @@ function initialize_security_index() { done } -function update_config () { +# Update the security index with the managed configuration files +function update_security_index () { last_applied_config_hashes=$(calculate_config_hashes) for filetype in "${managed_filetypes[@]}" @@ -216,7 +221,8 @@ function update_config () { done } -function update_security_index() { +# Initialize or update the security index +function apply_configuration_files() { info "Check the status of the security index." STATUS_CODE=$(curl \ @@ -230,8 +236,7 @@ function update_security_index() { if test "$STATUS_CODE" = "200" then info "The security index is already initialized." - - update_config + update_security_index elif test "$STATUS_CODE" = "404" then initialize_security_index @@ -242,8 +247,7 @@ function update_security_index() { fi } -# Ensure that VECTOR_CONTROL_DIR exists, so that calls to inotifywait do not -# fail. +# Ensure that VECTOR_CONTROL_DIR exists, so that calls to inotifywait do not fail mkdir --parents "$VECTOR_CONTROL_DIR" check_pod @@ -252,6 +256,6 @@ initialize_managed_config_filetypes while true do - update_security_index + apply_configuration_files wait_for_configuration_changes_or_shutdown done