1use crate::error::{ErrorData, Result};
11use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
12use crate::resources::ToolchainConfig;
13use crate::Platform;
14use alien_error::AlienError;
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::fmt::Debug;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase", tag = "type")]
24pub enum SandboxCode {
25 #[serde(rename_all = "camelCase")]
27 Image {
28 image: String,
30 },
31 #[serde(rename_all = "camelCase")]
33 Source {
34 src: String,
36 toolchain: ToolchainConfig,
38 },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48#[serde(rename_all = "camelCase", deny_unknown_fields)]
49pub struct SandboxLimits {
50 pub cpu: String,
52 pub memory: String,
54 pub disk: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub max_processes: Option<u32>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct MicrovmTier {
72 pub baseline_memory_mib: i64,
74 pub peak_memory_mib: i64,
76 pub peak_vcpu: u32,
78 pub max_disk_mib: i64,
80}
81
82const AWS_MAX_SESSION_LIFETIME_SECONDS: u32 = 28_800;
86
87const MICROVM_TIERS: &[MicrovmTier] = &[
88 MicrovmTier {
89 baseline_memory_mib: 512,
90 peak_memory_mib: 2048,
91 peak_vcpu: 1,
92 max_disk_mib: 8192,
93 },
94 MicrovmTier {
95 baseline_memory_mib: 1024,
96 peak_memory_mib: 4096,
97 peak_vcpu: 2,
98 max_disk_mib: 8192,
99 },
100 MicrovmTier {
101 baseline_memory_mib: 2048,
102 peak_memory_mib: 8192,
103 peak_vcpu: 4,
104 max_disk_mib: 8192,
105 },
106 MicrovmTier {
107 baseline_memory_mib: 4096,
108 peak_memory_mib: 16384,
109 peak_vcpu: 8,
110 max_disk_mib: 16384,
111 },
112 MicrovmTier {
113 baseline_memory_mib: 8192,
114 peak_memory_mib: 32768,
115 peak_vcpu: 16,
116 max_disk_mib: 32768,
117 },
118];
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123#[serde(rename_all = "camelCase", tag = "mode")]
124pub enum SandboxEgress {
125 Deny,
130 Allow,
135 #[serde(rename_all = "camelCase")]
137 AllowDomains {
138 domains: Vec<String>,
140 },
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146#[serde(rename_all = "camelCase", deny_unknown_fields)]
147pub struct SandboxSessionPolicy {
148 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub max_lifetime_seconds: Option<u32>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub idle_suspend_seconds: Option<u32>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
168#[serde(rename_all = "camelCase", deny_unknown_fields)]
169pub struct SandboxCapabilities {
170 pub files: bool,
174 pub reconnect: bool,
176 pub preview: bool,
178 pub suspend_resume: bool,
180 pub snapshot: bool,
182 pub domain_egress_rules: bool,
184 pub egress_deny: bool,
186 pub enforced_limits: bool,
188 pub process_limit: bool,
190 pub session_lifetime: bool,
192 pub supervisor_pid_namespace: bool,
198}
199
200impl SandboxCapabilities {
201 pub fn for_platform(platform: Platform) -> Result<Self> {
207 match platform {
208 Platform::Aws => Ok(Self {
209 files: true,
210 reconnect: true,
211 preview: true,
212 suspend_resume: true,
213 snapshot: false,
214 domain_egress_rules: false,
215 egress_deny: true,
216 enforced_limits: true,
217 process_limit: false,
219 session_lifetime: true,
222 supervisor_pid_namespace: false,
227 }),
228 Platform::Azure => Ok(Self {
233 files: false,
234 reconnect: true,
235 preview: false,
236 suspend_resume: false,
237 snapshot: false,
238 domain_egress_rules: false,
239 egress_deny: false,
240 enforced_limits: false,
241 process_limit: false,
242 session_lifetime: false,
243 supervisor_pid_namespace: false,
245 }),
246 Platform::Gcp => Ok(Self {
250 files: true,
251 reconnect: false,
252 preview: false,
253 suspend_resume: false,
254 snapshot: false,
255 domain_egress_rules: false,
256 egress_deny: true,
257 enforced_limits: false,
258 process_limit: false,
259 session_lifetime: false,
260 supervisor_pid_namespace: false,
262 }),
263 Platform::Kubernetes => Ok(Self {
266 files: true,
267 reconnect: true,
268 preview: false,
269 suspend_resume: false,
270 snapshot: false,
271 domain_egress_rules: false,
272 egress_deny: true,
273 enforced_limits: true,
274 process_limit: false,
276 session_lifetime: true,
278 supervisor_pid_namespace: false,
282 }),
283 Platform::Local => Ok(Self {
284 files: true,
285 reconnect: true,
286 preview: true,
287 suspend_resume: false,
288 snapshot: false,
289 domain_egress_rules: false,
290 egress_deny: true,
291 enforced_limits: true,
292 process_limit: true,
294 session_lifetime: false,
295 supervisor_pid_namespace: false,
298 }),
299 Platform::Machines | Platform::Test => {
300 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
301 platform: platform.to_string(),
302 }))
303 }
304 }
305 }
306
307 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
309 let available = match capability {
310 SandboxCapability::Files => self.files,
311 SandboxCapability::Reconnect => self.reconnect,
312 SandboxCapability::Preview => self.preview,
313 SandboxCapability::SuspendResume => self.suspend_resume,
314 SandboxCapability::Snapshot => self.snapshot,
315 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
316 SandboxCapability::EgressDeny => self.egress_deny,
317 SandboxCapability::EnforcedLimits => self.enforced_limits,
318 SandboxCapability::ProcessLimit => self.process_limit,
319 SandboxCapability::SessionLifetime => self.session_lifetime,
320 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
321 };
322
323 if available {
324 return Ok(());
325 }
326
327 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
328 capability: capability.as_str().to_string(),
329 platform: platform.to_string(),
330 }))
331 }
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
337#[serde(rename_all = "camelCase")]
338pub enum SandboxCapability {
339 Files,
341 Reconnect,
343 Preview,
345 SuspendResume,
347 Snapshot,
349 DomainEgressRules,
351 EgressDeny,
353 EnforcedLimits,
355 ProcessLimit,
357 SessionLifetime,
359 SupervisorPidNamespace,
361}
362
363impl SandboxCapability {
364 pub fn as_str(&self) -> &'static str {
366 match self {
367 Self::Files => "files",
368 Self::Reconnect => "reconnect",
369 Self::Preview => "preview",
370 Self::SuspendResume => "suspendResume",
371 Self::Snapshot => "snapshot",
372 Self::DomainEgressRules => "domainEgressRules",
373 Self::EgressDeny => "egressDeny",
374 Self::EnforcedLimits => "enforcedLimits",
375 Self::ProcessLimit => "processLimit",
376 Self::SessionLifetime => "sessionLifetime",
377 Self::SupervisorPidNamespace => "supervisorPidNamespace",
378 }
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
384#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
385#[serde(rename_all = "camelCase", deny_unknown_fields)]
386#[builder(start_fn = new)]
387pub struct Sandbox {
388 #[builder(start_fn)]
391 pub id: String,
392 pub code: SandboxCode,
394 #[serde(skip_serializing_if = "Option::is_none")]
400 pub limits: Option<SandboxLimits>,
401 pub egress: SandboxEgress,
403 pub session: SandboxSessionPolicy,
405 #[builder(default)]
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
409 pub preview_ports: Vec<u16>,
410}
411
412pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
419 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
420}
421
422pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
429 stack.resources().any(|(_resource_id, resource)| {
430 resource
431 .config
432 .downcast_ref::<Sandbox>()
433 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
434 })
435}
436
437impl Sandbox {
438 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
440
441 pub fn id(&self) -> &str {
443 &self.id
444 }
445
446 pub fn resolved_limits(&self) -> SandboxLimits {
452 self.limits.clone().unwrap_or_else(default_limits)
453 }
454
455 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
460 let capabilities = SandboxCapabilities::for_platform(platform)?;
461
462 if let SandboxCode::Source { .. } = &self.code {
466 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
467 resource_id: self.id.clone(),
468 field: "code".to_string(),
469 value: "source".to_string(),
470 reason: "no sandbox backend builds an image from source yet; give code.image a \
471 prebuilt reference"
472 .to_string(),
473 }));
474 }
475
476 let Some(limits) = self.limits.as_ref() else {
477 return self.validate_capabilities(&capabilities, platform);
479 };
480
481 validate_quantity(&self.id, "cpu", &limits.cpu)?;
482 validate_quantity(&self.id, "memory", &limits.memory)?;
483 validate_quantity(&self.id, "disk", &limits.disk)?;
484
485 if let Some(max_processes) = limits.max_processes {
486 if max_processes == 0 {
487 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
488 resource_id: self.id.clone(),
489 field: "maxProcesses".to_string(),
490 value: "0".to_string(),
491 reason: "a sandbox that may run no processes cannot run code".to_string(),
492 }));
493 }
494 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
495 }
496
497 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
500
501 if platform == Platform::Aws {
502 self.microvm_tier()?;
505
506 if let Some(seconds) = self.session.max_lifetime_seconds {
511 if !(1..=AWS_MAX_SESSION_LIFETIME_SECONDS).contains(&seconds) {
512 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
513 resource_id: self.id.clone(),
514 field: "maxLifetimeSeconds".to_string(),
515 value: seconds.to_string(),
516 reason: format!(
517 "AWS runs a MicroVM for between 1 and \
518 {AWS_MAX_SESSION_LIFETIME_SECONDS} seconds"
519 ),
520 }));
521 }
522 }
523 }
524
525 self.validate_capabilities(&capabilities, platform)
526 }
527
528 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
535 let Some(limits) = self.limits.as_ref() else {
536 return Ok(MICROVM_TIERS[2]);
538 };
539
540 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
541 AlienError::new(ErrorData::SandboxLimitInvalid {
542 resource_id: self.id.clone(),
543 field: "memory".to_string(),
544 value: limits.memory.clone(),
545 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
546 })
547 })?;
548 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
549 AlienError::new(ErrorData::SandboxLimitInvalid {
550 resource_id: self.id.clone(),
551 field: "disk".to_string(),
552 value: limits.disk.clone(),
553 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
554 })
555 })?;
556 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
557 AlienError::new(ErrorData::SandboxLimitInvalid {
558 resource_id: self.id.clone(),
559 field: "cpu".to_string(),
560 value: limits.cpu.clone(),
561 reason: "expected cores or millicores".to_string(),
562 })
563 })?;
564
565 let sized = |tier: &&MicrovmTier| {
570 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
571 };
572
573 let tier = MICROVM_TIERS
574 .iter()
575 .rev()
576 .find(sized)
577 .copied()
578 .ok_or_else(|| {
579 AlienError::new(ErrorData::SandboxLimitInvalid {
580 resource_id: self.id.clone(),
581 field: "memory".to_string(),
582 value: limits.memory.clone(),
583 reason: format!(
584 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
585 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
586 disk fit no size",
587 limits.memory, limits.disk
588 ),
589 })
590 })?;
591
592 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
593 if cpu_millicores < required_millicores {
594 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
595 resource_id: self.id.clone(),
596 field: "cpu".to_string(),
597 value: limits.cpu.clone(),
598 reason: format!(
599 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
600 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
601 limits.memory, tier.peak_vcpu, tier.peak_vcpu
602 ),
603 }));
604 }
605
606 Ok(tier)
607 }
608
609 fn validate_capabilities(
611 &self,
612 capabilities: &SandboxCapabilities,
613 platform: Platform,
614 ) -> Result<()> {
615 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
616 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
617 }
618
619 if matches!(self.egress, SandboxEgress::Deny) {
623 capabilities.require(SandboxCapability::EgressDeny, platform)?;
624 }
625
626 if !self.preview_ports.is_empty() {
627 capabilities.require(SandboxCapability::Preview, platform)?;
628 }
629
630 if self.session.idle_suspend_seconds.is_some() {
631 capabilities.require(SandboxCapability::SuspendResume, platform)?;
632 }
633
634 if self.session.max_lifetime_seconds.is_some() {
635 capabilities.require(SandboxCapability::SessionLifetime, platform)?;
636 }
637
638 Ok(())
639 }
640}
641
642fn default_limits() -> SandboxLimits {
647 SandboxLimits {
648 cpu: "1".to_string(),
649 memory: "2Gi".to_string(),
650 disk: "8Gi".to_string(),
651 max_processes: None,
652 }
653}
654
655fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
657 let invalid = |reason: &str| {
658 AlienError::new(ErrorData::SandboxLimitInvalid {
659 resource_id: resource_id.to_string(),
660 field: field.to_string(),
661 value: value.to_string(),
662 reason: reason.to_string(),
663 })
664 };
665
666 let digits_end = value
667 .find(|c: char| !c.is_ascii_digit() && c != '.')
668 .unwrap_or(value.len());
669 let (number, suffix) = value.split_at(digits_end);
670
671 let parsed: f64 = number
672 .parse()
673 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
674
675 if parsed <= 0.0 {
676 return Err(invalid("must be greater than zero"));
677 }
678
679 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
680 if !SUFFIXES.contains(&suffix) {
681 return Err(invalid(
682 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
683 ));
684 }
685
686 Ok(())
687}
688
689fn split_quantity(value: &str) -> Option<(f64, &str)> {
691 let trimmed = value.trim();
692 let digits_end = trimmed
693 .find(|c: char| !c.is_ascii_digit() && c != '.')
694 .unwrap_or(trimmed.len());
695 let (number, suffix) = trimmed.split_at(digits_end);
696 number.parse().ok().map(|number| (number, suffix))
697}
698
699pub fn quantity_mib(value: &str) -> Option<i64> {
705 let (number, suffix) = split_quantity(value)?;
706 let bytes = match suffix {
707 "" => number,
708 "k" => number * 1e3,
709 "M" => number * 1e6,
710 "G" => number * 1e9,
711 "T" => number * 1e12,
712 "Ki" => number * 1024.0,
713 "Mi" => number * 1024.0 * 1024.0,
714 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
715 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
716 _ => return None,
718 };
719 Some((bytes / (1024.0 * 1024.0)) as i64)
720}
721
722pub fn millicores(value: &str) -> Option<i64> {
724 let (number, suffix) = split_quantity(value)?;
725 match suffix {
726 "" => Some((number * 1000.0) as i64),
727 "m" => Some(number as i64),
728 _ => None,
729 }
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
735#[serde(rename_all = "camelCase")]
736pub struct SandboxOutputs {
737 pub parent_name: String,
739 #[serde(skip_serializing_if = "Option::is_none")]
741 pub identifier: Option<String>,
742 #[serde(skip_serializing_if = "Option::is_none")]
744 pub endpoint: Option<String>,
745}
746
747impl ResourceOutputsDefinition for SandboxOutputs {
748 fn get_resource_type(&self) -> ResourceType {
749 Sandbox::RESOURCE_TYPE
750 }
751
752 fn as_any(&self) -> &dyn Any {
753 self
754 }
755
756 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
757 Box::new(self.clone())
758 }
759
760 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
761 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
762 }
763
764 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
765 serde_json::to_value(self)
766 }
767}
768
769impl ResourceDefinition for Sandbox {
770 fn get_resource_type(&self) -> ResourceType {
771 Self::RESOURCE_TYPE
772 }
773
774 fn id(&self) -> &str {
775 &self.id
776 }
777
778 fn get_dependencies(&self) -> Vec<ResourceRef> {
779 Vec::new()
780 }
781
782 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
783 let new_sandbox = new_config
784 .as_any()
785 .downcast_ref::<Sandbox>()
786 .ok_or_else(|| {
787 AlienError::new(ErrorData::UnexpectedResourceType {
788 resource_id: self.id.clone(),
789 expected: Self::RESOURCE_TYPE,
790 actual: new_config.get_resource_type(),
791 })
792 })?;
793
794 if self.id != new_sandbox.id {
795 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
796 resource_id: self.id.clone(),
797 reason: "the 'id' field is immutable".to_string(),
798 }));
799 }
800
801 Ok(())
802 }
803
804 fn as_any(&self) -> &dyn Any {
805 self
806 }
807
808 fn as_any_mut(&mut self) -> &mut dyn Any {
809 self
810 }
811
812 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
813 Box::new(self.clone())
814 }
815
816 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
817 other.as_any().downcast_ref::<Sandbox>() == Some(self)
818 }
819
820 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
821 serde_json::to_value(self)
822 }
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
830 Sandbox::new("agent-sbx".to_string())
831 .code(SandboxCode::Image {
832 image: "ubuntu:24.04".to_string(),
833 })
834 .limits(SandboxLimits {
835 cpu: "1".to_string(),
836 memory: "2Gi".to_string(),
837 disk: "20Gi".to_string(),
838 max_processes: None,
839 })
840 .egress(egress)
841 .session(SandboxSessionPolicy {
842 max_lifetime_seconds: None,
843 idle_suspend_seconds: None,
844 })
845 .preview_ports(preview_ports)
846 .build()
847 }
848
849 #[test]
850 fn resource_type_is_stable() {
851 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
852 }
853
854 #[test]
855 fn capability_sets_are_per_platform() {
856 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
857 assert!(
858 !gcp.reconnect,
859 "a GCP session id is scoped to one instance, so reconnect is absent"
860 );
861 assert!(!gcp.preview);
862 assert!(!gcp.enforced_limits);
863
864 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
865 assert!(
866 !azure.files,
867 "the Azure binding implements no file transfer"
868 );
869 assert!(gcp.files, "every other backend moves files");
870 assert!(!azure.domain_egress_rules);
873 assert!(!azure.egress_deny);
874 assert!(!azure.enforced_limits);
875 assert!(!azure.snapshot);
878 assert!(!azure.preview);
879 assert!(!azure.suspend_resume);
880
881 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
882 assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
883 assert!(aws.suspend_resume);
884
885 let k8s =
886 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
887 assert!(
888 !k8s.preview,
889 "the session-scoped ingress gateway does not exist yet"
890 );
891 }
892
893 #[test]
894 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
895 let error = SandboxCapabilities::for_platform(Platform::Machines)
896 .expect_err("Machines has no sandbox backend");
897 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
898 }
899
900 #[test]
901 fn unsupported_capability_names_platform_and_capability() {
902 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
903 let error = capabilities
904 .require(SandboxCapability::Preview, Platform::Gcp)
905 .expect_err("GCP has no preview");
906
907 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
908 let rendered = error.to_string();
909 assert!(
910 rendered.contains("preview"),
911 "names the capability: {rendered}"
912 );
913 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
914 }
915
916 #[test]
920 fn a_hostname_allowlist_is_refused_on_every_backend() {
921 let sandbox = sandbox_with(
922 SandboxEgress::AllowDomains {
923 domains: vec!["example.com".to_string()],
924 },
925 vec![],
926 );
927
928 for platform in [
929 Platform::Aws,
930 Platform::Azure,
931 Platform::Gcp,
932 Platform::Kubernetes,
933 Platform::Local,
934 ] {
935 let error = sandbox
936 .validate_for_platform(platform)
937 .expect_err("no backend expresses a hostname allowlist");
938 assert_eq!(
939 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
940 "on {platform:?}"
941 );
942 }
943 }
944
945 #[test]
948 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
949 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
950
951 assert!(
954 SandboxCapabilities::for_platform(Platform::Gcp)
955 .expect("supported")
956 .egress_deny
957 );
958
959 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
960 sandbox
961 .validate_for_platform(platform)
962 .expect("deny is enforced here");
963 }
964
965 let egress_only = Sandbox::new("sbx".to_string())
967 .code(SandboxCode::Image {
968 image: "alpine:3.20".to_string(),
969 })
970 .egress(SandboxEgress::Deny)
971 .session(SandboxSessionPolicy {
972 max_lifetime_seconds: None,
973 idle_suspend_seconds: None,
974 })
975 .build();
976
977 let error = egress_only
978 .validate_for_platform(Platform::Azure)
979 .expect_err("the Azure binding renders no egress policy, so deny cannot be kept");
980 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
981 assert!(
982 error.message.contains("egressDeny"),
983 "names the capability: {}",
984 error.message
985 );
986 }
987
988 #[test]
992 fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() {
993 let declared = sandbox_with(SandboxEgress::Deny, Vec::new());
994 declared
995 .validate_for_platform(Platform::Gcp)
996 .expect_err("declaring ceilings GCP cannot enforce is rejected");
997
998 let undeclared = Sandbox::new("sbx".to_string())
999 .code(SandboxCode::Image {
1000 image: "alpine:3.20".to_string(),
1001 })
1002 .egress(SandboxEgress::Deny)
1003 .session(SandboxSessionPolicy {
1004 max_lifetime_seconds: None,
1005 idle_suspend_seconds: None,
1006 })
1007 .build();
1008
1009 undeclared
1010 .validate_for_platform(Platform::Gcp)
1011 .expect("a sandbox naming no ceilings takes the platform's own");
1012
1013 assert_eq!(undeclared.resolved_limits().cpu, "1");
1015 }
1016
1017 #[test]
1018 fn preview_ports_require_the_preview_capability() {
1019 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1020
1021 sandbox
1022 .validate_for_platform(Platform::Aws)
1023 .expect("AWS mints a port-scoped JWE");
1024
1025 let error = sandbox
1026 .validate_for_platform(Platform::Kubernetes)
1027 .expect_err("Kubernetes preview is deferred");
1028 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1029 }
1030
1031 #[test]
1032 fn gcp_rejects_a_sandbox_declaring_enforced_limits() {
1033 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1034 let error = sandbox
1035 .validate_for_platform(Platform::Gcp)
1036 .expect_err("GCP cannot enforce ceilings on a subprocess sandbox");
1037 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1038 }
1039
1040 #[test]
1041 fn invalid_quantities_are_rejected_with_the_offending_field() {
1042 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1043 sandbox
1044 .limits
1045 .as_mut()
1046 .expect("the fixture declares limits")
1047 .memory = "2Gb".to_string();
1048
1049 let error = sandbox
1050 .validate_for_platform(Platform::Aws)
1051 .expect_err("Gb is not a valid suffix");
1052 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1053 assert!(error.to_string().contains("memory"));
1054
1055 sandbox
1056 .limits
1057 .as_mut()
1058 .expect("the fixture declares limits")
1059 .memory = "2Gi".to_string();
1060 sandbox
1061 .limits
1062 .as_mut()
1063 .expect("the fixture declares limits")
1064 .cpu = "0".to_string();
1065 let error = sandbox
1066 .validate_for_platform(Platform::Aws)
1067 .expect_err("zero cpu is not a ceiling");
1068 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1069 }
1070
1071 #[test]
1072 fn zero_max_processes_is_rejected() {
1073 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1074 sandbox
1075 .limits
1076 .as_mut()
1077 .expect("the fixture declares limits")
1078 .max_processes = Some(0);
1079
1080 let error = sandbox
1081 .validate_for_platform(Platform::Local)
1082 .expect_err("a sandbox must be able to run at least one process");
1083 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1084 assert!(error.to_string().contains("maxProcesses"));
1085 }
1086
1087 #[test]
1091 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1092 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1093 sandbox
1094 .limits
1095 .as_mut()
1096 .expect("the fixture declares limits")
1097 .max_processes = Some(256);
1098
1099 sandbox
1100 .validate_for_platform(Platform::Local)
1101 .expect("Docker takes a pids limit");
1102
1103 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1104 let error = sandbox
1105 .validate_for_platform(platform)
1106 .expect_err("a process ceiling nothing applies must be refused");
1107 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1108 }
1109 }
1110
1111 #[test]
1115 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1116 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1117
1118 for seconds in [0, 28_801, 100_000] {
1119 sandbox.session.max_lifetime_seconds = Some(seconds);
1120 let error = sandbox
1121 .validate_for_platform(Platform::Aws)
1122 .expect_err("a lifetime outside what AWS runs is refused");
1123 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1124
1125 sandbox
1127 .validate_for_platform(Platform::Kubernetes)
1128 .expect("the kubelet takes any activeDeadlineSeconds");
1129 }
1130
1131 sandbox.session.max_lifetime_seconds = Some(28_800);
1132 sandbox
1133 .validate_for_platform(Platform::Aws)
1134 .expect("the ceiling itself is allowed");
1135 }
1136
1137 #[test]
1141 fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1142 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1143 sandbox.session.max_lifetime_seconds = Some(3600);
1144
1145 sandbox
1146 .validate_for_platform(Platform::Kubernetes)
1147 .expect("the kubelet enforces activeDeadlineSeconds");
1148 sandbox
1149 .validate_for_platform(Platform::Aws)
1150 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1151
1152 for platform in [Platform::Azure, Platform::Local] {
1153 let error = sandbox
1154 .validate_for_platform(platform)
1155 .expect_err("a deadline nothing applies must be refused");
1156 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1157 }
1158 }
1159
1160 #[test]
1164 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1165 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1166 let tier = sandbox
1167 .microvm_tier()
1168 .expect("2Gi/1cpu/20Gi is satisfiable");
1169
1170 assert_eq!(
1171 tier.peak_memory_mib, 2048,
1172 "the peak is the declared ceiling"
1173 );
1174 assert_eq!(
1175 tier.baseline_memory_mib, 512,
1176 "which is a quarter of it as the baseline"
1177 );
1178 assert!(tier.max_disk_mib <= 20 * 1024);
1179 }
1180
1181 #[test]
1185 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1186 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1187 {
1188 let limits = sandbox
1189 .limits
1190 .as_mut()
1191 .expect("the fixture declares limits");
1192 limits.cpu = "1".to_string();
1193 limits.memory = "8Gi".to_string();
1194 }
1195
1196 let error = sandbox
1197 .microvm_tier()
1198 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1199 assert!(
1200 error.to_string().contains("4 vCPU"),
1201 "the refusal must say what the memory ceiling implies: {error}"
1202 );
1203
1204 sandbox
1205 .limits
1206 .as_mut()
1207 .expect("the fixture declares limits")
1208 .cpu = "4".to_string();
1209 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1210 assert_eq!(tier.peak_memory_mib, 8192);
1211 }
1212
1213 #[test]
1216 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1217 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1218 sandbox
1219 .limits
1220 .as_mut()
1221 .expect("the fixture declares limits")
1222 .memory = "1Gi".to_string();
1223
1224 let error = sandbox
1225 .validate_for_platform(Platform::Aws)
1226 .expect_err("no MicroVM size peaks at or below 1Gi");
1227 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1228 assert!(
1229 error.to_string().contains("2Gi"),
1230 "the refusal must say what the smallest holdable ceiling is: {error}"
1231 );
1232 }
1233
1234 #[test]
1238 fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1239 let sandbox = Sandbox::new("agent".to_string())
1240 .code(SandboxCode::Source {
1241 src: "./sandbox".to_string(),
1242 toolchain: ToolchainConfig::Docker {
1243 dockerfile: None,
1244 build_args: None,
1245 target: None,
1246 },
1247 })
1248 .egress(SandboxEgress::Deny)
1249 .session(SandboxSessionPolicy {
1250 max_lifetime_seconds: None,
1251 idle_suspend_seconds: None,
1252 })
1253 .build();
1254
1255 for platform in [
1256 Platform::Aws,
1257 Platform::Azure,
1258 Platform::Gcp,
1259 Platform::Kubernetes,
1260 Platform::Local,
1261 ] {
1262 let error = sandbox
1263 .validate_for_platform(platform)
1264 .expect_err("no backend builds a sandbox image from source");
1265 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1266 assert!(
1267 error.to_string().contains("code.image"),
1268 "the refusal must say what to write instead: {error}"
1269 );
1270 }
1271 }
1272
1273 #[test]
1276 fn every_accepted_unit_converts_rather_than_falling_back() {
1277 assert_eq!(quantity_mib("2Gi"), Some(2048));
1278 assert_eq!(quantity_mib("512Mi"), Some(512));
1279 assert_eq!(quantity_mib("4G"), Some(3814));
1280 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1281 assert_eq!(millicores("1"), Some(1000));
1282 assert_eq!(millicores("500m"), Some(500));
1283 }
1284
1285 #[test]
1286 fn unknown_fields_are_rejected() {
1287 let json = r#"{
1288 "id": "sbx",
1289 "code": {"type": "image", "image": "ubuntu:24.04"},
1290 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1291 "egress": {"mode": "deny"},
1292 "session": {},
1293 "unexpected": true
1294 }"#;
1295
1296 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1297 }
1298
1299 #[test]
1300 fn serialization_roundtrips() {
1301 let sandbox = sandbox_with(
1302 SandboxEgress::AllowDomains {
1303 domains: vec!["example.com".to_string()],
1304 },
1305 vec![8080, 9090],
1306 );
1307
1308 let json = serde_json::to_string(&sandbox).expect("serializes");
1309 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1310 assert_eq!(sandbox, restored);
1311 }
1312
1313 #[test]
1314 fn id_is_immutable_across_updates() {
1315 let original = sandbox_with(SandboxEgress::Deny, vec![]);
1316 let renamed = Sandbox::new("other".to_string())
1317 .code(SandboxCode::Image {
1318 image: "ubuntu:24.04".to_string(),
1319 })
1320 .limits(
1321 original
1322 .limits
1323 .clone()
1324 .expect("the fixture declares limits"),
1325 )
1326 .egress(SandboxEgress::Deny)
1327 .session(SandboxSessionPolicy {
1328 max_lifetime_seconds: None,
1329 idle_suspend_seconds: None,
1330 })
1331 .build();
1332
1333 original
1334 .validate_update(&original.clone())
1335 .expect("an unchanged config is a valid update");
1336 original
1337 .validate_update(&renamed)
1338 .expect_err("renaming a sandbox is not an update");
1339 }
1340}