Skip to main content

alien_bindings/providers/sandbox/
kubernetes.rs

1//! Kubernetes sandbox provider: a pod under a sandboxed runtime class, reached over the agent
2//! protocol.
3//!
4//! The application never holds a cluster credential. It asks the operator's broker for a
5//! sandbox, and gets back a pod address plus a capability scoped to that sandbox. Claiming a pod
6//! is a `PATCH` on pods, which does not belong in the binding: `pods/exec`
7//! would reach every pod in the namespace.
8//!
9//! It authenticates to the broker with the ServiceAccount token Kubernetes already mounted in
10//! its pod. Nothing of Alien's is created, rotated or torn down for this.
11
12use std::collections::BTreeMap;
13use std::sync::Mutex;
14
15use async_trait::async_trait;
16use futures::stream::BoxStream;
17use serde::{Deserialize, Serialize};
18
19use crate::error::{ErrorData, Result};
20use crate::providers::sandbox::agent_protocol::{self, AgentTransport};
21use crate::traits::{
22    Binding, CommandOutput, CreateSandboxRequest, JobPoll, JobStart, PreviewCapability,
23    ResolvedSandbox, RunCommandRequest, Sandbox, SandboxInstance, SandboxState,
24};
25use alien_core::bindings::KubernetesSandboxBinding;
26use alien_core::{Platform, SandboxCapabilities, SandboxCapability};
27use alien_error::{AlienError, Context, IntoAlienError};
28
29/// What the broker hands back for a claimed sandbox.
30#[derive(Debug, Clone, Deserialize)]
31#[serde(rename_all = "camelCase")]
32struct ClaimResponse {
33    session_id: String,
34    endpoint: String,
35    capability: String,
36    expires_at: i64,
37}
38
39#[derive(Debug, Serialize)]
40#[serde(rename_all = "camelCase")]
41struct ClaimRequest<'a> {
42    sandbox_id: &'a str,
43    session_id: &'a str,
44}
45
46/// A Sandbox backed by pods under a sandboxed runtime class.
47#[derive(Debug)]
48pub struct KubernetesSandbox {
49    resource_id: String,
50    broker_url: String,
51    token_path: String,
52    binding_name: String,
53    client: reqwest::Client,
54    /// Claims this process has made, so a later call can address the sandbox it already has.
55    ///
56    /// The capability is short-lived and the endpoint is a pod IP, so this is a cache of live
57    /// sandboxes rather than durable state. A sandbox this process did not claim is not
58    /// reachable, which is what `reconnect` means here.
59    claims: Mutex<BTreeMap<String, ClaimResponse>>,
60}
61
62impl KubernetesSandbox {
63    /// Builds a provider from its binding.
64    pub fn new(
65        binding_name: &str,
66        binding: &KubernetesSandboxBinding,
67        resource_id: &str,
68    ) -> Result<Self> {
69        let value = |field: &'static str, value: alien_core::bindings::BindingValue<String>| {
70            value.into_value(binding_name, field).map_err(|error| {
71                AlienError::new(ErrorData::BindingConfigInvalid {
72                    binding_name: binding_name.to_string(),
73                    env_var: alien_core::bindings::binding_env_var_name(binding_name),
74                    reason: error.to_string(),
75                })
76            })
77        };
78
79        Ok(Self {
80            resource_id: resource_id.to_string(),
81            broker_url: value("brokerUrl", binding.broker_url.clone())?
82                .trim_end_matches('/')
83                .to_string(),
84            token_path: value("tokenPath", binding.token_path.clone())?,
85            binding_name: binding_name.to_string(),
86            client: reqwest::Client::new(),
87            claims: Mutex::new(BTreeMap::new()),
88        })
89    }
90
91    /// Reads the pod's ServiceAccount token.
92    ///
93    /// Read per call rather than cached: Kubernetes rotates projected tokens in place, and a
94    /// cached copy becomes a token the apiserver refuses at the least convenient moment.
95    async fn identity_token(&self) -> Result<String> {
96        tokio::fs::read_to_string(&self.token_path)
97            .await
98            .into_alien_error()
99            .context(ErrorData::BindingConfigInvalid {
100                binding_name: self.binding_name.clone(),
101                env_var: alien_core::bindings::binding_env_var_name(&self.binding_name),
102                reason: format!(
103                    "could not read the ServiceAccount token at '{}'",
104                    self.token_path
105                ),
106            })
107    }
108
109    /// Refused rather than dropped: a claim reaches a pod that is already running under the
110    /// deadline the declaration fixed, so a lifetime accepted here would never be applied.
111    fn refuse_create_time_lifetime(&self, request: &CreateSandboxRequest) -> Result<()> {
112        if request.timeout_ms.is_none() {
113            return Ok(());
114        }
115        Err(self.failed(
116            SandboxCapability::SandboxLifetime.as_str(),
117            "a pod is claimed from a warm pool already running under the deadline the sandbox \
118             declared, so a claim cannot choose its own",
119        ))
120    }
121
122    fn claimed(&self, sandbox_id: &str) -> Option<ClaimResponse> {
123        self.claims
124            .lock()
125            .expect("no panic holds this lock")
126            .get(sandbox_id)
127            .cloned()
128    }
129
130    fn failed(&self, operation: &str, reason: &str) -> AlienError<ErrorData> {
131        AlienError::new(ErrorData::OperationNotSupported {
132            operation: operation.to_string(),
133            reason: reason.to_string(),
134        })
135    }
136}
137
138#[async_trait]
139impl AgentTransport for KubernetesSandbox {
140    async fn request(
141        &self,
142        sandbox_id: &str,
143        method: reqwest::Method,
144        path: &str,
145    ) -> Result<reqwest::RequestBuilder> {
146        let claim = self.claimed(sandbox_id).ok_or_else(|| {
147            self.failed(
148                "sandbox.agent",
149                &format!(
150                    "sandbox '{sandbox_id}' was not claimed by this process; a pod IP and a \
151                     capability are only reachable by the caller that claimed them"
152                ),
153            )
154        })?;
155
156        if claim.expires_at <= chrono::Utc::now().timestamp() {
157            return Err(self.failed(
158                "sandbox.agent",
159                &format!(
160                    "the capability for sandbox '{sandbox_id}' expired; the agent would refuse \
161                     this with a 401 that reads like a broken sandbox"
162                ),
163            ));
164        }
165
166        Ok(self
167            .client
168            .request(method, format!("{}{path}", claim.endpoint))
169            .bearer_auth(claim.capability))
170    }
171
172    fn provider(&self) -> &'static str {
173        "kubernetes-sandbox"
174    }
175}
176
177impl Binding for KubernetesSandbox {}
178
179#[async_trait]
180impl Sandbox for KubernetesSandbox {
181    fn as_any(&self) -> &dyn std::any::Any {
182        self
183    }
184
185    /// Narrows the platform ceiling to this binding: the pool's pods carry
186    /// `activeDeadlineSeconds` from the declaration and are already running when a claim reaches
187    /// them, so nothing a create says can set a deadline. The declared ceiling still applies.
188    fn capabilities(&self) -> SandboxCapabilities {
189        let mut capabilities = SandboxCapabilities::for_platform(Platform::Kubernetes)
190            .expect("Kubernetes has a sandbox backend");
191        capabilities.sandbox_lifetime = false;
192        capabilities
193    }
194
195    /// Claims a warm pod through the broker.
196    async fn create(&self, request: CreateSandboxRequest) -> Result<SandboxInstance> {
197        self.refuse_create_time_lifetime(&request)?;
198        let sandbox_id = request
199            .sandbox_id
200            .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
201
202        let response = self
203            .client
204            .post(format!("{}/v1/sandbox/sessions", self.broker_url))
205            .bearer_auth(self.identity_token().await?)
206            .json(&ClaimRequest {
207                sandbox_id: &self.resource_id,
208                session_id: &sandbox_id,
209            })
210            .send()
211            .await
212            .into_alien_error()
213            .context(ErrorData::OperationNotSupported {
214                operation: "sandbox.create".to_string(),
215                reason: "the sandbox broker is unreachable".to_string(),
216            })?;
217
218        if !response.status().is_success() {
219            let status = response.status();
220            let body = response.text().await.unwrap_or_default();
221            // 503 is the pool being empty, which refills on the controller's next health tick.
222            // Saying so is the difference between a caller retrying and a caller giving up.
223            return Err(self.failed(
224                "sandbox.create",
225                &format!("the sandbox broker returned {status}: {body}"),
226            ));
227        }
228
229        let claim: ClaimResponse = response.json().await.into_alien_error().context(
230            ErrorData::UnexpectedResponseFormat {
231                provider: "kubernetes-sandbox".to_string(),
232                binding_name: "sandbox.create".to_string(),
233                field: "body".to_string(),
234                response_json: "the broker returned a body this provider cannot parse".to_string(),
235            },
236        )?;
237
238        self.claims
239            .lock()
240            .expect("no panic holds this lock")
241            .insert(claim.session_id.clone(), claim.clone());
242
243        Ok(SandboxInstance {
244            sandbox_id: claim.session_id,
245            state: SandboxState::Running,
246            // A released pod is deleted rather than fenced, so a sandbox never outlives its own
247            // generation.
248            generation: 1,
249        })
250    }
251
252    /// Only sandboxes this process claimed are addressable.
253    ///
254    /// A capability is minted to the caller that claimed the pod, so another process holding the
255    /// same sandbox id has nothing to reach it with. Returning `None` rather than erroring: the
256    /// sandbox may well exist, this caller simply cannot address it.
257    async fn get(&self, sandbox_id: &str) -> Result<Option<SandboxInstance>> {
258        Ok(self.claimed(sandbox_id).map(|claim| SandboxInstance {
259            sandbox_id: claim.session_id,
260            state: SandboxState::Running,
261            generation: 1,
262        }))
263    }
264
265    async fn get_or_create(&self, request: CreateSandboxRequest) -> Result<ResolvedSandbox> {
266        // Refused on the reconnect path too: a claimed pod honors a lifetime no more than a
267        // freshly claimed one would.
268        self.refuse_create_time_lifetime(&request)?;
269        if let Some(id) = request.sandbox_id.as_deref() {
270            if let Some(existing) = self.get(id).await? {
271                return Ok(ResolvedSandbox::found(existing));
272            }
273        }
274
275        self.create(request).await.map(ResolvedSandbox::created)
276    }
277
278    async fn list(&self) -> Result<Vec<SandboxInstance>> {
279        Ok(self
280            .claims
281            .lock()
282            .expect("no panic holds this lock")
283            .values()
284            .map(|claim| SandboxInstance {
285                sandbox_id: claim.session_id.clone(),
286                state: SandboxState::Running,
287                generation: 1,
288            })
289            .collect())
290    }
291
292    async fn run_command(
293        &self,
294        sandbox_id: &str,
295        request: RunCommandRequest,
296    ) -> Result<BoxStream<'static, Result<CommandOutput>>> {
297        agent_protocol::run_command(self, sandbox_id, request).await
298    }
299
300    async fn start_job(&self, sandbox_id: &str, request: RunCommandRequest) -> Result<JobStart> {
301        agent_protocol::start_job(self, sandbox_id, request).await
302    }
303
304    async fn poll_job(
305        &self,
306        sandbox_id: &str,
307        job_id: &str,
308        since_seq: Option<u64>,
309    ) -> Result<JobPoll> {
310        agent_protocol::poll_job(self, sandbox_id, job_id, since_seq).await
311    }
312
313    async fn cancel_job(&self, sandbox_id: &str, job_id: &str) -> Result<()> {
314        agent_protocol::cancel_job(self, sandbox_id, job_id).await
315    }
316
317    async fn read_file(&self, sandbox_id: &str, path: &str) -> Result<Vec<u8>> {
318        agent_protocol::read_file(self, sandbox_id, path).await
319    }
320
321    async fn write_files(&self, sandbox_id: &str, files: BTreeMap<String, Vec<u8>>) -> Result<()> {
322        agent_protocol::write_files(self, sandbox_id, files).await
323    }
324
325    async fn preview(&self, _sandbox_id: &str, _port: u16) -> Result<PreviewCapability> {
326        Err(self.failed(
327            "preview",
328            "preview needs a gateway that validates a sandbox-and-port capability, which this \
329             backend has none of",
330        ))
331    }
332
333    async fn pause(&self, _sandbox_id: &str) -> Result<()> {
334        Err(self.failed("pauseResume", "a pod cannot be paused and resumed"))
335    }
336
337    async fn resume(&self, _sandbox_id: &str) -> Result<()> {
338        Err(self.failed("pauseResume", "a pod cannot be paused and resumed"))
339    }
340
341    async fn snapshot(&self, _sandbox_id: &str) -> Result<String> {
342        Err(self.failed("snapshot", "a pod has no snapshot primitive"))
343    }
344
345    /// Releases the sandbox, which deletes its pod.
346    ///
347    /// Idempotent: a sandbox this process never claimed is already in the desired end state.
348    async fn terminate(&self, sandbox_id: &str) -> Result<()> {
349        let Some(claim) = self.claimed(sandbox_id) else {
350            return Ok(());
351        };
352
353        let response = self
354            .client
355            .delete(format!(
356                "{}/v1/sandbox/{}/sessions/{}",
357                self.broker_url, self.resource_id, claim.session_id
358            ))
359            .bearer_auth(self.identity_token().await?)
360            .send()
361            .await
362            .into_alien_error()
363            .context(ErrorData::OperationNotSupported {
364                operation: "sandbox.terminate".to_string(),
365                reason: "the sandbox broker is unreachable".to_string(),
366            })?;
367
368        if !response.status().is_success() {
369            let status = response.status();
370            let body = response.text().await.unwrap_or_default();
371            return Err(self.failed(
372                "sandbox.terminate",
373                &format!("the sandbox broker returned {status}: {body}"),
374            ));
375        }
376
377        self.claims
378            .lock()
379            .expect("no panic holds this lock")
380            .remove(sandbox_id);
381
382        Ok(())
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use alien_core::bindings::BindingValue;
390    use axum::extract::{Path, Request};
391    use axum::http::StatusCode;
392    use axum::routing::{delete, post};
393    use axum::{Json, Router};
394    use std::net::SocketAddr;
395    use std::sync::Arc;
396
397    /// Copied verbatim from `broker_router` in `alien-infra`'s `sandbox::kubernetes_route`, so a
398    /// client path the broker does not route falls through to the fallback below.
399    const BROKER_RELEASE_ROUTE: &str = "/v1/sandbox/{sandbox}/sessions/{session}";
400
401    async fn serve(router: Router) -> String {
402        let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
403            .await
404            .expect("bind");
405        let address = listener.local_addr().expect("address");
406        tokio::spawn(async move {
407            axum::serve(listener, router).await.expect("serve");
408        });
409        format!("http://{address}")
410    }
411
412    fn sandbox_over(broker: String, token: &tempfile::NamedTempFile) -> KubernetesSandbox {
413        std::fs::write(token.path(), "service-account-token").expect("the token is written");
414        KubernetesSandbox::new(
415            "sbx",
416            &KubernetesSandboxBinding {
417                namespace: BindingValue::Value("alien".to_string()),
418                runtime_class: BindingValue::Value("gvisor".to_string()),
419                selector: BindingValue::Value("alien/sandbox=sbx-pool".to_string()),
420                broker_url: BindingValue::Value(broker),
421                key_name: BindingValue::Value("sandbox-key".to_string()),
422                token_path: BindingValue::Value(token.path().display().to_string()),
423            },
424            "sbx-pool",
425        )
426        .expect("the binding is complete")
427    }
428
429    /// Terminate releases the claim at the route the broker serves, with the resource and the
430    /// session in the order it serves them.
431    ///
432    /// The broker is the only thing that can delete the pod, so a DELETE to a path it does not
433    /// route leaves the sandbox running and the claim held while the caller is told it is gone.
434    /// Mutation check: change the path in `terminate` and the fallback records the request.
435    #[tokio::test]
436    async fn terminate_releases_at_the_route_the_broker_serves() {
437        let released = Arc::new(Mutex::new(Vec::new()));
438        let unrouted = Arc::new(Mutex::new(Vec::new()));
439        let released_seen = Arc::clone(&released);
440        let unrouted_seen = Arc::clone(&unrouted);
441
442        let broker = serve(
443            Router::new()
444                .route(
445                    "/v1/sandbox/sessions",
446                    post(|| async {
447                        Json(serde_json::json!({
448                            "sessionId": "s1",
449                            "endpoint": "http://10.0.0.1:8080",
450                            "capability": "cap",
451                            "expiresAt": chrono::Utc::now().timestamp() + 300,
452                        }))
453                    }),
454                )
455                .route(
456                    BROKER_RELEASE_ROUTE,
457                    delete(move |Path(addressed): Path<(String, String)>| {
458                        let released = Arc::clone(&released);
459                        async move {
460                            released
461                                .lock()
462                                .expect("no panic holds this lock")
463                                .push(addressed);
464                            StatusCode::NO_CONTENT
465                        }
466                    }),
467                )
468                .fallback(move |request: Request| {
469                    let unrouted = Arc::clone(&unrouted);
470                    async move {
471                        unrouted
472                            .lock()
473                            .expect("no panic holds this lock")
474                            .push(format!("{} {}", request.method(), request.uri().path()));
475                        StatusCode::NOT_FOUND
476                    }
477                }),
478        )
479        .await;
480
481        let token = tempfile::NamedTempFile::new().expect("a token file");
482        let sandbox = sandbox_over(broker, &token);
483
484        let claimed = sandbox
485            .create(CreateSandboxRequest {
486                sandbox_id: Some("s1".to_string()),
487                tenant_key: None,
488                env: BTreeMap::new(),
489                ..Default::default()
490            })
491            .await
492            .expect("the broker claims a pod");
493
494        let released_claim = sandbox.terminate(&claimed.sandbox_id).await;
495
496        assert!(
497            unrouted_seen
498                .lock()
499                .expect("no panic holds this lock")
500                .is_empty(),
501            "terminate reached a path the broker does not route: {:?}",
502            unrouted_seen.lock().expect("no panic holds this lock")
503        );
504        released_claim.expect("the broker releases the claim");
505        assert_eq!(
506            *released_seen.lock().expect("no panic holds this lock"),
507            vec![("sbx-pool".to_string(), "s1".to_string())],
508            "the release names the resource and then the session"
509        );
510        assert!(
511            sandbox
512                .get(&claimed.sandbox_id)
513                .await
514                .expect("a released sandbox reads back")
515                .is_none(),
516            "a released sandbox is no longer claimed"
517        );
518    }
519}