microvms_core/sandbox.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The `Sandbox` lifecycle: one VM's whole life, with the state machine the symspec
3//! model describes made true of the code.
4//!
5//! Composes [`crate::control`] and [`crate::session`] into the surface the CLI and the
6//! bindings use: build, run, suspend, resume, terminate, the launch-time suspended
7//! window (STATE-12), and teardown in the order that does not leak.
8//!
9//! # The lifecycle is a field, and every transition is guarded
10//!
11//! [`Lifecycle`] is the symspec's `vm_state` verbatim, and [`Sandbox`] carries the other
12//! four variables beside it — `token_installed`, `image_exists`, `was_terminated`,
13//! `bootstrap_count`. The Z3 proofs over that model (bootstrap at most once, suspend from
14//! non-RUNNING unreachable, TERMINATED never returns to RUNNING) are proofs about *this*
15//! struct's reachable states, which is only worth something if the transitions here are
16//! the only way to move it. They are: every one of those fields is private, and every
17//! mutation happens in one of the five methods below.
18//!
19//! # Runtime-checked rather than typestate, deliberately
20//!
21//! The packet offered `Sandbox<Running>` returning a `Suspended` handle, which would make
22//! STATE-5's wrong call a compile error rather than a local refusal — strictly stronger on
23//! the ladder in [`crate`]. It is not what landed, for the reason the packet names as
24//! acceptable: T-W3-8 wraps **one** object for PyO3 and napi-rs, and a type whose Rust
25//! identity changes on every transition cannot be one `#[pyclass]`. A typestate sandbox
26//! would be re-erased into a runtime-checked enum at the binding boundary, so the check
27//! would exist twice with the binding's copy being the one most callers actually hit.
28//!
29//! What is kept from the typestate idea is the part that costs nothing: the check happens
30//! **before** the wire call, so a suspend from SUSPENDED is refused with zero
31//! control-plane calls rather than answered by AWS. The test asserts the call count, which
32//! is the observable that distinguishes the two.
33//!
34//! # The suspended window is the client's alone (STATE-12)
35//!
36//! `suspendedDurationSeconds` exists only in the `RunMicrovm` **request**. `GetMicrovm`
37//! does not return it, so the client that sent the launch is the only party that can name
38//! the window it asked for — and the launch-time `idlePolicy` *terminates* a suspended VM
39//! once that window passes, which means "resume later" silently stops working. A resume
40//! past the window is refused locally, before `ResumeMicrovm`, because the alternative is
41//! calling and reading the failure: the service answers about a terminated id, which is
42//! not the same statement as "the window you set at launch closed", and getting there
43//! costs the full poll timeout first.
44//!
45//! # Teardown never raises, and the log group is last
46//!
47//! [`Sandbox::terminate`] returns a [`TeardownReport`] rather than a `Result`. It runs
48//! where a caller's `finally` would, and an error raised there replaces the real failure
49//! with a teardown failure — the real one being the one worth reading.
50//!
51//! The order is VM, then image (retrying), then the log group **last**, because the
52//! service can recreate a group deleted before its image. See
53//! [`TeardownReport::undeleted`] for what this crate can and cannot delete.
54//!
55//! # There is no `Drop` that tears down
56//!
57//! Rust has no context manager and `Drop` cannot await. A `Drop` that blocked on a runtime
58//! would deadlock inside one; a `Drop` that spawned would race the process exit. So
59//! [`Sandbox`]'s `Drop` only **warns** about a live VM, naming the id, and the rule is
60//! that a caller calls [`Sandbox::terminate`] explicitly.
61
62use std::sync::Arc;
63use std::time::Duration;
64
65use crate::control::{
66 ControlPlane, CreateImageRequest, Image, Microvm, RunHookPayload, RunMicrovmRequest, WaitOpts,
67};
68use crate::error::{Error, ErrorKind};
69use crate::region::Region;
70use crate::session::{Session, TokenMinter};
71
72/// The default launch wait: five minutes, matching the Python client's `ready_timeout_sec`.
73pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(300);
74
75/// The default lifecycle wait for suspend, resume, and terminate.
76pub const DEFAULT_LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(300);
77
78/// How many times a teardown retries the image delete.
79///
80/// Twenty, from the Python client: an image in `CREATING` refuses deletion and a VM still
81/// terminating holds a reference, so this is the difference between a clean account and a
82/// billed leak rather than politeness.
83pub const DEFAULT_DELETE_ATTEMPTS: u32 = 20;
84
85/// The gap between image-delete attempts.
86pub const DEFAULT_DELETE_BACKOFF: Duration = Duration::from_secs(15);
87
88/// How often a lifecycle wait polls.
89const LIFECYCLE_POLL_INTERVAL: Duration = Duration::from_secs(5);
90
91/// The symspec's `vm_state`, verbatim.
92///
93/// Six states and no others, which is the S1 half of this module: a lifecycle held as a
94/// `String` would let `"RUNNING "` and `"Running"` both exist, and every guard below would
95/// have to decide which it meant.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum Lifecycle {
98 /// The initial state, and the state a launch is accepted into (STATE-1).
99 Pending,
100 /// The run hook answered with a success status and the token is installed (STATE-2).
101 Running,
102 /// A suspend was accepted and the platform has not yet reported it complete (STATE-4).
103 Suspending,
104 /// The platform reported suspension complete (STATE-6).
105 Suspended,
106 /// A terminate was accepted (STATE-9).
107 Terminating,
108 /// The platform reported termination complete (STATE-10).
109 Terminated,
110}
111
112impl Lifecycle {
113 /// The name the service uses for this state, which is also what an error message says.
114 pub fn as_str(self) -> &'static str {
115 match self {
116 Lifecycle::Pending => "PENDING",
117 Lifecycle::Running => "RUNNING",
118 Lifecycle::Suspending => "SUSPENDING",
119 Lifecycle::Suspended => "SUSPENDED",
120 Lifecycle::Terminating => "TERMINATING",
121 Lifecycle::Terminated => "TERMINATED",
122 }
123 }
124
125 /// Whether a VM in this state is still billing, which is what a `Drop` warning is for.
126 pub fn is_live(self) -> bool {
127 matches!(
128 self,
129 Lifecycle::Pending | Lifecycle::Running | Lifecycle::Suspending | Lifecycle::Suspended
130 )
131 }
132}
133
134impl std::fmt::Display for Lifecycle {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.write_str(self.as_str())
137 }
138}
139
140/// Everything a launch needs, with the defaults the Python client measured.
141///
142/// `agent_token` is optional because the common case is a per-VM secret nobody needs to
143/// see; a caller who has one already — a harness minting its own, or a retry that must
144/// reuse the first attempt's — passes it.
145#[derive(Clone, Debug)]
146pub struct RunRequest {
147 /// The image to launch, or `None` for the one [`Sandbox::build_image`] built.
148 pub image_identifier: Option<String>,
149 /// `imageVersion`, or `None` for the image's own latest active version.
150 ///
151 /// Pinning is what makes a canary a canary: without it a launch takes whatever version is
152 /// latest at the moment the call lands, which is not necessarily the one the caller just
153 /// built. Paired with [`crate::control::ControlPlane::set_image_version_status`] it is also
154 /// the rollback — set the bad version INACTIVE, re-pin here to the good one — and a version
155 /// set INACTIVE refuses to launch when named here.
156 ///
157 /// See [`crate::control::RunMicrovmRequest::image_version`], which this forwards to.
158 pub image_version: Option<String>,
159 /// The execution role. Optional in the model; every real launch needs one.
160 pub execution_role_arn: Option<String>,
161 /// The bearer token the daemon will accept, or `None` to mint one.
162 pub agent_token: Option<String>,
163 /// Base environment for every exec in the launched VM, delivered in the same
164 /// `runHookPayload` as the token.
165 ///
166 /// The daemon applies this *under* each request's own `env`, so a per-exec value
167 /// wins on a key both set. Empty by default, and an empty map produces byte-for-byte
168 /// the payload this client always sent — a caller who never touches this field
169 /// cannot be affected by the field existing.
170 ///
171 /// It shares the token's 4096-byte payload budget, and the check is local:
172 /// [`crate::control::RunHookPayload::for_launch`] refuses an over-ceiling payload
173 /// before any call, naming the byte count and how much of it the env is. That
174 /// matters here more than for the token, because one bearer token has always fit
175 /// with room to spare and a map of credentials does not.
176 pub launch_env: std::collections::HashMap<String, String>,
177 /// Whether to request the egress connector. Off means no outbound network.
178 pub egress: bool,
179 /// `idlePolicy.maxIdleDurationSeconds`.
180 pub max_idle_sec: u32,
181 /// `idlePolicy.suspendedDurationSeconds` — the window STATE-12 refuses past.
182 pub suspended_sec: u32,
183 /// `idlePolicy.autoResumeEnabled`.
184 pub auto_resume: bool,
185 /// `maximumDurationInSeconds`, checked against 1..=28800 before the call.
186 pub max_duration_sec: u32,
187 /// How long to wait for RUNNING.
188 pub ready_timeout: Duration,
189 /// A label for the run token (TRAP-1). Never the token.
190 pub token_scope: Option<String>,
191}
192
193impl Default for RunRequest {
194 fn default() -> Self {
195 Self {
196 image_identifier: None,
197 image_version: None,
198 execution_role_arn: None,
199 agent_token: None,
200 launch_env: std::collections::HashMap::new(),
201 egress: false,
202 max_idle_sec: 600,
203 suspended_sec: 600,
204 auto_resume: false,
205 max_duration_sec: 3_600,
206 ready_timeout: DEFAULT_READY_TIMEOUT,
207 token_scope: None,
208 }
209 }
210}
211
212impl RunRequest {
213 /// A launch with the measured defaults: ingress only, ten-minute idle and suspended
214 /// windows, a one-hour maximum duration, no auto-resume.
215 pub fn new() -> Self {
216 Self::default()
217 }
218
219 /// Launches `identifier` rather than the built image.
220 #[must_use]
221 pub fn with_image(mut self, identifier: impl Into<String>) -> Self {
222 self.image_identifier = Some(identifier.into());
223 self
224 }
225
226 /// Requests the egress connector, which is what gives the VM outbound network.
227 #[must_use]
228 pub fn with_egress(mut self) -> Self {
229 self.egress = true;
230 self
231 }
232
233 /// Pins the launch to one `imageVersion`. See the field for why.
234 #[must_use]
235 pub fn with_image_version(mut self, version: impl Into<String>) -> Self {
236 self.image_version = Some(version.into());
237 self
238 }
239
240 /// Sets the suspended window this sandbox will refuse a resume past (STATE-12).
241 #[must_use]
242 pub fn with_suspended_sec(mut self, seconds: u32) -> Self {
243 self.suspended_sec = seconds;
244 self
245 }
246
247 /// Reuses a caller-supplied agent token rather than minting one.
248 #[must_use]
249 pub fn with_agent_token(mut self, token: impl Into<String>) -> Self {
250 self.agent_token = Some(token.into());
251 self
252 }
253
254 /// Adds one launch-environment variable, which every exec in the VM starts with.
255 ///
256 /// One pair per call rather than a whole map, because that is how a caller builds
257 /// one — from flags, from a config file, one credential at a time — and a
258 /// map-taking setter makes the second call silently discard the first. The field is
259 /// public for a caller who really does hold a map.
260 #[must_use]
261 pub fn with_launch_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
262 self.launch_env.insert(key.into(), value.into());
263 self
264 }
265}
266
267/// What a teardown should delete beyond the VM itself.
268///
269/// Both deletions are opt-in, because both destroy something a caller may still want: the
270/// image is reusable across runs, and the log group is where a failed build's only evidence
271/// lives.
272#[derive(Clone, Copy, Debug)]
273pub struct TeardownOpts {
274 /// Whether to delete the image the sandbox built.
275 pub delete_image: bool,
276 /// Whether the build log group should be deleted.
277 ///
278 /// This crate **cannot** delete it — CloudWatch is not in the dependency set — so
279 /// asking names the group in [`TeardownReport::undeleted`] rather than removing it.
280 /// See that field for why naming is the honest answer rather than a silent success.
281 pub delete_log_group: bool,
282 /// How many times the image delete is retried.
283 pub delete_attempts: u32,
284 /// The gap between image-delete attempts.
285 pub delete_backoff: Duration,
286 /// How long to wait for TERMINATED, or `None` to return as soon as the terminate call
287 /// is accepted.
288 ///
289 /// `None` by default, matching the Python client: the caller is on the way out, and a
290 /// teardown that blocked five minutes on a state nobody reads is five minutes of a CI
291 /// job. A caller that needs STATE-10 *observed* passes
292 /// [`TeardownOpts::waiting_for_terminated`].
293 pub wait_for_terminated: Option<Duration>,
294}
295
296impl Default for TeardownOpts {
297 fn default() -> Self {
298 Self {
299 delete_image: false,
300 delete_log_group: false,
301 delete_attempts: DEFAULT_DELETE_ATTEMPTS,
302 delete_backoff: DEFAULT_DELETE_BACKOFF,
303 wait_for_terminated: None,
304 }
305 }
306}
307
308impl TeardownOpts {
309 /// Deletes the image as well as the VM.
310 #[must_use]
311 pub fn deleting_image(mut self) -> Self {
312 self.delete_image = true;
313 self
314 }
315
316 /// Asks for the log group too, which names it rather than deleting it.
317 #[must_use]
318 pub fn deleting_log_group(mut self) -> Self {
319 self.delete_log_group = true;
320 self
321 }
322
323 /// Waits for TERMINATED before returning (STATE-10).
324 #[must_use]
325 pub fn waiting_for_terminated(mut self) -> Self {
326 self.wait_for_terminated = Some(DEFAULT_LIFECYCLE_TIMEOUT);
327 self
328 }
329}
330
331/// What a teardown did, and what it left behind.
332///
333/// Returned rather than raised. See the module docs: this runs where a `finally` would.
334#[derive(Clone, Debug, Default, Eq, PartialEq)]
335pub struct TeardownReport {
336 /// Identifiers of everything a caller asked to have deleted that still exists.
337 ///
338 /// The CLI emits these (CLI-6), which is the whole reason they are identifiers rather
339 /// than a boolean: a leak nobody can name is a leak nobody can clean up.
340 ///
341 /// Two things land here. A delete that was attempted and failed — an image whose twenty
342 /// attempts all hit a conflict. And the build **log group**, which this crate cannot
343 /// delete at all, because CloudWatch is absent from the dependency set T-W2-2 froze and
344 /// this lane adds only source files. Naming it is strictly better than the
345 /// alternatives: deleting it is unavailable, and silently succeeding would report a
346 /// clean teardown over six accumulated log groups — which is how the leak was found in
347 /// the first place.
348 pub undeleted: Vec<String>,
349 /// Whether the terminate call was accepted.
350 pub terminate_accepted: bool,
351 /// Whether the image was deleted, or `None` when deletion was not asked for.
352 pub image_deleted: Option<bool>,
353 /// The lifecycle state the sandbox ended in.
354 pub lifecycle: Option<Lifecycle>,
355 /// Every failure the teardown swallowed, in the order it hit them.
356 ///
357 /// Kept because a teardown that never raises is a teardown whose failures are invisible
358 /// otherwise, and the first one is usually the cause of the rest.
359 pub failures: Vec<String>,
360}
361
362impl TeardownReport {
363 /// Whether anything a caller asked for was left behind.
364 pub fn leaked(&self) -> bool {
365 !self.undeleted.is_empty()
366 }
367}
368
369/// Mints proxy tokens for one MicroVM through the control plane.
370///
371/// The bridge between the two lanes: `ControlPlane::mint_auth_token` answers a
372/// `control::ProxyToken`, [`TokenMinter`] wants a `session::ProxyToken`, and the `From`
373/// impl at `session/proxy.rs` is the conversion — one `.into()` at the boundary, which is
374/// why that impl is `From` rather than a named function.
375///
376/// The plane sits behind an `Arc` rather than a reference because the minter must outlive
377/// the call that built the session: minting happens inside the request path on every later
378/// request, which is what makes it happen at all (TRAP-9).
379struct ControlPlaneMinter {
380 control: Arc<ControlPlane>,
381 microvm_id: String,
382}
383
384impl TokenMinter for ControlPlaneMinter {
385 fn mint(
386 &self,
387 ) -> futures_util::future::BoxFuture<'_, Result<crate::session::ProxyToken, Error>> {
388 Box::pin(async move {
389 let minted = self.control.mint_auth_token(&self.microvm_id).await?;
390 Ok(minted.into())
391 })
392 }
393
394 /// Overrides the default, which ignores the ports and delegates.
395 ///
396 /// This is the minter that has a control plane behind it, so it is the one that can
397 /// actually widen a token's scope — and without this override
398 /// `Session::connect_headers(8080)` would keep answering a header pair behind a token
399 /// scoped to 9000 only, which the proxy refuses with 403 `Access to port denied`. See
400 /// [`TokenMinter::mint_for_ports`] for the measurement.
401 fn mint_for_ports(
402 &self,
403 ports: &[u16],
404 ) -> futures_util::future::BoxFuture<'_, Result<crate::session::ProxyToken, Error>> {
405 let specs: Vec<crate::control::ops::PortSpecification> = ports
406 .iter()
407 .map(|port| crate::control::ops::PortSpecification::port(*port))
408 .collect();
409 Box::pin(async move {
410 let minted = self
411 .control
412 .mint_auth_token_for(&self.microvm_id, &specs)
413 .await?;
414 Ok(minted.into())
415 })
416 }
417}
418
419/// One MicroVM's whole life.
420///
421/// See the module docs for the state machine, the window, and why teardown is explicit.
422pub struct Sandbox {
423 control: Arc<ControlPlane>,
424 image: Option<Image>,
425 microvm: Option<Microvm>,
426 session: Option<Session>,
427 /// A backend for the session [`Sandbox::run`] builds, or `None` for the real HTTP one.
428 ///
429 /// The daemon-side sibling of `with_control_plane`'s transport seam: a test that
430 /// scripts the control plane can launch, but the session `run` then builds would dial
431 /// a real endpoint. This lets the same test script the daemon too. Never set in
432 /// production — `Session::builder` picks reqwest when no backend is given.
433 session_backend: Option<crate::session::SharedBackend>,
434
435 // ── the symspec's five variables ─────────────────────────────────────────
436 lifecycle: Lifecycle,
437 token_installed: bool,
438 image_exists: bool,
439 was_terminated: bool,
440 bootstrap_count: u32,
441
442 /// The window from *our own* `RunMicrovm` request. `GetMicrovm` does not return it.
443 suspended_window: Option<Duration>,
444 /// The clock reading when the suspend call was accepted, or `None` when not suspended.
445 suspended_at: Option<Duration>,
446 /// Set by [`Sandbox::terminate`], so `Drop` can tell an abandoned VM from a torn-down
447 /// one.
448 torn_down: bool,
449}
450
451impl std::fmt::Debug for Sandbox {
452 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
453 // No agent token: it is a credential, and a `Debug` that printed it would put it in
454 // every log line that formats a sandbox.
455 f.debug_struct("Sandbox")
456 .field("lifecycle", &self.lifecycle)
457 .field("microvm", &self.microvm.as_ref().map(|vm| &vm.id))
458 .field("image", &self.image.as_ref().map(|image| &image.identifier))
459 .field("token_installed", &self.token_installed)
460 .field("bootstrap_count", &self.bootstrap_count)
461 .field("was_terminated", &self.was_terminated)
462 .field("suspended_window", &self.suspended_window)
463 .finish_non_exhaustive()
464 }
465}
466
467impl Sandbox {
468 /// Resolves credentials for `region` and returns a sandbox with nothing launched.
469 ///
470 /// TRAP-6 is closed by [`Region`] before this is reached, which is why there is no
471 /// region check here.
472 pub async fn new(region: Region) -> Result<Self, Error> {
473 Ok(Self::with_control_plane(ControlPlane::new(region).await?))
474 }
475
476 /// A sandbox over an already-built [`ControlPlane`].
477 ///
478 /// The seam every test uses, and public for the same reason `ControlPlane::with_transport`
479 /// is: a caller who wants a non-default port, or a fake transport, has already built the
480 /// plane, and a sandbox constructible only from a [`Region`] would be a sandbox no test
481 /// can drive without AWS.
482 ///
483 /// The plane's own clock is what times the suspended window, rather than a second one
484 /// this type would own. Two clocks in one lifecycle is the trap the daemon's turmoil
485 /// tests record: a window measured on one clock while the poll loop sleeps on another
486 /// calls a closed window open.
487 pub fn with_control_plane(control: ControlPlane) -> Self {
488 Self {
489 control: Arc::new(control),
490 image: None,
491 microvm: None,
492 session: None,
493 session_backend: None,
494 lifecycle: Lifecycle::Pending,
495 token_installed: false,
496 image_exists: false,
497 was_terminated: false,
498 bootstrap_count: 0,
499 suspended_window: None,
500 suspended_at: None,
501 torn_down: false,
502 }
503 }
504
505 /// Routes the session [`Sandbox::run`] builds through `backend` instead of real HTTP.
506 ///
507 /// The daemon-side half of the test seam `with_control_plane` opens: scripting the
508 /// control plane gets a test through the launch, and this gets it through everything
509 /// the launched session then does — uploads, execs, downloads — without a daemon.
510 pub fn with_session_backend(mut self, backend: crate::session::SharedBackend) -> Self {
511 self.session_backend = Some(backend);
512 self
513 }
514
515 // ── the symspec's five variables, readable ───────────────────────────────
516
517 /// The lifecycle state, which is the symspec's `vm_state`.
518 pub fn lifecycle(&self) -> Lifecycle {
519 self.lifecycle
520 }
521
522 /// Whether the agent token has been installed (STATE-2).
523 pub fn token_installed(&self) -> bool {
524 self.token_installed
525 }
526
527 /// Whether an image is recorded as existing (STATE-1).
528 pub fn image_exists(&self) -> bool {
529 self.image_exists
530 }
531
532 /// Whether this VM was ever terminated (STATE-11).
533 pub fn was_terminated(&self) -> bool {
534 self.was_terminated
535 }
536
537 /// How many times the token has been installed. Never above one (STATE-3).
538 pub fn bootstrap_count(&self) -> u32 {
539 self.bootstrap_count
540 }
541
542 /// The image, once built.
543 pub fn image(&self) -> Option<&Image> {
544 self.image.as_ref()
545 }
546
547 /// The VM as the service last described it.
548 pub fn microvm(&self) -> Option<&Microvm> {
549 self.microvm.as_ref()
550 }
551
552 /// The session, once launched.
553 pub fn session(&self) -> Option<&Session> {
554 self.session.as_ref()
555 }
556
557 /// The suspended window this sandbox asked for at launch, once it has launched.
558 pub fn suspended_window(&self) -> Option<Duration> {
559 self.suspended_window
560 }
561
562 // ── build ────────────────────────────────────────────────────────────────
563
564 /// Builds an image and waits for it to become usable.
565 ///
566 /// Every local guard runs inside [`ControlPlane::create_image`], before the call —
567 /// which matters because the create happens *after* the caller's artifact upload, so a
568 /// rejection AWS raises costs the upload first.
569 pub async fn build_image(&mut self, request: CreateImageRequest) -> Result<&Image, Error> {
570 let size = request.size;
571 let created = self.control.create_image(request).await?;
572 let built = self
573 .control
574 .wait_for_image(&created.identifier, size, WaitOpts::default())
575 .await?;
576 // Recorded before any launch, because a built image exists whether or not anything
577 // is ever run from it — and the teardown has to be able to name it either way.
578 self.image_exists = true;
579 self.image = Some(built);
580 Ok(self.image.as_ref().expect("just assigned"))
581 }
582
583 /// The artifact bytes to upload to the request's `code_artifact_uri`.
584 ///
585 /// The upload is the caller's: S3 is not in this crate's dependency set.
586 pub fn build_artifact_for(&self, request: &CreateImageRequest) -> Result<Vec<u8>, Error> {
587 self.control.build_artifact_for(request)
588 }
589
590 /// Every local guard [`Self::build_image`] runs, callable **before** the artifact upload.
591 ///
592 /// A delegation to [`ControlPlane::preflight`], on this type so the CLI's build path
593 /// reaches it through the one sandbox it already holds. Local; zero calls. The point is
594 /// ordering: `build_image`'s guards run after the caller's upload, so a request this
595 /// client itself refuses would still cost the S3 PUT unless the caller checks first.
596 pub fn preflight(&self, request: &CreateImageRequest) -> Result<(), Error> {
597 self.control.preflight(request)
598 }
599
600 /// The ARN for `identifier`: an ARN passes through with zero calls, a bare name is
601 /// resolved through the image listing by exact match.
602 ///
603 /// A read-only delegation to [`ControlPlane::resolve_image_arn`], on this type so the
604 /// CLI's launch path reaches it through the one sandbox it already holds. It touches
605 /// none of the five state-machine variables — resolution is a question about the
606 /// account, not about this VM's lifecycle.
607 pub async fn resolve_image_arn(&self, identifier: &str) -> Result<String, Error> {
608 self.control.resolve_image_arn(identifier).await
609 }
610
611 /// The image with exactly `name`, or `None`. Read-only; see
612 /// [`ControlPlane::find_image_by_name`] for the pagination and exact-match rules.
613 pub async fn find_image_by_name(
614 &self,
615 name: &str,
616 ) -> Result<Option<crate::control::ops::MicrovmImageSummaryWire>, Error> {
617 self.control.find_image_by_name(name).await
618 }
619
620 /// The content hash `build --reuse` keys an image name to. Local; zero calls.
621 pub fn artifact_content_hash_for(&self, request: &CreateImageRequest) -> String {
622 self.control.artifact_content_hash_for(request)
623 }
624
625 /// The versions of a managed base image, for pinning
626 /// [`CreateImageRequest::base_image_version`].
627 ///
628 /// A read-only delegation to [`crate::control::ControlPlane::managed_base_versions`], on
629 /// this type for the reason [`Self::resolve_image_arn`] gives: it touches none of the five
630 /// state-machine variables, because a question about AWS's published bases is not a
631 /// question about this VM's lifecycle.
632 pub async fn managed_base_versions(
633 &self,
634 base_image_arn: &str,
635 ) -> Result<Vec<crate::control::ops::ManagedMicrovmImageVersionWire>, Error> {
636 self.control.managed_base_versions(base_image_arn).await
637 }
638
639 /// One version's configuration, state, and availability status. Read-only.
640 pub async fn image_version(
641 &self,
642 identifier: &str,
643 version: &str,
644 ) -> Result<crate::control::ops::MicrovmImageVersionSummaryWire, Error> {
645 self.control.get_image_version(identifier, version).await
646 }
647
648 // ── run (STATE-1, STATE-2, STATE-3) ──────────────────────────────────────
649
650 /// Launches a MicroVM, waits for RUNNING, and returns its session.
651 ///
652 /// # The three state requirements this is
653 ///
654 /// STATE-1: the accepted launch moves the lifecycle to PENDING and records the image as
655 /// existing. STATE-2: the platform reporting RUNNING is what marks the token installed
656 /// — not the launch call, because the run hook is what delivers it and a launch that
657 /// dies during startup delivered nothing. STATE-3: `bootstrap_count` is incremented
658 /// exactly here, and a second `run` on the same sandbox is refused, which is what makes
659 /// "at most once per VM lifetime" a property of the type rather than of a caller's
660 /// discipline.
661 ///
662 /// The agent token rides in `runHookPayload`, which is what keeps it out of the shared
663 /// image snapshot. That is safe because the platform forwards no external traffic until
664 /// the run hook returns 200, so a per-VM secret delivered at launch wins the
665 /// first-writer race through the endpoint.
666 pub async fn run(&mut self, request: RunRequest) -> Result<&mut Session, Error> {
667 // STATE-3's local half. A sandbox that has already bootstrapped cannot bootstrap
668 // again, and the refusal is here rather than in a comment because `run` twice is
669 // the plausible mistake — a retry loop around a launch that timed out.
670 if self.bootstrap_count > 0 || self.microvm.is_some() {
671 return Err(Error::invalid_arg(format!(
672 "this sandbox has already launched a VM ({} bootstrap(s), lifecycle {}), and the \
673 agent token is installed at most once per VM lifetime (STATE-3). A second VM \
674 needs a second Sandbox — reusing this one would either re-deliver a run-hook \
675 payload to a daemon whose one-shot bootstrap refuses it, or silently address \
676 two guests through one handle.",
677 self.bootstrap_count, self.lifecycle,
678 )));
679 }
680
681 let Some(identifier) = request
682 .image_identifier
683 .clone()
684 .or_else(|| self.image.as_ref().map(|image| image.identifier.clone()))
685 else {
686 return Err(Error::new(
687 ErrorKind::Precondition,
688 "no image to launch: pass RunRequest::with_image or call build_image first."
689 .to_string(),
690 ));
691 };
692
693 let agent_token = request.agent_token.clone().unwrap_or_else(mint_agent_token);
694 // Checked even though this builds the JSON itself, because neither half is ours:
695 // the token may be caller-supplied, so someone passing a signed blob rather than a
696 // bearer token is exactly who this catches (TRAP-5), and the launch env is entirely
697 // the caller's. This is the pre-flight refusal — over-ceiling fails here with the
698 // byte count, before the launch, rather than as a `ValidationException` on a member
699 // the caller did not know they were filling.
700 let payload = RunHookPayload::for_launch(&agent_token, &request.launch_env)?;
701
702 let mut wire = RunMicrovmRequest::new(&identifier, payload);
703 wire.image_version = request.image_version.clone();
704 wire.execution_role_arn = request.execution_role_arn.clone();
705 wire.max_idle_sec = request.max_idle_sec;
706 wire.suspended_sec = request.suspended_sec;
707 wire.auto_resume = request.auto_resume;
708 wire.max_duration_sec = request.max_duration_sec;
709 wire.token_scope = request.token_scope.clone();
710 if request.egress {
711 wire = wire.with_egress();
712 }
713
714 let launched = self.control.run_microvm(wire).await?;
715
716 // STATE-1: the launch was accepted.
717 self.lifecycle = Lifecycle::Pending;
718 self.image_exists = true;
719 // Recorded here rather than after the wait, because the window the idlePolicy
720 // enforces was set by *this* request and a launch that then fails still leaves a VM
721 // the caller may have to reason about.
722 self.suspended_window = Some(Duration::from_secs(u64::from(request.suspended_sec)));
723 let id = launched.id.clone();
724 self.microvm = Some(launched);
725
726 let running = self
727 .control
728 .wait_for_running(
729 &id,
730 WaitOpts {
731 timeout: request.ready_timeout,
732 poll_interval: LIFECYCLE_POLL_INTERVAL,
733 stall_grace: Duration::MAX,
734 },
735 )
736 .await?;
737
738 // STATE-2. The platform reported the run hook succeeded, so the token is in the
739 // guest's memory — and this is the one place that counts it (STATE-3).
740 self.lifecycle = Lifecycle::Running;
741 self.token_installed = true;
742 self.bootstrap_count += 1;
743 let endpoint = running.endpoint.clone();
744 self.microvm = Some(running);
745
746 let minter = Arc::new(ControlPlaneMinter {
747 control: Arc::clone(&self.control),
748 microvm_id: id,
749 });
750 let mut builder = Session::builder(endpoint, agent_token)
751 .with_minter(minter)
752 .with_port(self.control.port());
753 if let Some(backend) = &self.session_backend {
754 builder = builder.with_backend(Arc::clone(backend));
755 }
756 self.session = Some(builder.build()?);
757 Ok(self.session.as_mut().expect("just assigned"))
758 }
759
760 // ── suspend (STATE-4, STATE-5, STATE-6) ──────────────────────────────────
761
762 /// Freezes the VM and waits for the platform to report it.
763 ///
764 /// A freeze and restore rather than a stop and start: the guest keeps its memory, so the
765 /// token, the filesystem, and every exec record survive. The one thing that does not is
766 /// the guest's view of time — it observes the whole suspension as a single jump, so any
767 /// timeout, lease, or TLS session a running command holds expires at once on resume.
768 ///
769 /// # STATE-5 is checked before the wire, not after
770 ///
771 /// A suspend from anything but RUNNING is refused here with **zero** control-plane
772 /// calls. That is the observable difference between this and a client that lets AWS
773 /// answer, and it is what the test asserts on.
774 pub async fn suspend(&mut self) -> Result<(), Error> {
775 let id = self.require_microvm("suspend")?;
776
777 // STATE-5.
778 if self.lifecycle != Lifecycle::Running {
779 return Err(Error::invalid_arg(format!(
780 "microvm {id} is {} and a suspend is only issued from RUNNING (STATE-5). Refused \
781 here rather than by the service, because the service's answer about a \
782 non-running id does not say which of the two things went wrong — and a suspend \
783 issued from SUSPENDED is a caller who believes they resumed.",
784 self.lifecycle,
785 )));
786 }
787
788 // STATE-4: accepted, so the lifecycle moves before the wait. Acceptance is the
789 // wire call succeeding, so the assignment comes after it: moving to SUSPENDING
790 // first would leave a failed call (a throttle, a dead transport) stuck in a state
791 // neither suspend nor resume accepts, bricking the handle over one bad request.
792 self.control.suspend(&id).await?;
793 self.lifecycle = Lifecycle::Suspending;
794 // Stamped after the call and before the wait, not after the wait: the idlePolicy's
795 // window starts when the platform begins suspending, so timing it from SUSPENDED
796 // would under-count the transition and call a closed window open.
797 self.suspended_at = Some(self.control.clock().elapsed());
798
799 let settled = self
800 .control
801 .wait_for_state(
802 &id,
803 &crate::control::microvm::SUSPEND_WANTED,
804 &[],
805 self.lifecycle_wait(),
806 )
807 .await?;
808
809 // STATE-6, and the TERMINATED case beside it. A VM that dies while suspending is a
810 // state to report rather than an exception out of the middle of a teardown, so the
811 // wait *wants* TERMINATED — and recording it here is what stops a resume from being
812 // offered afterwards (STATE-11).
813 self.lifecycle = match settled.state.as_str() {
814 "SUSPENDED" => Lifecycle::Suspended,
815 "TERMINATED" => {
816 self.was_terminated = true;
817 Lifecycle::Terminated
818 }
819 other => {
820 return Err(Error::new(
821 ErrorKind::Platform,
822 format!(
823 "the suspend wait returned {other}, which is neither SUSPENDED nor \
824 TERMINATED — and those two are what this client asked for."
825 ),
826 ));
827 }
828 };
829 self.microvm = Some(settled);
830 Ok(())
831 }
832
833 // ── resume (STATE-7, STATE-8, STATE-12) ──────────────────────────────────
834
835 /// Thaws the VM and returns a usable session.
836 ///
837 /// # What is deliberately not re-delivered (STATE-7)
838 ///
839 /// Nothing. No run-hook payload, no token, no bootstrap. The in-memory token survived
840 /// the freeze, and re-delivering it would hit the daemon's one-shot bootstrap and be
841 /// refused — a 409 that reads like a broken VM.
842 ///
843 /// # The window is checked first (STATE-12)
844 ///
845 /// Before any wire call, because the answer is already known and calling costs the poll
846 /// timeout to learn something worse. The falsification is a service reporting
847 /// TERMINATED: without this check the resume burns the full deadline and then reports a
848 /// state the client could have named at once.
849 ///
850 /// # The proxy token is dropped (STATE-8)
851 ///
852 /// [`Session::rebind`] invalidates it. The endpoint URL does not change across
853 /// suspend/resume, so the rebind is usually a no-op on the URL — but a token minted
854 /// against the pre-suspend instance may no longer validate, and that rejection reads
855 /// exactly like a dead daemon.
856 pub async fn resume(&mut self) -> Result<&mut Session, Error> {
857 let id = self.require_microvm("resume")?;
858
859 // STATE-11's local half: a terminated VM never returns to RUNNING, so the refusal
860 // comes before the window check and before any call.
861 if self.was_terminated || self.lifecycle == Lifecycle::Terminated {
862 return Err(Error::invalid_arg(format!(
863 "microvm {id} was terminated, and a terminated VM never returns to RUNNING \
864 (STATE-11). There is nothing to resume: the guest's memory is gone, so even a \
865 call the service accepted would hand back a different machine."
866 )));
867 }
868 if self.lifecycle != Lifecycle::Suspended {
869 return Err(Error::invalid_arg(format!(
870 "microvm {id} is {} and a resume is only issued from SUSPENDED (STATE-7).",
871 self.lifecycle,
872 )));
873 }
874
875 // STATE-12, first and locally.
876 self.require_open_suspended_window(&id)?;
877
878 self.control.resume(&id).await?;
879 // `fail_on` is the *dead* states rather than the terminal ones: SUSPENDED is the
880 // state this call was made from, so failing on it would fail every resume. A VM the
881 // idlePolicy terminated during suspension never reaches RUNNING, and waiting only
882 // for RUNNING there burns the full timeout and then reports a timeout message
883 // hiding a cause the service had already stated in `stateReason`.
884 let running = self
885 .control
886 .wait_for_state(
887 &id,
888 &["RUNNING"],
889 &crate::constants::DEAD_STATES,
890 self.lifecycle_wait(),
891 )
892 .await?;
893
894 self.lifecycle = Lifecycle::Running;
895 // STATE-8, through the endpoint the service just reported rather than the one held:
896 // the URL is measured not to change, and reading it from the response is what makes
897 // that a fact this code depends on rather than an assumption it encodes.
898 let endpoint = running.endpoint.clone();
899 self.microvm = Some(running);
900 if let Some(session) = self.session.as_mut() {
901 session.rebind(endpoint);
902 }
903 // Cleared on success so the next cycle's window is measured from the next suspend.
904 // Leaving it set would accumulate every suspension's elapsed time into one total and
905 // reject a resume whose own window is wide open.
906 self.suspended_at = None;
907
908 self.session.as_mut().ok_or_else(|| {
909 Error::new(
910 ErrorKind::Unexpected,
911 "the VM resumed but this sandbox holds no session, which run() always builds"
912 .to_string(),
913 )
914 })
915 }
916
917 /// Rejects a resume the launch-time `idlePolicy` has already made impossible (STATE-12).
918 ///
919 /// The message names the elapsed time, the window, and the `idlePolicy` finding, because
920 /// "cannot resume" alone sends a reader looking for the flag that reopens it.
921 fn require_open_suspended_window(&self, id: &str) -> Result<(), Error> {
922 let (Some(window), Some(since)) = (self.suspended_window, self.suspended_at) else {
923 // No window recorded means this sandbox did not send the launch — the attach
924 // path — and guessing a default would refuse a resume the service would honour.
925 return Ok(());
926 };
927 let elapsed = self.control.clock().elapsed().saturating_sub(since);
928 if elapsed <= window {
929 return Ok(());
930 }
931 Err(Error::new(
932 ErrorKind::WindowClosed,
933 format!(
934 "microvm {id} has been suspended {}s, past the {}s suspendedDurationSeconds \
935 window set at launch — the idlePolicy terminates a suspended VM once that window \
936 passes, so there is nothing left to resume (docs/PLATFORM.md, '`idlePolicy`'). \
937 Refused before ResumeMicrovm because suspendedDurationSeconds exists only in the \
938 RunMicrovm request: GetMicrovm does not return it, so this client is the only \
939 party that can name the number. A longer window has to be set at launch on the \
940 next VM; there is no call that extends this one.",
941 elapsed.as_secs(),
942 window.as_secs(),
943 ),
944 ))
945 }
946
947 // ── terminate (STATE-9, STATE-10) ────────────────────────────────────────
948
949 /// Tears down, best-effort, never erroring.
950 ///
951 /// Order: VM, then image, then the log group **last**. The log group is last because the
952 /// service can recreate a group deleted before its image — and see
953 /// [`TeardownReport::undeleted`] for why this crate names it rather than deleting it.
954 pub async fn terminate(&mut self, opts: TeardownOpts) -> TeardownReport {
955 let mut report = TeardownReport::default();
956 self.torn_down = true;
957
958 // The session first: it holds a cached proxy token whose only remaining use would be
959 // a request against a VM that is going away.
960 self.session = None;
961
962 // 1. The VM.
963 if let Some(id) = self.microvm.as_ref().map(|vm| vm.id.clone()) {
964 // STATE-9. Recorded before the call, so a terminate whose call fails still marks
965 // the VM as one this client asked to destroy — which is what stops a later
966 // resume (STATE-11) rather than leaving the sandbox looking resumable.
967 self.lifecycle = Lifecycle::Terminating;
968 self.was_terminated = true;
969
970 match self.control.terminate(&id).await {
971 Ok(()) => report.terminate_accepted = true,
972 Err(error) => {
973 report.failures.push(format!("terminate {id}: {error}"));
974 report.undeleted.push(id.clone());
975 }
976 }
977
978 if report.terminate_accepted
979 && let Some(timeout) = opts.wait_for_terminated
980 {
981 let wait = WaitOpts {
982 timeout,
983 poll_interval: LIFECYCLE_POLL_INTERVAL,
984 stall_grace: Duration::MAX,
985 };
986 match self
987 .control
988 .wait_for_state(&id, &["TERMINATED"], &[], wait)
989 .await
990 {
991 // STATE-10.
992 Ok(settled) => {
993 self.lifecycle = Lifecycle::Terminated;
994 self.microvm = Some(settled);
995 }
996 // Not a leak: the platform accepted the terminate, so the VM is on its
997 // way out and the lifecycle stays TERMINATING honestly.
998 Err(error) => report
999 .failures
1000 .push(format!("waiting for {id} to reach TERMINATED: {error}")),
1001 }
1002 }
1003 }
1004
1005 // 2. The image, retrying — an image in CREATING refuses deletion and a VM still
1006 // terminating holds a reference.
1007 if opts.delete_image {
1008 match self.image.as_ref().map(|image| image.identifier.clone()) {
1009 Some(identifier) => {
1010 let deleted = self
1011 .control
1012 .delete_image(&identifier, opts.delete_attempts, opts.delete_backoff)
1013 .await;
1014 report.image_deleted = Some(deleted);
1015 if deleted {
1016 self.image_exists = false;
1017 } else {
1018 report.failures.push(format!(
1019 "the image {identifier} survived {} delete attempts",
1020 opts.delete_attempts.max(1)
1021 ));
1022 report.undeleted.push(identifier);
1023 }
1024 }
1025 None => report.image_deleted = Some(false),
1026 }
1027 }
1028
1029 // 3. The log group, LAST. Named rather than deleted; see TeardownReport::undeleted.
1030 if opts.delete_log_group
1031 && let Some(group) = self.image.as_ref().map(Image::build_log_group)
1032 {
1033 report.failures.push(format!(
1034 "the build log group {group} was not deleted: CloudWatch Logs is not in this \
1035 crate's dependency set, so it is reported rather than removed. It is \
1036 service-created, which means no Terraform stack owns it and `terraform destroy` \
1037 leaves it behind — six accumulated before anyone noticed."
1038 ));
1039 report.undeleted.push(group);
1040 }
1041
1042 report.lifecycle = Some(self.lifecycle);
1043 report
1044 }
1045
1046 // ── internals ────────────────────────────────────────────────────────────
1047
1048 /// The VM id, or a precondition error naming what was attempted.
1049 fn require_microvm(&self, what: &str) -> Result<String, Error> {
1050 self.microvm
1051 .as_ref()
1052 .map(|vm| vm.id.clone())
1053 .ok_or_else(|| {
1054 Error::new(
1055 ErrorKind::Precondition,
1056 format!("nothing to {what}: this sandbox has not launched a VM."),
1057 )
1058 })
1059 }
1060
1061 /// Five minutes at five-second polls, for suspend, resume, and terminate.
1062 fn lifecycle_wait(&self) -> WaitOpts {
1063 WaitOpts {
1064 timeout: DEFAULT_LIFECYCLE_TIMEOUT,
1065 poll_interval: LIFECYCLE_POLL_INTERVAL,
1066 stall_grace: Duration::MAX,
1067 }
1068 }
1069}
1070
1071/// Warns about a live VM rather than tearing it down.
1072///
1073/// See the module docs: `Drop` cannot await, so a teardown here would either deadlock
1074/// inside a runtime or race the process exit. The warning names the id, because the only
1075/// useful thing a drop can do is tell whoever reads stderr what to go delete.
1076///
1077/// `eprintln!` rather than a log macro because this crate has no logging dependency, and
1078/// taking one on to warn about a leak would be a dependency for a diagnostic.
1079impl Drop for Sandbox {
1080 fn drop(&mut self) {
1081 if self.torn_down {
1082 return;
1083 }
1084 if let Some(vm) = self.microvm.as_ref()
1085 && self.lifecycle.is_live()
1086 {
1087 eprintln!(
1088 "warning: the Sandbox for microvm {} was dropped in {} without terminate(). \
1089 Nothing was torn down — Drop cannot await, so a teardown here would deadlock \
1090 inside a runtime. The VM bills until its maximumDurationInSeconds ceiling: \
1091 terminate it with `microvm terminate {}`.",
1092 vm.id, self.lifecycle, vm.id,
1093 );
1094 }
1095 }
1096}
1097
1098/// A fresh per-VM bearer token: 32 bytes of randomness as 64 hex characters.
1099///
1100/// # Why `/dev/urandom` and not a crate
1101///
1102/// The dependency set T-W2-2 froze carries no CSPRNG and this lane adds only source files,
1103/// so what remains is the kernel pool directly — which is what `getrandom` reaches on Linux
1104/// anyway, and the daemon this drives is Linux-only. `control::token` reads it the same way
1105/// for the same reason; the duplication is deliberate rather than shared, because unifying
1106/// it would mean editing another lane's module for four lines.
1107///
1108/// # Why the fallback is not silent
1109///
1110/// A failed read falls back to `RandomState` mixed with the nanosecond clock and a stack
1111/// address, which is weaker and still per-VM distinct. That is the property that matters
1112/// here — this token is delivered once, over a channel the platform does not forward until
1113/// the hook returns 200, to a daemon that installs it at most once — and the alternative,
1114/// failing a launch for want of 32 bytes, turns an unreadable `/dev/urandom` into an
1115/// unusable client.
1116fn mint_agent_token() -> String {
1117 use std::fmt::Write as _;
1118 use std::io::Read as _;
1119
1120 let mut bytes = [0u8; 32];
1121 let read = std::fs::File::open("/dev/urandom")
1122 .ok()
1123 .and_then(|mut file| file.read_exact(&mut bytes).ok())
1124 .is_some();
1125 if !read {
1126 use std::collections::hash_map::RandomState;
1127 use std::hash::{BuildHasher as _, Hasher as _};
1128 for chunk in bytes.chunks_mut(8) {
1129 let mut hasher = RandomState::new().build_hasher();
1130 hasher.write_usize(chunk.as_ptr() as usize);
1131 hasher.write_u128(
1132 std::time::SystemTime::now()
1133 .duration_since(std::time::UNIX_EPOCH)
1134 .map(|since| since.as_nanos())
1135 .unwrap_or_default(),
1136 );
1137 let mixed = hasher.finish().to_le_bytes();
1138 chunk.copy_from_slice(&mixed[..chunk.len()]);
1139 }
1140 }
1141
1142 let mut token = String::with_capacity(bytes.len() * 2);
1143 for byte in bytes {
1144 let _ = write!(token, "{byte:02x}");
1145 }
1146 token
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152 use crate::control::fake::{self as fake, Answer, FakeControlPlane, TestClock};
1153 use crate::control::transport::Method;
1154
1155 /// A sandbox over the contract recorder, plus the two handles a test asserts through.
1156 ///
1157 /// The recorder is T-W2-4's, deliberately: its answers are literal JSON in the service
1158 /// model's spelling rather than values serialized from this crate's types, so a member
1159 /// this lane misreads cannot be misread identically by the fake.
1160 fn planted() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1161 let recorder = Arc::new(FakeControlPlane::new());
1162 let clock = Arc::new(TestClock::new());
1163 let plane = ControlPlane::with_transport(
1164 Arc::clone(&recorder) as Arc<dyn crate::control::transport::Transport>,
1165 Region::UsEast1,
1166 Arc::clone(&clock) as Arc<dyn crate::control::Clock>,
1167 );
1168 (Sandbox::with_control_plane(plane), recorder, clock)
1169 }
1170
1171 /// Queues everything a launch to RUNNING needs.
1172 fn answer_launch(recorder: &FakeControlPlane) {
1173 recorder
1174 .answer(
1175 "RunMicrovm",
1176 Answer::ok(fake::microvm_response("PENDING", None)),
1177 )
1178 .answer(
1179 "GetMicrovm",
1180 Answer::ok(fake::microvm_response("RUNNING", None)),
1181 )
1182 .answer(
1183 "CreateMicrovmAuthToken",
1184 Answer::ok(fake::auth_token_response("proxy-token")),
1185 );
1186 }
1187
1188 /// A launched sandbox in RUNNING, which is where four of the twelve keys start.
1189 async fn launched() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1190 let (mut sandbox, recorder, clock) = planted();
1191 answer_launch(&recorder);
1192 sandbox
1193 .run(RunRequest::new().with_image("arn:image"))
1194 .await
1195 .expect("the launch reaches RUNNING");
1196 (sandbox, recorder, clock)
1197 }
1198
1199 /// Drives a launched sandbox to SUSPENDED, which is where resume starts.
1200 ///
1201 /// Both `GetMicrovm` answers are queued **before** the launch rather than one before
1202 /// each wait, and that is not a style choice. The recorder repeats its last queued
1203 /// answer, so a queue left at `[RUNNING]` after the launch and then appended to becomes
1204 /// `[RUNNING, SUSPENDED]` — the suspend wait pops the stale RUNNING, sleeps a poll
1205 /// interval, and the fake's `sleep` **advances the clock**. That five seconds is
1206 /// invisible until it lands in the middle of the STATE-12 window arithmetic, which is
1207 /// exactly where it first showed up. Queueing both up front means every wait matches on
1208 /// its first poll and no test clock moves except when a test moves it.
1209 async fn suspended_with_window(
1210 suspended_sec: u32,
1211 ) -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1212 let (mut sandbox, recorder, clock) = planted();
1213 recorder
1214 .answer(
1215 "RunMicrovm",
1216 Answer::ok(fake::microvm_response("PENDING", None)),
1217 )
1218 .answer(
1219 "GetMicrovm",
1220 Answer::ok(fake::microvm_response("RUNNING", None)),
1221 )
1222 .answer(
1223 "GetMicrovm",
1224 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1225 )
1226 .answer(
1227 "CreateMicrovmAuthToken",
1228 Answer::ok(fake::auth_token_response("proxy-token")),
1229 )
1230 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()));
1231
1232 sandbox
1233 .run(
1234 RunRequest::new()
1235 .with_image("arn:image")
1236 .with_suspended_sec(suspended_sec),
1237 )
1238 .await
1239 .expect("launches");
1240 sandbox.suspend().await.expect("suspends");
1241 assert_eq!(
1242 clock.now(),
1243 Duration::ZERO,
1244 "no wait may have slept, or the window arithmetic below is measuring the fake's \
1245 poll interval as well as the elapsed suspension"
1246 );
1247 (sandbox, recorder, clock)
1248 }
1249
1250 /// A suspended sandbox at the default ten-minute window.
1251 async fn suspended() -> (Sandbox, Arc<FakeControlPlane>, Arc<TestClock>) {
1252 suspended_with_window(600).await
1253 }
1254
1255 /// **STATE-1 and STATE-2.** A launch accepted is PENDING with the image recorded as
1256 /// existing; the platform reporting the run hook succeeded is what marks the token
1257 /// installed.
1258 ///
1259 /// The two halves are asserted with the *same* fake because they are the same call, and
1260 /// what distinguishes them is which fact is recorded when: `image_exists` is true from
1261 /// the accepted launch, `token_installed` only from the RUNNING report. A client that
1262 /// set both at the launch call would pass an end-state assertion and be wrong about a
1263 /// VM that died during startup — which is the next test.
1264 #[tokio::test]
1265 async fn a_launch_records_the_image_and_the_running_report_installs_the_token() {
1266 let (mut sandbox, recorder, _) = planted();
1267 assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1268 assert!(
1269 !sandbox.image_exists(),
1270 "nothing is recorded before a launch"
1271 );
1272 assert!(!sandbox.token_installed());
1273
1274 answer_launch(&recorder);
1275 let session = sandbox
1276 .run(RunRequest::new().with_image("arn:image"))
1277 .await
1278 .expect("launches");
1279 assert_eq!(
1280 session.endpoint(),
1281 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1282 );
1283
1284 assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1285 assert!(sandbox.image_exists(), "STATE-1: the image is recorded");
1286 assert!(sandbox.token_installed(), "STATE-2: the token is installed");
1287 assert_eq!(sandbox.bootstrap_count(), 1);
1288 assert_eq!(sandbox.microvm().expect("launched").id, "mvm-abc123");
1289
1290 // The payload really carried the token, read off the wire member rather than from
1291 // the request type — a field on a struct proves nothing about what was emitted.
1292 let body = recorder.first_body("RunMicrovm");
1293 let payload = body["runHookPayload"].as_str().expect("a string");
1294 assert!(payload.starts_with(r#"{"agent_token":"#), "{payload}");
1295 assert_eq!(body["idlePolicy"]["suspendedDurationSeconds"], 600);
1296 }
1297
1298 /// A launch env reaches the wire member the daemon parses, and it is the only thing
1299 /// that changes about the request.
1300 ///
1301 /// Read off `runHookPayload` rather than off the request struct: a field on a struct
1302 /// proves nothing about what was emitted, which is the same argument the test above
1303 /// makes about the token.
1304 #[tokio::test]
1305 async fn a_launch_env_reaches_the_run_hook_payload_on_the_wire() {
1306 let (mut sandbox, recorder, _) = planted();
1307 answer_launch(&recorder);
1308 sandbox
1309 .run(
1310 RunRequest::new()
1311 .with_image("arn:image")
1312 .with_launch_env("ANTHROPIC_BASE_URL", "https://gateway.example")
1313 .with_launch_env("PATH", "/usr/local/bin:/usr/bin:/bin"),
1314 )
1315 .await
1316 .expect("launches");
1317
1318 let body = recorder.first_body("RunMicrovm");
1319 let payload = body["runHookPayload"].as_str().expect("a string");
1320 // One parse deeper, because that is where the daemon reads it from too.
1321 let inner: serde_json::Value =
1322 serde_json::from_str(payload).expect("the payload is itself JSON");
1323 assert!(
1324 inner["agent_token"].as_str().is_some_and(|t| !t.is_empty()),
1325 "the token still rides alongside: {payload}"
1326 );
1327 assert_eq!(
1328 inner["env"]["ANTHROPIC_BASE_URL"],
1329 "https://gateway.example"
1330 );
1331 assert_eq!(inner["env"]["PATH"], "/usr/local/bin:/usr/bin:/bin");
1332 }
1333
1334 /// **A pinned `imageVersion` reaches the wire through the sandbox**, and an unpinned
1335 /// launch omits the member entirely.
1336 ///
1337 /// This is the layer a caller actually uses, so the forwarding is what needs asserting:
1338 /// [`RunRequest::image_version`] is a field on a struct and proves nothing about the
1339 /// emitted body until it is read off the wire member.
1340 ///
1341 /// **Falsification** — run 2026-08-16. Drop `wire.image_version = ...` from `run` and the
1342 /// pinned assertion goes red with the member absent, while every other launch test still
1343 /// passes — which is why this one is here.
1344 #[tokio::test]
1345 async fn a_pinned_image_version_is_forwarded_to_the_launch_request() {
1346 let (mut sandbox, recorder, _) = planted();
1347 answer_launch(&recorder);
1348 sandbox
1349 .run(
1350 RunRequest::new()
1351 .with_image("arn:image")
1352 .with_image_version("2.0"),
1353 )
1354 .await
1355 .expect("launches");
1356 assert_eq!(
1357 recorder.first_body("RunMicrovm")["imageVersion"],
1358 "2.0",
1359 "a canary has to launch against the version it means to test"
1360 );
1361
1362 let (mut sandbox, recorder, _) = planted();
1363 answer_launch(&recorder);
1364 sandbox
1365 .run(RunRequest::new().with_image("arn:image"))
1366 .await
1367 .expect("launches");
1368 assert!(
1369 recorder
1370 .first_body("RunMicrovm")
1371 .get("imageVersion")
1372 .is_none(),
1373 "an unpinned launch sends what this client always sent"
1374 );
1375 }
1376
1377 /// **The local refusal, with zero control-plane calls.**
1378 ///
1379 /// The observable difference between this client and one that lets AWS answer, and the
1380 /// same shape as STATE-5's suspend refusal: an over-budget launch env fails before the
1381 /// launch rather than as a `ValidationException` on a member the caller did not know
1382 /// they were filling. botocore does not enforce the ceiling client-side, so without
1383 /// this there is no local signal at all.
1384 ///
1385 /// **Falsification** — build the payload after `run_microvm` instead of before, and the
1386 /// call count assertion goes red.
1387 #[tokio::test]
1388 async fn an_over_budget_launch_env_is_refused_before_any_control_plane_call() {
1389 let (mut sandbox, recorder, _) = planted();
1390 answer_launch(&recorder);
1391
1392 let error = sandbox
1393 .run(
1394 RunRequest::new()
1395 .with_image("arn:image")
1396 .with_launch_env("AWS_SESSION_TOKEN", "t".repeat(4096)),
1397 )
1398 .await
1399 .expect_err("a credential-scale launch env does not fit the payload");
1400
1401 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1402 let message = error.to_string();
1403 assert!(message.contains("ceiling of 4096"), "{message}");
1404 assert!(message.contains("launch env contributed"), "{message}");
1405 assert_eq!(
1406 recorder.calls().len(),
1407 0,
1408 "the refusal has to be local: a launch that reached AWS costs a VM and a bill"
1409 );
1410 // And the sandbox is left usable rather than half-launched, so a caller can trim
1411 // the env and retry through the same handle.
1412 assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1413 assert_eq!(sandbox.bootstrap_count(), 0);
1414 assert!(sandbox.microvm().is_none());
1415 }
1416
1417 /// **Issue #24's guards reach through `Sandbox`, which is the layer both bindings use.**
1418 ///
1419 /// `microvms-py`'s `run` and `microvms-js`'s `run` both build a [`RunRequest`] and hand it to
1420 /// [`Sandbox::run`], which fills a `RunMicrovmRequest` and calls
1421 /// [`crate::control::ControlPlane::run_microvm`]. So the guards there cover the bindings — but
1422 /// "cover" is a claim about a call chain, and a chain is what a refactor breaks. This asserts
1423 /// it at the layer the bindings actually enter, so a `Sandbox::run` that stopped delegating
1424 /// (or a binding-side default that bypassed the request type) fails a test rather than shipping
1425 /// an unguarded surface to Python and Node.
1426 ///
1427 /// `max_idle_sec` is the case that matters most here, because it is a plain `u32` keyword
1428 /// argument on both bindings with no type narrowing it: `sandbox.run(max_idle_sec=59)` is what
1429 /// a Python caller writes, and before this it reached the wire.
1430 ///
1431 /// **Guard proof.** Delete `require_idle_duration` from `run_microvm` and the idle row goes red
1432 /// on the call count; delete `require_valid_role_arn` and the role row does. Neither guard is in
1433 /// this file, which is the point — the test is about the chain.
1434 #[tokio::test]
1435 async fn the_bindings_layer_refuses_an_illegal_launch_with_zero_calls() {
1436 let cases: [(&str, RunRequest, &str); 3] = [
1437 (
1438 "max_idle_sec=59, which is what a Python keyword argument passes",
1439 RunRequest {
1440 max_idle_sec: 59,
1441 ..RunRequest::new().with_image("arn:image")
1442 },
1443 "maxIdleDurationSeconds",
1444 ),
1445 (
1446 "an execution role that is a bare name",
1447 RunRequest {
1448 execution_role_arn: Some("execution-role".to_string()),
1449 ..RunRequest::new().with_image("arn:image")
1450 },
1451 "role *name*",
1452 ),
1453 (
1454 "a pinned version with a trailing newline",
1455 RunRequest::new()
1456 .with_image("arn:image")
1457 .with_image_version("2.0\n"),
1458 "contains whitespace",
1459 ),
1460 ];
1461
1462 for (label, request, expected) in cases {
1463 let (mut sandbox, recorder, _) = planted();
1464 // The launch answered, so a missing guard is a *successful* launch — a real VM and a
1465 // real bill — rather than a different failure.
1466 answer_launch(&recorder);
1467
1468 // `expect_err` needs the Ok type to be `Debug`, and `run` answers `&mut Session`
1469 // (which is not) — so the discriminant is matched instead. That is also the more
1470 // honest assertion: it says the launch produced no session at all.
1471 let Err(error) = sandbox.run(request).await else {
1472 panic!("{label} must be refused");
1473 };
1474 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1475 assert!(
1476 error.to_string().contains(expected),
1477 "{label}: wanted {expected:?}, got {error}"
1478 );
1479 assert_eq!(
1480 recorder.calls().len(),
1481 0,
1482 "{label}: the refusal has to be local, because a launch that reached AWS costs a \
1483 VM and a bill"
1484 );
1485 assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1486 assert!(sandbox.microvm().is_none());
1487 }
1488 }
1489
1490 /// A launch whose VM dies during startup leaves the token **not** installed, because
1491 /// the run hook is what delivers it and a dead VM ran no hook.
1492 ///
1493 /// The distinction this pins is the one the test above cannot: both a correct client and
1494 /// one that installs the token at the launch call reach RUNNING with the token set, and
1495 /// only this case separates them.
1496 #[tokio::test]
1497 async fn a_launch_that_dies_during_startup_installs_no_token() {
1498 let (mut sandbox, recorder, _) = planted();
1499 recorder
1500 .answer(
1501 "RunMicrovm",
1502 Answer::ok(fake::microvm_response("PENDING", None)),
1503 )
1504 .answer(
1505 "GetMicrovm",
1506 Answer::ok(fake::microvm_response(
1507 "TERMINATED",
1508 Some("run hook returned 500"),
1509 )),
1510 );
1511
1512 let error = sandbox
1513 .run(RunRequest::new().with_image("arn:image"))
1514 .await
1515 .expect_err("a VM that died during startup is not a launch");
1516 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1517 assert!(
1518 error.to_string().contains("run hook returned 500"),
1519 "{error}"
1520 );
1521
1522 assert!(
1523 !sandbox.token_installed(),
1524 "the run hook never answered, so nothing was installed"
1525 );
1526 assert_eq!(
1527 sandbox.bootstrap_count(),
1528 0,
1529 "a bootstrap counted here would make STATE-3's ceiling unreachable for a retry"
1530 );
1531 assert_eq!(sandbox.lifecycle(), Lifecycle::Pending);
1532 assert!(
1533 sandbox.image_exists(),
1534 "STATE-1 still holds: the launch was accepted against a real image"
1535 );
1536 }
1537
1538 /// **STATE-3, the guard proof.** A second `run` on one sandbox is refused, and it is
1539 /// refused with **zero** further control-plane calls.
1540 ///
1541 /// The call count is the assertion that matters. A client that let the second launch
1542 /// through would create a second VM and silently address two guests through one handle,
1543 /// and it would also deliver a second run-hook payload to a daemon whose one-shot
1544 /// bootstrap answers 409 — so "an error came back" is not enough to tell the two apart.
1545 ///
1546 /// **Falsification** — delete the `bootstrap_count > 0` branch from `run` and this test
1547 /// is red on both the count and the call total. Verified; see the packet's guard proofs.
1548 #[tokio::test]
1549 async fn a_second_run_on_one_sandbox_is_refused_before_any_call() {
1550 let (mut sandbox, recorder, _) = launched().await;
1551 assert_eq!(sandbox.bootstrap_count(), 1);
1552 let before = recorder.calls().len();
1553
1554 let error = sandbox
1555 .run(RunRequest::new().with_image("arn:image"))
1556 .await
1557 .expect_err("the token is installed at most once per VM lifetime");
1558 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1559 assert!(error.to_string().contains("STATE-3"), "{error}");
1560 assert!(
1561 error.to_string().contains("needs a second Sandbox"),
1562 "the remedy has to be nameable: {error}"
1563 );
1564
1565 assert_eq!(
1566 sandbox.bootstrap_count(),
1567 1,
1568 "bootstrap_count <= 1 is the Z3-proved invariant"
1569 );
1570 assert_eq!(
1571 recorder.calls().len(),
1572 before,
1573 "the refusal must cost no control-plane call"
1574 );
1575 assert_eq!(recorder.call_count("RunMicrovm"), 1);
1576 }
1577
1578 /// **STATE-4 and STATE-6.** A suspend from RUNNING moves to SUSPENDING before the wait,
1579 /// and the platform reporting SUSPENDED is what moves it to SUSPENDED.
1580 #[tokio::test]
1581 async fn a_suspend_from_running_passes_through_suspending_to_suspended() {
1582 let (sandbox, recorder, _) = suspended().await;
1583 assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended);
1584 assert!(
1585 sandbox.token_installed(),
1586 "a freeze keeps the guest's memory, so the token survives"
1587 );
1588 assert_eq!(sandbox.bootstrap_count(), 1, "no re-bootstrap on a freeze");
1589
1590 let calls = recorder.calls();
1591 let suspend = calls
1592 .iter()
1593 .find(|call| call.operation == "SuspendMicrovm")
1594 .expect("the suspend went out");
1595 assert_eq!(suspend.method, Method::Post);
1596 assert_eq!(suspend.path, "/2025-09-09/microvms/mvm-abc123/suspend");
1597 }
1598
1599 /// A suspend whose VM dies while suspending reports TERMINATED rather than raising, and
1600 /// records it — which is what stops a later resume.
1601 ///
1602 /// TERMINATED is *wanted* by the suspend wait for a reason: a VM that dies mid-suspend
1603 /// is a state to report, not an exception out of the middle of a teardown.
1604 #[tokio::test]
1605 async fn a_vm_that_dies_while_suspending_is_recorded_rather_than_raised() {
1606 let (mut sandbox, recorder, _) = launched().await;
1607 recorder
1608 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1609 .answer(
1610 "GetMicrovm",
1611 Answer::ok(fake::microvm_response("TERMINATED", Some("idle policy"))),
1612 );
1613
1614 sandbox
1615 .suspend()
1616 .await
1617 .expect("a death mid-suspend is a state, not an error");
1618 assert_eq!(sandbox.lifecycle(), Lifecycle::Terminated);
1619 assert!(
1620 sandbox.was_terminated(),
1621 "STATE-11's precondition is recorded"
1622 );
1623 }
1624
1625 /// A suspend whose wire call fails leaves the sandbox in RUNNING, and a retry works.
1626 ///
1627 /// STATE-4's "accepted" is the wire call succeeding. A client that moved to SUSPENDING
1628 /// before the call would strand a failed call there — a state neither suspend nor
1629 /// resume accepts, so one throttled or dropped request would brick the handle.
1630 /// **Falsification** — move `self.lifecycle = Lifecycle::Suspending` back above the
1631 /// `control.suspend` call and the first assertion here reads SUSPENDING, and the retry
1632 /// is refused by the STATE-5 guard.
1633 #[tokio::test]
1634 async fn a_suspend_whose_call_fails_stays_running_and_can_be_retried() {
1635 let (mut sandbox, recorder, _) = planted();
1636 recorder
1637 .answer(
1638 "RunMicrovm",
1639 Answer::ok(fake::microvm_response("PENDING", None)),
1640 )
1641 .answer(
1642 "GetMicrovm",
1643 Answer::ok(fake::microvm_response("RUNNING", None)),
1644 )
1645 .answer(
1646 "GetMicrovm",
1647 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1648 )
1649 .answer(
1650 "CreateMicrovmAuthToken",
1651 Answer::ok(fake::auth_token_response("proxy-token")),
1652 )
1653 // A 409 rather than a 429 or a transport cut, because those two are retried
1654 // inside `send_with_retry` and this test wants the call to fail once, fast.
1655 .answer("SuspendMicrovm", Answer::failure(409, "ConflictException"))
1656 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()));
1657
1658 sandbox
1659 .run(RunRequest::new().with_image("arn:image"))
1660 .await
1661 .expect("launches");
1662
1663 sandbox
1664 .suspend()
1665 .await
1666 .expect_err("the control plane refused the suspend");
1667 assert_eq!(
1668 sandbox.lifecycle(),
1669 Lifecycle::Running,
1670 "a refused suspend must not move the lifecycle: SUSPENDING accepts neither a \
1671 suspend nor a resume, so recording it here would brick the handle"
1672 );
1673
1674 sandbox.suspend().await.expect("the retry suspends");
1675 assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended);
1676 assert_eq!(recorder.call_count("SuspendMicrovm"), 2);
1677 }
1678
1679 /// **STATE-5, the guard proof.** A suspend from anything but RUNNING is refused with
1680 /// **zero** control-plane calls.
1681 ///
1682 /// Every non-RUNNING state, so a state added to `Lifecycle` later is not silently
1683 /// exempt. The count is the load-bearing assertion: letting the call go and reading the
1684 /// service's answer also produces an error, and that error is about a non-running id
1685 /// rather than about which of two things the caller got wrong.
1686 ///
1687 /// **Falsification** — delete the `lifecycle != Running` branch from `suspend` and the
1688 /// SUSPENDED case emits a `SuspendMicrovm` call, turning the count assertion red.
1689 /// Verified; see the packet's guard proofs.
1690 #[tokio::test]
1691 async fn a_suspend_from_a_non_running_state_reaches_no_control_plane_call() {
1692 // From SUSPENDED — the caller who believes they resumed.
1693 let (mut sandbox, recorder, _) = suspended().await;
1694 let before = recorder.call_count("SuspendMicrovm");
1695
1696 let error = sandbox
1697 .suspend()
1698 .await
1699 .expect_err("a suspend is only issued from RUNNING");
1700 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1701 assert!(error.to_string().contains("STATE-5"), "{error}");
1702 assert!(error.to_string().contains("SUSPENDED"), "{error}");
1703 assert_eq!(
1704 recorder.call_count("SuspendMicrovm"),
1705 before,
1706 "the refusal must be local: no second suspend went to the wire"
1707 );
1708
1709 // And from PENDING, before anything launched at all.
1710 let (mut fresh, fresh_recorder, _) = planted();
1711 let error = fresh.suspend().await.expect_err("nothing to suspend");
1712 assert_eq!(error.kind(), ErrorKind::Precondition);
1713 assert_eq!(fresh_recorder.calls().len(), 0);
1714 }
1715
1716 /// **STATE-7 and STATE-8.** A resume from SUSPENDED returns to RUNNING, re-delivers
1717 /// **nothing**, and drops the cached proxy token.
1718 ///
1719 /// Three assertions, and each one is a different failure mode. The bootstrap count says
1720 /// no run-hook payload was re-delivered — which a daemon's one-shot bootstrap would
1721 /// answer 409 to, reading like a broken VM. The `RunMicrovm` count says no second launch
1722 /// happened. And `is_cached` says the token was invalidated, which is the only
1723 /// observable difference on a path where the endpoint URL does not change: a client that
1724 /// kept the token would produce identical requests until the pre-suspend token stopped
1725 /// validating, and that rejection reads exactly like a dead daemon.
1726 #[tokio::test]
1727 async fn a_resume_reuses_the_token_redelivers_nothing_and_drops_the_proxy_token() {
1728 let (mut sandbox, recorder, _) = suspended().await;
1729
1730 // The session mints once before the suspend, so there is a cached token to drop.
1731 let auth = Arc::clone(
1732 sandbox
1733 .session()
1734 .expect("launched")
1735 .proxy_auth()
1736 .expect("the launch wired a minter"),
1737 );
1738 auth.headers().await.expect("mints");
1739 assert!(
1740 auth.is_cached(),
1741 "there must be a token for the drop to matter"
1742 );
1743 assert_eq!(auth.mint_count(), 1);
1744
1745 recorder
1746 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1747 .answer(
1748 "GetMicrovm",
1749 Answer::ok(fake::microvm_response("RUNNING", None)),
1750 );
1751 let session = sandbox.resume().await.expect("resumes");
1752 assert_eq!(
1753 session.endpoint(),
1754 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1755 );
1756
1757 assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1758 assert!(
1759 sandbox.token_installed(),
1760 "STATE-7: the installed token is reused"
1761 );
1762 assert_eq!(
1763 sandbox.bootstrap_count(),
1764 1,
1765 "STATE-7: a resume re-delivers no run-hook payload"
1766 );
1767 assert_eq!(
1768 recorder.call_count("RunMicrovm"),
1769 1,
1770 "a resume is not a second launch"
1771 );
1772 assert!(
1773 !auth.is_cached(),
1774 "STATE-8: the cached proxy token survived the resume"
1775 );
1776 }
1777
1778 /// **STATE-8's guard proof, on the mint count.** The request after a resume mints a
1779 /// fresh token rather than reusing the pre-suspend one.
1780 ///
1781 /// Separate from the test above because `is_cached` is a fact about the cache and this is
1782 /// a fact about the wire: a client that dropped the cache but kept emitting the old
1783 /// header would pass the first assertion and fail every request.
1784 ///
1785 /// **Falsification** — delete the `proxy.invalidate()` line from `Session::rebind` and
1786 /// the mint count stays at 1 here. Verified; see the packet's guard proofs.
1787 #[tokio::test]
1788 async fn the_request_after_a_resume_mints_a_fresh_proxy_token() {
1789 let (mut sandbox, recorder, _) = suspended().await;
1790 let auth = Arc::clone(
1791 sandbox
1792 .session()
1793 .expect("launched")
1794 .proxy_auth()
1795 .expect("wired"),
1796 );
1797 auth.headers().await.expect("mints");
1798 assert_eq!(auth.mint_count(), 1);
1799
1800 recorder
1801 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1802 .answer(
1803 "GetMicrovm",
1804 Answer::ok(fake::microvm_response("RUNNING", None)),
1805 );
1806 sandbox.resume().await.expect("resumes");
1807
1808 // No clock movement at all, so a re-mint here is caused by the invalidation rather
1809 // than by the refresh window rolling over.
1810 auth.headers().await.expect("re-mints");
1811 assert_eq!(
1812 auth.mint_count(),
1813 2,
1814 "STATE-8: the resume did not force a fresh mint"
1815 );
1816 }
1817
1818 /// **STATE-12, the guard proof.** A resume past the launch-time suspended window is
1819 /// refused locally, with **zero** resume calls, and the message names the window.
1820 ///
1821 /// Four assertions, and the packet asks for all four because each rules out a different
1822 /// wrong implementation. `ErrorKind::WindowClosed` rather than `Timeout` separates this
1823 /// from the client that waits. Zero `ResumeMicrovm` calls is what "before any wire call"
1824 /// means. The elapsed reading rules out an implementation that polled to the deadline
1825 /// first — the fake's clock advances on every `sleep`, so a poll loop here would show up
1826 /// as time passed. And the message naming both numbers plus `idlePolicy` is what sends a
1827 /// reader to the finding rather than looking for the flag that reopens the window.
1828 ///
1829 /// **Falsification** — delete the `require_open_suspended_window` call from `resume` and
1830 /// the fake answers TERMINATED, so this test fails with an `ErrorKind::LaunchDied` after
1831 /// a `ResumeMicrovm` call, naming neither the window nor the seconds elapsed. Verified;
1832 /// see the packet's guard proofs.
1833 #[tokio::test]
1834 async fn a_resume_past_the_suspended_window_is_refused_before_the_wire() {
1835 // A short window, so the arithmetic is legible: sixty seconds asked for at launch.
1836 let (mut sandbox, recorder, clock) = suspended_with_window(60).await;
1837 assert_eq!(sandbox.suspended_window(), Some(Duration::from_secs(60)));
1838
1839 // The window closes. This is the whole of what the clock injection buys.
1840 clock.advance(Duration::from_secs(61));
1841
1842 // What a service says about a VM the idlePolicy already terminated — which is what a
1843 // client without the local check would spend its poll timeout discovering.
1844 recorder
1845 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1846 .answer(
1847 "GetMicrovm",
1848 Answer::ok(fake::microvm_response(
1849 "TERMINATED",
1850 Some("suspended window elapsed"),
1851 )),
1852 );
1853 let elapsed_before = clock.now();
1854 let resumes_before = recorder.call_count("ResumeMicrovm");
1855
1856 let error = sandbox
1857 .resume()
1858 .await
1859 .expect_err("the window the launch set has closed");
1860
1861 assert_eq!(error.kind(), ErrorKind::WindowClosed);
1862 assert_eq!(error.code(), "ERR_WINDOW_CLOSED");
1863 let message = error.to_string();
1864 assert!(message.contains("61s"), "the elapsed time: {message}");
1865 assert!(
1866 message.contains("60s suspendedDurationSeconds"),
1867 "the window has to be named: {message}"
1868 );
1869 assert!(message.contains("idlePolicy"), "the finding: {message}");
1870 assert!(
1871 message.contains("GetMicrovm does not return it"),
1872 "why the client is the only party that can say this: {message}"
1873 );
1874 assert_eq!(
1875 recorder.call_count("ResumeMicrovm"),
1876 resumes_before,
1877 "the refusal must come before ResumeMicrovm"
1878 );
1879 assert_eq!(
1880 clock.now(),
1881 elapsed_before,
1882 "the refusal must be immediate rather than polled to the deadline"
1883 );
1884 }
1885
1886 /// A resume **inside** the window goes through, so the guard is a comparison rather than
1887 /// a blanket refusal of every resume.
1888 ///
1889 /// The boundary is inclusive: elapsed exactly equal to the window is still open, matching
1890 /// the Python's `elapsed <= window`.
1891 #[tokio::test]
1892 async fn a_resume_inside_the_window_is_allowed_and_the_boundary_is_inclusive() {
1893 let (mut sandbox, recorder, clock) = suspended_with_window(60).await;
1894
1895 // Exactly at the window.
1896 clock.advance(Duration::from_secs(60));
1897 recorder
1898 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1899 .answer(
1900 "GetMicrovm",
1901 Answer::ok(fake::microvm_response("RUNNING", None)),
1902 );
1903 sandbox
1904 .resume()
1905 .await
1906 .expect("60s == the 60s window is open");
1907 assert_eq!(sandbox.lifecycle(), Lifecycle::Running);
1908 assert_eq!(recorder.call_count("ResumeMicrovm"), 1);
1909 }
1910
1911 /// A successful resume clears the stamp, so the next cycle's window is measured from the
1912 /// next suspend rather than from the first one.
1913 ///
1914 /// The failure this rules out is subtle and would only show on a second cycle:
1915 /// accumulating every suspension's elapsed time into one total refuses a resume whose own
1916 /// window is wide open. The clock is advanced past the window *between* the two cycles,
1917 /// which is what makes the accumulating implementation fail here.
1918 #[tokio::test]
1919 async fn a_successful_resume_clears_the_stamp_so_a_second_cycle_measures_its_own_window() {
1920 let (mut sandbox, recorder, clock) = planted();
1921 // Every `GetMicrovm` answer for both cycles, in the order the waits consume them:
1922 // the launch's RUNNING, then SUSPENDED / RUNNING twice. Queued up front for the
1923 // reason `suspended_with_window` documents — a stale answer left at the head makes a
1924 // wait poll twice, and the fake's `sleep` moves the very clock this test is measuring.
1925 recorder
1926 .answer(
1927 "RunMicrovm",
1928 Answer::ok(fake::microvm_response("PENDING", None)),
1929 )
1930 .answer(
1931 "CreateMicrovmAuthToken",
1932 Answer::ok(fake::auth_token_response("proxy-token")),
1933 )
1934 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1935 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1936 .answer(
1937 "GetMicrovm",
1938 Answer::ok(fake::microvm_response("RUNNING", None)),
1939 )
1940 .answer(
1941 "GetMicrovm",
1942 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1943 )
1944 .answer(
1945 "GetMicrovm",
1946 Answer::ok(fake::microvm_response("RUNNING", None)),
1947 )
1948 .answer(
1949 "GetMicrovm",
1950 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1951 )
1952 .answer(
1953 "GetMicrovm",
1954 Answer::ok(fake::microvm_response("RUNNING", None)),
1955 );
1956 sandbox
1957 .run(
1958 RunRequest::new()
1959 .with_image("arn:image")
1960 .with_suspended_sec(60),
1961 )
1962 .await
1963 .expect("launches");
1964
1965 for cycle in 0..2 {
1966 sandbox
1967 .suspend()
1968 .await
1969 .unwrap_or_else(|error| panic!("cycle {cycle} suspend: {error}"));
1970 assert_eq!(sandbox.lifecycle(), Lifecycle::Suspended, "cycle {cycle}");
1971
1972 // Forty-five seconds each time — inside the sixty-second window on its own, and
1973 // ninety in total. An implementation that never cleared the stamp measures the
1974 // total and refuses the second resume, which is the whole point of the loop.
1975 clock.advance(Duration::from_secs(45));
1976
1977 sandbox.resume().await.unwrap_or_else(|error| {
1978 panic!("cycle {cycle} resume, whose own window is wide open: {error}")
1979 });
1980 assert_eq!(sandbox.lifecycle(), Lifecycle::Running, "cycle {cycle}");
1981 assert_eq!(
1982 sandbox.bootstrap_count(),
1983 1,
1984 "cycle {cycle}: no re-bootstrap"
1985 );
1986 }
1987
1988 assert_eq!(
1989 clock.now(),
1990 Duration::from_secs(90),
1991 "ninety seconds of accumulated suspension against a sixty-second window: this is \
1992 the total an implementation that never cleared the stamp would have compared"
1993 );
1994 assert_eq!(
1995 recorder.call_count("ResumeMicrovm"),
1996 2,
1997 "both cycles resumed"
1998 );
1999 }
2000
2001 /// **STATE-11, the resume-after-terminate case the packet names.** A terminated VM never
2002 /// returns to RUNNING, and the recorder sees **zero** resume wire calls.
2003 ///
2004 /// The zero count is the assertion the packet asks for, and it is the right one: a client
2005 /// that called and read the answer would also fail, with whatever the service says about
2006 /// a terminated id — which is a different statement from "this VM is gone and even a
2007 /// successful call would hand you a different machine".
2008 #[tokio::test]
2009 async fn a_resume_after_terminate_records_zero_resume_calls() {
2010 let (mut sandbox, recorder, _) = launched().await;
2011 recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2012 let report = sandbox.terminate(TeardownOpts::default()).await;
2013 assert!(report.terminate_accepted);
2014 assert!(sandbox.was_terminated());
2015
2016 let error = sandbox
2017 .resume()
2018 .await
2019 .expect_err("a terminated VM never returns to RUNNING");
2020 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2021 assert!(error.to_string().contains("STATE-11"), "{error}");
2022 assert_eq!(
2023 recorder.call_count("ResumeMicrovm"),
2024 0,
2025 "no resume may reach the wire once the VM was terminated"
2026 );
2027 assert_ne!(
2028 sandbox.lifecycle(),
2029 Lifecycle::Running,
2030 "the lifecycle must not return to RUNNING"
2031 );
2032 }
2033
2034 /// **STATE-9 and STATE-10.** A terminate accepted is TERMINATING with the VM recorded
2035 /// terminated; the platform reporting termination complete is what marks TERMINATED.
2036 ///
2037 /// The two are separate states rather than one because the default teardown does not
2038 /// wait — so TERMINATING is a state a report really ends in, and calling it TERMINATED
2039 /// would claim an observation nobody made.
2040 #[tokio::test]
2041 async fn a_terminate_records_terminating_and_the_completion_report_marks_terminated() {
2042 let (mut sandbox, recorder, _) = launched().await;
2043 recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2044
2045 let report = sandbox.terminate(TeardownOpts::default()).await;
2046 assert!(report.terminate_accepted, "STATE-9: the call was accepted");
2047 assert!(sandbox.was_terminated(), "STATE-9: recorded terminated");
2048 assert_eq!(
2049 report.lifecycle,
2050 Some(Lifecycle::Terminating),
2051 "the default teardown does not wait, so TERMINATED is not claimed"
2052 );
2053 assert!(!report.leaked(), "{report:?}");
2054
2055 // And with the wait asked for, STATE-10 is observed.
2056 let (mut sandbox, recorder, _) = launched().await;
2057 recorder
2058 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2059 .answer(
2060 "GetMicrovm",
2061 Answer::ok(fake::microvm_response("TERMINATED", None)),
2062 );
2063 let report = sandbox
2064 .terminate(TeardownOpts::default().waiting_for_terminated())
2065 .await;
2066 assert_eq!(report.lifecycle, Some(Lifecycle::Terminated));
2067 assert_eq!(sandbox.lifecycle(), Lifecycle::Terminated);
2068 }
2069
2070 /// The teardown order is VM, then image, then the log group **last** — asserted on the
2071 /// recorder's ledger rather than on the report, because the order is a property of what
2072 /// was emitted.
2073 ///
2074 /// The log group is last because the service can recreate a group deleted before its
2075 /// image. This crate cannot delete it at all, so the assertion is that it is *named
2076 /// after* the image work rather than that a delete call went out — see
2077 /// `TeardownReport::undeleted`.
2078 #[tokio::test]
2079 async fn teardown_deletes_the_vm_then_the_image_and_names_the_log_group_last() {
2080 let (mut sandbox, recorder, _) = planted();
2081 recorder
2082 .answer(
2083 "CreateMicrovmImage",
2084 Answer::created(fake::create_image_response("agentd-conformance")),
2085 )
2086 .answer(
2087 "GetMicrovmImage",
2088 Answer::ok(fake::get_image_response("agentd-conformance", "CREATED")),
2089 );
2090 sandbox
2091 .build_image(CreateImageRequest::new(
2092 "agentd-conformance",
2093 b"binary".to_vec(),
2094 "s3://bucket/img.zip",
2095 "arn:aws:iam::123456789012:role/build",
2096 ))
2097 .await
2098 .expect("builds");
2099 answer_launch(&recorder);
2100 sandbox
2101 .run(RunRequest::new())
2102 .await
2103 .expect("launches from the built image");
2104
2105 recorder
2106 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2107 .answer(
2108 "ListMicrovmImageVersions",
2109 Answer::ok(fake::list_versions_response("1")),
2110 )
2111 .answer(
2112 "DeleteMicrovmImage",
2113 Answer::ok(fake::delete_image_response()),
2114 );
2115
2116 let report = sandbox
2117 .terminate(
2118 TeardownOpts::default()
2119 .deleting_image()
2120 .deleting_log_group(),
2121 )
2122 .await;
2123 assert!(report.terminate_accepted);
2124 assert_eq!(report.image_deleted, Some(true));
2125
2126 // The ledger, filtered to the three destructive operations.
2127 let order: Vec<&str> = recorder
2128 .operations()
2129 .into_iter()
2130 .filter(|operation| matches!(*operation, "TerminateMicrovm" | "DeleteMicrovmImage"))
2131 .collect();
2132 assert_eq!(
2133 order,
2134 ["TerminateMicrovm", "DeleteMicrovmImage"],
2135 "the VM goes before the image: a terminating VM holds a reference to it"
2136 );
2137
2138 // The log group is named, and named last.
2139 assert_eq!(
2140 report.undeleted,
2141 ["/aws/lambda-microvms/agentd-conformance"],
2142 "{report:?}"
2143 );
2144 assert!(report.leaked());
2145 assert!(
2146 report
2147 .failures
2148 .last()
2149 .expect("a failure")
2150 .contains("log group"),
2151 "the log group's failure has to be the last one recorded: {report:?}"
2152 );
2153 assert!(
2154 report
2155 .failures
2156 .last()
2157 .expect("a failure")
2158 .contains("terraform destroy"),
2159 "the reason it leaks has to be named: {report:?}"
2160 );
2161 }
2162
2163 /// An image delete that fails every attempt lands its identifier in the report rather
2164 /// than raising, so the CLI can emit what was left behind (CLI-6).
2165 ///
2166 /// The identifier rather than a flag is the whole point: a leak nobody can name is a leak
2167 /// nobody can clean up.
2168 #[tokio::test]
2169 async fn a_failing_image_delete_names_what_it_left_behind() {
2170 let (mut sandbox, recorder, _) = planted();
2171 recorder
2172 .answer(
2173 "CreateMicrovmImage",
2174 Answer::created(fake::create_image_response("img")),
2175 )
2176 .answer(
2177 "GetMicrovmImage",
2178 Answer::ok(fake::get_image_response("img", "CREATED")),
2179 );
2180 sandbox
2181 .build_image(CreateImageRequest::new(
2182 "img",
2183 b"binary".to_vec(),
2184 "s3://bucket/img.zip",
2185 "arn:aws:iam::123456789012:role/build",
2186 ))
2187 .await
2188 .expect("builds");
2189 answer_launch(&recorder);
2190 sandbox.run(RunRequest::new()).await.expect("launches");
2191
2192 recorder
2193 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2194 .answer(
2195 "ListMicrovmImageVersions",
2196 Answer::ok(fake::list_versions_response("1")),
2197 )
2198 .answer(
2199 "DeleteMicrovmImage",
2200 Answer::failure(409, "image is in CREATING"),
2201 );
2202
2203 let mut opts = TeardownOpts::default().deleting_image();
2204 opts.delete_attempts = 3;
2205 opts.delete_backoff = Duration::from_secs(1);
2206 let report = sandbox.terminate(opts).await;
2207
2208 assert_eq!(report.image_deleted, Some(false));
2209 assert_eq!(
2210 report.undeleted,
2211 ["arn:aws:lambda:us-east-1:123456789012:microvm-image:img"],
2212 "{report:?}"
2213 );
2214 assert!(
2215 sandbox.image_exists(),
2216 "the image really is still there, so the flag must say so"
2217 );
2218 assert_eq!(
2219 recorder.call_count("DeleteMicrovmImage"),
2220 3,
2221 "every attempt ran"
2222 );
2223 }
2224
2225 /// A terminate whose own call fails still records the VM as one this client asked to
2226 /// destroy, and names it as undeleted.
2227 ///
2228 /// Recording it is what stops a later resume from being offered against a VM the caller
2229 /// already tried to kill (STATE-11) — the alternative leaves the sandbox looking
2230 /// resumable, which is the worse of the two wrong answers.
2231 #[tokio::test]
2232 async fn a_terminate_whose_call_fails_still_records_the_intent_and_the_leak() {
2233 let (mut sandbox, recorder, _) = launched().await;
2234 recorder.answer(
2235 "TerminateMicrovm",
2236 Answer::failure(500, "InternalServerException"),
2237 );
2238
2239 let report = sandbox.terminate(TeardownOpts::default()).await;
2240 assert!(!report.terminate_accepted);
2241 assert_eq!(report.undeleted, ["mvm-abc123"], "{report:?}");
2242 assert!(sandbox.was_terminated());
2243 assert_eq!(report.lifecycle, Some(Lifecycle::Terminating));
2244 assert!(
2245 !report.failures.is_empty(),
2246 "the swallowed failure is recorded"
2247 );
2248 }
2249
2250 /// A teardown on a sandbox that never launched is a no-op report rather than a panic.
2251 ///
2252 /// The path a caller reaches by tearing down in a `finally` after a launch that failed
2253 /// before any VM existed, which is exactly when a teardown that assumed a VM would turn
2254 /// the real failure into a panic.
2255 #[tokio::test]
2256 async fn a_teardown_before_any_launch_is_an_empty_report() {
2257 let (mut sandbox, recorder, _) = planted();
2258 let report = sandbox.terminate(TeardownOpts::default()).await;
2259 assert!(!report.terminate_accepted);
2260 assert!(!report.leaked());
2261 assert_eq!(report.lifecycle, Some(Lifecycle::Pending));
2262 assert_eq!(recorder.calls().len(), 0);
2263 }
2264
2265 /// A run with no image at all is a precondition error before any call, rather than a
2266 /// launch of an empty identifier.
2267 #[tokio::test]
2268 async fn a_run_with_no_image_is_refused_before_any_call() {
2269 let (mut sandbox, recorder, _) = planted();
2270 let error = sandbox
2271 .run(RunRequest::new())
2272 .await
2273 .expect_err("there is nothing to launch");
2274 assert_eq!(error.kind(), ErrorKind::Precondition);
2275 assert!(error.to_string().contains("build_image first"), "{error}");
2276 assert_eq!(recorder.calls().len(), 0);
2277 }
2278
2279 /// The lifecycle's six states are the symspec's `vm_state` domain, spelled as the service
2280 /// spells them.
2281 ///
2282 /// Asserted rather than assumed because the spelling reaches an error message a reader
2283 /// compares against a `GetMicrovm` response: a lifecycle rendering `Suspended` beside a
2284 /// service saying `SUSPENDED` reads like two different facts.
2285 #[test]
2286 fn the_lifecycle_states_are_the_state_models_domain_in_the_services_spelling() {
2287 let all = [
2288 Lifecycle::Pending,
2289 Lifecycle::Running,
2290 Lifecycle::Suspending,
2291 Lifecycle::Suspended,
2292 Lifecycle::Terminating,
2293 Lifecycle::Terminated,
2294 ];
2295 assert_eq!(
2296 all.map(Lifecycle::as_str),
2297 [
2298 "PENDING",
2299 "RUNNING",
2300 "SUSPENDING",
2301 "SUSPENDED",
2302 "TERMINATING",
2303 "TERMINATED"
2304 ]
2305 );
2306 for state in all {
2307 assert_eq!(state.to_string(), state.as_str());
2308 }
2309 // The four live states are the ones a `Drop` warns about, and TERMINATING is not one
2310 // of them: the platform accepted the terminate, so nobody needs telling.
2311 assert_eq!(
2312 all.iter().filter(|state| state.is_live()).count(),
2313 4,
2314 "a state added to the enum defaults to not-live, which would silence the warning"
2315 );
2316 assert!(!Lifecycle::Terminating.is_live());
2317 assert!(!Lifecycle::Terminated.is_live());
2318 }
2319
2320 /// An agent token is 64 hex characters and never repeats.
2321 ///
2322 /// The distinctness matters because the token is the only thing separating one VM's
2323 /// control API from another's: two VMs sharing a token means either one's caller can
2324 /// drive the other's guest.
2325 #[test]
2326 fn a_minted_agent_token_is_sixty_four_hex_characters_and_never_repeats() {
2327 let minted: std::collections::HashSet<String> =
2328 (0..200).map(|_| mint_agent_token()).collect();
2329 assert_eq!(minted.len(), 200, "a repeated token is a shared guest");
2330 for token in &minted {
2331 assert_eq!(token.len(), 64, "{token}");
2332 assert!(token.chars().all(|c| c.is_ascii_hexdigit()), "{token}");
2333 assert_ne!(
2334 token,
2335 &"0".repeat(64),
2336 "an all-zero draw is a read that did nothing"
2337 );
2338 }
2339 }
2340
2341 /// A sandbox's `Debug` does not print the agent token.
2342 #[tokio::test]
2343 async fn a_sandbox_debug_does_not_print_the_agent_token() {
2344 let (mut sandbox, recorder, _) = planted();
2345 answer_launch(&recorder);
2346 sandbox
2347 .run(
2348 RunRequest::new()
2349 .with_image("arn:image")
2350 .with_agent_token("super-secret-agent-token"),
2351 )
2352 .await
2353 .expect("launches");
2354
2355 let rendered = format!("{sandbox:?}");
2356 assert!(rendered.contains("mvm-abc123"), "{rendered}");
2357 assert!(rendered.contains("Running"), "{rendered}");
2358 assert!(
2359 !rendered.contains("super-secret"),
2360 "the agent token reached a Debug string: {rendered}"
2361 );
2362 }
2363
2364 /// A torn-down sandbox does not warn on drop, and an abandoned one is the case the
2365 /// warning exists for.
2366 ///
2367 /// Asserted on the flag rather than on stderr — capturing another thread's stderr from a
2368 /// test is not something this crate can do without a dependency — so what is pinned is
2369 /// the condition the warning branches on. Both halves, so the branch cannot be
2370 /// vacuously true.
2371 #[tokio::test]
2372 async fn a_torn_down_sandbox_is_distinguishable_from_an_abandoned_one() {
2373 let (mut sandbox, recorder, _) = launched().await;
2374 assert!(
2375 sandbox.lifecycle().is_live() && !sandbox.torn_down,
2376 "an abandoned live VM is what the drop warning is for"
2377 );
2378
2379 recorder.answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
2380 sandbox.terminate(TeardownOpts::default()).await;
2381 assert!(
2382 sandbox.torn_down,
2383 "terminate must mark the sandbox torn down"
2384 );
2385 assert!(
2386 !sandbox.lifecycle().is_live(),
2387 "and the lifecycle must no longer read as live"
2388 );
2389 }
2390}