Skip to main content

a3s_box_core/
operator.rs

1//! Kubernetes BoxAutoscaler CRD types.
2//!
3//! Defines the Custom Resource Definition for autoscaling Box instances
4//! in a Kubernetes cluster. The controller watches these resources and
5//! adjusts replica counts based on metrics.
6//!
7//! ## CRD Schema
8//!
9//! ```yaml
10//! apiVersion: box.a3s.dev/v1alpha1
11//! kind: BoxAutoscaler
12//! metadata:
13//!   name: my-service-autoscaler
14//! spec:
15//!   targetRef:
16//!     kind: BoxService
17//!     name: my-service
18//!   minReplicas: 1
19//!   maxReplicas: 10
20//!   metrics:
21//!     - type: cpu
22//!       target: 70
23//!     - type: inflight
24//!       target: 50
25//!   behavior:
26//!     scaleUp:
27//!       stabilizationWindowSeconds: 60
28//!       maxScalePerMinute: 3
29//!     scaleDown:
30//!       stabilizationWindowSeconds: 300
31//!       maxScalePerMinute: 1
32//! ```
33
34use std::collections::HashMap;
35
36use chrono::{DateTime, Utc};
37use serde::{Deserialize, Serialize};
38
39/// API group for Box CRDs.
40pub const API_GROUP: &str = "box.a3s.dev";
41/// API version for BoxAutoscaler.
42pub const API_VERSION: &str = "v1alpha1";
43/// CRD kind.
44pub const KIND: &str = "BoxAutoscaler";
45
46/// BoxAutoscaler CRD spec — desired autoscaling behavior.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct BoxAutoscalerSpec {
49    /// Reference to the target resource to scale.
50    pub target_ref: TargetRef,
51    /// Minimum number of replicas (default: 1).
52    #[serde(default = "default_min_replicas")]
53    pub min_replicas: u32,
54    /// Maximum number of replicas.
55    pub max_replicas: u32,
56    /// Metrics to evaluate for scaling decisions.
57    #[serde(default)]
58    pub metrics: Vec<MetricSpec>,
59    /// Scaling behavior configuration.
60    #[serde(default)]
61    pub behavior: ScalingBehavior,
62    /// Cooldown period in seconds after a scale event (default: 60).
63    #[serde(default = "default_cooldown")]
64    pub cooldown_secs: u64,
65}
66
67fn default_min_replicas() -> u32 {
68    1
69}
70
71fn default_cooldown() -> u64 {
72    60
73}
74
75/// Reference to the target resource being scaled.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct TargetRef {
78    /// Resource kind (e.g., "BoxService", "Deployment").
79    pub kind: String,
80    /// Resource name.
81    pub name: String,
82    /// Namespace (optional, defaults to "default").
83    #[serde(default = "default_namespace")]
84    pub namespace: String,
85}
86
87fn default_namespace() -> String {
88    "default".to_string()
89}
90
91/// A metric used for autoscaling decisions.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct MetricSpec {
94    /// Metric type.
95    #[serde(rename = "type")]
96    pub metric_type: MetricType,
97    /// Target value (interpretation depends on metric type).
98    pub target: u32,
99    /// Tolerance percentage before triggering scale (default: 10%).
100    #[serde(default = "default_tolerance")]
101    pub tolerance_percent: u32,
102}
103
104fn default_tolerance() -> u32 {
105    10
106}
107
108/// Supported metric types for autoscaling.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum MetricType {
112    /// Average CPU utilization percentage across instances.
113    Cpu,
114    /// Average memory utilization percentage across instances.
115    Memory,
116    /// Total in-flight requests across instances.
117    Inflight,
118    /// Requests per second across all instances.
119    Rps,
120    /// Custom metric (from Prometheus or external source).
121    Custom,
122}
123
124impl std::fmt::Display for MetricType {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            Self::Cpu => write!(f, "cpu"),
128            Self::Memory => write!(f, "memory"),
129            Self::Inflight => write!(f, "inflight"),
130            Self::Rps => write!(f, "rps"),
131            Self::Custom => write!(f, "custom"),
132        }
133    }
134}
135
136/// Scaling behavior configuration.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ScalingBehavior {
139    /// Scale-up behavior.
140    #[serde(default)]
141    pub scale_up: ScalingRules,
142    /// Scale-down behavior.
143    #[serde(default)]
144    pub scale_down: ScalingRules,
145}
146
147impl Default for ScalingBehavior {
148    fn default() -> Self {
149        Self {
150            scale_up: ScalingRules {
151                stabilization_window_secs: 60,
152                max_scale_per_minute: 3,
153            },
154            scale_down: ScalingRules {
155                stabilization_window_secs: 300,
156                max_scale_per_minute: 1,
157            },
158        }
159    }
160}
161
162/// Rules for a scaling direction (up or down).
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ScalingRules {
165    /// Seconds to wait after a metric change before acting.
166    #[serde(default = "default_stabilization")]
167    pub stabilization_window_secs: u64,
168    /// Maximum replica changes per minute.
169    #[serde(default = "default_max_scale")]
170    pub max_scale_per_minute: u32,
171}
172
173fn default_stabilization() -> u64 {
174    60
175}
176
177fn default_max_scale() -> u32 {
178    2
179}
180
181impl Default for ScalingRules {
182    fn default() -> Self {
183        Self {
184            stabilization_window_secs: 60,
185            max_scale_per_minute: 2,
186        }
187    }
188}
189
190/// BoxAutoscaler CRD status — observed state.
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192pub struct BoxAutoscalerStatus {
193    /// Current number of replicas.
194    pub current_replicas: u32,
195    /// Desired number of replicas (as computed by the controller).
196    pub desired_replicas: u32,
197    /// Last time the autoscaler scaled.
198    #[serde(default)]
199    pub last_scale_time: Option<DateTime<Utc>>,
200    /// Current metric values.
201    #[serde(default)]
202    pub current_metrics: Vec<MetricValue>,
203    /// Conditions describing the autoscaler state.
204    #[serde(default)]
205    pub conditions: Vec<AutoscalerCondition>,
206}
207
208/// An observed metric value.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct MetricValue {
211    /// Metric type.
212    #[serde(rename = "type")]
213    pub metric_type: MetricType,
214    /// Current observed value.
215    pub current: u32,
216    /// Target value from spec.
217    pub target: u32,
218}
219
220/// A condition on the autoscaler.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct AutoscalerCondition {
223    /// Condition type (e.g., "Ready", "ScalingActive", "ScalingLimited").
224    #[serde(rename = "type")]
225    pub condition_type: String,
226    /// Status: "True", "False", or "Unknown".
227    pub status: String,
228    /// Last time the condition transitioned.
229    #[serde(default)]
230    pub last_transition_time: Option<DateTime<Utc>>,
231    /// Human-readable reason.
232    #[serde(default)]
233    pub reason: String,
234    /// Human-readable message.
235    #[serde(default)]
236    pub message: String,
237}
238
239/// Full BoxAutoscaler resource (spec + status + metadata).
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct BoxAutoscaler {
242    /// API version.
243    pub api_version: String,
244    /// Resource kind.
245    pub kind: String,
246    /// Resource name.
247    pub name: String,
248    /// Namespace.
249    #[serde(default = "default_namespace")]
250    pub namespace: String,
251    /// Labels.
252    #[serde(default)]
253    pub labels: HashMap<String, String>,
254    /// Spec.
255    pub spec: BoxAutoscalerSpec,
256    /// Status.
257    #[serde(default)]
258    pub status: BoxAutoscalerStatus,
259}
260
261impl BoxAutoscaler {
262    /// Create a new BoxAutoscaler resource.
263    pub fn new(name: &str, spec: BoxAutoscalerSpec) -> Self {
264        Self {
265            api_version: format!("{}/{}", API_GROUP, API_VERSION),
266            kind: KIND.to_string(),
267            name: name.to_string(),
268            namespace: "default".to_string(),
269            labels: HashMap::new(),
270            spec,
271            status: BoxAutoscalerStatus::default(),
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn sample_spec() -> BoxAutoscalerSpec {
281        BoxAutoscalerSpec {
282            target_ref: TargetRef {
283                kind: "BoxService".to_string(),
284                name: "my-service".to_string(),
285                namespace: "default".to_string(),
286            },
287            min_replicas: 1,
288            max_replicas: 10,
289            metrics: vec![MetricSpec {
290                metric_type: MetricType::Cpu,
291                target: 70,
292                tolerance_percent: 10,
293            }],
294            behavior: ScalingBehavior::default(),
295            cooldown_secs: 60,
296        }
297    }
298
299    #[test]
300    fn test_box_autoscaler_new() {
301        let ba = BoxAutoscaler::new("test-scaler", sample_spec());
302        assert_eq!(ba.name, "test-scaler");
303        assert_eq!(ba.api_version, "box.a3s.dev/v1alpha1");
304        assert_eq!(ba.kind, "BoxAutoscaler");
305        assert_eq!(ba.namespace, "default");
306        assert_eq!(ba.spec.min_replicas, 1);
307        assert_eq!(ba.spec.max_replicas, 10);
308    }
309
310    #[test]
311    fn test_spec_serde_roundtrip() {
312        let spec = sample_spec();
313        let json = serde_json::to_string(&spec).unwrap();
314        let parsed: BoxAutoscalerSpec = serde_json::from_str(&json).unwrap();
315        assert_eq!(parsed.min_replicas, 1);
316        assert_eq!(parsed.max_replicas, 10);
317        assert_eq!(parsed.metrics.len(), 1);
318        assert_eq!(parsed.metrics[0].metric_type, MetricType::Cpu);
319        assert_eq!(parsed.metrics[0].target, 70);
320    }
321
322    #[test]
323    fn test_spec_deserialize_minimal() {
324        let json = r#"{
325            "target_ref": {"kind": "BoxService", "name": "svc"},
326            "max_replicas": 5
327        }"#;
328        let spec: BoxAutoscalerSpec = serde_json::from_str(json).unwrap();
329        assert_eq!(spec.min_replicas, 1); // default
330        assert_eq!(spec.max_replicas, 5);
331        assert_eq!(spec.cooldown_secs, 60); // default
332        assert!(spec.metrics.is_empty());
333        assert_eq!(spec.target_ref.namespace, "default"); // default
334    }
335
336    #[test]
337    fn test_metric_type_display() {
338        assert_eq!(MetricType::Cpu.to_string(), "cpu");
339        assert_eq!(MetricType::Memory.to_string(), "memory");
340        assert_eq!(MetricType::Inflight.to_string(), "inflight");
341        assert_eq!(MetricType::Rps.to_string(), "rps");
342        assert_eq!(MetricType::Custom.to_string(), "custom");
343    }
344
345    #[test]
346    fn test_metric_type_serde() {
347        let json = r#""cpu""#;
348        let mt: MetricType = serde_json::from_str(json).unwrap();
349        assert_eq!(mt, MetricType::Cpu);
350
351        let json = serde_json::to_string(&MetricType::Inflight).unwrap();
352        assert_eq!(json, r#""inflight""#);
353    }
354
355    #[test]
356    fn test_scaling_behavior_default() {
357        let behavior = ScalingBehavior::default();
358        assert_eq!(behavior.scale_up.stabilization_window_secs, 60);
359        assert_eq!(behavior.scale_up.max_scale_per_minute, 3);
360        assert_eq!(behavior.scale_down.stabilization_window_secs, 300);
361        assert_eq!(behavior.scale_down.max_scale_per_minute, 1);
362    }
363
364    #[test]
365    fn test_status_default() {
366        let status = BoxAutoscalerStatus::default();
367        assert_eq!(status.current_replicas, 0);
368        assert_eq!(status.desired_replicas, 0);
369        assert!(status.last_scale_time.is_none());
370        assert!(status.current_metrics.is_empty());
371        assert!(status.conditions.is_empty());
372    }
373
374    #[test]
375    fn test_status_serde_roundtrip() {
376        let status = BoxAutoscalerStatus {
377            current_replicas: 3,
378            desired_replicas: 5,
379            last_scale_time: Some(Utc::now()),
380            current_metrics: vec![MetricValue {
381                metric_type: MetricType::Cpu,
382                current: 85,
383                target: 70,
384            }],
385            conditions: vec![AutoscalerCondition {
386                condition_type: "ScalingActive".to_string(),
387                status: "True".to_string(),
388                last_transition_time: Some(Utc::now()),
389                reason: "HighCPU".to_string(),
390                message: "CPU above target".to_string(),
391            }],
392        };
393        let json = serde_json::to_string(&status).unwrap();
394        let parsed: BoxAutoscalerStatus = serde_json::from_str(&json).unwrap();
395        assert_eq!(parsed.current_replicas, 3);
396        assert_eq!(parsed.desired_replicas, 5);
397        assert_eq!(parsed.current_metrics.len(), 1);
398        assert_eq!(parsed.conditions.len(), 1);
399        assert_eq!(parsed.conditions[0].reason, "HighCPU");
400    }
401
402    #[test]
403    fn test_full_resource_serde() {
404        let ba = BoxAutoscaler::new("my-scaler", sample_spec());
405        let json = serde_json::to_string_pretty(&ba).unwrap();
406        let parsed: BoxAutoscaler = serde_json::from_str(&json).unwrap();
407        assert_eq!(parsed.name, "my-scaler");
408        assert_eq!(parsed.spec.max_replicas, 10);
409        assert_eq!(parsed.status.current_replicas, 0);
410    }
411
412    #[test]
413    fn test_target_ref_serde() {
414        let tr = TargetRef {
415            kind: "BoxService".to_string(),
416            name: "web".to_string(),
417            namespace: "production".to_string(),
418        };
419        let json = serde_json::to_string(&tr).unwrap();
420        let parsed: TargetRef = serde_json::from_str(&json).unwrap();
421        assert_eq!(parsed.kind, "BoxService");
422        assert_eq!(parsed.name, "web");
423        assert_eq!(parsed.namespace, "production");
424    }
425
426    #[test]
427    fn test_metric_spec_with_tolerance() {
428        let ms = MetricSpec {
429            metric_type: MetricType::Rps,
430            target: 1000,
431            tolerance_percent: 15,
432        };
433        let json = serde_json::to_string(&ms).unwrap();
434        let parsed: MetricSpec = serde_json::from_str(&json).unwrap();
435        assert_eq!(parsed.metric_type, MetricType::Rps);
436        assert_eq!(parsed.target, 1000);
437        assert_eq!(parsed.tolerance_percent, 15);
438    }
439
440    #[test]
441    fn test_metric_spec_default_tolerance() {
442        let json = r#"{"type": "cpu", "target": 80}"#;
443        let ms: MetricSpec = serde_json::from_str(json).unwrap();
444        assert_eq!(ms.tolerance_percent, 10); // default
445    }
446
447    #[test]
448    fn test_constants() {
449        assert_eq!(API_GROUP, "box.a3s.dev");
450        assert_eq!(API_VERSION, "v1alpha1");
451        assert_eq!(KIND, "BoxAutoscaler");
452    }
453}