1use super::transport::{Call, paths, send_with_retry};
46use super::{ControlPlane, RunMicrovmRequest, WaitOpts, ops, timed_out, token};
47use crate::error::{Error, ErrorKind};
48
49pub const PROXY_AUTH_HEADER: &str = "X-aws-proxy-auth";
51
52pub const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
58
59pub const MAX_TOKEN_MINUTES: u32 = 60;
61
62#[derive(Clone, Eq, PartialEq)]
68pub struct RunHookPayload(String);
69
70impl std::fmt::Debug for RunHookPayload {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 write!(f, "RunHookPayload(<{} bytes>)", self.0.len())
81 }
82}
83
84impl RunHookPayload {
85 pub fn for_agent_token(agent_token: &str) -> Result<Self, Error> {
94 Self::for_launch(agent_token, &std::collections::HashMap::new())
95 }
96
97 pub fn for_launch(
123 agent_token: &str,
124 env: &std::collections::HashMap<String, String>,
125 ) -> Result<Self, Error> {
126 let payload = if env.is_empty() {
127 serde_json::json!({ "agent_token": agent_token })
128 } else {
129 serde_json::json!({ "agent_token": agent_token, "env": env })
130 };
131 let text = serde_json::to_string(&payload).map_err(|error| {
132 Error::new(
133 ErrorKind::Unexpected,
134 format!("could not serialize the run-hook payload: {error}"),
135 )
136 })?;
137 let total = text.len();
140 Self::new(text).map_err(|error| {
141 if env.is_empty() {
142 return error;
143 }
144 Error::invalid_arg(format!(
149 "{error} The launch env contributed {} of those bytes across {} variable(s); \
150 the payload's other {} are the token and the JSON framing. A launch env is \
151 for small values a workload needs at startup — move credential-scale \
152 material to PUT /v1/fs/file after bootstrap, or to a role the workload \
153 assumes.",
154 env_contribution(agent_token, env),
155 env.len(),
156 total.saturating_sub(env_contribution(agent_token, env)),
157 ))
158 })
159 }
160
161 pub fn new(payload: impl Into<String>) -> Result<Self, Error> {
166 let payload = payload.into();
167 let size = payload.len();
171 if size <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES {
172 return Ok(Self(payload));
173 }
174 Err(Error::invalid_arg(format!(
175 "runHookPayload is {size} bytes, over the ceiling of {} (service model {}, measured \
176 inclusive 2026-08-07: 4096 passes and 4097 fails). This is the only per-VM secret \
177 channel the platform offers — one bearer token fits, a cloud credential set does \
178 not. Note that docs/STRATEGY.md, docs/TRUST.md, and the service model's own \
179 documentation string for this member all claim 16 KB, which is wrong by 4x in the \
180 dangerous direction; the shape RunMicrovmRequestRunHookPayloadString is the \
181 authority and it states max 4096.",
182 crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
183 crate::constants::MODEL_API_VERSION,
184 )))
185 }
186
187 pub fn as_str(&self) -> &str {
189 &self.0
190 }
191
192 pub fn len(&self) -> usize {
194 self.0.len()
195 }
196
197 pub fn is_empty(&self) -> bool {
199 self.0.is_empty()
200 }
201}
202
203fn env_contribution(agent_token: &str, env: &std::collections::HashMap<String, String>) -> usize {
213 let with = serde_json::json!({ "agent_token": agent_token, "env": env }).to_string();
214 let without = serde_json::json!({ "agent_token": agent_token }).to_string();
215 with.len().saturating_sub(without.len())
216}
217
218#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct Microvm {
221 pub id: String,
222 pub state: String,
223 pub endpoint: String,
225 pub image_arn: String,
226 pub image_version: String,
227 pub state_reason: Option<String>,
230 pub idle_policy: Option<ops::IdlePolicy>,
241}
242
243impl Microvm {
244 fn reason(&self) -> &str {
250 self.state_reason.as_deref().unwrap_or("no stateReason")
251 }
252}
253
254impl From<ops::MicrovmResponseWire> for Microvm {
255 fn from(wire: ops::MicrovmResponseWire) -> Self {
256 Self {
257 id: wire.microvm_id,
258 state: wire.state,
259 endpoint: wire.endpoint,
260 image_arn: wire.image_arn,
261 image_version: wire.image_version,
262 state_reason: wire.state_reason,
263 idle_policy: wire.idle_policy,
264 }
265 }
266}
267
268#[derive(Clone, Eq, PartialEq)]
280pub struct ProxyToken {
281 value: String,
282 port: u16,
283}
284
285impl std::fmt::Debug for ProxyToken {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 f.debug_struct("ProxyToken")
294 .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
295 .field("port", &self.port)
296 .finish()
297 }
298}
299
300impl ProxyToken {
301 pub fn from_map(
308 map: &std::collections::BTreeMap<String, String>,
309 port: u16,
310 ) -> Result<Self, Error> {
311 let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
312 Error::wire(
313 crate::error::WireKind::AuthTokenMint,
314 format!(
315 "the CreateMicrovmAuthToken response carried no {PROXY_AUTH_HEADER} key. The \
316 authToken member is a map, not a string, and that key is the header value \
317 (keys present: {:?}).",
318 map.keys().collect::<Vec<_>>()
319 ),
320 )
321 })?;
322 Ok(Self {
323 value: value.clone(),
324 port,
325 })
326 }
327
328 pub fn headers(&self) -> [(&'static str, String); 2] {
333 [
334 (PROXY_AUTH_HEADER, self.value.clone()),
335 (PROXY_PORT_HEADER, self.port.to_string()),
336 ]
337 }
338
339 pub fn value(&self) -> &str {
341 &self.value
342 }
343
344 pub fn port(&self) -> u16 {
346 self.port
347 }
348}
349
350impl ControlPlane {
351 pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
357 super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
358 super::require_duration_in_range(request.max_duration_sec)?;
359 super::require_idle_duration(request.max_idle_sec)?;
363 if let Some(version) = request.image_version.as_deref() {
367 super::require_valid_version("imageVersion", version)?;
368 }
369 if let Some(role) = request.execution_role_arn.as_deref() {
373 super::require_valid_role_arn("executionRoleArn", role)?;
374 }
375
376 let ingress: Vec<String> = request
379 .connectors
380 .iter()
381 .filter(|intent| matches!(intent, super::ConnectorIntent::AllIngress))
382 .map(|intent| intent.arn(&self.region))
383 .collect();
384 let egress: Vec<String> = request
385 .connectors
386 .iter()
387 .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
388 .map(|intent| intent.arn(&self.region))
389 .collect();
390
391 if ingress.len() + egress.len() > crate::constants::MAX_NETWORK_CONNECTORS {
392 return Err(Error::invalid_arg(format!(
393 "{} network connectors were requested, over the NetworkConnectorList ceiling of \
394 {} (service model {}).",
395 ingress.len() + egress.len(),
396 crate::constants::MAX_NETWORK_CONNECTORS,
397 crate::constants::MODEL_API_VERSION,
398 )));
399 }
400
401 let wire = ops::RunMicrovmWire {
402 image_identifier: request.image_identifier.clone(),
403 image_version: request.image_version.clone(),
404 execution_role_arn: request.execution_role_arn.clone(),
405 ingress_network_connectors: ingress,
406 egress_network_connectors: (!egress.is_empty()).then_some(egress),
408 idle_policy: ops::IdlePolicy {
409 max_idle_duration_seconds: request.max_idle_sec,
410 suspended_duration_seconds: request.suspended_sec,
411 auto_resume_enabled: request.auto_resume,
412 },
413 maximum_duration_in_seconds: request.max_duration_sec,
414 run_hook_payload: request.run_hook_payload.as_str().to_string(),
415 client_token: token::run_token(
416 request
417 .token_scope
418 .as_deref()
419 .unwrap_or(&request.image_identifier),
420 ),
421 };
422
423 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
424 let reply = send_with_retry(self.transport(), call).await?;
425 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
426 Ok(launched.into())
427 }
428
429 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
436 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
437 .await
438 }
439
440 pub async fn wait_for_state(
451 &self,
452 id: &str,
453 wanted: &[&str],
454 fail_on: &[&str],
455 opts: WaitOpts,
456 ) -> Result<Microvm, Error> {
457 let started = self.clock().elapsed();
458 loop {
459 let got = self.get_microvm(id).await?;
460
461 if wanted.contains(&got.state.as_str()) {
462 return Ok(got);
463 }
464 if fail_on.contains(&got.state.as_str()) {
465 return Err(self.reached_terminal_state(&got, wanted));
466 }
467
468 let elapsed = self.clock().elapsed().saturating_sub(started);
469 if elapsed >= opts.timeout {
470 return Err(timed_out(
471 &format!(
472 "microvm {id} never reached {wanted:?} (last state {})",
473 got.state
474 ),
475 elapsed,
476 ));
477 }
478 self.clock().sleep(opts.poll_interval).await;
479 }
480 }
481
482 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
487 Error::new(
488 ErrorKind::LaunchDied,
489 format!(
490 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
491 before RUNNING died during startup, and for a hook-serving daemon that almost \
492 always means a lifecycle hook failed — the stateReason above is the only evidence \
493 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
494 startup reports a connection error that hides the cause').",
495 got.id,
496 got.state,
497 got.reason(),
498 ),
499 )
500 }
501
502 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
509 super::require_valid_identifier("microvmIdentifier", id)?;
510 let call = Call::get("GetMicrovm", paths::microvm(id));
511 let reply = send_with_retry(self.transport(), call).await?;
512 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
513 Ok(got.into())
514 }
515
516 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
526 let mut items = Vec::new();
527 let mut next_token: Option<String> = None;
528 loop {
529 let call = Call::get("ListMicrovms", paths::microvms_list(next_token.as_deref()));
530 let reply = send_with_retry(self.transport(), call).await?;
531 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
532 items.extend(page.items);
533 match page.next_token {
534 Some(token) => next_token = Some(token),
535 None => return Ok(items),
536 }
537 }
538 }
539
540 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
542 super::require_valid_identifier("microvmIdentifier", id)?;
543 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
544 send_with_retry(self.transport(), call).await?;
545 Ok(())
546 }
547
548 pub async fn resume(&self, id: &str) -> Result<(), Error> {
550 super::require_valid_identifier("microvmIdentifier", id)?;
551 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
552 send_with_retry(self.transport(), call).await?;
553 Ok(())
554 }
555
556 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
564 super::require_valid_identifier("microvmIdentifier", id)?;
565 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
566 send_with_retry(self.transport(), call).await?;
567 Ok(())
568 }
569
570 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
582 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
583 .await
584 }
585
586 pub async fn mint_auth_token_for(
599 &self,
600 id: &str,
601 ports: &[ops::PortSpecification],
602 ) -> Result<ProxyToken, Error> {
603 super::require_valid_identifier("microvmIdentifier", id)?;
604 if ports.is_empty() {
605 return Err(Error::new(
609 ErrorKind::Precondition,
610 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
611 Name the ports this token is for — the agent port plus any workload port \
612 reached through the endpoint.",
613 ));
614 }
615 for spec in ports {
623 match spec {
624 ops::PortSpecification::One { port } => {
625 super::require_valid_port("allowedPorts[].port", *port)?;
626 }
627 ops::PortSpecification::Range { range } => {
628 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
629 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
630 }
631 ops::PortSpecification::All { .. } => {}
633 }
634 }
635 let wire = ops::CreateAuthTokenWire {
636 expiration_in_minutes: MAX_TOKEN_MINUTES,
637 allowed_ports: ports.to_vec(),
638 };
639 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
640 let reply = send_with_retry(self.transport(), call).await?;
641 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
642 ProxyToken::from_map(&minted.auth_token, self.port())
643 }
644}
645
646pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
651
652#[cfg(test)]
653mod tests {
654 use std::sync::Arc;
655
656 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
657 use super::*;
658 use crate::region::Region;
659
660 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
661 let fake = Arc::new(FakeControlPlane::new());
662 let clock = Arc::new(TestClock::new());
663 let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
664 (plane, fake, clock)
665 }
666
667 #[test]
674 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
675 let at_ceiling = "a".repeat(4096);
676 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
677 assert_eq!(payload.len(), 4096);
678 assert_eq!(payload.as_str(), at_ceiling);
679
680 let over = "a".repeat(4097);
681 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
682 assert_eq!(error.kind(), ErrorKind::InvalidArg);
683 let message = error.to_string();
684 assert!(message.contains("4097 bytes"), "{message}");
685 assert!(
686 message.contains("ceiling of 4096"),
687 "the message must name the service-model ceiling: {message}"
688 );
689 assert!(message.contains("2025-09-09"), "{message}");
690 assert!(
691 message.contains("wrong by 4x"),
692 "the 16 KB claim has to be named as wrong: {message}"
693 );
694 }
695
696 #[tokio::test]
699 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
700 let (plane, fake, _) = planted();
701
702 let refused = RunHookPayload::new("a".repeat(4097));
704 assert!(refused.is_err());
705 assert_eq!(fake.calls().len(), 0);
706
707 fake.answer(
710 "RunMicrovm",
711 Answer::ok(fake::microvm_response("PENDING", None)),
712 );
713 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
714 plane
715 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
716 .await
717 .expect("launches");
718 assert_eq!(fake.call_count("RunMicrovm"), 1);
719 }
720
721 #[test]
724 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
725 let two_byte_chars = "é".repeat(2049);
726 assert_eq!(
727 two_byte_chars.chars().count(),
728 2049,
729 "well under 4096 chars"
730 );
731 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
732
733 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
734 assert!(error.to_string().contains("4098 bytes"), "{error}");
735
736 let exact = "é".repeat(2048);
738 assert_eq!(exact.len(), 4096);
739 RunHookPayload::new(exact).expect("exactly at the ceiling");
740 }
741
742 #[test]
745 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
746 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
747 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
748
749 let huge = "x".repeat(4096);
752 let error = RunHookPayload::for_agent_token(&huge)
753 .expect_err("the JSON wrapper pushes it over the ceiling");
754 assert_eq!(error.kind(), ErrorKind::InvalidArg);
755 assert!(
756 error.to_string().contains("one bearer token fits"),
757 "{error}"
758 );
759 }
760
761 #[test]
767 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
768 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
769 .expect("an empty env fits");
770 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
771 assert_eq!(with_empty.as_str(), token_only.as_str());
772 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
773 assert!(
774 !with_empty.as_str().contains("env"),
775 "an empty env must not appear on the wire at all: {}",
776 with_empty.as_str()
777 );
778 }
779
780 #[test]
782 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
783 let mut env = std::collections::HashMap::new();
784 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
785 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
786
787 let value: serde_json::Value =
789 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
790 assert_eq!(value["agent_token"], "tok");
791 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
792 }
793
794 #[test]
812 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
813 let mut env = std::collections::HashMap::new();
814 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
816
817 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
821 .to_string()
822 .len();
823 assert!(
824 expected > 4096,
825 "the fixture must actually be over: {expected}"
826 );
827
828 let error = RunHookPayload::for_launch("tok", &env)
829 .expect_err("a credential-scale launch env does not fit");
830 assert_eq!(error.kind(), ErrorKind::InvalidArg);
831 let message = error.to_string();
832
833 assert!(
834 message.contains("ceiling of 4096"),
835 "the ceiling has to be named: {message}"
836 );
837 assert!(
838 message.contains(&format!("{expected} bytes")),
839 "the actual byte count has to be named: {message}"
840 );
841 assert!(
842 message.contains("launch env contributed"),
843 "the env's share of the budget is the actionable half: {message}"
844 );
845 assert!(
846 message.contains("1 variable(s)"),
847 "the variable count has to be named: {message}"
848 );
849 }
850
851 #[test]
858 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
859 let mut env = std::collections::HashMap::new();
860 env.insert("A".to_string(), "a".repeat(2030));
861 env.insert("B".to_string(), "b".repeat(2030));
862 let raw: usize = env.values().map(String::len).sum();
863 assert_eq!(raw, 4060, "the raw values are under the ceiling");
864 assert!(
865 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
866 "the fixture only means something if the raw sum fits: {raw}"
867 );
868
869 let error = RunHookPayload::for_launch("tok", &env)
870 .expect_err("the serialized payload is over even though the raw values are not");
871 assert!(error.to_string().contains("2 variable(s)"), "{error}");
872
873 let mut fits = std::collections::HashMap::new();
876 fits.insert("A".to_string(), "a".repeat(2000));
877 fits.insert("B".to_string(), "b".repeat(2000));
878 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
879 assert!(payload.len() <= 4096, "{}", payload.len());
880 }
881
882 #[test]
884 fn an_empty_payload_is_legal() {
885 let payload = RunHookPayload::new("").expect("min is 0");
886 assert!(payload.is_empty());
887 assert_eq!(payload.len(), 0);
888 }
889
890 #[tokio::test]
893 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
894 let (plane, fake, _) = planted();
895 fake.answer(
896 "RunMicrovm",
897 Answer::ok(fake::microvm_response("PENDING", None)),
898 );
899
900 let payload = RunHookPayload::for_agent_token("token").expect("fits");
901 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
902 plane.run_microvm(request).await.expect("launches");
903
904 let calls = fake.calls();
905 assert_eq!(calls[0].path, "/2025-09-09/microvms");
906 assert_eq!(calls[0].method, super::super::transport::Method::Post);
907
908 let body = fake.first_body("RunMicrovm");
909 assert_eq!(
910 body["ingressNetworkConnectors"],
911 serde_json::json!([
912 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
913 ])
914 );
915 assert_eq!(
916 body["egressNetworkConnectors"],
917 serde_json::json!([
918 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
919 ])
920 );
921 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
922 assert_eq!(body["maximumDurationInSeconds"], 3600);
923 }
924
925 #[tokio::test]
928 async fn a_launch_without_egress_omits_the_member_entirely() {
929 let (plane, fake, _) = planted();
930 fake.answer(
931 "RunMicrovm",
932 Answer::ok(fake::microvm_response("PENDING", None)),
933 );
934 let payload = RunHookPayload::for_agent_token("token").expect("fits");
935 plane
936 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
937 .await
938 .expect("launches");
939
940 let body = fake.first_body("RunMicrovm");
941 assert!(
942 body.get("egressNetworkConnectors").is_none(),
943 "omitting egress is the whole mechanism: {body}"
944 );
945 assert!(body.get("ingressNetworkConnectors").is_some());
946 }
947
948 #[tokio::test]
961 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
962 let (plane, fake, _) = planted();
963 fake.answer(
964 "RunMicrovm",
965 Answer::ok(fake::microvm_response("PENDING", None)),
966 );
967 let payload = RunHookPayload::for_agent_token("token").expect("fits");
968 plane
969 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
970 .await
971 .expect("launches");
972
973 let body = fake.first_body("RunMicrovm");
974 assert_eq!(body["imageVersion"], "2.0");
975 assert_eq!(
976 body["imageIdentifier"], "arn:image",
977 "pinning a version does not replace the identifier; both are sent"
978 );
979
980 let (plane, fake, _) = planted();
981 fake.answer(
982 "RunMicrovm",
983 Answer::ok(fake::microvm_response("PENDING", None)),
984 );
985 let payload = RunHookPayload::for_agent_token("token").expect("fits");
986 plane
987 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
988 .await
989 .expect("launches");
990 assert!(
991 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
992 "an unpinned launch must send what this client always sent: {}",
993 fake.first_body("RunMicrovm")
994 );
995 }
996
997 #[tokio::test]
1007 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1008 for bad in ["", "2.0\n", "a b"] {
1009 let (plane, fake, _) = planted();
1010 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1011 let error = plane
1012 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1013 .await
1014 .expect_err(&format!("{bad:?} is not a legal Version"));
1015 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1016 assert!(
1017 error.to_string().contains("imageVersion"),
1018 "{bad:?}: {error}"
1019 );
1020 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1021 }
1022 }
1023
1024 #[tokio::test]
1028 async fn no_bare_connector_name_reaches_the_wire() {
1029 let (plane, fake, _) = planted();
1030 fake.answer(
1031 "RunMicrovm",
1032 Answer::ok(fake::microvm_response("PENDING", None)),
1033 );
1034 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1035 plane
1036 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1037 .await
1038 .expect("launches");
1039
1040 let body = fake.first_body("RunMicrovm");
1041 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1042 for value in body[member].as_array().expect("a list") {
1043 let text = value.as_str().expect("a string");
1044 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1045 assert_ne!(text, "ALL_INGRESS");
1046 assert_ne!(text, "INTERNET_EGRESS");
1047 }
1048 }
1049 }
1050
1051 #[tokio::test]
1053 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1054 let (plane, fake, _) = planted();
1055 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1056 let mut request = RunMicrovmRequest::new("arn:image", payload);
1057 request.max_duration_sec = 28_801;
1058
1059 let error = plane
1060 .run_microvm(request)
1061 .await
1062 .expect_err("over the ceiling");
1063 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1064 assert_eq!(fake.calls().len(), 0);
1065 }
1066
1067 #[tokio::test]
1077 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1078 let (plane, fake, _) = planted();
1079 fake.answer(
1080 "GetMicrovm",
1081 Answer::ok(fake::microvm_response(
1082 "TERMINATED",
1083 Some("run hook returned 500"),
1084 )),
1085 );
1086
1087 let error = plane
1088 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1089 .await
1090 .expect_err("a dead VM must not be waited out");
1091
1092 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1093 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1094 let message = error.to_string();
1095 assert!(
1096 message.contains("TERMINATED"),
1097 "the state must be attached: {message}"
1098 );
1099 assert!(
1100 message.contains("run hook returned 500"),
1101 "the stateReason must be attached: {message}"
1102 );
1103 assert!(
1104 message.contains("lifecycle hook failed"),
1105 "the likely cause is worth naming: {message}"
1106 );
1107 assert!(
1108 fake.call_count("GetMicrovm") == 1,
1109 "it fails fast rather than polling to the deadline"
1110 );
1111 }
1112
1113 #[tokio::test]
1120 async fn every_terminal_state_fails_a_launch_fast() {
1121 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1122 for state in crate::constants::TERMINAL_STATES {
1123 let (plane, fake, _) = planted();
1124 fake.answer(
1125 "GetMicrovm",
1126 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1127 );
1128 let error = plane
1129 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1130 .await
1131 .expect_err(&format!("{state} before RUNNING is a death"));
1132 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1133 assert!(error.to_string().contains(state), "{state}: {error}");
1134 assert!(
1135 error.to_string().contains("hook timeout"),
1136 "{state}: {error}"
1137 );
1138 assert_eq!(
1139 fake.call_count("GetMicrovm"),
1140 1,
1141 "{state} must fail fast rather than polling to the deadline"
1142 );
1143 }
1144 }
1145
1146 #[tokio::test]
1150 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1151 let (plane, fake, _) = planted();
1152 fake.answer(
1153 "GetMicrovm",
1154 Answer::ok(fake::microvm_response("TERMINATED", None)),
1155 );
1156
1157 let error = plane
1158 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1159 .await
1160 .expect_err("still a death");
1161 let message = error.to_string();
1162 assert!(message.contains("no stateReason"), "{message}");
1163 assert!(
1167 !message.contains(r#"["RUNNING"]: ."#),
1168 "an absent reason must not render as an empty string: {message}"
1169 );
1170 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1171 }
1172
1173 #[tokio::test]
1175 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1176 let (plane, fake, _) = planted();
1177 fake.answer(
1178 "GetMicrovm",
1179 Answer::ok(fake::microvm_response("PENDING", None)),
1180 )
1181 .answer(
1182 "GetMicrovm",
1183 Answer::ok(fake::microvm_response("RUNNING", None)),
1184 );
1185
1186 let vm = plane
1187 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1188 .await
1189 .expect("reaches RUNNING");
1190 assert_eq!(vm.state, "RUNNING");
1191 assert_eq!(
1192 vm.endpoint,
1193 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1194 );
1195 assert_eq!(fake.call_count("GetMicrovm"), 2);
1196 }
1197
1198 #[tokio::test]
1202 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1203 let (plane, fake, _) = planted();
1204 fake.answer(
1205 "GetMicrovm",
1206 Answer::ok(fake::microvm_response("PENDING", None)),
1207 );
1208
1209 let error = plane
1210 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1211 .await
1212 .expect_err("the deadline elapses");
1213 assert_eq!(error.kind(), ErrorKind::Timeout);
1214 assert_ne!(
1215 error.kind(),
1216 ErrorKind::LaunchDied,
1217 "a timeout is not a death, and conflating them is what hid the cause"
1218 );
1219 assert!(error.to_string().contains("PENDING"), "{error}");
1220 }
1221
1222 #[tokio::test]
1225 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1226 let (plane, fake, _) = planted();
1227 fake.answer(
1228 "GetMicrovm",
1229 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1230 )
1231 .answer(
1232 "GetMicrovm",
1233 Answer::ok(fake::microvm_response("RUNNING", None)),
1234 );
1235
1236 let vm = plane
1237 .wait_for_state(
1238 "mvm-abc123",
1239 &["RUNNING"],
1240 &crate::constants::DEAD_STATES,
1241 WaitOpts::for_launch(),
1242 )
1243 .await
1244 .expect("SUSPENDED is a waypoint on the resume path");
1245 assert_eq!(vm.state, "RUNNING");
1246
1247 let (plane, fake, _) = planted();
1248 fake.answer(
1249 "GetMicrovm",
1250 Answer::ok(fake::microvm_response(
1251 "TERMINATED",
1252 Some("suspended window elapsed"),
1253 )),
1254 );
1255 let error = plane
1256 .wait_for_state(
1257 "mvm-abc123",
1258 &["RUNNING"],
1259 &crate::constants::DEAD_STATES,
1260 WaitOpts::for_launch(),
1261 )
1262 .await
1263 .expect_err("a terminated VM never reaches RUNNING");
1264 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1265 assert!(
1266 error.to_string().contains("suspended window elapsed"),
1267 "{error}"
1268 );
1269 }
1270
1271 #[tokio::test]
1274 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1275 let (plane, fake, _) = planted();
1276 fake.answer(
1277 "GetMicrovm",
1278 Answer::ok(fake::microvm_response("TERMINATED", None)),
1279 );
1280
1281 let vm = plane
1282 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1283 .await
1284 .expect("a suspend reports the state it reached");
1285 assert_eq!(vm.state, "TERMINATED");
1286 }
1287
1288 #[tokio::test]
1291 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1292 let (plane, fake, _) = planted();
1293 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1294 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1295 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1296
1297 plane.suspend("mvm-1").await.expect("suspends");
1298 plane.resume("mvm-1").await.expect("resumes");
1299 plane.terminate("mvm-1").await.expect("terminates");
1300
1301 let calls = fake.calls();
1302 use super::super::transport::Method;
1303 assert_eq!(
1304 calls
1305 .iter()
1306 .map(|call| (call.operation, call.method, call.path.as_str()))
1307 .collect::<Vec<_>>(),
1308 [
1309 (
1310 "SuspendMicrovm",
1311 Method::Post,
1312 "/2025-09-09/microvms/mvm-1/suspend"
1313 ),
1314 (
1315 "ResumeMicrovm",
1316 Method::Post,
1317 "/2025-09-09/microvms/mvm-1/resume"
1318 ),
1319 (
1320 "TerminateMicrovm",
1321 Method::Delete,
1322 "/2025-09-09/microvms/mvm-1"
1323 ),
1324 ]
1325 );
1326 }
1327
1328 #[tokio::test]
1331 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1332 let (plane, fake, _) = planted();
1333 fake.answer(
1334 "CreateMicrovmAuthToken",
1335 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1336 );
1337
1338 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1339 assert_eq!(token.value(), "opaque-proxy-token");
1340 assert_eq!(token.port(), 9000);
1341
1342 let headers = token.headers();
1343 assert_eq!(
1344 headers[0],
1345 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1346 );
1347 assert_eq!(
1348 headers[1],
1349 ("X-aws-proxy-port", "9000".to_string()),
1350 "the auth header without the port header is rejected in a way that reads like a \
1351 bad token"
1352 );
1353
1354 let body = fake.first_body("CreateMicrovmAuthToken");
1355 assert_eq!(body["expirationInMinutes"], 60);
1356 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1357 }
1358
1359 #[test]
1367 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1368 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1369
1370 let rendered = format!("{payload:?}");
1371 assert!(
1372 !rendered.contains("SECRETTOKEN"),
1373 "the agent token reached a Debug string: {rendered}"
1374 );
1375 assert!(
1376 rendered.contains(&format!("{} bytes", payload.len())),
1377 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1378 );
1379
1380 let request = RunMicrovmRequest::new("arn:image", payload);
1381 let rendered = format!("{request:?}");
1382 assert!(rendered.contains("arn:image"), "{rendered}");
1383 assert!(
1384 !rendered.contains("SECRETTOKEN"),
1385 "the agent token reached a Debug string through the request: {rendered}"
1386 );
1387 }
1388
1389 #[test]
1396 fn a_proxy_token_debug_does_not_print_the_credential() {
1397 let mut map = std::collections::BTreeMap::new();
1398 map.insert(
1399 PROXY_AUTH_HEADER.to_string(),
1400 "eyJhbGciOi-secret".to_string(),
1401 );
1402 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1403
1404 let rendered = format!("{token:?}");
1405 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1406 assert!(rendered.contains("9000"), "{rendered}");
1407 assert!(
1408 !rendered.contains("secret"),
1409 "the token value reached a Debug string: {rendered}"
1410 );
1411 }
1412
1413 #[test]
1416 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1417 let mut map = std::collections::BTreeMap::new();
1418 map.insert("SomeOtherKey".to_string(), "value".to_string());
1419
1420 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1421 assert_eq!(
1422 error.wire_kind(),
1423 Some(crate::error::WireKind::AuthTokenMint)
1424 );
1425 assert!(error.retryable(), "minting is inside the retry path");
1426 assert!(
1427 error.to_string().contains("is a map, not a string"),
1428 "{error}"
1429 );
1430 }
1431
1432 #[tokio::test]
1434 async fn the_token_is_minted_for_the_configured_port() {
1435 let fake = Arc::new(FakeControlPlane::new());
1436 let plane =
1437 ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()))
1438 .with_port(8080)
1439 .expect("8080 is a legal port");
1440 fake.answer(
1441 "CreateMicrovmAuthToken",
1442 Answer::ok(fake::auth_token_response("t")),
1443 );
1444
1445 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1446 assert_eq!(token.port(), 8080);
1447 assert_eq!(
1448 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1449 8080
1450 );
1451 }
1452
1453 #[tokio::test]
1471 async fn a_full_lifecycle_never_calls_shell_auth_or_requests_shell_ingress() {
1472 let (plane, fake, _) = planted();
1473 fake.answer(
1474 "CreateMicrovmImage",
1475 Answer::created(fake::create_image_response("img")),
1476 )
1477 .answer(
1478 "GetMicrovmImage",
1479 Answer::ok(fake::get_image_response("img", "CREATED")),
1480 )
1481 .answer(
1482 "RunMicrovm",
1483 Answer::ok(fake::microvm_response("PENDING", None)),
1484 )
1485 .answer(
1486 "GetMicrovm",
1487 Answer::ok(fake::microvm_response("RUNNING", None)),
1488 )
1489 .answer(
1490 "CreateMicrovmAuthToken",
1491 Answer::ok(fake::auth_token_response("proxy-token")),
1492 )
1493 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1494 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1495 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1496 .answer(
1497 "ListMicrovmImageVersions",
1498 Answer::ok(fake::list_versions_response("1")),
1499 )
1500 .answer(
1501 "DeleteMicrovmImage",
1502 Answer::ok(fake::delete_image_response()),
1503 );
1504
1505 let request = super::super::CreateImageRequest::new(
1507 "img",
1508 b"binary".to_vec(),
1509 "s3://bucket/img.zip",
1510 "arn:aws:iam::123456789012:role/build",
1511 );
1512 let image = plane.create_image(request).await.expect("creates");
1513 plane
1514 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1515 .await
1516 .expect("becomes usable");
1517
1518 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1519 let vm = plane
1520 .run_microvm(
1521 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1522 )
1523 .await
1524 .expect("launches");
1525 plane
1526 .wait_for_running(&vm.id, WaitOpts::for_launch())
1527 .await
1528 .expect("reaches RUNNING");
1529 plane.mint_auth_token(&vm.id).await.expect("mints");
1530 plane.suspend(&vm.id).await.expect("suspends");
1531 plane.resume(&vm.id).await.expect("resumes");
1532 plane.terminate(&vm.id).await.expect("terminates");
1533 plane
1534 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
1535 .await;
1536
1537 let operations = fake.operations();
1540 assert!(
1541 operations.len() >= 10,
1542 "the lifecycle must actually have run: {operations:?}"
1543 );
1544 assert!(operations.contains(&"CreateMicrovmAuthToken"));
1545
1546 assert_eq!(
1547 fake.call_count("CreateMicrovmShellAuthToken"),
1548 0,
1549 "shell auth gates a debug console, not programmatic exec: {operations:?}"
1550 );
1551 for operation in &operations {
1552 assert!(
1553 !operation.contains("Shell"),
1554 "no operation may be a shell one: {operation}"
1555 );
1556 }
1557 for path in fake.paths() {
1558 assert!(
1559 !path.contains("shell"),
1560 "no request may reach a shell route: {path}"
1561 );
1562 }
1563 for body in fake.bodies_as_text() {
1564 assert!(
1565 !body.contains("SHELL_INGRESS"),
1566 "no request may name the shell connector: {body}"
1567 );
1568 assert!(!body.contains("SHELL"), "{body}");
1569 }
1570 }
1571
1572 #[tokio::test]
1585 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
1586 let (plane, fake, _) = planted();
1587 fake.answer(
1588 "ListMicrovms",
1589 Answer::ok(fake::list_microvms_page(
1590 &["mvm-page1-a", "mvm-page1-b"],
1591 Some("fleet-page-2"),
1592 )),
1593 )
1594 .answer(
1595 "ListMicrovms",
1596 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
1597 );
1598
1599 let fleet = plane.list_microvms().await.expect("lists");
1600 assert_eq!(
1601 fleet.len(),
1602 3,
1603 "a fleet spanning two pages is three VMs, not two"
1604 );
1605
1606 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
1607 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
1608 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
1609 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
1610
1611 let paths = fake.paths();
1612 assert!(
1613 paths[1].contains("nextToken=fleet-page-2"),
1614 "the second request must carry the first page's token: {}",
1615 paths[1]
1616 );
1617 assert!(
1618 !paths[0].contains('?'),
1619 "the first request carries no cursor: {}",
1620 paths[0]
1621 );
1622 }
1623
1624 #[tokio::test]
1634 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
1635 let (plane, fake, _) = planted();
1636 fake.answer(
1637 "GetMicrovm",
1638 Answer::ok(fake::microvm_response("RUNNING", None)),
1639 );
1640
1641 let vm = plane.get_microvm("mvm-1").await.expect("reads");
1642 let policy = vm
1643 .idle_policy
1644 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
1645 assert_eq!(policy.max_idle_duration_seconds, 1800);
1646 assert_eq!(
1647 policy.suspended_duration_seconds, 600,
1648 "the member the comment claimed was request-only"
1649 );
1650 assert!(!policy.auto_resume_enabled);
1651 }
1652
1653 #[tokio::test]
1657 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
1658 let (plane, fake, _) = planted();
1659 fake.answer(
1660 "GetMicrovm",
1661 Answer::ok(
1662 r#"{
1663 "microvmId": "mvm-1",
1664 "state": "RUNNING",
1665 "endpoint": "https://e",
1666 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1667 "imageVersion": "1"
1668 }"#,
1669 ),
1670 );
1671
1672 let vm = plane.get_microvm("mvm-1").await.expect("parses");
1673 assert_eq!(vm.idle_policy, None);
1674 }
1675
1676 #[tokio::test]
1679 async fn a_transport_failure_is_retried_rather_than_reported() {
1680 let (plane, fake, _) = planted();
1681 fake.fail_transport("GetMicrovm", 2).answer(
1682 "GetMicrovm",
1683 Answer::ok(fake::microvm_response("RUNNING", None)),
1684 );
1685
1686 let vm = plane
1687 .get_microvm("mvm-1")
1688 .await
1689 .expect("the retry succeeds");
1690 assert_eq!(vm.state, "RUNNING");
1691 assert_eq!(
1692 fake.call_count("GetMicrovm"),
1693 3,
1694 "two failures then a success"
1695 );
1696 }
1697
1698 #[tokio::test]
1715 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
1716 for under in [0, 1, 59] {
1717 let (plane, fake, _) = planted();
1718 fake.answer(
1719 "RunMicrovm",
1720 Answer::ok(fake::microvm_response("PENDING", None)),
1721 );
1722
1723 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1724 let mut request = RunMicrovmRequest::new("arn:image", payload);
1725 request.max_idle_sec = under;
1726
1727 let error = plane
1728 .run_microvm(request)
1729 .await
1730 .expect_err("the model's min is 60");
1731 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1732 let message = error.to_string();
1733 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
1734 assert!(message.contains(&under.to_string()), "{message}");
1735 assert_eq!(
1736 fake.calls().len(),
1737 0,
1738 "{under} was refused locally; before this guard it was serialized and sent"
1739 );
1740 }
1741
1742 let (plane, fake, _) = planted();
1745 fake.answer(
1746 "RunMicrovm",
1747 Answer::ok(fake::microvm_response("PENDING", None)),
1748 );
1749 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1750 let mut request = RunMicrovmRequest::new("arn:image", payload);
1751 request.max_idle_sec = 60;
1752 plane.run_microvm(request).await.expect("60 is the minimum");
1753 assert_eq!(
1754 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
1755 60
1756 );
1757 }
1758
1759 #[tokio::test]
1769 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
1770 type Mutate = fn(&mut RunMicrovmRequest);
1773 let rows: [(&str, Mutate, &str); 6] = [
1774 (
1775 "an execution role that is a bare name",
1776 |request| {
1777 request.execution_role_arn = Some("execution-role".to_string());
1778 },
1779 "role *name*",
1780 ),
1781 (
1782 "an execution role with eleven account digits",
1783 |request| {
1784 request.execution_role_arn =
1785 Some("arn:aws:iam::12345678901:role/exec".to_string());
1786 },
1787 "exactly twelve digits",
1788 ),
1789 (
1790 "an execution role that is a function ARN",
1791 |request| {
1792 request.execution_role_arn =
1793 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
1794 },
1795 "RoleArn pattern",
1796 ),
1797 (
1798 "an empty image identifier",
1799 |request| {
1800 request.image_identifier = String::new();
1801 },
1802 "collapses the path",
1803 ),
1804 (
1805 "a 257-character image identifier",
1806 |request| {
1807 request.image_identifier = "a".repeat(257);
1808 },
1809 "MicrovmImageArn permits 2048",
1810 ),
1811 (
1812 "an image version with a newline",
1813 |request| {
1814 request.image_version = Some("2.0\n".to_string());
1815 },
1816 "contains whitespace",
1817 ),
1818 ];
1819
1820 for (label, mutate, expected) in rows {
1821 let (plane, fake, _) = planted();
1822 fake.answer(
1823 "RunMicrovm",
1824 Answer::ok(fake::microvm_response("PENDING", None)),
1825 );
1826
1827 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1828 let mut request = RunMicrovmRequest::new("arn:image", payload);
1829 mutate(&mut request);
1830
1831 let error = plane
1832 .run_microvm(request)
1833 .await
1834 .expect_err(&format!("{label} must be refused"));
1835 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1836 assert!(
1837 error.to_string().contains(expected),
1838 "{label}: wanted {expected:?}, got {error}"
1839 );
1840 assert_eq!(fake.calls().len(), 0, "{label}");
1841 }
1842
1843 let (plane, fake, _) = planted();
1846 fake.answer(
1847 "RunMicrovm",
1848 Answer::ok(fake::microvm_response("PENDING", None)),
1849 );
1850 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1851 let mut request = RunMicrovmRequest::new(
1852 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
1853 payload,
1854 );
1855 request.execution_role_arn =
1856 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
1857 request.image_version = Some("1.0".to_string());
1858 plane
1859 .run_microvm(request)
1860 .await
1861 .expect("a realistic pinned launch with a real execution role");
1862 assert_eq!(fake.call_count("RunMicrovm"), 1);
1863 }
1864
1865 #[tokio::test]
1875 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
1876 for bad in ["", &"a".repeat(257)] {
1877 let (plane, fake, _) = planted();
1878 fake.answer(
1881 "GetMicrovm",
1882 Answer::ok(fake::microvm_response("RUNNING", None)),
1883 );
1884 fake.answer("SuspendMicrovm", Answer::ok("{}"));
1885 fake.answer("ResumeMicrovm", Answer::ok("{}"));
1886 fake.answer("TerminateMicrovm", Answer::ok("{}"));
1887 fake.answer(
1888 "CreateMicrovmAuthToken",
1889 Answer::ok(fake::auth_token_response("t")),
1890 );
1891
1892 plane.get_microvm(bad).await.expect_err("GetMicrovm");
1893 plane.suspend(bad).await.expect_err("SuspendMicrovm");
1894 plane.resume(bad).await.expect_err("ResumeMicrovm");
1895 plane.terminate(bad).await.expect_err("TerminateMicrovm");
1896 plane
1897 .mint_auth_token(bad)
1898 .await
1899 .expect_err("CreateMicrovmAuthToken");
1900 plane
1901 .wait_for_running(bad, WaitOpts::for_launch())
1902 .await
1903 .expect_err("GetMicrovm, in a loop");
1904
1905 assert_eq!(
1906 fake.calls().len(),
1907 0,
1908 "six operations refused {bad:?} locally. An empty identifier on the terminate \
1909 path is a DELETE addressed at the collection."
1910 );
1911 }
1912 }
1913
1914 #[tokio::test]
1929 async fn no_proxy_token_is_minted_for_port_zero() {
1930 let rows: [(&str, ops::PortSpecification, &str); 3] = [
1931 (
1932 "a single port of 0",
1933 ops::PortSpecification::port(0),
1934 "allowedPorts[].port",
1935 ),
1936 (
1937 "a range starting at 0",
1938 ops::PortSpecification::range(0, 9000),
1939 "allowedPorts[].range.startPort",
1940 ),
1941 (
1942 "a range ending at 0",
1943 ops::PortSpecification::range(0, 0),
1944 "allowedPorts[].range.startPort",
1945 ),
1946 ];
1947
1948 for (label, spec, member) in rows {
1949 let (plane, fake, _) = planted();
1950 fake.answer(
1951 "CreateMicrovmAuthToken",
1952 Answer::ok(fake::auth_token_response("t")),
1953 );
1954
1955 let error = plane
1956 .mint_auth_token_for("mvm-1", &[spec])
1957 .await
1958 .expect_err(&format!("{label} must be refused"));
1959 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1960 let message = error.to_string();
1961 assert!(message.contains(member), "{label}: {message}");
1962 assert!(
1963 message.contains("authorizes nothing"),
1964 "{label}: the 403 consequence has to be in the message: {message}"
1965 );
1966 assert_eq!(fake.calls().len(), 0, "{label}");
1967 }
1968
1969 let (plane, fake, _) = planted();
1973 fake.answer(
1974 "CreateMicrovmAuthToken",
1975 Answer::ok(fake::auth_token_response("t")),
1976 );
1977 plane
1978 .mint_auth_token_for(
1979 "mvm-1",
1980 &[
1981 ops::PortSpecification::port(9000),
1982 ops::PortSpecification::port(0),
1983 ],
1984 )
1985 .await
1986 .expect_err("every entry is checked, not the first");
1987 assert_eq!(fake.calls().len(), 0);
1988
1989 for legal in [
1992 ops::PortSpecification::port(1),
1993 ops::PortSpecification::port(65_535),
1994 ops::PortSpecification::range(1, 65_535),
1995 ops::PortSpecification::all(),
1996 ] {
1997 let (plane, fake, _) = planted();
1998 fake.answer(
1999 "CreateMicrovmAuthToken",
2000 Answer::ok(fake::auth_token_response("t")),
2001 );
2002 plane
2003 .mint_auth_token_for("mvm-1", &[legal])
2004 .await
2005 .expect("a legal port specification mints");
2006 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2007 }
2008 }
2009}