use std::time::Instant;
use a3s_box_core::operator::{
AutoscalerCondition, BoxAutoscalerSpec, BoxAutoscalerStatus, MetricType, MetricValue,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default)]
pub struct ObservedMetrics {
pub avg_cpu_percent: Option<f32>,
pub avg_memory_percent: Option<f32>,
pub total_inflight: Option<u32>,
pub rps: Option<u32>,
pub custom: std::collections::HashMap<String, u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReconcileResult {
pub desired_replicas: u32,
pub scale_needed: bool,
pub direction: String,
pub reason: String,
pub metric_values: Vec<MetricValue>,
}
pub struct AutoscalerController {
last_scale_up: Option<Instant>,
last_scale_down: Option<Instant>,
}
impl Default for AutoscalerController {
fn default() -> Self {
Self::new()
}
}
impl AutoscalerController {
pub fn new() -> Self {
Self {
last_scale_up: None,
last_scale_down: None,
}
}
pub fn reconcile(
&mut self,
spec: &BoxAutoscalerSpec,
current_replicas: u32,
metrics: &ObservedMetrics,
) -> ReconcileResult {
if spec.metrics.is_empty() {
return ReconcileResult {
desired_replicas: current_replicas,
scale_needed: false,
direction: "none".to_string(),
reason: "No metrics configured".to_string(),
metric_values: Vec::new(),
};
}
let mut computed_desired: Option<u32> = None;
let mut metric_values = Vec::new();
let mut reasons = Vec::new();
for metric_spec in &spec.metrics {
let current_value = match metric_spec.metric_type {
MetricType::Cpu => metrics.avg_cpu_percent.map(|v| v as u32),
MetricType::Memory => metrics.avg_memory_percent.map(|v| v as u32),
MetricType::Inflight => metrics.total_inflight,
MetricType::Rps => metrics.rps,
MetricType::Custom => None, };
let current = match current_value {
Some(v) => v,
None => continue,
};
metric_values.push(MetricValue {
metric_type: metric_spec.metric_type,
current,
target: metric_spec.target,
});
let desired = compute_desired_replicas(
current_replicas,
current,
metric_spec.target,
metric_spec.tolerance_percent,
spec.min_replicas,
spec.max_replicas,
);
computed_desired =
Some(computed_desired.map_or(desired, |prev: u32| prev.max(desired)));
if desired > current_replicas {
reasons.push(format!(
"{} at {}% (target {}%)",
metric_spec.metric_type, current, metric_spec.target
));
}
}
let max_desired = match computed_desired {
Some(d) => d,
None => {
return ReconcileResult {
desired_replicas: current_replicas,
scale_needed: false,
direction: "none".to_string(),
reason: "No metric data available".to_string(),
metric_values,
};
}
};
let desired = max_desired.clamp(spec.min_replicas, spec.max_replicas);
let (scale_needed, direction, reason) = if desired > current_replicas {
if let Some(last) = self.last_scale_up {
if last.elapsed().as_secs() < spec.behavior.scale_up.stabilization_window_secs {
return ReconcileResult {
desired_replicas: current_replicas,
scale_needed: false,
direction: "none".to_string(),
reason: "Scale-up stabilization window active".to_string(),
metric_values,
};
}
}
self.last_scale_up = Some(Instant::now());
(
true,
"up".to_string(),
if reasons.is_empty() {
"Metrics above target".to_string()
} else {
reasons.join("; ")
},
)
} else if desired < current_replicas {
if let Some(last) = self.last_scale_down {
if last.elapsed().as_secs() < spec.behavior.scale_down.stabilization_window_secs {
return ReconcileResult {
desired_replicas: current_replicas,
scale_needed: false,
direction: "none".to_string(),
reason: "Scale-down stabilization window active".to_string(),
metric_values,
};
}
}
self.last_scale_down = Some(Instant::now());
(true, "down".to_string(), "Metrics below target".to_string())
} else {
(false, "none".to_string(), "At target".to_string())
};
ReconcileResult {
desired_replicas: desired,
scale_needed,
direction,
reason,
metric_values,
}
}
pub fn build_status(
&self,
current_replicas: u32,
result: &ReconcileResult,
) -> BoxAutoscalerStatus {
let mut conditions = Vec::new();
conditions.push(AutoscalerCondition {
condition_type: "Ready".to_string(),
status: "True".to_string(),
last_transition_time: Some(Utc::now()),
reason: "Reconciled".to_string(),
message: result.reason.clone(),
});
if result.scale_needed {
conditions.push(AutoscalerCondition {
condition_type: "ScalingActive".to_string(),
status: "True".to_string(),
last_transition_time: Some(Utc::now()),
reason: format!(
"Scale{}",
if result.direction == "up" {
"Up"
} else {
"Down"
}
),
message: format!(
"{} → {} replicas",
current_replicas, result.desired_replicas
),
});
}
BoxAutoscalerStatus {
current_replicas,
desired_replicas: result.desired_replicas,
last_scale_time: if result.scale_needed {
Some(Utc::now())
} else {
None
},
current_metrics: result.metric_values.clone(),
conditions,
}
}
}
fn compute_desired_replicas(
current_replicas: u32,
current_value: u32,
target_value: u32,
tolerance_percent: u32,
min_replicas: u32,
max_replicas: u32,
) -> u32 {
if target_value == 0 || current_replicas == 0 {
return min_replicas;
}
let ratio = current_value as f64 / target_value as f64;
let tolerance = tolerance_percent as f64 / 100.0;
if (ratio - 1.0).abs() <= tolerance {
return current_replicas;
}
let desired = (current_replicas as f64 * ratio).ceil() as u32;
desired.clamp(min_replicas, max_replicas)
}
#[cfg(test)]
mod tests {
use super::*;
use a3s_box_core::operator::{
BoxAutoscalerSpec, MetricSpec, MetricType, ScalingBehavior, ScalingRules, TargetRef,
};
fn test_spec(max: u32) -> BoxAutoscalerSpec {
BoxAutoscalerSpec {
target_ref: TargetRef {
kind: "BoxService".to_string(),
name: "test".to_string(),
namespace: "default".to_string(),
},
min_replicas: 1,
max_replicas: max,
metrics: vec![MetricSpec {
metric_type: MetricType::Cpu,
target: 70,
tolerance_percent: 10,
}],
behavior: ScalingBehavior {
scale_up: ScalingRules {
stabilization_window_secs: 0,
max_scale_per_minute: 10,
},
scale_down: ScalingRules {
stabilization_window_secs: 0,
max_scale_per_minute: 10,
},
},
cooldown_secs: 0,
}
}
#[test]
fn test_compute_desired_replicas_above_target() {
let desired = compute_desired_replicas(3, 90, 70, 10, 1, 10);
assert_eq!(desired, 4);
}
#[test]
fn test_compute_desired_replicas_below_target() {
let desired = compute_desired_replicas(5, 30, 70, 10, 1, 10);
assert_eq!(desired, 3);
}
#[test]
fn test_compute_desired_replicas_within_tolerance() {
let desired = compute_desired_replicas(3, 75, 70, 10, 1, 10);
assert_eq!(desired, 3); }
#[test]
fn test_compute_desired_replicas_clamped_max() {
let desired = compute_desired_replicas(5, 200, 70, 10, 1, 10);
assert_eq!(desired, 10);
}
#[test]
fn test_compute_desired_replicas_clamped_min() {
let desired = compute_desired_replicas(5, 1, 70, 10, 1, 10);
assert_eq!(desired, 1);
}
#[test]
fn test_compute_desired_replicas_zero_target() {
let desired = compute_desired_replicas(3, 50, 0, 10, 1, 10);
assert_eq!(desired, 1); }
#[test]
fn test_reconcile_scale_up() {
let mut ctrl = AutoscalerController::new();
let spec = test_spec(10);
let metrics = ObservedMetrics {
avg_cpu_percent: Some(95.0),
..Default::default()
};
let result = ctrl.reconcile(&spec, 3, &metrics);
assert!(result.scale_needed);
assert_eq!(result.direction, "up");
assert!(result.desired_replicas > 3);
assert!(!result.metric_values.is_empty());
}
#[test]
fn test_reconcile_scale_down() {
let mut ctrl = AutoscalerController::new();
let spec = test_spec(10);
let metrics = ObservedMetrics {
avg_cpu_percent: Some(20.0),
..Default::default()
};
let result = ctrl.reconcile(&spec, 5, &metrics);
assert!(result.scale_needed);
assert_eq!(result.direction, "down");
assert!(result.desired_replicas < 5);
}
#[test]
fn test_reconcile_no_change() {
let mut ctrl = AutoscalerController::new();
let spec = test_spec(10);
let metrics = ObservedMetrics {
avg_cpu_percent: Some(72.0), ..Default::default()
};
let result = ctrl.reconcile(&spec, 3, &metrics);
assert!(!result.scale_needed);
assert_eq!(result.direction, "none");
assert_eq!(result.desired_replicas, 3);
}
#[test]
fn test_reconcile_no_metrics_configured() {
let mut ctrl = AutoscalerController::new();
let mut spec = test_spec(10);
spec.metrics.clear();
let metrics = ObservedMetrics::default();
let result = ctrl.reconcile(&spec, 3, &metrics);
assert!(!result.scale_needed);
assert_eq!(result.desired_replicas, 3);
}
#[test]
fn test_reconcile_no_metric_data() {
let mut ctrl = AutoscalerController::new();
let spec = test_spec(10);
let metrics = ObservedMetrics::default();
let result = ctrl.reconcile(&spec, 3, &metrics);
assert!(!result.scale_needed);
}
#[test]
fn test_reconcile_stabilization_window() {
let mut ctrl = AutoscalerController::new();
let mut spec = test_spec(10);
spec.behavior.scale_up.stabilization_window_secs = 3600;
let metrics = ObservedMetrics {
avg_cpu_percent: Some(95.0),
..Default::default()
};
let r1 = ctrl.reconcile(&spec, 3, &metrics);
assert!(r1.scale_needed);
let r2 = ctrl.reconcile(&spec, 3, &metrics);
assert!(!r2.scale_needed);
assert!(r2.reason.contains("stabilization"));
}
#[test]
fn test_reconcile_multiple_metrics() {
let mut ctrl = AutoscalerController::new();
let mut spec = test_spec(10);
spec.metrics = vec![
MetricSpec {
metric_type: MetricType::Cpu,
target: 70,
tolerance_percent: 10,
},
MetricSpec {
metric_type: MetricType::Inflight,
target: 50,
tolerance_percent: 10,
},
];
let metrics = ObservedMetrics {
avg_cpu_percent: Some(65.0), total_inflight: Some(200), ..Default::default()
};
let result = ctrl.reconcile(&spec, 2, &metrics);
assert!(result.scale_needed);
assert_eq!(result.direction, "up");
assert!(result.desired_replicas > 2);
}
#[test]
fn test_reconcile_respects_max_replicas() {
let mut ctrl = AutoscalerController::new();
let spec = test_spec(5);
let metrics = ObservedMetrics {
avg_cpu_percent: Some(200.0), ..Default::default()
};
let result = ctrl.reconcile(&spec, 4, &metrics);
assert!(result.desired_replicas <= 5);
}
#[test]
fn test_reconcile_respects_min_replicas() {
let mut ctrl = AutoscalerController::new();
let mut spec = test_spec(10);
spec.min_replicas = 2;
let metrics = ObservedMetrics {
avg_cpu_percent: Some(1.0), ..Default::default()
};
let result = ctrl.reconcile(&spec, 5, &metrics);
assert!(result.desired_replicas >= 2);
}
#[test]
fn test_build_status_no_scale() {
let ctrl = AutoscalerController::new();
let result = ReconcileResult {
desired_replicas: 3,
scale_needed: false,
direction: "none".to_string(),
reason: "At target".to_string(),
metric_values: vec![],
};
let status = ctrl.build_status(3, &result);
assert_eq!(status.current_replicas, 3);
assert_eq!(status.desired_replicas, 3);
assert!(status.last_scale_time.is_none());
assert_eq!(status.conditions.len(), 1);
assert_eq!(status.conditions[0].condition_type, "Ready");
}
#[test]
fn test_build_status_with_scale() {
let ctrl = AutoscalerController::new();
let result = ReconcileResult {
desired_replicas: 5,
scale_needed: true,
direction: "up".to_string(),
reason: "cpu at 90% (target 70%)".to_string(),
metric_values: vec![MetricValue {
metric_type: MetricType::Cpu,
current: 90,
target: 70,
}],
};
let status = ctrl.build_status(3, &result);
assert_eq!(status.current_replicas, 3);
assert_eq!(status.desired_replicas, 5);
assert!(status.last_scale_time.is_some());
assert_eq!(status.conditions.len(), 2); assert_eq!(status.conditions[1].condition_type, "ScalingActive");
}
#[test]
fn test_controller_new() {
let ctrl = AutoscalerController::new();
assert!(ctrl.last_scale_up.is_none());
assert!(ctrl.last_scale_down.is_none());
}
}