1use anyhow::{Result, bail};
2use serde::{Deserialize, Serialize};
3
4use crate::idle_metrics::IdleMetrics;
5use crate::pool::Role;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum InstanceStatus {
11 Created,
12 Ready,
13 Running,
14 Warm,
15 Sleeping,
16 Stopped,
17 Destroyed,
18}
19
20impl std::fmt::Display for InstanceStatus {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::Created => write!(f, "created"),
24 Self::Ready => write!(f, "ready"),
25 Self::Running => write!(f, "running"),
26 Self::Warm => write!(f, "warm"),
27 Self::Sleeping => write!(f, "sleeping"),
28 Self::Stopped => write!(f, "stopped"),
29 Self::Destroyed => write!(f, "destroyed"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct InstanceNet {
37 pub tap_dev: String,
39 pub mac: String,
41 pub guest_ip: String,
43 pub gateway_ip: String,
45 pub cidr: u8,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct InstanceState {
52 pub instance_id: String,
53 pub pool_id: String,
54 pub tenant_id: String,
55 pub status: InstanceStatus,
56 pub net: InstanceNet,
57 #[serde(default)]
59 pub role: Role,
60 pub revision_hash: Option<String>,
61 pub firecracker_pid: Option<u32>,
62 pub last_started_at: Option<String>,
63 pub last_stopped_at: Option<String>,
64 #[serde(default)]
65 pub idle_metrics: IdleMetrics,
66 pub healthy: Option<bool>,
67 pub last_health_check_at: Option<String>,
68 pub manual_override_until: Option<String>,
69 #[serde(default)]
71 pub config_version: Option<u64>,
72 #[serde(default)]
74 pub secrets_epoch: Option<u64>,
75 #[serde(default)]
77 pub entered_running_at: Option<String>,
78 #[serde(default)]
80 pub entered_warm_at: Option<String>,
81 #[serde(default)]
83 pub last_busy_at: Option<String>,
84}
85
86pub fn validate_transition(from: InstanceStatus, to: InstanceStatus) -> Result<()> {
91 if to == InstanceStatus::Destroyed {
93 return Ok(());
94 }
95
96 let valid = matches!(
97 (from, to),
98 (InstanceStatus::Created, InstanceStatus::Ready)
100 | (InstanceStatus::Ready, InstanceStatus::Running)
102 | (InstanceStatus::Running, InstanceStatus::Warm)
104 | (InstanceStatus::Running, InstanceStatus::Stopped)
106 | (InstanceStatus::Warm, InstanceStatus::Running)
108 | (InstanceStatus::Warm, InstanceStatus::Sleeping)
110 | (InstanceStatus::Warm, InstanceStatus::Stopped)
112 | (InstanceStatus::Sleeping, InstanceStatus::Running)
114 | (InstanceStatus::Sleeping, InstanceStatus::Stopped)
116 | (InstanceStatus::Stopped, InstanceStatus::Running)
118 | (InstanceStatus::Ready, InstanceStatus::Ready)
120 );
121
122 if valid {
123 Ok(())
124 } else {
125 bail!("Invalid state transition: {} -> {}", from, to)
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_valid_transitions() {
135 assert!(validate_transition(InstanceStatus::Created, InstanceStatus::Ready).is_ok());
136 assert!(validate_transition(InstanceStatus::Ready, InstanceStatus::Running).is_ok());
137 assert!(validate_transition(InstanceStatus::Running, InstanceStatus::Warm).is_ok());
138 assert!(validate_transition(InstanceStatus::Running, InstanceStatus::Stopped).is_ok());
139 assert!(validate_transition(InstanceStatus::Warm, InstanceStatus::Running).is_ok());
140 assert!(validate_transition(InstanceStatus::Warm, InstanceStatus::Sleeping).is_ok());
141 assert!(validate_transition(InstanceStatus::Warm, InstanceStatus::Stopped).is_ok());
142 assert!(validate_transition(InstanceStatus::Sleeping, InstanceStatus::Running).is_ok());
143 assert!(validate_transition(InstanceStatus::Sleeping, InstanceStatus::Stopped).is_ok());
144 assert!(validate_transition(InstanceStatus::Stopped, InstanceStatus::Running).is_ok());
145 assert!(validate_transition(InstanceStatus::Ready, InstanceStatus::Ready).is_ok());
146 }
147
148 #[test]
149 fn test_destroyed_from_any() {
150 for status in [
151 InstanceStatus::Created,
152 InstanceStatus::Ready,
153 InstanceStatus::Running,
154 InstanceStatus::Warm,
155 InstanceStatus::Sleeping,
156 InstanceStatus::Stopped,
157 ] {
158 assert!(
159 validate_transition(status, InstanceStatus::Destroyed).is_ok(),
160 "{} -> Destroyed should be valid",
161 status,
162 );
163 }
164 }
165
166 #[test]
167 fn test_invalid_transitions() {
168 assert!(validate_transition(InstanceStatus::Created, InstanceStatus::Running).is_err());
169 assert!(validate_transition(InstanceStatus::Created, InstanceStatus::Warm).is_err());
170 assert!(validate_transition(InstanceStatus::Running, InstanceStatus::Sleeping).is_err());
171 assert!(validate_transition(InstanceStatus::Sleeping, InstanceStatus::Warm).is_err());
172 assert!(validate_transition(InstanceStatus::Stopped, InstanceStatus::Warm).is_err());
173 assert!(validate_transition(InstanceStatus::Stopped, InstanceStatus::Sleeping).is_err());
174 }
175
176 #[test]
177 fn test_instance_state_json_roundtrip() {
178 let state = InstanceState {
179 instance_id: "i-a3f7b2c1".to_string(),
180 pool_id: "workers".to_string(),
181 tenant_id: "acme".to_string(),
182 status: InstanceStatus::Running,
183 net: InstanceNet {
184 tap_dev: "tn3i5".to_string(),
185 mac: "02:fc:00:03:00:05".to_string(),
186 guest_ip: "10.240.3.5".to_string(),
187 gateway_ip: "10.240.3.1".to_string(),
188 cidr: 24,
189 },
190 role: Role::Gateway,
191 revision_hash: Some("abc123".to_string()),
192 firecracker_pid: Some(12345),
193 last_started_at: Some("2025-01-01T00:00:00Z".to_string()),
194 last_stopped_at: None,
195 idle_metrics: IdleMetrics::default(),
196 healthy: Some(true),
197 last_health_check_at: None,
198 manual_override_until: None,
199 config_version: Some(3),
200 secrets_epoch: Some(1),
201 entered_running_at: Some("2025-01-01T00:00:00Z".to_string()),
202 entered_warm_at: None,
203 last_busy_at: None,
204 };
205
206 let json = serde_json::to_string_pretty(&state).unwrap();
207 let parsed: InstanceState = serde_json::from_str(&json).unwrap();
208 assert_eq!(parsed.instance_id, "i-a3f7b2c1");
209 assert_eq!(parsed.status, InstanceStatus::Running);
210 assert_eq!(parsed.net.tap_dev, "tn3i5");
211 assert_eq!(parsed.role, Role::Gateway);
212 assert_eq!(parsed.config_version, Some(3));
213 assert_eq!(
214 parsed.entered_running_at.as_deref(),
215 Some("2025-01-01T00:00:00Z")
216 );
217 }
218
219 #[test]
220 fn test_instance_state_backward_compat() {
221 let json = r#"{
223 "instance_id": "i-test",
224 "pool_id": "workers",
225 "tenant_id": "acme",
226 "status": "running",
227 "net": {
228 "tap_dev": "tn3i5",
229 "mac": "02:fc:00:03:00:05",
230 "guest_ip": "10.240.3.5",
231 "gateway_ip": "10.240.3.1",
232 "cidr": 24
233 },
234 "revision_hash": null,
235 "firecracker_pid": null,
236 "last_started_at": null,
237 "last_stopped_at": null,
238 "healthy": null,
239 "last_health_check_at": null,
240 "manual_override_until": null
241 }"#;
242 let parsed: InstanceState = serde_json::from_str(json).unwrap();
243 assert_eq!(parsed.role, Role::Worker);
244 assert_eq!(parsed.config_version, None);
245 assert_eq!(parsed.secrets_epoch, None);
246 assert_eq!(parsed.entered_running_at, None);
247 assert_eq!(parsed.entered_warm_at, None);
248 assert_eq!(parsed.last_busy_at, None);
249 }
250
251 #[test]
252 fn test_status_display() {
253 assert_eq!(InstanceStatus::Running.to_string(), "running");
254 assert_eq!(InstanceStatus::Sleeping.to_string(), "sleeping");
255 assert_eq!(InstanceStatus::Destroyed.to_string(), "destroyed");
256 }
257}