use crate::error::{ErrorData, Result};
use crate::resource::{ResourceDefinition, ResourceOutputsDefinition, ResourceRef, ResourceType};
use crate::resources::ToolchainConfig;
use crate::Platform;
use alien_error::AlienError;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::fmt::Debug;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SandboxCode {
#[serde(rename_all = "camelCase")]
Image {
image: String,
},
#[serde(rename_all = "camelCase")]
Source {
src: String,
toolchain: ToolchainConfig,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SandboxLimits {
pub cpu: String,
pub memory: String,
pub disk: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_processes: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MicrovmTier {
pub baseline_memory_mib: i64,
pub peak_memory_mib: i64,
pub peak_vcpu: u32,
pub max_disk_mib: i64,
}
const AWS_MAX_LIFETIME_SECONDS: u32 = 28_800;
const AZURE_CPU_STEP_MILLICORES: i64 = 250;
const AZURE_MAX_CPU_MILLICORES: i64 = 16_000;
const AZURE_MEMORY_MIB_PER_CORE: i64 = 2 * 1024;
const AZURE_DISK_MIB_PER_CORE: i64 = 20 * 1024;
const MICROVM_TIERS: &[MicrovmTier] = &[
MicrovmTier {
baseline_memory_mib: 512,
peak_memory_mib: 2048,
peak_vcpu: 1,
max_disk_mib: 8192,
},
MicrovmTier {
baseline_memory_mib: 1024,
peak_memory_mib: 4096,
peak_vcpu: 2,
max_disk_mib: 8192,
},
MicrovmTier {
baseline_memory_mib: 2048,
peak_memory_mib: 8192,
peak_vcpu: 4,
max_disk_mib: 8192,
},
MicrovmTier {
baseline_memory_mib: 4096,
peak_memory_mib: 16384,
peak_vcpu: 8,
max_disk_mib: 16384,
},
MicrovmTier {
baseline_memory_mib: 8192,
peak_memory_mib: 32768,
peak_vcpu: 16,
max_disk_mib: 32768,
},
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum SandboxEgress {
Deny,
Allow,
#[serde(rename_all = "camelCase")]
AllowDomains {
domains: Vec<String>,
},
}
impl SandboxEgress {
pub fn internet_access_switch(&self) -> Option<bool> {
match self {
SandboxEgress::Allow => Some(true),
SandboxEgress::Deny => Some(false),
SandboxEgress::AllowDomains { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SandboxLifecyclePolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_lifetime_seconds: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idle_pause_seconds: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SandboxCapabilities {
pub files: bool,
pub reconnect: bool,
pub jobs: bool,
pub preview: bool,
pub pause_resume: bool,
pub snapshot: bool,
pub domain_egress_rules: bool,
pub egress_deny: bool,
pub enforced_limits: bool,
pub process_limit: bool,
pub sandbox_lifetime: bool,
pub supervisor_pid_namespace: bool,
pub supervisor_isolation: bool,
}
impl SandboxCapabilities {
pub fn for_platform(platform: Platform) -> Result<Self> {
match platform {
Platform::Aws => Ok(Self {
files: true,
reconnect: true,
jobs: true,
preview: true,
pause_resume: true,
snapshot: false,
domain_egress_rules: false,
egress_deny: true,
enforced_limits: true,
process_limit: false,
sandbox_lifetime: true,
supervisor_pid_namespace: false,
supervisor_isolation: true,
}),
Platform::Azure => Ok(Self::azure()),
Platform::Gcp => Ok(Self::gcp_agent_platform()),
Platform::Kubernetes => Ok(Self {
files: true,
reconnect: true,
jobs: true,
preview: false,
pause_resume: false,
snapshot: false,
domain_egress_rules: false,
egress_deny: true,
enforced_limits: true,
process_limit: false,
sandbox_lifetime: true,
supervisor_pid_namespace: false,
supervisor_isolation: false,
}),
Platform::Local => Ok(Self {
files: true,
reconnect: true,
jobs: false,
preview: true,
pause_resume: false,
snapshot: false,
domain_egress_rules: false,
egress_deny: true,
enforced_limits: true,
process_limit: true,
sandbox_lifetime: false,
supervisor_pid_namespace: false,
supervisor_isolation: true,
}),
Platform::Machines | Platform::Test => {
Err(AlienError::new(ErrorData::SandboxPlatformUnsupported {
platform: platform.to_string(),
}))
}
}
}
pub fn azure() -> Self {
Self {
files: true,
reconnect: true,
jobs: false,
preview: false,
pause_resume: true,
snapshot: false,
domain_egress_rules: true,
egress_deny: true,
enforced_limits: true,
process_limit: false,
sandbox_lifetime: false,
supervisor_pid_namespace: false,
supervisor_isolation: false,
}
}
pub fn gcp_agent_platform() -> Self {
Self {
files: true,
reconnect: true,
jobs: true,
preview: false,
pause_resume: true,
snapshot: false,
domain_egress_rules: false,
egress_deny: true,
enforced_limits: true,
process_limit: false,
sandbox_lifetime: true,
supervisor_pid_namespace: false,
supervisor_isolation: false,
}
}
pub fn require(&self, capability: SandboxCapability, platform: Platform) -> Result<()> {
let available = match capability {
SandboxCapability::Files => self.files,
SandboxCapability::Reconnect => self.reconnect,
SandboxCapability::Jobs => self.jobs,
SandboxCapability::Preview => self.preview,
SandboxCapability::PauseResume => self.pause_resume,
SandboxCapability::Snapshot => self.snapshot,
SandboxCapability::DomainEgressRules => self.domain_egress_rules,
SandboxCapability::EgressDeny => self.egress_deny,
SandboxCapability::EnforcedLimits => self.enforced_limits,
SandboxCapability::ProcessLimit => self.process_limit,
SandboxCapability::SandboxLifetime => self.sandbox_lifetime,
SandboxCapability::SupervisorPidNamespace => self.supervisor_pid_namespace,
SandboxCapability::SupervisorIsolation => self.supervisor_isolation,
};
if available {
return Ok(());
}
Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
capability: capability.as_str().to_string(),
platform: platform.to_string(),
}))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub enum SandboxCapability {
Files,
Reconnect,
Jobs,
Preview,
PauseResume,
Snapshot,
DomainEgressRules,
EgressDeny,
EnforcedLimits,
ProcessLimit,
SandboxLifetime,
SupervisorPidNamespace,
SupervisorIsolation,
}
impl SandboxCapability {
pub fn as_str(&self) -> &'static str {
match self {
Self::Files => "files",
Self::Reconnect => "reconnect",
Self::Jobs => "jobs",
Self::Preview => "preview",
Self::PauseResume => "pauseResume",
Self::Snapshot => "snapshot",
Self::DomainEgressRules => "domainEgressRules",
Self::EgressDeny => "egressDeny",
Self::EnforcedLimits => "enforcedLimits",
Self::ProcessLimit => "processLimit",
Self::SandboxLifetime => "sandboxLifetime",
Self::SupervisorPidNamespace => "supervisorPidNamespace",
Self::SupervisorIsolation => "supervisorIsolation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[builder(start_fn = new)]
pub struct Sandbox {
#[builder(start_fn)]
pub id: String,
pub code: SandboxCode,
#[serde(skip_serializing_if = "Option::is_none")]
pub private_base_image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limits: Option<SandboxLimits>,
pub egress: SandboxEgress,
pub lifecycle: SandboxLifecyclePolicy,
#[builder(default)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preview_ports: Vec<u16>,
}
pub fn restricts_network_mode(stack: &crate::Stack, targets_kubernetes: bool) -> bool {
!targets_kubernetes && stack_needs_named_subnets_at_setup(stack)
}
pub fn stack_needs_named_subnets_at_setup(stack: &crate::Stack) -> bool {
stack.resources().any(|(_resource_id, resource)| {
resource
.config
.downcast_ref::<Sandbox>()
.is_some_and(|sandbox| !matches!(sandbox.egress, SandboxEgress::Allow))
})
}
impl Sandbox {
pub const RESOURCE_TYPE: ResourceType = ResourceType::from_static("sandbox");
pub fn id(&self) -> &str {
&self.id
}
pub fn resolved_limits(&self) -> SandboxLimits {
self.limits.clone().unwrap_or_else(default_limits)
}
pub fn validate_for_platform(&self, platform: Platform) -> Result<()> {
let capabilities = SandboxCapabilities::for_platform(platform)?;
if matches!(&self.code, SandboxCode::Source { .. }) && platform != Platform::Aws {
return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "code".to_string(),
value: "source".to_string(),
reason: format!(
"no sandbox backend builds an image from source on {platform}; give \
code.image a prebuilt reference"
),
}));
}
if self.private_base_image.is_some() && platform != Platform::Aws {
return Err(AlienError::new(ErrorData::SandboxCapabilityUnsupported {
capability: "privateBaseImage".to_string(),
platform: platform.to_string(),
}));
}
if platform == Platform::Azure {
self.azure_catalog_image()?;
}
let Some(limits) = self.limits.as_ref() else {
return self.validate_capabilities(&capabilities, platform);
};
validate_quantity(&self.id, "cpu", &limits.cpu)?;
validate_quantity(&self.id, "memory", &limits.memory)?;
validate_quantity(&self.id, "disk", &limits.disk)?;
if let Some(max_processes) = limits.max_processes {
if max_processes == 0 {
return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "maxProcesses".to_string(),
value: "0".to_string(),
reason: "a sandbox that may run no processes cannot run code".to_string(),
}));
}
capabilities.require(SandboxCapability::ProcessLimit, platform)?;
}
capabilities.require(SandboxCapability::EnforcedLimits, platform)?;
if platform == Platform::Azure {
self.azure_sandbox_limits()?;
}
if platform == Platform::Aws {
self.microvm_tier()?;
if let Some(seconds) = self.lifecycle.max_lifetime_seconds {
if !(1..=AWS_MAX_LIFETIME_SECONDS).contains(&seconds) {
return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "maxLifetimeSeconds".to_string(),
value: seconds.to_string(),
reason: format!(
"AWS runs a MicroVM for between 1 and \
{AWS_MAX_LIFETIME_SECONDS} seconds"
),
}));
}
}
}
self.validate_capabilities(&capabilities, platform)
}
pub fn azure_catalog_image(&self) -> Result<&str> {
let refused = |value: &str, reason: &str| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "code.image".to_string(),
value: value.to_string(),
reason: reason.to_string(),
})
};
let SandboxCode::Image { image } = &self.code else {
return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "code".to_string(),
value: "source".to_string(),
reason: "no sandbox backend builds an image from source yet".to_string(),
}));
};
let image = image.trim();
if image.is_empty() {
return Err(refused(image, "a sandbox has to name an image"));
}
if !image
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
{
return Err(refused(
image,
"Azure creates a sandbox from a public catalog disk image, so code.image must be \
a bare catalog name such as 'ubuntu'",
));
}
Ok(image)
}
pub fn azure_sandbox_limits(&self) -> Result<()> {
let Some(limits) = self.limits.as_ref() else {
return Ok(());
};
let refused = |field: &str, value: &str, reason: &str| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: field.to_string(),
value: value.to_string(),
reason: reason.to_string(),
})
};
let cpu_millicores = millicores(&limits.cpu)
.ok_or_else(|| refused("cpu", &limits.cpu, "expected cores or millicores"))?;
if cpu_millicores % AZURE_CPU_STEP_MILLICORES != 0
|| !(AZURE_CPU_STEP_MILLICORES..=AZURE_MAX_CPU_MILLICORES).contains(&cpu_millicores)
{
return Err(refused(
"cpu",
&limits.cpu,
"Azure allocates cpu in steps of 250m from 250m to 16000m",
));
}
let memory_ceiling_mib = cpu_millicores * AZURE_MEMORY_MIB_PER_CORE / 1000;
let disk_ceiling_mib = cpu_millicores * AZURE_DISK_MIB_PER_CORE / 1000;
let memory_mib = quantity_mib(&limits.memory)
.ok_or_else(|| refused("memory", &limits.memory, "Azure sizes memory in whole MiB"))?;
if memory_mib > memory_ceiling_mib {
return Err(refused(
"memory",
&limits.memory,
&format!(
"Azure allows at most 2Gi of memory per core, or {memory_ceiling_mib}Mi \
at the declared cpu"
),
));
}
let disk_mib = quantity_mib(&limits.disk)
.ok_or_else(|| refused("disk", &limits.disk, "Azure sizes disk in whole MiB"))?;
if disk_mib > disk_ceiling_mib {
return Err(refused(
"disk",
&limits.disk,
&format!(
"Azure allows at most 20Gi of disk per core, or {disk_ceiling_mib}Mi at \
the declared cpu"
),
));
}
Ok(())
}
pub fn microvm_tier(&self) -> Result<MicrovmTier> {
let Some(limits) = self.limits.as_ref() else {
return Ok(MICROVM_TIERS[2]);
};
let memory_mib = quantity_mib(&limits.memory).ok_or_else(|| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "memory".to_string(),
value: limits.memory.clone(),
reason: "AWS sizes a MicroVM in whole MiB".to_string(),
})
})?;
let disk_mib = quantity_mib(&limits.disk).ok_or_else(|| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "disk".to_string(),
value: limits.disk.clone(),
reason: "AWS sizes a MicroVM's disk in whole MiB".to_string(),
})
})?;
let cpu_millicores = millicores(&limits.cpu).ok_or_else(|| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "cpu".to_string(),
value: limits.cpu.clone(),
reason: "expected cores or millicores".to_string(),
})
})?;
let sized = |tier: &&MicrovmTier| {
tier.peak_memory_mib <= memory_mib && tier.max_disk_mib <= disk_mib
};
let tier = MICROVM_TIERS
.iter()
.rev()
.find(sized)
.copied()
.ok_or_else(|| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "memory".to_string(),
value: limits.memory.clone(),
reason: format!(
"a Lambda MicroVM bursts to four times its baseline, so the smallest \
ceiling AWS can hold is 2Gi memory with 8Gi disk; '{}' memory and '{}' \
disk fit no size",
limits.memory, limits.disk
),
})
})?;
let required_millicores = i64::from(tier.peak_vcpu) * 1000;
if cpu_millicores < required_millicores {
return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "cpu".to_string(),
value: limits.cpu.clone(),
reason: format!(
"AWS allocates one vCPU per 2GB, so a MicroVM sized to a '{}' memory ceiling \
reaches {} vCPU; declare cpu '{}' or lower the memory ceiling",
limits.memory, tier.peak_vcpu, tier.peak_vcpu
),
}));
}
Ok(tier)
}
fn validate_capabilities(
&self,
capabilities: &SandboxCapabilities,
platform: Platform,
) -> Result<()> {
if matches!(self.egress, SandboxEgress::AllowDomains { .. }) {
capabilities.require(SandboxCapability::DomainEgressRules, platform)?;
}
if let SandboxEgress::AllowDomains { domains } = &self.egress {
if domains.is_empty() {
return Err(AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: self.id.clone(),
field: "egress.domains".to_string(),
value: "[]".to_string(),
reason: "an allowlist naming no domain denies everything; declare \
egress: deny if that is what was meant"
.to_string(),
}));
}
}
if matches!(self.egress, SandboxEgress::Deny) {
capabilities.require(SandboxCapability::EgressDeny, platform)?;
}
if !self.preview_ports.is_empty() {
capabilities.require(SandboxCapability::Preview, platform)?;
}
if self.lifecycle.idle_pause_seconds.is_some() {
capabilities.require(SandboxCapability::PauseResume, platform)?;
}
if self.lifecycle.max_lifetime_seconds.is_some() {
capabilities.require(SandboxCapability::SandboxLifetime, platform)?;
}
Ok(())
}
}
fn default_limits() -> SandboxLimits {
SandboxLimits {
cpu: "1".to_string(),
memory: "2Gi".to_string(),
disk: "8Gi".to_string(),
max_processes: None,
}
}
fn validate_quantity(resource_id: &str, field: &str, value: &str) -> Result<()> {
let invalid = |reason: &str| {
AlienError::new(ErrorData::SandboxLimitInvalid {
resource_id: resource_id.to_string(),
field: field.to_string(),
value: value.to_string(),
reason: reason.to_string(),
})
};
let digits_end = value
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(value.len());
let (number, suffix) = value.split_at(digits_end);
let parsed: f64 = number
.parse()
.map_err(|_| invalid("expected a number, optionally followed by a unit suffix"))?;
if parsed <= 0.0 {
return Err(invalid("must be greater than zero"));
}
const SUFFIXES: &[&str] = &["", "m", "k", "M", "G", "T", "Ki", "Mi", "Gi", "Ti"];
if !SUFFIXES.contains(&suffix) {
return Err(invalid(
"unit must be one of m, k, M, G, T, Ki, Mi, Gi, Ti, or absent",
));
}
Ok(())
}
fn split_quantity(value: &str) -> Option<(f64, &str)> {
let trimmed = value.trim();
let digits_end = trimmed
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(trimmed.len());
let (number, suffix) = trimmed.split_at(digits_end);
number.parse().ok().map(|number| (number, suffix))
}
pub fn quantity_mib(value: &str) -> Option<i64> {
let (number, suffix) = split_quantity(value)?;
let bytes = match suffix {
"" => number,
"k" => number * 1e3,
"M" => number * 1e6,
"G" => number * 1e9,
"T" => number * 1e12,
"Ki" => number * 1024.0,
"Mi" => number * 1024.0 * 1024.0,
"Gi" => number * 1024.0 * 1024.0 * 1024.0,
"Ti" => number * 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => return None,
};
Some((bytes / (1024.0 * 1024.0)) as i64)
}
pub fn millicores(value: &str) -> Option<i64> {
let (number, suffix) = split_quantity(value)?;
match suffix {
"" => Some((number * 1000.0) as i64),
"m" => Some(number as i64),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "camelCase")]
pub struct SandboxOutputs {
pub parent_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
}
impl ResourceOutputsDefinition for SandboxOutputs {
fn get_resource_type(&self) -> ResourceType {
Sandbox::RESOURCE_TYPE
}
fn as_any(&self) -> &dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceOutputsDefinition> {
Box::new(self.clone())
}
fn outputs_eq(&self, other: &dyn ResourceOutputsDefinition) -> bool {
other.as_any().downcast_ref::<SandboxOutputs>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
impl ResourceDefinition for Sandbox {
fn get_resource_type(&self) -> ResourceType {
Self::RESOURCE_TYPE
}
fn id(&self) -> &str {
&self.id
}
fn get_dependencies(&self) -> Vec<ResourceRef> {
Vec::new()
}
fn validate_update(&self, new_config: &dyn ResourceDefinition) -> Result<()> {
let new_sandbox = new_config
.as_any()
.downcast_ref::<Sandbox>()
.ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResourceType {
resource_id: self.id.clone(),
expected: Self::RESOURCE_TYPE,
actual: new_config.get_resource_type(),
})
})?;
if self.id != new_sandbox.id {
return Err(AlienError::new(ErrorData::InvalidResourceUpdate {
resource_id: self.id.clone(),
reason: "the 'id' field is immutable".to_string(),
}));
}
Ok(())
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn box_clone(&self) -> Box<dyn ResourceDefinition> {
Box::new(self.clone())
}
fn resource_eq(&self, other: &dyn ResourceDefinition) -> bool {
other.as_any().downcast_ref::<Sandbox>() == Some(self)
}
fn to_json_value(&self) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(self)
}
}
pub const BUNDLE_REGION_TOKEN: &str = "{region}";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleUri<'a> {
Literal(&'a str),
Regional { before: &'a str, after: &'a str },
}
pub fn stable_bundle_key_prefix(key: &str) -> Option<&str> {
let (above_file, _) = key.rsplit_once('/')?;
let (above_version, _) = above_file.rsplit_once('/')?;
Some(above_version)
}
pub fn parse_bundle_uri(uri: &str) -> std::result::Result<BundleUri<'_>, String> {
let path = uri
.strip_prefix("s3://")
.ok_or_else(|| format!("'{uri}' is not an s3:// URI"))?;
let (bucket, key) = path
.split_once('/')
.ok_or_else(|| format!("'{uri}' names a bucket with no object key"))?;
if path.contains('*') || path.contains('?') {
return Err(format!(
"'{uri}' carries an IAM wildcard; the bundle's path is interpolated into the build \
role's grant, so '*' and '?' would widen it past the bundle"
));
}
if key.contains('{') || key.contains('}') {
return Err(format!(
"'{uri}' places a token in the object key; {BUNDLE_REGION_TOKEN} is accepted in the \
bucket name alone"
));
}
let Some((before, after)) = bucket.split_once(BUNDLE_REGION_TOKEN) else {
if bucket.contains('{') || bucket.contains('}') {
return Err(format!(
"'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the \
only one"
));
}
return Ok(BundleUri::Literal(uri));
};
if after.contains(BUNDLE_REGION_TOKEN) {
return Err(format!("'{uri}' repeats {BUNDLE_REGION_TOKEN}"));
}
if before.contains('{') || before.contains('}') || after.contains('{') || after.contains('}') {
return Err(format!(
"'{uri}' carries a token this build does not know; {BUNDLE_REGION_TOKEN} is the only one"
));
}
Ok(BundleUri::Regional {
before: &uri[.."s3://".len() + before.len()],
after: &uri["s3://".len() + before.len() + BUNDLE_REGION_TOKEN.len()..],
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EcrImageRegion<'a> {
Literal(&'a str),
Deployment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EcrImageRepository<'a> {
pub account_id: &'a str,
pub region: EcrImageRegion<'a>,
pub repository: &'a str,
}
impl EcrImageRepository<'_> {
pub fn arn(&self, partition: &str, region: &str) -> String {
let region = match self.region {
EcrImageRegion::Literal(region) => region,
EcrImageRegion::Deployment => region,
};
format!(
"arn:{partition}:ecr:{region}:{}:repository/{}",
self.account_id, self.repository
)
}
}
pub fn parse_ecr_image_repository(
image: &str,
) -> std::result::Result<EcrImageRepository<'_>, String> {
let refuse = |reason: &str| format!("privateBaseImage '{image}' {reason}");
let (host, path) = image
.split_once('/')
.ok_or_else(|| refuse("names no repository"))?;
let (account_id, rest) = host.split_once(".dkr.ecr.").ok_or_else(|| {
refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)")
})?;
let region = rest
.strip_suffix(".amazonaws.com.cn")
.or_else(|| rest.strip_suffix(".amazonaws.com"))
.ok_or_else(|| refuse("is not served by a private ECR registry (<account>.dkr.ecr.<region>.amazonaws.com)"))?;
if account_id.len() != 12 || !account_id.bytes().all(|b| b.is_ascii_digit()) {
return Err(refuse("names no 12-digit account in its registry host"));
}
let region = if region == BUNDLE_REGION_TOKEN {
EcrImageRegion::Deployment
} else if !region.is_empty()
&& region
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
EcrImageRegion::Literal(region)
} else {
return Err(refuse(&format!(
"names no region in its registry host; give one or {BUNDLE_REGION_TOKEN}"
)));
};
let repository = match path.split_once('@') {
Some((repository, _digest)) => repository,
None => match path.rsplit_once('/') {
Some((parent, last)) => match last.split_once(':') {
Some((name, _tag)) => &path[..parent.len() + 1 + name.len()],
None => path,
},
None => path.split_once(':').map_or(path, |(name, _tag)| name),
},
};
let valid_segment = |segment: &str| {
!segment.is_empty()
&& segment.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
})
};
if !repository.split('/').all(valid_segment) {
return Err(refuse(
"names a repository outside ECR's grammar (lowercase letters, digits, '.', '_', '-', and '/' between them)",
));
}
Ok(EcrImageRepository {
account_id,
region,
repository,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_database_setup_accepts_the_default_network() {
for lifecycle in [
crate::ResourceLifecycle::Frozen,
crate::ResourceLifecycle::Live,
] {
let stack = crate::Stack::new("database".to_string())
.add(
crate::Postgres::new("metadata".to_string()).build(),
lifecycle,
)
.build();
assert!(!restricts_network_mode(&stack, false));
assert!(!restricts_network_mode(&stack, true));
}
assert!(!restricts_network_mode(
&crate::Stack::new("empty".to_string()).build(),
false,
));
}
#[test]
fn a_private_base_image_names_one_repository() {
let deployment = EcrImageRegion::Deployment;
let literal = EcrImageRegion::Literal;
let accepted = [
(
"123456789012.dkr.ecr.us-east-1.amazonaws.com/base:1.0",
literal("us-east-1"),
"base",
),
(
"123456789012.dkr.ecr.us-east-1.amazonaws.com/team/agents/base:1.0",
literal("us-east-1"),
"team/agents/base",
),
(
"123456789012.dkr.ecr.{region}.amazonaws.com/team/base@sha256:abc123",
deployment,
"team/base",
),
(
"123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn/base",
literal("cn-north-1"),
"base",
),
(
"123456789012.dkr.ecr.us-gov-west-1.amazonaws.com/my.base_image-x",
literal("us-gov-west-1"),
"my.base_image-x",
),
];
for (image, region, repository) in accepted {
assert_eq!(
parse_ecr_image_repository(image),
Ok(EcrImageRepository {
account_id: "123456789012",
region,
repository,
}),
"{image}"
);
}
for refused in [
"public.ecr.aws/docker/library/alpine:3.20",
"docker.io/library/alpine:3.20",
"https://123456789012.dkr.ecr.us-east-1.amazonaws.com/base:1.0",
"123456789012.dkr.ecr.us-east-1.amazonaws.com",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/",
"12345.dkr.ecr.us-east-1.amazonaws.com/base",
"123456789012.dkr.ecr..amazonaws.com/base",
"123456789012.dkr.ecr.{account}.amazonaws.com/base",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/*",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/ba?e",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/${AWS::AccountId}",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/{region}/base",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/Base:1.0",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/team//base",
] {
assert!(
parse_ecr_image_repository(refused).is_err(),
"{refused} must be refused"
);
}
}
#[test]
fn a_repository_arn_takes_the_deployment_region_only_where_the_host_leaves_it() {
let regional =
parse_ecr_image_repository("123456789012.dkr.ecr.{region}.amazonaws.com/team/base:1")
.expect("parses");
let pinned =
parse_ecr_image_repository("123456789012.dkr.ecr.eu-west-1.amazonaws.com/team/base:1")
.expect("parses");
assert_eq!(
regional.arn("aws-us-gov", "us-gov-west-1"),
"arn:aws-us-gov:ecr:us-gov-west-1:123456789012:repository/team/base"
);
assert_eq!(
pinned.arn("aws", "us-east-1"),
"arn:aws:ecr:eu-west-1:123456789012:repository/team/base"
);
}
#[test]
fn a_uri_carrying_an_iam_wildcard_is_refused() {
for uri in [
"s3://acme/team-*/v1/bundle.zip",
"s3://acme/sandbox-bundle/f00d/bundle?.zip",
"s3://acme-*/sandbox-bundle/f00d/bundle.zip",
] {
let error = parse_bundle_uri(uri).expect_err("a wildcard must be refused");
assert!(error.contains("IAM wildcard"), "for {uri}: {error}");
}
parse_bundle_uri("s3://acme/sandbox-bundle/f00d/bundle.zip")
.expect("an ordinary key still parses");
}
#[test]
fn a_grantable_prefix_stops_above_the_segment_that_moves() {
assert_eq!(
stable_bundle_key_prefix("sandbox-bundle/f00dcafe/bundle.zip"),
Some("sandbox-bundle")
);
assert_eq!(
stable_bundle_key_prefix("artifacts/team-a/sandbox/f00dcafe/bundle.zip"),
Some("artifacts/team-a/sandbox"),
"a deeper key narrows the prefix, it never widens to the first segment"
);
assert_eq!(stable_bundle_key_prefix("agents/bundle.zip"), None);
assert_eq!(stable_bundle_key_prefix("bundle.zip"), None);
}
fn sandbox_with(egress: SandboxEgress, preview_ports: Vec<u16>) -> Sandbox {
Sandbox::new("agent-sbx".to_string())
.code(SandboxCode::Image {
image: "ubuntu".to_string(),
})
.limits(SandboxLimits {
cpu: "1".to_string(),
memory: "2Gi".to_string(),
disk: "20Gi".to_string(),
max_processes: None,
})
.egress(egress)
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.preview_ports(preview_ports)
.build()
}
#[test]
fn a_uri_without_a_token_is_carried_whole() {
assert_eq!(
parse_bundle_uri("s3://acme-artifacts-us-east-2/agents/bundle.zip"),
Ok(BundleUri::Literal(
"s3://acme-artifacts-us-east-2/agents/bundle.zip"
))
);
}
#[test]
fn a_regional_uri_splits_either_side_of_the_token() {
let BundleUri::Regional { before, after } =
parse_bundle_uri("s3://acme-artifacts-{region}/agents/bundle.zip")
.expect("the token is accepted in the bucket")
else {
panic!("a bucket-position token must split");
};
assert_eq!(before, "s3://acme-artifacts-");
assert_eq!(after, "/agents/bundle.zip");
assert_eq!(
format!("{before}us-east-2{after}"),
"s3://acme-artifacts-us-east-2/agents/bundle.zip",
"the halves must rejoin to the URI the vendor meant"
);
}
#[test]
fn a_token_this_build_cannot_resolve_is_refused() {
for uri in [
"s3://acme-artifacts-{regio}/bundle.zip",
"s3://acme-artifacts/{region}/bundle.zip",
"s3://acme-artifacts-{region}-{region}/bundle.zip",
"s3://acme-artifacts/bundle-{version}.zip",
"s3://acme}-artifacts-{region}/bundle.zip",
"s3://acme{-artifacts-{region}/bundle.zip",
] {
assert!(
parse_bundle_uri(uri).is_err(),
"'{uri}' must be refused before it can reach an image build"
);
}
}
#[test]
fn resource_type_is_stable() {
assert_eq!(Sandbox::RESOURCE_TYPE.as_ref(), "sandbox");
}
#[test]
fn capability_sets_are_per_platform() {
let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
assert!(
gcp.reconnect,
"generation from the container boot id makes a sandbox reachable across processes"
);
assert!(!gcp.preview);
assert!(gcp.enforced_limits);
let azure = SandboxCapabilities::for_platform(Platform::Azure).expect("azure is supported");
assert!(azure.files, "every backend moves files");
assert!(gcp.files);
assert!(azure.domain_egress_rules);
assert!(azure.egress_deny);
assert!(azure.enforced_limits);
assert!(azure.pause_resume);
assert!(!azure.snapshot);
assert!(!azure.preview);
let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
assert!(!aws.snapshot, "AWS has no user-callable sandbox snapshot");
assert!(aws.pause_resume);
let k8s =
SandboxCapabilities::for_platform(Platform::Kubernetes).expect("k8s is supported");
assert!(
!k8s.preview,
"the sandbox-scoped ingress gateway does not exist yet"
);
}
#[test]
fn supervisor_isolation_is_per_platform() {
let value = |platform| {
SandboxCapabilities::for_platform(platform)
.expect("supported")
.supervisor_isolation
};
assert!(
value(Platform::Aws),
"root agent setuids the command to 60000"
);
assert!(
value(Platform::Local),
"the supervisor is on the host, outside the container"
);
assert!(
!value(Platform::Kubernetes),
"a single pinned uid cannot be split"
);
assert!(!value(Platform::Azure), "no Alien process runs the command");
assert!(
!value(Platform::Gcp),
"no separate supervisor identity runs the command"
);
}
#[test]
fn supervisor_isolation_separates_aws_from_a_subprocess_backend() {
let aws = SandboxCapabilities::for_platform(Platform::Aws).expect("aws is supported");
let gcp = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
assert_eq!(
aws.supervisor_pid_namespace, gcp.supervisor_pid_namespace,
"the older axis cannot tell them apart"
);
assert!(
aws.supervisor_isolation,
"AWS setuids the command off the supervisor"
);
assert!(
!gcp.supervisor_isolation,
"the command runs under no separate supervisor identity"
);
}
#[test]
fn gcp_agent_platform_row_matches_measured_backend() {
let row = SandboxCapabilities::gcp_agent_platform();
assert!(row.files, "agent file ops move over the sandbox envelope");
assert!(
row.reconnect,
"generation is derived from the container boot id, so a sandbox is reachable across \
processes"
);
assert!(
!row.preview,
"the only ingress is :execute; no port-scoped capability"
);
assert!(
row.pause_resume,
":pause and :resume preserve the container"
);
assert!(
!row.snapshot,
"the create path never sends a snapshot, so none is reachable through the trait"
);
assert!(
!row.domain_egress_rules,
"VPC and DNS peering is not a hostname allowlist"
);
assert!(
row.egress_deny,
"a declared deny blocks both egress and DNS"
);
assert!(
row.enforced_limits,
"ceilings are enforced, by terminating the sandbox on breach"
);
assert!(!row.process_limit, "no process-count ceiling is observed");
assert!(row.sandbox_lifetime, "ttl maps to a sandbox expireTime");
assert!(!row.supervisor_pid_namespace, "no PID-namespace isolation");
assert!(
!row.supervisor_isolation,
"the command is not run under a separate supervisor identity"
);
let live = SandboxCapabilities::for_platform(Platform::Gcp).expect("gcp is supported");
assert_eq!(
live, row,
"the Platform::Gcp arm is the Agent Platform capability row"
);
}
#[test]
fn platforms_without_a_backend_are_an_error_not_an_empty_set() {
let error = SandboxCapabilities::for_platform(Platform::Machines)
.expect_err("Machines has no sandbox backend");
assert_eq!(error.code, "SANDBOX_PLATFORM_UNSUPPORTED");
}
#[test]
fn unsupported_capability_names_platform_and_capability() {
let capabilities = SandboxCapabilities::for_platform(Platform::Gcp).expect("supported");
let error = capabilities
.require(SandboxCapability::Preview, Platform::Gcp)
.expect_err("GCP has no preview");
assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
let rendered = error.to_string();
assert!(
rendered.contains("preview"),
"names the capability: {rendered}"
);
assert!(rendered.contains("gcp"), "names the platform: {rendered}");
}
#[test]
fn a_hostname_allowlist_is_refused_everywhere_it_would_be_approximated() {
let sandbox = sandbox_with(
SandboxEgress::AllowDomains {
domains: vec!["example.com".to_string()],
},
vec![],
);
for platform in [
Platform::Aws,
Platform::Gcp,
Platform::Kubernetes,
Platform::Local,
] {
let error = sandbox
.validate_for_platform(platform)
.expect_err("only Azure expresses a hostname allowlist");
assert_eq!(
error.code, "SANDBOX_CAPABILITY_UNSUPPORTED",
"on {platform:?}"
);
}
assert!(
SandboxCapabilities::for_platform(Platform::Azure)
.expect("supported")
.domain_egress_rules,
"Azure's egress policy matches on host pattern"
);
}
#[test]
fn a_denied_egress_is_refused_where_it_would_not_be_enforced() {
let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
assert!(
SandboxCapabilities::for_platform(Platform::Gcp)
.expect("supported")
.egress_deny
);
for platform in [Platform::Aws, Platform::Kubernetes, Platform::Local] {
sandbox
.validate_for_platform(platform)
.expect("deny is enforced here");
}
let egress_only = Sandbox::new("sbx".to_string())
.code(SandboxCode::Image {
image: "alpine".to_string(),
})
.egress(SandboxEgress::Deny)
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build();
egress_only
.validate_for_platform(Platform::Azure)
.expect("Azure creates the sandbox under a Deny policy with full inspection");
}
#[test]
fn a_sandbox_declaring_no_ceilings_takes_the_platforms_own() {
let undeclared = Sandbox::new("sbx".to_string())
.code(SandboxCode::Image {
image: "alpine".to_string(),
})
.egress(SandboxEgress::Deny)
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build();
undeclared
.validate_for_platform(Platform::Azure)
.expect("a sandbox naming no ceilings takes the platform's own");
assert_eq!(undeclared.resolved_limits().cpu, "1");
}
#[test]
fn azure_sizes_follow_the_rule_the_data_plane_states() {
let sized = |cpu: &str, memory: &str, disk: &str| {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
let limits = sandbox
.limits
.as_mut()
.expect("the fixture declares limits");
limits.cpu = cpu.to_string();
limits.memory = memory.to_string();
limits.disk = disk.to_string();
sandbox.validate_for_platform(Platform::Azure)
};
sized("250m", "512Mi", "5120Mi").expect("the smallest step the data plane accepts");
sized("4000m", "8192Mi", "40960Mi").expect("cpu, memory and disk are all honoured");
sized("16000m", "32Gi", "320Gi").expect("the top of the range");
let off_step = sized("333m", "512Mi", "5120Mi").expect_err("333m is not a step of 250m");
assert_eq!(off_step.code, "SANDBOX_LIMIT_INVALID", "{off_step}");
assert!(off_step.to_string().contains("cpu"), "{off_step}");
let too_big = sized("32000m", "64Gi", "640Gi").expect_err("32 cores is over the ceiling");
assert_eq!(too_big.code, "SANDBOX_LIMIT_INVALID", "{too_big}");
sized("1000m", "2Gi", "20Gi").expect("2Gi is exactly one core's worth");
let over_memory = sized("250m", "2Gi", "5120Mi").expect_err("2Gi needs a full core");
assert_eq!(over_memory.code, "SANDBOX_LIMIT_INVALID", "{over_memory}");
assert!(over_memory.to_string().contains("memory"), "{over_memory}");
let over_disk = sized("250m", "512Mi", "20Gi").expect_err("20Gi needs a full core");
assert!(over_disk.to_string().contains("disk"), "{over_disk}");
}
#[test]
fn preview_ports_require_the_preview_capability() {
let sandbox = sandbox_with(SandboxEgress::Deny, vec![8080]);
sandbox
.validate_for_platform(Platform::Aws)
.expect("AWS mints a port-scoped JWE");
let error = sandbox
.validate_for_platform(Platform::Kubernetes)
.expect_err("Kubernetes preview is deferred");
assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
}
#[test]
fn a_private_base_image_is_refused_off_aws() {
let mut sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
sandbox.code = SandboxCode::Image {
image: "s3://acme-artifacts/agents/bundle.zip".to_string(),
};
sandbox.private_base_image =
Some("123456789012.dkr.ecr.{region}.amazonaws.com/acme:tag".to_string());
sandbox
.validate_for_platform(Platform::Aws)
.expect("AWS builds its image from a bundle, so a base image sits behind code.image");
for platform in [
Platform::Gcp,
Platform::Azure,
Platform::Kubernetes,
Platform::Local,
] {
let error = sandbox
.validate_for_platform(platform)
.expect_err("a backend that builds no image must refuse a base image for one");
assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
assert!(
error.to_string().contains("privateBaseImage"),
"the refusal must name the field the user declared: {error}"
);
}
}
#[test]
fn gcp_accepts_a_sandbox_declaring_enforced_limits() {
let sandbox = sandbox_with(SandboxEgress::Allow, vec![]);
sandbox
.validate_for_platform(Platform::Gcp)
.expect("Agent Platform enforces declared ceilings, by terminating on breach");
}
#[test]
fn invalid_quantities_are_rejected_with_the_offending_field() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.memory = "2Gb".to_string();
let error = sandbox
.validate_for_platform(Platform::Aws)
.expect_err("Gb is not a valid suffix");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
assert!(error.to_string().contains("memory"));
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.memory = "2Gi".to_string();
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.cpu = "0".to_string();
let error = sandbox
.validate_for_platform(Platform::Aws)
.expect_err("zero cpu is not a ceiling");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
}
#[test]
fn zero_max_processes_is_rejected() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.max_processes = Some(0);
let error = sandbox
.validate_for_platform(Platform::Local)
.expect_err("a sandbox must be able to run at least one process");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
assert!(error.to_string().contains("maxProcesses"));
}
#[test]
fn a_process_ceiling_is_accepted_only_where_a_runtime_can_apply_it() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.max_processes = Some(256);
sandbox
.validate_for_platform(Platform::Local)
.expect("Docker takes a pids limit");
for platform in [Platform::Aws, Platform::Azure, Platform::Kubernetes] {
let error = sandbox
.validate_for_platform(platform)
.expect_err("a process ceiling nothing applies must be refused");
assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
}
}
#[test]
fn a_lifetime_aws_would_reject_is_refused_while_planning() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
for seconds in [0, 28_801, 100_000] {
sandbox.lifecycle.max_lifetime_seconds = Some(seconds);
let error = sandbox
.validate_for_platform(Platform::Aws)
.expect_err("a lifetime outside what AWS runs is refused");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "{seconds}s");
sandbox
.validate_for_platform(Platform::Kubernetes)
.expect("the kubelet takes any activeDeadlineSeconds");
}
sandbox.lifecycle.max_lifetime_seconds = Some(28_800);
sandbox
.validate_for_platform(Platform::Aws)
.expect("the ceiling itself is allowed");
}
#[test]
fn an_image_azure_cannot_pull_is_refused_while_planning() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
sandbox.limits = None;
for image in [
"ubuntu:24.04",
"ghcr.io/myorg/sandbox:latest",
"ubuntu@sha256:abc",
"",
" ",
"ubuntu latest",
"ubuntu?x",
] {
sandbox.code = SandboxCode::Image {
image: image.to_string(),
};
let error = sandbox
.validate_for_platform(Platform::Azure)
.expect_err("an image Azure has nowhere to put is refused");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID", "image '{image}'");
sandbox
.validate_for_platform(Platform::Kubernetes)
.expect("a registry reference is what every other backend takes");
}
for image in ["ubuntu", "ubuntu-22.04", "debian_slim"] {
sandbox.code = SandboxCode::Image {
image: image.to_string(),
};
sandbox
.validate_for_platform(Platform::Azure)
.unwrap_or_else(|error| panic!("'{image}' is a catalog name: {error}"));
}
sandbox.code = SandboxCode::Image {
image: " ubuntu ".to_string(),
};
assert_eq!(
sandbox
.azure_catalog_image()
.expect("a padded name is still a name"),
"ubuntu"
);
}
#[test]
fn a_sandbox_deadline_is_accepted_only_where_the_platform_applies_it() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
sandbox.lifecycle.max_lifetime_seconds = Some(3600);
sandbox
.validate_for_platform(Platform::Kubernetes)
.expect("the kubelet enforces activeDeadlineSeconds");
sandbox
.validate_for_platform(Platform::Aws)
.expect("Lambda terminates the MicroVM at maximumDurationInSeconds");
for platform in [Platform::Azure, Platform::Local] {
let error = sandbox
.validate_for_platform(platform)
.expect_err("a deadline nothing applies must be refused");
assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
}
}
#[test]
fn an_aws_size_is_chosen_so_its_peak_stays_inside_the_declared_ceiling() {
let sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
let tier = sandbox
.microvm_tier()
.expect("2Gi/1cpu/20Gi is satisfiable");
assert_eq!(
tier.peak_memory_mib, 2048,
"the peak is the declared ceiling"
);
assert_eq!(
tier.baseline_memory_mib, 512,
"which is a quarter of it as the baseline"
);
assert!(tier.max_disk_mib <= 20 * 1024);
}
#[test]
fn a_cpu_ceiling_below_what_the_memory_implies_is_refused_not_quietly_downsized() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
{
let limits = sandbox
.limits
.as_mut()
.expect("the fixture declares limits");
limits.cpu = "1".to_string();
limits.memory = "8Gi".to_string();
}
let error = sandbox
.microvm_tier()
.expect_err("1 cpu and 8Gi cannot both be ceilings on AWS");
assert!(
error.to_string().contains("4 vCPU"),
"the refusal must say what the memory ceiling implies: {error}"
);
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.cpu = "4".to_string();
let tier = sandbox.microvm_tier().expect("4 cpu matches 8Gi");
assert_eq!(tier.peak_memory_mib, 8192);
}
#[test]
fn an_aws_ceiling_smaller_than_any_size_is_refused_rather_than_rounded() {
let mut sandbox = sandbox_with(SandboxEgress::Deny, vec![]);
sandbox
.limits
.as_mut()
.expect("the fixture declares limits")
.memory = "1Gi".to_string();
let error = sandbox
.validate_for_platform(Platform::Aws)
.expect_err("no MicroVM size peaks at or below 1Gi");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
assert!(
error.to_string().contains("2Gi"),
"the refusal must say what the smallest holdable ceiling is: {error}"
);
}
#[test]
fn source_code_is_refused_off_aws_rather_than_producing_a_broken_manifest() {
let sandbox = Sandbox::new("agent".to_string())
.code(SandboxCode::Source {
src: "./sandbox".to_string(),
toolchain: ToolchainConfig::Docker {
dockerfile: None,
build_args: None,
target: None,
},
})
.egress(SandboxEgress::Deny)
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build();
sandbox
.validate_for_platform(Platform::Aws)
.expect("an AWS sandbox base image is built by `alien build`");
for platform in [
Platform::Azure,
Platform::Gcp,
Platform::Kubernetes,
Platform::Local,
] {
let error = sandbox
.validate_for_platform(platform)
.expect_err("no backend builds a sandbox image from source here");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
assert!(
error.to_string().contains("code.image"),
"the refusal must say what to write instead: {error}"
);
assert!(
error.to_string().contains(&platform.to_string()),
"the refusal must name the platform that cannot build it: {error}"
);
}
}
#[test]
fn every_accepted_unit_converts_rather_than_falling_back() {
assert_eq!(quantity_mib("2Gi"), Some(2048));
assert_eq!(quantity_mib("512Mi"), Some(512));
assert_eq!(quantity_mib("4G"), Some(3814));
assert_eq!(quantity_mib("1Ti"), Some(1024 * 1024));
assert_eq!(millicores("1"), Some(1000));
assert_eq!(millicores("500m"), Some(500));
}
#[test]
fn unknown_fields_are_rejected() {
let json = r#"{
"id": "sbx",
"code": {"type": "image", "image": "ubuntu:24.04"},
"limits": {"cpu": "1", "memory": "2Gi", "disk": "20Gi"},
"egress": {"mode": "deny"},
"lifecycle": {},
"unexpected": true
}"#;
serde_json::from_str::<Sandbox>(json).expect_err("deny_unknown_fields must reject");
}
#[test]
fn serialization_roundtrips() {
let sandbox = sandbox_with(
SandboxEgress::AllowDomains {
domains: vec!["example.com".to_string()],
},
vec![8080, 9090],
);
let json = serde_json::to_string(&sandbox).expect("serializes");
let restored: Sandbox = serde_json::from_str(&json).expect("deserializes");
assert_eq!(sandbox, restored);
}
#[test]
fn id_is_immutable_across_updates() {
let original = sandbox_with(SandboxEgress::Deny, vec![]);
let renamed = Sandbox::new("other".to_string())
.code(SandboxCode::Image {
image: "ubuntu".to_string(),
})
.limits(
original
.limits
.clone()
.expect("the fixture declares limits"),
)
.egress(SandboxEgress::Deny)
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build();
original
.validate_update(&original.clone())
.expect("an unchanged config is a valid update");
original
.validate_update(&renamed)
.expect_err("renaming a sandbox is not an update");
}
#[test]
fn azure_takes_an_idle_policy_and_still_refuses_a_lifetime_ceiling() {
let with_policy = |lifecycle: SandboxLifecyclePolicy| {
Sandbox::new("sbx".to_string())
.code(SandboxCode::Image {
image: "ubuntu".to_string(),
})
.egress(SandboxEgress::Allow)
.lifecycle(lifecycle)
.build()
.validate_for_platform(Platform::Azure)
};
with_policy(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: Some(900),
})
.expect("Azure pauses a sandbox on idle");
let error = with_policy(SandboxLifecyclePolicy {
max_lifetime_seconds: Some(3600),
idle_pause_seconds: None,
})
.expect_err("Azure has no wall-clock ceiling to enforce one with");
assert_eq!(error.code, "SANDBOX_CAPABILITY_UNSUPPORTED");
assert!(
error.message.contains("sandboxLifetime"),
"names the capability: {}",
error.message
);
}
#[test]
fn an_allowlist_with_no_domains_is_refused() {
let declared = |domains: Vec<String>| {
Sandbox::new("sbx".to_string())
.code(SandboxCode::Image {
image: "ubuntu".to_string(),
})
.egress(SandboxEgress::AllowDomains { domains })
.lifecycle(SandboxLifecyclePolicy {
max_lifetime_seconds: None,
idle_pause_seconds: None,
})
.build()
.validate_for_platform(Platform::Azure)
};
let error = declared(vec![]).expect_err("an empty allowlist must be refused");
assert_eq!(error.code, "SANDBOX_LIMIT_INVALID");
declared(vec!["api.example.com".to_string()])
.expect("a named domain is what an allowlist is for");
}
#[test]
fn internet_access_switch_maps_only_the_two_expressible_modes() {
assert_eq!(SandboxEgress::Allow.internet_access_switch(), Some(true));
assert_eq!(SandboxEgress::Deny.internet_access_switch(), Some(false));
assert_eq!(
SandboxEgress::AllowDomains {
domains: vec!["api.example.com".to_string()]
}
.internet_access_switch(),
None,
"a host list has no boolean and must not be approximated"
);
}
}