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