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>,
172 pub region: BindingValue<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub execution_role_arn: Option<BindingValue<String>>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub egress_connector_arns: Vec<BindingValue<String>>,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub preview_ports: Vec<u16>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub idle_pause_seconds: Option<u32>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub max_lifetime_seconds: Option<u32>,
200 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
206 pub allow_egress: bool,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct AzureSandboxBinding {
213 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
216 pub agent: bool,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub default_policy: Option<EgressPolicy>,
220 pub sandbox_group: BindingValue<String>,
222 pub data_plane_endpoint: BindingValue<String>,
224 pub region: BindingValue<String>,
226 pub resource_group: BindingValue<String>,
229 pub egress: SandboxEgressBinding,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub idle_pause_seconds: Option<u32>,
241 pub disk_image: BindingValue<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub cpu: Option<BindingValue<String>>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub memory: Option<BindingValue<String>>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub disk: Option<BindingValue<String>>,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "camelCase")]
262pub struct GcpAgentPlatformSandboxBinding {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub default_policy: Option<EgressPolicy>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub egress: Option<SandboxEgressBinding>,
269 pub engine: BindingValue<String>,
272 pub template: BindingValue<String>,
275 pub region: BindingValue<String>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub max_lifetime_seconds: Option<u32>,
282 #[serde(default)]
285 pub allow_egress: bool,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct KubernetesSandboxBinding {
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub default_policy: Option<EgressPolicy>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub egress: Option<SandboxEgressBinding>,
298 pub namespace: BindingValue<String>,
300 pub runtime_class: BindingValue<String>,
302 pub selector: BindingValue<String>,
304 pub broker_url: BindingValue<String>,
307 pub key_name: BindingValue<String>,
310 pub token_path: BindingValue<String>,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase")]
318pub struct LocalSandboxBinding {
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub default_policy: Option<EgressPolicy>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub egress: Option<SandboxEgressBinding>,
325 pub manager_url: BindingValue<String>,
327 pub sandbox_key: BindingValue<String>,
329 pub token_path: BindingValue<String>,
332}
333
334impl SandboxBinding {
335 pub fn egress(&self) -> Option<&SandboxEgressBinding> {
337 match self {
338 Self::Aws(value) => value.egress.as_ref(),
339 Self::Azure(value) => Some(&value.egress),
340 Self::GcpAgentPlatform(value) => value.egress.as_ref(),
341 Self::Kubernetes(value) => value.egress.as_ref(),
342 Self::Local(value) => value.egress.as_ref(),
343 }
344 }
345 pub fn default_policy(&self) -> Option<&EgressPolicy> {
347 let explicit = match self {
348 Self::Aws(value) => value.default_policy.as_ref(),
349 Self::Azure(value) => value.default_policy.as_ref(),
350 Self::GcpAgentPlatform(value) => value.default_policy.as_ref(),
351 Self::Kubernetes(value) => value.default_policy.as_ref(),
352 Self::Local(value) => value.default_policy.as_ref(),
353 };
354 explicit.or_else(|| self.egress().and_then(SandboxEgressBinding::default_policy))
355 }
356
357 pub fn aws(
359 image_arn: impl Into<BindingValue<String>>,
360 image_version: impl Into<BindingValue<String>>,
361 region: impl Into<BindingValue<String>>,
362 ) -> Self {
363 Self::Aws(AwsSandboxBinding {
364 default_policy: None,
365 egress: None,
366 image_arn: image_arn.into(),
367 image_version: image_version.into(),
368 region: region.into(),
369 execution_role_arn: None,
370 egress_connector_arns: Vec::new(),
371 preview_ports: Vec::new(),
372 idle_pause_seconds: None,
373 max_lifetime_seconds: None,
374 allow_egress: false,
375 })
376 }
377
378 pub fn azure(
380 sandbox_group: impl Into<BindingValue<String>>,
381 data_plane_endpoint: impl Into<BindingValue<String>>,
382 region: impl Into<BindingValue<String>>,
383 resource_group: impl Into<BindingValue<String>>,
384 disk_image: impl Into<BindingValue<String>>,
385 egress: SandboxEgress,
386 idle_pause_seconds: Option<u32>,
387 ) -> Self {
388 Self::Azure(AzureSandboxBinding {
389 agent: false,
390 default_policy: None,
391 sandbox_group: sandbox_group.into(),
392 data_plane_endpoint: data_plane_endpoint.into(),
393 region: region.into(),
394 resource_group: resource_group.into(),
395 egress: egress.into(),
396 idle_pause_seconds,
397 disk_image: disk_image.into(),
398 cpu: None,
401 memory: None,
402 disk: None,
403 })
404 }
405
406 pub fn gcp_agent_platform(
409 engine: impl Into<BindingValue<String>>,
410 template: impl Into<BindingValue<String>>,
411 region: impl Into<BindingValue<String>>,
412 max_lifetime_seconds: Option<u32>,
413 allow_egress: bool,
414 ) -> Self {
415 Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
416 default_policy: None,
417 egress: Some(
418 (if allow_egress {
419 SandboxEgress::Allow
420 } else {
421 SandboxEgress::None
422 })
423 .into(),
424 ),
425 engine: engine.into(),
426 template: template.into(),
427 region: region.into(),
428 max_lifetime_seconds,
429 allow_egress,
430 })
431 }
432
433 pub fn kubernetes(
435 namespace: impl Into<BindingValue<String>>,
436 runtime_class: impl Into<BindingValue<String>>,
437 selector: impl Into<BindingValue<String>>,
438 broker_url: impl Into<BindingValue<String>>,
439 key_name: impl Into<BindingValue<String>>,
440 token_path: impl Into<BindingValue<String>>,
441 egress: Option<SandboxEgress>,
442 ) -> Self {
443 Self::Kubernetes(KubernetesSandboxBinding {
444 default_policy: None,
445 egress: egress.map(Into::into),
446 namespace: namespace.into(),
447 runtime_class: runtime_class.into(),
448 selector: selector.into(),
449 broker_url: broker_url.into(),
450 key_name: key_name.into(),
451 token_path: token_path.into(),
452 })
453 }
454
455 pub fn local(
457 manager_url: impl Into<BindingValue<String>>,
458 sandbox_key: impl Into<BindingValue<String>>,
459 token_path: impl Into<BindingValue<String>>,
460 egress: Option<SandboxEgress>,
461 ) -> Self {
462 Self::Local(LocalSandboxBinding {
463 default_policy: None,
464 egress: egress.map(Into::into),
465 manager_url: manager_url.into(),
466 sandbox_key: sandbox_key.into(),
467 token_path: token_path.into(),
468 })
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::bindings::{ContainerBinding, KvBinding};
476
477 #[test]
478 fn every_variant_roundtrips() {
479 let bindings = vec![
480 SandboxBinding::aws(
481 "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
482 "3",
483 "us-east-2",
484 ),
485 SandboxBinding::azure(
486 "sbg1",
487 "https://management.swedencentral.azuredevcompute.io",
488 "swedencentral",
489 "rg",
490 "ubuntu",
491 SandboxEgress::None,
492 None,
493 ),
494 SandboxBinding::gcp_agent_platform(
495 "projects/p/locations/us-central1/reasoningEngines/1",
496 "projects/p/locations/us-central1/sandboxTemplates/agent",
497 "us-central1",
498 Some(3600),
499 true,
500 ),
501 SandboxBinding::kubernetes(
502 "alien-sandboxes",
503 "gvisor",
504 "alien.dev/sandbox=agent",
505 "http://alien-operator.alien.svc:8080",
506 "alien-sandbox-agent-capability",
507 "/var/run/secrets/kubernetes.io/serviceaccount/token",
508 None,
509 ),
510 SandboxBinding::local(
511 "http://127.0.0.1:8931",
512 "agent",
513 "/state/sandbox-manager.token",
514 None,
515 ),
516 ];
517
518 for binding in bindings {
519 let json = serde_json::to_string(&binding).expect("serializes");
520 let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
521 assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
522 }
523 }
524
525 #[test]
530 fn agent_platform_required_fields_have_no_default() {
531 let binding = SandboxBinding::gcp_agent_platform(
532 "projects/p/locations/us-central1/reasoningEngines/1",
533 "projects/p/locations/us-central1/sandboxTemplates/agent",
534 "us-central1",
535 Some(3600),
536 true,
537 );
538 let full = serde_json::to_value(&binding).expect("serializes");
539
540 for required in ["engine", "template", "region"] {
541 let mut stripped = full.clone();
542 stripped
543 .as_object_mut()
544 .expect("binding serializes as an object")
545 .remove(required)
546 .expect("the field is present before it is stripped");
547 serde_json::from_value::<SandboxBinding>(stripped)
548 .expect_err(&format!("a binding missing '{required}' must not load"));
549 }
550
551 let mut without_ttl = full;
552 without_ttl
553 .as_object_mut()
554 .expect("binding serializes as an object")
555 .remove("maxLifetimeSeconds")
556 .expect("the fixture set a ttl");
557 without_ttl
558 .as_object_mut()
559 .expect("binding serializes as an object")
560 .remove("allowEgress")
561 .expect("the fixture allowed egress");
562 without_ttl.as_object_mut().unwrap().remove("egress");
563 let restored: SandboxBinding = serde_json::from_value(without_ttl)
564 .expect("legacy binding without optional fields loads");
565 let SandboxBinding::GcpAgentPlatform(restored) = restored else {
566 panic!("the service tag must select Agent Platform");
567 };
568 assert_eq!(restored.max_lifetime_seconds, None);
569 assert!(!restored.allow_egress, "legacy bindings default to deny");
570 assert_eq!(
571 restored.egress, None,
572 "legacy bindings carry no explicit declaration"
573 );
574 }
575
576 #[test]
577 fn service_tags_are_prefixed_and_distinct() {
578 let tags: Vec<String> = vec![
579 SandboxBinding::aws("a", "1", "r"),
580 SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::None, None),
581 SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None, false),
582 SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t", None),
583 SandboxBinding::local("u", "k", "t", None),
584 ]
585 .iter()
586 .map(|binding| {
587 serde_json::to_value(binding).expect("serializes")["service"]
588 .as_str()
589 .expect("has a service tag")
590 .to_string()
591 })
592 .collect();
593
594 for tag in &tags {
595 assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
596 }
597
598 let mut unique = tags.clone();
599 unique.sort();
600 unique.dedup();
601 assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
602 }
603
604 #[test]
608 fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
609 let json = serde_json::to_string(&SandboxBinding::local(
610 "http://127.0.0.1:8931",
611 "agent",
612 "/state/sandbox-manager.token",
613 None,
614 ))
615 .expect("serializes");
616
617 serde_json::from_str::<KvBinding>(&json)
618 .expect_err("a sandbox binding must not parse as a KV binding");
619 serde_json::from_str::<ContainerBinding>(&json)
620 .expect_err("a sandbox binding must not parse as a container binding");
621 }
622
623 #[test]
624 fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
625 let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
626 .expect("serializes");
627
628 serde_json::from_str::<SandboxBinding>(&json)
629 .expect_err("a container binding must not parse as a sandbox binding");
630 }
631}