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,
33 },
34 #[serde(rename_all = "camelCase")]
39 Source {
40 src: String,
42 toolchain: ToolchainConfig,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct SandboxLimits {
56 pub cpu: String,
58 pub memory: String,
60 pub disk: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub max_processes: Option<u32>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct MicrovmTier {
78 pub baseline_memory_mib: i64,
80 pub peak_memory_mib: i64,
82 pub peak_vcpu: u32,
84 pub max_disk_mib: i64,
86}
87
88const AWS_MAX_LIFETIME_SECONDS: u32 = 28_800;
92
93const AZURE_CPU_STEP_MILLICORES: i64 = 250;
96const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
97const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
98const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
99
100const MICROVM_TIERS: &[MicrovmTier] = &[
101 MicrovmTier {
102 baseline_memory_mib: 512,
103 peak_memory_mib: 2048,
104 peak_vcpu: 1,
105 max_disk_mib: 8192,
106 },
107 MicrovmTier {
108 baseline_memory_mib: 1024,
109 peak_memory_mib: 4096,
110 peak_vcpu: 2,
111 max_disk_mib: 8192,
112 },
113 MicrovmTier {
114 baseline_memory_mib: 2048,
115 peak_memory_mib: 8192,
116 peak_vcpu: 4,
117 max_disk_mib: 8192,
118 },
119 MicrovmTier {
120 baseline_memory_mib: 4096,
121 peak_memory_mib: 16384,
122 peak_vcpu: 8,
123 max_disk_mib: 16384,
124 },
125 MicrovmTier {
126 baseline_memory_mib: 8192,
127 peak_memory_mib: 32768,
128 peak_vcpu: 16,
129 max_disk_mib: 32768,
130 },
131];
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
136#[serde(rename_all = "camelCase", tag = "mode")]
137pub enum SandboxEgress {
138 Deny,
143 Allow,
150 #[serde(rename_all = "camelCase")]
155 AllowDomains {
156 domains: Vec<String>,
158 },
159}
160
161impl SandboxEgress {
162 pub fn internet_access_switch(&self) -> Option<bool> {
169 match self {
170 SandboxEgress::Allow => Some(true),
171 SandboxEgress::Deny => Some(false),
172 SandboxEgress::AllowDomains { .. } => None,
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
183#[serde(rename_all = "camelCase", deny_unknown_fields)]
184pub struct SandboxLifecyclePolicy {
185 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub max_lifetime_seconds: Option<u32>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub idle_pause_seconds: Option<u32>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
205#[serde(rename_all = "camelCase", deny_unknown_fields)]
206pub struct SandboxCapabilities {
207 pub files: bool,
209 pub reconnect: bool,
211 pub jobs: bool,
214 pub preview: bool,
216 pub pause_resume: bool,
218 pub snapshot: bool,
220 pub domain_egress_rules: bool,
222 pub egress_deny: bool,
224 pub enforced_limits: bool,
226 pub process_limit: bool,
228 pub sandbox_lifetime: bool,
230 pub supervisor_pid_namespace: bool,
236 pub supervisor_isolation: bool,
242}
243
244impl SandboxCapabilities {
245 pub fn for_platform(platform: Platform) -> Result<Self> {
251 match platform {
252 Platform::Aws => Ok(Self {
253 files: true,
254 reconnect: true,
255 jobs: true,
256 preview: true,
257 pause_resume: true,
258 snapshot: false,
259 domain_egress_rules: false,
260 egress_deny: true,
261 enforced_limits: true,
262 process_limit: false,
264 sandbox_lifetime: true,
267 supervisor_pid_namespace: false,
272 supervisor_isolation: true,
275 }),
276 Platform::Azure => Ok(Self::azure()),
277 Platform::Gcp => Ok(Self::gcp_agent_platform()),
278 Platform::Kubernetes => Ok(Self {
281 files: true,
282 reconnect: true,
283 jobs: true,
284 preview: false,
285 pause_resume: false,
286 snapshot: false,
287 domain_egress_rules: false,
288 egress_deny: true,
289 enforced_limits: true,
290 process_limit: false,
292 sandbox_lifetime: true,
294 supervisor_pid_namespace: false,
298 supervisor_isolation: false,
303 }),
304 Platform::Local => Ok(Self {
305 files: true,
306 reconnect: true,
307 jobs: false,
309 preview: true,
310 pause_resume: false,
311 snapshot: false,
312 domain_egress_rules: false,
313 egress_deny: true,
314 enforced_limits: true,
315 process_limit: true,
317 sandbox_lifetime: false,
318 supervisor_pid_namespace: false,
321 supervisor_isolation: true,
325 }),
326 Platform::Machines | Platform::Test => {
327 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
328 platform: platform.to_string(),
329 }))
330 }
331 }
332 }
333
334 pub fn azure() -> Self {
336 Self {
337 files: true,
338 reconnect: true,
339 jobs: false,
341 preview: false,
346 pause_resume: true,
347 snapshot: false,
351 domain_egress_rules: true,
352 egress_deny: true,
353 enforced_limits: true,
357 process_limit: false,
358 sandbox_lifetime: false,
362 supervisor_pid_namespace: false,
364 supervisor_isolation: false,
367 }
368 }
369
370 pub fn gcp_agent_platform() -> Self {
372 Self {
373 files: true,
375 reconnect: true,
379 jobs: true,
380 preview: false,
382 pause_resume: true,
384 snapshot: false,
387 domain_egress_rules: false,
389 egress_deny: true,
391 enforced_limits: true,
395 process_limit: false,
397 sandbox_lifetime: true,
399 supervisor_pid_namespace: false,
401 supervisor_isolation: false,
404 }
405 }
406
407 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
409 let available = match capability {
410 SandboxCapability::Files => self.files,
411 SandboxCapability::Reconnect => self.reconnect,
412 SandboxCapability::Jobs => self.jobs,
413 SandboxCapability::Preview => self.preview,
414 SandboxCapability::PauseResume => self.pause_resume,
415 SandboxCapability::Snapshot => self.snapshot,
416 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
417 SandboxCapability::EgressDeny => self.egress_deny,
418 SandboxCapability::EnforcedLimits => self.enforced_limits,
419 SandboxCapability::ProcessLimit => self.process_limit,
420 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
421 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
422 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
423 };
424
425 if available {
426 return Ok(());
427 }
428
429 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
430 capability: capability.as_str().to_string(),
431 platform: platform.to_string(),
432 }))
433 }
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
438#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
439#[serde(rename_all = "camelCase")]
440pub enum SandboxCapability {
441 Files,
443 Reconnect,
445 Jobs,
447 Preview,
449 PauseResume,
451 Snapshot,
453 DomainEgressRules,
455 EgressDeny,
457 EnforcedLimits,
459 ProcessLimit,
461 SandboxLifetime,
463 SupervisorPidNamespace,
465 SupervisorIsolation,
467}
468
469impl SandboxCapability {
470 pub fn as_str(&self) -> &'static str {
472 match self {
473 Self::Files => "files",
474 Self::Reconnect => "reconnect",
475 Self::Jobs => "jobs",
476 Self::Preview => "preview",
477 Self::PauseResume => "pauseResume",
478 Self::Snapshot => "snapshot",
479 Self::DomainEgressRules => "domainEgressRules",
480 Self::EgressDeny => "egressDeny",
481 Self::EnforcedLimits => "enforcedLimits",
482 Self::ProcessLimit => "processLimit",
483 Self::SandboxLifetime => "sandboxLifetime",
484 Self::SupervisorPidNamespace => "supervisorPidNamespace",
485 Self::SupervisorIsolation => "supervisorIsolation",
486 }
487 }
488}
489
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
492#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
493#[serde(rename_all = "camelCase", deny_unknown_fields)]
494#[builder(start_fn = new)]
495pub struct Sandbox {
496 #[builder(start_fn)]
499 pub id: String,
500 pub code: SandboxCode,
502 #[serde(skip_serializing_if = "Option::is_none")]
506 pub private_base_image: Option<String>,
507 #[serde(skip_serializing_if = "Option::is_none")]
513 pub limits: Option<SandboxLimits>,
514 pub egress: SandboxEgress,
516 pub lifecycle: SandboxLifecyclePolicy,
518 #[builder(default)]
522 #[serde(default, skip_serializing_if = "Vec::is_empty")]
523 pub preview_ports: Vec<u16>,
524}
525
526pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
531 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
532}
533
534pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
540 stack.resources().any(|(_resource_id, resource)| {
541 resource
542 .config
543 .downcast_ref::<Sandbox>()
544 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
545 })
546}
547
548impl Sandbox {
549 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
551
552 pub fn id(&self) -> &str {
554 &self.id
555 }
556
557 pub fn resolved_limits(&self) -> SandboxLimits {
563 self.limits.clone().unwrap_or_else(default_limits)
564 }
565
566 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
571 let capabilities = SandboxCapabilities::for_platform(platform)?;
572
573 if matches!(&self.code, SandboxCode::Source { .. }) && platform != Platform::Aws {
577 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
578 resource_id: self.id.clone(),
579 field: "code".to_string(),
580 value: "source".to_string(),
581 reason: format!(
582 "no sandbox backend builds an image from source on {platform}; give \
583 code.image a prebuilt reference"
584 ),
585 }));
586 }
587
588 if self.private_base_image.is_some() && platform != Platform::Aws {
591 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
592 capability: "privateBaseImage".to_string(),
593 platform: platform.to_string(),
594 }));
595 }
596
597 if platform == Platform::Azure {
599 self.azure_catalog_image()?;
600 }
601
602 let Some(limits) = self.limits.as_ref() else {
603 return self.validate_capabilities(&capabilities, platform);
605 };
606
607 validate_quantity(&self.id, "cpu", &limits.cpu)?;
608 validate_quantity(&self.id, "memory", &limits.memory)?;
609 validate_quantity(&self.id, "disk", &limits.disk)?;
610
611 if let Some(max_processes) = limits.max_processes {
612 if max_processes == 0 {
613 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
614 resource_id: self.id.clone(),
615 field: "maxProcesses".to_string(),
616 value: "0".to_string(),
617 reason: "a sandbox that may run no processes cannot run code".to_string(),
618 }));
619 }
620 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
621 }
622
623 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
626
627 if platform == Platform::Azure {
628 self.azure_sandbox_limits()?;
629 }
630
631 if platform == Platform::Aws {
632 self.microvm_tier()?;
635
636 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
641 if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
642 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
643 resource_id: self.id.clone(),
644 field: "maxLifetimeSeconds".to_string(),
645 value: seconds.to_string(),
646 reason: format!(
647 "AWS runs a MicroVM for between 1 and \
648 {AWS_MAX_LIFETIME_SECONDS} seconds"
649 ),
650 }));
651 }
652 }
653 }
654
655 self.validate_capabilities(&capabilities, platform)
656 }
657
658 pub fn azure_catalog_image(&self) -> Result<&str> {
664 let refused = |value: &str, reason: &str| {
665 AlienError::new(ErrorData::SandboxLimitInvalid {
666 resource_id: self.id.clone(),
667 field: "code.image".to_string(),
668 value: value.to_string(),
669 reason: reason.to_string(),
670 })
671 };
672
673 let SandboxCode::Image { image } = &self.code else {
674 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
675 resource_id: self.id.clone(),
676 field: "code".to_string(),
677 value: "source".to_string(),
678 reason: "no sandbox backend builds an image from source yet".to_string(),
679 }));
680 };
681
682 let image = image.trim();
683 if image.is_empty() {
684 return Err(refused(image, "a sandbox has to name an image"));
685 }
686 if !image
687 .chars()
688 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
689 {
690 return Err(refused(
691 image,
692 "Azure creates a sandbox from a public catalog disk image, so code.image must be \
693 a bare catalog name such as 'ubuntu'",
694 ));
695 }
696 Ok(image)
697 }
698
699 pub fn azure_sandbox_limits(&self) -> Result<()> {
703 let Some(limits) = self.limits.as_ref() else {
704 return Ok(());
707 };
708
709 let refused = |field: &str, value: &str, reason: &str| {
710 AlienError::new(ErrorData::SandboxLimitInvalid {
711 resource_id: self.id.clone(),
712 field: field.to_string(),
713 value: value.to_string(),
714 reason: reason.to_string(),
715 })
716 };
717
718 let cpu_millicores = millicores(&limits.cpu)
719 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
720
721 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
725 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
726 {
727 return Err(refused(
728 "cpu",
729 &limits.cpu,
730 "Azure allocates cpu in steps of 250m from 250m to 16000m",
731 ));
732 }
733
734 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
736 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
737
738 let memory_mib = quantity_mib(&limits.memory)
739 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
740 if memory_mib > memory_ceiling_mib {
741 return Err(refused(
742 "memory",
743 &limits.memory,
744 &format!(
745 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
746 at the declared cpu"
747 ),
748 ));
749 }
750
751 let disk_mib = quantity_mib(&limits.disk)
752 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
753 if disk_mib > disk_ceiling_mib {
754 return Err(refused(
755 "disk",
756 &limits.disk,
757 &format!(
758 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
759 the declared cpu"
760 ),
761 ));
762 }
763
764 Ok(())
765 }
766
767 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
774 let Some(limits) = self.limits.as_ref() else {
775 return Ok(MICROVM_TIERS[2]);
777 };
778
779 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
780 AlienError::new(ErrorData::SandboxLimitInvalid {
781 resource_id: self.id.clone(),
782 field: "memory".to_string(),
783 value: limits.memory.clone(),
784 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
785 })
786 })?;
787 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
788 AlienError::new(ErrorData::SandboxLimitInvalid {
789 resource_id: self.id.clone(),
790 field: "disk".to_string(),
791 value: limits.disk.clone(),
792 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
793 })
794 })?;
795 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
796 AlienError::new(ErrorData::SandboxLimitInvalid {
797 resource_id: self.id.clone(),
798 field: "cpu".to_string(),
799 value: limits.cpu.clone(),
800 reason: "expected cores or millicores".to_string(),
801 })
802 })?;
803
804 let sized = |tier: &&MicrovmTier| {
809 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
810 };
811
812 let tier = MICROVM_TIERS
813 .iter()
814 .rev()
815 .find(sized)
816 .copied()
817 .ok_or_else(|| {
818 AlienError::new(ErrorData::SandboxLimitInvalid {
819 resource_id: self.id.clone(),
820 field: "memory".to_string(),
821 value: limits.memory.clone(),
822 reason: format!(
823 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
824 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
825 disk fit no size",
826 limits.memory, limits.disk
827 ),
828 })
829 })?;
830
831 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
832 if cpu_millicores < required_millicores {
833 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
834 resource_id: self.id.clone(),
835 field: "cpu".to_string(),
836 value: limits.cpu.clone(),
837 reason: format!(
838 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
839 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
840 limits.memory, tier.peak_vcpu, tier.peak_vcpu
841 ),
842 }));
843 }
844
845 Ok(tier)
846 }
847
848 fn validate_capabilities(
850 &self,
851 capabilities: &SandboxCapabilities,
852 platform: Platform,
853 ) -> Result<()> {
854 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
855 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
856 }
857
858 if let SandboxEgress::AllowDomains { domains } = &self.egress {
864 if domains.is_empty() {
865 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
866 resource_id: self.id.clone(),
867 field: "egress.domains".to_string(),
868 value: "[]".to_string(),
869 reason: "an allowlist naming no domain denies everything; declare \
870 egress: deny if that is what was meant"
871 .to_string(),
872 }));
873 }
874 }
875
876 if matches!(self.egress, SandboxEgress::Deny) {
877 capabilities.require(SandboxCapability::EgressDeny, platform)?;
878 }
879
880 if !self.preview_ports.is_empty() {
881 capabilities.require(SandboxCapability::Preview, platform)?;
882 }
883
884 if self.lifecycle.idle_pause_seconds.is_some() {
885 capabilities.require(SandboxCapability::PauseResume, platform)?;
886 }
887
888 if self.lifecycle.max_lifetime_seconds.is_some() {
889 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
890 }
891
892 Ok(())
893 }
894}
895
896fn default_limits() -> SandboxLimits {
901 SandboxLimits {
902 cpu: "1".to_string(),
903 memory: "2Gi".to_string(),
904 disk: "8Gi".to_string(),
905 max_processes: None,
906 }
907}
908
909fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
911 let invalid = |reason: &str| {
912 AlienError::new(ErrorData::SandboxLimitInvalid {
913 resource_id: resource_id.to_string(),
914 field: field.to_string(),
915 value: value.to_string(),
916 reason: reason.to_string(),
917 })
918 };
919
920 let digits_end = value
921 .find(|c: char| !c.is_ascii_digit() && c != '.')
922 .unwrap_or(value.len());
923 let (number, suffix) = value.split_at(digits_end);
924
925 let parsed: f64 = number
926 .parse()
927 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
928
929 if parsed <= 0.0 {
930 return Err(invalid("must be greater than zero"));
931 }
932
933 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
934 if !SUFFIXES.contains(&suffix) {
935 return Err(invalid(
936 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
937 ));
938 }
939
940 Ok(())
941}
942
943fn split_quantity(value: &str) -> Option<(f64, &str)> {
945 let trimmed = value.trim();
946 let digits_end = trimmed
947 .find(|c: char| !c.is_ascii_digit() && c != '.')
948 .unwrap_or(trimmed.len());
949 let (number, suffix) = trimmed.split_at(digits_end);
950 number.parse().ok().map(|number| (number, suffix))
951}
952
953pub fn quantity_mib(value: &str) -> Option<i64> {
959 let (number, suffix) = split_quantity(value)?;
960 let bytes = match suffix {
961 "" => number,
962 "k" => number * 1e3,
963 "M" => number * 1e6,
964 "G" => number * 1e9,
965 "T" => number * 1e12,
966 "Ki" => number * 1024.0,
967 "Mi" => number * 1024.0 * 1024.0,
968 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
969 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
970 _ => return None,
972 };
973 Some((bytes / (1024.0 * 1024.0)) as i64)
974}
975
976pub fn millicores(value: &str) -> Option<i64> {
978 let (number, suffix) = split_quantity(value)?;
979 match suffix {
980 "" => Some((number * 1000.0) as i64),
981 "m" => Some(number as i64),
982 _ => None,
983 }
984}
985
986#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
988#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
989#[serde(rename_all = "camelCase")]
990pub struct SandboxOutputs {
991 pub parent_name: String,
993 #[serde(skip_serializing_if = "Option::is_none")]
995 pub identifier: Option<String>,
996 #[serde(skip_serializing_if = "Option::is_none")]
998 pub endpoint: Option<String>,
999}
1000
1001impl ResourceOutputsDefinition for SandboxOutputs {
1002 fn get_resource_type(&self) -> ResourceType {
1003 Sandbox::RESOURCE_TYPE
1004 }
1005
1006 fn as_any(&self) -> &dyn Any {
1007 self
1008 }
1009
1010 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1011 Box::new(self.clone())
1012 }
1013
1014 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1015 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1016 }
1017
1018 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1019 serde_json::to_value(self)
1020 }
1021}
1022
1023impl ResourceDefinition for Sandbox {
1024 fn get_resource_type(&self) -> ResourceType {
1025 Self::RESOURCE_TYPE
1026 }
1027
1028 fn id(&self) -> &str {
1029 &self.id
1030 }
1031
1032 fn get_dependencies(&self) -> Vec<ResourceRef> {
1033 Vec::new()
1034 }
1035
1036 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1037 let new_sandbox = new_config
1038 .as_any()
1039 .downcast_ref::<Sandbox>()
1040 .ok_or_else(|| {
1041 AlienError::new(ErrorData::UnexpectedResourceType {
1042 resource_id: self.id.clone(),
1043 expected: Self::RESOURCE_TYPE,
1044 actual: new_config.get_resource_type(),
1045 })
1046 })?;
1047
1048 if self.id != new_sandbox.id {
1049 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1050 resource_id: self.id.clone(),
1051 reason: "the 'id' field is immutable".to_string(),
1052 }));
1053 }
1054
1055 Ok(())
1056 }
1057
1058 fn as_any(&self) -> &dyn Any {
1059 self
1060 }
1061
1062 fn as_any_mut(&mut self) -> &mut dyn Any {
1063 self
1064 }
1065
1066 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1067 Box::new(self.clone())
1068 }
1069
1070 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1071 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1072 }
1073
1074 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1075 serde_json::to_value(self)
1076 }
1077}
1078
1079pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1084
1085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1087pub enum BundleUri<'a> {
1088 Literal(&'a str),
1090 Regional { before: &'a str, after: &'a str },
1093}
1094
1095pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1099 let (above_file, _) = key.rsplit_once('/')?;
1100 let (above_version, _) = above_file.rsplit_once('/')?;
1101 Some(above_version)
1102}
1103
1104pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1110 let path = uri
1111 .strip_prefix("s3://")
1112 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1113 let (bucket, key) = path
1114 .split_once('/')
1115 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1116
1117 if path.contains('*') || path.contains('?') {
1121 return Err(format!(
1122 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1123 role's grant, so '*' and '?' would widen it past the bundle"
1124 ));
1125 }
1126
1127 if key.contains('{') || key.contains('}') {
1128 return Err(format!(
1129 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1130 bucket name alone"
1131 ));
1132 }
1133
1134 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1135 if bucket.contains('{') || bucket.contains('}') {
1136 return Err(format!(
1137 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1138 only one"
1139 ));
1140 }
1141 return Ok(BundleUri::Literal(uri));
1142 };
1143
1144 if after.contains(BUNDLE_REGION_TOKEN) {
1145 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1146 }
1147 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1148 return Err(format!(
1149 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1150 ));
1151 }
1152
1153 Ok(BundleUri::Regional {
1154 before: &uri[.."s3://".len() + before.len()],
1155 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1156 })
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162
1163 #[test]
1164 fn private_database_setup_accepts_the_default_network() {
1165 for lifecycle in [
1166 crate::ResourceLifecycle::Frozen,
1167 crate::ResourceLifecycle::Live,
1168 ] {
1169 let stack = crate::Stack::new("database".to_string())
1170 .add(
1171 crate::Postgres::new("metadata".to_string()).build(),
1172 lifecycle,
1173 )
1174 .build();
1175 assert!(!restricts_network_mode(&stack, false));
1176 assert!(!restricts_network_mode(&stack, true));
1177 }
1178 assert!(!restricts_network_mode(
1179 &crate::Stack::new("empty".to_string()).build(),
1180 false,
1181 ));
1182 }
1183
1184 #[test]
1187 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1188 for uri in [
1189 "s3://acme/team-*/v1/bundle.zip",
1190 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1191 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1192 ] {
1193 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1194 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1195 }
1196
1197 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1198 .expect("an ordinary key still parses");
1199 }
1200
1201 #[test]
1205 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1206 assert_eq!(
1207 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1208 Some("sandbox-bundle")
1209 );
1210 assert_eq!(
1211 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1212 Some("artifacts/team-a/sandbox"),
1213 "a deeper key narrows the prefix, it never widens to the first segment"
1214 );
1215
1216 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1219 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1220 }
1221
1222 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1223 Sandbox::new("agent-sbx".to_string())
1224 .code(SandboxCode::Image {
1225 image: "ubuntu".to_string(),
1226 })
1227 .limits(SandboxLimits {
1228 cpu: "1".to_string(),
1229 memory: "2Gi".to_string(),
1230 disk: "20Gi".to_string(),
1231 max_processes: None,
1232 })
1233 .egress(egress)
1234 .lifecycle(SandboxLifecyclePolicy {
1235 max_lifetime_seconds: None,
1236 idle_pause_seconds: None,
1237 })
1238 .preview_ports(preview_ports)
1239 .build()
1240 }
1241
1242 #[test]
1245 fn a_uri_without_a_token_is_carried_whole() {
1246 assert_eq!(
1247 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1248 Ok(BundleUri::Literal(
1249 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1250 ))
1251 );
1252 }
1253
1254 #[test]
1257 fn a_regional_uri_splits_either_side_of_the_token() {
1258 let BundleUri::Regional { before, after } =
1259 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1260 .expect("the token is accepted in the bucket")
1261 else {
1262 panic!("a bucket-position token must split");
1263 };
1264
1265 assert_eq!(before, "s3://acme-artifacts-");
1266 assert_eq!(after, "/agents/bundle.zip");
1267 assert_eq!(
1268 format!("{before}us-east-2{after}"),
1269 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1270 "the halves must rejoin to the URI the vendor meant"
1271 );
1272 }
1273
1274 #[test]
1277 fn a_token_this_build_cannot_resolve_is_refused() {
1278 for uri in [
1279 "s3://acme-artifacts-{regio}/bundle.zip",
1280 "s3://acme-artifacts/{region}/bundle.zip",
1281 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1282 "s3://acme-artifacts/bundle-{version}.zip",
1283 "s3://acme}-artifacts-{region}/bundle.zip",
1284 "s3://acme{-artifacts-{region}/bundle.zip",
1285 ] {
1286 assert!(
1287 parse_bundle_uri(uri).is_err(),
1288 "'{uri}' must be refused before it can reach an image build"
1289 );
1290 }
1291 }
1292
1293 #[test]
1294 fn resource_type_is_stable() {
1295 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1296 }
1297
1298 #[test]
1299 fn capability_sets_are_per_platform() {
1300 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1301 assert!(
1302 gcp.reconnect,
1303 "generation from the container boot id makes a sandbox reachable across processes"
1304 );
1305 assert!(!gcp.preview);
1306 assert!(gcp.enforced_limits);
1307
1308 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1309 assert!(azure.files, "every backend moves files");
1310 assert!(gcp.files);
1311 assert!(azure.domain_egress_rules);
1314 assert!(azure.egress_deny);
1315 assert!(azure.enforced_limits);
1318 assert!(azure.pause_resume);
1319 assert!(!azure.snapshot);
1323 assert!(!azure.preview);
1324
1325 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1326 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1327 assert!(aws.pause_resume);
1328
1329 let k8s =
1330 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1331 assert!(
1332 !k8s.preview,
1333 "the sandbox-scoped ingress gateway does not exist yet"
1334 );
1335 }
1336
1337 #[test]
1346 fn supervisor_isolation_is_per_platform() {
1347 let value = |platform| {
1348 SandboxCapabilities::for_platform(platform)
1349 .expect("supported")
1350 .supervisor_isolation
1351 };
1352
1353 assert!(
1354 value(Platform::Aws),
1355 "root agent setuids the command to 60000"
1356 );
1357 assert!(
1358 value(Platform::Local),
1359 "the supervisor is on the host, outside the container"
1360 );
1361 assert!(
1362 !value(Platform::Kubernetes),
1363 "a single pinned uid cannot be split"
1364 );
1365 assert!(!value(Platform::Azure), "no Alien process runs the command");
1366 assert!(
1367 !value(Platform::Gcp),
1368 "no separate supervisor identity runs the command"
1369 );
1370 }
1371
1372 #[test]
1376 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1377 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1378 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1379
1380 assert_eq!(
1381 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1382 "the older axis cannot tell them apart"
1383 );
1384 assert!(
1385 aws.supervisor_isolation,
1386 "AWS setuids the command off the supervisor"
1387 );
1388 assert!(
1389 !gcp.supervisor_isolation,
1390 "the command runs under no separate supervisor identity"
1391 );
1392 }
1393
1394 #[test]
1399 fn gcp_agent_platform_row_matches_measured_backend() {
1400 let row = SandboxCapabilities::gcp_agent_platform();
1401
1402 assert!(row.files, "agent file ops move over the sandbox envelope");
1403 assert!(
1404 row.reconnect,
1405 "generation is derived from the container boot id, so a sandbox is reachable across \
1406 processes"
1407 );
1408 assert!(
1409 !row.preview,
1410 "the only ingress is :execute; no port-scoped capability"
1411 );
1412 assert!(
1413 row.pause_resume,
1414 ":pause and :resume preserve the container"
1415 );
1416 assert!(
1417 !row.snapshot,
1418 "the create path never sends a snapshot, so none is reachable through the trait"
1419 );
1420 assert!(
1421 !row.domain_egress_rules,
1422 "VPC and DNS peering is not a hostname allowlist"
1423 );
1424 assert!(
1425 row.egress_deny,
1426 "a declared deny blocks both egress and DNS"
1427 );
1428 assert!(
1429 row.enforced_limits,
1430 "ceilings are enforced, by terminating the sandbox on breach"
1431 );
1432 assert!(!row.process_limit, "no process-count ceiling is observed");
1433 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1434 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1435 assert!(
1436 !row.supervisor_isolation,
1437 "the command is not run under a separate supervisor identity"
1438 );
1439
1440 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1442 assert_eq!(
1443 live, row,
1444 "the Platform::Gcp arm is the Agent Platform capability row"
1445 );
1446 }
1447
1448 #[test]
1449 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1450 let error = SandboxCapabilities::for_platform(Platform::Machines)
1451 .expect_err("Machines has no sandbox backend");
1452 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1453 }
1454
1455 #[test]
1456 fn unsupported_capability_names_platform_and_capability() {
1457 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1458 let error = capabilities
1459 .require(SandboxCapability::Preview, Platform::Gcp)
1460 .expect_err("GCP has no preview");
1461
1462 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1463 let rendered = error.to_string();
1464 assert!(
1465 rendered.contains("preview"),
1466 "names the capability: {rendered}"
1467 );
1468 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1469 }
1470
1471 #[test]
1475 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1476 let sandbox = sandbox_with(
1477 SandboxEgress::AllowDomains {
1478 domains: vec!["example.com".to_string()],
1479 },
1480 vec![],
1481 );
1482
1483 for platform in [
1484 Platform::Aws,
1485 Platform::Gcp,
1486 Platform::Kubernetes,
1487 Platform::Local,
1488 ] {
1489 let error = sandbox
1490 .validate_for_platform(platform)
1491 .expect_err("only Azure expresses a hostname allowlist");
1492 assert_eq!(
1493 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1494 "on {platform:?}"
1495 );
1496 }
1497
1498 assert!(
1499 SandboxCapabilities::for_platform(Platform::Azure)
1500 .expect("supported")
1501 .domain_egress_rules,
1502 "Azure's egress policy matches on host pattern"
1503 );
1504 }
1505
1506 #[test]
1509 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1510 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1511
1512 assert!(
1515 SandboxCapabilities::for_platform(Platform::Gcp)
1516 .expect("supported")
1517 .egress_deny
1518 );
1519
1520 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1521 sandbox
1522 .validate_for_platform(platform)
1523 .expect("deny is enforced here");
1524 }
1525
1526 let egress_only = Sandbox::new("sbx".to_string())
1528 .code(SandboxCode::Image {
1529 image: "alpine".to_string(),
1530 })
1531 .egress(SandboxEgress::Deny)
1532 .lifecycle(SandboxLifecyclePolicy {
1533 max_lifetime_seconds: None,
1534 idle_pause_seconds: None,
1535 })
1536 .build();
1537
1538 egress_only
1539 .validate_for_platform(Platform::Azure)
1540 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1541 }
1542
1543 #[test]
1547 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1548 let undeclared = Sandbox::new("sbx".to_string())
1549 .code(SandboxCode::Image {
1550 image: "alpine".to_string(),
1551 })
1552 .egress(SandboxEgress::Deny)
1553 .lifecycle(SandboxLifecyclePolicy {
1554 max_lifetime_seconds: None,
1555 idle_pause_seconds: None,
1556 })
1557 .build();
1558
1559 undeclared
1560 .validate_for_platform(Platform::Azure)
1561 .expect("a sandbox naming no ceilings takes the platform's own");
1562
1563 assert_eq!(undeclared.resolved_limits().cpu, "1");
1565 }
1566
1567 #[test]
1571 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1572 let sized = |cpu: &str, memory: &str, disk: &str| {
1573 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1574 let limits = sandbox
1575 .limits
1576 .as_mut()
1577 .expect("the fixture declares limits");
1578 limits.cpu = cpu.to_string();
1579 limits.memory = memory.to_string();
1580 limits.disk = disk.to_string();
1581 sandbox.validate_for_platform(Platform::Azure)
1582 };
1583
1584 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
1585 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
1586 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
1587
1588 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
1590 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
1591 assert!(off_step.to_string().contains("cpu"), "{off_step}");
1592
1593 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
1594 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
1595
1596 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
1599 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
1600 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
1601 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
1602
1603 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
1604 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
1605 }
1606
1607 #[test]
1608 fn preview_ports_require_the_preview_capability() {
1609 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1610
1611 sandbox
1612 .validate_for_platform(Platform::Aws)
1613 .expect("AWS mints a port-scoped JWE");
1614
1615 let error = sandbox
1616 .validate_for_platform(Platform::Kubernetes)
1617 .expect_err("Kubernetes preview is deferred");
1618 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1619 }
1620
1621 #[test]
1625 fn a_private_base_image_is_refused_off_aws() {
1626 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1627 sandbox.code = SandboxCode::Image {
1628 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
1629 };
1630 sandbox.private_base_image =
1631 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
1632
1633 sandbox
1634 .validate_for_platform(Platform::Aws)
1635 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
1636
1637 for platform in [
1638 Platform::Gcp,
1639 Platform::Azure,
1640 Platform::Kubernetes,
1641 Platform::Local,
1642 ] {
1643 let error = sandbox
1644 .validate_for_platform(platform)
1645 .expect_err("a backend that builds no image must refuse a base image for one");
1646 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1647 assert!(
1648 error.to_string().contains("privateBaseImage"),
1649 "the refusal must name the field the user declared: {error}"
1650 );
1651 }
1652 }
1653
1654 #[test]
1655 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1656 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1657 sandbox
1658 .validate_for_platform(Platform::Gcp)
1659 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1660 }
1661
1662 #[test]
1663 fn invalid_quantities_are_rejected_with_the_offending_field() {
1664 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1665 sandbox
1666 .limits
1667 .as_mut()
1668 .expect("the fixture declares limits")
1669 .memory = "2Gb".to_string();
1670
1671 let error = sandbox
1672 .validate_for_platform(Platform::Aws)
1673 .expect_err("Gb is not a valid suffix");
1674 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1675 assert!(error.to_string().contains("memory"));
1676
1677 sandbox
1678 .limits
1679 .as_mut()
1680 .expect("the fixture declares limits")
1681 .memory = "2Gi".to_string();
1682 sandbox
1683 .limits
1684 .as_mut()
1685 .expect("the fixture declares limits")
1686 .cpu = "0".to_string();
1687 let error = sandbox
1688 .validate_for_platform(Platform::Aws)
1689 .expect_err("zero cpu is not a ceiling");
1690 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1691 }
1692
1693 #[test]
1694 fn zero_max_processes_is_rejected() {
1695 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1696 sandbox
1697 .limits
1698 .as_mut()
1699 .expect("the fixture declares limits")
1700 .max_processes = Some(0);
1701
1702 let error = sandbox
1703 .validate_for_platform(Platform::Local)
1704 .expect_err("a sandbox must be able to run at least one process");
1705 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1706 assert!(error.to_string().contains("maxProcesses"));
1707 }
1708
1709 #[test]
1713 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1714 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1715 sandbox
1716 .limits
1717 .as_mut()
1718 .expect("the fixture declares limits")
1719 .max_processes = Some(256);
1720
1721 sandbox
1722 .validate_for_platform(Platform::Local)
1723 .expect("Docker takes a pids limit");
1724
1725 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1726 let error = sandbox
1727 .validate_for_platform(platform)
1728 .expect_err("a process ceiling nothing applies must be refused");
1729 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1730 }
1731 }
1732
1733 #[test]
1737 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1738 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1739
1740 for seconds in [0, 28_801, 100_000] {
1741 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
1742 let error = sandbox
1743 .validate_for_platform(Platform::Aws)
1744 .expect_err("a lifetime outside what AWS runs is refused");
1745 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1746
1747 sandbox
1749 .validate_for_platform(Platform::Kubernetes)
1750 .expect("the kubelet takes any activeDeadlineSeconds");
1751 }
1752
1753 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
1754 sandbox
1755 .validate_for_platform(Platform::Aws)
1756 .expect("the ceiling itself is allowed");
1757 }
1758
1759 #[test]
1764 fn an_image_azure_cannot_pull_is_refused_while_planning() {
1765 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1766 sandbox.limits = None;
1769
1770 for image in [
1771 "ubuntu:24.04",
1772 "ghcr.io/myorg/sandbox:latest",
1773 "ubuntu@sha256:abc",
1774 "",
1775 " ",
1776 "ubuntu latest",
1777 "ubuntu?x",
1778 ] {
1779 sandbox.code = SandboxCode::Image {
1780 image: image.to_string(),
1781 };
1782 let error = sandbox
1783 .validate_for_platform(Platform::Azure)
1784 .expect_err("an image Azure has nowhere to put is refused");
1785 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1786
1787 sandbox
1789 .validate_for_platform(Platform::Kubernetes)
1790 .expect("a registry reference is what every other backend takes");
1791 }
1792
1793 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1794 sandbox.code = SandboxCode::Image {
1795 image: image.to_string(),
1796 };
1797 sandbox
1798 .validate_for_platform(Platform::Azure)
1799 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1800 }
1801
1802 sandbox.code = SandboxCode::Image {
1804 image: " ubuntu ".to_string(),
1805 };
1806 assert_eq!(
1807 sandbox
1808 .azure_catalog_image()
1809 .expect("a padded name is still a name"),
1810 "ubuntu"
1811 );
1812 }
1813
1814 #[test]
1818 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
1819 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1820 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
1821
1822 sandbox
1823 .validate_for_platform(Platform::Kubernetes)
1824 .expect("the kubelet enforces activeDeadlineSeconds");
1825 sandbox
1826 .validate_for_platform(Platform::Aws)
1827 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1828
1829 for platform in [Platform::Azure, Platform::Local] {
1830 let error = sandbox
1831 .validate_for_platform(platform)
1832 .expect_err("a deadline nothing applies must be refused");
1833 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1834 }
1835 }
1836
1837 #[test]
1841 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1842 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1843 let tier = sandbox
1844 .microvm_tier()
1845 .expect("2Gi/1cpu/20Gi is satisfiable");
1846
1847 assert_eq!(
1848 tier.peak_memory_mib, 2048,
1849 "the peak is the declared ceiling"
1850 );
1851 assert_eq!(
1852 tier.baseline_memory_mib, 512,
1853 "which is a quarter of it as the baseline"
1854 );
1855 assert!(tier.max_disk_mib <= 20 * 1024);
1856 }
1857
1858 #[test]
1862 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1863 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1864 {
1865 let limits = sandbox
1866 .limits
1867 .as_mut()
1868 .expect("the fixture declares limits");
1869 limits.cpu = "1".to_string();
1870 limits.memory = "8Gi".to_string();
1871 }
1872
1873 let error = sandbox
1874 .microvm_tier()
1875 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1876 assert!(
1877 error.to_string().contains("4 vCPU"),
1878 "the refusal must say what the memory ceiling implies: {error}"
1879 );
1880
1881 sandbox
1882 .limits
1883 .as_mut()
1884 .expect("the fixture declares limits")
1885 .cpu = "4".to_string();
1886 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1887 assert_eq!(tier.peak_memory_mib, 8192);
1888 }
1889
1890 #[test]
1893 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1894 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1895 sandbox
1896 .limits
1897 .as_mut()
1898 .expect("the fixture declares limits")
1899 .memory = "1Gi".to_string();
1900
1901 let error = sandbox
1902 .validate_for_platform(Platform::Aws)
1903 .expect_err("no MicroVM size peaks at or below 1Gi");
1904 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1905 assert!(
1906 error.to_string().contains("2Gi"),
1907 "the refusal must say what the smallest holdable ceiling is: {error}"
1908 );
1909 }
1910
1911 #[test]
1915 fn source_code_is_refused_off_aws_rather_than_producing_a_broken_manifest() {
1916 let sandbox = Sandbox::new("agent".to_string())
1917 .code(SandboxCode::Source {
1918 src: "./sandbox".to_string(),
1919 toolchain: ToolchainConfig::Docker {
1920 dockerfile: None,
1921 build_args: None,
1922 target: None,
1923 },
1924 })
1925 .egress(SandboxEgress::Deny)
1926 .lifecycle(SandboxLifecyclePolicy {
1927 max_lifetime_seconds: None,
1928 idle_pause_seconds: None,
1929 })
1930 .build();
1931
1932 sandbox
1933 .validate_for_platform(Platform::Aws)
1934 .expect("an AWS sandbox base image is built by `alien build`");
1935
1936 for platform in [
1937 Platform::Azure,
1938 Platform::Gcp,
1939 Platform::Kubernetes,
1940 Platform::Local,
1941 ] {
1942 let error = sandbox
1943 .validate_for_platform(platform)
1944 .expect_err("no backend builds a sandbox image from source here");
1945 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1946 assert!(
1947 error.to_string().contains("code.image"),
1948 "the refusal must say what to write instead: {error}"
1949 );
1950 assert!(
1951 error.to_string().contains(&platform.to_string()),
1952 "the refusal must name the platform that cannot build it: {error}"
1953 );
1954 }
1955 }
1956
1957 #[test]
1960 fn every_accepted_unit_converts_rather_than_falling_back() {
1961 assert_eq!(quantity_mib("2Gi"), Some(2048));
1962 assert_eq!(quantity_mib("512Mi"), Some(512));
1963 assert_eq!(quantity_mib("4G"), Some(3814));
1964 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1965 assert_eq!(millicores("1"), Some(1000));
1966 assert_eq!(millicores("500m"), Some(500));
1967 }
1968
1969 #[test]
1970 fn unknown_fields_are_rejected() {
1971 let json = r#"{
1972 "id": "sbx",
1973 "code": {"type": "image", "image": "ubuntu:24.04"},
1974 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1975 "egress": {"mode": "deny"},
1976 "lifecycle": {},
1977 "unexpected": true
1978 }"#;
1979
1980 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1981 }
1982
1983 #[test]
1984 fn serialization_roundtrips() {
1985 let sandbox = sandbox_with(
1986 SandboxEgress::AllowDomains {
1987 domains: vec!["example.com".to_string()],
1988 },
1989 vec![8080, 9090],
1990 );
1991
1992 let json = serde_json::to_string(&sandbox).expect("serializes");
1993 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1994 assert_eq!(sandbox, restored);
1995 }
1996
1997 #[test]
1998 fn id_is_immutable_across_updates() {
1999 let original = sandbox_with(SandboxEgress::Deny, vec![]);
2000 let renamed = Sandbox::new("other".to_string())
2001 .code(SandboxCode::Image {
2002 image: "ubuntu".to_string(),
2003 })
2004 .limits(
2005 original
2006 .limits
2007 .clone()
2008 .expect("the fixture declares limits"),
2009 )
2010 .egress(SandboxEgress::Deny)
2011 .lifecycle(SandboxLifecyclePolicy {
2012 max_lifetime_seconds: None,
2013 idle_pause_seconds: None,
2014 })
2015 .build();
2016
2017 original
2018 .validate_update(&original.clone())
2019 .expect("an unchanged config is a valid update");
2020 original
2021 .validate_update(&renamed)
2022 .expect_err("renaming a sandbox is not an update");
2023 }
2024
2025 #[test]
2031 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2032 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2033 Sandbox::new("sbx".to_string())
2034 .code(SandboxCode::Image {
2035 image: "ubuntu".to_string(),
2036 })
2037 .egress(SandboxEgress::Allow)
2038 .lifecycle(lifecycle)
2039 .build()
2040 .validate_for_platform(Platform::Azure)
2041 };
2042
2043 with_policy(SandboxLifecyclePolicy {
2044 max_lifetime_seconds: None,
2045 idle_pause_seconds: Some(900),
2046 })
2047 .expect("Azure pauses a sandbox on idle");
2048
2049 let error = with_policy(SandboxLifecyclePolicy {
2050 max_lifetime_seconds: Some(3600),
2051 idle_pause_seconds: None,
2052 })
2053 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2054 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2055 assert!(
2056 error.message.contains("sandboxLifetime"),
2057 "names the capability: {}",
2058 error.message
2059 );
2060 }
2061
2062 #[test]
2068 fn an_allowlist_with_no_domains_is_refused() {
2069 let declared = |domains: Vec<String>| {
2070 Sandbox::new("sbx".to_string())
2071 .code(SandboxCode::Image {
2072 image: "ubuntu".to_string(),
2073 })
2074 .egress(SandboxEgress::AllowDomains { domains })
2075 .lifecycle(SandboxLifecyclePolicy {
2076 max_lifetime_seconds: None,
2077 idle_pause_seconds: None,
2078 })
2079 .build()
2080 .validate_for_platform(Platform::Azure)
2081 };
2082
2083 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2084 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2085
2086 declared(vec!["api.example.com".to_string()])
2087 .expect("a named domain is what an allowlist is for");
2088 }
2089
2090 #[test]
2093 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2094 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2095 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
2096 assert_eq!(
2097 SandboxEgress::AllowDomains {
2098 domains: vec!["api.example.com".to_string()]
2099 }
2100 .internet_access_switch(),
2101 None,
2102 "a host list has no boolean and must not be approximated"
2103 );
2104 }
2105}