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> {
745 self.list_microvms_matching(&MicrovmFilter::default()).await
746 }
747
748 pub async fn list_microvms_matching(
750 &self,
751 filter: &MicrovmFilter,
752 ) -> Result<Vec<ops::MicrovmItemWire>, Error> {
753 if let Some(image) = filter.image_identifier.as_deref() {
754 super::require_valid_identifier("imageIdentifier", image)?;
755 }
756 if let Some(version) = filter.image_version.as_deref() {
757 super::require_valid_version("imageVersion", version)?;
758 }
759 let mut items: Vec<ops::MicrovmItemWire> = Vec::new();
760 let mut position: std::collections::HashMap<String, usize> =
764 std::collections::HashMap::new();
765 let mut next_token: Option<String> = None;
766 loop {
767 let call = Call::get(
768 "ListMicrovms",
769 paths::microvms_list_matching(
770 filter.image_identifier.as_deref(),
771 filter.image_version.as_deref(),
772 next_token.as_deref(),
773 ),
774 );
775 let reply = send_with_retry(self.transport(), call).await?;
776 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
777 for item in page.items {
778 match position.get(&item.microvm_id) {
779 Some(&at) => items[at] = item,
780 None => {
781 position.insert(item.microvm_id.clone(), items.len());
782 items.push(item);
783 }
784 }
785 }
786 match page.next_token {
787 Some(token) => next_token = Some(token),
788 None => return Ok(items),
789 }
790 }
791 }
792
793 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
795 super::require_valid_identifier("microvmIdentifier", id)?;
796 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
797 send_with_retry(self.transport(), call).await?;
798 Ok(())
799 }
800
801 pub async fn resume(&self, id: &str) -> Result<(), Error> {
803 super::require_valid_identifier("microvmIdentifier", id)?;
804 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
805 send_with_retry(self.transport(), call).await?;
806 Ok(())
807 }
808
809 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
817 super::require_valid_identifier("microvmIdentifier", id)?;
818 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
819 send_with_retry(self.transport(), call).await?;
820 Ok(())
821 }
822
823 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
835 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
836 .await
837 }
838
839 pub async fn mint_auth_token_for(
852 &self,
853 id: &str,
854 ports: &[ops::PortSpecification],
855 ) -> Result<ProxyToken, Error> {
856 super::require_valid_identifier("microvmIdentifier", id)?;
857 if ports.is_empty() {
858 return Err(Error::new(
862 ErrorKind::Precondition,
863 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
864 Name the ports this token is for — the agent port plus any workload port \
865 reached through the endpoint.",
866 ));
867 }
868 for spec in ports {
876 match spec {
877 ops::PortSpecification::One { port } => {
878 super::require_valid_port("allowedPorts[].port", *port)?;
879 }
880 ops::PortSpecification::Range { range } => {
881 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
882 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
883 }
884 ops::PortSpecification::All { .. } => {}
886 }
887 }
888 let wire = ops::CreateAuthTokenWire {
889 expiration_in_minutes: MAX_TOKEN_MINUTES,
890 allowed_ports: ports.to_vec(),
891 };
892 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
893 let reply = send_with_retry(self.transport(), call).await?;
894 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
895 ProxyToken::from_map(&minted.auth_token, self.port())
896 }
897
898 pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
920 super::require_valid_identifier("microvmIdentifier", id)?;
921 let wire = ops::CreateShellAuthTokenWire {
922 expiration_in_minutes: MAX_TOKEN_MINUTES,
923 };
924 let call = Call::post_json(
925 "CreateMicrovmShellAuthToken",
926 paths::shell_auth_token(id),
927 &wire,
928 )?;
929 let reply = send_with_retry(self.transport(), call).await?;
930 let minted: ops::CreateShellAuthTokenResponseWire =
931 reply.json("CreateMicrovmShellAuthToken")?;
932 ProxyToken::from_operation_map(
933 "CreateMicrovmShellAuthToken",
934 &minted.auth_token,
935 self.port(),
936 )
937 }
938}
939
940pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
945
946#[cfg(test)]
947mod tests {
948 use std::sync::Arc;
949
950 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
951 use super::*;
952 use crate::region::Region;
953
954 #[test]
958 fn every_state_a_suspend_waits_for_is_in_the_models_enum() {
959 for state in SUSPEND_WANTED {
960 assert!(
961 crate::constants::MICROVM_STATES.contains(&state),
962 "SUSPEND_WANTED has {state}, which is not a MicrovmState: a suspend would wait \
963 for a state the service can't report and time out"
964 );
965 }
966 }
967
968 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
969 let fake = Arc::new(FakeControlPlane::new());
970 let clock = Arc::new(TestClock::new());
971 let plane = crate::testing::control_plane(fake.clone(), Region::UsEast1, clock.clone());
972 (plane, fake, clock)
973 }
974
975 #[test]
982 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
983 let at_ceiling = "a".repeat(4096);
984 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
985 assert_eq!(payload.len(), 4096);
986 assert_eq!(payload.as_str(), at_ceiling);
987
988 let over = "a".repeat(4097);
989 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
990 assert_eq!(error.kind(), ErrorKind::InvalidArg);
991 let message = error.to_string();
992 assert!(message.contains("4097 bytes"), "{message}");
993 assert!(
994 message.contains("ceiling of 4096"),
995 "the message must name the service-model ceiling: {message}"
996 );
997 assert!(message.contains("2025-09-09"), "{message}");
998 assert!(
999 message.contains("wrong by 4x"),
1000 "the 16 KB claim has to be named as wrong: {message}"
1001 );
1002 }
1003
1004 #[tokio::test]
1007 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
1008 let (plane, fake, _) = planted();
1009
1010 let refused = RunHookPayload::new("a".repeat(4097));
1012 assert!(refused.is_err());
1013 assert_eq!(fake.calls().len(), 0);
1014
1015 fake.answer(
1018 "RunMicrovm",
1019 Answer::ok(fake::microvm_response("PENDING", None)),
1020 );
1021 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
1022 plane
1023 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1024 .await
1025 .expect("launches");
1026 assert_eq!(fake.call_count("RunMicrovm"), 1);
1027 }
1028
1029 #[test]
1032 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
1033 let two_byte_chars = "é".repeat(2049);
1034 assert_eq!(
1035 two_byte_chars.chars().count(),
1036 2049,
1037 "well under 4096 chars"
1038 );
1039 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
1040
1041 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
1042 assert!(error.to_string().contains("4098 bytes"), "{error}");
1043
1044 let exact = "é".repeat(2048);
1046 assert_eq!(exact.len(), 4096);
1047 RunHookPayload::new(exact).expect("exactly at the ceiling");
1048 }
1049
1050 #[test]
1059 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
1060 let env = std::collections::HashMap::new();
1061 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
1062 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
1063 assert_eq!(
1064 without.as_str(),
1065 none_asked.as_str(),
1066 "no identity must produce byte-for-byte the payload this client always sent"
1067 );
1068
1069 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32])
1070 .expect("valid seeds");
1071 let with =
1072 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
1073 assert_eq!(
1074 with.len() - without.len(),
1075 protocol::identity::IDENTITY_PAYLOAD_BYTES,
1076 "the budget constant must equal what the material really costs on the wire"
1077 );
1078
1079 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
1081 assert_eq!(
1082 parsed.identity_seed.as_deref(),
1083 Some(identity.seed_field().as_str())
1084 );
1085 assert_eq!(
1086 parsed.identity_host_public_key.as_deref(),
1087 Some(identity.host_public_field().as_str())
1088 );
1089 }
1090
1091 #[test]
1094 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
1095 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
1096 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
1097
1098 let huge = "x".repeat(4096);
1101 let error = RunHookPayload::for_agent_token(&huge)
1102 .expect_err("the JSON wrapper pushes it over the ceiling");
1103 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1104 assert!(
1105 error.to_string().contains("one bearer token fits"),
1106 "{error}"
1107 );
1108 }
1109
1110 #[test]
1116 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
1117 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
1118 .expect("an empty env fits");
1119 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
1120 assert_eq!(with_empty.as_str(), token_only.as_str());
1121 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
1122 assert!(
1123 !with_empty.as_str().contains("env"),
1124 "an empty env must not appear on the wire at all: {}",
1125 with_empty.as_str()
1126 );
1127 }
1128
1129 #[test]
1131 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
1132 let mut env = std::collections::HashMap::new();
1133 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
1134 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
1135
1136 let value: serde_json::Value =
1138 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
1139 assert_eq!(value["agent_token"], "tok");
1140 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
1141 }
1142
1143 #[test]
1161 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
1162 let mut env = std::collections::HashMap::new();
1163 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
1165
1166 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1170 .to_string()
1171 .len();
1172 assert!(
1173 expected > 4096,
1174 "the fixture must actually be over: {expected}"
1175 );
1176
1177 let error = RunHookPayload::for_launch("tok", &env)
1178 .expect_err("a credential-scale launch env does not fit");
1179 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1180 let message = error.to_string();
1181
1182 assert!(
1183 message.contains("ceiling of 4096"),
1184 "the ceiling has to be named: {message}"
1185 );
1186 assert!(
1187 message.contains(&format!("{expected} bytes")),
1188 "the actual byte count has to be named: {message}"
1189 );
1190 assert!(
1191 message.contains("launch env contributed"),
1192 "the env's share of the budget is the actionable half: {message}"
1193 );
1194 assert!(
1195 message.contains("1 variable(s)"),
1196 "the variable count has to be named: {message}"
1197 );
1198 }
1199
1200 #[test]
1207 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1208 let mut env = std::collections::HashMap::new();
1209 env.insert("A".to_string(), "a".repeat(2030));
1210 env.insert("B".to_string(), "b".repeat(2030));
1211 let raw: usize = env.values().map(String::len).sum();
1212 assert_eq!(raw, 4060, "the raw values are under the ceiling");
1213 assert!(
1214 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1215 "the fixture only means something if the raw sum fits: {raw}"
1216 );
1217
1218 let error = RunHookPayload::for_launch("tok", &env)
1219 .expect_err("the serialized payload is over even though the raw values are not");
1220 assert!(error.to_string().contains("2 variable(s)"), "{error}");
1221
1222 let mut fits = std::collections::HashMap::new();
1225 fits.insert("A".to_string(), "a".repeat(2000));
1226 fits.insert("B".to_string(), "b".repeat(2000));
1227 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1228 assert!(payload.len() <= 4096, "{}", payload.len());
1229 }
1230
1231 #[test]
1233 fn an_empty_payload_is_legal() {
1234 let payload = RunHookPayload::new("").expect("min is 0");
1235 assert!(payload.is_empty());
1236 assert_eq!(payload.len(), 0);
1237 }
1238
1239 #[tokio::test]
1242 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1243 let (plane, fake, _) = planted();
1244 fake.answer(
1245 "RunMicrovm",
1246 Answer::ok(fake::microvm_response("PENDING", None)),
1247 );
1248
1249 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1250 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1251 plane.run_microvm(request).await.expect("launches");
1252
1253 let calls = fake.calls();
1254 assert_eq!(calls[0].path, "/2025-09-09/microvms");
1255 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1256
1257 let body = fake.first_body("RunMicrovm");
1258 assert_eq!(
1259 body["ingressNetworkConnectors"],
1260 serde_json::json!([
1261 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1262 ])
1263 );
1264 assert_eq!(
1265 body["egressNetworkConnectors"],
1266 serde_json::json!([
1267 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1268 ])
1269 );
1270 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1271 assert_eq!(body["maximumDurationInSeconds"], 3600);
1272 }
1273
1274 #[tokio::test]
1279 async fn a_launch_without_egress_omits_the_member_entirely() {
1280 let (plane, fake, _) = planted();
1281 fake.answer(
1282 "RunMicrovm",
1283 Answer::ok(fake::microvm_response("PENDING", None)),
1284 );
1285 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1286 plane
1287 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1288 .await
1289 .expect("launches");
1290
1291 let body = fake.first_body("RunMicrovm");
1292 assert!(
1293 body.get("egressNetworkConnectors").is_none(),
1294 "omitting egress is the whole mechanism: {body}"
1295 );
1296 assert!(body.get("ingressNetworkConnectors").is_some());
1297 }
1298
1299 #[tokio::test]
1300 async fn customer_vpc_connector_arns_reach_the_egress_member_unchanged() {
1301 let (plane, fake, _) = planted();
1302 fake.answer(
1303 "RunMicrovm",
1304 Answer::ok(fake::microvm_response("PENDING", None)),
1305 );
1306 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1307 let mut request = RunMicrovmRequest::new("arn:image", payload);
1308 let arns: Vec<String> = (0..10)
1309 .map(|id| format!("arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-{id}:1"))
1310 .collect();
1311 request.egress_network_connectors = arns.clone();
1312 plane
1313 .run_microvm(request)
1314 .await
1315 .expect("ten egress connectors plus ingress fits");
1316 let body = fake.first_body("RunMicrovm");
1317 assert_eq!(body["egressNetworkConnectors"], serde_json::json!(arns));
1318 assert_eq!(
1319 body["ingressNetworkConnectors"].as_array().unwrap().len(),
1320 1
1321 );
1322 }
1323
1324 #[tokio::test]
1325 async fn invalid_customer_egress_connectors_are_refused_before_launch() {
1326 let valid = "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test";
1327 let invalid = [
1328 "VPC_EGRESS",
1329 "arn:aws:lambda:us-west-2:123456789012:network-connector:vpc-test",
1330 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS",
1331 "arn:aws:lambda:us-east-1:123456789012:network-connector:",
1332 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test:0",
1333 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test/other",
1334 ];
1335 for arn in invalid {
1336 let (plane, fake, _) = planted();
1337 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1338 let error = plane
1339 .run_microvm(
1340 RunMicrovmRequest::new("arn:image", payload).with_egress_network_connector(arn),
1341 )
1342 .await
1343 .expect_err(arn);
1344 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1345 assert!(
1346 error.to_string().contains("egressNetworkConnectors"),
1347 "{error}"
1348 );
1349 assert!(fake.calls().is_empty());
1350 }
1351 for (internet, count) in [(true, 1), (false, 11)] {
1352 let (plane, fake, _) = planted();
1353 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1354 let mut request = RunMicrovmRequest::new("arn:image", payload);
1355 request.egress_network_connectors = vec![valid.to_string(); count];
1356 if internet {
1357 request = request.with_egress();
1358 }
1359 let error = plane
1360 .run_microvm(request)
1361 .await
1362 .expect_err("invalid routing request");
1363 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1364 assert!(fake.calls().is_empty());
1365 }
1366 }
1367
1368 #[tokio::test]
1381 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1382 let (plane, fake, _) = planted();
1383 fake.answer(
1384 "RunMicrovm",
1385 Answer::ok(fake::microvm_response("PENDING", None)),
1386 );
1387 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1388 plane
1389 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1390 .await
1391 .expect("launches");
1392
1393 let body = fake.first_body("RunMicrovm");
1394 assert_eq!(body["imageVersion"], "2.0");
1395 assert_eq!(
1396 body["imageIdentifier"], "arn:image",
1397 "pinning a version does not replace the identifier; both are sent"
1398 );
1399
1400 let (plane, fake, _) = planted();
1401 fake.answer(
1402 "RunMicrovm",
1403 Answer::ok(fake::microvm_response("PENDING", None)),
1404 );
1405 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1406 plane
1407 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1408 .await
1409 .expect("launches");
1410 assert!(
1411 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1412 "an unpinned launch must send what this client always sent: {}",
1413 fake.first_body("RunMicrovm")
1414 );
1415 }
1416
1417 #[tokio::test]
1427 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1428 for bad in ["", "2.0\n", "a b"] {
1429 let (plane, fake, _) = planted();
1430 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1431 let error = plane
1432 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1433 .await
1434 .expect_err(&format!("{bad:?} is not a legal Version"));
1435 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1436 assert!(
1437 error.to_string().contains("imageVersion"),
1438 "{bad:?}: {error}"
1439 );
1440 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1441 }
1442 }
1443
1444 #[tokio::test]
1448 async fn no_bare_connector_name_reaches_the_wire() {
1449 let (plane, fake, _) = planted();
1450 fake.answer(
1451 "RunMicrovm",
1452 Answer::ok(fake::microvm_response("PENDING", None)),
1453 );
1454 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1455 plane
1456 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1457 .await
1458 .expect("launches");
1459
1460 let body = fake.first_body("RunMicrovm");
1461 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1462 for value in body[member].as_array().expect("a list") {
1463 let text = value.as_str().expect("a string");
1464 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1465 assert_ne!(text, "ALL_INGRESS");
1466 assert_ne!(text, "INTERNET_EGRESS");
1467 }
1468 }
1469 }
1470
1471 #[tokio::test]
1473 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1474 let (plane, fake, _) = planted();
1475 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1476 let mut request = RunMicrovmRequest::new("arn:image", payload);
1477 request.max_duration_sec = 28_801;
1478
1479 let error = plane
1480 .run_microvm(request)
1481 .await
1482 .expect_err("over the ceiling");
1483 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1484 assert_eq!(fake.calls().len(), 0);
1485 }
1486
1487 #[tokio::test]
1497 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1498 let (plane, fake, _) = planted();
1499 fake.answer(
1500 "GetMicrovm",
1501 Answer::ok(fake::microvm_response(
1502 "TERMINATED",
1503 Some("run hook returned 500"),
1504 )),
1505 );
1506
1507 let error = plane
1508 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1509 .await
1510 .expect_err("a dead VM must not be waited out");
1511
1512 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1513 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1514 let message = error.to_string();
1515 assert!(
1516 message.contains("TERMINATED"),
1517 "the state must be attached: {message}"
1518 );
1519 assert!(
1520 message.contains("run hook returned 500"),
1521 "the stateReason must be attached: {message}"
1522 );
1523 assert!(
1524 message.contains("lifecycle hook failed"),
1525 "the likely cause is worth naming: {message}"
1526 );
1527 assert!(
1528 fake.call_count("GetMicrovm") == 1,
1529 "it fails fast rather than polling to the deadline"
1530 );
1531 }
1532
1533 #[tokio::test]
1540 async fn every_terminal_state_fails_a_launch_fast() {
1541 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1542 for state in crate::constants::TERMINAL_STATES {
1543 let (plane, fake, _) = planted();
1544 fake.answer(
1545 "GetMicrovm",
1546 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1547 );
1548 let error = plane
1549 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1550 .await
1551 .expect_err(&format!("{state} before RUNNING is a death"));
1552 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1553 assert!(error.to_string().contains(state), "{state}: {error}");
1554 assert!(
1555 error.to_string().contains("hook timeout"),
1556 "{state}: {error}"
1557 );
1558 assert_eq!(
1559 fake.call_count("GetMicrovm"),
1560 1,
1561 "{state} must fail fast rather than polling to the deadline"
1562 );
1563 }
1564 }
1565
1566 #[tokio::test]
1570 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1571 let (plane, fake, _) = planted();
1572 fake.answer(
1573 "GetMicrovm",
1574 Answer::ok(fake::microvm_response("TERMINATED", None)),
1575 );
1576
1577 let error = plane
1578 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1579 .await
1580 .expect_err("still a death");
1581 let message = error.to_string();
1582 assert!(message.contains("no stateReason"), "{message}");
1583 assert!(
1587 !message.contains(r#"["RUNNING"]: ."#),
1588 "an absent reason must not render as an empty string: {message}"
1589 );
1590 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1591 }
1592
1593 #[tokio::test]
1595 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1596 let (plane, fake, _) = planted();
1597 fake.answer(
1598 "GetMicrovm",
1599 Answer::ok(fake::microvm_response("PENDING", None)),
1600 )
1601 .answer(
1602 "GetMicrovm",
1603 Answer::ok(fake::microvm_response("RUNNING", None)),
1604 );
1605
1606 let vm = plane
1607 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1608 .await
1609 .expect("reaches RUNNING");
1610 assert_eq!(vm.state, "RUNNING");
1611 assert_eq!(
1612 vm.endpoint,
1613 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1614 );
1615 assert_eq!(fake.call_count("GetMicrovm"), 2);
1616 }
1617
1618 #[tokio::test]
1622 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1623 let (plane, fake, _) = planted();
1624 fake.answer(
1625 "GetMicrovm",
1626 Answer::ok(fake::microvm_response("PENDING", None)),
1627 );
1628
1629 let error = plane
1630 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1631 .await
1632 .expect_err("the deadline elapses");
1633 assert_eq!(error.kind(), ErrorKind::Timeout);
1634 assert_ne!(
1635 error.kind(),
1636 ErrorKind::LaunchDied,
1637 "a timeout is not a death, and conflating them is what hid the cause"
1638 );
1639 assert!(error.to_string().contains("PENDING"), "{error}");
1640 }
1641
1642 #[tokio::test]
1645 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1646 let (plane, fake, _) = planted();
1647 fake.answer(
1648 "GetMicrovm",
1649 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1650 )
1651 .answer(
1652 "GetMicrovm",
1653 Answer::ok(fake::microvm_response("RUNNING", None)),
1654 );
1655
1656 let vm = plane
1657 .wait_for_state(
1658 "mvm-abc123",
1659 &["RUNNING"],
1660 &crate::constants::DEAD_STATES,
1661 WaitOpts::for_launch(),
1662 )
1663 .await
1664 .expect("SUSPENDED is a waypoint on the resume path");
1665 assert_eq!(vm.state, "RUNNING");
1666
1667 let (plane, fake, _) = planted();
1668 fake.answer(
1669 "GetMicrovm",
1670 Answer::ok(fake::microvm_response(
1671 "TERMINATED",
1672 Some("suspended window elapsed"),
1673 )),
1674 );
1675 let error = plane
1676 .wait_for_state(
1677 "mvm-abc123",
1678 &["RUNNING"],
1679 &crate::constants::DEAD_STATES,
1680 WaitOpts::for_launch(),
1681 )
1682 .await
1683 .expect_err("a terminated VM never reaches RUNNING");
1684 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1685 assert!(
1686 error.to_string().contains("suspended window elapsed"),
1687 "{error}"
1688 );
1689 }
1690
1691 #[tokio::test]
1694 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1695 let (plane, fake, _) = planted();
1696 fake.answer(
1697 "GetMicrovm",
1698 Answer::ok(fake::microvm_response("TERMINATED", None)),
1699 );
1700
1701 let vm = plane
1702 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1703 .await
1704 .expect("a suspend reports the state it reached");
1705 assert_eq!(vm.state, "TERMINATED");
1706 }
1707
1708 #[tokio::test]
1711 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1712 let (plane, fake, _) = planted();
1713 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1714 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1715 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1716
1717 plane.suspend("mvm-1").await.expect("suspends");
1718 plane.resume("mvm-1").await.expect("resumes");
1719 plane.terminate("mvm-1").await.expect("terminates");
1720
1721 let calls = fake.calls();
1722 use super::super::transport::Method;
1723 assert_eq!(
1724 calls
1725 .iter()
1726 .map(|call| (call.operation, call.method, call.path.as_str()))
1727 .collect::<Vec<_>>(),
1728 [
1729 (
1730 "SuspendMicrovm",
1731 Method::Post,
1732 "/2025-09-09/microvms/mvm-1/suspend"
1733 ),
1734 (
1735 "ResumeMicrovm",
1736 Method::Post,
1737 "/2025-09-09/microvms/mvm-1/resume"
1738 ),
1739 (
1740 "TerminateMicrovm",
1741 Method::Delete,
1742 "/2025-09-09/microvms/mvm-1"
1743 ),
1744 ]
1745 );
1746 }
1747
1748 #[tokio::test]
1751 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1752 let (plane, fake, _) = planted();
1753 fake.answer(
1754 "CreateMicrovmAuthToken",
1755 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1756 );
1757
1758 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1759 assert_eq!(token.value(), "opaque-proxy-token");
1760 assert_eq!(token.port(), 9000);
1761
1762 let headers = token.headers();
1763 assert_eq!(
1764 headers[0],
1765 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1766 );
1767 assert_eq!(
1768 headers[1],
1769 ("X-aws-proxy-port", "9000".to_string()),
1770 "the auth header without the port header is rejected in a way that reads like a \
1771 bad token"
1772 );
1773
1774 let body = fake.first_body("CreateMicrovmAuthToken");
1775 assert_eq!(body["expirationInMinutes"], 60);
1776 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1777 }
1778
1779 #[tokio::test]
1791 async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
1792 let (plane, fake, _) = planted();
1793 fake.answer(
1794 "CreateMicrovmShellAuthToken",
1795 Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
1796 );
1797
1798 let token = plane
1799 .mint_shell_auth_token("mvm-1")
1800 .await
1801 .expect("mints a shell token");
1802 assert_eq!(token.value(), "opaque-shell-jwe");
1803
1804 let calls = fake.calls();
1805 assert_eq!(calls.len(), 1);
1806 assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
1807 assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
1808
1809 let body = fake.first_body("CreateMicrovmShellAuthToken");
1810 assert_eq!(body["expirationInMinutes"], 60);
1811 assert_eq!(
1812 body.get("allowedPorts"),
1813 None,
1814 "the shell is not a port: the request declares no allowedPorts member"
1815 );
1816 assert_eq!(
1817 body.as_object().map(serde_json::Map::len),
1818 Some(1),
1819 "expirationInMinutes is the body's only member: {body}"
1820 );
1821 }
1822
1823 #[tokio::test]
1827 async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
1828 let (plane, fake, _) = planted();
1829 let error = plane
1830 .mint_shell_auth_token("")
1831 .await
1832 .expect_err("an empty identifier is refused");
1833 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1834 assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
1835 }
1836
1837 #[test]
1845 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1846 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1847
1848 let rendered = format!("{payload:?}");
1849 assert!(
1850 !rendered.contains("SECRETTOKEN"),
1851 "the agent token reached a Debug string: {rendered}"
1852 );
1853 assert!(
1854 rendered.contains(&format!("{} bytes", payload.len())),
1855 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1856 );
1857
1858 let request = RunMicrovmRequest::new("arn:image", payload);
1859 let rendered = format!("{request:?}");
1860 assert!(rendered.contains("arn:image"), "{rendered}");
1861 assert!(
1862 !rendered.contains("SECRETTOKEN"),
1863 "the agent token reached a Debug string through the request: {rendered}"
1864 );
1865 }
1866
1867 #[test]
1874 fn a_proxy_token_debug_does_not_print_the_credential() {
1875 let mut map = std::collections::BTreeMap::new();
1876 map.insert(
1877 PROXY_AUTH_HEADER.to_string(),
1878 "eyJhbGciOi-secret".to_string(),
1879 );
1880 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1881
1882 let rendered = format!("{token:?}");
1883 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1884 assert!(rendered.contains("9000"), "{rendered}");
1885 assert!(
1886 !rendered.contains("secret"),
1887 "the token value reached a Debug string: {rendered}"
1888 );
1889 }
1890
1891 #[test]
1894 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1895 let mut map = std::collections::BTreeMap::new();
1896 map.insert("SomeOtherKey".to_string(), "value".to_string());
1897
1898 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1899 assert_eq!(
1900 error.wire_kind(),
1901 Some(crate::error::WireKind::AuthTokenMint)
1902 );
1903 assert!(error.retryable(), "minting is inside the retry path");
1904 assert!(
1905 error.to_string().contains("is a map, not a string"),
1906 "{error}"
1907 );
1908 }
1909
1910 #[tokio::test]
1912 async fn the_token_is_minted_for_the_configured_port() {
1913 let fake = Arc::new(FakeControlPlane::new());
1914 let plane = crate::testing::control_plane(
1915 fake.clone(),
1916 Region::UsEast1,
1917 Arc::new(TestClock::new()),
1918 )
1919 .with_port(8080)
1920 .expect("8080 is a legal port");
1921 fake.answer(
1922 "CreateMicrovmAuthToken",
1923 Answer::ok(fake::auth_token_response("t")),
1924 );
1925
1926 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1927 assert_eq!(token.port(), 8080);
1928 assert_eq!(
1929 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1930 8080
1931 );
1932 }
1933
1934 #[tokio::test]
1958 async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
1959 let (plane, fake, _) = planted();
1960 fake.answer(
1961 "CreateMicrovmImage",
1962 Answer::created(fake::create_image_response("img")),
1963 )
1964 .answer(
1965 "GetMicrovmImage",
1966 Answer::ok(fake::get_image_response("img", "CREATED")),
1967 )
1968 .answer(
1969 "RunMicrovm",
1970 Answer::ok(fake::microvm_response("PENDING", None)),
1971 )
1972 .answer(
1973 "GetMicrovm",
1974 Answer::ok(fake::microvm_response("RUNNING", None)),
1975 )
1976 .answer(
1977 "CreateMicrovmAuthToken",
1978 Answer::ok(fake::auth_token_response("proxy-token")),
1979 )
1980 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1981 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1982 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1983 .answer(
1984 "ListMicrovmImageVersions",
1985 Answer::ok(fake::list_versions_response("1")),
1986 )
1987 .answer(
1988 "DeleteMicrovmImage",
1989 Answer::ok(fake::delete_image_response()),
1990 );
1991
1992 let request = super::super::CreateImageRequest::new(
1994 "img",
1995 crate::testing::aarch64_daemon(b"binary"),
1996 "s3://bucket/img.zip",
1997 "arn:aws:iam::123456789012:role/build",
1998 );
1999 let image = plane.create_image(request).await.expect("creates");
2000 plane
2001 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
2002 .await
2003 .expect("becomes usable");
2004
2005 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2006 let vm = plane
2007 .run_microvm(
2008 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
2009 )
2010 .await
2011 .expect("launches");
2012 plane
2013 .wait_for_running(&vm.id, WaitOpts::for_launch())
2014 .await
2015 .expect("reaches RUNNING");
2016 plane.mint_auth_token(&vm.id).await.expect("mints");
2017 plane.suspend(&vm.id).await.expect("suspends");
2018 plane.resume(&vm.id).await.expect("resumes");
2019 plane.terminate(&vm.id).await.expect("terminates");
2020 plane
2021 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
2022 .await;
2023
2024 let operations = fake.operations();
2027 assert!(
2028 operations.len() >= 10,
2029 "the lifecycle must actually have run: {operations:?}"
2030 );
2031 assert!(operations.contains(&"CreateMicrovmAuthToken"));
2032
2033 assert_eq!(
2034 fake.call_count("CreateMicrovmShellAuthToken"),
2035 0,
2036 "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
2037 for no shell must mint no shell token: {operations:?}"
2038 );
2039 for operation in &operations {
2040 assert!(
2041 !operation.contains("Shell"),
2042 "no operation may be a shell one: {operation}"
2043 );
2044 }
2045 for path in fake.paths() {
2046 assert!(
2047 !path.contains("shell"),
2048 "no request may reach a shell route: {path}"
2049 );
2050 }
2051
2052 let launch = fake.first_body("RunMicrovm");
2058 assert_eq!(
2059 launch["ingressNetworkConnectors"],
2060 serde_json::json!([
2061 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
2062 ]),
2063 "ingress is exactly what the caller asked for"
2064 );
2065 assert_eq!(
2066 launch["egressNetworkConnectors"],
2067 serde_json::json!([
2068 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
2069 ]),
2070 "egress is exactly what the caller asked for"
2071 );
2072 }
2073
2074 #[tokio::test]
2080 async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
2081 let (plane, fake, _) = planted();
2082 fake.answer(
2083 "RunMicrovm",
2084 Answer::ok(fake::microvm_response("PENDING", None)),
2085 );
2086
2087 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2088 let mut request = RunMicrovmRequest::new("arn:image", payload);
2089 request.connectors = vec![
2090 super::super::ConnectorIntent::HttpIngress,
2091 super::super::ConnectorIntent::ShellIngress,
2092 ];
2093 plane.run_microvm(request).await.expect("launches");
2094
2095 let launch = fake.first_body("RunMicrovm");
2096 assert_eq!(
2097 launch["ingressNetworkConnectors"],
2098 serde_json::json!([
2099 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
2100 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
2101 ]),
2102 "both fine-grained ingress connectors reach the wire, in the order asked"
2103 );
2104 assert_eq!(
2105 launch.get("egressNetworkConnectors"),
2106 None,
2107 "no egress was asked for, so the member is absent rather than empty"
2108 );
2109 }
2110
2111 #[tokio::test]
2120 async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
2121 let (plane, fake, _) = planted();
2122
2123 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2124 let mut request = RunMicrovmRequest::new("arn:image", payload);
2125 request
2126 .connectors
2127 .push(super::super::ConnectorIntent::ShellIngress);
2128 let error = plane
2129 .run_microvm(request)
2130 .await
2131 .expect_err("the invalid combination is refused locally");
2132
2133 assert!(
2134 error
2135 .to_string()
2136 .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
2137 "the refusal names the pair that works: {error}"
2138 );
2139 assert_eq!(
2140 fake.call_count("RunMicrovm"),
2141 0,
2142 "nothing launched, so nothing billed"
2143 );
2144 }
2145
2146 #[tokio::test]
2157 async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
2158 let (plane, fake, _) = planted();
2159
2160 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2161 let mut request = RunMicrovmRequest::new("arn:image", payload);
2162 request.connectors = vec![
2163 super::super::ConnectorIntent::ShellIngress,
2164 super::super::ConnectorIntent::Egress,
2165 ];
2166 let error = plane
2167 .run_microvm(request)
2168 .await
2169 .expect_err("shell ingress without HTTP ingress is refused locally");
2170
2171 assert!(
2172 error.to_string().contains("HTTP_INGRESS"),
2173 "the refusal names the missing connector: {error}"
2174 );
2175 assert_eq!(
2176 fake.call_count("RunMicrovm"),
2177 0,
2178 "nothing launched, so nothing billed"
2179 );
2180 }
2181
2182 #[tokio::test]
2191 async fn the_fleet_listing_retries_a_page_the_service_throttled_as_400() {
2192 let (plane, fake, _) = planted();
2193 fake.answer(
2194 "ListMicrovms",
2195 Answer::ok(fake::list_microvms_page(
2196 &["mvm-page1-a"],
2197 Some("fleet-page-2"),
2198 )),
2199 )
2200 .answer("ListMicrovms", Answer::throttled_as_400())
2201 .answer(
2202 "ListMicrovms",
2203 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2204 );
2205
2206 let fleet = plane
2207 .list_microvms()
2208 .await
2209 .expect("lists past the throttle");
2210 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2211 assert_eq!(ids, ["mvm-page1-a", "mvm-page2-a"]);
2212 assert_eq!(
2213 fake.call_count("ListMicrovms"),
2214 3,
2215 "page two was asked for again after the throttle"
2216 );
2217 let paths = fake.paths();
2218 assert_eq!(paths[1], paths[2], "the retry is the identical request");
2219 }
2220
2221 #[tokio::test]
2234 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
2235 let (plane, fake, _) = planted();
2236 fake.answer(
2237 "ListMicrovms",
2238 Answer::ok(fake::list_microvms_page(
2239 &["mvm-page1-a", "mvm-page1-b"],
2240 Some("fleet-page-2"),
2241 )),
2242 )
2243 .answer(
2244 "ListMicrovms",
2245 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2246 );
2247
2248 let fleet = plane.list_microvms().await.expect("lists");
2249 assert_eq!(
2250 fleet.len(),
2251 3,
2252 "a fleet spanning two pages is three VMs, not two"
2253 );
2254
2255 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2256 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
2257 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
2258 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2259
2260 let paths = fake.paths();
2261 assert!(
2262 paths[1].contains("nextToken=fleet-page-2"),
2263 "the second request must carry the first page's token: {}",
2264 paths[1]
2265 );
2266 assert!(
2267 !paths[0].contains('?'),
2268 "the first request carries no cursor: {}",
2269 paths[0]
2270 );
2271 }
2272
2273 #[tokio::test]
2281 async fn a_vm_the_fleet_listing_meets_on_two_pages_is_listed_once() {
2282 let (plane, fake, _) = planted();
2283 fake.answer(
2284 "ListMicrovms",
2285 Answer::ok(fake::list_microvms_page(
2286 &["mvm-a", "mvm-moved"],
2287 Some("fleet-page-2"),
2288 )),
2289 )
2290 .answer(
2291 "ListMicrovms",
2292 Answer::ok(
2293 fake::list_microvms_page(&["mvm-moved", "mvm-b"], None).replacen(
2294 "RUNNING",
2295 "SUSPENDED",
2296 1,
2297 ),
2298 ),
2299 );
2300
2301 let fleet = plane.list_microvms().await.expect("lists");
2302 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2303 assert_eq!(
2304 ids,
2305 ["mvm-a", "mvm-moved", "mvm-b"],
2306 "each VM once, in the order first listed"
2307 );
2308 assert_eq!(
2309 fleet[1].state, "SUSPENDED",
2310 "the page read later is the newer reading of the VM"
2311 );
2312 assert_eq!(fleet[0].state, "RUNNING");
2313 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2314 }
2315
2316 #[tokio::test]
2326 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2327 let (plane, fake, _) = planted();
2328 fake.answer(
2329 "GetMicrovm",
2330 Answer::ok(fake::microvm_response("RUNNING", None)),
2331 );
2332
2333 let vm = plane.get_microvm("mvm-1").await.expect("reads");
2334 let policy = vm
2335 .idle_policy
2336 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2337 assert_eq!(policy.max_idle_duration_seconds, 1800);
2338 assert_eq!(
2339 policy.suspended_duration_seconds, 600,
2340 "the member the comment claimed was request-only"
2341 );
2342 assert!(!policy.auto_resume_enabled);
2343 }
2344
2345 #[tokio::test]
2349 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2350 let (plane, fake, _) = planted();
2351 fake.answer(
2352 "GetMicrovm",
2353 Answer::ok(
2354 r#"{
2355 "microvmId": "mvm-1",
2356 "state": "RUNNING",
2357 "endpoint": "https://e",
2358 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2359 "imageVersion": "1"
2360 }"#,
2361 ),
2362 );
2363
2364 let vm = plane.get_microvm("mvm-1").await.expect("parses");
2365 assert_eq!(vm.idle_policy, None);
2366 }
2367
2368 #[tokio::test]
2371 async fn a_transport_failure_is_retried_rather_than_reported() {
2372 let (plane, fake, _) = planted();
2373 fake.fail_transport("GetMicrovm", 2).answer(
2374 "GetMicrovm",
2375 Answer::ok(fake::microvm_response("RUNNING", None)),
2376 );
2377
2378 let vm = plane
2379 .get_microvm("mvm-1")
2380 .await
2381 .expect("the retry succeeds");
2382 assert_eq!(vm.state, "RUNNING");
2383 assert_eq!(
2384 fake.call_count("GetMicrovm"),
2385 3,
2386 "two failures then a success"
2387 );
2388 }
2389
2390 #[tokio::test]
2407 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2408 for under in [0, 1, 59] {
2409 let (plane, fake, _) = planted();
2410 fake.answer(
2411 "RunMicrovm",
2412 Answer::ok(fake::microvm_response("PENDING", None)),
2413 );
2414
2415 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2416 let mut request = RunMicrovmRequest::new("arn:image", payload);
2417 request.max_idle_sec = under;
2418
2419 let error = plane
2420 .run_microvm(request)
2421 .await
2422 .expect_err("the model's min is 60");
2423 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2424 let message = error.to_string();
2425 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2426 assert!(message.contains(&under.to_string()), "{message}");
2427 assert_eq!(
2428 fake.calls().len(),
2429 0,
2430 "{under} was refused locally; before this guard it was serialized and sent"
2431 );
2432 }
2433
2434 let (plane, fake, _) = planted();
2437 fake.answer(
2438 "RunMicrovm",
2439 Answer::ok(fake::microvm_response("PENDING", None)),
2440 );
2441 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2442 let mut request = RunMicrovmRequest::new("arn:image", payload);
2443 request.max_idle_sec = 60;
2444 plane.run_microvm(request).await.expect("60 is the minimum");
2445 assert_eq!(
2446 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2447 60
2448 );
2449 }
2450
2451 #[tokio::test]
2461 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2462 type Mutate = fn(&mut RunMicrovmRequest);
2465 let rows: [(&str, Mutate, &str); 6] = [
2466 (
2467 "an execution role that is a bare name",
2468 |request| {
2469 request.execution_role_arn = Some("execution-role".to_string());
2470 },
2471 "role *name*",
2472 ),
2473 (
2474 "an execution role with eleven account digits",
2475 |request| {
2476 request.execution_role_arn =
2477 Some("arn:aws:iam::12345678901:role/exec".to_string());
2478 },
2479 "exactly twelve digits",
2480 ),
2481 (
2482 "an execution role that is a function ARN",
2483 |request| {
2484 request.execution_role_arn =
2485 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2486 },
2487 "RoleArn pattern",
2488 ),
2489 (
2490 "an empty image identifier",
2491 |request| {
2492 request.image_identifier = String::new();
2493 },
2494 "collapses the path",
2495 ),
2496 (
2497 "a 257-character image identifier",
2498 |request| {
2499 request.image_identifier = "a".repeat(257);
2500 },
2501 "MicrovmImageArn permits 2048",
2502 ),
2503 (
2504 "an image version with a newline",
2505 |request| {
2506 request.image_version = Some("2.0\n".to_string());
2507 },
2508 "contains whitespace",
2509 ),
2510 ];
2511
2512 for (label, mutate, expected) in rows {
2513 let (plane, fake, _) = planted();
2514 fake.answer(
2515 "RunMicrovm",
2516 Answer::ok(fake::microvm_response("PENDING", None)),
2517 );
2518
2519 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2520 let mut request = RunMicrovmRequest::new("arn:image", payload);
2521 mutate(&mut request);
2522
2523 let error = plane
2524 .run_microvm(request)
2525 .await
2526 .expect_err(&format!("{label} must be refused"));
2527 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2528 assert!(
2529 error.to_string().contains(expected),
2530 "{label}: wanted {expected:?}, got {error}"
2531 );
2532 assert_eq!(fake.calls().len(), 0, "{label}");
2533 }
2534
2535 let (plane, fake, _) = planted();
2538 fake.answer(
2539 "RunMicrovm",
2540 Answer::ok(fake::microvm_response("PENDING", None)),
2541 );
2542 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2543 let mut request = RunMicrovmRequest::new(
2544 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2545 payload,
2546 );
2547 request.execution_role_arn =
2548 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2549 request.image_version = Some("1.0".to_string());
2550 plane
2551 .run_microvm(request)
2552 .await
2553 .expect("a realistic pinned launch with a real execution role");
2554 assert_eq!(fake.call_count("RunMicrovm"), 1);
2555 }
2556
2557 #[tokio::test]
2567 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2568 for bad in ["", &"a".repeat(257)] {
2569 let (plane, fake, _) = planted();
2570 fake.answer(
2573 "GetMicrovm",
2574 Answer::ok(fake::microvm_response("RUNNING", None)),
2575 );
2576 fake.answer("SuspendMicrovm", Answer::ok("{}"));
2577 fake.answer("ResumeMicrovm", Answer::ok("{}"));
2578 fake.answer("TerminateMicrovm", Answer::ok("{}"));
2579 fake.answer(
2580 "CreateMicrovmAuthToken",
2581 Answer::ok(fake::auth_token_response("t")),
2582 );
2583
2584 plane.get_microvm(bad).await.expect_err("GetMicrovm");
2585 plane.suspend(bad).await.expect_err("SuspendMicrovm");
2586 plane.resume(bad).await.expect_err("ResumeMicrovm");
2587 plane.terminate(bad).await.expect_err("TerminateMicrovm");
2588 plane
2589 .mint_auth_token(bad)
2590 .await
2591 .expect_err("CreateMicrovmAuthToken");
2592 plane
2593 .wait_for_running(bad, WaitOpts::for_launch())
2594 .await
2595 .expect_err("GetMicrovm, in a loop");
2596
2597 assert_eq!(
2598 fake.calls().len(),
2599 0,
2600 "six operations refused {bad:?} locally. An empty identifier on the terminate \
2601 path is a DELETE addressed at the collection."
2602 );
2603 }
2604 }
2605
2606 #[tokio::test]
2621 async fn no_proxy_token_is_minted_for_port_zero() {
2622 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2623 (
2624 "a single port of 0",
2625 ops::PortSpecification::port(0),
2626 "allowedPorts[].port",
2627 ),
2628 (
2629 "a range starting at 0",
2630 ops::PortSpecification::range(0, 9000),
2631 "allowedPorts[].range.startPort",
2632 ),
2633 (
2634 "a range ending at 0",
2635 ops::PortSpecification::range(0, 0),
2636 "allowedPorts[].range.startPort",
2637 ),
2638 ];
2639
2640 for (label, spec, member) in rows {
2641 let (plane, fake, _) = planted();
2642 fake.answer(
2643 "CreateMicrovmAuthToken",
2644 Answer::ok(fake::auth_token_response("t")),
2645 );
2646
2647 let error = plane
2648 .mint_auth_token_for("mvm-1", &[spec])
2649 .await
2650 .expect_err(&format!("{label} must be refused"));
2651 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2652 let message = error.to_string();
2653 assert!(message.contains(member), "{label}: {message}");
2654 assert!(
2655 message.contains("authorizes nothing"),
2656 "{label}: the 403 consequence has to be in the message: {message}"
2657 );
2658 assert_eq!(fake.calls().len(), 0, "{label}");
2659 }
2660
2661 let (plane, fake, _) = planted();
2665 fake.answer(
2666 "CreateMicrovmAuthToken",
2667 Answer::ok(fake::auth_token_response("t")),
2668 );
2669 plane
2670 .mint_auth_token_for(
2671 "mvm-1",
2672 &[
2673 ops::PortSpecification::port(9000),
2674 ops::PortSpecification::port(0),
2675 ],
2676 )
2677 .await
2678 .expect_err("every entry is checked, not the first");
2679 assert_eq!(fake.calls().len(), 0);
2680
2681 for legal in [
2684 ops::PortSpecification::port(1),
2685 ops::PortSpecification::port(65_535),
2686 ops::PortSpecification::range(1, 65_535),
2687 ops::PortSpecification::all(),
2688 ] {
2689 let (plane, fake, _) = planted();
2690 fake.answer(
2691 "CreateMicrovmAuthToken",
2692 Answer::ok(fake::auth_token_response("t")),
2693 );
2694 plane
2695 .mint_auth_token_for("mvm-1", &[legal])
2696 .await
2697 .expect("a legal port specification mints");
2698 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2699 }
2700 }
2701
2702 #[tokio::test]
2708 async fn an_adopted_launch_that_finds_its_vm_suspended_resumes_it() {
2709 for first in ["SUSPENDING", "SUSPENDED"] {
2710 let (plane, fake, _) = planted();
2711 fake.answer(
2712 "GetMicrovm",
2713 Answer::ok(fake::microvm_response(first, None)),
2714 )
2715 .answer(
2716 "GetMicrovm",
2717 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2718 )
2719 .answer(
2720 "GetMicrovm",
2721 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2722 )
2723 .answer(
2724 "GetMicrovm",
2725 Answer::ok(fake::microvm_response("RUNNING", None)),
2726 )
2727 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()));
2728 let running = plane
2729 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2730 .await
2731 .unwrap_or_else(|error| panic!("{first}: {error}"));
2732 assert_eq!(running.state, "RUNNING", "{first}");
2733 assert_eq!(
2734 fake.call_count("ResumeMicrovm"),
2735 1,
2736 "{first}: one resume, even though GetMicrovm still read SUSPENDED after it"
2737 );
2738 }
2739 }
2740
2741 #[tokio::test]
2743 async fn an_adopted_launch_still_fails_fast_on_a_dead_state() {
2744 for state in crate::constants::DEAD_STATES {
2745 let (plane, fake, _) = planted();
2746 fake.answer(
2747 "GetMicrovm",
2748 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
2749 );
2750 let error = plane
2751 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2752 .await
2753 .expect_err(&format!("{state} is a death"));
2754 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
2755 assert!(
2756 error.to_string().contains("hook timeout"),
2757 "{state}: {error}"
2758 );
2759 assert_eq!(fake.call_count("GetMicrovm"), 1, "{state}");
2760 assert_eq!(fake.call_count("ResumeMicrovm"), 0, "{state}");
2761 }
2762 }
2763
2764 #[tokio::test]
2766 async fn get_microvm_carries_the_lifetime_members() {
2767 let (plane, fake, _) = planted();
2768 fake.answer(
2769 "GetMicrovm",
2770 Answer::ok(fake::microvm_response("RUNNING", None)),
2771 );
2772 let vm = plane.get_microvm("mvm-abc123").await.expect("parses");
2773 assert_eq!(vm.maximum_duration_seconds, Some(3600));
2774 assert_eq!(
2775 vm.started_at,
2776 Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_524_800))
2777 );
2778 assert_eq!(vm.terminated_at, None);
2779 }
2780
2781 #[tokio::test]
2790 async fn an_out_of_range_timestamp_reads_as_absent() {
2791 for seconds in ["2e19", "1e300", "1.7976931348623157e308", "-1"] {
2792 let (plane, fake, _) = planted();
2793 let body = fake::microvm_response("TERMINATED", None).replace(
2794 r#""startedAt": 1754524800"#,
2795 &format!(r#""startedAt": {seconds}, "terminatedAt": {seconds}"#),
2796 );
2797 fake.answer("GetMicrovm", Answer::ok(body));
2798 let vm = plane
2799 .get_microvm("mvm-abc123")
2800 .await
2801 .unwrap_or_else(|error| panic!("{seconds}: {error}"));
2802 assert_eq!(vm.state, "TERMINATED", "{seconds}");
2803 assert_eq!(vm.maximum_duration_seconds, Some(3600), "{seconds}");
2804 assert_eq!(vm.started_at, None, "{seconds}");
2805 assert_eq!(vm.terminated_at, None, "{seconds}");
2806 }
2807 }
2808
2809 #[tokio::test]
2811 async fn list_filters_reach_the_query_string() {
2812 let (plane, fake, _) = planted();
2813 fake.answer(
2814 "ListMicrovms",
2815 Answer::ok(fake::list_microvms_page(&["mvm-1"], None)),
2816 );
2817 let filter = MicrovmFilter {
2818 image_identifier: Some(
2819 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img".into(),
2820 ),
2821 image_version: Some("1.0".into()),
2822 };
2823 let items = plane.list_microvms_matching(&filter).await.expect("lists");
2824 assert_eq!(items.len(), 1);
2825 let path = fake.paths().pop().expect("one call");
2826 assert!(
2827 path.ends_with(
2828 "/microvms?imageIdentifier=arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg&imageVersion=1.0"
2829 ),
2830 "{path}"
2831 );
2832 plane.list_microvms().await.expect("unfiltered");
2833 assert!(fake.paths().pop().expect("second").ends_with("/microvms"));
2834 }
2835
2836 #[tokio::test]
2838 async fn per_vm_logging_is_sent_and_checked_locally() {
2839 let (plane, fake, _) = planted();
2840 fake.answer(
2841 "RunMicrovm",
2842 Answer::ok(fake::microvm_response("PENDING", None)),
2843 );
2844 let payload = || RunHookPayload::for_agent_token("t").expect("fits");
2845 let mut request = RunMicrovmRequest::new("arn:image", payload());
2846 request.logging = Some(ops::Logging::cloud_watch("/team/agents", None));
2847 plane.run_microvm(request).await.expect("launches");
2848 assert_eq!(
2849 fake.first_body("RunMicrovm")["logging"],
2850 serde_json::json!({"cloudWatch": {"logGroup": "/team/agents"}})
2851 );
2852
2853 let mut disabled = RunMicrovmRequest::new("arn:image", payload());
2854 disabled.logging = Some(ops::Logging::disabled());
2855 plane.run_microvm(disabled).await.expect("launches");
2856 assert_eq!(
2857 fake.body_of("RunMicrovm", 1)["logging"],
2858 serde_json::json!({"disabled": {}})
2859 );
2860
2861 let plain = RunMicrovmRequest::new("arn:image", payload());
2862 plane.run_microvm(plain).await.expect("launches");
2863 assert!(fake.body_of("RunMicrovm", 2).get("logging").is_none());
2864
2865 for bad in [
2866 ops::Logging::cloud_watch("bad group!", None),
2867 ops::Logging::CloudWatch {
2868 cloud_watch: ops::CloudWatchLogging {
2869 log_group: None,
2870 log_stream: Some("s".into()),
2871 },
2872 },
2873 ] {
2874 let mut request = RunMicrovmRequest::new("arn:image", payload());
2875 request.logging = Some(bad);
2876 let error = plane.run_microvm(request).await.expect_err("refused");
2877 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
2878 }
2879 assert_eq!(
2880 fake.call_count("RunMicrovm"),
2881 3,
2882 "the refusals cost no call"
2883 );
2884 }
2885}