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_SESSION_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)]
176#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
177#[serde(rename_all = "camelCase", deny_unknown_fields)]
178pub struct SandboxSessionPolicy {
179 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub max_lifetime_seconds: Option<u32>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub idle_suspend_seconds: Option<u32>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct SandboxCapabilities {
201 pub files: bool,
203 pub reconnect: bool,
205 pub jobs: bool,
208 pub preview: bool,
210 pub suspend_resume: bool,
212 pub snapshot: bool,
214 pub domain_egress_rules: bool,
216 pub egress_deny: bool,
218 pub enforced_limits: bool,
220 pub process_limit: bool,
222 pub session_lifetime: bool,
224 pub supervisor_pid_namespace: bool,
230 pub supervisor_isolation: bool,
236}
237
238impl SandboxCapabilities {
239 pub fn for_platform(platform: Platform) -> Result<Self> {
245 match platform {
246 Platform::Aws => Ok(Self {
247 files: true,
248 reconnect: true,
249 jobs: true,
250 preview: true,
251 suspend_resume: true,
252 snapshot: false,
253 domain_egress_rules: false,
254 egress_deny: true,
255 enforced_limits: true,
256 process_limit: false,
258 session_lifetime: true,
261 supervisor_pid_namespace: false,
266 supervisor_isolation: true,
269 }),
270 Platform::Azure => Ok(Self::azure()),
271 Platform::Gcp => Ok(Self::gcp_agent_platform()),
272 Platform::Kubernetes => Ok(Self {
275 files: true,
276 reconnect: true,
277 jobs: true,
278 preview: false,
279 suspend_resume: false,
280 snapshot: false,
281 domain_egress_rules: false,
282 egress_deny: true,
283 enforced_limits: true,
284 process_limit: false,
286 session_lifetime: true,
288 supervisor_pid_namespace: false,
292 supervisor_isolation: false,
297 }),
298 Platform::Local => Ok(Self {
299 files: true,
300 reconnect: true,
301 jobs: false,
303 preview: true,
304 suspend_resume: false,
305 snapshot: false,
306 domain_egress_rules: false,
307 egress_deny: true,
308 enforced_limits: true,
309 process_limit: true,
311 session_lifetime: false,
312 supervisor_pid_namespace: false,
315 supervisor_isolation: true,
319 }),
320 Platform::Machines | Platform::Test => {
321 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
322 platform: platform.to_string(),
323 }))
324 }
325 }
326 }
327
328 pub fn azure() -> Self {
330 Self {
331 files: true,
332 reconnect: true,
333 jobs: false,
335 preview: false,
340 suspend_resume: true,
341 snapshot: false,
345 domain_egress_rules: true,
346 egress_deny: true,
347 enforced_limits: true,
351 process_limit: false,
352 session_lifetime: false,
356 supervisor_pid_namespace: false,
358 supervisor_isolation: false,
361 }
362 }
363
364 pub fn gcp_agent_platform() -> Self {
366 Self {
367 files: true,
369 reconnect: true,
373 jobs: true,
374 preview: false,
376 suspend_resume: true,
378 snapshot: false,
381 domain_egress_rules: false,
383 egress_deny: true,
385 enforced_limits: true,
389 process_limit: false,
391 session_lifetime: true,
393 supervisor_pid_namespace: false,
395 supervisor_isolation: false,
398 }
399 }
400
401 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
403 let available = match capability {
404 SandboxCapability::Files => self.files,
405 SandboxCapability::Reconnect => self.reconnect,
406 SandboxCapability::Jobs => self.jobs,
407 SandboxCapability::Preview => self.preview,
408 SandboxCapability::SuspendResume => self.suspend_resume,
409 SandboxCapability::Snapshot => self.snapshot,
410 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
411 SandboxCapability::EgressDeny => self.egress_deny,
412 SandboxCapability::EnforcedLimits => self.enforced_limits,
413 SandboxCapability::ProcessLimit => self.process_limit,
414 SandboxCapability::SessionLifetime => self.session_lifetime,
415 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
416 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
417 };
418
419 if available {
420 return Ok(());
421 }
422
423 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
424 capability: capability.as_str().to_string(),
425 platform: platform.to_string(),
426 }))
427 }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
432#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
433#[serde(rename_all = "camelCase")]
434pub enum SandboxCapability {
435 Files,
437 Reconnect,
439 Jobs,
441 Preview,
443 SuspendResume,
445 Snapshot,
447 DomainEgressRules,
449 EgressDeny,
451 EnforcedLimits,
453 ProcessLimit,
455 SessionLifetime,
457 SupervisorPidNamespace,
459 SupervisorIsolation,
461}
462
463impl SandboxCapability {
464 pub fn as_str(&self) -> &'static str {
466 match self {
467 Self::Files => "files",
468 Self::Reconnect => "reconnect",
469 Self::Jobs => "jobs",
470 Self::Preview => "preview",
471 Self::SuspendResume => "suspendResume",
472 Self::Snapshot => "snapshot",
473 Self::DomainEgressRules => "domainEgressRules",
474 Self::EgressDeny => "egressDeny",
475 Self::EnforcedLimits => "enforcedLimits",
476 Self::ProcessLimit => "processLimit",
477 Self::SessionLifetime => "sessionLifetime",
478 Self::SupervisorPidNamespace => "supervisorPidNamespace",
479 Self::SupervisorIsolation => "supervisorIsolation",
480 }
481 }
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
486#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
487#[serde(rename_all = "camelCase", deny_unknown_fields)]
488#[builder(start_fn = new)]
489pub struct Sandbox {
490 #[builder(start_fn)]
493 pub id: String,
494 pub code: SandboxCode,
496 #[serde(skip_serializing_if = "Option::is_none")]
502 pub limits: Option<SandboxLimits>,
503 pub egress: SandboxEgress,
505 pub session: SandboxSessionPolicy,
507 #[builder(default)]
511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
512 pub preview_ports: Vec<u16>,
513}
514
515pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
520 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
521}
522
523pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
530 stack.resources().any(|(_resource_id, resource)| {
531 if resource.lifecycle == crate::ResourceLifecycle::Frozen
532 && resource.config.downcast_ref::<crate::Postgres>().is_some()
533 {
534 return true;
535 }
536 resource
537 .config
538 .downcast_ref::<Sandbox>()
539 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
540 })
541}
542
543impl Sandbox {
544 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
546
547 pub fn id(&self) -> &str {
549 &self.id
550 }
551
552 pub fn resolved_limits(&self) -> SandboxLimits {
558 self.limits.clone().unwrap_or_else(default_limits)
559 }
560
561 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
566 let capabilities = SandboxCapabilities::for_platform(platform)?;
567
568 if let SandboxCode::Source { .. } = &self.code {
572 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
573 resource_id: self.id.clone(),
574 field: "code".to_string(),
575 value: "source".to_string(),
576 reason: "no sandbox backend builds an image from source yet; give code.image a \
577 prebuilt reference"
578 .to_string(),
579 }));
580 }
581
582 if platform == Platform::Azure {
584 self.azure_catalog_image()?;
585 }
586
587 let Some(limits) = self.limits.as_ref() else {
588 return self.validate_capabilities(&capabilities, platform);
590 };
591
592 validate_quantity(&self.id, "cpu", &limits.cpu)?;
593 validate_quantity(&self.id, "memory", &limits.memory)?;
594 validate_quantity(&self.id, "disk", &limits.disk)?;
595
596 if let Some(max_processes) = limits.max_processes {
597 if max_processes == 0 {
598 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
599 resource_id: self.id.clone(),
600 field: "maxProcesses".to_string(),
601 value: "0".to_string(),
602 reason: "a sandbox that may run no processes cannot run code".to_string(),
603 }));
604 }
605 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
606 }
607
608 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
611
612 if platform == Platform::Azure {
613 self.azure_session_limits()?;
614 }
615
616 if platform == Platform::Aws {
617 self.microvm_tier()?;
620
621 if let Some(seconds) = self.session.max_lifetime_seconds {
626 if !(1..=AWS_MAX_SESSION_LIFETIME_SECONDS).contains(&seconds) {
627 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
628 resource_id: self.id.clone(),
629 field: "maxLifetimeSeconds".to_string(),
630 value: seconds.to_string(),
631 reason: format!(
632 "AWS runs a MicroVM for between 1 and \
633 {AWS_MAX_SESSION_LIFETIME_SECONDS} seconds"
634 ),
635 }));
636 }
637 }
638 }
639
640 self.validate_capabilities(&capabilities, platform)
641 }
642
643 pub fn azure_catalog_image(&self) -> Result<&str> {
649 let refused = |value: &str, reason: &str| {
650 AlienError::new(ErrorData::SandboxLimitInvalid {
651 resource_id: self.id.clone(),
652 field: "code.image".to_string(),
653 value: value.to_string(),
654 reason: reason.to_string(),
655 })
656 };
657
658 let SandboxCode::Image { image } = &self.code else {
659 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
660 resource_id: self.id.clone(),
661 field: "code".to_string(),
662 value: "source".to_string(),
663 reason: "no sandbox backend builds an image from source yet".to_string(),
664 }));
665 };
666
667 let image = image.trim();
668 if image.is_empty() {
669 return Err(refused(image, "a sandbox has to name an image"));
670 }
671 if !image
672 .chars()
673 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
674 {
675 return Err(refused(
676 image,
677 "Azure creates a session from a public catalog disk image, so code.image must be \
678 a bare catalog name such as 'ubuntu'",
679 ));
680 }
681 Ok(image)
682 }
683
684 pub fn azure_session_limits(&self) -> Result<()> {
688 let Some(limits) = self.limits.as_ref() else {
689 return Ok(());
692 };
693
694 let refused = |field: &str, value: &str, reason: &str| {
695 AlienError::new(ErrorData::SandboxLimitInvalid {
696 resource_id: self.id.clone(),
697 field: field.to_string(),
698 value: value.to_string(),
699 reason: reason.to_string(),
700 })
701 };
702
703 let cpu_millicores = millicores(&limits.cpu)
704 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
705
706 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
710 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
711 {
712 return Err(refused(
713 "cpu",
714 &limits.cpu,
715 "Azure allocates cpu in steps of 250m from 250m to 16000m",
716 ));
717 }
718
719 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
721 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
722
723 let memory_mib = quantity_mib(&limits.memory)
724 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
725 if memory_mib > memory_ceiling_mib {
726 return Err(refused(
727 "memory",
728 &limits.memory,
729 &format!(
730 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
731 at the declared cpu"
732 ),
733 ));
734 }
735
736 let disk_mib = quantity_mib(&limits.disk)
737 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
738 if disk_mib > disk_ceiling_mib {
739 return Err(refused(
740 "disk",
741 &limits.disk,
742 &format!(
743 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
744 the declared cpu"
745 ),
746 ));
747 }
748
749 Ok(())
750 }
751
752 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
759 let Some(limits) = self.limits.as_ref() else {
760 return Ok(MICROVM_TIERS[2]);
762 };
763
764 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
765 AlienError::new(ErrorData::SandboxLimitInvalid {
766 resource_id: self.id.clone(),
767 field: "memory".to_string(),
768 value: limits.memory.clone(),
769 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
770 })
771 })?;
772 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
773 AlienError::new(ErrorData::SandboxLimitInvalid {
774 resource_id: self.id.clone(),
775 field: "disk".to_string(),
776 value: limits.disk.clone(),
777 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
778 })
779 })?;
780 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
781 AlienError::new(ErrorData::SandboxLimitInvalid {
782 resource_id: self.id.clone(),
783 field: "cpu".to_string(),
784 value: limits.cpu.clone(),
785 reason: "expected cores or millicores".to_string(),
786 })
787 })?;
788
789 let sized = |tier: &&MicrovmTier| {
794 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
795 };
796
797 let tier = MICROVM_TIERS
798 .iter()
799 .rev()
800 .find(sized)
801 .copied()
802 .ok_or_else(|| {
803 AlienError::new(ErrorData::SandboxLimitInvalid {
804 resource_id: self.id.clone(),
805 field: "memory".to_string(),
806 value: limits.memory.clone(),
807 reason: format!(
808 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
809 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
810 disk fit no size",
811 limits.memory, limits.disk
812 ),
813 })
814 })?;
815
816 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
817 if cpu_millicores < required_millicores {
818 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
819 resource_id: self.id.clone(),
820 field: "cpu".to_string(),
821 value: limits.cpu.clone(),
822 reason: format!(
823 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
824 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
825 limits.memory, tier.peak_vcpu, tier.peak_vcpu
826 ),
827 }));
828 }
829
830 Ok(tier)
831 }
832
833 fn validate_capabilities(
835 &self,
836 capabilities: &SandboxCapabilities,
837 platform: Platform,
838 ) -> Result<()> {
839 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
840 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
841 }
842
843 if let SandboxEgress::AllowDomains { domains } = &self.egress {
849 if domains.is_empty() {
850 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
851 resource_id: self.id.clone(),
852 field: "egress.domains".to_string(),
853 value: "[]".to_string(),
854 reason: "an allowlist naming no domain denies everything; declare \
855 egress: deny if that is what was meant"
856 .to_string(),
857 }));
858 }
859 }
860
861 if matches!(self.egress, SandboxEgress::Deny) {
862 capabilities.require(SandboxCapability::EgressDeny, platform)?;
863 }
864
865 if !self.preview_ports.is_empty() {
866 capabilities.require(SandboxCapability::Preview, platform)?;
867 }
868
869 if self.session.idle_suspend_seconds.is_some() {
870 capabilities.require(SandboxCapability::SuspendResume, platform)?;
871 }
872
873 if self.session.max_lifetime_seconds.is_some() {
874 capabilities.require(SandboxCapability::SessionLifetime, platform)?;
875 }
876
877 Ok(())
878 }
879}
880
881fn default_limits() -> SandboxLimits {
886 SandboxLimits {
887 cpu: "1".to_string(),
888 memory: "2Gi".to_string(),
889 disk: "8Gi".to_string(),
890 max_processes: None,
891 }
892}
893
894fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
896 let invalid = |reason: &str| {
897 AlienError::new(ErrorData::SandboxLimitInvalid {
898 resource_id: resource_id.to_string(),
899 field: field.to_string(),
900 value: value.to_string(),
901 reason: reason.to_string(),
902 })
903 };
904
905 let digits_end = value
906 .find(|c: char| !c.is_ascii_digit() && c != '.')
907 .unwrap_or(value.len());
908 let (number, suffix) = value.split_at(digits_end);
909
910 let parsed: f64 = number
911 .parse()
912 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
913
914 if parsed <= 0.0 {
915 return Err(invalid("must be greater than zero"));
916 }
917
918 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
919 if !SUFFIXES.contains(&suffix) {
920 return Err(invalid(
921 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
922 ));
923 }
924
925 Ok(())
926}
927
928fn split_quantity(value: &str) -> Option<(f64, &str)> {
930 let trimmed = value.trim();
931 let digits_end = trimmed
932 .find(|c: char| !c.is_ascii_digit() && c != '.')
933 .unwrap_or(trimmed.len());
934 let (number, suffix) = trimmed.split_at(digits_end);
935 number.parse().ok().map(|number| (number, suffix))
936}
937
938pub fn quantity_mib(value: &str) -> Option<i64> {
944 let (number, suffix) = split_quantity(value)?;
945 let bytes = match suffix {
946 "" => number,
947 "k" => number * 1e3,
948 "M" => number * 1e6,
949 "G" => number * 1e9,
950 "T" => number * 1e12,
951 "Ki" => number * 1024.0,
952 "Mi" => number * 1024.0 * 1024.0,
953 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
954 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
955 _ => return None,
957 };
958 Some((bytes / (1024.0 * 1024.0)) as i64)
959}
960
961pub fn millicores(value: &str) -> Option<i64> {
963 let (number, suffix) = split_quantity(value)?;
964 match suffix {
965 "" => Some((number * 1000.0) as i64),
966 "m" => Some(number as i64),
967 _ => None,
968 }
969}
970
971#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
973#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
974#[serde(rename_all = "camelCase")]
975pub struct SandboxOutputs {
976 pub parent_name: String,
978 #[serde(skip_serializing_if = "Option::is_none")]
980 pub identifier: Option<String>,
981 #[serde(skip_serializing_if = "Option::is_none")]
983 pub endpoint: Option<String>,
984}
985
986impl ResourceOutputsDefinition for SandboxOutputs {
987 fn get_resource_type(&self) -> ResourceType {
988 Sandbox::RESOURCE_TYPE
989 }
990
991 fn as_any(&self) -> &dyn Any {
992 self
993 }
994
995 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
996 Box::new(self.clone())
997 }
998
999 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1000 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1001 }
1002
1003 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1004 serde_json::to_value(self)
1005 }
1006}
1007
1008impl ResourceDefinition for Sandbox {
1009 fn get_resource_type(&self) -> ResourceType {
1010 Self::RESOURCE_TYPE
1011 }
1012
1013 fn id(&self) -> &str {
1014 &self.id
1015 }
1016
1017 fn get_dependencies(&self) -> Vec<ResourceRef> {
1018 Vec::new()
1019 }
1020
1021 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1022 let new_sandbox = new_config
1023 .as_any()
1024 .downcast_ref::<Sandbox>()
1025 .ok_or_else(|| {
1026 AlienError::new(ErrorData::UnexpectedResourceType {
1027 resource_id: self.id.clone(),
1028 expected: Self::RESOURCE_TYPE,
1029 actual: new_config.get_resource_type(),
1030 })
1031 })?;
1032
1033 if self.id != new_sandbox.id {
1034 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1035 resource_id: self.id.clone(),
1036 reason: "the 'id' field is immutable".to_string(),
1037 }));
1038 }
1039
1040 Ok(())
1041 }
1042
1043 fn as_any(&self) -> &dyn Any {
1044 self
1045 }
1046
1047 fn as_any_mut(&mut self) -> &mut dyn Any {
1048 self
1049 }
1050
1051 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1052 Box::new(self.clone())
1053 }
1054
1055 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1056 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1057 }
1058
1059 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1060 serde_json::to_value(self)
1061 }
1062}
1063
1064pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub enum BundleUri<'a> {
1073 Literal(&'a str),
1075 Regional { before: &'a str, after: &'a str },
1078}
1079
1080pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1084 let (above_file, _) = key.rsplit_once('/')?;
1085 let (above_version, _) = above_file.rsplit_once('/')?;
1086 Some(above_version)
1087}
1088
1089pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1095 let path = uri
1096 .strip_prefix("s3://")
1097 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1098 let (bucket, key) = path
1099 .split_once('/')
1100 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1101
1102 if path.contains('*') || path.contains('?') {
1106 return Err(format!(
1107 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1108 role's grant, so '*' and '?' would widen it past the bundle"
1109 ));
1110 }
1111
1112 if key.contains('{') || key.contains('}') {
1113 return Err(format!(
1114 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1115 bucket name alone"
1116 ));
1117 }
1118
1119 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1120 if bucket.contains('{') || bucket.contains('}') {
1121 return Err(format!(
1122 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1123 only one"
1124 ));
1125 }
1126 return Ok(BundleUri::Literal(uri));
1127 };
1128
1129 if after.contains(BUNDLE_REGION_TOKEN) {
1130 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1131 }
1132 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1133 return Err(format!(
1134 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1135 ));
1136 }
1137
1138 Ok(BundleUri::Regional {
1139 before: &uri[.."s3://".len() + before.len()],
1140 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1141 })
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147
1148 #[test]
1149 fn private_database_setup_requires_named_subnets() {
1150 for lifecycle in [
1151 crate::ResourceLifecycle::Frozen,
1152 crate::ResourceLifecycle::Live,
1153 ] {
1154 let stack = crate::Stack::new("database".to_string())
1155 .add(
1156 crate::Postgres::new("metadata".to_string()).build(),
1157 lifecycle,
1158 )
1159 .build();
1160 assert_eq!(
1161 restricts_network_mode(&stack, false),
1162 lifecycle == crate::ResourceLifecycle::Frozen,
1163 );
1164 assert!(!restricts_network_mode(&stack, true));
1165 }
1166 assert!(!restricts_network_mode(
1167 &crate::Stack::new("empty".to_string()).build(),
1168 false,
1169 ));
1170 }
1171
1172 #[test]
1175 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1176 for uri in [
1177 "s3://acme/team-*/v1/bundle.zip",
1178 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1179 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1180 ] {
1181 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1182 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1183 }
1184
1185 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1186 .expect("an ordinary key still parses");
1187 }
1188
1189 #[test]
1193 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1194 assert_eq!(
1195 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1196 Some("sandbox-bundle")
1197 );
1198 assert_eq!(
1199 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1200 Some("artifacts/team-a/sandbox"),
1201 "a deeper key narrows the prefix, it never widens to the first segment"
1202 );
1203
1204 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1207 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1208 }
1209
1210 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1211 Sandbox::new("agent-sbx".to_string())
1212 .code(SandboxCode::Image {
1213 image: "ubuntu".to_string(),
1214 })
1215 .limits(SandboxLimits {
1216 cpu: "1".to_string(),
1217 memory: "2Gi".to_string(),
1218 disk: "20Gi".to_string(),
1219 max_processes: None,
1220 })
1221 .egress(egress)
1222 .session(SandboxSessionPolicy {
1223 max_lifetime_seconds: None,
1224 idle_suspend_seconds: None,
1225 })
1226 .preview_ports(preview_ports)
1227 .build()
1228 }
1229
1230 #[test]
1233 fn a_uri_without_a_token_is_carried_whole() {
1234 assert_eq!(
1235 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1236 Ok(BundleUri::Literal(
1237 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1238 ))
1239 );
1240 }
1241
1242 #[test]
1245 fn a_regional_uri_splits_either_side_of_the_token() {
1246 let BundleUri::Regional { before, after } =
1247 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1248 .expect("the token is accepted in the bucket")
1249 else {
1250 panic!("a bucket-position token must split");
1251 };
1252
1253 assert_eq!(before, "s3://acme-artifacts-");
1254 assert_eq!(after, "/agents/bundle.zip");
1255 assert_eq!(
1256 format!("{before}us-east-2{after}"),
1257 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1258 "the halves must rejoin to the URI the vendor meant"
1259 );
1260 }
1261
1262 #[test]
1265 fn a_token_this_build_cannot_resolve_is_refused() {
1266 for uri in [
1267 "s3://acme-artifacts-{regio}/bundle.zip",
1268 "s3://acme-artifacts/{region}/bundle.zip",
1269 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1270 "s3://acme-artifacts/bundle-{version}.zip",
1271 "s3://acme}-artifacts-{region}/bundle.zip",
1272 "s3://acme{-artifacts-{region}/bundle.zip",
1273 ] {
1274 assert!(
1275 parse_bundle_uri(uri).is_err(),
1276 "'{uri}' must be refused before it can reach an image build"
1277 );
1278 }
1279 }
1280
1281 #[test]
1282 fn resource_type_is_stable() {
1283 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1284 }
1285
1286 #[test]
1287 fn capability_sets_are_per_platform() {
1288 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1289 assert!(
1290 gcp.reconnect,
1291 "generation from the container boot id makes a session reachable across processes"
1292 );
1293 assert!(!gcp.preview);
1294 assert!(gcp.enforced_limits);
1295
1296 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1297 assert!(azure.files, "every backend moves files");
1298 assert!(gcp.files);
1299 assert!(azure.domain_egress_rules);
1302 assert!(azure.egress_deny);
1303 assert!(azure.enforced_limits);
1306 assert!(azure.suspend_resume);
1307 assert!(!azure.snapshot);
1311 assert!(!azure.preview);
1312
1313 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1314 assert!(!aws.snapshot, "AWS has no user-callable session snapshot");
1315 assert!(aws.suspend_resume);
1316
1317 let k8s =
1318 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1319 assert!(
1320 !k8s.preview,
1321 "the session-scoped ingress gateway does not exist yet"
1322 );
1323 }
1324
1325 #[test]
1334 fn supervisor_isolation_is_per_platform() {
1335 let value = |platform| {
1336 SandboxCapabilities::for_platform(platform)
1337 .expect("supported")
1338 .supervisor_isolation
1339 };
1340
1341 assert!(
1342 value(Platform::Aws),
1343 "root agent setuids the command to 60000"
1344 );
1345 assert!(
1346 value(Platform::Local),
1347 "the supervisor is on the host, outside the container"
1348 );
1349 assert!(
1350 !value(Platform::Kubernetes),
1351 "a single pinned uid cannot be split"
1352 );
1353 assert!(!value(Platform::Azure), "no Alien process runs the command");
1354 assert!(
1355 !value(Platform::Gcp),
1356 "no separate supervisor identity runs the command"
1357 );
1358 }
1359
1360 #[test]
1364 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1365 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1366 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1367
1368 assert_eq!(
1369 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1370 "the older axis cannot tell them apart"
1371 );
1372 assert!(
1373 aws.supervisor_isolation,
1374 "AWS setuids the command off the supervisor"
1375 );
1376 assert!(
1377 !gcp.supervisor_isolation,
1378 "the command runs under no separate supervisor identity"
1379 );
1380 }
1381
1382 #[test]
1387 fn gcp_agent_platform_row_matches_measured_backend() {
1388 let row = SandboxCapabilities::gcp_agent_platform();
1389
1390 assert!(row.files, "agent file ops move over the session envelope");
1391 assert!(
1392 row.reconnect,
1393 "generation is derived from the container boot id, so a session is reachable across \
1394 processes"
1395 );
1396 assert!(
1397 !row.preview,
1398 "the only ingress is :execute; no port-scoped capability"
1399 );
1400 assert!(
1401 row.suspend_resume,
1402 ":pause and :resume preserve the container"
1403 );
1404 assert!(
1405 !row.snapshot,
1406 "the create path never sends a snapshot, so none is reachable through the trait"
1407 );
1408 assert!(
1409 !row.domain_egress_rules,
1410 "VPC and DNS peering is not a hostname allowlist"
1411 );
1412 assert!(
1413 row.egress_deny,
1414 "a declared deny blocks both egress and DNS"
1415 );
1416 assert!(
1417 row.enforced_limits,
1418 "ceilings are enforced, by terminating the session on breach"
1419 );
1420 assert!(!row.process_limit, "no process-count ceiling is observed");
1421 assert!(row.session_lifetime, "ttl maps to a session expireTime");
1422 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1423 assert!(
1424 !row.supervisor_isolation,
1425 "the command is not run under a separate supervisor identity"
1426 );
1427
1428 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1430 assert_eq!(
1431 live, row,
1432 "the Platform::Gcp arm is the Agent Platform capability row"
1433 );
1434 }
1435
1436 #[test]
1437 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1438 let error = SandboxCapabilities::for_platform(Platform::Machines)
1439 .expect_err("Machines has no sandbox backend");
1440 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1441 }
1442
1443 #[test]
1444 fn unsupported_capability_names_platform_and_capability() {
1445 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1446 let error = capabilities
1447 .require(SandboxCapability::Preview, Platform::Gcp)
1448 .expect_err("GCP has no preview");
1449
1450 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1451 let rendered = error.to_string();
1452 assert!(
1453 rendered.contains("preview"),
1454 "names the capability: {rendered}"
1455 );
1456 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1457 }
1458
1459 #[test]
1463 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1464 let sandbox = sandbox_with(
1465 SandboxEgress::AllowDomains {
1466 domains: vec!["example.com".to_string()],
1467 },
1468 vec![],
1469 );
1470
1471 for platform in [
1472 Platform::Aws,
1473 Platform::Gcp,
1474 Platform::Kubernetes,
1475 Platform::Local,
1476 ] {
1477 let error = sandbox
1478 .validate_for_platform(platform)
1479 .expect_err("only Azure expresses a hostname allowlist");
1480 assert_eq!(
1481 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1482 "on {platform:?}"
1483 );
1484 }
1485
1486 assert!(
1487 SandboxCapabilities::for_platform(Platform::Azure)
1488 .expect("supported")
1489 .domain_egress_rules,
1490 "Azure's egress policy matches on host pattern"
1491 );
1492 }
1493
1494 #[test]
1497 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1498 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1499
1500 assert!(
1503 SandboxCapabilities::for_platform(Platform::Gcp)
1504 .expect("supported")
1505 .egress_deny
1506 );
1507
1508 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1509 sandbox
1510 .validate_for_platform(platform)
1511 .expect("deny is enforced here");
1512 }
1513
1514 let egress_only = Sandbox::new("sbx".to_string())
1516 .code(SandboxCode::Image {
1517 image: "alpine".to_string(),
1518 })
1519 .egress(SandboxEgress::Deny)
1520 .session(SandboxSessionPolicy {
1521 max_lifetime_seconds: None,
1522 idle_suspend_seconds: None,
1523 })
1524 .build();
1525
1526 egress_only
1527 .validate_for_platform(Platform::Azure)
1528 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1529 }
1530
1531 #[test]
1535 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1536 let undeclared = Sandbox::new("sbx".to_string())
1537 .code(SandboxCode::Image {
1538 image: "alpine".to_string(),
1539 })
1540 .egress(SandboxEgress::Deny)
1541 .session(SandboxSessionPolicy {
1542 max_lifetime_seconds: None,
1543 idle_suspend_seconds: None,
1544 })
1545 .build();
1546
1547 undeclared
1548 .validate_for_platform(Platform::Azure)
1549 .expect("a sandbox naming no ceilings takes the platform's own");
1550
1551 assert_eq!(undeclared.resolved_limits().cpu, "1");
1553 }
1554
1555 #[test]
1559 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1560 let sized = |cpu: &str, memory: &str, disk: &str| {
1561 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1562 let limits = sandbox
1563 .limits
1564 .as_mut()
1565 .expect("the fixture declares limits");
1566 limits.cpu = cpu.to_string();
1567 limits.memory = memory.to_string();
1568 limits.disk = disk.to_string();
1569 sandbox.validate_for_platform(Platform::Azure)
1570 };
1571
1572 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
1573 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
1574 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
1575
1576 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
1578 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
1579 assert!(off_step.to_string().contains("cpu"), "{off_step}");
1580
1581 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
1582 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
1583
1584 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
1587 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
1588 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
1589 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
1590
1591 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
1592 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
1593 }
1594
1595 #[test]
1596 fn preview_ports_require_the_preview_capability() {
1597 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1598
1599 sandbox
1600 .validate_for_platform(Platform::Aws)
1601 .expect("AWS mints a port-scoped JWE");
1602
1603 let error = sandbox
1604 .validate_for_platform(Platform::Kubernetes)
1605 .expect_err("Kubernetes preview is deferred");
1606 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1607 }
1608
1609 #[test]
1610 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1611 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1612 sandbox
1613 .validate_for_platform(Platform::Gcp)
1614 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1615 }
1616
1617 #[test]
1618 fn invalid_quantities_are_rejected_with_the_offending_field() {
1619 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1620 sandbox
1621 .limits
1622 .as_mut()
1623 .expect("the fixture declares limits")
1624 .memory = "2Gb".to_string();
1625
1626 let error = sandbox
1627 .validate_for_platform(Platform::Aws)
1628 .expect_err("Gb is not a valid suffix");
1629 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1630 assert!(error.to_string().contains("memory"));
1631
1632 sandbox
1633 .limits
1634 .as_mut()
1635 .expect("the fixture declares limits")
1636 .memory = "2Gi".to_string();
1637 sandbox
1638 .limits
1639 .as_mut()
1640 .expect("the fixture declares limits")
1641 .cpu = "0".to_string();
1642 let error = sandbox
1643 .validate_for_platform(Platform::Aws)
1644 .expect_err("zero cpu is not a ceiling");
1645 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1646 }
1647
1648 #[test]
1649 fn zero_max_processes_is_rejected() {
1650 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1651 sandbox
1652 .limits
1653 .as_mut()
1654 .expect("the fixture declares limits")
1655 .max_processes = Some(0);
1656
1657 let error = sandbox
1658 .validate_for_platform(Platform::Local)
1659 .expect_err("a sandbox must be able to run at least one process");
1660 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1661 assert!(error.to_string().contains("maxProcesses"));
1662 }
1663
1664 #[test]
1668 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1669 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1670 sandbox
1671 .limits
1672 .as_mut()
1673 .expect("the fixture declares limits")
1674 .max_processes = Some(256);
1675
1676 sandbox
1677 .validate_for_platform(Platform::Local)
1678 .expect("Docker takes a pids limit");
1679
1680 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1681 let error = sandbox
1682 .validate_for_platform(platform)
1683 .expect_err("a process ceiling nothing applies must be refused");
1684 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1685 }
1686 }
1687
1688 #[test]
1692 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1693 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1694
1695 for seconds in [0, 28_801, 100_000] {
1696 sandbox.session.max_lifetime_seconds = Some(seconds);
1697 let error = sandbox
1698 .validate_for_platform(Platform::Aws)
1699 .expect_err("a lifetime outside what AWS runs is refused");
1700 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1701
1702 sandbox
1704 .validate_for_platform(Platform::Kubernetes)
1705 .expect("the kubelet takes any activeDeadlineSeconds");
1706 }
1707
1708 sandbox.session.max_lifetime_seconds = Some(28_800);
1709 sandbox
1710 .validate_for_platform(Platform::Aws)
1711 .expect("the ceiling itself is allowed");
1712 }
1713
1714 #[test]
1719 fn an_image_azure_cannot_pull_is_refused_while_planning() {
1720 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1721 sandbox.limits = None;
1724
1725 for image in [
1726 "ubuntu:24.04",
1727 "ghcr.io/myorg/sandbox:latest",
1728 "ubuntu@sha256:abc",
1729 "",
1730 " ",
1731 "ubuntu latest",
1732 "ubuntu?x",
1733 ] {
1734 sandbox.code = SandboxCode::Image {
1735 image: image.to_string(),
1736 };
1737 let error = sandbox
1738 .validate_for_platform(Platform::Azure)
1739 .expect_err("an image Azure has nowhere to put is refused");
1740 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1741
1742 sandbox
1744 .validate_for_platform(Platform::Kubernetes)
1745 .expect("a registry reference is what every other backend takes");
1746 }
1747
1748 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1749 sandbox.code = SandboxCode::Image {
1750 image: image.to_string(),
1751 };
1752 sandbox
1753 .validate_for_platform(Platform::Azure)
1754 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1755 }
1756
1757 sandbox.code = SandboxCode::Image {
1759 image: " ubuntu ".to_string(),
1760 };
1761 assert_eq!(
1762 sandbox
1763 .azure_catalog_image()
1764 .expect("a padded name is still a name"),
1765 "ubuntu"
1766 );
1767 }
1768
1769 #[test]
1773 fn a_session_deadline_is_accepted_only_where_the_platform_applies_it() {
1774 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1775 sandbox.session.max_lifetime_seconds = Some(3600);
1776
1777 sandbox
1778 .validate_for_platform(Platform::Kubernetes)
1779 .expect("the kubelet enforces activeDeadlineSeconds");
1780 sandbox
1781 .validate_for_platform(Platform::Aws)
1782 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
1783
1784 for platform in [Platform::Azure, Platform::Local] {
1785 let error = sandbox
1786 .validate_for_platform(platform)
1787 .expect_err("a deadline nothing applies must be refused");
1788 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1789 }
1790 }
1791
1792 #[test]
1796 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
1797 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1798 let tier = sandbox
1799 .microvm_tier()
1800 .expect("2Gi/1cpu/20Gi is satisfiable");
1801
1802 assert_eq!(
1803 tier.peak_memory_mib, 2048,
1804 "the peak is the declared ceiling"
1805 );
1806 assert_eq!(
1807 tier.baseline_memory_mib, 512,
1808 "which is a quarter of it as the baseline"
1809 );
1810 assert!(tier.max_disk_mib <= 20 * 1024);
1811 }
1812
1813 #[test]
1817 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
1818 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1819 {
1820 let limits = sandbox
1821 .limits
1822 .as_mut()
1823 .expect("the fixture declares limits");
1824 limits.cpu = "1".to_string();
1825 limits.memory = "8Gi".to_string();
1826 }
1827
1828 let error = sandbox
1829 .microvm_tier()
1830 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
1831 assert!(
1832 error.to_string().contains("4 vCPU"),
1833 "the refusal must say what the memory ceiling implies: {error}"
1834 );
1835
1836 sandbox
1837 .limits
1838 .as_mut()
1839 .expect("the fixture declares limits")
1840 .cpu = "4".to_string();
1841 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
1842 assert_eq!(tier.peak_memory_mib, 8192);
1843 }
1844
1845 #[test]
1848 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
1849 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1850 sandbox
1851 .limits
1852 .as_mut()
1853 .expect("the fixture declares limits")
1854 .memory = "1Gi".to_string();
1855
1856 let error = sandbox
1857 .validate_for_platform(Platform::Aws)
1858 .expect_err("no MicroVM size peaks at or below 1Gi");
1859 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1860 assert!(
1861 error.to_string().contains("2Gi"),
1862 "the refusal must say what the smallest holdable ceiling is: {error}"
1863 );
1864 }
1865
1866 #[test]
1870 fn source_code_is_refused_everywhere_rather_than_producing_a_broken_manifest() {
1871 let sandbox = Sandbox::new("agent".to_string())
1872 .code(SandboxCode::Source {
1873 src: "./sandbox".to_string(),
1874 toolchain: ToolchainConfig::Docker {
1875 dockerfile: None,
1876 build_args: None,
1877 target: None,
1878 },
1879 })
1880 .egress(SandboxEgress::Deny)
1881 .session(SandboxSessionPolicy {
1882 max_lifetime_seconds: None,
1883 idle_suspend_seconds: None,
1884 })
1885 .build();
1886
1887 for platform in [
1888 Platform::Aws,
1889 Platform::Azure,
1890 Platform::Gcp,
1891 Platform::Kubernetes,
1892 Platform::Local,
1893 ] {
1894 let error = sandbox
1895 .validate_for_platform(platform)
1896 .expect_err("no backend builds a sandbox image from source");
1897 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1898 assert!(
1899 error.to_string().contains("code.image"),
1900 "the refusal must say what to write instead: {error}"
1901 );
1902 }
1903 }
1904
1905 #[test]
1908 fn every_accepted_unit_converts_rather_than_falling_back() {
1909 assert_eq!(quantity_mib("2Gi"), Some(2048));
1910 assert_eq!(quantity_mib("512Mi"), Some(512));
1911 assert_eq!(quantity_mib("4G"), Some(3814));
1912 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
1913 assert_eq!(millicores("1"), Some(1000));
1914 assert_eq!(millicores("500m"), Some(500));
1915 }
1916
1917 #[test]
1918 fn unknown_fields_are_rejected() {
1919 let json = r#"{
1920 "id": "sbx",
1921 "code": {"type": "image", "image": "ubuntu:24.04"},
1922 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
1923 "egress": {"mode": "deny"},
1924 "session": {},
1925 "unexpected": true
1926 }"#;
1927
1928 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
1929 }
1930
1931 #[test]
1932 fn serialization_roundtrips() {
1933 let sandbox = sandbox_with(
1934 SandboxEgress::AllowDomains {
1935 domains: vec!["example.com".to_string()],
1936 },
1937 vec![8080, 9090],
1938 );
1939
1940 let json = serde_json::to_string(&sandbox).expect("serializes");
1941 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
1942 assert_eq!(sandbox, restored);
1943 }
1944
1945 #[test]
1946 fn id_is_immutable_across_updates() {
1947 let original = sandbox_with(SandboxEgress::Deny, vec![]);
1948 let renamed = Sandbox::new("other".to_string())
1949 .code(SandboxCode::Image {
1950 image: "ubuntu".to_string(),
1951 })
1952 .limits(
1953 original
1954 .limits
1955 .clone()
1956 .expect("the fixture declares limits"),
1957 )
1958 .egress(SandboxEgress::Deny)
1959 .session(SandboxSessionPolicy {
1960 max_lifetime_seconds: None,
1961 idle_suspend_seconds: None,
1962 })
1963 .build();
1964
1965 original
1966 .validate_update(&original.clone())
1967 .expect("an unchanged config is a valid update");
1968 original
1969 .validate_update(&renamed)
1970 .expect_err("renaming a sandbox is not an update");
1971 }
1972
1973 #[test]
1979 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
1980 let with_policy = |session: SandboxSessionPolicy| {
1981 Sandbox::new("sbx".to_string())
1982 .code(SandboxCode::Image {
1983 image: "ubuntu".to_string(),
1984 })
1985 .egress(SandboxEgress::Allow)
1986 .session(session)
1987 .build()
1988 .validate_for_platform(Platform::Azure)
1989 };
1990
1991 with_policy(SandboxSessionPolicy {
1992 max_lifetime_seconds: None,
1993 idle_suspend_seconds: Some(900),
1994 })
1995 .expect("Azure suspends a session on idle");
1996
1997 let error = with_policy(SandboxSessionPolicy {
1998 max_lifetime_seconds: Some(3600),
1999 idle_suspend_seconds: None,
2000 })
2001 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2002 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2003 assert!(
2004 error.message.contains("sessionLifetime"),
2005 "names the capability: {}",
2006 error.message
2007 );
2008 }
2009
2010 #[test]
2016 fn an_allowlist_with_no_domains_is_refused() {
2017 let declared = |domains: Vec<String>| {
2018 Sandbox::new("sbx".to_string())
2019 .code(SandboxCode::Image {
2020 image: "ubuntu".to_string(),
2021 })
2022 .egress(SandboxEgress::AllowDomains { domains })
2023 .session(SandboxSessionPolicy {
2024 max_lifetime_seconds: None,
2025 idle_suspend_seconds: None,
2026 })
2027 .build()
2028 .validate_for_platform(Platform::Azure)
2029 };
2030
2031 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2032 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2033
2034 declared(vec!["api.example.com".to_string()])
2035 .expect("a named domain is what an allowlist is for");
2036 }
2037
2038 #[test]
2041 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2042 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2043 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
2044 assert_eq!(
2045 SandboxEgress::AllowDomains {
2046 domains: vec!["api.example.com".to_string()]
2047 }
2048 .internet_access_switch(),
2049 None,
2050 "a host list has no boolean and must not be approximated"
2051 );
2052 }
2053}