1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct SnapshotImageConfig {
23 #[serde(default)]
25 pub entrypoint: Option<Vec<String>>,
26 #[serde(default)]
28 pub cmd: Option<Vec<String>>,
29 #[serde(default)]
31 pub env: Vec<(String, String)>,
32 #[serde(default)]
34 pub working_dir: Option<String>,
35 #[serde(default)]
37 pub user: Option<String>,
38 #[serde(default)]
40 pub exposed_ports: Vec<String>,
41 #[serde(default)]
43 pub labels: HashMap<String, String>,
44 #[serde(default)]
46 pub volumes: Vec<String>,
47 #[serde(default)]
49 pub stop_signal: Option<String>,
50 #[serde(default)]
52 pub health_check: Option<SnapshotImageHealthCheck>,
53 #[serde(default)]
55 pub onbuild: Vec<String>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SnapshotImageHealthCheck {
61 #[serde(default)]
63 pub test: Vec<String>,
64 #[serde(default)]
66 pub interval: Option<u64>,
67 #[serde(default)]
69 pub timeout: Option<u64>,
70 #[serde(default)]
72 pub retries: Option<u32>,
73 #[serde(default)]
75 pub start_period: Option<u64>,
76}
77
78impl SnapshotImageHealthCheck {
79 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#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SnapshotMetadata {
100 pub id: String,
102 pub name: String,
104 pub source_box_id: String,
106 pub image: String,
108 pub vcpus: u32,
110 pub memory_mb: u32,
112 pub volumes: Vec<String>,
114 pub env: HashMap<String, String>,
116 pub cmd: Vec<String>,
118 #[serde(default)]
120 pub entrypoint: Option<Vec<String>>,
121 #[serde(default)]
123 pub workdir: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub image_config: Option<SnapshotImageConfig>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub health_check: Option<ExecutionHealthCheck>,
130 #[serde(default)]
132 pub healthcheck_disabled: bool,
133 #[serde(default)]
135 pub port_map: Vec<String>,
136 #[serde(default)]
138 pub labels: HashMap<String, String>,
139 #[serde(default)]
141 pub network_mode: Option<String>,
142 #[serde(default)]
144 pub rootfs_cache_key: Option<String>,
145 #[serde(default)]
147 pub size_bytes: u64,
148 #[serde(default = "epoch")]
150 pub created_at: DateTime<Utc>,
151 #[serde(default)]
153 pub description: String,
154}
155
156fn epoch() -> DateTime<Utc> {
161 DateTime::<Utc>::UNIX_EPOCH
162}
163
164impl SnapshotMetadata {
165 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 pub fn with_description(mut self, desc: &str) -> Self {
194 self.description = desc.to_string();
195 self
196 }
197
198 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct SnapshotConfig {
234 pub enabled: bool,
236 pub snapshot_dir: Option<PathBuf>,
238 pub max_snapshots: usize,
240 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 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}