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//! session, 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, CreateSessionRequest, JobPoll, JobStart, PreviewCapability,
22    RunCommandRequest, Sandbox, SandboxSession, SandboxSessionState,
23};
24use alien_aws_clients::aws::lambda_microvms::{LambdaMicrovmsApi, Microvm, MAX_AUTH_TOKEN_MINUTES};
25use alien_core::{Platform, SandboxCapabilities};
26use alien_error::AlienError;
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 SESSION_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
39#[cfg(not(test))]
40const SESSION_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 SESSION_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20);
45#[cfg(test)]
46const SESSION_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 session 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_suspend_seconds: Option<u32>,
83    /// Wall-clock ceiling on a session, 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_suspend_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_suspend_seconds,
107            max_lifetime_seconds,
108            agent: reqwest::Client::new(),
109        }
110    }
111
112    /// Reads a session's record, with no ownership check: absent is `None`, and whose it is
113    /// stays for the caller to ask [`Self::owns`]. Read off the session itself rather than by
114    /// enumerating the image, because listing would need an account-wide grant.
115    async fn fetched_microvm(&self, session_id: &str) -> Result<Option<Microvm>> {
116        let microvm = match self.microvms.get_microvm(session_id).await {
117            Ok(microvm) => microvm,
118            // A session id that names nothing is absent, not a failure — `get` reports that as
119            // `None`. Read as the variant rather than as `http_status_code`: the client's own
120            // status-bearing variant declares no status of its own, so every error would arrive
121            // as 500 and this arm would never match.
122            Err(error)
123                if matches!(
124                    &error.error,
125                    Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
126                ) =>
127            {
128                return Ok(None)
129            }
130            Err(error) => {
131                return Err(error).unreachable(
132                    "sandbox.session",
133                    &format!("could not read session '{session_id}'"),
134                )
135            }
136        };
137
138        Ok(Some(microvm))
139    }
140
141    /// Whether a fetched record is one of this sandbox's own sessions.
142    ///
143    /// The image ARN is the boundary — one per declared sandbox by construction, and IAM does not
144    /// draw this line: the token mint is scoped to `microvm-image:<stack prefix>-*`, which matches
145    /// every sibling. An absent `imageArn` is refused rather than assumed to match, so a response
146    /// the client failed to parse never passes as ownership.
147    fn owns(&self, microvm: &Microvm) -> bool {
148        microvm
149            .image_arn
150            .as_deref()
151            .is_some_and(|image| image == self.image_identifier)
152    }
153
154    /// The session's record if it exists *and* is this sandbox's own; absent and someone else's
155    /// collapse to `None`, which is what every operation except `terminate` wants.
156    async fn owned_microvm(&self, session_id: &str) -> Result<Option<Microvm>> {
157        Ok(self
158            .fetched_microvm(session_id)
159            .await?
160            .filter(|microvm| self.owns(microvm)))
161    }
162
163    /// Refuses a session that is absent or not one of this sandbox's own.
164    async fn ensure_owned(&self, session_id: &str) -> Result<()> {
165        if self.owned_microvm(session_id).await?.is_none() {
166            return Err(AlienError::new(ErrorData::SandboxUnreachable {
167                operation: "sandbox.session".to_string(),
168                reason: format!("session '{session_id}' does not belong to this sandbox"),
169            }));
170        }
171        Ok(())
172    }
173
174    /// Builds a request to the agent inside one session, authorised and port-scoped.
175    ///
176    /// This is the whole of what AWS does differently; everything after it is the shared agent
177    /// protocol. Two AWS calls per request, because the mint response carries no expiry — without
178    /// one, caching a token means guessing how long it stays valid.
179    async fn authorized_request(
180        &self,
181        session_id: &str,
182        method: reqwest::Method,
183        path: &str,
184    ) -> Result<reqwest::RequestBuilder> {
185        // One read serves both: the record that proves the session is ours also carries the
186        // endpoint to reach it.
187        let microvm = self.owned_microvm(session_id).await?.ok_or_else(|| {
188            AlienError::new(ErrorData::SandboxUnreachable {
189                operation: "sandbox.agent".to_string(),
190                reason: format!("session '{session_id}' does not belong to this sandbox"),
191            })
192        })?;
193
194        let endpoint = microvm.endpoint.ok_or_else(|| {
195            AlienError::new(ErrorData::SandboxUnreachable {
196                operation: "sandbox.agent".to_string(),
197                reason: format!("MicroVM '{session_id}' has no endpoint yet"),
198            })
199        })?;
200
201        let token = self
202            .microvms
203            .create_microvm_auth_token(session_id, vec![AGENT_PORT], AGENT_TOKEN_MINUTES)
204            .await
205            .unreachable(
206                "sandbox.agent",
207                &format!("could not mint an endpoint token for '{session_id}'"),
208            )?;
209
210        let mut request = self
211            .agent
212            .request(method, format!("https://{endpoint}{path}"))
213            .header(PROXY_PORT_HEADER, AGENT_PORT.to_string());
214
215        // The mint returns a header map, not a bearer string. Sending it as `Authorization:
216        // Bearer` yields a 403 that reads like a permissions problem.
217        for (name, value) in token.auth_token {
218            request = request.header(name, value);
219        }
220
221        Ok(request)
222    }
223
224    fn session(&self, microvm_id: String, state: Option<String>) -> SandboxSession {
225        SandboxSession {
226            session_id: microvm_id,
227            state: session_state(state.as_deref()),
228            // Terminate destroys the MicroVM rather than fencing it, so a session never outlives
229            // its own generation and there is nothing for a second one to mean.
230            generation: 1,
231        }
232    }
233}
234
235/// Maps a MicroVM lifecycle state onto the binding's.
236fn session_state(state: Option<&str>) -> SandboxSessionState {
237    match state {
238        Some("RUNNING") => SandboxSessionState::Running,
239        Some("SUSPENDED") => SandboxSessionState::Suspended,
240        Some("TERMINATED") | Some("TERMINATING") => SandboxSessionState::Terminated,
241        // Anything else is a MicroVM on its way up. Reporting Running would tell a caller to
242        // start sending commands to something that cannot answer yet.
243        _ => SandboxSessionState::Starting,
244    }
245}
246
247impl AwsSandbox {
248    /// Blocks until the agent answers, so `create` returns a session that can take work.
249    async fn wait_until_servable(&self, session_id: &str) -> Result<()> {
250        let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT;
251
252        // Only the endpoint's absence is worth waiting on. A refused token mint, a session that is
253        // not ours, an API error — none of those resolve by waiting, and folding them into the
254        // timeout would report a permission problem as a slow boot a minute later.
255        let probe = loop {
256            let published = self
257                .owned_microvm(session_id)
258                .await?
259                .is_some_and(|microvm| microvm.endpoint.is_some());
260
261            if published {
262                break self
263                    .authorized_request(session_id, reqwest::Method::GET, HEALTH_PATH)
264                    .await?;
265            }
266            if std::time::Instant::now() >= deadline {
267                return Err(AlienError::new(ErrorData::SandboxUnreachable {
268                    operation: "sandbox.create".to_string(),
269                    reason: format!(
270                        "MicroVM '{session_id}' published no endpoint within {}s",
271                        SESSION_READY_TIMEOUT.as_secs()
272                    ),
273                }));
274            }
275            tokio::time::sleep(SESSION_READY_POLL).await;
276        };
277
278        Self::poll_until_healthy(probe, deadline, session_id).await
279    }
280
281    /// Repeats the health probe until it answers or the deadline passes.
282    ///
283    /// Separated from the endpoint wait so the restore window this absorbs — AWS answering 502
284    /// while a snapshot restores — can be exercised without a MicroVM.
285    async fn poll_until_healthy(
286        probe: reqwest::RequestBuilder,
287        deadline: std::time::Instant,
288        session_id: &str,
289    ) -> Result<()> {
290        let mut last_seen;
291        loop {
292            // Cloned rather than rebuilt: the token outlives this wait, so the health poll costs
293            // no further describes or mints.
294            let request = probe.try_clone().ok_or_else(|| {
295                AlienError::new(ErrorData::SandboxUnreachable {
296                    operation: "sandbox.create".to_string(),
297                    reason: "the readiness probe could not be repeated".to_string(),
298                })
299            })?;
300
301            match request.send().await {
302                Ok(response) if response.status().is_success() => return Ok(()),
303                Ok(response) => last_seen = format!("the endpoint answered {}", response.status()),
304                Err(error) => last_seen = error.to_string(),
305            }
306
307            if std::time::Instant::now() >= deadline {
308                return Err(AlienError::new(ErrorData::SandboxUnreachable {
309                    operation: "sandbox.create".to_string(),
310                    reason: format!(
311                        "MicroVM '{session_id}' did not become servable in time: {last_seen}"
312                    ),
313                }));
314            }
315            tokio::time::sleep(SESSION_READY_POLL).await;
316        }
317    }
318}
319
320#[async_trait]
321impl AgentTransport for AwsSandbox {
322    async fn request(
323        &self,
324        session_id: &str,
325        method: reqwest::Method,
326        path: &str,
327    ) -> Result<reqwest::RequestBuilder> {
328        self.authorized_request(session_id, method, path).await
329    }
330
331    fn provider(&self) -> &'static str {
332        "aws-sandbox"
333    }
334}
335
336impl Binding for AwsSandbox {}
337
338/// Refused rather than dropped: `RunMicrovm` has nowhere to put either, and a session-level
339/// value that silently never applies is worse than no session. Per-command `env` on
340/// `RunCommandRequest` is the path that works here.
341fn refuse_unsupported_session_fields(
342    request: &CreateSessionRequest,
343    operation: &str,
344) -> Result<()> {
345    if !request.env.is_empty() {
346        return Err(AlienError::new(ErrorData::OperationNotSupported {
347            operation: operation.to_string(),
348            reason: "AWS sandboxes take no session-level env; set env per command instead"
349                .to_string(),
350        }));
351    }
352    if request.tenant_key.is_some() {
353        return Err(AlienError::new(ErrorData::OperationNotSupported {
354            operation: operation.to_string(),
355            reason: "AWS sandboxes take no tenantKey; a MicroVM is already single-tenant"
356                .to_string(),
357        }));
358    }
359    Ok(())
360}
361
362#[async_trait]
363impl Sandbox for AwsSandbox {
364    /// Narrows the platform ceiling to this instance: `preview` is false with no declared
365    /// `preview_ports`, since `preview()` would refuse every port anyway and callers are meant
366    /// to branch on this flag rather than call and fail.
367    fn capabilities(&self) -> SandboxCapabilities {
368        let mut capabilities =
369            SandboxCapabilities::for_platform(Platform::Aws).expect("AWS has a sandbox backend");
370        capabilities.preview = !self.preview_ports.is_empty();
371        capabilities
372    }
373
374    /// Starts a MicroVM.
375    ///
376    /// The client token is fresh per attempt and is **never** the caller's `session_id`. AWS
377    /// returns the MicroVM a token previously created even after it has been terminated, so a
378    /// caller reusing a session id would receive a dead MicroVM and wait for one that will never
379    /// start — observed against the live API. Reconnecting to an existing session is
380    /// [`Sandbox::get`]'s job, not an idempotency key's.
381    async fn create(&self, request: CreateSessionRequest) -> Result<SandboxSession> {
382        let _ = request.session_id;
383        refuse_unsupported_session_fields(&request, "sandbox.create")?;
384        let client_token = uuid::Uuid::new_v4().simple().to_string();
385
386        let microvm = self
387            .microvms
388            .run_microvm(
389                &self.image_identifier,
390                &self.image_version,
391                &client_token,
392                // Never a role: a session reads an attached role's credentials from instance
393                // metadata, which the egress connector does not govern.
394                None,
395                self.egress_connector_arns.clone(),
396                self.idle_suspend_seconds,
397                self.max_lifetime_seconds,
398            )
399            .await
400            .unreachable(
401                "sandbox.create",
402                &format!("could not start a MicroVM from '{}'", self.image_identifier),
403            )?;
404
405        let microvm_id = microvm.microvm_id.ok_or_else(|| {
406            AlienError::new(ErrorData::UnexpectedResponseFormat {
407                provider: "aws-sandbox".to_string(),
408                binding_name: "sandbox.create".to_string(),
409                field: "microvmId".to_string(),
410                response_json: "RunMicrovm returned no MicroVM id".to_string(),
411            })
412        })?;
413
414        // RunMicrovm returns once the MicroVM is accepted, not once it can serve: AWS restores the
415        // snapshot afterwards and its proxy answers 502 until that finishes. Returning here hands
416        // back a session whose first command races that window, which is invisible to a caller and
417        // fails a fraction of the time. Local, Azure and Kubernetes all return a session that can
418        // already serve, so this is what makes AWS mean the same thing.
419        if let Err(error) = self.wait_until_servable(&microvm_id).await {
420            // The caller never receives this id, so nothing else can terminate it and it bills to
421            // its lifetime ceiling. This error is retryable, so leaving it would leak one MicroVM
422            // per attempt.
423            if let Err(cleanup) = self.microvms.terminate_microvm(&microvm_id).await {
424                warn!(
425                    microvm = %microvm_id,
426                    "could not terminate a MicroVM that never became servable: {cleanup}"
427                );
428            }
429            return Err(error);
430        }
431
432        Ok(self.session(microvm_id, Some("RUNNING".to_string())))
433    }
434
435    async fn get(&self, session_id: &str) -> Result<Option<SandboxSession>> {
436        let Some(microvm) = self.owned_microvm(session_id).await? else {
437            return Ok(None);
438        };
439
440        // Echoing the caller's own id when the response carried none would report a session the
441        // client could not parse as a session it read — the same substitution `owned_microvm`
442        // refuses for the image.
443        let microvm_id = microvm.microvm_id.ok_or_else(|| {
444            AlienError::new(ErrorData::SandboxUnreachable {
445                operation: "sandbox.session".to_string(),
446                reason: format!("the record for session '{session_id}' carried no id"),
447            })
448        })?;
449
450        Ok(Some(self.session(microvm_id, microvm.state)))
451    }
452
453    async fn get_or_create(&self, request: CreateSessionRequest) -> Result<SandboxSession> {
454        // Refused on the reconnect path too: an existing session honors these fields no more
455        // than a fresh one would.
456        refuse_unsupported_session_fields(&request, "sandbox.getOrCreate")?;
457        if let Some(id) = request.session_id.as_deref() {
458            if let Some(existing) = self.get(id).await? {
459                // Reaching a session someone else started still has to mean what `create` means,
460                // or the guarantee holds only for whoever won the race. Waited for here rather
461                // than in `get`, which reports a session's state and does not promise one.
462                if matches!(existing.state, SandboxSessionState::Starting) {
463                    self.wait_until_servable(id).await?;
464                    return Ok(self.session(id.to_string(), Some("RUNNING".to_string())));
465                }
466                return Ok(existing);
467            }
468        }
469
470        self.create(request).await
471    }
472
473    /// Not offered, as on Azure and GCP.
474    ///
475    /// Enumerating would mean `lambda:ListMicrovms`, which AWS authorizes against no resource
476    /// type — the grant could only be account-wide, on the management profile any stack with a
477    /// sandbox holds. The
478    /// reason to accept that would be recovering a MicroVM whose `RunMicrovm` response never
479    /// arrived, since nobody holds its id. Lambda already reaps those: with no traffic to its
480    /// endpoint a MicroVM is suspended after the idle duration and terminated after the suspended
481    /// one, both 300s unless the declaration widens the first — and an orphan receives no traffic
482    /// by definition. A declared `maxLifetimeSeconds` bounds it outright. Reconnecting to a
483    /// session whose id *is* known is `get`, which reads it directly.
484    async fn list(&self) -> Result<Vec<SandboxSession>> {
485        Err(AlienError::new(ErrorData::OperationNotSupported {
486            operation: "sandbox.list".to_string(),
487            reason: "enumerating sessions would need an account-wide grant; reach a known session \
488                     with get, and Lambda terminates one nobody reaches"
489                .to_string(),
490        }))
491    }
492
493    async fn run_command(
494        &self,
495        session_id: &str,
496        request: RunCommandRequest,
497    ) -> Result<BoxStream<'static, Result<CommandOutput>>> {
498        agent_protocol::run_command(self, session_id, request).await
499    }
500
501    async fn start_job(&self, session_id: &str, request: RunCommandRequest) -> Result<JobStart> {
502        agent_protocol::start_job(self, session_id, request).await
503    }
504
505    async fn poll_job(
506        &self,
507        session_id: &str,
508        job_id: &str,
509        since_seq: Option<u64>,
510    ) -> Result<JobPoll> {
511        agent_protocol::poll_job(self, session_id, job_id, since_seq).await
512    }
513
514    async fn cancel_job(&self, session_id: &str, job_id: &str) -> Result<()> {
515        agent_protocol::cancel_job(self, session_id, job_id).await
516    }
517
518    async fn read_file(&self, session_id: &str, path: &str) -> Result<Vec<u8>> {
519        agent_protocol::read_file(self, session_id, path).await
520    }
521
522    async fn write_files(&self, session_id: &str, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
523        agent_protocol::write_files(self, session_id, files).await
524    }
525
526    async fn mkdir(&self, session_id: &str, path: &str) -> Result<()> {
527        agent_protocol::mkdir(self, session_id, path).await
528    }
529
530    /// Mints a capability to reach one port inside the session.
531    ///
532    /// The endpoint is never returned bare: a caller cannot reach it without the token headers
533    /// and the port header, and handing over a URL would push them into building the auth
534    /// themselves.
535    async fn preview(&self, session_id: &str, port: u16) -> Result<PreviewCapability> {
536        // Port first, ownership second: an undeclared port is refused without spending a call.
537        if !self.preview_ports.contains(&port) {
538            return Err(AlienError::new(ErrorData::OperationNotSupported {
539                operation: "sandbox.preview".to_string(),
540                reason: format!(
541                    "port {port} is not one of this sandbox's declared preview ports {:?}; a \
542                     minted token would grant ingress the stack never asked for",
543                    self.preview_ports
544                ),
545            }));
546        }
547
548        // One read again: ownership and the endpoint come off the same record.
549        let microvm = self.owned_microvm(session_id).await?.ok_or_else(|| {
550            AlienError::new(ErrorData::SandboxUnreachable {
551                operation: "sandbox.preview".to_string(),
552                reason: format!("session '{session_id}' does not belong to this sandbox"),
553            })
554        })?;
555
556        let endpoint = microvm.endpoint.ok_or_else(|| {
557            AlienError::new(ErrorData::SandboxUnreachable {
558                operation: "sandbox.preview".to_string(),
559                reason: format!("MicroVM '{session_id}' has no endpoint yet"),
560            })
561        })?;
562
563        let token = self
564            .microvms
565            .create_microvm_auth_token(session_id, vec![port], PREVIEW_TOKEN_MINUTES)
566            .await
567            .unreachable(
568                "sandbox.preview",
569                &format!("could not mint a preview token for port {port}"),
570            )?;
571
572        let mut headers: BTreeMap<String, String> = token.auth_token.into_iter().collect();
573        headers.insert(PROXY_PORT_HEADER.to_string(), port.to_string());
574
575        Ok(PreviewCapability {
576            endpoint: format!("https://{endpoint}"),
577            headers,
578            allowed_ports: vec![port],
579            expires_in_seconds: preview_lifetime_seconds(),
580        })
581    }
582
583    async fn suspend(&self, session_id: &str) -> Result<()> {
584        self.ensure_owned(session_id).await?;
585
586        self.microvms.suspend_microvm(session_id).await.unreachable(
587            "sandbox.suspend",
588            &format!("could not suspend MicroVM '{session_id}'"),
589        )
590    }
591
592    async fn resume(&self, session_id: &str) -> Result<()> {
593        self.ensure_owned(session_id).await?;
594
595        self.microvms.resume_microvm(session_id).await.unreachable(
596            "sandbox.resume",
597            &format!("could not resume MicroVM '{session_id}'"),
598        )
599    }
600
601    async fn snapshot(&self, _session_id: &str) -> Result<String> {
602        Err(AlienError::new(ErrorData::OperationNotSupported {
603            operation: "sandbox.snapshot".to_string(),
604            reason: "Lambda MicroVMs expose no snapshot API".to_string(),
605        }))
606    }
607
608    /// Idempotent per the trait: a session AWS has already reaped is terminated, not an error.
609    /// Absence and "someone else's" part ways here alone — every other operation needs the
610    /// session to exist, so for them the two are the same refusal.
611    async fn terminate(&self, session_id: &str) -> Result<()> {
612        match self.fetched_microvm(session_id).await? {
613            None => return Ok(()),
614            Some(microvm) if !self.owns(&microvm) => {
615                return Err(AlienError::new(ErrorData::SandboxUnreachable {
616                    operation: "sandbox.terminate".to_string(),
617                    reason: format!("session '{session_id}' does not belong to this sandbox"),
618                }))
619            }
620            Some(_) => {}
621        }
622
623        self.microvms
624            .terminate_microvm(session_id)
625            .await
626            .unreachable(
627                "sandbox.terminate",
628                &format!("could not terminate MicroVM '{session_id}'"),
629            )
630    }
631
632    fn as_any(&self) -> &dyn std::any::Any {
633        self
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use alien_aws_clients::aws::lambda_microvms::{
641        Microvm, MicrovmAuthToken, MockLambdaMicrovmsApi,
642    };
643    use alien_error::Context;
644    use std::time::Duration;
645
646    fn image_version(version: &str) -> alien_aws_clients::aws::lambda_microvms::MicrovmImage {
647        alien_aws_clients::aws::lambda_microvms::MicrovmImage {
648            image_identifier: Some("sbx-image".to_string()),
649            image_arn: None,
650            image_version: Some(version.to_string()),
651            state: Some("CREATED".to_string()),
652        }
653    }
654
655    /// A MicroVM belonging to this sandbox's image. Ownership is now a field on the record, so
656    /// every fixture has to say whose session it is.
657    fn owned(id: &str, state: &str) -> Microvm {
658        Microvm {
659            microvm_id: Some(id.to_string()),
660            endpoint: None,
661            state: Some(state.to_string()),
662            image_arn: Some("sbx-image".to_string()),
663            image_version: Some("1".to_string()),
664        }
665    }
666
667    fn sandbox(client: MockLambdaMicrovmsApi) -> AwsSandbox {
668        sandbox_previewing(client, Vec::new())
669    }
670
671    fn sandbox_previewing(client: MockLambdaMicrovmsApi, preview_ports: Vec<u16>) -> AwsSandbox {
672        AwsSandbox::new(
673            Arc::new(client),
674            "sbx-image",
675            "3",
676            Vec::new(),
677            preview_ports,
678            None,
679            None,
680        )
681    }
682
683    /// AWS reaps the microvm record after termination, so pinning idempotent-on-absent keeps a
684    /// caller's retry loop from flaking once the record is gone.
685    #[tokio::test]
686    async fn terminating_an_absent_session_succeeds() {
687        let mut client = MockLambdaMicrovmsApi::new();
688        client.expect_get_microvm().returning(|id| {
689            Err(alien_error::AlienError::new(
690                alien_client_core::ErrorData::RemoteResourceNotFound {
691                    resource_type: "Microvm".to_string(),
692                    resource_name: id.to_string(),
693                },
694            ))
695        });
696        client.expect_terminate_microvm().never();
697
698        let sandbox = sandbox(client);
699
700        sandbox
701            .terminate("already-reaped")
702            .await
703            .expect("an absent session is already terminated");
704    }
705
706    /// See `capabilities()` for why this tracks `preview_ports`.
707    #[tokio::test]
708    async fn capabilities_reflect_the_declared_preview_ports() {
709        assert!(
710            !sandbox(MockLambdaMicrovmsApi::new()).capabilities().preview,
711            "no declared ports means no preview capability"
712        );
713        assert!(
714            sandbox_previewing(MockLambdaMicrovmsApi::new(), vec![8080])
715                .capabilities()
716                .preview,
717            "a declared port makes the capability real"
718        );
719    }
720
721    /// See `create()` for why these are refused rather than silently dropped.
722    #[tokio::test]
723    async fn unsupported_session_fields_are_refused_not_dropped() {
724        let mut client = MockLambdaMicrovmsApi::new();
725        client.expect_run_microvm().never();
726
727        let sandbox = sandbox(client);
728
729        let with_env = CreateSessionRequest {
730            env: [("KEY".to_string(), "value".to_string())].into(),
731            ..Default::default()
732        };
733        let error = sandbox
734            .create(with_env)
735            .await
736            .expect_err("a session-level env must be refused");
737        assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
738
739        let with_tenant = CreateSessionRequest {
740            tenant_key: Some("tenant-1".to_string()),
741            ..Default::default()
742        };
743        let error = sandbox
744            .create(with_tenant)
745            .await
746            .expect_err("a tenant key must be refused");
747        assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
748    }
749
750    /// IAM cannot draw this line: the stack binding scopes the token mint to
751    /// `microvm-image:<stack prefix>-*`, which matches every sibling sandbox in the stack, so a
752    /// workload passing a sibling's session id would be authorised for it. The session's own
753    /// `imageArn` is what says whose it is.
754    #[tokio::test]
755    async fn a_session_from_another_sandbox_is_refused_before_anything_is_minted() {
756        let mut client = MockLambdaMicrovmsApi::new();
757        client.expect_get_microvm().returning(|id| {
758            Ok(Microvm {
759                microvm_id: Some(id.to_string()),
760                endpoint: Some("vm.example.invalid".to_string()),
761                state: Some("RUNNING".to_string()),
762                // A live session, reachable, running — and belonging to a different sandbox.
763                image_arn: Some("someone-elses-image".to_string()),
764                image_version: Some("1".to_string()),
765            })
766        });
767        client.expect_create_microvm_auth_token().never();
768        client.expect_terminate_microvm().never();
769        client.expect_suspend_microvm().never();
770
771        let sandbox = sandbox_previewing(client, vec![8080]);
772
773        for outcome in [
774            sandbox.preview("a-siblings-session", 8080).await.err(),
775            sandbox.terminate("a-siblings-session").await.err(),
776            sandbox.suspend("a-siblings-session").await.err(),
777        ] {
778            let error = outcome.expect("a session this sandbox does not own is refused");
779            assert!(
780                error
781                    .to_string()
782                    .contains("does not belong to this sandbox"),
783                "names the reason: {error}"
784            );
785        }
786
787        assert!(
788            sandbox
789                .get("a-siblings-session")
790                .await
791                .expect("reading it is not an error")
792                .is_none(),
793            "a sibling's session reads as absent rather than as one of ours"
794        );
795    }
796
797    /// The absent-session path, built the way the client builds it rather than by hand. `get`
798    /// must report a session that does not exist as `None`, because `get_or_create` reads that
799    /// answer to decide whether to create one — an error there means a caller supplying a fresh
800    /// id can never create a session at all.
801    #[tokio::test]
802    async fn a_session_that_does_not_exist_reads_as_absent_rather_than_as_a_failure() {
803        let mut client = MockLambdaMicrovmsApi::new();
804        client.expect_get_microvm().returning(|_| {
805            Err(alien_error::AlienError::new(
806                alien_client_core::ErrorData::RemoteResourceNotFound {
807                    resource_type: "Microvm".to_string(),
808                    resource_name: "GetMicrovm".to_string(),
809                },
810            ))
811        });
812
813        assert!(sandbox(client)
814            .get("never-existed")
815            .await
816            .expect("an absent session is an answer, not an error")
817            .is_none());
818    }
819
820    /// A read that genuinely failed is not an absent session. Flattening it into `None` would
821    /// have `get_or_create` start a second session while the first is still running.
822    #[tokio::test]
823    async fn a_failed_read_is_not_reported_as_an_absent_session() {
824        let mut client = MockLambdaMicrovmsApi::new();
825        client.expect_get_microvm().returning(|_| {
826            Err(alien_error::AlienError::new(
827                alien_client_core::ErrorData::RateLimitExceeded {
828                    message: "throttled".to_string(),
829                },
830            ))
831        });
832
833        sandbox(client)
834            .get("ours")
835            .await
836            .expect_err("a throttle is not an absent session");
837    }
838
839    /// A response the client could not parse an image out of must not pass as ours. Defaulting
840    /// the other way would make every unparsed session belong to whoever asked.
841    #[tokio::test]
842    async fn a_session_with_no_image_is_not_assumed_to_be_ours() {
843        let mut client = MockLambdaMicrovmsApi::new();
844        client.expect_get_microvm().returning(|id| {
845            Ok(Microvm {
846                microvm_id: Some(id.to_string()),
847                endpoint: Some("vm.example.invalid".to_string()),
848                state: Some("RUNNING".to_string()),
849                image_arn: None,
850                image_version: None,
851            })
852        });
853        client.expect_create_microvm_auth_token().never();
854
855        let error = sandbox_previewing(client, vec![8080])
856            .preview("unlabelled", 8080)
857            .await
858            .expect_err("an unattributable session is refused");
859        assert!(error
860            .to_string()
861            .contains("does not belong to this sandbox"));
862    }
863
864    /// The check costs one `GetMicrovm`, which `sandbox/execute` grants scoped to this image.
865    /// Enumerating instead would need `ListMicrovms`, which that set does not carry — an app
866    /// linked to a sandbox would fail on its first command.
867    #[tokio::test]
868    async fn reaching_a_session_does_not_enumerate_the_image() {
869        let mut client = MockLambdaMicrovmsApi::new();
870        client.expect_list_microvms().never();
871        client.expect_list_microvm_image_versions().never();
872        client
873            .expect_get_microvm()
874            .returning(|id| Ok(owned(id, "RUNNING")));
875
876        let session = sandbox(client)
877            .get("ours")
878            .await
879            .expect("reading our own session succeeds")
880            .expect("it is present");
881        assert_eq!(session.session_id, "ours");
882    }
883
884    /// A bare URL would be unusable: the endpoint refuses anything without the token headers and
885    /// the port header, so a caller handed only a string would have to rebuild the auth.
886    /// AWS answers 502 at the proxy while a MicroVM's snapshot restores, so the wait exists to
887    /// outlast that window rather than to hand the first command a session that cannot serve.
888    /// Served over plain HTTP against a local listener: what is under test is the polling, not
889    /// the transport that reaches a real MicroVM.
890    #[tokio::test]
891    async fn the_readiness_poll_outlasts_the_snapshot_restore_window() {
892        use std::sync::atomic::{AtomicUsize, Ordering};
893
894        let attempts = std::sync::Arc::new(AtomicUsize::new(0));
895        let seen = attempts.clone();
896        let handler = move || {
897            let seen = seen.clone();
898            async move {
899                // Two 502s with an empty body — exactly what the proxy returns mid-restore.
900                if seen.fetch_add(1, Ordering::SeqCst) < 2 {
901                    axum::http::StatusCode::BAD_GATEWAY
902                } else {
903                    axum::http::StatusCode::OK
904                }
905            }
906        };
907        let router = axum::Router::new().route(HEALTH_PATH, axum::routing::get(handler));
908        let listener = tokio::net::TcpListener::bind::<std::net::SocketAddr>(
909            "127.0.0.1:0".parse().expect("a loopback address"),
910        )
911        .await
912        .expect("bind");
913        let address = listener.local_addr().expect("address");
914        tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
915
916        let probe = reqwest::Client::new().get(format!("http://{address}{HEALTH_PATH}"));
917        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
918
919        AwsSandbox::poll_until_healthy(probe, deadline, "mvm-restoring")
920            .await
921            .expect("the wait must outlast a restore that answers 502 before it answers 200");
922        assert_eq!(
923            attempts.load(Ordering::SeqCst),
924            3,
925            "it must keep probing through the restore rather than give up on the first 502"
926        );
927    }
928
929    /// The counterpart: a MicroVM that never answers has to fail, and say which session.
930    #[tokio::test]
931    async fn the_readiness_poll_gives_up_on_a_session_that_never_answers() {
932        let router = axum::Router::new().route(
933            HEALTH_PATH,
934            axum::routing::get(|| async { axum::http::StatusCode::BAD_GATEWAY }),
935        );
936        let listener = tokio::net::TcpListener::bind::<std::net::SocketAddr>(
937            "127.0.0.1:0".parse().expect("a loopback address"),
938        )
939        .await
940        .expect("bind");
941        let address = listener.local_addr().expect("address");
942        tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
943
944        let probe = reqwest::Client::new().get(format!("http://{address}{HEALTH_PATH}"));
945        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(30);
946
947        let error = AwsSandbox::poll_until_healthy(probe, deadline, "mvm-dead")
948            .await
949            .expect_err("a session that never answers must not be reported as ready");
950        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
951        assert!(
952            error.to_string().contains("mvm-dead") && error.to_string().contains("502"),
953            "the failure has to name the session and what it last saw: {error}"
954        );
955    }
956
957    #[tokio::test]
958    async fn preview_returns_the_headers_a_caller_cannot_construct() {
959        let mut client = MockLambdaMicrovmsApi::new();
960        client
961            .expect_list_microvm_image_versions()
962            .returning(|_| Ok(vec![image_version("3")]));
963        client.expect_list_microvms().returning(|_, _| {
964            Ok(vec![Microvm {
965                microvm_id: Some("mvm-1".into()),
966                endpoint: None,
967                state: Some("RUNNING".into()),
968                image_arn: Some("sbx-image".to_string()),
969                image_version: Some("1".to_string()),
970            }])
971        });
972        client.expect_get_microvm().returning(|_| {
973            Ok(Microvm {
974                microvm_id: Some("mvm-1".to_string()),
975                endpoint: Some("mvm-1.lambda-microvms.aws".to_string()),
976                state: Some("RUNNING".to_string()),
977                image_arn: Some("sbx-image".to_string()),
978                image_version: Some("1".to_string()),
979            })
980        });
981        client
982            .expect_create_microvm_auth_token()
983            .withf(|_, ports, minutes| {
984                ports.as_slice() == [8080] && *minutes == PREVIEW_TOKEN_MINUTES
985            })
986            .returning(|_, _, _| {
987                Ok(MicrovmAuthToken {
988                    auth_token: std::collections::HashMap::from([(
989                        "X-aws-proxy-auth".to_string(),
990                        "jwe-value".to_string(),
991                    )]),
992                })
993            });
994
995        let capability = sandbox_previewing(client, vec![8080])
996            .preview("mvm-1", 8080)
997            .await
998            .expect("mints");
999
1000        assert_eq!(capability.endpoint, "https://mvm-1.lambda-microvms.aws");
1001        assert_eq!(
1002            capability
1003                .headers
1004                .get("X-aws-proxy-auth")
1005                .map(String::as_str),
1006            Some("jwe-value")
1007        );
1008        assert_eq!(
1009            capability
1010                .headers
1011                .get(PROXY_PORT_HEADER)
1012                .map(String::as_str),
1013            Some("8080")
1014        );
1015        assert_eq!(capability.allowed_ports, vec![8080]);
1016        assert_eq!(capability.expires_in_seconds, 1800);
1017    }
1018
1019    /// The declared list is what bounds ingress for callers on this path: `CreateMicrovmAuthToken`
1020    /// mints a token for whatever port it is handed and has no port condition key, so an unlisted
1021    /// port must be refused before the call rather than after it.
1022    #[tokio::test]
1023    async fn a_port_the_stack_did_not_declare_is_refused_before_a_token_exists() {
1024        let mut client = MockLambdaMicrovmsApi::new();
1025        client.expect_get_microvm().never();
1026        client.expect_create_microvm_auth_token().never();
1027
1028        let error = sandbox_previewing(client, vec![8080])
1029            .preview("mvm-1", 22)
1030            .await
1031            .expect_err("port 22 was never declared");
1032
1033        assert!(
1034            error.to_string().contains("22"),
1035            "the refusal must name the port asked for: {error}"
1036        );
1037    }
1038
1039    /// The figure handed to a caller must not outrun the token behind it: AWS caps the mint at
1040    /// 60 minutes, so an unclamped 30-minute promise would still be honest, but a raised
1041    /// `PREVIEW_TOKEN_MINUTES` past the cap would not.
1042    #[test]
1043    fn a_reported_preview_lifetime_never_exceeds_what_aws_will_mint() {
1044        assert_eq!(preview_lifetime_seconds(), 1800);
1045        assert!(
1046            preview_lifetime_seconds() <= u64::from(MAX_AUTH_TOKEN_MINUTES) * 60,
1047            "the reported lifetime must not outrun the cap the client sends"
1048        );
1049    }
1050
1051    /// The lifecycle states AWS reports, mapped onto the binding's. Read through `get`, which is
1052    /// the only way a session is reached now that enumeration is gone.
1053    #[tokio::test]
1054    async fn a_microvm_that_is_not_running_yet_is_reported_as_starting() {
1055        for (aws_state, expected) in [
1056            ("PENDING", SandboxSessionState::Starting),
1057            ("RUNNING", SandboxSessionState::Running),
1058            ("SUSPENDED", SandboxSessionState::Suspended),
1059            ("TERMINATED", SandboxSessionState::Terminated),
1060        ] {
1061            let mut client = MockLambdaMicrovmsApi::new();
1062            client
1063                .expect_get_microvm()
1064                .returning(move |id| Ok(owned(id, aws_state)));
1065
1066            let session = sandbox(client)
1067                .get("s1")
1068                .await
1069                .expect("reads")
1070                .expect("present");
1071            assert_eq!(session.state, expected, "AWS state {aws_state}");
1072        }
1073    }
1074
1075    /// The declared ceiling has to survive the last hop as well as the first: the binding carries
1076    /// it onto `AwsSandbox`, and only this call puts it on the wire. A field dropped here would
1077    /// leave a sandbox running past a limit its stack declared, with every other test still green.
1078    #[tokio::test]
1079    async fn the_declared_lifetime_reaches_the_run_call() {
1080        let mut client = MockLambdaMicrovmsApi::new();
1081        client
1082            .expect_run_microvm()
1083            .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(1800))
1084            .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1085        // The wait reads the session back; with no endpoint published it stays unreachable,
1086        // which is all a unit test can offer. Create then terminates what it started.
1087        client
1088            .expect_get_microvm()
1089            .returning(|id| Ok(owned(id, "RUNNING")));
1090        client
1091            .expect_terminate_microvm()
1092            .times(1)
1093            .returning(|_| Ok(()));
1094
1095        let result = AwsSandbox::new(
1096            std::sync::Arc::new(client),
1097            "sbx-image",
1098            "3",
1099            vec!["connector".to_string()],
1100            Vec::new(),
1101            None,
1102            Some(1800),
1103        )
1104        .create(CreateSessionRequest {
1105            session_id: None,
1106            tenant_key: None,
1107            env: BTreeMap::new(),
1108        })
1109        .await;
1110
1111        // `withf` above is the assertion: a run carrying the wrong ceiling matches no
1112        // expectation and panics. Create then waits for an agent no unit test can serve.
1113        let error = result.expect_err("no agent answers in a unit test");
1114        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1115        assert!(
1116            error.to_string().contains("published no endpoint"),
1117            "the failure has to name the readiness wait, not any error: {error}"
1118        );
1119    }
1120
1121    /// Observed live: AWS returns the MicroVM a client token previously created **even after it
1122    /// is terminated**. Using the caller's session id as that token hands back a dead MicroVM
1123    /// and then waits for it to start, which is a hang, not an error.
1124    #[tokio::test]
1125    async fn a_caller_supplied_session_id_is_never_the_client_token() {
1126        let mut client = MockLambdaMicrovmsApi::new();
1127        client
1128            .expect_run_microvm()
1129            .withf(|image, version, token, _, _, _, _| {
1130                image == "sbx-image" && version == "3" && token != "caller-chosen"
1131            })
1132            .returning(|_, _, _, _, _, _, _| {
1133                Ok(Microvm {
1134                    microvm_id: Some("mvm-9".to_string()),
1135                    endpoint: None,
1136                    state: Some("PENDING".to_string()),
1137                    image_arn: Some("sbx-image".to_string()),
1138                    image_version: Some("1".to_string()),
1139                })
1140            });
1141        // The wait reads the session back; with no endpoint published it stays unreachable,
1142        // which is all a unit test can offer. Create then terminates what it started.
1143        client
1144            .expect_get_microvm()
1145            // AWS assigns the id and `create` has to carry that one forward, not the caller's.
1146            .withf(|id| id == "mvm-9")
1147            .returning(|id| Ok(owned(id, "RUNNING")));
1148        client
1149            .expect_terminate_microvm()
1150            .withf(|id| id == "mvm-9")
1151            .times(1)
1152            .returning(|_| Ok(()));
1153
1154        let result = sandbox(client)
1155            .create(CreateSessionRequest {
1156                session_id: Some("caller-chosen".to_string()),
1157                tenant_key: None,
1158                env: BTreeMap::new(),
1159            })
1160            .await;
1161
1162        // The client token assertion is `withf` above; a run reusing the caller's id matches no
1163        // expectation and panics. Create then waits for an agent a unit test cannot serve.
1164        let error = result.expect_err("no agent answers in a unit test");
1165        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1166        assert!(
1167            error.to_string().contains("published no endpoint"),
1168            "the failure has to name the readiness wait, not any error: {error}"
1169        );
1170    }
1171
1172    #[tokio::test]
1173    async fn a_command_without_a_deadline_is_refused_before_any_aws_call() {
1174        // No expectations set: a call to AWS here would fail the mock, which is the assertion.
1175        let outcome = sandbox(MockLambdaMicrovmsApi::new())
1176            .run_command(
1177                "mvm-1",
1178                RunCommandRequest {
1179                    command: vec!["/bin/echo".to_string()],
1180                    working_directory: None,
1181                    env: BTreeMap::new(),
1182                    deadline: Duration::ZERO,
1183                },
1184            )
1185            .await;
1186
1187        match outcome {
1188            Ok(_) => panic!("a zero deadline must be refused"),
1189            Err(error) => assert!(error.to_string().contains("non-zero deadline"), "{error}"),
1190        }
1191    }
1192
1193    /// A rolled image version does not end the sessions running on the previous one, and does
1194    /// not change whose they are. Comparing the version as well as the image would make `get`
1195    /// return None for a live session after a roll, which a caller reads as expired — the exact
1196    /// false negative GCP's capability set refuses to ship.
1197    #[tokio::test]
1198    async fn a_session_on_a_previous_image_version_is_still_ours() {
1199        let mut client = MockLambdaMicrovmsApi::new();
1200        client.expect_get_microvm().returning(|id| {
1201            Ok(Microvm {
1202                microvm_id: Some(id.to_string()),
1203                endpoint: None,
1204                state: Some("RUNNING".to_string()),
1205                image_arn: Some("sbx-image".to_string()),
1206                // The binding is pinned to version 3; this session predates the roll.
1207                image_version: Some("2".to_string()),
1208            })
1209        });
1210
1211        let found = sandbox(client)
1212            .get("older")
1213            .await
1214            .expect("reads")
1215            .expect("a session on the previous version is still live and still ours");
1216
1217        assert_eq!(found.session_id, "older");
1218    }
1219
1220    /// Enumeration would cost an account-wide `ListMicrovms`, and the case it would serve —
1221    /// a `RunMicrovm` whose response never arrived, leaving a MicroVM nobody holds the id for —
1222    /// is already handled by Lambda: no traffic reaches an orphan's endpoint, so it suspends
1223    /// after the idle duration and is terminated after the suspended one.
1224    #[tokio::test]
1225    async fn sessions_are_not_enumerable_and_nothing_asks_aws_to_be() {
1226        let mut client = MockLambdaMicrovmsApi::new();
1227        client.expect_list_microvms().never();
1228        client.expect_list_microvm_image_versions().never();
1229
1230        let error = sandbox(client)
1231            .list()
1232            .await
1233            .expect_err("listing is not offered on AWS");
1234        assert!(
1235            error.to_string().contains("get"),
1236            "points the caller at what does work: {error}"
1237        );
1238    }
1239
1240    /// A `RunMicrovm` refused for a missing IAM action, shaped as `LambdaMicrovmsClient::send`
1241    /// shapes one: the transport records the response body, and `classify` wraps a non-404 as
1242    /// its own generic failure.
1243    fn refused_run() -> Result<Microvm, alien_client_core::ErrorData> {
1244        Err(AlienError::new(
1245            alien_client_core::ErrorData::HttpResponseError {
1246                message: "Request failed with HTTP 403: Forbidden".to_string(),
1247                url: "https://lambda.us-east-2.amazonaws.com/2025-09-09/microvms".to_string(),
1248                http_status: 403,
1249                http_request_text: None,
1250                http_response_text: Some(
1251                    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"}"#
1252                        .to_string(),
1253                ),
1254            },
1255        ))
1256        .context(alien_client_core::ErrorData::GenericError {
1257            message: "Lambda MicroVMs RunMicrovm failed".to_string(),
1258        })
1259    }
1260
1261    /// The refused action is what sends a reader to the role rather than to this code, and the
1262    /// wire format past this binding is a flat message string — so `reason` is the only place a
1263    /// structured consumer sees it. It reaches an operator's log alone: an IAM identity makes the
1264    /// whole error internal, and `into_external` replaces it.
1265    #[tokio::test]
1266    async fn a_refused_create_reports_what_aws_refused_it_with() {
1267        let mut client = MockLambdaMicrovmsApi::new();
1268        client
1269            .expect_run_microvm()
1270            .returning(|_, _, _, _, _, _, _| refused_run());
1271        // Nothing was started, so nothing is cleaned up.
1272        client.expect_terminate_microvm().never();
1273
1274        let error = sandbox(client)
1275            .create(CreateSessionRequest {
1276                session_id: None,
1277                tenant_key: None,
1278                env: BTreeMap::new(),
1279            })
1280            .await
1281            .expect_err("a refused RunMicrovm cannot produce a session");
1282
1283        assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1284        assert!(
1285            error
1286                .message
1287                .contains("could not start a MicroVM from 'sbx-image'"),
1288            "the binding still says which call it was: {}",
1289            error.message
1290        );
1291        assert!(
1292            error
1293                .message
1294                .contains("is not authorized to perform: lambda:PassNetworkConnector"),
1295            "and AWS's own sentence is what tells the operator why: {}",
1296            error.message
1297        );
1298        assert!(
1299            error.internal,
1300            "an IAM identity in the message makes the error internal: {error}"
1301        );
1302        assert_eq!(
1303            error.into_external().message,
1304            "Internal server error",
1305            "so none of it is published to the caller"
1306        );
1307    }
1308}