Skip to main content

mvm_core/
instance.rs

1use anyhow::{Result, bail};
2use serde::{Deserialize, Serialize};
3
4use crate::idle_metrics::IdleMetrics;
5use crate::pool::Role;
6
7/// Instance lifecycle status. Only instances have runtime state.
8#[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/// Per-instance network configuration.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct InstanceNet {
37    /// TAP device name: "tn<net_id>i<offset>"
38    pub tap_dev: String,
39    /// Deterministic MAC: "02:xx:xx:xx:xx:xx"
40    pub mac: String,
41    /// Guest IP within tenant subnet, e.g. "10.240.3.5"
42    pub guest_ip: String,
43    /// Tenant gateway, e.g. "10.240.3.1"
44    pub gateway_ip: String,
45    /// CIDR prefix length from tenant subnet, e.g. 24
46    pub cidr: u8,
47}
48
49/// Full instance state, persisted at instances/<id>/instance.json.
50#[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    /// Role inherited from pool at creation time.
58    #[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    /// Config drive version currently mounted.
70    #[serde(default)]
71    pub config_version: Option<u64>,
72    /// Secrets epoch currently mounted.
73    #[serde(default)]
74    pub secrets_epoch: Option<u64>,
75    /// Timestamp when instance entered Running status.
76    #[serde(default)]
77    pub entered_running_at: Option<String>,
78    /// Timestamp when instance entered Warm status.
79    #[serde(default)]
80    pub entered_warm_at: Option<String>,
81    /// Timestamp of last work activity (from guest agent or metrics).
82    #[serde(default)]
83    pub last_busy_at: Option<String>,
84}
85
86/// Validate that a state transition is allowed.
87///
88/// Returns Ok(()) if the transition is valid, Err with explanation otherwise.
89/// Enforces the state machine defined in the spec.
90pub fn validate_transition(from: InstanceStatus, to: InstanceStatus) -> Result<()> {
91    // Any state -> Destroyed is always allowed
92    if to == InstanceStatus::Destroyed {
93        return Ok(());
94    }
95
96    let valid = matches!(
97        (from, to),
98        // Build completes
99        (InstanceStatus::Created, InstanceStatus::Ready)
100        // Start
101        | (InstanceStatus::Ready, InstanceStatus::Running)
102        // Pause vCPUs
103        | (InstanceStatus::Running, InstanceStatus::Warm)
104        // Stop from running
105        | (InstanceStatus::Running, InstanceStatus::Stopped)
106        // Resume from warm
107        | (InstanceStatus::Warm, InstanceStatus::Running)
108        // Snapshot + shutdown
109        | (InstanceStatus::Warm, InstanceStatus::Sleeping)
110        // Stop from warm
111        | (InstanceStatus::Warm, InstanceStatus::Stopped)
112        // Wake from snapshot
113        | (InstanceStatus::Sleeping, InstanceStatus::Running)
114        // Stop from sleeping (discard snapshot)
115        | (InstanceStatus::Sleeping, InstanceStatus::Stopped)
116        // Fresh boot from stopped
117        | (InstanceStatus::Stopped, InstanceStatus::Running)
118        // Rebuild
119        | (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        // JSON without new fields should deserialize with defaults
222        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}