From 7f1f7df6430bad69ebc6aad9ede56286d8ebff5f Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Tue, 2 Sep 2025 19:51:01 +0200 Subject: [PATCH] test: Add unit tests --- Cargo.lock | 86 +++ Cargo.nix | 222 ++++++++ Cargo.toml | 1 + rust/operator-binary/Cargo.toml | 3 + rust/operator-binary/src/controller.rs | 195 ++++++- rust/operator-binary/src/controller/build.rs | 147 ++++++ .../src/controller/build/node_config.rs | 182 +++++-- .../src/controller/build/role_builder.rs | 271 ++++++++++ .../controller/build/role_group_builder.rs | 495 +++++++++++++++++- .../src/controller/validate.rs | 438 +++++++++++++++- rust/operator-binary/src/crd/mod.rs | 2 +- rust/operator-binary/src/framework.rs | 98 +++- .../src/framework/builder/meta.rs | 101 +++- .../src/framework/builder/pdb.rs | 161 +++++- .../src/framework/builder/pod/container.rs | 162 +++++- .../src/framework/kvp/label.rs | 152 +++++- .../src/framework/role_group_utils.rs | 28 +- .../src/framework/role_utils.rs | 189 ++++++- tests/templates/kuttl/smoke/10-assert.yaml.j2 | 16 +- 19 files changed, 2820 insertions(+), 129 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abc5798..b437b15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -1954,6 +1960,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2114,6 +2129,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.22" @@ -2162,12 +2183,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.104", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.28" @@ -2551,6 +2610,7 @@ dependencies = [ "built", "clap", "futures 0.3.31", + "rstest", "schemars", "serde", "serde_json", @@ -2905,6 +2965,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.10.0", + "toml_datetime", + "winnow", +] + [[package]] name = "tonic" version = "0.12.3" @@ -3453,6 +3530,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.nix b/Cargo.nix index 3b14228..d618989 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -2475,6 +2475,21 @@ rec { }; resolvedDefaultFeatures = [ "alloc" "std" ]; }; + "futures-timer" = rec { + crateName = "futures-timer"; + version = "3.0.3"; + edition = "2018"; + sha256 = "094vw8k37djpbwv74bwf2qb7n6v6ghif4myss6smd6hgyajb127j"; + libName = "futures_timer"; + authors = [ + "Alex Crichton " + ]; + features = { + "gloo-timers" = [ "dep:gloo-timers" ]; + "send_wrapper" = [ "dep:send_wrapper" ]; + "wasm-bindgen" = [ "gloo-timers" "send_wrapper" ]; + }; + }; "futures-util" = rec { crateName = "futures-util"; version = "0.3.31"; @@ -6405,6 +6420,25 @@ rec { }; resolvedDefaultFeatures = [ "simd" "std" ]; }; + "proc-macro-crate" = rec { + crateName = "proc-macro-crate"; + version = "3.3.0"; + edition = "2021"; + sha256 = "0d9xlymplfi9yv3f5g4bp0d6qh70apnihvqcjllampx4f5lmikpd"; + libName = "proc_macro_crate"; + authors = [ + "Bastian Köcher " + ]; + dependencies = [ + { + name = "toml_edit"; + packageId = "toml_edit"; + usesDefaultFeatures = false; + features = [ "parse" ]; + } + ]; + + }; "proc-macro2" = rec { crateName = "proc-macro2"; version = "1.0.95"; @@ -6931,6 +6965,20 @@ rec { }; resolvedDefaultFeatures = [ "default" "std" "unicode" "unicode-age" "unicode-bool" "unicode-case" "unicode-gencat" "unicode-perl" "unicode-script" "unicode-segment" ]; }; + "relative-path" = rec { + crateName = "relative-path"; + version = "1.9.3"; + edition = "2021"; + sha256 = "1limlh8fzwi21g0473fqzd6fln9iqkwvzp3816bxi31pkilz6fds"; + libName = "relative_path"; + authors = [ + "John-John Tedro " + ]; + features = { + "serde" = [ "dep:serde" ]; + }; + resolvedDefaultFeatures = [ "default" ]; + }; "reqwest" = rec { crateName = "reqwest"; version = "0.12.22"; @@ -7224,6 +7272,99 @@ rec { }; resolvedDefaultFeatures = [ "alloc" "default" "dev_urandom_fallback" ]; }; + "rstest" = rec { + crateName = "rstest"; + version = "0.26.1"; + edition = "2021"; + sha256 = "0jcxhg9mxlr2p9an14algbcq6ax7r0sk1w1kbals5aiv0qy1k8zm"; + authors = [ + "Michele d'Amico " + ]; + dependencies = [ + { + name = "futures-timer"; + packageId = "futures-timer"; + optional = true; + } + { + name = "futures-util"; + packageId = "futures-util"; + optional = true; + } + { + name = "rstest_macros"; + packageId = "rstest_macros"; + usesDefaultFeatures = false; + } + ]; + features = { + "async-timeout" = [ "dep:futures-timer" "dep:futures-util" "rstest_macros/async-timeout" ]; + "crate-name" = [ "rstest_macros/crate-name" ]; + "default" = [ "async-timeout" "crate-name" ]; + }; + resolvedDefaultFeatures = [ "async-timeout" "crate-name" "default" ]; + }; + "rstest_macros" = rec { + crateName = "rstest_macros"; + version = "0.26.1"; + edition = "2021"; + sha256 = "185v185wn2x3llp3nn1i7h44vi5ffnnsj8b1a32m2ygzy08m714w"; + procMacro = true; + authors = [ + "Michele d'Amico " + ]; + dependencies = [ + { + name = "cfg-if"; + packageId = "cfg-if"; + } + { + name = "glob"; + packageId = "glob"; + } + { + name = "proc-macro-crate"; + packageId = "proc-macro-crate"; + optional = true; + } + { + name = "proc-macro2"; + packageId = "proc-macro2"; + } + { + name = "quote"; + packageId = "quote"; + } + { + name = "regex"; + packageId = "regex"; + } + { + name = "relative-path"; + packageId = "relative-path"; + } + { + name = "syn"; + packageId = "syn 2.0.104"; + features = [ "full" "parsing" "extra-traits" "visit" "visit-mut" ]; + } + { + name = "unicode-ident"; + packageId = "unicode-ident"; + } + ]; + buildDependencies = [ + { + name = "rustc_version"; + packageId = "rustc_version"; + } + ]; + features = { + "crate-name" = [ "dep:proc-macro-crate" ]; + "default" = [ "async-timeout" "crate-name" ]; + }; + resolvedDefaultFeatures = [ "async-timeout" "crate-name" ]; + }; "rustc-demangle" = rec { crateName = "rustc-demangle"; version = "0.1.25"; @@ -7238,6 +7379,19 @@ rec { "rustc-dep-of-std" = [ "core" ]; }; }; + "rustc_version" = rec { + crateName = "rustc_version"; + version = "0.4.1"; + edition = "2018"; + sha256 = "14lvdsmr5si5qbqzrajgb6vfn69k0sfygrvfvr2mps26xwi3mjyg"; + dependencies = [ + { + name = "semver"; + packageId = "semver"; + } + ]; + + }; "rustls" = rec { crateName = "rustls"; version = "0.23.28"; @@ -8408,6 +8562,12 @@ rec { features = [ "chrono" "git2" ]; } ]; + devDependencies = [ + { + name = "rstest"; + packageId = "rstest"; + } + ]; }; "stackable-operator" = rec { @@ -9618,6 +9778,46 @@ rec { }; resolvedDefaultFeatures = [ "codec" "default" "io" "slab" "time" ]; }; + "toml_datetime" = rec { + crateName = "toml_datetime"; + version = "0.6.11"; + edition = "2021"; + sha256 = "077ix2hb1dcya49hmi1avalwbixmrs75zgzb3b2i7g2gizwdmk92"; + features = { + "serde" = [ "dep:serde" ]; + }; + }; + "toml_edit" = rec { + crateName = "toml_edit"; + version = "0.22.27"; + edition = "2021"; + sha256 = "16l15xm40404asih8vyjvnka9g0xs9i4hfb6ry3ph9g419k8rzj1"; + dependencies = [ + { + name = "indexmap"; + packageId = "indexmap 2.10.0"; + features = [ "std" ]; + } + { + name = "toml_datetime"; + packageId = "toml_datetime"; + } + { + name = "winnow"; + packageId = "winnow"; + optional = true; + } + ]; + features = { + "default" = [ "parse" "display" ]; + "display" = [ "dep:toml_write" ]; + "parse" = [ "dep:winnow" ]; + "perf" = [ "dep:kstring" ]; + "serde" = [ "dep:serde" "toml_datetime/serde" "dep:serde_spanned" ]; + "unstable-debug" = [ "winnow?/debug" ]; + }; + resolvedDefaultFeatures = [ "parse" ]; + }; "tonic" = rec { crateName = "tonic"; version = "0.12.3"; @@ -12325,6 +12525,28 @@ rec { ]; }; + "winnow" = rec { + crateName = "winnow"; + version = "0.7.13"; + edition = "2021"; + sha256 = "1krrjc1wj2vx0r57m9nwnlc1zrhga3fq41d8w9hysvvqb5mj7811"; + dependencies = [ + { + name = "memchr"; + packageId = "memchr"; + optional = true; + usesDefaultFeatures = false; + } + ]; + features = { + "debug" = [ "std" "dep:anstream" "dep:anstyle" "dep:is_terminal_polyfill" "dep:terminal_size" ]; + "default" = [ "std" ]; + "simd" = [ "dep:memchr" ]; + "std" = [ "alloc" "memchr?/std" ]; + "unstable-doc" = [ "alloc" "std" "simd" "unstable-recover" ]; + }; + resolvedDefaultFeatures = [ "alloc" "default" "std" ]; + }; "wit-bindgen-rt" = rec { crateName = "wit-bindgen-rt"; version = "0.39.0"; diff --git a/Cargo.toml b/Cargo.toml index 19dce34..36e6dd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", built = { version = "0.8.0", features = ["chrono", "git2"] } clap = "4.5" futures = { version = "0.3", features = ["compat"] } +rstest = "0.26" schemars = { version = "0.8.21" } # same as in operator-rs serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/rust/operator-binary/Cargo.toml b/rust/operator-binary/Cargo.toml index 9d97df9..06272e5 100644 --- a/rust/operator-binary/Cargo.toml +++ b/rust/operator-binary/Cargo.toml @@ -24,3 +24,6 @@ tracing.workspace = true [build-dependencies] built.workspace = true + +[dev-dependencies] +rstest.workspace = true diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index 4d27a3a..0fcf4e0 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -54,13 +54,17 @@ impl Context { pub fn new(client: stackable_operator::client::Client, operator_name: OperatorName) -> Self { Context { client, - names: ContextNames { - product_name: ProductName::from_str("opensearch") - .expect("should be a valid product name"), - operator_name, - controller_name: ControllerName::from_str("opensearchcluster") - .expect("should be a valid controller name"), - }, + names: Self::context_names(operator_name), + } + } + + fn context_names(operator_name: OperatorName) -> ContextNames { + ContextNames { + product_name: ProductName::from_str("opensearch") + .expect("should be a valid product name"), + operator_name, + controller_name: ControllerName::from_str("opensearchcluster") + .expect("should be a valid controller name"), } } @@ -106,11 +110,14 @@ impl ReconcilerError for Error { type OpenSearchRoleGroupConfig = RoleGroupConfig; +type OpenSearchNodeResources = + stackable_operator::commons::resources::Resources; + #[derive(Clone, Debug, PartialEq)] pub struct ValidatedOpenSearchConfig { pub affinity: StackableAffinity, pub node_roles: NodeRoles, - pub resources: stackable_operator::commons::resources::Resources, + pub resources: OpenSearchNodeResources, pub termination_grace_period_seconds: i64, pub listener_class: String, } @@ -132,6 +139,32 @@ pub struct ValidatedCluster { } impl ValidatedCluster { + pub fn new( + image: ProductImage, + product_version: ProductVersion, + name: ClusterName, + namespace: String, + uid: String, + role_config: GenericRoleConfig, + role_group_configs: BTreeMap, + ) -> Self { + ValidatedCluster { + metadata: ObjectMeta { + name: Some(name.to_object_name()), + namespace: Some(namespace.clone()), + uid: Some(uid.clone()), + ..ObjectMeta::default() + }, + image, + product_version, + name, + namespace, + uid, + role_config, + role_group_configs, + } + } + pub fn role_name() -> RoleName { RoleName::from_str("nodes").expect("should be a valid role name") } @@ -177,7 +210,6 @@ impl HasUid for ValidatedCluster { } } -// ? impl IsLabelValue for ValidatedCluster { fn to_label_value(&self) -> String { // opinionated! @@ -283,3 +315,148 @@ struct KubernetesResources { pod_disruption_budgets: Vec, status: PhantomData, } + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use stackable_operator::{ + commons::affinity::StackableAffinity, k8s_openapi::api::core::v1::PodTemplateSpec, + role_utils::GenericRoleConfig, + }; + + use super::{Context, OpenSearchRoleGroupConfig, ValidatedCluster}; + use crate::{ + controller::{OpenSearchNodeResources, ValidatedOpenSearchConfig}, + crd::{NodeRoles, v1alpha1}, + framework::{ + ClusterName, OperatorName, ProductVersion, RoleGroupName, + builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, + }, + }; + + #[test] + fn test_context_names() { + // Test that the function does not panic + Context::context_names(OperatorName::from_str_unsafe("my-operator")); + } + + #[test] + fn test_validated_cluster_role_name() { + // Test that the function does not panic + ValidatedCluster::role_name(); + } + + #[test] + fn test_validated_cluster_is_single_node() { + let validated_cluster = validated_cluster(); + + assert!(!validated_cluster.is_single_node()); + } + + #[test] + fn test_validated_cluster_node_count() { + let validated_cluster = validated_cluster(); + + assert_eq!(18, validated_cluster.node_count()); + } + + #[test] + fn test_validated_cluster_role_group_configs_filtered_by_node_role() { + let validated_cluster = validated_cluster(); + + assert_eq!( + BTreeMap::from([ + ( + RoleGroupName::from_str_unsafe("data1"), + role_group_config( + 4, + &[ + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient, + ], + ), + ), + ( + RoleGroupName::from_str_unsafe("data2"), + role_group_config( + 6, + &[ + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient, + ], + ), + ), + ]), + validated_cluster.role_group_configs_filtered_by_node_role(&v1alpha1::NodeRole::Data) + ); + } + + fn validated_cluster() -> ValidatedCluster { + ValidatedCluster::new( + serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage structure"), + ProductVersion::from_str_unsafe("3.1.0"), + ClusterName::from_str_unsafe("my-opensearch"), + "default".to_owned(), + "e6ac237d-a6d4-43a1-8135-f36506110912".to_owned(), + GenericRoleConfig::default(), + [ + ( + RoleGroupName::from_str_unsafe("coordinating"), + role_group_config(5, &[v1alpha1::NodeRole::CoordinatingOnly]), + ), + ( + RoleGroupName::from_str_unsafe("cluster-manager"), + role_group_config(3, &[v1alpha1::NodeRole::ClusterManager]), + ), + ( + RoleGroupName::from_str_unsafe("data1"), + role_group_config( + 4, + &[ + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient, + ], + ), + ), + ( + RoleGroupName::from_str_unsafe("data2"), + role_group_config( + 6, + &[ + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient, + ], + ), + ), + ] + .into(), + ) + } + + fn role_group_config( + replicas: u16, + node_roles: &[v1alpha1::NodeRole], + ) -> OpenSearchRoleGroupConfig { + OpenSearchRoleGroupConfig { + replicas, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity::default(), + node_roles: NodeRoles(node_roles.to_vec()), + resources: OpenSearchNodeResources::default(), + termination_grace_period_seconds: 120, + listener_class: "external-stable".to_owned(), + }, + config_overrides: HashMap::default(), + env_overrides: EnvVarSet::default(), + cli_overrides: BTreeMap::default(), + pod_overrides: PodTemplateSpec::default(), + product_specific_common_config: GenericProductSpecificCommonConfig::default(), + } + } +} diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index db8e35e..e105b1b 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -43,3 +43,150 @@ pub fn build(names: &ContextNames, cluster: ValidatedCluster) -> KubernetesResou status: PhantomData, } } + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use stackable_operator::{ + commons::affinity::StackableAffinity, k8s_openapi::api::core::v1::PodTemplateSpec, + kube::Resource, role_utils::GenericRoleConfig, + }; + + use super::build; + use crate::{ + controller::{ + ContextNames, OpenSearchNodeResources, OpenSearchRoleGroupConfig, ValidatedCluster, + ValidatedOpenSearchConfig, + }, + crd::{NodeRoles, v1alpha1}, + framework::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, + builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, + }, + }; + + #[test] + fn test_build() { + let resources = build(&context_names(), validated_cluster()); + + assert_eq!( + vec![ + "my-opensearch-nodes-cluster-manager", + "my-opensearch-nodes-coordinating", + "my-opensearch-nodes-data", + ], + extract_resource_names(&resources.stateful_sets) + ); + assert_eq!( + vec![ + "my-opensearch", + "my-opensearch-nodes-cluster-manager-headless", + "my-opensearch-nodes-coordinating-headless", + "my-opensearch-nodes-data-headless" + ], + extract_resource_names(&resources.services) + ); + assert_eq!( + vec![ + "my-opensearch-nodes-cluster-manager", + "my-opensearch-nodes-coordinating", + "my-opensearch-nodes-data" + ], + extract_resource_names(&resources.listeners) + ); + assert_eq!( + vec![ + "my-opensearch-nodes-cluster-manager", + "my-opensearch-nodes-coordinating", + "my-opensearch-nodes-data" + ], + extract_resource_names(&resources.config_maps) + ); + assert_eq!( + vec!["my-opensearch-serviceaccount"], + extract_resource_names(&resources.service_accounts) + ); + assert_eq!( + vec!["my-opensearch-rolebinding"], + extract_resource_names(&resources.role_bindings) + ); + assert_eq!( + vec!["my-opensearch-nodes"], + extract_resource_names(&resources.pod_disruption_budgets) + ); + } + + fn extract_resource_names(resources: &[impl Resource]) -> Vec<&str> { + let mut resource_names: Vec<&str> = resources + .iter() + .filter_map(|resource| resource.meta().name.as_ref()) + .map(|x| x.as_str()) + .collect(); + resource_names.sort(); + resource_names + } + + fn context_names() -> ContextNames { + ContextNames { + product_name: ProductName::from_str_unsafe("opensearch"), + operator_name: OperatorName::from_str_unsafe("opensearch.stackable.tech"), + controller_name: ControllerName::from_str_unsafe("opensearchcluster"), + } + } + + fn validated_cluster() -> ValidatedCluster { + ValidatedCluster::new( + serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage structure"), + ProductVersion::from_str_unsafe("3.1.0"), + ClusterName::from_str_unsafe("my-opensearch"), + "default".to_owned(), + "e6ac237d-a6d4-43a1-8135-f36506110912".to_owned(), + GenericRoleConfig::default(), + [ + ( + RoleGroupName::from_str_unsafe("coordinating"), + role_group_config(5, &[v1alpha1::NodeRole::CoordinatingOnly]), + ), + ( + RoleGroupName::from_str_unsafe("cluster-manager"), + role_group_config(3, &[v1alpha1::NodeRole::ClusterManager]), + ), + ( + RoleGroupName::from_str_unsafe("data"), + role_group_config( + 8, + &[ + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient, + ], + ), + ), + ] + .into(), + ) + } + + fn role_group_config( + replicas: u16, + node_roles: &[v1alpha1::NodeRole], + ) -> OpenSearchRoleGroupConfig { + OpenSearchRoleGroupConfig { + replicas, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity::default(), + node_roles: NodeRoles(node_roles.to_vec()), + resources: OpenSearchNodeResources::default(), + termination_grace_period_seconds: 120, + listener_class: "external-stable".to_owned(), + }, + config_overrides: HashMap::default(), + env_overrides: EnvVarSet::default(), + cli_overrides: BTreeMap::default(), + pod_overrides: PodTemplateSpec::default(), + product_specific_common_config: GenericProductSpecificCommonConfig::default(), + } + } +} diff --git a/rust/operator-binary/src/controller/build/node_config.rs b/rust/operator-binary/src/controller/build/node_config.rs index f1f2424..0eb318c 100644 --- a/rust/operator-binary/src/controller/build/node_config.rs +++ b/rust/operator-binary/src/controller/build/node_config.rs @@ -238,11 +238,7 @@ impl NodeConfig { #[cfg(test)] mod tests { - - use std::{ - collections::{BTreeMap, HashMap}, - str::FromStr, - }; + use std::collections::BTreeMap; use stackable_operator::{ commons::{ @@ -250,7 +246,6 @@ mod tests { resources::Resources, }, k8s_openapi::api::core::v1::PodTemplateSpec, - kube::api::ObjectMeta, role_utils::GenericRoleConfig, }; @@ -258,9 +253,103 @@ mod tests { use crate::{ controller::ValidatedOpenSearchConfig, crd::NodeRoles, - framework::{ClusterName, ProductVersion, role_utils::GenericProductSpecificCommonConfig}, + framework::{ + ClusterName, ProductVersion, RoleGroupName, + role_utils::GenericProductSpecificCommonConfig, + }, }; + pub fn node_config( + replicas: u16, + config_settings: &[(&str, &str)], + env_vars: &[(&str, &str)], + ) -> NodeConfig { + let image: ProductImage = serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage"); + + let role_group_config = OpenSearchRoleGroupConfig { + replicas, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity::default(), + node_roles: NodeRoles(vec![ + v1alpha1::NodeRole::ClusterManager, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::RemoteClusterClient, + ]), + resources: Resources::default(), + termination_grace_period_seconds: 30, + listener_class: "cluster-internal".to_string(), + }, + config_overrides: [( + CONFIGURATION_FILE_OPENSEARCH_YML.to_owned(), + config_settings + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + )] + .into(), + env_overrides: EnvVarSet::new().with_values( + env_vars + .iter() + .map(|(k, v)| (EnvVarName::from_str_unsafe(k), *v)), + ), + cli_overrides: BTreeMap::default(), + pod_overrides: PodTemplateSpec::default(), + product_specific_common_config: GenericProductSpecificCommonConfig::default(), + }; + + let cluster = ValidatedCluster::new( + image.clone(), + ProductVersion::from_str_unsafe(image.product_version()), + ClusterName::from_str_unsafe("my-opensearch-cluster"), + "default".to_owned(), + "0b1e30e6-326e-4c1a-868d-ad6598b49e8b".to_owned(), + GenericRoleConfig::default(), + [( + RoleGroupName::from_str_unsafe("default"), + role_group_config.clone(), + )] + .into(), + ); + + NodeConfig::new( + cluster, + role_group_config, + "my-opensearch-cluster-manager".to_owned(), + ) + } + + #[test] + pub fn test_static_opensearch_config_file() { + let node_config = node_config(2, &[("test", "value")], &[]); + + assert_eq!( + concat!( + "cluster.name: \"my-opensearch-cluster\"\n", + "discovery.type: \"zen\"\n", + "network.host: \"0.0.0.0\"\n", + "plugins.security.nodes_dn: [\"CN=generated certificate for pod\"]\n", + "test: \"value\"" + ) + .to_owned(), + node_config.static_opensearch_config_file() + ); + } + + #[test] + pub fn test_tls_on_http_port_enabled() { + let node_config_tls_undefined = node_config(2, &[], &[]); + let node_config_tls_enabled = + node_config(2, &[("plugins.security.ssl.http.enabled", "true")], &[]); + let node_config_tls_disabled = + node_config(2, &[("plugins.security.ssl.http.enabled", "false")], &[]); + + assert!(!node_config_tls_undefined.tls_on_http_port_enabled()); + assert!(node_config_tls_enabled.tls_on_http_port_enabled()); + assert!(!node_config_tls_disabled.tls_on_http_port_enabled()); + } + #[test] pub fn test_value_as_bool() { // boolean @@ -300,52 +389,14 @@ mod tests { #[test] pub fn test_environment_variables() { - let image: ProductImage = serde_json::from_str(r#"{"productVersion": "3.0.0"}"#) - .expect("should be a valid ProductImage"); - let cluster = ValidatedCluster { - metadata: ObjectMeta::default(), - image: image.clone(), - product_version: ProductVersion::from_str(image.product_version()) - .expect("should be a valid ProductVersion"), - name: ClusterName::from_str("my-opensearch-cluster") - .expect("should be a valid ClusterName"), - namespace: "default".to_owned(), - uid: "0b1e30e6-326e-4c1a-868d-ad6598b49e8b".to_owned(), - role_config: GenericRoleConfig::default(), - role_group_configs: BTreeMap::new(), - }; - - let role_group_config = OpenSearchRoleGroupConfig { - replicas: 1, - config: ValidatedOpenSearchConfig { - affinity: StackableAffinity::default(), - node_roles: NodeRoles::default(), - resources: Resources::default(), - termination_grace_period_seconds: 30, - listener_class: "cluster-internal".to_string(), - }, - config_overrides: HashMap::default(), - env_overrides: EnvVarSet::new() - .with_value(EnvVarName::from_str_unsafe("TEST"), "value"), - cli_overrides: BTreeMap::default(), - pod_overrides: PodTemplateSpec::default(), - product_specific_common_config: GenericProductSpecificCommonConfig::default(), - }; - - let node_config = NodeConfig::new( - cluster, - role_group_config, - "my-opensearch-cluster-manager".to_owned(), - ); - - let env_vars = node_config.environment_variables(); + let node_config = node_config(2, &[], &[("TEST", "value")]); assert_eq!( EnvVarSet::new() .with_value(EnvVarName::from_str_unsafe("TEST"), "value",) .with_value( EnvVarName::from_str_unsafe("cluster.initial_cluster_manager_nodes"), - "", + "my-opensearch-cluster-nodes-default-0,my-opensearch-cluster-nodes-default-1", ) .with_value( EnvVarName::from_str_unsafe("discovery.seed_hosts"), @@ -355,8 +406,41 @@ mod tests { EnvVarName::from_str_unsafe("node.name"), FieldPathEnvVar::Name ) - .with_value(EnvVarName::from_str_unsafe("node.roles"), "",), - env_vars + .with_value( + EnvVarName::from_str_unsafe("node.roles"), + "cluster_manager,data,ingest,remote_cluster_client" + ), + node_config.environment_variables() + ); + } + + #[test] + pub fn test_discovery_type() { + let node_config_single_node = node_config(1, &[], &[]); + let node_config_multiple_nodes = node_config(2, &[], &[]); + + assert_eq!( + "single-node".to_owned(), + node_config_single_node.discovery_type() + ); + assert_eq!( + "zen".to_owned(), + node_config_multiple_nodes.discovery_type() + ); + } + + #[test] + pub fn test_initial_cluster_manager_nodes() { + let node_config_single_node = node_config(1, &[], &[]); + let node_config_multiple_nodes = node_config(3, &[], &[]); + + assert_eq!( + "".to_owned(), + node_config_single_node.initial_cluster_manager_nodes() + ); + assert_eq!( + "my-opensearch-cluster-nodes-default-0,my-opensearch-cluster-nodes-default-1,my-opensearch-cluster-nodes-default-2".to_owned(), + node_config_multiple_nodes.initial_cluster_manager_nodes() ); } } diff --git a/rust/operator-binary/src/controller/build/role_builder.rs b/rust/operator-binary/src/controller/build/role_builder.rs index 2110edf..359597c 100644 --- a/rust/operator-binary/src/controller/build/role_builder.rs +++ b/rust/operator-binary/src/controller/build/role_builder.rs @@ -194,3 +194,274 @@ impl<'a> RoleBuilder<'a> { labels } } + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use serde_json::json; + use stackable_operator::{ + commons::{ + affinity::StackableAffinity, product_image_selection::ProductImage, + resources::Resources, + }, + k8s_openapi::api::core::v1::PodTemplateSpec, + role_utils::GenericRoleConfig, + }; + + use super::RoleBuilder; + use crate::{ + controller::{ + ContextNames, OpenSearchRoleGroupConfig, ValidatedCluster, ValidatedOpenSearchConfig, + }, + crd::{NodeRoles, v1alpha1}, + framework::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, + builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, + }, + }; + + fn context_names() -> ContextNames { + ContextNames { + product_name: ProductName::from_str_unsafe("opensearch"), + operator_name: OperatorName::from_str_unsafe("opensearch.stackable.tech"), + controller_name: ControllerName::from_str_unsafe("opensearchcluster"), + } + } + + fn role_builder<'a>(context_names: &'a ContextNames) -> RoleBuilder<'a> { + let image: ProductImage = serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage"); + + let role_group_config = OpenSearchRoleGroupConfig { + replicas: 1, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity::default(), + node_roles: NodeRoles(vec![ + v1alpha1::NodeRole::ClusterManager, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::RemoteClusterClient, + ]), + resources: Resources::default(), + termination_grace_period_seconds: 30, + listener_class: "cluster-internal".to_string(), + }, + config_overrides: HashMap::default(), + env_overrides: EnvVarSet::default(), + cli_overrides: BTreeMap::default(), + pod_overrides: PodTemplateSpec::default(), + product_specific_common_config: GenericProductSpecificCommonConfig::default(), + }; + + let cluster = ValidatedCluster::new( + image.clone(), + ProductVersion::from_str_unsafe(image.product_version()), + ClusterName::from_str_unsafe("my-opensearch-cluster"), + "default".to_owned(), + "0b1e30e6-326e-4c1a-868d-ad6598b49e8b".to_owned(), + GenericRoleConfig::default(), + [( + RoleGroupName::from_str_unsafe("default"), + role_group_config.clone(), + )] + .into(), + ); + + RoleBuilder::new(cluster, context_names) + } + + #[test] + fn test_build_service_account() { + let context_names = context_names(); + let role_builder = role_builder(&context_names); + + let service_account = serde_json::to_value(role_builder.build_service_account()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "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.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-serviceaccount", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + } + }), + service_account + ); + } + + #[test] + fn test_build_role_binding() { + let context_names = context_names(); + let role_builder = role_builder(&context_names); + + let role_binding = serde_json::to_value(role_builder.build_role_binding()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "RoleBinding", + "metadata": { + "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.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-rolebinding", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "opensearch-clusterrole" + }, + "subjects": [ + { + "apiGroup": "", + "kind": "ServiceAccount", + "name": "my-opensearch-cluster-serviceaccount", + "namespace": "default" + } + ] + }), + role_binding + ); + } + + #[test] + fn test_build_cluster_manager_service() { + let context_names = context_names(); + let role_builder = role_builder(&context_names); + + let cluster_manager_service = + serde_json::to_value(role_builder.build_cluster_manager_service()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "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.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "spec": { + "clusterIP": "None", + "ports": [ + { + "name": "http", + "port": 9200 + }, + { + "name": "transport", + "port": 9300 + } + ], + "publishNotReadyAddresses": true, + "selector": { + "app.kubernetes.io/component": "nodes", + "app.kubernetes.io/instance": "my-opensearch-cluster", + "app.kubernetes.io/name": "opensearch", + "stackable.tech/opensearch-role.cluster_manager": "true" + }, + "type": "ClusterIP" + } + }), + cluster_manager_service + ); + } + + #[test] + fn test_build_pdb() { + let context_names = context_names(); + let role_builder = role_builder(&context_names); + + let pdb = serde_json::to_value(role_builder.build_pdb()).expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "policy/v1", + "kind": "PodDisruptionBudget", + "metadata": { + "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" + }, + "name": "my-opensearch-cluster-nodes", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "spec": { + "maxUnavailable": 1, + "selector": { + "matchLabels": { + "app.kubernetes.io/component": "nodes", + "app.kubernetes.io/instance": "my-opensearch-cluster", + "app.kubernetes.io/name": "opensearch" + } + } + } + }), + pdb + ); + } +} 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 55f488b..51e70c1 100644 --- a/rust/operator-binary/src/controller/build/role_group_builder.rs +++ b/rust/operator-binary/src/controller/build/role_group_builder.rs @@ -483,27 +483,504 @@ impl<'a> RoleGroupBuilder<'a> { #[cfg(test)] mod tests { + use std::collections::{BTreeMap, HashMap}; + + use serde_json::json; + use stackable_operator::{ + commons::{ + affinity::StackableAffinity, product_image_selection::ProductImage, + resources::Resources, + }, + k8s_openapi::api::core::v1::PodTemplateSpec, + role_utils::GenericRoleConfig, + }; use strum::IntoEnumIterator; use super::RoleGroupBuilder; - use crate::crd::v1alpha1; + use crate::{ + controller::{ + ContextNames, OpenSearchRoleGroupConfig, ValidatedCluster, ValidatedOpenSearchConfig, + }, + crd::{NodeRoles, v1alpha1}, + framework::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, + builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, + }, + }; + + fn context_names() -> ContextNames { + ContextNames { + product_name: ProductName::from_str_unsafe("opensearch"), + operator_name: OperatorName::from_str_unsafe("opensearch.stackable.tech"), + controller_name: ControllerName::from_str_unsafe("opensearchcluster"), + } + } + + fn validated_cluster() -> ValidatedCluster { + let image: ProductImage = serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage"); + + let role_group_config = OpenSearchRoleGroupConfig { + replicas: 1, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity::default(), + node_roles: NodeRoles(vec![ + v1alpha1::NodeRole::ClusterManager, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::RemoteClusterClient, + ]), + resources: Resources::default(), + termination_grace_period_seconds: 30, + listener_class: "cluster-internal".to_string(), + }, + config_overrides: HashMap::default(), + env_overrides: EnvVarSet::default(), + cli_overrides: BTreeMap::default(), + pod_overrides: PodTemplateSpec::default(), + product_specific_common_config: GenericProductSpecificCommonConfig::default(), + }; + + ValidatedCluster::new( + image.clone(), + ProductVersion::from_str_unsafe(image.product_version()), + ClusterName::from_str_unsafe("my-opensearch-cluster"), + "default".to_owned(), + "0b1e30e6-326e-4c1a-868d-ad6598b49e8b".to_owned(), + GenericRoleConfig::default(), + [( + RoleGroupName::from_str_unsafe("default"), + role_group_config.clone(), + )] + .into(), + ) + } + + fn role_group_builder<'a>(context_names: &'a ContextNames) -> RoleGroupBuilder<'a> { + let cluster = validated_cluster(); + + let (role_group_name, role_group_config) = cluster + .role_group_configs + .first_key_value() + .expect("should be set"); + + let role_group_name = role_group_name.to_owned(); + let role_group_config = role_group_config.to_owned(); + + RoleGroupBuilder::new( + "my-opensearch-cluster-serviceaccount".to_owned(), + cluster, + role_group_name, + role_group_config, + context_names, + "my-opensearch-cluster".to_owned(), + ) + } + + #[test] + fn test_build_config_map() { + let context_names = context_names(); + let role_group_builder = role_group_builder(&context_names); + + let config_map = serde_json::to_value(role_group_builder.build_config_map()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "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/role-group": "default", + "app.kubernetes.io/version": "3.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-nodes-default", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "data": { + "opensearch.yml": concat!( + "cluster.name: \"my-opensearch-cluster\"\n", + "discovery.type: \"single-node\"\n", + "network.host: \"0.0.0.0\"\n", + "plugins.security.nodes_dn: [\"CN=generated certificate for pod\"]" + ) + } + }), + config_map + ); + } + + #[test] + fn test_build_stateful_set() { + let context_names = context_names(); + let role_group_builder = role_group_builder(&context_names); + + let stateful_set = serde_json::to_value(role_group_builder.build_stateful_set()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": { + "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/role-group": "default", + "app.kubernetes.io/version": "3.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-nodes-default", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "spec": { + "podManagementPolicy": "Parallel", + "replicas": 1, + "selector": { + "matchLabels": { + "app.kubernetes.io/component": "nodes", + "app.kubernetes.io/instance": "my-opensearch-cluster", + "app.kubernetes.io/name": "opensearch", + "app.kubernetes.io/role-group": "default" + } + }, + "serviceName": "my-opensearch-cluster-nodes-default-headless", + "template": { + "metadata": { + "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/role-group": "default", + "app.kubernetes.io/version": "3.1.0", + "stackable.tech/opensearch-role.cluster_manager": "true", + "stackable.tech/opensearch-role.data": "true", + "stackable.tech/opensearch-role.ingest": "true", + "stackable.tech/opensearch-role.remote_cluster_client": "true", + "stackable.tech/vendor": "Stackable" + } + }, + "spec": { + "affinity": {}, + "containers": [ + { + "args": [], + "command": [ + "/stackable/opensearch/opensearch-docker-entrypoint.sh" + ], + "env": [ + { + "name": "cluster.initial_cluster_manager_nodes", + "value": "" + }, + { + "name": "discovery.seed_hosts", + "value": "my-opensearch-cluster" + }, + { + "name": "node.name", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.name" + } + } + }, + { + "name": "node.roles", + "value": "cluster_manager,data,ingest,remote_cluster_client" + } + ], + "image": "oci.stackable.tech/sdp/opensearch:3.1.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "opensearch", + "ports": [ + { + "containerPort": 9200, + "name": "http" + }, + { + "containerPort": 9300, + "name": "transport" + } + ], + "readinessProbe": { + "failureThreshold": 3, + "periodSeconds": 5, + "tcpSocket": { + "port": "http" + }, + "timeoutSeconds": 3 + }, + "resources": {}, + "startupProbe": { + "failureThreshold": 30, + "initialDelaySeconds": 5, + "periodSeconds": 10, + "tcpSocket": { + "port": "http" + }, + "timeoutSeconds": 3 + }, + "volumeMounts": [ + { + "mountPath": "/stackable/opensearch/config/opensearch.yml", + "name": "config", + "readOnly": true, + "subPath": "opensearch.yml" + }, + { + "mountPath": "/stackable/opensearch/data", + "name": "data" + }, + { + "mountPath": "/stackable/listener", + "name": "listener" + } + ] + } + ], + "securityContext": { + "fsGroup": 1000 + }, + "serviceAccountName": "my-opensearch-cluster-serviceaccount", + "terminationGracePeriodSeconds": 30, + "volumes": [ + { + "configMap": { + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "config" + } + ] + } + }, + "volumeClaimTemplates": [ + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "name": "data" + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": {} + } + } + }, + { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "annotations": { + "listeners.stackable.tech/listener-name": "my-opensearch-cluster-nodes-default" + }, + "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/role-group": "default", + "app.kubernetes.io/version": "3.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "listener" + }, + "spec": { + "accessModes": [ + "ReadWriteMany" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "listeners.stackable.tech" + } + } + ] + } + }), + stateful_set + ); + } + + #[test] + fn test_build_cluster_manager_labels() { + let cluster_manager_labels = + RoleGroupBuilder::cluster_manager_labels(&validated_cluster(), &context_names()); + + assert_eq!( + BTreeMap::from( + [ + ("app.kubernetes.io/component", "nodes"), + ("app.kubernetes.io/instance", "my-opensearch-cluster"), + ("app.kubernetes.io/name", "opensearch"), + ("stackable.tech/opensearch-role.cluster_manager", "true") + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + ), + cluster_manager_labels.into() + ); + } + + #[test] + fn test_build_headless_service() { + let context_names = context_names(); + let role_group_builder = role_group_builder(&context_names); + + let headless_service = serde_json::to_value(role_group_builder.build_headless_service()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "annotations": { + "prometheus.io/path": "/_prometheus/metrics", + "prometheus.io/port": "9200", + "prometheus.io/scheme": "http", + "prometheus.io/scrape": "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/role-group": "default", + "app.kubernetes.io/version": "3.1.0", + "prometheus.io/scrape": "true", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-nodes-default-headless", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "spec": { + "clusterIP": "None", + "ports": [ + { + "name": "http", + "port": 9200 + }, + { + "name": "transport", + "port": 9300 + } + ], + "publishNotReadyAddresses": true, + "selector": { + "app.kubernetes.io/component": "nodes", + "app.kubernetes.io/instance": "my-opensearch-cluster", + "app.kubernetes.io/name": "opensearch", + "app.kubernetes.io/role-group": "default" + }, + "type": "ClusterIP" + } + }), + headless_service + ); + } + + #[test] + fn test_build_listener() { + let context_names = context_names(); + let role_group_builder = role_group_builder(&context_names); + + let listener = serde_json::to_value(role_group_builder.build_listener()) + .expect("should be serializable"); + + assert_eq!( + json!({ + "apiVersion": "listeners.stackable.tech/v1alpha1", + "kind": "Listener", + "metadata": { + "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/role-group": "default", + "app.kubernetes.io/version": "3.1.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-nodes-default", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "spec": { + "className": "cluster-internal", + "extraPodSelectorLabels": {}, + "ports": [ + { + "name": "http", + "port": 9200, + "protocol": "TCP" + } + ], + "publishNotReadyAddresses": null + } + }), + listener + ); + } #[test] fn test_build_node_role_label() { + // Test that the function does not panic on all possible inputs for node_role in v1alpha1::NodeRole::iter() { RoleGroupBuilder::build_node_role_label(&node_role); } } #[test] - pub fn test_prometheus_labels() { - // Test that the function does not panic - RoleGroupBuilder::prometheus_labels(); - } - - #[test] - pub fn test_prometheus_annotations() { - // Test that the function does not panic on all possible execution paths + fn test_prometheus_annotations() { + // Test that the function does not panic on all possible inputs RoleGroupBuilder::prometheus_annotations(false); RoleGroupBuilder::prometheus_annotations(true); } diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index 00b3f60..98bf027 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -87,16 +87,15 @@ pub fn validate( role_group_configs.insert(role_group_name, validated_role_group_config); } - Ok(ValidatedCluster { - metadata: cluster.meta().to_owned(), - image: cluster.spec.image.clone(), + Ok(ValidatedCluster::new( + cluster.spec.image.clone(), product_version, - name: cluster_name, + cluster_name, namespace, uid, - role_config: cluster.spec.nodes.role_config.clone(), + cluster.spec.nodes.role_config.clone(), role_group_configs, - }) + )) } fn validate_role_group_config( @@ -151,3 +150,430 @@ fn validate_role_group_config( product_specific_common_config: merged_role_group.config.product_specific_common_config, }) } + +#[cfg(test)] +mod tests { + use stackable_operator::{ + commons::{ + affinity::StackableAffinity, + cluster_operation::ClusterOperation, + resources::{CpuLimits, MemoryLimits, PvcConfig, Resources}, + }, + k8s_openapi::{ + api::core::v1::{ + PodAffinityTerm, PodAntiAffinity, PodTemplateSpec, WeightedPodAffinityTerm, + }, + apimachinery::pkg::{api::resource::Quantity, apis::meta::v1::LabelSelector}, + }, + kube::api::ObjectMeta, + role_utils::{CommonConfiguration, GenericRoleConfig, Role, RoleGroup}, + time::Duration, + }; + + use super::{ErrorDiscriminants, validate}; + use crate::{ + controller::{ContextNames, ValidatedCluster, ValidatedOpenSearchConfig}, + crd::{ + NodeRoles, + v1alpha1::{self, OpenSearchClusterSpec, OpenSearchConfigFragment, StorageConfig}, + }, + framework::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, + builder::pod::container::{EnvVarName, EnvVarSet}, + role_utils::{GenericProductSpecificCommonConfig, RoleGroupConfig}, + }, + }; + + #[test] + fn test_validate_ok() { + let result = validate(&context_names(), &cluster()); + + assert_eq!( + Some(ValidatedCluster::new( + serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage structure"), + ProductVersion::from_str_unsafe("3.1.0"), + ClusterName::from_str_unsafe("my-opensearch"), + "default".to_owned(), + "e6ac237d-a6d4-43a1-8135-f36506110912".to_owned(), + GenericRoleConfig::default(), + [( + RoleGroupName::from_str_unsafe("default"), + RoleGroupConfig { + replicas: 3, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity { + pod_anti_affinity: Some(PodAntiAffinity { + preferred_during_scheduling_ignored_during_execution: Some( + [WeightedPodAffinityTerm { + pod_affinity_term: PodAffinityTerm { + label_selector: Some(LabelSelector { + match_labels: Some( + [ + ( + "app.kubernetes.io/component" + .to_owned(), + "nodes".to_owned() + ), + ( + "app.kubernetes.io/instance" + .to_owned(), + "my-opensearch".to_owned() + ), + ( + "app.kubernetes.io/name".to_owned(), + "opensearch".to_owned() + ) + ] + .into() + ), + ..LabelSelector::default() + }), + topology_key: "kubernetes.io/hostname".to_owned(), + ..PodAffinityTerm::default() + }, + weight: 1 + }] + .into() + ), + ..PodAntiAffinity::default() + }), + ..StackableAffinity::default() + }, + node_roles: NodeRoles( + [ + v1alpha1::NodeRole::ClusterManager, + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient + ] + .into() + ), + resources: Resources { + memory: MemoryLimits { + limit: Some(Quantity("2Gi".to_owned())), + ..MemoryLimits::default() + }, + cpu: CpuLimits { + min: Some(Quantity("1".to_owned())), + max: Some(Quantity("4".to_owned())) + }, + storage: StorageConfig { + data: PvcConfig { + capacity: Some(Quantity("8Gi".to_owned())), + ..PvcConfig::default() + } + } + }, + termination_grace_period_seconds: 300, + listener_class: "listener-class-from-role-group-level".to_owned(), + }, + config_overrides: [( + "opensearch.yml".to_owned(), + [ + ("setting1".to_owned(), "value from role level".to_owned()), + ( + "setting2".to_owned(), + "value from role-group level".to_owned() + ), + ( + "setting3".to_owned(), + "value from role-group level".to_owned() + ), + ] + .into() + )] + .into(), + env_overrides: EnvVarSet::new().with_values([ + ( + EnvVarName::from_str_unsafe("ENV1"), + "value from role level".to_owned() + ), + ( + EnvVarName::from_str_unsafe("ENV2"), + "value from role-group level".to_owned() + ), + ( + EnvVarName::from_str_unsafe("ENV3"), + "value from role-group level".to_owned() + ) + ]), + cli_overrides: [ + ("--param1".to_owned(), "value from role level".to_owned()), + ( + "--param2".to_owned(), + "value from role-group level".to_owned() + ), + ( + "--param3".to_owned(), + "value from role-group level".to_owned() + ) + ] + .into(), + pod_overrides: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some( + [ + ("label1".to_owned(), "value from role level".to_owned()), + ( + "label2".to_owned(), + "value from role-group level".to_owned() + ), + ( + "label3".to_owned(), + "value from role-group level".to_owned() + ) + ] + .into() + ), + ..ObjectMeta::default() + }), + ..PodTemplateSpec::default() + }, + product_specific_common_config: GenericProductSpecificCommonConfig::default( + ) + } + )] + .into(), + )), + result.ok() + ); + } + + #[test] + fn test_validate_err_get_cluster_name() { + test_validate_err( + |cluster| cluster.metadata.name = None, + ErrorDiscriminants::GetClusterName, + ); + } + + #[test] + fn test_validate_err_parse_cluster_name() { + test_validate_err( + |cluster| cluster.metadata.name = Some("invalid cluster name".to_owned()), + ErrorDiscriminants::ParseClusterName, + ); + } + + #[test] + fn test_validate_err_get_cluster_namespace() { + test_validate_err( + |cluster| cluster.metadata.namespace = None, + ErrorDiscriminants::GetClusterNamespace, + ); + } + + #[test] + fn test_validate_err_get_cluster_uid() { + test_validate_err( + |cluster| cluster.metadata.uid = None, + ErrorDiscriminants::GetClusterUid, + ); + } + + #[test] + fn test_validate_err_parse_product_version() { + test_validate_err( + |cluster| { + cluster.spec.image = + serde_json::from_str(r#"{"productVersion": "invalid product version"}"#) + .expect("should be a valid ProductImage structure") + }, + ErrorDiscriminants::ParseProductVersion, + ); + } + + #[test] + fn test_validate_err_parse_role_group_name() { + test_validate_err( + |cluster| { + let role_group = cluster + .spec + .nodes + .role_groups + .remove("default") + .expect("should be set"); + cluster + .spec + .nodes + .role_groups + .insert("invalid role-group name".to_owned(), role_group); + }, + ErrorDiscriminants::ParseRoleGroupName, + ); + } + + #[test] + fn test_validate_err_termination_grace_period_too_long() { + test_validate_err( + |cluster| { + cluster.spec.nodes.config.config.graceful_shutdown_timeout = + Some(Duration::from_secs(u64::MAX)) + }, + ErrorDiscriminants::TerminationGracePeriodTooLong, + ); + } + + #[test] + fn test_validate_err_parse_environment_variable() { + test_validate_err( + |cluster| { + cluster.spec.nodes.config.env_overrides = [( + "INVALID_ENVIRONMENT_VARIABLE_WITH_=".to_owned(), + "value".to_owned(), + )] + .into() + }, + ErrorDiscriminants::ParseEnvironmentVariable, + ); + } + + fn test_validate_err( + f: fn(&mut v1alpha1::OpenSearchCluster) -> (), + expected_err: ErrorDiscriminants, + ) { + let mut cluster = cluster(); + f(&mut cluster); + + let result = validate(&context_names(), &cluster); + + assert_eq!(Err(expected_err), result.map_err(ErrorDiscriminants::from)); + } + + fn context_names() -> ContextNames { + ContextNames { + product_name: ProductName::from_str_unsafe("opensearch"), + operator_name: OperatorName::from_str_unsafe("opensearch.stackable.tech"), + controller_name: ControllerName::from_str_unsafe("opensearchcluster"), + } + } + + fn cluster() -> v1alpha1::OpenSearchCluster { + v1alpha1::OpenSearchCluster { + metadata: ObjectMeta { + name: Some("my-opensearch".to_owned()), + namespace: Some("default".to_owned()), + uid: Some("e6ac237d-a6d4-43a1-8135-f36506110912".to_owned()), + ..ObjectMeta::default() + }, + spec: OpenSearchClusterSpec { + image: serde_json::from_str(r#"{"productVersion": "3.1.0"}"#) + .expect("should be a valid ProductImage structure"), + cluster_operation: ClusterOperation::default(), + nodes: Role { + config: CommonConfiguration { + config: OpenSearchConfigFragment { + graceful_shutdown_timeout: Some(Duration::from_minutes_unchecked(5)), + listener_class: Some("listener-class-from-role-level".to_owned()), + ..OpenSearchConfigFragment::default() + }, + config_overrides: [( + "opensearch.yml".to_owned(), + [ + ("setting1".to_owned(), "value from role level".to_owned()), + ("setting2".to_owned(), "value from role level".to_owned()), + ] + .into(), + )] + .into(), + env_overrides: [ + ("ENV1".to_owned(), "value from role level".to_owned()), + ("ENV2".to_owned(), "value from role level".to_owned()), + ] + .into(), + cli_overrides: [ + ("--param1".to_owned(), "value from role level".to_owned()), + ("--param2".to_owned(), "value from role level".to_owned()), + ] + .into(), + pod_overrides: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some( + [ + ("label1".to_owned(), "value from role level".to_owned()), + ("label2".to_owned(), "value from role level".to_owned()), + ] + .into(), + ), + ..ObjectMeta::default() + }), + ..PodTemplateSpec::default() + }, + product_specific_common_config: GenericProductSpecificCommonConfig::default( + ), + }, + role_config: GenericRoleConfig::default(), + role_groups: [( + "default".to_owned(), + RoleGroup { + config: CommonConfiguration { + config: OpenSearchConfigFragment { + listener_class: Some( + "listener-class-from-role-group-level".to_owned(), + ), + ..OpenSearchConfigFragment::default() + }, + config_overrides: [( + "opensearch.yml".to_owned(), + [ + ( + "setting2".to_owned(), + "value from role-group level".to_owned(), + ), + ( + "setting3".to_owned(), + "value from role-group level".to_owned(), + ), + ] + .into(), + )] + .into(), + env_overrides: [ + ("ENV2".to_owned(), "value from role-group level".to_owned()), + ("ENV3".to_owned(), "value from role-group level".to_owned()), + ] + .into(), + cli_overrides: [ + ( + "--param2".to_owned(), + "value from role-group level".to_owned(), + ), + ( + "--param3".to_owned(), + "value from role-group level".to_owned(), + ), + ] + .into(), + pod_overrides: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some( + [ + ( + "label2".to_owned(), + "value from role-group level".to_owned(), + ), + ( + "label3".to_owned(), + "value from role-group level".to_owned(), + ), + ] + .into(), + ), + ..ObjectMeta::default() + }), + ..PodTemplateSpec::default() + }, + product_specific_common_config: + GenericProductSpecificCommonConfig::default(), + }, + replicas: Some(3), + }, + )] + .into(), + }, + }, + status: None, + } + } +} diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 9b68ed2..307687b 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -250,7 +250,7 @@ impl v1alpha1::OpenSearchConfig { } #[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)] -pub struct NodeRoles(Vec); +pub struct NodeRoles(pub Vec); impl NodeRoles { pub fn contains(&self, node_role: &v1alpha1::NodeRole) -> bool { diff --git a/rust/operator-binary/src/framework.rs b/rust/operator-binary/src/framework.rs index 42d38dc..c4ed7ba 100644 --- a/rust/operator-binary/src/framework.rs +++ b/rust/operator-binary/src/framework.rs @@ -62,8 +62,8 @@ pub trait IsLabelValue { /// Restricted string type with attributes like maximum length. macro_rules! attributed_string_type { - ($name:ident, $description:literal $(, $attribute:tt)*) => { - #[doc = $description] + ($name:ident, $description:literal, $example:literal $(, $attribute:tt)*) => { + #[doc = concat!($description, ", e.g. \"", $example, "\"")] #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct $name(String); @@ -84,6 +84,19 @@ macro_rules! attributed_string_type { } } + #[cfg(test)] + impl $name { + #[allow(dead_code)] + pub fn from_str_unsafe(s: &str) -> Self { + FromStr::from_str(s).expect("should be a valid {name}") + } + + // A dead_code warning is emitted if there is no unit test that calls this function. + pub fn test_example() { + Self::from_str_unsafe($example); + } + } + $(attributed_string_type!(@trait_impl $name, $attribute);)* }; (@from_str $name:ident, $s:expr, (max_length = $max_length:expr)) => { @@ -126,7 +139,8 @@ macro_rules! attributed_string_type { attributed_string_type! { ProductName, - "The name of a product, e.g. \"opensearch\"", + "The name of a product", + "opensearch", // A suffix is added to produce a label value. An according compile-time check ensures that // max_length cannot be set higher. (max_length = 54), @@ -134,13 +148,15 @@ attributed_string_type! { } attributed_string_type! { ProductVersion, - "The version of a product, e.g. \"3.0.0\"", + "The version of a product", + "3.1.0", (max_length = MAX_LABEL_VALUE_LENGTH), is_valid_label_value } attributed_string_type! { ClusterName, - "The name of a cluster/stacklet, e.g. \"my-opensearch-cluster\"", + "The name of a cluster/stacklet", + "my-opensearch-cluster", // Suffixes are added to produce a resource names. According compile-time check ensures that // max_length cannot be set higher. (max_length = 24), @@ -149,26 +165,30 @@ attributed_string_type! { } attributed_string_type! { ControllerName, - "The name of a controller in an operator, e.g. \"opensearchcluster\"", + "The name of a controller in an operator", + "opensearchcluster", (max_length = MAX_LABEL_VALUE_LENGTH), is_valid_label_value } attributed_string_type! { OperatorName, - "The name of an operator, e.g. \"opensearch.stackable.tech\"", + "The name of an operator", + "opensearch.stackable.tech", (max_length = MAX_LABEL_VALUE_LENGTH), is_valid_label_value } attributed_string_type! { RoleGroupName, - "The name of a role-group name, e.g. \"cluster-manager\"", + "The name of a role-group name", + "cluster-manager", (max_length = 16), is_object_name, is_valid_label_value } attributed_string_type! { RoleName, - "The name of a role name, e.g. \"nodes\"", + "The name of a role name", + "nodes", (max_length = 10), is_object_name, is_valid_label_value @@ -178,17 +198,59 @@ attributed_string_type! { mod tests { use std::str::FromStr; - use crate::framework::ProductName; + use super::{ + ClusterName, ControllerName, OperatorName, ProductVersion, RoleGroupName, RoleName, + }; + use crate::framework::{HasObjectName, IsLabelValue, ProductName}; + + #[test] + fn test_attributed_string_type_examples() { + ProductName::test_example(); + ProductVersion::test_example(); + ClusterName::test_example(); + ControllerName::test_example(); + OperatorName::test_example(); + RoleGroupName::test_example(); + RoleName::test_example(); + } #[test] - fn test_object_name_constraints() { - assert!(ProductName::from_str("valid-role-group-name").is_ok()); - assert!(ProductName::from_str("invalid-character: /").is_err()); - assert!( - ProductName::from_str( - "too-long-123456789012345678901234567890123456789012345678901234567890" - ) - .is_err() + fn test_attributed_string_type_fmt() { + assert_eq!( + "my-cluster-name".to_owned(), + format!("{}", ClusterName::from_str_unsafe("my-cluster-name")) + ); + } + + #[test] + fn test_attributed_string_type_max_length() { + assert_eq!(24, ClusterName::MAX_LENGTH); + + assert!(ClusterName::from_str(&"a".repeat(ClusterName::MAX_LENGTH)).is_ok()); + assert!(ClusterName::from_str(&"a".repeat(ClusterName::MAX_LENGTH + 1)).is_err()); + } + + #[test] + fn test_attributed_string_type_is_object_name() { + assert_eq!( + "valid-object.name.123", + ClusterName::from_str_unsafe("valid-object.name.123").to_object_name() + ); + // A valid object name contains only lowercase characters. + assert!(ClusterName::from_str("InvalidObjectName").is_err()); + } + + #[test] + fn test_attributed_string_type_is_valid_label_value() { + // Use a struct implementing the trait `IsLabelValue` but not `HasObjectName` because + // object names are proper subsets of label values and the test should not already fail on + // the object check. + + assert_eq!( + "valid-label_value.123", + ProductName::from_str_unsafe("valid-label_value.123").to_label_value() ); + // A valid label value must end with an alphanumeric character. + assert!(ProductName::from_str("invalid-label-value-").is_err()); } } diff --git a/rust/operator-binary/src/framework/builder/meta.rs b/rust/operator-binary/src/framework/builder/meta.rs index 56b5279..6ed6b53 100644 --- a/rust/operator-binary/src/framework/builder/meta.rs +++ b/rust/operator-binary/src/framework/builder/meta.rs @@ -3,11 +3,11 @@ use stackable_operator::{ k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference, kube::Resource, }; -use crate::framework::HasUid; +use crate::framework::{HasObjectName, HasUid}; /// Infallible variant of `stackable_operator::builder::meta::ObjectMetaBuilder::ownerreference_from_resource` pub fn ownerreference_from_resource( - resource: &(impl Resource + HasUid), + resource: &(impl Resource + HasObjectName + HasUid), block_owner_deletion: Option, controller: Option, ) -> OwnerReference { @@ -19,5 +19,100 @@ pub fn ownerreference_from_resource( .block_owner_deletion_opt(block_owner_deletion) .controller_opt(controller) .build() - .expect("api_version, kind, name and uid should be set") + .expect( + "OwnerReference should be created because the resource has an api_version, kind, name \ + and uid.", + ) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use stackable_operator::{ + k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference}, + kube::Resource, + }; + + use crate::framework::{HasObjectName, HasUid, builder::meta::ownerreference_from_resource}; + + struct Cluster { + object_meta: ObjectMeta, + } + + impl Cluster { + fn new() -> Self { + Cluster { + object_meta: ObjectMeta { + name: Some("cluster-name".to_owned()), + uid: Some("a6b89911-d48e-4328-88d6-b9251226583d".to_owned()), + ..ObjectMeta::default() + }, + } + } + } + + impl Resource for Cluster { + type DynamicType = (); + type Scope = (); + + fn kind(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("kind") + } + + fn group(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("group") + } + + fn version(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("version") + } + + fn plural(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("plural") + } + + fn meta(&self) -> &ObjectMeta { + &self.object_meta + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.object_meta + } + } + + impl HasObjectName for Cluster { + fn to_object_name(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + impl HasUid for Cluster { + fn to_uid(&self) -> String { + self.object_meta + .uid + .clone() + .expect("should be set in Cluster::new") + } + } + + #[test] + fn test_ownerreference_from_resource() { + let actual_owner_reference = + ownerreference_from_resource(&Cluster::new(), Some(true), Some(true)); + + let expected_owner_reference = OwnerReference { + api_version: "group/version".to_owned(), + block_owner_deletion: Some(true), + controller: Some(true), + kind: "kind".to_owned(), + name: "cluster-name".to_owned(), + uid: "a6b89911-d48e-4328-88d6-b9251226583d".to_owned(), + }; + + assert_eq!(expected_owner_reference, actual_owner_reference); + } } diff --git a/rust/operator-binary/src/framework/builder/pdb.rs b/rust/operator-binary/src/framework/builder/pdb.rs index 0f3be90..3721040 100644 --- a/rust/operator-binary/src/framework/builder/pdb.rs +++ b/rust/operator-binary/src/framework/builder/pdb.rs @@ -4,10 +4,12 @@ use stackable_operator::{ kube::{Resource, api::ObjectMeta}, }; -use crate::framework::{ControllerName, IsLabelValue, OperatorName, ProductName, RoleName}; +use crate::framework::{ + ControllerName, HasObjectName, HasUid, IsLabelValue, OperatorName, ProductName, RoleName, +}; pub fn pod_disruption_budget_builder_with_role( - owner: &(impl Resource + IsLabelValue), + owner: &(impl Resource + HasObjectName + HasUid + IsLabelValue), product_name: &ProductName, role_name: &RoleName, operator_name: &OperatorName, @@ -20,5 +22,158 @@ pub fn pod_disruption_budget_builder_with_role( &operator_name.to_label_value(), &controller_name.to_label_value(), ) - .expect("Labels should be created because all given parameters produce valid label values") + .expect( + "PodDisruptionBudgetBuilder should be created because the owner has an object name and UID \ + and all given parameters produce valid label values.", + ) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use stackable_operator::{ + k8s_openapi::{ + api::policy::v1::{PodDisruptionBudget, PodDisruptionBudgetSpec}, + apimachinery::pkg::{ + apis::meta::v1::{LabelSelector, ObjectMeta, OwnerReference}, + util::intstr::IntOrString, + }, + }, + kube::Resource, + }; + + use crate::framework::{ + ControllerName, HasObjectName, HasUid, IsLabelValue, OperatorName, ProductName, RoleName, + builder::pdb::pod_disruption_budget_builder_with_role, + }; + + struct Cluster { + object_meta: ObjectMeta, + } + + impl Cluster { + fn new() -> Self { + Cluster { + object_meta: ObjectMeta { + name: Some("cluster-name".to_owned()), + uid: Some("a6b89911-d48e-4328-88d6-b9251226583d".to_owned()), + ..ObjectMeta::default() + }, + } + } + } + + impl Resource for Cluster { + type DynamicType = (); + type Scope = (); + + fn kind(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("kind") + } + + fn group(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("group") + } + + fn version(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("version") + } + + fn plural(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("plural") + } + + fn meta(&self) -> &ObjectMeta { + &self.object_meta + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.object_meta + } + } + + impl HasObjectName for Cluster { + fn to_object_name(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + impl HasUid for Cluster { + fn to_uid(&self) -> String { + self.object_meta + .uid + .clone() + .expect("should be set in Cluster::new") + } + } + + impl IsLabelValue for Cluster { + fn to_label_value(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + #[test] + fn test_pod_disruption_budget_builder_with_role() { + let actual_pdb = pod_disruption_budget_builder_with_role( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &RoleName::from_str_unsafe("my-role"), + &OperatorName::from_str_unsafe("my-operator"), + &ControllerName::from_str_unsafe("my-controller"), + ) + .with_max_unavailable(2) + .build(); + + let expected_pdb = PodDisruptionBudget { + metadata: ObjectMeta { + labels: Some( + [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/managed-by", "my-operator_my-controller"), + ("app.kubernetes.io/name", "my-product"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(), + ), + name: Some("cluster-name-my-role".to_owned()), + owner_references: Some(vec![OwnerReference { + api_version: "group/version".to_owned(), + controller: Some(true), + kind: "kind".to_owned(), + name: "cluster-name".to_owned(), + uid: "a6b89911-d48e-4328-88d6-b9251226583d".to_owned(), + ..OwnerReference::default() + }]), + ..ObjectMeta::default() + }, + spec: Some(PodDisruptionBudgetSpec { + max_unavailable: Some(IntOrString::Int(2)), + selector: Some(LabelSelector { + match_labels: Some( + [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/name", "my-product"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(), + ), + ..LabelSelector::default() + }), + ..PodDisruptionBudgetSpec::default() + }), + ..PodDisruptionBudget::default() + }; + + assert_eq!(expected_pdb, actual_pdb); + } } diff --git a/rust/operator-binary/src/framework/builder/pod/container.rs b/rust/operator-binary/src/framework/builder/pod/container.rs index 67ee112..4914f79 100644 --- a/rust/operator-binary/src/framework/builder/pod/container.rs +++ b/rust/operator-binary/src/framework/builder/pod/container.rs @@ -11,7 +11,8 @@ use strum::{EnumDiscriminants, IntoStaticStr}; #[strum_discriminants(derive(IntoStaticStr))] pub enum Error { #[snafu(display( - "invalid environment variable name: a valid environment variable name must consist only of printable ASCII characters other than '='" + "invalid environment variable name: a valid environment variable name must not be empty \ + and must consist only of printable ASCII characters other than '='" ))] ParseEnvVarName { env_var_name: String }, } @@ -37,9 +38,7 @@ impl FromStr for EnvVarName { fn from_str(s: &str) -> Result { // The length of the environment variable names seems not to be restricted. - if s.find(|c: char| !c.is_ascii_graphic() || c == '=') - .is_none() - { + if !s.is_empty() && s.chars().all(|c| matches!(c, ' '..='<' | '>'..='~')) { Ok(Self(s.to_owned())) } else { Err(Error::ParseEnvVarName { @@ -126,3 +125,158 @@ impl From for Vec { value.0.values().cloned().collect() } } + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use stackable_operator::{ + builder::pod::container::FieldPathEnvVar, + k8s_openapi::api::core::v1::{EnvVar, EnvVarSource, ObjectFieldSelector}, + }; + + use super::{EnvVarName, EnvVarSet}; + + #[test] + fn test_envvarname_fromstr() { + // actually accepted by Kubernetes + assert!(EnvVarName::from_str(" !\"#$%&'()*+,-./0123456789:;<>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~").is_ok()); + + // empty string + assert!(EnvVarName::from_str("").is_err()); + // non-printable ASCII characters + assert!(EnvVarName::from_str("\n").is_err()); + assert!(EnvVarName::from_str("€").is_err()); + // equals sign + assert!(EnvVarName::from_str("=").is_err()); + } + + #[test] + fn test_envvarname_format() { + assert_eq!( + "TEST".to_owned(), + format!("{}", EnvVarName::from_str_unsafe("TEST")) + ); + } + + #[test] + fn test_envvarset_merge() { + let env_var_set1 = EnvVarSet::new().with_values([ + ( + EnvVarName::from_str_unsafe("ENV1"), + "value1 from env_var_set1", + ), + ( + EnvVarName::from_str_unsafe("ENV2"), + "value2 from env_var_set1", + ), + ( + EnvVarName::from_str_unsafe("ENV3"), + "value3 from env_var_set1", + ), + ]); + let env_var_set2 = EnvVarSet::new() + .with_value( + EnvVarName::from_str_unsafe("ENV2"), + "value2 from env_var_set2", + ) + .with_field_path(EnvVarName::from_str_unsafe("ENV3"), FieldPathEnvVar::Name) + .with_value( + EnvVarName::from_str_unsafe("ENV4"), + "value4 from env_var_set2", + ); + + let merged_env_var_set = env_var_set1.merge(env_var_set2); + + assert_eq!( + vec![ + EnvVar { + name: "ENV1".to_owned(), + value: Some("value1 from env_var_set1".to_owned()), + value_from: None + }, + EnvVar { + name: "ENV2".to_owned(), + value: Some("value2 from env_var_set2".to_owned()), + value_from: None + }, + EnvVar { + name: "ENV3".to_owned(), + value: None, + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: "metadata.name".to_owned(), + ..ObjectFieldSelector::default() + }), + ..EnvVarSource::default() + }), + }, + EnvVar { + name: "ENV4".to_owned(), + value: Some("value4 from env_var_set2".to_owned()), + value_from: None + } + ], + Vec::from(merged_env_var_set) + ); + } + + #[test] + fn test_envvarset_with_values() { + let env_var_set = EnvVarSet::new().with_values([ + (EnvVarName::from_str_unsafe("ENV1"), "value1"), + (EnvVarName::from_str_unsafe("ENV2"), "value2"), + ]); + + assert_eq!( + vec![ + EnvVar { + name: "ENV1".to_owned(), + value: Some("value1".to_owned()), + value_from: None + }, + EnvVar { + name: "ENV2".to_owned(), + value: Some("value2".to_owned()), + value_from: None + } + ], + Vec::from(env_var_set) + ); + } + + #[test] + fn test_envvarset_with_value() { + let env_var_set = EnvVarSet::new().with_value(EnvVarName::from_str_unsafe("ENV"), "value"); + + assert_eq!( + Some(&EnvVar { + name: "ENV".to_owned(), + value: Some("value".to_owned()), + value_from: None + }), + env_var_set.get(EnvVarName::from_str_unsafe("ENV")) + ); + } + + #[test] + fn test_envvarset_with_field_path() { + let env_var_set = EnvVarSet::new() + .with_field_path(EnvVarName::from_str_unsafe("ENV"), FieldPathEnvVar::Name); + + assert_eq!( + Some(&EnvVar { + name: "ENV".to_owned(), + value: None, + value_from: Some(EnvVarSource { + field_ref: Some(ObjectFieldSelector { + field_path: "metadata.name".to_owned(), + ..ObjectFieldSelector::default() + }), + ..EnvVarSource::default() + }), + }), + env_var_set.get(EnvVarName::from_str_unsafe("ENV")) + ); + } +} diff --git a/rust/operator-binary/src/framework/kvp/label.rs b/rust/operator-binary/src/framework/kvp/label.rs index ce875ea..b220488 100644 --- a/rust/operator-binary/src/framework/kvp/label.rs +++ b/rust/operator-binary/src/framework/kvp/label.rs @@ -4,15 +4,15 @@ use stackable_operator::{ }; use crate::framework::{ - ControllerName, IsLabelValue, OperatorName, ProductName, ProductVersion, RoleGroupName, - RoleName, + ControllerName, HasObjectName, IsLabelValue, OperatorName, ProductName, ProductVersion, + RoleGroupName, RoleName, }; pub const MAX_LABEL_VALUE_LENGTH: usize = 63; /// Infallible variant of `Labels::recommended` pub fn recommended_labels( - owner: &(impl Resource + IsLabelValue), + owner: &(impl Resource + HasObjectName + IsLabelValue), product_name: &ProductName, product_version: &ProductVersion, operator_name: &OperatorName, @@ -30,7 +30,7 @@ pub fn recommended_labels( role_group: &role_group_name.to_label_value(), }; Labels::recommended(object_labels) - .expect("Labels should be created because all given parameters produce valid label values") + .expect("Labels should be created because the owner has an object name and all given parameters produce valid label values.") } /// Infallible variant of `Labels::role_selector` @@ -62,3 +62,147 @@ pub fn role_group_selector( ) .expect("Labels should be created because all given parameters produce valid label values") } + +#[cfg(test)] +mod tests { + use std::{borrow::Cow, collections::BTreeMap}; + + use stackable_operator::{ + k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta, kube::Resource, + }; + + use crate::framework::{ + ControllerName, HasObjectName, IsLabelValue, OperatorName, ProductName, ProductVersion, + RoleGroupName, RoleName, + kvp::label::{recommended_labels, role_group_selector, role_selector}, + }; + + struct Cluster { + object_meta: ObjectMeta, + } + + impl Cluster { + fn new() -> Self { + Cluster { + object_meta: ObjectMeta { + name: Some("cluster-name".to_owned()), + ..ObjectMeta::default() + }, + } + } + } + + impl Resource for Cluster { + type DynamicType = (); + type Scope = (); + + fn kind(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("kind") + } + + fn group(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("group") + } + + fn version(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("version") + } + + fn plural(_dt: &Self::DynamicType) -> Cow<'_, str> { + Cow::from("plural") + } + + fn meta(&self) -> &ObjectMeta { + &self.object_meta + } + + fn meta_mut(&mut self) -> &mut ObjectMeta { + &mut self.object_meta + } + } + + impl HasObjectName for Cluster { + fn to_object_name(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + impl IsLabelValue for Cluster { + fn to_label_value(&self) -> String { + self.object_meta + .name + .clone() + .expect("should be set in Cluster::new") + } + } + + #[test] + fn test_recommended_labels() { + let actual_labels = recommended_labels( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &ProductVersion::from_str_unsafe("1.0.0"), + &OperatorName::from_str_unsafe("my-operator"), + &ControllerName::from_str_unsafe("my-controller"), + &RoleName::from_str_unsafe("my-role"), + &RoleGroupName::from_str_unsafe("my-role-group"), + ); + + let expected_labels: BTreeMap = [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/managed-by", "my-operator_my-controller"), + ("app.kubernetes.io/name", "my-product"), + ("app.kubernetes.io/role-group", "my-role-group"), + ("app.kubernetes.io/version", "1.0.0"), + ("stackable.tech/vendor", "Stackable"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } + + #[test] + fn test_role_selector() { + let actual_labels = role_selector( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &RoleName::from_str_unsafe("my-role"), + ); + + let expected_labels: BTreeMap = [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/name", "my-product"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } + + #[test] + fn test_role_group_selector() { + let actual_labels = role_group_selector( + &Cluster::new(), + &ProductName::from_str_unsafe("my-product"), + &RoleName::from_str_unsafe("my-role"), + &RoleGroupName::from_str_unsafe("my-role-group"), + ); + + let expected_labels: BTreeMap = [ + ("app.kubernetes.io/component", "my-role"), + ("app.kubernetes.io/instance", "cluster-name"), + ("app.kubernetes.io/name", "my-product"), + ("app.kubernetes.io/role-group", "my-role-group"), + ] + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .into(); + + assert_eq!(expected_labels, actual_labels.into()); + } +} diff --git a/rust/operator-binary/src/framework/role_group_utils.rs b/rust/operator-binary/src/framework/role_group_utils.rs index 7cd26df..c0234c1 100644 --- a/rust/operator-binary/src/framework/role_group_utils.rs +++ b/rust/operator-binary/src/framework/role_group_utils.rs @@ -79,24 +79,36 @@ impl ResourceNames { #[cfg(test)] mod tests { - use std::str::FromStr; - use super::{ClusterName, RoleGroupName, RoleName}; use crate::framework::role_group_utils::ResourceNames; #[test] - fn test_stateful_set_name() { + fn test_resource_names() { let resource_names = ResourceNames { - cluster_name: ClusterName::from_str("test-cluster") - .expect("should be a valid cluster name"), - role_name: RoleName::from_str("data-nodes").expect("should be a valid role name"), - role_group_name: RoleGroupName::from_str("ssd-storage") - .expect("should be a valid role group name"), + cluster_name: ClusterName::from_str_unsafe("test-cluster"), + role_name: RoleName::from_str_unsafe("data-nodes"), + role_group_name: RoleGroupName::from_str_unsafe("ssd-storage"), }; + assert_eq!( + "test-cluster-data-nodes-ssd-storage", + resource_names.qualified_role_group_name() + ); + assert_eq!( + "test-cluster-data-nodes-ssd-storage", + resource_names.role_group_config_map() + ); assert_eq!( "test-cluster-data-nodes-ssd-storage", resource_names.stateful_set_name() ); + assert_eq!( + "test-cluster-data-nodes-ssd-storage-headless", + resource_names.headless_service_name() + ); + assert_eq!( + "test-cluster-data-nodes-ssd-storage", + resource_names.listener_service_name() + ); } } diff --git a/rust/operator-binary/src/framework/role_utils.rs b/rust/operator-binary/src/framework/role_utils.rs index 3539d3b..2829c1c 100644 --- a/rust/operator-binary/src/framework/role_utils.rs +++ b/rust/operator-binary/src/framework/role_utils.rs @@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize}; use stackable_operator::{ config::{ fragment::{self, FromFragment}, - merge::Merge, + merge::{Merge, merge}, }, - k8s_openapi::api::core::v1::PodTemplateSpec, + k8s_openapi::{DeepMerge, api::core::v1::PodTemplateSpec}, role_utils::{CommonConfiguration, Role, RoleGroup}, schemars::JsonSchema, }; @@ -143,8 +143,8 @@ fn merged_pod_overrides( role_pod_overrides: PodTemplateSpec, role_group_pod_overrides: PodTemplateSpec, ) -> PodTemplateSpec { - let mut merged_pod_overrides = role_group_pod_overrides; - merged_pod_overrides.merge(&role_pod_overrides); + let mut merged_pod_overrides = role_pod_overrides; + merged_pod_overrides.merge_from(role_group_pod_overrides); merged_pod_overrides } @@ -152,9 +152,7 @@ fn merged_product_specific_common_config(role_config: T, role_group_config: T where T: Merge, { - let mut merged_config = role_group_config; - merged_config.merge(&role_config); - merged_config + merge(role_group_config, &role_config) } pub struct ResourceNames { @@ -201,3 +199,180 @@ impl ResourceNames { format!("{}", self.cluster_name) } } + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, HashMap}; + + use rstest::*; + use schemars::JsonSchema; + use serde::Serialize; + use stackable_operator::{ + config::{fragment::Fragment, merge::Merge}, + k8s_openapi::api::core::v1::PodTemplateSpec, + kube::api::ObjectMeta, + role_utils::{CommonConfiguration, GenericRoleConfig, Role, RoleGroup}, + }; + + use super::ResourceNames; + use crate::framework::{ClusterName, ProductName, role_utils::with_validated_config}; + + #[derive(Debug, Fragment, PartialEq)] + #[fragment_attrs(derive(Clone, Debug, Default, Merge, PartialEq))] + struct Config { + property: String, + } + + impl Config { + fn new(value: &str) -> Self { + Self { + property: value.to_owned(), + } + } + } + + impl ConfigFragment { + fn new(value: Option<&str>) -> Self { + Self { + property: value.map(str::to_owned), + } + } + } + + #[derive(Clone, Debug, Default, JsonSchema, Merge, PartialEq, Serialize)] + struct ProductCommonConfig { + property: Option, + } + + fn new_common_config( + config: T, + override_value: Option<&str>, + ) -> CommonConfiguration { + let mut config_file_overrides = HashMap::new(); + let mut env_overrides = HashMap::new(); + let mut cli_overrides = BTreeMap::new(); + + if let Some(value) = override_value { + config_file_overrides.insert("property".to_owned(), value.to_owned()); + env_overrides.insert("PROPERTY".to_owned(), value.to_owned()); + cli_overrides.insert("--property".to_owned(), value.to_owned()); + } + + CommonConfiguration { + config, + config_overrides: [("config.file".to_owned(), config_file_overrides)].into(), + env_overrides, + cli_overrides, + pod_overrides: PodTemplateSpec { + metadata: Some(ObjectMeta { + name: override_value.map(str::to_owned), + ..ObjectMeta::default() + }), + ..PodTemplateSpec::default() + }, + product_specific_common_config: ProductCommonConfig { + property: override_value.map(str::to_owned), + }, + } + } + + #[rstest] + #[case( + "role-group", + Some("role-group"), + Some("role-group"), + Some("role"), + Some("default") + )] + #[case( + "role-group", + Some("role-group"), + Some("role-group"), + Some("role"), + None + )] + #[case( + "role-group", + Some("role-group"), + Some("role-group"), + None, + Some("default") + )] + #[case("role-group", Some("role-group"), Some("role-group"), None, None)] + #[case("role", Some("role"), None, Some("role"), Some("default"))] + #[case("role", Some("role"), None, Some("role"), None)] + #[case("default", None, None, None, Some("default"))] + fn test_with_validated_config_and_result_ok( + #[case] expected_config_value: &str, + #[case] expected_override_value: Option<&str>, + #[case] role_group_value: Option<&str>, + #[case] role_value: Option<&str>, + #[case] default_value: Option<&str>, + ) { + let role_group = RoleGroup { + config: new_common_config(ConfigFragment::new(role_group_value), role_group_value), + replicas: Some(3), + }; + let role = Role::<_, GenericRoleConfig, _> { + config: new_common_config(ConfigFragment::new(role_value), role_value), + ..Role::default() + }; + let default_config = ConfigFragment::new(default_value); + + let result = with_validated_config(&role_group, &role, &default_config); + + assert_eq!( + Some(RoleGroup { + config: new_common_config( + Config::new(expected_config_value), + expected_override_value + ), + replicas: Some(3) + }), + result.ok() + ) + } + + #[test] + fn test_with_validated_config_and_result_err() { + let role_group = RoleGroup { + config: new_common_config(ConfigFragment::new(None), None), + replicas: None, + }; + let role = Role::<_, GenericRoleConfig, _> { + config: new_common_config(ConfigFragment::new(None), None), + ..Role::default() + }; + let default_config = ConfigFragment::new(None); + + let result: Result, _> = + with_validated_config(&role_group, &role, &default_config); + + assert!(result.is_err()) + } + + #[test] + fn test_resource_names() { + let resource_names = ResourceNames { + cluster_name: ClusterName::from_str_unsafe("my-cluster"), + product_name: ProductName::from_str_unsafe("my-product"), + }; + + assert_eq!( + "my-cluster-serviceaccount".to_owned(), + resource_names.service_account_name() + ); + assert_eq!( + "my-cluster-rolebinding".to_owned(), + resource_names.role_binding_name() + ); + assert_eq!( + "my-product-clusterrole".to_owned(), + resource_names.cluster_role_name() + ); + assert_eq!( + "my-cluster".to_owned(), + resource_names.discovery_service_name() + ); + } +} diff --git a/tests/templates/kuttl/smoke/10-assert.yaml.j2 b/tests/templates/kuttl/smoke/10-assert.yaml.j2 index d5fe3fa..760f936 100644 --- a/tests/templates/kuttl/smoke/10-assert.yaml.j2 +++ b/tests/templates/kuttl/smoke/10-assert.yaml.j2 @@ -131,6 +131,10 @@ spec: defaultMode: 420 name: opensearch-nodes-cluster-manager name: config + - name: security-config + secret: + defaultMode: 420 + secretName: opensearch-security-config - ephemeral: volumeClaimTemplate: metadata: @@ -146,10 +150,6 @@ spec: storageClassName: secrets.stackable.tech volumeMode: Filesystem name: tls - - name: security-config - secret: - defaultMode: 420 - secretName: opensearch-security-config volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim @@ -318,6 +318,10 @@ spec: defaultMode: 420 name: opensearch-nodes-data name: config + - name: security-config + secret: + defaultMode: 420 + secretName: opensearch-security-config - ephemeral: volumeClaimTemplate: metadata: @@ -333,10 +337,6 @@ spec: storageClassName: secrets.stackable.tech volumeMode: Filesystem name: tls - - name: security-config - secret: - defaultMode: 420 - secretName: opensearch-security-config volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim