arcbox_computer_runtime/sandbox/checkpoint.rs
1use super::reconcile::JournaledLease;
2use super::record::{ProvisionIntent, SandboxProvisionOutcome};
3use super::*;
4use crate::lifecycle::tasks::CaptureSpec;
5
6/// The image format for tests that seed the catalog by hand — the format
7/// of the driver those tests actually run, which is the testkit fake.
8/// Production never names a format: [`checkpoint_impl`] records whatever
9/// the driver's `CheckpointImage` reports, and a restore hands that back to
10/// the driver, which refuses any other.
11#[cfg(test)]
12pub(super) const CHECKPOINT_FORMAT: &str = arcbox_vm_driver::testkit::CHECKPOINT_FORMAT;
13
14/// Parameters for the internal restore path
15/// ([`SandboxManager::restore_from_snapshot`]), shared by the Restore RPC
16/// and internal callers.
17pub(super) struct RestoreRequest {
18 /// Source checkpoint id.
19 pub(super) snapshot_id: String,
20 /// Assign a fresh TAP + IP to the restored sandbox.
21 pub(super) network_override: bool,
22 /// Spec journaled with the provision intent and installed on the
23 /// instance. Restore consumes its identity fields (`id`, `labels`,
24 /// `ttl_seconds`) — plus, on the warm-create origin, the initial
25 /// workload fields; the boot-recipe fields are ignored — the snapshot
26 /// carries the boot state.
27 pub(super) spec: SandboxSpec,
28 /// Which API surface this restore serves; decides the event contract.
29 pub(super) origin: RestoreOrigin,
30}
31
32impl SandboxManager {
33 /// Rootfs images that existing snapshots or catalog templates need to
34 /// stay restorable/bootable.
35 ///
36 /// Exposed so whoever owns the converted-rootfs cache can pin them; see
37 /// [`SnapshotCatalog::referenced_rootfs_paths`] and
38 /// [`TemplateCatalog::rootfs_paths`](crate::template_catalog::TemplateCatalog::rootfs_paths).
39 /// The union matters: a non-prewarmed template pins no snapshot, so
40 /// without the catalog half a vm-agent update plus a create for the same
41 /// docker layer would sweep the template's ext4 as superseded.
42 pub fn pinned_rootfs_paths(&self) -> Result<std::collections::BTreeSet<PathBuf>> {
43 let mut pinned = self.snapshots.referenced_rootfs_paths()?;
44 pinned.extend(self.templates.rootfs_paths()?);
45 Ok(pinned)
46 }
47
48 /// Checkpoint a `Ready` sandbox into the snapshot catalog.
49 pub async fn checkpoint_sandbox(
50 &self,
51 sandbox_id: &SandboxId,
52 name: String,
53 labels: HashMap<String, String>,
54 ) -> Result<CheckpointInfo> {
55 self.check_reconcile()?;
56 // The pause machinery owns this name: Remove deletes every snapshot
57 // carrying it, so a user checkpoint must not squat on it (CORE-21).
58 if name == super::pause::PAUSE_SNAPSHOT_NAME {
59 return Err(VmmError::Config(format!(
60 "checkpoint name {:?} is reserved for sandbox pause",
61 super::pause::PAUSE_SNAPSHOT_NAME
62 )));
63 }
64 // The warm-create cache (CORE-77) trusts its label as the lookup
65 // key; a caller must not be able to plant one.
66 super::warm::reject_reserved_labels(&labels)?;
67 self.capture_checkpoint(sandbox_id, name, labels).await
68 }
69
70 /// [`Self::checkpoint_sandbox`] without the reserved-name and
71 /// reserved-label guards, for the catalogs that own those names.
72 ///
73 /// The guards exist to stop a *caller* squatting on the pause
74 /// machinery's snapshot name or the warm cache's lookup label; the
75 /// template catalog's own prewarm writes `arcbox.template`, which is
76 /// exactly what the label guard refuses.
77 pub(super) async fn capture_checkpoint(
78 &self,
79 sandbox_id: &SandboxId,
80 name: String,
81 labels: HashMap<String, String>,
82 ) -> Result<CheckpointInfo> {
83 self.mailbox(sandbox_id)?
84 .ask(sandbox_id, |reply| Command::Checkpoint {
85 spec: CaptureSpec { name, labels },
86 reply,
87 })
88 .await
89 }
90
91 #[allow(
92 clippy::too_many_arguments,
93 reason = "restore rollback owns every partially acquired resource"
94 )]
95 /// Restore a new sandbox from a previously created checkpoint.
96 ///
97 /// The restored sandbox starts in `Ready` state immediately.
98 ///
99 /// Returns `(sandbox_id, ip_address)`.
100 pub async fn restore_sandbox(&self, spec: RestoreSandboxSpec) -> Result<(SandboxId, String)> {
101 self.restore_sandbox_keyed(spec, &Uuid::new_v4().to_string())
102 .await
103 }
104
105 /// Restore with a stable request key for durable replay.
106 pub async fn restore_sandbox_keyed(
107 &self,
108 spec: RestoreSandboxSpec,
109 restore_key: &str,
110 ) -> Result<(SandboxId, String)> {
111 let RestoreSandboxSpec {
112 id,
113 snapshot_id,
114 labels,
115 network_override,
116 ttl_seconds,
117 } = spec;
118 self.restore_from_snapshot(
119 RestoreRequest {
120 snapshot_id,
121 network_override,
122 spec: SandboxSpec {
123 id,
124 labels,
125 ttl_seconds,
126 ..Default::default()
127 },
128 origin: RestoreOrigin::Restore,
129 },
130 restore_key,
131 )
132 .await
133 }
134
135 /// Internal restore path shared by the Restore RPC and internal callers.
136 pub(super) async fn restore_from_snapshot(
137 &self,
138 request: RestoreRequest,
139 restore_key: &str,
140 ) -> Result<(SandboxId, String)> {
141 // Gate on the startup sweep before touching per-id resources (see
142 // create_sandbox / await_reconcile).
143 self.await_reconcile().await?;
144
145 let caller_supplied_id = request.spec.id.as_ref().is_some_and(|id| !id.is_empty());
146 let new_id = request
147 .spec
148 .id
149 .clone()
150 .filter(|s| !s.is_empty())
151 .unwrap_or_else(|| Uuid::new_v4().to_string());
152
153 super::validate_new_sandbox_id(&new_id, &*self.services.driver, &self.config)?;
154 // The snapshot id is caller-supplied and flows into snapshot dir paths
155 // (create_dir_all / copy / remove_dir_all) — validate it too, or a
156 // `../` id would traverse out of the snapshots directory.
157 super::validate_id("snapshot id", &request.snapshot_id)?;
158
159 // Phase clocks for the completion log: restore latency is a product
160 // metric (CORE-75) and the breakdown is what makes a regression
161 // attributable.
162 let restore_started = std::time::Instant::now();
163
164 if caller_supplied_id
165 && let Some(outcome) = self.records.replay_provision(&new_id, restore_key)?
166 {
167 return Ok((new_id, outcome.ip_address));
168 }
169
170 // Resolve read-only prerequisites before claiming durable ownership.
171 // Once the intent exists, every failure goes through rollback.
172 let snap_meta = self.snapshots.find_by_id(&request.snapshot_id)?;
173 // A pause checkpoint pairs with its sandbox's retained disk overlay;
174 // cloning it with a fresh overlay would silently discard that disk
175 // state. Resume is the only consumer (CORE-21).
176 if snap_meta.name.as_deref() == Some(super::pause::PAUSE_SNAPSHOT_NAME) {
177 return Err(VmmError::WrongState {
178 id: request.snapshot_id.clone(),
179 expected: "a user checkpoint".into(),
180 actual: "the internal pause checkpoint of a paused sandbox".into(),
181 });
182 }
183 if self.config.firecracker.jailer.is_none() {
184 return Err(VmmError::Config(
185 "checkpoint restore requires jailer isolation; direct mode embeds shared origin \
186 paths"
187 .into(),
188 ));
189 }
190
191 // Claim the id atomically so a concurrent restore/create of the same
192 // id fails fast with AlreadyExists instead of both proceeding to set up
193 // the deterministic per-id CoW/dm/TAP resources and corrupting each
194 // other (see reserve_actor). Unwound on every error path via Drop.
195 let vm_dir = PathBuf::from(&self.config.firecracker.data_dir)
196 .join("sandboxes")
197 .join(&new_id);
198 let mut restore_spec = request.spec.clone();
199 restore_spec.id = Some(new_id.clone());
200 let reservation = super::reserve_actor(
201 &self.computers,
202 &new_id,
203 ComputerRuntime::new(new_id.clone(), restore_spec.clone(), None, vm_dir.clone()),
204 )?;
205 let record = match self
206 .records
207 .provision_intent(&new_id, restore_key, restore_spec)?
208 {
209 ProvisionIntent::Created(record) | ProvisionIntent::Resume(record) => record,
210 ProvisionIntent::Replay(record) => {
211 let outcome = record
212 .provision_outcome
213 .ok_or_else(|| VmmError::WrongState {
214 id: new_id.clone(),
215 expected: "a persisted restore outcome".into(),
216 actual: "none".into(),
217 })?;
218 return Ok((new_id, outcome.ip_address));
219 }
220 ProvisionIntent::Blocked(_) => return Err(VmmError::AlreadyExists(new_id)),
221 };
222 let generation = record.generation;
223 let deadlines = Deadlines {
224 ttl: record.ttl_deadline,
225 idle_timeout_seconds: record.effective_spec.idle_timeout_seconds,
226 on_idle: record.effective_spec.on_idle,
227 };
228 {
229 let mut computer = reservation.runtime().lock().unwrap();
230 computer.record_generation = Some(generation);
231 computer.labels.clone_from(&record.effective_spec.labels);
232 // The effective spec carries the warm-create origin's initial
233 // workload; the durable copy is redacted once the record leaves
234 // the provisioning phases, so the gate reads it from here.
235 computer.spec = record.effective_spec;
236 computer.created_at = record.created_at;
237 computer.ttl_deadline = record.ttl_deadline;
238 }
239
240 // Reserve network metadata, journal it, then materialize the TAP. This
241 // mirrors Create so an agent crash never leaves an unowned interface.
242 let lease = if request.network_override {
243 match self
244 .services
245 .network
246 .reserve(
247 &VmId::new(new_id.as_str())?,
248 super::sandbox_network_policy(),
249 )
250 .await
251 {
252 Ok(lease) => Some(lease),
253 Err(error) => {
254 let abort = self.records.abort_provision(&new_id, generation)?;
255 if let Some(durability_error) = abort.durability_error {
256 return Err(VmmError::Unavailable(format!(
257 "{error}; restore rollback is visible, but durability is unconfirmed: {durability_error}"
258 )));
259 }
260 return Err(error.into());
261 }
262 }
263 } else {
264 None
265 };
266 let ip_address = lease
267 .as_ref()
268 .map(|lease| lease.ip.to_string())
269 .unwrap_or_default();
270 // Every journal this restore writes records the lease the way the
271 // `activate` below attaches it: from the snapshot's own flag, never
272 // from a constant. One binding so the two cannot drift.
273 let net_invariant = snap_meta.net_invariant;
274 // The NIC is tracked apart from the lease: the lease is owed back
275 // to the pool from `reserve` on, so the rollback below must see one
276 // whose TAP a failed journal write meant was never built.
277 let mut nic: Option<NicSpec> = None;
278 let setup = async {
279 super::reconcile::create_runtime_dir(&vm_dir)?;
280 let cleanup_record = super::reconcile::SandboxStateRecord::new(
281 &new_id,
282 None,
283 lease
284 .as_ref()
285 .map(|lease| JournaledLease::from_snapshot(lease, net_invariant)),
286 None,
287 &self.config,
288 None,
289 )?;
290 super::reconcile::write_state_record(&vm_dir, &cleanup_record)?;
291 if let Some(lease) = &lease {
292 // The restored guest keeps the addressing its snapshot baked:
293 // invariant snapshots pair with an invariant TAP (host-side
294 // NAT, no guest work); legacy snapshots keep the legacy TAP
295 // shape and are re-addressed over the reconfig RPC below.
296 nic = Some(
297 self.services
298 .network
299 .activate(lease, super::attach_mode(snap_meta.net_invariant))
300 .await?,
301 );
302 }
303 Ok::<_, VmmError>(())
304 }
305 .await;
306 if let Err(error) = setup {
307 // Nothing has been handed to a computer yet: the claim's drop
308 // frees the id, and the rollback here is the caller's own — the
309 // same shape a create's pre-actor setup failure takes.
310 let abort = self.records.abort_provision(&new_id, generation);
311 let mut unwound = Vec::new();
312 if let Some(reserved) = lease
313 && let Err(release_error) = self.services.network.release(reserved).await
314 {
315 unwound.push(format!("network: {release_error}"));
316 }
317 if let Err(abort_error) = abort {
318 unwound.push(format!("record: {abort_error}"));
319 }
320 if unwound.is_empty() {
321 return Err(error);
322 }
323 return Err(VmmError::Unavailable(format!(
324 "{error}; restore rollback incomplete: {}",
325 unwound.join("; ")
326 )));
327 }
328
329 // The actor takes it from here: the restore proper, the atomic
330 // `ReadyWithOutcome` commit, the CREATED event a warm create owes,
331 // the gate and READY. A failure before the commit is unwound by the
332 // machine's own force-remove — the id and its request key must be
333 // free again for a retry, and a warm create must be able to fall
334 // back to a cold boot.
335 let (services, timers_enabled) = self.spawn_context();
336 let mailbox = reservation.spawn(super::ActorSpawn {
337 services,
338 timers_enabled,
339 generation: Some(generation),
340 deadlines,
341 launch: Launch::Restore(Box::new(RestoreLaunch {
342 snapshot_id: request.snapshot_id.clone(),
343 snap_meta,
344 lease,
345 nic,
346 started: restore_started,
347 })),
348 seeded: Seeded::Fresh,
349 });
350 let outcome = SandboxProvisionOutcome {
351 ip_address: ip_address.clone(),
352 };
353 // A restore's caller waits for READY: that is what makes the sandbox
354 // usable, and what a warm create's own caller was promised.
355 let restored = mailbox
356 .ask(&new_id, |reply| Command::Provision {
357 provision: Provision::Restore {
358 origin: request.origin,
359 },
360 outcome,
361 reply,
362 })
363 .await;
364 match restored {
365 Ok(()) => Ok((new_id, ip_address)),
366 Err(error) => Err(error),
367 }
368 }
369
370 /// List checkpoints, optionally filtered by origin sandbox ID.
371 ///
372 /// Internal pause checkpoints are hidden: they are lifecycle state, not
373 /// user-owned snapshots, and deleting one would strand a paused sandbox.
374 /// Template-owned snapshots (CORE-107) are hidden for the same reason —
375 /// they surface via `TemplateService.Get/List`, not as user checkpoints.
376 pub fn list_checkpoints(&self, sandbox_id: Option<&str>) -> Result<Vec<CheckpointSummary>> {
377 let infos = match sandbox_id {
378 Some(sid) => self.snapshots.list(sid)?,
379 None => self.snapshots.list_all()?,
380 };
381 Ok(infos
382 .into_iter()
383 .filter(|s| s.name.as_deref() != Some(super::pause::PAUSE_SNAPSHOT_NAME))
384 .filter(|s| {
385 !s.labels
386 .contains_key(crate::template_catalog::TEMPLATE_LABEL)
387 })
388 .map(|s| CheckpointSummary {
389 id: s.id,
390 sandbox_id: s.vm_id,
391 name: s.name.unwrap_or_default(),
392 labels: s.labels,
393 snapshot_dir: s
394 .vmstate_path
395 .parent()
396 .map(|p| p.to_string_lossy().into_owned())
397 .unwrap_or_default(),
398 created_at: s.created_at.to_rfc3339(),
399 })
400 .collect())
401 }
402
403 /// Delete a checkpoint by its ID, tearing down any pre-warmed restore
404 /// slots staged from it first.
405 ///
406 /// Internal pause checkpoints are refused — deleting one would strand
407 /// its paused sandbox; they die with the sandbox via `Remove`.
408 /// Template-owned snapshots (CORE-107) are refused while the catalog
409 /// still references them — they are reclaimed through template deletion.
410 /// A labeled snapshot no record references (orphaned by a failed
411 /// post-commit cleanup) is deliberately deletable: this is the operator's
412 /// only recovery path, since `list_checkpoints` hides it and
413 /// `delete_template` answers `TemplateNotFound`.
414 pub async fn delete_checkpoint(&self, snapshot_id: &str) -> Result<()> {
415 let meta = self.snapshots.find_by_id(snapshot_id)?;
416 if meta.name.as_deref() == Some(super::pause::PAUSE_SNAPSHOT_NAME) {
417 return Err(VmmError::WrongState {
418 id: snapshot_id.to_owned(),
419 expected: "a user checkpoint".into(),
420 actual: "the internal pause checkpoint of a paused sandbox".into(),
421 });
422 }
423 // Fail closed on a catalog scan error: never delete what an
424 // unreadable catalog might still reference.
425 if let Some(owner) = meta.labels.get(crate::template_catalog::TEMPLATE_LABEL)
426 && self.templates.references_snapshot(snapshot_id)?
427 {
428 return Err(VmmError::FailedPrecondition(format!(
429 "snapshot {snapshot_id} is owned by template {owner}; delete the template instead"
430 )));
431 }
432 self.drain_pool(Some(snapshot_id)).await;
433 self.snapshots.delete_by_id(snapshot_id).map_err(Into::into)
434 }
435}