1use super::BindingValue;
8use crate::{EgressPolicy, 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)]
39#[serde(untagged)]
40pub enum SandboxEgressBinding {
41 Gateway(SandboxEgressGateway),
43 Policy(SandboxEgress),
45 Defaults {
47 #[serde(rename = "defaultPolicy")]
48 default_policy: EgressPolicy,
49 },
50}
51
52impl SandboxEgressBinding {
53 pub fn declaration(&self) -> &SandboxEgress {
55 match self {
56 Self::Gateway(_) => &SandboxEgress::Internet,
57 Self::Policy(value) => value,
58 Self::Defaults { .. } => &SandboxEgress::Internet,
59 }
60 }
61 pub fn default_policy(&self) -> Option<&EgressPolicy> {
63 match self {
64 Self::Defaults { default_policy } => Some(default_policy),
65 _ => None,
66 }
67 }
68 pub fn gateway(&self) -> Option<&SandboxEgressGateway> {
70 match self {
71 Self::Gateway(value) => Some(value),
72 Self::Policy(_) | Self::Defaults { .. } => None,
73 }
74 }
75}
76
77impl<'de> Deserialize<'de> for SandboxEgressBinding {
78 fn deserialize<D: serde::Deserializer<'de>>(
79 deserializer: D,
80 ) -> std::result::Result<Self, D::Error> {
81 #[derive(Deserialize)]
82 #[serde(untagged)]
83 enum Input {
84 Gateway(SandboxEgressGateway),
85 Policy(SandboxEgress),
86 Defaults {
87 #[serde(rename = "defaultPolicy")]
88 default_policy: EgressPolicy,
89 },
90 }
91 Ok(match Input::deserialize(deserializer)? {
92 Input::Gateway(value) => Self::Gateway(value),
93 Input::Policy(value) => Self::Policy(value),
94 Input::Defaults { default_policy } => Self::Defaults { default_policy },
95 })
96 }
97}
98impl From<&crate::Sandbox> for SandboxEgressBinding {
99 fn from(value: &crate::Sandbox) -> Self {
100 match &value.default_policy {
101 Some(default_policy) => Self::Defaults {
102 default_policy: default_policy.clone(),
103 },
104 None => Self::Policy(value.egress.clone()),
105 }
106 }
107}
108
109impl From<SandboxEgress> for SandboxEgressBinding {
110 fn from(value: SandboxEgress) -> Self {
111 Self::Policy(value)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118pub struct SandboxEgressGateway {
119 pub container: String,
121 pub audience: String,
123 pub signing_key: SandboxEgressSigningKey,
125 pub kid: String,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub admin_ca_pem: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub replicas: Option<SandboxEgressReplicas>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase", deny_unknown_fields)]
139pub struct SandboxEgressReplicas {
140 pub dns: String,
142 pub port: u16,
144 pub count: u32,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase", deny_unknown_fields)]
151pub struct SandboxEgressSigningKey {
152 pub vault: String,
154 pub key: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct AwsSandboxBinding {
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub default_policy: Option<EgressPolicy>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub egress: Option<SandboxEgressBinding>,
168 pub image_arn: BindingValue<String>,
170 pub image_version: BindingValue<String>,
173 pub region: BindingValue<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub execution_role_arn: Option<BindingValue<String>>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub egress_connector_arns: Vec<BindingValue<String>>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub preview_ports: Vec<u16>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub idle_pause_seconds: Option<u32>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub max_lifetime_seconds: Option<u32>,
201 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
207 pub allow_egress: bool,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct AzureSandboxBinding {
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub default_policy: Option<EgressPolicy>,
217 pub sandbox_group: BindingValue<String>,
219 pub data_plane_endpoint: BindingValue<String>,
221 pub region: BindingValue<String>,
223 pub resource_group: BindingValue<String>,
226 pub egress: SandboxEgressBinding,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub idle_pause_seconds: Option<u32>,
238 pub disk_image: BindingValue<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub cpu: Option<BindingValue<String>>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub memory: Option<BindingValue<String>>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub disk: Option<BindingValue<String>>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259pub struct GcpAgentPlatformSandboxBinding {
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub default_policy: Option<EgressPolicy>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub egress: Option<SandboxEgressBinding>,
266 pub engine: BindingValue<String>,
269 pub template: BindingValue<String>,
272 pub region: BindingValue<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub max_lifetime_seconds: Option<u32>,
279 #[serde(default)]
282 pub allow_egress: bool,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct KubernetesSandboxBinding {
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub default_policy: Option<EgressPolicy>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub egress: Option<SandboxEgressBinding>,
295 pub namespace: BindingValue<String>,
297 pub runtime_class: BindingValue<String>,
299 pub selector: BindingValue<String>,
301 pub broker_url: BindingValue<String>,
304 pub key_name: BindingValue<String>,
307 pub token_path: BindingValue<String>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315pub struct LocalSandboxBinding {
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub default_policy: Option<EgressPolicy>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub egress: Option<SandboxEgressBinding>,
322 pub manager_url: BindingValue<String>,
324 pub sandbox_key: BindingValue<String>,
326 pub token_path: BindingValue<String>,
329}
330
331impl SandboxBinding {
332 pub fn egress(&self) -> Option<&SandboxEgressBinding> {
334 match self {
335 Self::Aws(value) => value.egress.as_ref(),
336 Self::Azure(value) => Some(&value.egress),
337 Self::GcpAgentPlatform(value) => value.egress.as_ref(),
338 Self::Kubernetes(value) => value.egress.as_ref(),
339 Self::Local(value) => value.egress.as_ref(),
340 }
341 }
342 pub fn default_policy(&self) -> Option<&EgressPolicy> {
344 let explicit = match self {
345 Self::Aws(value) => value.default_policy.as_ref(),
346 Self::Azure(value) => value.default_policy.as_ref(),
347 Self::GcpAgentPlatform(value) => value.default_policy.as_ref(),
348 Self::Kubernetes(value) => value.default_policy.as_ref(),
349 Self::Local(value) => value.default_policy.as_ref(),
350 };
351 explicit.or_else(|| self.egress().and_then(SandboxEgressBinding::default_policy))
352 }
353
354 pub fn aws(
356 image_arn: impl Into<BindingValue<String>>,
357 image_version: impl Into<BindingValue<String>>,
358 region: impl Into<BindingValue<String>>,
359 ) -> Self {
360 Self::Aws(AwsSandboxBinding {
361 default_policy: None,
362 egress: None,
363 image_arn: image_arn.into(),
364 image_version: image_version.into(),
365 region: region.into(),
366 execution_role_arn: None,
367 egress_connector_arns: Vec::new(),
368 preview_ports: Vec::new(),
369 idle_pause_seconds: None,
370 max_lifetime_seconds: None,
371 allow_egress: false,
372 })
373 }
374
375 pub fn azure(
377 sandbox_group: impl Into<BindingValue<String>>,
378 data_plane_endpoint: impl Into<BindingValue<String>>,
379 region: impl Into<BindingValue<String>>,
380 resource_group: impl Into<BindingValue<String>>,
381 disk_image: impl Into<BindingValue<String>>,
382 egress: SandboxEgress,
383 idle_pause_seconds: Option<u32>,
384 ) -> Self {
385 Self::Azure(AzureSandboxBinding {
386 default_policy: None,
387 sandbox_group: sandbox_group.into(),
388 data_plane_endpoint: data_plane_endpoint.into(),
389 region: region.into(),
390 resource_group: resource_group.into(),
391 egress: egress.into(),
392 idle_pause_seconds,
393 disk_image: disk_image.into(),
394 cpu: None,
397 memory: None,
398 disk: None,
399 })
400 }
401
402 pub fn gcp_agent_platform(
405 engine: impl Into<BindingValue<String>>,
406 template: impl Into<BindingValue<String>>,
407 region: impl Into<BindingValue<String>>,
408 max_lifetime_seconds: Option<u32>,
409 allow_egress: bool,
410 ) -> Self {
411 Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
412 default_policy: None,
413 egress: Some(
414 (if allow_egress {
415 SandboxEgress::Allow
416 } else {
417 SandboxEgress::None
418 })
419 .into(),
420 ),
421 engine: engine.into(),
422 template: template.into(),
423 region: region.into(),
424 max_lifetime_seconds,
425 allow_egress,
426 })
427 }
428
429 pub fn kubernetes(
431 namespace: impl Into<BindingValue<String>>,
432 runtime_class: impl Into<BindingValue<String>>,
433 selector: impl Into<BindingValue<String>>,
434 broker_url: impl Into<BindingValue<String>>,
435 key_name: impl Into<BindingValue<String>>,
436 token_path: impl Into<BindingValue<String>>,
437 egress: Option<SandboxEgress>,
438 ) -> Self {
439 Self::Kubernetes(KubernetesSandboxBinding {
440 default_policy: None,
441 egress: egress.map(Into::into),
442 namespace: namespace.into(),
443 runtime_class: runtime_class.into(),
444 selector: selector.into(),
445 broker_url: broker_url.into(),
446 key_name: key_name.into(),
447 token_path: token_path.into(),
448 })
449 }
450
451 pub fn local(
453 manager_url: impl Into<BindingValue<String>>,
454 sandbox_key: impl Into<BindingValue<String>>,
455 token_path: impl Into<BindingValue<String>>,
456 egress: Option<SandboxEgress>,
457 ) -> Self {
458 Self::Local(LocalSandboxBinding {
459 default_policy: None,
460 egress: egress.map(Into::into),
461 manager_url: manager_url.into(),
462 sandbox_key: sandbox_key.into(),
463 token_path: token_path.into(),
464 })
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::bindings::{ContainerBinding, KvBinding};
472
473 #[test]
474 fn every_variant_roundtrips() {
475 let bindings = vec![
476 SandboxBinding::aws(
477 "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
478 "3",
479 "us-east-2",
480 ),
481 SandboxBinding::azure(
482 "sbg1",
483 "https://management.swedencentral.azuredevcompute.io",
484 "swedencentral",
485 "rg",
486 "ubuntu",
487 SandboxEgress::None,
488 None,
489 ),
490 SandboxBinding::gcp_agent_platform(
491 "projects/p/locations/us-central1/reasoningEngines/1",
492 "projects/p/locations/us-central1/sandboxTemplates/agent",
493 "us-central1",
494 Some(3600),
495 true,
496 ),
497 SandboxBinding::kubernetes(
498 "alien-sandboxes",
499 "gvisor",
500 "alien.dev/sandbox=agent",
501 "http://alien-operator.alien.svc:8080",
502 "alien-sandbox-agent-capability",
503 "/var/run/secrets/kubernetes.io/serviceaccount/token",
504 None,
505 ),
506 SandboxBinding::local(
507 "http://127.0.0.1:8931",
508 "agent",
509 "/state/sandbox-manager.token",
510 None,
511 ),
512 ];
513
514 for binding in bindings {
515 let json = serde_json::to_string(&binding).expect("serializes");
516 let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
517 assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
518 }
519 }
520
521 #[test]
526 fn agent_platform_required_fields_have_no_default() {
527 let binding = SandboxBinding::gcp_agent_platform(
528 "projects/p/locations/us-central1/reasoningEngines/1",
529 "projects/p/locations/us-central1/sandboxTemplates/agent",
530 "us-central1",
531 Some(3600),
532 true,
533 );
534 let full = serde_json::to_value(&binding).expect("serializes");
535
536 for required in ["engine", "template", "region"] {
537 let mut stripped = full.clone();
538 stripped
539 .as_object_mut()
540 .expect("binding serializes as an object")
541 .remove(required)
542 .expect("the field is present before it is stripped");
543 serde_json::from_value::<SandboxBinding>(stripped)
544 .expect_err(&format!("a binding missing '{required}' must not load"));
545 }
546
547 let mut without_ttl = full;
548 without_ttl
549 .as_object_mut()
550 .expect("binding serializes as an object")
551 .remove("maxLifetimeSeconds")
552 .expect("the fixture set a ttl");
553 without_ttl
554 .as_object_mut()
555 .expect("binding serializes as an object")
556 .remove("allowEgress")
557 .expect("the fixture allowed egress");
558 without_ttl.as_object_mut().unwrap().remove("egress");
559 let restored: SandboxBinding = serde_json::from_value(without_ttl)
560 .expect("legacy binding without optional fields loads");
561 let SandboxBinding::GcpAgentPlatform(restored) = restored else {
562 panic!("the service tag must select Agent Platform");
563 };
564 assert_eq!(restored.max_lifetime_seconds, None);
565 assert!(!restored.allow_egress, "legacy bindings default to deny");
566 assert_eq!(
567 restored.egress, None,
568 "legacy bindings carry no explicit declaration"
569 );
570 }
571
572 #[test]
573 fn service_tags_are_prefixed_and_distinct() {
574 let tags: Vec<String> = vec![
575 SandboxBinding::aws("a", "1", "r"),
576 SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::None, None),
577 SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None, false),
578 SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t", None),
579 SandboxBinding::local("u", "k", "t", None),
580 ]
581 .iter()
582 .map(|binding| {
583 serde_json::to_value(binding).expect("serializes")["service"]
584 .as_str()
585 .expect("has a service tag")
586 .to_string()
587 })
588 .collect();
589
590 for tag in &tags {
591 assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
592 }
593
594 let mut unique = tags.clone();
595 unique.sort();
596 unique.dedup();
597 assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
598 }
599
600 #[test]
604 fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
605 let json = serde_json::to_string(&SandboxBinding::local(
606 "http://127.0.0.1:8931",
607 "agent",
608 "/state/sandbox-manager.token",
609 None,
610 ))
611 .expect("serializes");
612
613 serde_json::from_str::<KvBinding>(&json)
614 .expect_err("a sandbox binding must not parse as a KV binding");
615 serde_json::from_str::<ContainerBinding>(&json)
616 .expect_err("a sandbox binding must not parse as a container binding");
617 }
618
619 #[test]
620 fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
621 let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
622 .expect("serializes");
623
624 serde_json::from_str::<SandboxBinding>(&json)
625 .expect_err("a container binding must not parse as a sandbox binding");
626 }
627}