Skip to content

Commit ce78136

Browse files
sbernauerTechassi
andauthored
feat(operator): Add Role::{fixed,estimated}_replica_count (#1241)
* feat: Add Role::fixed_replica_count function * Add Role::estimated_replica_count as well * changelog * Update crates/stackable-operator/src/role_utils.rs Co-authored-by: Techassi <git@techassi.dev> * refactor: Use enum instead of bool * Random rustdoc fix * cargo fmt * Unrelated docs fix --------- Co-authored-by: Techassi <git@techassi.dev>
1 parent b400132 commit ce78136

8 files changed

Lines changed: 164 additions & 6 deletions

File tree

crates/stackable-operator/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ All notable changes to this project will be documented in this file.
88

99
- Support the annotation `secrets.stackable.tech/backend.autotls.cert.domain-components-in-subject-dn`
1010
in the `SecretOperatorVolumeSourceBuilder` ([#1209]).
11+
- Add `Role::fixed_replica_count` and `Role::estimated_replica_count` helper functions ([#1241]).
1112

1213
[#1209]: https://github.com/stackabletech/operator-rs/pull/1209
14+
[#1241]: https://github.com/stackabletech/operator-rs/pull/1241
1315

1416
## [0.113.0] - 2026-06-22
1517

crates/stackable-operator/crds/AuthenticationClass.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ spec:
104104
type: array
105105
type: object
106106
secretClass:
107-
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.'
107+
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.'
108108
type: string
109109
required:
110110
- secretClass

crates/stackable-operator/crds/DummyCluster.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1932,7 +1932,7 @@ spec:
19321932
type: array
19331933
type: object
19341934
secretClass:
1935-
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.'
1935+
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.'
19361936
type: string
19371937
required:
19381938
- secretClass

crates/stackable-operator/crds/S3Bucket.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ spec:
9393
type: array
9494
type: object
9595
secretClass:
96-
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.'
96+
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.'
9797
type: string
9898
required:
9999
- secretClass

crates/stackable-operator/crds/S3Connection.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ spec:
7777
type: array
7878
type: object
7979
secretClass:
80-
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) containing the LDAP bind credentials.'
80+
description: '[SecretClass](https://docs.stackable.tech/home/nightly/secret-operator/secretclass) providing the requested secrets.'
8181
type: string
8282
required:
8383
- secretClass

crates/stackable-operator/src/builder/pdb.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ impl PodDisruptionBudgetBuilder<ObjectMeta, LabelSelector, ()> {
154154
/// Mutually exclusive with [`PodDisruptionBudgetBuilder::with_max_unavailable`].
155155
#[deprecated(
156156
since = "0.51.0",
157-
note = "It is strongly recommended to use [`max_unavailable`]. Please read the ADR on Pod disruptions before using this function."
157+
note = "It is strongly recommended to use [`PodDisruptionBudgetBuilder::with_max_unavailable`]. Please read the ADR on Pod disruptions before using this function."
158158
)]
159159
pub fn with_min_available(
160160
self,

crates/stackable-operator/src/commons/secret_class.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub enum SecretClassVolumeError {
2020
)]
2121
#[serde(rename_all = "camelCase")]
2222
pub struct SecretClassVolume {
23-
/// [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) containing the LDAP bind credentials.
23+
/// [SecretClass](DOCS_BASE_URL_PLACEHOLDER/secret-operator/secretclass) providing the requested secrets.
2424
pub secret_class: String,
2525

2626
/// [Scope](DOCS_BASE_URL_PLACEHOLDER/secret-operator/scope) of the

crates/stackable-operator/src/role_utils.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,62 @@ where
403403
}
404404
}
405405

406+
impl<Config, ConfigOverrides, RoleConfig, CommonConfig>
407+
Role<Config, ConfigOverrides, RoleConfig, CommonConfig>
408+
where
409+
RoleConfig: Default + JsonSchema + Serialize,
410+
CommonConfig: Default + JsonSchema + Serialize,
411+
ConfigOverrides: Default + JsonSchema + Serialize,
412+
{
413+
/// Returns [`Some<u32>`] in case the number of replicas is hard-coded to a certain value.
414+
///
415+
/// This is the case when all `replicas` are set to [`Some<u16>`], in which case they are simply
416+
/// summed.
417+
///
418+
/// The argument `zero_replicas_counting` is a safety mechanism, which allows the caller to
419+
/// decide if an explicit replica count of `0` should be treated as [`None`]. It also means that
420+
/// [`None`] is returned in case no roleGroups are configured at all.
421+
pub fn fixed_replica_count(&self, zero_replicas_counting: ZeroReplicasCounting) -> Option<u32> {
422+
// An empty role has no fixed replica count when zeros are treated as None.
423+
if zero_replicas_counting == ZeroReplicasCounting::TreatAsNone
424+
&& self.role_groups.is_empty()
425+
{
426+
return None;
427+
}
428+
429+
self.role_groups
430+
.values()
431+
.map(|rg| match rg.replicas {
432+
None => None,
433+
Some(0) if zero_replicas_counting == ZeroReplicasCounting::TreatAsNone => None,
434+
// The individual replicas are [`u16`]s, so a [`u32`] sum has plenty of space.
435+
Some(replicas) => Some(u32::from(replicas)),
436+
})
437+
.sum()
438+
}
439+
440+
/// Returns the estimated total number of replicas across all role groups.
441+
///
442+
/// Unlike [`Self::fixed_replica_count`], this always returns a value: a role group with an unset
443+
/// (i.e. [`None`]) replica count is assumed to run a single replica. Use this when a best-effort
444+
/// estimate is needed even though the exact number of replicas is not hard-coded.
445+
pub fn estimated_replica_count(&self) -> u32 {
446+
self.role_groups
447+
.values()
448+
.map(|rg| u32::from(rg.replicas.unwrap_or(1)))
449+
.sum()
450+
}
451+
}
452+
453+
/// How explicit zero (`0`) replicas on a role group should be counted
454+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
455+
pub enum ZeroReplicasCounting {
456+
/// Treat them as what they are: `Some(0)`.
457+
TreatAsZero,
458+
/// Treat them as if the user configured [`None`].
459+
TreatAsNone,
460+
}
461+
406462
impl<Config, ConfigOverrides, RoleConfig>
407463
Role<Config, ConfigOverrides, RoleConfig, JavaCommonConfig>
408464
where
@@ -654,4 +710,104 @@ mod tests {
654710
]
655711
);
656712
}
713+
714+
#[test]
715+
fn replica_counts_with_all_replicas_set() {
716+
let role = construct_role_with_replicas([Some(3), Some(2), Some(5)]);
717+
718+
assert_eq!(
719+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero),
720+
Some(10)
721+
);
722+
assert_eq!(
723+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone),
724+
Some(10)
725+
);
726+
assert_eq!(role.estimated_replica_count(), 10);
727+
}
728+
729+
#[test]
730+
fn replica_counts_with_one_replica_unset() {
731+
let role = construct_role_with_replicas([Some(3), None, Some(2)]);
732+
733+
assert_eq!(
734+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero),
735+
None
736+
);
737+
assert_eq!(
738+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone),
739+
None
740+
);
741+
assert_eq!(role.estimated_replica_count(), 6);
742+
}
743+
744+
#[test]
745+
fn replica_counts_with_a_zero_replica() {
746+
let role = construct_role_with_replicas([Some(3), Some(0)]);
747+
748+
assert_eq!(
749+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero),
750+
Some(3)
751+
);
752+
// With treat_zero_as_none the zero turns the whole count into None.
753+
assert_eq!(
754+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone),
755+
None
756+
);
757+
assert_eq!(role.estimated_replica_count(), 3);
758+
}
759+
760+
#[test]
761+
fn replica_counts_with_a_single_zero_role_group() {
762+
let role = construct_role_with_replicas([Some(0)]);
763+
764+
assert_eq!(
765+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero),
766+
Some(0)
767+
);
768+
assert_eq!(
769+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone),
770+
None
771+
);
772+
assert_eq!(role.estimated_replica_count(), 0);
773+
}
774+
775+
#[test]
776+
fn replica_counts_without_role_groups() {
777+
let role = construct_role_with_replicas(vec![]);
778+
779+
assert_eq!(
780+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsZero),
781+
Some(0)
782+
);
783+
assert_eq!(
784+
role.fixed_replica_count(ZeroReplicasCounting::TreatAsNone),
785+
None
786+
);
787+
assert_eq!(role.estimated_replica_count(), 0);
788+
}
789+
790+
/// Builds a [`Role`] with one role group per passed `replicas` entry, so tests only need to
791+
/// care about the replica counts that [`Role::fixed_replica_count`] operates on.
792+
fn construct_role_with_replicas(
793+
replicas: impl IntoIterator<Item = Option<u16>>,
794+
) -> Role<(), EmptyConfigOverrides, GenericRoleConfig, GenericCommonConfig> {
795+
Role {
796+
config: CommonConfiguration::default(),
797+
role_config: GenericRoleConfig::default(),
798+
role_groups: replicas
799+
.into_iter()
800+
.enumerate()
801+
.map(|(index, replicas)| {
802+
(
803+
format!("role-group-{index}"),
804+
RoleGroup {
805+
config: CommonConfiguration::default(),
806+
replicas,
807+
},
808+
)
809+
})
810+
.collect(),
811+
}
812+
}
657813
}

0 commit comments

Comments
 (0)