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