Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion crates/stackable-operator/crds/AuthenticationClass.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/stackable-operator/crds/DummyCluster.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/stackable-operator/crds/S3Bucket.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/stackable-operator/crds/S3Connection.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/stackable-operator/src/builder/pdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ impl PodDisruptionBudgetBuilder<ObjectMeta, LabelSelector, ()> {
/// 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,
Expand Down
2 changes: 1 addition & 1 deletion crates/stackable-operator/src/commons/secret_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
156 changes: 156 additions & 0 deletions crates/stackable-operator/src/role_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,62 @@ where
}
}

impl<Config, ConfigOverrides, RoleConfig, CommonConfig>
Role<Config, ConfigOverrides, RoleConfig, CommonConfig>
where
RoleConfig: Default + JsonSchema + Serialize,
CommonConfig: Default + JsonSchema + Serialize,
ConfigOverrides: Default + JsonSchema + Serialize,
{
/// Returns [`Some<u32>`] in case the number of replicas is hard-coded to a certain value.
///
/// This is the case when all `replicas` are set to [`Some<u16>`], in which case they are simply
/// summed.
Comment on lines +415 to +416

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This especially mentions all replicas fields. The function however doesn't assert that this is actually the case in all role groups.

So either this comment needs adjusting, or the function body needs to change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As soon as a single rolegroup has None (or 0 for that matter), None is returned. See your other comment in https://github.com/stackabletech/operator-rs/pull/1241/changes#r3512315258

///
/// 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<u32> {
// 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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious: Does sum flatten the Options produced by the map above?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sum on an iterator of Options returns None if there is at least one None present in the iterator. If all are Some the sum is returned.

E.g. the replica_counts_with_one_replica_unset test showcases this

}

/// 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<Config, ConfigOverrides, RoleConfig>
Role<Config, ConfigOverrides, RoleConfig, JavaCommonConfig>
where
Expand Down Expand Up @@ -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<Item = Option<u16>>,
) -> 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(),
}
}
}