1use 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#[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#[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: Mutex<BTreeMap<String, ClaimResponse>>,
60}
61
62impl KubernetesSandbox {
63 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 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 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 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 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 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 generation: 1,
249 })
250 }
251
252 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 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 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 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 #[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}