Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/alien-test/src/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ pub enum Binding {
Vault,
/// Managed Postgres database (Aurora, Cloud SQL, Flexible Server, embedded pgvector on Local)
Postgres,
/// Sandbox session: run a command, move a file in and out, terminate
Sandbox,
/// Message queue (SQS, Pub/Sub, Service Bus)
Queue,
/// Direct worker-to-worker invocation
Expand Down Expand Up @@ -225,6 +227,7 @@ impl std::fmt::Display for Binding {
Binding::Kv => write!(f, "kv"),
Binding::Vault => write!(f, "vault"),
Binding::Postgres => write!(f, "postgres"),
Binding::Sandbox => write!(f, "sandbox"),
Binding::Queue => write!(f, "queue"),
Binding::Worker => write!(f, "worker"),
Binding::Container => write!(f, "container"),
Expand Down Expand Up @@ -304,6 +307,9 @@ pub fn supported_bindings(platform: Platform, model: DeploymentModel) -> Vec<Bin
// Only the embedded Local controller ships in this repo, so Postgres is
// exercised on Local only.
bindings.push(Binding::Postgres);
// Same reason for Sandbox: the cloud sandbox controllers are not in this repo, so
// the cloud matrix is covered by recorded live runs rather than by this suite.
bindings.push(Binding::Sandbox);
}
_ => {}
}
Expand Down
41 changes: 41 additions & 0 deletions crates/alien-test/tests/common/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use tracing::info;
/// The binding name used in test app stack configurations.
pub(super) const STORAGE_BINDING: &str = "alien-storage";
const KV_BINDING: &str = "alien-kv";
const SANDBOX_BINDING: &str = "alien-sandbox";
const VAULT_BINDING: &str = "alien-vault";
const POSTGRES_BINDING: &str = "alien-postgres";
const QUEUE_BINDING: &str = "alien-queue";
Expand Down Expand Up @@ -1081,3 +1082,43 @@ pub async fn check_ai(deployment: &TestDeployment) -> anyhow::Result<()> {
);
Ok(())
}

/// Runs a real sandbox session through the deployed app.
///
/// The app creates a session, runs a command, moves a file both directions and terminates. A
/// failure anywhere in that chain comes back as a non-2xx, so the assertion here is the whole
/// chain rather than any one call.
pub async fn check_sandbox(deployment: &TestDeployment) -> anyhow::Result<()> {
let url = deployment_url(deployment)?;
info!("Checking sandbox binding");

let client = reqwest::Client::new();
let resp = post_empty(&client, format!("{}/sandbox-test/{}", url, SANDBOX_BINDING))
.send()
.await
.context("Sandbox test request failed")?;

let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("Sandbox test returned {}: {}", status, body);
}

let data: BindingTestResponse = resp
.json()
.await
.context("Failed to parse sandbox response")?;
if !data.success {
bail!("Sandbox test reported failure");
}
if data.binding_name != SANDBOX_BINDING {
bail!(
"Sandbox test binding mismatch: expected {}, got {}",
SANDBOX_BINDING,
data.binding_name
);
}

info!("Sandbox binding check passed");
Ok(())
}
1 change: 1 addition & 0 deletions crates/alien-test/tests/common/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub async fn check_all_bindings(
Binding::Kv => bindings::check_kv(deployment).await?,
Binding::Vault => bindings::check_vault(deployment).await?,
Binding::Postgres => bindings::check_postgres(deployment).await?,
Binding::Sandbox => bindings::check_sandbox(deployment).await?,
Binding::Queue => bindings::check_queue(deployment).await?,
Binding::Worker => bindings::check_worker(deployment).await?,
Binding::Container => bindings::check_container(deployment).await?,
Expand Down
20 changes: 20 additions & 0 deletions tests/e2e/test-apps/comprehensive-rust/alien.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ const queue = new alien.Queue("alien-queue").build()
const eventsQueue = new alien.Queue("alien-events-queue").build()
const serviceAccount = new alien.ServiceAccount("test-alien-sa").build()
const postgres = isLocal ? new alien.Postgres("alien-postgres").build() : undefined
// Sandbox is Local-only here for the same reason as Postgres: the cloud sandbox controllers
// do not ship in this repo, so declaring one on a cloud target would ask the executor to
// provision a backend with no registered controller.
const sandbox = isLocal
? new alien.Sandbox("alien-sandbox")
.code({ type: "image", image: "alpine:3.20" })
.limits({ cpu: "500m", memory: "512Mi", disk: "1Gi", maxProcesses: 64 })
.egress({ mode: "deny" })
.session({ maxLifetimeSeconds: 600 })
.build()
: undefined

let workerBuilder = new alien.Worker("alien-rs-worker")
.code({
Expand Down Expand Up @@ -52,6 +63,9 @@ let workerBuilder = new alien.Worker("alien-rs-worker")
if (postgres) {
workerBuilder = workerBuilder.link(postgres)
}
if (sandbox) {
workerBuilder = workerBuilder.link(sandbox)
}
const worker = workerBuilder.build()

const executionPermissions = [
Expand All @@ -70,6 +84,9 @@ const executionPermissions = [
if (postgres) {
executionPermissions.push("postgres/data-access")
}
if (sandbox) {
executionPermissions.push("sandbox/execute")
}

let stackBuilder = new alien.Stack("alien-rs-stack")
.permissions({
Expand All @@ -90,6 +107,9 @@ let stackBuilder = new alien.Stack("alien-rs-stack")
if (postgres) {
stackBuilder = stackBuilder.add(postgres, "live")
}
if (sandbox) {
stackBuilder = stackBuilder.add(sandbox, "frozen")
}
const stack = stackBuilder.add(worker, "live").build()

export default stack
4 changes: 4 additions & 0 deletions tests/e2e/test-apps/comprehensive-rust/src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,10 @@ fn build_router(app_state: AppState) -> Router {
post(handlers::artifact_registry::test_artifact_registry),
)
.route("/kv-test/{binding_name}", post(handlers::kv::test_kv))
.route(
"/sandbox-test/{binding_name}",
post(handlers::sandbox::test_sandbox),
)
.route(
"/queue-test/{binding_name}",
post(handlers::queue::test_queue),
Expand Down
13 changes: 13 additions & 0 deletions tests/e2e/test-apps/comprehensive-rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ pub enum ErrorData {
operation: String,
},

/// Sandbox operation failed.
#[error(
code = "SANDBOX_OPERATION_FAILED",
message = "Sandbox operation failed: {operation}",
retryable = "true",
internal = "false",
http_status_code = 500
)]
SandboxOperationFailed {
/// Description of the sandbox operation that failed
operation: String,
},

/// KV operation failed.
#[error(
code = "KV_OPERATION_FAILED",
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/test-apps/comprehensive-rust/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod kv;
pub mod postgres;
pub mod queue;
pub mod queue_message;
pub mod sandbox;
pub mod service_account;
pub mod sse;
pub mod storage;
Expand Down
183 changes: 183 additions & 0 deletions tests/e2e/test-apps/comprehensive-rust/src/handlers/sandbox.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
use std::collections::BTreeMap;
use std::time::Duration;

use axum::{
extract::{Path, State},
response::Json,
};
use chrono::Utc;
use futures_util::StreamExt;
use tracing::info;

use crate::{
models::{AppState, KvTestResponse},
ErrorData, Result,
};
use alien_error::{AlienError, Context};
use alien_sdk::traits::{
CommandOutput, CreateSessionRequest, RunCommandRequest, Sandbox, SandboxSessionState,
};

/// Test a sandbox binding by running a command and moving a file through a session.
#[utoipa::path(
post,
path = "/sandbox-test/{binding_name}",
tag = "sandbox",
params(
("binding_name" = String, Path, description = "Name of the sandbox binding to test")
),
responses(
(status = 200, description = "Sandbox test completed", body = KvTestResponse),
(status = 400, description = "Binding not found", body = AlienError),
(status = 500, description = "Sandbox operation failed", body = AlienError),
),
operation_id = "test_sandbox",
summary = "Test sandbox session operations",
description = "Creates a session, runs a command, writes and reads a file, then terminates"
)]
pub async fn test_sandbox(
State(app_state): State<AppState>,
Path(binding_name): Path<String>,
) -> Result<Json<KvTestResponse>> {
info!(%binding_name, "Received sandbox test request");

let sandbox = app_state
.ctx
.bindings()
.sandbox(&binding_name)
.await
.context(ErrorData::BindingNotFound {
binding_name: binding_name.clone(),
})?;

let session = sandbox
.create(CreateSessionRequest {
session_id: Some(format!("e2e-{}", Utc::now().timestamp_millis())),
tenant_key: None,
env: BTreeMap::new(),
})
.await
.context(ErrorData::SandboxOperationFailed {
operation: "create".to_string(),
})?;

// Everything after create runs in a helper so a failure still reaches terminate below. A
// session left running is a session still billing.
let outcome = exercise(sandbox.as_ref(), &session.session_id).await;
let terminated = terminate_and_confirm(sandbox.as_ref(), &session.session_id).await;

// The exercise failure says more about what broke, so it is reported first — but a session
// that survived the test fails it too, rather than being logged and passed over.
outcome?;
terminated?;

info!(%binding_name, "Sandbox test completed successfully");

Ok(Json(KvTestResponse {
binding_name,
success: true,
}))
}

/// Terminates the session and reads it back to confirm it is gone.
///
/// A successful terminate is not the same claim: the backends return once deletion is accepted,
/// so a test that stops at the return value passes while the session keeps running.
async fn terminate_and_confirm(sandbox: &dyn Sandbox, session_id: &str) -> Result<()> {
sandbox
.terminate(session_id)
.await
.context(ErrorData::SandboxOperationFailed {
operation: "terminate".to_string(),
})?;

let remaining = sandbox
.get(session_id)
.await
.context(ErrorData::SandboxOperationFailed {
operation: "get after terminate".to_string(),
})?;

match remaining {
None => Ok(()),
Some(session) if session.state == SandboxSessionState::Terminated => Ok(()),
Some(session) => Err(AlienError::new(ErrorData::TestValidationFailed {
reason: format!(
"session '{session_id}' is still {:?} after terminate",
session.state
),
})),
}
}

/// The part of the test that can fail without leaking a session.
async fn exercise(sandbox: &dyn Sandbox, session_id: &str) -> Result<()> {
let marker = format!("alien-sandbox-e2e-{}", Utc::now().timestamp_millis());

let mut frames = sandbox
.run_command(
session_id,
RunCommandRequest {
command: vec!["/bin/echo".to_string(), marker.clone()],
working_directory: None,
env: BTreeMap::new(),
deadline: Duration::from_secs(30),
},
)
.await
.context(ErrorData::SandboxOperationFailed {
operation: "run_command".to_string(),
})?;

let mut stdout = Vec::new();
let mut exit_code = None;
while let Some(frame) = frames.next().await {
match frame.context(ErrorData::SandboxOperationFailed {
operation: "run_command stream".to_string(),
})? {
CommandOutput::Stdout { data, .. } => stdout.extend_from_slice(&data),
CommandOutput::Exit { code, .. } => exit_code = Some(code),
CommandOutput::Stderr { .. } => {}
}
}

if exit_code != Some(0) {
return Err(AlienError::new(ErrorData::TestValidationFailed {
reason: format!("run_command exited with {exit_code:?}, expected 0"),
}));
}

let printed = String::from_utf8_lossy(&stdout);
if !printed.contains(&marker) {
return Err(AlienError::new(ErrorData::TestValidationFailed {
reason: format!("stdout did not carry the marker, got '{printed}'"),
}));
}

// Files both directions through the same session, which is what makes it a session rather
// than a sequence of unrelated commands.
sandbox
.write_files(
session_id,
BTreeMap::from([("e2e/input.txt".to_string(), marker.as_bytes().to_vec())]),
)
.await
.context(ErrorData::SandboxOperationFailed {
operation: "write_files".to_string(),
})?;

let read_back = sandbox
.read_file(session_id, "e2e/input.txt")
.await
.context(ErrorData::SandboxOperationFailed {
operation: "read_file".to_string(),
})?;

if read_back != marker.as_bytes() {
return Err(AlienError::new(ErrorData::TestValidationFailed {
reason: "read_file returned different bytes than write_files sent".to_string(),
}));
}

Ok(())
}
Loading
Loading