1use super::transport::{Call, paths, send_with_retry};
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 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 let (ingress, egress) = self.launch_connectors(&request)?;
435
436 let wire = ops::RunMicrovmWire {
437 image_identifier: request.image_identifier.clone(),
438 image_version: request.image_version.clone(),
439 execution_role_arn: request.execution_role_arn.clone(),
440 ingress_network_connectors: ingress,
441 egress_network_connectors: (!egress.is_empty()).then_some(egress),
446 idle_policy: ops::IdlePolicy {
447 max_idle_duration_seconds: request.max_idle_sec,
448 suspended_duration_seconds: request.suspended_sec,
449 auto_resume_enabled: request.auto_resume,
450 },
451 maximum_duration_in_seconds: request.max_duration_sec,
452 run_hook_payload: request.run_hook_payload.as_str().to_string(),
453 logging: request.logging.clone(),
454 client_token: match request.client_token {
455 Some(token) => token,
456 None => token::run_token_with(
457 request
458 .token_scope
459 .as_deref()
460 .unwrap_or(&request.image_identifier),
461 self.entropy(),
462 )?,
463 },
464 };
465
466 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
467 let reply = send_with_retry(self.transport(), call).await?;
468 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
469 Ok(launched.into())
470 }
471
472 pub(crate) fn launch_connectors(
478 &self,
479 request: &RunMicrovmRequest,
480 ) -> Result<(Vec<String>, Vec<String>), Error> {
481 if let Some(token) = request.client_token.as_deref() {
482 super::token::require_run_token(token)?;
483 }
484 super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
485 super::require_duration_in_range(request.max_duration_sec)?;
486 super::require_idle_duration(request.max_idle_sec)?;
490 if let Some(version) = request.image_version.as_deref() {
494 super::require_valid_version("imageVersion", version)?;
495 }
496 if let Some(role) = request.execution_role_arn.as_deref() {
500 super::require_valid_role_arn("executionRoleArn", role)?;
501 }
502 if let Some(ops::Logging::CloudWatch { cloud_watch }) = &request.logging {
503 let Some(group) = cloud_watch.log_group.as_deref() else {
504 return Err(Error::invalid_arg(
505 "per-VM CloudWatch logging needs a logGroup; pass a group name or disable logging",
506 ));
507 };
508 super::require_valid_log_group(group)?;
509 if let Some(stream) = cloud_watch.log_stream.as_deref() {
510 super::require_valid_log_stream(stream)?;
511 }
512 }
513
514 let has_all_ingress = request
521 .connectors
522 .contains(&super::ConnectorIntent::AllIngress);
523 let has_finer_ingress = request.connectors.iter().any(|intent| {
524 matches!(
525 intent,
526 super::ConnectorIntent::HttpIngress | super::ConnectorIntent::ShellIngress
527 )
528 });
529 if has_all_ingress && has_finer_ingress {
530 return Err(Error::invalid_arg(
531 "ALL_INGRESS cannot be combined with other ingress connectors; use \
532 HTTP_INGRESS and/or SHELL_INGRESS instead. The platform accepts the \
533 combination at launch and rejects it only when a shell token is minted, \
534 after the VM has launched and billed — so it is refused here.",
535 ));
536 }
537
538 let has_shell_ingress = request
543 .connectors
544 .contains(&super::ConnectorIntent::ShellIngress);
545 let has_http_ingress = request
546 .connectors
547 .contains(&super::ConnectorIntent::HttpIngress);
548 if has_shell_ingress && !has_http_ingress {
549 return Err(Error::invalid_arg(
550 "SHELL_INGRESS was requested without HTTP_INGRESS. The measured pair that \
551 launches and mints a shell token is [HTTP_INGRESS, SHELL_INGRESS] \
552 (docs/PLATFORM.md); the platform accepts an invalid ingress set at launch \
553 and the VM runs and bills before any failure appears — so it is refused \
554 here.",
555 ));
556 }
557
558 let ingress: Vec<String> = request
561 .connectors
562 .iter()
563 .filter(|intent| {
564 matches!(
565 intent,
566 super::ConnectorIntent::AllIngress
567 | super::ConnectorIntent::HttpIngress
568 | super::ConnectorIntent::ShellIngress
569 )
570 })
571 .map(|intent| intent.arn(&self.region))
572 .collect();
573 let mut egress: Vec<String> = request
574 .connectors
575 .iter()
576 .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
577 .map(|intent| intent.arn(&self.region))
578 .collect();
579
580 if !egress.is_empty() && !request.egress_network_connectors.is_empty() {
583 return Err(super::connector::egress_with_connectors());
584 }
585 for arn in &request.egress_network_connectors {
586 super::connector::require_egress_connector_arn(arn, &self.region)?;
587 egress.push(arn.clone());
588 }
589
590 for (member, count) in [
592 ("ingressNetworkConnectors", ingress.len()),
593 ("egressNetworkConnectors", egress.len()),
594 ] {
595 if let Some(refusal) = super::connector::over_connector_ceiling(member, count) {
596 return Err(refusal);
597 }
598 }
599
600 Ok((ingress, egress))
601 }
602
603 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
610 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
611 .await
612 }
613
614 pub async fn wait_for_launch(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
623 let started = self.clock().elapsed();
624 let mut resumed = false;
625 loop {
626 let got = self.get_microvm(id).await?;
627 match got.state.as_str() {
628 "RUNNING" => return Ok(got),
629 state if crate::constants::DEAD_STATES.contains(&state) => {
630 return Err(self.reached_terminal_state(&got, &["RUNNING"]));
631 }
632 "SUSPENDED" if !resumed => {
635 self.resume(id).await?;
636 resumed = true;
637 continue;
638 }
639 _ => {}
640 }
641
642 let elapsed = self.clock().elapsed().saturating_sub(started);
643 if elapsed >= opts.timeout {
644 return Err(timed_out(
645 &format!(
646 "microvm {id} never reached [\"RUNNING\"] (last state {})",
647 got.state
648 ),
649 elapsed,
650 ));
651 }
652 self.clock().sleep(opts.poll_interval).await;
653 }
654 }
655
656 pub async fn wait_for_state(
667 &self,
668 id: &str,
669 wanted: &[&str],
670 fail_on: &[&str],
671 opts: WaitOpts,
672 ) -> Result<Microvm, Error> {
673 let started = self.clock().elapsed();
674 loop {
675 let got = self.get_microvm(id).await?;
676
677 if wanted.contains(&got.state.as_str()) {
678 return Ok(got);
679 }
680 if fail_on.contains(&got.state.as_str()) {
681 return Err(self.reached_terminal_state(&got, wanted));
682 }
683
684 let elapsed = self.clock().elapsed().saturating_sub(started);
685 if elapsed >= opts.timeout {
686 return Err(timed_out(
687 &format!(
688 "microvm {id} never reached {wanted:?} (last state {})",
689 got.state
690 ),
691 elapsed,
692 ));
693 }
694 self.clock().sleep(opts.poll_interval).await;
695 }
696 }
697
698 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
703 Error::new(
704 ErrorKind::LaunchDied,
705 format!(
706 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
707 before RUNNING died during startup, and for a hook-serving daemon that almost \
708 always means a lifecycle hook failed — the stateReason above is the only evidence \
709 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
710 startup reports a connection error that hides the cause').",
711 got.id,
712 got.state,
713 got.reason(),
714 ),
715 )
716 }
717
718 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
725 super::require_valid_identifier("microvmIdentifier", id)?;
726 let call = Call::get("GetMicrovm", paths::microvm(id));
727 let reply = send_with_retry(self.transport(), call).await?;
728 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
729 Ok(got.into())
730 }
731
732 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
742 self.list_microvms_matching(&MicrovmFilter::default()).await
743 }
744
745 pub async fn list_microvms_matching(
747 &self,
748 filter: &MicrovmFilter,
749 ) -> Result<Vec<ops::MicrovmItemWire>, Error> {
750 if let Some(image) = filter.image_identifier.as_deref() {
751 super::require_valid_identifier("imageIdentifier", image)?;
752 }
753 if let Some(version) = filter.image_version.as_deref() {
754 super::require_valid_version("imageVersion", version)?;
755 }
756 let mut items = Vec::new();
757 let mut next_token: Option<String> = None;
758 loop {
759 let call = Call::get(
760 "ListMicrovms",
761 paths::microvms_list_matching(
762 filter.image_identifier.as_deref(),
763 filter.image_version.as_deref(),
764 next_token.as_deref(),
765 ),
766 );
767 let reply = send_with_retry(self.transport(), call).await?;
768 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
769 items.extend(page.items);
770 match page.next_token {
771 Some(token) => next_token = Some(token),
772 None => return Ok(items),
773 }
774 }
775 }
776
777 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
779 super::require_valid_identifier("microvmIdentifier", id)?;
780 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
781 send_with_retry(self.transport(), call).await?;
782 Ok(())
783 }
784
785 pub async fn resume(&self, id: &str) -> Result<(), Error> {
787 super::require_valid_identifier("microvmIdentifier", id)?;
788 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
789 send_with_retry(self.transport(), call).await?;
790 Ok(())
791 }
792
793 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
801 super::require_valid_identifier("microvmIdentifier", id)?;
802 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
803 send_with_retry(self.transport(), call).await?;
804 Ok(())
805 }
806
807 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
819 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
820 .await
821 }
822
823 pub async fn mint_auth_token_for(
836 &self,
837 id: &str,
838 ports: &[ops::PortSpecification],
839 ) -> Result<ProxyToken, Error> {
840 super::require_valid_identifier("microvmIdentifier", id)?;
841 if ports.is_empty() {
842 return Err(Error::new(
846 ErrorKind::Precondition,
847 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
848 Name the ports this token is for — the agent port plus any workload port \
849 reached through the endpoint.",
850 ));
851 }
852 for spec in ports {
860 match spec {
861 ops::PortSpecification::One { port } => {
862 super::require_valid_port("allowedPorts[].port", *port)?;
863 }
864 ops::PortSpecification::Range { range } => {
865 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
866 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
867 }
868 ops::PortSpecification::All { .. } => {}
870 }
871 }
872 let wire = ops::CreateAuthTokenWire {
873 expiration_in_minutes: MAX_TOKEN_MINUTES,
874 allowed_ports: ports.to_vec(),
875 };
876 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
877 let reply = send_with_retry(self.transport(), call).await?;
878 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
879 ProxyToken::from_map(&minted.auth_token, self.port())
880 }
881
882 pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
904 super::require_valid_identifier("microvmIdentifier", id)?;
905 let wire = ops::CreateShellAuthTokenWire {
906 expiration_in_minutes: MAX_TOKEN_MINUTES,
907 };
908 let call = Call::post_json(
909 "CreateMicrovmShellAuthToken",
910 paths::shell_auth_token(id),
911 &wire,
912 )?;
913 let reply = send_with_retry(self.transport(), call).await?;
914 let minted: ops::CreateShellAuthTokenResponseWire =
915 reply.json("CreateMicrovmShellAuthToken")?;
916 ProxyToken::from_operation_map(
917 "CreateMicrovmShellAuthToken",
918 &minted.auth_token,
919 self.port(),
920 )
921 }
922}
923
924pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
929
930#[cfg(test)]
931mod tests {
932 use std::sync::Arc;
933
934 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
935 use super::*;
936 use crate::region::Region;
937
938 #[test]
942 fn every_state_a_suspend_waits_for_is_in_the_models_enum() {
943 for state in SUSPEND_WANTED {
944 assert!(
945 crate::constants::MICROVM_STATES.contains(&state),
946 "SUSPEND_WANTED has {state}, which is not a MicrovmState: a suspend would wait \
947 for a state the service can't report and time out"
948 );
949 }
950 }
951
952 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
953 let fake = Arc::new(FakeControlPlane::new());
954 let clock = Arc::new(TestClock::new());
955 let plane = crate::testing::control_plane(fake.clone(), Region::UsEast1, clock.clone());
956 (plane, fake, clock)
957 }
958
959 #[test]
966 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
967 let at_ceiling = "a".repeat(4096);
968 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
969 assert_eq!(payload.len(), 4096);
970 assert_eq!(payload.as_str(), at_ceiling);
971
972 let over = "a".repeat(4097);
973 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
974 assert_eq!(error.kind(), ErrorKind::InvalidArg);
975 let message = error.to_string();
976 assert!(message.contains("4097 bytes"), "{message}");
977 assert!(
978 message.contains("ceiling of 4096"),
979 "the message must name the service-model ceiling: {message}"
980 );
981 assert!(message.contains("2025-09-09"), "{message}");
982 assert!(
983 message.contains("wrong by 4x"),
984 "the 16 KB claim has to be named as wrong: {message}"
985 );
986 }
987
988 #[tokio::test]
991 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
992 let (plane, fake, _) = planted();
993
994 let refused = RunHookPayload::new("a".repeat(4097));
996 assert!(refused.is_err());
997 assert_eq!(fake.calls().len(), 0);
998
999 fake.answer(
1002 "RunMicrovm",
1003 Answer::ok(fake::microvm_response("PENDING", None)),
1004 );
1005 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
1006 plane
1007 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1008 .await
1009 .expect("launches");
1010 assert_eq!(fake.call_count("RunMicrovm"), 1);
1011 }
1012
1013 #[test]
1016 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
1017 let two_byte_chars = "é".repeat(2049);
1018 assert_eq!(
1019 two_byte_chars.chars().count(),
1020 2049,
1021 "well under 4096 chars"
1022 );
1023 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
1024
1025 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
1026 assert!(error.to_string().contains("4098 bytes"), "{error}");
1027
1028 let exact = "é".repeat(2048);
1030 assert_eq!(exact.len(), 4096);
1031 RunHookPayload::new(exact).expect("exactly at the ceiling");
1032 }
1033
1034 #[test]
1043 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
1044 let env = std::collections::HashMap::new();
1045 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
1046 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
1047 assert_eq!(
1048 without.as_str(),
1049 none_asked.as_str(),
1050 "no identity must produce byte-for-byte the payload this client always sent"
1051 );
1052
1053 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32])
1054 .expect("valid seeds");
1055 let with =
1056 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
1057 assert_eq!(
1058 with.len() - without.len(),
1059 protocol::identity::IDENTITY_PAYLOAD_BYTES,
1060 "the budget constant must equal what the material really costs on the wire"
1061 );
1062
1063 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
1065 assert_eq!(
1066 parsed.identity_seed.as_deref(),
1067 Some(identity.seed_field().as_str())
1068 );
1069 assert_eq!(
1070 parsed.identity_host_public_key.as_deref(),
1071 Some(identity.host_public_field().as_str())
1072 );
1073 }
1074
1075 #[test]
1078 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
1079 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
1080 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
1081
1082 let huge = "x".repeat(4096);
1085 let error = RunHookPayload::for_agent_token(&huge)
1086 .expect_err("the JSON wrapper pushes it over the ceiling");
1087 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1088 assert!(
1089 error.to_string().contains("one bearer token fits"),
1090 "{error}"
1091 );
1092 }
1093
1094 #[test]
1100 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
1101 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
1102 .expect("an empty env fits");
1103 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
1104 assert_eq!(with_empty.as_str(), token_only.as_str());
1105 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
1106 assert!(
1107 !with_empty.as_str().contains("env"),
1108 "an empty env must not appear on the wire at all: {}",
1109 with_empty.as_str()
1110 );
1111 }
1112
1113 #[test]
1115 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
1116 let mut env = std::collections::HashMap::new();
1117 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
1118 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
1119
1120 let value: serde_json::Value =
1122 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
1123 assert_eq!(value["agent_token"], "tok");
1124 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
1125 }
1126
1127 #[test]
1145 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
1146 let mut env = std::collections::HashMap::new();
1147 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
1149
1150 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1154 .to_string()
1155 .len();
1156 assert!(
1157 expected > 4096,
1158 "the fixture must actually be over: {expected}"
1159 );
1160
1161 let error = RunHookPayload::for_launch("tok", &env)
1162 .expect_err("a credential-scale launch env does not fit");
1163 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1164 let message = error.to_string();
1165
1166 assert!(
1167 message.contains("ceiling of 4096"),
1168 "the ceiling has to be named: {message}"
1169 );
1170 assert!(
1171 message.contains(&format!("{expected} bytes")),
1172 "the actual byte count has to be named: {message}"
1173 );
1174 assert!(
1175 message.contains("launch env contributed"),
1176 "the env's share of the budget is the actionable half: {message}"
1177 );
1178 assert!(
1179 message.contains("1 variable(s)"),
1180 "the variable count has to be named: {message}"
1181 );
1182 }
1183
1184 #[test]
1191 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1192 let mut env = std::collections::HashMap::new();
1193 env.insert("A".to_string(), "a".repeat(2030));
1194 env.insert("B".to_string(), "b".repeat(2030));
1195 let raw: usize = env.values().map(String::len).sum();
1196 assert_eq!(raw, 4060, "the raw values are under the ceiling");
1197 assert!(
1198 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1199 "the fixture only means something if the raw sum fits: {raw}"
1200 );
1201
1202 let error = RunHookPayload::for_launch("tok", &env)
1203 .expect_err("the serialized payload is over even though the raw values are not");
1204 assert!(error.to_string().contains("2 variable(s)"), "{error}");
1205
1206 let mut fits = std::collections::HashMap::new();
1209 fits.insert("A".to_string(), "a".repeat(2000));
1210 fits.insert("B".to_string(), "b".repeat(2000));
1211 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1212 assert!(payload.len() <= 4096, "{}", payload.len());
1213 }
1214
1215 #[test]
1217 fn an_empty_payload_is_legal() {
1218 let payload = RunHookPayload::new("").expect("min is 0");
1219 assert!(payload.is_empty());
1220 assert_eq!(payload.len(), 0);
1221 }
1222
1223 #[tokio::test]
1226 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1227 let (plane, fake, _) = planted();
1228 fake.answer(
1229 "RunMicrovm",
1230 Answer::ok(fake::microvm_response("PENDING", None)),
1231 );
1232
1233 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1234 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1235 plane.run_microvm(request).await.expect("launches");
1236
1237 let calls = fake.calls();
1238 assert_eq!(calls[0].path, "/2025-09-09/microvms");
1239 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1240
1241 let body = fake.first_body("RunMicrovm");
1242 assert_eq!(
1243 body["ingressNetworkConnectors"],
1244 serde_json::json!([
1245 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1246 ])
1247 );
1248 assert_eq!(
1249 body["egressNetworkConnectors"],
1250 serde_json::json!([
1251 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1252 ])
1253 );
1254 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1255 assert_eq!(body["maximumDurationInSeconds"], 3600);
1256 }
1257
1258 #[tokio::test]
1263 async fn a_launch_without_egress_omits_the_member_entirely() {
1264 let (plane, fake, _) = planted();
1265 fake.answer(
1266 "RunMicrovm",
1267 Answer::ok(fake::microvm_response("PENDING", None)),
1268 );
1269 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1270 plane
1271 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1272 .await
1273 .expect("launches");
1274
1275 let body = fake.first_body("RunMicrovm");
1276 assert!(
1277 body.get("egressNetworkConnectors").is_none(),
1278 "omitting egress is the whole mechanism: {body}"
1279 );
1280 assert!(body.get("ingressNetworkConnectors").is_some());
1281 }
1282
1283 #[tokio::test]
1284 async fn customer_vpc_connector_arns_reach_the_egress_member_unchanged() {
1285 let (plane, fake, _) = planted();
1286 fake.answer(
1287 "RunMicrovm",
1288 Answer::ok(fake::microvm_response("PENDING", None)),
1289 );
1290 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1291 let mut request = RunMicrovmRequest::new("arn:image", payload);
1292 let arns: Vec<String> = (0..10)
1293 .map(|id| format!("arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-{id}:1"))
1294 .collect();
1295 request.egress_network_connectors = arns.clone();
1296 plane
1297 .run_microvm(request)
1298 .await
1299 .expect("ten egress connectors plus ingress fits");
1300 let body = fake.first_body("RunMicrovm");
1301 assert_eq!(body["egressNetworkConnectors"], serde_json::json!(arns));
1302 assert_eq!(
1303 body["ingressNetworkConnectors"].as_array().unwrap().len(),
1304 1
1305 );
1306 }
1307
1308 #[tokio::test]
1309 async fn invalid_customer_egress_connectors_are_refused_before_launch() {
1310 let valid = "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test";
1311 let invalid = [
1312 "VPC_EGRESS",
1313 "arn:aws:lambda:us-west-2:123456789012:network-connector:vpc-test",
1314 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS",
1315 "arn:aws:lambda:us-east-1:123456789012:network-connector:",
1316 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test:0",
1317 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test/other",
1318 ];
1319 for arn in invalid {
1320 let (plane, fake, _) = planted();
1321 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1322 let error = plane
1323 .run_microvm(
1324 RunMicrovmRequest::new("arn:image", payload).with_egress_network_connector(arn),
1325 )
1326 .await
1327 .expect_err(arn);
1328 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1329 assert!(
1330 error.to_string().contains("egressNetworkConnectors"),
1331 "{error}"
1332 );
1333 assert!(fake.calls().is_empty());
1334 }
1335 for (internet, count) in [(true, 1), (false, 11)] {
1336 let (plane, fake, _) = planted();
1337 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1338 let mut request = RunMicrovmRequest::new("arn:image", payload);
1339 request.egress_network_connectors = vec![valid.to_string(); count];
1340 if internet {
1341 request = request.with_egress();
1342 }
1343 let error = plane
1344 .run_microvm(request)
1345 .await
1346 .expect_err("invalid routing request");
1347 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1348 assert!(fake.calls().is_empty());
1349 }
1350 }
1351
1352 #[tokio::test]
1365 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1366 let (plane, fake, _) = planted();
1367 fake.answer(
1368 "RunMicrovm",
1369 Answer::ok(fake::microvm_response("PENDING", None)),
1370 );
1371 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1372 plane
1373 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1374 .await
1375 .expect("launches");
1376
1377 let body = fake.first_body("RunMicrovm");
1378 assert_eq!(body["imageVersion"], "2.0");
1379 assert_eq!(
1380 body["imageIdentifier"], "arn:image",
1381 "pinning a version does not replace the identifier; both are sent"
1382 );
1383
1384 let (plane, fake, _) = planted();
1385 fake.answer(
1386 "RunMicrovm",
1387 Answer::ok(fake::microvm_response("PENDING", None)),
1388 );
1389 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1390 plane
1391 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1392 .await
1393 .expect("launches");
1394 assert!(
1395 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1396 "an unpinned launch must send what this client always sent: {}",
1397 fake.first_body("RunMicrovm")
1398 );
1399 }
1400
1401 #[tokio::test]
1411 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1412 for bad in ["", "2.0\n", "a b"] {
1413 let (plane, fake, _) = planted();
1414 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1415 let error = plane
1416 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1417 .await
1418 .expect_err(&format!("{bad:?} is not a legal Version"));
1419 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1420 assert!(
1421 error.to_string().contains("imageVersion"),
1422 "{bad:?}: {error}"
1423 );
1424 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1425 }
1426 }
1427
1428 #[tokio::test]
1432 async fn no_bare_connector_name_reaches_the_wire() {
1433 let (plane, fake, _) = planted();
1434 fake.answer(
1435 "RunMicrovm",
1436 Answer::ok(fake::microvm_response("PENDING", None)),
1437 );
1438 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1439 plane
1440 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1441 .await
1442 .expect("launches");
1443
1444 let body = fake.first_body("RunMicrovm");
1445 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1446 for value in body[member].as_array().expect("a list") {
1447 let text = value.as_str().expect("a string");
1448 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1449 assert_ne!(text, "ALL_INGRESS");
1450 assert_ne!(text, "INTERNET_EGRESS");
1451 }
1452 }
1453 }
1454
1455 #[tokio::test]
1457 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1458 let (plane, fake, _) = planted();
1459 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1460 let mut request = RunMicrovmRequest::new("arn:image", payload);
1461 request.max_duration_sec = 28_801;
1462
1463 let error = plane
1464 .run_microvm(request)
1465 .await
1466 .expect_err("over the ceiling");
1467 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1468 assert_eq!(fake.calls().len(), 0);
1469 }
1470
1471 #[tokio::test]
1481 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1482 let (plane, fake, _) = planted();
1483 fake.answer(
1484 "GetMicrovm",
1485 Answer::ok(fake::microvm_response(
1486 "TERMINATED",
1487 Some("run hook returned 500"),
1488 )),
1489 );
1490
1491 let error = plane
1492 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1493 .await
1494 .expect_err("a dead VM must not be waited out");
1495
1496 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1497 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1498 let message = error.to_string();
1499 assert!(
1500 message.contains("TERMINATED"),
1501 "the state must be attached: {message}"
1502 );
1503 assert!(
1504 message.contains("run hook returned 500"),
1505 "the stateReason must be attached: {message}"
1506 );
1507 assert!(
1508 message.contains("lifecycle hook failed"),
1509 "the likely cause is worth naming: {message}"
1510 );
1511 assert!(
1512 fake.call_count("GetMicrovm") == 1,
1513 "it fails fast rather than polling to the deadline"
1514 );
1515 }
1516
1517 #[tokio::test]
1524 async fn every_terminal_state_fails_a_launch_fast() {
1525 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1526 for state in crate::constants::TERMINAL_STATES {
1527 let (plane, fake, _) = planted();
1528 fake.answer(
1529 "GetMicrovm",
1530 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1531 );
1532 let error = plane
1533 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1534 .await
1535 .expect_err(&format!("{state} before RUNNING is a death"));
1536 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1537 assert!(error.to_string().contains(state), "{state}: {error}");
1538 assert!(
1539 error.to_string().contains("hook timeout"),
1540 "{state}: {error}"
1541 );
1542 assert_eq!(
1543 fake.call_count("GetMicrovm"),
1544 1,
1545 "{state} must fail fast rather than polling to the deadline"
1546 );
1547 }
1548 }
1549
1550 #[tokio::test]
1554 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1555 let (plane, fake, _) = planted();
1556 fake.answer(
1557 "GetMicrovm",
1558 Answer::ok(fake::microvm_response("TERMINATED", None)),
1559 );
1560
1561 let error = plane
1562 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1563 .await
1564 .expect_err("still a death");
1565 let message = error.to_string();
1566 assert!(message.contains("no stateReason"), "{message}");
1567 assert!(
1571 !message.contains(r#"["RUNNING"]: ."#),
1572 "an absent reason must not render as an empty string: {message}"
1573 );
1574 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1575 }
1576
1577 #[tokio::test]
1579 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1580 let (plane, fake, _) = planted();
1581 fake.answer(
1582 "GetMicrovm",
1583 Answer::ok(fake::microvm_response("PENDING", None)),
1584 )
1585 .answer(
1586 "GetMicrovm",
1587 Answer::ok(fake::microvm_response("RUNNING", None)),
1588 );
1589
1590 let vm = plane
1591 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1592 .await
1593 .expect("reaches RUNNING");
1594 assert_eq!(vm.state, "RUNNING");
1595 assert_eq!(
1596 vm.endpoint,
1597 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1598 );
1599 assert_eq!(fake.call_count("GetMicrovm"), 2);
1600 }
1601
1602 #[tokio::test]
1606 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1607 let (plane, fake, _) = planted();
1608 fake.answer(
1609 "GetMicrovm",
1610 Answer::ok(fake::microvm_response("PENDING", None)),
1611 );
1612
1613 let error = plane
1614 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1615 .await
1616 .expect_err("the deadline elapses");
1617 assert_eq!(error.kind(), ErrorKind::Timeout);
1618 assert_ne!(
1619 error.kind(),
1620 ErrorKind::LaunchDied,
1621 "a timeout is not a death, and conflating them is what hid the cause"
1622 );
1623 assert!(error.to_string().contains("PENDING"), "{error}");
1624 }
1625
1626 #[tokio::test]
1629 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1630 let (plane, fake, _) = planted();
1631 fake.answer(
1632 "GetMicrovm",
1633 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1634 )
1635 .answer(
1636 "GetMicrovm",
1637 Answer::ok(fake::microvm_response("RUNNING", None)),
1638 );
1639
1640 let vm = plane
1641 .wait_for_state(
1642 "mvm-abc123",
1643 &["RUNNING"],
1644 &crate::constants::DEAD_STATES,
1645 WaitOpts::for_launch(),
1646 )
1647 .await
1648 .expect("SUSPENDED is a waypoint on the resume path");
1649 assert_eq!(vm.state, "RUNNING");
1650
1651 let (plane, fake, _) = planted();
1652 fake.answer(
1653 "GetMicrovm",
1654 Answer::ok(fake::microvm_response(
1655 "TERMINATED",
1656 Some("suspended window elapsed"),
1657 )),
1658 );
1659 let error = plane
1660 .wait_for_state(
1661 "mvm-abc123",
1662 &["RUNNING"],
1663 &crate::constants::DEAD_STATES,
1664 WaitOpts::for_launch(),
1665 )
1666 .await
1667 .expect_err("a terminated VM never reaches RUNNING");
1668 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1669 assert!(
1670 error.to_string().contains("suspended window elapsed"),
1671 "{error}"
1672 );
1673 }
1674
1675 #[tokio::test]
1678 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1679 let (plane, fake, _) = planted();
1680 fake.answer(
1681 "GetMicrovm",
1682 Answer::ok(fake::microvm_response("TERMINATED", None)),
1683 );
1684
1685 let vm = plane
1686 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1687 .await
1688 .expect("a suspend reports the state it reached");
1689 assert_eq!(vm.state, "TERMINATED");
1690 }
1691
1692 #[tokio::test]
1695 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1696 let (plane, fake, _) = planted();
1697 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1698 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1699 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1700
1701 plane.suspend("mvm-1").await.expect("suspends");
1702 plane.resume("mvm-1").await.expect("resumes");
1703 plane.terminate("mvm-1").await.expect("terminates");
1704
1705 let calls = fake.calls();
1706 use super::super::transport::Method;
1707 assert_eq!(
1708 calls
1709 .iter()
1710 .map(|call| (call.operation, call.method, call.path.as_str()))
1711 .collect::<Vec<_>>(),
1712 [
1713 (
1714 "SuspendMicrovm",
1715 Method::Post,
1716 "/2025-09-09/microvms/mvm-1/suspend"
1717 ),
1718 (
1719 "ResumeMicrovm",
1720 Method::Post,
1721 "/2025-09-09/microvms/mvm-1/resume"
1722 ),
1723 (
1724 "TerminateMicrovm",
1725 Method::Delete,
1726 "/2025-09-09/microvms/mvm-1"
1727 ),
1728 ]
1729 );
1730 }
1731
1732 #[tokio::test]
1735 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1736 let (plane, fake, _) = planted();
1737 fake.answer(
1738 "CreateMicrovmAuthToken",
1739 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1740 );
1741
1742 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1743 assert_eq!(token.value(), "opaque-proxy-token");
1744 assert_eq!(token.port(), 9000);
1745
1746 let headers = token.headers();
1747 assert_eq!(
1748 headers[0],
1749 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1750 );
1751 assert_eq!(
1752 headers[1],
1753 ("X-aws-proxy-port", "9000".to_string()),
1754 "the auth header without the port header is rejected in a way that reads like a \
1755 bad token"
1756 );
1757
1758 let body = fake.first_body("CreateMicrovmAuthToken");
1759 assert_eq!(body["expirationInMinutes"], 60);
1760 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1761 }
1762
1763 #[tokio::test]
1775 async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
1776 let (plane, fake, _) = planted();
1777 fake.answer(
1778 "CreateMicrovmShellAuthToken",
1779 Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
1780 );
1781
1782 let token = plane
1783 .mint_shell_auth_token("mvm-1")
1784 .await
1785 .expect("mints a shell token");
1786 assert_eq!(token.value(), "opaque-shell-jwe");
1787
1788 let calls = fake.calls();
1789 assert_eq!(calls.len(), 1);
1790 assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
1791 assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
1792
1793 let body = fake.first_body("CreateMicrovmShellAuthToken");
1794 assert_eq!(body["expirationInMinutes"], 60);
1795 assert_eq!(
1796 body.get("allowedPorts"),
1797 None,
1798 "the shell is not a port: the request declares no allowedPorts member"
1799 );
1800 assert_eq!(
1801 body.as_object().map(serde_json::Map::len),
1802 Some(1),
1803 "expirationInMinutes is the body's only member: {body}"
1804 );
1805 }
1806
1807 #[tokio::test]
1811 async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
1812 let (plane, fake, _) = planted();
1813 let error = plane
1814 .mint_shell_auth_token("")
1815 .await
1816 .expect_err("an empty identifier is refused");
1817 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1818 assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
1819 }
1820
1821 #[test]
1829 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1830 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1831
1832 let rendered = format!("{payload:?}");
1833 assert!(
1834 !rendered.contains("SECRETTOKEN"),
1835 "the agent token reached a Debug string: {rendered}"
1836 );
1837 assert!(
1838 rendered.contains(&format!("{} bytes", payload.len())),
1839 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1840 );
1841
1842 let request = RunMicrovmRequest::new("arn:image", payload);
1843 let rendered = format!("{request:?}");
1844 assert!(rendered.contains("arn:image"), "{rendered}");
1845 assert!(
1846 !rendered.contains("SECRETTOKEN"),
1847 "the agent token reached a Debug string through the request: {rendered}"
1848 );
1849 }
1850
1851 #[test]
1858 fn a_proxy_token_debug_does_not_print_the_credential() {
1859 let mut map = std::collections::BTreeMap::new();
1860 map.insert(
1861 PROXY_AUTH_HEADER.to_string(),
1862 "eyJhbGciOi-secret".to_string(),
1863 );
1864 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1865
1866 let rendered = format!("{token:?}");
1867 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1868 assert!(rendered.contains("9000"), "{rendered}");
1869 assert!(
1870 !rendered.contains("secret"),
1871 "the token value reached a Debug string: {rendered}"
1872 );
1873 }
1874
1875 #[test]
1878 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1879 let mut map = std::collections::BTreeMap::new();
1880 map.insert("SomeOtherKey".to_string(), "value".to_string());
1881
1882 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1883 assert_eq!(
1884 error.wire_kind(),
1885 Some(crate::error::WireKind::AuthTokenMint)
1886 );
1887 assert!(error.retryable(), "minting is inside the retry path");
1888 assert!(
1889 error.to_string().contains("is a map, not a string"),
1890 "{error}"
1891 );
1892 }
1893
1894 #[tokio::test]
1896 async fn the_token_is_minted_for_the_configured_port() {
1897 let fake = Arc::new(FakeControlPlane::new());
1898 let plane = crate::testing::control_plane(
1899 fake.clone(),
1900 Region::UsEast1,
1901 Arc::new(TestClock::new()),
1902 )
1903 .with_port(8080)
1904 .expect("8080 is a legal port");
1905 fake.answer(
1906 "CreateMicrovmAuthToken",
1907 Answer::ok(fake::auth_token_response("t")),
1908 );
1909
1910 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1911 assert_eq!(token.port(), 8080);
1912 assert_eq!(
1913 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1914 8080
1915 );
1916 }
1917
1918 #[tokio::test]
1942 async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
1943 let (plane, fake, _) = planted();
1944 fake.answer(
1945 "CreateMicrovmImage",
1946 Answer::created(fake::create_image_response("img")),
1947 )
1948 .answer(
1949 "GetMicrovmImage",
1950 Answer::ok(fake::get_image_response("img", "CREATED")),
1951 )
1952 .answer(
1953 "RunMicrovm",
1954 Answer::ok(fake::microvm_response("PENDING", None)),
1955 )
1956 .answer(
1957 "GetMicrovm",
1958 Answer::ok(fake::microvm_response("RUNNING", None)),
1959 )
1960 .answer(
1961 "CreateMicrovmAuthToken",
1962 Answer::ok(fake::auth_token_response("proxy-token")),
1963 )
1964 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1965 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1966 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1967 .answer(
1968 "ListMicrovmImageVersions",
1969 Answer::ok(fake::list_versions_response("1")),
1970 )
1971 .answer(
1972 "DeleteMicrovmImage",
1973 Answer::ok(fake::delete_image_response()),
1974 );
1975
1976 let request = super::super::CreateImageRequest::new(
1978 "img",
1979 crate::testing::aarch64_daemon(b"binary"),
1980 "s3://bucket/img.zip",
1981 "arn:aws:iam::123456789012:role/build",
1982 );
1983 let image = plane.create_image(request).await.expect("creates");
1984 plane
1985 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1986 .await
1987 .expect("becomes usable");
1988
1989 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1990 let vm = plane
1991 .run_microvm(
1992 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1993 )
1994 .await
1995 .expect("launches");
1996 plane
1997 .wait_for_running(&vm.id, WaitOpts::for_launch())
1998 .await
1999 .expect("reaches RUNNING");
2000 plane.mint_auth_token(&vm.id).await.expect("mints");
2001 plane.suspend(&vm.id).await.expect("suspends");
2002 plane.resume(&vm.id).await.expect("resumes");
2003 plane.terminate(&vm.id).await.expect("terminates");
2004 plane
2005 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
2006 .await;
2007
2008 let operations = fake.operations();
2011 assert!(
2012 operations.len() >= 10,
2013 "the lifecycle must actually have run: {operations:?}"
2014 );
2015 assert!(operations.contains(&"CreateMicrovmAuthToken"));
2016
2017 assert_eq!(
2018 fake.call_count("CreateMicrovmShellAuthToken"),
2019 0,
2020 "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
2021 for no shell must mint no shell token: {operations:?}"
2022 );
2023 for operation in &operations {
2024 assert!(
2025 !operation.contains("Shell"),
2026 "no operation may be a shell one: {operation}"
2027 );
2028 }
2029 for path in fake.paths() {
2030 assert!(
2031 !path.contains("shell"),
2032 "no request may reach a shell route: {path}"
2033 );
2034 }
2035
2036 let launch = fake.first_body("RunMicrovm");
2042 assert_eq!(
2043 launch["ingressNetworkConnectors"],
2044 serde_json::json!([
2045 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
2046 ]),
2047 "ingress is exactly what the caller asked for"
2048 );
2049 assert_eq!(
2050 launch["egressNetworkConnectors"],
2051 serde_json::json!([
2052 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
2053 ]),
2054 "egress is exactly what the caller asked for"
2055 );
2056 }
2057
2058 #[tokio::test]
2064 async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
2065 let (plane, fake, _) = planted();
2066 fake.answer(
2067 "RunMicrovm",
2068 Answer::ok(fake::microvm_response("PENDING", None)),
2069 );
2070
2071 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2072 let mut request = RunMicrovmRequest::new("arn:image", payload);
2073 request.connectors = vec![
2074 super::super::ConnectorIntent::HttpIngress,
2075 super::super::ConnectorIntent::ShellIngress,
2076 ];
2077 plane.run_microvm(request).await.expect("launches");
2078
2079 let launch = fake.first_body("RunMicrovm");
2080 assert_eq!(
2081 launch["ingressNetworkConnectors"],
2082 serde_json::json!([
2083 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
2084 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
2085 ]),
2086 "both fine-grained ingress connectors reach the wire, in the order asked"
2087 );
2088 assert_eq!(
2089 launch.get("egressNetworkConnectors"),
2090 None,
2091 "no egress was asked for, so the member is absent rather than empty"
2092 );
2093 }
2094
2095 #[tokio::test]
2104 async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
2105 let (plane, fake, _) = planted();
2106
2107 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2108 let mut request = RunMicrovmRequest::new("arn:image", payload);
2109 request
2110 .connectors
2111 .push(super::super::ConnectorIntent::ShellIngress);
2112 let error = plane
2113 .run_microvm(request)
2114 .await
2115 .expect_err("the invalid combination is refused locally");
2116
2117 assert!(
2118 error
2119 .to_string()
2120 .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
2121 "the refusal names the pair that works: {error}"
2122 );
2123 assert_eq!(
2124 fake.call_count("RunMicrovm"),
2125 0,
2126 "nothing launched, so nothing billed"
2127 );
2128 }
2129
2130 #[tokio::test]
2141 async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
2142 let (plane, fake, _) = planted();
2143
2144 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2145 let mut request = RunMicrovmRequest::new("arn:image", payload);
2146 request.connectors = vec![
2147 super::super::ConnectorIntent::ShellIngress,
2148 super::super::ConnectorIntent::Egress,
2149 ];
2150 let error = plane
2151 .run_microvm(request)
2152 .await
2153 .expect_err("shell ingress without HTTP ingress is refused locally");
2154
2155 assert!(
2156 error.to_string().contains("HTTP_INGRESS"),
2157 "the refusal names the missing connector: {error}"
2158 );
2159 assert_eq!(
2160 fake.call_count("RunMicrovm"),
2161 0,
2162 "nothing launched, so nothing billed"
2163 );
2164 }
2165
2166 #[tokio::test]
2175 async fn the_fleet_listing_retries_a_page_the_service_throttled_as_400() {
2176 let (plane, fake, _) = planted();
2177 fake.answer(
2178 "ListMicrovms",
2179 Answer::ok(fake::list_microvms_page(
2180 &["mvm-page1-a"],
2181 Some("fleet-page-2"),
2182 )),
2183 )
2184 .answer("ListMicrovms", Answer::throttled_as_400())
2185 .answer(
2186 "ListMicrovms",
2187 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2188 );
2189
2190 let fleet = plane
2191 .list_microvms()
2192 .await
2193 .expect("lists past the throttle");
2194 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2195 assert_eq!(ids, ["mvm-page1-a", "mvm-page2-a"]);
2196 assert_eq!(
2197 fake.call_count("ListMicrovms"),
2198 3,
2199 "page two was asked for again after the throttle"
2200 );
2201 let paths = fake.paths();
2202 assert_eq!(paths[1], paths[2], "the retry is the identical request");
2203 }
2204
2205 #[tokio::test]
2218 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
2219 let (plane, fake, _) = planted();
2220 fake.answer(
2221 "ListMicrovms",
2222 Answer::ok(fake::list_microvms_page(
2223 &["mvm-page1-a", "mvm-page1-b"],
2224 Some("fleet-page-2"),
2225 )),
2226 )
2227 .answer(
2228 "ListMicrovms",
2229 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2230 );
2231
2232 let fleet = plane.list_microvms().await.expect("lists");
2233 assert_eq!(
2234 fleet.len(),
2235 3,
2236 "a fleet spanning two pages is three VMs, not two"
2237 );
2238
2239 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2240 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
2241 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
2242 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2243
2244 let paths = fake.paths();
2245 assert!(
2246 paths[1].contains("nextToken=fleet-page-2"),
2247 "the second request must carry the first page's token: {}",
2248 paths[1]
2249 );
2250 assert!(
2251 !paths[0].contains('?'),
2252 "the first request carries no cursor: {}",
2253 paths[0]
2254 );
2255 }
2256
2257 #[tokio::test]
2267 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2268 let (plane, fake, _) = planted();
2269 fake.answer(
2270 "GetMicrovm",
2271 Answer::ok(fake::microvm_response("RUNNING", None)),
2272 );
2273
2274 let vm = plane.get_microvm("mvm-1").await.expect("reads");
2275 let policy = vm
2276 .idle_policy
2277 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2278 assert_eq!(policy.max_idle_duration_seconds, 1800);
2279 assert_eq!(
2280 policy.suspended_duration_seconds, 600,
2281 "the member the comment claimed was request-only"
2282 );
2283 assert!(!policy.auto_resume_enabled);
2284 }
2285
2286 #[tokio::test]
2290 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2291 let (plane, fake, _) = planted();
2292 fake.answer(
2293 "GetMicrovm",
2294 Answer::ok(
2295 r#"{
2296 "microvmId": "mvm-1",
2297 "state": "RUNNING",
2298 "endpoint": "https://e",
2299 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2300 "imageVersion": "1"
2301 }"#,
2302 ),
2303 );
2304
2305 let vm = plane.get_microvm("mvm-1").await.expect("parses");
2306 assert_eq!(vm.idle_policy, None);
2307 }
2308
2309 #[tokio::test]
2312 async fn a_transport_failure_is_retried_rather_than_reported() {
2313 let (plane, fake, _) = planted();
2314 fake.fail_transport("GetMicrovm", 2).answer(
2315 "GetMicrovm",
2316 Answer::ok(fake::microvm_response("RUNNING", None)),
2317 );
2318
2319 let vm = plane
2320 .get_microvm("mvm-1")
2321 .await
2322 .expect("the retry succeeds");
2323 assert_eq!(vm.state, "RUNNING");
2324 assert_eq!(
2325 fake.call_count("GetMicrovm"),
2326 3,
2327 "two failures then a success"
2328 );
2329 }
2330
2331 #[tokio::test]
2348 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2349 for under in [0, 1, 59] {
2350 let (plane, fake, _) = planted();
2351 fake.answer(
2352 "RunMicrovm",
2353 Answer::ok(fake::microvm_response("PENDING", None)),
2354 );
2355
2356 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2357 let mut request = RunMicrovmRequest::new("arn:image", payload);
2358 request.max_idle_sec = under;
2359
2360 let error = plane
2361 .run_microvm(request)
2362 .await
2363 .expect_err("the model's min is 60");
2364 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2365 let message = error.to_string();
2366 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2367 assert!(message.contains(&under.to_string()), "{message}");
2368 assert_eq!(
2369 fake.calls().len(),
2370 0,
2371 "{under} was refused locally; before this guard it was serialized and sent"
2372 );
2373 }
2374
2375 let (plane, fake, _) = planted();
2378 fake.answer(
2379 "RunMicrovm",
2380 Answer::ok(fake::microvm_response("PENDING", None)),
2381 );
2382 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2383 let mut request = RunMicrovmRequest::new("arn:image", payload);
2384 request.max_idle_sec = 60;
2385 plane.run_microvm(request).await.expect("60 is the minimum");
2386 assert_eq!(
2387 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2388 60
2389 );
2390 }
2391
2392 #[tokio::test]
2402 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2403 type Mutate = fn(&mut RunMicrovmRequest);
2406 let rows: [(&str, Mutate, &str); 6] = [
2407 (
2408 "an execution role that is a bare name",
2409 |request| {
2410 request.execution_role_arn = Some("execution-role".to_string());
2411 },
2412 "role *name*",
2413 ),
2414 (
2415 "an execution role with eleven account digits",
2416 |request| {
2417 request.execution_role_arn =
2418 Some("arn:aws:iam::12345678901:role/exec".to_string());
2419 },
2420 "exactly twelve digits",
2421 ),
2422 (
2423 "an execution role that is a function ARN",
2424 |request| {
2425 request.execution_role_arn =
2426 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2427 },
2428 "RoleArn pattern",
2429 ),
2430 (
2431 "an empty image identifier",
2432 |request| {
2433 request.image_identifier = String::new();
2434 },
2435 "collapses the path",
2436 ),
2437 (
2438 "a 257-character image identifier",
2439 |request| {
2440 request.image_identifier = "a".repeat(257);
2441 },
2442 "MicrovmImageArn permits 2048",
2443 ),
2444 (
2445 "an image version with a newline",
2446 |request| {
2447 request.image_version = Some("2.0\n".to_string());
2448 },
2449 "contains whitespace",
2450 ),
2451 ];
2452
2453 for (label, mutate, expected) in rows {
2454 let (plane, fake, _) = planted();
2455 fake.answer(
2456 "RunMicrovm",
2457 Answer::ok(fake::microvm_response("PENDING", None)),
2458 );
2459
2460 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2461 let mut request = RunMicrovmRequest::new("arn:image", payload);
2462 mutate(&mut request);
2463
2464 let error = plane
2465 .run_microvm(request)
2466 .await
2467 .expect_err(&format!("{label} must be refused"));
2468 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2469 assert!(
2470 error.to_string().contains(expected),
2471 "{label}: wanted {expected:?}, got {error}"
2472 );
2473 assert_eq!(fake.calls().len(), 0, "{label}");
2474 }
2475
2476 let (plane, fake, _) = planted();
2479 fake.answer(
2480 "RunMicrovm",
2481 Answer::ok(fake::microvm_response("PENDING", None)),
2482 );
2483 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2484 let mut request = RunMicrovmRequest::new(
2485 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2486 payload,
2487 );
2488 request.execution_role_arn =
2489 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2490 request.image_version = Some("1.0".to_string());
2491 plane
2492 .run_microvm(request)
2493 .await
2494 .expect("a realistic pinned launch with a real execution role");
2495 assert_eq!(fake.call_count("RunMicrovm"), 1);
2496 }
2497
2498 #[tokio::test]
2508 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2509 for bad in ["", &"a".repeat(257)] {
2510 let (plane, fake, _) = planted();
2511 fake.answer(
2514 "GetMicrovm",
2515 Answer::ok(fake::microvm_response("RUNNING", None)),
2516 );
2517 fake.answer("SuspendMicrovm", Answer::ok("{}"));
2518 fake.answer("ResumeMicrovm", Answer::ok("{}"));
2519 fake.answer("TerminateMicrovm", Answer::ok("{}"));
2520 fake.answer(
2521 "CreateMicrovmAuthToken",
2522 Answer::ok(fake::auth_token_response("t")),
2523 );
2524
2525 plane.get_microvm(bad).await.expect_err("GetMicrovm");
2526 plane.suspend(bad).await.expect_err("SuspendMicrovm");
2527 plane.resume(bad).await.expect_err("ResumeMicrovm");
2528 plane.terminate(bad).await.expect_err("TerminateMicrovm");
2529 plane
2530 .mint_auth_token(bad)
2531 .await
2532 .expect_err("CreateMicrovmAuthToken");
2533 plane
2534 .wait_for_running(bad, WaitOpts::for_launch())
2535 .await
2536 .expect_err("GetMicrovm, in a loop");
2537
2538 assert_eq!(
2539 fake.calls().len(),
2540 0,
2541 "six operations refused {bad:?} locally. An empty identifier on the terminate \
2542 path is a DELETE addressed at the collection."
2543 );
2544 }
2545 }
2546
2547 #[tokio::test]
2562 async fn no_proxy_token_is_minted_for_port_zero() {
2563 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2564 (
2565 "a single port of 0",
2566 ops::PortSpecification::port(0),
2567 "allowedPorts[].port",
2568 ),
2569 (
2570 "a range starting at 0",
2571 ops::PortSpecification::range(0, 9000),
2572 "allowedPorts[].range.startPort",
2573 ),
2574 (
2575 "a range ending at 0",
2576 ops::PortSpecification::range(0, 0),
2577 "allowedPorts[].range.startPort",
2578 ),
2579 ];
2580
2581 for (label, spec, member) in rows {
2582 let (plane, fake, _) = planted();
2583 fake.answer(
2584 "CreateMicrovmAuthToken",
2585 Answer::ok(fake::auth_token_response("t")),
2586 );
2587
2588 let error = plane
2589 .mint_auth_token_for("mvm-1", &[spec])
2590 .await
2591 .expect_err(&format!("{label} must be refused"));
2592 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2593 let message = error.to_string();
2594 assert!(message.contains(member), "{label}: {message}");
2595 assert!(
2596 message.contains("authorizes nothing"),
2597 "{label}: the 403 consequence has to be in the message: {message}"
2598 );
2599 assert_eq!(fake.calls().len(), 0, "{label}");
2600 }
2601
2602 let (plane, fake, _) = planted();
2606 fake.answer(
2607 "CreateMicrovmAuthToken",
2608 Answer::ok(fake::auth_token_response("t")),
2609 );
2610 plane
2611 .mint_auth_token_for(
2612 "mvm-1",
2613 &[
2614 ops::PortSpecification::port(9000),
2615 ops::PortSpecification::port(0),
2616 ],
2617 )
2618 .await
2619 .expect_err("every entry is checked, not the first");
2620 assert_eq!(fake.calls().len(), 0);
2621
2622 for legal in [
2625 ops::PortSpecification::port(1),
2626 ops::PortSpecification::port(65_535),
2627 ops::PortSpecification::range(1, 65_535),
2628 ops::PortSpecification::all(),
2629 ] {
2630 let (plane, fake, _) = planted();
2631 fake.answer(
2632 "CreateMicrovmAuthToken",
2633 Answer::ok(fake::auth_token_response("t")),
2634 );
2635 plane
2636 .mint_auth_token_for("mvm-1", &[legal])
2637 .await
2638 .expect("a legal port specification mints");
2639 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2640 }
2641 }
2642
2643 #[tokio::test]
2649 async fn an_adopted_launch_that_finds_its_vm_suspended_resumes_it() {
2650 for first in ["SUSPENDING", "SUSPENDED"] {
2651 let (plane, fake, _) = planted();
2652 fake.answer(
2653 "GetMicrovm",
2654 Answer::ok(fake::microvm_response(first, None)),
2655 )
2656 .answer(
2657 "GetMicrovm",
2658 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2659 )
2660 .answer(
2661 "GetMicrovm",
2662 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2663 )
2664 .answer(
2665 "GetMicrovm",
2666 Answer::ok(fake::microvm_response("RUNNING", None)),
2667 )
2668 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()));
2669 let running = plane
2670 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2671 .await
2672 .unwrap_or_else(|error| panic!("{first}: {error}"));
2673 assert_eq!(running.state, "RUNNING", "{first}");
2674 assert_eq!(
2675 fake.call_count("ResumeMicrovm"),
2676 1,
2677 "{first}: one resume, even though GetMicrovm still read SUSPENDED after it"
2678 );
2679 }
2680 }
2681
2682 #[tokio::test]
2684 async fn an_adopted_launch_still_fails_fast_on_a_dead_state() {
2685 for state in crate::constants::DEAD_STATES {
2686 let (plane, fake, _) = planted();
2687 fake.answer(
2688 "GetMicrovm",
2689 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
2690 );
2691 let error = plane
2692 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2693 .await
2694 .expect_err(&format!("{state} is a death"));
2695 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
2696 assert!(
2697 error.to_string().contains("hook timeout"),
2698 "{state}: {error}"
2699 );
2700 assert_eq!(fake.call_count("GetMicrovm"), 1, "{state}");
2701 assert_eq!(fake.call_count("ResumeMicrovm"), 0, "{state}");
2702 }
2703 }
2704
2705 #[tokio::test]
2707 async fn get_microvm_carries_the_lifetime_members() {
2708 let (plane, fake, _) = planted();
2709 fake.answer(
2710 "GetMicrovm",
2711 Answer::ok(fake::microvm_response("RUNNING", None)),
2712 );
2713 let vm = plane.get_microvm("mvm-abc123").await.expect("parses");
2714 assert_eq!(vm.maximum_duration_seconds, Some(3600));
2715 assert_eq!(
2716 vm.started_at,
2717 Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_524_800))
2718 );
2719 assert_eq!(vm.terminated_at, None);
2720 }
2721
2722 #[tokio::test]
2731 async fn an_out_of_range_timestamp_reads_as_absent() {
2732 for seconds in ["2e19", "1e300", "1.7976931348623157e308", "-1"] {
2733 let (plane, fake, _) = planted();
2734 let body = fake::microvm_response("TERMINATED", None).replace(
2735 r#""startedAt": 1754524800"#,
2736 &format!(r#""startedAt": {seconds}, "terminatedAt": {seconds}"#),
2737 );
2738 fake.answer("GetMicrovm", Answer::ok(body));
2739 let vm = plane
2740 .get_microvm("mvm-abc123")
2741 .await
2742 .unwrap_or_else(|error| panic!("{seconds}: {error}"));
2743 assert_eq!(vm.state, "TERMINATED", "{seconds}");
2744 assert_eq!(vm.maximum_duration_seconds, Some(3600), "{seconds}");
2745 assert_eq!(vm.started_at, None, "{seconds}");
2746 assert_eq!(vm.terminated_at, None, "{seconds}");
2747 }
2748 }
2749
2750 #[tokio::test]
2752 async fn list_filters_reach_the_query_string() {
2753 let (plane, fake, _) = planted();
2754 fake.answer(
2755 "ListMicrovms",
2756 Answer::ok(fake::list_microvms_page(&["mvm-1"], None)),
2757 );
2758 let filter = MicrovmFilter {
2759 image_identifier: Some(
2760 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img".into(),
2761 ),
2762 image_version: Some("1.0".into()),
2763 };
2764 let items = plane.list_microvms_matching(&filter).await.expect("lists");
2765 assert_eq!(items.len(), 1);
2766 let path = fake.paths().pop().expect("one call");
2767 assert!(
2768 path.ends_with(
2769 "/microvms?imageIdentifier=arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg&imageVersion=1.0"
2770 ),
2771 "{path}"
2772 );
2773 plane.list_microvms().await.expect("unfiltered");
2774 assert!(fake.paths().pop().expect("second").ends_with("/microvms"));
2775 }
2776
2777 #[tokio::test]
2779 async fn per_vm_logging_is_sent_and_checked_locally() {
2780 let (plane, fake, _) = planted();
2781 fake.answer(
2782 "RunMicrovm",
2783 Answer::ok(fake::microvm_response("PENDING", None)),
2784 );
2785 let payload = || RunHookPayload::for_agent_token("t").expect("fits");
2786 let mut request = RunMicrovmRequest::new("arn:image", payload());
2787 request.logging = Some(ops::Logging::cloud_watch("/team/agents", None));
2788 plane.run_microvm(request).await.expect("launches");
2789 assert_eq!(
2790 fake.first_body("RunMicrovm")["logging"],
2791 serde_json::json!({"cloudWatch": {"logGroup": "/team/agents"}})
2792 );
2793
2794 let mut disabled = RunMicrovmRequest::new("arn:image", payload());
2795 disabled.logging = Some(ops::Logging::disabled());
2796 plane.run_microvm(disabled).await.expect("launches");
2797 assert_eq!(
2798 fake.body_of("RunMicrovm", 1)["logging"],
2799 serde_json::json!({"disabled": {}})
2800 );
2801
2802 let plain = RunMicrovmRequest::new("arn:image", payload());
2803 plane.run_microvm(plain).await.expect("launches");
2804 assert!(fake.body_of("RunMicrovm", 2).get("logging").is_none());
2805
2806 for bad in [
2807 ops::Logging::cloud_watch("bad group!", None),
2808 ops::Logging::CloudWatch {
2809 cloud_watch: ops::CloudWatchLogging {
2810 log_group: None,
2811 log_stream: Some("s".into()),
2812 },
2813 },
2814 ] {
2815 let mut request = RunMicrovmRequest::new("arn:image", payload());
2816 request.logging = Some(bad);
2817 let error = plane.run_microvm(request).await.expect_err("refused");
2818 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
2819 }
2820 assert_eq!(
2821 fake.call_count("RunMicrovm"),
2822 3,
2823 "the refusals cost no call"
2824 );
2825 }
2826}