Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/stackable-operator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ semver.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
serde.workspace = true
sha2.workspace = true
snafu.workspace = true
strum.workspace = true
tokio.workspace = true
Expand Down
195 changes: 186 additions & 9 deletions crates/stackable-operator/src/v2/role_group_utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::str::FromStr;

use sha2::{Digest, Sha256};

use super::types::{
kubernetes::{ConfigMapName, ListenerName, ServiceName, StatefulSetName},
operator::{ClusterName, RoleGroupName, RoleName},
Expand Down Expand Up @@ -27,18 +29,42 @@ pub struct ResourceNames {
impl ResourceNames {
/// Creates a qualified role group name in the format
/// `<cluster_name>-<role_name>-<role_group_name>`
fn qualified_role_group_name(&self) -> QualifiedRoleGroupName {
///
/// If the result would exceed the maximum length of qualified role group names, then it is
/// truncated and a hash is appended. The maximum length of the cluster name is short enough,
/// so that a part of the role name is always rendered. The role group name is barely used and
/// often set to "default", so that the qualified role group name is still meaningful:
///
/// ```rust
/// # use std::str::FromStr;
/// # use stackable_operator::v2::role_group_utils::ResourceNames;
/// # use stackable_operator::v2::types::operator::{ClusterName, RoleGroupName, RoleName};
///
/// let resource_names = ResourceNames {
/// cluster_name: ClusterName::from_str("an-exceptional-long-cluster-name").unwrap(),
/// role_name: RoleName::from_str("dagprocessor").unwrap(),
/// role_group_name: RoleGroupName::from_str("default").unwrap(),
/// };
///
/// assert_eq!(
/// "an-exceptional-long-cluster-name-dagprocessor-6cc08b",
/// resource_names.qualified_role_group_name().to_string()
/// );
/// ```
pub fn qualified_role_group_name(&self) -> QualifiedRoleGroupName {
// compile-time checks
const HASH_LENGTH: usize = 6;

// At least the cluster name should be short enough to not be replaced by the hash.
const _: () = assert!(
ClusterName::MAX_LENGTH
+ 1 // dash
+ RoleName::MAX_LENGTH
+ 1 // dash
+ RoleGroupName::MAX_LENGTH
+ HASH_LENGTH
<= QualifiedRoleGroupName::MAX_LENGTH,
"The string `<cluster_name>-<role_name>-<role_group_name>` must not exceed the limit \
of RFC 1035 label names."
"The string `<cluster_name>-<hash>` must not exceed the limit of qualified role group \
names."
);

// qualified_role_group_name is only an RFC 1035 label name if it starts with an
// alphabetic character, therefore cluster_name must also be an RFC 1035 label name.
// role_name and role_group_name and the middle of the qualified_role_group_name can
Expand All @@ -47,11 +73,59 @@ impl ResourceNames {
let _ = RoleName::IS_RFC_1123_LABEL_NAME;
let _ = RoleGroupName::IS_RFC_1123_LABEL_NAME;

QualifiedRoleGroupName::from_str(&format!(
let concatenated_name = format!(
"{}-{}-{}",
self.cluster_name, self.role_name, self.role_group_name,
))
.expect("should be a valid QualifiedRoleGroupName")
);
let sanitized_name = Self::ensure_max_length(
concatenated_name,
QualifiedRoleGroupName::MAX_LENGTH,
HASH_LENGTH,
);

QualifiedRoleGroupName::from_str(&sanitized_name)
.expect("should be a valid QualifiedRoleGroupName")
}

/// Ensures that the given resource name does not exceed the given maximum length.
/// If required, the resource name is truncated and a hex encoded hash is appended with a dash.
///
/// # Panics
///
/// Panics if `max_length < 1 /* character */ + 1 /* dash */ + hash_length`.
fn ensure_max_length(resource_name: String, max_length: usize, hash_length: usize) -> String {
assert!(max_length >= 1 /* character */ + 1 /* dash */ + hash_length);

if resource_name.len() <= max_length {
resource_name
} else if hash_length == 0 {
let mut truncated_name = resource_name;
truncated_name.truncate(max_length);
truncated_name
Comment thread
maltesander marked this conversation as resolved.
} else {
let mut hash = format!("{:x}", Sha256::digest(resource_name.as_bytes()));
hash.truncate(hash_length);

let mut truncated_name = resource_name;
// Truncate the name so that the hash can be appended without exceeding the maximum
// length.
truncated_name.truncate(max_length - hash_length);

let last_char = truncated_name
.pop()
.expect("should be guaranteed by the assertion above");
let second_to_last_char = truncated_name
.pop()
.expect("should be guaranteed by the assertion above");

// If the truncated name already ends with a dash then do not add another one,
// otherwise replace the last character with a dash.
if second_to_last_char == '-' && last_char != '-' {
format!("{truncated_name}{second_to_last_char}{hash}")
} else {
format!("{truncated_name}{second_to_last_char}-{hash}")
}
}
}

pub fn role_group_config_map(&self) -> ConfigMapName {
Expand Down Expand Up @@ -150,4 +224,107 @@ mod tests {
resource_names.listener_name()
);
}

#[test]
fn test_fitting_qualified_role_group_name() {
let cluster_name_length = ClusterName::MAX_LENGTH;
let role_name_and_role_group_name_length = QualifiedRoleGroupName::MAX_LENGTH - cluster_name_length - 2 /* dashes */;
let role_name_length = role_name_and_role_group_name_length / 2;
let role_group_name_length = role_name_and_role_group_name_length - role_name_length;

let resource_names = ResourceNames {
cluster_name: ClusterName::from_str_unsafe(&"c".repeat(cluster_name_length)),
role_name: RoleName::from_str_unsafe(&"r".repeat(role_name_length)),
role_group_name: RoleGroupName::from_str_unsafe(&"g".repeat(role_group_name_length)),
};

let qualified_role_group_name = resource_names.qualified_role_group_name();

assert_eq!(
QualifiedRoleGroupName::MAX_LENGTH,
qualified_role_group_name.to_string().len()
);
assert_eq!(
QualifiedRoleGroupName::from_str_unsafe(
"cccccccccccccccccccccccccccccccccccccccc-rrrrr-ggggg"
),
qualified_role_group_name
);
}

#[test]
fn test_hashed_qualified_role_group_name() {
let resource_names = ResourceNames {
cluster_name: ClusterName::from_str_unsafe(&"c".repeat(ClusterName::MAX_LENGTH)),
role_name: RoleName::from_str_unsafe(&"r".repeat(RoleName::MAX_LENGTH)),
role_group_name: RoleGroupName::from_str_unsafe(&"g".repeat(RoleGroupName::MAX_LENGTH)),
};

let qualified_role_group_name = resource_names.qualified_role_group_name();

assert_eq!(
QualifiedRoleGroupName::MAX_LENGTH,
qualified_role_group_name.to_string().len()
);
assert_eq!(
QualifiedRoleGroupName::from_str_unsafe(
"cccccccccccccccccccccccccccccccccccccccc-rrrr-a12cc0"
),
qualified_role_group_name
);
}

#[test]
fn test_ensure_max_length() {
// empty resource name, no hash length
assert_eq!(
String::new(),
ResourceNames::ensure_max_length(String::new(), 2, 0)
);

// resource_name.len() <= max_length
assert_eq!(
"abcdef".to_owned(),
ResourceNames::ensure_max_length("abcdef".to_owned(), 6, 4)
);

// hash_length == 0
assert_eq!(
"abcdef".to_owned(),
ResourceNames::ensure_max_length("abcdefg".to_owned(), 6, 0)
);

// hash appended with dash
assert_eq!(
"a-7d1a".to_owned(),
ResourceNames::ensure_max_length("abcdefg".to_owned(), 6, 4)
);

// hash appended without an extra dash
assert_eq!(
"ab-a1b1".to_owned(),
ResourceNames::ensure_max_length("ab-defgh".to_owned(), 7, 4)
);

// hash appended without an extra dash
// In this case, the result is one character shorter than the maximum length.
assert_eq!(
"a-3951".to_owned(),
ResourceNames::ensure_max_length("a-cdefgh".to_owned(), 7, 4)
);

// hash appended without an extra dash
// The two dashes in the given resource name are intentionally kept.
assert_eq!(
"a--f7a0".to_owned(),
ResourceNames::ensure_max_length("a--defgh".to_owned(), 7, 4)
);

// A hash_length longer than the produced hash string may not produce the desired result.
// Just use sensible values!
assert_eq!(
"aaaaaaaaa-d476ce01c3787bcab054a2cf48d6af6dd303a0eb549e21a74125132f79d90c36".to_owned(),
ResourceNames::ensure_max_length("a".repeat(1011), 1010, 1000)
);
}
}
17 changes: 6 additions & 11 deletions crates/stackable-operator/src/v2/types/operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ 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),
// Suffixes are added to produce resource names.
//
// 40 characters for cluster names should be sufficient and still allow the operators to append
// custom suffixes to build resource names. Increasing this value could break existing operator
// code.
(max_length = 40),
is_rfc_1035_label_name,
is_valid_label_value
}
Expand All @@ -51,10 +54,6 @@ 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
}
Expand All @@ -63,10 +62,6 @@ 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
}
Expand Down
Loading