Skip to content
Merged
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
2 changes: 1 addition & 1 deletion client-sdks/platform/openapi.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client-sdks/platform/rust/openapi-3.0.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client-sdks/platform/rust/openapi.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/alien-bindings-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ pub struct RemoteBindingsHandle {
#[cfg(feature = "platform-sdk")]
#[napi]
impl RemoteBindingsHandle {
/// Select a customer's Storage deployment by Project and external ID.
/// Address a customer by Project and external ID. Each binding resolves its own deployment.
#[napi(factory)]
pub async fn for_customer(
project: String,
Expand Down
138 changes: 100 additions & 38 deletions crates/alien-bindings/src/providers/sandbox/aws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ use futures::stream::BoxStream;

use crate::error::{ErrorData, Result};
use crate::providers::sandbox::agent_protocol::{self, AgentTransport, AGENT_PORT};
use crate::providers::sandbox::refusal::Unreachable;
use crate::traits::{
Binding, CommandOutput, CreateSessionRequest, PreviewCapability, RunCommandRequest, Sandbox,
SandboxSession, SandboxSessionState,
};
use alien_aws_clients::aws::lambda_microvms::{LambdaMicrovmsApi, Microvm, MAX_AUTH_TOKEN_MINUTES};
use alien_core::{Platform, SandboxCapabilities};
use alien_error::{AlienError, Context};
use alien_error::AlienError;
use tracing::warn;

/// Header the proxy reads to decide which port inside the MicroVM a request reaches.
Expand Down Expand Up @@ -137,10 +138,10 @@ impl AwsSandbox {
return Ok(None)
}
Err(error) => {
return Err(error).context(ErrorData::SandboxUnreachable {
operation: "sandbox.session".to_string(),
reason: format!("could not read session '{session_id}'"),
})
return Err(error).unreachable(
"sandbox.session",
&format!("could not read session '{session_id}'"),
)
}
};

Expand Down Expand Up @@ -195,10 +196,10 @@ impl AwsSandbox {
.microvms
.create_microvm_auth_token(session_id, vec![AGENT_PORT], AGENT_TOKEN_MINUTES)
.await
.context(ErrorData::SandboxUnreachable {
operation: "sandbox.agent".to_string(),
reason: format!("could not mint an endpoint token for '{session_id}'"),
})?;
.unreachable(
"sandbox.agent",
&format!("could not mint an endpoint token for '{session_id}'"),
)?;

let mut request = self
.agent
Expand Down Expand Up @@ -359,13 +360,10 @@ impl Sandbox for AwsSandbox {
self.max_lifetime_seconds,
)
.await
// The cloud's own refusal is carried in the message rather than only in the source:
// the chain does not survive into the SDK, and "could not start a MicroVM" without a
// reason sends a reader to the code instead of to the quota or role that refused.
.context(ErrorData::SandboxUnreachable {
operation: "sandbox.create".to_string(),
reason: format!("could not start a MicroVM from '{}'", self.image_identifier),
})?;
.unreachable(
"sandbox.create",
&format!("could not start a MicroVM from '{}'", self.image_identifier),
)?;

let microvm_id = microvm.microvm_id.ok_or_else(|| {
AlienError::new(ErrorData::UnexpectedResponseFormat {
Expand Down Expand Up @@ -509,10 +507,10 @@ impl Sandbox for AwsSandbox {
.microvms
.create_microvm_auth_token(session_id, vec![port], PREVIEW_TOKEN_MINUTES)
.await
.context(ErrorData::SandboxUnreachable {
operation: "sandbox.preview".to_string(),
reason: format!("could not mint a preview token for port {port}"),
})?;
.unreachable(
"sandbox.preview",
&format!("could not mint a preview token for port {port}"),
)?;

let mut headers: BTreeMap<String, String> = token.auth_token.into_iter().collect();
headers.insert(PROXY_PORT_HEADER.to_string(), port.to_string());
Expand All @@ -528,25 +526,19 @@ impl Sandbox for AwsSandbox {
async fn suspend(&self, session_id: &str) -> Result<()> {
self.ensure_owned(session_id).await?;

self.microvms
.suspend_microvm(session_id)
.await
.context(ErrorData::SandboxUnreachable {
operation: "sandbox.suspend".to_string(),
reason: format!("could not suspend MicroVM '{session_id}'"),
})
self.microvms.suspend_microvm(session_id).await.unreachable(
"sandbox.suspend",
&format!("could not suspend MicroVM '{session_id}'"),
)
}

async fn resume(&self, session_id: &str) -> Result<()> {
self.ensure_owned(session_id).await?;

self.microvms
.resume_microvm(session_id)
.await
.context(ErrorData::SandboxUnreachable {
operation: "sandbox.resume".to_string(),
reason: format!("could not resume MicroVM '{session_id}'"),
})
self.microvms.resume_microvm(session_id).await.unreachable(
"sandbox.resume",
&format!("could not resume MicroVM '{session_id}'"),
)
}

async fn snapshot(&self, _session_id: &str) -> Result<String> {
Expand All @@ -562,10 +554,10 @@ impl Sandbox for AwsSandbox {
self.microvms
.terminate_microvm(session_id)
.await
.context(ErrorData::SandboxUnreachable {
operation: "sandbox.terminate".to_string(),
reason: format!("could not terminate MicroVM '{session_id}'"),
})
.unreachable(
"sandbox.terminate",
&format!("could not terminate MicroVM '{session_id}'"),
)
}

fn as_any(&self) -> &dyn std::any::Any {
Expand All @@ -579,6 +571,7 @@ mod tests {
use alien_aws_clients::aws::lambda_microvms::{
Microvm, MicrovmAuthToken, MockLambdaMicrovmsApi,
};
use alien_error::Context;
use std::time::Duration;

fn image_version(version: &str) -> alien_aws_clients::aws::lambda_microvms::MicrovmImage {
Expand Down Expand Up @@ -1107,4 +1100,73 @@ mod tests {
"points the caller at what does work: {error}"
);
}

/// A `RunMicrovm` refused for a missing IAM action, shaped as `LambdaMicrovmsClient::send`
/// shapes one: the transport records the response body, and `classify` wraps a non-404 as
/// its own generic failure.
fn refused_run() -> Result<Microvm, alien_client_core::ErrorData> {
Err(AlienError::new(
alien_client_core::ErrorData::HttpResponseError {
message: "Request failed with HTTP 403: Forbidden".to_string(),
url: "https://lambda.us-east-2.amazonaws.com/2025-09-09/microvms".to_string(),
http_status: 403,
http_request_text: None,
http_response_text: Some(
r#"{"Message":"User: arn:aws:sts::123456789012:assumed-role/stack-access/session is not authorized to perform: lambda:PassNetworkConnector on resource: arn:aws:lambda:us-east-2:aws:network-connector:aws-network-connector:INTERNET_EGRESS"}"#
.to_string(),
),
},
))
.context(alien_client_core::ErrorData::GenericError {
message: "Lambda MicroVMs RunMicrovm failed".to_string(),
})
}

/// The refused action is what sends a reader to the role rather than to this code, and the
/// wire format past this binding is a flat message string — so `reason` is the only place a
/// structured consumer sees it. It reaches an operator's log alone: an IAM identity makes the
/// whole error internal, and `into_external` replaces it.
#[tokio::test]
async fn a_refused_create_reports_what_aws_refused_it_with() {
let mut client = MockLambdaMicrovmsApi::new();
client
.expect_run_microvm()
.returning(|_, _, _, _, _, _, _| refused_run());
// Nothing was started, so nothing is cleaned up.
client.expect_terminate_microvm().never();

let error = sandbox(client)
.create(CreateSessionRequest {
session_id: None,
tenant_key: None,
env: BTreeMap::new(),
})
.await
.expect_err("a refused RunMicrovm cannot produce a session");

assert_eq!(error.code, "SANDBOX_UNREACHABLE");
assert!(
error
.message
.contains("could not start a MicroVM from 'sbx-image'"),
"the binding still says which call it was: {}",
error.message
);
assert!(
error
.message
.contains("is not authorized to perform: lambda:PassNetworkConnector"),
"and AWS's own sentence is what tells the operator why: {}",
error.message
);
assert!(
error.internal,
"an IAM identity in the message makes the error internal: {error}"
);
assert_eq!(
error.into_external().message,
"Internal server error",
"so none of it is published to the caller"
);
}
}
5 changes: 3 additions & 2 deletions crates/alien-bindings/src/providers/sandbox/azure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ impl AzureSandbox {
/// A refusal is a request the data plane understood and rejected, so repeating it repeats the
/// refusal. Anything else left the outcome unknown: for the idempotent file operations that is
/// worth another attempt, but `run_command` may already have started the command and must not
/// carry the retry signal. The cause stays on the source chain rather than in `reason`, which
/// is what keeps a raw response body out of an externally visible message.
/// carry the retry signal. `reason` says only what this binding knows: `is_refusal` classifies
/// to public variants, and `into_external` passes a public error's source chain through
/// untouched, so the chain hides nothing a `reason` would have exposed.
fn failed(operation: &str, error: AlienError<ClientErrorData>) -> AlienError<ErrorData> {
if is_refusal(&error) {
return error.context(ErrorData::SandboxCommandFailed {
Expand Down
3 changes: 3 additions & 0 deletions crates/alien-bindings/src/providers/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub mod kubernetes;
#[cfg(feature = "local")]
pub mod local;

#[cfg(feature = "aws")]
mod refusal;

/// The longest command deadline these backends accept.
///
/// A ceiling rather than a guard: a timer takes a point in time, and a duration near
Expand Down
Loading
Loading