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 serde::{Deserialize, Serialize};
9
10/// Represents a sandbox binding for creating and reaching sandbox sessions.
11///
12/// Service tags are prefixed with `sandbox-` because serde selects the variant on the `service`
13/// field alone. An unprefixed `local` would deserialize as another resource's local binding by
14/// silently dropping the fields that differ.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "service")]
17pub enum SandboxBinding {
18    /// AWS Lambda MicroVM sandboxes
19    #[serde(rename = "sandbox-aws")]
20    Aws(AwsSandboxBinding),
21    /// Azure Container Apps Sandboxes
22    #[serde(rename = "sandbox-azure")]
23    Azure(AzureSandboxBinding),
24    /// Cloud Run sandboxes, launched inside the workload's own instance
25    #[serde(rename = "sandbox-gcp")]
26    Gcp(GcpSandboxBinding),
27    /// Sandbox pods under a sandboxed runtime class
28    #[serde(rename = "sandbox-kubernetes")]
29    Kubernetes(KubernetesSandboxBinding),
30    /// Local Docker sandboxes managed by the local sandbox manager
31    #[serde(rename = "sandbox-local")]
32    Local(LocalSandboxBinding),
33}
34
35/// AWS sandbox binding configuration.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct AwsSandboxBinding {
39    /// MicroVM image ARN that scopes this sandbox's sessions
40    pub image_arn: BindingValue<String>,
41    /// Image version. Sessions are enumerated by image and version together, so a rolled
42    /// version remains a cleanup scope until its own MicroVMs are gone.
43    pub image_version: BindingValue<String>,
44    /// Region the MicroVMs run in
45    pub region: BindingValue<String>,
46    /// Execution role attached to each MicroVM, distinct from the workload's own role
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub execution_role_arn: Option<BindingValue<String>>,
49    /// Egress connectors every session is started with.
50    ///
51    /// Carried rather than implied: a MicroVM started with no connector reaches the public
52    /// internet, so an empty list here is `allow`, not `deny`. The declared mode is realised by
53    /// which connector setup built, and the session has to be started with it.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub egress_connector_arns: Vec<BindingValue<String>>,
56    /// Ports a preview capability may be minted for.
57    ///
58    /// Carried because the token is what grants ingress: `CreateMicrovmAuthToken` mints access to
59    /// whatever port it is asked for, so "a port not listed here can never be exposed" is only
60    /// true if the declared list reaches the code that mints.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub preview_ports: Vec<u16>,
63    /// Idle seconds after which a session suspends, if the declaration asked for one.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub idle_suspend_seconds: Option<u32>,
66    /// Wall-clock ceiling on a session, if the declaration asked for one.
67    ///
68    /// Enforced by Lambda rather than by us: `RunMicrovm` takes it as
69    /// `maximumDurationInSeconds` and terminates the MicroVM when it elapses.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub max_lifetime_seconds: Option<u32>,
72    /// Whether the declaration asked for open egress.
73    ///
74    /// Carried because an empty connector list cannot otherwise be read: a MicroVM started with
75    /// no connector reaches the internet, so a `deny` binding stripped of its connectors would be
76    /// indistinguishable from `allow`. Absent means `deny`, which is the answer that fails closed.
77    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
78    pub allow_egress: bool,
79}
80
81/// Azure sandbox binding configuration.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct AzureSandboxBinding {
85    /// Sandbox group that scopes every sandbox, image, snapshot and secret
86    pub sandbox_group: BindingValue<String>,
87    /// ADC data-plane endpoint, which is separate from the ARM control plane
88    pub data_plane_endpoint: BindingValue<String>,
89    /// Region the sandbox group lives in; selects the per-region ADC endpoint
90    pub region: BindingValue<String>,
91    /// Resource group the sandbox group sits in. The data-plane path is scoped by it, and the
92    /// Azure client config does not carry one.
93    pub resource_group: BindingValue<String>,
94}
95
96/// GCP sandbox binding configuration.
97///
98/// There is no durable parent to address: a Cloud Run sandbox is a subprocess of the workload's
99/// own instance, created through a CLI on the container's filesystem.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct GcpSandboxBinding {
103    /// Path to the sandbox CLI inside the Cloud Run container
104    pub launcher_path: BindingValue<String>,
105    /// Whether sandboxes may reach the network. Carried in the binding rather than passed per
106    /// create: the launcher takes `--allow-egress` per sandbox, and a limit the application
107    /// supplies is a limit it can decline to supply.
108    pub allow_egress: BindingValue<bool>,
109}
110
111/// Kubernetes sandbox binding configuration.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct KubernetesSandboxBinding {
115    /// Namespace sandbox pods are created in
116    pub namespace: BindingValue<String>,
117    /// Runtime class every sandbox pod must carry, such as `gvisor` or `kata`
118    pub runtime_class: BindingValue<String>,
119    /// Label selector identifying this sandbox's pods, used for enumeration and reaping
120    pub selector: BindingValue<String>,
121    /// Session broker served by the operator. Claiming a pod is a `PATCH` on pods, which must
122    /// not reach the application.
123    pub broker_url: BindingValue<String>,
124    /// Secret holding the capability signing key, by name. The binding names it; only the
125    /// broker can read it.
126    pub key_name: BindingValue<String>,
127    /// Where Kubernetes mounted the pod's own ServiceAccount token. A path, not a secret: the
128    /// platform put the file there and the broker verifies it with a `TokenReview`.
129    pub token_path: BindingValue<String>,
130}
131
132/// Local sandbox binding configuration.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct LocalSandboxBinding {
136    /// Loopback endpoint of the local sandbox manager
137    pub manager_url: BindingValue<String>,
138    /// Key scoping this sandbox's sessions within the manager
139    pub sandbox_key: BindingValue<String>,
140    /// File holding the route's bearer token. A locator, not the token: a binding is
141    /// serialized into the workload's environment, and a secret there is a secret in state.
142    pub token_path: BindingValue<String>,
143}
144
145impl SandboxBinding {
146    /// Creates an AWS sandbox binding.
147    pub fn aws(
148        image_arn: impl Into<BindingValue<String>>,
149        image_version: impl Into<BindingValue<String>>,
150        region: impl Into<BindingValue<String>>,
151    ) -> Self {
152        Self::Aws(AwsSandboxBinding {
153            image_arn: image_arn.into(),
154            image_version: image_version.into(),
155            region: region.into(),
156            execution_role_arn: None,
157            egress_connector_arns: Vec::new(),
158            preview_ports: Vec::new(),
159            idle_suspend_seconds: None,
160            max_lifetime_seconds: None,
161            allow_egress: false,
162        })
163    }
164
165    /// Creates an Azure sandbox binding.
166    pub fn azure(
167        sandbox_group: impl Into<BindingValue<String>>,
168        data_plane_endpoint: impl Into<BindingValue<String>>,
169        region: impl Into<BindingValue<String>>,
170        resource_group: impl Into<BindingValue<String>>,
171    ) -> Self {
172        Self::Azure(AzureSandboxBinding {
173            sandbox_group: sandbox_group.into(),
174            data_plane_endpoint: data_plane_endpoint.into(),
175            region: region.into(),
176            resource_group: resource_group.into(),
177        })
178    }
179
180    /// Creates a GCP sandbox binding.
181    pub fn gcp(
182        launcher_path: impl Into<BindingValue<String>>,
183        allow_egress: impl Into<BindingValue<bool>>,
184    ) -> Self {
185        Self::Gcp(GcpSandboxBinding {
186            launcher_path: launcher_path.into(),
187            allow_egress: allow_egress.into(),
188        })
189    }
190
191    /// Creates a Kubernetes sandbox binding.
192    pub fn kubernetes(
193        namespace: impl Into<BindingValue<String>>,
194        runtime_class: impl Into<BindingValue<String>>,
195        selector: impl Into<BindingValue<String>>,
196        broker_url: impl Into<BindingValue<String>>,
197        key_name: impl Into<BindingValue<String>>,
198        token_path: impl Into<BindingValue<String>>,
199    ) -> Self {
200        Self::Kubernetes(KubernetesSandboxBinding {
201            namespace: namespace.into(),
202            runtime_class: runtime_class.into(),
203            selector: selector.into(),
204            broker_url: broker_url.into(),
205            key_name: key_name.into(),
206            token_path: token_path.into(),
207        })
208    }
209
210    /// Creates a local sandbox binding.
211    pub fn local(
212        manager_url: impl Into<BindingValue<String>>,
213        sandbox_key: impl Into<BindingValue<String>>,
214        token_path: impl Into<BindingValue<String>>,
215    ) -> Self {
216        Self::Local(LocalSandboxBinding {
217            manager_url: manager_url.into(),
218            sandbox_key: sandbox_key.into(),
219            token_path: token_path.into(),
220        })
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::bindings::{ContainerBinding, KvBinding};
228
229    #[test]
230    fn every_variant_roundtrips() {
231        let bindings = vec![
232            SandboxBinding::aws(
233                "arn:aws:lambda:us-east-2:1:microvm-image:sbx",
234                "3",
235                "us-east-2",
236            ),
237            SandboxBinding::azure(
238                "sbg1",
239                "https://management.swedencentral.azuredevcompute.io",
240                "swedencentral",
241                "rg",
242            ),
243            SandboxBinding::gcp("/usr/local/gcp/bin/sandbox", false),
244            SandboxBinding::kubernetes(
245                "alien-sandboxes",
246                "gvisor",
247                "alien.dev/sandbox=agent",
248                "http://alien-operator.alien.svc:8080",
249                "alien-sandbox-agent-capability",
250                "/var/run/secrets/kubernetes.io/serviceaccount/token",
251            ),
252            SandboxBinding::local(
253                "http://127.0.0.1:8931",
254                "agent",
255                "/state/sandbox-manager.token",
256            ),
257        ];
258
259        for binding in bindings {
260            let json = serde_json::to_string(&binding).expect("serializes");
261            let restored: SandboxBinding = serde_json::from_str(&json).expect("deserializes");
262            assert_eq!(binding, restored, "roundtrip changed the binding: {json}");
263        }
264    }
265
266    #[test]
267    fn service_tags_are_prefixed_and_distinct() {
268        let tags: Vec<String> = vec![
269            SandboxBinding::aws("a", "1", "r"),
270            SandboxBinding::azure("g", "e", "r", "rg"),
271            SandboxBinding::gcp("p", true),
272            SandboxBinding::kubernetes("n", "gvisor", "s", "http://op:8080", "k", "/t"),
273            SandboxBinding::local("u", "k", "t"),
274        ]
275        .iter()
276        .map(|binding| {
277            serde_json::to_value(binding).expect("serializes")["service"]
278                .as_str()
279                .expect("has a service tag")
280                .to_string()
281        })
282        .collect();
283
284        for tag in &tags {
285            assert!(tag.starts_with("sandbox-"), "tag '{tag}' is not namespaced");
286        }
287
288        let mut unique = tags.clone();
289        unique.sort();
290        unique.dedup();
291        assert_eq!(unique.len(), tags.len(), "duplicate service tags: {tags:?}");
292    }
293
294    /// The failure the bindings AGENTS.md warns about: with `tag = "service"`, serde picks the
295    /// variant on that field alone, so a shared tag lets one resource's binding deserialize as
296    /// another's by dropping the fields that differ.
297    #[test]
298    fn a_sandbox_binding_cannot_deserialize_as_another_resource() {
299        let json = serde_json::to_string(&SandboxBinding::local(
300            "http://127.0.0.1:8931",
301            "agent",
302            "/state/sandbox-manager.token",
303        ))
304        .expect("serializes");
305
306        serde_json::from_str::<KvBinding>(&json)
307            .expect_err("a sandbox binding must not parse as a KV binding");
308        serde_json::from_str::<ContainerBinding>(&json)
309            .expect_err("a sandbox binding must not parse as a container binding");
310    }
311
312    #[test]
313    fn another_resource_binding_cannot_deserialize_as_a_sandbox() {
314        let json = serde_json::to_string(&ContainerBinding::local("api", "http://api.svc:8080"))
315            .expect("serializes");
316
317        serde_json::from_str::<SandboxBinding>(&json)
318            .expect_err("a container binding must not parse as a sandbox binding");
319    }
320}