1use crate::error::{ErrorData, Result};
11use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
12use crate::resources::ToolchainConfig;
13use crate::Platform;
14use alien_error::AlienError;
15use bon::Builder;
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::fmt::Debug;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[serde(rename_all = "camelCase", tag = "type")]
24pub enum SandboxCode {
25 #[serde(rename_all = "camelCase")]
27 Image {
28 image: String,
33 },
34 #[serde(rename_all = "camelCase")]
39 Source {
40 src: String,
42 toolchain: ToolchainConfig,
44 },
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct SandboxLimits {
56 pub cpu: String,
58 pub memory: String,
60 pub disk: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub max_processes: Option<u32>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct MicrovmTier {
78 pub baseline_memory_mib: i64,
80 pub peak_memory_mib: i64,
82 pub peak_vcpu: u32,
84 pub max_disk_mib: i64,
86}
87
88const AWS_MAX_LIFETIME_SECONDS: u32 = 28_800;
92
93const AZURE_CPU_STEP_MILLICORES: i64 = 250;
96const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
97const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
98const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
99
100const MICROVM_TIERS: &[MicrovmTier] = &[
101 MicrovmTier {
102 baseline_memory_mib: 512,
103 peak_memory_mib: 2048,
104 peak_vcpu: 1,
105 max_disk_mib: 8192,
106 },
107 MicrovmTier {
108 baseline_memory_mib: 1024,
109 peak_memory_mib: 4096,
110 peak_vcpu: 2,
111 max_disk_mib: 8192,
112 },
113 MicrovmTier {
114 baseline_memory_mib: 2048,
115 peak_memory_mib: 8192,
116 peak_vcpu: 4,
117 max_disk_mib: 8192,
118 },
119 MicrovmTier {
120 baseline_memory_mib: 4096,
121 peak_memory_mib: 16384,
122 peak_vcpu: 8,
123 max_disk_mib: 16384,
124 },
125 MicrovmTier {
126 baseline_memory_mib: 8192,
127 peak_memory_mib: 32768,
128 peak_vcpu: 16,
129 max_disk_mib: 32768,
130 },
131];
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
136#[serde(rename_all = "camelCase", tag = "mode")]
137pub enum SandboxEgress {
138 Deny,
147 Allow,
154 #[serde(rename_all = "camelCase")]
159 AllowDomains {
160 domains: Vec<String>,
162 },
163}
164
165impl SandboxEgress {
166 pub fn internet_access_switch(&self) -> Option<bool> {
173 match self {
174 SandboxEgress::Allow => Some(true),
175 SandboxEgress::Deny => Some(false),
176 SandboxEgress::AllowDomains { .. } => None,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
187#[serde(rename_all = "camelCase", deny_unknown_fields)]
188pub struct SandboxLifecyclePolicy {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub max_lifetime_seconds: Option<u32>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub idle_pause_seconds: Option<u32>,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
209#[serde(rename_all = "camelCase", deny_unknown_fields)]
210pub struct SandboxCapabilities {
211 pub files: bool,
213 pub reconnect: bool,
215 pub jobs: bool,
218 pub preview: bool,
220 pub pause_resume: bool,
222 pub snapshot: bool,
224 pub domain_egress_rules: bool,
226 pub egress_deny: bool,
229 pub enforced_limits: bool,
231 pub process_limit: bool,
233 pub sandbox_lifetime: bool,
235 pub supervisor_pid_namespace: bool,
241 pub supervisor_isolation: bool,
247}
248
249impl SandboxCapabilities {
250 pub fn for_platform(platform: Platform) -> Result<Self> {
256 match platform {
257 Platform::Aws => Ok(Self {
258 files: true,
259 reconnect: true,
260 jobs: true,
261 preview: true,
262 pause_resume: true,
263 snapshot: false,
264 domain_egress_rules: false,
265 egress_deny: true,
268 enforced_limits: true,
269 process_limit: false,
271 sandbox_lifetime: true,
274 supervisor_pid_namespace: false,
279 supervisor_isolation: true,
282 }),
283 Platform::Azure => Ok(Self::azure()),
284 Platform::Gcp => Ok(Self::gcp_agent_platform()),
285 Platform::Kubernetes => Ok(Self {
288 files: true,
289 reconnect: true,
290 jobs: true,
291 preview: false,
292 pause_resume: false,
293 snapshot: false,
294 domain_egress_rules: false,
295 egress_deny: true,
296 enforced_limits: true,
297 process_limit: false,
299 sandbox_lifetime: true,
301 supervisor_pid_namespace: false,
305 supervisor_isolation: false,
310 }),
311 Platform::Local => Ok(Self {
312 files: true,
313 reconnect: true,
314 jobs: false,
316 preview: true,
317 pause_resume: false,
318 snapshot: false,
319 domain_egress_rules: false,
320 egress_deny: true,
321 enforced_limits: true,
322 process_limit: true,
324 sandbox_lifetime: false,
325 supervisor_pid_namespace: false,
328 supervisor_isolation: true,
332 }),
333 Platform::Machines | Platform::Test => {
334 Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
335 platform: platform.to_string(),
336 }))
337 }
338 }
339 }
340
341 pub fn azure() -> Self {
343 Self {
344 files: true,
345 reconnect: true,
346 jobs: false,
348 preview: false,
353 pause_resume: true,
354 snapshot: false,
358 domain_egress_rules: true,
359 egress_deny: true,
360 enforced_limits: true,
364 process_limit: false,
365 sandbox_lifetime: false,
369 supervisor_pid_namespace: false,
371 supervisor_isolation: false,
374 }
375 }
376
377 pub fn gcp_agent_platform() -> Self {
379 Self {
380 files: true,
382 reconnect: true,
386 jobs: true,
387 preview: false,
389 pause_resume: true,
391 snapshot: false,
394 domain_egress_rules: false,
396 egress_deny: true,
398 enforced_limits: true,
402 process_limit: false,
404 sandbox_lifetime: true,
406 supervisor_pid_namespace: false,
408 supervisor_isolation: false,
411 }
412 }
413
414 pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
416 let available = match capability {
417 SandboxCapability::Files => self.files,
418 SandboxCapability::Reconnect => self.reconnect,
419 SandboxCapability::Jobs => self.jobs,
420 SandboxCapability::Preview => self.preview,
421 SandboxCapability::PauseResume => self.pause_resume,
422 SandboxCapability::Snapshot => self.snapshot,
423 SandboxCapability::DomainEgressRules => self.domain_egress_rules,
424 SandboxCapability::EgressDeny => self.egress_deny,
425 SandboxCapability::EnforcedLimits => self.enforced_limits,
426 SandboxCapability::ProcessLimit => self.process_limit,
427 SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
428 SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
429 SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
430 };
431
432 if available {
433 return Ok(());
434 }
435
436 Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
437 capability: capability.as_str().to_string(),
438 platform: platform.to_string(),
439 }))
440 }
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
446#[serde(rename_all = "camelCase")]
447pub enum SandboxCapability {
448 Files,
450 Reconnect,
452 Jobs,
454 Preview,
456 PauseResume,
458 Snapshot,
460 DomainEgressRules,
462 EgressDeny,
464 EnforcedLimits,
466 ProcessLimit,
468 SandboxLifetime,
470 SupervisorPidNamespace,
472 SupervisorIsolation,
474}
475
476impl SandboxCapability {
477 pub fn as_str(&self) -> &'static str {
479 match self {
480 Self::Files => "files",
481 Self::Reconnect => "reconnect",
482 Self::Jobs => "jobs",
483 Self::Preview => "preview",
484 Self::PauseResume => "pauseResume",
485 Self::Snapshot => "snapshot",
486 Self::DomainEgressRules => "domainEgressRules",
487 Self::EgressDeny => "egressDeny",
488 Self::EnforcedLimits => "enforcedLimits",
489 Self::ProcessLimit => "processLimit",
490 Self::SandboxLifetime => "sandboxLifetime",
491 Self::SupervisorPidNamespace => "supervisorPidNamespace",
492 Self::SupervisorIsolation => "supervisorIsolation",
493 }
494 }
495}
496
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
499#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
500#[serde(rename_all = "camelCase", deny_unknown_fields)]
501#[builder(start_fn = new)]
502pub struct Sandbox {
503 #[builder(start_fn)]
506 pub id: String,
507 pub code: SandboxCode,
509 #[serde(skip_serializing_if = "Option::is_none")]
513 pub private_base_image: Option<String>,
514 #[serde(skip_serializing_if = "Option::is_none")]
520 pub limits: Option<SandboxLimits>,
521 pub egress: SandboxEgress,
523 pub lifecycle: SandboxLifecyclePolicy,
525 #[builder(default)]
529 #[serde(default, skip_serializing_if = "Vec::is_empty")]
530 pub preview_ports: Vec<u16>,
531}
532
533pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
538 !targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
539}
540
541pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
547 stack.resources().any(|(_resource_id, resource)| {
548 resource
549 .config
550 .downcast_ref::<Sandbox>()
551 .is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
552 })
553}
554
555impl Sandbox {
556 pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
558
559 pub fn id(&self) -> &str {
561 &self.id
562 }
563
564 pub fn resolved_limits(&self) -> SandboxLimits {
570 self.limits.clone().unwrap_or_else(default_limits)
571 }
572
573 pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
578 let capabilities = SandboxCapabilities::for_platform(platform)?;
579
580 if matches!(&self.code, SandboxCode::Source { .. }) && platform != Platform::Aws {
584 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
585 resource_id: self.id.clone(),
586 field: "code".to_string(),
587 value: "source".to_string(),
588 reason: format!(
589 "no sandbox backend builds an image from source on {platform}; give \
590 code.image a prebuilt reference"
591 ),
592 }));
593 }
594
595 if self.private_base_image.is_some() && platform != Platform::Aws {
598 return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
599 capability: "privateBaseImage".to_string(),
600 platform: platform.to_string(),
601 }));
602 }
603
604 if platform == Platform::Azure {
606 self.azure_catalog_image()?;
607 }
608
609 let Some(limits) = self.limits.as_ref() else {
610 return self.validate_capabilities(&capabilities, platform);
612 };
613
614 validate_quantity(&self.id, "cpu", &limits.cpu)?;
615 validate_quantity(&self.id, "memory", &limits.memory)?;
616 validate_quantity(&self.id, "disk", &limits.disk)?;
617
618 if let Some(max_processes) = limits.max_processes {
619 if max_processes == 0 {
620 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
621 resource_id: self.id.clone(),
622 field: "maxProcesses".to_string(),
623 value: "0".to_string(),
624 reason: "a sandbox that may run no processes cannot run code".to_string(),
625 }));
626 }
627 capabilities.require(SandboxCapability::ProcessLimit, platform)?;
628 }
629
630 capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
633
634 if platform == Platform::Azure {
635 self.azure_sandbox_limits()?;
636 }
637
638 if platform == Platform::Aws {
639 self.microvm_tier()?;
642
643 if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
648 if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
649 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
650 resource_id: self.id.clone(),
651 field: "maxLifetimeSeconds".to_string(),
652 value: seconds.to_string(),
653 reason: format!(
654 "AWS runs a MicroVM for between 1 and \
655 {AWS_MAX_LIFETIME_SECONDS} seconds"
656 ),
657 }));
658 }
659 }
660 }
661
662 self.validate_capabilities(&capabilities, platform)
663 }
664
665 pub fn azure_catalog_image(&self) -> Result<&str> {
671 let refused = |value: &str, reason: &str| {
672 AlienError::new(ErrorData::SandboxLimitInvalid {
673 resource_id: self.id.clone(),
674 field: "code.image".to_string(),
675 value: value.to_string(),
676 reason: reason.to_string(),
677 })
678 };
679
680 let SandboxCode::Image { image } = &self.code else {
681 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
682 resource_id: self.id.clone(),
683 field: "code".to_string(),
684 value: "source".to_string(),
685 reason: "no sandbox backend builds an image from source yet".to_string(),
686 }));
687 };
688
689 let image = image.trim();
690 if image.is_empty() {
691 return Err(refused(image, "a sandbox has to name an image"));
692 }
693 if !image
694 .chars()
695 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
696 {
697 return Err(refused(
698 image,
699 "Azure creates a sandbox from a public catalog disk image, so code.image must be \
700 a bare catalog name such as 'ubuntu'",
701 ));
702 }
703 Ok(image)
704 }
705
706 pub fn azure_sandbox_limits(&self) -> Result<()> {
710 let Some(limits) = self.limits.as_ref() else {
711 return Ok(());
714 };
715
716 let refused = |field: &str, value: &str, reason: &str| {
717 AlienError::new(ErrorData::SandboxLimitInvalid {
718 resource_id: self.id.clone(),
719 field: field.to_string(),
720 value: value.to_string(),
721 reason: reason.to_string(),
722 })
723 };
724
725 let cpu_millicores = millicores(&limits.cpu)
726 .ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
727
728 if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
732 || !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
733 {
734 return Err(refused(
735 "cpu",
736 &limits.cpu,
737 "Azure allocates cpu in steps of 250m from 250m to 16000m",
738 ));
739 }
740
741 let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
743 let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
744
745 let memory_mib = quantity_mib(&limits.memory)
746 .ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
747 if memory_mib > memory_ceiling_mib {
748 return Err(refused(
749 "memory",
750 &limits.memory,
751 &format!(
752 "Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
753 at the declared cpu"
754 ),
755 ));
756 }
757
758 let disk_mib = quantity_mib(&limits.disk)
759 .ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
760 if disk_mib > disk_ceiling_mib {
761 return Err(refused(
762 "disk",
763 &limits.disk,
764 &format!(
765 "Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
766 the declared cpu"
767 ),
768 ));
769 }
770
771 Ok(())
772 }
773
774 pub fn microvm_tier(&self) -> Result<MicrovmTier> {
781 let Some(limits) = self.limits.as_ref() else {
782 return Ok(MICROVM_TIERS[2]);
784 };
785
786 let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
787 AlienError::new(ErrorData::SandboxLimitInvalid {
788 resource_id: self.id.clone(),
789 field: "memory".to_string(),
790 value: limits.memory.clone(),
791 reason: "AWS sizes a MicroVM in whole MiB".to_string(),
792 })
793 })?;
794 let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
795 AlienError::new(ErrorData::SandboxLimitInvalid {
796 resource_id: self.id.clone(),
797 field: "disk".to_string(),
798 value: limits.disk.clone(),
799 reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
800 })
801 })?;
802 let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
803 AlienError::new(ErrorData::SandboxLimitInvalid {
804 resource_id: self.id.clone(),
805 field: "cpu".to_string(),
806 value: limits.cpu.clone(),
807 reason: "expected cores or millicores".to_string(),
808 })
809 })?;
810
811 let sized = |tier: &&MicrovmTier| {
816 tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
817 };
818
819 let tier = MICROVM_TIERS
820 .iter()
821 .rev()
822 .find(sized)
823 .copied()
824 .ok_or_else(|| {
825 AlienError::new(ErrorData::SandboxLimitInvalid {
826 resource_id: self.id.clone(),
827 field: "memory".to_string(),
828 value: limits.memory.clone(),
829 reason: format!(
830 "a Lambda MicroVM bursts to four times its baseline, so the smallest \
831 ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
832 disk fit no size",
833 limits.memory, limits.disk
834 ),
835 })
836 })?;
837
838 let required_millicores = i64::from(tier.peak_vcpu) * 1000;
839 if cpu_millicores < required_millicores {
840 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
841 resource_id: self.id.clone(),
842 field: "cpu".to_string(),
843 value: limits.cpu.clone(),
844 reason: format!(
845 "AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
846 reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
847 limits.memory, tier.peak_vcpu, tier.peak_vcpu
848 ),
849 }));
850 }
851
852 Ok(tier)
853 }
854
855 fn validate_capabilities(
857 &self,
858 capabilities: &SandboxCapabilities,
859 platform: Platform,
860 ) -> Result<()> {
861 if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
862 capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
863 }
864
865 if let SandboxEgress::AllowDomains { domains } = &self.egress {
871 if domains.is_empty() {
872 return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
873 resource_id: self.id.clone(),
874 field: "egress.domains".to_string(),
875 value: "[]".to_string(),
876 reason: "an allowlist naming no domain denies everything; declare \
877 egress: deny if that is what was meant"
878 .to_string(),
879 }));
880 }
881 }
882
883 if matches!(self.egress, SandboxEgress::Deny) {
884 capabilities.require(SandboxCapability::EgressDeny, platform)?;
885 }
886
887 if !self.preview_ports.is_empty() {
888 capabilities.require(SandboxCapability::Preview, platform)?;
889 }
890
891 if self.lifecycle.idle_pause_seconds.is_some() {
892 capabilities.require(SandboxCapability::PauseResume, platform)?;
893 }
894
895 if self.lifecycle.max_lifetime_seconds.is_some() {
896 capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
897 }
898
899 Ok(())
900 }
901}
902
903fn default_limits() -> SandboxLimits {
908 SandboxLimits {
909 cpu: "1".to_string(),
910 memory: "2Gi".to_string(),
911 disk: "8Gi".to_string(),
912 max_processes: None,
913 }
914}
915
916fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
918 let invalid = |reason: &str| {
919 AlienError::new(ErrorData::SandboxLimitInvalid {
920 resource_id: resource_id.to_string(),
921 field: field.to_string(),
922 value: value.to_string(),
923 reason: reason.to_string(),
924 })
925 };
926
927 let digits_end = value
928 .find(|c: char| !c.is_ascii_digit() && c != '.')
929 .unwrap_or(value.len());
930 let (number, suffix) = value.split_at(digits_end);
931
932 let parsed: f64 = number
933 .parse()
934 .map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
935
936 if parsed <= 0.0 {
937 return Err(invalid("must be greater than zero"));
938 }
939
940 const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
941 if !SUFFIXES.contains(&suffix) {
942 return Err(invalid(
943 "unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
944 ));
945 }
946
947 Ok(())
948}
949
950fn split_quantity(value: &str) -> Option<(f64, &str)> {
952 let trimmed = value.trim();
953 let digits_end = trimmed
954 .find(|c: char| !c.is_ascii_digit() && c != '.')
955 .unwrap_or(trimmed.len());
956 let (number, suffix) = trimmed.split_at(digits_end);
957 number.parse().ok().map(|number| (number, suffix))
958}
959
960pub fn quantity_mib(value: &str) -> Option<i64> {
966 let (number, suffix) = split_quantity(value)?;
967 let bytes = match suffix {
968 "" => number,
969 "k" => number * 1e3,
970 "M" => number * 1e6,
971 "G" => number * 1e9,
972 "T" => number * 1e12,
973 "Ki" => number * 1024.0,
974 "Mi" => number * 1024.0 * 1024.0,
975 "Gi" => number * 1024.0 * 1024.0 * 1024.0,
976 "Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
977 _ => return None,
979 };
980 Some((bytes / (1024.0 * 1024.0)) as i64)
981}
982
983pub fn millicores(value: &str) -> Option<i64> {
985 let (number, suffix) = split_quantity(value)?;
986 match suffix {
987 "" => Some((number * 1000.0) as i64),
988 "m" => Some(number as i64),
989 _ => None,
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
995#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
996#[serde(rename_all = "camelCase")]
997pub struct SandboxOutputs {
998 pub parent_name: String,
1000 #[serde(skip_serializing_if = "Option::is_none")]
1002 pub identifier: Option<String>,
1003 #[serde(skip_serializing_if = "Option::is_none")]
1005 pub endpoint: Option<String>,
1006}
1007
1008impl ResourceOutputsDefinition for SandboxOutputs {
1009 fn get_resource_type(&self) -> ResourceType {
1010 Sandbox::RESOURCE_TYPE
1011 }
1012
1013 fn as_any(&self) -> &dyn Any {
1014 self
1015 }
1016
1017 fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
1018 Box::new(self.clone())
1019 }
1020
1021 fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
1022 other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
1023 }
1024
1025 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1026 serde_json::to_value(self)
1027 }
1028}
1029
1030impl ResourceDefinition for Sandbox {
1031 fn get_resource_type(&self) -> ResourceType {
1032 Self::RESOURCE_TYPE
1033 }
1034
1035 fn id(&self) -> &str {
1036 &self.id
1037 }
1038
1039 fn get_dependencies(&self) -> Vec<ResourceRef> {
1040 Vec::new()
1041 }
1042
1043 fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
1044 let new_sandbox = new_config
1045 .as_any()
1046 .downcast_ref::<Sandbox>()
1047 .ok_or_else(|| {
1048 AlienError::new(ErrorData::UnexpectedResourceType {
1049 resource_id: self.id.clone(),
1050 expected: Self::RESOURCE_TYPE,
1051 actual: new_config.get_resource_type(),
1052 })
1053 })?;
1054
1055 if self.id != new_sandbox.id {
1056 return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
1057 resource_id: self.id.clone(),
1058 reason: "the 'id' field is immutable".to_string(),
1059 }));
1060 }
1061
1062 Ok(())
1063 }
1064
1065 fn as_any(&self) -> &dyn Any {
1066 self
1067 }
1068
1069 fn as_any_mut(&mut self) -> &mut dyn Any {
1070 self
1071 }
1072
1073 fn box_clone(&self) -> Box<dyn ResourceDefinition> {
1074 Box::new(self.clone())
1075 }
1076
1077 fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
1078 other.as_any().downcast_ref::<Sandbox>() == Some(self)
1079 }
1080
1081 fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
1082 serde_json::to_value(self)
1083 }
1084}
1085
1086pub const BUNDLE_REGION_TOKEN: &str = "{region}";
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1094pub enum BundleUri<'a> {
1095 Literal(&'a str),
1097 Regional { before: &'a str, after: &'a str },
1100}
1101
1102pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
1106 let (above_file, _) = key.rsplit_once('/')?;
1107 let (above_version, _) = above_file.rsplit_once('/')?;
1108 Some(above_version)
1109}
1110
1111pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
1117 let path = uri
1118 .strip_prefix("s3://")
1119 .ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
1120 let (bucket, key) = path
1121 .split_once('/')
1122 .ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
1123
1124 if path.contains('*') || path.contains('?') {
1128 return Err(format!(
1129 "'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
1130 role's grant, so '*' and '?' would widen it past the bundle"
1131 ));
1132 }
1133
1134 if key.contains('{') || key.contains('}') {
1135 return Err(format!(
1136 "'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
1137 bucket name alone"
1138 ));
1139 }
1140
1141 let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
1142 if bucket.contains('{') || bucket.contains('}') {
1143 return Err(format!(
1144 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
1145 only one"
1146 ));
1147 }
1148 return Ok(BundleUri::Literal(uri));
1149 };
1150
1151 if after.contains(BUNDLE_REGION_TOKEN) {
1152 return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
1153 }
1154 if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
1155 return Err(format!(
1156 "'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
1157 ));
1158 }
1159
1160 Ok(BundleUri::Regional {
1161 before: &uri[.."s3://".len() + before.len()],
1162 after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
1163 })
1164}
1165
1166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1168pub enum EcrImageRegion<'a> {
1169 Literal(&'a str),
1171 Deployment,
1173}
1174
1175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1177pub struct EcrImageRepository<'a> {
1178 pub account_id: &'a str,
1179 pub region: EcrImageRegion<'a>,
1180 pub repository: &'a str,
1182}
1183
1184impl EcrImageRepository<'_> {
1185 pub fn arn(&self, partition: &str, region: &str) -> String {
1188 let region = match self.region {
1189 EcrImageRegion::Literal(region) => region,
1190 EcrImageRegion::Deployment => region,
1191 };
1192 format!(
1193 "arn:{partition}:ecr:{region}:{}:repository/{}",
1194 self.account_id, self.repository
1195 )
1196 }
1197}
1198
1199pub fn parse_ecr_image_repository(
1205 image: &str,
1206) -> std::result::Result<EcrImageRepository<'_>, String> {
1207 let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
1208 let (host, path) = image
1209 .split_once('/')
1210 .ok_or_else(|| refuse("names no repository"))?;
1211 let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
1212 refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
1213 })?;
1214 let region = rest
1216 .strip_suffix(".amazonaws.com.cn")
1217 .or_else(|| rest.strip_suffix(".amazonaws.com"))
1218 .ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
1219 if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
1220 return Err(refuse("names no 12-digit account in its registry host"));
1221 }
1222 let region = if region == BUNDLE_REGION_TOKEN {
1223 EcrImageRegion::Deployment
1224 } else if !region.is_empty()
1225 && region
1226 .bytes()
1227 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1228 {
1229 EcrImageRegion::Literal(region)
1230 } else {
1231 return Err(refuse(&format!(
1232 "names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
1233 )));
1234 };
1235
1236 let repository = match path.split_once('@') {
1237 Some((repository, _digest)) => repository,
1238 None => match path.rsplit_once('/') {
1239 Some((parent, last)) => match last.split_once(':') {
1240 Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
1241 None => path,
1242 },
1243 None => path.split_once(':').map_or(path, |(name, _tag)| name),
1244 },
1245 };
1246 let valid_segment = |segment: &str| {
1247 !segment.is_empty()
1248 && segment.bytes().all(|b| {
1249 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
1250 })
1251 };
1252 if !repository.split('/').all(valid_segment) {
1253 return Err(refuse(
1254 "names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
1255 ));
1256 }
1257 Ok(EcrImageRepository {
1258 account_id,
1259 region,
1260 repository,
1261 })
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::*;
1267
1268 #[test]
1269 fn private_database_setup_accepts_the_default_network() {
1270 for lifecycle in [
1271 crate::ResourceLifecycle::Frozen,
1272 crate::ResourceLifecycle::Live,
1273 ] {
1274 let stack = crate::Stack::new("database".to_string())
1275 .add(
1276 crate::Postgres::new("metadata".to_string()).build(),
1277 lifecycle,
1278 )
1279 .build();
1280 assert!(!restricts_network_mode(&stack, false));
1281 assert!(!restricts_network_mode(&stack, true));
1282 }
1283 assert!(!restricts_network_mode(
1284 &crate::Stack::new("empty".to_string()).build(),
1285 false,
1286 ));
1287 }
1288
1289 #[test]
1290 fn a_private_base_image_names_one_repository() {
1291 let deployment = EcrImageRegion::Deployment;
1292 let literal = EcrImageRegion::Literal;
1293 let accepted = [
1294 (
1295 "123456789012.dkr.ecr.us-east-1.amazonaws.com/base:1.0",
1296 literal("us-east-1"),
1297 "base",
1298 ),
1299 (
1300 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team/agents/base:1.0",
1301 literal("us-east-1"),
1302 "team/agents/base",
1303 ),
1304 (
1305 "123456789012.dkr.ecr.{region}.amazonaws.com/team/base@sha256:abc123",
1306 deployment,
1307 "team/base",
1308 ),
1309 (
1310 "123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn/base",
1311 literal("cn-north-1"),
1312 "base",
1313 ),
1314 (
1315 "123456789012.dkr.ecr.us-gov-west-1.amazonaws.com/my.base_image-x",
1316 literal("us-gov-west-1"),
1317 "my.base_image-x",
1318 ),
1319 ];
1320 for (image, region, repository) in accepted {
1321 assert_eq!(
1322 parse_ecr_image_repository(image),
1323 Ok(EcrImageRepository {
1324 account_id: "123456789012",
1325 region,
1326 repository,
1327 }),
1328 "{image}"
1329 );
1330 }
1331
1332 for refused in [
1333 "public.ecr.aws/docker/library/alpine:3.20",
1334 "docker.io/library/alpine:3.20",
1335 "https://123456789012.dkr.ecr.us-east-1.amazonaws.com/base:1.0",
1336 "123456789012.dkr.ecr.us-east-1.amazonaws.com",
1337 "123456789012.dkr.ecr.us-east-1.amazonaws.com/",
1338 "12345.dkr.ecr.us-east-1.amazonaws.com/base",
1339 "123456789012.dkr.ecr..amazonaws.com/base",
1340 "123456789012.dkr.ecr.{account}.amazonaws.com/base",
1341 "123456789012.dkr.ecr.us-east-1.amazonaws.com/*",
1342 "123456789012.dkr.ecr.us-east-1.amazonaws.com/ba?e",
1343 "123456789012.dkr.ecr.us-east-1.amazonaws.com/${AWS::AccountId}",
1344 "123456789012.dkr.ecr.us-east-1.amazonaws.com/{region}/base",
1345 "123456789012.dkr.ecr.us-east-1.amazonaws.com/Base:1.0",
1346 "123456789012.dkr.ecr.us-east-1.amazonaws.com/team//base",
1347 ] {
1348 assert!(
1349 parse_ecr_image_repository(refused).is_err(),
1350 "{refused} must be refused"
1351 );
1352 }
1353 }
1354
1355 #[test]
1356 fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
1357 let regional =
1358 parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
1359 .expect("parses");
1360 let pinned =
1361 parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
1362 .expect("parses");
1363 assert_eq!(
1364 regional.arn("aws-us-gov", "us-gov-west-1"),
1365 "arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
1366 );
1367 assert_eq!(
1368 pinned.arn("aws", "us-east-1"),
1369 "arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
1370 );
1371 }
1372
1373 #[test]
1376 fn a_uri_carrying_an_iam_wildcard_is_refused() {
1377 for uri in [
1378 "s3://acme/team-*/v1/bundle.zip",
1379 "s3://acme/sandbox-bundle/f00d/bundle?.zip",
1380 "s3://acme-*/sandbox-bundle/f00d/bundle.zip",
1381 ] {
1382 let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
1383 assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
1384 }
1385
1386 parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
1387 .expect("an ordinary key still parses");
1388 }
1389
1390 #[test]
1394 fn a_grantable_prefix_stops_above_the_segment_that_moves() {
1395 assert_eq!(
1396 stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
1397 Some("sandbox-bundle")
1398 );
1399 assert_eq!(
1400 stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
1401 Some("artifacts/team-a/sandbox"),
1402 "a deeper key narrows the prefix, it never widens to the first segment"
1403 );
1404
1405 assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
1408 assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
1409 }
1410
1411 fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
1412 Sandbox::new("agent-sbx".to_string())
1413 .code(SandboxCode::Image {
1414 image: "ubuntu".to_string(),
1415 })
1416 .limits(SandboxLimits {
1417 cpu: "1".to_string(),
1418 memory: "2Gi".to_string(),
1419 disk: "20Gi".to_string(),
1420 max_processes: None,
1421 })
1422 .egress(egress)
1423 .lifecycle(SandboxLifecyclePolicy {
1424 max_lifetime_seconds: None,
1425 idle_pause_seconds: None,
1426 })
1427 .preview_ports(preview_ports)
1428 .build()
1429 }
1430
1431 #[test]
1434 fn a_uri_without_a_token_is_carried_whole() {
1435 assert_eq!(
1436 parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
1437 Ok(BundleUri::Literal(
1438 "s3://acme-artifacts-us-east-2/agents/bundle.zip"
1439 ))
1440 );
1441 }
1442
1443 #[test]
1446 fn a_regional_uri_splits_either_side_of_the_token() {
1447 let BundleUri::Regional { before, after } =
1448 parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
1449 .expect("the token is accepted in the bucket")
1450 else {
1451 panic!("a bucket-position token must split");
1452 };
1453
1454 assert_eq!(before, "s3://acme-artifacts-");
1455 assert_eq!(after, "/agents/bundle.zip");
1456 assert_eq!(
1457 format!("{before}us-east-2{after}"),
1458 "s3://acme-artifacts-us-east-2/agents/bundle.zip",
1459 "the halves must rejoin to the URI the vendor meant"
1460 );
1461 }
1462
1463 #[test]
1466 fn a_token_this_build_cannot_resolve_is_refused() {
1467 for uri in [
1468 "s3://acme-artifacts-{regio}/bundle.zip",
1469 "s3://acme-artifacts/{region}/bundle.zip",
1470 "s3://acme-artifacts-{region}-{region}/bundle.zip",
1471 "s3://acme-artifacts/bundle-{version}.zip",
1472 "s3://acme}-artifacts-{region}/bundle.zip",
1473 "s3://acme{-artifacts-{region}/bundle.zip",
1474 ] {
1475 assert!(
1476 parse_bundle_uri(uri).is_err(),
1477 "'{uri}' must be refused before it can reach an image build"
1478 );
1479 }
1480 }
1481
1482 #[test]
1483 fn resource_type_is_stable() {
1484 assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
1485 }
1486
1487 #[test]
1488 fn capability_sets_are_per_platform() {
1489 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1490 assert!(
1491 gcp.reconnect,
1492 "generation from the container boot id makes a sandbox reachable across processes"
1493 );
1494 assert!(!gcp.preview);
1495 assert!(gcp.enforced_limits);
1496
1497 let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
1498 assert!(azure.files, "every backend moves files");
1499 assert!(gcp.files);
1500 assert!(azure.domain_egress_rules);
1503 assert!(azure.egress_deny);
1504 assert!(azure.enforced_limits);
1507 assert!(azure.pause_resume);
1508 assert!(!azure.snapshot);
1512 assert!(!azure.preview);
1513
1514 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1515 assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
1516 assert!(aws.pause_resume);
1517
1518 let k8s =
1519 SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
1520 assert!(
1521 !k8s.preview,
1522 "the sandbox-scoped ingress gateway does not exist yet"
1523 );
1524 }
1525
1526 #[test]
1535 fn supervisor_isolation_is_per_platform() {
1536 let value = |platform| {
1537 SandboxCapabilities::for_platform(platform)
1538 .expect("supported")
1539 .supervisor_isolation
1540 };
1541
1542 assert!(
1543 value(Platform::Aws),
1544 "root agent setuids the command to 60000"
1545 );
1546 assert!(
1547 value(Platform::Local),
1548 "the supervisor is on the host, outside the container"
1549 );
1550 assert!(
1551 !value(Platform::Kubernetes),
1552 "a single pinned uid cannot be split"
1553 );
1554 assert!(!value(Platform::Azure), "no Alien process runs the command");
1555 assert!(
1556 !value(Platform::Gcp),
1557 "no separate supervisor identity runs the command"
1558 );
1559 }
1560
1561 #[test]
1565 fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
1566 let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
1567 let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1568
1569 assert_eq!(
1570 aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
1571 "the older axis cannot tell them apart"
1572 );
1573 assert!(
1574 aws.supervisor_isolation,
1575 "AWS setuids the command off the supervisor"
1576 );
1577 assert!(
1578 !gcp.supervisor_isolation,
1579 "the command runs under no separate supervisor identity"
1580 );
1581 }
1582
1583 #[test]
1588 fn gcp_agent_platform_row_matches_measured_backend() {
1589 let row = SandboxCapabilities::gcp_agent_platform();
1590
1591 assert!(row.files, "agent file ops move over the sandbox envelope");
1592 assert!(
1593 row.reconnect,
1594 "generation is derived from the container boot id, so a sandbox is reachable across \
1595 processes"
1596 );
1597 assert!(
1598 !row.preview,
1599 "the only ingress is :execute; no port-scoped capability"
1600 );
1601 assert!(
1602 row.pause_resume,
1603 ":pause and :resume preserve the container"
1604 );
1605 assert!(
1606 !row.snapshot,
1607 "the create path never sends a snapshot, so none is reachable through the trait"
1608 );
1609 assert!(
1610 !row.domain_egress_rules,
1611 "VPC and DNS peering is not a hostname allowlist"
1612 );
1613 assert!(
1614 row.egress_deny,
1615 "a declared deny blocks both egress and DNS"
1616 );
1617 assert!(
1618 row.enforced_limits,
1619 "ceilings are enforced, by terminating the sandbox on breach"
1620 );
1621 assert!(!row.process_limit, "no process-count ceiling is observed");
1622 assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
1623 assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
1624 assert!(
1625 !row.supervisor_isolation,
1626 "the command is not run under a separate supervisor identity"
1627 );
1628
1629 let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
1631 assert_eq!(
1632 live, row,
1633 "the Platform::Gcp arm is the Agent Platform capability row"
1634 );
1635 }
1636
1637 #[test]
1638 fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
1639 let error = SandboxCapabilities::for_platform(Platform::Machines)
1640 .expect_err("Machines has no sandbox backend");
1641 assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
1642 }
1643
1644 #[test]
1645 fn unsupported_capability_names_platform_and_capability() {
1646 let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
1647 let error = capabilities
1648 .require(SandboxCapability::Preview, Platform::Gcp)
1649 .expect_err("GCP has no preview");
1650
1651 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1652 let rendered = error.to_string();
1653 assert!(
1654 rendered.contains("preview"),
1655 "names the capability: {rendered}"
1656 );
1657 assert!(rendered.contains("gcp"), "names the platform: {rendered}");
1658 }
1659
1660 #[test]
1664 fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
1665 let sandbox = sandbox_with(
1666 SandboxEgress::AllowDomains {
1667 domains: vec!["example.com".to_string()],
1668 },
1669 vec![],
1670 );
1671
1672 for platform in [
1673 Platform::Aws,
1674 Platform::Gcp,
1675 Platform::Kubernetes,
1676 Platform::Local,
1677 ] {
1678 let error = sandbox
1679 .validate_for_platform(platform)
1680 .expect_err("only Azure expresses a hostname allowlist");
1681 assert_eq!(
1682 error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
1683 "on {platform:?}"
1684 );
1685 }
1686
1687 assert!(
1688 SandboxCapabilities::for_platform(Platform::Azure)
1689 .expect("supported")
1690 .domain_egress_rules,
1691 "Azure's egress policy matches on host pattern"
1692 );
1693 }
1694
1695 #[test]
1698 fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
1699 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1700
1701 assert!(
1704 SandboxCapabilities::for_platform(Platform::Gcp)
1705 .expect("supported")
1706 .egress_deny
1707 );
1708
1709 for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
1710 sandbox
1711 .validate_for_platform(platform)
1712 .expect("deny is enforced here");
1713 }
1714
1715 let egress_only = Sandbox::new("sbx".to_string())
1717 .code(SandboxCode::Image {
1718 image: "alpine".to_string(),
1719 })
1720 .egress(SandboxEgress::Deny)
1721 .lifecycle(SandboxLifecyclePolicy {
1722 max_lifetime_seconds: None,
1723 idle_pause_seconds: None,
1724 })
1725 .build();
1726
1727 egress_only
1728 .validate_for_platform(Platform::Azure)
1729 .expect("Azure creates the sandbox under a Deny policy with full inspection");
1730 }
1731
1732 #[test]
1736 fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
1737 let undeclared = Sandbox::new("sbx".to_string())
1738 .code(SandboxCode::Image {
1739 image: "alpine".to_string(),
1740 })
1741 .egress(SandboxEgress::Deny)
1742 .lifecycle(SandboxLifecyclePolicy {
1743 max_lifetime_seconds: None,
1744 idle_pause_seconds: None,
1745 })
1746 .build();
1747
1748 undeclared
1749 .validate_for_platform(Platform::Azure)
1750 .expect("a sandbox naming no ceilings takes the platform's own");
1751
1752 assert_eq!(undeclared.resolved_limits().cpu, "1");
1754 }
1755
1756 #[test]
1760 fn azure_sizes_follow_the_rule_the_data_plane_states() {
1761 let sized = |cpu: &str, memory: &str, disk: &str| {
1762 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1763 let limits = sandbox
1764 .limits
1765 .as_mut()
1766 .expect("the fixture declares limits");
1767 limits.cpu = cpu.to_string();
1768 limits.memory = memory.to_string();
1769 limits.disk = disk.to_string();
1770 sandbox.validate_for_platform(Platform::Azure)
1771 };
1772
1773 sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
1774 sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
1775 sized("16000m", "32Gi", "320Gi").expect("the top of the range");
1776
1777 let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
1779 assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
1780 assert!(off_step.to_string().contains("cpu"), "{off_step}");
1781
1782 let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
1783 assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
1784
1785 sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
1788 let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
1789 assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
1790 assert!(over_memory.to_string().contains("memory"), "{over_memory}");
1791
1792 let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
1793 assert!(over_disk.to_string().contains("disk"), "{over_disk}");
1794 }
1795
1796 #[test]
1797 fn preview_ports_require_the_preview_capability() {
1798 let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
1799
1800 sandbox
1801 .validate_for_platform(Platform::Aws)
1802 .expect("AWS mints a port-scoped JWE");
1803
1804 let error = sandbox
1805 .validate_for_platform(Platform::Kubernetes)
1806 .expect_err("Kubernetes preview is deferred");
1807 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1808 }
1809
1810 #[test]
1814 fn a_private_base_image_is_refused_off_aws() {
1815 let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1816 sandbox.code = SandboxCode::Image {
1817 image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
1818 };
1819 sandbox.private_base_image =
1820 Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
1821
1822 sandbox
1823 .validate_for_platform(Platform::Aws)
1824 .expect("AWS builds its image from a bundle, so a base image sits behind code.image");
1825
1826 for platform in [
1827 Platform::Gcp,
1828 Platform::Azure,
1829 Platform::Kubernetes,
1830 Platform::Local,
1831 ] {
1832 let error = sandbox
1833 .validate_for_platform(platform)
1834 .expect_err("a backend that builds no image must refuse a base image for one");
1835 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1836 assert!(
1837 error.to_string().contains("privateBaseImage"),
1838 "the refusal must name the field the user declared: {error}"
1839 );
1840 }
1841 }
1842
1843 #[test]
1844 fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
1845 let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
1846 sandbox
1847 .validate_for_platform(Platform::Gcp)
1848 .expect("Agent Platform enforces declared ceilings, by terminating on breach");
1849 }
1850
1851 #[test]
1852 fn invalid_quantities_are_rejected_with_the_offending_field() {
1853 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1854 sandbox
1855 .limits
1856 .as_mut()
1857 .expect("the fixture declares limits")
1858 .memory = "2Gb".to_string();
1859
1860 let error = sandbox
1861 .validate_for_platform(Platform::Aws)
1862 .expect_err("Gb is not a valid suffix");
1863 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1864 assert!(error.to_string().contains("memory"));
1865
1866 sandbox
1867 .limits
1868 .as_mut()
1869 .expect("the fixture declares limits")
1870 .memory = "2Gi".to_string();
1871 sandbox
1872 .limits
1873 .as_mut()
1874 .expect("the fixture declares limits")
1875 .cpu = "0".to_string();
1876 let error = sandbox
1877 .validate_for_platform(Platform::Aws)
1878 .expect_err("zero cpu is not a ceiling");
1879 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1880 }
1881
1882 #[test]
1883 fn zero_max_processes_is_rejected() {
1884 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1885 sandbox
1886 .limits
1887 .as_mut()
1888 .expect("the fixture declares limits")
1889 .max_processes = Some(0);
1890
1891 let error = sandbox
1892 .validate_for_platform(Platform::Local)
1893 .expect_err("a sandbox must be able to run at least one process");
1894 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
1895 assert!(error.to_string().contains("maxProcesses"));
1896 }
1897
1898 #[test]
1902 fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
1903 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1904 sandbox
1905 .limits
1906 .as_mut()
1907 .expect("the fixture declares limits")
1908 .max_processes = Some(256);
1909
1910 sandbox
1911 .validate_for_platform(Platform::Local)
1912 .expect("Docker takes a pids limit");
1913
1914 for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
1915 let error = sandbox
1916 .validate_for_platform(platform)
1917 .expect_err("a process ceiling nothing applies must be refused");
1918 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
1919 }
1920 }
1921
1922 #[test]
1926 fn a_lifetime_aws_would_reject_is_refused_while_planning() {
1927 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1928
1929 for seconds in [0, 28_801, 100_000] {
1930 sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
1931 let error = sandbox
1932 .validate_for_platform(Platform::Aws)
1933 .expect_err("a lifetime outside what AWS runs is refused");
1934 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
1935
1936 sandbox
1938 .validate_for_platform(Platform::Kubernetes)
1939 .expect("the kubelet takes any activeDeadlineSeconds");
1940 }
1941
1942 sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
1943 sandbox
1944 .validate_for_platform(Platform::Aws)
1945 .expect("the ceiling itself is allowed");
1946 }
1947
1948 #[test]
1953 fn an_image_azure_cannot_pull_is_refused_while_planning() {
1954 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
1955 sandbox.limits = None;
1958
1959 for image in [
1960 "ubuntu:24.04",
1961 "ghcr.io/myorg/sandbox:latest",
1962 "ubuntu@sha256:abc",
1963 "",
1964 " ",
1965 "ubuntu latest",
1966 "ubuntu?x",
1967 ] {
1968 sandbox.code = SandboxCode::Image {
1969 image: image.to_string(),
1970 };
1971 let error = sandbox
1972 .validate_for_platform(Platform::Azure)
1973 .expect_err("an image Azure has nowhere to put is refused");
1974 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
1975
1976 sandbox
1978 .validate_for_platform(Platform::Kubernetes)
1979 .expect("a registry reference is what every other backend takes");
1980 }
1981
1982 for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
1983 sandbox.code = SandboxCode::Image {
1984 image: image.to_string(),
1985 };
1986 sandbox
1987 .validate_for_platform(Platform::Azure)
1988 .unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
1989 }
1990
1991 sandbox.code = SandboxCode::Image {
1993 image: " ubuntu ".to_string(),
1994 };
1995 assert_eq!(
1996 sandbox
1997 .azure_catalog_image()
1998 .expect("a padded name is still a name"),
1999 "ubuntu"
2000 );
2001 }
2002
2003 #[test]
2007 fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
2008 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2009 sandbox.lifecycle.max_lifetime_seconds = Some(3600);
2010
2011 sandbox
2012 .validate_for_platform(Platform::Kubernetes)
2013 .expect("the kubelet enforces activeDeadlineSeconds");
2014 sandbox
2015 .validate_for_platform(Platform::Aws)
2016 .expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
2017
2018 for platform in [Platform::Azure, Platform::Local] {
2019 let error = sandbox
2020 .validate_for_platform(platform)
2021 .expect_err("a deadline nothing applies must be refused");
2022 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2023 }
2024 }
2025
2026 #[test]
2030 fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
2031 let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2032 let tier = sandbox
2033 .microvm_tier()
2034 .expect("2Gi/1cpu/20Gi is satisfiable");
2035
2036 assert_eq!(
2037 tier.peak_memory_mib, 2048,
2038 "the peak is the declared ceiling"
2039 );
2040 assert_eq!(
2041 tier.baseline_memory_mib, 512,
2042 "which is a quarter of it as the baseline"
2043 );
2044 assert!(tier.max_disk_mib <= 20 * 1024);
2045 }
2046
2047 #[test]
2051 fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
2052 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2053 {
2054 let limits = sandbox
2055 .limits
2056 .as_mut()
2057 .expect("the fixture declares limits");
2058 limits.cpu = "1".to_string();
2059 limits.memory = "8Gi".to_string();
2060 }
2061
2062 let error = sandbox
2063 .microvm_tier()
2064 .expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
2065 assert!(
2066 error.to_string().contains("4 vCPU"),
2067 "the refusal must say what the memory ceiling implies: {error}"
2068 );
2069
2070 sandbox
2071 .limits
2072 .as_mut()
2073 .expect("the fixture declares limits")
2074 .cpu = "4".to_string();
2075 let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
2076 assert_eq!(tier.peak_memory_mib, 8192);
2077 }
2078
2079 #[test]
2082 fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
2083 let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
2084 sandbox
2085 .limits
2086 .as_mut()
2087 .expect("the fixture declares limits")
2088 .memory = "1Gi".to_string();
2089
2090 let error = sandbox
2091 .validate_for_platform(Platform::Aws)
2092 .expect_err("no MicroVM size peaks at or below 1Gi");
2093 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2094 assert!(
2095 error.to_string().contains("2Gi"),
2096 "the refusal must say what the smallest holdable ceiling is: {error}"
2097 );
2098 }
2099
2100 #[test]
2104 fn source_code_is_refused_off_aws_rather_than_producing_a_broken_manifest() {
2105 let sandbox = Sandbox::new("agent".to_string())
2106 .code(SandboxCode::Source {
2107 src: "./sandbox".to_string(),
2108 toolchain: ToolchainConfig::Docker {
2109 dockerfile: None,
2110 build_args: None,
2111 target: None,
2112 },
2113 })
2114 .egress(SandboxEgress::Deny)
2115 .lifecycle(SandboxLifecyclePolicy {
2116 max_lifetime_seconds: None,
2117 idle_pause_seconds: None,
2118 })
2119 .build();
2120
2121 sandbox
2122 .validate_for_platform(Platform::Aws)
2123 .expect("an AWS sandbox base image is built by `alien build`");
2124
2125 for platform in [
2126 Platform::Azure,
2127 Platform::Gcp,
2128 Platform::Kubernetes,
2129 Platform::Local,
2130 ] {
2131 let error = sandbox
2132 .validate_for_platform(platform)
2133 .expect_err("no backend builds a sandbox image from source here");
2134 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2135 assert!(
2136 error.to_string().contains("code.image"),
2137 "the refusal must say what to write instead: {error}"
2138 );
2139 assert!(
2140 error.to_string().contains(&platform.to_string()),
2141 "the refusal must name the platform that cannot build it: {error}"
2142 );
2143 }
2144 }
2145
2146 #[test]
2149 fn every_accepted_unit_converts_rather_than_falling_back() {
2150 assert_eq!(quantity_mib("2Gi"), Some(2048));
2151 assert_eq!(quantity_mib("512Mi"), Some(512));
2152 assert_eq!(quantity_mib("4G"), Some(3814));
2153 assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
2154 assert_eq!(millicores("1"), Some(1000));
2155 assert_eq!(millicores("500m"), Some(500));
2156 }
2157
2158 #[test]
2159 fn unknown_fields_are_rejected() {
2160 let json = r#"{
2161 "id": "sbx",
2162 "code": {"type": "image", "image": "ubuntu:24.04"},
2163 "limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
2164 "egress": {"mode": "deny"},
2165 "lifecycle": {},
2166 "unexpected": true
2167 }"#;
2168
2169 serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
2170 }
2171
2172 #[test]
2173 fn serialization_roundtrips() {
2174 let sandbox = sandbox_with(
2175 SandboxEgress::AllowDomains {
2176 domains: vec!["example.com".to_string()],
2177 },
2178 vec![8080, 9090],
2179 );
2180
2181 let json = serde_json::to_string(&sandbox).expect("serializes");
2182 let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
2183 assert_eq!(sandbox, restored);
2184 }
2185
2186 #[test]
2187 fn id_is_immutable_across_updates() {
2188 let original = sandbox_with(SandboxEgress::Deny, vec![]);
2189 let renamed = Sandbox::new("other".to_string())
2190 .code(SandboxCode::Image {
2191 image: "ubuntu".to_string(),
2192 })
2193 .limits(
2194 original
2195 .limits
2196 .clone()
2197 .expect("the fixture declares limits"),
2198 )
2199 .egress(SandboxEgress::Deny)
2200 .lifecycle(SandboxLifecyclePolicy {
2201 max_lifetime_seconds: None,
2202 idle_pause_seconds: None,
2203 })
2204 .build();
2205
2206 original
2207 .validate_update(&original.clone())
2208 .expect("an unchanged config is a valid update");
2209 original
2210 .validate_update(&renamed)
2211 .expect_err("renaming a sandbox is not an update");
2212 }
2213
2214 #[test]
2220 fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
2221 let with_policy = |lifecycle: SandboxLifecyclePolicy| {
2222 Sandbox::new("sbx".to_string())
2223 .code(SandboxCode::Image {
2224 image: "ubuntu".to_string(),
2225 })
2226 .egress(SandboxEgress::Allow)
2227 .lifecycle(lifecycle)
2228 .build()
2229 .validate_for_platform(Platform::Azure)
2230 };
2231
2232 with_policy(SandboxLifecyclePolicy {
2233 max_lifetime_seconds: None,
2234 idle_pause_seconds: Some(900),
2235 })
2236 .expect("Azure pauses a sandbox on idle");
2237
2238 let error = with_policy(SandboxLifecyclePolicy {
2239 max_lifetime_seconds: Some(3600),
2240 idle_pause_seconds: None,
2241 })
2242 .expect_err("Azure has no wall-clock ceiling to enforce one with");
2243 assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
2244 assert!(
2245 error.message.contains("sandboxLifetime"),
2246 "names the capability: {}",
2247 error.message
2248 );
2249 }
2250
2251 #[test]
2257 fn an_allowlist_with_no_domains_is_refused() {
2258 let declared = |domains: Vec<String>| {
2259 Sandbox::new("sbx".to_string())
2260 .code(SandboxCode::Image {
2261 image: "ubuntu".to_string(),
2262 })
2263 .egress(SandboxEgress::AllowDomains { domains })
2264 .lifecycle(SandboxLifecyclePolicy {
2265 max_lifetime_seconds: None,
2266 idle_pause_seconds: None,
2267 })
2268 .build()
2269 .validate_for_platform(Platform::Azure)
2270 };
2271
2272 let error = declared(vec![]).expect_err("an empty allowlist must be refused");
2273 assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
2274
2275 declared(vec!["api.example.com".to_string()])
2276 .expect("a named domain is what an allowlist is for");
2277 }
2278
2279 #[test]
2282 fn internet_access_switch_maps_only_the_two_expressible_modes() {
2283 assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
2284 assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
2285 assert_eq!(
2286 SandboxEgress::AllowDomains {
2287 domains: vec!["api.example.com".to_string()]
2288 }
2289 .internet_access_switch(),
2290 None,
2291 "a host list has no boolean and must not be approximated"
2292 );
2293 }
2294}