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_pause_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_pause_seconds: Option<u32>,
107 pub disk_image: BindingValue<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub cpu: Option<BindingValue<String>>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub memory: Option<BindingValue<String>>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub disk: Option<BindingValue<String>>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct GcpAgentPlatformSandboxBinding {
129 pub engine: BindingValue<String>,
132 pub template: BindingValue<String>,
135 pub region: BindingValue<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub max_lifetime_seconds: Option<u32>,
142 #[serde(default)]
145 pub allow_egress: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct KubernetesSandboxBinding {
152 pub namespace: BindingValue<String>,
154 pub runtime_class: BindingValue<String>,
156 pub selector: BindingValue<String>,
158 pub broker_url: BindingValue<String>,
161 pub key_name: BindingValue<String>,
164 pub token_path: BindingValue<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct LocalSandboxBinding {
173 pub manager_url: BindingValue<String>,
175 pub sandbox_key: BindingValue<String>,
177 pub token_path: BindingValue<String>,
180}
181
182impl SandboxBinding {
183 pub fn aws(
185 image_arn: impl Into<BindingValue<String>>,
186 image_version: impl Into<BindingValue<String>>,
187 region: impl Into<BindingValue<String>>,
188 ) -> Self {
189 Self::Aws(AwsSandboxBinding {
190 image_arn: image_arn.into(),
191 image_version: image_version.into(),
192 region: region.into(),
193 execution_role_arn: None,
194 egress_connector_arns: Vec::new(),
195 preview_ports: Vec::new(),
196 idle_pause_seconds: None,
197 max_lifetime_seconds: None,
198 allow_egress: false,
199 })
200 }
201
202 pub fn azure(
204 sandbox_group: impl Into<BindingValue<String>>,
205 data_plane_endpoint: impl Into<BindingValue<String>>,
206 region: impl Into<BindingValue<String>>,
207 resource_group: impl Into<BindingValue<String>>,
208 disk_image: impl Into<BindingValue<String>>,
209 egress: SandboxEgress,
210 idle_pause_seconds: Option<u32>,
211 ) -> Self {
212 Self::Azure(AzureSandboxBinding {
213 sandbox_group: sandbox_group.into(),
214 data_plane_endpoint: data_plane_endpoint.into(),
215 region: region.into(),
216 resource_group: resource_group.into(),
217 egress,
218 idle_pause_seconds,
219 disk_image: disk_image.into(),
220 cpu: None,
223 memory: None,
224 disk: None,
225 })
226 }
227
228 pub fn gcp_agent_platform(
230 engine: impl Into<BindingValue<String>>,
231 template: impl Into<BindingValue<String>>,
232 region: impl Into<BindingValue<String>>,
233 max_lifetime_seconds: Option<u32>,
234 allow_egress: bool,
235 ) -> Self {
236 Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
237 engine: engine.into(),
238 template: template.into(),
239 region: region.into(),
240 max_lifetime_seconds,
241 allow_egress,
242 })
243 }
244
245 pub fn kubernetes(
247 namespace: impl Into<BindingValue<String>>,
248 runtime_class: impl Into<BindingValue<String>>,
249 selector: impl Into<BindingValue<String>>,
250 broker_url: impl Into<BindingValue<String>>,
251 key_name: impl Into<BindingValue<String>>,
252 token_path: impl Into<BindingValue<String>>,
253 ) -> Self {
254 Self::Kubernetes(KubernetesSandboxBinding {
255 namespace: namespace.into(),
256 runtime_class: runtime_class.into(),
257 selector: selector.into(),
258 broker_url: broker_url.into(),
259 key_name: key_name.into(),
260 token_path: token_path.into(),
261 })
262 }
263
264 pub fn local(
266 manager_url: impl Into<BindingValue<String>>,
267 sandbox_key: impl Into<BindingValue<String>>,
268 token_path: impl Into<BindingValue<String>>,
269 ) -> Self {
270 Self::Local(LocalSandboxBinding {
271 manager_url: manager_url.into(),
272 sandbox_key: sandbox_key.into(),
273 token_path: token_path.into(),
274 })
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::bindings::{ContainerBinding, KvBinding};
282
283 #[test]
284 fn every_variant_roundtrips() {
285 let bindings = vec![
286 SandboxBinding::aws(
287 "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
288 "3",
289 "us-east-2",
290 ),
291 SandboxBinding::azure(
292 "sbg1",
293 "https://management.swedencentral.azuredevcompute.io",
294 "swedencentral",
295 "rg",
296 "ubuntu",
297 SandboxEgress::Deny,
298 None,
299 ),
300 SandboxBinding::gcp_agent_platform(
301 "projects/p/locations/us-central1/reasoningEngines/1",
302 "projects/p/locations/us-central1/sandboxTemplates/agent",
303 "us-central1",
304 Some(3600),
305 true,
306 ),
307 SandboxBinding::kubernetes(
308 "alien-sandboxes",
309 "gvisor",
310 "alien.dev/sandbox=agent",
311 "http://alien-operator.alien.svc:8080",
312 "alien-sandbox-agent-capability",
313 "/var/run/secrets/kubernetes.io/serviceaccount/token",
314 ),
315 SandboxBinding::local(
316 "http://127.0.0.1:8931",
317 "agent",
318 "/state/sandbox-manager.token",
319 ),
320 ];
321
322 for binding in bindings {
323 let json = serde_json::to_string(&binding).expect("serializes");
324 let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
325 assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
326 }
327 }
328
329 #[test]
334 fn agent_platform_required_fields_have_no_default() {
335 let binding = SandboxBinding::gcp_agent_platform(
336 "projects/p/locations/us-central1/reasoningEngines/1",
337 "projects/p/locations/us-central1/sandboxTemplates/agent",
338 "us-central1",
339 Some(3600),
340 true,
341 );
342 let full = serde_json::to_value(&binding).expect("serializes");
343
344 for required in ["engine", "template", "region"] {
345 let mut stripped = full.clone();
346 stripped
347 .as_object_mut()
348 .expect("binding serializes as an object")
349 .remove(required)
350 .expect("the field is present before it is stripped");
351 serde_json::from_value::<SandboxBinding>(stripped)
352 .expect_err(&format!("a binding missing '{required}' must not load"));
353 }
354
355 let mut without_ttl = full;
356 without_ttl
357 .as_object_mut()
358 .expect("binding serializes as an object")
359 .remove("maxLifetimeSeconds")
360 .expect("the fixture set a ttl");
361 without_ttl
362 .as_object_mut()
363 .expect("binding serializes as an object")
364 .remove("allowEgress")
365 .expect("the fixture allowed egress");
366 let restored: SandboxBinding =
367 serde_json::from_value(without_ttl).expect("an absent ttl still loads");
368 assert_eq!(
369 restored,
370 SandboxBinding::gcp_agent_platform(
371 "projects/p/locations/us-central1/reasoningEngines/1",
372 "projects/p/locations/us-central1/sandboxTemplates/agent",
373 "us-central1",
374 None,
375 false,
376 ),
377 "an absent ttl deserializes as None and an absent allowEgress as deny"
378 );
379 }
380
381 #[test]
382 fn service_tags_are_prefixed_and_distinct() {
383 let tags: Vec<String> = vec![
384 SandboxBinding::aws("a", "1", "r"),
385 SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None),
386 SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None, false),
387 SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"),
388 SandboxBinding::local("u", "k", "t"),
389 ]
390 .iter()
391 .map(|binding| {
392 serde_json::to_value(binding).expect("serializes")["service"]
393 .as_str()
394 .expect("has a service tag")
395 .to_string()
396 })
397 .collect();
398
399 for tag in &tags {
400 assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
401 }
402
403 let mut unique = tags.clone();
404 unique.sort();
405 unique.dedup();
406 assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
407 }
408
409 #[test]
413 fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
414 let json = serde_json::to_string(&SandboxBinding::local(
415 "http://127.0.0.1:8931",
416 "agent",
417 "/state/sandbox-manager.token",
418 ))
419 .expect("serializes");
420
421 serde_json::from_str::<KvBinding>(&json)
422 .expect_err("a sandbox binding must not parse as a KV binding");
423 serde_json::from_str::<ContainerBinding>(&json)
424 .expect_err("a sandbox binding must not parse as a container binding");
425 }
426
427 #[test]
428 fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
429 let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
430 .expect("serializes");
431
432 serde_json::from_str::<SandboxBinding>(&json)
433 .expect_err("a container binding must not parse as a sandbox binding");
434 }
435}