diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index f8d19421a..ce1d4e734 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -8,8 +8,10 @@ All notable changes to this project will be documented in this file. - Support the annotation `secrets.stackable.tech/backend.autotls.cert.domain-components-in-subject-dn` in the `SecretOperatorVolumeSourceBuilder` ([#1209]). +- Add `Role::fixed_replica_count` and `Role::estimated_replica_count` helper functions ([#1241]). [#1209]: https://github.com/stackabletech/operator-rs/pull/1209 +[#1241]: https://github.com/stackabletech/operator-rs/pull/1241 ## [0.113.0] - 2026-06-22 diff --git a/crates/stackable-operator/crds/AuthenticationClass.yaml b/crates/stackable-operator/crds/AuthenticationClass.yaml index 438cb49ec..8b35e556c 100644 --- a/crates/stackable-operator/crds/AuthenticationClass.yaml +++ b/crates/stackable-operator/crds/AuthenticationClass.yaml @@ -104,7 +104,7 @@ spec: type: array type: object secretClass: - description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.' + description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.' type: string required: - secretClass diff --git a/crates/stackable-operator/crds/DummyCluster.yaml b/crates/stackable-operator/crds/DummyCluster.yaml index fab66510b..79692113e 100644 --- a/crates/stackable-operator/crds/DummyCluster.yaml +++ b/crates/stackable-operator/crds/DummyCluster.yaml @@ -1932,7 +1932,7 @@ spec: type: array type: object secretClass: - description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.' + description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.' type: string required: - secretClass diff --git a/crates/stackable-operator/crds/S3Bucket.yaml b/crates/stackable-operator/crds/S3Bucket.yaml index 650ed1025..f44b24004 100644 --- a/crates/stackable-operator/crds/S3Bucket.yaml +++ b/crates/stackable-operator/crds/S3Bucket.yaml @@ -93,7 +93,7 @@ spec: type: array type: object secretClass: - description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.' + description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.' type: string required: - secretClass diff --git a/crates/stackable-operator/crds/S3Connection.yaml b/crates/stackable-operator/crds/S3Connection.yaml index 29468bd8c..997108950 100644 --- a/crates/stackable-operator/crds/S3Connection.yaml +++ b/crates/stackable-operator/crds/S3Connection.yaml @@ -77,7 +77,7 @@ spec: type: array type: object secretClass: - description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.' + description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.' type: string required: - secretClass diff --git a/crates/stackable-operator/src/builder/pdb.rs b/crates/stackable-operator/src/builder/pdb.rs index 2d1af56f2..5e2fb1d2c 100644 --- a/crates/stackable-operator/src/builder/pdb.rs +++ b/crates/stackable-operator/src/builder/pdb.rs @@ -154,7 +154,7 @@ impl PodDisruptionBudgetBuilder { /// Mutually exclusive with [`PodDisruptionBudgetBuilder::with_max_unavailable`]. #[deprecated( since = "0.51.0", - note = "It is strongly recommended to use [`max_unavailable`]. Please read the ADR on Pod disruptions before using this function." + note = "It is strongly recommended to use [`PodDisruptionBudgetBuilder::with_max_unavailable`]. Please read the ADR on Pod disruptions before using this function." )] pub fn with_min_available( self, diff --git a/crates/stackable-operator/src/commons/secret_class.rs b/crates/stackable-operator/src/commons/secret_class.rs index c32bbac64..43dd69713 100644 --- a/crates/stackable-operator/src/commons/secret_class.rs +++ b/crates/stackable-operator/src/commons/secret_class.rs @@ -20,7 +20,7 @@ pub enum SecretClassVolumeError { )] #[serde(rename_all = "camelCase")] pub struct SecretClassVolume { - /// [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) containing the LDAP bind credentials. + /// [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) providing the requested secrets. pub secret_class: String, /// [Scope](DOCS_BASE_URL_PLACEHOLDER/secret-operator/scope) of the diff --git a/crates/stackable-operator/src/role_utils.rs b/crates/stackable-operator/src/role_utils.rs index 511b84b34..b0b1dcc8d 100644 --- a/crates/stackable-operator/src/role_utils.rs +++ b/crates/stackable-operator/src/role_utils.rs @@ -403,6 +403,62 @@ where } } +impl + Role +where + RoleConfig: Default + JsonSchema + Serialize, + CommonConfig: Default + JsonSchema + Serialize, + ConfigOverrides: Default + JsonSchema + Serialize, +{ + /// Returns [`Some`] in case the number of replicas is hard-coded to a certain value. + /// + /// This is the case when all `replicas` are set to [`Some`], in which case they are simply + /// summed. + /// + /// The argument `zero_replicas_counting` is a safety mechanism, which allows the caller to + /// decide if an explicit replica count of `0` should be treated as [`None`]. It also means that + /// [`None`] is returned in case no roleGroups are configured at all. + pub fn fixed_replica_count(&self, zero_replicas_counting: ZeroReplicasCounting) -> Option { + // An empty role has no fixed replica count when zeros are treated as None. + if zero_replicas_counting == ZeroReplicasCounting::TreatAsNone + && self.role_groups.is_empty() + { + return None; + } + + self.role_groups + .values() + .map(|rg| match rg.replicas { + None => None, + Some(0) if zero_replicas_counting == ZeroReplicasCounting::TreatAsNone => None, + // The individual replicas are [`u16`]s, so a [`u32`] sum has plenty of space. + Some(replicas) => Some(u32::from(replicas)), + }) + .sum() + } + + /// Returns the estimated total number of replicas across all role groups. + /// + /// Unlike [`Self::fixed_replica_count`], this always returns a value: a role group with an unset + /// (i.e. [`None`]) replica count is assumed to run a single replica. Use this when a best-effort + /// estimate is needed even though the exact number of replicas is not hard-coded. + pub fn estimated_replica_count(&self) -> u32 { + self.role_groups + .values() + .map(|rg| u32::from(rg.replicas.unwrap_or(1))) + .sum() + } +} + +/// How explicit zero (`0`) replicas on a role group should be counted +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ZeroReplicasCounting { + /// Treat them as what they are: `Some(0)`. + TreatAsZero, + /// Treat them as if the user configured [`None`]. + TreatAsNone, +} + impl Role where @@ -654,4 +710,104 @@ mod tests { ] ); } + + #[test] + fn replica_counts_with_all_replicas_set() { + let role = construct_role_with_replicas([Some(3), Some(2), Some(5)]); + + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero), + Some(10) + ); + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone), + Some(10) + ); + assert_eq!(role.estimated_replica_count(), 10); + } + + #[test] + fn replica_counts_with_one_replica_unset() { + let role = construct_role_with_replicas([Some(3), None, Some(2)]); + + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero), + None + ); + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone), + None + ); + assert_eq!(role.estimated_replica_count(), 6); + } + + #[test] + fn replica_counts_with_a_zero_replica() { + let role = construct_role_with_replicas([Some(3), Some(0)]); + + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero), + Some(3) + ); + // With treat_zero_as_none the zero turns the whole count into None. + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone), + None + ); + assert_eq!(role.estimated_replica_count(), 3); + } + + #[test] + fn replica_counts_with_a_single_zero_role_group() { + let role = construct_role_with_replicas([Some(0)]); + + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero), + Some(0) + ); + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone), + None + ); + assert_eq!(role.estimated_replica_count(), 0); + } + + #[test] + fn replica_counts_without_role_groups() { + let role = construct_role_with_replicas(vec![]); + + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero), + Some(0) + ); + assert_eq!( + role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone), + None + ); + assert_eq!(role.estimated_replica_count(), 0); + } + + /// Builds a [`Role`] with one role group per passed `replicas` entry, so tests only need to + /// care about the replica counts that [`Role::fixed_replica_count`] operates on. + fn construct_role_with_replicas( + replicas: impl IntoIterator>, + ) -> Role<(), EmptyConfigOverrides, GenericRoleConfig, GenericCommonConfig> { + Role { + config: CommonConfiguration::default(), + role_config: GenericRoleConfig::default(), + role_groups: replicas + .into_iter() + .enumerate() + .map(|(index, replicas)| { + ( + format!("role-group-{index}"), + RoleGroup { + config: CommonConfiguration::default(), + replicas, + }, + ) + }) + .collect(), + } + } }