Skip to main content

alien_core/bindings/
sandbox.rs

1//! Sandbox binding definitions.
2//!
3//! Carries what a provider needs to reach the durable parent and create sandboxes inside it.
4//! Sandbox identity is never in here — sandboxes are created at runtime and the provider is the
5//! record, so a binding describes the parent only.
6
7use super::BindingValue;
8use crate::SandboxEgress;
9use serde::{Deserialize, Serialize};
10
11/// Represents a sandbox binding for creating and reaching sandboxes.
12///
13/// Service tags are prefixed with `sandbox-` because serde selects the variant on the `service`
14/// field alone. An unprefixed `local` would deserialize as another resource's local binding by
15/// silently dropping the fields that differ.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "service")]
18pub enum SandboxBinding {
19    /// AWS Lambda MicroVM sandboxes
20    #[serde(rename = "sandbox-aws")]
21    Aws(AwsSandboxBinding),
22    /// Azure Container Apps Sandboxes
23    #[serde(rename = "sandbox-azure")]
24    Azure(AzureSandboxBinding),
25    /// GCP Agent Platform sandboxes, created under a durable Agent Engine
26    #[serde(rename = "sandbox-gcp-agent-platform")]
27    GcpAgentPlatform(GcpAgentPlatformSandboxBinding),
28    /// Sandbox pods under a sandboxed runtime class
29    #[serde(rename = "sandbox-kubernetes")]
30    Kubernetes(KubernetesSandboxBinding),
31    /// Local Docker sandboxes managed by the local sandbox manager
32    #[serde(rename = "sandbox-local")]
33    Local(LocalSandboxBinding),
34}
35
36/// AWS sandbox binding configuration.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct AwsSandboxBinding {
40    /// MicroVM image ARN that scopes the sandboxes this binding creates
41    pub image_arn: BindingValue<String>,
42    /// Image version. Sandboxes are enumerated by image and version together, so a rolled
43    /// version remains a cleanup scope until its own MicroVMs are gone.
44    pub image_version: BindingValue<String>,
45    /// Region the MicroVMs run in
46    pub region: BindingValue<String>,
47    /// Execution role attached to each MicroVM, distinct from the workload's own role
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub execution_role_arn: Option<BindingValue<String>>,
50    /// Egress connectors every sandbox is started with.
51    ///
52    /// Carried rather than implied: a MicroVM started with no connector reaches the public
53    /// internet, so an empty list here is `allow`, not `deny`. The declared mode is realised by
54    /// which connector setup built, and the sandbox has to be started with it.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub egress_connector_arns: Vec<BindingValue<String>>,
57    /// Ports a preview capability may be minted for.
58    ///
59    /// Carried because the token is what grants ingress: `CreateMicrovmAuthToken` mints access to
60    /// whatever port it is asked for, so "a port not listed here can never be exposed" is only
61    /// true if the declared list reaches the code that mints.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub preview_ports: Vec<u16>,
64    /// Idle seconds after which a sandbox pauses, if the declaration asked for one.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub idle_pause_seconds: Option<u32>,
67    /// Wall-clock ceiling on a sandbox, if the declaration asked for one.
68    ///
69    /// Enforced by Lambda rather than by us: `RunMicrovm` takes it as
70    /// `maximumDurationInSeconds` and terminates the MicroVM when it elapses.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub max_lifetime_seconds: Option<u32>,
73    /// Whether the declaration asked for open egress.
74    ///
75    /// Carried because an empty connector list cannot otherwise be read: a MicroVM started with
76    /// no connector reaches the internet, so a `deny` binding stripped of its connectors would be
77    /// indistinguishable from `allow`. Absent means `deny`, which is the answer that fails closed.
78    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
79    pub allow_egress: bool,
80}
81
82/// Azure sandbox binding configuration.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct AzureSandboxBinding {
86    /// Sandbox group that scopes every sandbox, image, snapshot and secret
87    pub sandbox_group: BindingValue<String>,
88    /// ADC data-plane endpoint, which is separate from the ARM control plane
89    pub data_plane_endpoint: BindingValue<String>,
90    /// Region the sandbox group lives in; selects the per-region ADC endpoint
91    pub region: BindingValue<String>,
92    /// Resource group the sandbox group sits in. The data-plane path is scoped by it, and the
93    /// Azure client config does not carry one.
94    pub resource_group: BindingValue<String>,
95    /// Outbound policy every sandbox is created with, as declared.
96    ///
97    /// Carried whole rather than as a flag: the data plane's default action is `Allow`, so a
98    /// sandbox created without a policy is an open one, and a hostname list has no boolean to
99    /// travel in.
100    pub egress: SandboxEgress,
101    /// Idle seconds after which a sandbox pauses, if the declaration asked for one.
102    ///
103    /// Carried because the data plane takes it at create and nowhere else: a policy that does not
104    /// travel with the create body is a declaration the sandbox never hears about.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub idle_pause_seconds: Option<u32>,
107    /// Catalog name or registry image every sandbox is created from, as `code` declares it. A
108    /// registry image is resolved by label to the disk image the controller built, so the value
109    /// stays one a setup package can render.
110    pub disk_image: BindingValue<String>,
111    /// Sandbox ceilings in the data plane's own units, from the declaration. Optional because a
112    /// binding from an earlier release carries none — a required field would fail to deserialize
113    /// on an already-running deployment. Absent takes the data plane's own default.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub cpu: Option<BindingValue<String>>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub memory: Option<BindingValue<String>>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub disk: Option<BindingValue<String>>,
120}
121
122/// GCP Agent Platform sandbox binding configuration.
123///
124/// Sandboxes have a durable parent to address: an Agent Engine provisioned at deploy and reached
125/// through a regional endpoint.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct GcpAgentPlatformSandboxBinding {
129    /// Agent Engine that parents every sandbox. Sandboxes are created and enumerated under it,
130    /// so a binding without it can neither reach nor reap them.
131    pub engine: BindingValue<String>,
132    /// Template every sandbox is created from. It carries the image digest, the ceilings and the
133    /// egress rules, so a sandbox created without it runs an unpinned image with none applied.
134    pub template: BindingValue<String>,
135    /// Region selecting the regional aiplatform endpoint. The engine is regional with no global
136    /// alias, so the endpoint cannot be derived without it.
137    pub region: BindingValue<String>,
138    /// Seconds a sandbox may live, from the declaration. Carried only when one was declared; an
139    /// absent value takes the service default, which is why it is not defaulted here.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub max_lifetime_seconds: Option<u32>,
142    /// Whether the declaration asked for open egress. The template enforces the policy; this lets a
143    /// reader that never sees the template tell `allow` from `deny`. Absent means `deny`.
144    #[serde(default)]
145    pub allow_egress: bool,
146}
147
148/// Kubernetes sandbox binding configuration.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct KubernetesSandboxBinding {
152    /// Namespace sandbox pods are created in
153    pub namespace: BindingValue<String>,
154    /// Runtime class every sandbox pod must carry, such as `gvisor` or `kata`
155    pub runtime_class: BindingValue<String>,
156    /// Label selector identifying this sandbox's pods, used for enumeration and reaping
157    pub selector: BindingValue<String>,
158    /// Sandbox broker served by the operator. Claiming a pod is a `PATCH` on pods, which must
159    /// not reach the application.
160    pub broker_url: BindingValue<String>,
161    /// Secret holding the capability signing key, by name. The binding names it; only the
162    /// broker can read it.
163    pub key_name: BindingValue<String>,
164    /// Where Kubernetes mounted the pod's own ServiceAccount token. A path, not a secret: the
165    /// platform put the file there and the broker verifies it with a `TokenReview`.
166    pub token_path: BindingValue<String>,
167}
168
169/// Local sandbox binding configuration.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct LocalSandboxBinding {
173    /// Loopback endpoint of the local sandbox manager
174    pub manager_url: BindingValue<String>,
175    /// Key scoping this binding's sandboxes within the manager
176    pub sandbox_key: BindingValue<String>,
177    /// File holding the route's bearer token. A locator, not the token: a binding is
178    /// serialized into the workload's environment, and a secret there is a secret in state.
179    pub token_path: BindingValue<String>,
180}
181
182impl SandboxBinding {
183    /// Creates an AWS sandbox binding.
184    pub fn aws(
185        image_arn: impl Into<BindingValue<String>>,
186        image_version: impl Into<BindingValue<String>>,
187        region: impl Into<BindingValue<String>>,
188    ) -> Self {
189        Self::Aws(AwsSandboxBinding {
190            image_arn: image_arn.into(),
191            image_version: image_version.into(),
192            region: region.into(),
193            execution_role_arn: None,
194            egress_connector_arns: Vec::new(),
195            preview_ports: Vec::new(),
196            idle_pause_seconds: None,
197            max_lifetime_seconds: None,
198            allow_egress: false,
199        })
200    }
201
202    /// Creates an Azure sandbox binding.
203    pub fn azure(
204        sandbox_group: impl Into<BindingValue<String>>,
205        data_plane_endpoint: impl Into<BindingValue<String>>,
206        region: impl Into<BindingValue<String>>,
207        resource_group: impl Into<BindingValue<String>>,
208        disk_image: impl Into<BindingValue<String>>,
209        egress: SandboxEgress,
210        idle_pause_seconds: Option<u32>,
211    ) -> Self {
212        Self::Azure(AzureSandboxBinding {
213            sandbox_group: sandbox_group.into(),
214            data_plane_endpoint: data_plane_endpoint.into(),
215            region: region.into(),
216            resource_group: resource_group.into(),
217            egress,
218            idle_pause_seconds,
219            disk_image: disk_image.into(),
220            // Ceilings are set on the struct where a caller has them; a positional argument each
221            // would make this constructor ten wide for the case that rarely carries them.
222            cpu: None,
223            memory: None,
224            disk: None,
225        })
226    }
227
228    /// Creates a GCP Agent Platform sandbox binding.
229    pub fn gcp_agent_platform(
230        engine: impl Into<BindingValue<String>>,
231        template: impl Into<BindingValue<String>>,
232        region: impl Into<BindingValue<String>>,
233        max_lifetime_seconds: Option<u32>,
234        allow_egress: bool,
235    ) -> Self {
236        Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
237            engine: engine.into(),
238            template: template.into(),
239            region: region.into(),
240            max_lifetime_seconds,
241            allow_egress,
242        })
243    }
244
245    /// Creates a Kubernetes sandbox binding.
246    pub fn kubernetes(
247        namespace: impl Into<BindingValue<String>>,
248        runtime_class: impl Into<BindingValue<String>>,
249        selector: impl Into<BindingValue<String>>,
250        broker_url: impl Into<BindingValue<String>>,
251        key_name: impl Into<BindingValue<String>>,
252        token_path: impl Into<BindingValue<String>>,
253    ) -> Self {
254        Self::Kubernetes(KubernetesSandboxBinding {
255            namespace: namespace.into(),
256            runtime_class: runtime_class.into(),
257            selector: selector.into(),
258            broker_url: broker_url.into(),
259            key_name: key_name.into(),
260            token_path: token_path.into(),
261        })
262    }
263
264    /// Creates a local sandbox binding.
265    pub fn local(
266        manager_url: impl Into<BindingValue<String>>,
267        sandbox_key: impl Into<BindingValue<String>>,
268        token_path: impl Into<BindingValue<String>>,
269    ) -> Self {
270        Self::Local(LocalSandboxBinding {
271            manager_url: manager_url.into(),
272            sandbox_key: sandbox_key.into(),
273            token_path: token_path.into(),
274        })
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::bindings::{ContainerBinding, KvBinding};
282
283    #[test]
284    fn every_variant_roundtrips() {
285        let bindings = vec![
286            SandboxBinding::aws(
287                "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
288                "3",
289                "us-east-2",
290            ),
291            SandboxBinding::azure(
292                "sbg1",
293                "https://management.swedencentral.azuredevcompute.io",
294                "swedencentral",
295                "rg",
296                "ubuntu",
297                SandboxEgress::Deny,
298                None,
299            ),
300            SandboxBinding::gcp_agent_platform(
301                "projects/p/locations/us-central1/reasoningEngines/1",
302                "projects/p/locations/us-central1/sandboxTemplates/agent",
303                "us-central1",
304                Some(3600),
305                true,
306            ),
307            SandboxBinding::kubernetes(
308                "alien-sandboxes",
309                "gvisor",
310                "alien.dev/sandbox=agent",
311                "http://alien-operator.alien.svc:8080",
312                "alien-sandbox-agent-capability",
313                "/var/run/secrets/kubernetes.io/serviceaccount/token",
314            ),
315            SandboxBinding::local(
316                "http://127.0.0.1:8931",
317                "agent",
318                "/state/sandbox-manager.token",
319            ),
320        ];
321
322        for binding in bindings {
323            let json = serde_json::to_string(&binding).expect("serializes");
324            let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
325            assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
326        }
327    }
328
329    /// The Agent Platform binding carries an egress-bearing template, so there is no safe default
330    /// for a missing required field: a binding stripped of one must fail to load rather than
331    /// deserialize into a sandbox with no image, no limits and open egress. `maxLifetimeSeconds` is
332    /// the one field that may be absent, and its absence must still parse.
333    #[test]
334    fn agent_platform_required_fields_have_no_default() {
335        let binding = SandboxBinding::gcp_agent_platform(
336            "projects/p/locations/us-central1/reasoningEngines/1",
337            "projects/p/locations/us-central1/sandboxTemplates/agent",
338            "us-central1",
339            Some(3600),
340            true,
341        );
342        let full = serde_json::to_value(&binding).expect("serializes");
343
344        for required in ["engine", "template", "region"] {
345            let mut stripped = full.clone();
346            stripped
347                .as_object_mut()
348                .expect("binding serializes as an object")
349                .remove(required)
350                .expect("the field is present before it is stripped");
351            serde_json::from_value::<SandboxBinding>(stripped)
352                .expect_err(&format!("a binding missing '{required}' must not load"));
353        }
354
355        let mut without_ttl = full;
356        without_ttl
357            .as_object_mut()
358            .expect("binding serializes as an object")
359            .remove("maxLifetimeSeconds")
360            .expect("the fixture set a ttl");
361        without_ttl
362            .as_object_mut()
363            .expect("binding serializes as an object")
364            .remove("allowEgress")
365            .expect("the fixture allowed egress");
366        let restored: SandboxBinding =
367            serde_json::from_value(without_ttl).expect("an absent ttl still loads");
368        assert_eq!(
369            restored,
370            SandboxBinding::gcp_agent_platform(
371                "projects/p/locations/us-central1/reasoningEngines/1",
372                "projects/p/locations/us-central1/sandboxTemplates/agent",
373                "us-central1",
374                None,
375                false,
376            ),
377            "an absent ttl deserializes as None and an absent allowEgress as deny"
378        );
379    }
380
381    #[test]
382    fn service_tags_are_prefixed_and_distinct() {
383        let tags: Vec<String> = vec![
384            SandboxBinding::aws("a", "1", "r"),
385            SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None),
386            SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None, false),
387            SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"),
388            SandboxBinding::local("u", "k", "t"),
389        ]
390        .iter()
391        .map(|binding| {
392            serde_json::to_value(binding).expect("serializes")["service"]
393                .as_str()
394                .expect("has a service tag")
395                .to_string()
396        })
397        .collect();
398
399        for tag in &tags {
400            assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
401        }
402
403        let mut unique = tags.clone();
404        unique.sort();
405        unique.dedup();
406        assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
407    }
408
409    /// The failure the bindings AGENTS.md warns about: with `tag = "service"`, serde picks the
410    /// variant on that field alone, so a shared tag lets one resource's binding deserialize as
411    /// another's by dropping the fields that differ.
412    #[test]
413    fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
414        let json = serde_json::to_string(&SandboxBinding::local(
415            "http://127.0.0.1:8931",
416            "agent",
417            "/state/sandbox-manager.token",
418        ))
419        .expect("serializes");
420
421        serde_json::from_str::<KvBinding>(&json)
422            .expect_err("a sandbox binding must not parse as a KV binding");
423        serde_json::from_str::<ContainerBinding>(&json)
424            .expect_err("a sandbox binding must not parse as a container binding");
425    }
426
427    #[test]
428    fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
429        let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
430            .expect("serializes");
431
432        serde_json::from_str::<SandboxBinding>(&json)
433            .expect_err("a container binding must not parse as a sandbox binding");
434    }
435}