1use super::transport::{Call, paths};
49use super::{ControlPlane, RunMicrovmRequest, WaitOpts, ops, timed_out, token};
50use crate::error::{Error, ErrorKind};
51
52pub const PROXY_AUTH_HEADER: &str = "X-aws-proxy-auth";
54
55pub const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
61
62pub const MAX_TOKEN_MINUTES: u32 = 60;
64
65#[derive(Clone, Eq, PartialEq)]
71pub struct RunHookPayload(String);
72
73impl std::fmt::Debug for RunHookPayload {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "RunHookPayload(<{} bytes>)", self.0.len())
84 }
85}
86
87impl RunHookPayload {
88 pub fn for_agent_token(agent_token: &str) -> Result<Self, Error> {
97 Self::for_launch(agent_token, &std::collections::HashMap::new())
98 }
99
100 pub fn for_launch(
126 agent_token: &str,
127 env: &std::collections::HashMap<String, String>,
128 ) -> Result<Self, Error> {
129 Self::for_launch_with_identity(agent_token, env, None)
130 }
131
132 pub fn for_launch_with_identity(
145 agent_token: &str,
146 env: &std::collections::HashMap<String, String>,
147 identity: Option<&crate::identity::LaunchIdentity>,
148 ) -> Result<Self, Error> {
149 let mut payload = if env.is_empty() {
150 serde_json::json!({ "agent_token": agent_token })
151 } else {
152 serde_json::json!({ "agent_token": agent_token, "env": env })
153 };
154 if let Some(identity) = identity {
155 let object = payload
156 .as_object_mut()
157 .expect("the payload is built as an object two lines up");
158 object.insert(
159 protocol::identity::SEED_KEY.to_string(),
160 serde_json::Value::String(identity.seed_field()),
161 );
162 object.insert(
163 protocol::identity::HOST_PUBLIC_KEY_KEY.to_string(),
164 serde_json::Value::String(identity.host_public_field()),
165 );
166 }
167 let text = serde_json::to_string(&payload).map_err(|error| {
168 Error::new(
169 ErrorKind::Unexpected,
170 format!("could not serialize the run-hook payload: {error}"),
171 )
172 })?;
173 let total = text.len();
176 Self::new(text).map_err(|error| {
177 if env.is_empty() {
178 return error;
179 }
180 Error::invalid_arg(format!(
185 "{error} The launch env contributed {} of those bytes across {} variable(s); \
186 the payload's other {} are the token and the JSON framing. A launch env is \
187 for small values a workload needs at startup — move credential-scale \
188 material to PUT /v1/fs/file after bootstrap, or to a role the workload \
189 assumes.",
190 env_contribution(agent_token, env),
191 env.len(),
192 total.saturating_sub(env_contribution(agent_token, env)),
193 ))
194 })
195 }
196
197 pub fn new(payload: impl Into<String>) -> Result<Self, Error> {
202 let payload = payload.into();
203 let size = payload.len();
207 if size <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES {
208 return Ok(Self(payload));
209 }
210 Err(Error::invalid_arg(format!(
211 "runHookPayload is {size} bytes, over the ceiling of {} (service model {}, measured \
212 inclusive 2026-08-07: 4096 passes and 4097 fails). This is the only per-VM secret \
213 channel the platform offers — one bearer token fits, a cloud credential set does \
214 not. Note that docs/STRATEGY.md, docs/TRUST.md, and the service model's own \
215 documentation string for this member all claim 16 KB, which is wrong by 4x in the \
216 dangerous direction; the shape RunMicrovmRequestRunHookPayloadString is the \
217 authority and it states max 4096.",
218 crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
219 crate::constants::MODEL_API_VERSION,
220 )))
221 }
222
223 pub fn as_str(&self) -> &str {
225 &self.0
226 }
227
228 pub fn len(&self) -> usize {
230 self.0.len()
231 }
232
233 pub fn is_empty(&self) -> bool {
235 self.0.is_empty()
236 }
237}
238
239fn env_contribution(agent_token: &str, env: &std::collections::HashMap<String, String>) -> usize {
249 let with = serde_json::json!({ "agent_token": agent_token, "env": env }).to_string();
250 let without = serde_json::json!({ "agent_token": agent_token }).to_string();
251 with.len().saturating_sub(without.len())
252}
253
254#[derive(Clone, Debug, Eq, PartialEq)]
256pub struct Microvm {
257 pub id: String,
258 pub state: String,
259 pub endpoint: String,
261 pub image_arn: String,
262 pub image_version: String,
263 pub state_reason: Option<String>,
266 pub idle_policy: Option<ops::IdlePolicy>,
277 pub maximum_duration_seconds: Option<u32>,
279 pub started_at: Option<std::time::SystemTime>,
281 pub terminated_at: Option<std::time::SystemTime>,
283}
284
285fn epoch(seconds: Option<f64>) -> Option<std::time::SystemTime> {
291 let since = std::time::Duration::try_from_secs_f64(seconds?).ok()?;
292 std::time::UNIX_EPOCH.checked_add(since)
293}
294
295#[derive(Clone, Debug, Default, Eq, PartialEq)]
297pub struct MicrovmFilter {
298 pub image_identifier: Option<String>,
300 pub image_version: Option<String>,
302}
303
304impl Microvm {
305 pub(crate) fn reason(&self) -> &str {
311 self.state_reason.as_deref().unwrap_or("no stateReason")
312 }
313}
314
315impl From<ops::MicrovmResponseWire> for Microvm {
316 fn from(wire: ops::MicrovmResponseWire) -> Self {
317 Self {
318 id: wire.microvm_id,
319 state: wire.state,
320 endpoint: wire.endpoint,
321 image_arn: wire.image_arn,
322 image_version: wire.image_version,
323 state_reason: wire.state_reason,
324 idle_policy: wire.idle_policy,
325 maximum_duration_seconds: wire.maximum_duration_in_seconds,
326 started_at: epoch(wire.started_at),
327 terminated_at: epoch(wire.terminated_at),
328 }
329 }
330}
331
332#[derive(Clone, Eq, PartialEq)]
344pub struct ProxyToken {
345 value: String,
346 port: u16,
347}
348
349impl std::fmt::Debug for ProxyToken {
356 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357 f.debug_struct("ProxyToken")
358 .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
359 .field("port", &self.port)
360 .finish()
361 }
362}
363
364impl ProxyToken {
365 pub fn from_map(
372 map: &std::collections::BTreeMap<String, String>,
373 port: u16,
374 ) -> Result<Self, Error> {
375 Self::from_operation_map("CreateMicrovmAuthToken", map, port)
376 }
377
378 pub fn from_operation_map(
384 operation: &str,
385 map: &std::collections::BTreeMap<String, String>,
386 port: u16,
387 ) -> Result<Self, Error> {
388 let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
389 Error::wire(
390 crate::error::WireKind::AuthTokenMint,
391 format!(
392 "the {operation} response carried no {PROXY_AUTH_HEADER} key. The \
393 authToken member is a map, not a string, and that key is the header value \
394 (keys present: {:?}).",
395 map.keys().collect::<Vec<_>>()
396 ),
397 )
398 })?;
399 Ok(Self {
400 value: value.clone(),
401 port,
402 })
403 }
404
405 pub fn headers(&self) -> [(&'static str, String); 2] {
410 [
411 (PROXY_AUTH_HEADER, self.value.clone()),
412 (PROXY_PORT_HEADER, self.port.to_string()),
413 ]
414 }
415
416 pub fn value(&self) -> &str {
418 &self.value
419 }
420
421 pub fn port(&self) -> u16 {
423 self.port
424 }
425}
426
427impl ControlPlane {
428 pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
434 self.run_microvm_reported(request, None)
435 .await
436 .map(|(vm, _)| vm)
437 }
438
439 pub(crate) async fn run_microvm_reported(
440 &self,
441 request: RunMicrovmRequest,
442 cancellation: Option<&super::transport::RetryCancellation>,
443 ) -> Result<(Microvm, super::transport::RetryDetails), Error> {
444 let (ingress, egress) = self.launch_connectors(&request)?;
445
446 let wire = ops::RunMicrovmWire {
447 image_identifier: request.image_identifier.clone(),
448 image_version: request.image_version.clone(),
449 execution_role_arn: request.execution_role_arn.clone(),
450 ingress_network_connectors: ingress,
451 egress_network_connectors: (!egress.is_empty()).then_some(egress),
456 idle_policy: ops::IdlePolicy {
457 max_idle_duration_seconds: request.max_idle_sec,
458 suspended_duration_seconds: request.suspended_sec,
459 auto_resume_enabled: request.auto_resume,
460 },
461 maximum_duration_in_seconds: request.max_duration_sec,
462 run_hook_payload: request.run_hook_payload.as_str().to_string(),
463 logging: request.logging.clone(),
464 client_token: match request.client_token {
465 Some(token) => token,
466 None => token::run_token_with(
467 request
468 .token_scope
469 .as_deref()
470 .unwrap_or(&request.image_identifier),
471 self.entropy(),
472 )?,
473 },
474 };
475
476 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
477 let (reply, details) = super::transport::send_cancellable(
478 self.transport(),
479 call,
480 &[],
481 self.retry,
482 self.clock.as_ref(),
483 cancellation,
484 )
485 .await?;
486 let reply = super::ReportedReply { reply, details };
487 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
488 Ok((launched.into(), details))
489 }
490
491 pub(crate) fn launch_connectors(
497 &self,
498 request: &RunMicrovmRequest,
499 ) -> Result<(Vec<String>, Vec<String>), Error> {
500 if let Some(token) = request.client_token.as_deref() {
501 super::token::require_run_token(token)?;
502 }
503 super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
504 super::require_duration_in_range(request.max_duration_sec)?;
505 super::require_idle_duration(request.max_idle_sec)?;
509 if let Some(version) = request.image_version.as_deref() {
513 super::require_valid_version("imageVersion", version)?;
514 }
515 if let Some(role) = request.execution_role_arn.as_deref() {
519 super::require_valid_role_arn("executionRoleArn", role)?;
520 }
521 if let Some(ops::Logging::CloudWatch { cloud_watch }) = &request.logging {
522 let Some(group) = cloud_watch.log_group.as_deref() else {
523 return Err(Error::invalid_arg(
524 "per-VM CloudWatch logging needs a logGroup; pass a group name or disable logging",
525 ));
526 };
527 super::require_valid_log_group(group)?;
528 if let Some(stream) = cloud_watch.log_stream.as_deref() {
529 super::require_valid_log_stream(stream)?;
530 }
531 }
532
533 let has_all_ingress = request
540 .connectors
541 .contains(&super::ConnectorIntent::AllIngress);
542 let has_finer_ingress = request.connectors.iter().any(|intent| {
543 matches!(
544 intent,
545 super::ConnectorIntent::HttpIngress | super::ConnectorIntent::ShellIngress
546 )
547 });
548 if has_all_ingress && has_finer_ingress {
549 return Err(Error::invalid_arg(
550 "ALL_INGRESS cannot be combined with other ingress connectors; use \
551 HTTP_INGRESS and/or SHELL_INGRESS instead. The platform accepts the \
552 combination at launch and rejects it only when a shell token is minted, \
553 after the VM has launched and billed — so it is refused here.",
554 ));
555 }
556
557 let has_shell_ingress = request
562 .connectors
563 .contains(&super::ConnectorIntent::ShellIngress);
564 let has_http_ingress = request
565 .connectors
566 .contains(&super::ConnectorIntent::HttpIngress);
567 if has_shell_ingress && !has_http_ingress {
568 return Err(Error::invalid_arg(
569 "SHELL_INGRESS was requested without HTTP_INGRESS. The measured pair that \
570 launches and mints a shell token is [HTTP_INGRESS, SHELL_INGRESS] \
571 (docs/PLATFORM.md); the platform accepts an invalid ingress set at launch \
572 and the VM runs and bills before any failure appears — so it is refused \
573 here.",
574 ));
575 }
576
577 let ingress: Vec<String> = request
580 .connectors
581 .iter()
582 .filter(|intent| {
583 matches!(
584 intent,
585 super::ConnectorIntent::AllIngress
586 | super::ConnectorIntent::HttpIngress
587 | super::ConnectorIntent::ShellIngress
588 )
589 })
590 .map(|intent| intent.arn(&self.region))
591 .collect();
592 let mut egress: Vec<String> = request
593 .connectors
594 .iter()
595 .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
596 .map(|intent| intent.arn(&self.region))
597 .collect();
598
599 if !egress.is_empty() && !request.egress_network_connectors.is_empty() {
602 return Err(super::connector::egress_with_connectors());
603 }
604 for arn in &request.egress_network_connectors {
605 super::connector::require_egress_connector_arn(arn, &self.region)?;
606 egress.push(arn.clone());
607 }
608
609 for (member, count) in [
611 ("ingressNetworkConnectors", ingress.len()),
612 ("egressNetworkConnectors", egress.len()),
613 ] {
614 if let Some(refusal) = super::connector::over_connector_ceiling(member, count) {
615 return Err(refusal);
616 }
617 }
618
619 Ok((ingress, egress))
620 }
621
622 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
629 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
630 .await
631 }
632
633 pub async fn wait_for_launch(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
642 self.wait_for_launch_inner(id, opts, true, false).await
643 }
644
645 pub(crate) async fn wait_for_sandbox_launch(
648 &self,
649 id: &str,
650 opts: WaitOpts,
651 adoptable: bool,
652 ) -> Result<Microvm, Error> {
653 self.wait_for_launch_inner(id, opts, adoptable, true).await
654 }
655
656 async fn wait_for_launch_inner(
657 &self,
658 id: &str,
659 opts: WaitOpts,
660 adoptable: bool,
661 fast_start: bool,
662 ) -> Result<Microvm, Error> {
663 let started = self.clock().elapsed();
664 let mut resumed = false;
665 let mut fast_polls_remaining = 3;
666 let fail_on = if adoptable {
667 &crate::constants::DEAD_STATES[..]
668 } else {
669 &crate::constants::TERMINAL_STATES[..]
670 };
671 loop {
672 let got = self.get_microvm(id).await?;
673 match got.state.as_str() {
674 "RUNNING" => return Ok(got),
675 state if fail_on.contains(&state) => {
676 return Err(self.reached_terminal_state(&got, &["RUNNING"]));
677 }
678 "SUSPENDED" if adoptable && !resumed => {
681 self.resume(id).await?;
682 resumed = true;
683 continue;
684 }
685 _ => {}
686 }
687
688 let elapsed = self.clock().elapsed().saturating_sub(started);
689 if elapsed >= opts.timeout {
690 return Err(timed_out(
691 &format!(
692 "microvm {id} never reached [\"RUNNING\"] (last state {})",
693 got.state
694 ),
695 elapsed,
696 ));
697 }
698 let interval = if fast_start && fast_polls_remaining > 0 {
701 fast_polls_remaining -= 1;
702 opts.poll_interval.min(std::time::Duration::from_secs(1))
703 } else {
704 opts.poll_interval
705 };
706 self.clock().sleep(interval).await;
707 }
708 }
709
710 pub async fn wait_for_state(
721 &self,
722 id: &str,
723 wanted: &[&str],
724 fail_on: &[&str],
725 opts: WaitOpts,
726 ) -> Result<Microvm, Error> {
727 let started = self.clock().elapsed();
728 loop {
729 let got = self.get_microvm(id).await?;
730
731 if wanted.contains(&got.state.as_str()) {
732 return Ok(got);
733 }
734 if fail_on.contains(&got.state.as_str()) {
735 return Err(self.reached_terminal_state(&got, wanted));
736 }
737
738 let elapsed = self.clock().elapsed().saturating_sub(started);
739 if elapsed >= opts.timeout {
740 return Err(timed_out(
741 &format!(
742 "microvm {id} never reached {wanted:?} (last state {})",
743 got.state
744 ),
745 elapsed,
746 ));
747 }
748 self.clock().sleep(opts.poll_interval).await;
749 }
750 }
751
752 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
757 Error::new(
758 ErrorKind::LaunchDied,
759 format!(
760 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
761 before RUNNING died during startup, and for a hook-serving daemon that almost \
762 always means a lifecycle hook failed — the stateReason above is the only evidence \
763 left, because the VM is gone (docs/PLATFORM.md, 'MicroVM states, and terminal \
764 states reached before RUNNING').",
765 got.id,
766 got.state,
767 got.reason(),
768 ),
769 )
770 }
771
772 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
779 super::require_valid_identifier("microvmIdentifier", id)?;
780 let call = Call::get("GetMicrovm", paths::microvm(id));
781 let reply = self.send(call).await?;
782 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
783 Ok(got.into())
784 }
785
786 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
799 self.list_microvms_matching(&MicrovmFilter::default()).await
800 }
801
802 pub async fn list_microvms_matching(
804 &self,
805 filter: &MicrovmFilter,
806 ) -> Result<Vec<ops::MicrovmItemWire>, Error> {
807 Ok(self
808 .list_microvm_summaries(filter)
809 .await?
810 .into_iter()
811 .map(Into::into)
812 .collect())
813 }
814
815 pub async fn list_microvm_summaries(
818 &self,
819 filter: &MicrovmFilter,
820 ) -> Result<Vec<ops::MicrovmSummaryWire>, Error> {
821 if let Some(image) = filter.image_identifier.as_deref() {
822 super::require_valid_identifier("imageIdentifier", image)?;
823 }
824 if let Some(version) = filter.image_version.as_deref() {
825 super::require_valid_version("imageVersion", version)?;
826 }
827 let mut items: Vec<ops::MicrovmSummaryWire> = Vec::new();
828 let mut position: std::collections::HashMap<String, usize> =
832 std::collections::HashMap::new();
833 let mut next_token: Option<String> = None;
834 loop {
835 let call = Call::get(
836 "ListMicrovms",
837 paths::microvms_list_matching(
838 filter.image_identifier.as_deref(),
839 filter.image_version.as_deref(),
840 next_token.as_deref(),
841 ),
842 );
843 let reply = self.send(call).await?;
844 let page: ops::ListMicrovmSummariesResponseWire = reply.json("ListMicrovms")?;
845 for item in page.items {
846 match position.get(&item.microvm_id) {
847 Some(&at) => items[at] = item,
848 None => {
849 position.insert(item.microvm_id.clone(), items.len());
850 items.push(item);
851 }
852 }
853 }
854 match page.next_token {
855 Some(token) => next_token = Some(token),
856 None => return Ok(items),
857 }
858 }
859 }
860
861 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
863 super::require_valid_identifier("microvmIdentifier", id)?;
864 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
865 self.send(call).await?;
866 Ok(())
867 }
868
869 pub async fn resume(&self, id: &str) -> Result<(), Error> {
888 super::require_valid_identifier("microvmIdentifier", id)?;
889 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
890 match self.send(call).await {
891 Ok(_) => Ok(()),
892 Err(refused) if refused.kind() == ErrorKind::Platform => {
893 Err(self.ended_before_resume(id, refused).await)
894 }
895 Err(error) => Err(error),
896 }
897 }
898
899 async fn ended_before_resume(&self, id: &str, refused: Error) -> Error {
903 let Ok(vm) = self.get_microvm(id).await else {
904 return refused;
905 };
906 if !crate::constants::DEAD_STATES.contains(&vm.state.as_str()) {
907 return refused;
908 }
909 Error::new(
910 ErrorKind::WindowClosed,
911 format!(
912 "microvm {id} can't be resumed: the service refused ResumeMicrovm, and \
913 GetMicrovm reports the VM {} ({}). A VM that has ended doesn't come back; \
914 launch a new one.",
915 vm.state,
916 vm.reason(),
917 ),
918 )
919 .with_source(refused)
920 }
921
922 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
930 super::require_valid_identifier("microvmIdentifier", id)?;
931 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
932 self.send(call).await?;
933 Ok(())
934 }
935
936 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
948 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
949 .await
950 }
951
952 pub async fn mint_auth_token_for(
965 &self,
966 id: &str,
967 ports: &[ops::PortSpecification],
968 ) -> Result<ProxyToken, Error> {
969 super::require_valid_identifier("microvmIdentifier", id)?;
970 if ports.is_empty() {
971 return Err(Error::new(
975 ErrorKind::Precondition,
976 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
977 Name the ports this token is for — the agent port plus any workload port \
978 reached through the endpoint.",
979 ));
980 }
981 for spec in ports {
989 match spec {
990 ops::PortSpecification::One { port } => {
991 super::require_valid_port("allowedPorts[].port", *port)?;
992 }
993 ops::PortSpecification::Range { range } => {
994 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
995 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
996 }
997 ops::PortSpecification::All { .. } => {}
999 }
1000 }
1001 let wire = ops::CreateAuthTokenWire {
1002 expiration_in_minutes: MAX_TOKEN_MINUTES,
1003 allowed_ports: ports.to_vec(),
1004 };
1005 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
1006 let reply = self.send(call).await?;
1007 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
1008 ProxyToken::from_map(&minted.auth_token, self.port()).map_err(|error| reply.error(error))
1009 }
1010
1011 pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
1033 super::require_valid_identifier("microvmIdentifier", id)?;
1034 let wire = ops::CreateShellAuthTokenWire {
1035 expiration_in_minutes: MAX_TOKEN_MINUTES,
1036 };
1037 let call = Call::post_json(
1038 "CreateMicrovmShellAuthToken",
1039 paths::shell_auth_token(id),
1040 &wire,
1041 )?;
1042 let reply = self.send(call).await?;
1043 let minted: ops::CreateShellAuthTokenResponseWire =
1044 reply.json("CreateMicrovmShellAuthToken")?;
1045 ProxyToken::from_operation_map(
1046 "CreateMicrovmShellAuthToken",
1047 &minted.auth_token,
1048 self.port(),
1049 )
1050 .map_err(|error| reply.error(error))
1051 }
1052}
1053
1054pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
1059
1060#[cfg(test)]
1061mod tests {
1062 use std::sync::Arc;
1063
1064 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
1065 use super::*;
1066 use crate::region::Region;
1067
1068 #[test]
1072 fn every_state_a_suspend_waits_for_is_in_the_models_enum() {
1073 for state in SUSPEND_WANTED {
1074 assert!(
1075 crate::constants::MICROVM_STATES.contains(&state),
1076 "SUSPEND_WANTED has {state}, which is not a MicrovmState: a suspend would wait \
1077 for a state the service can't report and time out"
1078 );
1079 }
1080 }
1081
1082 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
1083 let fake = Arc::new(FakeControlPlane::new());
1084 let clock = Arc::new(TestClock::new());
1085 let plane = crate::testing::control_plane(fake.clone(), Region::US_EAST_1, clock.clone());
1086 (plane, fake, clock)
1087 }
1088
1089 #[tokio::test]
1090 async fn post_response_failures_keep_request_retry_details() {
1091 let (plane, fake, _) = planted();
1092 fake.answer("GetMicrovm", Answer::throttled_as_400())
1093 .answer("GetMicrovm", Answer::ok("{}"));
1094 let malformed = plane.get_microvm("mvm-one").await.unwrap_err();
1095 assert_eq!(malformed.kind(), ErrorKind::Platform);
1096 assert_eq!(malformed.attempt_count(), 2);
1097 assert!(!malformed.backoff().is_zero());
1098 assert!(!malformed.throttled());
1099
1100 let (plane, fake, _) = planted();
1101 fake.answer("ResumeMicrovm", Answer::throttled_as_400())
1102 .answer("ResumeMicrovm", terminated_refusal())
1103 .answer(
1104 "GetMicrovm",
1105 Answer::ok(fake::microvm_response("TERMINATED", None)),
1106 );
1107 let ended = plane.resume("mvm-one").await.unwrap_err();
1108 assert_eq!(ended.kind(), ErrorKind::WindowClosed);
1109 assert_eq!(ended.attempt_count(), 2);
1110 assert!(!ended.backoff().is_zero());
1111 assert!(!ended.throttled());
1112 }
1113
1114 #[test]
1121 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
1122 let at_ceiling = "a".repeat(4096);
1123 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
1124 assert_eq!(payload.len(), 4096);
1125 assert_eq!(payload.as_str(), at_ceiling);
1126
1127 let over = "a".repeat(4097);
1128 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
1129 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1130 let message = error.to_string();
1131 assert!(message.contains("4097 bytes"), "{message}");
1132 assert!(
1133 message.contains("ceiling of 4096"),
1134 "the message must name the service-model ceiling: {message}"
1135 );
1136 assert!(message.contains("2025-09-09"), "{message}");
1137 assert!(
1138 message.contains("wrong by 4x"),
1139 "the 16 KB claim has to be named as wrong: {message}"
1140 );
1141 }
1142
1143 #[tokio::test]
1146 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
1147 let (plane, fake, _) = planted();
1148
1149 let refused = RunHookPayload::new("a".repeat(4097));
1151 assert!(refused.is_err());
1152 assert_eq!(fake.calls().len(), 0);
1153
1154 fake.answer(
1157 "RunMicrovm",
1158 Answer::ok(fake::microvm_response("PENDING", None)),
1159 );
1160 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
1161 plane
1162 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1163 .await
1164 .expect("launches");
1165 assert_eq!(fake.call_count("RunMicrovm"), 1);
1166 }
1167
1168 #[test]
1171 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
1172 let two_byte_chars = "é".repeat(2049);
1173 assert_eq!(
1174 two_byte_chars.chars().count(),
1175 2049,
1176 "well under 4096 chars"
1177 );
1178 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
1179
1180 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
1181 assert!(error.to_string().contains("4098 bytes"), "{error}");
1182
1183 let exact = "é".repeat(2048);
1185 assert_eq!(exact.len(), 4096);
1186 RunHookPayload::new(exact).expect("exactly at the ceiling");
1187 }
1188
1189 #[test]
1198 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
1199 let env = std::collections::HashMap::new();
1200 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
1201 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
1202 assert_eq!(
1203 without.as_str(),
1204 none_asked.as_str(),
1205 "no identity must produce byte-for-byte the payload this client always sent"
1206 );
1207
1208 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32])
1209 .expect("valid seeds");
1210 let with =
1211 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
1212 assert_eq!(
1213 with.len() - without.len(),
1214 protocol::identity::IDENTITY_PAYLOAD_BYTES,
1215 "the budget constant must equal what the material really costs on the wire"
1216 );
1217
1218 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
1220 assert_eq!(
1221 parsed.identity_seed.as_deref(),
1222 Some(identity.seed_field().as_str())
1223 );
1224 assert_eq!(
1225 parsed.identity_host_public_key.as_deref(),
1226 Some(identity.host_public_field().as_str())
1227 );
1228 }
1229
1230 #[test]
1233 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
1234 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
1235 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
1236
1237 let huge = "x".repeat(4096);
1240 let error = RunHookPayload::for_agent_token(&huge)
1241 .expect_err("the JSON wrapper pushes it over the ceiling");
1242 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1243 assert!(
1244 error.to_string().contains("one bearer token fits"),
1245 "{error}"
1246 );
1247 }
1248
1249 #[test]
1255 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
1256 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
1257 .expect("an empty env fits");
1258 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
1259 assert_eq!(with_empty.as_str(), token_only.as_str());
1260 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
1261 assert!(
1262 !with_empty.as_str().contains("env"),
1263 "an empty env must not appear on the wire at all: {}",
1264 with_empty.as_str()
1265 );
1266 }
1267
1268 #[test]
1270 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
1271 let mut env = std::collections::HashMap::new();
1272 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
1273 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
1274
1275 let value: serde_json::Value =
1277 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
1278 assert_eq!(value["agent_token"], "tok");
1279 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
1280 }
1281
1282 #[test]
1300 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
1301 let mut env = std::collections::HashMap::new();
1302 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
1304
1305 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1309 .to_string()
1310 .len();
1311 assert!(
1312 expected > 4096,
1313 "the fixture must actually be over: {expected}"
1314 );
1315
1316 let error = RunHookPayload::for_launch("tok", &env)
1317 .expect_err("a credential-scale launch env does not fit");
1318 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1319 let message = error.to_string();
1320
1321 assert!(
1322 message.contains("ceiling of 4096"),
1323 "the ceiling has to be named: {message}"
1324 );
1325 assert!(
1326 message.contains(&format!("{expected} bytes")),
1327 "the actual byte count has to be named: {message}"
1328 );
1329 assert!(
1330 message.contains("launch env contributed"),
1331 "the env's share of the budget is the actionable half: {message}"
1332 );
1333 assert!(
1334 message.contains("1 variable(s)"),
1335 "the variable count has to be named: {message}"
1336 );
1337 }
1338
1339 #[test]
1346 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1347 let mut env = std::collections::HashMap::new();
1348 env.insert("A".to_string(), "a".repeat(2030));
1349 env.insert("B".to_string(), "b".repeat(2030));
1350 let raw: usize = env.values().map(String::len).sum();
1351 assert_eq!(raw, 4060, "the raw values are under the ceiling");
1352 assert!(
1353 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1354 "the fixture only means something if the raw sum fits: {raw}"
1355 );
1356
1357 let error = RunHookPayload::for_launch("tok", &env)
1358 .expect_err("the serialized payload is over even though the raw values are not");
1359 assert!(error.to_string().contains("2 variable(s)"), "{error}");
1360
1361 let mut fits = std::collections::HashMap::new();
1364 fits.insert("A".to_string(), "a".repeat(2000));
1365 fits.insert("B".to_string(), "b".repeat(2000));
1366 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1367 assert!(payload.len() <= 4096, "{}", payload.len());
1368 }
1369
1370 #[test]
1372 fn an_empty_payload_is_legal() {
1373 let payload = RunHookPayload::new("").expect("min is 0");
1374 assert!(payload.is_empty());
1375 assert_eq!(payload.len(), 0);
1376 }
1377
1378 #[tokio::test]
1381 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1382 let (plane, fake, _) = planted();
1383 fake.answer(
1384 "RunMicrovm",
1385 Answer::ok(fake::microvm_response("PENDING", None)),
1386 );
1387
1388 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1389 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1390 plane.run_microvm(request).await.expect("launches");
1391
1392 let calls = fake.calls();
1393 assert_eq!(calls[0].path, "/2025-09-09/microvms");
1394 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1395
1396 let body = fake.first_body("RunMicrovm");
1397 assert_eq!(
1398 body["ingressNetworkConnectors"],
1399 serde_json::json!([
1400 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1401 ])
1402 );
1403 assert_eq!(
1404 body["egressNetworkConnectors"],
1405 serde_json::json!([
1406 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1407 ])
1408 );
1409 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1410 assert_eq!(body["maximumDurationInSeconds"], 3600);
1411 }
1412
1413 #[tokio::test]
1418 async fn a_launch_without_egress_omits_the_member_entirely() {
1419 let (plane, fake, _) = planted();
1420 fake.answer(
1421 "RunMicrovm",
1422 Answer::ok(fake::microvm_response("PENDING", None)),
1423 );
1424 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1425 plane
1426 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1427 .await
1428 .expect("launches");
1429
1430 let body = fake.first_body("RunMicrovm");
1431 assert!(
1432 body.get("egressNetworkConnectors").is_none(),
1433 "omitting egress is the whole mechanism: {body}"
1434 );
1435 assert!(body.get("ingressNetworkConnectors").is_some());
1436 }
1437
1438 #[tokio::test]
1439 async fn customer_vpc_connector_arns_reach_the_egress_member_unchanged() {
1440 let (plane, fake, _) = planted();
1441 fake.answer(
1442 "RunMicrovm",
1443 Answer::ok(fake::microvm_response("PENDING", None)),
1444 );
1445 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1446 let mut request = RunMicrovmRequest::new("arn:image", payload);
1447 let arns: Vec<String> = (0..10)
1448 .map(|id| format!("arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-{id}:1"))
1449 .collect();
1450 request.egress_network_connectors = arns.clone();
1451 plane
1452 .run_microvm(request)
1453 .await
1454 .expect("ten egress connectors plus ingress fits");
1455 let body = fake.first_body("RunMicrovm");
1456 assert_eq!(body["egressNetworkConnectors"], serde_json::json!(arns));
1457 assert_eq!(
1458 body["ingressNetworkConnectors"].as_array().unwrap().len(),
1459 1
1460 );
1461 }
1462
1463 #[tokio::test]
1464 async fn invalid_customer_egress_connectors_are_refused_before_launch() {
1465 let valid = "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test";
1466 let invalid = [
1467 "VPC_EGRESS",
1468 "arn:aws:lambda:us-west-2:123456789012:network-connector:vpc-test",
1469 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS",
1470 "arn:aws:lambda:us-east-1:123456789012:network-connector:",
1471 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test:0",
1472 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test/other",
1473 ];
1474 for arn in invalid {
1475 let (plane, fake, _) = planted();
1476 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1477 let error = plane
1478 .run_microvm(
1479 RunMicrovmRequest::new("arn:image", payload).with_egress_network_connector(arn),
1480 )
1481 .await
1482 .expect_err(arn);
1483 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1484 assert!(
1485 error.to_string().contains("egressNetworkConnectors"),
1486 "{error}"
1487 );
1488 assert!(fake.calls().is_empty());
1489 }
1490 for (internet, count) in [(true, 1), (false, 11)] {
1491 let (plane, fake, _) = planted();
1492 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1493 let mut request = RunMicrovmRequest::new("arn:image", payload);
1494 request.egress_network_connectors = vec![valid.to_string(); count];
1495 if internet {
1496 request = request.with_egress();
1497 }
1498 let error = plane
1499 .run_microvm(request)
1500 .await
1501 .expect_err("invalid routing request");
1502 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1503 assert!(fake.calls().is_empty());
1504 }
1505 }
1506
1507 #[tokio::test]
1520 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1521 let (plane, fake, _) = planted();
1522 fake.answer(
1523 "RunMicrovm",
1524 Answer::ok(fake::microvm_response("PENDING", None)),
1525 );
1526 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1527 plane
1528 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1529 .await
1530 .expect("launches");
1531
1532 let body = fake.first_body("RunMicrovm");
1533 assert_eq!(body["imageVersion"], "2.0");
1534 assert_eq!(
1535 body["imageIdentifier"], "arn:image",
1536 "pinning a version does not replace the identifier; both are sent"
1537 );
1538
1539 let (plane, fake, _) = planted();
1540 fake.answer(
1541 "RunMicrovm",
1542 Answer::ok(fake::microvm_response("PENDING", None)),
1543 );
1544 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1545 plane
1546 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1547 .await
1548 .expect("launches");
1549 assert!(
1550 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1551 "an unpinned launch must send what this client always sent: {}",
1552 fake.first_body("RunMicrovm")
1553 );
1554 }
1555
1556 #[tokio::test]
1566 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1567 for bad in ["", "2.0\n", "a b"] {
1568 let (plane, fake, _) = planted();
1569 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1570 let error = plane
1571 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1572 .await
1573 .expect_err(&format!("{bad:?} is not a legal Version"));
1574 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1575 assert!(
1576 error.to_string().contains("imageVersion"),
1577 "{bad:?}: {error}"
1578 );
1579 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1580 }
1581 }
1582
1583 #[tokio::test]
1587 async fn no_bare_connector_name_reaches_the_wire() {
1588 let (plane, fake, _) = planted();
1589 fake.answer(
1590 "RunMicrovm",
1591 Answer::ok(fake::microvm_response("PENDING", None)),
1592 );
1593 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1594 plane
1595 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1596 .await
1597 .expect("launches");
1598
1599 let body = fake.first_body("RunMicrovm");
1600 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1601 for value in body[member].as_array().expect("a list") {
1602 let text = value.as_str().expect("a string");
1603 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1604 assert_ne!(text, "ALL_INGRESS");
1605 assert_ne!(text, "INTERNET_EGRESS");
1606 }
1607 }
1608 }
1609
1610 #[tokio::test]
1612 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1613 let (plane, fake, _) = planted();
1614 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1615 let mut request = RunMicrovmRequest::new("arn:image", payload);
1616 request.max_duration_sec = 28_801;
1617
1618 let error = plane
1619 .run_microvm(request)
1620 .await
1621 .expect_err("over the ceiling");
1622 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1623 assert_eq!(fake.calls().len(), 0);
1624 }
1625
1626 #[tokio::test]
1636 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1637 let (plane, fake, _) = planted();
1638 fake.answer(
1639 "GetMicrovm",
1640 Answer::ok(fake::microvm_response(
1641 "TERMINATED",
1642 Some("run hook returned 500"),
1643 )),
1644 );
1645
1646 let error = plane
1647 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1648 .await
1649 .expect_err("a dead VM must not be waited out");
1650
1651 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1652 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1653 let message = error.to_string();
1654 assert!(
1655 message.contains("TERMINATED"),
1656 "the state must be attached: {message}"
1657 );
1658 assert!(
1659 message.contains("run hook returned 500"),
1660 "the stateReason must be attached: {message}"
1661 );
1662 assert!(
1663 message.contains("lifecycle hook failed"),
1664 "the likely cause is worth naming: {message}"
1665 );
1666 assert!(
1667 fake.call_count("GetMicrovm") == 1,
1668 "it fails fast rather than polling to the deadline"
1669 );
1670 }
1671
1672 #[tokio::test]
1679 async fn every_terminal_state_fails_a_launch_fast() {
1680 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1681 for state in crate::constants::TERMINAL_STATES {
1682 let (plane, fake, _) = planted();
1683 fake.answer(
1684 "GetMicrovm",
1685 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1686 );
1687 let error = plane
1688 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1689 .await
1690 .expect_err(&format!("{state} before RUNNING is a death"));
1691 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1692 assert!(error.to_string().contains(state), "{state}: {error}");
1693 assert!(
1694 error.to_string().contains("hook timeout"),
1695 "{state}: {error}"
1696 );
1697 assert_eq!(
1698 fake.call_count("GetMicrovm"),
1699 1,
1700 "{state} must fail fast rather than polling to the deadline"
1701 );
1702 }
1703 }
1704
1705 #[tokio::test]
1709 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1710 let (plane, fake, _) = planted();
1711 fake.answer(
1712 "GetMicrovm",
1713 Answer::ok(fake::microvm_response("TERMINATED", None)),
1714 );
1715
1716 let error = plane
1717 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1718 .await
1719 .expect_err("still a death");
1720 let message = error.to_string();
1721 assert!(message.contains("no stateReason"), "{message}");
1722 assert!(
1726 !message.contains(r#"["RUNNING"]: ."#),
1727 "an absent reason must not render as an empty string: {message}"
1728 );
1729 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1730 }
1731
1732 #[tokio::test]
1734 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1735 let (plane, fake, _) = planted();
1736 fake.answer(
1737 "GetMicrovm",
1738 Answer::ok(fake::microvm_response("PENDING", None)),
1739 )
1740 .answer(
1741 "GetMicrovm",
1742 Answer::ok(fake::microvm_response("RUNNING", None)),
1743 );
1744
1745 let vm = plane
1746 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1747 .await
1748 .expect("reaches RUNNING");
1749 assert_eq!(vm.state, "RUNNING");
1750 assert_eq!(
1751 vm.endpoint,
1752 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1753 );
1754 assert_eq!(fake.call_count("GetMicrovm"), 2);
1755 }
1756
1757 #[tokio::test]
1761 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1762 let (plane, fake, _) = planted();
1763 fake.answer(
1764 "GetMicrovm",
1765 Answer::ok(fake::microvm_response("PENDING", None)),
1766 );
1767
1768 let error = plane
1769 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1770 .await
1771 .expect_err("the deadline elapses");
1772 assert_eq!(error.kind(), ErrorKind::Timeout);
1773 assert_ne!(
1774 error.kind(),
1775 ErrorKind::LaunchDied,
1776 "a timeout is not a death, and conflating them is what hid the cause"
1777 );
1778 assert!(error.to_string().contains("PENDING"), "{error}");
1779 }
1780
1781 #[tokio::test]
1784 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1785 let (plane, fake, _) = planted();
1786 fake.answer(
1787 "GetMicrovm",
1788 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1789 )
1790 .answer(
1791 "GetMicrovm",
1792 Answer::ok(fake::microvm_response("RUNNING", None)),
1793 );
1794
1795 let vm = plane
1796 .wait_for_state(
1797 "mvm-abc123",
1798 &["RUNNING"],
1799 &crate::constants::DEAD_STATES,
1800 WaitOpts::for_launch(),
1801 )
1802 .await
1803 .expect("SUSPENDED is a waypoint on the resume path");
1804 assert_eq!(vm.state, "RUNNING");
1805
1806 let (plane, fake, _) = planted();
1807 fake.answer(
1808 "GetMicrovm",
1809 Answer::ok(fake::microvm_response(
1810 "TERMINATED",
1811 Some("suspended window elapsed"),
1812 )),
1813 );
1814 let error = plane
1815 .wait_for_state(
1816 "mvm-abc123",
1817 &["RUNNING"],
1818 &crate::constants::DEAD_STATES,
1819 WaitOpts::for_launch(),
1820 )
1821 .await
1822 .expect_err("a terminated VM never reaches RUNNING");
1823 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1824 assert!(
1825 error.to_string().contains("suspended window elapsed"),
1826 "{error}"
1827 );
1828 }
1829
1830 #[tokio::test]
1833 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1834 let (plane, fake, _) = planted();
1835 fake.answer(
1836 "GetMicrovm",
1837 Answer::ok(fake::microvm_response("TERMINATED", None)),
1838 );
1839
1840 let vm = plane
1841 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1842 .await
1843 .expect("a suspend reports the state it reached");
1844 assert_eq!(vm.state, "TERMINATED");
1845 }
1846
1847 #[tokio::test]
1850 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1851 let (plane, fake, _) = planted();
1852 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1853 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1854 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1855
1856 plane.suspend("mvm-1").await.expect("suspends");
1857 plane.resume("mvm-1").await.expect("resumes");
1858 plane.terminate("mvm-1").await.expect("terminates");
1859
1860 let calls = fake.calls();
1861 use super::super::transport::Method;
1862 assert_eq!(
1863 calls
1864 .iter()
1865 .map(|call| (call.operation, call.method, call.path.as_str()))
1866 .collect::<Vec<_>>(),
1867 [
1868 (
1869 "SuspendMicrovm",
1870 Method::Post,
1871 "/2025-09-09/microvms/mvm-1/suspend"
1872 ),
1873 (
1874 "ResumeMicrovm",
1875 Method::Post,
1876 "/2025-09-09/microvms/mvm-1/resume"
1877 ),
1878 (
1879 "TerminateMicrovm",
1880 Method::Delete,
1881 "/2025-09-09/microvms/mvm-1"
1882 ),
1883 ]
1884 );
1885 }
1886
1887 fn terminated_refusal() -> Answer {
1890 Answer {
1891 status: 400,
1892 body: r#"{"message": "The MicroVM mvm-abc123 has been terminated and its state cannot be changed."}"#
1893 .to_string(),
1894 error_type: Some("ValidationException".to_string()),
1895 }
1896 }
1897
1898 #[tokio::test]
1907 async fn a_resume_refused_for_a_vm_that_terminated_while_suspended_is_window_closed() {
1908 for state in crate::constants::DEAD_STATES {
1909 let (plane, fake, _) = planted();
1910 fake.answer("ResumeMicrovm", terminated_refusal()).answer(
1911 "GetMicrovm",
1912 Answer::ok(fake::microvm_response(
1913 state,
1914 Some("MicroVM exceeded maximum lifetime."),
1915 )),
1916 );
1917
1918 let error = plane
1919 .resume("mvm-abc123")
1920 .await
1921 .expect_err("the VM is gone");
1922
1923 assert_eq!(error.kind(), ErrorKind::WindowClosed, "{state}: {error}");
1924 let message = error.to_string();
1925 assert!(message.contains(state), "{state}: {message}");
1926 assert!(
1927 message.contains("MicroVM exceeded maximum lifetime."),
1928 "{state}: the stateReason: {message}"
1929 );
1930 assert_eq!(
1931 fake.operations(),
1932 ["ResumeMicrovm", "GetMicrovm"],
1933 "{state}: one refused resume, then one read"
1934 );
1935 }
1936 }
1937
1938 #[tokio::test]
1945 async fn a_resume_refused_for_a_vm_a_terminate_ended_names_no_cause_and_keeps_the_refusal() {
1946 let (plane, fake, _) = planted();
1947 fake.answer("ResumeMicrovm", terminated_refusal()).answer(
1948 "GetMicrovm",
1949 Answer::ok(fake::microvm_response("TERMINATED", Some("Success."))),
1950 );
1951
1952 let error = plane
1953 .resume("mvm-abc123")
1954 .await
1955 .expect_err("the VM is gone");
1956
1957 assert_eq!(error.kind(), ErrorKind::WindowClosed, "{error}");
1958 let message = error.to_string();
1959 assert_eq!(
1960 message,
1961 "microvm mvm-abc123 can't be resumed: the service refused ResumeMicrovm, and \
1962 GetMicrovm reports the VM TERMINATED (Success.). A VM that has ended doesn't come \
1963 back; launch a new one."
1964 );
1965 assert!(!message.contains(".."), "{message}");
1966 assert!(
1967 !message.contains("suspended"),
1968 "no cause it can't know: {message}"
1969 );
1970
1971 let source = std::error::Error::source(&error).expect("the refusal is the source");
1972 let refused = source
1973 .downcast_ref::<Error>()
1974 .expect("the source is the service's refusal");
1975 assert_eq!(refused.kind(), ErrorKind::Platform);
1976 assert!(
1977 refused
1978 .to_string()
1979 .contains("has been terminated and its state cannot be changed"),
1980 "the service's own text: {refused}"
1981 );
1982 }
1983
1984 #[tokio::test]
1987 async fn a_resume_refused_for_a_live_or_unreadable_vm_keeps_the_services_answer() {
1988 let (plane, fake, _) = planted();
1989 fake.answer(
1990 "ResumeMicrovm",
1991 Answer::failure(409, "The MicroVM is changing state."),
1992 )
1993 .answer(
1994 "GetMicrovm",
1995 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1996 );
1997 let error = plane
1998 .resume("mvm-abc123")
1999 .await
2000 .expect_err("the service refused");
2001 assert_eq!(error.kind(), ErrorKind::Platform, "{error}");
2002 assert!(error.to_string().contains("ConflictException"), "{error}");
2003
2004 let (plane, fake, _) = planted();
2005 fake.answer("ResumeMicrovm", terminated_refusal())
2006 .answer("GetMicrovm", Answer::failure(404, "not found"));
2007 let error = plane
2008 .resume("mvm-abc123")
2009 .await
2010 .expect_err("the service refused");
2011 assert_eq!(error.kind(), ErrorKind::Platform, "{error}");
2012 let message = error.to_string();
2013 assert!(
2014 message.contains("ResumeMicrovm failed") && !message.contains("GetMicrovm"),
2015 "the resume's refusal rather than the read's: {message}"
2016 );
2017 }
2018
2019 #[tokio::test]
2022 async fn a_resume_denied_for_its_credentials_reads_no_state() {
2023 let (plane, fake, _) = planted();
2024 fake.answer(
2025 "ResumeMicrovm",
2026 Answer::failure(403, "User is not authorized to perform ResumeMicrovm"),
2027 )
2028 .answer(
2029 "GetMicrovm",
2030 Answer::ok(fake::microvm_response("TERMINATED", None)),
2031 );
2032 let error = plane.resume("mvm-abc123").await.expect_err("denied");
2033 assert_eq!(error.kind(), ErrorKind::Credentials, "{error}");
2034 assert_eq!(fake.call_count("GetMicrovm"), 0);
2035 }
2036
2037 #[tokio::test]
2040 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
2041 let (plane, fake, _) = planted();
2042 fake.answer(
2043 "CreateMicrovmAuthToken",
2044 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
2045 );
2046
2047 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
2048 assert_eq!(token.value(), "opaque-proxy-token");
2049 assert_eq!(token.port(), 9000);
2050
2051 let headers = token.headers();
2052 assert_eq!(
2053 headers[0],
2054 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
2055 );
2056 assert_eq!(
2057 headers[1],
2058 ("X-aws-proxy-port", "9000".to_string()),
2059 "the auth header without the port header is rejected in a way that reads like a \
2060 bad token"
2061 );
2062
2063 let body = fake.first_body("CreateMicrovmAuthToken");
2064 assert_eq!(body["expirationInMinutes"], 60);
2065 assert_eq!(body["allowedPorts"][0]["port"], 9000);
2066 }
2067
2068 #[tokio::test]
2080 async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
2081 let (plane, fake, _) = planted();
2082 fake.answer(
2083 "CreateMicrovmShellAuthToken",
2084 Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
2085 );
2086
2087 let token = plane
2088 .mint_shell_auth_token("mvm-1")
2089 .await
2090 .expect("mints a shell token");
2091 assert_eq!(token.value(), "opaque-shell-jwe");
2092
2093 let calls = fake.calls();
2094 assert_eq!(calls.len(), 1);
2095 assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
2096 assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
2097
2098 let body = fake.first_body("CreateMicrovmShellAuthToken");
2099 assert_eq!(body["expirationInMinutes"], 60);
2100 assert_eq!(
2101 body.get("allowedPorts"),
2102 None,
2103 "the shell is not a port: the request declares no allowedPorts member"
2104 );
2105 assert_eq!(
2106 body.as_object().map(serde_json::Map::len),
2107 Some(1),
2108 "expirationInMinutes is the body's only member: {body}"
2109 );
2110 }
2111
2112 #[tokio::test]
2116 async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
2117 let (plane, fake, _) = planted();
2118 let error = plane
2119 .mint_shell_auth_token("")
2120 .await
2121 .expect_err("an empty identifier is refused");
2122 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2123 assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
2124 }
2125
2126 #[test]
2134 fn a_launch_requests_debug_does_not_print_the_agent_token() {
2135 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
2136
2137 let rendered = format!("{payload:?}");
2138 assert!(
2139 !rendered.contains("SECRETTOKEN"),
2140 "the agent token reached a Debug string: {rendered}"
2141 );
2142 assert!(
2143 rendered.contains(&format!("{} bytes", payload.len())),
2144 "the size is what a TRAP-5 diagnosis needs: {rendered}"
2145 );
2146
2147 let request = RunMicrovmRequest::new("arn:image", payload);
2148 let rendered = format!("{request:?}");
2149 assert!(rendered.contains("arn:image"), "{rendered}");
2150 assert!(
2151 !rendered.contains("SECRETTOKEN"),
2152 "the agent token reached a Debug string through the request: {rendered}"
2153 );
2154 }
2155
2156 #[test]
2163 fn a_proxy_token_debug_does_not_print_the_credential() {
2164 let mut map = std::collections::BTreeMap::new();
2165 map.insert(
2166 PROXY_AUTH_HEADER.to_string(),
2167 "eyJhbGciOi-secret".to_string(),
2168 );
2169 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
2170
2171 let rendered = format!("{token:?}");
2172 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
2173 assert!(rendered.contains("9000"), "{rendered}");
2174 assert!(
2175 !rendered.contains("secret"),
2176 "the token value reached a Debug string: {rendered}"
2177 );
2178 }
2179
2180 #[test]
2183 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
2184 let mut map = std::collections::BTreeMap::new();
2185 map.insert("SomeOtherKey".to_string(), "value".to_string());
2186
2187 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
2188 assert_eq!(
2189 error.wire_kind(),
2190 Some(crate::error::WireKind::AuthTokenMint)
2191 );
2192 assert!(error.retryable(), "minting is inside the retry path");
2193 assert!(
2194 error.to_string().contains("is a map, not a string"),
2195 "{error}"
2196 );
2197 }
2198
2199 #[tokio::test]
2201 async fn the_token_is_minted_for_the_configured_port() {
2202 let fake = Arc::new(FakeControlPlane::new());
2203 let plane = crate::testing::control_plane(
2204 fake.clone(),
2205 Region::US_EAST_1,
2206 Arc::new(TestClock::new()),
2207 )
2208 .with_port(8080)
2209 .expect("8080 is a legal port");
2210 fake.answer(
2211 "CreateMicrovmAuthToken",
2212 Answer::ok(fake::auth_token_response("t")),
2213 );
2214
2215 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
2216 assert_eq!(token.port(), 8080);
2217 assert_eq!(
2218 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
2219 8080
2220 );
2221 }
2222
2223 #[tokio::test]
2247 async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
2248 let (plane, fake, _) = planted();
2249 fake.answer(
2250 "CreateMicrovmImage",
2251 Answer::created(fake::create_image_response("img")),
2252 )
2253 .answer(
2254 "GetMicrovmImage",
2255 Answer::ok(fake::get_image_response("img", "CREATED")),
2256 )
2257 .answer(
2258 "RunMicrovm",
2259 Answer::ok(fake::microvm_response("PENDING", None)),
2260 )
2261 .answer(
2262 "GetMicrovm",
2263 Answer::ok(fake::microvm_response("RUNNING", None)),
2264 )
2265 .answer(
2266 "CreateMicrovmAuthToken",
2267 Answer::ok(fake::auth_token_response("proxy-token")),
2268 )
2269 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
2270 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
2271 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2272 .answer(
2273 "ListMicrovmImageVersions",
2274 Answer::ok(fake::list_versions_response("1")),
2275 )
2276 .answer(
2277 "DeleteMicrovmImage",
2278 Answer::ok(fake::delete_image_response()),
2279 );
2280
2281 let request = super::super::CreateImageRequest::new(
2283 "img",
2284 crate::testing::aarch64_daemon(b"binary"),
2285 "s3://bucket/img.zip",
2286 "arn:aws:iam::123456789012:role/build",
2287 );
2288 let image = plane.create_image(request).await.expect("creates");
2289 plane
2290 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
2291 .await
2292 .expect("becomes usable");
2293
2294 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2295 let vm = plane
2296 .run_microvm(
2297 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
2298 )
2299 .await
2300 .expect("launches");
2301 plane
2302 .wait_for_running(&vm.id, WaitOpts::for_launch())
2303 .await
2304 .expect("reaches RUNNING");
2305 plane.mint_auth_token(&vm.id).await.expect("mints");
2306 plane.suspend(&vm.id).await.expect("suspends");
2307 plane.resume(&vm.id).await.expect("resumes");
2308 plane.terminate(&vm.id).await.expect("terminates");
2309 plane
2310 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
2311 .await;
2312
2313 let operations = fake.operations();
2316 assert!(
2317 operations.len() >= 10,
2318 "the lifecycle must actually have run: {operations:?}"
2319 );
2320 assert!(operations.contains(&"CreateMicrovmAuthToken"));
2321
2322 assert_eq!(
2323 fake.call_count("CreateMicrovmShellAuthToken"),
2324 0,
2325 "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
2326 for no shell must mint no shell token: {operations:?}"
2327 );
2328 for operation in &operations {
2329 assert!(
2330 !operation.contains("Shell"),
2331 "no operation may be a shell one: {operation}"
2332 );
2333 }
2334 for path in fake.paths() {
2335 assert!(
2336 !path.contains("shell"),
2337 "no request may reach a shell route: {path}"
2338 );
2339 }
2340
2341 let launch = fake.first_body("RunMicrovm");
2347 assert_eq!(
2348 launch["ingressNetworkConnectors"],
2349 serde_json::json!([
2350 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
2351 ]),
2352 "ingress is exactly what the caller asked for"
2353 );
2354 assert_eq!(
2355 launch["egressNetworkConnectors"],
2356 serde_json::json!([
2357 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
2358 ]),
2359 "egress is exactly what the caller asked for"
2360 );
2361 }
2362
2363 #[tokio::test]
2369 async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
2370 let (plane, fake, _) = planted();
2371 fake.answer(
2372 "RunMicrovm",
2373 Answer::ok(fake::microvm_response("PENDING", None)),
2374 );
2375
2376 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2377 let mut request = RunMicrovmRequest::new("arn:image", payload);
2378 request.connectors = vec![
2379 super::super::ConnectorIntent::HttpIngress,
2380 super::super::ConnectorIntent::ShellIngress,
2381 ];
2382 plane.run_microvm(request).await.expect("launches");
2383
2384 let launch = fake.first_body("RunMicrovm");
2385 assert_eq!(
2386 launch["ingressNetworkConnectors"],
2387 serde_json::json!([
2388 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
2389 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
2390 ]),
2391 "both fine-grained ingress connectors reach the wire, in the order asked"
2392 );
2393 assert_eq!(
2394 launch.get("egressNetworkConnectors"),
2395 None,
2396 "no egress was asked for, so the member is absent rather than empty"
2397 );
2398 }
2399
2400 #[tokio::test]
2409 async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
2410 let (plane, fake, _) = planted();
2411
2412 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2413 let mut request = RunMicrovmRequest::new("arn:image", payload);
2414 request
2415 .connectors
2416 .push(super::super::ConnectorIntent::ShellIngress);
2417 let error = plane
2418 .run_microvm(request)
2419 .await
2420 .expect_err("the invalid combination is refused locally");
2421
2422 assert!(
2423 error
2424 .to_string()
2425 .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
2426 "the refusal names the pair that works: {error}"
2427 );
2428 assert_eq!(
2429 fake.call_count("RunMicrovm"),
2430 0,
2431 "nothing launched, so nothing billed"
2432 );
2433 }
2434
2435 #[tokio::test]
2446 async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
2447 let (plane, fake, _) = planted();
2448
2449 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2450 let mut request = RunMicrovmRequest::new("arn:image", payload);
2451 request.connectors = vec![
2452 super::super::ConnectorIntent::ShellIngress,
2453 super::super::ConnectorIntent::Egress,
2454 ];
2455 let error = plane
2456 .run_microvm(request)
2457 .await
2458 .expect_err("shell ingress without HTTP ingress is refused locally");
2459
2460 assert!(
2461 error.to_string().contains("HTTP_INGRESS"),
2462 "the refusal names the missing connector: {error}"
2463 );
2464 assert_eq!(
2465 fake.call_count("RunMicrovm"),
2466 0,
2467 "nothing launched, so nothing billed"
2468 );
2469 }
2470
2471 #[tokio::test]
2480 async fn the_fleet_listing_retries_a_page_the_service_throttled_as_400() {
2481 let (plane, fake, _) = planted();
2482 fake.answer(
2483 "ListMicrovms",
2484 Answer::ok(fake::list_microvms_page(
2485 &["mvm-page1-a"],
2486 Some("fleet-page-2"),
2487 )),
2488 )
2489 .answer("ListMicrovms", Answer::throttled_as_400())
2490 .answer(
2491 "ListMicrovms",
2492 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2493 );
2494
2495 let fleet = plane
2496 .list_microvms()
2497 .await
2498 .expect("lists past the throttle");
2499 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2500 assert_eq!(ids, ["mvm-page1-a", "mvm-page2-a"]);
2501 assert_eq!(
2502 fake.call_count("ListMicrovms"),
2503 3,
2504 "page two was asked for again after the throttle"
2505 );
2506 let paths = fake.paths();
2507 assert_eq!(paths[1], paths[2], "the retry is the identical request");
2508 }
2509
2510 #[tokio::test]
2523 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
2524 let (plane, fake, _) = planted();
2525 fake.answer(
2526 "ListMicrovms",
2527 Answer::ok(fake::list_microvms_page(
2528 &["mvm-page1-a", "mvm-page1-b"],
2529 Some("fleet-page-2"),
2530 )),
2531 )
2532 .answer(
2533 "ListMicrovms",
2534 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2535 );
2536
2537 let fleet = plane.list_microvms().await.expect("lists");
2538 assert_eq!(
2539 fleet.len(),
2540 3,
2541 "a fleet spanning two pages is three VMs, not two"
2542 );
2543
2544 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2545 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
2546 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
2547 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2548
2549 let paths = fake.paths();
2550 assert!(
2551 paths[1].contains("nextToken=fleet-page-2"),
2552 "the second request must carry the first page's token: {}",
2553 paths[1]
2554 );
2555 assert!(
2556 !paths[0].contains('?'),
2557 "the first request carries no cursor: {}",
2558 paths[0]
2559 );
2560 }
2561
2562 #[tokio::test]
2570 async fn a_vm_the_fleet_listing_meets_on_two_pages_is_listed_once() {
2571 let (plane, fake, _) = planted();
2572 fake.answer(
2573 "ListMicrovms",
2574 Answer::ok(fake::list_microvms_page(
2575 &["mvm-a", "mvm-moved"],
2576 Some("fleet-page-2"),
2577 )),
2578 )
2579 .answer(
2580 "ListMicrovms",
2581 Answer::ok(
2582 fake::list_microvms_page(&["mvm-moved", "mvm-b"], None).replacen(
2583 "RUNNING",
2584 "SUSPENDED",
2585 1,
2586 ),
2587 ),
2588 );
2589
2590 let fleet = plane.list_microvms().await.expect("lists");
2591 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2592 assert_eq!(
2593 ids,
2594 ["mvm-a", "mvm-moved", "mvm-b"],
2595 "each VM once, in the order first listed"
2596 );
2597 assert_eq!(
2598 fleet[1].state, "SUSPENDED",
2599 "the page read later is the newer reading of the VM"
2600 );
2601 assert_eq!(fleet[0].state, "RUNNING");
2602 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2603 }
2604
2605 #[tokio::test]
2615 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2616 let (plane, fake, _) = planted();
2617 fake.answer(
2618 "GetMicrovm",
2619 Answer::ok(fake::microvm_response("RUNNING", None)),
2620 );
2621
2622 let vm = plane.get_microvm("mvm-1").await.expect("reads");
2623 let policy = vm
2624 .idle_policy
2625 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2626 assert_eq!(policy.max_idle_duration_seconds, 1800);
2627 assert_eq!(
2628 policy.suspended_duration_seconds, 600,
2629 "the member the comment claimed was request-only"
2630 );
2631 assert!(!policy.auto_resume_enabled);
2632 }
2633
2634 #[tokio::test]
2638 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2639 let (plane, fake, _) = planted();
2640 fake.answer(
2641 "GetMicrovm",
2642 Answer::ok(
2643 r#"{
2644 "microvmId": "mvm-1",
2645 "state": "RUNNING",
2646 "endpoint": "https://e",
2647 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2648 "imageVersion": "1"
2649 }"#,
2650 ),
2651 );
2652
2653 let vm = plane.get_microvm("mvm-1").await.expect("parses");
2654 assert_eq!(vm.idle_policy, None);
2655 }
2656
2657 #[tokio::test]
2660 async fn a_transport_failure_is_retried_rather_than_reported() {
2661 let (plane, fake, _) = planted();
2662 fake.fail_transport("GetMicrovm", 2).answer(
2663 "GetMicrovm",
2664 Answer::ok(fake::microvm_response("RUNNING", None)),
2665 );
2666
2667 let vm = plane
2668 .get_microvm("mvm-1")
2669 .await
2670 .expect("the retry succeeds");
2671 assert_eq!(vm.state, "RUNNING");
2672 assert_eq!(
2673 fake.call_count("GetMicrovm"),
2674 3,
2675 "two failures then a success"
2676 );
2677 }
2678
2679 #[tokio::test]
2696 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2697 for under in [0, 1, 59] {
2698 let (plane, fake, _) = planted();
2699 fake.answer(
2700 "RunMicrovm",
2701 Answer::ok(fake::microvm_response("PENDING", None)),
2702 );
2703
2704 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2705 let mut request = RunMicrovmRequest::new("arn:image", payload);
2706 request.max_idle_sec = under;
2707
2708 let error = plane
2709 .run_microvm(request)
2710 .await
2711 .expect_err("the model's min is 60");
2712 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2713 let message = error.to_string();
2714 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2715 assert!(message.contains(&under.to_string()), "{message}");
2716 assert_eq!(
2717 fake.calls().len(),
2718 0,
2719 "{under} was refused locally; before this guard it was serialized and sent"
2720 );
2721 }
2722
2723 let (plane, fake, _) = planted();
2726 fake.answer(
2727 "RunMicrovm",
2728 Answer::ok(fake::microvm_response("PENDING", None)),
2729 );
2730 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2731 let mut request = RunMicrovmRequest::new("arn:image", payload);
2732 request.max_idle_sec = 60;
2733 plane.run_microvm(request).await.expect("60 is the minimum");
2734 assert_eq!(
2735 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2736 60
2737 );
2738 }
2739
2740 #[tokio::test]
2750 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2751 type Mutate = fn(&mut RunMicrovmRequest);
2754 let rows: [(&str, Mutate, &str); 6] = [
2755 (
2756 "an execution role that is a bare name",
2757 |request| {
2758 request.execution_role_arn = Some("execution-role".to_string());
2759 },
2760 "role *name*",
2761 ),
2762 (
2763 "an execution role with eleven account digits",
2764 |request| {
2765 request.execution_role_arn =
2766 Some("arn:aws:iam::12345678901:role/exec".to_string());
2767 },
2768 "exactly twelve digits",
2769 ),
2770 (
2771 "an execution role that is a function ARN",
2772 |request| {
2773 request.execution_role_arn =
2774 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2775 },
2776 "RoleArn pattern",
2777 ),
2778 (
2779 "an empty image identifier",
2780 |request| {
2781 request.image_identifier = String::new();
2782 },
2783 "collapses the path",
2784 ),
2785 (
2786 "a 257-character image identifier",
2787 |request| {
2788 request.image_identifier = "a".repeat(257);
2789 },
2790 "MicrovmImageArn permits 2048",
2791 ),
2792 (
2793 "an image version with a newline",
2794 |request| {
2795 request.image_version = Some("2.0\n".to_string());
2796 },
2797 "contains whitespace",
2798 ),
2799 ];
2800
2801 for (label, mutate, expected) in rows {
2802 let (plane, fake, _) = planted();
2803 fake.answer(
2804 "RunMicrovm",
2805 Answer::ok(fake::microvm_response("PENDING", None)),
2806 );
2807
2808 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2809 let mut request = RunMicrovmRequest::new("arn:image", payload);
2810 mutate(&mut request);
2811
2812 let error = plane
2813 .run_microvm(request)
2814 .await
2815 .expect_err(&format!("{label} must be refused"));
2816 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2817 assert!(
2818 error.to_string().contains(expected),
2819 "{label}: wanted {expected:?}, got {error}"
2820 );
2821 assert_eq!(fake.calls().len(), 0, "{label}");
2822 }
2823
2824 let (plane, fake, _) = planted();
2827 fake.answer(
2828 "RunMicrovm",
2829 Answer::ok(fake::microvm_response("PENDING", None)),
2830 );
2831 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2832 let mut request = RunMicrovmRequest::new(
2833 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2834 payload,
2835 );
2836 request.execution_role_arn =
2837 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2838 request.image_version = Some("1.0".to_string());
2839 plane
2840 .run_microvm(request)
2841 .await
2842 .expect("a realistic pinned launch with a real execution role");
2843 assert_eq!(fake.call_count("RunMicrovm"), 1);
2844 }
2845
2846 #[tokio::test]
2856 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2857 for bad in ["", &"a".repeat(257)] {
2858 let (plane, fake, _) = planted();
2859 fake.answer(
2862 "GetMicrovm",
2863 Answer::ok(fake::microvm_response("RUNNING", None)),
2864 );
2865 fake.answer("SuspendMicrovm", Answer::ok("{}"));
2866 fake.answer("ResumeMicrovm", Answer::ok("{}"));
2867 fake.answer("TerminateMicrovm", Answer::ok("{}"));
2868 fake.answer(
2869 "CreateMicrovmAuthToken",
2870 Answer::ok(fake::auth_token_response("t")),
2871 );
2872
2873 plane.get_microvm(bad).await.expect_err("GetMicrovm");
2874 plane.suspend(bad).await.expect_err("SuspendMicrovm");
2875 plane.resume(bad).await.expect_err("ResumeMicrovm");
2876 plane.terminate(bad).await.expect_err("TerminateMicrovm");
2877 plane
2878 .mint_auth_token(bad)
2879 .await
2880 .expect_err("CreateMicrovmAuthToken");
2881 plane
2882 .wait_for_running(bad, WaitOpts::for_launch())
2883 .await
2884 .expect_err("GetMicrovm, in a loop");
2885
2886 assert_eq!(
2887 fake.calls().len(),
2888 0,
2889 "six operations refused {bad:?} locally. An empty identifier on the terminate \
2890 path is a DELETE addressed at the collection."
2891 );
2892 }
2893 }
2894
2895 #[tokio::test]
2910 async fn no_proxy_token_is_minted_for_port_zero() {
2911 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2912 (
2913 "a single port of 0",
2914 ops::PortSpecification::port(0),
2915 "allowedPorts[].port",
2916 ),
2917 (
2918 "a range starting at 0",
2919 ops::PortSpecification::range(0, 9000),
2920 "allowedPorts[].range.startPort",
2921 ),
2922 (
2923 "a range ending at 0",
2924 ops::PortSpecification::range(0, 0),
2925 "allowedPorts[].range.startPort",
2926 ),
2927 ];
2928
2929 for (label, spec, member) in rows {
2930 let (plane, fake, _) = planted();
2931 fake.answer(
2932 "CreateMicrovmAuthToken",
2933 Answer::ok(fake::auth_token_response("t")),
2934 );
2935
2936 let error = plane
2937 .mint_auth_token_for("mvm-1", &[spec])
2938 .await
2939 .expect_err(&format!("{label} must be refused"));
2940 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2941 let message = error.to_string();
2942 assert!(message.contains(member), "{label}: {message}");
2943 assert!(
2944 message.contains("authorizes nothing"),
2945 "{label}: the 403 consequence has to be in the message: {message}"
2946 );
2947 assert_eq!(fake.calls().len(), 0, "{label}");
2948 }
2949
2950 let (plane, fake, _) = planted();
2954 fake.answer(
2955 "CreateMicrovmAuthToken",
2956 Answer::ok(fake::auth_token_response("t")),
2957 );
2958 plane
2959 .mint_auth_token_for(
2960 "mvm-1",
2961 &[
2962 ops::PortSpecification::port(9000),
2963 ops::PortSpecification::port(0),
2964 ],
2965 )
2966 .await
2967 .expect_err("every entry is checked, not the first");
2968 assert_eq!(fake.calls().len(), 0);
2969
2970 for legal in [
2973 ops::PortSpecification::port(1),
2974 ops::PortSpecification::port(65_535),
2975 ops::PortSpecification::range(1, 65_535),
2976 ops::PortSpecification::all(),
2977 ] {
2978 let (plane, fake, _) = planted();
2979 fake.answer(
2980 "CreateMicrovmAuthToken",
2981 Answer::ok(fake::auth_token_response("t")),
2982 );
2983 plane
2984 .mint_auth_token_for("mvm-1", &[legal])
2985 .await
2986 .expect("a legal port specification mints");
2987 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2988 }
2989 }
2990
2991 #[tokio::test]
2997 async fn an_adopted_launch_that_finds_its_vm_suspended_resumes_it() {
2998 for first in ["SUSPENDING", "SUSPENDED"] {
2999 let (plane, fake, _) = planted();
3000 fake.answer(
3001 "GetMicrovm",
3002 Answer::ok(fake::microvm_response(first, None)),
3003 )
3004 .answer(
3005 "GetMicrovm",
3006 Answer::ok(fake::microvm_response("SUSPENDED", None)),
3007 )
3008 .answer(
3009 "GetMicrovm",
3010 Answer::ok(fake::microvm_response("SUSPENDED", None)),
3011 )
3012 .answer(
3013 "GetMicrovm",
3014 Answer::ok(fake::microvm_response("RUNNING", None)),
3015 )
3016 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()));
3017 let running = plane
3018 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
3019 .await
3020 .unwrap_or_else(|error| panic!("{first}: {error}"));
3021 assert_eq!(running.state, "RUNNING", "{first}");
3022 assert_eq!(
3023 fake.call_count("ResumeMicrovm"),
3024 1,
3025 "{first}: one resume, even though GetMicrovm still read SUSPENDED after it"
3026 );
3027 }
3028 }
3029
3030 #[tokio::test]
3032 async fn an_adopted_launch_still_fails_fast_on_a_dead_state() {
3033 for state in crate::constants::DEAD_STATES {
3034 let (plane, fake, _) = planted();
3035 fake.answer(
3036 "GetMicrovm",
3037 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
3038 );
3039 let error = plane
3040 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
3041 .await
3042 .expect_err(&format!("{state} is a death"));
3043 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
3044 assert!(
3045 error.to_string().contains("hook timeout"),
3046 "{state}: {error}"
3047 );
3048 assert_eq!(fake.call_count("GetMicrovm"), 1, "{state}");
3049 assert_eq!(fake.call_count("ResumeMicrovm"), 0, "{state}");
3050 }
3051 }
3052
3053 #[tokio::test]
3055 async fn get_microvm_carries_the_lifetime_members() {
3056 let (plane, fake, _) = planted();
3057 fake.answer(
3058 "GetMicrovm",
3059 Answer::ok(fake::microvm_response("RUNNING", None)),
3060 );
3061 let vm = plane.get_microvm("mvm-abc123").await.expect("parses");
3062 assert_eq!(vm.maximum_duration_seconds, Some(3600));
3063 assert_eq!(
3064 vm.started_at,
3065 Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_524_800))
3066 );
3067 assert_eq!(vm.terminated_at, None);
3068 }
3069
3070 #[tokio::test]
3079 async fn an_out_of_range_timestamp_reads_as_absent() {
3080 for seconds in ["2e19", "1e300", "1.7976931348623157e308", "-1"] {
3081 let (plane, fake, _) = planted();
3082 let body = fake::microvm_response("TERMINATED", None).replace(
3083 r#""startedAt": 1754524800"#,
3084 &format!(r#""startedAt": {seconds}, "terminatedAt": {seconds}"#),
3085 );
3086 fake.answer("GetMicrovm", Answer::ok(body));
3087 let vm = plane
3088 .get_microvm("mvm-abc123")
3089 .await
3090 .unwrap_or_else(|error| panic!("{seconds}: {error}"));
3091 assert_eq!(vm.state, "TERMINATED", "{seconds}");
3092 assert_eq!(vm.maximum_duration_seconds, Some(3600), "{seconds}");
3093 assert_eq!(vm.started_at, None, "{seconds}");
3094 assert_eq!(vm.terminated_at, None, "{seconds}");
3095 }
3096 }
3097
3098 #[tokio::test]
3100 async fn list_filters_reach_the_query_string() {
3101 let (plane, fake, _) = planted();
3102 fake.answer(
3103 "ListMicrovms",
3104 Answer::ok(fake::list_microvms_page(&["mvm-1"], None)),
3105 );
3106 let filter = MicrovmFilter {
3107 image_identifier: Some(
3108 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img".into(),
3109 ),
3110 image_version: Some("1.0".into()),
3111 };
3112 let items = plane.list_microvms_matching(&filter).await.expect("lists");
3113 assert_eq!(items.len(), 1);
3114 let path = fake.paths().pop().expect("one call");
3115 assert!(
3116 path.ends_with(
3117 "/microvms?imageIdentifier=arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg&imageVersion=1.0"
3118 ),
3119 "{path}"
3120 );
3121 plane.list_microvms().await.expect("unfiltered");
3122 assert!(fake.paths().pop().expect("second").ends_with("/microvms"));
3123 }
3124
3125 #[tokio::test]
3127 async fn per_vm_logging_is_sent_and_checked_locally() {
3128 let (plane, fake, _) = planted();
3129 fake.answer(
3130 "RunMicrovm",
3131 Answer::ok(fake::microvm_response("PENDING", None)),
3132 );
3133 let payload = || RunHookPayload::for_agent_token("t").expect("fits");
3134 let mut request = RunMicrovmRequest::new("arn:image", payload());
3135 request.logging = Some(ops::Logging::cloud_watch("/team/agents", None));
3136 plane.run_microvm(request).await.expect("launches");
3137 assert_eq!(
3138 fake.first_body("RunMicrovm")["logging"],
3139 serde_json::json!({"cloudWatch": {"logGroup": "/team/agents"}})
3140 );
3141
3142 let mut disabled = RunMicrovmRequest::new("arn:image", payload());
3143 disabled.logging = Some(ops::Logging::disabled());
3144 plane.run_microvm(disabled).await.expect("launches");
3145 assert_eq!(
3146 fake.body_of("RunMicrovm", 1)["logging"],
3147 serde_json::json!({"disabled": {}})
3148 );
3149
3150 let plain = RunMicrovmRequest::new("arn:image", payload());
3151 plane.run_microvm(plain).await.expect("launches");
3152 assert!(fake.body_of("RunMicrovm", 2).get("logging").is_none());
3153
3154 for bad in [
3155 ops::Logging::cloud_watch("bad group!", None),
3156 ops::Logging::CloudWatch {
3157 cloud_watch: ops::CloudWatchLogging {
3158 log_group: None,
3159 log_stream: Some("s".into()),
3160 },
3161 },
3162 ] {
3163 let mut request = RunMicrovmRequest::new("arn:image", payload());
3164 request.logging = Some(bad);
3165 let error = plane.run_microvm(request).await.expect_err("refused");
3166 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
3167 }
3168 assert_eq!(
3169 fake.call_count("RunMicrovm"),
3170 3,
3171 "the refusals cost no call"
3172 );
3173 }
3174}