Skip to main content

a3s_box_core/
scale.rs

1//! Scale API types for Gateway ↔ Box communication.
2//!
3//! Defines the request/response types for the internal Scale API that
4//! Gateway uses to request instance scale-up/scale-down in standalone mode.
5//!
6//! ## Protocol
7//!
8//! Gateway → Box: `ScaleRequest` (service, desired replicas, config)
9//! Box → Gateway: `ScaleResponse` (actual replicas, instance states)
10//! Box → Gateway: `InstanceEvent` (state transitions, health updates)
11
12use std::collections::HashMap;
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17/// Current version of the Gateway-to-Box scale operation contract.
18pub const SCALE_OPERATION_SCHEMA_VERSION: u32 = 1;
19
20/// Direction asserted by Gateway for a scale transition.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22pub enum ScaleDirection {
23    Up,
24    Down,
25}
26
27/// Versioned, compare-and-set scale mutation issued by Gateway.
28#[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/// Desired replica state returned by the Box scale authority.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ScaleObservation {
43    /// Durable desired replica count used for compare-and-set mutations.
44    pub replicas: u32,
45    pub revision: Option<String>,
46    /// Replicas whose execution and declared service endpoint are ready.
47    #[serde(default)]
48    pub ready_replicas: u32,
49    /// Live, runtime-owned endpoints. These are observations, not durable
50    /// operation-replay receipts, and may change after Box restarts.
51    #[serde(default)]
52    pub endpoints: Vec<ScaleEndpoint>,
53}
54
55/// One live endpoint published for a deterministic replica slot.
56#[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/// Result of accepting one scale mutation.
64#[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/// Machine-readable conflict returned without changing desired state.
73#[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/// Instance lifecycle state for readiness signaling.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub enum InstanceState {
83    /// Instance is being created (image pull, rootfs build)
84    Creating,
85    /// VM is booting (kernel + init)
86    Booting,
87    /// Agent is ready, accepting requests
88    Ready,
89    /// Instance is actively processing a request
90    Busy,
91    /// Instance is draining in-flight requests before shutdown
92    Draining,
93    /// Instance is shutting down
94    Stopping,
95    /// Instance has terminated
96    Stopped,
97    /// Instance failed to start or crashed
98    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/// Request from Gateway to scale a service.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ScaleRequest {
119    /// Service identifier
120    pub service: String,
121    /// Desired number of running instances
122    pub replicas: u32,
123    /// Instance configuration overrides
124    #[serde(default)]
125    pub config: ScaleConfig,
126    /// Request ID for correlation
127    #[serde(default)]
128    pub request_id: String,
129}
130
131/// Instance configuration for scale requests.
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct ScaleConfig {
134    /// OCI image to use (overrides service default)
135    #[serde(default)]
136    pub image: Option<String>,
137    /// vCPUs per instance
138    #[serde(default)]
139    pub vcpus: Option<u8>,
140    /// Memory in MiB per instance
141    #[serde(default)]
142    pub memory_mib: Option<u32>,
143    /// Environment variables
144    #[serde(default)]
145    pub env: HashMap<String, String>,
146    /// Port mappings
147    #[serde(default)]
148    pub port_map: Vec<String>,
149}
150
151/// Response from Box after processing a scale request.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ScaleResponse {
154    /// Request ID for correlation
155    pub request_id: String,
156    /// Whether the scale operation was accepted
157    pub accepted: bool,
158    /// Current number of running instances
159    pub current_replicas: u32,
160    /// Target number of instances (may differ from requested if at capacity)
161    pub target_replicas: u32,
162    /// Per-instance status
163    pub instances: Vec<InstanceInfo>,
164    /// Error message if not accepted
165    #[serde(default)]
166    pub error: Option<String>,
167}
168
169/// Information about a single instance.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct InstanceInfo {
172    /// Instance (box) ID
173    pub id: String,
174    /// Current state
175    pub state: InstanceState,
176    /// Service this instance belongs to
177    pub service: String,
178    /// When the instance was created
179    pub created_at: DateTime<Utc>,
180    /// When the instance became ready (None if not yet ready)
181    #[serde(default)]
182    pub ready_at: Option<DateTime<Utc>>,
183    /// Instance endpoint (host:port) for traffic routing
184    #[serde(default)]
185    pub endpoint: Option<String>,
186    /// Health metrics
187    #[serde(default)]
188    pub health: InstanceHealth,
189}
190
191/// Health metrics for an instance.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct InstanceHealth {
194    /// CPU usage percentage (0-100)
195    #[serde(default)]
196    pub cpu_percent: Option<f32>,
197    /// Memory usage in bytes
198    #[serde(default)]
199    pub memory_bytes: Option<u64>,
200    /// Number of in-flight requests
201    #[serde(default)]
202    pub inflight_requests: u32,
203    /// Whether the instance is healthy
204    #[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/// Event emitted by Box when an instance state changes.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct InstanceEvent {
226    /// Instance (box) ID
227    pub instance_id: String,
228    /// Service this instance belongs to
229    pub service: String,
230    /// Previous state
231    pub from_state: InstanceState,
232    /// New state
233    pub to_state: InstanceState,
234    /// When the transition occurred
235    pub timestamp: DateTime<Utc>,
236    /// Optional message (e.g., error details for Failed state)
237    #[serde(default)]
238    pub message: String,
239}
240
241impl InstanceEvent {
242    /// Create a new state transition event.
243    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    /// Add a message to the event.
260    pub fn with_message(mut self, msg: &str) -> Self {
261        self.message = msg.to_string();
262        self
263    }
264}
265
266/// Registration payload for instance self-registration with Gateway.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct InstanceRegistration {
269    /// Instance (box) ID
270    pub instance_id: String,
271    /// Service this instance belongs to
272    pub service: String,
273    /// Endpoint for traffic routing (host:port)
274    pub endpoint: String,
275    /// Instance metadata
276    #[serde(default)]
277    pub metadata: HashMap<String, String>,
278    /// When the instance started
279    pub started_at: DateTime<Utc>,
280}
281
282/// Deregistration payload when an instance is shutting down.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct InstanceDeregistration {
285    /// Instance (box) ID
286    pub instance_id: String,
287    /// Service this instance belongs to
288    pub service: String,
289    /// Reason for deregistration
290    #[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(&reg).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); // duplicate
514        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}