Skip to content

Commit 3528930

Browse files
committed
refactor: introduce KubernetesResource struct
1 parent 8ee6472 commit 3528930

2 files changed

Lines changed: 256 additions & 89 deletions

File tree

rust/operator-binary/src/controller.rs

Lines changed: 89 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ use stackable_operator::{
99
cli::OperatorEnvironmentOptions,
1010
cluster_resources::ClusterResourceApplyStrategy,
1111
commons::rbac::build_rbac_resources,
12+
crd::listener::v1alpha1::Listener,
13+
k8s_openapi::api::{
14+
apps::v1::StatefulSet,
15+
core::v1::{ConfigMap, Service},
16+
policy::v1::PodDisruptionBudget,
17+
},
1218
kube::{
1319
ResourceExt,
1420
core::{DeserializeGuard, error_boundary},
@@ -29,11 +35,7 @@ use stackable_operator::{
2935
use strum::{EnumDiscriminants, IntoStaticStr};
3036

3137
use crate::{
32-
controller::build::resource::{
33-
listener::{build_group_listener, group_listener_name},
34-
pdb::build_pdb,
35-
service::{build_rolegroup_headless_service, build_rolegroup_metrics_service},
36-
},
38+
controller::build::resource::listener::{build_group_listener, group_listener_name},
3739
crd::{APP_NAME, DruidClusterStatus, DruidRole, OPERATOR_NAME, v1alpha1},
3840
internal_secret::create_shared_internal_secret,
3941
};
@@ -70,9 +72,30 @@ pub struct Ctx {
7072
pub operator_environment: OperatorEnvironmentOptions,
7173
}
7274

75+
/// Every Kubernetes resource produced by the client-free [`build`](build::build) step.
76+
///
77+
/// The Router group `Listener` and the discovery `ConfigMap`s are intentionally *not* yet part of this
78+
/// bundle: the discovery `ConfigMap` derives from the *applied* Router listener's ingress address,
79+
/// so both are built and applied in the reconcile step instead.
80+
pub struct KubernetesResources {
81+
pub stateful_sets: Vec<StatefulSet>,
82+
pub services: Vec<Service>,
83+
pub listeners: Vec<Listener>,
84+
pub config_maps: Vec<ConfigMap>,
85+
pub pod_disruption_budgets: Vec<PodDisruptionBudget>,
86+
}
87+
7388
#[derive(Snafu, Debug, EnumDiscriminants)]
7489
#[strum_discriminants(derive(IntoStaticStr))]
7590
pub enum Error {
91+
#[snafu(display("failed to build the Kubernetes resources"))]
92+
BuildResources { source: build::Error },
93+
94+
#[snafu(display("failed to apply Kubernetes resource"))]
95+
ApplyResource {
96+
source: stackable_operator::cluster_resources::Error,
97+
},
98+
7699
#[snafu(display("failed to apply Service for role group {role_group}"))]
77100
ApplyRoleGroupService {
78101
source: stackable_operator::cluster_resources::Error,
@@ -234,99 +257,76 @@ pub async fn reconcile_druid(
234257
.await
235258
.context(FailedInternalSecretCreationSnafu)?;
236259

237-
let mut ss_cond_builder = StatefulSetConditionBuilder::default();
260+
let resources =
261+
build::build(&validated_cluster, &service_account_name).context(BuildResourcesSnafu)?;
238262

239-
for (druid_role, groups) in validated_cluster.role_group_configs.iter() {
240-
for (rolegroup_name, rg) in groups.iter() {
241-
let rg_headless_service =
242-
build_rolegroup_headless_service(&validated_cluster, druid_role, rolegroup_name);
243-
let rg_metrics_service =
244-
build_rolegroup_metrics_service(&validated_cluster, druid_role, rolegroup_name);
245-
246-
let rg_configmap = build::resource::config_map::build_rolegroup_config_map(
247-
&validated_cluster,
248-
druid_role,
249-
rolegroup_name,
250-
rg,
251-
)
252-
.context(BuildConfigMapSnafu)?;
253-
let rg_statefulset = build::resource::statefulset::build_rolegroup_statefulset(
254-
&validated_cluster,
255-
druid_role,
256-
rolegroup_name,
257-
rg,
258-
&service_account_name,
259-
)
260-
.context(BuildRoleGroupStatefulSetSnafu)?;
263+
let mut ss_cond_builder = StatefulSetConditionBuilder::default();
261264

265+
// Apply order: everything a Pod mounts (ConfigMaps) must exist before the StatefulSets, so the
266+
// StatefulSets are applied last to prevent unnecessary Pod restarts.
267+
// See https://github.com/stackabletech/commons-operator/issues/111 for details.
268+
for service in resources.services {
269+
cluster_resources
270+
.add(client, service)
271+
.await
272+
.context(ApplyResourceSnafu)?;
273+
}
274+
for listener in resources.listeners {
275+
cluster_resources
276+
.add(client, listener)
277+
.await
278+
.context(ApplyResourceSnafu)?;
279+
}
280+
for config_map in resources.config_maps {
281+
cluster_resources
282+
.add(client, config_map)
283+
.await
284+
.context(ApplyResourceSnafu)?;
285+
}
286+
for pdb in resources.pod_disruption_budgets {
287+
cluster_resources
288+
.add(client, pdb)
289+
.await
290+
.context(ApplyResourceSnafu)?;
291+
}
292+
for stateful_set in resources.stateful_sets {
293+
ss_cond_builder.add(
262294
cluster_resources
263-
.add(client, rg_headless_service)
264-
.await
265-
.with_context(|_| ApplyRoleGroupServiceSnafu {
266-
role_group: rolegroup_name.to_string(),
267-
})?;
268-
cluster_resources
269-
.add(client, rg_metrics_service)
270-
.await
271-
.with_context(|_| ApplyRoleGroupServiceSnafu {
272-
role_group: rolegroup_name.to_string(),
273-
})?;
274-
cluster_resources
275-
.add(client, rg_configmap)
295+
.add(client, stateful_set)
276296
.await
277-
.with_context(|_| ApplyRoleGroupConfigSnafu {
278-
role_group: rolegroup_name.to_string(),
279-
})?;
280-
281-
// Note: The StatefulSet needs to be applied after all ConfigMaps and Secrets it mounts
282-
// to prevent unnecessary Pod restarts.
283-
// See https://github.com/stackabletech/commons-operator/issues/111 for details.
284-
ss_cond_builder.add(
285-
cluster_resources
286-
.add(client, rg_statefulset)
287-
.await
288-
.with_context(|_| ApplyRoleGroupStatefulSetSnafu {
289-
role_group: rolegroup_name.to_string(),
290-
})?,
291-
);
292-
}
297+
.context(ApplyResourceSnafu)?,
298+
);
299+
}
293300

294-
if let Some(listener_class) = &validated_cluster.role_config(druid_role).listener_class
295-
&& let Some(listener_group_name) = group_listener_name(&validated_cluster, druid_role)
296-
{
297-
let role_group_listener = build_group_listener(
298-
&validated_cluster,
299-
listener_class,
300-
listener_group_name,
301-
druid_role,
302-
);
303-
304-
let listener = cluster_resources
305-
.add(client, role_group_listener)
306-
.await
307-
.context(ApplyGroupListenerSnafu)?;
308-
309-
if *druid_role == DruidRole::Router {
310-
// discovery
311-
for discovery_cm in build_discovery_configmaps(&validated_cluster, listener)
312-
.await
313-
.context(BuildDiscoveryConfigSnafu)?
314-
{
315-
cluster_resources
316-
.add(client, discovery_cm)
317-
.await
318-
.context(ApplyDiscoveryConfigSnafu)?;
319-
}
320-
}
321-
}
301+
// The Router group Listener and its discovery ConfigMaps are applied here rather than in the
302+
// build step: the discovery ConfigMap derives from the *applied* Router listener's ingress
303+
// address, which is only known after the Listener has been applied.
304+
if let Some(listener_class) = &validated_cluster
305+
.role_config(&DruidRole::Router)
306+
.listener_class
307+
&& let Some(listener_group_name) =
308+
group_listener_name(&validated_cluster, &DruidRole::Router)
309+
{
310+
let router_listener = build_group_listener(
311+
&validated_cluster,
312+
listener_class,
313+
listener_group_name,
314+
&DruidRole::Router,
315+
);
322316

323-
let role_config = validated_cluster.role_config(druid_role);
317+
let listener = cluster_resources
318+
.add(client, router_listener)
319+
.await
320+
.context(ApplyGroupListenerSnafu)?;
324321

325-
if let Some(pdb) = build_pdb(&role_config.pdb, &validated_cluster, druid_role) {
322+
for discovery_cm in build_discovery_configmaps(&validated_cluster, listener)
323+
.await
324+
.context(BuildDiscoveryConfigSnafu)?
325+
{
326326
cluster_resources
327-
.add(client, pdb)
327+
.add(client, discovery_cm)
328328
.await
329-
.context(ApplyPdbSnafu)?;
329+
.context(ApplyDiscoveryConfigSnafu)?;
330330
}
331331
}
332332

rust/operator-binary/src/controller/build/mod.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,24 @@
22
33
use std::str::FromStr;
44

5+
use snafu::{ResultExt, Snafu};
56
use stackable_operator::v2::types::operator::{ProductVersion, RoleGroupName};
67

8+
use crate::{
9+
controller::{
10+
KubernetesResources,
11+
build::resource::{
12+
config_map::build_rolegroup_config_map,
13+
listener::{build_group_listener, group_listener_name},
14+
pdb::build_pdb,
15+
service::{build_rolegroup_headless_service, build_rolegroup_metrics_service},
16+
statefulset::build_rolegroup_statefulset,
17+
},
18+
validate::ValidatedCluster,
19+
},
20+
crd::DruidRole,
21+
};
22+
723
// Placeholder role-group name used for the recommended labels of the role-level discovery
824
// `ConfigMap` (which is not tied to a single role group).
925
stackable_operator::constant!(pub(crate) PLACEHOLDER_DISCOVERY_ROLE_GROUP: RoleGroupName = "discovery");
@@ -22,3 +38,154 @@ pub mod jvm;
2238
pub mod properties;
2339
pub mod resource;
2440
pub mod security;
41+
42+
#[derive(Snafu, Debug)]
43+
pub enum Error {
44+
#[snafu(display("failed to build ConfigMap for role group {role_group}"))]
45+
ConfigMap {
46+
source: resource::config_map::Error,
47+
role_group: RoleGroupName,
48+
},
49+
50+
#[snafu(display("failed to build StatefulSet for role group {role_group}"))]
51+
StatefulSet {
52+
source: resource::statefulset::Error,
53+
role_group: RoleGroupName,
54+
},
55+
}
56+
57+
/// Builds the Kubernetes resources for the given validated cluster.
58+
///
59+
/// Does not need a Kubernetes client: every reference to another Kubernetes resource is already
60+
/// dereferenced and validated by this point. The remaining errors are resource-assembly failures
61+
/// only.
62+
///
63+
/// `service_account_name` is the name of the RBAC `ServiceAccount` the role-group Pods run under
64+
/// (RBAC resources are built and applied separately, in the reconcile step).
65+
///
66+
/// The Router group `Listener` and the discovery `ConfigMap`s are not built here: the discovery
67+
/// `ConfigMap` derives from the *applied* Router listener's ingress address, so both are built and
68+
/// applied in the reconcile step instead.
69+
pub fn build(
70+
cluster: &ValidatedCluster,
71+
service_account_name: &str,
72+
) -> Result<KubernetesResources, Error> {
73+
let mut stateful_sets = vec![];
74+
let mut services = vec![];
75+
let mut listeners = vec![];
76+
let mut config_maps = vec![];
77+
let mut pod_disruption_budgets = vec![];
78+
79+
for (druid_role, role_group_configs) in &cluster.role_group_configs {
80+
let role_config = cluster.role_config(druid_role);
81+
82+
if let Some(pdb) = build_pdb(&role_config.pdb, cluster, druid_role) {
83+
pod_disruption_budgets.push(pdb);
84+
}
85+
86+
// The Router group Listener is built and applied in the reconcile step instead (see the
87+
// module docs and [`KubernetesResources`]), so it is skipped here.
88+
if *druid_role != DruidRole::Router
89+
&& let Some(listener_class) = &role_config.listener_class
90+
&& let Some(listener_group_name) = group_listener_name(cluster, druid_role)
91+
{
92+
listeners.push(build_group_listener(
93+
cluster,
94+
listener_class,
95+
listener_group_name,
96+
druid_role,
97+
));
98+
}
99+
100+
for (role_group_name, rg) in role_group_configs {
101+
services.push(build_rolegroup_headless_service(
102+
cluster,
103+
druid_role,
104+
role_group_name,
105+
));
106+
services.push(build_rolegroup_metrics_service(
107+
cluster,
108+
druid_role,
109+
role_group_name,
110+
));
111+
config_maps.push(
112+
build_rolegroup_config_map(cluster, druid_role, role_group_name, rg).context(
113+
ConfigMapSnafu {
114+
role_group: role_group_name.clone(),
115+
},
116+
)?,
117+
);
118+
stateful_sets.push(
119+
build_rolegroup_statefulset(
120+
cluster,
121+
druid_role,
122+
role_group_name,
123+
rg,
124+
service_account_name,
125+
)
126+
.context(StatefulSetSnafu {
127+
role_group: role_group_name.clone(),
128+
})?,
129+
);
130+
}
131+
}
132+
133+
Ok(KubernetesResources {
134+
stateful_sets,
135+
services,
136+
listeners,
137+
config_maps,
138+
pod_disruption_budgets,
139+
})
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use stackable_operator::kube::Resource;
145+
146+
use super::build;
147+
use crate::controller::validate::test_support::{
148+
MINIMAL_DRUID_YAML, druid_from_yaml, validated_cluster,
149+
};
150+
151+
fn sorted_names(resources: &[impl Resource]) -> Vec<&str> {
152+
let mut names: Vec<&str> = resources
153+
.iter()
154+
.filter_map(|resource| resource.meta().name.as_deref())
155+
.collect();
156+
names.sort();
157+
names
158+
}
159+
160+
#[test]
161+
fn build_produces_expected_resource_names() {
162+
let druid = druid_from_yaml(MINIMAL_DRUID_YAML);
163+
let cluster = validated_cluster(&druid);
164+
let resources = build(&cluster, "simple-druid-serviceaccount").expect("build succeeds");
165+
166+
// One StatefulSet and one ConfigMap per role group (one role group per role).
167+
let expected_role_group_names = [
168+
"simple-druid-broker-default",
169+
"simple-druid-coordinator-default",
170+
"simple-druid-historical-default",
171+
"simple-druid-middlemanager-default",
172+
"simple-druid-router-default",
173+
];
174+
assert_eq!(
175+
sorted_names(&resources.stateful_sets),
176+
expected_role_group_names
177+
);
178+
assert_eq!(
179+
sorted_names(&resources.config_maps),
180+
expected_role_group_names
181+
);
182+
183+
// Group Listeners are built for the externally reachable roles except the Router (whose
184+
// Listener is applied in the reconcile step): Broker and Coordinator.
185+
// TODO: add router listener here once properly build in the built step.
186+
assert_eq!(
187+
sorted_names(&resources.listeners),
188+
["simple-druid-broker", "simple-druid-coordinator"]
189+
);
190+
}
191+
}

0 commit comments

Comments
 (0)