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 sessions inside it.
4//! Session identity is never in here — sessions 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 sandbox sessions.
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 as sessions 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 this sandbox's sessions
41    pub image_arn: BindingValue<String>,
42    /// Image version. Sessions 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 session 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 session 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 session suspends, if the declaration asked for one.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub idle_suspend_seconds: Option<u32>,
67    /// Wall-clock ceiling on a session, 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 session 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    /// session 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 session suspends, 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_suspend_seconds: Option<u32>,
107    /// Catalog disk image every session is created from, taken from the declaration's `code`.
108    ///
109    /// Carried rather than hardcoded in the provider because the declaration is the only place
110    /// that knows it, and a sandbox running an image its author did not choose is the one Azure
111    /// gap that fails without an error.
112    pub disk_image: BindingValue<String>,
113    /// Session ceilings in the data plane's own units, from the declaration. Optional because a
114    /// binding from an earlier release carries none — a required field would fail to deserialize
115    /// on an already-running deployment. Absent takes the data plane's own default.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub cpu: Option<BindingValue<String>>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub memory: Option<BindingValue<String>>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub disk: Option<BindingValue<String>>,
122}
123
124/// GCP Agent Platform sandbox binding configuration.
125///
126/// Sessions have a durable parent to address: an Agent Engine provisioned at deploy and reached
127/// through a regional endpoint.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct GcpAgentPlatformSandboxBinding {
131    /// Agent Engine that parents every session. Sessions are created and enumerated under it,
132    /// so a binding without it can neither reach nor reap them.
133    pub engine: BindingValue<String>,
134    /// Template every session is created from. It carries the image digest, the ceilings and the
135    /// egress rules, so a session created without it runs an unpinned image with none applied.
136    pub template: BindingValue<String>,
137    /// Region selecting the regional aiplatform endpoint. The engine is regional with no global
138    /// alias, so the endpoint cannot be derived without it.
139    pub region: BindingValue<String>,
140    /// Seconds a session may live, from the declaration. Carried only when one was declared; an
141    /// absent value takes the service default, which is why it is not defaulted here.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub session_ttl_seconds: Option<u32>,
144}
145
146/// Kubernetes sandbox binding configuration.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct KubernetesSandboxBinding {
150    /// Namespace sandbox pods are created in
151    pub namespace: BindingValue<String>,
152    /// Runtime class every sandbox pod must carry, such as `gvisor` or `kata`
153    pub runtime_class: BindingValue<String>,
154    /// Label selector identifying this sandbox's pods, used for enumeration and reaping
155    pub selector: BindingValue<String>,
156    /// Session broker served by the operator. Claiming a pod is a `PATCH` on pods, which must
157    /// not reach the application.
158    pub broker_url: BindingValue<String>,
159    /// Secret holding the capability signing key, by name. The binding names it; only the
160    /// broker can read it.
161    pub key_name: BindingValue<String>,
162    /// Where Kubernetes mounted the pod's own ServiceAccount token. A path, not a secret: the
163    /// platform put the file there and the broker verifies it with a `TokenReview`.
164    pub token_path: BindingValue<String>,
165}
166
167/// Local sandbox binding configuration.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct LocalSandboxBinding {
171    /// Loopback endpoint of the local sandbox manager
172    pub manager_url: BindingValue<String>,
173    /// Key scoping this sandbox's sessions within the manager
174    pub sandbox_key: BindingValue<String>,
175    /// File holding the route's bearer token. A locator, not the token: a binding is
176    /// serialized into the workload's environment, and a secret there is a secret in state.
177    pub token_path: BindingValue<String>,
178}
179
180impl SandboxBinding {
181    /// Creates an AWS sandbox binding.
182    pub fn aws(
183        image_arn: impl Into<BindingValue<String>>,
184        image_version: impl Into<BindingValue<String>>,
185        region: impl Into<BindingValue<String>>,
186    ) -> Self {
187        Self::Aws(AwsSandboxBinding {
188            image_arn: image_arn.into(),
189            image_version: image_version.into(),
190            region: region.into(),
191            execution_role_arn: None,
192            egress_connector_arns: Vec::new(),
193            preview_ports: Vec::new(),
194            idle_suspend_seconds: None,
195            max_lifetime_seconds: None,
196            allow_egress: false,
197        })
198    }
199
200    /// Creates an Azure sandbox binding.
201    pub fn azure(
202        sandbox_group: impl Into<BindingValue<String>>,
203        data_plane_endpoint: impl Into<BindingValue<String>>,
204        region: impl Into<BindingValue<String>>,
205        resource_group: impl Into<BindingValue<String>>,
206        disk_image: impl Into<BindingValue<String>>,
207        egress: SandboxEgress,
208        idle_suspend_seconds: Option<u32>,
209    ) -> Self {
210        Self::Azure(AzureSandboxBinding {
211            sandbox_group: sandbox_group.into(),
212            data_plane_endpoint: data_plane_endpoint.into(),
213            region: region.into(),
214            resource_group: resource_group.into(),
215            egress,
216            idle_suspend_seconds,
217            disk_image: disk_image.into(),
218            // Ceilings are set on the struct where a caller has them; a positional argument each
219            // would make this constructor ten wide for the case that rarely carries them.
220            cpu: None,
221            memory: None,
222            disk: None,
223        })
224    }
225
226    /// Creates a GCP Agent Platform sandbox binding.
227    pub fn gcp_agent_platform(
228        engine: impl Into<BindingValue<String>>,
229        template: impl Into<BindingValue<String>>,
230        region: impl Into<BindingValue<String>>,
231        session_ttl_seconds: Option<u32>,
232    ) -> Self {
233        Self::GcpAgentPlatform(GcpAgentPlatformSandboxBinding {
234            engine: engine.into(),
235            template: template.into(),
236            region: region.into(),
237            session_ttl_seconds,
238        })
239    }
240
241    /// Creates a Kubernetes sandbox binding.
242    pub fn kubernetes(
243        namespace: impl Into<BindingValue<String>>,
244        runtime_class: impl Into<BindingValue<String>>,
245        selector: impl Into<BindingValue<String>>,
246        broker_url: impl Into<BindingValue<String>>,
247        key_name: impl Into<BindingValue<String>>,
248        token_path: impl Into<BindingValue<String>>,
249    ) -> Self {
250        Self::Kubernetes(KubernetesSandboxBinding {
251            namespace: namespace.into(),
252            runtime_class: runtime_class.into(),
253            selector: selector.into(),
254            broker_url: broker_url.into(),
255            key_name: key_name.into(),
256            token_path: token_path.into(),
257        })
258    }
259
260    /// Creates a local sandbox binding.
261    pub fn local(
262        manager_url: impl Into<BindingValue<String>>,
263        sandbox_key: impl Into<BindingValue<String>>,
264        token_path: impl Into<BindingValue<String>>,
265    ) -> Self {
266        Self::Local(LocalSandboxBinding {
267            manager_url: manager_url.into(),
268            sandbox_key: sandbox_key.into(),
269            token_path: token_path.into(),
270        })
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::bindings::{ContainerBinding, KvBinding};
278
279    #[test]
280    fn every_variant_roundtrips() {
281        let bindings = vec![
282            SandboxBinding::aws(
283                "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
284                "3",
285                "us-east-2",
286            ),
287            SandboxBinding::azure(
288                "sbg1",
289                "https://management.swedencentral.azuredevcompute.io",
290                "swedencentral",
291                "rg",
292                "ubuntu",
293                SandboxEgress::Deny,
294                None,
295            ),
296            SandboxBinding::gcp_agent_platform(
297                "projects/p/locations/us-central1/reasoningEngines/1",
298                "projects/p/locations/us-central1/sandboxTemplates/agent",
299                "us-central1",
300                Some(3600),
301            ),
302            SandboxBinding::kubernetes(
303                "alien-sandboxes",
304                "gvisor",
305                "alien.dev/sandbox=agent",
306                "http://alien-operator.alien.svc:8080",
307                "alien-sandbox-agent-capability",
308                "/var/run/secrets/kubernetes.io/serviceaccount/token",
309            ),
310            SandboxBinding::local(
311                "http://127.0.0.1:8931",
312                "agent",
313                "/state/sandbox-manager.token",
314            ),
315        ];
316
317        for binding in bindings {
318            let json = serde_json::to_string(&binding).expect("serializes");
319            let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
320            assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
321        }
322    }
323
324    /// The Agent Platform binding carries an egress-bearing template, so there is no safe default
325    /// for a missing required field: a binding stripped of one must fail to load rather than
326    /// deserialize into a session with no image, no limits and open egress. `sessionTtlSeconds` is
327    /// the one field that may be absent, and its absence must still parse.
328    #[test]
329    fn agent_platform_required_fields_have_no_default() {
330        let binding = SandboxBinding::gcp_agent_platform(
331            "projects/p/locations/us-central1/reasoningEngines/1",
332            "projects/p/locations/us-central1/sandboxTemplates/agent",
333            "us-central1",
334            Some(3600),
335        );
336        let full = serde_json::to_value(&binding).expect("serializes");
337
338        for required in ["engine", "template", "region"] {
339            let mut stripped = full.clone();
340            stripped
341                .as_object_mut()
342                .expect("binding serializes as an object")
343                .remove(required)
344                .expect("the field is present before it is stripped");
345            serde_json::from_value::<SandboxBinding>(stripped)
346                .expect_err(&format!("a binding missing '{required}' must not load"));
347        }
348
349        let mut without_ttl = full;
350        without_ttl
351            .as_object_mut()
352            .expect("binding serializes as an object")
353            .remove("sessionTtlSeconds")
354            .expect("the fixture set a ttl");
355        let restored: SandboxBinding =
356            serde_json::from_value(without_ttl).expect("an absent ttl still loads");
357        assert_eq!(
358            restored,
359            SandboxBinding::gcp_agent_platform(
360                "projects/p/locations/us-central1/reasoningEngines/1",
361                "projects/p/locations/us-central1/sandboxTemplates/agent",
362                "us-central1",
363                None,
364            ),
365            "an absent ttl deserializes as None"
366        );
367    }
368
369    #[test]
370    fn service_tags_are_prefixed_and_distinct() {
371        let tags: Vec<String> = vec![
372            SandboxBinding::aws("a", "1", "r"),
373            SandboxBinding::azure("g", "e", "r", "rg", "ubuntu", SandboxEgress::Deny, None),
374            SandboxBinding::gcp_agent_platform("e", "t", "us-central1", None),
375            SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"),
376            SandboxBinding::local("u", "k", "t"),
377        ]
378        .iter()
379        .map(|binding| {
380            serde_json::to_value(binding).expect("serializes")["service"]
381                .as_str()
382                .expect("has a service tag")
383                .to_string()
384        })
385        .collect();
386
387        for tag in &tags {
388            assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
389        }
390
391        let mut unique = tags.clone();
392        unique.sort();
393        unique.dedup();
394        assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
395    }
396
397    /// The failure the bindings AGENTS.md warns about: with `tag = "service"`, serde picks the
398    /// variant on that field alone, so a shared tag lets one resource's binding deserialize as
399    /// another's by dropping the fields that differ.
400    #[test]
401    fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
402        let json = serde_json::to_string(&SandboxBinding::local(
403            "http://127.0.0.1:8931",
404            "agent",
405            "/state/sandbox-manager.token",
406        ))
407        .expect("serializes");
408
409        serde_json::from_str::<KvBinding>(&json)
410            .expect_err("a sandbox binding must not parse as a KV binding");
411        serde_json::from_str::<ContainerBinding>(&json)
412            .expect_err("a sandbox binding must not parse as a container binding");
413    }
414
415    #[test]
416    fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
417        let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
418            .expect("serializes");
419
420        serde_json::from_str::<SandboxBinding>(&json)
421            .expect_err("a container binding must not parse as a sandbox binding");
422    }
423}