Skip to main content

alien_bindings/providers/sandbox/
aws.rs

1//! AWS sandbox provider: Lambda MicroVMs, reached over the agent protocol.
2//!
3//! AWS gives a transport and nothing on the other end — a MicroVM is reachable only through its
4//! HTTPS endpoint, and the agent Alien ships in the image is what answers there.
5//!
6//! Authorization is the endpoint token, not an Alien capability. `CreateMicrovmAuthToken` is
7//! minted with the workload's own IAM identity and scoped to one MicroVM, an explicit port set
8//! and an expiry — a request to a port outside it is refused at the proxy. One MicroVM is one
9//! sandbox, so that scope is exactly the one a capability would express.
10
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use futures::stream::BoxStream;
16
17use crate::error::{ErrorData, Result};
18use crate::providers::sandbox::agent_protocol::{self, AgentTransport, AGENT_PORT};
19use crate::providers::sandbox::refusal::Unreachable;
20use crate::traits::{
21    Binding, CommandOutput, CreateSandboxRequest, JobPoll, JobStart, PreviewCapability,
22    ResolvedSandbox, RunCommandRequest, Sandbox, SandboxInstance, SandboxState,
23};
24use alien_aws_clients::aws::lambda_microvms::{LambdaMicrovmsApi, Microvm, MAX_AUTH_TOKEN_MINUTES};
25use alien_core::{Platform, SandboxCapabilities};
26use alien_error::{AlienError, ContextError};
27use tracing::warn;
28
29/// Header the proxy reads to decide which port inside the MicroVM a request reaches.
30const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
31
32/// How long `create` waits for a MicroVM to become servable.
33///
34/// AWS answers 502 at the proxy "during the first few seconds after the MicroVM is run while the
35/// snapshot is being restored", so the window is short; this is generous enough that a slow
36/// restore reads as slow rather than broken.
37#[cfg(not(test))]
38const SANDBOX_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
39#[cfg(not(test))]
40const SANDBOX_READY_POLL: std::time::Duration = std::time::Duration::from_millis(500);
41
42// A unit test has no reachable agent, so the budget only decides how long it takes to say so.
43#[cfg(test)]
44const SANDBOX_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20);
45#[cfg(test)]
46const SANDBOX_READY_POLL: std::time::Duration = std::time::Duration::from_millis(5);
47
48/// Side-effect free, which is what makes it safe to repeat while `run_command` is not.
49const HEALTH_PATH: &str = "/v1/health";
50
51/// Life of a token minted to talk to the agent.
52///
53/// Short because it is minted per request anyway: the mint response carries no expiry, so there
54/// is nothing to cache against and a long-lived token would only widen the window if one leaked.
55const AGENT_TOKEN_MINUTES: u32 = 5;
56
57/// Life of a preview capability handed to a caller.
58///
59/// Clamped where it is reported, not only where it is requested: AWS caps the mint at 60
60/// minutes, so an unclamped figure here would promise a caller more life than the token has.
61const PREVIEW_TOKEN_MINUTES: u32 = 30;
62
63/// What a caller is told a preview capability is good for.
64fn preview_lifetime_seconds() -> u64 {
65    u64::from(PREVIEW_TOKEN_MINUTES.min(MAX_AUTH_TOKEN_MINUTES)) * 60
66}
67
68/// A Sandbox backed by Lambda MicroVMs.
69#[derive(Debug)]
70pub struct AwsSandbox {
71    microvms: Arc<dyn LambdaMicrovmsApi>,
72    image_identifier: String,
73    image_version: String,
74    /// Connectors every sandbox starts with. Empty means the public internet is reachable, so
75    /// `deny` is a connector rather than the absence of one.
76    egress_connector_arns: Vec<String>,
77    /// Ports preview may be minted for. `CreateMicrovmAuthToken` carries no port condition key
78    /// and grants whatever port it is asked for, so this list bounds callers that go through this
79    /// provider; a Remote Bindings caller holding the raw credential is not bounded by it.
80    preview_ports: Vec<u16>,
81    /// Idle seconds before AWS suspends the MicroVM, where the declaration asked for it.
82    idle_pause_seconds: Option<u32>,
83    /// Wall-clock ceiling on a sandbox, where the declaration asked for one. Lambda terminates
84    /// the MicroVM when it elapses.
85    max_lifetime_seconds: Option<u32>,
86    agent: reqwest::Client,
87}
88
89impl AwsSandbox {
90    /// Builds a provider over the MicroVMs API.
91    pub fn new(
92        microvms: Arc<dyn LambdaMicrovmsApi>,
93        image_identifier: impl Into<String>,
94        image_version: impl Into<String>,
95        egress_connector_arns: Vec<String>,
96        preview_ports: Vec<u16>,
97        idle_pause_seconds: Option<u32>,
98        max_lifetime_seconds: Option<u32>,
99    ) -> Self {
100        Self {
101            microvms,
102            image_identifier: image_identifier.into(),
103            image_version: image_version.into(),
104            egress_connector_arns,
105            preview_ports,
106            idle_pause_seconds,
107            max_lifetime_seconds,
108            agent: reqwest::Client::new(),
109        }
110    }
111
112    /// The lifetime `RunMicrovm` is asked for: what the caller asked for, never above what the
113    /// declaration allows. AWS cannot move a running MicroVM's deadline, so this is the only
114    /// point at which it can be chosen.
115    fn lifetime_seconds(&self, timeout_ms: Option<u64>, operation: &str) -> Result<Option<u32>> {
116        match timeout_ms {
117            Some(timeout_ms) => {
118                super::requested_lifetime_seconds(timeout_ms, self.max_lifetime_seconds, operation)
119                    .map(Some)
120            }
121            None => Ok(self.max_lifetime_seconds),
122        }
123    }
124
125    /// Reads a sandbox's record, with no ownership check: absent is `None`, and whose it is
126    /// stays for the caller to ask [`Self::owns`]. Read off the sandbox itself rather than by
127    /// enumerating the image, because listing would need an account-wide grant.
128    async fn fetched_microvm(&self, operation: &str, sandbox_id: &str) -> Result<Option<Microvm>> {
129        let microvm = match self.microvms.get_microvm(sandbox_id).await {
130            Ok(microvm) => microvm,
131            // A sandbox id that names nothing is absent, not a failure — `get` reports that as
132            // `None`. Read as the variant rather than as `http_status_code`: the client's own
133            // status-bearing variant declares no status of its own, so every error would arrive
134            // as 500 and this arm would never match.
135            Err(error)
136                if matches!(
137                    &error.error,
138                    Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
139                ) =>
140            {
141                return Ok(None)
142            }
143            Err(error) => {
144                return Err(error)
145                    .unreachable(operation, &format!("could not read sandbox '{sandbox_id}'"))
146            }
147        };
148
149        Ok(Some(microvm))
150    }
151
152    /// Whether a fetched record is one of this binding's own sandboxes.
153    ///
154    /// The image ARN is the boundary — one per declared sandbox by construction, and IAM does not
155    /// draw this line: the token mint is scoped to `microvm-image:<stack prefix>-*`, which matches
156    /// every sibling. An absent `imageArn` is refused rather than assumed to match, so a response
157    /// the client failed to parse never passes as ownership.
158    fn owns(&self, microvm: &Microvm) -> bool {
159        microvm
160            .image_arn
161            .as_deref()
162            .is_some_and(|image| image == self.image_identifier)
163    }
164
165    /// The sandbox's record if it exists *and* is this binding's own; absent and someone else's
166    /// collapse to `None`, which is what every operation except `terminate` wants.
167    async fn owned_microvm(&self, operation: &str, sandbox_id: &str) -> Result<Option<Microvm>> {
168        Ok(self
169            .fetched_microvm(operation, sandbox_id)
170            .await?
171            .filter(|microvm| self.owns(microvm)))
172    }
173
174    /// Refuses a sandbox that is absent or not one of this binding's own.
175    async fn ensure_owned(&self, operation: &str, sandbox_id: &str) -> Result<()> {
176        if self.owned_microvm(operation, sandbox_id).await?.is_none() {
177            return Err(AlienError::new(ErrorData::SandboxUnreachable {
178                operation: operation.to_string(),
179                reason: format!("sandbox '{sandbox_id}' does not belong to this sandbox resource"),
180            }));
181        }
182        Ok(())
183    }
184
185    /// Builds a request to the agent inside one sandbox, authorised and port-scoped.
186    ///
187    /// This is the whole of what AWS does differently; everything after it is the shared agent
188    /// protocol. Two AWS calls per request, because the mint response carries no expiry — without
189    /// one, caching a token means guessing how long it stays valid.
190    async fn authorized_request(
191        &self,
192        sandbox_id: &str,
193        method: reqwest::Method,
194        path: &str,
195    ) -> Result<reqwest::RequestBuilder> {
196        // One read serves both: the record that proves the sandbox is ours also carries the
197        // endpoint to reach it.
198        let microvm = self
199            .owned_microvm("sandbox.agent", sandbox_id)
200            .await?
201            .ok_or_else(|| {
202                AlienError::new(ErrorData::SandboxUnreachable {
203                    operation: "sandbox.agent".to_string(),
204                    reason: format!(
205                        "sandbox '{sandbox_id}' does not belong to this sandbox resource"
206                    ),
207                })
208            })?;
209
210        let endpoint = microvm.endpoint.ok_or_else(|| {
211            AlienError::new(ErrorData::SandboxUnreachable {
212                operation: "sandbox.agent".to_string(),
213                reason: format!("MicroVM '{sandbox_id}' has no endpoint yet"),
214            })
215        })?;
216
217        let token = self
218            .microvms
219            .create_microvm_auth_token(sandbox_id, vec![AGENT_PORT], AGENT_TOKEN_MINUTES)
220            .await
221            .unreachable(
222                "sandbox.agent",
223                &format!("could not mint an endpoint token for '{sandbox_id}'"),
224            )?;
225
226        let mut request = self
227            .agent
228            .request(method, format!("https://{endpoint}{path}"))
229            .header(PROXY_PORT_HEADER, AGENT_PORT.to_string());
230
231        // The mint returns a header map, not a bearer string. Sending it as `Authorization:
232        // Bearer` yields a 403 that reads like a permissions problem.
233        for (name, value) in token.auth_token {
234            request = request.header(name, value);
235        }
236
237        Ok(request)
238    }
239
240    fn instance(&self, microvm_id: String, state: Option<String>) -> SandboxInstance {
241        SandboxInstance {
242            sandbox_id: microvm_id,
243            state: sandbox_state(state.as_deref()),
244            // Terminate destroys the MicroVM rather than fencing it, so a sandbox never outlives
245            // its own generation and there is nothing for a second one to mean.
246            generation: 1,
247        }
248    }
249}
250
251/// Maps a MicroVM lifecycle state onto the binding's.
252fn sandbox_state(state: Option<&str>) -> SandboxState {
253    match state {
254        Some("RUNNING") => SandboxState::Running,
255        Some("SUSPENDED") => SandboxState::Paused,
256        Some("TERMINATED") | Some("TERMINATING") => SandboxState::Terminated,
257        // Anything else is a MicroVM on its way up. Reporting Running would tell a caller to
258        // start sending commands to something that cannot answer yet.
259        _ => SandboxState::Starting,
260    }
261}
262
263impl AwsSandbox {
264    /// Reads one of this binding's own sandboxes, or `None` if there is no such sandbox.
265    ///
266    /// `operation` is the verb the caller invoked, not this lookup: `get_or_create` reconnects
267    /// through here, and reporting its failure as `sandbox.get` would name a call the caller
268    /// never made.
269    async fn fetch(&self, sandbox_id: &str, operation: &str) -> Result<Option<SandboxInstance>> {
270        let Some(microvm) = self.owned_microvm(operation, sandbox_id).await? else {
271            return Ok(None);
272        };
273
274        // Echoing the caller's own id when the response carried none would report a sandbox the
275        // client could not parse as a sandbox it read — the same substitution `owned_microvm`
276        // refuses for the image.
277        let microvm_id = microvm.microvm_id.ok_or_else(|| {
278            AlienError::new(ErrorData::SandboxUnreachable {
279                operation: operation.to_string(),
280                reason: format!("the record for sandbox '{sandbox_id}' carried no id"),
281            })
282        })?;
283
284        Ok(Some(self.instance(microvm_id, microvm.state)))
285    }
286
287    /// Blocks until the agent answers, so `create` returns a sandbox that can take work.
288    async fn wait_until_servable(&self, sandbox_id: &str) -> Result<()> {
289        let deadline = std::time::Instant::now() + SANDBOX_READY_TIMEOUT;
290
291        // Only the endpoint's absence is worth waiting on. A refused token mint, a sandbox that is
292        // not ours, an API error — none of those resolve by waiting, and folding them into the
293        // timeout would report a permission problem as a slow boot a minute later.
294        let probe = loop {
295            let published = self
296                .owned_microvm("sandbox.create", sandbox_id)
297                .await?
298                .is_some_and(|microvm| microvm.endpoint.is_some());
299
300            if published {
301                break self
302                    .authorized_request(sandbox_id, reqwest::Method::GET, HEALTH_PATH)
303                    .await?;
304            }
305            if std::time::Instant::now() >= deadline {
306                return Err(AlienError::new(ErrorData::SandboxUnreachable {
307                    operation: "sandbox.create".to_string(),
308                    reason: format!(
309                        "MicroVM '{sandbox_id}' published no endpoint within {}s",
310                        SANDBOX_READY_TIMEOUT.as_secs()
311                    ),
312                }));
313            }
314            tokio::time::sleep(SANDBOX_READY_POLL).await;
315        };
316
317        Self::poll_until_healthy(probe, deadline, sandbox_id).await
318    }
319
320    /// Repeats the health probe until it answers or the deadline passes.
321    ///
322    /// Separated from the endpoint wait so the restore window this absorbs — AWS answering 502
323    /// while a snapshot restores — can be exercised without a MicroVM.
324    async fn poll_until_healthy(
325        probe: reqwest::RequestBuilder,
326        deadline: std::time::Instant,
327        sandbox_id: &str,
328    ) -> Result<()> {
329        let mut last_seen;
330        loop {
331            // Cloned rather than rebuilt: the token outlives this wait, so the health poll costs
332            // no further describes or mints.
333            let request = probe.try_clone().ok_or_else(|| {
334                AlienError::new(ErrorData::SandboxUnreachable {
335                    operation: "sandbox.create".to_string(),
336                    reason: "the readiness probe could not be repeated".to_string(),
337                })
338            })?;
339
340            match request.send().await {
341                Ok(response) if response.status().is_success() => return Ok(()),
342                Ok(response) => last_seen = format!("the endpoint answered {}", response.status()),
343                Err(error) => last_seen = error.to_string(),
344            }
345
346            if std::time::Instant::now() >= deadline {
347                return Err(AlienError::new(ErrorData::SandboxUnreachable {
348                    operation: "sandbox.create".to_string(),
349                    reason: format!(
350                        "MicroVM '{sandbox_id}' did not become servable in time: {last_seen}"
351                    ),
352                }));
353            }
354            tokio::time::sleep(SANDBOX_READY_POLL).await;
355        }
356    }
357}
358
359#[async_trait]
360impl AgentTransport for AwsSandbox {
361    async fn request(
362        &self,
363        sandbox_id: &str,
364        method: reqwest::Method,
365        path: &str,
366    ) -> Result<reqwest::RequestBuilder> {
367        self.authorized_request(sandbox_id, method, path).await
368    }
369
370    fn provider(&self) -> &'static str {
371        "aws-sandbox"
372    }
373}
374
375impl Binding for AwsSandbox {}
376
377/// Refused rather than dropped: `RunMicrovm` has nowhere to put either, and a sandbox-level
378/// value that silently never applies is worse than no sandbox. Per-command `env` on
379/// `RunCommandRequest` is the path that works here.
380fn refuse_unsupported_create_fields(request: &CreateSandboxRequest, operation: &str) -> Result<()> {
381    if !request.env.is_empty() {
382        return Err(AlienError::new(ErrorData::OperationNotSupported {
383            operation: operation.to_string(),
384            reason: "AWS sandboxes take no sandbox-level env; set env per command instead"
385                .to_string(),
386        }));
387    }
388    if request.tenant_key.is_some() {
389        return Err(AlienError::new(ErrorData::OperationNotSupported {
390            operation: operation.to_string(),
391            reason: "AWS sandboxes take no tenantKey; a MicroVM is already single-tenant"
392                .to_string(),
393        }));
394    }
395    Ok(())
396}
397
398#[async_trait]
399impl Sandbox for AwsSandbox {
400    /// Narrows the platform ceiling to this instance: `preview` is false with no declared
401    /// `preview_ports`, since `preview()` would refuse every port anyway and callers are meant
402    /// to branch on this flag rather than call and fail.
403    fn capabilities(&self) -> SandboxCapabilities {
404        let mut capabilities =
405            SandboxCapabilities::for_platform(Platform::Aws).expect("AWS has a sandbox backend");
406        capabilities.preview = !self.preview_ports.is_empty();
407        capabilities
408    }
409
410    /// Starts a MicroVM.
411    ///
412    /// The client token is fresh per attempt and is **never** the caller's `sandbox_id`. AWS
413    /// returns the MicroVM a token previously created even after it has been terminated, so a
414    /// caller reusing a sandbox id would receive a dead MicroVM and wait for one that will never
415    /// start — observed against the live API. Reconnecting to an existing sandbox is
416    /// [`Sandbox::get`]'s job, not an idempotency key's.
417    async fn create(&self, request: CreateSandboxRequest) -> Result<SandboxInstance> {
418        let _ = request.sandbox_id;
419        refuse_unsupported_create_fields(&request, "sandbox.create")?;
420        let max_lifetime_seconds = self.lifetime_seconds(request.timeout_ms, "sandbox.create")?;
421        let client_token = uuid::Uuid::new_v4().simple().to_string();
422
423        let microvm = self
424            .microvms
425            .run_microvm(
426                &self.image_identifier,
427                &self.image_version,
428                &client_token,
429                // Never a role: a sandbox reads an attached role's credentials from instance
430                // metadata, which the egress connector does not govern.
431                None,
432                self.egress_connector_arns.clone(),
433                self.idle_pause_seconds,
434                max_lifetime_seconds,
435            )
436            .await
437            .unreachable(
438                "sandbox.create",
439                &format!("could not start a MicroVM from '{}'", self.image_identifier),
440            )?;
441
442        let microvm_id = microvm.microvm_id.ok_or_else(|| {
443            AlienError::new(ErrorData::UnexpectedResponseFormat {
444                provider: "aws-sandbox".to_string(),
445                binding_name: "sandbox.create".to_string(),
446                field: "microvmId".to_string(),
447                response_json: "RunMicrovm returned no MicroVM id".to_string(),
448            })
449        })?;
450
451        // RunMicrovm returns once the MicroVM is accepted, not once it can serve: AWS restores the
452        // snapshot afterwards and its proxy answers 502 until that finishes. Returning here hands
453        // back a sandbox whose first command races that window, which is invisible to a caller and
454        // fails a fraction of the time. Local, Azure and Kubernetes all return a sandbox that can
455        // already serve, so this is what makes AWS mean the same thing.
456        if let Err(error) = self.wait_until_servable(&microvm_id).await {
457            return Err(match self.microvms.terminate_microvm(&microvm_id).await {
458                Ok(()) => error,
459                Err(cleanup) => {
460                    warn!(
461                        microvm = %microvm_id,
462                        %cleanup,
463                        "could not terminate a MicroVM that never became servable"
464                    );
465                    // Names the leak and refuses a retry: no caller holds this MicroVM's id, so
466                    // honouring the wait's retryable error would mint another orphan beside it.
467                    error.context(ErrorData::SandboxCommandFailed {
468                        failure: "sandboxLeftBehind".to_string(),
469                        reason: format!(
470                            "MicroVM '{microvm_id}' was not handed to its caller and could not be \
471                             terminated, so it is still running"
472                        ),
473                    })
474                }
475            });
476        }
477
478        Ok(self.instance(microvm_id, Some("RUNNING".to_string())))
479    }
480
481    async fn get(&self, sandbox_id: &str) -> Result<Option<SandboxInstance>> {
482        self.fetch(sandbox_id, "sandbox.get").await
483    }
484
485    async fn get_or_create(&self, request: CreateSandboxRequest) -> Result<ResolvedSandbox> {
486        // Refused on the reconnect path too: an existing sandbox honors these fields no more
487        // than a fresh one would.
488        refuse_unsupported_create_fields(&request, "sandbox.getOrCreate")?;
489        if let Some(id) = request.sandbox_id.as_deref() {
490            if let Some(existing) = self.fetch(id, "sandbox.getOrCreate").await? {
491                // Reaching a sandbox someone else started still has to mean what `create` means,
492                // or the guarantee holds only for whoever won the race. Waited for here rather
493                // than in `get`, which reports a sandbox's state and does not promise one.
494                if matches!(existing.state, SandboxState::Starting) {
495                    self.wait_until_servable(id).await?;
496                    return Ok(ResolvedSandbox::found(
497                        self.instance(id.to_string(), Some("RUNNING".to_string())),
498                    ));
499                }
500                return Ok(ResolvedSandbox::found(existing));
501            }
502        }
503
504        self.create(request).await.map(ResolvedSandbox::created)
505    }
506
507    /// Not offered, as on Azure and GCP.
508    ///
509    /// Enumerating would mean `lambda:ListMicrovms`, which AWS authorizes against no resource
510    /// type — the grant could only be account-wide, on the management profile any stack with a
511    /// sandbox holds. The
512    /// reason to accept that would be recovering a MicroVM whose `RunMicrovm` response never
513    /// arrived, since nobody holds its id. Lambda already reaps those: with no traffic to its
514    /// endpoint a MicroVM is suspended after the idle duration and terminated after the suspended
515    /// one, both 300s unless the declaration widens the first — and an orphan receives no traffic
516    /// by definition. A declared `maxLifetimeSeconds` bounds it outright. Reconnecting to a
517    /// sandbox whose id *is* known is `get`, which reads it directly.
518    async fn list(&self) -> Result<Vec<SandboxInstance>> {
519        Err(AlienError::new(ErrorData::OperationNotSupported {
520            operation: "sandbox.list".to_string(),
521            reason:
522                "enumerating sandboxes would need an account-wide grant; reach a known sandbox \
523                     with get, and Lambda terminates one nobody reaches"
524                    .to_string(),
525        }))
526    }
527
528    async fn run_command(
529        &self,
530        sandbox_id: &str,
531        request: RunCommandRequest,
532    ) -> Result<BoxStream<'static, Result<CommandOutput>>> {
533        agent_protocol::run_command(self, sandbox_id, request).await
534    }
535
536    async fn start_job(&self, sandbox_id: &str, request: RunCommandRequest) -> Result<JobStart> {
537        agent_protocol::start_job(self, sandbox_id, request).await
538    }
539
540    async fn poll_job(
541        &self,
542        sandbox_id: &str,
543        job_id: &str,
544        since_seq: Option<u64>,
545    ) -> Result<JobPoll> {
546        agent_protocol::poll_job(self, sandbox_id, job_id, since_seq).await
547    }
548
549    async fn cancel_job(&self, sandbox_id: &str, job_id: &str) -> Result<()> {
550        agent_protocol::cancel_job(self, sandbox_id, job_id).await
551    }
552
553    async fn read_file(&self, sandbox_id: &str, path: &str) -> Result<Vec<u8>> {
554        agent_protocol::read_file(self, sandbox_id, path).await
555    }
556
557    async fn write_files(&self, sandbox_id: &str, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
558        agent_protocol::write_files(self, sandbox_id, files).await
559    }
560
561    /// Mints a capability to reach one port inside the sandbox.
562    ///
563    /// The endpoint is never returned bare: a caller cannot reach it without the token headers
564    /// and the port header, and handing over a URL would push them into building the auth
565    /// themselves.
566    async fn preview(&self, sandbox_id: &str, port: u16) -> Result<PreviewCapability> {
567        // Port first, ownership second: an undeclared port is refused without spending a call.
568        if !self.preview_ports.contains(&port) {
569            return Err(AlienError::new(ErrorData::OperationNotSupported {
570                operation: "sandbox.preview".to_string(),
571                reason: format!(
572                    "port {port} is not one of this sandbox's declared preview ports {:?}; a \
573                     minted token would grant ingress the stack never asked for",
574                    self.preview_ports
575                ),
576            }));
577        }
578
579        // One read again: ownership and the endpoint come off the same record.
580        let microvm = self
581            .owned_microvm("sandbox.preview", sandbox_id)
582            .await?
583            .ok_or_else(|| {
584                AlienError::new(ErrorData::SandboxUnreachable {
585                    operation: "sandbox.preview".to_string(),
586                    reason: format!(
587                        "sandbox '{sandbox_id}' does not belong to this sandbox resource"
588                    ),
589                })
590            })?;
591
592        let endpoint = microvm.endpoint.ok_or_else(|| {
593            AlienError::new(ErrorData::SandboxUnreachable {
594                operation: "sandbox.preview".to_string(),
595                reason: format!("MicroVM '{sandbox_id}' has no endpoint yet"),
596            })
597        })?;
598
599        let token = self
600            .microvms
601            .create_microvm_auth_token(sandbox_id, vec![port], PREVIEW_TOKEN_MINUTES)
602            .await
603            .unreachable(
604                "sandbox.preview",
605                &format!("could not mint a preview token for port {port}"),
606            )?;
607
608        let mut headers: BTreeMap<String, String> = token.auth_token.into_iter().collect();
609        headers.insert(PROXY_PORT_HEADER.to_string(), port.to_string());
610
611        Ok(PreviewCapability {
612            endpoint: format!("https://{endpoint}"),
613            headers,
614            allowed_ports: vec![port],
615            expires_in_seconds: preview_lifetime_seconds(),
616        })
617    }
618
619    async fn pause(&self, sandbox_id: &str) -> Result<()> {
620        self.ensure_owned("sandbox.pause", sandbox_id).await?;
621
622        self.microvms.suspend_microvm(sandbox_id).await.unreachable(
623            "sandbox.pause",
624            &format!("could not pause MicroVM '{sandbox_id}'"),
625        )
626    }
627
628    async fn resume(&self, sandbox_id: &str) -> Result<()> {
629        self.ensure_owned("sandbox.resume", sandbox_id).await?;
630
631        self.microvms.resume_microvm(sandbox_id).await.unreachable(
632            "sandbox.resume",
633            &format!("could not resume MicroVM '{sandbox_id}'"),
634        )
635    }
636
637    async fn snapshot(&self, _sandbox_id: &str) -> Result<String> {
638        Err(AlienError::new(ErrorData::OperationNotSupported {
639            operation: "sandbox.snapshot".to_string(),
640            reason: "Lambda MicroVMs expose no snapshot API".to_string(),
641        }))
642    }
643
644    /// Idempotent per the trait: a sandbox AWS has already reaped is terminated, not an error.
645    /// Absence and "someone else's" part ways here alone — every other operation needs the
646    /// sandbox to exist, so for them the two are the same refusal.
647    async fn terminate(&self, sandbox_id: &str) -> Result<()> {
648        match self
649            .fetched_microvm("sandbox.terminate", sandbox_id)
650            .await?
651        {
652            None => return Ok(()),
653            Some(microvm) if !self.owns(&microvm) => {
654                return Err(AlienError::new(ErrorData::SandboxUnreachable {
655                    operation: "sandbox.terminate".to_string(),
656                    reason: format!(
657                        "sandbox '{sandbox_id}' does not belong to this sandbox resource"
658                    ),
659                }))
660            }
661            Some(_) => {}
662        }
663
664        self.microvms
665            .terminate_microvm(sandbox_id)
666            .await
667            .unreachable(
668                "sandbox.terminate",
669                &format!("could not terminate MicroVM '{sandbox_id}'"),
670            )
671    }
672
673    fn as_any(&self) -> &dyn std::any::Any {
674        self
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use alien_aws_clients::aws::lambda_microvms::{
682        Microvm, MicrovmAuthToken, MockLambdaMicrovmsApi,
683    };
684    use alien_error::Context;
685    use std::time::Duration;
686
687    fn image_version(version: &str) -> alien_aws_clients::aws::lambda_microvms::MicrovmImage {
688        alien_aws_clients::aws::lambda_microvms::MicrovmImage {
689            image_identifier: Some("sbx-image".to_string()),
690            image_arn: None,
691            image_version: Some(version.to_string()),
692            state: Some("CREATED".to_string()),
693        }
694    }
695
696    /// A MicroVM belonging to this sandbox's image. Ownership is now a field on the record, so
697    /// every fixture has to say whose sandbox it is.
698    fn owned(id: &str, state: &str) -> Microvm {
699        Microvm {
700            microvm_id: Some(id.to_string()),
701            endpoint: None,
702            state: Some(state.to_string()),
703            image_arn: Some("sbx-image".to_string()),
704            image_version: Some("1".to_string()),
705        }
706    }
707
708    fn sandbox(client: MockLambdaMicrovmsApi) -> AwsSandbox {
709        sandbox_previewing(client, Vec::new())
710    }
711
712    fn sandbox_previewing(client: MockLambdaMicrovmsApi, preview_ports: Vec<u16>) -> AwsSandbox {
713        AwsSandbox::new(
714            Arc::new(client),
715            "sbx-image",
716            "3",
717            Vec::new(),
718            preview_ports,
719            None,
720            None,
721        )
722    }
723
724    /// AWS reaps the microvm record after termination, so pinning idempotent-on-absent keeps a
725    /// caller's retry loop from flaking once the record is gone.
726    #[tokio::test]
727    async fn terminating_an_absent_sandbox_succeeds() {
728        let mut client = MockLambdaMicrovmsApi::new();
729        client.expect_get_microvm().returning(|id| {
730            Err(alien_error::AlienError::new(
731                alien_client_core::ErrorData::RemoteResourceNotFound {
732                    resource_type: "Microvm".to_string(),
733                    resource_name: id.to_string(),
734                },
735            ))
736        });
737        client.expect_terminate_microvm().never();
738
739        let sandbox = sandbox(client);
740
741        sandbox
742            .terminate("already-reaped")
743            .await
744            .expect("an absent sandbox is already terminated");
745    }
746
747    /// Reconnecting must not report a sandbox this call did not make, or a caller runs its
748    /// first-run setup a second time on a sandbox that already had it.
749    #[tokio::test]
750    async fn reconnecting_to_a_running_sandbox_reports_found() {
751        let mut client = MockLambdaMicrovmsApi::new();
752        client
753            .expect_get_microvm()
754            .returning(|id| Ok(owned(id, "RUNNING")));
755        client.expect_run_microvm().never();
756
757        let resolved = sandbox(client)
758            .get_or_create(CreateSandboxRequest {
759                sandbox_id: Some("already-up".to_string()),
760                ..Default::default()
761            })
762            .await
763            .expect("a running sandbox is handed back");
764
765        assert_eq!(resolved.sandbox.sandbox_id, "already-up");
766        assert!(
767            !resolved.created,
768            "the sandbox was already running, so this call did not create it"
769        );
770    }
771
772    /// The reconnect read is `get_or_create`'s, not `get`'s. Naming the helper it happens to
773    /// share would point a reader at a call the caller never made.
774    #[tokio::test]
775    async fn a_failed_reconnect_reports_the_verb_the_caller_used() {
776        let mut client = MockLambdaMicrovmsApi::new();
777        client.expect_get_microvm().returning(|_| {
778            Err(alien_error::AlienError::new(
779                alien_client_core::ErrorData::RemoteServiceUnavailable {
780                    message: "GetMicrovm was throttled".to_string(),
781                },
782            ))
783        });
784        client.expect_run_microvm().never();
785
786        let error = sandbox(client)
787            .get_or_create(CreateSandboxRequest {
788                sandbox_id: Some("unreadable".to_string()),
789                ..Default::default()
790            })
791            .await
792            .expect_err("a read that fails is not a sandbox that is absent");
793
794        assert!(
795            error.to_string().contains("sandbox.getOrCreate"),
796            "the reconnect failure names getOrCreate, not get: {error}"
797        );
798    }
799
800    /// The branch that waits: a MicroVM still coming up is waited for, and the wait failing is
801    /// answered with the failure rather than with a second MicroVM nobody holds an id for.
802    ///
803    /// Pins only the never-create half. Carrying the wait through to success needs a TLS listener,
804    /// because `authorized_request` builds an `https://` URL — the readiness poll itself is covered
805    /// directly by `the_readiness_poll_outlasts_the_snapshot_restore_window`.
806    #[tokio::test]
807    async fn a_booting_sandbox_is_never_answered_with_a_second_microvm() {
808        let mut client = MockLambdaMicrovmsApi::new();
809        // Coming up, and with no endpoint published yet, so the wait runs and then gives up.
810        client
811            .expect_get_microvm()
812            .returning(|id| Ok(owned(id, "PENDING")));
813        client.expect_run_microvm().never();
814
815        let error = sandbox(client)
816            .get_or_create(CreateSandboxRequest {
817                sandbox_id: Some("still-booting".to_string()),
818                ..Default::default()
819            })
820            .await
821            .expect_err("a sandbox that never publishes an endpoint cannot be served");
822
823        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
824        assert!(
825            error.to_string().contains("still-booting"),
826            "the failure names the sandbox it waited on: {error}"
827        );
828    }
829
830    /// See `capabilities()` for why this tracks `preview_ports`.
831    #[tokio::test]
832    async fn capabilities_reflect_the_declared_preview_ports() {
833        assert!(
834            !sandbox(MockLambdaMicrovmsApi::new()).capabilities().preview,
835            "no declared ports means no preview capability"
836        );
837        assert!(
838            sandbox_previewing(MockLambdaMicrovmsApi::new(), vec![8080])
839                .capabilities()
840                .preview,
841            "a declared port makes the capability real"
842        );
843    }
844
845    /// See `create()` for why these are refused rather than silently dropped.
846    #[tokio::test]
847    async fn unsupported_sandbox_fields_are_refused_not_dropped() {
848        let mut client = MockLambdaMicrovmsApi::new();
849        client.expect_run_microvm().never();
850
851        let sandbox = sandbox(client);
852
853        let with_env = CreateSandboxRequest {
854            env: [("KEY".to_string(), "value".to_string())].into(),
855            ..Default::default()
856        };
857        let error = sandbox
858            .create(with_env)
859            .await
860            .expect_err("a sandbox-level env must be refused");
861        assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
862
863        let with_tenant = CreateSandboxRequest {
864            tenant_key: Some("tenant-1".to_string()),
865            ..Default::default()
866        };
867        let error = sandbox
868            .create(with_tenant)
869            .await
870            .expect_err("a tenant key must be refused");
871        assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
872    }
873
874    /// IAM cannot draw this line: the stack binding scopes the token mint to
875    /// `microvm-image:<stack prefix>-*`, which matches every sibling sandbox in the stack, so a
876    /// workload passing a sibling's sandbox id would be authorised for it. The sandbox's own
877    /// `imageArn` is what says whose it is.
878    #[tokio::test]
879    async fn a_sandbox_from_another_declaration_is_refused_before_anything_is_minted() {
880        let mut client = MockLambdaMicrovmsApi::new();
881        client.expect_get_microvm().returning(|id| {
882            Ok(Microvm {
883                microvm_id: Some(id.to_string()),
884                endpoint: Some("vm.example.invalid".to_string()),
885                state: Some("RUNNING".to_string()),
886                // A live sandbox, reachable, running — and belonging to a different declaration.
887                image_arn: Some("someone-elses-image".to_string()),
888                image_version: Some("1".to_string()),
889            })
890        });
891        client.expect_create_microvm_auth_token().never();
892        client.expect_terminate_microvm().never();
893        client.expect_suspend_microvm().never();
894
895        let sandbox = sandbox_previewing(client, vec![8080]);
896
897        for outcome in [
898            sandbox.preview("a-siblings-sandbox", 8080).await.err(),
899            sandbox.terminate("a-siblings-sandbox").await.err(),
900            sandbox.pause("a-siblings-sandbox").await.err(),
901        ] {
902            let error = outcome.expect("a sandbox this sandbox does not own is refused");
903            assert!(
904                error
905                    .to_string()
906                    .contains("does not belong to this sandbox"),
907                "names the reason: {error}"
908            );
909        }
910
911        assert!(
912            sandbox
913                .get("a-siblings-sandbox")
914                .await
915                .expect("reading it is not an error")
916                .is_none(),
917            "a sibling's sandbox reads as absent rather than as one of ours"
918        );
919    }
920
921    /// The absent-sandbox path, built the way the client builds it rather than by hand. `get`
922    /// must report a sandbox that does not exist as `None`, because `get_or_create` reads that
923    /// answer to decide whether to create one — an error there means a caller supplying a fresh
924    /// id can never create a sandbox at all.
925    #[tokio::test]
926    async fn a_sandbox_that_does_not_exist_reads_as_absent_rather_than_as_a_failure() {
927        let mut client = MockLambdaMicrovmsApi::new();
928        client.expect_get_microvm().returning(|_| {
929            Err(alien_error::AlienError::new(
930                alien_client_core::ErrorData::RemoteResourceNotFound {
931                    resource_type: "Microvm".to_string(),
932                    resource_name: "GetMicrovm".to_string(),
933                },
934            ))
935        });
936
937        assert!(sandbox(client)
938            .get("never-existed")
939            .await
940            .expect("an absent sandbox is an answer, not an error")
941            .is_none());
942    }
943
944    /// A read that genuinely failed is not an absent sandbox. Flattening it into `None` would
945    /// have `get_or_create` start a second sandbox while the first is still running.
946    #[tokio::test]
947    async fn a_failed_read_is_not_reported_as_an_absent_sandbox() {
948        let mut client = MockLambdaMicrovmsApi::new();
949        client.expect_get_microvm().returning(|_| {
950            Err(alien_error::AlienError::new(
951                alien_client_core::ErrorData::RateLimitExceeded {
952                    message: "throttled".to_string(),
953                },
954            ))
955        });
956
957        sandbox(client)
958            .get("ours")
959            .await
960            .expect_err("a throttle is not an absent sandbox");
961    }
962
963    /// A response the client could not parse an image out of must not pass as ours. Defaulting
964    /// the other way would make every unparsed sandbox belong to whoever asked.
965    #[tokio::test]
966    async fn a_sandbox_with_no_image_is_not_assumed_to_be_ours() {
967        let mut client = MockLambdaMicrovmsApi::new();
968        client.expect_get_microvm().returning(|id| {
969            Ok(Microvm {
970                microvm_id: Some(id.to_string()),
971                endpoint: Some("vm.example.invalid".to_string()),
972                state: Some("RUNNING".to_string()),
973                image_arn: None,
974                image_version: None,
975            })
976        });
977        client.expect_create_microvm_auth_token().never();
978
979        let error = sandbox_previewing(client, vec![8080])
980            .preview("unlabelled", 8080)
981            .await
982            .expect_err("an unattributable sandbox is refused");
983        assert!(error
984            .to_string()
985            .contains("does not belong to this sandbox"));
986    }
987
988    /// The check costs one `GetMicrovm`, which `sandbox/execute` grants scoped to this image.
989    /// Enumerating instead would need `ListMicrovms`, which that set does not carry — an app
990    /// linked to a sandbox would fail on its first command.
991    #[tokio::test]
992    async fn reaching_a_sandbox_does_not_enumerate_the_image() {
993        let mut client = MockLambdaMicrovmsApi::new();
994        client.expect_list_microvms().never();
995        client.expect_list_microvm_image_versions().never();
996        client
997            .expect_get_microvm()
998            .returning(|id| Ok(owned(id, "RUNNING")));
999
1000        let instance = sandbox(client)
1001            .get("ours")
1002            .await
1003            .expect("reading our own sandbox succeeds")
1004            .expect("it is present");
1005        assert_eq!(instance.sandbox_id, "ours");
1006    }
1007
1008    /// A bare URL would be unusable: the endpoint refuses anything without the token headers and
1009    /// the port header, so a caller handed only a string would have to rebuild the auth.
1010    /// AWS answers 502 at the proxy while a MicroVM's snapshot restores, so the wait exists to
1011    /// outlast that window rather than to hand the first command a sandbox that cannot serve.
1012    /// Served over plain HTTP against a local listener: what is under test is the polling, not
1013    /// the transport that reaches a real MicroVM.
1014    #[tokio::test]
1015    async fn the_readiness_poll_outlasts_the_snapshot_restore_window() {
1016        use std::sync::atomic::{AtomicUsize, Ordering};
1017
1018        let attempts = std::sync::Arc::new(AtomicUsize::new(0));
1019        let seen = attempts.clone();
1020        let handler = move || {
1021            let seen = seen.clone();
1022            async move {
1023                // Two 502s with an empty body — exactly what the proxy returns mid-restore.
1024                if seen.fetch_add(1, Ordering::SeqCst) < 2 {
1025                    axum::http::StatusCode::BAD_GATEWAY
1026                } else {
1027                    axum::http::StatusCode::OK
1028                }
1029            }
1030        };
1031        let router = axum::Router::new().route(HEALTH_PATH, axum::routing::get(handler));
1032        let listener = tokio::net::TcpListener::bind::<std::net::SocketAddr>(
1033            "127.0.0.1:0".parse().expect("a loopback address"),
1034        )
1035        .await
1036        .expect("bind");
1037        let address = listener.local_addr().expect("address");
1038        tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
1039
1040        let probe = reqwest::Client::new().get(format!("http://{address}{HEALTH_PATH}"));
1041        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1042
1043        AwsSandbox::poll_until_healthy(probe, deadline, "mvm-restoring")
1044            .await
1045            .expect("the wait must outlast a restore that answers 502 before it answers 200");
1046        assert_eq!(
1047            attempts.load(Ordering::SeqCst),
1048            3,
1049            "it must keep probing through the restore rather than give up on the first 502"
1050        );
1051    }
1052
1053    /// The counterpart: a MicroVM that never answers has to fail, and say which sandbox.
1054    #[tokio::test]
1055    async fn the_readiness_poll_gives_up_on_a_sandbox_that_never_answers() {
1056        let router = axum::Router::new().route(
1057            HEALTH_PATH,
1058            axum::routing::get(|| async { axum::http::StatusCode::BAD_GATEWAY }),
1059        );
1060        let listener = tokio::net::TcpListener::bind::<std::net::SocketAddr>(
1061            "127.0.0.1:0".parse().expect("a loopback address"),
1062        )
1063        .await
1064        .expect("bind");
1065        let address = listener.local_addr().expect("address");
1066        tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
1067
1068        let probe = reqwest::Client::new().get(format!("http://{address}{HEALTH_PATH}"));
1069        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(30);
1070
1071        let error = AwsSandbox::poll_until_healthy(probe, deadline, "mvm-dead")
1072            .await
1073            .expect_err("a sandbox that never answers must not be reported as ready");
1074        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1075        assert!(
1076            error.to_string().contains("mvm-dead") && error.to_string().contains("502"),
1077            "the failure has to name the sandbox and what it last saw: {error}"
1078        );
1079    }
1080
1081    #[tokio::test]
1082    async fn preview_returns_the_headers_a_caller_cannot_construct() {
1083        let mut client = MockLambdaMicrovmsApi::new();
1084        client
1085            .expect_list_microvm_image_versions()
1086            .returning(|_| Ok(vec![image_version("3")]));
1087        client.expect_list_microvms().returning(|_, _| {
1088            Ok(vec![Microvm {
1089                microvm_id: Some("mvm-1".into()),
1090                endpoint: None,
1091                state: Some("RUNNING".into()),
1092                image_arn: Some("sbx-image".to_string()),
1093                image_version: Some("1".to_string()),
1094            }])
1095        });
1096        client.expect_get_microvm().returning(|_| {
1097            Ok(Microvm {
1098                microvm_id: Some("mvm-1".to_string()),
1099                endpoint: Some("mvm-1.lambda-microvms.aws".to_string()),
1100                state: Some("RUNNING".to_string()),
1101                image_arn: Some("sbx-image".to_string()),
1102                image_version: Some("1".to_string()),
1103            })
1104        });
1105        client
1106            .expect_create_microvm_auth_token()
1107            .withf(|_, ports, minutes| {
1108                ports.as_slice() == [8080] && *minutes == PREVIEW_TOKEN_MINUTES
1109            })
1110            .returning(|_, _, _| {
1111                Ok(MicrovmAuthToken {
1112                    auth_token: std::collections::HashMap::from([(
1113                        "X-aws-proxy-auth".to_string(),
1114                        "jwe-value".to_string(),
1115                    )]),
1116                })
1117            });
1118
1119        let capability = sandbox_previewing(client, vec![8080])
1120            .preview("mvm-1", 8080)
1121            .await
1122            .expect("mints");
1123
1124        assert_eq!(capability.endpoint, "https://mvm-1.lambda-microvms.aws");
1125        assert_eq!(
1126            capability
1127                .headers
1128                .get("X-aws-proxy-auth")
1129                .map(String::as_str),
1130            Some("jwe-value")
1131        );
1132        assert_eq!(
1133            capability
1134                .headers
1135                .get(PROXY_PORT_HEADER)
1136                .map(String::as_str),
1137            Some("8080")
1138        );
1139        assert_eq!(capability.allowed_ports, vec![8080]);
1140        assert_eq!(capability.expires_in_seconds, 1800);
1141    }
1142
1143    /// The declared list is what bounds ingress for callers on this path: `CreateMicrovmAuthToken`
1144    /// mints a token for whatever port it is handed and has no port condition key, so an unlisted
1145    /// port must be refused before the call rather than after it.
1146    #[tokio::test]
1147    async fn a_port_the_stack_did_not_declare_is_refused_before_a_token_exists() {
1148        let mut client = MockLambdaMicrovmsApi::new();
1149        client.expect_get_microvm().never();
1150        client.expect_create_microvm_auth_token().never();
1151
1152        let error = sandbox_previewing(client, vec![8080])
1153            .preview("mvm-1", 22)
1154            .await
1155            .expect_err("port 22 was never declared");
1156
1157        assert!(
1158            error.to_string().contains("22"),
1159            "the refusal must name the port asked for: {error}"
1160        );
1161    }
1162
1163    /// The figure handed to a caller must not outrun the token behind it: AWS caps the mint at
1164    /// 60 minutes, so an unclamped 30-minute promise would still be honest, but a raised
1165    /// `PREVIEW_TOKEN_MINUTES` past the cap would not.
1166    #[test]
1167    fn a_reported_preview_lifetime_never_exceeds_what_aws_will_mint() {
1168        assert_eq!(preview_lifetime_seconds(), 1800);
1169        assert!(
1170            preview_lifetime_seconds() <= u64::from(MAX_AUTH_TOKEN_MINUTES) * 60,
1171            "the reported lifetime must not outrun the cap the client sends"
1172        );
1173    }
1174
1175    /// The lifecycle states AWS reports, mapped onto the binding's. Read through `get`, which is
1176    /// the only way a sandbox is reached now that enumeration is gone.
1177    #[tokio::test]
1178    async fn a_microvm_that_is_not_running_yet_is_reported_as_starting() {
1179        for (aws_state, expected) in [
1180            ("PENDING", SandboxState::Starting),
1181            ("RUNNING", SandboxState::Running),
1182            ("SUSPENDED", SandboxState::Paused),
1183            ("TERMINATED", SandboxState::Terminated),
1184        ] {
1185            let mut client = MockLambdaMicrovmsApi::new();
1186            client
1187                .expect_get_microvm()
1188                .returning(move |id| Ok(owned(id, aws_state)));
1189
1190            let instance = sandbox(client)
1191                .get("s1")
1192                .await
1193                .expect("reads")
1194                .expect("present");
1195            assert_eq!(instance.state, expected, "AWS state {aws_state}");
1196        }
1197    }
1198
1199    /// The declared ceiling has to survive the last hop as well as the first: the binding carries
1200    /// it onto `AwsSandbox`, and only this call puts it on the wire. A field dropped here would
1201    /// leave a sandbox running past a limit its stack declared, with every other test still green.
1202    #[tokio::test]
1203    async fn the_declared_lifetime_reaches_the_run_call() {
1204        let mut client = MockLambdaMicrovmsApi::new();
1205        client
1206            .expect_run_microvm()
1207            .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(1800))
1208            .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1209        // The wait reads the sandbox back; with no endpoint published it stays unreachable,
1210        // which is all a unit test can offer. Create then terminates what it started.
1211        client
1212            .expect_get_microvm()
1213            .returning(|id| Ok(owned(id, "RUNNING")));
1214        client
1215            .expect_terminate_microvm()
1216            .times(1)
1217            .returning(|_| Ok(()));
1218
1219        let result = AwsSandbox::new(
1220            std::sync::Arc::new(client),
1221            "sbx-image",
1222            "3",
1223            vec!["connector".to_string()],
1224            Vec::new(),
1225            None,
1226            Some(1800),
1227        )
1228        .create(CreateSandboxRequest {
1229            sandbox_id: None,
1230            tenant_key: None,
1231            env: BTreeMap::new(),
1232            ..Default::default()
1233        })
1234        .await;
1235
1236        // `withf` above is the assertion: a run carrying the wrong ceiling matches no
1237        // expectation and panics. Create then waits for an agent no unit test can serve.
1238        let error = result.expect_err("no agent answers in a unit test");
1239        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1240        assert!(
1241            error.to_string().contains("published no endpoint"),
1242            "the failure has to name the readiness wait, not any error: {error}"
1243        );
1244    }
1245
1246    /// AWS cannot move a running MicroVM's deadline, so a lifetime the caller asked for is only
1247    /// ever applied here. Pins the unit as well as the value: `timeoutMs` is milliseconds and
1248    /// `maximumDurationInSeconds` is seconds, and a missed conversion is 1000x either way.
1249    #[tokio::test]
1250    async fn a_requested_lifetime_reaches_the_run_call_in_seconds() {
1251        let mut client = MockLambdaMicrovmsApi::new();
1252        client
1253            .expect_run_microvm()
1254            .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(90))
1255            .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1256        client
1257            .expect_get_microvm()
1258            .returning(|id| Ok(owned(id, "RUNNING")));
1259        client
1260            .expect_terminate_microvm()
1261            .times(1)
1262            .returning(|_| Ok(()));
1263
1264        let error = sandbox(client)
1265            .create(CreateSandboxRequest {
1266                timeout_ms: Some(90_000),
1267                ..Default::default()
1268            })
1269            .await
1270            .expect_err("no agent answers in a unit test");
1271
1272        // `withf` above is the assertion: a run carrying anything but 90 seconds matches no
1273        // expectation and panics. Create then waits for an agent a unit test cannot serve.
1274        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1275    }
1276
1277    /// The declared ceiling is the deployment's, not the caller's. A request that could raise it
1278    /// would let an application outlive the limit its own stack declared, which is the one
1279    /// direction this field must never move.
1280    #[tokio::test]
1281    async fn a_requested_lifetime_cannot_raise_the_declared_ceiling() {
1282        let mut client = MockLambdaMicrovmsApi::new();
1283        client
1284            .expect_run_microvm()
1285            .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(1800))
1286            .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1287        client
1288            .expect_get_microvm()
1289            .returning(|id| Ok(owned(id, "RUNNING")));
1290        client
1291            .expect_terminate_microvm()
1292            .times(1)
1293            .returning(|_| Ok(()));
1294
1295        let error = AwsSandbox::new(
1296            std::sync::Arc::new(client),
1297            "sbx-image",
1298            "3",
1299            vec!["connector".to_string()],
1300            Vec::new(),
1301            None,
1302            Some(1800),
1303        )
1304        .create(CreateSandboxRequest {
1305            timeout_ms: Some(7_200_000),
1306            ..Default::default()
1307        })
1308        .await
1309        .expect_err("no agent answers in a unit test");
1310
1311        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1312    }
1313
1314    /// A MicroVM that never became servable and could not be terminated is still running, and
1315    /// only this error carries its id. The wait's own failure is retryable, so returning it would
1316    /// invite a retry that mints a second MicroVM beside the first.
1317    #[tokio::test]
1318    async fn a_microvm_that_could_not_be_terminated_is_reported_by_id() {
1319        let mut client = MockLambdaMicrovmsApi::new();
1320        client
1321            .expect_run_microvm()
1322            .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-orphan", "PENDING")));
1323        // Published no endpoint, so the readiness wait gives up, and the cleanup it then tries
1324        // is refused — one missing grant refuses both verbs in practice.
1325        client
1326            .expect_get_microvm()
1327            .returning(|id| Ok(owned(id, "RUNNING")));
1328        client.expect_terminate_microvm().times(1).returning(|_| {
1329            Err(alien_error::AlienError::new(
1330                alien_client_core::ErrorData::RemoteServiceUnavailable {
1331                    message: "TerminateMicrovm was refused".to_string(),
1332                },
1333            ))
1334        });
1335
1336        let error = sandbox(client)
1337            .create(CreateSandboxRequest::default())
1338            .await
1339            .expect_err("a MicroVM that never became servable is not a sandbox");
1340
1341        assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
1342        assert!(
1343            !error.retryable,
1344            "a retry would mint another MicroVM nobody can reach: {error}"
1345        );
1346        assert!(
1347            error.message.contains("sandboxLeftBehind"),
1348            "the leak is reported under the label the siblings use: {error}"
1349        );
1350        // The outermost message, not the rendered chain: the wait's own error names the MicroVM
1351        // too, so the chain would read as green with the leak unnamed.
1352        assert!(
1353            error.message.contains("mvm-orphan"),
1354            "only this error can send an operator to the MicroVM left running: {error}"
1355        );
1356    }
1357
1358    /// Observed live: AWS returns the MicroVM a client token previously created **even after it
1359    /// is terminated**. Using the caller's sandbox id as that token hands back a dead MicroVM
1360    /// and then waits for it to start, which is a hang, not an error.
1361    #[tokio::test]
1362    async fn a_caller_supplied_sandbox_id_is_never_the_client_token() {
1363        let mut client = MockLambdaMicrovmsApi::new();
1364        client
1365            .expect_run_microvm()
1366            .withf(|image, version, token, _, _, _, _| {
1367                image == "sbx-image" && version == "3" && token != "caller-chosen"
1368            })
1369            .returning(|_, _, _, _, _, _, _| {
1370                Ok(Microvm {
1371                    microvm_id: Some("mvm-9".to_string()),
1372                    endpoint: None,
1373                    state: Some("PENDING".to_string()),
1374                    image_arn: Some("sbx-image".to_string()),
1375                    image_version: Some("1".to_string()),
1376                })
1377            });
1378        // The wait reads the sandbox back; with no endpoint published it stays unreachable,
1379        // which is all a unit test can offer. Create then terminates what it started.
1380        client
1381            .expect_get_microvm()
1382            // AWS assigns the id and `create` has to carry that one forward, not the caller's.
1383            .withf(|id| id == "mvm-9")
1384            .returning(|id| Ok(owned(id, "RUNNING")));
1385        client
1386            .expect_terminate_microvm()
1387            .withf(|id| id == "mvm-9")
1388            .times(1)
1389            .returning(|_| Ok(()));
1390
1391        let result = sandbox(client)
1392            .create(CreateSandboxRequest {
1393                sandbox_id: Some("caller-chosen".to_string()),
1394                tenant_key: None,
1395                env: BTreeMap::new(),
1396                ..Default::default()
1397            })
1398            .await;
1399
1400        // The client token assertion is `withf` above; a run reusing the caller's id matches no
1401        // expectation and panics. Create then waits for an agent a unit test cannot serve.
1402        let error = result.expect_err("no agent answers in a unit test");
1403        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1404        assert!(
1405            error.to_string().contains("published no endpoint"),
1406            "the failure has to name the readiness wait, not any error: {error}"
1407        );
1408    }
1409
1410    #[tokio::test]
1411    async fn a_command_without_a_timeout_is_refused_before_any_aws_call() {
1412        // No expectations set: a call to AWS here would fail the mock, which is the assertion.
1413        let outcome = sandbox(MockLambdaMicrovmsApi::new())
1414            .run_command(
1415                "mvm-1",
1416                RunCommandRequest {
1417                    command: "/bin/echo".to_string(),
1418                    args: Vec::new(),
1419                    cwd: None,
1420                    env: BTreeMap::new(),
1421                    timeout: Duration::ZERO,
1422                },
1423            )
1424            .await;
1425
1426        match outcome {
1427            Ok(_) => panic!("a zero timeout must be refused"),
1428            Err(error) => assert!(error.to_string().contains("non-zero timeout"), "{error}"),
1429        }
1430    }
1431
1432    /// A rolled image version does not end the sandboxes running on the previous one, and does
1433    /// not change whose they are. Comparing the version as well as the image would make `get`
1434    /// return None for a live sandbox after a roll, which a caller reads as expired — the exact
1435    /// false negative GCP's capability set refuses to ship.
1436    #[tokio::test]
1437    async fn a_sandbox_on_a_previous_image_version_is_still_ours() {
1438        let mut client = MockLambdaMicrovmsApi::new();
1439        client.expect_get_microvm().returning(|id| {
1440            Ok(Microvm {
1441                microvm_id: Some(id.to_string()),
1442                endpoint: None,
1443                state: Some("RUNNING".to_string()),
1444                image_arn: Some("sbx-image".to_string()),
1445                // The binding is pinned to version 3; this sandbox predates the roll.
1446                image_version: Some("2".to_string()),
1447            })
1448        });
1449
1450        let found = sandbox(client)
1451            .get("older")
1452            .await
1453            .expect("reads")
1454            .expect("a sandbox on the previous version is still live and still ours");
1455
1456        assert_eq!(found.sandbox_id, "older");
1457    }
1458
1459    /// Enumeration would cost an account-wide `ListMicrovms`, and the case it would serve —
1460    /// a `RunMicrovm` whose response never arrived, leaving a MicroVM nobody holds the id for —
1461    /// is already handled by Lambda: no traffic reaches an orphan's endpoint, so it suspends
1462    /// after the idle duration and is terminated after the suspended one.
1463    #[tokio::test]
1464    async fn sandboxes_are_not_enumerable_and_nothing_asks_aws_to_be() {
1465        let mut client = MockLambdaMicrovmsApi::new();
1466        client.expect_list_microvms().never();
1467        client.expect_list_microvm_image_versions().never();
1468
1469        let error = sandbox(client)
1470            .list()
1471            .await
1472            .expect_err("listing is not offered on AWS");
1473        assert!(
1474            error.to_string().contains("get"),
1475            "points the caller at what does work: {error}"
1476        );
1477    }
1478
1479    /// A `RunMicrovm` refused for a missing IAM action, shaped as `LambdaMicrovmsClient::send`
1480    /// shapes one: the transport records the response body, and `classify` wraps a non-404 as
1481    /// its own generic failure.
1482    fn refused_run() -> Result<Microvm, alien_client_core::ErrorData> {
1483        Err(AlienError::new(
1484            alien_client_core::ErrorData::HttpResponseError {
1485                message: "Request failed with HTTP 403: Forbidden".to_string(),
1486                url: "https://lambda.us-east-2.amazonaws.com/2025-09-09/microvms".to_string(),
1487                http_status: 403,
1488                http_request_text: None,
1489                http_response_text: Some(
1490                    r#"{"Message":"User: arn:aws:sts::123456789012:assumed-role/stack-access/session is not authorized to perform: lambda:PassNetworkConnector on resource: arn:aws:lambda:us-east-2:aws:network-connector:aws-network-connector:INTERNET_EGRESS"}"#
1491                        .to_string(),
1492                ),
1493            },
1494        ))
1495        .context(alien_client_core::ErrorData::GenericError {
1496            message: "Lambda MicroVMs RunMicrovm failed".to_string(),
1497        })
1498    }
1499
1500    /// The refused action is what sends a reader to the role rather than to this code, and the
1501    /// wire format past this binding is a flat message string — so `reason` is the only place a
1502    /// structured consumer sees it. It reaches an operator's log alone: an IAM identity makes the
1503    /// whole error internal, and `into_external` replaces it.
1504    #[tokio::test]
1505    async fn a_refused_create_reports_what_aws_refused_it_with() {
1506        let mut client = MockLambdaMicrovmsApi::new();
1507        client
1508            .expect_run_microvm()
1509            .returning(|_, _, _, _, _, _, _| refused_run());
1510        // Nothing was started, so nothing is cleaned up.
1511        client.expect_terminate_microvm().never();
1512
1513        let error = sandbox(client)
1514            .create(CreateSandboxRequest {
1515                sandbox_id: None,
1516                tenant_key: None,
1517                env: BTreeMap::new(),
1518                ..Default::default()
1519            })
1520            .await
1521            .expect_err("a refused RunMicrovm cannot produce a sandbox");
1522
1523        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1524        assert!(
1525            error
1526                .message
1527                .contains("could not start a MicroVM from 'sbx-image'"),
1528            "the binding still says which call it was: {}",
1529            error.message
1530        );
1531        assert!(
1532            error
1533                .message
1534                .contains("is not authorized to perform: lambda:PassNetworkConnector"),
1535            "and AWS's own sentence is what tells the operator why: {}",
1536            error.message
1537        );
1538        assert!(
1539            error.internal,
1540            "an IAM identity in the message makes the error internal: {error}"
1541        );
1542        assert_eq!(
1543            error.into_external().message,
1544            "Internal server error",
1545            "so none of it is published to the caller"
1546        );
1547    }
1548}