1use 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
29const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
31
32#[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#[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
48const HEALTH_PATH: &str = "/v1/health";
50
51const AGENT_TOKEN_MINUTES: u32 = 5;
56
57const PREVIEW_TOKEN_MINUTES: u32 = 30;
62
63fn preview_lifetime_seconds() -> u64 {
65 u64::from(PREVIEW_TOKEN_MINUTES.min(MAX_AUTH_TOKEN_MINUTES)) * 60
66}
67
68#[derive(Debug)]
70pub struct AwsSandbox {
71 microvms: Arc<dyn LambdaMicrovmsApi>,
72 image_identifier: String,
73 image_version: String,
74 egress_connector_arns: Vec<String>,
77 preview_ports: Vec<u16>,
81 idle_suspend_seconds: Option<u32>,
83 max_lifetime_seconds: Option<u32>,
86 agent: reqwest::Client,
87}
88
89impl AwsSandbox {
90 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 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 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 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 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 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 async fn authorized_request(
180 &self,
181 session_id: &str,
182 method: reqwest::Method,
183 path: &str,
184 ) -> Result<reqwest::RequestBuilder> {
185 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 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 generation: 1,
231 }
232 }
233}
234
235fn 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 _ => SandboxSessionState::Starting,
244 }
245}
246
247impl AwsSandbox {
248 async fn wait_until_servable(&self, session_id: &str) -> Result<()> {
250 let deadline = std::time::Instant::now() + SESSION_READY_TIMEOUT;
251
252 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 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 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
338fn 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 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 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 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 if let Err(error) = self.wait_until_servable(µvm_id).await {
420 if let Err(cleanup) = self.microvms.terminate_microvm(µvm_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 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 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 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 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 async fn preview(&self, session_id: &str, port: u16) -> Result<PreviewCapability> {
536 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 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 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(µvm) => {
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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 client
1144 .expect_get_microvm()
1145 .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 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 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 #[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 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 #[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 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 #[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 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}