Skip to content

Commit be90d5c

Browse files
authored
feat(scaling): Add Scaler CRD (#1190)
* feat: Add Scaler CRD * fix: Adjust status representation/schema * chore: Add dev comments * chore: Add CRD preview for Scaler
1 parent 03913eb commit be90d5c

5 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
apiVersion: apiextensions.k8s.io/v1
3+
kind: CustomResourceDefinition
4+
metadata:
5+
name: scalers.autoscaling.stackable.tech
6+
spec:
7+
group: autoscaling.stackable.tech
8+
names:
9+
categories: []
10+
kind: Scaler
11+
plural: scalers
12+
shortNames: []
13+
singular: scaler
14+
scope: Namespaced
15+
versions:
16+
- additionalPrinterColumns: []
17+
name: v1alpha1
18+
schema:
19+
openAPIV3Schema:
20+
description: Auto-generated derived type for ScalerSpec via `CustomResource`
21+
properties:
22+
spec:
23+
properties:
24+
replicas:
25+
description: |-
26+
Desired replica count.
27+
28+
Written by the horizontal pod autoscaling mechanism via the /scale subresource.
29+
30+
NOTE: This and other replica fields)use a [`u16`] instead of a [`i32`] used by
31+
[`k8s_openapi`] types to force a non-negative replica count. All [`u16`]s can be
32+
converted losslessly to [`i32`]s where needed.
33+
34+
Upstream issues:
35+
36+
- https://github.com/kubernetes/kubernetes/issues/105533
37+
- https://github.com/Arnavion/k8s-openapi/issues/136
38+
format: uint16
39+
maximum: 65535.0
40+
minimum: 0.0
41+
type: integer
42+
required:
43+
- replicas
44+
type: object
45+
status:
46+
description: Status of a StackableScaler.
47+
nullable: true
48+
properties:
49+
lastTransitionTime:
50+
description: Timestamp indicating when the scaler state last transitioned.
51+
format: date-time
52+
type: string
53+
replicas:
54+
description: |-
55+
The current total number of replicas targeted by the managed StatefulSet.
56+
57+
Exposed via the `/scale` subresource for horizontal pod autoscaling consumption.
58+
format: uint16
59+
maximum: 65535.0
60+
minimum: 0.0
61+
type: integer
62+
selector:
63+
description: Label selector string for HPA pod counting. Written at `.status.selector`.
64+
nullable: true
65+
type: string
66+
state:
67+
description: The current state of the scaler state machine.
68+
properties:
69+
details:
70+
properties:
71+
failedIn:
72+
description: In which state the scaling operation failed.
73+
enum:
74+
- preScaling
75+
- scaling
76+
- postScaling
77+
type: string
78+
previous_replicas:
79+
maximum: 65535.0
80+
minimum: 0.0
81+
type: uint16
82+
reason:
83+
type: string
84+
type: object
85+
state:
86+
enum:
87+
- idle
88+
- preScaling
89+
- scaling
90+
- postScaling
91+
- failed
92+
type: string
93+
required:
94+
- state
95+
type: object
96+
required:
97+
- replicas
98+
- state
99+
- lastTransitionTime
100+
type: object
101+
required:
102+
- spec
103+
title: Scaler
104+
type: object
105+
served: true
106+
storage: true
107+
subresources:
108+
scale:
109+
labelSelectorPath: .status.selector
110+
specReplicasPath: .spec.replicas
111+
statusReplicasPath: .status.replicas
112+
status: {}

crates/stackable-operator/src/crd/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod authentication;
88
pub mod git_sync;
99
pub mod listener;
1010
pub mod s3;
11+
pub mod scaler;
1112

1213
/// A reference to a product cluster (for example, a `ZookeeperCluster`)
1314
///
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
use std::borrow::Cow;
2+
3+
use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
4+
use kube::CustomResource;
5+
use schemars::JsonSchema;
6+
use serde::{Deserialize, Serialize};
7+
8+
#[cfg(doc)]
9+
use crate::kvp::Annotation;
10+
use crate::versioned::versioned;
11+
12+
#[versioned(version(name = "v1alpha1"))]
13+
pub mod versioned {
14+
#[versioned(crd(
15+
group = "autoscaling.stackable.tech",
16+
status = ScalerStatus,
17+
scale(
18+
spec_replicas_path = ".spec.replicas",
19+
status_replicas_path = ".status.replicas",
20+
label_selector_path = ".status.selector"
21+
),
22+
namespaced
23+
))]
24+
#[derive(Clone, Debug, PartialEq, CustomResource, Deserialize, Serialize, JsonSchema)]
25+
pub struct ScalerSpec {
26+
/// Desired replica count.
27+
///
28+
/// Written by the horizontal pod autoscaling mechanism via the /scale subresource.
29+
///
30+
/// NOTE: This and other replica fields)use a [`u16`] instead of a [`i32`] used by
31+
/// [`k8s_openapi`] types to force a non-negative replica count. All [`u16`]s can be
32+
/// converted losslessly to [`i32`]s where needed.
33+
///
34+
/// Upstream issues:
35+
///
36+
/// - https://github.com/kubernetes/kubernetes/issues/105533
37+
/// - https://github.com/Arnavion/k8s-openapi/issues/136
38+
pub replicas: u16,
39+
}
40+
}
41+
42+
/// Status of a StackableScaler.
43+
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
44+
#[serde(rename_all = "camelCase")]
45+
pub struct ScalerStatus {
46+
/// The current total number of replicas targeted by the managed StatefulSet.
47+
///
48+
/// Exposed via the `/scale` subresource for horizontal pod autoscaling consumption.
49+
pub replicas: u16,
50+
51+
/// Label selector string for HPA pod counting. Written at `.status.selector`.
52+
#[serde(skip_serializing_if = "Option::is_none")]
53+
pub selector: Option<String>,
54+
55+
/// The current state of the scaler state machine.
56+
pub state: ScalerState,
57+
58+
/// Timestamp indicating when the scaler state last transitioned.
59+
pub last_transition_time: Time,
60+
}
61+
62+
// We use `#[serde(tag)]` and `#[serde(content)]` here to circumvent Kubernetes restrictions in their
63+
// structural schema subset of OpenAPI schemas. They don't allow one variant to be typed as a string
64+
// and others to be typed as objects. We therefore encode the variant data in a separate details
65+
// key/object. With this, all variants can be encoded as strings, while the status can still contain
66+
// additional data in an extra field when needed.
67+
#[derive(Clone, Debug, Deserialize, Serialize, strum::Display)]
68+
#[serde(
69+
tag = "state",
70+
content = "details",
71+
rename_all = "camelCase",
72+
rename_all_fields = "camelCase"
73+
)]
74+
#[strum(serialize_all = "camelCase")]
75+
pub enum ScalerState {
76+
/// No scaling operation is in progress.
77+
Idle,
78+
79+
/// Running the `pre_scale` hook (e.g. data offload).
80+
PreScaling,
81+
82+
/// Waiting for the StatefulSet to converge to the new replica count.
83+
///
84+
/// This stage additionally tracks the previous replica count to be able derive the direction
85+
/// of the scaling operation.
86+
Scaling { previous_replicas: u16 },
87+
88+
/// Running the `post_scale` hook (e.g. cluster rebalance).
89+
///
90+
/// This stage additionally tracks the previous replica count to be able derive the direction
91+
/// of the scaling operation.
92+
PostScaling { previous_replicas: u16 },
93+
94+
/// A hook returned an error.
95+
///
96+
/// The scaler stays here until the user applies the [`Annotation::autoscaling_retry`] annotation
97+
/// to trigger a reset to [`ScalerState::Idle`].
98+
Failed {
99+
/// Which stage produced the error.
100+
failed_in: FailedInState,
101+
102+
/// Human-readable error message from the hook.
103+
reason: String,
104+
},
105+
}
106+
107+
// We manually implement the JSON schema instead of deriving it, because kube's schema transformer
108+
// cannot handle the derived JsonSchema and proceeds to hit the following error: "Property "state"
109+
// has the schema ... but was already defined as ... in another subschema. The schemas for a
110+
// property used in multiple subschemas must be identical".
111+
impl JsonSchema for ScalerState {
112+
fn schema_name() -> Cow<'static, str> {
113+
"ScalerState".into()
114+
}
115+
116+
fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
117+
schemars::json_schema!({
118+
"type": "object",
119+
"required": ["state"],
120+
"properties": {
121+
"state": {
122+
"type": "string",
123+
"enum": ["idle", "preScaling", "scaling", "postScaling", "failed"]
124+
},
125+
"details": {
126+
"type": "object",
127+
"properties": {
128+
"failedIn": generator.subschema_for::<FailedInState>(),
129+
"previous_replicas": {
130+
"type": "uint16",
131+
"minimum": u16::MIN,
132+
"maximum": u16::MAX
133+
},
134+
"reason": { "type": "string" }
135+
}
136+
}
137+
}
138+
})
139+
}
140+
}
141+
142+
/// In which state the scaling operation failed.
143+
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
144+
#[serde(rename_all = "camelCase")]
145+
pub enum FailedInState {
146+
/// The `pre_scale` hook returned an error.
147+
PreScaling,
148+
149+
/// The StatefulSet failed to reach the desired replica count.
150+
Scaling,
151+
152+
/// The `post_scale` hook returned an error.
153+
PostScaling,
154+
}

crates/stackable-operator/src/kvp/annotation/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ impl Annotation {
156156
))?;
157157
Ok(Self(kvp))
158158
}
159+
160+
/// Constructs a `autoscaling.stackable.tech/retry` annotation.
161+
pub fn autoscaling_retry(retry: bool) -> Self {
162+
// SAFETY: We use expect here, because the input parameter can only be one of two possible
163+
// values: true or false. This fact in combination with the known annotation key length
164+
// allows use to use expect here, instead of bubbling up the error.
165+
let kvp = KeyValuePair::try_from(("autoscaling.stackable.tech/retry", retry.to_string()))
166+
.expect("autoscaling retry annotation must be valid");
167+
Self(kvp)
168+
}
159169
}
160170

161171
/// A validated set/list of Kubernetes annotations.

crates/xtask/src/crd/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use stackable_operator::{
1010
PodListenersVersion,
1111
},
1212
s3::{S3Bucket, S3BucketVersion, S3Connection, S3ConnectionVersion},
13+
scaler::{Scaler, ScalerVersion},
1314
},
1415
kube::core::crd::MergeError,
1516
};
@@ -77,6 +78,7 @@ pub fn generate_preview() -> Result<(), Error> {
7778
write_crd!(path, PodListeners, V1Alpha1);
7879
write_crd!(path, S3Bucket, V1Alpha1);
7980
write_crd!(path, S3Connection, V1Alpha1);
81+
write_crd!(path, Scaler, V1Alpha1);
8082

8183
write_crd!(path, DummyCluster, V1Alpha1);
8284

0 commit comments

Comments
 (0)