1use std::collections::HashMap;
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17pub const SCALE_OPERATION_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum ScaleDirection {
23 Up,
24 Down,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ScaleOperationRequest {
30 pub schema_version: u32,
31 pub operation_id: String,
32 pub service: String,
33 pub expected_revision: Option<String>,
34 pub direction: ScaleDirection,
35 pub current_replicas: u32,
36 pub desired_replicas: u32,
37 pub reason: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ScaleObservation {
43 pub replicas: u32,
45 pub revision: Option<String>,
46 #[serde(default)]
48 pub ready_replicas: u32,
49 #[serde(default)]
52 pub endpoints: Vec<ScaleEndpoint>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ScaleEndpoint {
58 pub instance_id: String,
59 pub slot: u32,
60 pub url: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ScaleOperationResponse {
66 pub accepted: bool,
67 pub actual_replicas: u32,
68 pub revision: Option<String>,
69 pub message: String,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ScaleOperationConflict {
75 pub code: String,
76 pub message: String,
77 pub observation: ScaleObservation,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum InstanceState {
83 Creating,
85 Booting,
87 Ready,
89 Busy,
91 Draining,
93 Stopping,
95 Stopped,
97 Failed,
99}
100
101impl std::fmt::Display for InstanceState {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 Self::Creating => write!(f, "creating"),
105 Self::Booting => write!(f, "booting"),
106 Self::Ready => write!(f, "ready"),
107 Self::Busy => write!(f, "busy"),
108 Self::Draining => write!(f, "draining"),
109 Self::Stopping => write!(f, "stopping"),
110 Self::Stopped => write!(f, "stopped"),
111 Self::Failed => write!(f, "failed"),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ScaleRequest {
119 pub service: String,
121 pub replicas: u32,
123 #[serde(default)]
125 pub config: ScaleConfig,
126 #[serde(default)]
128 pub request_id: String,
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct ScaleConfig {
134 #[serde(default)]
136 pub image: Option<String>,
137 #[serde(default)]
139 pub vcpus: Option<u8>,
140 #[serde(default)]
142 pub memory_mib: Option<u32>,
143 #[serde(default)]
145 pub env: HashMap<String, String>,
146 #[serde(default)]
148 pub port_map: Vec<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ScaleResponse {
154 pub request_id: String,
156 pub accepted: bool,
158 pub current_replicas: u32,
160 pub target_replicas: u32,
162 pub instances: Vec<InstanceInfo>,
164 #[serde(default)]
166 pub error: Option<String>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct InstanceInfo {
172 pub id: String,
174 pub state: InstanceState,
176 pub service: String,
178 pub created_at: DateTime<Utc>,
180 #[serde(default)]
182 pub ready_at: Option<DateTime<Utc>>,
183 #[serde(default)]
185 pub endpoint: Option<String>,
186 #[serde(default)]
188 pub health: InstanceHealth,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct InstanceHealth {
194 #[serde(default)]
196 pub cpu_percent: Option<f32>,
197 #[serde(default)]
199 pub memory_bytes: Option<u64>,
200 #[serde(default)]
202 pub inflight_requests: u32,
203 #[serde(default = "default_true")]
205 pub healthy: bool,
206}
207
208impl Default for InstanceHealth {
209 fn default() -> Self {
210 Self {
211 cpu_percent: None,
212 memory_bytes: None,
213 inflight_requests: 0,
214 healthy: true,
215 }
216 }
217}
218
219fn default_true() -> bool {
220 true
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct InstanceEvent {
226 pub instance_id: String,
228 pub service: String,
230 pub from_state: InstanceState,
232 pub to_state: InstanceState,
234 pub timestamp: DateTime<Utc>,
236 #[serde(default)]
238 pub message: String,
239}
240
241impl InstanceEvent {
242 pub fn transition(
244 instance_id: &str,
245 service: &str,
246 from: InstanceState,
247 to: InstanceState,
248 ) -> Self {
249 Self {
250 instance_id: instance_id.to_string(),
251 service: service.to_string(),
252 from_state: from,
253 to_state: to,
254 timestamp: Utc::now(),
255 message: String::new(),
256 }
257 }
258
259 pub fn with_message(mut self, msg: &str) -> Self {
261 self.message = msg.to_string();
262 self
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct InstanceRegistration {
269 pub instance_id: String,
271 pub service: String,
273 pub endpoint: String,
275 #[serde(default)]
277 pub metadata: HashMap<String, String>,
278 pub started_at: DateTime<Utc>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct InstanceDeregistration {
285 pub instance_id: String,
287 pub service: String,
289 #[serde(default)]
291 pub reason: String,
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn test_instance_state_display() {
300 assert_eq!(InstanceState::Creating.to_string(), "creating");
301 assert_eq!(InstanceState::Booting.to_string(), "booting");
302 assert_eq!(InstanceState::Ready.to_string(), "ready");
303 assert_eq!(InstanceState::Busy.to_string(), "busy");
304 assert_eq!(InstanceState::Draining.to_string(), "draining");
305 assert_eq!(InstanceState::Stopping.to_string(), "stopping");
306 assert_eq!(InstanceState::Stopped.to_string(), "stopped");
307 assert_eq!(InstanceState::Failed.to_string(), "failed");
308 }
309
310 #[test]
311 fn test_scale_request_serde() {
312 let req = ScaleRequest {
313 service: "my-service".to_string(),
314 replicas: 3,
315 config: ScaleConfig {
316 image: Some("nginx:latest".to_string()),
317 vcpus: Some(2),
318 memory_mib: Some(512),
319 env: HashMap::from([("PORT".to_string(), "8080".to_string())]),
320 port_map: vec!["8080:80".to_string()],
321 },
322 request_id: "req-001".to_string(),
323 };
324 let json = serde_json::to_string(&req).unwrap();
325 let parsed: ScaleRequest = serde_json::from_str(&json).unwrap();
326 assert_eq!(parsed.service, "my-service");
327 assert_eq!(parsed.replicas, 3);
328 assert_eq!(parsed.config.image, Some("nginx:latest".to_string()));
329 assert_eq!(parsed.config.vcpus, Some(2));
330 assert_eq!(parsed.config.env.get("PORT").unwrap(), "8080");
331 }
332
333 #[test]
334 fn test_scale_request_minimal() {
335 let json = r#"{"service":"svc","replicas":1}"#;
336 let req: ScaleRequest = serde_json::from_str(json).unwrap();
337 assert_eq!(req.service, "svc");
338 assert_eq!(req.replicas, 1);
339 assert!(req.config.image.is_none());
340 assert!(req.request_id.is_empty());
341 }
342
343 #[test]
344 fn test_scale_response_accepted() {
345 let resp = ScaleResponse {
346 request_id: "req-001".to_string(),
347 accepted: true,
348 current_replicas: 2,
349 target_replicas: 3,
350 instances: vec![InstanceInfo {
351 id: "box-1".to_string(),
352 state: InstanceState::Ready,
353 service: "svc".to_string(),
354 created_at: Utc::now(),
355 ready_at: Some(Utc::now()),
356 endpoint: Some("10.0.0.2:8080".to_string()),
357 health: InstanceHealth::default(),
358 }],
359 error: None,
360 };
361 let json = serde_json::to_string(&resp).unwrap();
362 let parsed: ScaleResponse = serde_json::from_str(&json).unwrap();
363 assert!(parsed.accepted);
364 assert_eq!(parsed.current_replicas, 2);
365 assert_eq!(parsed.target_replicas, 3);
366 assert_eq!(parsed.instances.len(), 1);
367 assert_eq!(parsed.instances[0].state, InstanceState::Ready);
368 }
369
370 #[test]
371 fn test_scale_response_rejected() {
372 let resp = ScaleResponse {
373 request_id: "req-002".to_string(),
374 accepted: false,
375 current_replicas: 5,
376 target_replicas: 5,
377 instances: vec![],
378 error: Some("At maximum capacity".to_string()),
379 };
380 let json = serde_json::to_string(&resp).unwrap();
381 let parsed: ScaleResponse = serde_json::from_str(&json).unwrap();
382 assert!(!parsed.accepted);
383 assert_eq!(parsed.error, Some("At maximum capacity".to_string()));
384 }
385
386 #[test]
387 fn test_instance_info_serde() {
388 let info = InstanceInfo {
389 id: "box-abc".to_string(),
390 state: InstanceState::Busy,
391 service: "api".to_string(),
392 created_at: Utc::now(),
393 ready_at: Some(Utc::now()),
394 endpoint: Some("10.0.0.5:3000".to_string()),
395 health: InstanceHealth {
396 cpu_percent: Some(45.2),
397 memory_bytes: Some(256 * 1024 * 1024),
398 inflight_requests: 3,
399 healthy: true,
400 },
401 };
402 let json = serde_json::to_string(&info).unwrap();
403 let parsed: InstanceInfo = serde_json::from_str(&json).unwrap();
404 assert_eq!(parsed.id, "box-abc");
405 assert_eq!(parsed.state, InstanceState::Busy);
406 assert_eq!(parsed.health.cpu_percent, Some(45.2));
407 assert_eq!(parsed.health.inflight_requests, 3);
408 }
409
410 #[test]
411 fn test_instance_health_default() {
412 let health = InstanceHealth::default();
413 assert!(health.cpu_percent.is_none());
414 assert!(health.memory_bytes.is_none());
415 assert_eq!(health.inflight_requests, 0);
416 assert!(health.healthy);
417 }
418
419 #[test]
420 fn test_instance_event_transition() {
421 let event = InstanceEvent::transition(
422 "box-123",
423 "my-svc",
424 InstanceState::Booting,
425 InstanceState::Ready,
426 );
427 assert_eq!(event.instance_id, "box-123");
428 assert_eq!(event.service, "my-svc");
429 assert_eq!(event.from_state, InstanceState::Booting);
430 assert_eq!(event.to_state, InstanceState::Ready);
431 assert!(event.message.is_empty());
432 }
433
434 #[test]
435 fn test_instance_event_with_message() {
436 let event = InstanceEvent::transition(
437 "box-456",
438 "svc",
439 InstanceState::Booting,
440 InstanceState::Failed,
441 )
442 .with_message("OOM killed");
443 assert_eq!(event.message, "OOM killed");
444 assert_eq!(event.to_state, InstanceState::Failed);
445 }
446
447 #[test]
448 fn test_instance_event_serde() {
449 let event = InstanceEvent::transition(
450 "box-789",
451 "api",
452 InstanceState::Ready,
453 InstanceState::Draining,
454 );
455 let json = serde_json::to_string(&event).unwrap();
456 let parsed: InstanceEvent = serde_json::from_str(&json).unwrap();
457 assert_eq!(parsed.instance_id, "box-789");
458 assert_eq!(parsed.from_state, InstanceState::Ready);
459 assert_eq!(parsed.to_state, InstanceState::Draining);
460 }
461
462 #[test]
463 fn test_instance_registration_serde() {
464 let reg = InstanceRegistration {
465 instance_id: "box-reg".to_string(),
466 service: "web".to_string(),
467 endpoint: "10.0.0.10:8080".to_string(),
468 metadata: HashMap::from([("version".to_string(), "v1.2".to_string())]),
469 started_at: Utc::now(),
470 };
471 let json = serde_json::to_string(®).unwrap();
472 let parsed: InstanceRegistration = serde_json::from_str(&json).unwrap();
473 assert_eq!(parsed.instance_id, "box-reg");
474 assert_eq!(parsed.endpoint, "10.0.0.10:8080");
475 assert_eq!(parsed.metadata.get("version").unwrap(), "v1.2");
476 }
477
478 #[test]
479 fn test_instance_deregistration_serde() {
480 let dereg = InstanceDeregistration {
481 instance_id: "box-dereg".to_string(),
482 service: "web".to_string(),
483 reason: "scale-down".to_string(),
484 };
485 let json = serde_json::to_string(&dereg).unwrap();
486 let parsed: InstanceDeregistration = serde_json::from_str(&json).unwrap();
487 assert_eq!(parsed.instance_id, "box-dereg");
488 assert_eq!(parsed.reason, "scale-down");
489 }
490
491 #[test]
492 fn test_scale_config_default() {
493 let config = ScaleConfig::default();
494 assert!(config.image.is_none());
495 assert!(config.vcpus.is_none());
496 assert!(config.memory_mib.is_none());
497 assert!(config.env.is_empty());
498 assert!(config.port_map.is_empty());
499 }
500
501 #[test]
502 fn test_instance_state_equality() {
503 assert_eq!(InstanceState::Ready, InstanceState::Ready);
504 assert_ne!(InstanceState::Ready, InstanceState::Busy);
505 }
506
507 #[test]
508 fn test_instance_state_hash() {
509 use std::collections::HashSet;
510 let mut set = HashSet::new();
511 set.insert(InstanceState::Ready);
512 set.insert(InstanceState::Busy);
513 set.insert(InstanceState::Ready); assert_eq!(set.len(), 2);
515 }
516
517 #[test]
518 fn versioned_scale_operation_matches_gateway_wire_shape() {
519 let request: ScaleOperationRequest = serde_json::from_value(serde_json::json!({
520 "schema_version": 1,
521 "operation_id": "scale-v1-abc",
522 "service": "api",
523 "expected_revision": "17",
524 "direction": "Up",
525 "current_replicas": 1,
526 "desired_replicas": 3,
527 "reason": "fixture load"
528 }))
529 .unwrap();
530
531 assert_eq!(request.schema_version, SCALE_OPERATION_SCHEMA_VERSION);
532 assert_eq!(request.direction, ScaleDirection::Up);
533 assert_eq!(request.expected_revision.as_deref(), Some("17"));
534 assert_eq!(request.current_replicas, 1);
535 assert_eq!(request.desired_replicas, 3);
536
537 let response = ScaleOperationResponse {
538 accepted: true,
539 actual_replicas: 3,
540 revision: Some("18".to_string()),
541 message: "accepted".to_string(),
542 };
543 assert_eq!(
544 serde_json::to_value(response).unwrap(),
545 serde_json::json!({
546 "accepted": true,
547 "actual_replicas": 3,
548 "revision": "18",
549 "message": "accepted"
550 })
551 );
552 }
553
554 #[test]
555 fn scale_observation_adds_live_endpoints_compatibly() {
556 let legacy: ScaleObservation = serde_json::from_value(serde_json::json!({
557 "replicas": 2,
558 "revision": "4"
559 }))
560 .unwrap();
561 assert_eq!(legacy.ready_replicas, 0);
562 assert!(legacy.endpoints.is_empty());
563
564 let observation = ScaleObservation {
565 replicas: 2,
566 revision: Some("4".to_string()),
567 ready_replicas: 1,
568 endpoints: vec![ScaleEndpoint {
569 instance_id: "box-api-0".to_string(),
570 slot: 0,
571 url: "http://127.0.0.1:18080".to_string(),
572 }],
573 };
574 let encoded = serde_json::to_value(observation).unwrap();
575 assert_eq!(encoded["ready_replicas"], 1);
576 assert_eq!(encoded["endpoints"][0]["slot"], 0);
577 }
578}