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")]
36 Source {
37 src: String,
39 toolchain: ToolchainConfig,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct SandboxLimits {
53 pub cpu: String,
55 pub memory: String,
57 pub disk: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub max_processes: Option<u32>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct MicrovmTier {
75 pub baseline_memory_mib: i64,
77 pub peak_memory_mib: i64,
79 pub peak_vcpu: u32,
81 pub max_disk_mib: i64,
83}
84
85const AWS_MAX_LIFETIME_SECONDS: u32 = 28_800;
89
90const AZURE_CPU_STEP_MILLICORES: i64 = 250;
93const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
94const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
95const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
96
97const MICROVM_TIERS: &[MicrovmTier] = &[
98 MicrovmTier {
99 baseline_memory_mib: 512,
100 peak_memory_mib: 2048,
101 peak_vcpu: 1,
102 max_disk_mib: 8192,
103 },
104 MicrovmTier {
105 baseline_memory_mib: 1024,
106 peak_memory_mib: 4096,
107 peak_vcpu: 2,
108 max_disk_mib: 8192,
109 },
110 MicrovmTier {
111 baseline_memory_mib: 2048,
112 peak_memory_mib: 8192,
113 peak_vcpu: 4,
114 max_disk_mib: 8192,
115 },
116 MicrovmTier {
117 baseline_memory_mib: 4096,
118 peak_memory_mib: 16384,
119 peak_vcpu: 8,
120 max_disk_mib: 16384,
121 },
122 MicrovmTier {
123 baseline_memory_mib: 8192,
124 peak_memory_mib: 32768,
125 peak_vcpu: 16,
126 max_disk_mib: 32768,
127 },
128];
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
133#[serde(rename_all = "camelCase", tag = "mode")]
134pub enum SandboxEgress {
135 Deny,
140 Allow,
147 #[serde(rename_all = "camelCase")]
152 AllowDomains {
153 domains: Vec<String>,
155 },
156}
157
158impl SandboxEgress {
159 pub fn internet_access_switch(&self) -> Option<bool> {
166 match self {
167 SandboxEgress::Allow => Some(true),
168 SandboxEgress::Deny => Some(false),
169 SandboxEgress::AllowDomains { .. } => None,
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct SandboxLifecyclePolicy {
182 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub max_lifetime_seconds: Option<u32>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub idle_pause_seconds: Option<u32>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct SandboxCapabilities {
204 pub files: bool,
206 pub reconnect: bool,
208 pub jobs: bool,
211 pub preview: bool,
213 pub pause_resume: bool,
215 pub snapshot: bool,
217 pub domain_egress_rules: bool,
219 pub egress_deny: bool,
221 pub enforced_limits: bool,
223 pub process_limit: bool,
225 pub sandbox_lifetime: bool,
227 pub supervisor_pid_namespace: bool,
233 pub supervisor_isolation: bool,
239}
240
241impl SandboxCapabilities {
242 pub fn for_platform(platform: Platform) -> Result<Self> {
248 match platform {
249 Platform::Aws => Ok(Self {
250 files: true,
251 reconnect: true,
252 jobs: true,
253 preview: true,
254 pause_resume: true,
255 snapshot: false,
256 domain_egress_rules: false,
257 egress_deny: true,
258 enforced_limits: true,
259 process_limit: false,
261 sandbox_lifetime: true,
264 supervisor_pid_namespace: false,
269 supervisor_isolation: true,
272 }),
273 Platform::Azure => Ok(Self::azure()),
274 Platform::Gcp => Ok(Self::gcp_agent_platform()),
275 Platform::Kubernetes => Ok(Self {
278 files: true,
279 reconnect: true,
280 jobs: true,
281 preview: false,
282 pause_resume: false,
283 snapshot: false,
284 domain_egress_rules: false,
285 egress_deny: true,
286 enforced_limits: true,
287 process_limit: false,
289 sandbox_lifetime: true,
291 supervisor_pid_namespace: false,
295 supervisor_isolation: false,
300 }),
301 Platform::Local => Ok(Self {
302 files: true,
303 reconnect: true,
304 jobs: false,
306 preview: true,
307 pause_resume: false,
308 snapshot: false,
309 domain_egress_rules: false,
310 egress_deny: true,
311 enforced_limits: true,
312 process_limit: true,
314 sandbox_lifetime: false,
315 supervisor_pid_namespace: false,
318 supervisor_isolation: true,
322 }),
323 Platform::Machines | Platform::Test => {
324 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
325 platform: platform.to_string(),
326 }))
327 }
328 }
329 }
330
331 pub fn azure() -> Self {
333 Self {
334 files: true,
335 reconnect: true,
336 jobs: false,
338 preview: false,
343 pause_resume: true,
344 snapshot: false,
348 domain_egress_rules: true,
349 egress_deny: true,
350 enforced_limits: true,
354 process_limit: false,
355 sandbox_lifetime: false,
359 supervisor_pid_namespace: false,
361 supervisor_isolation: false,
364 }
365 }
366
367 pub fn gcp_agent_platform() -> Self {
369 Self {
370 files: true,
372 reconnect: true,
376 jobs: true,
377 preview: false,
379 pause_resume: true,
381 snapshot: false,
384 domain_egress_rules: false,
386 egress_deny: true,
388 enforced_limits: true,
392 process_limit: false,
394 sandbox_lifetime: true,
396 supervisor_pid_namespace: false,
398 supervisor_isolation: false,
401 }
402 }
403
404 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
406 let available = match capability {
407 SandboxCapability::Files => self.files,
408 SandboxCapability::Reconnect => self.reconnect,
409 SandboxCapability::Jobs => self.jobs,
410 SandboxCapability::Preview => self.preview,
411 SandboxCapability::PauseResume => self.pause_resume,
412 SandboxCapability::Snapshot => self.snapshot,
413 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
414 SandboxCapability::EgressDeny => self.egress_deny,
415 SandboxCapability::EnforcedLimits => self.enforced_limits,
416 SandboxCapability::ProcessLimit => self.process_limit,
417 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
418 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
419 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
420 };
421
422 if available {
423 return Ok(());
424 }
425
426 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
427 capability: capability.as_str().to_string(),
428 platform: platform.to_string(),
429 }))
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
435#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
436#[serde(rename_all = "camelCase")]
437pub enum SandboxCapability {
438 Files,
440 Reconnect,
442 Jobs,
444 Preview,
446 PauseResume,
448 Snapshot,
450 DomainEgressRules,
452 EgressDeny,
454 EnforcedLimits,
456 ProcessLimit,
458 SandboxLifetime,
460 SupervisorPidNamespace,
462 SupervisorIsolation,
464}
465
466impl SandboxCapability {
467 pub fn as_str(&self) -> &'static str {
469 match self {
470 Self::Files => "files",
471 Self::Reconnect => "reconnect",
472 Self::Jobs => "jobs",
473 Self::Preview => "preview",
474 Self::PauseResume => "pauseResume",
475 Self::Snapshot => "snapshot",
476 Self::DomainEgressRules => "domainEgressRules",
477 Self::EgressDeny => "egressDeny",
478 Self::EnforcedLimits => "enforcedLimits",
479 Self::ProcessLimit => "processLimit",
480 Self::SandboxLifetime => "sandboxLifetime",
481 Self::SupervisorPidNamespace => "supervisorPidNamespace",
482 Self::SupervisorIsolation => "supervisorIsolation",
483 }
484 }
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
489#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
490#[serde(rename_all = "camelCase", deny_unknown_fields)]
491#[builder(start_fn = new)]
492pub struct Sandbox {
493 #[builder(start_fn)]
496 pub id: String,
497 pub code: SandboxCode,
499 #[serde(skip_serializing_if = "Option::is_none")]
505 pub limits: Option<SandboxLimits>,
506 pub egress: SandboxEgress,
508 pub lifecycle: SandboxLifecyclePolicy,
510 #[builder(default)]
514 #[serde(default, skip_serializing_if = "Vec::is_empty")]
515 pub preview_ports: Vec<u16>,
516}
517
518pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
523 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
524}
525
526pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
532 stack.resources().any(|(_resource_id, resource)| {
533 resource
534 .config
535 .downcast_ref::<Sandbox>()
536 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
537 })
538}
539
540impl Sandbox {
541 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
543
544 pub fn id(&self) -> &str {
546 &self.id
547 }
548
549 pub fn resolved_limits(&self) -> SandboxLimits {
555 self.limits.clone().unwrap_or_else(default_limits)
556 }
557
558 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
563 let capabilities = SandboxCapabilities::for_platform(platform)?;
564
565 if let SandboxCode::Source { .. } = &self.code {
569 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
570 resource_id: self.id.clone(),
571 field: "code".to_string(),
572 value: "source".to_string(),
573 reason: "no sandbox backend builds an image from source yet; give code.image a \
574 prebuilt reference"
575 .to_string(),
576 }));
577 }
578
579 if platform == Platform::Azure {
581 self.azure_catalog_image()?;
582 }
583
584 let Some(limits) = self.limits.as_ref() else {
585 return self.validate_capabilities(&capabilities, platform);
587 };
588
589 validate_quantity(&self.id, "cpu", &limits.cpu)?;
590 validate_quantity(&self.id, "memory", &limits.memory)?;
591 validate_quantity(&self.id, "disk", &limits.disk)?;
592
593 if let Some(max_processes) = limits.max_processes {
594 if max_processes == 0 {
595 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
596 resource_id: self.id.clone(),
597 field: "maxProcesses".to_string(),
598 value: "0".to_string(),
599 reason: "a sandbox that may run no processes cannot run code".to_string(),
600 }));
601 }
602 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
603 }
604
605 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
608
609 if platform == Platform::Azure {
610 self.azure_sandbox_limits()?;
611 }
612
613 if platform == Platform::Aws {
614 self.microvm_tier()?;
617
618 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
623 if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
624 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
625 resource_id: self.id.clone(),
626 field: "maxLifetimeSeconds".to_string(),
627 value: seconds.to_string(),
628 reason: format!(
629 "AWS runs a MicroVM for between 1 and \
630 {AWS_MAX_LIFETIME_SECONDS} seconds"
631 ),
632 }));
633 }
634 }
635 }
636
637 self.validate_capabilities(&capabilities, platform)
638 }
639
640 pub fn azure_catalog_image(&self) -> Result<&str> {
646 let refused = |value: &str, reason: &str| {
647 AlienError::new(ErrorData::SandboxLimitInvalid {
648 resource_id: self.id.clone(),
649 field: "code.image".to_string(),
650 value: value.to_string(),
651 reason: reason.to_string(),
652 })
653 };
654
655 let SandboxCode::Image { image } = &self.code else {
656 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
657 resource_id: self.id.clone(),
658 field: "code".to_string(),
659 value: "source".to_string(),
660 reason: "no sandbox backend builds an image from source yet".to_string(),
661 }));
662 };
663
664 let image = image.trim();
665 if image.is_empty() {
666 return Err(refused(image, "a sandbox has to name an image"));
667 }
668 if !image
669 .chars()
670 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
671 {
672 return Err(refused(
673 image,
674 "Azure creates a sandbox from a public catalog disk image, so code.image must be \
675 a bare catalog name such as 'ubuntu'",
676 ));
677 }
678 Ok(image)
679 }
680
681 pub fn azure_sandbox_limits(&self) -> Result<()> {
685 let Some(limits) = self.limits.as_ref() else {
686 return Ok(());
689 };
690
691 let refused = |field: &str, value: &str, reason: &str| {
692 AlienError::new(ErrorData::SandboxLimitInvalid {
693 resource_id: self.id.clone(),
694 field: field.to_string(),
695 value: value.to_string(),
696 reason: reason.to_string(),
697 })
698 };
699
700 let cpu_millicores = millicores(&limits.cpu)
701 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
702
703 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
707 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
708 {
709 return Err(refused(
710 "cpu",
711 &limits.cpu,
712 "Azure allocates cpu in steps of 250m from 250m to 16000m",
713 ));
714 }
715
716 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
718 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
719
720 let memory_mib = quantity_mib(&limits.memory)
721 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
722 if memory_mib > memory_ceiling_mib {
723 return Err(refused(
724 "memory",
725 &limits.memory,
726 &format!(
727 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
728 at the declared cpu"
729 ),
730 ));
731 }
732
733 let disk_mib = quantity_mib(&limits.disk)
734 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
735 if disk_mib > disk_ceiling_mib {
736 return Err(refused(
737 "disk",
738 &limits.disk,
739 &format!(
740 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
741 the declared cpu"
742 ),
743 ));
744 }
745
746 Ok(())
747 }
748
749 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
756 let Some(limits) = self.limits.as_ref() else {
757 return Ok(MICROVM_TIERS[2]);
759 };
760
761 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
762 AlienError::new(ErrorData::SandboxLimitInvalid {
763 resource_id: self.id.clone(),
764 field: "memory".to_string(),
765 value: limits.memory.clone(),
766 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
767 })
768 })?;
769 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
770 AlienError::new(ErrorData::SandboxLimitInvalid {
771 resource_id: self.id.clone(),
772 field: "disk".to_string(),
773 value: limits.disk.clone(),
774 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
775 })
776 })?;
777 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
778 AlienError::new(ErrorData::SandboxLimitInvalid {
779 resource_id: self.id.clone(),
780 field: "cpu".to_string(),
781 value: limits.cpu.clone(),
782 reason: "expected cores or millicores".to_string(),
783 })
784 })?;
785
786 let sized = |tier: &&MicrovmTier| {
791 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
792 };
793
794 let tier = MICROVM_TIERS
795 .iter()
796 .rev()
797 .find(sized)
798 .copied()
799 .ok_or_else(|| {
800 AlienError::new(ErrorData::SandboxLimitInvalid {
801 resource_id: self.id.clone(),
802 field: "memory".to_string(),
803 value: limits.memory.clone(),
804 reason: format!(
805 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
806 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
807 disk fit no size",
808 limits.memory, limits.disk
809 ),
810 })
811 })?;
812
813 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
814 if cpu_millicores < required_millicores {
815 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
816 resource_id: self.id.clone(),
817 field: "cpu".to_string(),
818 value: limits.cpu.clone(),
819 reason: format!(
820 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
821 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
822 limits.memory, tier.peak_vcpu, tier.peak_vcpu
823 ),
824 }));
825 }
826
827 Ok(tier)
828 }
829
830 fn validate_capabilities(
832 &self,
833 capabilities: &SandboxCapabilities,
834 platform: Platform,
835 ) -> Result<()> {
836 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
837 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
838 }
839
840 if let SandboxEgress::AllowDomains { domains } = &self.egress {
846 if domains.is_empty() {
847 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
848 resource_id: self.id.clone(),
849 field: "egress.domains".to_string(),
850 value: "[]".to_string(),
851 reason: "an allowlist naming no domain denies everything; declare \
852 egress: deny if that is what was meant"
853 .to_string(),
854 }));
855 }
856 }
857
858 if matches!(self.egress, SandboxEgress::Deny) {
859 capabilities.require(SandboxCapability::EgressDeny, platform)?;
860 }
861
862 if !self.preview_ports.is_empty() {
863 capabilities.require(SandboxCapability::Preview, platform)?;
864 }
865
866 if self.lifecycle.idle_pause_seconds.is_some() {
867 capabilities.require(SandboxCapability::PauseResume, platform)?;
868 }
869
870 if self.lifecycle.max_lifetime_seconds.is_some() {
871 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
872 }
873
874 Ok(())
875 }
876}
877
878fn default_limits() -> SandboxLimits {
883 SandboxLimits {
884 cpu: "1".to_string(),
885 memory: "2Gi".to_string(),
886 disk: "8Gi".to_string(),
887 max_processes: None,
888 }
889}
890
891fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
893 let invalid = |reason: &str| {
894 AlienError::new(ErrorData::SandboxLimitInvalid {
895 resource_id: resource_id.to_string(),
896 field: field.to_string(),
897 value: value.to_string(),
898 reason: reason.to_string(),
899 })
900 };
901
902 let digits_end = value
903 .find(|c: char| !c.is_ascii_digit() && c != '.')
904 .unwrap_or(value.len());
905 let (number, suffix) = value.split_at(digits_end);
906
907 let parsed: f64 = number
908 .parse()
909 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
910
911 if parsed <= 0.0 {
912 return Err(invalid("must be greater than zero"));
913 }
914
915 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
916 if !SUFFIXES.contains(&suffix) {
917 return Err(invalid(
918 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
919 ));
920 }
921
922 Ok(())
923}
924
925fn split_quantity(value: &str) -> Option<(f64, &str)> {
927 let trimmed = value.trim();
928 let digits_end = trimmed
929 .find(|c: char| !c.is_ascii_digit() && c != '.')
930 .unwrap_or(trimmed.len());
931 let (number, suffix) = trimmed.split_at(digits_end);
932 number.parse().ok().map(|number| (number, suffix))
933}
934
935pub fn quantity_mib(value: &str) -> Option<i64> {
941 let (number, suffix) = split_quantity(value)?;
942 let bytes = match suffix {
943 "" => number,
944 "k" => number * 1e3,
945 "M" => number * 1e6,
946 "G" => number * 1e9,
947 "T" => number * 1e12,
948 "Ki" => number * 1024.0,
949 "Mi" => number * 1024.0 * 1024.0,
950 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
951 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
952 _ => return None,
954 };
955 Some((bytes / (1024.0 * 1024.0)) as i64)
956}
957
958pub fn millicores(value: &str) -> Option<i64> {
960 let (number, suffix) = split_quantity(value)?;
961 match suffix {
962 "" => Some((number * 1000.0) as i64),
963 "m" => Some(number as i64),
964 _ => None,
965 }
966}
967
968#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
971#[serde(rename_all = "camelCase")]
972pub struct SandboxOutputs {
973 pub parent_name: String,
975 #[serde(skip_serializing_if = "Option::is_none")]
977 pub identifier: Option<String>,
978 #[serde(skip_serializing_if = "Option::is_none")]
980 pub endpoint: Option<String>,
981}
982
983impl ResourceOutputsDefinition for SandboxOutputs {
984 fn get_resource_type(&self) -> ResourceType {
985 Sandbox::RESOURCE_TYPE
986 }
987
988 fn as_any(&self) -> &dyn Any {
989 self
990 }
991
992 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
993 Box::new(self.clone())
994 }
995
996 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
997 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
998 }
999
1000 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1001 serde_json::to_value(self)
1002 }
1003}
1004
1005impl ResourceDefinition for Sandbox {
1006 fn get_resource_type(&self) -> ResourceType {
1007 Self::RESOURCE_TYPE
1008 }
1009
1010 fn id(&self) -> &str {
1011 &self.id
1012 }
1013
1014 fn get_dependencies(&self) -> Vec<ResourceRef> {
1015 Vec::new()
1016 }
1017
1018 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1019 let new_sandbox = new_config
1020 .as_any()
1021 .downcast_ref::<Sandbox>()
1022 .ok_or_else(|| {
1023 AlienError::new(ErrorData::UnexpectedResourceType {
1024 resource_id: self.id.clone(),
1025 expected: Self::RESOURCE_TYPE,
1026 actual: new_config.get_resource_type(),
1027 })
1028 })?;
1029
1030 if self.id != new_sandbox.id {
1031 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1032 resource_id: self.id.clone(),
1033 reason: "the 'id' field is immutable".to_string(),
1034 }));
1035 }
1036
1037 Ok(())
1038 }
1039
1040 fn as_any(&self) -> &dyn Any {
1041 self
1042 }
1043
1044 fn as_any_mut(&mut self) -> &mut dyn Any {
1045 self
1046 }
1047
1048 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1049 Box::new(self.clone())
1050 }
1051
1052 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1053 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1054 }
1055
1056 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1057 serde_json::to_value(self)
1058 }
1059}
1060
1061pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1066
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069pub enum BundleUri<'a> {
1070 Literal(&'a str),
1072 Regional { before: &'a str, after: &'a str },
1075}
1076
1077pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1081 let (above_file, _) = key.rsplit_once('/')?;
1082 let (above_version, _) = above_file.rsplit_once('/')?;
1083 Some(above_version)
1084}
1085
1086pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1092 let path = uri
1093 .strip_prefix("s3://")
1094 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1095 let (bucket, key) = path
1096 .split_once('/')
1097 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1098
1099 if path.contains('*') || path.contains('?') {
1103 return Err(format!(
1104 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1105 role's grant, so '*' and '?' would widen it past the bundle"
1106 ));
1107 }
1108
1109 if key.contains('{') || key.contains('}') {
1110 return Err(format!(
1111 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1112 bucket name alone"
1113 ));
1114 }
1115
1116 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1117 if bucket.contains('{') || bucket.contains('}') {
1118 return Err(format!(
1119 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1120 only one"
1121 ));
1122 }
1123 return Ok(BundleUri::Literal(uri));
1124 };
1125
1126 if after.contains(BUNDLE_REGION_TOKEN) {
1127 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1128 }
1129 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1130 return Err(format!(
1131 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1132 ));
1133 }
1134
1135 Ok(BundleUri::Regional {
1136 before: &uri[.."s3://".len() + before.len()],
1137 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1138 })
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144
1145 #[test]
1146 fn private_database_setup_accepts_the_default_network() {
1147 for lifecycle in [
1148 crate::ResourceLifecycle::Frozen,
1149 crate::ResourceLifecycle::Live,
1150 ] {
1151 let stack = crate::Stack::new("database".to_string())
1152 .add(
1153 crate::Postgres::new("metadata".to_string()).build(),
1154 lifecycle,
1155 )
1156 .build();
1157 assert!(!restricts_network_mode(&stack, false));
1158 assert!(!restricts_network_mode(&stack, true));
1159 }
1160 assert!(!restricts_network_mode(
1161 &crate::Stack::new("empty".to_string()).build(),
1162 false,
1163 ));
1164 }
1165
1166 #[test]
1169 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1170 for uri in [
1171 "s3://acme/team-*/v1/bundle.zip",
1172 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1173 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1174 ] {
1175 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1176 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1177 }
1178
1179 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1180 .expect("an ordinary key still parses");
1181 }
1182
1183 #[test]
1187 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1188 assert_eq!(
1189 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1190 Some("sandbox-bundle")
1191 );
1192 assert_eq!(
1193 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1194 Some("artifacts/team-a/sandbox"),
1195 "a deeper key narrows the prefix, it never widens to the first segment"
1196 );
1197
1198 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1201 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1202 }
1203
1204 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1205 Sandbox::new("agent-sbx".to_string())
1206 .code(SandboxCode::Image {
1207 image: "ubuntu".to_string(),
1208 })
1209 .limits(SandboxLimits {
1210 cpu: "1".to_string(),
1211 memory: "2Gi".to_string(),
1212 disk: "20Gi".to_string(),
1213 max_processes: None,
1214 })
1215 .egress(egress)
1216 .lifecycle(SandboxLifecyclePolicy {
1217 max_lifetime_seconds: None,
1218 idle_pause_seconds: None,
1219 })
1220 .preview_ports(preview_ports)
1221 .build()
1222 }
1223
1224 #[test]
1227 fn a_uri_without_a_token_is_carried_whole() {
1228 assert_eq!(
1229 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1230 Ok(BundleUri::Literal(
1231 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1232 ))
1233 );
1234 }
1235
1236 #[test]
1239 fn a_regional_uri_splits_either_side_of_the_token() {
1240 let BundleUri::Regional { before, after } =
1241 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1242 .expect("the token is accepted in the bucket")
1243 else {
1244 panic!("a bucket-position token must split");
1245 };
1246
1247 assert_eq!(before, "s3://acme-artifacts-");
1248 assert_eq!(after, "/agents/bundle.zip");
1249 assert_eq!(
1250 format!("{before}us-east-2{after}"),
1251 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1252 "the halves must rejoin to the URI the vendor meant"
1253 );
1254 }
1255
1256 #[test]
1259 fn a_token_this_build_cannot_resolve_is_refused() {
1260 for uri in [
1261 "s3://acme-artifacts-{regio}/bundle.zip",
1262 "s3://acme-artifacts/{region}/bundle.zip",
1263 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1264 "s3://acme-artifacts/bundle-{version}.zip",
1265 "s3://acme}-artifacts-{region}/bundle.zip",
1266 "s3://acme{-artifacts-{region}/bundle.zip",
1267 ] {
1268 assert!(
1269 parse_bundle_uri(uri).is_err(),
1270 "'{uri}' must be refused before it can reach an image build"
1271 );
1272 }
1273 }
1274
1275 #[test]
1276 fn resource_type_is_stable() {
1277 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1278 }
1279
1280 #[test]
1281 fn capability_sets_are_per_platform() {
1282 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1283 assert!(
1284 gcp.reconnect,
1285 "generation from the container boot id makes a sandbox reachable across processes"
1286 );
1287 assert!(!gcp.preview);
1288 assert!(gcp.enforced_limits);
1289
1290 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1291 assert!(azure.files, "every backend moves files");
1292 assert!(gcp.files);
1293 assert!(azure.domain_egress_rules);
1296 assert!(azure.egress_deny);
1297 assert!(azure.enforced_limits);
1300 assert!(azure.pause_resume);
1301 assert!(!azure.snapshot);
1305 assert!(!azure.preview);
1306
1307 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1308 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1309 assert!(aws.pause_resume);
1310
1311 let k8s =
1312 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1313 assert!(
1314 !k8s.preview,
1315 "the sandbox-scoped ingress gateway does not exist yet"
1316 );
1317 }
1318
1319 #[test]
1328 fn supervisor_isolation_is_per_platform() {
1329 let value = |platform| {
1330 SandboxCapabilities::for_platform(platform)
1331 .expect("supported")
1332 .supervisor_isolation
1333 };
1334
1335 assert!(
1336 value(Platform::Aws),
1337 "root agent setuids the command to 60000"
1338 );
1339 assert!(
1340 value(Platform::Local),
1341 "the supervisor is on the host, outside the container"
1342 );
1343 assert!(
1344 !value(Platform::Kubernetes),
1345 "a single pinned uid cannot be split"
1346 );
1347 assert!(!value(Platform::Azure), "no Alien process runs the command");
1348 assert!(
1349 !value(Platform::Gcp),
1350 "no separate supervisor identity runs the command"
1351 );
1352 }
1353
1354 #[test]
1358 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1359 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1360 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1361
1362 assert_eq!(
1363 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1364 "the older axis cannot tell them apart"
1365 );
1366 assert!(
1367 aws.supervisor_isolation,
1368 "AWS setuids the command off the supervisor"
1369 );
1370 assert!(
1371 !gcp.supervisor_isolation,
1372 "the command runs under no separate supervisor identity"
1373 );
1374 }
1375
1376 #[test]
1381 fn gcp_agent_platform_row_matches_measured_backend() {
1382 let row = SandboxCapabilities::gcp_agent_platform();
1383
1384 assert!(row.files, "agent file ops move over the sandbox envelope");
1385 assert!(
1386 row.reconnect,
1387 "generation is derived from the container boot id, so a sandbox is reachable across \
1388 processes"
1389 );
1390 assert!(
1391 !row.preview,
1392 "the only ingress is :execute; no port-scoped capability"
1393 );
1394 assert!(
1395 row.pause_resume,
1396 ":pause and :resume preserve the container"
1397 );
1398 assert!(
1399 !row.snapshot,
1400 "the create path never sends a snapshot, so none is reachable through the trait"
1401 );
1402 assert!(
1403 !row.domain_egress_rules,
1404 "VPC and DNS peering is not a hostname allowlist"
1405 );
1406 assert!(
1407 row.egress_deny,
1408 "a declared deny blocks both egress and DNS"
1409 );
1410 assert!(
1411 row.enforced_limits,
1412 "ceilings are enforced, by terminating the sandbox on breach"
1413 );
1414 assert!(!row.process_limit, "no process-count ceiling is observed");
1415 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1416 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1417 assert!(
1418 !row.supervisor_isolation,
1419 "the command is not run under a separate supervisor identity"
1420 );
1421
1422 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1424 assert_eq!(
1425 live, row,
1426 "the Platform::Gcp arm is the Agent Platform capability row"
1427 );
1428 }
1429
1430 #[test]
1431 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1432 let error = SandboxCapabilities::for_platform(Platform::Machines)
1433 .expect_err("Machines has no sandbox backend");
1434 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1435 }
1436
1437 #[test]
1438 fn unsupported_capability_names_platform_and_capability() {
1439 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1440 let error = capabilities
1441 .require(SandboxCapability::Preview, Platform::Gcp)
1442 .expect_err("GCP has no preview");
1443
1444 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1445 let rendered = error.to_string();
1446 assert!(
1447 rendered.contains("preview"),
1448 "names the capability: {rendered}"
1449 );
1450 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1451 }
1452
1453 #[test]
1457 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1458 let sandbox = sandbox_with(
1459 SandboxEgress::AllowDomains {
1460 domains: vec!["example.com".to_string()],
1461 },
1462 vec![],
1463 );
1464
1465 for platform in [
1466 Platform::Aws,
1467 Platform::Gcp,
1468 Platform::Kubernetes,
1469 Platform::Local,
1470 ] {
1471 let error = sandbox
1472 .validate_for_platform(platform)
1473 .expect_err("only Azure expresses a hostname allowlist");
1474 assert_eq!(
1475 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1476 "on {platform:?}"
1477 );
1478 }
1479
1480 assert!(
1481 SandboxCapabilities::for_platform(Platform::Azure)
1482 .expect("supported")
1483 .domain_egress_rules,
1484 "Azure's egress policy matches on host pattern"
1485 );
1486 }
1487
1488 #[test]
1491 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1492 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1493
1494 assert!(
1497 SandboxCapabilities::for_platform(Platform::Gcp)
1498 .expect("supported")
1499 .egress_deny
1500 );
1501
1502 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1503 sandbox
1504 .validate_for_platform(platform)
1505 .expect("deny is enforced here");
1506 }
1507
1508 let egress_only = Sandbox::new("sbx".to_string())
1510 .code(SandboxCode::Image {
1511 image: "alpine".to_string(),
1512 })
1513 .egress(SandboxEgress::Deny)
1514 .lifecycle(SandboxLifecyclePolicy {
1515 max_lifetime_seconds: None,
1516 idle_pause_seconds: None,
1517 })
1518 .build();
1519
1520 egress_only
1521 .validate_for_platform(Platform::Azure)
1522 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1523 }
1524
1525 #[test]
1529 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1530 let undeclared = Sandbox::new("sbx".to_string())
1531 .code(SandboxCode::Image {
1532 image: "alpine".to_string(),
1533 })
1534 .egress(SandboxEgress::Deny)
1535 .lifecycle(SandboxLifecyclePolicy {
1536 max_lifetime_seconds: None,
1537 idle_pause_seconds: None,
1538 })
1539 .build();
1540
1541 undeclared
1542 .validate_for_platform(Platform::Azure)
1543 .expect("a sandbox naming no ceilings takes the platform's own");
1544
1545 assert_eq!(undeclared.resolved_limits().cpu, "1");
1547 }
1548
1549 #[test]
1553 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1554 let sized = |cpu: &str, memory: &str, disk: &str| {
1555 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1556 let limits = sandbox
1557 .limits
1558 .as_mut()
1559 .expect("the fixture declares limits");
1560 limits.cpu = cpu.to_string();
1561 limits.memory = memory.to_string();
1562 limits.disk = disk.to_string();
1563 sandbox.validate_for_platform(Platform::Azure)
1564 };
1565
1566 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
1567 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
1568 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
1569
1570 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
1572 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
1573 assert!(off_step.to_string().contains("cpu"), "{off_step}");
1574
1575 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
1576 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
1577
1578 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
1581 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
1582 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
1583 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
1584
1585 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
1586 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
1587 }
1588
1589 #[test]
1590 fn preview_ports_require_the_preview_capability() {
1591 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1592
1593 sandbox
1594 .validate_for_platform(Platform::Aws)
1595 .expect("AWS mints a port-scoped JWE");
1596
1597 let error = sandbox
1598 .validate_for_platform(Platform::Kubernetes)
1599 .expect_err("Kubernetes preview is deferred");
1600 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1601 }
1602
1603 #[test]
1604 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1605 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1606 sandbox
1607 .validate_for_platform(Platform::Gcp)
1608 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1609 }
1610
1611 #[test]
1612 fn invalid_quantities_are_rejected_with_the_offending_field() {
1613 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1614 sandbox
1615 .limits
1616 .as_mut()
1617 .expect("the fixture declares limits")
1618 .memory = "2Gb".to_string();
1619
1620 let error = sandbox
1621 .validate_for_platform(Platform::Aws)
1622 .expect_err("Gb is not a valid suffix");
1623 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1624 assert!(error.to_string().contains("memory"));
1625
1626 sandbox
1627 .limits
1628 .as_mut()
1629 .expect("the fixture declares limits")
1630 .memory = "2Gi".to_string();
1631 sandbox
1632 .limits
1633 .as_mut()
1634 .expect("the fixture declares limits")
1635 .cpu = "0".to_string();
1636 let error = sandbox
1637 .validate_for_platform(Platform::Aws)
1638 .expect_err("zero cpu is not a ceiling");
1639 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1640 }
1641
1642 #[test]
1643 fn zero_max_processes_is_rejected() {
1644 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1645 sandbox
1646 .limits
1647 .as_mut()
1648 .expect("the fixture declares limits")
1649 .max_processes = Some(0);
1650
1651 let error = sandbox
1652 .validate_for_platform(Platform::Local)
1653 .expect_err("a sandbox must be able to run at least one process");
1654 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1655 assert!(error.to_string().contains("maxProcesses"));
1656 }
1657
1658 #[test]
1662 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1663 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1664 sandbox
1665 .limits
1666 .as_mut()
1667 .expect("the fixture declares limits")
1668 .max_processes = Some(256);
1669
1670 sandbox
1671 .validate_for_platform(Platform::Local)
1672 .expect("Docker takes a pids limit");
1673
1674 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1675 let error = sandbox
1676 .validate_for_platform(platform)
1677 .expect_err("a process ceiling nothing applies must be refused");
1678 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1679 }
1680 }
1681
1682 #[test]
1686 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1687 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1688
1689 for seconds in [0, 28_801, 100_000] {
1690 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
1691 let error = sandbox
1692 .validate_for_platform(Platform::Aws)
1693 .expect_err("a lifetime outside what AWS runs is refused");
1694 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1695
1696 sandbox
1698 .validate_for_platform(Platform::Kubernetes)
1699 .expect("the kubelet takes any activeDeadlineSeconds");
1700 }
1701
1702 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
1703 sandbox
1704 .validate_for_platform(Platform::Aws)
1705 .expect("the ceiling itself is allowed");
1706 }
1707
1708 #[test]
1713 fn an_image_azure_cannot_pull_is_refused_while_planning() {
1714 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1715 sandbox.limits = None;
1718
1719 for image in [
1720 "ubuntu:24.04",
1721 "ghcr.io/myorg/sandbox:latest",
1722 "ubuntu@sha256:abc",
1723 "",
1724 " ",
1725 "ubuntu latest",
1726 "ubuntu?x",
1727 ] {
1728 sandbox.code = SandboxCode::Image {
1729 image: image.to_string(),
1730 };
1731 let error = sandbox
1732 .validate_for_platform(Platform::Azure)
1733 .expect_err("an image Azure has nowhere to put is refused");
1734 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1735
1736 sandbox
1738 .validate_for_platform(Platform::Kubernetes)
1739 .expect("a registry reference is what every other backend takes");
1740 }
1741
1742 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1743 sandbox.code = SandboxCode::Image {
1744 image: image.to_string(),
1745 };
1746 sandbox
1747 .validate_for_platform(Platform::Azure)
1748 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1749 }
1750
1751 sandbox.code = SandboxCode::Image {
1753 image: " ubuntu ".to_string(),
1754 };
1755 assert_eq!(
1756 sandbox
1757 .azure_catalog_image()
1758 .expect("a padded name is still a name"),
1759 "ubuntu"
1760 );
1761 }
1762
1763 #[test]
1767 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
1768 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1769 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
1770
1771 sandbox
1772 .validate_for_platform(Platform::Kubernetes)
1773 .expect("the kubelet enforces activeDeadlineSeconds");
1774 sandbox
1775 .validate_for_platform(Platform::Aws)
1776 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1777
1778 for platform in [Platform::Azure, Platform::Local] {
1779 let error = sandbox
1780 .validate_for_platform(platform)
1781 .expect_err("a deadline nothing applies must be refused");
1782 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1783 }
1784 }
1785
1786 #[test]
1790 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1791 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1792 let tier = sandbox
1793 .microvm_tier()
1794 .expect("2Gi/1cpu/20Gi is satisfiable");
1795
1796 assert_eq!(
1797 tier.peak_memory_mib, 2048,
1798 "the peak is the declared ceiling"
1799 );
1800 assert_eq!(
1801 tier.baseline_memory_mib, 512,
1802 "which is a quarter of it as the baseline"
1803 );
1804 assert!(tier.max_disk_mib <= 20 * 1024);
1805 }
1806
1807 #[test]
1811 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1812 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1813 {
1814 let limits = sandbox
1815 .limits
1816 .as_mut()
1817 .expect("the fixture declares limits");
1818 limits.cpu = "1".to_string();
1819 limits.memory = "8Gi".to_string();
1820 }
1821
1822 let error = sandbox
1823 .microvm_tier()
1824 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1825 assert!(
1826 error.to_string().contains("4 vCPU"),
1827 "the refusal must say what the memory ceiling implies: {error}"
1828 );
1829
1830 sandbox
1831 .limits
1832 .as_mut()
1833 .expect("the fixture declares limits")
1834 .cpu = "4".to_string();
1835 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1836 assert_eq!(tier.peak_memory_mib, 8192);
1837 }
1838
1839 #[test]
1842 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1843 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1844 sandbox
1845 .limits
1846 .as_mut()
1847 .expect("the fixture declares limits")
1848 .memory = "1Gi".to_string();
1849
1850 let error = sandbox
1851 .validate_for_platform(Platform::Aws)
1852 .expect_err("no MicroVM size peaks at or below 1Gi");
1853 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1854 assert!(
1855 error.to_string().contains("2Gi"),
1856 "the refusal must say what the smallest holdable ceiling is: {error}"
1857 );
1858 }
1859
1860 #[test]
1864 fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1865 let sandbox = Sandbox::new("agent".to_string())
1866 .code(SandboxCode::Source {
1867 src: "./sandbox".to_string(),
1868 toolchain: ToolchainConfig::Docker {
1869 dockerfile: None,
1870 build_args: None,
1871 target: None,
1872 },
1873 })
1874 .egress(SandboxEgress::Deny)
1875 .lifecycle(SandboxLifecyclePolicy {
1876 max_lifetime_seconds: None,
1877 idle_pause_seconds: None,
1878 })
1879 .build();
1880
1881 for platform in [
1882 Platform::Aws,
1883 Platform::Azure,
1884 Platform::Gcp,
1885 Platform::Kubernetes,
1886 Platform::Local,
1887 ] {
1888 let error = sandbox
1889 .validate_for_platform(platform)
1890 .expect_err("no backend builds a sandbox image from source");
1891 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1892 assert!(
1893 error.to_string().contains("code.image"),
1894 "the refusal must say what to write instead: {error}"
1895 );
1896 }
1897 }
1898
1899 #[test]
1902 fn every_accepted_unit_converts_rather_than_falling_back() {
1903 assert_eq!(quantity_mib("2Gi"), Some(2048));
1904 assert_eq!(quantity_mib("512Mi"), Some(512));
1905 assert_eq!(quantity_mib("4G"), Some(3814));
1906 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1907 assert_eq!(millicores("1"), Some(1000));
1908 assert_eq!(millicores("500m"), Some(500));
1909 }
1910
1911 #[test]
1912 fn unknown_fields_are_rejected() {
1913 let json = r#"{
1914 "id": "sbx",
1915 "code": {"type": "image", "image": "ubuntu:24.04"},
1916 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1917 "egress": {"mode": "deny"},
1918 "lifecycle": {},
1919 "unexpected": true
1920 }"#;
1921
1922 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1923 }
1924
1925 #[test]
1926 fn serialization_roundtrips() {
1927 let sandbox = sandbox_with(
1928 SandboxEgress::AllowDomains {
1929 domains: vec!["example.com".to_string()],
1930 },
1931 vec![8080, 9090],
1932 );
1933
1934 let json = serde_json::to_string(&sandbox).expect("serializes");
1935 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1936 assert_eq!(sandbox, restored);
1937 }
1938
1939 #[test]
1940 fn id_is_immutable_across_updates() {
1941 let original = sandbox_with(SandboxEgress::Deny, vec![]);
1942 let renamed = Sandbox::new("other".to_string())
1943 .code(SandboxCode::Image {
1944 image: "ubuntu".to_string(),
1945 })
1946 .limits(
1947 original
1948 .limits
1949 .clone()
1950 .expect("the fixture declares limits"),
1951 )
1952 .egress(SandboxEgress::Deny)
1953 .lifecycle(SandboxLifecyclePolicy {
1954 max_lifetime_seconds: None,
1955 idle_pause_seconds: None,
1956 })
1957 .build();
1958
1959 original
1960 .validate_update(&original.clone())
1961 .expect("an unchanged config is a valid update");
1962 original
1963 .validate_update(&renamed)
1964 .expect_err("renaming a sandbox is not an update");
1965 }
1966
1967 #[test]
1973 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
1974 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
1975 Sandbox::new("sbx".to_string())
1976 .code(SandboxCode::Image {
1977 image: "ubuntu".to_string(),
1978 })
1979 .egress(SandboxEgress::Allow)
1980 .lifecycle(lifecycle)
1981 .build()
1982 .validate_for_platform(Platform::Azure)
1983 };
1984
1985 with_policy(SandboxLifecyclePolicy {
1986 max_lifetime_seconds: None,
1987 idle_pause_seconds: Some(900),
1988 })
1989 .expect("Azure pauses a sandbox on idle");
1990
1991 let error = with_policy(SandboxLifecyclePolicy {
1992 max_lifetime_seconds: Some(3600),
1993 idle_pause_seconds: None,
1994 })
1995 .expect_err("Azure has no wall-clock ceiling to enforce one with");
1996 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1997 assert!(
1998 error.message.contains("sandboxLifetime"),
1999 "names the capability: {}",
2000 error.message
2001 );
2002 }
2003
2004 #[test]
2010 fn an_allowlist_with_no_domains_is_refused() {
2011 let declared = |domains: Vec<String>| {
2012 Sandbox::new("sbx".to_string())
2013 .code(SandboxCode::Image {
2014 image: "ubuntu".to_string(),
2015 })
2016 .egress(SandboxEgress::AllowDomains { domains })
2017 .lifecycle(SandboxLifecyclePolicy {
2018 max_lifetime_seconds: None,
2019 idle_pause_seconds: None,
2020 })
2021 .build()
2022 .validate_for_platform(Platform::Azure)
2023 };
2024
2025 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2026 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2027
2028 declared(vec!["api.example.com".to_string()])
2029 .expect("a named domain is what an allowlist is for");
2030 }
2031
2032 #[test]
2035 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2036 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2037 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
2038 assert_eq!(
2039 SandboxEgress::AllowDomains {
2040 domains: vec!["api.example.com".to_string()]
2041 }
2042 .internet_access_switch(),
2043 None,
2044 "a host list has no boolean and must not be approximated"
2045 );
2046 }
2047}