1use super::BindingValue;
8use crate::SandboxEgress;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "service")]
18pub enum SandboxBinding {
19 #[serde(rename = "sandbox-aws")]
21 Aws(AwsSandboxBinding),
22 #[serde(rename = "sandbox-azure")]
24 Azure(AzureSandboxBinding),
25 #[serde(rename = "sandbox-gcp-agent-platform")]
27 GcpAgentPlatform(GcpAgentPlatformSandboxBinding),
28 #[serde(rename = "sandbox-kubernetes")]
30 Kubernetes(KubernetesSandboxBinding),
31 #[serde(rename = "sandbox-local")]
33 Local(LocalSandboxBinding),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct AwsSandboxBinding {
40 pub image_arn: BindingValue<String>,
42 pub image_version: BindingValue<String>,
45 pub region: BindingValue<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub execution_role_arn: Option<BindingValue<String>>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub egress_connector_arns: Vec<BindingValue<String>>,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub preview_ports: Vec<u16>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub idle_suspend_seconds: Option<u32>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub max_lifetime_seconds: Option<u32>,
73 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
79 pub allow_egress: bool,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct AzureSandboxBinding {
86 pub sandbox_group: BindingValue<String>,
88 pub data_plane_endpoint: BindingValue<String>,
90 pub region: BindingValue<String>,
92 pub resource_group: BindingValue<String>,
95 pub egress: SandboxEgress,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub idle_suspend_seconds: Option<u32>,
107 pub disk_image: BindingValue<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct GcpAgentPlatformSandboxBinding {
122 pub engine: BindingValue<String>,
125 pub template: BindingValue<String>,
128 pub region: BindingValue<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub session_ttl_seconds: Option<u32>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct KubernetesSandboxBinding {
141 pub namespace: BindingValue<String>,
143 pub runtime_class: BindingValue<String>,
145 pub selector: BindingValue<String>,
147 pub broker_url: BindingValue<String>,
150 pub key_name: BindingValue<String>,
153 pub token_path: BindingValue<String>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct LocalSandboxBinding {
162 pub manager_url: BindingValue<String>,
164 pub sandbox_key: BindingValue<String>,
166 pub token_path: BindingValue<String>,
169}
170
171impl SandboxBinding {
172 pub fn aws(
174 image_arn: impl Into<BindingValue<String>>,
175 image_version: impl Into<BindingValue<String>>,
176 region: impl Into<BindingValue<String>>,
177 ) -> Self {
178 Self::Aws(AwsSandboxBinding {
179 image_arn: image_arn.into(),
180 image_version: image_version.into(),
181 region: region.into(),
182 execution_role_arn: None,
183 egress_connector_arns: Vec::new(),
184 preview_ports: Vec::new(),
185 idle_suspend_seconds: None,
186 max_lifetime_seconds: None,
187 allow_egress: false,
188 })
189 }
190
191 pub fn azure(
193 sandbox_group: impl Into<BindingValue<String>>,
194 data_plane_endpoint: impl Into<BindingValue<String>>,
195 region: impl Into<BindingValue<String>>,
196 resource_group: impl Into<BindingValue<String>>,
197 disk_image: impl Into<BindingValue<String>>,
198 egress: SandboxEgress,
199 idle_suspend_seconds: Option<u32>,
200 ) -> Self {
201 Self::Azure(AzureSandboxBinding {
202 sandbox_group: sandbox_group.into(),
203 data_plane_endpoint: data_plane_endpoint.into(),
204 region: region.into(),
205 resource_group: resource_group.into(),
206 egress,
207 idle_suspend_seconds,
208 disk_image: disk_image.into(),
209 })
210 }
211
212 pub fn gcp_agent_platform(
214 engine: impl Into<BindingValue<String>>,
215 template: impl Into<BindingValue<String>>,
216 region: impl Into<BindingValue<String>>,
217 session_ttl_seconds: Option<u32>,
218 ) -> Self {
219 Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
220 engine: engine.into(),
221 template: template.into(),
222 region: region.into(),
223 session_ttl_seconds,
224 })
225 }
226
227 pub fn kubernetes(
229 namespace: impl Into<BindingValue<String>>,
230 runtime_class: impl Into<BindingValue<String>>,
231 selector: impl Into<BindingValue<String>>,
232 broker_url: impl Into<BindingValue<String>>,
233 key_name: impl Into<BindingValue<String>>,
234 token_path: impl Into<BindingValue<String>>,
235 ) -> Self {
236 Self::Kubernetes(KubernetesSandboxBinding {
237 namespace: namespace.into(),
238 runtime_class: runtime_class.into(),
239 selector: selector.into(),
240 broker_url: broker_url.into(),
241 key_name: key_name.into(),
242 token_path: token_path.into(),
243 })
244 }
245
246 pub fn local(
248 manager_url: impl Into<BindingValue<String>>,
249 sandbox_key: impl Into<BindingValue<String>>,
250 token_path: impl Into<BindingValue<String>>,
251 ) -> Self {
252 Self::Local(LocalSandboxBinding {
253 manager_url: manager_url.into(),
254 sandbox_key: sandbox_key.into(),
255 token_path: token_path.into(),
256 })
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::bindings::{ContainerBinding, KvBinding};
264
265 #[test]
266 fn every_variant_roundtrips() {
267 let bindings = vec![
268 SandboxBinding::aws(
269 "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
270 "3",
271 "us-east-2",
272 ),
273 SandboxBinding::azure(
274 "sbg1",
275 "https://management.swedencentral.azuredevcompute.io",
276 "swedencentral",
277 "rg",
278 "ubuntu",
279 SandboxEgress::Deny,
280 None,
281 ),
282 SandboxBinding::gcp_agent_platform(
283 "projects/p/locations/us-central1/reasoningEngines/1",
284 "projects/p/locations/us-central1/sandboxTemplates/agent",
285 "us-central1",
286 Some(3600),
287 ),
288 SandboxBinding::kubernetes(
289 "alien-sandboxes",
290 "gvisor",
291 "alien.dev/sandbox=agent",
292 "http://alien-operator.alien.svc:8080",
293 "alien-sandbox-agent-capability",
294 "/var/run/secrets/kubernetes.io/serviceaccount/token",
295 ),
296 SandboxBinding::local(
297 "http://127.0.0.1:8931",
298 "agent",
299 "/state/sandbox-manager.token",
300 ),
301 ];
302
303 for binding in bindings {
304 let json = serde_json::to_string(&binding).expect("serializes");
305 let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
306 assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
307 }
308 }
309
310 #[test]
315 fn agent_platform_required_fields_have_no_default() {
316 let binding = SandboxBinding::gcp_agent_platform(
317 "projects/p/locations/us-central1/reasoningEngines/1",
318 "projects/p/locations/us-central1/sandboxTemplates/agent",
319 "us-central1",
320 Some(3600),
321 );
322 let full = serde_json::to_value(&binding).expect("serializes");
323
324 for required in ["engine", "template", "region"] {
325 let mut stripped = full.clone();
326 stripped
327 .as_object_mut()
328 .expect("binding serializes as an object")
329 .remove(required)
330 .expect("the field is present before it is stripped");
331 serde_json::from_value::<SandboxBinding>(stripped)
332 .expect_err(&format!("a binding missing '{required}' must not load"));
333 }
334
335 let mut without_ttl = full;
336 without_ttl
337 .as_object_mut()
338 .expect("binding serializes as an object")
339 .remove("sessionTtlSeconds")
340 .expect("the fixture set a ttl");
341 let restored: SandboxBinding =
342 serde_json::from_value(without_ttl).expect("an absent ttl still loads");
343 assert_eq!(
344 restored,
345 SandboxBinding::gcp_agent_platform(
346 "projects/p/locations/us-central1/reasoningEngines/1",
347 "projects/p/locations/us-central1/sandboxTemplates/agent",
348 "us-central1",
349 None,
350 ),
351 "an absent ttl deserializes as None"
352 );
353 }
354
355 #[test]
356 fn service_tags_are_prefixed_and_distinct() {
357 let tags: Vec<String> = vec![
358 SandboxBinding::aws("a", "1", "r"),
359 SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None),
360 SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None),
361 SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"),
362 SandboxBinding::local("u", "k", "t"),
363 ]
364 .iter()
365 .map(|binding| {
366 serde_json::to_value(binding).expect("serializes")["service"]
367 .as_str()
368 .expect("has a service tag")
369 .to_string()
370 })
371 .collect();
372
373 for tag in &tags {
374 assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
375 }
376
377 let mut unique = tags.clone();
378 unique.sort();
379 unique.dedup();
380 assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
381 }
382
383 #[test]
387 fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
388 let json = serde_json::to_string(&SandboxBinding::local(
389 "http://127.0.0.1:8931",
390 "agent",
391 "/state/sandbox-manager.token",
392 ))
393 .expect("serializes");
394
395 serde_json::from_str::<KvBinding>(&json)
396 .expect_err("a sandbox binding must not parse as a KV binding");
397 serde_json::from_str::<ContainerBinding>(&json)
398 .expect_err("a sandbox binding must not parse as a container binding");
399 }
400
401 #[test]
402 fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
403 let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
404 .expect("serializes");
405
406 serde_json::from_str::<SandboxBinding>(&json)
407 .expect_err("a container binding must not parse as a sandbox binding");
408 }
409}