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