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}