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