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}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct SandboxEgressSigningKey {
136 pub vault: String,
138 pub key: String,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct AwsSandboxBinding {
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub default_policy: Option<EgressPolicy>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub egress: Option<SandboxEgressBinding>,
152 pub image_arn: BindingValue<String>,
154 pub image_version: BindingValue<String>,
157 pub region: BindingValue<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub execution_role_arn: Option<BindingValue<String>>,
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub egress_connector_arns: Vec<BindingValue<String>>,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub preview_ports: Vec<u16>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub idle_pause_seconds: Option<u32>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub max_lifetime_seconds: Option<u32>,
185 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
191 pub allow_egress: bool,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct AzureSandboxBinding {
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub default_policy: Option<EgressPolicy>,
201 pub sandbox_group: BindingValue<String>,
203 pub data_plane_endpoint: BindingValue<String>,
205 pub region: BindingValue<String>,
207 pub resource_group: BindingValue<String>,
210 pub egress: SandboxEgressBinding,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub idle_pause_seconds: Option<u32>,
222 pub disk_image: BindingValue<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub cpu: Option<BindingValue<String>>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub memory: Option<BindingValue<String>>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub disk: Option<BindingValue<String>>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct GcpAgentPlatformSandboxBinding {
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub default_policy: Option<EgressPolicy>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub egress: Option<SandboxEgressBinding>,
250 pub engine: BindingValue<String>,
253 pub template: BindingValue<String>,
256 pub region: BindingValue<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub max_lifetime_seconds: Option<u32>,
263 #[serde(default)]
266 pub allow_egress: bool,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct KubernetesSandboxBinding {
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub default_policy: Option<EgressPolicy>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub egress: Option<SandboxEgressBinding>,
279 pub namespace: BindingValue<String>,
281 pub runtime_class: BindingValue<String>,
283 pub selector: BindingValue<String>,
285 pub broker_url: BindingValue<String>,
288 pub key_name: BindingValue<String>,
291 pub token_path: BindingValue<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct LocalSandboxBinding {
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub default_policy: Option<EgressPolicy>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub egress: Option<SandboxEgressBinding>,
306 pub manager_url: BindingValue<String>,
308 pub sandbox_key: BindingValue<String>,
310 pub token_path: BindingValue<String>,
313}
314
315impl SandboxBinding {
316 pub fn egress(&self) -> Option<&SandboxEgressBinding> {
318 match self {
319 Self::Aws(value) => value.egress.as_ref(),
320 Self::Azure(value) => Some(&value.egress),
321 Self::GcpAgentPlatform(value) => value.egress.as_ref(),
322 Self::Kubernetes(value) => value.egress.as_ref(),
323 Self::Local(value) => value.egress.as_ref(),
324 }
325 }
326 pub fn default_policy(&self) -> Option<&EgressPolicy> {
328 let explicit = match self {
329 Self::Aws(value) => value.default_policy.as_ref(),
330 Self::Azure(value) => value.default_policy.as_ref(),
331 Self::GcpAgentPlatform(value) => value.default_policy.as_ref(),
332 Self::Kubernetes(value) => value.default_policy.as_ref(),
333 Self::Local(value) => value.default_policy.as_ref(),
334 };
335 explicit.or_else(|| self.egress().and_then(SandboxEgressBinding::default_policy))
336 }
337
338 pub fn aws(
340 image_arn: impl Into<BindingValue<String>>,
341 image_version: impl Into<BindingValue<String>>,
342 region: impl Into<BindingValue<String>>,
343 ) -> Self {
344 Self::Aws(AwsSandboxBinding {
345 default_policy: None,
346 egress: None,
347 image_arn: image_arn.into(),
348 image_version: image_version.into(),
349 region: region.into(),
350 execution_role_arn: None,
351 egress_connector_arns: Vec::new(),
352 preview_ports: Vec::new(),
353 idle_pause_seconds: None,
354 max_lifetime_seconds: None,
355 allow_egress: false,
356 })
357 }
358
359 pub fn azure(
361 sandbox_group: impl Into<BindingValue<String>>,
362 data_plane_endpoint: impl Into<BindingValue<String>>,
363 region: impl Into<BindingValue<String>>,
364 resource_group: impl Into<BindingValue<String>>,
365 disk_image: impl Into<BindingValue<String>>,
366 egress: SandboxEgress,
367 idle_pause_seconds: Option<u32>,
368 ) -> Self {
369 Self::Azure(AzureSandboxBinding {
370 default_policy: None,
371 sandbox_group: sandbox_group.into(),
372 data_plane_endpoint: data_plane_endpoint.into(),
373 region: region.into(),
374 resource_group: resource_group.into(),
375 egress: egress.into(),
376 idle_pause_seconds,
377 disk_image: disk_image.into(),
378 cpu: None,
381 memory: None,
382 disk: None,
383 })
384 }
385
386 pub fn gcp_agent_platform(
389 engine: impl Into<BindingValue<String>>,
390 template: impl Into<BindingValue<String>>,
391 region: impl Into<BindingValue<String>>,
392 max_lifetime_seconds: Option<u32>,
393 allow_egress: bool,
394 ) -> Self {
395 Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
396 default_policy: None,
397 egress: Some(
398 (if allow_egress {
399 SandboxEgress::Allow
400 } else {
401 SandboxEgress::None
402 })
403 .into(),
404 ),
405 engine: engine.into(),
406 template: template.into(),
407 region: region.into(),
408 max_lifetime_seconds,
409 allow_egress,
410 })
411 }
412
413 pub fn kubernetes(
415 namespace: impl Into<BindingValue<String>>,
416 runtime_class: impl Into<BindingValue<String>>,
417 selector: impl Into<BindingValue<String>>,
418 broker_url: impl Into<BindingValue<String>>,
419 key_name: impl Into<BindingValue<String>>,
420 token_path: impl Into<BindingValue<String>>,
421 egress: Option<SandboxEgress>,
422 ) -> Self {
423 Self::Kubernetes(KubernetesSandboxBinding {
424 default_policy: None,
425 egress: egress.map(Into::into),
426 namespace: namespace.into(),
427 runtime_class: runtime_class.into(),
428 selector: selector.into(),
429 broker_url: broker_url.into(),
430 key_name: key_name.into(),
431 token_path: token_path.into(),
432 })
433 }
434
435 pub fn local(
437 manager_url: impl Into<BindingValue<String>>,
438 sandbox_key: impl Into<BindingValue<String>>,
439 token_path: impl Into<BindingValue<String>>,
440 egress: Option<SandboxEgress>,
441 ) -> Self {
442 Self::Local(LocalSandboxBinding {
443 default_policy: None,
444 egress: egress.map(Into::into),
445 manager_url: manager_url.into(),
446 sandbox_key: sandbox_key.into(),
447 token_path: token_path.into(),
448 })
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use crate::bindings::{ContainerBinding, KvBinding};
456
457 #[test]
458 fn every_variant_roundtrips() {
459 let bindings = vec![
460 SandboxBinding::aws(
461 "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
462 "3",
463 "us-east-2",
464 ),
465 SandboxBinding::azure(
466 "sbg1",
467 "https://management.swedencentral.azuredevcompute.io",
468 "swedencentral",
469 "rg",
470 "ubuntu",
471 SandboxEgress::None,
472 None,
473 ),
474 SandboxBinding::gcp_agent_platform(
475 "projects/p/locations/us-central1/reasoningEngines/1",
476 "projects/p/locations/us-central1/sandboxTemplates/agent",
477 "us-central1",
478 Some(3600),
479 true,
480 ),
481 SandboxBinding::kubernetes(
482 "alien-sandboxes",
483 "gvisor",
484 "alien.dev/sandbox=agent",
485 "http://alien-operator.alien.svc:8080",
486 "alien-sandbox-agent-capability",
487 "/var/run/secrets/kubernetes.io/serviceaccount/token",
488 None,
489 ),
490 SandboxBinding::local(
491 "http://127.0.0.1:8931",
492 "agent",
493 "/state/sandbox-manager.token",
494 None,
495 ),
496 ];
497
498 for binding in bindings {
499 let json = serde_json::to_string(&binding).expect("serializes");
500 let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
501 assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
502 }
503 }
504
505 #[test]
510 fn agent_platform_required_fields_have_no_default() {
511 let binding = SandboxBinding::gcp_agent_platform(
512 "projects/p/locations/us-central1/reasoningEngines/1",
513 "projects/p/locations/us-central1/sandboxTemplates/agent",
514 "us-central1",
515 Some(3600),
516 true,
517 );
518 let full = serde_json::to_value(&binding).expect("serializes");
519
520 for required in ["engine", "template", "region"] {
521 let mut stripped = full.clone();
522 stripped
523 .as_object_mut()
524 .expect("binding serializes as an object")
525 .remove(required)
526 .expect("the field is present before it is stripped");
527 serde_json::from_value::<SandboxBinding>(stripped)
528 .expect_err(&format!("a binding missing '{required}' must not load"));
529 }
530
531 let mut without_ttl = full;
532 without_ttl
533 .as_object_mut()
534 .expect("binding serializes as an object")
535 .remove("maxLifetimeSeconds")
536 .expect("the fixture set a ttl");
537 without_ttl
538 .as_object_mut()
539 .expect("binding serializes as an object")
540 .remove("allowEgress")
541 .expect("the fixture allowed egress");
542 without_ttl.as_object_mut().unwrap().remove("egress");
543 let restored: SandboxBinding = serde_json::from_value(without_ttl)
544 .expect("legacy binding without optional fields loads");
545 let SandboxBinding::GcpAgentPlatform(restored) = restored else {
546 panic!("the service tag must select Agent Platform");
547 };
548 assert_eq!(restored.max_lifetime_seconds, None);
549 assert!(!restored.allow_egress, "legacy bindings default to deny");
550 assert_eq!(
551 restored.egress, None,
552 "legacy bindings carry no explicit declaration"
553 );
554 }
555
556 #[test]
557 fn service_tags_are_prefixed_and_distinct() {
558 let tags: Vec<String> = vec![
559 SandboxBinding::aws("a", "1", "r"),
560 SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::None, None),
561 SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None, false),
562 SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t", None),
563 SandboxBinding::local("u", "k", "t", None),
564 ]
565 .iter()
566 .map(|binding| {
567 serde_json::to_value(binding).expect("serializes")["service"]
568 .as_str()
569 .expect("has a service tag")
570 .to_string()
571 })
572 .collect();
573
574 for tag in &tags {
575 assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
576 }
577
578 let mut unique = tags.clone();
579 unique.sort();
580 unique.dedup();
581 assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
582 }
583
584 #[test]
588 fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
589 let json = serde_json::to_string(&SandboxBinding::local(
590 "http://127.0.0.1:8931",
591 "agent",
592 "/state/sandbox-manager.token",
593 None,
594 ))
595 .expect("serializes");
596
597 serde_json::from_str::<KvBinding>(&json)
598 .expect_err("a sandbox binding must not parse as a KV binding");
599 serde_json::from_str::<ContainerBinding>(&json)
600 .expect_err("a sandbox binding must not parse as a container binding");
601 }
602
603 #[test]
604 fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
605 let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
606 .expect("serializes");
607
608 serde_json::from_str::<SandboxBinding>(&json)
609 .expect_err("a container binding must not parse as a sandbox binding");
610 }
611}