Skip to main content

arcbox_computer_runtime/sandbox/
checkpoint.rs

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