1use std::collections::HashMap;
12use std::path::Path;
13
14use tracing::{info, warn};
15use uuid::Uuid;
16
17use super::ComputerManager;
18use super::types::{ComputerId, ComputerSpec, ComputerState};
19use crate::error::{ComputerError, Result};
20use crate::snapshot::{SnapshotDraft, SnapshotGeometry};
21use crate::template_catalog::{
22 ReleasedArtifacts, ResolvedTemplate, TEMPLATE_LABEL, TemplateDefaultsSpec, TemplateEntry,
23};
24use arcbox_snapshot::SnapshotError;
25
26#[derive(Debug)]
29pub struct PromotedSnapshot {
30 pub snapshot_id: String,
32 pub geometry: SnapshotGeometry,
34 pub rootfs_path: String,
36 pub artifact_bytes: u64,
40}
41
42impl ComputerManager {
43 pub fn get_template(&self, reference: &str) -> Result<ResolvedTemplate> {
45 self.templates.resolve(reference).map_err(Into::into)
46 }
47
48 pub fn list_templates(&self) -> Result<Vec<(String, TemplateEntry)>> {
51 self.templates.list().map_err(Into::into)
52 }
53
54 pub async fn register_template_draft(
57 &self,
58 name: &str,
59 entry: TemplateEntry,
60 ) -> Result<TemplateEntry> {
61 let outcome = self.templates.put_draft(name, entry)?;
62 self.delete_released_snapshots(&outcome.released).await;
63 info!(template = name, digest = %outcome.entry.digest, "template draft registered");
64 Ok(outcome.entry)
65 }
66
67 pub async fn publish_template(&self, name: &str, version: &str) -> Result<TemplateEntry> {
71 let outcome = self.templates.publish(name, version)?;
72 self.delete_released_snapshots(&outcome.released).await;
73 Ok(outcome.entry)
74 }
75
76 pub async fn delete_template(&self, reference: &str) -> Result<()> {
81 self.check_reconcile()?;
82 let released = self.templates.delete(reference)?;
83 self.delete_released_snapshots(&released).await;
84 info!(template = reference, "template deleted");
85 Ok(())
86 }
87
88 pub async fn promote_snapshot_to_template(
94 &self,
95 source_snapshot_id: &str,
96 template_name: &str,
97 ) -> Result<PromotedSnapshot> {
98 self.check_reconcile()?;
99 crate::template_catalog::validate_template_name(template_name)?;
100 let source = self.snapshots.find_by_id(source_snapshot_id)?;
101 if source.name.as_deref() == Some(super::pause::PAUSE_SNAPSHOT_NAME) {
102 return Err(ComputerError::FailedPrecondition(format!(
103 "snapshot {source_snapshot_id} is the internal pause checkpoint of a \
104 paused sandbox; checkpoint the sandbox instead"
105 )));
106 }
107 let geometry = source.geometry.ok_or_else(|| {
108 ComputerError::FailedPrecondition(format!(
109 "snapshot {source_snapshot_id} predates geometry recording; \
110 re-checkpoint the sandbox with this agent and promote the new snapshot"
111 ))
112 })?;
113 let rootfs_path = source.rootfs_path.clone().ok_or_else(|| {
114 ComputerError::FailedPrecondition(format!(
115 "snapshot {source_snapshot_id} records no rootfs path; \
116 re-checkpoint the sandbox and promote the new snapshot"
117 ))
118 })?;
119
120 let pending = self.snapshots.begin(&format!("template-{template_name}"))?;
121 let staging = pending.dir();
122 let mut artifact_bytes = clone_or_copy(&source.vmstate_path, &staging.join("vmstate"))?;
123 if let Some(mem) = &source.mem_path {
124 artifact_bytes += clone_or_copy(mem, &staging.join("mem"))?;
125 }
126 let meta = pending.commit(SnapshotDraft {
127 name: None,
128 labels: HashMap::from([(TEMPLATE_LABEL.to_owned(), template_name.to_owned())]),
129 snapshot_type: source.snapshot_type,
130 parent_id: source.parent_id.clone(),
131 kernel_path: source.kernel_path.clone(),
132 rootfs_path: Some(rootfs_path.clone()),
133 net_invariant: source.net_invariant,
134 geometry: Some(geometry),
135 format: source.format,
136 })?;
137 info!(
138 template = template_name,
139 source = source_snapshot_id,
140 snapshot_id = %meta.id,
141 "checkpoint promoted into template ownership"
142 );
143 Ok(PromotedSnapshot {
144 snapshot_id: meta.id,
145 geometry,
146 rootfs_path,
147 artifact_bytes,
148 })
149 }
150
151 pub async fn prewarm_template(
158 &self,
159 name: &str,
160 rootfs_path: &str,
161 defaults: &TemplateDefaultsSpec,
162 ) -> Result<PrewarmOutcome> {
163 if self.config.firecracker.jailer.is_none() {
164 return Err(ComputerError::FailedPrecondition(
165 "prewarm requires jailer mode (snapshot restore does)".into(),
166 ));
167 }
168 let vcpus = if defaults.vcpus != 0 {
169 defaults.vcpus
170 } else {
171 u32::try_from(self.config.defaults.vcpus).unwrap_or(1)
172 };
173 let memory_mib = if defaults.memory_mib != 0 {
174 defaults.memory_mib
175 } else {
176 self.config.defaults.memory_mib
177 };
178 let mut suffix = Uuid::new_v4().simple().to_string();
185 suffix.truncate(16);
186 let builder_id = format!("tpl-build-{suffix}");
187 let spec = ComputerSpec {
188 id: Some(builder_id.clone()),
189 rootfs: rootfs_path.to_owned(),
190 vcpus,
191 memory_mib,
192 ..Default::default()
196 };
197 let (id, _ip) = self
198 .create_computer_inner(
199 spec,
200 &Uuid::new_v4().to_string(),
201 super::lifecycle::WarmPolicy::Disabled,
202 )
203 .await?;
204 let checkpoint = self.prewarm_checkpoint(&id, name).await;
205 if let Err(remove_error) = self.remove_computer(&id, true).await {
212 if let Ok((snapshot_id, _)) = &checkpoint {
213 self.discard_promoted_snapshot(snapshot_id).await;
214 }
215 return Err(ComputerError::Unavailable(format!(
216 "prewarm builder sandbox {id} could not be removed ({remove_error}); \
217 remove it manually and retry the build"
218 )));
219 }
220 let (snapshot_id, artifact_bytes) = checkpoint?;
221 Ok(PrewarmOutcome {
222 warm: crate::template_catalog::WarmArtifact {
223 snapshot_id,
224 vcpus,
225 memory_mib,
226 },
227 artifact_bytes,
228 })
229 }
230
231 async fn prewarm_checkpoint(&self, id: &ComputerId, template: &str) -> Result<(String, u64)> {
235 const READY_POLL_MS: u64 = 250;
236 const READY_TIMEOUT_SECS: u64 = 180;
237 let deadline =
238 tokio::time::Instant::now() + std::time::Duration::from_secs(READY_TIMEOUT_SECS);
239 loop {
240 let info = self.inspect_computer(id)?;
241 match info.state {
242 ComputerState::Ready => break,
243 ComputerState::Failed => {
244 return Err(ComputerError::FailedPrecondition(format!(
245 "prewarm boot failed: {}",
246 info.error.unwrap_or_else(|| "unknown boot failure".into())
247 )));
248 }
249 _ if tokio::time::Instant::now() >= deadline => {
250 return Err(ComputerError::DeadlineExceeded(format!(
251 "prewarm builder did not reach READY within {READY_TIMEOUT_SECS}s"
252 )));
253 }
254 _ => tokio::time::sleep(std::time::Duration::from_millis(READY_POLL_MS)).await,
255 }
256 }
257 let info = self
260 .capture_checkpoint(
261 &id.to_owned(),
262 format!("template-{template}"),
263 HashMap::from([(TEMPLATE_LABEL.to_owned(), template.to_owned())]),
264 )
265 .await
266 ?;
270 let meta = self.snapshots.find_by_id(&info.snapshot_id)?;
271 let mut artifact_bytes = std::fs::metadata(&meta.vmstate_path).map_or(0, |m| m.len());
272 if let Some(mem) = &meta.mem_path {
273 artifact_bytes += std::fs::metadata(mem).map_or(0, |m| m.len());
274 }
275 Ok((info.snapshot_id, artifact_bytes))
276 }
277
278 pub async fn discard_promoted_snapshot(&self, snapshot_id: &str) {
282 self.delete_released_snapshots(&ReleasedArtifacts {
283 snapshot_ids: vec![snapshot_id.to_owned()],
284 })
285 .await;
286 }
287
288 async fn delete_released_snapshots(&self, released: &ReleasedArtifacts) {
302 for snapshot_id in &released.snapshot_ids {
303 self.drain_pool(Some(snapshot_id)).await;
304 match self.snapshots.delete_by_id(snapshot_id) {
305 Ok(()) => {}
306 Err(SnapshotError::Snapshot(msg)) if msg.ends_with("not found") => {}
310 Err(error) => warn!(
311 snapshot_id,
312 %error,
313 "failed to delete a template-owned snapshot; artifact is orphaned"
314 ),
315 }
316 }
317 }
318}
319
320#[derive(Debug)]
322pub struct PrewarmOutcome {
323 pub warm: crate::template_catalog::WarmArtifact,
325 pub artifact_bytes: u64,
327}
328
329pub(super) fn template_restore_eligible(
340 config: &crate::config::RuntimeConfig,
341 spec: &ComputerSpec,
342 caller_supplied_boot: bool,
343 warm: &crate::sandbox::TemplateWarmRef,
344) -> bool {
345 config.firecracker.jailer.is_some()
346 && !caller_supplied_boot
347 && spec.network.mode == "tap"
348 && !spec.boot_args.contains("ip=")
349 && spec.mounts.is_empty()
350 && spec.ssh_public_key.is_none()
351 && spec.vcpus == warm.vcpus
352 && spec.memory_mib == warm.memory_mib
353}
354
355fn clone_or_copy(src: &Path, dst: &Path) -> Result<u64> {
358 #[cfg(target_os = "linux")]
359 {
360 use std::os::fd::AsRawFd as _;
361 use std::os::unix::fs::OpenOptionsExt as _;
362 let source = std::fs::File::open(src).map_err(ComputerError::Io)?;
363 let dest = std::fs::OpenOptions::new()
364 .write(true)
365 .create_new(true)
366 .mode(0o600)
367 .open(dst)
368 .map_err(ComputerError::Io)?;
369 let rc = unsafe { libc::ioctl(dest.as_raw_fd(), libc::FICLONE as _, source.as_raw_fd()) };
372 if rc == 0 {
373 dest.sync_all().map_err(ComputerError::Io)?;
374 return Ok(source.metadata().map_err(ComputerError::Io)?.len());
375 }
376 drop(dest);
379 let _ = std::fs::remove_file(dst);
380 }
381 let bytes = std::fs::copy(src, dst).map_err(ComputerError::Io)?;
382 let dest = std::fs::File::open(dst).map_err(ComputerError::Io)?;
383 std::fs::set_permissions(dst, {
384 use std::os::unix::fs::PermissionsExt as _;
385 std::fs::Permissions::from_mode(0o600)
386 })
387 .map_err(ComputerError::Io)?;
388 dest.sync_all().map_err(ComputerError::Io)?;
389 Ok(bytes)
390}
391
392#[cfg(test)]
393mod tests {
394 use std::collections::HashMap;
395 use std::path::Path;
396
397 use crate::config::RuntimeConfig;
398 use crate::sandbox::ComputerManager;
399 use crate::snapshot::{SnapshotCatalog, SnapshotDraft};
400 use crate::template_catalog::{
401 TEMPLATE_LABEL, TemplateDefaultsSpec, TemplateEntry, WarmArtifact,
402 };
403
404 async fn manager(data_dir: &Path) -> ComputerManager {
405 let mut config = RuntimeConfig::default();
406 config.firecracker.data_dir = data_dir.to_string_lossy().into_owned();
407 let environment = crate::testkit::fake_environment(&config).unwrap();
408 let manager = ComputerManager::new(config, environment).unwrap();
409 manager.await_reconcile().await.unwrap();
410 manager
411 }
412
413 fn publish_template_snapshot(catalog: &SnapshotCatalog, template: &str) -> String {
414 let pending = catalog.begin(&format!("template-{template}")).unwrap();
415 std::fs::write(pending.dir().join("vmstate"), b"vmstate").unwrap();
416 pending
417 .commit(SnapshotDraft {
418 name: None,
419 labels: HashMap::from([(TEMPLATE_LABEL.to_owned(), template.to_owned())]),
420 snapshot_type: crate::config::SnapshotType::Full,
421 parent_id: None,
422 kernel_path: None,
423 rootfs_path: Some("/cache/rootfs-tpl.ext4".into()),
424 net_invariant: true,
425 geometry: None,
426 format: crate::sandbox::checkpoint::CHECKPOINT_FORMAT.to_owned(),
427 })
428 .unwrap()
429 .id
430 }
431
432 fn entry_with_warm(snapshot_id: &str) -> TemplateEntry {
433 TemplateEntry {
434 version: String::new(),
435 digest: "sha256:d1".into(),
436 rootfs_path: "/cache/rootfs-tpl.ext4".into(),
437 warm: Some(WarmArtifact {
438 snapshot_id: snapshot_id.to_owned(),
439 vcpus: 2,
440 memory_mib: 512,
441 }),
442 defaults: TemplateDefaultsSpec::default(),
443 labels: HashMap::new(),
444 created_at: chrono::Utc::now(),
445 size_bytes: 0,
446 }
447 }
448
449 #[tokio::test]
450 async fn template_snapshots_are_hidden_and_delete_refused_while_referenced() {
451 let dir = tempfile::tempdir().unwrap();
452 let m = manager(dir.path()).await;
453 let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
454 let snapshot_id = publish_template_snapshot(&catalog, "code");
455 m.register_template_draft("code", entry_with_warm(&snapshot_id))
456 .await
457 .unwrap();
458
459 let listed = m.list_checkpoints(None).unwrap();
460 assert!(
461 listed.iter().all(|s| s.id != snapshot_id),
462 "template snapshot leaked into the user checkpoint list"
463 );
464
465 let err = m.delete_checkpoint(&snapshot_id).await.unwrap_err();
466 assert!(
467 err.to_string().contains("owned by template"),
468 "unexpected refusal message: {err}"
469 );
470 assert!(
471 catalog.find_by_id(&snapshot_id).is_ok(),
472 "guard must not delete"
473 );
474 }
475
476 #[tokio::test]
477 async fn promotion_copies_the_checkpoint_with_independent_lifetime() {
478 let dir = tempfile::tempdir().unwrap();
479 let m = manager(dir.path()).await;
480 let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
481 let pending = catalog.begin("box-1").unwrap();
483 std::fs::write(pending.dir().join("vmstate"), b"vmstate").unwrap();
484 std::fs::write(pending.dir().join("mem"), b"memory-image").unwrap();
485 let source_id = pending
486 .commit(crate::snapshot::SnapshotDraft {
487 name: Some("user-checkpoint".into()),
488 labels: HashMap::new(),
489 snapshot_type: crate::config::SnapshotType::Full,
490 parent_id: None,
491 kernel_path: Some("/kernel".into()),
492 rootfs_path: Some("/cache/rootfs-tpl.ext4".into()),
493 net_invariant: true,
494 geometry: Some(crate::snapshot::SnapshotGeometry {
495 vcpus: 2,
496 memory_mib: 512,
497 }),
498 format: crate::sandbox::checkpoint::CHECKPOINT_FORMAT.to_owned(),
499 })
500 .unwrap()
501 .id;
502
503 let promoted = m
504 .promote_snapshot_to_template(&source_id, "code")
505 .await
506 .unwrap();
507 assert_ne!(promoted.snapshot_id, source_id);
508 assert_eq!(promoted.geometry.vcpus, 2);
509 assert_eq!(promoted.rootfs_path, "/cache/rootfs-tpl.ext4");
510 assert_eq!(
511 promoted.artifact_bytes,
512 b"vmstate".len() as u64 + b"memory-image".len() as u64
513 );
514
515 let copy = catalog.find_by_id(&promoted.snapshot_id).unwrap();
516 assert_eq!(
517 copy.labels.get(TEMPLATE_LABEL).map(String::as_str),
518 Some("code")
519 );
520 assert!(copy.net_invariant);
521
522 m.delete_checkpoint(&source_id).await.unwrap();
524 assert!(catalog.find_by_id(&promoted.snapshot_id).is_ok());
525 }
526
527 #[tokio::test]
528 async fn promotion_refuses_a_snapshot_without_geometry() {
529 let dir = tempfile::tempdir().unwrap();
530 let m = manager(dir.path()).await;
531 let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
532 let source_id = publish_template_snapshot(&catalog, "seed");
533
534 let err = m
535 .promote_snapshot_to_template(&source_id, "code")
536 .await
537 .unwrap_err();
538 assert!(
539 err.to_string().contains("predates geometry recording"),
540 "unexpected error: {err}"
541 );
542 }
543
544 #[tokio::test]
545 async fn an_orphaned_template_snapshot_is_reclaimable_by_id() {
546 let dir = tempfile::tempdir().unwrap();
547 let m = manager(dir.path()).await;
548 let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
549 let snapshot_id = publish_template_snapshot(&catalog, "ghost");
552
553 m.delete_checkpoint(&snapshot_id).await.unwrap();
554 assert!(catalog.find_by_id(&snapshot_id).is_err());
555 }
556
557 #[tokio::test]
558 async fn deleting_a_template_deletes_its_owned_snapshot() {
559 let dir = tempfile::tempdir().unwrap();
560 let m = manager(dir.path()).await;
561 let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
562 let snapshot_id = publish_template_snapshot(&catalog, "code");
563 m.register_template_draft("code", entry_with_warm(&snapshot_id))
564 .await
565 .unwrap();
566
567 m.delete_template("code").await.unwrap();
568 assert!(
569 catalog.find_by_id(&snapshot_id).is_err(),
570 "snapshot must be reclaimed"
571 );
572 assert!(m.get_template("code").is_err());
573 }
574}