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() {
532 return Err(super::connector::egress_with_connectors());
533 }
534 for arn in request.egress_network_connectors {
535 super::connector::require_egress_connector_arn(&arn, &self.region)?;
536 egress.push(arn);
537 }
538
539 for (member, count) in [
541 ("ingressNetworkConnectors", ingress.len()),
542 ("egressNetworkConnectors", egress.len()),
543 ] {
544 if let Some(refusal) = super::connector::over_connector_ceiling(member, count) {
545 return Err(refusal);
546 }
547 }
548
549 let wire = ops::RunMicrovmWire {
550 image_identifier: request.image_identifier.clone(),
551 image_version: request.image_version.clone(),
552 execution_role_arn: request.execution_role_arn.clone(),
553 ingress_network_connectors: ingress,
554 egress_network_connectors: (!egress.is_empty()).then_some(egress),
559 idle_policy: ops::IdlePolicy {
560 max_idle_duration_seconds: request.max_idle_sec,
561 suspended_duration_seconds: request.suspended_sec,
562 auto_resume_enabled: request.auto_resume,
563 },
564 maximum_duration_in_seconds: request.max_duration_sec,
565 run_hook_payload: request.run_hook_payload.as_str().to_string(),
566 logging: request.logging.clone(),
567 client_token: request.client_token.unwrap_or_else(|| {
568 token::run_token(
569 request
570 .token_scope
571 .as_deref()
572 .unwrap_or(&request.image_identifier),
573 )
574 }),
575 };
576
577 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
578 let reply = send_with_retry(self.transport(), call).await?;
579 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
580 Ok(launched.into())
581 }
582
583 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
590 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
591 .await
592 }
593
594 pub async fn wait_for_launch(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
603 let started = self.clock().elapsed();
604 let mut resumed = false;
605 loop {
606 let got = self.get_microvm(id).await?;
607 match got.state.as_str() {
608 "RUNNING" => return Ok(got),
609 state if crate::constants::DEAD_STATES.contains(&state) => {
610 return Err(self.reached_terminal_state(&got, &["RUNNING"]));
611 }
612 "SUSPENDED" if !resumed => {
615 self.resume(id).await?;
616 resumed = true;
617 continue;
618 }
619 _ => {}
620 }
621
622 let elapsed = self.clock().elapsed().saturating_sub(started);
623 if elapsed >= opts.timeout {
624 return Err(timed_out(
625 &format!(
626 "microvm {id} never reached [\"RUNNING\"] (last state {})",
627 got.state
628 ),
629 elapsed,
630 ));
631 }
632 self.clock().sleep(opts.poll_interval).await;
633 }
634 }
635
636 pub async fn wait_for_state(
647 &self,
648 id: &str,
649 wanted: &[&str],
650 fail_on: &[&str],
651 opts: WaitOpts,
652 ) -> Result<Microvm, Error> {
653 let started = self.clock().elapsed();
654 loop {
655 let got = self.get_microvm(id).await?;
656
657 if wanted.contains(&got.state.as_str()) {
658 return Ok(got);
659 }
660 if fail_on.contains(&got.state.as_str()) {
661 return Err(self.reached_terminal_state(&got, wanted));
662 }
663
664 let elapsed = self.clock().elapsed().saturating_sub(started);
665 if elapsed >= opts.timeout {
666 return Err(timed_out(
667 &format!(
668 "microvm {id} never reached {wanted:?} (last state {})",
669 got.state
670 ),
671 elapsed,
672 ));
673 }
674 self.clock().sleep(opts.poll_interval).await;
675 }
676 }
677
678 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
683 Error::new(
684 ErrorKind::LaunchDied,
685 format!(
686 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
687 before RUNNING died during startup, and for a hook-serving daemon that almost \
688 always means a lifecycle hook failed — the stateReason above is the only evidence \
689 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
690 startup reports a connection error that hides the cause').",
691 got.id,
692 got.state,
693 got.reason(),
694 ),
695 )
696 }
697
698 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
705 super::require_valid_identifier("microvmIdentifier", id)?;
706 let call = Call::get("GetMicrovm", paths::microvm(id));
707 let reply = send_with_retry(self.transport(), call).await?;
708 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
709 Ok(got.into())
710 }
711
712 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
722 self.list_microvms_matching(&MicrovmFilter::default()).await
723 }
724
725 pub async fn list_microvms_matching(
727 &self,
728 filter: &MicrovmFilter,
729 ) -> Result<Vec<ops::MicrovmItemWire>, Error> {
730 if let Some(image) = filter.image_identifier.as_deref() {
731 super::require_valid_identifier("imageIdentifier", image)?;
732 }
733 if let Some(version) = filter.image_version.as_deref() {
734 super::require_valid_version("imageVersion", version)?;
735 }
736 let mut items = Vec::new();
737 let mut next_token: Option<String> = None;
738 loop {
739 let call = Call::get(
740 "ListMicrovms",
741 paths::microvms_list_matching(
742 filter.image_identifier.as_deref(),
743 filter.image_version.as_deref(),
744 next_token.as_deref(),
745 ),
746 );
747 let reply = send_with_retry(self.transport(), call).await?;
748 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
749 items.extend(page.items);
750 match page.next_token {
751 Some(token) => next_token = Some(token),
752 None => return Ok(items),
753 }
754 }
755 }
756
757 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
759 super::require_valid_identifier("microvmIdentifier", id)?;
760 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
761 send_with_retry(self.transport(), call).await?;
762 Ok(())
763 }
764
765 pub async fn resume(&self, id: &str) -> Result<(), Error> {
767 super::require_valid_identifier("microvmIdentifier", id)?;
768 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
769 send_with_retry(self.transport(), call).await?;
770 Ok(())
771 }
772
773 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
781 super::require_valid_identifier("microvmIdentifier", id)?;
782 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
783 send_with_retry(self.transport(), call).await?;
784 Ok(())
785 }
786
787 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
799 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
800 .await
801 }
802
803 pub async fn mint_auth_token_for(
816 &self,
817 id: &str,
818 ports: &[ops::PortSpecification],
819 ) -> Result<ProxyToken, Error> {
820 super::require_valid_identifier("microvmIdentifier", id)?;
821 if ports.is_empty() {
822 return Err(Error::new(
826 ErrorKind::Precondition,
827 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
828 Name the ports this token is for — the agent port plus any workload port \
829 reached through the endpoint.",
830 ));
831 }
832 for spec in ports {
840 match spec {
841 ops::PortSpecification::One { port } => {
842 super::require_valid_port("allowedPorts[].port", *port)?;
843 }
844 ops::PortSpecification::Range { range } => {
845 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
846 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
847 }
848 ops::PortSpecification::All { .. } => {}
850 }
851 }
852 let wire = ops::CreateAuthTokenWire {
853 expiration_in_minutes: MAX_TOKEN_MINUTES,
854 allowed_ports: ports.to_vec(),
855 };
856 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
857 let reply = send_with_retry(self.transport(), call).await?;
858 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
859 ProxyToken::from_map(&minted.auth_token, self.port())
860 }
861
862 pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
884 super::require_valid_identifier("microvmIdentifier", id)?;
885 let wire = ops::CreateShellAuthTokenWire {
886 expiration_in_minutes: MAX_TOKEN_MINUTES,
887 };
888 let call = Call::post_json(
889 "CreateMicrovmShellAuthToken",
890 paths::shell_auth_token(id),
891 &wire,
892 )?;
893 let reply = send_with_retry(self.transport(), call).await?;
894 let minted: ops::CreateShellAuthTokenResponseWire =
895 reply.json("CreateMicrovmShellAuthToken")?;
896 ProxyToken::from_operation_map(
897 "CreateMicrovmShellAuthToken",
898 &minted.auth_token,
899 self.port(),
900 )
901 }
902}
903
904pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
909
910#[cfg(test)]
911mod tests {
912 use std::sync::Arc;
913
914 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
915 use super::*;
916 use crate::region::Region;
917
918 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
919 let fake = Arc::new(FakeControlPlane::new());
920 let clock = Arc::new(TestClock::new());
921 let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
922 (plane, fake, clock)
923 }
924
925 #[test]
932 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
933 let at_ceiling = "a".repeat(4096);
934 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
935 assert_eq!(payload.len(), 4096);
936 assert_eq!(payload.as_str(), at_ceiling);
937
938 let over = "a".repeat(4097);
939 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
940 assert_eq!(error.kind(), ErrorKind::InvalidArg);
941 let message = error.to_string();
942 assert!(message.contains("4097 bytes"), "{message}");
943 assert!(
944 message.contains("ceiling of 4096"),
945 "the message must name the service-model ceiling: {message}"
946 );
947 assert!(message.contains("2025-09-09"), "{message}");
948 assert!(
949 message.contains("wrong by 4x"),
950 "the 16 KB claim has to be named as wrong: {message}"
951 );
952 }
953
954 #[tokio::test]
957 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
958 let (plane, fake, _) = planted();
959
960 let refused = RunHookPayload::new("a".repeat(4097));
962 assert!(refused.is_err());
963 assert_eq!(fake.calls().len(), 0);
964
965 fake.answer(
968 "RunMicrovm",
969 Answer::ok(fake::microvm_response("PENDING", None)),
970 );
971 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
972 plane
973 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
974 .await
975 .expect("launches");
976 assert_eq!(fake.call_count("RunMicrovm"), 1);
977 }
978
979 #[test]
982 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
983 let two_byte_chars = "é".repeat(2049);
984 assert_eq!(
985 two_byte_chars.chars().count(),
986 2049,
987 "well under 4096 chars"
988 );
989 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
990
991 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
992 assert!(error.to_string().contains("4098 bytes"), "{error}");
993
994 let exact = "é".repeat(2048);
996 assert_eq!(exact.len(), 4096);
997 RunHookPayload::new(exact).expect("exactly at the ceiling");
998 }
999
1000 #[test]
1009 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
1010 let env = std::collections::HashMap::new();
1011 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
1012 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
1013 assert_eq!(
1014 without.as_str(),
1015 none_asked.as_str(),
1016 "no identity must produce byte-for-byte the payload this client always sent"
1017 );
1018
1019 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
1020 let with =
1021 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
1022 assert_eq!(
1023 with.len() - without.len(),
1024 protocol::identity::IDENTITY_PAYLOAD_BYTES,
1025 "the budget constant must equal what the material really costs on the wire"
1026 );
1027
1028 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
1030 assert_eq!(
1031 parsed.identity_seed.as_deref(),
1032 Some(identity.seed_field().as_str())
1033 );
1034 assert_eq!(
1035 parsed.identity_host_public_key.as_deref(),
1036 Some(identity.host_public_field().as_str())
1037 );
1038 }
1039
1040 #[test]
1043 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
1044 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
1045 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
1046
1047 let huge = "x".repeat(4096);
1050 let error = RunHookPayload::for_agent_token(&huge)
1051 .expect_err("the JSON wrapper pushes it over the ceiling");
1052 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1053 assert!(
1054 error.to_string().contains("one bearer token fits"),
1055 "{error}"
1056 );
1057 }
1058
1059 #[test]
1065 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
1066 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
1067 .expect("an empty env fits");
1068 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
1069 assert_eq!(with_empty.as_str(), token_only.as_str());
1070 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
1071 assert!(
1072 !with_empty.as_str().contains("env"),
1073 "an empty env must not appear on the wire at all: {}",
1074 with_empty.as_str()
1075 );
1076 }
1077
1078 #[test]
1080 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
1081 let mut env = std::collections::HashMap::new();
1082 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
1083 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
1084
1085 let value: serde_json::Value =
1087 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
1088 assert_eq!(value["agent_token"], "tok");
1089 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
1090 }
1091
1092 #[test]
1110 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
1111 let mut env = std::collections::HashMap::new();
1112 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
1114
1115 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1119 .to_string()
1120 .len();
1121 assert!(
1122 expected > 4096,
1123 "the fixture must actually be over: {expected}"
1124 );
1125
1126 let error = RunHookPayload::for_launch("tok", &env)
1127 .expect_err("a credential-scale launch env does not fit");
1128 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1129 let message = error.to_string();
1130
1131 assert!(
1132 message.contains("ceiling of 4096"),
1133 "the ceiling has to be named: {message}"
1134 );
1135 assert!(
1136 message.contains(&format!("{expected} bytes")),
1137 "the actual byte count has to be named: {message}"
1138 );
1139 assert!(
1140 message.contains("launch env contributed"),
1141 "the env's share of the budget is the actionable half: {message}"
1142 );
1143 assert!(
1144 message.contains("1 variable(s)"),
1145 "the variable count has to be named: {message}"
1146 );
1147 }
1148
1149 #[test]
1156 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1157 let mut env = std::collections::HashMap::new();
1158 env.insert("A".to_string(), "a".repeat(2030));
1159 env.insert("B".to_string(), "b".repeat(2030));
1160 let raw: usize = env.values().map(String::len).sum();
1161 assert_eq!(raw, 4060, "the raw values are under the ceiling");
1162 assert!(
1163 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1164 "the fixture only means something if the raw sum fits: {raw}"
1165 );
1166
1167 let error = RunHookPayload::for_launch("tok", &env)
1168 .expect_err("the serialized payload is over even though the raw values are not");
1169 assert!(error.to_string().contains("2 variable(s)"), "{error}");
1170
1171 let mut fits = std::collections::HashMap::new();
1174 fits.insert("A".to_string(), "a".repeat(2000));
1175 fits.insert("B".to_string(), "b".repeat(2000));
1176 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1177 assert!(payload.len() <= 4096, "{}", payload.len());
1178 }
1179
1180 #[test]
1182 fn an_empty_payload_is_legal() {
1183 let payload = RunHookPayload::new("").expect("min is 0");
1184 assert!(payload.is_empty());
1185 assert_eq!(payload.len(), 0);
1186 }
1187
1188 #[tokio::test]
1191 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1192 let (plane, fake, _) = planted();
1193 fake.answer(
1194 "RunMicrovm",
1195 Answer::ok(fake::microvm_response("PENDING", None)),
1196 );
1197
1198 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1199 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1200 plane.run_microvm(request).await.expect("launches");
1201
1202 let calls = fake.calls();
1203 assert_eq!(calls[0].path, "/2025-09-09/microvms");
1204 assert_eq!(calls[0].method, super::super::transport::Method::Post);
1205
1206 let body = fake.first_body("RunMicrovm");
1207 assert_eq!(
1208 body["ingressNetworkConnectors"],
1209 serde_json::json!([
1210 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1211 ])
1212 );
1213 assert_eq!(
1214 body["egressNetworkConnectors"],
1215 serde_json::json!([
1216 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1217 ])
1218 );
1219 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1220 assert_eq!(body["maximumDurationInSeconds"], 3600);
1221 }
1222
1223 #[tokio::test]
1228 async fn a_launch_without_egress_omits_the_member_entirely() {
1229 let (plane, fake, _) = planted();
1230 fake.answer(
1231 "RunMicrovm",
1232 Answer::ok(fake::microvm_response("PENDING", None)),
1233 );
1234 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1235 plane
1236 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1237 .await
1238 .expect("launches");
1239
1240 let body = fake.first_body("RunMicrovm");
1241 assert!(
1242 body.get("egressNetworkConnectors").is_none(),
1243 "omitting egress is the whole mechanism: {body}"
1244 );
1245 assert!(body.get("ingressNetworkConnectors").is_some());
1246 }
1247
1248 #[tokio::test]
1249 async fn customer_vpc_connector_arns_reach_the_egress_member_unchanged() {
1250 let (plane, fake, _) = planted();
1251 fake.answer(
1252 "RunMicrovm",
1253 Answer::ok(fake::microvm_response("PENDING", None)),
1254 );
1255 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1256 let mut request = RunMicrovmRequest::new("arn:image", payload);
1257 let arns: Vec<String> = (0..10)
1258 .map(|id| format!("arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-{id}:1"))
1259 .collect();
1260 request.egress_network_connectors = arns.clone();
1261 plane
1262 .run_microvm(request)
1263 .await
1264 .expect("ten egress connectors plus ingress fits");
1265 let body = fake.first_body("RunMicrovm");
1266 assert_eq!(body["egressNetworkConnectors"], serde_json::json!(arns));
1267 assert_eq!(
1268 body["ingressNetworkConnectors"].as_array().unwrap().len(),
1269 1
1270 );
1271 }
1272
1273 #[tokio::test]
1274 async fn invalid_customer_egress_connectors_are_refused_before_launch() {
1275 let valid = "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test";
1276 let invalid = [
1277 "VPC_EGRESS",
1278 "arn:aws:lambda:us-west-2:123456789012:network-connector:vpc-test",
1279 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS",
1280 "arn:aws:lambda:us-east-1:123456789012:network-connector:",
1281 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test:0",
1282 "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test/other",
1283 ];
1284 for arn in invalid {
1285 let (plane, fake, _) = planted();
1286 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1287 let error = plane
1288 .run_microvm(
1289 RunMicrovmRequest::new("arn:image", payload).with_egress_network_connector(arn),
1290 )
1291 .await
1292 .expect_err(arn);
1293 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1294 assert!(
1295 error.to_string().contains("egressNetworkConnectors"),
1296 "{error}"
1297 );
1298 assert!(fake.calls().is_empty());
1299 }
1300 for (internet, count) in [(true, 1), (false, 11)] {
1301 let (plane, fake, _) = planted();
1302 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1303 let mut request = RunMicrovmRequest::new("arn:image", payload);
1304 request.egress_network_connectors = vec![valid.to_string(); count];
1305 if internet {
1306 request = request.with_egress();
1307 }
1308 let error = plane
1309 .run_microvm(request)
1310 .await
1311 .expect_err("invalid routing request");
1312 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1313 assert!(fake.calls().is_empty());
1314 }
1315 }
1316
1317 #[tokio::test]
1330 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1331 let (plane, fake, _) = planted();
1332 fake.answer(
1333 "RunMicrovm",
1334 Answer::ok(fake::microvm_response("PENDING", None)),
1335 );
1336 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1337 plane
1338 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1339 .await
1340 .expect("launches");
1341
1342 let body = fake.first_body("RunMicrovm");
1343 assert_eq!(body["imageVersion"], "2.0");
1344 assert_eq!(
1345 body["imageIdentifier"], "arn:image",
1346 "pinning a version does not replace the identifier; both are sent"
1347 );
1348
1349 let (plane, fake, _) = planted();
1350 fake.answer(
1351 "RunMicrovm",
1352 Answer::ok(fake::microvm_response("PENDING", None)),
1353 );
1354 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1355 plane
1356 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1357 .await
1358 .expect("launches");
1359 assert!(
1360 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1361 "an unpinned launch must send what this client always sent: {}",
1362 fake.first_body("RunMicrovm")
1363 );
1364 }
1365
1366 #[tokio::test]
1376 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1377 for bad in ["", "2.0\n", "a b"] {
1378 let (plane, fake, _) = planted();
1379 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1380 let error = plane
1381 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1382 .await
1383 .expect_err(&format!("{bad:?} is not a legal Version"));
1384 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1385 assert!(
1386 error.to_string().contains("imageVersion"),
1387 "{bad:?}: {error}"
1388 );
1389 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1390 }
1391 }
1392
1393 #[tokio::test]
1397 async fn no_bare_connector_name_reaches_the_wire() {
1398 let (plane, fake, _) = planted();
1399 fake.answer(
1400 "RunMicrovm",
1401 Answer::ok(fake::microvm_response("PENDING", None)),
1402 );
1403 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1404 plane
1405 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1406 .await
1407 .expect("launches");
1408
1409 let body = fake.first_body("RunMicrovm");
1410 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1411 for value in body[member].as_array().expect("a list") {
1412 let text = value.as_str().expect("a string");
1413 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1414 assert_ne!(text, "ALL_INGRESS");
1415 assert_ne!(text, "INTERNET_EGRESS");
1416 }
1417 }
1418 }
1419
1420 #[tokio::test]
1422 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1423 let (plane, fake, _) = planted();
1424 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1425 let mut request = RunMicrovmRequest::new("arn:image", payload);
1426 request.max_duration_sec = 28_801;
1427
1428 let error = plane
1429 .run_microvm(request)
1430 .await
1431 .expect_err("over the ceiling");
1432 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1433 assert_eq!(fake.calls().len(), 0);
1434 }
1435
1436 #[tokio::test]
1446 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1447 let (plane, fake, _) = planted();
1448 fake.answer(
1449 "GetMicrovm",
1450 Answer::ok(fake::microvm_response(
1451 "TERMINATED",
1452 Some("run hook returned 500"),
1453 )),
1454 );
1455
1456 let error = plane
1457 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1458 .await
1459 .expect_err("a dead VM must not be waited out");
1460
1461 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1462 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1463 let message = error.to_string();
1464 assert!(
1465 message.contains("TERMINATED"),
1466 "the state must be attached: {message}"
1467 );
1468 assert!(
1469 message.contains("run hook returned 500"),
1470 "the stateReason must be attached: {message}"
1471 );
1472 assert!(
1473 message.contains("lifecycle hook failed"),
1474 "the likely cause is worth naming: {message}"
1475 );
1476 assert!(
1477 fake.call_count("GetMicrovm") == 1,
1478 "it fails fast rather than polling to the deadline"
1479 );
1480 }
1481
1482 #[tokio::test]
1489 async fn every_terminal_state_fails_a_launch_fast() {
1490 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1491 for state in crate::constants::TERMINAL_STATES {
1492 let (plane, fake, _) = planted();
1493 fake.answer(
1494 "GetMicrovm",
1495 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1496 );
1497 let error = plane
1498 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1499 .await
1500 .expect_err(&format!("{state} before RUNNING is a death"));
1501 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1502 assert!(error.to_string().contains(state), "{state}: {error}");
1503 assert!(
1504 error.to_string().contains("hook timeout"),
1505 "{state}: {error}"
1506 );
1507 assert_eq!(
1508 fake.call_count("GetMicrovm"),
1509 1,
1510 "{state} must fail fast rather than polling to the deadline"
1511 );
1512 }
1513 }
1514
1515 #[tokio::test]
1519 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1520 let (plane, fake, _) = planted();
1521 fake.answer(
1522 "GetMicrovm",
1523 Answer::ok(fake::microvm_response("TERMINATED", None)),
1524 );
1525
1526 let error = plane
1527 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1528 .await
1529 .expect_err("still a death");
1530 let message = error.to_string();
1531 assert!(message.contains("no stateReason"), "{message}");
1532 assert!(
1536 !message.contains(r#"["RUNNING"]: ."#),
1537 "an absent reason must not render as an empty string: {message}"
1538 );
1539 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1540 }
1541
1542 #[tokio::test]
1544 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1545 let (plane, fake, _) = planted();
1546 fake.answer(
1547 "GetMicrovm",
1548 Answer::ok(fake::microvm_response("PENDING", None)),
1549 )
1550 .answer(
1551 "GetMicrovm",
1552 Answer::ok(fake::microvm_response("RUNNING", None)),
1553 );
1554
1555 let vm = plane
1556 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1557 .await
1558 .expect("reaches RUNNING");
1559 assert_eq!(vm.state, "RUNNING");
1560 assert_eq!(
1561 vm.endpoint,
1562 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1563 );
1564 assert_eq!(fake.call_count("GetMicrovm"), 2);
1565 }
1566
1567 #[tokio::test]
1571 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1572 let (plane, fake, _) = planted();
1573 fake.answer(
1574 "GetMicrovm",
1575 Answer::ok(fake::microvm_response("PENDING", None)),
1576 );
1577
1578 let error = plane
1579 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1580 .await
1581 .expect_err("the deadline elapses");
1582 assert_eq!(error.kind(), ErrorKind::Timeout);
1583 assert_ne!(
1584 error.kind(),
1585 ErrorKind::LaunchDied,
1586 "a timeout is not a death, and conflating them is what hid the cause"
1587 );
1588 assert!(error.to_string().contains("PENDING"), "{error}");
1589 }
1590
1591 #[tokio::test]
1594 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1595 let (plane, fake, _) = planted();
1596 fake.answer(
1597 "GetMicrovm",
1598 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1599 )
1600 .answer(
1601 "GetMicrovm",
1602 Answer::ok(fake::microvm_response("RUNNING", None)),
1603 );
1604
1605 let vm = plane
1606 .wait_for_state(
1607 "mvm-abc123",
1608 &["RUNNING"],
1609 &crate::constants::DEAD_STATES,
1610 WaitOpts::for_launch(),
1611 )
1612 .await
1613 .expect("SUSPENDED is a waypoint on the resume path");
1614 assert_eq!(vm.state, "RUNNING");
1615
1616 let (plane, fake, _) = planted();
1617 fake.answer(
1618 "GetMicrovm",
1619 Answer::ok(fake::microvm_response(
1620 "TERMINATED",
1621 Some("suspended window elapsed"),
1622 )),
1623 );
1624 let error = plane
1625 .wait_for_state(
1626 "mvm-abc123",
1627 &["RUNNING"],
1628 &crate::constants::DEAD_STATES,
1629 WaitOpts::for_launch(),
1630 )
1631 .await
1632 .expect_err("a terminated VM never reaches RUNNING");
1633 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1634 assert!(
1635 error.to_string().contains("suspended window elapsed"),
1636 "{error}"
1637 );
1638 }
1639
1640 #[tokio::test]
1643 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1644 let (plane, fake, _) = planted();
1645 fake.answer(
1646 "GetMicrovm",
1647 Answer::ok(fake::microvm_response("TERMINATED", None)),
1648 );
1649
1650 let vm = plane
1651 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1652 .await
1653 .expect("a suspend reports the state it reached");
1654 assert_eq!(vm.state, "TERMINATED");
1655 }
1656
1657 #[tokio::test]
1660 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1661 let (plane, fake, _) = planted();
1662 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1663 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1664 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1665
1666 plane.suspend("mvm-1").await.expect("suspends");
1667 plane.resume("mvm-1").await.expect("resumes");
1668 plane.terminate("mvm-1").await.expect("terminates");
1669
1670 let calls = fake.calls();
1671 use super::super::transport::Method;
1672 assert_eq!(
1673 calls
1674 .iter()
1675 .map(|call| (call.operation, call.method, call.path.as_str()))
1676 .collect::<Vec<_>>(),
1677 [
1678 (
1679 "SuspendMicrovm",
1680 Method::Post,
1681 "/2025-09-09/microvms/mvm-1/suspend"
1682 ),
1683 (
1684 "ResumeMicrovm",
1685 Method::Post,
1686 "/2025-09-09/microvms/mvm-1/resume"
1687 ),
1688 (
1689 "TerminateMicrovm",
1690 Method::Delete,
1691 "/2025-09-09/microvms/mvm-1"
1692 ),
1693 ]
1694 );
1695 }
1696
1697 #[tokio::test]
1700 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1701 let (plane, fake, _) = planted();
1702 fake.answer(
1703 "CreateMicrovmAuthToken",
1704 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1705 );
1706
1707 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1708 assert_eq!(token.value(), "opaque-proxy-token");
1709 assert_eq!(token.port(), 9000);
1710
1711 let headers = token.headers();
1712 assert_eq!(
1713 headers[0],
1714 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1715 );
1716 assert_eq!(
1717 headers[1],
1718 ("X-aws-proxy-port", "9000".to_string()),
1719 "the auth header without the port header is rejected in a way that reads like a \
1720 bad token"
1721 );
1722
1723 let body = fake.first_body("CreateMicrovmAuthToken");
1724 assert_eq!(body["expirationInMinutes"], 60);
1725 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1726 }
1727
1728 #[tokio::test]
1740 async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
1741 let (plane, fake, _) = planted();
1742 fake.answer(
1743 "CreateMicrovmShellAuthToken",
1744 Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
1745 );
1746
1747 let token = plane
1748 .mint_shell_auth_token("mvm-1")
1749 .await
1750 .expect("mints a shell token");
1751 assert_eq!(token.value(), "opaque-shell-jwe");
1752
1753 let calls = fake.calls();
1754 assert_eq!(calls.len(), 1);
1755 assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
1756 assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
1757
1758 let body = fake.first_body("CreateMicrovmShellAuthToken");
1759 assert_eq!(body["expirationInMinutes"], 60);
1760 assert_eq!(
1761 body.get("allowedPorts"),
1762 None,
1763 "the shell is not a port: the request declares no allowedPorts member"
1764 );
1765 assert_eq!(
1766 body.as_object().map(serde_json::Map::len),
1767 Some(1),
1768 "expirationInMinutes is the body's only member: {body}"
1769 );
1770 }
1771
1772 #[tokio::test]
1776 async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
1777 let (plane, fake, _) = planted();
1778 let error = plane
1779 .mint_shell_auth_token("")
1780 .await
1781 .expect_err("an empty identifier is refused");
1782 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1783 assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
1784 }
1785
1786 #[test]
1794 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1795 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1796
1797 let rendered = format!("{payload:?}");
1798 assert!(
1799 !rendered.contains("SECRETTOKEN"),
1800 "the agent token reached a Debug string: {rendered}"
1801 );
1802 assert!(
1803 rendered.contains(&format!("{} bytes", payload.len())),
1804 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1805 );
1806
1807 let request = RunMicrovmRequest::new("arn:image", payload);
1808 let rendered = format!("{request:?}");
1809 assert!(rendered.contains("arn:image"), "{rendered}");
1810 assert!(
1811 !rendered.contains("SECRETTOKEN"),
1812 "the agent token reached a Debug string through the request: {rendered}"
1813 );
1814 }
1815
1816 #[test]
1823 fn a_proxy_token_debug_does_not_print_the_credential() {
1824 let mut map = std::collections::BTreeMap::new();
1825 map.insert(
1826 PROXY_AUTH_HEADER.to_string(),
1827 "eyJhbGciOi-secret".to_string(),
1828 );
1829 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1830
1831 let rendered = format!("{token:?}");
1832 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1833 assert!(rendered.contains("9000"), "{rendered}");
1834 assert!(
1835 !rendered.contains("secret"),
1836 "the token value reached a Debug string: {rendered}"
1837 );
1838 }
1839
1840 #[test]
1843 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1844 let mut map = std::collections::BTreeMap::new();
1845 map.insert("SomeOtherKey".to_string(), "value".to_string());
1846
1847 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1848 assert_eq!(
1849 error.wire_kind(),
1850 Some(crate::error::WireKind::AuthTokenMint)
1851 );
1852 assert!(error.retryable(), "minting is inside the retry path");
1853 assert!(
1854 error.to_string().contains("is a map, not a string"),
1855 "{error}"
1856 );
1857 }
1858
1859 #[tokio::test]
1861 async fn the_token_is_minted_for_the_configured_port() {
1862 let fake = Arc::new(FakeControlPlane::new());
1863 let plane =
1864 ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()))
1865 .with_port(8080)
1866 .expect("8080 is a legal port");
1867 fake.answer(
1868 "CreateMicrovmAuthToken",
1869 Answer::ok(fake::auth_token_response("t")),
1870 );
1871
1872 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1873 assert_eq!(token.port(), 8080);
1874 assert_eq!(
1875 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1876 8080
1877 );
1878 }
1879
1880 #[tokio::test]
1904 async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
1905 let (plane, fake, _) = planted();
1906 fake.answer(
1907 "CreateMicrovmImage",
1908 Answer::created(fake::create_image_response("img")),
1909 )
1910 .answer(
1911 "GetMicrovmImage",
1912 Answer::ok(fake::get_image_response("img", "CREATED")),
1913 )
1914 .answer(
1915 "RunMicrovm",
1916 Answer::ok(fake::microvm_response("PENDING", None)),
1917 )
1918 .answer(
1919 "GetMicrovm",
1920 Answer::ok(fake::microvm_response("RUNNING", None)),
1921 )
1922 .answer(
1923 "CreateMicrovmAuthToken",
1924 Answer::ok(fake::auth_token_response("proxy-token")),
1925 )
1926 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1927 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1928 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1929 .answer(
1930 "ListMicrovmImageVersions",
1931 Answer::ok(fake::list_versions_response("1")),
1932 )
1933 .answer(
1934 "DeleteMicrovmImage",
1935 Answer::ok(fake::delete_image_response()),
1936 );
1937
1938 let request = super::super::CreateImageRequest::new(
1940 "img",
1941 b"binary".to_vec(),
1942 "s3://bucket/img.zip",
1943 "arn:aws:iam::123456789012:role/build",
1944 );
1945 let image = plane.create_image(request).await.expect("creates");
1946 plane
1947 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1948 .await
1949 .expect("becomes usable");
1950
1951 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1952 let vm = plane
1953 .run_microvm(
1954 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1955 )
1956 .await
1957 .expect("launches");
1958 plane
1959 .wait_for_running(&vm.id, WaitOpts::for_launch())
1960 .await
1961 .expect("reaches RUNNING");
1962 plane.mint_auth_token(&vm.id).await.expect("mints");
1963 plane.suspend(&vm.id).await.expect("suspends");
1964 plane.resume(&vm.id).await.expect("resumes");
1965 plane.terminate(&vm.id).await.expect("terminates");
1966 plane
1967 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
1968 .await;
1969
1970 let operations = fake.operations();
1973 assert!(
1974 operations.len() >= 10,
1975 "the lifecycle must actually have run: {operations:?}"
1976 );
1977 assert!(operations.contains(&"CreateMicrovmAuthToken"));
1978
1979 assert_eq!(
1980 fake.call_count("CreateMicrovmShellAuthToken"),
1981 0,
1982 "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
1983 for no shell must mint no shell token: {operations:?}"
1984 );
1985 for operation in &operations {
1986 assert!(
1987 !operation.contains("Shell"),
1988 "no operation may be a shell one: {operation}"
1989 );
1990 }
1991 for path in fake.paths() {
1992 assert!(
1993 !path.contains("shell"),
1994 "no request may reach a shell route: {path}"
1995 );
1996 }
1997
1998 let launch = fake.first_body("RunMicrovm");
2004 assert_eq!(
2005 launch["ingressNetworkConnectors"],
2006 serde_json::json!([
2007 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
2008 ]),
2009 "ingress is exactly what the caller asked for"
2010 );
2011 assert_eq!(
2012 launch["egressNetworkConnectors"],
2013 serde_json::json!([
2014 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
2015 ]),
2016 "egress is exactly what the caller asked for"
2017 );
2018 }
2019
2020 #[tokio::test]
2026 async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
2027 let (plane, fake, _) = planted();
2028 fake.answer(
2029 "RunMicrovm",
2030 Answer::ok(fake::microvm_response("PENDING", None)),
2031 );
2032
2033 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2034 let mut request = RunMicrovmRequest::new("arn:image", payload);
2035 request.connectors = vec![
2036 super::super::ConnectorIntent::HttpIngress,
2037 super::super::ConnectorIntent::ShellIngress,
2038 ];
2039 plane.run_microvm(request).await.expect("launches");
2040
2041 let launch = fake.first_body("RunMicrovm");
2042 assert_eq!(
2043 launch["ingressNetworkConnectors"],
2044 serde_json::json!([
2045 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
2046 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
2047 ]),
2048 "both fine-grained ingress connectors reach the wire, in the order asked"
2049 );
2050 assert_eq!(
2051 launch.get("egressNetworkConnectors"),
2052 None,
2053 "no egress was asked for, so the member is absent rather than empty"
2054 );
2055 }
2056
2057 #[tokio::test]
2066 async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
2067 let (plane, fake, _) = planted();
2068
2069 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2070 let mut request = RunMicrovmRequest::new("arn:image", payload);
2071 request
2072 .connectors
2073 .push(super::super::ConnectorIntent::ShellIngress);
2074 let error = plane
2075 .run_microvm(request)
2076 .await
2077 .expect_err("the invalid combination is refused locally");
2078
2079 assert!(
2080 error
2081 .to_string()
2082 .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
2083 "the refusal names the pair that works: {error}"
2084 );
2085 assert_eq!(
2086 fake.call_count("RunMicrovm"),
2087 0,
2088 "nothing launched, so nothing billed"
2089 );
2090 }
2091
2092 #[tokio::test]
2103 async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
2104 let (plane, fake, _) = planted();
2105
2106 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2107 let mut request = RunMicrovmRequest::new("arn:image", payload);
2108 request.connectors = vec![
2109 super::super::ConnectorIntent::ShellIngress,
2110 super::super::ConnectorIntent::Egress,
2111 ];
2112 let error = plane
2113 .run_microvm(request)
2114 .await
2115 .expect_err("shell ingress without HTTP ingress is refused locally");
2116
2117 assert!(
2118 error.to_string().contains("HTTP_INGRESS"),
2119 "the refusal names the missing connector: {error}"
2120 );
2121 assert_eq!(
2122 fake.call_count("RunMicrovm"),
2123 0,
2124 "nothing launched, so nothing billed"
2125 );
2126 }
2127
2128 #[tokio::test]
2141 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
2142 let (plane, fake, _) = planted();
2143 fake.answer(
2144 "ListMicrovms",
2145 Answer::ok(fake::list_microvms_page(
2146 &["mvm-page1-a", "mvm-page1-b"],
2147 Some("fleet-page-2"),
2148 )),
2149 )
2150 .answer(
2151 "ListMicrovms",
2152 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2153 );
2154
2155 let fleet = plane.list_microvms().await.expect("lists");
2156 assert_eq!(
2157 fleet.len(),
2158 3,
2159 "a fleet spanning two pages is three VMs, not two"
2160 );
2161
2162 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2163 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
2164 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
2165 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2166
2167 let paths = fake.paths();
2168 assert!(
2169 paths[1].contains("nextToken=fleet-page-2"),
2170 "the second request must carry the first page's token: {}",
2171 paths[1]
2172 );
2173 assert!(
2174 !paths[0].contains('?'),
2175 "the first request carries no cursor: {}",
2176 paths[0]
2177 );
2178 }
2179
2180 #[tokio::test]
2190 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2191 let (plane, fake, _) = planted();
2192 fake.answer(
2193 "GetMicrovm",
2194 Answer::ok(fake::microvm_response("RUNNING", None)),
2195 );
2196
2197 let vm = plane.get_microvm("mvm-1").await.expect("reads");
2198 let policy = vm
2199 .idle_policy
2200 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2201 assert_eq!(policy.max_idle_duration_seconds, 1800);
2202 assert_eq!(
2203 policy.suspended_duration_seconds, 600,
2204 "the member the comment claimed was request-only"
2205 );
2206 assert!(!policy.auto_resume_enabled);
2207 }
2208
2209 #[tokio::test]
2213 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2214 let (plane, fake, _) = planted();
2215 fake.answer(
2216 "GetMicrovm",
2217 Answer::ok(
2218 r#"{
2219 "microvmId": "mvm-1",
2220 "state": "RUNNING",
2221 "endpoint": "https://e",
2222 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2223 "imageVersion": "1"
2224 }"#,
2225 ),
2226 );
2227
2228 let vm = plane.get_microvm("mvm-1").await.expect("parses");
2229 assert_eq!(vm.idle_policy, None);
2230 }
2231
2232 #[tokio::test]
2235 async fn a_transport_failure_is_retried_rather_than_reported() {
2236 let (plane, fake, _) = planted();
2237 fake.fail_transport("GetMicrovm", 2).answer(
2238 "GetMicrovm",
2239 Answer::ok(fake::microvm_response("RUNNING", None)),
2240 );
2241
2242 let vm = plane
2243 .get_microvm("mvm-1")
2244 .await
2245 .expect("the retry succeeds");
2246 assert_eq!(vm.state, "RUNNING");
2247 assert_eq!(
2248 fake.call_count("GetMicrovm"),
2249 3,
2250 "two failures then a success"
2251 );
2252 }
2253
2254 #[tokio::test]
2271 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2272 for under in [0, 1, 59] {
2273 let (plane, fake, _) = planted();
2274 fake.answer(
2275 "RunMicrovm",
2276 Answer::ok(fake::microvm_response("PENDING", None)),
2277 );
2278
2279 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2280 let mut request = RunMicrovmRequest::new("arn:image", payload);
2281 request.max_idle_sec = under;
2282
2283 let error = plane
2284 .run_microvm(request)
2285 .await
2286 .expect_err("the model's min is 60");
2287 assert_eq!(error.kind(), ErrorKind::InvalidArg);
2288 let message = error.to_string();
2289 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2290 assert!(message.contains(&under.to_string()), "{message}");
2291 assert_eq!(
2292 fake.calls().len(),
2293 0,
2294 "{under} was refused locally; before this guard it was serialized and sent"
2295 );
2296 }
2297
2298 let (plane, fake, _) = planted();
2301 fake.answer(
2302 "RunMicrovm",
2303 Answer::ok(fake::microvm_response("PENDING", None)),
2304 );
2305 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2306 let mut request = RunMicrovmRequest::new("arn:image", payload);
2307 request.max_idle_sec = 60;
2308 plane.run_microvm(request).await.expect("60 is the minimum");
2309 assert_eq!(
2310 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2311 60
2312 );
2313 }
2314
2315 #[tokio::test]
2325 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2326 type Mutate = fn(&mut RunMicrovmRequest);
2329 let rows: [(&str, Mutate, &str); 6] = [
2330 (
2331 "an execution role that is a bare name",
2332 |request| {
2333 request.execution_role_arn = Some("execution-role".to_string());
2334 },
2335 "role *name*",
2336 ),
2337 (
2338 "an execution role with eleven account digits",
2339 |request| {
2340 request.execution_role_arn =
2341 Some("arn:aws:iam::12345678901:role/exec".to_string());
2342 },
2343 "exactly twelve digits",
2344 ),
2345 (
2346 "an execution role that is a function ARN",
2347 |request| {
2348 request.execution_role_arn =
2349 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2350 },
2351 "RoleArn pattern",
2352 ),
2353 (
2354 "an empty image identifier",
2355 |request| {
2356 request.image_identifier = String::new();
2357 },
2358 "collapses the path",
2359 ),
2360 (
2361 "a 257-character image identifier",
2362 |request| {
2363 request.image_identifier = "a".repeat(257);
2364 },
2365 "MicrovmImageArn permits 2048",
2366 ),
2367 (
2368 "an image version with a newline",
2369 |request| {
2370 request.image_version = Some("2.0\n".to_string());
2371 },
2372 "contains whitespace",
2373 ),
2374 ];
2375
2376 for (label, mutate, expected) in rows {
2377 let (plane, fake, _) = planted();
2378 fake.answer(
2379 "RunMicrovm",
2380 Answer::ok(fake::microvm_response("PENDING", None)),
2381 );
2382
2383 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2384 let mut request = RunMicrovmRequest::new("arn:image", payload);
2385 mutate(&mut request);
2386
2387 let error = plane
2388 .run_microvm(request)
2389 .await
2390 .expect_err(&format!("{label} must be refused"));
2391 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2392 assert!(
2393 error.to_string().contains(expected),
2394 "{label}: wanted {expected:?}, got {error}"
2395 );
2396 assert_eq!(fake.calls().len(), 0, "{label}");
2397 }
2398
2399 let (plane, fake, _) = planted();
2402 fake.answer(
2403 "RunMicrovm",
2404 Answer::ok(fake::microvm_response("PENDING", None)),
2405 );
2406 let payload = RunHookPayload::for_agent_token("token").expect("fits");
2407 let mut request = RunMicrovmRequest::new(
2408 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2409 payload,
2410 );
2411 request.execution_role_arn =
2412 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2413 request.image_version = Some("1.0".to_string());
2414 plane
2415 .run_microvm(request)
2416 .await
2417 .expect("a realistic pinned launch with a real execution role");
2418 assert_eq!(fake.call_count("RunMicrovm"), 1);
2419 }
2420
2421 #[tokio::test]
2431 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2432 for bad in ["", &"a".repeat(257)] {
2433 let (plane, fake, _) = planted();
2434 fake.answer(
2437 "GetMicrovm",
2438 Answer::ok(fake::microvm_response("RUNNING", None)),
2439 );
2440 fake.answer("SuspendMicrovm", Answer::ok("{}"));
2441 fake.answer("ResumeMicrovm", Answer::ok("{}"));
2442 fake.answer("TerminateMicrovm", Answer::ok("{}"));
2443 fake.answer(
2444 "CreateMicrovmAuthToken",
2445 Answer::ok(fake::auth_token_response("t")),
2446 );
2447
2448 plane.get_microvm(bad).await.expect_err("GetMicrovm");
2449 plane.suspend(bad).await.expect_err("SuspendMicrovm");
2450 plane.resume(bad).await.expect_err("ResumeMicrovm");
2451 plane.terminate(bad).await.expect_err("TerminateMicrovm");
2452 plane
2453 .mint_auth_token(bad)
2454 .await
2455 .expect_err("CreateMicrovmAuthToken");
2456 plane
2457 .wait_for_running(bad, WaitOpts::for_launch())
2458 .await
2459 .expect_err("GetMicrovm, in a loop");
2460
2461 assert_eq!(
2462 fake.calls().len(),
2463 0,
2464 "six operations refused {bad:?} locally. An empty identifier on the terminate \
2465 path is a DELETE addressed at the collection."
2466 );
2467 }
2468 }
2469
2470 #[tokio::test]
2485 async fn no_proxy_token_is_minted_for_port_zero() {
2486 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2487 (
2488 "a single port of 0",
2489 ops::PortSpecification::port(0),
2490 "allowedPorts[].port",
2491 ),
2492 (
2493 "a range starting at 0",
2494 ops::PortSpecification::range(0, 9000),
2495 "allowedPorts[].range.startPort",
2496 ),
2497 (
2498 "a range ending at 0",
2499 ops::PortSpecification::range(0, 0),
2500 "allowedPorts[].range.startPort",
2501 ),
2502 ];
2503
2504 for (label, spec, member) in rows {
2505 let (plane, fake, _) = planted();
2506 fake.answer(
2507 "CreateMicrovmAuthToken",
2508 Answer::ok(fake::auth_token_response("t")),
2509 );
2510
2511 let error = plane
2512 .mint_auth_token_for("mvm-1", &[spec])
2513 .await
2514 .expect_err(&format!("{label} must be refused"));
2515 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2516 let message = error.to_string();
2517 assert!(message.contains(member), "{label}: {message}");
2518 assert!(
2519 message.contains("authorizes nothing"),
2520 "{label}: the 403 consequence has to be in the message: {message}"
2521 );
2522 assert_eq!(fake.calls().len(), 0, "{label}");
2523 }
2524
2525 let (plane, fake, _) = planted();
2529 fake.answer(
2530 "CreateMicrovmAuthToken",
2531 Answer::ok(fake::auth_token_response("t")),
2532 );
2533 plane
2534 .mint_auth_token_for(
2535 "mvm-1",
2536 &[
2537 ops::PortSpecification::port(9000),
2538 ops::PortSpecification::port(0),
2539 ],
2540 )
2541 .await
2542 .expect_err("every entry is checked, not the first");
2543 assert_eq!(fake.calls().len(), 0);
2544
2545 for legal in [
2548 ops::PortSpecification::port(1),
2549 ops::PortSpecification::port(65_535),
2550 ops::PortSpecification::range(1, 65_535),
2551 ops::PortSpecification::all(),
2552 ] {
2553 let (plane, fake, _) = planted();
2554 fake.answer(
2555 "CreateMicrovmAuthToken",
2556 Answer::ok(fake::auth_token_response("t")),
2557 );
2558 plane
2559 .mint_auth_token_for("mvm-1", &[legal])
2560 .await
2561 .expect("a legal port specification mints");
2562 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2563 }
2564 }
2565
2566 #[tokio::test]
2572 async fn an_adopted_launch_that_finds_its_vm_suspended_resumes_it() {
2573 for first in ["SUSPENDING", "SUSPENDED"] {
2574 let (plane, fake, _) = planted();
2575 fake.answer(
2576 "GetMicrovm",
2577 Answer::ok(fake::microvm_response(first, None)),
2578 )
2579 .answer(
2580 "GetMicrovm",
2581 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2582 )
2583 .answer(
2584 "GetMicrovm",
2585 Answer::ok(fake::microvm_response("SUSPENDED", None)),
2586 )
2587 .answer(
2588 "GetMicrovm",
2589 Answer::ok(fake::microvm_response("RUNNING", None)),
2590 )
2591 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()));
2592 let running = plane
2593 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2594 .await
2595 .unwrap_or_else(|error| panic!("{first}: {error}"));
2596 assert_eq!(running.state, "RUNNING", "{first}");
2597 assert_eq!(
2598 fake.call_count("ResumeMicrovm"),
2599 1,
2600 "{first}: one resume, even though GetMicrovm still read SUSPENDED after it"
2601 );
2602 }
2603 }
2604
2605 #[tokio::test]
2607 async fn an_adopted_launch_still_fails_fast_on_a_dead_state() {
2608 for state in crate::constants::DEAD_STATES {
2609 let (plane, fake, _) = planted();
2610 fake.answer(
2611 "GetMicrovm",
2612 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
2613 );
2614 let error = plane
2615 .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
2616 .await
2617 .expect_err(&format!("{state} is a death"));
2618 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
2619 assert!(
2620 error.to_string().contains("hook timeout"),
2621 "{state}: {error}"
2622 );
2623 assert_eq!(fake.call_count("GetMicrovm"), 1, "{state}");
2624 assert_eq!(fake.call_count("ResumeMicrovm"), 0, "{state}");
2625 }
2626 }
2627
2628 #[tokio::test]
2630 async fn get_microvm_carries_the_lifetime_members() {
2631 let (plane, fake, _) = planted();
2632 fake.answer(
2633 "GetMicrovm",
2634 Answer::ok(fake::microvm_response("RUNNING", None)),
2635 );
2636 let vm = plane.get_microvm("mvm-abc123").await.expect("parses");
2637 assert_eq!(vm.maximum_duration_seconds, Some(3600));
2638 assert_eq!(
2639 vm.started_at,
2640 Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_524_800))
2641 );
2642 assert_eq!(vm.terminated_at, None);
2643 }
2644
2645 #[tokio::test]
2647 async fn list_filters_reach_the_query_string() {
2648 let (plane, fake, _) = planted();
2649 fake.answer(
2650 "ListMicrovms",
2651 Answer::ok(fake::list_microvms_page(&["mvm-1"], None)),
2652 );
2653 let filter = MicrovmFilter {
2654 image_identifier: Some(
2655 "arn:aws:lambda:us-east-1:123456789012:microvm-image:img".into(),
2656 ),
2657 image_version: Some("1.0".into()),
2658 };
2659 let items = plane.list_microvms_matching(&filter).await.expect("lists");
2660 assert_eq!(items.len(), 1);
2661 let path = fake.paths().pop().expect("one call");
2662 assert!(
2663 path.ends_with(
2664 "/microvms?imageIdentifier=arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg&imageVersion=1.0"
2665 ),
2666 "{path}"
2667 );
2668 plane.list_microvms().await.expect("unfiltered");
2669 assert!(fake.paths().pop().expect("second").ends_with("/microvms"));
2670 }
2671
2672 #[tokio::test]
2674 async fn per_vm_logging_is_sent_and_checked_locally() {
2675 let (plane, fake, _) = planted();
2676 fake.answer(
2677 "RunMicrovm",
2678 Answer::ok(fake::microvm_response("PENDING", None)),
2679 );
2680 let payload = || RunHookPayload::for_agent_token("t").expect("fits");
2681 let mut request = RunMicrovmRequest::new("arn:image", payload());
2682 request.logging = Some(ops::Logging::cloud_watch("/team/agents", None));
2683 plane.run_microvm(request).await.expect("launches");
2684 assert_eq!(
2685 fake.first_body("RunMicrovm")["logging"],
2686 serde_json::json!({"cloudWatch": {"logGroup": "/team/agents"}})
2687 );
2688
2689 let mut disabled = RunMicrovmRequest::new("arn:image", payload());
2690 disabled.logging = Some(ops::Logging::disabled());
2691 plane.run_microvm(disabled).await.expect("launches");
2692 assert_eq!(
2693 fake.body_of("RunMicrovm", 1)["logging"],
2694 serde_json::json!({"disabled": {}})
2695 );
2696
2697 let plain = RunMicrovmRequest::new("arn:image", payload());
2698 plane.run_microvm(plain).await.expect("launches");
2699 assert!(fake.body_of("RunMicrovm", 2).get("logging").is_none());
2700
2701 for bad in [
2702 ops::Logging::cloud_watch("bad group!", None),
2703 ops::Logging::CloudWatch {
2704 cloud_watch: ops::CloudWatchLogging {
2705 log_group: None,
2706 log_stream: Some("s".into()),
2707 },
2708 },
2709 ] {
2710 let mut request = RunMicrovmRequest::new("arn:image", payload());
2711 request.logging = Some(bad);
2712 let error = plane.run_microvm(request).await.expect_err("refused");
2713 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
2714 }
2715 assert_eq!(
2716 fake.call_count("RunMicrovm"),
2717 3,
2718 "the refusals cost no call"
2719 );
2720 }
2721}