diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index 7f2e06039..aa03bb8c0 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -164,9 +164,56 @@ spec: type: object hostName: type: string + jsonConfigOverrides: + nullable: true + oneOf: + - required: + - jsonMergePatch + - required: + - jsonPatches + - required: + - userProvided + properties: + jsonMergePatch: + description: |- + Can be set to arbitrary YAML content, which is converted to JSON and used as + [RFC 7396](https://datatracker.ietf.org/doc/html/rfc7396) JSON merge patch. + type: object + x-kubernetes-preserve-unknown-fields: true + jsonPatches: + description: |- + List of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON patches. + + Can be used when more flexibility is needed, e.g. to only modify elements + in a list based on a condition. + + A patch looks something like + + `{"op": "test", "path": "/0/name", "value": "Andrew"}` + + or + + `{"op": "add", "path": "/0/happy", "value": true}` + items: + type: string + type: array + userProvided: + description: Override the entire config file with the specified String. + type: string + type: object kerberosRealmName: description: A validated kerberos realm name type, for use in CRDs. type: string + keyValueConfigOverrides: + additionalProperties: + type: string + description: |- + Flat key-value overrides for `*.properties`, Hadoop XML, etc. + + This is backwards-compatible with the existing flat key-value YAML format + used by `HashMap`. + nullable: true + type: object nodes: description: |- This struct represents a role - e.g. HDFS datanodes or Trino workers. It has a key-value-map containing @@ -717,7 +764,6 @@ spec: additionalProperties: type: string type: object - default: {} description: |- The `configOverrides` can be used to configure properties in product config files that are not exposed in the CRD. Read the @@ -1329,7 +1375,6 @@ spec: additionalProperties: type: string type: object - default: {} description: |- The `configOverrides` can be used to configure properties in product config files that are not exposed in the CRD. Read the diff --git a/crates/stackable-operator/src/config_overrides.rs b/crates/stackable-operator/src/config_overrides.rs new file mode 100644 index 000000000..7405abff6 --- /dev/null +++ b/crates/stackable-operator/src/config_overrides.rs @@ -0,0 +1,309 @@ +//! Building-block types for strategy-based `configOverrides`. +//! +//! Operators declare typed override structs choosing patch strategies per file +//! (e.g. [`JsonConfigOverrides`] for JSON files, [`KeyValueConfigOverrides`] for +//! properties files). The types here are composed by each operator into its +//! CRD-specific `configOverrides` struct. + +use std::collections::{BTreeMap, HashMap}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; + +use crate::utils::crds::raw_object_schema; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to serialize base document to JSON"))] + SerializeBaseDocument { source: serde_json::Error }, + + #[snafu(display("failed to apply JSON patch (RFC 6902)"))] + ApplyJsonPatch { source: json_patch::PatchError }, + + #[snafu(display("failed to deserialize JSON patch operation {index} from string"))] + DeserializeJsonPatchOperation { + source: serde_json::Error, + index: usize, + }, + + #[snafu(display("failed to parse user-provided JSON content"))] + ParseUserProvidedJson { source: serde_json::Error }, +} + +/// Trait that allows the product config pipeline to extract flat key-value +/// overrides from any `configOverrides` type. +/// +/// The default `HashMap>` implements this +/// by looking up the file name and returning its entries. Typed override +/// structs that have no key-value files can use the default implementation, +/// which returns an empty map. +pub trait KeyValueOverridesProvider { + fn get_key_value_overrides(&self, _file: &str) -> BTreeMap> { + BTreeMap::new() + } +} + +impl KeyValueOverridesProvider for HashMap> { + fn get_key_value_overrides(&self, file: &str) -> BTreeMap> { + self.get(file) + .map(|entries| { + entries + .iter() + .map(|(k, v)| (k.clone(), Some(v.clone()))) + .collect() + }) + .unwrap_or_default() + } +} + +/// Flat key-value overrides for `*.properties`, Hadoop XML, etc. +/// +/// This is backwards-compatible with the existing flat key-value YAML format +/// used by `HashMap`. +#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +pub struct KeyValueConfigOverrides { + #[serde(flatten)] + pub overrides: BTreeMap, +} + +impl KeyValueConfigOverrides { + /// Returns the overrides as a `BTreeMap>`, matching + /// the format expected by the product config pipeline. + /// + /// This is useful when implementing [`KeyValueOverridesProvider`] for a + /// typed override struct that contains [`KeyValueConfigOverrides`] fields. + pub fn as_overrides(&self) -> BTreeMap> { + self.overrides + .iter() + .map(|(k, v)| (k.clone(), Some(v.clone()))) + .collect() + } +} + +/// ConfigOverrides that can be applied to a JSON file. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum JsonConfigOverrides { + /// Can be set to arbitrary YAML content, which is converted to JSON and used as + /// [RFC 7396](https://datatracker.ietf.org/doc/html/rfc7396) JSON merge patch. + #[schemars(schema_with = "raw_object_schema")] + JsonMergePatch(serde_json::Value), + + /// List of [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON patches. + /// + /// Can be used when more flexibility is needed, e.g. to only modify elements + /// in a list based on a condition. + /// + /// A patch looks something like + /// + /// `{"op": "test", "path": "/0/name", "value": "Andrew"}` + /// + /// or + /// + /// `{"op": "add", "path": "/0/happy", "value": true}` + JsonPatches(Vec), + + /// Override the entire config file with the specified String. + UserProvided(String), +} + +impl JsonConfigOverrides { + /// Applies this override to a base JSON document and returns the patched + /// document as a [`serde_json::Value`]. + /// + /// For [`JsonConfigOverrides::JsonMergePatch`] and + /// [`JsonConfigOverrides::JsonPatches`], the base document is patched + /// according to the respective RFC. + /// + /// For [`JsonConfigOverrides::UserProvided`], the base document is ignored + /// entirely and the user-provided string is parsed and returned. + pub fn apply(&self, base: &serde_json::Value) -> Result { + match self { + JsonConfigOverrides::JsonMergePatch(patch) => { + let mut doc = base.clone(); + json_patch::merge(&mut doc, patch); + Ok(doc) + } + JsonConfigOverrides::JsonPatches(patches) => { + let mut doc = base.clone(); + let operations: Vec = patches + .iter() + .enumerate() + .map(|(index, patch_str)| { + serde_json::from_str(patch_str) + .context(DeserializeJsonPatchOperationSnafu { index }) + }) + .collect::, _>>()?; + json_patch::patch(&mut doc, &operations).context(ApplyJsonPatchSnafu)?; + Ok(doc) + } + JsonConfigOverrides::UserProvided(content) => { + serde_json::from_str(content).context(ParseUserProvidedJsonSnafu) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use super::*; + + #[test] + fn json_merge_patch_add_and_overwrite_fields() { + let base = json!({ + "bundles": { + "authz": { + "polling": { + "min_delay_seconds": 10, + "max_delay_seconds": 20 + } + } + } + }); + + let overrides = JsonConfigOverrides::JsonMergePatch(json!({ + "bundles": { + "authz": { + "polling": { + "min_delay_seconds": 3, + "max_delay_seconds": 5 + } + } + }, + "default_decision": "/http/example/authz/allow" + })); + + let result = overrides.apply(&base).expect("merge patch should succeed"); + + assert_eq!( + result["bundles"]["authz"]["polling"]["min_delay_seconds"], + 3 + ); + assert_eq!( + result["bundles"]["authz"]["polling"]["max_delay_seconds"], + 5 + ); + assert_eq!(result["default_decision"], "/http/example/authz/allow"); + } + + #[test] + fn json_merge_patch_remove_field_with_null() { + let base = json!({ + "keep": "this", + "remove": "this" + }); + + let overrides = JsonConfigOverrides::JsonMergePatch(json!({ + "remove": null + })); + + let result = overrides.apply(&base).expect("merge patch should succeed"); + + assert_eq!(result["keep"], "this"); + assert!(result.get("remove").is_none()); + } + + #[test] + fn json_patch_add_remove_replace() { + let base = json!({ + "foo": "bar", + "baz": "qux" + }); + + let overrides = JsonConfigOverrides::JsonPatches(vec![ + r#"{"op": "replace", "path": "/foo", "value": "replaced"}"#.to_owned(), + r#"{"op": "remove", "path": "/baz"}"#.to_owned(), + r#"{"op": "add", "path": "/new_key", "value": "new_value"}"#.to_owned(), + ]); + + let result = overrides.apply(&base).expect("JSON patch should succeed"); + + assert_eq!(result["foo"], "replaced"); + assert!(result.get("baz").is_none()); + assert_eq!(result["new_key"], "new_value"); + } + + #[test] + fn json_patch_invalid_path_returns_error() { + let base = json!({"foo": "bar"}); + + let overrides = JsonConfigOverrides::JsonPatches(vec![ + r#"{"op": "remove", "path": "/nonexistent"}"#.to_owned(), + ]); + + let result = overrides.apply(&base); + assert!(result.is_err(), "removing a nonexistent path should fail"); + } + + #[test] + fn json_patch_invalid_operation_returns_error() { + let base = json!({"foo": "bar"}); + + let overrides = JsonConfigOverrides::JsonPatches(vec![r#"{"not_an_op": true}"#.to_owned()]); + + let result = overrides.apply(&base); + assert!( + result.is_err(), + "invalid patch operation should return an error" + ); + } + + #[test] + fn user_provided_ignores_base() { + let base = json!({"foo": "bar"}); + let content = "{\"custom\": true}"; + + let overrides = JsonConfigOverrides::UserProvided(content.to_owned()); + + let result = overrides + .apply(&base) + .expect("user provided should succeed"); + assert_eq!(result, json!({"custom": true})); + } + + #[test] + fn user_provided_invalid_json_returns_error() { + let base = json!({"foo": "bar"}); + + let overrides = JsonConfigOverrides::UserProvided("not valid json".to_owned()); + + let result = overrides.apply(&base); + assert!(result.is_err(), "invalid JSON should return an error"); + } + + #[test] + fn key_value_config_overrides_as_overrides() { + let mut overrides = BTreeMap::new(); + overrides.insert("key1".to_owned(), "value1".to_owned()); + overrides.insert("key2".to_owned(), "value2".to_owned()); + + let kv = KeyValueConfigOverrides { overrides }; + let result = kv.as_overrides(); + + assert_eq!(result.len(), 2); + assert_eq!(result.get("key1"), Some(&Some("value1".to_owned()))); + assert_eq!(result.get("key2"), Some(&Some("value2".to_owned()))); + } + + #[test] + fn key_value_overrides_provider_for_hashmap() { + let mut config_overrides = HashMap::>::new(); + let mut file_overrides = HashMap::new(); + file_overrides.insert("key1".to_owned(), "value1".to_owned()); + file_overrides.insert("key2".to_owned(), "value2".to_owned()); + config_overrides.insert("myfile.properties".to_owned(), file_overrides); + + let result = config_overrides.get_key_value_overrides("myfile.properties"); + assert_eq!(result.len(), 2); + assert_eq!(result.get("key1"), Some(&Some("value1".to_owned()))); + assert_eq!(result.get("key2"), Some(&Some("value2".to_owned()))); + + let empty = config_overrides.get_key_value_overrides("nonexistent.properties"); + assert!(empty.is_empty()); + } +} diff --git a/crates/stackable-operator/src/lib.rs b/crates/stackable-operator/src/lib.rs index 46616252c..1e0fe620c 100644 --- a/crates/stackable-operator/src/lib.rs +++ b/crates/stackable-operator/src/lib.rs @@ -12,6 +12,7 @@ pub mod client; pub mod cluster_resources; pub mod commons; pub mod config; +pub mod config_overrides; pub mod constants; pub mod cpu; #[cfg(feature = "crds")] diff --git a/crates/stackable-operator/src/product_config_utils.rs b/crates/stackable-operator/src/product_config_utils.rs index a95879c1a..044e34db8 100644 --- a/crates/stackable-operator/src/product_config_utils.rs +++ b/crates/stackable-operator/src/product_config_utils.rs @@ -7,7 +7,10 @@ use serde::Serialize; use snafu::{ResultExt, Snafu}; use tracing::{debug, error, warn}; -use crate::role_utils::{CommonConfiguration, Role}; +use crate::{ + config_overrides::KeyValueOverridesProvider, + role_utils::{CommonConfiguration, Role}, +}; pub const CONFIG_OVERRIDE_FILE_HEADER_KEY: &str = "EXPERIMENTAL_FILE_HEADER"; pub const CONFIG_OVERRIDE_FILE_FOOTER_KEY: &str = "EXPERIMENTAL_FILE_FOOTER"; @@ -171,13 +174,13 @@ pub fn config_for_role_and_group<'a>( /// - `roles`: A map keyed by role names. The value is a tuple of a vector of `PropertyNameKind` /// like (Cli, Env or Files) and [`crate::role_utils::Role`] with a boxed [`Configuration`]. #[allow(clippy::type_complexity)] -pub fn transform_all_roles_to_config( +pub fn transform_all_roles_to_config( resource: &T::Configurable, roles: HashMap< String, ( Vec, - Role, + Role, ), >, ) -> Result @@ -185,6 +188,7 @@ where T: Configuration, U: Default + JsonSchema + Serialize, ProductSpecificCommonConfig: Default + JsonSchema + Serialize, + ConfigOverrides: Default + JsonSchema + Serialize + KeyValueOverridesProvider, { let mut result = HashMap::new(); @@ -380,16 +384,17 @@ fn process_validation_result( /// - `role_name` - The name of the role. /// - `role` - The role for which to transform the configuration parameters. /// - `property_kinds` - Used as "buckets" to partition the configuration properties by. -fn transform_role_to_config( +fn transform_role_to_config( resource: &T::Configurable, role_name: &str, - role: &Role, + role: &Role, property_kinds: &[PropertyNameKind], ) -> Result where T: Configuration, U: Default + JsonSchema + Serialize, ProductSpecificCommonConfig: Default + JsonSchema + Serialize, + ConfigOverrides: Default + JsonSchema + Serialize + KeyValueOverridesProvider, { let mut result = HashMap::new(); @@ -444,10 +449,10 @@ where /// - `role_name` - Not used directly but passed on to the `Configuration::compute_*` calls. /// - `config` - The configuration properties to partition. /// - `property_kinds` - The "buckets" used to partition the configuration properties. -fn parse_role_config( +fn parse_role_config( resource: &::Configurable, role_name: &str, - config: &CommonConfiguration, + config: &CommonConfiguration, property_kinds: &[PropertyNameKind], ) -> Result>>> where @@ -474,12 +479,13 @@ where Ok(result) } -fn parse_role_overrides( - config: &CommonConfiguration, +fn parse_role_overrides( + config: &CommonConfiguration, property_kinds: &[PropertyNameKind], ) -> Result>>> where T: Configuration, + ConfigOverrides: KeyValueOverridesProvider, { let mut result = HashMap::new(); for property_kind in property_kinds { @@ -511,23 +517,15 @@ where Ok(result) } -fn parse_file_overrides( - config: &CommonConfiguration, +fn parse_file_overrides( + config: &CommonConfiguration, file: &str, ) -> Result>> where T: Configuration, + ConfigOverrides: KeyValueOverridesProvider, { - let mut final_overrides: BTreeMap> = BTreeMap::new(); - - // For Conf files only process overrides that match our file name - if let Some(config) = config.config_overrides.get(file) { - for (key, value) in config { - final_overrides.insert(key.clone(), Some(value.clone())); - } - } - - Ok(final_overrides) + Ok(config.config_overrides.get_key_value_overrides(file)) } /// Extract the environment variables of a rolegroup config into a vector of EnvVars. diff --git a/crates/stackable-operator/src/role_utils.rs b/crates/stackable-operator/src/role_utils.rs index b82bd6623..747af2334 100644 --- a/crates/stackable-operator/src/role_utils.rs +++ b/crates/stackable-operator/src/role_utils.rs @@ -118,10 +118,17 @@ pub enum Error { #[serde( rename_all = "camelCase", bound( - deserialize = "T: Default + Deserialize<'de>, ProductSpecificCommonConfig: Default + Deserialize<'de>" + deserialize = "T: Default + Deserialize<'de>, ProductSpecificCommonConfig: Default + Deserialize<'de>, ConfigOverrides: Default + Deserialize<'de>" ) )] -pub struct CommonConfiguration { +#[schemars( + bound = "T: JsonSchema, ProductSpecificCommonConfig: JsonSchema, ConfigOverrides: Default + JsonSchema" +)] +pub struct CommonConfiguration< + T, + ProductSpecificCommonConfig, + ConfigOverrides = HashMap>, +> { #[serde(default)] // We can't depend on T being `Default`, since that trait is not object-safe // We only need to generate schemas for fully specified types, but schemars_derive @@ -135,7 +142,7 @@ pub struct CommonConfiguration { /// and consult the operator specific usage guide documentation for details on the /// available config files and settings for the specific product. #[serde(default)] - pub config_overrides: HashMap>, + pub config_overrides: ConfigOverrides, /// `envOverrides` configure environment variables to be set in the Pods. /// It is a map from strings to strings - environment variables and the value to set. @@ -171,7 +178,9 @@ pub struct CommonConfiguration { pub product_specific_common_config: ProductSpecificCommonConfig, } -impl CommonConfiguration { +impl + CommonConfiguration +{ fn default_config() -> serde_json::Value { serde_json::json!({}) } @@ -302,32 +311,36 @@ pub struct Role< T, U = GenericRoleConfig, ProductSpecificCommonConfig = GenericProductSpecificCommonConfig, + ConfigOverrides = HashMap>, > where // Don't remove this trait bounds!!! // We don't know why, but if you remove either of them, the generated default value in the CRDs will // be missing! U: Default + JsonSchema + Serialize, ProductSpecificCommonConfig: Default + JsonSchema + Serialize, + ConfigOverrides: Default + JsonSchema + Serialize, { #[serde( flatten, bound( - deserialize = "T: Default + Deserialize<'de>, ProductSpecificCommonConfig: Deserialize<'de>" + deserialize = "T: Default + Deserialize<'de>, ProductSpecificCommonConfig: Deserialize<'de>, ConfigOverrides: Deserialize<'de>" ) )] - pub config: CommonConfiguration, + pub config: CommonConfiguration, #[serde(default)] pub role_config: U, - pub role_groups: HashMap>, + pub role_groups: HashMap>, } -impl Role +impl + Role where T: Configuration + 'static, U: Default + JsonSchema + Serialize, ProductSpecificCommonConfig: Default + JsonSchema + Serialize + Clone, + ConfigOverrides: Default + JsonSchema + Serialize, { /// This casts a generic struct implementing [`crate::product_config_utils::Configuration`] /// and used in [`Role`] into a Box of a dynamically dispatched @@ -336,8 +349,12 @@ where /// have different structs implementing Configuration. pub fn erase( self, - ) -> Role>, U, ProductSpecificCommonConfig> - { + ) -> Role< + Box>, + U, + ProductSpecificCommonConfig, + ConfigOverrides, + > { Role { config: CommonConfiguration { config: Box::new(self.config.config) @@ -376,9 +393,10 @@ where } } -impl Role +impl Role where U: Default + JsonSchema + Serialize, + ConfigOverrides: Default + JsonSchema + Serialize, { /// Merges jvm argument overrides from /// @@ -431,25 +449,36 @@ pub struct EmptyRoleConfig {} #[serde( rename_all = "camelCase", bound( - deserialize = "T: Default + Deserialize<'de>, ProductSpecificCommonConfig: Default + Deserialize<'de>" + deserialize = "T: Default + Deserialize<'de>, ProductSpecificCommonConfig: Default + Deserialize<'de>, ConfigOverrides: Default + Deserialize<'de>" ) )] -pub struct RoleGroup { +#[schemars( + bound = "T: JsonSchema, ProductSpecificCommonConfig: JsonSchema, ConfigOverrides: Default + JsonSchema" +)] +pub struct RoleGroup< + T, + ProductSpecificCommonConfig, + ConfigOverrides = HashMap>, +> { #[serde(flatten)] - pub config: CommonConfiguration, + pub config: CommonConfiguration, pub replicas: Option, } -impl RoleGroup { +impl + RoleGroup +{ pub fn validate_config( &self, - role: &Role, + role: &Role, default_config: &T, ) -> Result where C: FromFragment, T: Merge + Clone, U: Default + JsonSchema + Serialize, + ProductSpecificCommonConfig: Default + JsonSchema + Serialize, + ConfigOverrides: Default + JsonSchema + Serialize, { let mut role_config = role.config.config.clone(); role_config.merge(default_config); diff --git a/crates/xtask/src/crd/dummy.rs b/crates/xtask/src/crd/dummy.rs index 28ed0581c..917e534f3 100644 --- a/crates/xtask/src/crd/dummy.rs +++ b/crates/xtask/src/crd/dummy.rs @@ -54,6 +54,10 @@ pub mod versioned { #[serde(default)] pub object_overrides: ObjectOverrides, + json_config_overrides: Option, + key_value_config_overrides: + Option, + // Already versioned client_authentication_details: stackable_operator::crd::authentication::core::v1alpha1::ClientAuthenticationDetails,