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, CreateSandboxRequest, JobPoll, JobStart, PreviewCapability,
22 ResolvedSandbox, RunCommandRequest, Sandbox, SandboxInstance, SandboxState,
23};
24use alien_aws_clients::aws::lambda_microvms::{LambdaMicrovmsApi, Microvm, MAX_AUTH_TOKEN_MINUTES};
25use alien_core::{Platform, SandboxCapabilities};
26use alien_error::{AlienError, ContextError};
27use tracing::warn;
28
29const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
31
32#[cfg(not(test))]
38const SANDBOX_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
39#[cfg(not(test))]
40const SANDBOX_READY_POLL: std::time::Duration = std::time::Duration::from_millis(500);
41
42#[cfg(test)]
44const SANDBOX_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20);
45#[cfg(test)]
46const SANDBOX_READY_POLL: std::time::Duration = std::time::Duration::from_millis(5);
47
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_pause_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_pause_seconds: Option<u32>,
98 max_lifetime_seconds: Option<u32>,
99 ) -> Self {
100 Self {
101 microvms,
102 image_identifier: image_identifier.into(),
103 image_version: image_version.into(),
104 egress_connector_arns,
105 preview_ports,
106 idle_pause_seconds,
107 max_lifetime_seconds,
108 agent: reqwest::Client::new(),
109 }
110 }
111
112 fn lifetime_seconds(&self, timeout_ms: Option<u64>, operation: &str) -> Result<Option<u32>> {
116 match timeout_ms {
117 Some(timeout_ms) => {
118 super::requested_lifetime_seconds(timeout_ms, self.max_lifetime_seconds, operation)
119 .map(Some)
120 }
121 None => Ok(self.max_lifetime_seconds),
122 }
123 }
124
125 async fn fetched_microvm(&self, operation: &str, sandbox_id: &str) -> Result<Option<Microvm>> {
129 let microvm = match self.microvms.get_microvm(sandbox_id).await {
130 Ok(microvm) => microvm,
131 Err(error)
136 if matches!(
137 &error.error,
138 Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
139 ) =>
140 {
141 return Ok(None)
142 }
143 Err(error) => {
144 return Err(error)
145 .unreachable(operation, &format!("could not read sandbox '{sandbox_id}'"))
146 }
147 };
148
149 Ok(Some(microvm))
150 }
151
152 fn owns(&self, microvm: &Microvm) -> bool {
159 microvm
160 .image_arn
161 .as_deref()
162 .is_some_and(|image| image == self.image_identifier)
163 }
164
165 async fn owned_microvm(&self, operation: &str, sandbox_id: &str) -> Result<Option<Microvm>> {
168 Ok(self
169 .fetched_microvm(operation, sandbox_id)
170 .await?
171 .filter(|microvm| self.owns(microvm)))
172 }
173
174 async fn ensure_owned(&self, operation: &str, sandbox_id: &str) -> Result<()> {
176 if self.owned_microvm(operation, sandbox_id).await?.is_none() {
177 return Err(AlienError::new(ErrorData::SandboxUnreachable {
178 operation: operation.to_string(),
179 reason: format!("sandbox '{sandbox_id}' does not belong to this sandbox resource"),
180 }));
181 }
182 Ok(())
183 }
184
185 async fn authorized_request(
191 &self,
192 sandbox_id: &str,
193 method: reqwest::Method,
194 path: &str,
195 ) -> Result<reqwest::RequestBuilder> {
196 let microvm = self
199 .owned_microvm("sandbox.agent", sandbox_id)
200 .await?
201 .ok_or_else(|| {
202 AlienError::new(ErrorData::SandboxUnreachable {
203 operation: "sandbox.agent".to_string(),
204 reason: format!(
205 "sandbox '{sandbox_id}' does not belong to this sandbox resource"
206 ),
207 })
208 })?;
209
210 let endpoint = microvm.endpoint.ok_or_else(|| {
211 AlienError::new(ErrorData::SandboxUnreachable {
212 operation: "sandbox.agent".to_string(),
213 reason: format!("MicroVM '{sandbox_id}' has no endpoint yet"),
214 })
215 })?;
216
217 let token = self
218 .microvms
219 .create_microvm_auth_token(sandbox_id, vec![AGENT_PORT], AGENT_TOKEN_MINUTES)
220 .await
221 .unreachable(
222 "sandbox.agent",
223 &format!("could not mint an endpoint token for '{sandbox_id}'"),
224 )?;
225
226 let mut request = self
227 .agent
228 .request(method, format!("https://{endpoint}{path}"))
229 .header(PROXY_PORT_HEADER, AGENT_PORT.to_string());
230
231 for (name, value) in token.auth_token {
234 request = request.header(name, value);
235 }
236
237 Ok(request)
238 }
239
240 fn instance(&self, microvm_id: String, state: Option<String>) -> SandboxInstance {
241 SandboxInstance {
242 sandbox_id: microvm_id,
243 state: sandbox_state(state.as_deref()),
244 generation: 1,
247 }
248 }
249}
250
251fn sandbox_state(state: Option<&str>) -> SandboxState {
253 match state {
254 Some("RUNNING") => SandboxState::Running,
255 Some("SUSPENDED") => SandboxState::Paused,
256 Some("TERMINATED") | Some("TERMINATING") => SandboxState::Terminated,
257 _ => SandboxState::Starting,
260 }
261}
262
263impl AwsSandbox {
264 async fn fetch(&self, sandbox_id: &str, operation: &str) -> Result<Option<SandboxInstance>> {
270 let Some(microvm) = self.owned_microvm(operation, sandbox_id).await? else {
271 return Ok(None);
272 };
273
274 let microvm_id = microvm.microvm_id.ok_or_else(|| {
278 AlienError::new(ErrorData::SandboxUnreachable {
279 operation: operation.to_string(),
280 reason: format!("the record for sandbox '{sandbox_id}' carried no id"),
281 })
282 })?;
283
284 Ok(Some(self.instance(microvm_id, microvm.state)))
285 }
286
287 async fn wait_until_servable(&self, sandbox_id: &str) -> Result<()> {
289 let deadline = std::time::Instant::now() + SANDBOX_READY_TIMEOUT;
290
291 let probe = loop {
295 let published = self
296 .owned_microvm("sandbox.create", sandbox_id)
297 .await?
298 .is_some_and(|microvm| microvm.endpoint.is_some());
299
300 if published {
301 break self
302 .authorized_request(sandbox_id, reqwest::Method::GET, HEALTH_PATH)
303 .await?;
304 }
305 if std::time::Instant::now() >= deadline {
306 return Err(AlienError::new(ErrorData::SandboxUnreachable {
307 operation: "sandbox.create".to_string(),
308 reason: format!(
309 "MicroVM '{sandbox_id}' published no endpoint within {}s",
310 SANDBOX_READY_TIMEOUT.as_secs()
311 ),
312 }));
313 }
314 tokio::time::sleep(SANDBOX_READY_POLL).await;
315 };
316
317 Self::poll_until_healthy(probe, deadline, sandbox_id).await
318 }
319
320 async fn poll_until_healthy(
325 probe: reqwest::RequestBuilder,
326 deadline: std::time::Instant,
327 sandbox_id: &str,
328 ) -> Result<()> {
329 let mut last_seen;
330 loop {
331 let request = probe.try_clone().ok_or_else(|| {
334 AlienError::new(ErrorData::SandboxUnreachable {
335 operation: "sandbox.create".to_string(),
336 reason: "the readiness probe could not be repeated".to_string(),
337 })
338 })?;
339
340 match request.send().await {
341 Ok(response) if response.status().is_success() => return Ok(()),
342 Ok(response) => last_seen = format!("the endpoint answered {}", response.status()),
343 Err(error) => last_seen = error.to_string(),
344 }
345
346 if std::time::Instant::now() >= deadline {
347 return Err(AlienError::new(ErrorData::SandboxUnreachable {
348 operation: "sandbox.create".to_string(),
349 reason: format!(
350 "MicroVM '{sandbox_id}' did not become servable in time: {last_seen}"
351 ),
352 }));
353 }
354 tokio::time::sleep(SANDBOX_READY_POLL).await;
355 }
356 }
357}
358
359#[async_trait]
360impl AgentTransport for AwsSandbox {
361 async fn request(
362 &self,
363 sandbox_id: &str,
364 method: reqwest::Method,
365 path: &str,
366 ) -> Result<reqwest::RequestBuilder> {
367 self.authorized_request(sandbox_id, method, path).await
368 }
369
370 fn provider(&self) -> &'static str {
371 "aws-sandbox"
372 }
373}
374
375impl Binding for AwsSandbox {}
376
377fn refuse_unsupported_create_fields(request: &CreateSandboxRequest, operation: &str) -> Result<()> {
381 if !request.env.is_empty() {
382 return Err(AlienError::new(ErrorData::OperationNotSupported {
383 operation: operation.to_string(),
384 reason: "AWS sandboxes take no sandbox-level env; set env per command instead"
385 .to_string(),
386 }));
387 }
388 if request.tenant_key.is_some() {
389 return Err(AlienError::new(ErrorData::OperationNotSupported {
390 operation: operation.to_string(),
391 reason: "AWS sandboxes take no tenantKey; a MicroVM is already single-tenant"
392 .to_string(),
393 }));
394 }
395 Ok(())
396}
397
398#[async_trait]
399impl Sandbox for AwsSandbox {
400 fn capabilities(&self) -> SandboxCapabilities {
404 let mut capabilities =
405 SandboxCapabilities::for_platform(Platform::Aws).expect("AWS has a sandbox backend");
406 capabilities.preview = !self.preview_ports.is_empty();
407 capabilities
408 }
409
410 async fn create(&self, request: CreateSandboxRequest) -> Result<SandboxInstance> {
418 let _ = request.sandbox_id;
419 refuse_unsupported_create_fields(&request, "sandbox.create")?;
420 let max_lifetime_seconds = self.lifetime_seconds(request.timeout_ms, "sandbox.create")?;
421 let client_token = uuid::Uuid::new_v4().simple().to_string();
422
423 let microvm = self
424 .microvms
425 .run_microvm(
426 &self.image_identifier,
427 &self.image_version,
428 &client_token,
429 None,
432 self.egress_connector_arns.clone(),
433 self.idle_pause_seconds,
434 max_lifetime_seconds,
435 )
436 .await
437 .unreachable(
438 "sandbox.create",
439 &format!("could not start a MicroVM from '{}'", self.image_identifier),
440 )?;
441
442 let microvm_id = microvm.microvm_id.ok_or_else(|| {
443 AlienError::new(ErrorData::UnexpectedResponseFormat {
444 provider: "aws-sandbox".to_string(),
445 binding_name: "sandbox.create".to_string(),
446 field: "microvmId".to_string(),
447 response_json: "RunMicrovm returned no MicroVM id".to_string(),
448 })
449 })?;
450
451 if let Err(error) = self.wait_until_servable(µvm_id).await {
457 return Err(match self.microvms.terminate_microvm(µvm_id).await {
458 Ok(()) => error,
459 Err(cleanup) => {
460 warn!(
461 microvm = %microvm_id,
462 %cleanup,
463 "could not terminate a MicroVM that never became servable"
464 );
465 error.context(ErrorData::SandboxCommandFailed {
468 failure: "sandboxLeftBehind".to_string(),
469 reason: format!(
470 "MicroVM '{microvm_id}' was not handed to its caller and could not be \
471 terminated, so it is still running"
472 ),
473 })
474 }
475 });
476 }
477
478 Ok(self.instance(microvm_id, Some("RUNNING".to_string())))
479 }
480
481 async fn get(&self, sandbox_id: &str) -> Result<Option<SandboxInstance>> {
482 self.fetch(sandbox_id, "sandbox.get").await
483 }
484
485 async fn get_or_create(&self, request: CreateSandboxRequest) -> Result<ResolvedSandbox> {
486 refuse_unsupported_create_fields(&request, "sandbox.getOrCreate")?;
489 if let Some(id) = request.sandbox_id.as_deref() {
490 if let Some(existing) = self.fetch(id, "sandbox.getOrCreate").await? {
491 if matches!(existing.state, SandboxState::Starting) {
495 self.wait_until_servable(id).await?;
496 return Ok(ResolvedSandbox::found(
497 self.instance(id.to_string(), Some("RUNNING".to_string())),
498 ));
499 }
500 return Ok(ResolvedSandbox::found(existing));
501 }
502 }
503
504 self.create(request).await.map(ResolvedSandbox::created)
505 }
506
507 async fn list(&self) -> Result<Vec<SandboxInstance>> {
519 Err(AlienError::new(ErrorData::OperationNotSupported {
520 operation: "sandbox.list".to_string(),
521 reason:
522 "enumerating sandboxes would need an account-wide grant; reach a known sandbox \
523 with get, and Lambda terminates one nobody reaches"
524 .to_string(),
525 }))
526 }
527
528 async fn run_command(
529 &self,
530 sandbox_id: &str,
531 request: RunCommandRequest,
532 ) -> Result<BoxStream<'static, Result<CommandOutput>>> {
533 agent_protocol::run_command(self, sandbox_id, request).await
534 }
535
536 async fn start_job(&self, sandbox_id: &str, request: RunCommandRequest) -> Result<JobStart> {
537 agent_protocol::start_job(self, sandbox_id, request).await
538 }
539
540 async fn poll_job(
541 &self,
542 sandbox_id: &str,
543 job_id: &str,
544 since_seq: Option<u64>,
545 ) -> Result<JobPoll> {
546 agent_protocol::poll_job(self, sandbox_id, job_id, since_seq).await
547 }
548
549 async fn cancel_job(&self, sandbox_id: &str, job_id: &str) -> Result<()> {
550 agent_protocol::cancel_job(self, sandbox_id, job_id).await
551 }
552
553 async fn read_file(&self, sandbox_id: &str, path: &str) -> Result<Vec<u8>> {
554 agent_protocol::read_file(self, sandbox_id, path).await
555 }
556
557 async fn write_files(&self, sandbox_id: &str, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
558 agent_protocol::write_files(self, sandbox_id, files).await
559 }
560
561 async fn preview(&self, sandbox_id: &str, port: u16) -> Result<PreviewCapability> {
567 if !self.preview_ports.contains(&port) {
569 return Err(AlienError::new(ErrorData::OperationNotSupported {
570 operation: "sandbox.preview".to_string(),
571 reason: format!(
572 "port {port} is not one of this sandbox's declared preview ports {:?}; a \
573 minted token would grant ingress the stack never asked for",
574 self.preview_ports
575 ),
576 }));
577 }
578
579 let microvm = self
581 .owned_microvm("sandbox.preview", sandbox_id)
582 .await?
583 .ok_or_else(|| {
584 AlienError::new(ErrorData::SandboxUnreachable {
585 operation: "sandbox.preview".to_string(),
586 reason: format!(
587 "sandbox '{sandbox_id}' does not belong to this sandbox resource"
588 ),
589 })
590 })?;
591
592 let endpoint = microvm.endpoint.ok_or_else(|| {
593 AlienError::new(ErrorData::SandboxUnreachable {
594 operation: "sandbox.preview".to_string(),
595 reason: format!("MicroVM '{sandbox_id}' has no endpoint yet"),
596 })
597 })?;
598
599 let token = self
600 .microvms
601 .create_microvm_auth_token(sandbox_id, vec![port], PREVIEW_TOKEN_MINUTES)
602 .await
603 .unreachable(
604 "sandbox.preview",
605 &format!("could not mint a preview token for port {port}"),
606 )?;
607
608 let mut headers: BTreeMap<String, String> = token.auth_token.into_iter().collect();
609 headers.insert(PROXY_PORT_HEADER.to_string(), port.to_string());
610
611 Ok(PreviewCapability {
612 endpoint: format!("https://{endpoint}"),
613 headers,
614 allowed_ports: vec![port],
615 expires_in_seconds: preview_lifetime_seconds(),
616 })
617 }
618
619 async fn pause(&self, sandbox_id: &str) -> Result<()> {
620 self.ensure_owned("sandbox.pause", sandbox_id).await?;
621
622 self.microvms.suspend_microvm(sandbox_id).await.unreachable(
623 "sandbox.pause",
624 &format!("could not pause MicroVM '{sandbox_id}'"),
625 )
626 }
627
628 async fn resume(&self, sandbox_id: &str) -> Result<()> {
629 self.ensure_owned("sandbox.resume", sandbox_id).await?;
630
631 self.microvms.resume_microvm(sandbox_id).await.unreachable(
632 "sandbox.resume",
633 &format!("could not resume MicroVM '{sandbox_id}'"),
634 )
635 }
636
637 async fn snapshot(&self, _sandbox_id: &str) -> Result<String> {
638 Err(AlienError::new(ErrorData::OperationNotSupported {
639 operation: "sandbox.snapshot".to_string(),
640 reason: "Lambda MicroVMs expose no snapshot API".to_string(),
641 }))
642 }
643
644 async fn terminate(&self, sandbox_id: &str) -> Result<()> {
648 match self
649 .fetched_microvm("sandbox.terminate", sandbox_id)
650 .await?
651 {
652 None => return Ok(()),
653 Some(microvm) if !self.owns(µvm) => {
654 return Err(AlienError::new(ErrorData::SandboxUnreachable {
655 operation: "sandbox.terminate".to_string(),
656 reason: format!(
657 "sandbox '{sandbox_id}' does not belong to this sandbox resource"
658 ),
659 }))
660 }
661 Some(_) => {}
662 }
663
664 self.microvms
665 .terminate_microvm(sandbox_id)
666 .await
667 .unreachable(
668 "sandbox.terminate",
669 &format!("could not terminate MicroVM '{sandbox_id}'"),
670 )
671 }
672
673 fn as_any(&self) -> &dyn std::any::Any {
674 self
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use alien_aws_clients::aws::lambda_microvms::{
682 Microvm, MicrovmAuthToken, MockLambdaMicrovmsApi,
683 };
684 use alien_error::Context;
685 use std::time::Duration;
686
687 fn image_version(version: &str) -> alien_aws_clients::aws::lambda_microvms::MicrovmImage {
688 alien_aws_clients::aws::lambda_microvms::MicrovmImage {
689 image_identifier: Some("sbx-image".to_string()),
690 image_arn: None,
691 image_version: Some(version.to_string()),
692 state: Some("CREATED".to_string()),
693 }
694 }
695
696 fn owned(id: &str, state: &str) -> Microvm {
699 Microvm {
700 microvm_id: Some(id.to_string()),
701 endpoint: None,
702 state: Some(state.to_string()),
703 image_arn: Some("sbx-image".to_string()),
704 image_version: Some("1".to_string()),
705 }
706 }
707
708 fn sandbox(client: MockLambdaMicrovmsApi) -> AwsSandbox {
709 sandbox_previewing(client, Vec::new())
710 }
711
712 fn sandbox_previewing(client: MockLambdaMicrovmsApi, preview_ports: Vec<u16>) -> AwsSandbox {
713 AwsSandbox::new(
714 Arc::new(client),
715 "sbx-image",
716 "3",
717 Vec::new(),
718 preview_ports,
719 None,
720 None,
721 )
722 }
723
724 #[tokio::test]
727 async fn terminating_an_absent_sandbox_succeeds() {
728 let mut client = MockLambdaMicrovmsApi::new();
729 client.expect_get_microvm().returning(|id| {
730 Err(alien_error::AlienError::new(
731 alien_client_core::ErrorData::RemoteResourceNotFound {
732 resource_type: "Microvm".to_string(),
733 resource_name: id.to_string(),
734 },
735 ))
736 });
737 client.expect_terminate_microvm().never();
738
739 let sandbox = sandbox(client);
740
741 sandbox
742 .terminate("already-reaped")
743 .await
744 .expect("an absent sandbox is already terminated");
745 }
746
747 #[tokio::test]
750 async fn reconnecting_to_a_running_sandbox_reports_found() {
751 let mut client = MockLambdaMicrovmsApi::new();
752 client
753 .expect_get_microvm()
754 .returning(|id| Ok(owned(id, "RUNNING")));
755 client.expect_run_microvm().never();
756
757 let resolved = sandbox(client)
758 .get_or_create(CreateSandboxRequest {
759 sandbox_id: Some("already-up".to_string()),
760 ..Default::default()
761 })
762 .await
763 .expect("a running sandbox is handed back");
764
765 assert_eq!(resolved.sandbox.sandbox_id, "already-up");
766 assert!(
767 !resolved.created,
768 "the sandbox was already running, so this call did not create it"
769 );
770 }
771
772 #[tokio::test]
775 async fn a_failed_reconnect_reports_the_verb_the_caller_used() {
776 let mut client = MockLambdaMicrovmsApi::new();
777 client.expect_get_microvm().returning(|_| {
778 Err(alien_error::AlienError::new(
779 alien_client_core::ErrorData::RemoteServiceUnavailable {
780 message: "GetMicrovm was throttled".to_string(),
781 },
782 ))
783 });
784 client.expect_run_microvm().never();
785
786 let error = sandbox(client)
787 .get_or_create(CreateSandboxRequest {
788 sandbox_id: Some("unreadable".to_string()),
789 ..Default::default()
790 })
791 .await
792 .expect_err("a read that fails is not a sandbox that is absent");
793
794 assert!(
795 error.to_string().contains("sandbox.getOrCreate"),
796 "the reconnect failure names getOrCreate, not get: {error}"
797 );
798 }
799
800 #[tokio::test]
807 async fn a_booting_sandbox_is_never_answered_with_a_second_microvm() {
808 let mut client = MockLambdaMicrovmsApi::new();
809 client
811 .expect_get_microvm()
812 .returning(|id| Ok(owned(id, "PENDING")));
813 client.expect_run_microvm().never();
814
815 let error = sandbox(client)
816 .get_or_create(CreateSandboxRequest {
817 sandbox_id: Some("still-booting".to_string()),
818 ..Default::default()
819 })
820 .await
821 .expect_err("a sandbox that never publishes an endpoint cannot be served");
822
823 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
824 assert!(
825 error.to_string().contains("still-booting"),
826 "the failure names the sandbox it waited on: {error}"
827 );
828 }
829
830 #[tokio::test]
832 async fn capabilities_reflect_the_declared_preview_ports() {
833 assert!(
834 !sandbox(MockLambdaMicrovmsApi::new()).capabilities().preview,
835 "no declared ports means no preview capability"
836 );
837 assert!(
838 sandbox_previewing(MockLambdaMicrovmsApi::new(), vec![8080])
839 .capabilities()
840 .preview,
841 "a declared port makes the capability real"
842 );
843 }
844
845 #[tokio::test]
847 async fn unsupported_sandbox_fields_are_refused_not_dropped() {
848 let mut client = MockLambdaMicrovmsApi::new();
849 client.expect_run_microvm().never();
850
851 let sandbox = sandbox(client);
852
853 let with_env = CreateSandboxRequest {
854 env: [("KEY".to_string(), "value".to_string())].into(),
855 ..Default::default()
856 };
857 let error = sandbox
858 .create(with_env)
859 .await
860 .expect_err("a sandbox-level env must be refused");
861 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
862
863 let with_tenant = CreateSandboxRequest {
864 tenant_key: Some("tenant-1".to_string()),
865 ..Default::default()
866 };
867 let error = sandbox
868 .create(with_tenant)
869 .await
870 .expect_err("a tenant key must be refused");
871 assert_eq!(error.code, "OPERATION_NOT_SUPPORTED");
872 }
873
874 #[tokio::test]
879 async fn a_sandbox_from_another_declaration_is_refused_before_anything_is_minted() {
880 let mut client = MockLambdaMicrovmsApi::new();
881 client.expect_get_microvm().returning(|id| {
882 Ok(Microvm {
883 microvm_id: Some(id.to_string()),
884 endpoint: Some("vm.example.invalid".to_string()),
885 state: Some("RUNNING".to_string()),
886 image_arn: Some("someone-elses-image".to_string()),
888 image_version: Some("1".to_string()),
889 })
890 });
891 client.expect_create_microvm_auth_token().never();
892 client.expect_terminate_microvm().never();
893 client.expect_suspend_microvm().never();
894
895 let sandbox = sandbox_previewing(client, vec![8080]);
896
897 for outcome in [
898 sandbox.preview("a-siblings-sandbox", 8080).await.err(),
899 sandbox.terminate("a-siblings-sandbox").await.err(),
900 sandbox.pause("a-siblings-sandbox").await.err(),
901 ] {
902 let error = outcome.expect("a sandbox this sandbox does not own is refused");
903 assert!(
904 error
905 .to_string()
906 .contains("does not belong to this sandbox"),
907 "names the reason: {error}"
908 );
909 }
910
911 assert!(
912 sandbox
913 .get("a-siblings-sandbox")
914 .await
915 .expect("reading it is not an error")
916 .is_none(),
917 "a sibling's sandbox reads as absent rather than as one of ours"
918 );
919 }
920
921 #[tokio::test]
926 async fn a_sandbox_that_does_not_exist_reads_as_absent_rather_than_as_a_failure() {
927 let mut client = MockLambdaMicrovmsApi::new();
928 client.expect_get_microvm().returning(|_| {
929 Err(alien_error::AlienError::new(
930 alien_client_core::ErrorData::RemoteResourceNotFound {
931 resource_type: "Microvm".to_string(),
932 resource_name: "GetMicrovm".to_string(),
933 },
934 ))
935 });
936
937 assert!(sandbox(client)
938 .get("never-existed")
939 .await
940 .expect("an absent sandbox is an answer, not an error")
941 .is_none());
942 }
943
944 #[tokio::test]
947 async fn a_failed_read_is_not_reported_as_an_absent_sandbox() {
948 let mut client = MockLambdaMicrovmsApi::new();
949 client.expect_get_microvm().returning(|_| {
950 Err(alien_error::AlienError::new(
951 alien_client_core::ErrorData::RateLimitExceeded {
952 message: "throttled".to_string(),
953 },
954 ))
955 });
956
957 sandbox(client)
958 .get("ours")
959 .await
960 .expect_err("a throttle is not an absent sandbox");
961 }
962
963 #[tokio::test]
966 async fn a_sandbox_with_no_image_is_not_assumed_to_be_ours() {
967 let mut client = MockLambdaMicrovmsApi::new();
968 client.expect_get_microvm().returning(|id| {
969 Ok(Microvm {
970 microvm_id: Some(id.to_string()),
971 endpoint: Some("vm.example.invalid".to_string()),
972 state: Some("RUNNING".to_string()),
973 image_arn: None,
974 image_version: None,
975 })
976 });
977 client.expect_create_microvm_auth_token().never();
978
979 let error = sandbox_previewing(client, vec![8080])
980 .preview("unlabelled", 8080)
981 .await
982 .expect_err("an unattributable sandbox is refused");
983 assert!(error
984 .to_string()
985 .contains("does not belong to this sandbox"));
986 }
987
988 #[tokio::test]
992 async fn reaching_a_sandbox_does_not_enumerate_the_image() {
993 let mut client = MockLambdaMicrovmsApi::new();
994 client.expect_list_microvms().never();
995 client.expect_list_microvm_image_versions().never();
996 client
997 .expect_get_microvm()
998 .returning(|id| Ok(owned(id, "RUNNING")));
999
1000 let instance = sandbox(client)
1001 .get("ours")
1002 .await
1003 .expect("reading our own sandbox succeeds")
1004 .expect("it is present");
1005 assert_eq!(instance.sandbox_id, "ours");
1006 }
1007
1008 #[tokio::test]
1015 async fn the_readiness_poll_outlasts_the_snapshot_restore_window() {
1016 use std::sync::atomic::{AtomicUsize, Ordering};
1017
1018 let attempts = std::sync::Arc::new(AtomicUsize::new(0));
1019 let seen = attempts.clone();
1020 let handler = move || {
1021 let seen = seen.clone();
1022 async move {
1023 if seen.fetch_add(1, Ordering::SeqCst) < 2 {
1025 axum::http::StatusCode::BAD_GATEWAY
1026 } else {
1027 axum::http::StatusCode::OK
1028 }
1029 }
1030 };
1031 let router = axum::Router::new().route(HEALTH_PATH, axum::routing::get(handler));
1032 let listener = tokio::net::TcpListener::bind::<std::net::SocketAddr>(
1033 "127.0.0.1:0".parse().expect("a loopback address"),
1034 )
1035 .await
1036 .expect("bind");
1037 let address = listener.local_addr().expect("address");
1038 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
1039
1040 let probe = reqwest::Client::new().get(format!("http://{address}{HEALTH_PATH}"));
1041 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1042
1043 AwsSandbox::poll_until_healthy(probe, deadline, "mvm-restoring")
1044 .await
1045 .expect("the wait must outlast a restore that answers 502 before it answers 200");
1046 assert_eq!(
1047 attempts.load(Ordering::SeqCst),
1048 3,
1049 "it must keep probing through the restore rather than give up on the first 502"
1050 );
1051 }
1052
1053 #[tokio::test]
1055 async fn the_readiness_poll_gives_up_on_a_sandbox_that_never_answers() {
1056 let router = axum::Router::new().route(
1057 HEALTH_PATH,
1058 axum::routing::get(|| async { axum::http::StatusCode::BAD_GATEWAY }),
1059 );
1060 let listener = tokio::net::TcpListener::bind::<std::net::SocketAddr>(
1061 "127.0.0.1:0".parse().expect("a loopback address"),
1062 )
1063 .await
1064 .expect("bind");
1065 let address = listener.local_addr().expect("address");
1066 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
1067
1068 let probe = reqwest::Client::new().get(format!("http://{address}{HEALTH_PATH}"));
1069 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(30);
1070
1071 let error = AwsSandbox::poll_until_healthy(probe, deadline, "mvm-dead")
1072 .await
1073 .expect_err("a sandbox that never answers must not be reported as ready");
1074 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1075 assert!(
1076 error.to_string().contains("mvm-dead") && error.to_string().contains("502"),
1077 "the failure has to name the sandbox and what it last saw: {error}"
1078 );
1079 }
1080
1081 #[tokio::test]
1082 async fn preview_returns_the_headers_a_caller_cannot_construct() {
1083 let mut client = MockLambdaMicrovmsApi::new();
1084 client
1085 .expect_list_microvm_image_versions()
1086 .returning(|_| Ok(vec![image_version("3")]));
1087 client.expect_list_microvms().returning(|_, _| {
1088 Ok(vec![Microvm {
1089 microvm_id: Some("mvm-1".into()),
1090 endpoint: None,
1091 state: Some("RUNNING".into()),
1092 image_arn: Some("sbx-image".to_string()),
1093 image_version: Some("1".to_string()),
1094 }])
1095 });
1096 client.expect_get_microvm().returning(|_| {
1097 Ok(Microvm {
1098 microvm_id: Some("mvm-1".to_string()),
1099 endpoint: Some("mvm-1.lambda-microvms.aws".to_string()),
1100 state: Some("RUNNING".to_string()),
1101 image_arn: Some("sbx-image".to_string()),
1102 image_version: Some("1".to_string()),
1103 })
1104 });
1105 client
1106 .expect_create_microvm_auth_token()
1107 .withf(|_, ports, minutes| {
1108 ports.as_slice() == [8080] && *minutes == PREVIEW_TOKEN_MINUTES
1109 })
1110 .returning(|_, _, _| {
1111 Ok(MicrovmAuthToken {
1112 auth_token: std::collections::HashMap::from([(
1113 "X-aws-proxy-auth".to_string(),
1114 "jwe-value".to_string(),
1115 )]),
1116 })
1117 });
1118
1119 let capability = sandbox_previewing(client, vec![8080])
1120 .preview("mvm-1", 8080)
1121 .await
1122 .expect("mints");
1123
1124 assert_eq!(capability.endpoint, "https://mvm-1.lambda-microvms.aws");
1125 assert_eq!(
1126 capability
1127 .headers
1128 .get("X-aws-proxy-auth")
1129 .map(String::as_str),
1130 Some("jwe-value")
1131 );
1132 assert_eq!(
1133 capability
1134 .headers
1135 .get(PROXY_PORT_HEADER)
1136 .map(String::as_str),
1137 Some("8080")
1138 );
1139 assert_eq!(capability.allowed_ports, vec![8080]);
1140 assert_eq!(capability.expires_in_seconds, 1800);
1141 }
1142
1143 #[tokio::test]
1147 async fn a_port_the_stack_did_not_declare_is_refused_before_a_token_exists() {
1148 let mut client = MockLambdaMicrovmsApi::new();
1149 client.expect_get_microvm().never();
1150 client.expect_create_microvm_auth_token().never();
1151
1152 let error = sandbox_previewing(client, vec![8080])
1153 .preview("mvm-1", 22)
1154 .await
1155 .expect_err("port 22 was never declared");
1156
1157 assert!(
1158 error.to_string().contains("22"),
1159 "the refusal must name the port asked for: {error}"
1160 );
1161 }
1162
1163 #[test]
1167 fn a_reported_preview_lifetime_never_exceeds_what_aws_will_mint() {
1168 assert_eq!(preview_lifetime_seconds(), 1800);
1169 assert!(
1170 preview_lifetime_seconds() <= u64::from(MAX_AUTH_TOKEN_MINUTES) * 60,
1171 "the reported lifetime must not outrun the cap the client sends"
1172 );
1173 }
1174
1175 #[tokio::test]
1178 async fn a_microvm_that_is_not_running_yet_is_reported_as_starting() {
1179 for (aws_state, expected) in [
1180 ("PENDING", SandboxState::Starting),
1181 ("RUNNING", SandboxState::Running),
1182 ("SUSPENDED", SandboxState::Paused),
1183 ("TERMINATED", SandboxState::Terminated),
1184 ] {
1185 let mut client = MockLambdaMicrovmsApi::new();
1186 client
1187 .expect_get_microvm()
1188 .returning(move |id| Ok(owned(id, aws_state)));
1189
1190 let instance = sandbox(client)
1191 .get("s1")
1192 .await
1193 .expect("reads")
1194 .expect("present");
1195 assert_eq!(instance.state, expected, "AWS state {aws_state}");
1196 }
1197 }
1198
1199 #[tokio::test]
1203 async fn the_declared_lifetime_reaches_the_run_call() {
1204 let mut client = MockLambdaMicrovmsApi::new();
1205 client
1206 .expect_run_microvm()
1207 .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(1800))
1208 .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1209 client
1212 .expect_get_microvm()
1213 .returning(|id| Ok(owned(id, "RUNNING")));
1214 client
1215 .expect_terminate_microvm()
1216 .times(1)
1217 .returning(|_| Ok(()));
1218
1219 let result = AwsSandbox::new(
1220 std::sync::Arc::new(client),
1221 "sbx-image",
1222 "3",
1223 vec!["connector".to_string()],
1224 Vec::new(),
1225 None,
1226 Some(1800),
1227 )
1228 .create(CreateSandboxRequest {
1229 sandbox_id: None,
1230 tenant_key: None,
1231 env: BTreeMap::new(),
1232 ..Default::default()
1233 })
1234 .await;
1235
1236 let error = result.expect_err("no agent answers in a unit test");
1239 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1240 assert!(
1241 error.to_string().contains("published no endpoint"),
1242 "the failure has to name the readiness wait, not any error: {error}"
1243 );
1244 }
1245
1246 #[tokio::test]
1250 async fn a_requested_lifetime_reaches_the_run_call_in_seconds() {
1251 let mut client = MockLambdaMicrovmsApi::new();
1252 client
1253 .expect_run_microvm()
1254 .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(90))
1255 .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1256 client
1257 .expect_get_microvm()
1258 .returning(|id| Ok(owned(id, "RUNNING")));
1259 client
1260 .expect_terminate_microvm()
1261 .times(1)
1262 .returning(|_| Ok(()));
1263
1264 let error = sandbox(client)
1265 .create(CreateSandboxRequest {
1266 timeout_ms: Some(90_000),
1267 ..Default::default()
1268 })
1269 .await
1270 .expect_err("no agent answers in a unit test");
1271
1272 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1275 }
1276
1277 #[tokio::test]
1281 async fn a_requested_lifetime_cannot_raise_the_declared_ceiling() {
1282 let mut client = MockLambdaMicrovmsApi::new();
1283 client
1284 .expect_run_microvm()
1285 .withf(|_, _, _, _, _, _, max_lifetime| *max_lifetime == Some(1800))
1286 .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-1", "PENDING")));
1287 client
1288 .expect_get_microvm()
1289 .returning(|id| Ok(owned(id, "RUNNING")));
1290 client
1291 .expect_terminate_microvm()
1292 .times(1)
1293 .returning(|_| Ok(()));
1294
1295 let error = AwsSandbox::new(
1296 std::sync::Arc::new(client),
1297 "sbx-image",
1298 "3",
1299 vec!["connector".to_string()],
1300 Vec::new(),
1301 None,
1302 Some(1800),
1303 )
1304 .create(CreateSandboxRequest {
1305 timeout_ms: Some(7_200_000),
1306 ..Default::default()
1307 })
1308 .await
1309 .expect_err("no agent answers in a unit test");
1310
1311 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1312 }
1313
1314 #[tokio::test]
1318 async fn a_microvm_that_could_not_be_terminated_is_reported_by_id() {
1319 let mut client = MockLambdaMicrovmsApi::new();
1320 client
1321 .expect_run_microvm()
1322 .returning(|_, _, _, _, _, _, _| Ok(owned("mvm-orphan", "PENDING")));
1323 client
1326 .expect_get_microvm()
1327 .returning(|id| Ok(owned(id, "RUNNING")));
1328 client.expect_terminate_microvm().times(1).returning(|_| {
1329 Err(alien_error::AlienError::new(
1330 alien_client_core::ErrorData::RemoteServiceUnavailable {
1331 message: "TerminateMicrovm was refused".to_string(),
1332 },
1333 ))
1334 });
1335
1336 let error = sandbox(client)
1337 .create(CreateSandboxRequest::default())
1338 .await
1339 .expect_err("a MicroVM that never became servable is not a sandbox");
1340
1341 assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "{error}");
1342 assert!(
1343 !error.retryable,
1344 "a retry would mint another MicroVM nobody can reach: {error}"
1345 );
1346 assert!(
1347 error.message.contains("sandboxLeftBehind"),
1348 "the leak is reported under the label the siblings use: {error}"
1349 );
1350 assert!(
1353 error.message.contains("mvm-orphan"),
1354 "only this error can send an operator to the MicroVM left running: {error}"
1355 );
1356 }
1357
1358 #[tokio::test]
1362 async fn a_caller_supplied_sandbox_id_is_never_the_client_token() {
1363 let mut client = MockLambdaMicrovmsApi::new();
1364 client
1365 .expect_run_microvm()
1366 .withf(|image, version, token, _, _, _, _| {
1367 image == "sbx-image" && version == "3" && token != "caller-chosen"
1368 })
1369 .returning(|_, _, _, _, _, _, _| {
1370 Ok(Microvm {
1371 microvm_id: Some("mvm-9".to_string()),
1372 endpoint: None,
1373 state: Some("PENDING".to_string()),
1374 image_arn: Some("sbx-image".to_string()),
1375 image_version: Some("1".to_string()),
1376 })
1377 });
1378 client
1381 .expect_get_microvm()
1382 .withf(|id| id == "mvm-9")
1384 .returning(|id| Ok(owned(id, "RUNNING")));
1385 client
1386 .expect_terminate_microvm()
1387 .withf(|id| id == "mvm-9")
1388 .times(1)
1389 .returning(|_| Ok(()));
1390
1391 let result = sandbox(client)
1392 .create(CreateSandboxRequest {
1393 sandbox_id: Some("caller-chosen".to_string()),
1394 tenant_key: None,
1395 env: BTreeMap::new(),
1396 ..Default::default()
1397 })
1398 .await;
1399
1400 let error = result.expect_err("no agent answers in a unit test");
1403 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1404 assert!(
1405 error.to_string().contains("published no endpoint"),
1406 "the failure has to name the readiness wait, not any error: {error}"
1407 );
1408 }
1409
1410 #[tokio::test]
1411 async fn a_command_without_a_timeout_is_refused_before_any_aws_call() {
1412 let outcome = sandbox(MockLambdaMicrovmsApi::new())
1414 .run_command(
1415 "mvm-1",
1416 RunCommandRequest {
1417 command: "/bin/echo".to_string(),
1418 args: Vec::new(),
1419 cwd: None,
1420 env: BTreeMap::new(),
1421 timeout: Duration::ZERO,
1422 },
1423 )
1424 .await;
1425
1426 match outcome {
1427 Ok(_) => panic!("a zero timeout must be refused"),
1428 Err(error) => assert!(error.to_string().contains("non-zero timeout"), "{error}"),
1429 }
1430 }
1431
1432 #[tokio::test]
1437 async fn a_sandbox_on_a_previous_image_version_is_still_ours() {
1438 let mut client = MockLambdaMicrovmsApi::new();
1439 client.expect_get_microvm().returning(|id| {
1440 Ok(Microvm {
1441 microvm_id: Some(id.to_string()),
1442 endpoint: None,
1443 state: Some("RUNNING".to_string()),
1444 image_arn: Some("sbx-image".to_string()),
1445 image_version: Some("2".to_string()),
1447 })
1448 });
1449
1450 let found = sandbox(client)
1451 .get("older")
1452 .await
1453 .expect("reads")
1454 .expect("a sandbox on the previous version is still live and still ours");
1455
1456 assert_eq!(found.sandbox_id, "older");
1457 }
1458
1459 #[tokio::test]
1464 async fn sandboxes_are_not_enumerable_and_nothing_asks_aws_to_be() {
1465 let mut client = MockLambdaMicrovmsApi::new();
1466 client.expect_list_microvms().never();
1467 client.expect_list_microvm_image_versions().never();
1468
1469 let error = sandbox(client)
1470 .list()
1471 .await
1472 .expect_err("listing is not offered on AWS");
1473 assert!(
1474 error.to_string().contains("get"),
1475 "points the caller at what does work: {error}"
1476 );
1477 }
1478
1479 fn refused_run() -> Result<Microvm, alien_client_core::ErrorData> {
1483 Err(AlienError::new(
1484 alien_client_core::ErrorData::HttpResponseError {
1485 message: "Request failed with HTTP 403: Forbidden".to_string(),
1486 url: "https://lambda.us-east-2.amazonaws.com/2025-09-09/microvms".to_string(),
1487 http_status: 403,
1488 http_request_text: None,
1489 http_response_text: Some(
1490 r#"{"Message":"User: arn:aws:sts::123456789012:assumed-role/stack-access/session is not authorized to perform: lambda:PassNetworkConnector on resource: arn:aws:lambda:us-east-2:aws:network-connector:aws-network-connector:INTERNET_EGRESS"}"#
1491 .to_string(),
1492 ),
1493 },
1494 ))
1495 .context(alien_client_core::ErrorData::GenericError {
1496 message: "Lambda MicroVMs RunMicrovm failed".to_string(),
1497 })
1498 }
1499
1500 #[tokio::test]
1505 async fn a_refused_create_reports_what_aws_refused_it_with() {
1506 let mut client = MockLambdaMicrovmsApi::new();
1507 client
1508 .expect_run_microvm()
1509 .returning(|_, _, _, _, _, _, _| refused_run());
1510 client.expect_terminate_microvm().never();
1512
1513 let error = sandbox(client)
1514 .create(CreateSandboxRequest {
1515 sandbox_id: None,
1516 tenant_key: None,
1517 env: BTreeMap::new(),
1518 ..Default::default()
1519 })
1520 .await
1521 .expect_err("a refused RunMicrovm cannot produce a sandbox");
1522
1523 assert_eq!(error.code, "SANDBOX_UNREACHABLE");
1524 assert!(
1525 error
1526 .message
1527 .contains("could not start a MicroVM from 'sbx-image'"),
1528 "the binding still says which call it was: {}",
1529 error.message
1530 );
1531 assert!(
1532 error
1533 .message
1534 .contains("is not authorized to perform: lambda:PassNetworkConnector"),
1535 "and AWS's own sentence is what tells the operator why: {}",
1536 error.message
1537 );
1538 assert!(
1539 error.internal,
1540 "an IAM identity in the message makes the error internal: {error}"
1541 );
1542 assert_eq!(
1543 error.into_external().message,
1544 "Internal server error",
1545 "so none of it is published to the caller"
1546 );
1547 }
1548}