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 Self::for_launch_with_identity(agent_token, env, None)
127 }
128
129 pub fn for_launch_with_identity(
142 agent_token: &str,
143 env: &std::collections::HashMap<String, String>,
144 identity: Option<&crate::identity::LaunchIdentity>,
145 ) -> Result<Self, Error> {
146 let mut payload = if env.is_empty() {
147 serde_json::json!({ "agent_token": agent_token })
148 } else {
149 serde_json::json!({ "agent_token": agent_token, "env": env })
150 };
151 if let Some(identity) = identity {
152 let object = payload
153 .as_object_mut()
154 .expect("the payload is built as an object two lines up");
155 object.insert(
156 protocol::identity::SEED_KEY.to_string(),
157 serde_json::Value::String(identity.seed_field()),
158 );
159 object.insert(
160 protocol::identity::HOST_PUBLIC_KEY_KEY.to_string(),
161 serde_json::Value::String(identity.host_public_field()),
162 );
163 }
164 let text = serde_json::to_string(&payload).map_err(|error| {
165 Error::new(
166 ErrorKind::Unexpected,
167 format!("could not serialize the run-hook payload: {error}"),
168 )
169 })?;
170 let total = text.len();
173 Self::new(text).map_err(|error| {
174 if env.is_empty() {
175 return error;
176 }
177 Error::invalid_arg(format!(
182 "{error} The launch env contributed {} of those bytes across {} variable(s); \
183 the payload's other {} are the token and the JSON framing. A launch env is \
184 for small values a workload needs at startup — move credential-scale \
185 material to PUT /v1/fs/file after bootstrap, or to a role the workload \
186 assumes.",
187 env_contribution(agent_token, env),
188 env.len(),
189 total.saturating_sub(env_contribution(agent_token, env)),
190 ))
191 })
192 }
193
194 pub fn new(payload: impl Into<String>) -> Result<Self, Error> {
199 let payload = payload.into();
200 let size = payload.len();
204 if size <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES {
205 return Ok(Self(payload));
206 }
207 Err(Error::invalid_arg(format!(
208 "runHookPayload is {size} bytes, over the ceiling of {} (service model {}, measured \
209 inclusive 2026-08-07: 4096 passes and 4097 fails). This is the only per-VM secret \
210 channel the platform offers — one bearer token fits, a cloud credential set does \
211 not. Note that docs/STRATEGY.md, docs/TRUST.md, and the service model's own \
212 documentation string for this member all claim 16 KB, which is wrong by 4x in the \
213 dangerous direction; the shape RunMicrovmRequestRunHookPayloadString is the \
214 authority and it states max 4096.",
215 crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
216 crate::constants::MODEL_API_VERSION,
217 )))
218 }
219
220 pub fn as_str(&self) -> &str {
222 &self.0
223 }
224
225 pub fn len(&self) -> usize {
227 self.0.len()
228 }
229
230 pub fn is_empty(&self) -> bool {
232 self.0.is_empty()
233 }
234}
235
236fn env_contribution(agent_token: &str, env: &std::collections::HashMap<String, String>) -> usize {
246 let with = serde_json::json!({ "agent_token": agent_token, "env": env }).to_string();
247 let without = serde_json::json!({ "agent_token": agent_token }).to_string();
248 with.len().saturating_sub(without.len())
249}
250
251#[derive(Clone, Debug, Eq, PartialEq)]
253pub struct Microvm {
254 pub id: String,
255 pub state: String,
256 pub endpoint: String,
258 pub image_arn: String,
259 pub image_version: String,
260 pub state_reason: Option<String>,
263 pub idle_policy: Option<ops::IdlePolicy>,
274}
275
276impl Microvm {
277 fn reason(&self) -> &str {
283 self.state_reason.as_deref().unwrap_or("no stateReason")
284 }
285}
286
287impl From<ops::MicrovmResponseWire> for Microvm {
288 fn from(wire: ops::MicrovmResponseWire) -> Self {
289 Self {
290 id: wire.microvm_id,
291 state: wire.state,
292 endpoint: wire.endpoint,
293 image_arn: wire.image_arn,
294 image_version: wire.image_version,
295 state_reason: wire.state_reason,
296 idle_policy: wire.idle_policy,
297 }
298 }
299}
300
301#[derive(Clone, Eq, PartialEq)]
313pub struct ProxyToken {
314 value: String,
315 port: u16,
316}
317
318impl std::fmt::Debug for ProxyToken {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 f.debug_struct("ProxyToken")
327 .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
328 .field("port", &self.port)
329 .finish()
330 }
331}
332
333impl ProxyToken {
334 pub fn from_map(
341 map: &std::collections::BTreeMap<String, String>,
342 port: u16,
343 ) -> Result<Self, Error> {
344 let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
345 Error::wire(
346 crate::error::WireKind::AuthTokenMint,
347 format!(
348 "the CreateMicrovmAuthToken response carried no {PROXY_AUTH_HEADER} key. The \
349 authToken member is a map, not a string, and that key is the header value \
350 (keys present: {:?}).",
351 map.keys().collect::<Vec<_>>()
352 ),
353 )
354 })?;
355 Ok(Self {
356 value: value.clone(),
357 port,
358 })
359 }
360
361 pub fn headers(&self) -> [(&'static str, String); 2] {
366 [
367 (PROXY_AUTH_HEADER, self.value.clone()),
368 (PROXY_PORT_HEADER, self.port.to_string()),
369 ]
370 }
371
372 pub fn value(&self) -> &str {
374 &self.value
375 }
376
377 pub fn port(&self) -> u16 {
379 self.port
380 }
381}
382
383impl ControlPlane {
384 pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
390 super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
391 super::require_duration_in_range(request.max_duration_sec)?;
392 super::require_idle_duration(request.max_idle_sec)?;
396 if let Some(version) = request.image_version.as_deref() {
400 super::require_valid_version("imageVersion", version)?;
401 }
402 if let Some(role) = request.execution_role_arn.as_deref() {
406 super::require_valid_role_arn("executionRoleArn", role)?;
407 }
408
409 let ingress: Vec<String> = request
412 .connectors
413 .iter()
414 .filter(|intent| matches!(intent, super::ConnectorIntent::AllIngress))
415 .map(|intent| intent.arn(&self.region))
416 .collect();
417 let egress: Vec<String> = request
418 .connectors
419 .iter()
420 .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
421 .map(|intent| intent.arn(&self.region))
422 .collect();
423
424 if ingress.len() + egress.len() > crate::constants::MAX_NETWORK_CONNECTORS {
425 return Err(Error::invalid_arg(format!(
426 "{} network connectors were requested, over the NetworkConnectorList ceiling of \
427 {} (service model {}).",
428 ingress.len() + egress.len(),
429 crate::constants::MAX_NETWORK_CONNECTORS,
430 crate::constants::MODEL_API_VERSION,
431 )));
432 }
433
434 let wire = ops::RunMicrovmWire {
435 image_identifier: request.image_identifier.clone(),
436 image_version: request.image_version.clone(),
437 execution_role_arn: request.execution_role_arn.clone(),
438 ingress_network_connectors: ingress,
439 egress_network_connectors: (!egress.is_empty()).then_some(egress),
441 idle_policy: ops::IdlePolicy {
442 max_idle_duration_seconds: request.max_idle_sec,
443 suspended_duration_seconds: request.suspended_sec,
444 auto_resume_enabled: request.auto_resume,
445 },
446 maximum_duration_in_seconds: request.max_duration_sec,
447 run_hook_payload: request.run_hook_payload.as_str().to_string(),
448 client_token: token::run_token(
449 request
450 .token_scope
451 .as_deref()
452 .unwrap_or(&request.image_identifier),
453 ),
454 };
455
456 let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
457 let reply = send_with_retry(self.transport(), call).await?;
458 let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
459 Ok(launched.into())
460 }
461
462 pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
469 self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
470 .await
471 }
472
473 pub async fn wait_for_state(
484 &self,
485 id: &str,
486 wanted: &[&str],
487 fail_on: &[&str],
488 opts: WaitOpts,
489 ) -> Result<Microvm, Error> {
490 let started = self.clock().elapsed();
491 loop {
492 let got = self.get_microvm(id).await?;
493
494 if wanted.contains(&got.state.as_str()) {
495 return Ok(got);
496 }
497 if fail_on.contains(&got.state.as_str()) {
498 return Err(self.reached_terminal_state(&got, wanted));
499 }
500
501 let elapsed = self.clock().elapsed().saturating_sub(started);
502 if elapsed >= opts.timeout {
503 return Err(timed_out(
504 &format!(
505 "microvm {id} never reached {wanted:?} (last state {})",
506 got.state
507 ),
508 elapsed,
509 ));
510 }
511 self.clock().sleep(opts.poll_interval).await;
512 }
513 }
514
515 fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
520 Error::new(
521 ErrorKind::LaunchDied,
522 format!(
523 "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
524 before RUNNING died during startup, and for a hook-serving daemon that almost \
525 always means a lifecycle hook failed — the stateReason above is the only evidence \
526 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
527 startup reports a connection error that hides the cause').",
528 got.id,
529 got.state,
530 got.reason(),
531 ),
532 )
533 }
534
535 pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
542 super::require_valid_identifier("microvmIdentifier", id)?;
543 let call = Call::get("GetMicrovm", paths::microvm(id));
544 let reply = send_with_retry(self.transport(), call).await?;
545 let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
546 Ok(got.into())
547 }
548
549 pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
559 let mut items = Vec::new();
560 let mut next_token: Option<String> = None;
561 loop {
562 let call = Call::get("ListMicrovms", paths::microvms_list(next_token.as_deref()));
563 let reply = send_with_retry(self.transport(), call).await?;
564 let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
565 items.extend(page.items);
566 match page.next_token {
567 Some(token) => next_token = Some(token),
568 None => return Ok(items),
569 }
570 }
571 }
572
573 pub async fn suspend(&self, id: &str) -> Result<(), Error> {
575 super::require_valid_identifier("microvmIdentifier", id)?;
576 let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
577 send_with_retry(self.transport(), call).await?;
578 Ok(())
579 }
580
581 pub async fn resume(&self, id: &str) -> Result<(), Error> {
583 super::require_valid_identifier("microvmIdentifier", id)?;
584 let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
585 send_with_retry(self.transport(), call).await?;
586 Ok(())
587 }
588
589 pub async fn terminate(&self, id: &str) -> Result<(), Error> {
597 super::require_valid_identifier("microvmIdentifier", id)?;
598 let call = Call::delete("TerminateMicrovm", paths::microvm(id));
599 send_with_retry(self.transport(), call).await?;
600 Ok(())
601 }
602
603 pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
615 self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
616 .await
617 }
618
619 pub async fn mint_auth_token_for(
632 &self,
633 id: &str,
634 ports: &[ops::PortSpecification],
635 ) -> Result<ProxyToken, Error> {
636 super::require_valid_identifier("microvmIdentifier", id)?;
637 if ports.is_empty() {
638 return Err(Error::new(
642 ErrorKind::Precondition,
643 "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
644 Name the ports this token is for — the agent port plus any workload port \
645 reached through the endpoint.",
646 ));
647 }
648 for spec in ports {
656 match spec {
657 ops::PortSpecification::One { port } => {
658 super::require_valid_port("allowedPorts[].port", *port)?;
659 }
660 ops::PortSpecification::Range { range } => {
661 super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
662 super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
663 }
664 ops::PortSpecification::All { .. } => {}
666 }
667 }
668 let wire = ops::CreateAuthTokenWire {
669 expiration_in_minutes: MAX_TOKEN_MINUTES,
670 allowed_ports: ports.to_vec(),
671 };
672 let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
673 let reply = send_with_retry(self.transport(), call).await?;
674 let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
675 ProxyToken::from_map(&minted.auth_token, self.port())
676 }
677}
678
679pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
684
685#[cfg(test)]
686mod tests {
687 use std::sync::Arc;
688
689 use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
690 use super::*;
691 use crate::region::Region;
692
693 fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
694 let fake = Arc::new(FakeControlPlane::new());
695 let clock = Arc::new(TestClock::new());
696 let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
697 (plane, fake, clock)
698 }
699
700 #[test]
707 fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
708 let at_ceiling = "a".repeat(4096);
709 let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
710 assert_eq!(payload.len(), 4096);
711 assert_eq!(payload.as_str(), at_ceiling);
712
713 let over = "a".repeat(4097);
714 let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
715 assert_eq!(error.kind(), ErrorKind::InvalidArg);
716 let message = error.to_string();
717 assert!(message.contains("4097 bytes"), "{message}");
718 assert!(
719 message.contains("ceiling of 4096"),
720 "the message must name the service-model ceiling: {message}"
721 );
722 assert!(message.contains("2025-09-09"), "{message}");
723 assert!(
724 message.contains("wrong by 4x"),
725 "the 16 KB claim has to be named as wrong: {message}"
726 );
727 }
728
729 #[tokio::test]
732 async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
733 let (plane, fake, _) = planted();
734
735 let refused = RunHookPayload::new("a".repeat(4097));
737 assert!(refused.is_err());
738 assert_eq!(fake.calls().len(), 0);
739
740 fake.answer(
743 "RunMicrovm",
744 Answer::ok(fake::microvm_response("PENDING", None)),
745 );
746 let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
747 plane
748 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
749 .await
750 .expect("launches");
751 assert_eq!(fake.call_count("RunMicrovm"), 1);
752 }
753
754 #[test]
757 fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
758 let two_byte_chars = "é".repeat(2049);
759 assert_eq!(
760 two_byte_chars.chars().count(),
761 2049,
762 "well under 4096 chars"
763 );
764 assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
765
766 let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
767 assert!(error.to_string().contains("4098 bytes"), "{error}");
768
769 let exact = "é".repeat(2048);
771 assert_eq!(exact.len(), 4096);
772 RunHookPayload::new(exact).expect("exactly at the ceiling");
773 }
774
775 #[test]
784 fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
785 let env = std::collections::HashMap::new();
786 let without = RunHookPayload::for_launch("tok", &env).expect("fits");
787 let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
788 assert_eq!(
789 without.as_str(),
790 none_asked.as_str(),
791 "no identity must produce byte-for-byte the payload this client always sent"
792 );
793
794 let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
795 let with =
796 RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
797 assert_eq!(
798 with.len() - without.len(),
799 protocol::identity::IDENTITY_PAYLOAD_BYTES,
800 "the budget constant must equal what the material really costs on the wire"
801 );
802
803 let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
805 assert_eq!(
806 parsed.identity_seed.as_deref(),
807 Some(identity.seed_field().as_str())
808 );
809 assert_eq!(
810 parsed.identity_host_public_key.as_deref(),
811 Some(identity.host_public_field().as_str())
812 );
813 }
814
815 #[test]
818 fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
819 let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
820 assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
821
822 let huge = "x".repeat(4096);
825 let error = RunHookPayload::for_agent_token(&huge)
826 .expect_err("the JSON wrapper pushes it over the ceiling");
827 assert_eq!(error.kind(), ErrorKind::InvalidArg);
828 assert!(
829 error.to_string().contains("one bearer token fits"),
830 "{error}"
831 );
832 }
833
834 #[test]
840 fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
841 let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
842 .expect("an empty env fits");
843 let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
844 assert_eq!(with_empty.as_str(), token_only.as_str());
845 assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
846 assert!(
847 !with_empty.as_str().contains("env"),
848 "an empty env must not appear on the wire at all: {}",
849 with_empty.as_str()
850 );
851 }
852
853 #[test]
855 fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
856 let mut env = std::collections::HashMap::new();
857 env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
858 let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
859
860 let value: serde_json::Value =
862 serde_json::from_str(payload.as_str()).expect("the payload is JSON");
863 assert_eq!(value["agent_token"], "tok");
864 assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
865 }
866
867 #[test]
885 fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
886 let mut env = std::collections::HashMap::new();
887 env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
889
890 let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
894 .to_string()
895 .len();
896 assert!(
897 expected > 4096,
898 "the fixture must actually be over: {expected}"
899 );
900
901 let error = RunHookPayload::for_launch("tok", &env)
902 .expect_err("a credential-scale launch env does not fit");
903 assert_eq!(error.kind(), ErrorKind::InvalidArg);
904 let message = error.to_string();
905
906 assert!(
907 message.contains("ceiling of 4096"),
908 "the ceiling has to be named: {message}"
909 );
910 assert!(
911 message.contains(&format!("{expected} bytes")),
912 "the actual byte count has to be named: {message}"
913 );
914 assert!(
915 message.contains("launch env contributed"),
916 "the env's share of the budget is the actionable half: {message}"
917 );
918 assert!(
919 message.contains("1 variable(s)"),
920 "the variable count has to be named: {message}"
921 );
922 }
923
924 #[test]
931 fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
932 let mut env = std::collections::HashMap::new();
933 env.insert("A".to_string(), "a".repeat(2030));
934 env.insert("B".to_string(), "b".repeat(2030));
935 let raw: usize = env.values().map(String::len).sum();
936 assert_eq!(raw, 4060, "the raw values are under the ceiling");
937 assert!(
938 raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
939 "the fixture only means something if the raw sum fits: {raw}"
940 );
941
942 let error = RunHookPayload::for_launch("tok", &env)
943 .expect_err("the serialized payload is over even though the raw values are not");
944 assert!(error.to_string().contains("2 variable(s)"), "{error}");
945
946 let mut fits = std::collections::HashMap::new();
949 fits.insert("A".to_string(), "a".repeat(2000));
950 fits.insert("B".to_string(), "b".repeat(2000));
951 let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
952 assert!(payload.len() <= 4096, "{}", payload.len());
953 }
954
955 #[test]
957 fn an_empty_payload_is_legal() {
958 let payload = RunHookPayload::new("").expect("min is 0");
959 assert!(payload.is_empty());
960 assert_eq!(payload.len(), 0);
961 }
962
963 #[tokio::test]
966 async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
967 let (plane, fake, _) = planted();
968 fake.answer(
969 "RunMicrovm",
970 Answer::ok(fake::microvm_response("PENDING", None)),
971 );
972
973 let payload = RunHookPayload::for_agent_token("token").expect("fits");
974 let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
975 plane.run_microvm(request).await.expect("launches");
976
977 let calls = fake.calls();
978 assert_eq!(calls[0].path, "/2025-09-09/microvms");
979 assert_eq!(calls[0].method, super::super::transport::Method::Post);
980
981 let body = fake.first_body("RunMicrovm");
982 assert_eq!(
983 body["ingressNetworkConnectors"],
984 serde_json::json!([
985 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
986 ])
987 );
988 assert_eq!(
989 body["egressNetworkConnectors"],
990 serde_json::json!([
991 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
992 ])
993 );
994 assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
995 assert_eq!(body["maximumDurationInSeconds"], 3600);
996 }
997
998 #[tokio::test]
1001 async fn a_launch_without_egress_omits_the_member_entirely() {
1002 let (plane, fake, _) = planted();
1003 fake.answer(
1004 "RunMicrovm",
1005 Answer::ok(fake::microvm_response("PENDING", None)),
1006 );
1007 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1008 plane
1009 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1010 .await
1011 .expect("launches");
1012
1013 let body = fake.first_body("RunMicrovm");
1014 assert!(
1015 body.get("egressNetworkConnectors").is_none(),
1016 "omitting egress is the whole mechanism: {body}"
1017 );
1018 assert!(body.get("ingressNetworkConnectors").is_some());
1019 }
1020
1021 #[tokio::test]
1034 async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1035 let (plane, fake, _) = planted();
1036 fake.answer(
1037 "RunMicrovm",
1038 Answer::ok(fake::microvm_response("PENDING", None)),
1039 );
1040 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1041 plane
1042 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1043 .await
1044 .expect("launches");
1045
1046 let body = fake.first_body("RunMicrovm");
1047 assert_eq!(body["imageVersion"], "2.0");
1048 assert_eq!(
1049 body["imageIdentifier"], "arn:image",
1050 "pinning a version does not replace the identifier; both are sent"
1051 );
1052
1053 let (plane, fake, _) = planted();
1054 fake.answer(
1055 "RunMicrovm",
1056 Answer::ok(fake::microvm_response("PENDING", None)),
1057 );
1058 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1059 plane
1060 .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1061 .await
1062 .expect("launches");
1063 assert!(
1064 fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1065 "an unpinned launch must send what this client always sent: {}",
1066 fake.first_body("RunMicrovm")
1067 );
1068 }
1069
1070 #[tokio::test]
1080 async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1081 for bad in ["", "2.0\n", "a b"] {
1082 let (plane, fake, _) = planted();
1083 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1084 let error = plane
1085 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1086 .await
1087 .expect_err(&format!("{bad:?} is not a legal Version"));
1088 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1089 assert!(
1090 error.to_string().contains("imageVersion"),
1091 "{bad:?}: {error}"
1092 );
1093 assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1094 }
1095 }
1096
1097 #[tokio::test]
1101 async fn no_bare_connector_name_reaches_the_wire() {
1102 let (plane, fake, _) = planted();
1103 fake.answer(
1104 "RunMicrovm",
1105 Answer::ok(fake::microvm_response("PENDING", None)),
1106 );
1107 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1108 plane
1109 .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1110 .await
1111 .expect("launches");
1112
1113 let body = fake.first_body("RunMicrovm");
1114 for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1115 for value in body[member].as_array().expect("a list") {
1116 let text = value.as_str().expect("a string");
1117 assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1118 assert_ne!(text, "ALL_INGRESS");
1119 assert_ne!(text, "INTERNET_EGRESS");
1120 }
1121 }
1122 }
1123
1124 #[tokio::test]
1126 async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1127 let (plane, fake, _) = planted();
1128 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1129 let mut request = RunMicrovmRequest::new("arn:image", payload);
1130 request.max_duration_sec = 28_801;
1131
1132 let error = plane
1133 .run_microvm(request)
1134 .await
1135 .expect_err("over the ceiling");
1136 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1137 assert_eq!(fake.calls().len(), 0);
1138 }
1139
1140 #[tokio::test]
1150 async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1151 let (plane, fake, _) = planted();
1152 fake.answer(
1153 "GetMicrovm",
1154 Answer::ok(fake::microvm_response(
1155 "TERMINATED",
1156 Some("run hook returned 500"),
1157 )),
1158 );
1159
1160 let error = plane
1161 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1162 .await
1163 .expect_err("a dead VM must not be waited out");
1164
1165 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1166 assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1167 let message = error.to_string();
1168 assert!(
1169 message.contains("TERMINATED"),
1170 "the state must be attached: {message}"
1171 );
1172 assert!(
1173 message.contains("run hook returned 500"),
1174 "the stateReason must be attached: {message}"
1175 );
1176 assert!(
1177 message.contains("lifecycle hook failed"),
1178 "the likely cause is worth naming: {message}"
1179 );
1180 assert!(
1181 fake.call_count("GetMicrovm") == 1,
1182 "it fails fast rather than polling to the deadline"
1183 );
1184 }
1185
1186 #[tokio::test]
1193 async fn every_terminal_state_fails_a_launch_fast() {
1194 assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1195 for state in crate::constants::TERMINAL_STATES {
1196 let (plane, fake, _) = planted();
1197 fake.answer(
1198 "GetMicrovm",
1199 Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1200 );
1201 let error = plane
1202 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1203 .await
1204 .expect_err(&format!("{state} before RUNNING is a death"));
1205 assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1206 assert!(error.to_string().contains(state), "{state}: {error}");
1207 assert!(
1208 error.to_string().contains("hook timeout"),
1209 "{state}: {error}"
1210 );
1211 assert_eq!(
1212 fake.call_count("GetMicrovm"),
1213 1,
1214 "{state} must fail fast rather than polling to the deadline"
1215 );
1216 }
1217 }
1218
1219 #[tokio::test]
1223 async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1224 let (plane, fake, _) = planted();
1225 fake.answer(
1226 "GetMicrovm",
1227 Answer::ok(fake::microvm_response("TERMINATED", None)),
1228 );
1229
1230 let error = plane
1231 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1232 .await
1233 .expect_err("still a death");
1234 let message = error.to_string();
1235 assert!(message.contains("no stateReason"), "{message}");
1236 assert!(
1240 !message.contains(r#"["RUNNING"]: ."#),
1241 "an absent reason must not render as an empty string: {message}"
1242 );
1243 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1244 }
1245
1246 #[tokio::test]
1248 async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1249 let (plane, fake, _) = planted();
1250 fake.answer(
1251 "GetMicrovm",
1252 Answer::ok(fake::microvm_response("PENDING", None)),
1253 )
1254 .answer(
1255 "GetMicrovm",
1256 Answer::ok(fake::microvm_response("RUNNING", None)),
1257 );
1258
1259 let vm = plane
1260 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1261 .await
1262 .expect("reaches RUNNING");
1263 assert_eq!(vm.state, "RUNNING");
1264 assert_eq!(
1265 vm.endpoint,
1266 "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1267 );
1268 assert_eq!(fake.call_count("GetMicrovm"), 2);
1269 }
1270
1271 #[tokio::test]
1275 async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1276 let (plane, fake, _) = planted();
1277 fake.answer(
1278 "GetMicrovm",
1279 Answer::ok(fake::microvm_response("PENDING", None)),
1280 );
1281
1282 let error = plane
1283 .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1284 .await
1285 .expect_err("the deadline elapses");
1286 assert_eq!(error.kind(), ErrorKind::Timeout);
1287 assert_ne!(
1288 error.kind(),
1289 ErrorKind::LaunchDied,
1290 "a timeout is not a death, and conflating them is what hid the cause"
1291 );
1292 assert!(error.to_string().contains("PENDING"), "{error}");
1293 }
1294
1295 #[tokio::test]
1298 async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1299 let (plane, fake, _) = planted();
1300 fake.answer(
1301 "GetMicrovm",
1302 Answer::ok(fake::microvm_response("SUSPENDED", None)),
1303 )
1304 .answer(
1305 "GetMicrovm",
1306 Answer::ok(fake::microvm_response("RUNNING", None)),
1307 );
1308
1309 let vm = plane
1310 .wait_for_state(
1311 "mvm-abc123",
1312 &["RUNNING"],
1313 &crate::constants::DEAD_STATES,
1314 WaitOpts::for_launch(),
1315 )
1316 .await
1317 .expect("SUSPENDED is a waypoint on the resume path");
1318 assert_eq!(vm.state, "RUNNING");
1319
1320 let (plane, fake, _) = planted();
1321 fake.answer(
1322 "GetMicrovm",
1323 Answer::ok(fake::microvm_response(
1324 "TERMINATED",
1325 Some("suspended window elapsed"),
1326 )),
1327 );
1328 let error = plane
1329 .wait_for_state(
1330 "mvm-abc123",
1331 &["RUNNING"],
1332 &crate::constants::DEAD_STATES,
1333 WaitOpts::for_launch(),
1334 )
1335 .await
1336 .expect_err("a terminated VM never reaches RUNNING");
1337 assert_eq!(error.kind(), ErrorKind::LaunchDied);
1338 assert!(
1339 error.to_string().contains("suspended window elapsed"),
1340 "{error}"
1341 );
1342 }
1343
1344 #[tokio::test]
1347 async fn a_suspend_treats_terminated_as_a_state_to_report() {
1348 let (plane, fake, _) = planted();
1349 fake.answer(
1350 "GetMicrovm",
1351 Answer::ok(fake::microvm_response("TERMINATED", None)),
1352 );
1353
1354 let vm = plane
1355 .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1356 .await
1357 .expect("a suspend reports the state it reached");
1358 assert_eq!(vm.state, "TERMINATED");
1359 }
1360
1361 #[tokio::test]
1364 async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1365 let (plane, fake, _) = planted();
1366 fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1367 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1368 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1369
1370 plane.suspend("mvm-1").await.expect("suspends");
1371 plane.resume("mvm-1").await.expect("resumes");
1372 plane.terminate("mvm-1").await.expect("terminates");
1373
1374 let calls = fake.calls();
1375 use super::super::transport::Method;
1376 assert_eq!(
1377 calls
1378 .iter()
1379 .map(|call| (call.operation, call.method, call.path.as_str()))
1380 .collect::<Vec<_>>(),
1381 [
1382 (
1383 "SuspendMicrovm",
1384 Method::Post,
1385 "/2025-09-09/microvms/mvm-1/suspend"
1386 ),
1387 (
1388 "ResumeMicrovm",
1389 Method::Post,
1390 "/2025-09-09/microvms/mvm-1/resume"
1391 ),
1392 (
1393 "TerminateMicrovm",
1394 Method::Delete,
1395 "/2025-09-09/microvms/mvm-1"
1396 ),
1397 ]
1398 );
1399 }
1400
1401 #[tokio::test]
1404 async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1405 let (plane, fake, _) = planted();
1406 fake.answer(
1407 "CreateMicrovmAuthToken",
1408 Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1409 );
1410
1411 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1412 assert_eq!(token.value(), "opaque-proxy-token");
1413 assert_eq!(token.port(), 9000);
1414
1415 let headers = token.headers();
1416 assert_eq!(
1417 headers[0],
1418 ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1419 );
1420 assert_eq!(
1421 headers[1],
1422 ("X-aws-proxy-port", "9000".to_string()),
1423 "the auth header without the port header is rejected in a way that reads like a \
1424 bad token"
1425 );
1426
1427 let body = fake.first_body("CreateMicrovmAuthToken");
1428 assert_eq!(body["expirationInMinutes"], 60);
1429 assert_eq!(body["allowedPorts"][0]["port"], 9000);
1430 }
1431
1432 #[test]
1440 fn a_launch_requests_debug_does_not_print_the_agent_token() {
1441 let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1442
1443 let rendered = format!("{payload:?}");
1444 assert!(
1445 !rendered.contains("SECRETTOKEN"),
1446 "the agent token reached a Debug string: {rendered}"
1447 );
1448 assert!(
1449 rendered.contains(&format!("{} bytes", payload.len())),
1450 "the size is what a TRAP-5 diagnosis needs: {rendered}"
1451 );
1452
1453 let request = RunMicrovmRequest::new("arn:image", payload);
1454 let rendered = format!("{request:?}");
1455 assert!(rendered.contains("arn:image"), "{rendered}");
1456 assert!(
1457 !rendered.contains("SECRETTOKEN"),
1458 "the agent token reached a Debug string through the request: {rendered}"
1459 );
1460 }
1461
1462 #[test]
1469 fn a_proxy_token_debug_does_not_print_the_credential() {
1470 let mut map = std::collections::BTreeMap::new();
1471 map.insert(
1472 PROXY_AUTH_HEADER.to_string(),
1473 "eyJhbGciOi-secret".to_string(),
1474 );
1475 let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1476
1477 let rendered = format!("{token:?}");
1478 assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1479 assert!(rendered.contains("9000"), "{rendered}");
1480 assert!(
1481 !rendered.contains("secret"),
1482 "the token value reached a Debug string: {rendered}"
1483 );
1484 }
1485
1486 #[test]
1489 fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1490 let mut map = std::collections::BTreeMap::new();
1491 map.insert("SomeOtherKey".to_string(), "value".to_string());
1492
1493 let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1494 assert_eq!(
1495 error.wire_kind(),
1496 Some(crate::error::WireKind::AuthTokenMint)
1497 );
1498 assert!(error.retryable(), "minting is inside the retry path");
1499 assert!(
1500 error.to_string().contains("is a map, not a string"),
1501 "{error}"
1502 );
1503 }
1504
1505 #[tokio::test]
1507 async fn the_token_is_minted_for_the_configured_port() {
1508 let fake = Arc::new(FakeControlPlane::new());
1509 let plane =
1510 ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()))
1511 .with_port(8080)
1512 .expect("8080 is a legal port");
1513 fake.answer(
1514 "CreateMicrovmAuthToken",
1515 Answer::ok(fake::auth_token_response("t")),
1516 );
1517
1518 let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1519 assert_eq!(token.port(), 8080);
1520 assert_eq!(
1521 fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1522 8080
1523 );
1524 }
1525
1526 #[tokio::test]
1544 async fn a_full_lifecycle_never_calls_shell_auth_or_requests_shell_ingress() {
1545 let (plane, fake, _) = planted();
1546 fake.answer(
1547 "CreateMicrovmImage",
1548 Answer::created(fake::create_image_response("img")),
1549 )
1550 .answer(
1551 "GetMicrovmImage",
1552 Answer::ok(fake::get_image_response("img", "CREATED")),
1553 )
1554 .answer(
1555 "RunMicrovm",
1556 Answer::ok(fake::microvm_response("PENDING", None)),
1557 )
1558 .answer(
1559 "GetMicrovm",
1560 Answer::ok(fake::microvm_response("RUNNING", None)),
1561 )
1562 .answer(
1563 "CreateMicrovmAuthToken",
1564 Answer::ok(fake::auth_token_response("proxy-token")),
1565 )
1566 .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1567 .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1568 .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1569 .answer(
1570 "ListMicrovmImageVersions",
1571 Answer::ok(fake::list_versions_response("1")),
1572 )
1573 .answer(
1574 "DeleteMicrovmImage",
1575 Answer::ok(fake::delete_image_response()),
1576 );
1577
1578 let request = super::super::CreateImageRequest::new(
1580 "img",
1581 b"binary".to_vec(),
1582 "s3://bucket/img.zip",
1583 "arn:aws:iam::123456789012:role/build",
1584 );
1585 let image = plane.create_image(request).await.expect("creates");
1586 plane
1587 .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1588 .await
1589 .expect("becomes usable");
1590
1591 let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1592 let vm = plane
1593 .run_microvm(
1594 super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1595 )
1596 .await
1597 .expect("launches");
1598 plane
1599 .wait_for_running(&vm.id, WaitOpts::for_launch())
1600 .await
1601 .expect("reaches RUNNING");
1602 plane.mint_auth_token(&vm.id).await.expect("mints");
1603 plane.suspend(&vm.id).await.expect("suspends");
1604 plane.resume(&vm.id).await.expect("resumes");
1605 plane.terminate(&vm.id).await.expect("terminates");
1606 plane
1607 .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
1608 .await;
1609
1610 let operations = fake.operations();
1613 assert!(
1614 operations.len() >= 10,
1615 "the lifecycle must actually have run: {operations:?}"
1616 );
1617 assert!(operations.contains(&"CreateMicrovmAuthToken"));
1618
1619 assert_eq!(
1620 fake.call_count("CreateMicrovmShellAuthToken"),
1621 0,
1622 "shell auth gates a debug console, not programmatic exec: {operations:?}"
1623 );
1624 for operation in &operations {
1625 assert!(
1626 !operation.contains("Shell"),
1627 "no operation may be a shell one: {operation}"
1628 );
1629 }
1630 for path in fake.paths() {
1631 assert!(
1632 !path.contains("shell"),
1633 "no request may reach a shell route: {path}"
1634 );
1635 }
1636 for body in fake.bodies_as_text() {
1637 assert!(
1638 !body.contains("SHELL_INGRESS"),
1639 "no request may name the shell connector: {body}"
1640 );
1641 assert!(!body.contains("SHELL"), "{body}");
1642 }
1643 }
1644
1645 #[tokio::test]
1658 async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
1659 let (plane, fake, _) = planted();
1660 fake.answer(
1661 "ListMicrovms",
1662 Answer::ok(fake::list_microvms_page(
1663 &["mvm-page1-a", "mvm-page1-b"],
1664 Some("fleet-page-2"),
1665 )),
1666 )
1667 .answer(
1668 "ListMicrovms",
1669 Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
1670 );
1671
1672 let fleet = plane.list_microvms().await.expect("lists");
1673 assert_eq!(
1674 fleet.len(),
1675 3,
1676 "a fleet spanning two pages is three VMs, not two"
1677 );
1678
1679 let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
1680 assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
1681 assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
1682 assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
1683
1684 let paths = fake.paths();
1685 assert!(
1686 paths[1].contains("nextToken=fleet-page-2"),
1687 "the second request must carry the first page's token: {}",
1688 paths[1]
1689 );
1690 assert!(
1691 !paths[0].contains('?'),
1692 "the first request carries no cursor: {}",
1693 paths[0]
1694 );
1695 }
1696
1697 #[tokio::test]
1707 async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
1708 let (plane, fake, _) = planted();
1709 fake.answer(
1710 "GetMicrovm",
1711 Answer::ok(fake::microvm_response("RUNNING", None)),
1712 );
1713
1714 let vm = plane.get_microvm("mvm-1").await.expect("reads");
1715 let policy = vm
1716 .idle_policy
1717 .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
1718 assert_eq!(policy.max_idle_duration_seconds, 1800);
1719 assert_eq!(
1720 policy.suspended_duration_seconds, 600,
1721 "the member the comment claimed was request-only"
1722 );
1723 assert!(!policy.auto_resume_enabled);
1724 }
1725
1726 #[tokio::test]
1730 async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
1731 let (plane, fake, _) = planted();
1732 fake.answer(
1733 "GetMicrovm",
1734 Answer::ok(
1735 r#"{
1736 "microvmId": "mvm-1",
1737 "state": "RUNNING",
1738 "endpoint": "https://e",
1739 "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1740 "imageVersion": "1"
1741 }"#,
1742 ),
1743 );
1744
1745 let vm = plane.get_microvm("mvm-1").await.expect("parses");
1746 assert_eq!(vm.idle_policy, None);
1747 }
1748
1749 #[tokio::test]
1752 async fn a_transport_failure_is_retried_rather_than_reported() {
1753 let (plane, fake, _) = planted();
1754 fake.fail_transport("GetMicrovm", 2).answer(
1755 "GetMicrovm",
1756 Answer::ok(fake::microvm_response("RUNNING", None)),
1757 );
1758
1759 let vm = plane
1760 .get_microvm("mvm-1")
1761 .await
1762 .expect("the retry succeeds");
1763 assert_eq!(vm.state, "RUNNING");
1764 assert_eq!(
1765 fake.call_count("GetMicrovm"),
1766 3,
1767 "two failures then a success"
1768 );
1769 }
1770
1771 #[tokio::test]
1788 async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
1789 for under in [0, 1, 59] {
1790 let (plane, fake, _) = planted();
1791 fake.answer(
1792 "RunMicrovm",
1793 Answer::ok(fake::microvm_response("PENDING", None)),
1794 );
1795
1796 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1797 let mut request = RunMicrovmRequest::new("arn:image", payload);
1798 request.max_idle_sec = under;
1799
1800 let error = plane
1801 .run_microvm(request)
1802 .await
1803 .expect_err("the model's min is 60");
1804 assert_eq!(error.kind(), ErrorKind::InvalidArg);
1805 let message = error.to_string();
1806 assert!(message.contains("maxIdleDurationSeconds"), "{message}");
1807 assert!(message.contains(&under.to_string()), "{message}");
1808 assert_eq!(
1809 fake.calls().len(),
1810 0,
1811 "{under} was refused locally; before this guard it was serialized and sent"
1812 );
1813 }
1814
1815 let (plane, fake, _) = planted();
1818 fake.answer(
1819 "RunMicrovm",
1820 Answer::ok(fake::microvm_response("PENDING", None)),
1821 );
1822 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1823 let mut request = RunMicrovmRequest::new("arn:image", payload);
1824 request.max_idle_sec = 60;
1825 plane.run_microvm(request).await.expect("60 is the minimum");
1826 assert_eq!(
1827 fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
1828 60
1829 );
1830 }
1831
1832 #[tokio::test]
1842 async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
1843 type Mutate = fn(&mut RunMicrovmRequest);
1846 let rows: [(&str, Mutate, &str); 6] = [
1847 (
1848 "an execution role that is a bare name",
1849 |request| {
1850 request.execution_role_arn = Some("execution-role".to_string());
1851 },
1852 "role *name*",
1853 ),
1854 (
1855 "an execution role with eleven account digits",
1856 |request| {
1857 request.execution_role_arn =
1858 Some("arn:aws:iam::12345678901:role/exec".to_string());
1859 },
1860 "exactly twelve digits",
1861 ),
1862 (
1863 "an execution role that is a function ARN",
1864 |request| {
1865 request.execution_role_arn =
1866 Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
1867 },
1868 "RoleArn pattern",
1869 ),
1870 (
1871 "an empty image identifier",
1872 |request| {
1873 request.image_identifier = String::new();
1874 },
1875 "collapses the path",
1876 ),
1877 (
1878 "a 257-character image identifier",
1879 |request| {
1880 request.image_identifier = "a".repeat(257);
1881 },
1882 "MicrovmImageArn permits 2048",
1883 ),
1884 (
1885 "an image version with a newline",
1886 |request| {
1887 request.image_version = Some("2.0\n".to_string());
1888 },
1889 "contains whitespace",
1890 ),
1891 ];
1892
1893 for (label, mutate, expected) in rows {
1894 let (plane, fake, _) = planted();
1895 fake.answer(
1896 "RunMicrovm",
1897 Answer::ok(fake::microvm_response("PENDING", None)),
1898 );
1899
1900 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1901 let mut request = RunMicrovmRequest::new("arn:image", payload);
1902 mutate(&mut request);
1903
1904 let error = plane
1905 .run_microvm(request)
1906 .await
1907 .expect_err(&format!("{label} must be refused"));
1908 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1909 assert!(
1910 error.to_string().contains(expected),
1911 "{label}: wanted {expected:?}, got {error}"
1912 );
1913 assert_eq!(fake.calls().len(), 0, "{label}");
1914 }
1915
1916 let (plane, fake, _) = planted();
1919 fake.answer(
1920 "RunMicrovm",
1921 Answer::ok(fake::microvm_response("PENDING", None)),
1922 );
1923 let payload = RunHookPayload::for_agent_token("token").expect("fits");
1924 let mut request = RunMicrovmRequest::new(
1925 "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
1926 payload,
1927 );
1928 request.execution_role_arn =
1929 Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
1930 request.image_version = Some("1.0".to_string());
1931 plane
1932 .run_microvm(request)
1933 .await
1934 .expect("a realistic pinned launch with a real execution role");
1935 assert_eq!(fake.call_count("RunMicrovm"), 1);
1936 }
1937
1938 #[tokio::test]
1948 async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
1949 for bad in ["", &"a".repeat(257)] {
1950 let (plane, fake, _) = planted();
1951 fake.answer(
1954 "GetMicrovm",
1955 Answer::ok(fake::microvm_response("RUNNING", None)),
1956 );
1957 fake.answer("SuspendMicrovm", Answer::ok("{}"));
1958 fake.answer("ResumeMicrovm", Answer::ok("{}"));
1959 fake.answer("TerminateMicrovm", Answer::ok("{}"));
1960 fake.answer(
1961 "CreateMicrovmAuthToken",
1962 Answer::ok(fake::auth_token_response("t")),
1963 );
1964
1965 plane.get_microvm(bad).await.expect_err("GetMicrovm");
1966 plane.suspend(bad).await.expect_err("SuspendMicrovm");
1967 plane.resume(bad).await.expect_err("ResumeMicrovm");
1968 plane.terminate(bad).await.expect_err("TerminateMicrovm");
1969 plane
1970 .mint_auth_token(bad)
1971 .await
1972 .expect_err("CreateMicrovmAuthToken");
1973 plane
1974 .wait_for_running(bad, WaitOpts::for_launch())
1975 .await
1976 .expect_err("GetMicrovm, in a loop");
1977
1978 assert_eq!(
1979 fake.calls().len(),
1980 0,
1981 "six operations refused {bad:?} locally. An empty identifier on the terminate \
1982 path is a DELETE addressed at the collection."
1983 );
1984 }
1985 }
1986
1987 #[tokio::test]
2002 async fn no_proxy_token_is_minted_for_port_zero() {
2003 let rows: [(&str, ops::PortSpecification, &str); 3] = [
2004 (
2005 "a single port of 0",
2006 ops::PortSpecification::port(0),
2007 "allowedPorts[].port",
2008 ),
2009 (
2010 "a range starting at 0",
2011 ops::PortSpecification::range(0, 9000),
2012 "allowedPorts[].range.startPort",
2013 ),
2014 (
2015 "a range ending at 0",
2016 ops::PortSpecification::range(0, 0),
2017 "allowedPorts[].range.startPort",
2018 ),
2019 ];
2020
2021 for (label, spec, member) in rows {
2022 let (plane, fake, _) = planted();
2023 fake.answer(
2024 "CreateMicrovmAuthToken",
2025 Answer::ok(fake::auth_token_response("t")),
2026 );
2027
2028 let error = plane
2029 .mint_auth_token_for("mvm-1", &[spec])
2030 .await
2031 .expect_err(&format!("{label} must be refused"));
2032 assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2033 let message = error.to_string();
2034 assert!(message.contains(member), "{label}: {message}");
2035 assert!(
2036 message.contains("authorizes nothing"),
2037 "{label}: the 403 consequence has to be in the message: {message}"
2038 );
2039 assert_eq!(fake.calls().len(), 0, "{label}");
2040 }
2041
2042 let (plane, fake, _) = planted();
2046 fake.answer(
2047 "CreateMicrovmAuthToken",
2048 Answer::ok(fake::auth_token_response("t")),
2049 );
2050 plane
2051 .mint_auth_token_for(
2052 "mvm-1",
2053 &[
2054 ops::PortSpecification::port(9000),
2055 ops::PortSpecification::port(0),
2056 ],
2057 )
2058 .await
2059 .expect_err("every entry is checked, not the first");
2060 assert_eq!(fake.calls().len(), 0);
2061
2062 for legal in [
2065 ops::PortSpecification::port(1),
2066 ops::PortSpecification::port(65_535),
2067 ops::PortSpecification::range(1, 65_535),
2068 ops::PortSpecification::all(),
2069 ] {
2070 let (plane, fake, _) = planted();
2071 fake.answer(
2072 "CreateMicrovmAuthToken",
2073 Answer::ok(fake::auth_token_response("t")),
2074 );
2075 plane
2076 .mint_auth_token_for("mvm-1", &[legal])
2077 .await
2078 .expect("a legal port specification mints");
2079 assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2080 }
2081 }
2082}