Skip to main content

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}