From 8912e137ef42792340505051c7ef283ed37897fc Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 09:53:09 +0000 Subject: [PATCH 1/7] feat: Add min_length and regex to attributed_string_type --- rust/operator-binary/src/framework.rs | 325 ++++++++++++++++++++++---- 1 file changed, 278 insertions(+), 47 deletions(-) diff --git a/rust/operator-binary/src/framework.rs b/rust/operator-binary/src/framework.rs index 3442b2e..5caadff 100644 --- a/rust/operator-binary/src/framework.rs +++ b/rust/operator-binary/src/framework.rs @@ -23,9 +23,7 @@ use std::str::FromStr; use snafu::Snafu; -use stackable_operator::validation::{ - RFC_1035_LABEL_MAX_LENGTH, RFC_1123_LABEL_MAX_LENGTH, RFC_1123_SUBDOMAIN_MAX_LENGTH, -}; +use stackable_operator::validation::{RFC_1123_LABEL_MAX_LENGTH, RFC_1123_SUBDOMAIN_MAX_LENGTH}; use strum::{EnumDiscriminants, IntoStaticStr}; pub mod builder; @@ -42,9 +40,18 @@ pub enum Error { #[snafu(display("empty strings are not allowed"))] EmptyString {}, + #[snafu(display("minimum length not met"))] + MinimumLengthNotMet { length: usize, min_length: usize }, + #[snafu(display("maximum length exceeded"))] LengthExceeded { length: usize, max_length: usize }, + #[snafu(display("invalid regular expression"))] + InvalidRegex { source: regex::Error }, + + #[snafu(display("regular expression not matched"))] + RegexNotMatched { value: String, regex: &'static str }, + #[snafu(display("not a valid ConfigMap key"))] InvalidConfigMapKey { source: crate::framework::validation::Error, @@ -98,6 +105,28 @@ pub trait NameIsValidLabelValue { fn to_label_value(&self) -> String; } +#[derive(Clone, Copy, Debug)] +pub enum Regex { + /// There is a regular expression but it is unknown (or too complicated). + Unknown, + + /// `MatchAll` equals Expression(".*") but can be matched in a const context. + MatchAll, + + /// There is a regular expression. + Expression(&'static str), +} + +impl Regex { + pub const fn combine(self, other: Regex) -> Regex { + match (self, other) { + (_, Regex::MatchAll) => self, + (Regex::MatchAll, _) => other, + _ => Regex::Unknown, + } + } +} + /// Restricted string type with attributes like maximum length. /// /// Fully-qualified types are used to ease the import into other modules. @@ -109,7 +138,6 @@ pub trait NameIsValidLabelValue { /// ConfigMapName, /// "The name of a ConfigMap", /// "opensearch-nodes-default", -/// (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), /// is_rfc_1123_dns_subdomain_name /// } /// ``` @@ -184,6 +212,38 @@ macro_rules! attributed_string_type { impl stackable_operator::config::merge::Atomic for $name {} + impl $name { + pub const MIN_LENGTH: usize = attributed_string_type!(@min_length $($attribute)*); + pub const MAX_LENGTH: usize = attributed_string_type!(@max_length $($attribute)*); + + /// None if there are restrictions but the regular expression could not be calculated. + pub const REGEX: $crate::framework::Regex = attributed_string_type!(@regex $($attribute)*); + } + + // The JsonSchema implementation requires `max_length`. + impl schemars::JsonSchema for $name { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::stringify!($name).into() + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "minLength": $name::MIN_LENGTH, + "maxLength": if $name::MAX_LENGTH != usize::MAX { + Some($name::MAX_LENGTH) + } else { + // Do not set maxLength if it is usize::MAX. + None + }, + "pattern": match $name::REGEX { + $crate::framework::Regex::Expression(regex) => Some(regex), + _ => None + } + }) + } + } + #[cfg(test)] impl $name { #[allow(dead_code)] @@ -199,6 +259,19 @@ macro_rules! attributed_string_type { $(attributed_string_type!(@trait_impl $name, $attribute);)* }; + + // std::str::FromStr + + (@from_str $name:ident, $s:expr, (min_length = $min_length:expr)) => { + let length = $s.len() as usize; + snafu::ensure!( + length >= $name::MIN_LENGTH, + $crate::framework::MinimumLengthNotMetSnafu { + length, + min_length: $name::MIN_LENGTH, + } + ); + }; (@from_str $name:ident, $s:expr, (max_length = $max_length:expr)) => { let length = $s.len() as usize; snafu::ensure!( @@ -209,6 +282,16 @@ macro_rules! attributed_string_type { } ); }; + (@from_str $name:ident, $s:expr, (regex = $regex:expr)) => { + let regex = regex::Regex::new($regex).context($crate::framework::InvalidRegexSnafu)?; + snafu::ensure!( + regex.is_match($s), + $crate::framework::RegexNotMatchedSnafu { + value: $s, + regex: $regex + } + ); + }; (@from_str $name:ident, $s:expr, is_config_map_key) => { $crate::framework::validation::is_config_map_key($s).context($crate::framework::InvalidConfigMapKeySnafu)?; }; @@ -227,26 +310,172 @@ macro_rules! attributed_string_type { (@from_str $name:ident, $s:expr, is_uid) => { uuid::Uuid::try_parse($s).context($crate::framework::InvalidUidSnafu)?; }; - (@trait_impl $name:ident, (max_length = $max_length:expr)) => { - impl $name { - // type arithmetic would be better - pub const MAX_LENGTH: usize = $max_length; - } - // The JsonSchema implementation requires `max_length`. - impl schemars::JsonSchema for $name { - fn schema_name() -> std::borrow::Cow<'static, str> { - std::stringify!($name).into() - } + // MIN_LENGTH - fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { - schemars::json_schema!({ - "type": "string", - "minLength": 1, - "maxLength": $name::MAX_LENGTH - }) - } - } + (@min_length) => { + // The minimum String length is 0. + 0 + }; + (@min_length (min_length = $min_length:expr) $($attribute:tt)*) => { + $crate::framework::max( + $min_length, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length (max_length = $max_length:expr) $($attribute:tt)*) => { + // max_length has no opinion on the min_length. + attributed_string_type!(@min_length $($attribute)*) + }; + (@min_length (regex = $regex:expr) $($attribute:tt)*) => { + // regex has no influence on the min_length. + attributed_string_type!(@min_length $($attribute)*) + }; + (@min_length is_config_map_key $($attribute:tt)*) => { + $crate::framework::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::framework::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::framework::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::framework::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_valid_label_value $($attribute:tt)*) => { + $crate::framework::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_uid $($attribute:tt)*) => { + $crate::framework::max( + uuid::fmt::Hyphenated::LENGTH, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + + // MAX_LENGTH + + (@max_length) => { + // If there is no other max_length defined, then the upper bound is usize::MAX. + usize::MAX + }; + (@max_length (min_length = $min_length:expr) $($attribute:tt)*) => { + // min_length has no opinion on the max_length. + attributed_string_type!(@max_length $($attribute)*) + }; + (@max_length (max_length = $max_length:expr) $($attribute:tt)*) => { + $crate::framework::min( + $max_length, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length (regex = $regex:expr) $($attribute:tt)*) => { + // regex has no influence on the max_length. + attributed_string_type!(@max_length $($attribute)*) + }; + (@max_length is_config_map_key $($attribute:tt)*) => { + $crate::framework::min( + stackable_operator::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::framework::min( + stackable_operator::validation::RFC_1035_LABEL_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::framework::min( + stackable_operator::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::framework::min( + stackable_operator::validation::RFC_1123_LABEL_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_valid_label_value $($attribute:tt)*) => { + $crate::framework::min( + $crate::framework::MAX_LABEL_VALUE_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_uid $($attribute:tt)*) => { + $crate::framework::min( + uuid::fmt::Hyphenated::LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + + // REGEX + + (@regex) => { + // Everything is allowed if there is no other regular expression. + $crate::framework::Regex::MatchAll + }; + (@regex (min_length = $min_length:expr) $($attribute:tt)*) => { + // min_length has no influence on the regular expression. + attributed_string_type!(@regex $($attribute)*) + }; + (@regex (max_length = $max_length:expr) $($attribute:tt)*) => { + // max_length has no influence on the regular expression. + attributed_string_type!(@regex $($attribute)*) + }; + (@regex (regex = $regex:expr) $($attribute:tt)*) => { + $crate::framework::Regex::Expression($regex) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_config_map_key $($attribute:tt)*) => { + $crate::framework::Regex::Expression($crate::framework::validation::CONFIG_MAP_KEY_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1035_LABEL_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1123_SUBDOMAIN_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1123_LABEL_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_valid_label_value $($attribute:tt)*) => { + // regular expression from stackable_operator::kvp::label::LABEL_VALUE_REGEX + $crate::framework::Regex::Expression("^[a-z0-9A-Z]([a-z0-9A-Z-_.]*[a-z0-9A-Z]+)?$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_uid $($attribute:tt)*) => { + $crate::framework::Regex::Expression("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + + // additional constants and trait implementations + + (@trait_impl $name:ident, (min_length = $max_length:expr)) => { + }; + (@trait_impl $name:ident, (max_length = $max_length:expr)) => { + }; + (@trait_impl $name:ident, (regex = $regex:expr)) => { }; (@trait_impl $name:ident, is_config_map_key) => { }; @@ -327,13 +556,27 @@ pub const fn min(x: usize, y: usize) -> usize { if x < y { x } else { y } } +/// Returns the maximum of the given values. +/// +/// As opposed to [`std::cmp::max`], this function can be used at compile-time. +/// +/// # Examples +/// +/// ```rust +/// assert_eq!(3, max(2, 3)); +/// assert_eq!(5, max(5, 4)); +/// assert_eq!(1, max(1, 1)); +/// ``` +pub const fn max(x: usize, y: usize) -> usize { + if x < y { y } else { x } +} + // Kubernetes (resource) names attributed_string_type! { ConfigMapName, "The name of a ConfigMap", "opensearch-nodes-default", - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { @@ -348,7 +591,6 @@ attributed_string_type! { ContainerName, "The name of a container in a Pod", "opensearch", - (max_length = RFC_1123_LABEL_MAX_LENGTH), is_rfc_1123_label_name } attributed_string_type! { @@ -359,28 +601,24 @@ attributed_string_type! { // subdomain names, on the other hand, their length does not seem to be restricted – at least // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid // problems on other Kubernetes providers, the length is restricted here. - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { ListenerName, "The name of a Listener", "opensearch-nodes-default", - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { ListenerClassName, "The name of a Listener", "external-stable", - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { NamespaceName, "The name of a Namespace", "stackable-operators", - (max_length = min(RFC_1123_LABEL_MAX_LENGTH, MAX_LABEL_VALUE_LENGTH)), is_rfc_1123_label_name, is_valid_label_value } @@ -388,7 +626,6 @@ attributed_string_type! { PersistentVolumeClaimName, "The name of a PersistentVolumeClaim", "config", - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { @@ -399,21 +636,18 @@ attributed_string_type! { // subdomain names, on the other hand, their length does not seem to be restricted – at least // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid // problems on other Kubernetes providers, the length is restricted here. - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { ServiceAccountName, "The name of a ServiceAccount", "opensearch-serviceaccount", - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), is_rfc_1123_dns_subdomain_name } attributed_string_type! { ServiceName, "The name of a Service", "opensearch-nodes-default-headless", - (max_length = min(RFC_1035_LABEL_MAX_LENGTH, MAX_LABEL_VALUE_LENGTH)), is_rfc_1035_label_name, is_valid_label_value } @@ -421,12 +655,11 @@ attributed_string_type! { StatefulSetName, "The name of a StatefulSet", "opensearch-nodes-default", - (max_length = min( + (max_length = // see https://github.com/kubernetes/kubernetes/issues/64023 RFC_1123_LABEL_MAX_LENGTH - 1 /* dash */ - - 10 /* digits for the controller-revision-hash label */, - MAX_LABEL_VALUE_LENGTH)), + - 10 /* digits for the controller-revision-hash label */), is_rfc_1123_label_name, is_valid_label_value } @@ -434,7 +667,6 @@ attributed_string_type! { Uid, "A UID", "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", - (max_length = min(uuid::fmt::Hyphenated::LENGTH, MAX_LABEL_VALUE_LENGTH)), is_uid, is_valid_label_value } @@ -442,7 +674,6 @@ attributed_string_type! { VolumeName, "The name of a Volume", "opensearch-nodes-default", - (max_length = min(RFC_1123_LABEL_MAX_LENGTH, MAX_LABEL_VALUE_LENGTH)), is_rfc_1123_label_name, is_valid_label_value } @@ -455,7 +686,7 @@ attributed_string_type! { "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 = min(54, MAX_LABEL_VALUE_LENGTH)), + (max_length = 54), is_rfc_1123_dns_subdomain_name, is_valid_label_value } @@ -463,7 +694,6 @@ attributed_string_type! { ProductVersion, "The version of a product", "3.1.0", - (max_length = MAX_LABEL_VALUE_LENGTH), is_valid_label_value } attributed_string_type! { @@ -472,7 +702,7 @@ attributed_string_type! { "my-opensearch-cluster", // Suffixes are added to produce resource names. According compile-time checks ensure that // max_length cannot be set higher. - (max_length = min(24, MAX_LABEL_VALUE_LENGTH)), + (max_length = 24), is_rfc_1035_label_name, is_valid_label_value } @@ -480,14 +710,12 @@ attributed_string_type! { ControllerName, "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", "opensearch.stackable.tech", - (max_length = MAX_LABEL_VALUE_LENGTH), is_valid_label_value } attributed_string_type! { @@ -497,7 +725,7 @@ attributed_string_type! { // The role-group name is used to produce resource names. To make sure that all resource names // are valid, max_length is restricted. Compile-time checks ensure that max_length cannot be // set higher if not other names like the RoleName are set lower accordingly. - (max_length = min(16, MAX_LABEL_VALUE_LENGTH)), + (max_length = 16), is_rfc_1123_label_name, is_valid_label_value } @@ -508,7 +736,7 @@ attributed_string_type! { // The role name is used to produce resource names. To make sure that all resource names are // valid, max_length is restricted. Compile-time checks ensure that max_length cannot be set // higher if not other names like the RoleGroupName are set lower accordingly. - (max_length = min(10, MAX_LABEL_VALUE_LENGTH)), + (max_length = 10), is_rfc_1123_label_name, is_valid_label_value } @@ -610,7 +838,9 @@ mod tests { JsonSchemaTest, "JsonSchemaTest test", "test", - (max_length = 4) + (min_length = 4), + (max_length = 8), + (regex = "^[est]+$") } #[test] @@ -622,8 +852,9 @@ mod tests { assert_eq!( json!({ "type": "string", - "minLength": 1, - "maxLength": 4 + "minLength": 4, + "maxLength": 8, + "pattern": "^[tes]+$", }), JsonSchemaTest::json_schema(&mut SchemaGenerator::default()) ); From bb8387816340b15f8b3e26598361c8004913367e Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 10:25:19 +0000 Subject: [PATCH 2/7] chore: Regenerate charts --- deploy/helm/opensearch-operator/crds/crds.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/helm/opensearch-operator/crds/crds.yaml b/deploy/helm/opensearch-operator/crds/crds.yaml index 12f3da0..50f4a28 100644 --- a/deploy/helm/opensearch-operator/crds/crds.yaml +++ b/deploy/helm/opensearch-operator/crds/crds.yaml @@ -41,6 +41,7 @@ spec: maxLength: 253 minLength: 1 nullable: true + pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' type: string type: object clusterOperation: @@ -191,6 +192,7 @@ spec: maxLength: 253 minLength: 1 nullable: true + pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' type: string logging: default: @@ -539,6 +541,7 @@ spec: maxLength: 253 minLength: 1 nullable: true + pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' type: string logging: default: From 122eec4d8769b67a0ee34290b20c457b22b626d4 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 10:44:53 +0000 Subject: [PATCH 3/7] feat: Replace is_config_map_key in attributed_string_type with regex --- rust/operator-binary/src/framework.rs | 69 ++++--------- .../src/framework/validation.rs | 96 ------------------- 2 files changed, 17 insertions(+), 148 deletions(-) delete mode 100644 rust/operator-binary/src/framework/validation.rs diff --git a/rust/operator-binary/src/framework.rs b/rust/operator-binary/src/framework.rs index 5caadff..b0457bb 100644 --- a/rust/operator-binary/src/framework.rs +++ b/rust/operator-binary/src/framework.rs @@ -32,7 +32,6 @@ pub mod kvp; pub mod product_logging; pub mod role_group_utils; pub mod role_utils; -pub mod validation; #[derive(Debug, EnumDiscriminants, Snafu)] #[strum_discriminants(derive(IntoStaticStr))] @@ -52,11 +51,6 @@ pub enum Error { #[snafu(display("regular expression not matched"))] RegexNotMatched { value: String, regex: &'static str }, - #[snafu(display("not a valid ConfigMap key"))] - InvalidConfigMapKey { - source: crate::framework::validation::Error, - }, - #[snafu(display("not a valid label value"))] InvalidLabelValue { source: stackable_operator::kvp::LabelValueError, @@ -237,7 +231,7 @@ macro_rules! attributed_string_type { None }, "pattern": match $name::REGEX { - $crate::framework::Regex::Expression(regex) => Some(regex), + $crate::framework::Regex::Expression(regex) => Some(std::format!("^{regex}$")), _ => None } }) @@ -292,9 +286,6 @@ macro_rules! attributed_string_type { } ); }; - (@from_str $name:ident, $s:expr, is_config_map_key) => { - $crate::framework::validation::is_config_map_key($s).context($crate::framework::InvalidConfigMapKeySnafu)?; - }; (@from_str $name:ident, $s:expr, is_rfc_1035_label_name) => { stackable_operator::validation::is_lowercase_rfc_1035_label($s).context($crate::framework::InvalidRfc1035LabelNameSnafu)?; }; @@ -331,12 +322,6 @@ macro_rules! attributed_string_type { // regex has no influence on the min_length. attributed_string_type!(@min_length $($attribute)*) }; - (@min_length is_config_map_key $($attribute:tt)*) => { - $crate::framework::max( - 1, - attributed_string_type!(@min_length $($attribute)*) - ) - }; (@min_length is_rfc_1035_label_name $($attribute:tt)*) => { $crate::framework::max( 1, @@ -388,12 +373,6 @@ macro_rules! attributed_string_type { // regex has no influence on the max_length. attributed_string_type!(@max_length $($attribute)*) }; - (@max_length is_config_map_key $($attribute:tt)*) => { - $crate::framework::min( - stackable_operator::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH, - attributed_string_type!(@max_length $($attribute)*) - ) - }; (@max_length is_rfc_1035_label_name $($attribute:tt)*) => { $crate::framework::min( stackable_operator::validation::RFC_1035_LABEL_MAX_LENGTH, @@ -443,10 +422,6 @@ macro_rules! attributed_string_type { $crate::framework::Regex::Expression($regex) .combine(attributed_string_type!(@regex $($attribute)*)) }; - (@regex is_config_map_key $($attribute:tt)*) => { - $crate::framework::Regex::Expression($crate::framework::validation::CONFIG_MAP_KEY_FMT) - .combine(attributed_string_type!(@regex $($attribute)*)) - }; (@regex is_rfc_1035_label_name $($attribute:tt)*) => { $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1035_LABEL_FMT) .combine(attributed_string_type!(@regex $($attribute)*)) @@ -461,11 +436,11 @@ macro_rules! attributed_string_type { }; (@regex is_valid_label_value $($attribute:tt)*) => { // regular expression from stackable_operator::kvp::label::LABEL_VALUE_REGEX - $crate::framework::Regex::Expression("^[a-z0-9A-Z]([a-z0-9A-Z-_.]*[a-z0-9A-Z]+)?$") + $crate::framework::Regex::Expression("[a-z0-9A-Z]([a-z0-9A-Z-_.]*[a-z0-9A-Z]+)?") .combine(attributed_string_type!(@regex $($attribute)*)) }; (@regex is_uid $($attribute:tt)*) => { - $crate::framework::Regex::Expression("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + $crate::framework::Regex::Expression("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") .combine(attributed_string_type!(@regex $($attribute)*)) }; @@ -477,8 +452,6 @@ macro_rules! attributed_string_type { }; (@trait_impl $name:ident, (regex = $regex:expr)) => { }; - (@trait_impl $name:ident, is_config_map_key) => { - }; (@trait_impl $name:ident, is_rfc_1035_label_name) => { impl $name { pub const IS_RFC_1035_LABEL_NAME: bool = true; @@ -581,11 +554,12 @@ attributed_string_type! { } attributed_string_type! { ConfigMapKey, - "The key for a ConfigMap or Secret", + "The key for a ConfigMap", "log4j2.properties", + (min_length = 1), // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), - is_config_map_key + (regex = "[-._a-zA-Z0-9]+") } attributed_string_type! { ContainerName, @@ -638,6 +612,15 @@ attributed_string_type! { // problems on other Kubernetes providers, the length is restricted here. is_rfc_1123_dns_subdomain_name } +attributed_string_type! { + SecretKey, + "The key for a Secret", + "accessKey", + (min_length = 1), + // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 + (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), + (regex = "[-._a-zA-Z0-9]+") +} attributed_string_type! { ServiceAccountName, "The name of a ServiceAccount", @@ -840,7 +823,7 @@ mod tests { "test", (min_length = 4), (max_length = 8), - (regex = "^[est]+$") + (regex = "[est]+") } #[test] @@ -854,7 +837,7 @@ mod tests { "type": "string", "minLength": 4, "maxLength": 8, - "pattern": "^[tes]+$", + "pattern": "^[est]+$", }), JsonSchemaTest::json_schema(&mut SchemaGenerator::default()) ); @@ -936,24 +919,6 @@ mod tests { ); } - attributed_string_type! { - IsConfigMapKeyTest, - "is_config_map_key test", - "a_B-c.1", - is_config_map_key - } - - #[test] - fn test_attributed_string_type_is_config_map_key() { - type T = IsConfigMapKeyTest; - - T::test_example(); - assert_eq!( - Err(ErrorDiscriminants::InvalidConfigMapKey), - T::from_str(" ").map_err(ErrorDiscriminants::from) - ); - } - attributed_string_type! { IsRfc1035LabelNameTest, "is_rfc_1035_label_name test", diff --git a/rust/operator-binary/src/framework/validation.rs b/rust/operator-binary/src/framework/validation.rs deleted file mode 100644 index b4e3a20..0000000 --- a/rust/operator-binary/src/framework/validation.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::sync::LazyLock; - -use regex::Regex; -use snafu::{Snafu, ensure}; -use stackable_operator::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH; - -/// Format of a key for a ConfigMap or Secret -pub const CONFIG_MAP_KEY_FMT: &str = "[-._a-zA-Z0-9]+"; -const CONFIG_MAP_KEY_ERROR_MSG: &str = - "a valid config key must consist of alphanumeric characters, '-', '_' or '.'"; -static CONFIG_MAP_KEY_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(&format!("^{CONFIG_MAP_KEY_FMT}$")).expect("failed to compile ConfigMap key regex") -}); - -#[derive(Debug, Eq, PartialEq, Snafu)] -pub enum Error { - #[snafu(display("value is empty"))] - Empty { value: String }, - - #[snafu(display("value does not match the regular expression"))] - Regex { - value: String, - regex: &'static str, - message: &'static str, - }, - - #[snafu(display("value exceeds the maximum length"))] - TooLong { value: String, max_length: usize }, -} - -type Result = std::result::Result<(), Error>; - -/// Tests if the given value is a valid key for a ConfigMap or Secret -/// -/// see -pub fn is_config_map_key(value: &str) -> Result { - // When adding this function to stackable_operator, use the private functions like - // validate_all. - - ensure!(!value.is_empty(), EmptySnafu { value }); - - let max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH; - ensure!( - value.len() <= max_length, - TooLongSnafu { - value: value.to_owned(), - max_length - } - ); - - ensure!( - CONFIG_MAP_KEY_REGEX.is_match(value), - RegexSnafu { - value: value.to_owned(), - regex: CONFIG_MAP_KEY_FMT, - message: CONFIG_MAP_KEY_ERROR_MSG - } - ); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{CONFIG_MAP_KEY_ERROR_MSG, CONFIG_MAP_KEY_FMT, Error, is_config_map_key}; - - #[test] - fn test_is_config_map_key() { - assert_eq!(Ok(()), is_config_map_key("_a-A.1")); - - assert_eq!( - Err(Error::Empty { - value: "".to_owned() - }), - is_config_map_key("") - ); - - assert_eq!(Ok(()), is_config_map_key(&"a".repeat(253))); - assert_eq!( - Err(Error::TooLong { - value: "a".repeat(254), - max_length: 253 - }), - is_config_map_key(&"a".repeat(254)) - ); - - assert_eq!( - Err(Error::Regex { - value: " ".to_string(), - regex: CONFIG_MAP_KEY_FMT, - message: CONFIG_MAP_KEY_ERROR_MSG, - }), - is_config_map_key(" ") - ); - } -} From b23432cb7e03c363f15ab0eaa9ccc58287a91930 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 14:38:07 +0000 Subject: [PATCH 4/7] chore: Move macros and types to separate modules --- rust/operator-binary/src/controller.rs | 17 +- rust/operator-binary/src/controller/apply.rs | 8 +- rust/operator-binary/src/controller/build.rs | 10 +- .../src/controller/build/node_config.rs | 7 +- .../src/controller/build/role_builder.rs | 10 +- .../controller/build/role_group_builder.rs | 19 +- .../src/controller/validate.rs | 34 +- rust/operator-binary/src/crd/mod.rs | 8 +- rust/operator-binary/src/framework.rs | 989 +----------------- .../src/framework/builder/pdb.rs | 11 +- .../src/framework/builder/pod/container.rs | 5 +- .../src/framework/builder/pod/volume.rs | 4 +- .../src/framework/cluster_resources.rs | 9 +- .../src/framework/kvp/label.rs | 12 +- rust/operator-binary/src/framework/macros.rs | 2 + .../macros/attributed_string_type.rs | 755 +++++++++++++ .../src/framework/macros/constant.rs | 17 + .../framework/product_logging/framework.rs | 11 +- .../src/framework/role_group_utils.rs | 13 +- .../src/framework/role_utils.rs | 13 +- rust/operator-binary/src/framework/types.rs | 2 + .../src/framework/types/kubernetes.rs | 162 +++ .../src/framework/types/operator.rs | 91 ++ rust/operator-binary/src/main.rs | 2 +- 24 files changed, 1173 insertions(+), 1038 deletions(-) create mode 100644 rust/operator-binary/src/framework/macros.rs create mode 100644 rust/operator-binary/src/framework/macros/attributed_string_type.rs create mode 100644 rust/operator-binary/src/framework/macros/constant.rs create mode 100644 rust/operator-binary/src/framework/types.rs create mode 100644 rust/operator-binary/src/framework/types/kubernetes.rs create mode 100644 rust/operator-binary/src/framework/types/operator.rs diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index 832e488..3a6d210 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -33,10 +33,16 @@ use crate::{ v1alpha1::{self}, }, framework::{ - ClusterName, ControllerName, HasName, HasUid, ListenerClassName, NameIsValidLabelValue, - NamespaceName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, Uid, + HasName, HasUid, NameIsValidLabelValue, product_logging::framework::{ValidatedContainerLogConfigChoice, VectorContainerLogConfig}, role_utils::{GenericProductSpecificCommonConfig, RoleGroupConfig}, + types::{ + kubernetes::{ListenerClassName, NamespaceName, Uid}, + operator::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, + RoleGroupName, RoleName, + }, + }, }, }; @@ -380,10 +386,13 @@ mod tests { controller::{OpenSearchNodeResources, ValidatedOpenSearchConfig}, crd::{NodeRoles, v1alpha1}, framework::{ - ClusterName, ListenerClassName, NamespaceName, OperatorName, ProductVersion, - RoleGroupName, builder::pod::container::EnvVarSet, + builder::pod::container::EnvVarSet, product_logging::framework::ValidatedContainerLogConfigChoice, role_utils::GenericProductSpecificCommonConfig, + types::{ + kubernetes::{ListenerClassName, NamespaceName}, + operator::{ClusterName, OperatorName, ProductVersion, RoleGroupName}, + }, }, }; diff --git a/rust/operator-binary/src/controller/apply.rs b/rust/operator-binary/src/controller/apply.rs index 49afd02..1365e68 100644 --- a/rust/operator-binary/src/controller/apply.rs +++ b/rust/operator-binary/src/controller/apply.rs @@ -10,7 +10,13 @@ use stackable_operator::{ use strum::{EnumDiscriminants, IntoStaticStr}; use super::{Applied, ContextNames, KubernetesResources, Prepared}; -use crate::framework::{ClusterName, NamespaceName, Uid, cluster_resources::cluster_resources_new}; +use crate::framework::{ + cluster_resources::cluster_resources_new, + types::{ + kubernetes::{NamespaceName, Uid}, + operator::ClusterName, + }, +}; #[derive(Snafu, Debug, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr))] diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 43c3bda..08a1afb 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -79,9 +79,15 @@ mod tests { }, crd::{NodeRoles, v1alpha1}, framework::{ - ClusterName, ControllerName, ListenerClassName, NamespaceName, OperatorName, - ProductName, ProductVersion, RoleGroupName, builder::pod::container::EnvVarSet, + builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, + types::{ + kubernetes::{ListenerClassName, NamespaceName}, + operator::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, + RoleGroupName, + }, + }, }, }; diff --git a/rust/operator-binary/src/controller/build/node_config.rs b/rust/operator-binary/src/controller/build/node_config.rs index 9249042..6a1aa4b 100644 --- a/rust/operator-binary/src/controller/build/node_config.rs +++ b/rust/operator-binary/src/controller/build/node_config.rs @@ -10,9 +10,9 @@ use crate::{ controller::OpenSearchRoleGroupConfig, crd::v1alpha1, framework::{ - RoleGroupName, ServiceName, builder::pod::container::{EnvVarName, EnvVarSet}, role_group_utils, + types::{kubernetes::ServiceName, operator::RoleGroupName}, }, }; @@ -296,9 +296,12 @@ mod tests { controller::{ValidatedLogging, ValidatedOpenSearchConfig}, crd::NodeRoles, framework::{ - ClusterName, ListenerClassName, NamespaceName, ProductVersion, RoleGroupName, product_logging::framework::ValidatedContainerLogConfigChoice, role_utils::GenericProductSpecificCommonConfig, + types::{ + kubernetes::{ListenerClassName, NamespaceName}, + operator::{ClusterName, ProductVersion, RoleGroupName}, + }, }, }; diff --git a/rust/operator-binary/src/controller/build/role_builder.rs b/rust/operator-binary/src/controller/build/role_builder.rs index 454d7d5..f556909 100644 --- a/rust/operator-binary/src/controller/build/role_builder.rs +++ b/rust/operator-binary/src/controller/build/role_builder.rs @@ -241,9 +241,15 @@ mod tests { }, crd::{NodeRoles, v1alpha1}, framework::{ - ClusterName, ControllerName, ListenerClassName, NamespaceName, OperatorName, - ProductName, ProductVersion, RoleGroupName, builder::pod::container::EnvVarSet, + builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, + types::{ + kubernetes::{ListenerClassName, NamespaceName}, + operator::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, + RoleGroupName, + }, + }, }, }; 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 896c344..8d8880f 100644 --- a/rust/operator-binary/src/controller/build/role_group_builder.rs +++ b/rust/operator-binary/src/controller/build/role_group_builder.rs @@ -42,7 +42,6 @@ use crate::{ }, crd::v1alpha1, framework::{ - PersistentVolumeClaimName, RoleGroupName, ServiceAccountName, ServiceName, VolumeName, builder::{ meta::ownerreference_from_resource, pod::{ @@ -55,6 +54,10 @@ use crate::{ STACKABLE_LOG_DIR, ValidatedContainerLogConfigChoice, vector_container, }, role_group_utils::ResourceNames, + types::{ + kubernetes::{PersistentVolumeClaimName, ServiceAccountName, ServiceName, VolumeName}, + operator::RoleGroupName, + }, }, }; @@ -663,11 +666,19 @@ mod tests { }, crd::{NodeRoles, v1alpha1}, framework::{ - ClusterName, ConfigMapName, ControllerName, ListenerClassName, NamespaceName, - OperatorName, ProductName, ProductVersion, RoleGroupName, ServiceAccountName, - ServiceName, builder::pod::container::EnvVarSet, + builder::pod::container::EnvVarSet, product_logging::framework::VectorContainerLogConfig, role_utils::GenericProductSpecificCommonConfig, + types::{ + kubernetes::{ + ConfigMapName, ListenerClassName, NamespaceName, ServiceAccountName, + ServiceName, + }, + operator::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, + RoleGroupName, + }, + }, }, }; diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index c23ff48..0f6f10c 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -18,12 +18,15 @@ use super::{ use crate::{ crd::v1alpha1::{self}, framework::{ - ClusterName, ConfigMapName, NamespaceName, Uid, builder::pod::container::{EnvVarName, EnvVarSet}, product_logging::framework::{ VectorContainerLogConfig, validate_logging_configuration_for_container, }, role_utils::{GenericProductSpecificCommonConfig, RoleGroupConfig, with_validated_config}, + types::{ + kubernetes::{ConfigMapName, NamespaceName, Uid}, + operator::ClusterName, + }, }, }; @@ -45,13 +48,19 @@ pub enum Error { GetVectorAggregatorConfigMapName {}, #[snafu(display("failed to set cluster name"))] - ParseClusterName { source: crate::framework::Error }, + ParseClusterName { + source: crate::framework::macros::attributed_string_type::Error, + }, #[snafu(display("failed to set cluster namespace"))] - ParseClusterNamespace { source: crate::framework::Error }, + ParseClusterNamespace { + source: crate::framework::macros::attributed_string_type::Error, + }, #[snafu(display("failed to set UID"))] - ParseClusterUid { source: crate::framework::Error }, + ParseClusterUid { + source: crate::framework::macros::attributed_string_type::Error, + }, #[snafu(display("failed to parse environment variable"))] ParseEnvironmentVariable { @@ -59,10 +68,14 @@ pub enum Error { }, #[snafu(display("failed to set product version"))] - ParseProductVersion { source: crate::framework::Error }, + ParseProductVersion { + source: crate::framework::macros::attributed_string_type::Error, + }, #[snafu(display("failed to set role-group name"))] - ParseRoleGroupName { source: crate::framework::Error }, + ParseRoleGroupName { + source: crate::framework::macros::attributed_string_type::Error, + }, #[snafu(display("failed to resolve product image"))] ResolveProductImage { @@ -277,13 +290,18 @@ mod tests { v1alpha1::{self}, }, framework::{ - ClusterName, ConfigMapName, ControllerName, ListenerClassName, NamespaceName, - OperatorName, ProductName, ProductVersion, RoleGroupName, builder::pod::container::{EnvVarName, EnvVarSet}, product_logging::framework::{ ValidatedContainerLogConfigChoice, VectorContainerLogConfig, }, role_utils::{GenericProductSpecificCommonConfig, RoleGroupConfig}, + types::{ + kubernetes::{ConfigMapName, ListenerClassName, NamespaceName}, + operator::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, + RoleGroupName, + }, + }, }, }; diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 4803e70..768ea11 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -29,8 +29,12 @@ use strum::{Display, EnumIter}; use crate::{ constant, framework::{ - ClusterName, ConfigMapName, ContainerName, ListenerClassName, NameIsValidLabelValue, - ProductName, RoleName, role_utils::GenericProductSpecificCommonConfig, + NameIsValidLabelValue, + role_utils::GenericProductSpecificCommonConfig, + types::{ + kubernetes::{ConfigMapName, ContainerName, ListenerClassName}, + operator::{ClusterName, ProductName, RoleName}, + }, }, }; diff --git a/rust/operator-binary/src/framework.rs b/rust/operator-binary/src/framework.rs index b0457bb..2e42c3c 100644 --- a/rust/operator-binary/src/framework.rs +++ b/rust/operator-binary/src/framework.rs @@ -20,65 +20,16 @@ //! become less frequent, then this module can be incorporated into stackable-operator. The module //! structure should already resemble the one of stackable-operator. -use std::str::FromStr; - -use snafu::Snafu; -use stackable_operator::validation::{RFC_1123_LABEL_MAX_LENGTH, RFC_1123_SUBDOMAIN_MAX_LENGTH}; -use strum::{EnumDiscriminants, IntoStaticStr}; +use types::kubernetes::Uid; pub mod builder; pub mod cluster_resources; pub mod kvp; +pub mod macros; pub mod product_logging; pub mod role_group_utils; pub mod role_utils; - -#[derive(Debug, EnumDiscriminants, Snafu)] -#[strum_discriminants(derive(IntoStaticStr))] -pub enum Error { - #[snafu(display("empty strings are not allowed"))] - EmptyString {}, - - #[snafu(display("minimum length not met"))] - MinimumLengthNotMet { length: usize, min_length: usize }, - - #[snafu(display("maximum length exceeded"))] - LengthExceeded { length: usize, max_length: usize }, - - #[snafu(display("invalid regular expression"))] - InvalidRegex { source: regex::Error }, - - #[snafu(display("regular expression not matched"))] - RegexNotMatched { value: String, regex: &'static str }, - - #[snafu(display("not a valid label value"))] - InvalidLabelValue { - source: stackable_operator::kvp::LabelValueError, - }, - - #[snafu(display("not a valid label name as defined in RFC 1035"))] - InvalidRfc1035LabelName { - source: stackable_operator::validation::Errors, - }, - - #[snafu(display("not a valid DNS subdomain name as defined in RFC 1123"))] - InvalidRfc1123DnsSubdomainName { - source: stackable_operator::validation::Errors, - }, - - #[snafu(display("not a valid label name as defined in RFC 1123"))] - InvalidRfc1123LabelName { - source: stackable_operator::validation::Errors, - }, - - #[snafu(display("not a valid UUID"))] - InvalidUid { source: uuid::Error }, -} - -/// Maximum length of label values -/// -/// Duplicates the private constant [`stackable-operator::kvp::label::value::LABEL_VALUE_MAX_LEN`] -pub const MAX_LABEL_VALUE_LENGTH: usize = 63; +pub mod types; /// Has a non-empty name /// @@ -98,937 +49,3 @@ pub trait HasUid { pub trait NameIsValidLabelValue { fn to_label_value(&self) -> String; } - -#[derive(Clone, Copy, Debug)] -pub enum Regex { - /// There is a regular expression but it is unknown (or too complicated). - Unknown, - - /// `MatchAll` equals Expression(".*") but can be matched in a const context. - MatchAll, - - /// There is a regular expression. - Expression(&'static str), -} - -impl Regex { - pub const fn combine(self, other: Regex) -> Regex { - match (self, other) { - (_, Regex::MatchAll) => self, - (Regex::MatchAll, _) => other, - _ => Regex::Unknown, - } - } -} - -/// Restricted string type with attributes like maximum length. -/// -/// Fully-qualified types are used to ease the import into other modules. -/// -/// # Examples -/// -/// ```rust -/// attributed_string_type! { -/// ConfigMapName, -/// "The name of a ConfigMap", -/// "opensearch-nodes-default", -/// is_rfc_1123_dns_subdomain_name -/// } -/// ``` -#[macro_export(local_inner_macros)] -macro_rules! attributed_string_type { - ($name:ident, $description:literal, $example:literal $(, $attribute:tt)*) => { - #[doc = std::concat!($description, ", e.g. \"", $example, "\"")] - #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] - pub struct $name(String); - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } - } - - impl From<$name> for String { - fn from(value: $name) -> Self { - value.0 - } - } - - impl From<&$name> for String { - fn from(value: &$name) -> Self { - value.0.clone() - } - } - - impl AsRef for $name { - fn as_ref(&self) -> &str { - &self.0 - } - } - - impl std::str::FromStr for $name { - type Err = $crate::framework::Error; - - fn from_str(s: &str) -> std::result::Result { - // ResultExt::context is used on most but not all usages of this macro - #[allow(unused_imports)] - use snafu::ResultExt; - - snafu::ensure!( - !s.is_empty(), - $crate::framework::EmptyStringSnafu {} - ); - - $(attributed_string_type!(@from_str $name, s, $attribute);)* - - Ok(Self(s.to_owned())) - } - } - - impl<'de> serde::Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let string: String = serde::Deserialize::deserialize(deserializer)?; - $name::from_str(&string).map_err(|err| serde::de::Error::custom(&err)) - } - } - - impl serde::Serialize for $name { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.0.serialize(serializer) - } - } - - impl stackable_operator::config::merge::Atomic for $name {} - - impl $name { - pub const MIN_LENGTH: usize = attributed_string_type!(@min_length $($attribute)*); - pub const MAX_LENGTH: usize = attributed_string_type!(@max_length $($attribute)*); - - /// None if there are restrictions but the regular expression could not be calculated. - pub const REGEX: $crate::framework::Regex = attributed_string_type!(@regex $($attribute)*); - } - - // The JsonSchema implementation requires `max_length`. - impl schemars::JsonSchema for $name { - fn schema_name() -> std::borrow::Cow<'static, str> { - std::stringify!($name).into() - } - - fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { - schemars::json_schema!({ - "type": "string", - "minLength": $name::MIN_LENGTH, - "maxLength": if $name::MAX_LENGTH != usize::MAX { - Some($name::MAX_LENGTH) - } else { - // Do not set maxLength if it is usize::MAX. - None - }, - "pattern": match $name::REGEX { - $crate::framework::Regex::Expression(regex) => Some(std::format!("^{regex}$")), - _ => None - } - }) - } - } - - #[cfg(test)] - impl $name { - #[allow(dead_code)] - pub fn from_str_unsafe(s: &str) -> Self { - std::str::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);)* - }; - - // std::str::FromStr - - (@from_str $name:ident, $s:expr, (min_length = $min_length:expr)) => { - let length = $s.len() as usize; - snafu::ensure!( - length >= $name::MIN_LENGTH, - $crate::framework::MinimumLengthNotMetSnafu { - length, - min_length: $name::MIN_LENGTH, - } - ); - }; - (@from_str $name:ident, $s:expr, (max_length = $max_length:expr)) => { - let length = $s.len() as usize; - snafu::ensure!( - length <= $name::MAX_LENGTH, - $crate::framework::LengthExceededSnafu { - length, - max_length: $name::MAX_LENGTH, - } - ); - }; - (@from_str $name:ident, $s:expr, (regex = $regex:expr)) => { - let regex = regex::Regex::new($regex).context($crate::framework::InvalidRegexSnafu)?; - snafu::ensure!( - regex.is_match($s), - $crate::framework::RegexNotMatchedSnafu { - value: $s, - regex: $regex - } - ); - }; - (@from_str $name:ident, $s:expr, is_rfc_1035_label_name) => { - stackable_operator::validation::is_lowercase_rfc_1035_label($s).context($crate::framework::InvalidRfc1035LabelNameSnafu)?; - }; - (@from_str $name:ident, $s:expr, is_rfc_1123_dns_subdomain_name) => { - stackable_operator::validation::is_lowercase_rfc_1123_subdomain($s).context($crate::framework::InvalidRfc1123DnsSubdomainNameSnafu)?; - }; - (@from_str $name:ident, $s:expr, is_rfc_1123_label_name) => { - stackable_operator::validation::is_lowercase_rfc_1123_label($s).context($crate::framework::InvalidRfc1123LabelNameSnafu)?; - }; - (@from_str $name:ident, $s:expr, is_valid_label_value) => { - stackable_operator::kvp::LabelValue::from_str($s).context($crate::framework::InvalidLabelValueSnafu)?; - }; - (@from_str $name:ident, $s:expr, is_uid) => { - uuid::Uuid::try_parse($s).context($crate::framework::InvalidUidSnafu)?; - }; - - // MIN_LENGTH - - (@min_length) => { - // The minimum String length is 0. - 0 - }; - (@min_length (min_length = $min_length:expr) $($attribute:tt)*) => { - $crate::framework::max( - $min_length, - attributed_string_type!(@min_length $($attribute)*) - ) - }; - (@min_length (max_length = $max_length:expr) $($attribute:tt)*) => { - // max_length has no opinion on the min_length. - attributed_string_type!(@min_length $($attribute)*) - }; - (@min_length (regex = $regex:expr) $($attribute:tt)*) => { - // regex has no influence on the min_length. - attributed_string_type!(@min_length $($attribute)*) - }; - (@min_length is_rfc_1035_label_name $($attribute:tt)*) => { - $crate::framework::max( - 1, - attributed_string_type!(@min_length $($attribute)*) - ) - }; - (@min_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { - $crate::framework::max( - 1, - attributed_string_type!(@min_length $($attribute)*) - ) - }; - (@min_length is_rfc_1123_label_name $($attribute:tt)*) => { - $crate::framework::max( - 1, - attributed_string_type!(@min_length $($attribute)*) - ) - }; - (@min_length is_valid_label_value $($attribute:tt)*) => { - $crate::framework::max( - 1, - attributed_string_type!(@min_length $($attribute)*) - ) - }; - (@min_length is_uid $($attribute:tt)*) => { - $crate::framework::max( - uuid::fmt::Hyphenated::LENGTH, - attributed_string_type!(@min_length $($attribute)*) - ) - }; - - // MAX_LENGTH - - (@max_length) => { - // If there is no other max_length defined, then the upper bound is usize::MAX. - usize::MAX - }; - (@max_length (min_length = $min_length:expr) $($attribute:tt)*) => { - // min_length has no opinion on the max_length. - attributed_string_type!(@max_length $($attribute)*) - }; - (@max_length (max_length = $max_length:expr) $($attribute:tt)*) => { - $crate::framework::min( - $max_length, - attributed_string_type!(@max_length $($attribute)*) - ) - }; - (@max_length (regex = $regex:expr) $($attribute:tt)*) => { - // regex has no influence on the max_length. - attributed_string_type!(@max_length $($attribute)*) - }; - (@max_length is_rfc_1035_label_name $($attribute:tt)*) => { - $crate::framework::min( - stackable_operator::validation::RFC_1035_LABEL_MAX_LENGTH, - attributed_string_type!(@max_length $($attribute)*) - ) - }; - (@max_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { - $crate::framework::min( - stackable_operator::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH, - attributed_string_type!(@max_length $($attribute)*) - ) - }; - (@max_length is_rfc_1123_label_name $($attribute:tt)*) => { - $crate::framework::min( - stackable_operator::validation::RFC_1123_LABEL_MAX_LENGTH, - attributed_string_type!(@max_length $($attribute)*) - ) - }; - (@max_length is_valid_label_value $($attribute:tt)*) => { - $crate::framework::min( - $crate::framework::MAX_LABEL_VALUE_LENGTH, - attributed_string_type!(@max_length $($attribute)*) - ) - }; - (@max_length is_uid $($attribute:tt)*) => { - $crate::framework::min( - uuid::fmt::Hyphenated::LENGTH, - attributed_string_type!(@max_length $($attribute)*) - ) - }; - - // REGEX - - (@regex) => { - // Everything is allowed if there is no other regular expression. - $crate::framework::Regex::MatchAll - }; - (@regex (min_length = $min_length:expr) $($attribute:tt)*) => { - // min_length has no influence on the regular expression. - attributed_string_type!(@regex $($attribute)*) - }; - (@regex (max_length = $max_length:expr) $($attribute:tt)*) => { - // max_length has no influence on the regular expression. - attributed_string_type!(@regex $($attribute)*) - }; - (@regex (regex = $regex:expr) $($attribute:tt)*) => { - $crate::framework::Regex::Expression($regex) - .combine(attributed_string_type!(@regex $($attribute)*)) - }; - (@regex is_rfc_1035_label_name $($attribute:tt)*) => { - $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1035_LABEL_FMT) - .combine(attributed_string_type!(@regex $($attribute)*)) - }; - (@regex is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { - $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1123_SUBDOMAIN_FMT) - .combine(attributed_string_type!(@regex $($attribute)*)) - }; - (@regex is_rfc_1123_label_name $($attribute:tt)*) => { - $crate::framework::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1123_LABEL_FMT) - .combine(attributed_string_type!(@regex $($attribute)*)) - }; - (@regex is_valid_label_value $($attribute:tt)*) => { - // regular expression from stackable_operator::kvp::label::LABEL_VALUE_REGEX - $crate::framework::Regex::Expression("[a-z0-9A-Z]([a-z0-9A-Z-_.]*[a-z0-9A-Z]+)?") - .combine(attributed_string_type!(@regex $($attribute)*)) - }; - (@regex is_uid $($attribute:tt)*) => { - $crate::framework::Regex::Expression("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") - .combine(attributed_string_type!(@regex $($attribute)*)) - }; - - // additional constants and trait implementations - - (@trait_impl $name:ident, (min_length = $max_length:expr)) => { - }; - (@trait_impl $name:ident, (max_length = $max_length:expr)) => { - }; - (@trait_impl $name:ident, (regex = $regex:expr)) => { - }; - (@trait_impl $name:ident, is_rfc_1035_label_name) => { - impl $name { - pub const IS_RFC_1035_LABEL_NAME: bool = true; - pub const IS_RFC_1123_LABEL_NAME: bool = true; - pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; - } - }; - (@trait_impl $name:ident, is_rfc_1123_dns_subdomain_name) => { - impl $name { - pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; - } - }; - (@trait_impl $name:ident, is_rfc_1123_label_name) => { - impl $name { - pub const IS_RFC_1123_LABEL_NAME: bool = true; - pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; - } - }; - (@trait_impl $name:ident, is_uid) => { - impl From for $name { - fn from(value: uuid::Uuid) -> Self { - Self(value.to_string()) - } - } - - impl From<&uuid::Uuid> for $name { - fn from(value: &uuid::Uuid) -> Self { - Self(value.to_string()) - } - } - }; - (@trait_impl $name:ident, is_valid_label_value) => { - impl $name { - pub const IS_VALID_LABEL_VALUE: bool = true; - } - - impl $crate::framework::NameIsValidLabelValue for $name { - fn to_label_value(&self) -> String { - self.0.clone() - } - } - }; -} - -/// Use [`std::sync::LazyLock`] to define a static "constant" from a string. -/// -/// The string is converted into the given type with [`std::str::FromStr::from_str`]. -/// -/// # Examples -/// -/// ```rust -/// constant!(DATA_VOLUME_NAME: VolumeName = "data"); -/// constant!(pub CONFIG_VOLUME_NAME: VolumeName = "config"); -/// ``` -#[macro_export(local_inner_macros)] -macro_rules! constant { - ($qualifier:vis $name:ident: $type:ident = $value:literal) => { - $qualifier static $name: std::sync::LazyLock<$type> = - std::sync::LazyLock::new(|| $type::from_str($value).expect("should be a valid $name")); - }; -} - -/// Returns the minimum of the given values. -/// -/// As opposed to [`std::cmp::min`], this function can be used at compile-time. -/// -/// # Examples -/// -/// ```rust -/// assert_eq!(2, min(2, 3)); -/// assert_eq!(4, min(5, 4)); -/// assert_eq!(1, min(1, 1)); -/// ``` -pub const fn min(x: usize, y: usize) -> usize { - if x < y { x } else { y } -} - -/// Returns the maximum of the given values. -/// -/// As opposed to [`std::cmp::max`], this function can be used at compile-time. -/// -/// # Examples -/// -/// ```rust -/// assert_eq!(3, max(2, 3)); -/// assert_eq!(5, max(5, 4)); -/// assert_eq!(1, max(1, 1)); -/// ``` -pub const fn max(x: usize, y: usize) -> usize { - if x < y { y } else { x } -} - -// Kubernetes (resource) names - -attributed_string_type! { - ConfigMapName, - "The name of a ConfigMap", - "opensearch-nodes-default", - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - ConfigMapKey, - "The key for a ConfigMap", - "log4j2.properties", - (min_length = 1), - // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), - (regex = "[-._a-zA-Z0-9]+") -} -attributed_string_type! { - ContainerName, - "The name of a container in a Pod", - "opensearch", - is_rfc_1123_label_name -} -attributed_string_type! { - ClusterRoleName, - "The name of a ClusterRole", - "opensearch-clusterrole", - // On the one hand, ClusterRoles must only contain characters that are allowed for DNS - // subdomain names, on the other hand, their length does not seem to be restricted – at least - // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid - // problems on other Kubernetes providers, the length is restricted here. - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - ListenerName, - "The name of a Listener", - "opensearch-nodes-default", - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - ListenerClassName, - "The name of a Listener", - "external-stable", - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - NamespaceName, - "The name of a Namespace", - "stackable-operators", - is_rfc_1123_label_name, - is_valid_label_value -} -attributed_string_type! { - PersistentVolumeClaimName, - "The name of a PersistentVolumeClaim", - "config", - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - RoleBindingName, - "The name of a RoleBinding", - "opensearch-rolebinding", - // On the one hand, RoleBindings must only contain characters that are allowed for DNS - // subdomain names, on the other hand, their length does not seem to be restricted – at least - // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid - // problems on other Kubernetes providers, the length is restricted here. - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - SecretKey, - "The key for a Secret", - "accessKey", - (min_length = 1), - // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 - (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), - (regex = "[-._a-zA-Z0-9]+") -} -attributed_string_type! { - ServiceAccountName, - "The name of a ServiceAccount", - "opensearch-serviceaccount", - is_rfc_1123_dns_subdomain_name -} -attributed_string_type! { - ServiceName, - "The name of a Service", - "opensearch-nodes-default-headless", - is_rfc_1035_label_name, - is_valid_label_value -} -attributed_string_type! { - StatefulSetName, - "The name of a StatefulSet", - "opensearch-nodes-default", - (max_length = - // see https://github.com/kubernetes/kubernetes/issues/64023 - RFC_1123_LABEL_MAX_LENGTH - - 1 /* dash */ - - 10 /* digits for the controller-revision-hash label */), - is_rfc_1123_label_name, - is_valid_label_value -} -attributed_string_type! { - Uid, - "A UID", - "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", - is_uid, - is_valid_label_value -} -attributed_string_type! { - VolumeName, - "The name of a Volume", - "opensearch-nodes-default", - is_rfc_1123_label_name, - is_valid_label_value -} - -// Operator names - -attributed_string_type! { - ProductName, - "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), - is_rfc_1123_dns_subdomain_name, - is_valid_label_value -} -attributed_string_type! { - ProductVersion, - "The version of a product", - "3.1.0", - is_valid_label_value -} -attributed_string_type! { - ClusterName, - "The name of a cluster/stacklet", - "my-opensearch-cluster", - // Suffixes are added to produce resource names. According compile-time checks ensure that - // max_length cannot be set higher. - (max_length = 24), - is_rfc_1035_label_name, - is_valid_label_value -} -attributed_string_type! { - ControllerName, - "The name of a controller in an operator", - "opensearchcluster", - is_valid_label_value -} -attributed_string_type! { - OperatorName, - "The name of an operator", - "opensearch.stackable.tech", - is_valid_label_value -} -attributed_string_type! { - RoleGroupName, - "The name of a role-group name", - "cluster-manager", - // The role-group name is used to produce resource names. To make sure that all resource names - // are valid, max_length is restricted. Compile-time checks ensure that max_length cannot be - // set higher if not other names like the RoleName are set lower accordingly. - (max_length = 16), - is_rfc_1123_label_name, - is_valid_label_value -} -attributed_string_type! { - RoleName, - "The name of a role name", - "nodes", - // The role name is used to produce resource names. To make sure that all resource names are - // valid, max_length is restricted. Compile-time checks ensure that max_length cannot be set - // higher if not other names like the RoleGroupName are set lower accordingly. - (max_length = 10), - is_rfc_1123_label_name, - is_valid_label_value -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use schemars::{JsonSchema, SchemaGenerator}; - use serde_json::{Number, Value, json}; - use uuid::uuid; - - use super::{ - ClusterName, ClusterRoleName, ConfigMapKey, ConfigMapName, ContainerName, ControllerName, - ErrorDiscriminants, ListenerClassName, ListenerName, NamespaceName, OperatorName, - PersistentVolumeClaimName, ProductVersion, RoleBindingName, RoleGroupName, RoleName, - ServiceAccountName, ServiceName, StatefulSetName, Uid, VolumeName, - }; - use crate::framework::{NameIsValidLabelValue, ProductName}; - - #[test] - fn test_attributed_string_type_examples() { - ConfigMapName::test_example(); - ConfigMapKey::test_example(); - ContainerName::test_example(); - ClusterRoleName::test_example(); - ListenerName::test_example(); - ListenerClassName::test_example(); - NamespaceName::test_example(); - PersistentVolumeClaimName::test_example(); - RoleBindingName::test_example(); - ServiceAccountName::test_example(); - ServiceName::test_example(); - StatefulSetName::test_example(); - Uid::test_example(); - VolumeName::test_example(); - - ProductName::test_example(); - ProductVersion::test_example(); - ClusterName::test_example(); - ControllerName::test_example(); - OperatorName::test_example(); - RoleGroupName::test_example(); - RoleName::test_example(); - } - - attributed_string_type! { - DisplayFmtTest, - "Display::fmt test", - "test" - } - - #[test] - fn test_attributed_string_type_display_fmt() { - type T = DisplayFmtTest; - - assert_eq!("test", format!("{}", T::from_str_unsafe("test"))); - } - - attributed_string_type! { - StringFromTest, - "String::from test", - "test" - } - - #[test] - fn test_attributed_string_type_string_from() { - type T = StringFromTest; - - T::test_example(); - assert_eq!("test", String::from(T::from_str_unsafe("test"))); - assert_eq!("test", String::from(&T::from_str_unsafe("test"))); - } - - attributed_string_type! { - LengthTest, - "empty string and max_length test", - "test", - (max_length = 4) - } - - #[test] - fn test_attributed_string_type_length() { - type T = LengthTest; - - T::test_example(); - assert_eq!(4, T::MAX_LENGTH); - assert_eq!( - Err(ErrorDiscriminants::EmptyString), - T::from_str("").map_err(ErrorDiscriminants::from) - ); - assert_eq!( - Err(ErrorDiscriminants::LengthExceeded), - T::from_str("testX").map_err(ErrorDiscriminants::from) - ); - } - - attributed_string_type! { - JsonSchemaTest, - "JsonSchemaTest test", - "test", - (min_length = 4), - (max_length = 8), - (regex = "[est]+") - } - - #[test] - fn test_attributed_string_type_json_schema() { - type T = JsonSchemaTest; - - T::test_example(); - assert_eq!("JsonSchemaTest", JsonSchemaTest::schema_name()); - assert_eq!( - json!({ - "type": "string", - "minLength": 4, - "maxLength": 8, - "pattern": "^[est]+$", - }), - JsonSchemaTest::json_schema(&mut SchemaGenerator::default()) - ); - } - - attributed_string_type! { - SerializeTest, - "serde::Serialize test", - "test" - } - - #[test] - fn test_attributed_string_type_serialize() { - type T = SerializeTest; - - T::test_example(); - assert_eq!( - "\"test\"".to_owned(), - serde_json::to_string(&T::from_str_unsafe("test")).expect("should be serializable") - ); - } - - attributed_string_type! { - DeserializeTest, - "serde::Deserialize test", - "test", - (max_length = 4), - is_rfc_1123_label_name - } - - #[test] - fn test_attributed_string_type_deserialize() { - type T = DeserializeTest; - - T::test_example(); - assert_eq!( - T::from_str_unsafe("test"), - serde_json::from_value(Value::String("test".to_owned())) - .expect("should be deserializable") - ); - assert_eq!( - Err("empty strings are not allowed".to_owned()), - serde_json::from_value::(Value::String("".to_owned())) - .map_err(|err| err.to_string()) - ); - assert_eq!( - Err("maximum length exceeded".to_owned()), - serde_json::from_value::(Value::String("testx".to_owned())) - .map_err(|err| err.to_string()) - ); - assert_eq!( - Err("not a valid label name as defined in RFC 1123".to_owned()), - serde_json::from_value::(Value::String("-".to_owned())) - .map_err(|err| err.to_string()) - ); - assert_eq!( - Err("invalid type: null, expected a string".to_owned()), - serde_json::from_value::(Value::Null).map_err(|err| err.to_string()) - ); - assert_eq!( - Err("invalid type: boolean `true`, expected a string".to_owned()), - serde_json::from_value::(Value::Bool(true)).map_err(|err| err.to_string()) - ); - assert_eq!( - Err("invalid type: integer `1`, expected a string".to_owned()), - serde_json::from_value::(Value::Number( - Number::from_i128(1).expect("should be a valid number") - )) - .map_err(|err| err.to_string()) - ); - assert_eq!( - Err("invalid type: sequence, expected a string".to_owned()), - serde_json::from_value::(Value::Array(vec![])).map_err(|err| err.to_string()) - ); - assert_eq!( - Err("invalid type: map, expected a string".to_owned()), - serde_json::from_value::(Value::Object(serde_json::Map::new())) - .map_err(|err| err.to_string()) - ); - } - - attributed_string_type! { - IsRfc1035LabelNameTest, - "is_rfc_1035_label_name test", - "a-b", - is_rfc_1035_label_name - } - - #[test] - fn test_attributed_string_type_is_rfc_1035_label_name() { - type T = IsRfc1035LabelNameTest; - - let _ = T::IS_RFC_1035_LABEL_NAME; - let _ = T::IS_RFC_1123_LABEL_NAME; - let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; - - T::test_example(); - assert_eq!( - Err(ErrorDiscriminants::InvalidRfc1035LabelName), - T::from_str("A").map_err(ErrorDiscriminants::from) - ); - } - - attributed_string_type! { - IsRfc1123DnsSubdomainNameTest, - "is_rfc_1123_dns_subdomain_name test", - "a-b.c", - is_rfc_1123_dns_subdomain_name - } - - #[test] - fn test_attributed_string_type_is_rfc_1123_dns_subdomain_name() { - type T = IsRfc1123DnsSubdomainNameTest; - - let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; - - T::test_example(); - assert_eq!( - Err(ErrorDiscriminants::InvalidRfc1123DnsSubdomainName), - T::from_str("A").map_err(ErrorDiscriminants::from) - ); - } - - attributed_string_type! { - IsRfc1123LabelNameTest, - "is_rfc_1123_label_name test", - "1-a", - is_rfc_1123_label_name - } - - #[test] - fn test_attributed_string_type_is_rfc_1123_label_name() { - type T = IsRfc1123LabelNameTest; - - let _ = T::IS_RFC_1123_LABEL_NAME; - let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; - - T::test_example(); - assert_eq!( - Err(ErrorDiscriminants::InvalidRfc1123LabelName), - T::from_str("A").map_err(ErrorDiscriminants::from) - ); - } - - attributed_string_type! { - IsValidLabelValueTest, - "is_valid_label_value test", - "a-_.1", - is_valid_label_value - } - - #[test] - fn test_attributed_string_type_is_valid_label_value() { - type T = IsValidLabelValueTest; - - let _ = T::IS_VALID_LABEL_VALUE; - - T::test_example(); - assert_eq!( - Err(ErrorDiscriminants::InvalidLabelValue), - T::from_str("invalid label value").map_err(ErrorDiscriminants::from) - ); - assert_eq!( - "label-value", - T::from_str_unsafe("label-value").to_label_value() - ); - } - - attributed_string_type! { - IsUidTest, - "is_uid test", - "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", - is_uid - } - - #[test] - fn test_attributed_string_type_is_uid() { - type T = IsUidTest; - - T::test_example(); - assert_eq!( - Err(ErrorDiscriminants::InvalidUid), - T::from_str("invalid UID").map_err(ErrorDiscriminants::from) - ); - assert_eq!( - "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", - T::from(uuid!("c27b3971-ca72-42c1-80a4-abdfc1db0ddd")).to_string() - ); - assert_eq!( - "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", - T::from(&uuid!("c27b3971-ca72-42c1-80a4-abdfc1db0ddd")).to_string() - ); - } -} diff --git a/rust/operator-binary/src/framework/builder/pdb.rs b/rust/operator-binary/src/framework/builder/pdb.rs index b45383e..b9c7fdc 100644 --- a/rust/operator-binary/src/framework/builder/pdb.rs +++ b/rust/operator-binary/src/framework/builder/pdb.rs @@ -5,7 +5,8 @@ use stackable_operator::{ }; use crate::framework::{ - ControllerName, HasName, HasUid, NameIsValidLabelValue, OperatorName, ProductName, RoleName, + HasName, HasUid, NameIsValidLabelValue, + types::operator::{ControllerName, OperatorName, ProductName, RoleName}, }; /// Infallible variant of @@ -46,8 +47,12 @@ mod tests { }; use crate::framework::{ - ControllerName, HasName, HasUid, NameIsValidLabelValue, OperatorName, ProductName, - RoleName, Uid, builder::pdb::pod_disruption_budget_builder_with_role, + HasName, HasUid, NameIsValidLabelValue, + builder::pdb::pod_disruption_budget_builder_with_role, + types::{ + kubernetes::Uid, + operator::{ControllerName, OperatorName, ProductName, RoleName}, + }, }; struct Cluster { diff --git a/rust/operator-binary/src/framework/builder/pod/container.rs b/rust/operator-binary/src/framework/builder/pod/container.rs index a7a8ced..244bf00 100644 --- a/rust/operator-binary/src/framework/builder/pod/container.rs +++ b/rust/operator-binary/src/framework/builder/pod/container.rs @@ -7,7 +7,7 @@ use stackable_operator::{ }; use strum::{EnumDiscriminants, IntoStaticStr}; -use crate::framework::{ConfigMapKey, ConfigMapName, ContainerName}; +use crate::framework::types::kubernetes::{ConfigMapKey, ConfigMapName, ContainerName}; #[derive(Snafu, Debug, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr))] @@ -187,7 +187,8 @@ mod tests { use super::{EnvVarName, EnvVarSet}; use crate::framework::{ - ConfigMapKey, ConfigMapName, ContainerName, builder::pod::container::new_container_builder, + builder::pod::container::new_container_builder, + types::kubernetes::{ConfigMapKey, ConfigMapName, ContainerName}, }; #[test] diff --git a/rust/operator-binary/src/framework/builder/pod/volume.rs b/rust/operator-binary/src/framework/builder/pod/volume.rs index ea1121f..06dc484 100644 --- a/rust/operator-binary/src/framework/builder/pod/volume.rs +++ b/rust/operator-binary/src/framework/builder/pod/volume.rs @@ -3,7 +3,9 @@ use stackable_operator::{ k8s_openapi::api::core::v1::PersistentVolumeClaim, kvp::Labels, }; -use crate::framework::{ListenerClassName, ListenerName, PersistentVolumeClaimName}; +use crate::framework::types::kubernetes::{ + ListenerClassName, ListenerName, PersistentVolumeClaimName, +}; /// Infallible variant of [`stackable_operator::builder::pod::volume::ListenerReference`] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/rust/operator-binary/src/framework/cluster_resources.rs b/rust/operator-binary/src/framework/cluster_resources.rs index b218e53..a57c9fa 100644 --- a/rust/operator-binary/src/framework/cluster_resources.rs +++ b/rust/operator-binary/src/framework/cluster_resources.rs @@ -3,8 +3,13 @@ use stackable_operator::{ k8s_openapi::api::core::v1::ObjectReference, }; -use super::{ClusterName, ControllerName, NamespaceName, OperatorName, ProductName, Uid}; -use crate::framework::{MAX_LABEL_VALUE_LENGTH, NameIsValidLabelValue}; +use super::types::{ + kubernetes::{NamespaceName, Uid}, + operator::{ClusterName, ControllerName, OperatorName, ProductName}, +}; +use crate::framework::{ + NameIsValidLabelValue, macros::attributed_string_type::MAX_LABEL_VALUE_LENGTH, +}; /// Infallible variant of [`stackable_operator::cluster_resources::ClusterResources::new`] pub fn cluster_resources_new( diff --git a/rust/operator-binary/src/framework/kvp/label.rs b/rust/operator-binary/src/framework/kvp/label.rs index 7f87e0f..918e69a 100644 --- a/rust/operator-binary/src/framework/kvp/label.rs +++ b/rust/operator-binary/src/framework/kvp/label.rs @@ -4,8 +4,10 @@ use stackable_operator::{ }; use crate::framework::{ - ControllerName, HasName, NameIsValidLabelValue, OperatorName, ProductName, ProductVersion, - RoleGroupName, RoleName, + HasName, NameIsValidLabelValue, + types::operator::{ + ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, + }, }; /// Infallible variant of [`stackable_operator::kvp::Labels::recommended`] @@ -72,9 +74,11 @@ mod tests { }; use crate::framework::{ - ControllerName, HasName, NameIsValidLabelValue, OperatorName, ProductName, ProductVersion, - RoleGroupName, RoleName, + HasName, NameIsValidLabelValue, kvp::label::{recommended_labels, role_group_selector, role_selector}, + types::operator::{ + ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, + }, }; struct Cluster { diff --git a/rust/operator-binary/src/framework/macros.rs b/rust/operator-binary/src/framework/macros.rs new file mode 100644 index 0000000..c25def9 --- /dev/null +++ b/rust/operator-binary/src/framework/macros.rs @@ -0,0 +1,2 @@ +pub mod attributed_string_type; +pub mod constant; diff --git a/rust/operator-binary/src/framework/macros/attributed_string_type.rs b/rust/operator-binary/src/framework/macros/attributed_string_type.rs new file mode 100644 index 0000000..db4fb91 --- /dev/null +++ b/rust/operator-binary/src/framework/macros/attributed_string_type.rs @@ -0,0 +1,755 @@ +use snafu::Snafu; +use strum::{EnumDiscriminants, IntoStaticStr}; + +/// Maximum length of label values +/// +/// Duplicates the private constant [`stackable-operator::kvp::label::value::LABEL_VALUE_MAX_LEN`] +pub const MAX_LABEL_VALUE_LENGTH: usize = 63; + +#[derive(Debug, EnumDiscriminants, Snafu)] +#[snafu(visibility(pub))] +#[strum_discriminants(derive(IntoStaticStr))] +pub enum Error { + #[snafu(display("empty strings are not allowed"))] + EmptyString {}, + + #[snafu(display("minimum length not met"))] + MinimumLengthNotMet { length: usize, min_length: usize }, + + #[snafu(display("maximum length exceeded"))] + LengthExceeded { length: usize, max_length: usize }, + + #[snafu(display("invalid regular expression"))] + InvalidRegex { source: regex::Error }, + + #[snafu(display("regular expression not matched"))] + RegexNotMatched { value: String, regex: &'static str }, + + #[snafu(display("not a valid label value"))] + InvalidLabelValue { + source: stackable_operator::kvp::LabelValueError, + }, + + #[snafu(display("not a valid label name as defined in RFC 1035"))] + InvalidRfc1035LabelName { + source: stackable_operator::validation::Errors, + }, + + #[snafu(display("not a valid DNS subdomain name as defined in RFC 1123"))] + InvalidRfc1123DnsSubdomainName { + source: stackable_operator::validation::Errors, + }, + + #[snafu(display("not a valid label name as defined in RFC 1123"))] + InvalidRfc1123LabelName { + source: stackable_operator::validation::Errors, + }, + + #[snafu(display("not a valid UUID"))] + InvalidUid { source: uuid::Error }, +} + +#[derive(Clone, Copy, Debug)] +pub enum Regex { + /// There is a regular expression but it is unknown (or too complicated). + Unknown, + + /// `MatchAll` equals Expression(".*") but can be matched in a const context. + MatchAll, + + /// There is a regular expression. + Expression(&'static str), +} + +impl Regex { + pub const fn combine(self, other: Regex) -> Regex { + match (self, other) { + (_, Regex::MatchAll) => self, + (Regex::MatchAll, _) => other, + _ => Regex::Unknown, + } + } +} + +/// Restricted string type with attributes like maximum length. +/// +/// Fully-qualified types are used to ease the import into other modules. +/// +/// # Examples +/// +/// ```rust +/// attributed_string_type! { +/// ConfigMapName, +/// "The name of a ConfigMap", +/// "opensearch-nodes-default", +/// is_rfc_1123_dns_subdomain_name +/// } +/// ``` +#[macro_export(local_inner_macros)] +macro_rules! attributed_string_type { + ($name:ident, $description:literal, $example:literal $(, $attribute:tt)*) => { + #[doc = std::concat!($description, ", e.g. \"", $example, "\"")] + #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] + pub struct $name(String); + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + + impl From<&$name> for String { + fn from(value: &$name) -> Self { + value.0.clone() + } + } + + impl AsRef for $name { + fn as_ref(&self) -> &str { + &self.0 + } + } + + impl std::str::FromStr for $name { + type Err = $crate::framework::macros::attributed_string_type::Error; + + fn from_str(s: &str) -> std::result::Result { + // ResultExt::context is used on most but not all usages of this macro + #[allow(unused_imports)] + use snafu::ResultExt; + + snafu::ensure!( + !s.is_empty(), + $crate::framework::macros::attributed_string_type::EmptyStringSnafu {} + ); + + $(attributed_string_type!(@from_str $name, s, $attribute);)* + + Ok(Self(s.to_owned())) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let string: String = serde::Deserialize::deserialize(deserializer)?; + $name::from_str(&string).map_err(|err| serde::de::Error::custom(&err)) + } + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } + } + + impl stackable_operator::config::merge::Atomic for $name {} + + impl $name { + pub const MIN_LENGTH: usize = attributed_string_type!(@min_length $($attribute)*); + pub const MAX_LENGTH: usize = attributed_string_type!(@max_length $($attribute)*); + + /// None if there are restrictions but the regular expression could not be calculated. + pub const REGEX: $crate::framework::macros::attributed_string_type::Regex = attributed_string_type!(@regex $($attribute)*); + } + + // The JsonSchema implementation requires `max_length`. + impl schemars::JsonSchema for $name { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::stringify!($name).into() + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "minLength": $name::MIN_LENGTH, + "maxLength": if $name::MAX_LENGTH != usize::MAX { + Some($name::MAX_LENGTH) + } else { + // Do not set maxLength if it is usize::MAX. + None + }, + "pattern": match $name::REGEX { + $crate::framework::macros::attributed_string_type::Regex::Expression(regex) => Some(std::format!("^{regex}$")), + _ => None + } + }) + } + } + + #[cfg(test)] + impl $name { + #[allow(dead_code)] + pub fn from_str_unsafe(s: &str) -> Self { + std::str::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);)* + }; + + // std::str::FromStr + + (@from_str $name:ident, $s:expr, (min_length = $min_length:expr)) => { + let length = $s.len() as usize; + snafu::ensure!( + length >= $name::MIN_LENGTH, + $crate::framework::macros::attributed_string_type::MinimumLengthNotMetSnafu { + length, + min_length: $name::MIN_LENGTH, + } + ); + }; + (@from_str $name:ident, $s:expr, (max_length = $max_length:expr)) => { + let length = $s.len() as usize; + snafu::ensure!( + length <= $name::MAX_LENGTH, + $crate::framework::macros::attributed_string_type::LengthExceededSnafu { + length, + max_length: $name::MAX_LENGTH, + } + ); + }; + (@from_str $name:ident, $s:expr, (regex = $regex:expr)) => { + let regex = regex::Regex::new($regex).context($crate::framework::macros::attributed_string_type::InvalidRegexSnafu)?; + snafu::ensure!( + regex.is_match($s), + $crate::framework::macros::attributed_string_type::RegexNotMatchedSnafu { + value: $s, + regex: $regex + } + ); + }; + (@from_str $name:ident, $s:expr, is_rfc_1035_label_name) => { + stackable_operator::validation::is_lowercase_rfc_1035_label($s).context($crate::framework::macros::attributed_string_type::InvalidRfc1035LabelNameSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_rfc_1123_dns_subdomain_name) => { + stackable_operator::validation::is_lowercase_rfc_1123_subdomain($s).context($crate::framework::macros::attributed_string_type::InvalidRfc1123DnsSubdomainNameSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_rfc_1123_label_name) => { + stackable_operator::validation::is_lowercase_rfc_1123_label($s).context($crate::framework::macros::attributed_string_type::InvalidRfc1123LabelNameSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_valid_label_value) => { + stackable_operator::kvp::LabelValue::from_str($s).context($crate::framework::macros::attributed_string_type::InvalidLabelValueSnafu)?; + }; + (@from_str $name:ident, $s:expr, is_uid) => { + uuid::Uuid::try_parse($s).context($crate::framework::macros::attributed_string_type::InvalidUidSnafu)?; + }; + + // MIN_LENGTH + + (@min_length) => { + // The minimum String length is 0. + 0 + }; + (@min_length (min_length = $min_length:expr) $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::max( + $min_length, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length (max_length = $max_length:expr) $($attribute:tt)*) => { + // max_length has no opinion on the min_length. + attributed_string_type!(@min_length $($attribute)*) + }; + (@min_length (regex = $regex:expr) $($attribute:tt)*) => { + // regex has no influence on the min_length. + attributed_string_type!(@min_length $($attribute)*) + }; + (@min_length is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_valid_label_value $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::max( + 1, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + (@min_length is_uid $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::max( + uuid::fmt::Hyphenated::LENGTH, + attributed_string_type!(@min_length $($attribute)*) + ) + }; + + // MAX_LENGTH + + (@max_length) => { + // If there is no other max_length defined, then the upper bound is usize::MAX. + usize::MAX + }; + (@max_length (min_length = $min_length:expr) $($attribute:tt)*) => { + // min_length has no opinion on the max_length. + attributed_string_type!(@max_length $($attribute)*) + }; + (@max_length (max_length = $max_length:expr) $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::min( + $max_length, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length (regex = $regex:expr) $($attribute:tt)*) => { + // regex has no influence on the max_length. + attributed_string_type!(@max_length $($attribute)*) + }; + (@max_length is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::min( + stackable_operator::validation::RFC_1035_LABEL_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::min( + stackable_operator::validation::RFC_1123_SUBDOMAIN_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::min( + stackable_operator::validation::RFC_1123_LABEL_MAX_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_valid_label_value $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::min( + $crate::framework::macros::attributed_string_type::MAX_LABEL_VALUE_LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + (@max_length is_uid $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::min( + uuid::fmt::Hyphenated::LENGTH, + attributed_string_type!(@max_length $($attribute)*) + ) + }; + + // REGEX + + (@regex) => { + // Everything is allowed if there is no other regular expression. + $crate::framework::macros::attributed_string_type::Regex::MatchAll + }; + (@regex (min_length = $min_length:expr) $($attribute:tt)*) => { + // min_length has no influence on the regular expression. + attributed_string_type!(@regex $($attribute)*) + }; + (@regex (max_length = $max_length:expr) $($attribute:tt)*) => { + // max_length has no influence on the regular expression. + attributed_string_type!(@regex $($attribute)*) + }; + (@regex (regex = $regex:expr) $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::Regex::Expression($regex) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1035_label_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1035_LABEL_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1123_dns_subdomain_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1123_SUBDOMAIN_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_rfc_1123_label_name $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::Regex::Expression(stackable_operator::validation::LOWERCASE_RFC_1123_LABEL_FMT) + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_valid_label_value $($attribute:tt)*) => { + // regular expression from stackable_operator::kvp::label::LABEL_VALUE_REGEX + $crate::framework::macros::attributed_string_type::Regex::Expression("[a-z0-9A-Z]([a-z0-9A-Z-_.]*[a-z0-9A-Z]+)?") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + (@regex is_uid $($attribute:tt)*) => { + $crate::framework::macros::attributed_string_type::Regex::Expression("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .combine(attributed_string_type!(@regex $($attribute)*)) + }; + + // additional constants and trait implementations + + (@trait_impl $name:ident, (min_length = $max_length:expr)) => { + }; + (@trait_impl $name:ident, (max_length = $max_length:expr)) => { + }; + (@trait_impl $name:ident, (regex = $regex:expr)) => { + }; + (@trait_impl $name:ident, is_rfc_1035_label_name) => { + impl $name { + pub const IS_RFC_1035_LABEL_NAME: bool = true; + pub const IS_RFC_1123_LABEL_NAME: bool = true; + pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; + } + }; + (@trait_impl $name:ident, is_rfc_1123_dns_subdomain_name) => { + impl $name { + pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; + } + }; + (@trait_impl $name:ident, is_rfc_1123_label_name) => { + impl $name { + pub const IS_RFC_1123_LABEL_NAME: bool = true; + pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; + } + }; + (@trait_impl $name:ident, is_uid) => { + impl From for $name { + fn from(value: uuid::Uuid) -> Self { + Self(value.to_string()) + } + } + + impl From<&uuid::Uuid> for $name { + fn from(value: &uuid::Uuid) -> Self { + Self(value.to_string()) + } + } + }; + (@trait_impl $name:ident, is_valid_label_value) => { + impl $name { + pub const IS_VALID_LABEL_VALUE: bool = true; + } + + impl $crate::framework::NameIsValidLabelValue for $name { + fn to_label_value(&self) -> String { + self.0.clone() + } + } + }; +} + +/// Returns the minimum of the given values. +/// +/// As opposed to [`std::cmp::min`], this function can be used at compile-time. +/// +/// # Examples +/// +/// ```rust +/// assert_eq!(2, min(2, 3)); +/// assert_eq!(4, min(5, 4)); +/// assert_eq!(1, min(1, 1)); +/// ``` +pub const fn min(x: usize, y: usize) -> usize { + if x < y { x } else { y } +} + +/// Returns the maximum of the given values. +/// +/// As opposed to [`std::cmp::max`], this function can be used at compile-time. +/// +/// # Examples +/// +/// ```rust +/// assert_eq!(3, max(2, 3)); +/// assert_eq!(5, max(5, 4)); +/// assert_eq!(1, max(1, 1)); +/// ``` +pub const fn max(x: usize, y: usize) -> usize { + if x < y { y } else { x } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use schemars::{JsonSchema, SchemaGenerator}; + use serde_json::{Number, Value, json}; + use uuid::uuid; + + use super::ErrorDiscriminants; + use crate::framework::NameIsValidLabelValue; + + attributed_string_type! { + DisplayFmtTest, + "Display::fmt test", + "test" + } + + #[test] + fn test_attributed_string_type_display_fmt() { + type T = DisplayFmtTest; + + assert_eq!("test", format!("{}", T::from_str_unsafe("test"))); + } + + attributed_string_type! { + StringFromTest, + "String::from test", + "test" + } + + #[test] + fn test_attributed_string_type_string_from() { + type T = StringFromTest; + + T::test_example(); + assert_eq!("test", String::from(T::from_str_unsafe("test"))); + assert_eq!("test", String::from(&T::from_str_unsafe("test"))); + } + + attributed_string_type! { + LengthTest, + "empty string and max_length test", + "test", + (max_length = 4) + } + + #[test] + fn test_attributed_string_type_length() { + type T = LengthTest; + + T::test_example(); + assert_eq!(4, T::MAX_LENGTH); + assert_eq!( + Err(ErrorDiscriminants::EmptyString), + T::from_str("").map_err(ErrorDiscriminants::from) + ); + assert_eq!( + Err(ErrorDiscriminants::LengthExceeded), + T::from_str("testX").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + JsonSchemaTest, + "JsonSchemaTest test", + "test", + (min_length = 4), + (max_length = 8), + (regex = "[est]+") + } + + #[test] + fn test_attributed_string_type_json_schema() { + type T = JsonSchemaTest; + + T::test_example(); + assert_eq!("JsonSchemaTest", JsonSchemaTest::schema_name()); + assert_eq!( + json!({ + "type": "string", + "minLength": 4, + "maxLength": 8, + "pattern": "^[est]+$", + }), + JsonSchemaTest::json_schema(&mut SchemaGenerator::default()) + ); + } + + attributed_string_type! { + SerializeTest, + "serde::Serialize test", + "test" + } + + #[test] + fn test_attributed_string_type_serialize() { + type T = SerializeTest; + + T::test_example(); + assert_eq!( + "\"test\"".to_owned(), + serde_json::to_string(&T::from_str_unsafe("test")).expect("should be serializable") + ); + } + + attributed_string_type! { + DeserializeTest, + "serde::Deserialize test", + "test", + (max_length = 4), + is_rfc_1123_label_name + } + + #[test] + fn test_attributed_string_type_deserialize() { + type T = DeserializeTest; + + T::test_example(); + assert_eq!( + T::from_str_unsafe("test"), + serde_json::from_value(Value::String("test".to_owned())) + .expect("should be deserializable") + ); + assert_eq!( + Err("empty strings are not allowed".to_owned()), + serde_json::from_value::(Value::String("".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("maximum length exceeded".to_owned()), + serde_json::from_value::(Value::String("testx".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("not a valid label name as defined in RFC 1123".to_owned()), + serde_json::from_value::(Value::String("-".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: null, expected a string".to_owned()), + serde_json::from_value::(Value::Null).map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: boolean `true`, expected a string".to_owned()), + serde_json::from_value::(Value::Bool(true)).map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: integer `1`, expected a string".to_owned()), + serde_json::from_value::(Value::Number( + Number::from_i128(1).expect("should be a valid number") + )) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: sequence, expected a string".to_owned()), + serde_json::from_value::(Value::Array(vec![])).map_err(|err| err.to_string()) + ); + assert_eq!( + Err("invalid type: map, expected a string".to_owned()), + serde_json::from_value::(Value::Object(serde_json::Map::new())) + .map_err(|err| err.to_string()) + ); + } + + attributed_string_type! { + IsRfc1035LabelNameTest, + "is_rfc_1035_label_name test", + "a-b", + is_rfc_1035_label_name + } + + #[test] + fn test_attributed_string_type_is_rfc_1035_label_name() { + type T = IsRfc1035LabelNameTest; + + let _ = T::IS_RFC_1035_LABEL_NAME; + let _ = T::IS_RFC_1123_LABEL_NAME; + let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidRfc1035LabelName), + T::from_str("A").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + IsRfc1123DnsSubdomainNameTest, + "is_rfc_1123_dns_subdomain_name test", + "a-b.c", + is_rfc_1123_dns_subdomain_name + } + + #[test] + fn test_attributed_string_type_is_rfc_1123_dns_subdomain_name() { + type T = IsRfc1123DnsSubdomainNameTest; + + let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidRfc1123DnsSubdomainName), + T::from_str("A").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + IsRfc1123LabelNameTest, + "is_rfc_1123_label_name test", + "1-a", + is_rfc_1123_label_name + } + + #[test] + fn test_attributed_string_type_is_rfc_1123_label_name() { + type T = IsRfc1123LabelNameTest; + + let _ = T::IS_RFC_1123_LABEL_NAME; + let _ = T::IS_RFC_1123_SUBDOMAIN_NAME; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidRfc1123LabelName), + T::from_str("A").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + IsValidLabelValueTest, + "is_valid_label_value test", + "a-_.1", + is_valid_label_value + } + + #[test] + fn test_attributed_string_type_is_valid_label_value() { + type T = IsValidLabelValueTest; + + let _ = T::IS_VALID_LABEL_VALUE; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidLabelValue), + T::from_str("invalid label value").map_err(ErrorDiscriminants::from) + ); + assert_eq!( + "label-value", + T::from_str_unsafe("label-value").to_label_value() + ); + } + + attributed_string_type! { + IsUidTest, + "is_uid test", + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + is_uid + } + + #[test] + fn test_attributed_string_type_is_uid() { + type T = IsUidTest; + + T::test_example(); + assert_eq!( + Err(ErrorDiscriminants::InvalidUid), + T::from_str("invalid UID").map_err(ErrorDiscriminants::from) + ); + assert_eq!( + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + T::from(uuid!("c27b3971-ca72-42c1-80a4-abdfc1db0ddd")).to_string() + ); + assert_eq!( + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + T::from(&uuid!("c27b3971-ca72-42c1-80a4-abdfc1db0ddd")).to_string() + ); + } +} diff --git a/rust/operator-binary/src/framework/macros/constant.rs b/rust/operator-binary/src/framework/macros/constant.rs new file mode 100644 index 0000000..ae4e9c6 --- /dev/null +++ b/rust/operator-binary/src/framework/macros/constant.rs @@ -0,0 +1,17 @@ +/// Use [`std::sync::LazyLock`] to define a static "constant" from a string. +/// +/// The string is converted into the given type with [`std::str::FromStr::from_str`]. +/// +/// # Examples +/// +/// ```rust +/// constant!(DATA_VOLUME_NAME: VolumeName = "data"); +/// constant!(pub CONFIG_VOLUME_NAME: VolumeName = "config"); +/// ``` +#[macro_export(local_inner_macros)] +macro_rules! constant { + ($qualifier:vis $name:ident: $type:ident = $value:literal) => { + $qualifier static $name: std::sync::LazyLock<$type> = + std::sync::LazyLock::new(|| $type::from_str($value).expect("should be a valid $name")); + }; +} diff --git a/rust/operator-binary/src/framework/product_logging/framework.rs b/rust/operator-binary/src/framework/product_logging/framework.rs index 1b49109..f0639bd 100644 --- a/rust/operator-binary/src/framework/product_logging/framework.rs +++ b/rust/operator-binary/src/framework/product_logging/framework.rs @@ -18,9 +18,9 @@ use strum::{EnumDiscriminants, IntoStaticStr}; use crate::{ constant, framework::{ - ConfigMapKey, ConfigMapName, ContainerName, VolumeName, builder::pod::container::{EnvVarName, EnvVarSet, new_container_builder}, role_group_utils, + types::kubernetes::{ConfigMapKey, ConfigMapName, ContainerName, VolumeName}, }, }; @@ -50,7 +50,9 @@ pub enum Error { GetContainerLogConfiguration { container: String }, #[snafu(display("failed to parse the container name"))] - ParseContainerName { source: crate::framework::Error }, + ParseContainerName { + source: crate::framework::macros::attributed_string_type::Error, + }, } type Result = std::result::Result; @@ -241,9 +243,12 @@ mod tests { validate_logging_configuration_for_container, vector_container, }; use crate::framework::{ - ClusterName, ConfigMapName, ContainerName, RoleGroupName, RoleName, VolumeName, builder::pod::container::{EnvVarName, EnvVarSet}, role_group_utils, + types::{ + kubernetes::{ConfigMapName, ContainerName, VolumeName}, + operator::{ClusterName, RoleGroupName, RoleName}, + }, }; #[test] diff --git a/rust/operator-binary/src/framework/role_group_utils.rs b/rust/operator-binary/src/framework/role_group_utils.rs index f7b7f92..cb21faf 100644 --- a/rust/operator-binary/src/framework/role_group_utils.rs +++ b/rust/operator-binary/src/framework/role_group_utils.rs @@ -1,11 +1,10 @@ use std::str::FromStr; -use stackable_operator::validation::RFC_1035_LABEL_MAX_LENGTH; - -use super::{ - ClusterName, ConfigMapName, ListenerName, RoleGroupName, RoleName, StatefulSetName, min, +use super::types::{ + kubernetes::{ConfigMapName, ListenerName, ServiceName, StatefulSetName}, + operator::{ClusterName, RoleGroupName, RoleName}, }; -use crate::{attributed_string_type, framework::ServiceName}; +use crate::attributed_string_type; attributed_string_type! { QualifiedRoleGroupName, @@ -13,7 +12,7 @@ attributed_string_type! { "opensearch-nodes-default", // Suffixes are added to produce resource names. According compile-time checks ensure that // max_length cannot be set higher. - (max_length = min(52, RFC_1035_LABEL_MAX_LENGTH)), + (max_length = 52), is_rfc_1035_label_name, is_valid_label_value } @@ -116,8 +115,8 @@ impl ResourceNames { mod tests { use super::{ClusterName, RoleGroupName, RoleName}; use crate::framework::{ - ConfigMapName, ListenerName, ServiceName, StatefulSetName, role_group_utils::{QualifiedRoleGroupName, ResourceNames}, + types::kubernetes::{ConfigMapName, ListenerName, ServiceName, StatefulSetName}, }; #[test] diff --git a/rust/operator-binary/src/framework/role_utils.rs b/rust/operator-binary/src/framework/role_utils.rs index cfda143..d6facda 100644 --- a/rust/operator-binary/src/framework/role_utils.rs +++ b/rust/operator-binary/src/framework/role_utils.rs @@ -15,10 +15,12 @@ use stackable_operator::{ }; use super::{ - ProductName, RoleBindingName, ServiceAccountName, ServiceName, builder::pod::container::EnvVarSet, + types::{ + kubernetes::{ClusterRoleName, RoleBindingName, ServiceAccountName, ServiceName}, + operator::{ClusterName, ProductName}, + }, }; -use crate::framework::{ClusterName, ClusterRoleName}; /// Variant of [`stackable_operator::role_utils::GenericProductSpecificCommonConfig`] that /// implements [`Merge`] @@ -248,8 +250,11 @@ mod tests { use super::ResourceNames; use crate::framework::{ - ClusterName, ClusterRoleName, ProductName, RoleBindingName, ServiceAccountName, - ServiceName, role_utils::with_validated_config, + role_utils::with_validated_config, + types::{ + kubernetes::{ClusterRoleName, RoleBindingName, ServiceAccountName, ServiceName}, + operator::{ClusterName, ProductName}, + }, }; #[derive(Debug, Fragment, PartialEq)] diff --git a/rust/operator-binary/src/framework/types.rs b/rust/operator-binary/src/framework/types.rs new file mode 100644 index 0000000..fb75cd3 --- /dev/null +++ b/rust/operator-binary/src/framework/types.rs @@ -0,0 +1,2 @@ +pub mod kubernetes; +pub mod operator; diff --git a/rust/operator-binary/src/framework/types/kubernetes.rs b/rust/operator-binary/src/framework/types/kubernetes.rs new file mode 100644 index 0000000..58cd6c3 --- /dev/null +++ b/rust/operator-binary/src/framework/types/kubernetes.rs @@ -0,0 +1,162 @@ +//! Kubernetes (resource) names +use std::str::FromStr; + +use stackable_operator::validation::{RFC_1123_LABEL_MAX_LENGTH, RFC_1123_SUBDOMAIN_MAX_LENGTH}; + +use crate::attributed_string_type; + +attributed_string_type! { + ConfigMapName, + "The name of a ConfigMap", + "opensearch-nodes-default", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ConfigMapKey, + "The key for a ConfigMap", + "log4j2.properties", + (min_length = 1), + // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 + (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), + (regex = "[-._a-zA-Z0-9]+") +} + +attributed_string_type! { + ContainerName, + "The name of a container in a Pod", + "opensearch", + is_rfc_1123_label_name +} + +attributed_string_type! { + ClusterRoleName, + "The name of a ClusterRole", + "opensearch-clusterrole", + // On the one hand, ClusterRoles must only contain characters that are allowed for DNS + // subdomain names, on the other hand, their length does not seem to be restricted – at least + // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid + // problems on other Kubernetes providers, the length is restricted here. + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ListenerName, + "The name of a Listener", + "opensearch-nodes-default", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ListenerClassName, + "The name of a Listener", + "external-stable", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + NamespaceName, + "The name of a Namespace", + "stackable-operators", + is_rfc_1123_label_name, + is_valid_label_value +} + +attributed_string_type! { + PersistentVolumeClaimName, + "The name of a PersistentVolumeClaim", + "config", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + RoleBindingName, + "The name of a RoleBinding", + "opensearch-rolebinding", + // On the one hand, RoleBindings must only contain characters that are allowed for DNS + // subdomain names, on the other hand, their length does not seem to be restricted – at least + // on Kind. However, 253 characters are sufficient for the Stackable operators, and to avoid + // problems on other Kubernetes providers, the length is restricted here. + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + SecretKey, + "The key for a Secret", + "accessKey", + (min_length = 1), + // see https://github.com/kubernetes/kubernetes/blob/v1.34.1/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go#L435-L451 + (max_length = RFC_1123_SUBDOMAIN_MAX_LENGTH), + (regex = "[-._a-zA-Z0-9]+") +} + +attributed_string_type! { + ServiceAccountName, + "The name of a ServiceAccount", + "opensearch-serviceaccount", + is_rfc_1123_dns_subdomain_name +} + +attributed_string_type! { + ServiceName, + "The name of a Service", + "opensearch-nodes-default-headless", + is_rfc_1035_label_name, + is_valid_label_value +} + +attributed_string_type! { + StatefulSetName, + "The name of a StatefulSet", + "opensearch-nodes-default", + (max_length = + // see https://github.com/kubernetes/kubernetes/issues/64023 + RFC_1123_LABEL_MAX_LENGTH + - 1 /* dash */ + - 10 /* digits for the controller-revision-hash label */), + is_rfc_1123_label_name, + is_valid_label_value +} + +attributed_string_type! { + Uid, + "A UID", + "c27b3971-ca72-42c1-80a4-abdfc1db0ddd", + is_uid, + is_valid_label_value +} + +attributed_string_type! { + VolumeName, + "The name of a Volume", + "opensearch-nodes-default", + is_rfc_1123_label_name, + is_valid_label_value +} + +#[cfg(test)] +mod tests { + use super::{ + ClusterRoleName, ConfigMapKey, ConfigMapName, ContainerName, ListenerClassName, + ListenerName, NamespaceName, PersistentVolumeClaimName, RoleBindingName, + ServiceAccountName, ServiceName, StatefulSetName, Uid, VolumeName, + }; + + #[test] + fn test_attributed_string_type_examples() { + ConfigMapName::test_example(); + ConfigMapKey::test_example(); + ContainerName::test_example(); + ClusterRoleName::test_example(); + ListenerName::test_example(); + ListenerClassName::test_example(); + NamespaceName::test_example(); + PersistentVolumeClaimName::test_example(); + RoleBindingName::test_example(); + ServiceAccountName::test_example(); + ServiceName::test_example(); + StatefulSetName::test_example(); + Uid::test_example(); + VolumeName::test_example(); + } +} diff --git a/rust/operator-binary/src/framework/types/operator.rs b/rust/operator-binary/src/framework/types/operator.rs new file mode 100644 index 0000000..d2020ab --- /dev/null +++ b/rust/operator-binary/src/framework/types/operator.rs @@ -0,0 +1,91 @@ +//! Names for operators + +use std::str::FromStr; + +use crate::attributed_string_type; + +attributed_string_type! { + ProductName, + "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), + is_rfc_1123_dns_subdomain_name, + is_valid_label_value +} + +attributed_string_type! { + ProductVersion, + "The version of a product", + "3.1.0", + is_valid_label_value +} + +attributed_string_type! { + ClusterName, + "The name of a cluster/stacklet", + "my-opensearch-cluster", + // Suffixes are added to produce resource names. According compile-time checks ensure that + // max_length cannot be set higher. + (max_length = 24), + is_rfc_1035_label_name, + is_valid_label_value +} + +attributed_string_type! { + ControllerName, + "The name of a controller in an operator", + "opensearchcluster", + is_valid_label_value +} + +attributed_string_type! { + OperatorName, + "The name of an operator", + "opensearch.stackable.tech", + is_valid_label_value +} + +attributed_string_type! { + RoleGroupName, + "The name of a role-group name", + "cluster-manager", + // The role-group name is used to produce resource names. To make sure that all resource names + // are valid, max_length is restricted. Compile-time checks ensure that max_length cannot be + // set higher if not other names like the RoleName are set lower accordingly. + (max_length = 16), + is_rfc_1123_label_name, + is_valid_label_value +} + +attributed_string_type! { + RoleName, + "The name of a role name", + "nodes", + // The role name is used to produce resource names. To make sure that all resource names are + // valid, max_length is restricted. Compile-time checks ensure that max_length cannot be set + // higher if not other names like the RoleGroupName are set lower accordingly. + (max_length = 10), + is_rfc_1123_label_name, + is_valid_label_value +} + +#[cfg(test)] +mod tests { + use super::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, + RoleName, + }; + + #[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(); + } +} diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index 2852117..e7df8f2 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -2,7 +2,7 @@ use std::{str::FromStr, sync::Arc}; use clap::Parser as _; use crd::{OpenSearchCluster, OpenSearchClusterVersion, v1alpha1}; -use framework::OperatorName; +use framework::types::operator::OperatorName; use futures::{FutureExt, StreamExt}; use snafu::{ResultExt as _, Snafu}; use stackable_operator::{ From 68fea790a7ecb6f8d6b681cb6e498684c0b8c44c Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 16:46:50 +0000 Subject: [PATCH 5/7] test: Update the integration test for the attributed_string_type --- .../macros/attributed_string_type.rs | 337 +++++++++++++----- 1 file changed, 251 insertions(+), 86 deletions(-) diff --git a/rust/operator-binary/src/framework/macros/attributed_string_type.rs b/rust/operator-binary/src/framework/macros/attributed_string_type.rs index db4fb91..f961712 100644 --- a/rust/operator-binary/src/framework/macros/attributed_string_type.rs +++ b/rust/operator-binary/src/framework/macros/attributed_string_type.rs @@ -10,9 +10,6 @@ pub const MAX_LABEL_VALUE_LENGTH: usize = 63; #[snafu(visibility(pub))] #[strum_discriminants(derive(IntoStaticStr))] pub enum Error { - #[snafu(display("empty strings are not allowed"))] - EmptyString {}, - #[snafu(display("minimum length not met"))] MinimumLengthNotMet { length: usize, min_length: usize }, @@ -49,23 +46,30 @@ pub enum Error { InvalidUid { source: uuid::Error }, } -#[derive(Clone, Copy, Debug)] +/// Helper data type to determine combined regular expressions +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Regex { - /// There is a regular expression but it is unknown (or too complicated). + /// There is a regular expression but it is unknown (because it was too complicated to + /// calculate it). Unknown, - /// `MatchAll` equals Expression(".*") but can be matched in a const context. + /// `MatchAll` equals `Expression(".*")`, but `MatchAll` can be pattern matched in a const + /// context, whereas `Expression(...)` cannot. MatchAll, - /// There is a regular expression. + /// A regular expression Expression(&'static str), } impl Regex { + /// Combine this regular expression with the given one. pub const fn combine(self, other: Regex) -> Regex { match (self, other) { (_, Regex::MatchAll) => self, (Regex::MatchAll, _) => other, + // It is hard to combine two regular expressions and nearly impossible to do this in a + // const context. Fortunately, for most of the data types, only one regular expression + // is set. _ => Regex::Unknown, } } @@ -92,6 +96,21 @@ macro_rules! attributed_string_type { #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct $name(String); + impl $name { + /// The minimum length + pub const MIN_LENGTH: usize = attributed_string_type!(@min_length $($attribute)*); + + /// The maximum length + pub const MAX_LENGTH: usize = attributed_string_type!(@max_length $($attribute)*); + + /// The regular expression + /// + /// This field is not meant to be used outside of this macro. + pub const REGEX: $crate::framework::macros::attributed_string_type::Regex = attributed_string_type!(@regex $($attribute)*); + } + + impl stackable_operator::config::merge::Atomic for $name {} + impl std::fmt::Display for $name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) @@ -124,11 +143,6 @@ macro_rules! attributed_string_type { #[allow(unused_imports)] use snafu::ResultExt; - snafu::ensure!( - !s.is_empty(), - $crate::framework::macros::attributed_string_type::EmptyStringSnafu {} - ); - $(attributed_string_type!(@from_str $name, s, $attribute);)* Ok(Self(s.to_owned())) @@ -154,16 +168,6 @@ macro_rules! attributed_string_type { } } - impl stackable_operator::config::merge::Atomic for $name {} - - impl $name { - pub const MIN_LENGTH: usize = attributed_string_type!(@min_length $($attribute)*); - pub const MAX_LENGTH: usize = attributed_string_type!(@max_length $($attribute)*); - - /// None if there are restrictions but the regular expression could not be calculated. - pub const REGEX: $crate::framework::macros::attributed_string_type::Regex = attributed_string_type!(@regex $($attribute)*); - } - // The JsonSchema implementation requires `max_length`. impl schemars::JsonSchema for $name { fn schema_name() -> std::borrow::Cow<'static, str> { @@ -420,6 +424,17 @@ macro_rules! attributed_string_type { pub const IS_RFC_1123_SUBDOMAIN_NAME: bool = true; } }; + (@trait_impl $name:ident, is_valid_label_value) => { + impl $name { + pub const IS_VALID_LABEL_VALUE: bool = true; + } + + impl $crate::framework::NameIsValidLabelValue for $name { + fn to_label_value(&self) -> String { + self.0.clone() + } + } + }; (@trait_impl $name:ident, is_uid) => { impl From for $name { fn from(value: uuid::Uuid) -> Self { @@ -433,17 +448,6 @@ macro_rules! attributed_string_type { } } }; - (@trait_impl $name:ident, is_valid_label_value) => { - impl $name { - pub const IS_VALID_LABEL_VALUE: bool = true; - } - - impl $crate::framework::NameIsValidLabelValue for $name { - fn to_label_value(&self) -> String { - self.0.clone() - } - } - }; } /// Returns the minimum of the given values. @@ -477,6 +481,8 @@ pub const fn max(x: usize, y: usize) -> usize { } #[cfg(test)] +// `InvalidRegexTest` intentionally contains an invalid regular expression. +#[allow(clippy::invalid_regex)] mod tests { use std::str::FromStr; @@ -484,109 +490,197 @@ mod tests { use serde_json::{Number, Value, json}; use uuid::uuid; - use super::ErrorDiscriminants; + use super::{ErrorDiscriminants, Regex}; use crate::framework::NameIsValidLabelValue; attributed_string_type! { - DisplayFmtTest, - "Display::fmt test", - "test" + MinLengthWithoutConstraintsTest, + "min_length test without constraints", + "" } #[test] - fn test_attributed_string_type_display_fmt() { - type T = DisplayFmtTest; + fn test_attributed_string_type_min_length_without_constraints() { + type T = MinLengthWithoutConstraintsTest; - assert_eq!("test", format!("{}", T::from_str_unsafe("test"))); + T::test_example(); + assert_eq!(0, T::MIN_LENGTH); } attributed_string_type! { - StringFromTest, - "String::from test", - "test" + MinLengthWithConstraintsTest, + "min_length test with constraints", + "test", + (min_length = 2), // should set the minimum length to 2 + (max_length = 8), // should not affect the minimum length + (regex = ".{4}"), // should not affect the minimum length + is_rfc_1035_label_name, // should be overruled by the greater min_length + is_valid_label_value // should be overruled by the greater min_length } #[test] - fn test_attributed_string_type_string_from() { - type T = StringFromTest; + fn test_attributed_string_type_min_length_with_constraints() { + type T = MinLengthWithConstraintsTest; T::test_example(); - assert_eq!("test", String::from(T::from_str_unsafe("test"))); - assert_eq!("test", String::from(&T::from_str_unsafe("test"))); + assert_eq!(2, T::MIN_LENGTH); + assert_eq!( + Err(ErrorDiscriminants::MinimumLengthNotMet), + T::from_str("a").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + MaxLengthWithoutConstraintsTest, + "max_length test without constraints", + "" + } + + #[test] + fn test_attributed_string_type_max_length_without_constraints() { + type T = MaxLengthWithoutConstraintsTest; + + T::test_example(); + assert_eq!(usize::MAX, T::MAX_LENGTH); } attributed_string_type! { - LengthTest, - "empty string and max_length test", + MaxLengthWithConstraintsTest, + "max_length test with constraints", "test", - (max_length = 4) + (min_length = 2), // should not affect the maximum length + (max_length = 8), // should set the maximum length to 8 + (regex = ".{4}"), // should not affect the maximum length + is_rfc_1035_label_name, // should be overruled by the lower max_length + is_valid_label_value // should be overruled by the lower max_length } #[test] - fn test_attributed_string_type_length() { - type T = LengthTest; + fn test_attributed_string_type_max_length_with_constraints() { + type T = MaxLengthWithConstraintsTest; T::test_example(); - assert_eq!(4, T::MAX_LENGTH); - assert_eq!( - Err(ErrorDiscriminants::EmptyString), - T::from_str("").map_err(ErrorDiscriminants::from) - ); + assert_eq!(8, T::MAX_LENGTH); assert_eq!( Err(ErrorDiscriminants::LengthExceeded), - T::from_str("testX").map_err(ErrorDiscriminants::from) + T::from_str("test-12345").map_err(ErrorDiscriminants::from) ); } attributed_string_type! { - JsonSchemaTest, - "JsonSchemaTest test", + RegexWithoutConstraintsTest, + "regex test without constraints", + "" + } + + #[test] + fn test_attributed_string_type_regex_without_constraints() { + type T = RegexWithoutConstraintsTest; + + T::test_example(); + assert_eq!(Regex::MatchAll, T::REGEX); + } + + attributed_string_type! { + RegexWithOneConstraintTest, + "regex test with one constraint", "test", - (min_length = 4), - (max_length = 8), - (regex = "[est]+") + (min_length = 2), // should not affect the regular expression + (max_length = 8), // should not affect the regular expression + (regex = "[est]{4}") // should set the regular expression to "[est]{4}" } #[test] - fn test_attributed_string_type_json_schema() { - type T = JsonSchemaTest; + fn test_attributed_string_type_regex_with_one_constraint() { + type T = RegexWithOneConstraintTest; T::test_example(); - assert_eq!("JsonSchemaTest", JsonSchemaTest::schema_name()); + assert_eq!(Regex::Expression("[est]{4}"), T::REGEX); assert_eq!( - json!({ - "type": "string", - "minLength": 4, - "maxLength": 8, - "pattern": "^[est]+$", - }), - JsonSchemaTest::json_schema(&mut SchemaGenerator::default()) + Err(ErrorDiscriminants::RegexNotMatched), + T::from_str("t-st").map_err(ErrorDiscriminants::from) ); } attributed_string_type! { - SerializeTest, - "serde::Serialize test", - "test" + RegexWithMultipleConstraintsTest, + "regex test with multiple constraints", + "test", + (min_length = 2), // should not affect the regular expression + (max_length = 8), // should not affect the regular expression + (regex = "[est]{4}"), // should not be combinable with is_rfc_1123_dns_subdomain_name + is_rfc_1123_dns_subdomain_name // should not be combinable with regex } #[test] - fn test_attributed_string_type_serialize() { - type T = SerializeTest; + fn test_attributed_string_type_regex_with_multiple_constraints() { + type T = RegexWithMultipleConstraintsTest; T::test_example(); + assert_eq!(Regex::Unknown, T::REGEX); assert_eq!( - "\"test\"".to_owned(), - serde_json::to_string(&T::from_str_unsafe("test")).expect("should be serializable") + Err(ErrorDiscriminants::RegexNotMatched), + T::from_str("t-st").map_err(ErrorDiscriminants::from) ); } + attributed_string_type! { + InvalidRegexTest, + "regex test with invalid expression", + "test", + (min_length = 2), // should not affect the regular expression + (max_length = 8), // should not affect the regular expression + (regex = "{") // should throw an error at runtime + } + + #[test] + fn test_attributed_string_type_regex_with_invalid_expression() { + type T = InvalidRegexTest; + + // It is not known yet at compile-time that this expression is invalid. + assert_eq!(Regex::Expression("{"), T::REGEX); + assert_eq!( + Err(ErrorDiscriminants::InvalidRegex), + T::from_str("test").map_err(ErrorDiscriminants::from) + ); + } + + attributed_string_type! { + DisplayFmtTest, + "Display::fmt test", + "test" + } + + #[test] + fn test_attributed_string_type_display_fmt() { + type T = DisplayFmtTest; + + assert_eq!("test", format!("{}", T::from_str_unsafe("test"))); + } + + attributed_string_type! { + StringFromTest, + "String::from test", + "test" + } + + #[test] + fn test_attributed_string_type_string_from() { + type T = StringFromTest; + + T::test_example(); + assert_eq!("test", String::from(T::from_str_unsafe("test"))); + assert_eq!("test", String::from(&T::from_str_unsafe("test"))); + } + attributed_string_type! { DeserializeTest, "serde::Deserialize test", "test", + (min_length = 2), (max_length = 4), - is_rfc_1123_label_name + (regex = "[est-]+"), + is_rfc_1035_label_name } #[test] @@ -600,18 +694,23 @@ mod tests { .expect("should be deserializable") ); assert_eq!( - Err("empty strings are not allowed".to_owned()), - serde_json::from_value::(Value::String("".to_owned())) + Err("minimum length not met".to_owned()), + serde_json::from_value::(Value::String("e".to_owned())) .map_err(|err| err.to_string()) ); assert_eq!( Err("maximum length exceeded".to_owned()), - serde_json::from_value::(Value::String("testx".to_owned())) + serde_json::from_value::(Value::String("testt".to_owned())) .map_err(|err| err.to_string()) ); assert_eq!( - Err("not a valid label name as defined in RFC 1123".to_owned()), - serde_json::from_value::(Value::String("-".to_owned())) + Err("regular expression not matched".to_owned()), + serde_json::from_value::(Value::String("abc".to_owned())) + .map_err(|err| err.to_string()) + ); + assert_eq!( + Err("not a valid label name as defined in RFC 1035".to_owned()), + serde_json::from_value::(Value::String("-tst".to_owned())) .map_err(|err| err.to_string()) ); assert_eq!( @@ -640,6 +739,72 @@ mod tests { ); } + attributed_string_type! { + SerializeTest, + "serde::Serialize test", + "test" + } + + #[test] + fn test_attributed_string_type_serialize() { + type T = SerializeTest; + + T::test_example(); + assert_eq!( + "\"test\"".to_owned(), + serde_json::to_string(&T::from_str_unsafe("test")).expect("should be serializable") + ); + } + + attributed_string_type! { + JsonSchemaWithoutConstraintsTest, + "JsonSchema test with constraints", + "test" + } + + #[test] + fn test_attributed_string_type_json_schema_without_constaints() { + type T = JsonSchemaWithoutConstraintsTest; + + T::test_example(); + assert_eq!("JsonSchemaWithoutConstraintsTest", T::schema_name()); + assert_eq!( + json!({ + "type": "string", + "minLength": 0, + "maxLength": None::, + "pattern": None:: + }), + T::json_schema(&mut SchemaGenerator::default()) + ); + } + + attributed_string_type! { + JsonSchemaWithConstraintsTest, + "JsonSchema test with constraints", + "test", + (min_length = 4), + (max_length = 8), + (regex = "[est]+") + } + + #[test] + fn test_attributed_string_type_json_schema_with_constraints() { + type T = JsonSchemaWithConstraintsTest; + + T::test_example(); + assert_eq!("JsonSchemaWithConstraintsTest", T::schema_name()); + assert_eq!( + json!({ + "type": "string", + "minLength": 4, + "maxLength": 8, + "pattern": "^[est]+$" + }), + T::json_schema(&mut SchemaGenerator::default()) + ); + } + attributed_string_type! { IsRfc1035LabelNameTest, "is_rfc_1035_label_name test", From 8428c33a9315b6a47ee16dbbdda05355b4f975b9 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 16:57:41 +0000 Subject: [PATCH 6/7] chore: Regenerate charts --- deploy/helm/opensearch-operator/crds/crds.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/helm/opensearch-operator/crds/crds.yaml b/deploy/helm/opensearch-operator/crds/crds.yaml index 50f4a28..50448f6 100644 --- a/deploy/helm/opensearch-operator/crds/crds.yaml +++ b/deploy/helm/opensearch-operator/crds/crds.yaml @@ -41,7 +41,7 @@ spec: maxLength: 253 minLength: 1 nullable: true - pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string type: object clusterOperation: @@ -192,7 +192,7 @@ spec: maxLength: 253 minLength: 1 nullable: true - pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string logging: default: @@ -541,7 +541,7 @@ spec: maxLength: 253 minLength: 1 nullable: true - pattern: '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string logging: default: From c32830004a642bf3a58670e6b31035e71f2a30f6 Mon Sep 17 00:00:00 2001 From: Siegfried Weber Date: Fri, 5 Dec 2025 17:21:22 +0000 Subject: [PATCH 7/7] feat: Add type SecretName --- .../src/framework/types/kubernetes.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/rust/operator-binary/src/framework/types/kubernetes.rs b/rust/operator-binary/src/framework/types/kubernetes.rs index 58cd6c3..b172806 100644 --- a/rust/operator-binary/src/framework/types/kubernetes.rs +++ b/rust/operator-binary/src/framework/types/kubernetes.rs @@ -90,6 +90,13 @@ attributed_string_type! { (regex = "[-._a-zA-Z0-9]+") } +attributed_string_type! { + SecretName, + "The name of a Secret", + "opensearch-security-config", + is_rfc_1123_dns_subdomain_name +} + attributed_string_type! { ServiceAccountName, "The name of a ServiceAccount", @@ -138,8 +145,8 @@ attributed_string_type! { mod tests { use super::{ ClusterRoleName, ConfigMapKey, ConfigMapName, ContainerName, ListenerClassName, - ListenerName, NamespaceName, PersistentVolumeClaimName, RoleBindingName, - ServiceAccountName, ServiceName, StatefulSetName, Uid, VolumeName, + ListenerName, NamespaceName, PersistentVolumeClaimName, RoleBindingName, SecretKey, + SecretName, ServiceAccountName, ServiceName, StatefulSetName, Uid, VolumeName, }; #[test] @@ -153,6 +160,8 @@ mod tests { NamespaceName::test_example(); PersistentVolumeClaimName::test_example(); RoleBindingName::test_example(); + SecretKey::test_example(); + SecretName::test_example(); ServiceAccountName::test_example(); ServiceName::test_example(); StatefulSetName::test_example();