Skip to main content

microvms_core/
sandbox.rs

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