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!(!azure.files, "the Azure data plane has no file transfer");
866 assert!(gcp.files, "every other backend moves files");
867 assert!(!azure.domain_egress_rules);
870 assert!(!azure.egress_deny);
871 assert!(!azure.enforced_limits);
872 assert!(!azure.snapshot);
875 assert!(!azure.preview);
876 assert!(!azure.suspend_resume);
877
878 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
879 assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
880 assert!(aws.suspend_resume);
881
882 let k8s =
883 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
884 assert!(
885 !k8s.preview,
886 "the session-scoped ingress gateway does not exist yet"
887 );
888 }
889
890 #[test]
891 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
892 let error = SandboxCapabilities::for_platform(Platform::Machines)
893 .expect_err("Machines has no sandbox backend");
894 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
895 }
896
897 #[test]
898 fn unsupported_capability_names_platform_and_capability() {
899 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
900 let error = capabilities
901 .require(SandboxCapability::Preview, Platform::Gcp)
902 .expect_err("GCP has no preview");
903
904 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
905 let rendered = error.to_string();
906 assert!(
907 rendered.contains("preview"),
908 "names the capability: {rendered}"
909 );
910 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
911 }
912
913 #[test]
917 fn a_hostname_allowlist_is_refused_on_every_backend() {
918 let sandbox = sandbox_with(
919 SandboxEgress::AllowDomains {
920 domains: vec!["example.com".to_string()],
921 },
922 vec![],
923 );
924
925 for platform in [
926 Platform::Aws,
927 Platform::Azure,
928 Platform::Gcp,
929 Platform::Kubernetes,
930 Platform::Local,
931 ] {
932 let error = sandbox
933 .validate_for_platform(platform)
934 .expect_err("no backend expresses a hostname allowlist");
935 assert_eq!(
936 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
937 "on {platform:?}"
938 );
939 }
940 }
941
942 #[test]
945 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
946 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
947
948 assert!(
951 SandboxCapabilities::for_platform(Platform::Gcp)
952 .expect("supported")
953 .egress_deny
954 );
955
956 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
957 sandbox
958 .validate_for_platform(platform)
959 .expect("deny is enforced here");
960 }
961
962 let egress_only = Sandbox::new("sbx".to_string())
964 .code(SandboxCode::Image {
965 image: "alpine:3.20".to_string(),
966 })
967 .egress(SandboxEgress::Deny)
968 .session(SandboxSessionPolicy {
969 max_lifetime_seconds: None,
970 idle_suspend_seconds: None,
971 })
972 .build();
973
974 let error = egress_only
975 .validate_for_platform(Platform::Azure)
976 .expect_err("the Azure data plane takes no egress policy, so deny cannot be kept");
977 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
978 assert!(
979 error.message.contains("egressDeny"),
980 "names the capability: {}",
981 error.message
982 );
983 }
984
985 #[test]
989 fn a_platform_that_cannot_enforce_limits_still_takes_a_sandbox_without_them() {
990 let declared = sandbox_with(SandboxEgress::Deny, Vec::new());
991 declared
992 .validate_for_platform(Platform::Gcp)
993 .expect_err("declaring ceilings GCP cannot enforce is rejected");
994
995 let undeclared = Sandbox::new("sbx".to_string())
996 .code(SandboxCode::Image {
997 image: "alpine:3.20".to_string(),
998 })
999 .egress(SandboxEgress::Deny)
1000 .session(SandboxSessionPolicy {
1001 max_lifetime_seconds: None,
1002 idle_suspend_seconds: None,
1003 })
1004 .build();
1005
1006 undeclared
1007 .validate_for_platform(Platform::Gcp)
1008 .expect("a sandbox naming no ceilings takes the platform's own");
1009
1010 assert_eq!(undeclared.resolved_limits().cpu, "1");
1012 }
1013
1014 #[test]
1015 fn preview_ports_require_the_preview_capability() {
1016 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1017
1018 sandbox
1019 .validate_for_platform(Platform::Aws)
1020 .expect("AWS mints a port-scoped JWE");
1021
1022 let error = sandbox
1023 .validate_for_platform(Platform::Kubernetes)
1024 .expect_err("Kubernetes preview is deferred");
1025 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1026 }
1027
1028 #[test]
1029 fn gcp_rejects_a_sandbox_declaring_enforced_limits() {
1030 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1031 let error = sandbox
1032 .validate_for_platform(Platform::Gcp)
1033 .expect_err("GCP cannot enforce ceilings on a subprocess sandbox");
1034 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1035 }
1036
1037 #[test]
1038 fn invalid_quantities_are_rejected_with_the_offending_field() {
1039 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1040 sandbox
1041 .limits
1042 .as_mut()
1043 .expect("the fixture declares limits")
1044 .memory = "2Gb".to_string();
1045
1046 let error = sandbox
1047 .validate_for_platform(Platform::Aws)
1048 .expect_err("Gb is not a valid suffix");
1049 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1050 assert!(error.to_string().contains("memory"));
1051
1052 sandbox
1053 .limits
1054 .as_mut()
1055 .expect("the fixture declares limits")
1056 .memory = "2Gi".to_string();
1057 sandbox
1058 .limits
1059 .as_mut()
1060 .expect("the fixture declares limits")
1061 .cpu = "0".to_string();
1062 let error = sandbox
1063 .validate_for_platform(Platform::Aws)
1064 .expect_err("zero cpu is not a ceiling");
1065 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1066 }
1067
1068 #[test]
1069 fn zero_max_processes_is_rejected() {
1070 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1071 sandbox
1072 .limits
1073 .as_mut()
1074 .expect("the fixture declares limits")
1075 .max_processes = Some(0);
1076
1077 let error = sandbox
1078 .validate_for_platform(Platform::Local)
1079 .expect_err("a sandbox must be able to run at least one process");
1080 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1081 assert!(error.to_string().contains("maxProcesses"));
1082 }
1083
1084 #[test]
1088 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1089 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1090 sandbox
1091 .limits
1092 .as_mut()
1093 .expect("the fixture declares limits")
1094 .max_processes = Some(256);
1095
1096 sandbox
1097 .validate_for_platform(Platform::Local)
1098 .expect("Docker takes a pids limit");
1099
1100 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1101 let error = sandbox
1102 .validate_for_platform(platform)
1103 .expect_err("a process ceiling nothing applies must be refused");
1104 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1105 }
1106 }
1107
1108 #[test]
1112 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1113 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1114
1115 for seconds in [0, 28_801, 100_000] {
1116 sandbox.session.max_lifetime_seconds = Some(seconds);
1117 let error = sandbox
1118 .validate_for_platform(Platform::Aws)
1119 .expect_err("a lifetime outside what AWS runs is refused");
1120 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1121
1122 sandbox
1124 .validate_for_platform(Platform::Kubernetes)
1125 .expect("the kubelet takes any activeDeadlineSeconds");
1126 }
1127
1128 sandbox.session.max_lifetime_seconds = Some(28_800);
1129 sandbox
1130 .validate_for_platform(Platform::Aws)
1131 .expect("the ceiling itself is allowed");
1132 }
1133
1134 #[test]
1138 fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1139 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1140 sandbox.session.max_lifetime_seconds = Some(3600);
1141
1142 sandbox
1143 .validate_for_platform(Platform::Kubernetes)
1144 .expect("the kubelet enforces activeDeadlineSeconds");
1145 sandbox
1146 .validate_for_platform(Platform::Aws)
1147 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1148
1149 for platform in [Platform::Azure, Platform::Local] {
1150 let error = sandbox
1151 .validate_for_platform(platform)
1152 .expect_err("a deadline nothing applies must be refused");
1153 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1154 }
1155 }
1156
1157 #[test]
1161 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1162 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1163 let tier = sandbox
1164 .microvm_tier()
1165 .expect("2Gi/1cpu/20Gi is satisfiable");
1166
1167 assert_eq!(
1168 tier.peak_memory_mib, 2048,
1169 "the peak is the declared ceiling"
1170 );
1171 assert_eq!(
1172 tier.baseline_memory_mib, 512,
1173 "which is a quarter of it as the baseline"
1174 );
1175 assert!(tier.max_disk_mib <= 20 * 1024);
1176 }
1177
1178 #[test]
1182 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1183 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1184 {
1185 let limits = sandbox
1186 .limits
1187 .as_mut()
1188 .expect("the fixture declares limits");
1189 limits.cpu = "1".to_string();
1190 limits.memory = "8Gi".to_string();
1191 }
1192
1193 let error = sandbox
1194 .microvm_tier()
1195 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1196 assert!(
1197 error.to_string().contains("4 vCPU"),
1198 "the refusal must say what the memory ceiling implies: {error}"
1199 );
1200
1201 sandbox
1202 .limits
1203 .as_mut()
1204 .expect("the fixture declares limits")
1205 .cpu = "4".to_string();
1206 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1207 assert_eq!(tier.peak_memory_mib, 8192);
1208 }
1209
1210 #[test]
1213 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1214 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1215 sandbox
1216 .limits
1217 .as_mut()
1218 .expect("the fixture declares limits")
1219 .memory = "1Gi".to_string();
1220
1221 let error = sandbox
1222 .validate_for_platform(Platform::Aws)
1223 .expect_err("no MicroVM size peaks at or below 1Gi");
1224 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1225 assert!(
1226 error.to_string().contains("2Gi"),
1227 "the refusal must say what the smallest holdable ceiling is: {error}"
1228 );
1229 }
1230
1231 #[test]
1235 fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1236 let sandbox = Sandbox::new("agent".to_string())
1237 .code(SandboxCode::Source {
1238 src: "./sandbox".to_string(),
1239 toolchain: ToolchainConfig::Docker {
1240 dockerfile: None,
1241 build_args: None,
1242 target: None,
1243 },
1244 })
1245 .egress(SandboxEgress::Deny)
1246 .session(SandboxSessionPolicy {
1247 max_lifetime_seconds: None,
1248 idle_suspend_seconds: None,
1249 })
1250 .build();
1251
1252 for platform in [
1253 Platform::Aws,
1254 Platform::Azure,
1255 Platform::Gcp,
1256 Platform::Kubernetes,
1257 Platform::Local,
1258 ] {
1259 let error = sandbox
1260 .validate_for_platform(platform)
1261 .expect_err("no backend builds a sandbox image from source");
1262 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1263 assert!(
1264 error.to_string().contains("code.image"),
1265 "the refusal must say what to write instead: {error}"
1266 );
1267 }
1268 }
1269
1270 #[test]
1273 fn every_accepted_unit_converts_rather_than_falling_back() {
1274 assert_eq!(quantity_mib("2Gi"), Some(2048));
1275 assert_eq!(quantity_mib("512Mi"), Some(512));
1276 assert_eq!(quantity_mib("4G"), Some(3814));
1277 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1278 assert_eq!(millicores("1"), Some(1000));
1279 assert_eq!(millicores("500m"), Some(500));
1280 }
1281
1282 #[test]
1283 fn unknown_fields_are_rejected() {
1284 let json = r#"{
1285 "id": "sbx",
1286 "code": {"type": "image", "image": "ubuntu:24.04"},
1287 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1288 "egress": {"mode": "deny"},
1289 "session": {},
1290 "unexpected": true
1291 }"#;
1292
1293 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1294 }
1295
1296 #[test]
1297 fn serialization_roundtrips() {
1298 let sandbox = sandbox_with(
1299 SandboxEgress::AllowDomains {
1300 domains: vec!["example.com".to_string()],
1301 },
1302 vec![8080, 9090],
1303 );
1304
1305 let json = serde_json::to_string(&sandbox).expect("serializes");
1306 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1307 assert_eq!(sandbox, restored);
1308 }
1309
1310 #[test]
1311 fn id_is_immutable_across_updates() {
1312 let original = sandbox_with(SandboxEgress::Deny, vec![]);
1313 let renamed = Sandbox::new("other".to_string())
1314 .code(SandboxCode::Image {
1315 image: "ubuntu:24.04".to_string(),
1316 })
1317 .limits(
1318 original
1319 .limits
1320 .clone()
1321 .expect("the fixture declares limits"),
1322 )
1323 .egress(SandboxEgress::Deny)
1324 .session(SandboxSessionPolicy {
1325 max_lifetime_seconds: None,
1326 idle_suspend_seconds: None,
1327 })
1328 .build();
1329
1330 original
1331 .validate_update(&original.clone())
1332 .expect("an unchanged config is a valid update");
1333 original
1334 .validate_update(&renamed)
1335 .expect_err("renaming a sandbox is not an update");
1336 }
1337}