Skip to main content

a3s_box_core/
snapshot.rs

1//! VM Snapshot Types — Configuration-based snapshot metadata.
2//!
3//! Snapshots capture the full VM configuration (not memory state) so a box
4//! can be reconstructed from the saved spec. Combined with rootfs caching,
5//! restore achieves sub-500ms cold start.
6
7use crate::error::{BoxError, Result};
8use crate::traits::ExecutionHealthCheck;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use crate::config::DEFAULT_VCPUS;
15
16/// Resolved OCI image defaults required to reproduce a captured rootfs.
17///
18/// These values are distinct from [`SnapshotMetadata`] command, environment,
19/// and working-directory fields: those fields are user overrides, while this
20/// structure records the immutable defaults resolved from the source image.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct SnapshotImageConfig {
23    /// Image entrypoint.
24    #[serde(default)]
25    pub entrypoint: Option<Vec<String>>,
26    /// Image command arguments.
27    #[serde(default)]
28    pub cmd: Option<Vec<String>>,
29    /// Image environment in declaration order.
30    #[serde(default)]
31    pub env: Vec<(String, String)>,
32    /// Image working directory.
33    #[serde(default)]
34    pub working_dir: Option<String>,
35    /// Image user.
36    #[serde(default)]
37    pub user: Option<String>,
38    /// Ports declared by the image.
39    #[serde(default)]
40    pub exposed_ports: Vec<String>,
41    /// Image labels.
42    #[serde(default)]
43    pub labels: HashMap<String, String>,
44    /// Volumes declared by the image.
45    #[serde(default)]
46    pub volumes: Vec<String>,
47    /// Image stop signal.
48    #[serde(default)]
49    pub stop_signal: Option<String>,
50    /// Image health check.
51    #[serde(default)]
52    pub health_check: Option<SnapshotImageHealthCheck>,
53    /// Image ONBUILD triggers.
54    #[serde(default)]
55    pub onbuild: Vec<String>,
56}
57
58/// Health-check defaults embedded in a resolved OCI image configuration.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SnapshotImageHealthCheck {
61    /// Health-check command.
62    #[serde(default)]
63    pub test: Vec<String>,
64    /// Interval in seconds.
65    #[serde(default)]
66    pub interval: Option<u64>,
67    /// Timeout in seconds.
68    #[serde(default)]
69    pub timeout: Option<u64>,
70    /// Retry count.
71    #[serde(default)]
72    pub retries: Option<u32>,
73    /// Start period in seconds.
74    #[serde(default)]
75    pub start_period: Option<u64>,
76}
77
78impl SnapshotImageHealthCheck {
79    /// Whether the OCI health-check declaration contains an executable command.
80    pub fn is_enabled(&self) -> bool {
81        let Some(marker) = self.test.first() else {
82            return false;
83        };
84        if marker.eq_ignore_ascii_case("NONE") {
85            return false;
86        }
87        if marker.eq_ignore_ascii_case("CMD") || marker.eq_ignore_ascii_case("CMD-SHELL") {
88            return self
89                .test
90                .get(1..)
91                .is_some_and(|command| command.iter().any(|part| !part.trim().is_empty()));
92        }
93        self.test.iter().any(|part| !part.trim().is_empty())
94    }
95}
96
97/// Metadata for a saved VM snapshot.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SnapshotMetadata {
100    /// Unique snapshot identifier
101    pub id: String,
102    /// User-assigned name (or auto-generated)
103    pub name: String,
104    /// Box ID this snapshot was taken from
105    pub source_box_id: String,
106    /// OCI image reference used by the source box
107    pub image: String,
108    /// Number of vCPUs
109    pub vcpus: u32,
110    /// Memory in MB
111    pub memory_mb: u32,
112    /// Volume mounts (host:guest pairs)
113    pub volumes: Vec<String>,
114    /// Environment variables
115    pub env: HashMap<String, String>,
116    /// Command override
117    pub cmd: Vec<String>,
118    /// Entrypoint override
119    #[serde(default)]
120    pub entrypoint: Option<Vec<String>>,
121    /// Working directory inside the box
122    #[serde(default)]
123    pub workdir: Option<String>,
124    /// Resolved defaults from the source OCI image.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub image_config: Option<SnapshotImageConfig>,
127    /// Explicit health check selected for the source box.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub health_check: Option<ExecutionHealthCheck>,
130    /// Whether health checks were explicitly disabled for the source box.
131    #[serde(default)]
132    pub healthcheck_disabled: bool,
133    /// Port mappings
134    #[serde(default)]
135    pub port_map: Vec<String>,
136    /// User-defined labels
137    #[serde(default)]
138    pub labels: HashMap<String, String>,
139    /// Network mode
140    #[serde(default)]
141    pub network_mode: Option<String>,
142    /// Rootfs cache key (for fast restore via cached rootfs)
143    #[serde(default)]
144    pub rootfs_cache_key: Option<String>,
145    /// Size of the snapshot on disk in bytes
146    #[serde(default)]
147    pub size_bytes: u64,
148    /// Creation timestamp
149    #[serde(default = "epoch")]
150    pub created_at: DateTime<Utc>,
151    /// User-provided description
152    #[serde(default)]
153    pub description: String,
154}
155
156/// Default `created_at` for metadata written by an older/partial build that
157/// omitted the field. Without a default a single missing field would make
158/// serde reject the whole record, dropping the snapshot from `list()`/`prune()`
159/// (invisible, un-prunable, leaking disk) — see SnapshotStore::list.
160fn epoch() -> DateTime<Utc> {
161    DateTime::<Utc>::UNIX_EPOCH
162}
163
164impl SnapshotMetadata {
165    /// Create a new snapshot metadata with required fields.
166    pub fn new(id: String, name: String, source_box_id: String, image: String) -> Self {
167        Self {
168            id,
169            name,
170            source_box_id,
171            image,
172            vcpus: DEFAULT_VCPUS,
173            memory_mb: 512,
174            volumes: Vec::new(),
175            env: HashMap::new(),
176            cmd: Vec::new(),
177            entrypoint: None,
178            workdir: None,
179            image_config: None,
180            health_check: None,
181            healthcheck_disabled: false,
182            port_map: Vec::new(),
183            labels: HashMap::new(),
184            network_mode: None,
185            rootfs_cache_key: None,
186            size_bytes: 0,
187            created_at: Utc::now(),
188            description: String::new(),
189        }
190    }
191
192    /// Set description.
193    pub fn with_description(mut self, desc: &str) -> Self {
194        self.description = desc.to_string();
195        self
196    }
197
198    /// Set resources.
199    pub fn with_resources(mut self, vcpus: u32, memory_mb: u32) -> Self {
200        self.vcpus = vcpus;
201        self.memory_mb = memory_mb;
202        self
203    }
204
205    /// Return the captured OCI image defaults required for a safe restore.
206    ///
207    /// Snapshot records created before this field existed remain readable so
208    /// operators can inspect and delete them, but restoring one would lose
209    /// image entrypoint, environment, user, and working-directory semantics.
210    pub fn require_image_config(&self) -> Result<&SnapshotImageConfig> {
211        self.image_config.as_ref().ok_or_else(|| {
212            BoxError::ConfigError(format!(
213                "Snapshot '{}' does not contain resolved OCI image configuration and cannot be restored safely; recreate it with the current A3S Box version",
214                self.id
215            ))
216        })
217    }
218
219    /// Whether restoring this snapshot would enable an explicit or image health check.
220    pub fn has_effective_health_check(&self) -> bool {
221        !self.healthcheck_disabled
222            && (self.health_check.is_some()
223                || self
224                    .image_config
225                    .as_ref()
226                    .and_then(|config| config.health_check.as_ref())
227                    .is_some_and(SnapshotImageHealthCheck::is_enabled))
228    }
229}
230
231/// Configuration for snapshot operations.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct SnapshotConfig {
234    /// Whether snapshots are enabled
235    pub enabled: bool,
236    /// Directory to store snapshots (default: ~/.a3s/snapshots)
237    pub snapshot_dir: Option<PathBuf>,
238    /// Maximum number of snapshots to keep (0 = unlimited)
239    pub max_snapshots: usize,
240    /// Maximum total size in bytes (0 = unlimited)
241    pub max_total_bytes: u64,
242}
243
244impl Default for SnapshotConfig {
245    fn default() -> Self {
246        Self {
247            enabled: true,
248            snapshot_dir: None,
249            max_snapshots: 0,
250            max_total_bytes: 0,
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_snapshot_metadata_new() {
261        let meta = SnapshotMetadata::new(
262            "snap-001".to_string(),
263            "my-snapshot".to_string(),
264            "box-abc".to_string(),
265            "alpine:latest".to_string(),
266        );
267        assert_eq!(meta.id, "snap-001");
268        assert_eq!(meta.name, "my-snapshot");
269        assert_eq!(meta.source_box_id, "box-abc");
270        assert_eq!(meta.image, "alpine:latest");
271        assert_eq!(meta.vcpus, DEFAULT_VCPUS);
272        assert_eq!(meta.memory_mb, 512);
273        assert!(meta.volumes.is_empty());
274        assert!(meta.env.is_empty());
275        assert!(meta.description.is_empty());
276        assert!(meta.image_config.is_none());
277        assert!(meta.health_check.is_none());
278        assert!(!meta.healthcheck_disabled);
279    }
280
281    #[test]
282    fn test_snapshot_metadata_with_description() {
283        let meta = SnapshotMetadata::new(
284            "snap-002".to_string(),
285            "test".to_string(),
286            "box-xyz".to_string(),
287            "ubuntu:22.04".to_string(),
288        )
289        .with_description("Before migration");
290        assert_eq!(meta.description, "Before migration");
291    }
292
293    #[test]
294    fn test_snapshot_metadata_with_resources() {
295        let meta = SnapshotMetadata::new(
296            "snap-003".to_string(),
297            "test".to_string(),
298            "box-xyz".to_string(),
299            "python:3.12".to_string(),
300        )
301        .with_resources(4, 2048);
302        assert_eq!(meta.vcpus, 4);
303        assert_eq!(meta.memory_mb, 2048);
304    }
305
306    #[test]
307    fn test_snapshot_metadata_tolerates_missing_size_and_created_at() {
308        // Metadata written by an older/partial build (or lightly corrupted) that
309        // omits size_bytes/created_at must still deserialize, so the snapshot
310        // stays visible to list()/count()/prune() instead of being silently
311        // dropped and leaking disk. Only the truly-required identity fields are
312        // present here.
313        let json = r#"{
314            "id": "snap-old",
315            "name": "old",
316            "source_box_id": "box-1",
317            "image": "alpine:latest",
318            "vcpus": 2,
319            "memory_mb": 512,
320            "volumes": [],
321            "env": {},
322            "cmd": []
323        }"#;
324        let parsed: SnapshotMetadata =
325            serde_json::from_str(json).expect("must tolerate missing size_bytes/created_at");
326        assert_eq!(parsed.id, "snap-old");
327        assert_eq!(parsed.size_bytes, 0);
328        assert_eq!(parsed.created_at, DateTime::<Utc>::UNIX_EPOCH);
329        assert!(parsed.health_check.is_none());
330        assert!(!parsed.healthcheck_disabled);
331        assert!(parsed.require_image_config().is_err());
332    }
333
334    #[test]
335    fn test_snapshot_metadata_serde_roundtrip() {
336        let mut meta = SnapshotMetadata::new(
337            "snap-rt".to_string(),
338            "roundtrip".to_string(),
339            "box-123".to_string(),
340            "nginx:latest".to_string(),
341        );
342        meta.vcpus = 4;
343        meta.memory_mb = 1024;
344        meta.volumes = vec!["/data:/data".to_string()];
345        meta.env.insert("FOO".to_string(), "bar".to_string());
346        meta.cmd = vec!["nginx".to_string(), "-g".to_string()];
347        meta.entrypoint = Some(vec!["/docker-entrypoint.sh".to_string()]);
348        meta.workdir = Some("/app".to_string());
349        meta.image_config = Some(SnapshotImageConfig {
350            entrypoint: Some(vec!["/usr/bin/envd".to_string()]),
351            cmd: Some(vec!["--listen".to_string(), "0.0.0.0:49983".to_string()]),
352            env: vec![
353                ("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string()),
354                ("HOME".to_string(), "/home/user".to_string()),
355            ],
356            working_dir: Some("/home/user".to_string()),
357            user: Some("1000:1000".to_string()),
358            exposed_ports: vec!["49983/tcp".to_string()],
359            labels: HashMap::from([("runtime".to_string(), "envd".to_string())]),
360            volumes: vec!["/home/user".to_string()],
361            stop_signal: Some("SIGTERM".to_string()),
362            health_check: Some(SnapshotImageHealthCheck {
363                test: vec!["CMD".to_string(), "envd-health".to_string()],
364                interval: Some(10),
365                timeout: Some(2),
366                retries: Some(3),
367                start_period: Some(5),
368            }),
369            onbuild: vec!["RUN prepare-runtime".to_string()],
370        });
371        meta.health_check = Some(ExecutionHealthCheck {
372            cmd: vec!["test".to_string(), "-f".to_string(), "/ready".to_string()],
373            interval_secs: 11,
374            timeout_secs: 3,
375            retries: 7,
376            start_period_secs: 2,
377        });
378        meta.healthcheck_disabled = true;
379        meta.port_map = vec!["8080:80".to_string()];
380        meta.labels.insert("env".to_string(), "prod".to_string());
381        meta.network_mode = Some("bridge".to_string());
382        meta.rootfs_cache_key = Some("abc123".to_string());
383        meta.size_bytes = 1024 * 1024;
384        meta.description = "test snapshot".to_string();
385
386        let json = serde_json::to_string(&meta).unwrap();
387        let parsed: SnapshotMetadata = serde_json::from_str(&json).unwrap();
388
389        assert_eq!(parsed.id, "snap-rt");
390        assert_eq!(parsed.name, "roundtrip");
391        assert_eq!(parsed.source_box_id, "box-123");
392        assert_eq!(parsed.image, "nginx:latest");
393        assert_eq!(parsed.vcpus, 4);
394        assert_eq!(parsed.memory_mb, 1024);
395        assert_eq!(parsed.volumes, vec!["/data:/data"]);
396        assert_eq!(parsed.env.get("FOO").unwrap(), "bar");
397        assert_eq!(parsed.cmd, vec!["nginx", "-g"]);
398        assert_eq!(
399            parsed.entrypoint,
400            Some(vec!["/docker-entrypoint.sh".to_string()])
401        );
402        assert_eq!(parsed.workdir, Some("/app".to_string()));
403        assert_eq!(parsed.image_config, meta.image_config);
404        assert_eq!(parsed.health_check, meta.health_check);
405        assert!(parsed.healthcheck_disabled);
406        assert!(!parsed.has_effective_health_check());
407        assert_eq!(parsed.port_map, vec!["8080:80"]);
408        assert_eq!(parsed.labels.get("env").unwrap(), "prod");
409        assert_eq!(parsed.network_mode, Some("bridge".to_string()));
410        assert_eq!(parsed.rootfs_cache_key, Some("abc123".to_string()));
411        assert_eq!(parsed.size_bytes, 1024 * 1024);
412        assert_eq!(parsed.description, "test snapshot");
413    }
414
415    #[test]
416    fn test_snapshot_metadata_deserialize_minimal() {
417        let json = r#"{
418            "id": "snap-min",
419            "name": "minimal",
420            "source_box_id": "box-1",
421            "image": "alpine:latest",
422            "vcpus": 1,
423            "memory_mb": 256,
424            "volumes": [],
425            "env": {},
426            "cmd": [],
427            "size_bytes": 0,
428            "created_at": "2024-01-01T00:00:00Z",
429            "description": ""
430        }"#;
431        let meta: SnapshotMetadata = serde_json::from_str(json).unwrap();
432        assert_eq!(meta.id, "snap-min");
433        assert!(meta.entrypoint.is_none());
434        assert!(meta.workdir.is_none());
435        assert!(meta.image_config.is_none());
436        assert!(meta.health_check.is_none());
437        assert!(!meta.healthcheck_disabled);
438        assert!(meta.port_map.is_empty());
439        assert!(meta.labels.is_empty());
440        assert!(meta.network_mode.is_none());
441        assert!(meta.rootfs_cache_key.is_none());
442    }
443
444    #[test]
445    fn test_snapshot_config_default() {
446        let config = SnapshotConfig::default();
447        assert!(config.enabled);
448        assert!(config.snapshot_dir.is_none());
449        assert_eq!(config.max_snapshots, 0);
450        assert_eq!(config.max_total_bytes, 0);
451    }
452
453    #[test]
454    fn test_snapshot_config_serde_roundtrip() {
455        let config = SnapshotConfig {
456            enabled: true,
457            snapshot_dir: Some(PathBuf::from("/custom/snapshots")),
458            max_snapshots: 10,
459            max_total_bytes: 5 * 1024 * 1024 * 1024,
460        };
461        let json = serde_json::to_string(&config).unwrap();
462        let parsed: SnapshotConfig = serde_json::from_str(&json).unwrap();
463        assert!(parsed.enabled);
464        assert_eq!(
465            parsed.snapshot_dir,
466            Some(PathBuf::from("/custom/snapshots"))
467        );
468        assert_eq!(parsed.max_snapshots, 10);
469        assert_eq!(parsed.max_total_bytes, 5 * 1024 * 1024 * 1024);
470    }
471
472    #[test]
473    fn test_snapshot_metadata_clone() {
474        let meta = SnapshotMetadata::new(
475            "snap-clone".to_string(),
476            "clone-test".to_string(),
477            "box-c".to_string(),
478            "redis:7".to_string(),
479        );
480        let cloned = meta.clone();
481        assert_eq!(cloned.id, meta.id);
482        assert_eq!(cloned.name, meta.name);
483        assert_eq!(cloned.source_box_id, meta.source_box_id);
484    }
485
486    #[test]
487    fn effective_health_check_respects_none_and_disabled_declarations() {
488        let mut meta = SnapshotMetadata::new(
489            "snap-health".to_string(),
490            "health".to_string(),
491            "box-health".to_string(),
492            "image:latest".to_string(),
493        );
494        meta.image_config = Some(SnapshotImageConfig {
495            health_check: Some(SnapshotImageHealthCheck {
496                test: vec!["NONE".to_string()],
497                ..Default::default()
498            }),
499            ..Default::default()
500        });
501        assert!(!meta.has_effective_health_check());
502
503        meta.image_config.as_mut().unwrap().health_check = Some(SnapshotImageHealthCheck {
504            test: vec!["CMD".to_string(), "true".to_string()],
505            ..Default::default()
506        });
507        assert!(meta.has_effective_health_check());
508
509        meta.healthcheck_disabled = true;
510        assert!(!meta.has_effective_health_check());
511    }
512}