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> {
287 let seconds = seconds.filter(|value| value.is_finite() && *value >= 0.0)?;
288 std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs_f64(seconds))
289}
290
291#[derive(Clone, Debug, Default, Eq, PartialEq)]
293pub struct MicrovmFilter {
294 pub image_identifier: Option<String>,
296 pub image_version: Option<String>,
298}
299
300impl Microvm {
301 fn reason(&self) -> &str {
307 self.state_reason.as_deref().unwrap_or("no stateReason")
308 }
309}
310
311impl From<ops::MicrovmResponseWire> for Microvm {
312 fn from(wire: ops::MicrovmResponseWire) -> Self {
313 Self {
314 id: wire.microvm_id,
315 state: wire.state,
316 endpoint: wire.endpoint,
317 image_arn: wire.image_arn,
318 image_version: wire.image_version,
319 state_reason: wire.state_reason,
320 idle_policy: wire.idle_policy,
321 maximum_duration_seconds: wire.maximum_duration_in_seconds,
322 started_at: epoch(wire.started_at),
323 terminated_at: epoch(wire.terminated_at),
324 }
325 }
326}
327
328#[derive(Clone, Eq, PartialEq)]
340pub struct ProxyToken {
341 value: String,
342 port: u16,
343}
344
345impl std::fmt::Debug for ProxyToken {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 f.debug_struct("ProxyToken")
354 .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
355 .field("port", &self.port)
356 .finish()
357 }
358}
359
360impl ProxyToken {
361 pub fn from_map(
368 map: &std::collections::BTreeMap<String, String>,
369 port: u16,
370 ) -> Result<Self, Error> {
371 Self::from_operation_map("CreateMicrovmAuthToken", map, port)
372 }
373
374 pub fn from_operation_map(
380 operation: &str,
381 map: &std::collections::BTreeMap<String, String>,
382 port: u16,
383 ) -> Result<Self, Error> {
384 let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
385 Error::wire(
386 crate::error::WireKind::AuthTokenMint,
387 format!(
388 "the {operation} response carried no {PROXY_AUTH_HEADER} key. The \
389 authToken member is a map, not a string, and that key is the header value \
390 (keys present: {:?}).",
391 map.keys().collect::<Vec<_>>()
392 ),
393 )
394 })?;
395 Ok(Self {
396 value: value.clone(),
397 port,
398 })
399 }
400
401 pub fn headers(&self) -> [(&'static str, String); 2] {
406 [
407 (PROXY_AUTH_HEADER, self.value.clone()),
408 (PROXY_PORT_HEADER, self.port.to_string()),
409 ]
410 }
411
412 pub fn value(&self) -> &str {
414 &self.value
415 }
416
417 pub fn port(&self) -> u16 {
419 self.port
420 }
421}
422
423impl ControlPlane {
424 pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
430 if let Some(token) = request.client_token.as_deref() {
431 super::token::require_run_token(token)?;
432 }
433 super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
434 super::require_duration_in_range(request.max_duration_sec)?;
435 super::require_idle_duration(request.max_idle_sec)?;
439 if let Some(version) = request.image_version.as_deref() {
443 super::require_valid_version("imageVersion", version)?;
444 }
445 if let Some(role) = request.execution_role_arn.as_deref() {
449 super::require_valid_role_arn("executionRoleArn", role)?;
450 }
451 if let Some(ops::Logging::CloudWatch { cloud_watch }) = &request.logging {
452 let Some(group) = cloud_watch.log_group.as_deref() else {
453 return Err(Error::invalid_arg(
454 "per-VM CloudWatch logging needs a logGroup; pass a group name or disable logging",
455 ));
456 };
457 super::require_valid_log_group(group)?;
458 if let Some(stream) = cloud_watch.log_stream.as_deref() {
459 super::require_valid_log_stream(stream)?;
460 }
461 }
462
463 let has_all_ingress = request
470 .connectors
471 .contains(&super::ConnectorIntent::AllIngress);
472 let has_finer_ingress = request.connectors.iter().any(|intent| {
473 matches!(
474 intent,
475 super::ConnectorIntent::HttpIngress | super::ConnectorIntent::ShellIngress
476 )
477 });
478 if has_all_ingress && has_finer_ingress {
479 return Err(Error::invalid_arg(
480 "ALL_INGRESS cannot be combined with other ingress connectors; use \
481 HTTP_INGRESS and/or SHELL_INGRESS instead. The platform accepts the \
482 combination at launch and rejects it only when a shell token is minted, \
483 after the VM has launched and billed — so it is refused here.",
484 ));
485 }
486
487 let has_shell_ingress = request
492 .connectors
493 .contains(&super::ConnectorIntent::ShellIngress);
494 let has_http_ingress = request
495 .connectors
496 .contains(&super::ConnectorIntent::HttpIngress);
497 if has_shell_ingress && !has_http_ingress {
498 return Err(Error::invalid_arg(
499 "SHELL_INGRESS was requested without HTTP_INGRESS. The measured pair that \
500 launches and mints a shell token is [HTTP_INGRESS, SHELL_INGRESS] \
501 (docs/PLATFORM.md); the platform accepts an invalid ingress set at launch \
502 and the VM runs and bills before any failure appears — so it is refused \
503 here.",
504 ));
505 }
506
507 let ingress: Vec<String> = request
510 .connectors
511 .iter()
512 .filter(|intent| {
513 matches!(
514 intent,
515 super::ConnectorIntent::AllIngress
516 | super::ConnectorIntent::HttpIngress
517 | super::ConnectorIntent::ShellIngress
518 )
519 })
520 .map(|intent| intent.arn(&self.region))
521 .collect();
522 let mut egress: Vec<String> = request
523 .connectors
524 .iter()
525 .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
526 .map(|intent| intent.arn(&self.region))
527 .collect();
528
529 if !egress.is_empty() && !request.egress_network_connectors.is_empty() {
530 return Err(Error::invalid_arg(
531 "INTERNET_EGRESS cannot be combined with customer-managed egress connectors: \
532 choose managed internet egress or VPC routing",
533 ));
534 }
535 for arn in request.egress_network_connectors {
536 super::connector::require_egress_connector_arn(&arn, &self.region)?;
537 egress.push(arn);
538 }
539
540 for (member, count) in [
542 ("ingressNetworkConnectors", ingress.len()),
543 ("egressNetworkConnectors", egress.len()),
544 ] {
545 if count > crate::constants::MAX_NETWORK_CONNECTORS {
546 return Err(Error::invalid_arg(format!(
547 "{member} has {count} network connectors, over the NetworkConnectorList \
548 ceiling of {} (service model {}).",
549 crate::constants::MAX_NETWORK_CONNECTORS,
550 crate::constants::MODEL_API_VERSION,
551 )));
552 }
553 }
554
555 let wire = ops::RunMicrovmWire {
556 image_identifier: request.image_identifier.clone(),
557 image_version: request.image_version.clone(),
558 execution_role_arn: request.execution_role_arn.clone(),
559 ingress_network_connectors: ingress,
560 egress_network_connectors: (!egress.is_empty()).then_some(egress),
565 idle_policy: ops::IdlePolicy {
566 max_idle_duration_seconds: request.max_idle_sec,
567 suspended_duration_seconds: request.suspended_sec,
568 auto_resume_enabled: request.auto_resume,
569 },
570 maximum_duration_in_seconds: request.max_duration_sec,
571 run_hook_payload: request.run_hook_payload.as_str().to_string(),
572 logging: request.logging.clone(),
573 client_token: request.client_token.unwrap_or_else(|| {
574 token::run_token(
575 request
576 .token_scope
577 .as_deref()
578 .unwrap_or(&request.image_identifier),
579 )
580 }),
581 };
582
583 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
584 let reply = send_with_retry(self.transport(), call).await?;
585 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
586 Ok(launched.into())
587 }
588
589 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
596 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
597 .await
598 }
599
600 pub async fn wait_for_launch(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
609 let started = self.clock().elapsed();
610 let mut resumed = false;
611 loop {
612 let got = self.get_microvm(id).await?;
613 match got.state.as_str() {
614 "RUNNING" => return Ok(got),
615 state if crate::constants::DEAD_STATES.contains(&state) => {
616 return Err(self.reached_terminal_state(&got, &["RUNNING"]));
617 }
618 "SUSPENDED" if !resumed => {
621 self.resume(id).await?;
622 resumed = true;
623 continue;
624 }
625 _ => {}
626 }
627
628 let elapsed = self.clock().elapsed().saturating_sub(started);
629 if elapsed >= opts.timeout {
630 return Err(timed_out(
631 &format!(
632 "microvm {id} never reached [\"RUNNING\"] (last state {})",
633 got.state
634 ),
635 elapsed,
636 ));
637 }
638 self.clock().sleep(opts.poll_interval).await;
639 }
640 }
641
642 pub async fn wait_for_state(
653 &self,
654 id: &str,
655 wanted: &[&str],
656 fail_on: &[&str],
657 opts: WaitOpts,
658 ) -> Result<Microvm, Error> {
659 let started = self.clock().elapsed();
660 loop {
661 let got = self.get_microvm(id).await?;
662
663 if wanted.contains(&got.state.as_str()) {
664 return Ok(got);
665 }
666 if fail_on.contains(&got.state.as_str()) {
667 return Err(self.reached_terminal_state(&got, wanted));
668 }
669
670 let elapsed = self.clock().elapsed().saturating_sub(started);
671 if elapsed >= opts.timeout {
672 return Err(timed_out(
673 &format!(
674 "microvm {id} never reached {wanted:?} (last state {})",
675 got.state
676 ),
677 elapsed,
678 ));
679 }
680 self.clock().sleep(opts.poll_interval).await;
681 }
682 }
683
684 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
689 Error::new(
690 ErrorKind::LaunchDied,
691 format!(
692 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
693 before RUNNING died during startup, and for a hook-serving daemon that almost \
694 always means a lifecycle hook failed — the stateReason above is the only evidence \
695 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
696 startup reports a connection error that hides the cause').",
697 got.id,
698 got.state,
699 got.reason(),
700 ),
701 )
702 }
703
704 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
711 super::require_valid_identifier("microvmIdentifier", id)?;
712 let call = Call::get("GetMicrovm", paths::microvm(id));
713 let reply = send_with_retry(self.transport(), call).await?;
714 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
715 Ok(got.into())
716 }
717
718 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
728 self.list_microvms_matching(&MicrovmFilter::default()).await
729 }
730
731 pub async fn list_microvms_matching(
733 &self,
734 filter: &MicrovmFilter,
735 ) -> Result<Vec<ops::MicrovmItemWire>, Error> {
736 if let Some(image) = filter.image_identifier.as_deref() {
737 super::require_valid_identifier("imageIdentifier", image)?;
738 }
739 if let Some(version) = filter.image_version.as_deref() {
740 super::require_valid_version("imageVersion", version)?;
741 }
742 let mut items = Vec::new();
743 let mut next_token: Option<String> = None;
744 loop {
745 let call = Call::get(
746 "ListMicrovms",
747 paths::microvms_list_matching(
748 filter.image_identifier.as_deref(),
749 filter.image_version.as_deref(),
750 next_token.as_deref(),
751 ),
752 );
753 let reply = send_with_retry(self.transport(), call).await?;
754 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
755 items.extend(page.items);
756 match page.next_token {
757 Some(token) => next_token = Some(token),
758 None => return Ok(items),
759 }
760 }
761 }
762
763 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
765 super::require_valid_identifier("microvmIdentifier", id)?;
766 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
767 send_with_retry(self.transport(), call).await?;
768 Ok(())
769 }
770
771 pub async fn resume(&self, id: &str) -> Result<(), Error> {
773 super::require_valid_identifier("microvmIdentifier", id)?;
774 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
775 send_with_retry(self.transport(), call).await?;
776 Ok(())
777 }
778
779 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
787 super::require_valid_identifier("microvmIdentifier", id)?;
788 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
789 send_with_retry(self.transport(), call).await?;
790 Ok(())
791 }
792
793 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
805 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
806 .await
807 }
808
809 pub async fn mint_auth_token_for(
822 &self,
823 id: &str,
824 ports: &[ops::PortSpecification],
825 ) -> Result<ProxyToken, Error> {
826 super::require_valid_identifier("microvmIdentifier", id)?;
827 if ports.is_empty() {
828 return Err(Error::new(
832 ErrorKind::Precondition,
833 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
834 Name the ports this token is for — the agent port plus any workload port \
835 reached through the endpoint.",
836 ));
837 }
838 for spec in ports {
846 match spec {
847 ops::PortSpecification::One { port } => {
848 super::require_valid_port("allowedPorts[].port", *port)?;
849 }
850 ops::PortSpecification::Range { range } => {
851 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
852 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
853 }
854 ops::PortSpecification::All { .. } => {}
856 }
857 }
858 let wire = ops::CreateAuthTokenWire {
859 expiration_in_minutes: MAX_TOKEN_MINUTES,
860 allowed_ports: ports.to_vec(),
861 };
862 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
863 let reply = send_with_retry(self.transport(), call).await?;
864 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
865 ProxyToken::from_map(&minted.auth_token, self.port())
866 }
867
868 pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
890 super::require_valid_identifier("microvmIdentifier", id)?;
891 let wire = ops::CreateShellAuthTokenWire {
892 expiration_in_minutes: MAX_TOKEN_MINUTES,
893 };
894 let call = Call::post_json(
895 "CreateMicrovmShellAuthToken",
896 paths::shell_auth_token(id),
897 &wire,
898 )?;
899 let reply = send_with_retry(self.transport(), call).await?;
900 let minted: ops::CreateShellAuthTokenResponseWire =
901 reply.json("CreateMicrovmShellAuthToken")?;
902 ProxyToken::from_operation_map(
903 "CreateMicrovmShellAuthToken",
904 &minted.auth_token,
905 self.port(),
906 )
907 }
908}
909
910pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
915
916#[cfg(test)]
917mod tests {
918 use std::sync::Arc;
919
920 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
921 use super::*;
922 use crate::region::Region;
923
924 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
925 let fake = Arc::new(FakeControlPlane::new());
926 let clock = Arc::new(TestClock::new());
927 let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
928 (plane, fake, clock)
929 }
930
931 #[test]
938 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
939 let at_ceiling = "a".repeat(4096);
940 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
941 assert_eq!(payload.len(), 4096);
942 assert_eq!(payload.as_str(), at_ceiling);
943
944 let over = "a".repeat(4097);
945 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
946 assert_eq!(error.kind(), ErrorKind::InvalidArg);
947 let message = error.to_string();
948 assert!(message.contains("4097 bytes"), "{message}");
949 assert!(
950 message.contains("ceiling of 4096"),
951 "the message must name the service-model ceiling: {message}"
952 );
953 assert!(message.contains("2025-09-09"), "{message}");
954 assert!(
955 message.contains("wrong by 4x"),
956 "the 16 KB claim has to be named as wrong: {message}"
957 );
958 }
959
960 #[tokio::test]
963 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
964 let (plane, fake, _) = planted();
965
966 let refused = RunHookPayload::new("a".repeat(4097));
968 assert!(refused.is_err());
969 assert_eq!(fake.calls().len(), 0);
970
971 fake.answer(
974 "RunMicrovm",
975 Answer::ok(fake::microvm_response("PENDING", None)),
976 );
977 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
978 plane
979 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
980 .await
981 .expect("launches");
982 assert_eq!(fake.call_count("RunMicrovm"), 1);
983 }
984
985 #[test]
988 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
989 let two_byte_chars = "é".repeat(2049);
990 assert_eq!(
991 two_byte_chars.chars().count(),
992 2049,
993 "well under 4096 chars"
994 );
995 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
996
997 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
998 assert!(error.to_string().contains("4098 bytes"), "{error}");
999
1000 let exact = "é".repeat(2048);
1002 assert_eq!(exact.len(), 4096);
1003 RunHookPayload::new(exact).expect("exactly at the ceiling");
1004 }
1005
1006 #[test]
1015 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
1016 let env = std::collections::HashMap::new();
1017 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
1018 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
1019 assert_eq!(
1020 without.as_str(),
1021 none_asked.as_str(),
1022 "no identity must produce byte-for-byte the payload this client always sent"
1023 );
1024
1025 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
1026 let with =
1027 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
1028 assert_eq!(
1029 with.len() - without.len(),
1030 protocol::identity::IDENTITY_PAYLOAD_BYTES,
1031 "the budget constant must equal what the material really costs on the wire"
1032 );
1033
1034 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
1036 assert_eq!(
1037 parsed.identity_seed.as_deref(),
1038 Some(identity.seed_field().as_str())
1039 );
1040 assert_eq!(
1041 parsed.identity_host_public_key.as_deref(),
1042 Some(identity.host_public_field().as_str())
1043 );
1044 }
1045
1046 #[test]
1049 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
1050 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
1051 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
1052
1053 let huge = "x".repeat(4096);
1056 let error = RunHookPayload::for_agent_token(&huge)
1057 .expect_err("the JSON wrapper pushes it over the ceiling");
1058 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1059 assert!(
1060 error.to_string().contains("one bearer token fits"),
1061 "{error}"
1062 );
1063 }
1064
1065 #[test]
1071 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
1072 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
1073 .expect("an empty env fits");
1074 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
1075 assert_eq!(with_empty.as_str(), token_only.as_str());
1076 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
1077 assert!(
1078 !with_empty.as_str().contains("env"),
1079 "an empty env must not appear on the wire at all: {}",
1080 with_empty.as_str()
1081 );
1082 }
1083
1084 #[test]
1086 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
1087 let mut env = std::collections::HashMap::new();
1088 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
1089 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
1090
1091 let value: serde_json::Value =
1093 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
1094 assert_eq!(value["agent_token"], "tok");
1095 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
1096 }
1097
1098 #[test]
1116 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
1117 let mut env = std::collections::HashMap::new();
1118 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
1120
1121 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1125 .to_string()
1126 .len();
1127 assert!(
1128 expected > 4096,
1129 "the fixture must actually be over: {expected}"
1130 );
1131
1132 let error = RunHookPayload::for_launch("tok", &env)
1133 .expect_err("a credential-scale launch env does not fit");
1134 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1135 let message = error.to_string();
1136
1137 assert!(
1138 message.contains("ceiling of 4096"),
1139 "the ceiling has to be named: {message}"
1140 );
1141 assert!(
1142 message.contains(&format!("{expected} bytes")),
1143 "the actual byte count has to be named: {message}"
1144 );
1145 assert!(
1146 message.contains("launch env contributed"),
1147 "the env's share of the budget is the actionable half: {message}"
1148 );
1149 assert!(
1150 message.contains("1 variable(s)"),
1151 "the variable count has to be named: {message}"
1152 );
1153 }
1154
1155 #[test]
1162 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1163 let mut env = std::collections::HashMap::new();
1164 env.insert("A".to_string(), "a".repeat(2030));
1165 env.insert("B".to_string(), "b".repeat(2030));
1166 let raw: usize = env.values().map(String::len).sum();
1167 assert_eq!(raw, 4060, "the raw values are under the ceiling");
1168 assert!(
1169 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1170 "the fixture only means something if the raw sum fits: {raw}"
1171 );
1172
1173 let error = RunHookPayload::for_launch("tok", &env)
1174 .expect_err("the serialized payload is over even though the raw values are not");
1175 assert!(error.to_string().contains("2 variable(s)"), "{error}");
1176
1177 let mut fits = std::collections::HashMap::new();
1180 fits.insert("A".to_string(), "a".repeat(2000));
1181 fits.insert("B".to_string(), "b".repeat(2000));
1182 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1183 assert!(payload.len() <= 4096, "{}", payload.len());
1184 }
1185
1186 #[test]
1188 fn an_empty_payload_is_legal() {
1189 let payload = RunHookPayload::new("").expect("min is 0");
1190 assert!(payload.is_empty());
1191 assert_eq!(payload.len(), 0);
1192 }
1193
1194 #[tokio::test]
1197 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1198 let (plane, fake, _) = planted();
1199 fake.answer(
1200 "RunMicrovm",
1201 Answer::ok(fake::microvm_response("PENDING", None)),
1202 );
1203
1204 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1205 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1206 plane.run_microvm(request).await.expect("launches");
1207
1208 let calls = fake.calls();
1209 assert_eq!(calls[0].path, "/2025-09-09/microvms");
1210 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1211
1212 let body = fake.first_body("RunMicrovm");
1213 assert_eq!(
1214 body["ingressNetworkConnectors"],
1215 serde_json::json!([
1216 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1217 ])
1218 );
1219 assert_eq!(
1220 body["egressNetworkConnectors"],
1221 serde_json::json!([
1222 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1223 ])
1224 );
1225 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1226 assert_eq!(body["maximumDurationInSeconds"], 3600);
1227 }
1228
1229 #[tokio::test]
1234 async fn a_launch_without_egress_omits_the_member_entirely() {
1235 let (plane, fake, _) = planted();
1236 fake.answer(
1237 "RunMicrovm",
1238 Answer::ok(fake::microvm_response("PENDING", None)),
1239 );
1240 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1241 plane
1242 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1243 .await
1244 .expect("launches");
1245
1246 let body = fake.first_body("RunMicrovm");
1247 assert!(
1248 body.get("egressNetworkConnectors").is_none(),
1249 "omitting egress is the whole mechanism: {body}"
1250 );
1251 assert!(body.get("ingressNetworkConnectors").is_some());
1252 }
1253
1254 #[tokio::test]
1255 async fn customer_vpc_connector_arns_reach_the_egress_member_unchanged() {
1256 let (plane, fake, _) = planted();
1257 fake.answer(
1258 "RunMicrovm",
1259 Answer::ok(fake::microvm_response("PENDING", None)),
1260 );
1261 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1262 let mut request = RunMicrovmRequest::new("arn:image", payload);
1263 let arns: Vec<String> = (0..10)
1264 .map(|id| format!("arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-{id}:1"))
1265 .collect();
1266 request.egress_network_connectors = arns.clone();
1267 plane
1268 .run_microvm(request)
1269 .await
1270 .expect("ten egress connectors plus ingress fits");
1271 let body = fake.first_body("RunMicrovm");
1272 assert_eq!(body["egressNetworkConnectors"], serde_json::json!(arns));
1273 assert_eq!(
1274 body["ingressNetworkConnectors"].as_array().unwrap().len(),
1275 1
1276 );
1277 }
1278
1279 #[tokio::test]
1280 async fn invalid_customer_egress_connectors_are_refused_before_launch() {
1281 let valid = "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test";
1282 let invalid = [
1283 "VPC_EGRESS",
1284 "arn:aws:lambda:us-west-2:123456789012:network-connector:vpc-test",
1285 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS",
1286 "arn:aws:lambda:us-east-1:123456789012:network-connector:",
1287 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test:0",
1288 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test/other",
1289 ];
1290 for arn in invalid {
1291 let (plane, fake, _) = planted();
1292 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1293 let error = plane
1294 .run_microvm(
1295 RunMicrovmRequest::new("arn:image", payload).with_egress_network_connector(arn),
1296 )
1297 .await
1298 .expect_err(arn);
1299 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1300 assert!(
1301 error.to_string().contains("egressNetworkConnectors"),
1302 "{error}"
1303 );
1304 assert!(fake.calls().is_empty());
1305 }
1306 for (internet, count) in [(true, 1), (false, 11)] {
1307 let (plane, fake, _) = planted();
1308 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1309 let mut request = RunMicrovmRequest::new("arn:image", payload);
1310 request.egress_network_connectors = vec![valid.to_string(); count];
1311 if internet {
1312 request = request.with_egress();
1313 }
1314 let error = plane
1315 .run_microvm(request)
1316 .await
1317 .expect_err("invalid routing request");
1318 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1319 assert!(fake.calls().is_empty());
1320 }
1321 }
1322
1323 #[tokio::test]
1336 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1337 let (plane, fake, _) = planted();
1338 fake.answer(
1339 "RunMicrovm",
1340 Answer::ok(fake::microvm_response("PENDING", None)),
1341 );
1342 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1343 plane
1344 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1345 .await
1346 .expect("launches");
1347
1348 let body = fake.first_body("RunMicrovm");
1349 assert_eq!(body["imageVersion"], "2.0");
1350 assert_eq!(
1351 body["imageIdentifier"], "arn:image",
1352 "pinning a version does not replace the identifier; both are sent"
1353 );
1354
1355 let (plane, fake, _) = planted();
1356 fake.answer(
1357 "RunMicrovm",
1358 Answer::ok(fake::microvm_response("PENDING", None)),
1359 );
1360 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1361 plane
1362 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1363 .await
1364 .expect("launches");
1365 assert!(
1366 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1367 "an unpinned launch must send what this client always sent: {}",
1368 fake.first_body("RunMicrovm")
1369 );
1370 }
1371
1372 #[tokio::test]
1382 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1383 for bad in ["", "2.0\n", "a b"] {
1384 let (plane, fake, _) = planted();
1385 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1386 let error = plane
1387 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1388 .await
1389 .expect_err(&format!("{bad:?} is not a legal Version"));
1390 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1391 assert!(
1392 error.to_string().contains("imageVersion"),
1393 "{bad:?}: {error}"
1394 );
1395 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1396 }
1397 }
1398
1399 #[tokio::test]
1403 async fn no_bare_connector_name_reaches_the_wire() {
1404 let (plane, fake, _) = planted();
1405 fake.answer(
1406 "RunMicrovm",
1407 Answer::ok(fake::microvm_response("PENDING", None)),
1408 );
1409 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1410 plane
1411 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1412 .await
1413 .expect("launches");
1414
1415 let body = fake.first_body("RunMicrovm");
1416 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1417 for value in body[member].as_array().expect("a list") {
1418 let text = value.as_str().expect("a string");
1419 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1420 assert_ne!(text, "ALL_INGRESS");
1421 assert_ne!(text, "INTERNET_EGRESS");
1422 }
1423 }
1424 }
1425
1426 #[tokio::test]
1428 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1429 let (plane, fake, _) = planted();
1430 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1431 let mut request = RunMicrovmRequest::new("arn:image", payload);
1432 request.max_duration_sec = 28_801;
1433
1434 let error = plane
1435 .run_microvm(request)
1436 .await
1437 .expect_err("over the ceiling");
1438 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1439 assert_eq!(fake.calls().len(), 0);
1440 }
1441
1442 #[tokio::test]
1452 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1453 let (plane, fake, _) = planted();
1454 fake.answer(
1455 "GetMicrovm",
1456 Answer::ok(fake::microvm_response(
1457 "TERMINATED",
1458 Some("run hook returned 500"),
1459 )),
1460 );
1461
1462 let error = plane
1463 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1464 .await
1465 .expect_err("a dead VM must not be waited out");
1466
1467 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1468 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1469 let message = error.to_string();
1470 assert!(
1471 message.contains("TERMINATED"),
1472 "the state must be attached: {message}"
1473 );
1474 assert!(
1475 message.contains("run hook returned 500"),
1476 "the stateReason must be attached: {message}"
1477 );
1478 assert!(
1479 message.contains("lifecycle hook failed"),
1480 "the likely cause is worth naming: {message}"
1481 );
1482 assert!(
1483 fake.call_count("GetMicrovm") == 1,
1484 "it fails fast rather than polling to the deadline"
1485 );
1486 }
1487
1488 #[tokio::test]
1495 async fn every_terminal_state_fails_a_launch_fast() {
1496 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1497 for state in crate::constants::TERMINAL_STATES {
1498 let (plane, fake, _) = planted();
1499 fake.answer(
1500 "GetMicrovm",
1501 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1502 );
1503 let error = plane
1504 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1505 .await
1506 .expect_err(&format!("{state} before RUNNING is a death"));
1507 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1508 assert!(error.to_string().contains(state), "{state}: {error}");
1509 assert!(
1510 error.to_string().contains("hook timeout"),
1511 "{state}: {error}"
1512 );
1513 assert_eq!(
1514 fake.call_count("GetMicrovm"),
1515 1,
1516 "{state} must fail fast rather than polling to the deadline"
1517 );
1518 }
1519 }
1520
1521 #[tokio::test]
1525 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1526 let (plane, fake, _) = planted();
1527 fake.answer(
1528 "GetMicrovm",
1529 Answer::ok(fake::microvm_response("TERMINATED", None)),
1530 );
1531
1532 let error = plane
1533 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1534 .await
1535 .expect_err("still a death");
1536 let message = error.to_string();
1537 assert!(message.contains("no stateReason"), "{message}");
1538 assert!(
1542 !message.contains(r#"["RUNNING"]: ."#),
1543 "an absent reason must not render as an empty string: {message}"
1544 );
1545 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1546 }
1547
1548 #[tokio::test]
1550 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1551 let (plane, fake, _) = planted();
1552 fake.answer(
1553 "GetMicrovm",
1554 Answer::ok(fake::microvm_response("PENDING", None)),
1555 )
1556 .answer(
1557 "GetMicrovm",
1558 Answer::ok(fake::microvm_response("RUNNING", None)),
1559 );
1560
1561 let vm = plane
1562 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1563 .await
1564 .expect("reaches RUNNING");
1565 assert_eq!(vm.state, "RUNNING");
1566 assert_eq!(
1567 vm.endpoint,
1568 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1569 );
1570 assert_eq!(fake.call_count("GetMicrovm"), 2);
1571 }
1572
1573 #[tokio::test]
1577 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1578 let (plane, fake, _) = planted();
1579 fake.answer(
1580 "GetMicrovm",
1581 Answer::ok(fake::microvm_response("PENDING", None)),
1582 );
1583
1584 let error = plane
1585 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1586 .await
1587 .expect_err("the deadline elapses");
1588 assert_eq!(error.kind(), ErrorKind::Timeout);
1589 assert_ne!(
1590 error.kind(),
1591 ErrorKind::LaunchDied,
1592 "a timeout is not a death, and conflating them is what hid the cause"
1593 );
1594 assert!(error.to_string().contains("PENDING"), "{error}");
1595 }
1596
1597 #[tokio::test]
1600 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1601 let (plane, fake, _) = planted();
1602 fake.answer(
1603 "GetMicrovm",
1604 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1605 )
1606 .answer(
1607 "GetMicrovm",
1608 Answer::ok(fake::microvm_response("RUNNING", None)),
1609 );
1610
1611 let vm = plane
1612 .wait_for_state(
1613 "mvm-abc123",
1614 &["RUNNING"],
1615 &crate::constants::DEAD_STATES,
1616 WaitOpts::for_launch(),
1617 )
1618 .await
1619 .expect("SUSPENDED is a waypoint on the resume path");
1620 assert_eq!(vm.state, "RUNNING");
1621
1622 let (plane, fake, _) = planted();
1623 fake.answer(
1624 "GetMicrovm",
1625 Answer::ok(fake::microvm_response(
1626 "TERMINATED",
1627 Some("suspended window elapsed"),
1628 )),
1629 );
1630 let error = plane
1631 .wait_for_state(
1632 "mvm-abc123",
1633 &["RUNNING"],
1634 &crate::constants::DEAD_STATES,
1635 WaitOpts::for_launch(),
1636 )
1637 .await
1638 .expect_err("a terminated VM never reaches RUNNING");
1639 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1640 assert!(
1641 error.to_string().contains("suspended window elapsed"),
1642 "{error}"
1643 );
1644 }
1645
1646 #[tokio::test]
1649 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1650 let (plane, fake, _) = planted();
1651 fake.answer(
1652 "GetMicrovm",
1653 Answer::ok(fake::microvm_response("TERMINATED", None)),
1654 );
1655
1656 let vm = plane
1657 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1658 .await
1659 .expect("a suspend reports the state it reached");
1660 assert_eq!(vm.state, "TERMINATED");
1661 }
1662
1663 #[tokio::test]
1666 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1667 let (plane, fake, _) = planted();
1668 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1669 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1670 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1671
1672 plane.suspend("mvm-1").await.expect("suspends");
1673 plane.resume("mvm-1").await.expect("resumes");
1674 plane.terminate("mvm-1").await.expect("terminates");
1675
1676 let calls = fake.calls();
1677 use super::super::transport::Method;
1678 assert_eq!(
1679 calls
1680 .iter()
1681 .map(|call| (call.operation, call.method, call.path.as_str()))
1682 .collect::<Vec<_>>(),
1683 [
1684 (
1685 "SuspendMicrovm",
1686 Method::Post,
1687 "/2025-09-09/microvms/mvm-1/suspend"
1688 ),
1689 (
1690 "ResumeMicrovm",
1691 Method::Post,
1692 "/2025-09-09/microvms/mvm-1/resume"
1693 ),
1694 (
1695 "TerminateMicrovm",
1696 Method::Delete,
1697 "/2025-09-09/microvms/mvm-1"
1698 ),
1699 ]
1700 );
1701 }
1702
1703 #[tokio::test]
1706 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1707 let (plane, fake, _) = planted();
1708 fake.answer(
1709 "CreateMicrovmAuthToken",
1710 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1711 );
1712
1713 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1714 assert_eq!(token.value(), "opaque-proxy-token");
1715 assert_eq!(token.port(), 9000);
1716
1717 let headers = token.headers();
1718 assert_eq!(
1719 headers[0],
1720 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1721 );
1722 assert_eq!(
1723 headers[1],
1724 ("X-aws-proxy-port", "9000".to_string()),
1725 "the auth header without the port header is rejected in a way that reads like a \
1726 bad token"
1727 );
1728
1729 let body = fake.first_body("CreateMicrovmAuthToken");
1730 assert_eq!(body["expirationInMinutes"], 60);
1731 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1732 }
1733
1734 #[tokio::test]
1746 async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
1747 let (plane, fake, _) = planted();
1748 fake.answer(
1749 "CreateMicrovmShellAuthToken",
1750 Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
1751 );
1752
1753 let token = plane
1754 .mint_shell_auth_token("mvm-1")
1755 .await
1756 .expect("mints a shell token");
1757 assert_eq!(token.value(), "opaque-shell-jwe");
1758
1759 let calls = fake.calls();
1760 assert_eq!(calls.len(), 1);
1761 assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
1762 assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
1763
1764 let body = fake.first_body("CreateMicrovmShellAuthToken");
1765 assert_eq!(body["expirationInMinutes"], 60);
1766 assert_eq!(
1767 body.get("allowedPorts"),
1768 None,
1769 "the shell is not a port: the request declares no allowedPorts member"
1770 );
1771 assert_eq!(
1772 body.as_object().map(serde_json::Map::len),
1773 Some(1),
1774 "expirationInMinutes is the body's only member: {body}"
1775 );
1776 }
1777
1778 #[tokio::test]
1782 async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
1783 let (plane, fake, _) = planted();
1784 let error = plane
1785 .mint_shell_auth_token("")
1786 .await
1787 .expect_err("an empty identifier is refused");
1788 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1789 assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
1790 }
1791
1792 #[test]
1800 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1801 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1802
1803 let rendered = format!("{payload:?}");
1804 assert!(
1805 !rendered.contains("SECRETTOKEN"),
1806 "the agent token reached a Debug string: {rendered}"
1807 );
1808 assert!(
1809 rendered.contains(&format!("{} bytes", payload.len())),
1810 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1811 );
1812
1813 let request = RunMicrovmRequest::new("arn:image", payload);
1814 let rendered = format!("{request:?}");
1815 assert!(rendered.contains("arn:image"), "{rendered}");
1816 assert!(
1817 !rendered.contains("SECRETTOKEN"),
1818 "the agent token reached a Debug string through the request: {rendered}"
1819 );
1820 }
1821
1822 #[test]
1829 fn a_proxy_token_debug_does_not_print_the_credential() {
1830 let mut map = std::collections::BTreeMap::new();
1831 map.insert(
1832 PROXY_AUTH_HEADER.to_string(),
1833 "eyJhbGciOi-secret".to_string(),
1834 );
1835 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1836
1837 let rendered = format!("{token:?}");
1838 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1839 assert!(rendered.contains("9000"), "{rendered}");
1840 assert!(
1841 !rendered.contains("secret"),
1842 "the token value reached a Debug string: {rendered}"
1843 );
1844 }
1845
1846 #[test]
1849 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1850 let mut map = std::collections::BTreeMap::new();
1851 map.insert("SomeOtherKey".to_string(), "value".to_string());
1852
1853 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1854 assert_eq!(
1855 error.wire_kind(),
1856 Some(crate::error::WireKind::AuthTokenMint)
1857 );
1858 assert!(error.retryable(), "minting is inside the retry path");
1859 assert!(
1860 error.to_string().contains("is a map, not a string"),
1861 "{error}"
1862 );
1863 }
1864
1865 #[tokio::test]
1867 async fn the_token_is_minted_for_the_configured_port() {
1868 let fake = Arc::new(FakeControlPlane::new());
1869 let plane =
1870 ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()))
1871 .with_port(8080)
1872 .expect("8080 is a legal port");
1873 fake.answer(
1874 "CreateMicrovmAuthToken",
1875 Answer::ok(fake::auth_token_response("t")),
1876 );
1877
1878 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1879 assert_eq!(token.port(), 8080);
1880 assert_eq!(
1881 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1882 8080
1883 );
1884 }
1885
1886 #[tokio::test]
1910 async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
1911 let (plane, fake, _) = planted();
1912 fake.answer(
1913 "CreateMicrovmImage",
1914 Answer::created(fake::create_image_response("img")),
1915 )
1916 .answer(
1917 "GetMicrovmImage",
1918 Answer::ok(fake::get_image_response("img", "CREATED")),
1919 )
1920 .answer(
1921 "RunMicrovm",
1922 Answer::ok(fake::microvm_response("PENDING", None)),
1923 )
1924 .answer(
1925 "GetMicrovm",
1926 Answer::ok(fake::microvm_response("RUNNING", None)),
1927 )
1928 .answer(
1929 "CreateMicrovmAuthToken",
1930 Answer::ok(fake::auth_token_response("proxy-token")),
1931 )
1932 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1933 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1934 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1935 .answer(
1936 "ListMicrovmImageVersions",
1937 Answer::ok(fake::list_versions_response("1")),
1938 )
1939 .answer(
1940 "DeleteMicrovmImage",
1941 Answer::ok(fake::delete_image_response()),
1942 );
1943
1944 let request = super::super::CreateImageRequest::new(
1946 "img",
1947 b"binary".to_vec(),
1948 "s3://bucket/img.zip",
1949 "arn:aws:iam::123456789012:role/build",
1950 );
1951 let image = plane.create_image(request).await.expect("creates");
1952 plane
1953 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1954 .await
1955 .expect("becomes usable");
1956
1957 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1958 let vm = plane
1959 .run_microvm(
1960 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1961 )
1962 .await
1963 .expect("launches");
1964 plane
1965 .wait_for_running(&vm.id, WaitOpts::for_launch())
1966 .await
1967 .expect("reaches RUNNING");
1968 plane.mint_auth_token(&vm.id).await.expect("mints");
1969 plane.suspend(&vm.id).await.expect("suspends");
1970 plane.resume(&vm.id).await.expect("resumes");
1971 plane.terminate(&vm.id).await.expect("terminates");
1972 plane
1973 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
1974 .await;
1975
1976 let operations = fake.operations();
1979 assert!(
1980 operations.len() >= 10,
1981 "the lifecycle must actually have run: {operations:?}"
1982 );
1983 assert!(operations.contains(&"CreateMicrovmAuthToken"));
1984
1985 assert_eq!(
1986 fake.call_count("CreateMicrovmShellAuthToken"),
1987 0,
1988 "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
1989 for no shell must mint no shell token: {operations:?}"
1990 );
1991 for operation in &operations {
1992 assert!(
1993 !operation.contains("Shell"),
1994 "no operation may be a shell one: {operation}"
1995 );
1996 }
1997 for path in fake.paths() {
1998 assert!(
1999 !path.contains("shell"),
2000 "no request may reach a shell route: {path}"
2001 );
2002 }
2003
2004 let launch = fake.first_body("RunMicrovm");
2010 assert_eq!(
2011 launch["ingressNetworkConnectors"],
2012 serde_json::json!([
2013 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
2014 ]),
2015 "ingress is exactly what the caller asked for"
2016 );
2017 assert_eq!(
2018 launch["egressNetworkConnectors"],
2019 serde_json::json!([
2020 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
2021 ]),
2022 "egress is exactly what the caller asked for"
2023 );
2024 }
2025
2026 #[tokio::test]
2032 async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
2033 let (plane, fake, _) = planted();
2034 fake.answer(
2035 "RunMicrovm",
2036 Answer::ok(fake::microvm_response("PENDING", None)),
2037 );
2038
2039 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2040 let mut request = RunMicrovmRequest::new("arn:image", payload);
2041 request.connectors = vec![
2042 super::super::ConnectorIntent::HttpIngress,
2043 super::super::ConnectorIntent::ShellIngress,
2044 ];
2045 plane.run_microvm(request).await.expect("launches");
2046
2047 let launch = fake.first_body("RunMicrovm");
2048 assert_eq!(
2049 launch["ingressNetworkConnectors"],
2050 serde_json::json!([
2051 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
2052 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
2053 ]),
2054 "both fine-grained ingress connectors reach the wire, in the order asked"
2055 );
2056 assert_eq!(
2057 launch.get("egressNetworkConnectors"),
2058 None,
2059 "no egress was asked for, so the member is absent rather than empty"
2060 );
2061 }
2062
2063 #[tokio::test]
2072 async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
2073 let (plane, fake, _) = planted();
2074
2075 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2076 let mut request = RunMicrovmRequest::new("arn:image", payload);
2077 request
2078 .connectors
2079 .push(super::super::ConnectorIntent::ShellIngress);
2080 let error = plane
2081 .run_microvm(request)
2082 .await
2083 .expect_err("the invalid combination is refused locally");
2084
2085 assert!(
2086 error
2087 .to_string()
2088 .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
2089 "the refusal names the pair that works: {error}"
2090 );
2091 assert_eq!(
2092 fake.call_count("RunMicrovm"),
2093 0,
2094 "nothing launched, so nothing billed"
2095 );
2096 }
2097
2098 #[tokio::test]
2109 async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
2110 let (plane, fake, _) = planted();
2111
2112 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2113 let mut request = RunMicrovmRequest::new("arn:image", payload);
2114 request.connectors = vec![
2115 super::super::ConnectorIntent::ShellIngress,
2116 super::super::ConnectorIntent::Egress,
2117 ];
2118 let error = plane
2119 .run_microvm(request)
2120 .await
2121 .expect_err("shell ingress without HTTP ingress is refused locally");
2122
2123 assert!(
2124 error.to_string().contains("HTTP_INGRESS"),
2125 "the refusal names the missing connector: {error}"
2126 );
2127 assert_eq!(
2128 fake.call_count("RunMicrovm"),
2129 0,
2130 "nothing launched, so nothing billed"
2131 );
2132 }
2133
2134 #[tokio::test]
2147 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
2148 let (plane, fake, _) = planted();
2149 fake.answer(
2150 "ListMicrovms",
2151 Answer::ok(fake::list_microvms_page(
2152 &["mvm-page1-a", "mvm-page1-b"],
2153 Some("fleet-page-2"),
2154 )),
2155 )
2156 .answer(
2157 "ListMicrovms",
2158 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2159 );
2160
2161 let fleet = plane.list_microvms().await.expect("lists");
2162 assert_eq!(
2163 fleet.len(),
2164 3,
2165 "a fleet spanning two pages is three VMs, not two"
2166 );
2167
2168 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2169 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
2170 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
2171 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2172
2173 let paths = fake.paths();
2174 assert!(
2175 paths[1].contains("nextToken=fleet-page-2"),
2176 "the second request must carry the first page's token: {}",
2177 paths[1]
2178 );
2179 assert!(
2180 !paths[0].contains('?'),
2181 "the first request carries no cursor: {}",
2182 paths[0]
2183 );
2184 }
2185
2186 #[tokio::test]
2196 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2197 let (plane, fake, _) = planted();
2198 fake.answer(
2199 "GetMicrovm",
2200 Answer::ok(fake::microvm_response("RUNNING", None)),
2201 );
2202
2203 let vm = plane.get_microvm("mvm-1").await.expect("reads");
2204 let policy = vm
2205 .idle_policy
2206 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2207 assert_eq!(policy.max_idle_duration_seconds, 1800);
2208 assert_eq!(
2209 policy.suspended_duration_seconds, 600,
2210 "the member the comment claimed was request-only"
2211 );
2212 assert!(!policy.auto_resume_enabled);
2213 }
2214
2215 #[tokio::test]
2219 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2220 let (plane, fake, _) = planted();
2221 fake.answer(
2222 "GetMicrovm",
2223 Answer::ok(
2224 r#"{
2225 "microvmId": "mvm-1",
2226 "state": "RUNNING",
2227 "endpoint": "https://e",
2228 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2229 "imageVersion": "1"
2230 }"#,
2231 ),
2232 );
2233
2234 let vm = plane.get_microvm("mvm-1").await.expect("parses");
2235 assert_eq!(vm.idle_policy, None);
2236 }
2237
2238 #[tokio::test]
2241 async fn a_transport_failure_is_retried_rather_than_reported() {
2242 let (plane, fake, _) = planted();
2243 fake.fail_transport("GetMicrovm", 2).answer(
2244 "GetMicrovm",
2245 Answer::ok(fake::microvm_response("RUNNING", None)),
2246 );
2247
2248 let vm = plane
2249 .get_microvm("mvm-1")
2250 .await
2251 .expect("the retry succeeds");
2252 assert_eq!(vm.state, "RUNNING");
2253 assert_eq!(
2254 fake.call_count("GetMicrovm"),
2255 3,
2256 "two failures then a success"
2257 );
2258 }
2259
2260 #[tokio::test]
2277 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2278 for under in [0, 1, 59] {
2279 let (plane, fake, _) = planted();
2280 fake.answer(
2281 "RunMicrovm",
2282 Answer::ok(fake::microvm_response("PENDING", None)),
2283 );
2284
2285 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2286 let mut request = RunMicrovmRequest::new("arn:image", payload);
2287 request.max_idle_sec = under;
2288
2289 let error = plane
2290 .run_microvm(request)
2291 .await
2292 .expect_err("the model's min is 60");
2293 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2294 let message = error.to_string();
2295 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2296 assert!(message.contains(&under.to_string()), "{message}");
2297 assert_eq!(
2298 fake.calls().len(),
2299 0,
2300 "{under} was refused locally; before this guard it was serialized and sent"
2301 );
2302 }
2303
2304 let (plane, fake, _) = planted();
2307 fake.answer(
2308 "RunMicrovm",
2309 Answer::ok(fake::microvm_response("PENDING", None)),
2310 );
2311 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2312 let mut request = RunMicrovmRequest::new("arn:image", payload);
2313 request.max_idle_sec = 60;
2314 plane.run_microvm(request).await.expect("60 is the minimum");
2315 assert_eq!(
2316 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2317 60
2318 );
2319 }
2320
2321 #[tokio::test]
2331 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2332 type Mutate = fn(&mut RunMicrovmRequest);
2335 let rows: [(&str, Mutate, &str); 6] = [
2336 (
2337 "an execution role that is a bare name",
2338 |request| {
2339 request.execution_role_arn = Some("execution-role".to_string());
2340 },
2341 "role *name*",
2342 ),
2343 (
2344 "an execution role with eleven account digits",
2345 |request| {
2346 request.execution_role_arn =
2347 Some("arn:aws:iam::12345678901:role/exec".to_string());
2348 },
2349 "exactly twelve digits",
2350 ),
2351 (
2352 "an execution role that is a function ARN",
2353 |request| {
2354 request.execution_role_arn =
2355 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2356 },
2357 "RoleArn pattern",
2358 ),
2359 (
2360 "an empty image identifier",
2361 |request| {
2362 request.image_identifier = String::new();
2363 },
2364 "collapses the path",
2365 ),
2366 (
2367 "a 257-character image identifier",
2368 |request| {
2369 request.image_identifier = "a".repeat(257);
2370 },
2371 "MicrovmImageArn permits 2048",
2372 ),
2373 (
2374 "an image version with a newline",
2375 |request| {
2376 request.image_version = Some("2.0\n".to_string());
2377 },
2378 "contains whitespace",
2379 ),
2380 ];
2381
2382 for (label, mutate, expected) in rows {
2383 let (plane, fake, _) = planted();
2384 fake.answer(
2385 "RunMicrovm",
2386 Answer::ok(fake::microvm_response("PENDING", None)),
2387 );
2388
2389 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2390 let mut request = RunMicrovmRequest::new("arn:image", payload);
2391 mutate(&mut request);
2392
2393 let error = plane
2394 .run_microvm(request)
2395 .await
2396 .expect_err(&format!("{label} must be refused"));
2397 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2398 assert!(
2399 error.to_string().contains(expected),
2400 "{label}: wanted {expected:?}, got {error}"
2401 );
2402 assert_eq!(fake.calls().len(), 0, "{label}");
2403 }
2404
2405 let (plane, fake, _) = planted();
2408 fake.answer(
2409 "RunMicrovm",
2410 Answer::ok(fake::microvm_response("PENDING", None)),
2411 );
2412 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2413 let mut request = RunMicrovmRequest::new(
2414 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2415 payload,
2416 );
2417 request.execution_role_arn =
2418 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2419 request.image_version = Some("1.0".to_string());
2420 plane
2421 .run_microvm(request)
2422 .await
2423 .expect("a realistic pinned launch with a real execution role");
2424 assert_eq!(fake.call_count("RunMicrovm"), 1);
2425 }
2426
2427 #[tokio::test]
2437 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2438 for bad in ["", &"a".repeat(257)] {
2439 let (plane, fake, _) = planted();
2440 fake.answer(
2443 "GetMicrovm",
2444 Answer::ok(fake::microvm_response("RUNNING", None)),
2445 );
2446 fake.answer("SuspendMicrovm", Answer::ok("{}"));
2447 fake.answer("ResumeMicrovm", Answer::ok("{}"));
2448 fake.answer("TerminateMicrovm", Answer::ok("{}"));
2449 fake.answer(
2450 "CreateMicrovmAuthToken",
2451 Answer::ok(fake::auth_token_response("t")),
2452 );
2453
2454 plane.get_microvm(bad).await.expect_err("GetMicrovm");
2455 plane.suspend(bad).await.expect_err("SuspendMicrovm");
2456 plane.resume(bad).await.expect_err("ResumeMicrovm");
2457 plane.terminate(bad).await.expect_err("TerminateMicrovm");
2458 plane
2459 .mint_auth_token(bad)
2460 .await
2461 .expect_err("CreateMicrovmAuthToken");
2462 plane
2463 .wait_for_running(bad, WaitOpts::for_launch())
2464 .await
2465 .expect_err("GetMicrovm, in a loop");
2466
2467 assert_eq!(
2468 fake.calls().len(),
2469 0,
2470 "six operations refused {bad:?} locally. An empty identifier on the terminate \
2471 path is a DELETE addressed at the collection."
2472 );
2473 }
2474 }
2475
2476 #[tokio::test]
2491 async fn no_proxy_token_is_minted_for_port_zero() {
2492 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2493 (
2494 "a single port of 0",
2495 ops::PortSpecification::port(0),
2496 "allowedPorts[].port",
2497 ),
2498 (
2499 "a range starting at 0",
2500 ops::PortSpecification::range(0, 9000),
2501 "allowedPorts[].range.startPort",
2502 ),
2503 (
2504 "a range ending at 0",
2505 ops::PortSpecification::range(0, 0),
2506 "allowedPorts[].range.startPort",
2507 ),
2508 ];
2509
2510 for (label, spec, member) in rows {
2511 let (plane, fake, _) = planted();
2512 fake.answer(
2513 "CreateMicrovmAuthToken",
2514 Answer::ok(fake::auth_token_response("t")),
2515 );
2516
2517 let error = plane
2518 .mint_auth_token_for("mvm-1", &[spec])
2519 .await
2520 .expect_err(&format!("{label} must be refused"));
2521 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2522 let message = error.to_string();
2523 assert!(message.contains(member), "{label}: {message}");
2524 assert!(
2525 message.contains("authorizes nothing"),
2526 "{label}: the 403 consequence has to be in the message: {message}"
2527 );
2528 assert_eq!(fake.calls().len(), 0, "{label}");
2529 }
2530
2531 let (plane, fake, _) = planted();
2535 fake.answer(
2536 "CreateMicrovmAuthToken",
2537 Answer::ok(fake::auth_token_response("t")),
2538 );
2539 plane
2540 .mint_auth_token_for(
2541 "mvm-1",
2542 &[
2543 ops::PortSpecification::port(9000),
2544 ops::PortSpecification::port(0),
2545 ],
2546 )
2547 .await
2548 .expect_err("every entry is checked, not the first");
2549 assert_eq!(fake.calls().len(), 0);
2550
2551 for legal in [
2554 ops::PortSpecification::port(1),
2555 ops::PortSpecification::port(65_535),
2556 ops::PortSpecification::range(1, 65_535),
2557 ops::PortSpecification::all(),
2558 ] {
2559 let (plane, fake, _) = planted();
2560 fake.answer(
2561 "CreateMicrovmAuthToken",
2562 Answer::ok(fake::auth_token_response("t")),
2563 );
2564 plane
2565 .mint_auth_token_for("mvm-1", &[legal])
2566 .await
2567 .expect("a legal port specification mints");
2568 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2569 }
2570 }
2571
2572 #[tokio::test]
2578 async fn an_adopted_launch_that_finds_its_vm_suspended_resumes_it() {
2579 for first in ["SUSPENDING", "SUSPENDED"] {
2580 let (plane, fake, _) = planted();
2581 fake.answer(
2582 "GetMicrovm",
2583 Answer::ok(fake::microvm_response(first, None)),
2584 )
2585 .answer(
2586 "GetMicrovm",
2587 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2588 )
2589 .answer(
2590 "GetMicrovm",
2591 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2592 )
2593 .answer(
2594 "GetMicrovm",
2595 Answer::ok(fake::microvm_response("RUNNING", None)),
2596 )
2597 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()));
2598 let running = plane
2599 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2600 .await
2601 .unwrap_or_else(|error| panic!("{first}: {error}"));
2602 assert_eq!(running.state, "RUNNING", "{first}");
2603 assert_eq!(
2604 fake.call_count("ResumeMicrovm"),
2605 1,
2606 "{first}: one resume, even though GetMicrovm still read SUSPENDED after it"
2607 );
2608 }
2609 }
2610
2611 #[tokio::test]
2613 async fn an_adopted_launch_still_fails_fast_on_a_dead_state() {
2614 for state in crate::constants::DEAD_STATES {
2615 let (plane, fake, _) = planted();
2616 fake.answer(
2617 "GetMicrovm",
2618 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
2619 );
2620 let error = plane
2621 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2622 .await
2623 .expect_err(&format!("{state} is a death"));
2624 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
2625 assert!(
2626 error.to_string().contains("hook timeout"),
2627 "{state}: {error}"
2628 );
2629 assert_eq!(fake.call_count("GetMicrovm"), 1, "{state}");
2630 assert_eq!(fake.call_count("ResumeMicrovm"), 0, "{state}");
2631 }
2632 }
2633
2634 #[tokio::test]
2636 async fn get_microvm_carries_the_lifetime_members() {
2637 let (plane, fake, _) = planted();
2638 fake.answer(
2639 "GetMicrovm",
2640 Answer::ok(fake::microvm_response("RUNNING", None)),
2641 );
2642 let vm = plane.get_microvm("mvm-abc123").await.expect("parses");
2643 assert_eq!(vm.maximum_duration_seconds, Some(3600));
2644 assert_eq!(
2645 vm.started_at,
2646 Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_524_800))
2647 );
2648 assert_eq!(vm.terminated_at, None);
2649 }
2650
2651 #[tokio::test]
2653 async fn list_filters_reach_the_query_string() {
2654 let (plane, fake, _) = planted();
2655 fake.answer(
2656 "ListMicrovms",
2657 Answer::ok(fake::list_microvms_page(&["mvm-1"], None)),
2658 );
2659 let filter = MicrovmFilter {
2660 image_identifier: Some(
2661 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img".into(),
2662 ),
2663 image_version: Some("1.0".into()),
2664 };
2665 let items = plane.list_microvms_matching(&filter).await.expect("lists");
2666 assert_eq!(items.len(), 1);
2667 let path = fake.paths().pop().expect("one call");
2668 assert!(
2669 path.ends_with(
2670 "/microvms?imageIdentifier=arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg&imageVersion=1.0"
2671 ),
2672 "{path}"
2673 );
2674 plane.list_microvms().await.expect("unfiltered");
2675 assert!(fake.paths().pop().expect("second").ends_with("/microvms"));
2676 }
2677
2678 #[tokio::test]
2680 async fn per_vm_logging_is_sent_and_checked_locally() {
2681 let (plane, fake, _) = planted();
2682 fake.answer(
2683 "RunMicrovm",
2684 Answer::ok(fake::microvm_response("PENDING", None)),
2685 );
2686 let payload = || RunHookPayload::for_agent_token("t").expect("fits");
2687 let mut request = RunMicrovmRequest::new("arn:image", payload());
2688 request.logging = Some(ops::Logging::cloud_watch("/team/agents", None));
2689 plane.run_microvm(request).await.expect("launches");
2690 assert_eq!(
2691 fake.first_body("RunMicrovm")["logging"],
2692 serde_json::json!({"cloudWatch": {"logGroup": "/team/agents"}})
2693 );
2694
2695 let mut disabled = RunMicrovmRequest::new("arn:image", payload());
2696 disabled.logging = Some(ops::Logging::disabled());
2697 plane.run_microvm(disabled).await.expect("launches");
2698 assert_eq!(
2699 fake.body_of("RunMicrovm", 1)["logging"],
2700 serde_json::json!({"disabled": {}})
2701 );
2702
2703 let plain = RunMicrovmRequest::new("arn:image", payload());
2704 plane.run_microvm(plain).await.expect("launches");
2705 assert!(fake.body_of("RunMicrovm", 2).get("logging").is_none());
2706
2707 for bad in [
2708 ops::Logging::cloud_watch("bad group!", None),
2709 ops::Logging::CloudWatch {
2710 cloud_watch: ops::CloudWatchLogging {
2711 log_group: None,
2712 log_stream: Some("s".into()),
2713 },
2714 },
2715 ] {
2716 let mut request = RunMicrovmRequest::new("arn:image", payload());
2717 request.logging = Some(bad);
2718 let error = plane.run_microvm(request).await.expect_err("refused");
2719 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
2720 }
2721 assert_eq!(
2722 fake.call_count("RunMicrovm"),
2723 3,
2724 "the refusals cost no call"
2725 );
2726 }
2727}