use std::time::Duration;
use serde_json::{json, Value};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::error::SailError;
use crate::http::{HttpCore, IdempotencyKey, Method, RequestSpec};
use crate::retry::{RetryPolicy, DEFAULT_RETRY_POLICY, NO_RETRY};
use crate::sailbox::types::{
AddListenerWire, CreateSailboxRequest, IngressPort, IngressProtocol, IssuedUserCert,
ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo, SailboxPage, SailboxStatus,
VolumeInfo, VolumeMount,
};
use crate::worker::Listener;
const RESERVED_HTTP_INGRESS_PORTS: &[u32] = &[22, 10000, 10001, 15001, 15002];
const RESERVED_TCP_INGRESS_PORTS: &[u32] = &[10000, 10001, 15001, 15002];
const UNAUTHENTICATED_TCP_SERVICE_PORTS: &[u32] = &[5432, 3306, 6379, 27017, 9200, 11211];
const VOLUME_RESERVED_MOUNT_PATHS: &[&str] =
&["/dev", "/proc", "/sys", "/run/sail", "/var/run/sail"];
fn is_cidr_or_ip(value: &str) -> bool {
value.parse::<ipnet::IpNet>().is_ok() || value.parse::<std::net::IpAddr>().is_ok()
}
const SAILBOX_SIZE_RANGES: &[(&str, u32, u32, u32, u32)] =
&[("s", 2, 64, 8, 128), ("m", 8, 128, 32, 512)];
const DEFAULT_SIZE_LABEL: &str = "m";
pub fn validate_size_limits(
size: Option<&str>,
memory_gib: Option<u32>,
disk_gib: Option<u32>,
) -> Result<(), SailError> {
let label = size.unwrap_or(DEFAULT_SIZE_LABEL);
let Some((_, mem_min, mem_max, disk_min, disk_max)) = SAILBOX_SIZE_RANGES
.iter()
.find(|(name, ..)| *name == label)
.copied()
else {
return Ok(());
};
if let Some(memory_gib) = memory_gib {
if !(mem_min..=mem_max).contains(&memory_gib) {
return Err(SailError::InvalidArgument {
message: format!(
"memory_gib for size {label} must be between {mem_min} and {mem_max}"
),
});
}
}
if let Some(disk_gib) = disk_gib {
if !(disk_min..=disk_max).contains(&disk_gib) {
return Err(SailError::InvalidArgument {
message: format!(
"disk_gib for size {label} must be between {disk_min} and {disk_max}"
),
});
}
}
Ok(())
}
pub fn validate_autosleep_timeout(minutes: Option<i64>) -> Result<(), SailError> {
if let Some(minutes) = minutes {
if !(1..=1440).contains(&minutes) {
return Err(SailError::InvalidArgument {
message: "autosleep_timeout_minutes must be between 1 and 1440".to_string(),
});
}
}
Ok(())
}
pub fn normalize_allowlist(entries: &[String]) -> Result<Vec<String>, SailError> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in entries {
let value = entry.trim();
if value.is_empty() {
continue;
}
let normalized = if let Ok(net) = value.parse::<ipnet::IpNet>() {
net.trunc().to_string()
} else if let Ok(addr) = value.parse::<std::net::IpAddr>() {
match addr {
std::net::IpAddr::V4(v4) => format!("{v4}/32"),
std::net::IpAddr::V6(v6) => format!("{v6}/128"),
}
} else if value.contains('/') {
return Err(SailError::InvalidArgument {
message: format!("allowlist entry {value:?} is not a valid CIDR"),
});
} else {
value.to_string()
};
if seen.insert(normalized.clone()) {
out.push(normalized);
}
}
Ok(out)
}
pub fn validate_ingress_ports(ports: &[IngressPort]) -> Result<(), SailError> {
let invalid = |message: String| Err(SailError::InvalidArgument { message });
let mut seen = std::collections::HashSet::new();
for port in ports {
if port.guest_port < 1 || port.guest_port > 65535 {
return invalid("ingress_ports must be between 1 and 65535".to_string());
}
let is_tcp = matches!(port.protocol, IngressProtocol::Tcp);
let mut has_allowlist = false;
for entry in &port.allowlist {
let value = entry.trim();
if value.is_empty() {
continue;
}
has_allowlist = true;
let is_cidr = is_cidr_or_ip(value);
if value.contains('/') && !is_cidr {
return invalid(format!("allowlist entry {value:?} is not a valid CIDR"));
}
if is_tcp && !is_cidr {
return invalid(format!(
"allowlist entry {value:?}: app-name entries are not supported for tcp \
listeners; tcp allowlists must be CIDR prefixes"
));
}
}
let reserved = if is_tcp {
RESERVED_TCP_INGRESS_PORTS
} else {
RESERVED_HTTP_INGRESS_PORTS
};
if reserved.contains(&port.guest_port) {
if port.guest_port == 22 {
return invalid(
"guest port 22 is reserved for ssh and cannot be exposed as an http port; \
expose it as raw TCP instead"
.to_string(),
);
}
if port.guest_port == 10000 || port.guest_port == 10001 {
return invalid(format!(
"guest port {} is reserved for the in-guest sail agent and cannot be exposed \
as an ingress port",
port.guest_port
));
}
let proto = if is_tcp { "tcp" } else { "http" };
return invalid(format!(
"ingress_ports contains reserved {proto} port {}",
port.guest_port
));
}
if !seen.insert(port.guest_port) {
return invalid(format!(
"ingress_ports guest port {} must be unique",
port.guest_port
));
}
if is_tcp && UNAUTHENTICATED_TCP_SERVICE_PORTS.contains(&port.guest_port) && !has_allowlist
{
return invalid(format!(
"guest port {} is a well-known unauthenticated service port; exposing it as raw \
public TCP with no source restriction is a common breach vector. Pass an \
allowlist of CIDR prefixes to restrict source IPs (use [\"0.0.0.0/0\", \"::/0\"] \
to allow every source)",
port.guest_port
));
}
}
Ok(())
}
fn normalize_posix_path(path: &str) -> String {
let is_absolute = path.starts_with('/');
let mut parts: Vec<&str> = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => {}
".." => {
if parts.last().is_some_and(|&last| last != "..") {
parts.pop();
} else if !is_absolute {
parts.push("..");
}
}
other => parts.push(other),
}
}
let joined = parts.join("/");
if is_absolute {
format!("/{joined}")
} else if joined.is_empty() {
".".to_string()
} else {
joined
}
}
fn mount_paths_overlap(a: &str, b: &str) -> bool {
a == b || a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/"))
}
pub fn validate_volume_mounts(mounts: &[VolumeMount]) -> Result<(), SailError> {
let invalid = |message: String| Err(SailError::InvalidArgument { message });
let mut seen: Vec<String> = Vec::new();
for mount in mounts {
if mount.volume_id.trim().is_empty() {
return invalid("volume mount volume_id must not be empty".to_string());
}
let path = normalize_posix_path(mount.mount_path.trim());
if !path.starts_with('/') {
return invalid("volume mount paths must be absolute".to_string());
}
if path == "/" {
return invalid("volume mount path must not be filesystem root".to_string());
}
if VOLUME_RESERVED_MOUNT_PATHS
.iter()
.any(|reserved| mount_paths_overlap(&path, reserved))
{
return invalid(format!("volume mount path {path:?} is reserved"));
}
if seen.iter().any(|other| mount_paths_overlap(&path, other)) {
return invalid("volume mount paths must not overlap".to_string());
}
seen.push(path);
}
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UpgradeResult {
pub applied: bool,
pub status: SailboxStatus,
}
pub struct SailboxApi<'a> {
http: &'a HttpCore,
}
impl<'a> SailboxApi<'a> {
pub fn new(http: &'a HttpCore) -> SailboxApi<'a> {
SailboxApi { http }
}
pub async fn create(
&self,
req: &CreateSailboxRequest,
timeout: Option<Duration>,
) -> Result<SailboxHandle, SailError> {
validate_size_limits(req.size.map(|s| s.as_str()), req.memory_gib, req.disk_gib)?;
validate_ingress_ports(&req.ingress_ports)?;
validate_autosleep_timeout(req.autosleep_timeout_minutes)?;
validate_volume_mounts(&req.volume_mounts)?;
let mut ingress_ports = req.ingress_ports.clone();
for port in &mut ingress_ports {
port.allowlist = normalize_allowlist(&port.allowlist)?;
}
let image = serde_json::to_value(&req.image).map_err(|e| SailError::Internal {
message: format!("failed to serialize image spec: {e}"),
})?;
let mut body = json!({
"app_id": req.app_id,
"name": req.name,
"ingress_ports": ingress_ports,
"volume_mounts": req.volume_mounts,
"image": image,
});
if let Some(size) = req.size {
body["size"] = json!(size.as_str());
}
if let Some(memory_gib) = req.memory_gib {
body["memory_limit_gib"] = json!(memory_gib);
}
if let Some(disk_gib) = req.disk_gib {
body["state_disk_limit_gib"] = json!(disk_gib);
}
if let Some(timeout) = req.autosleep_timeout_minutes {
body["autosleep_timeout_min"] = json!(timeout);
}
let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
message: format!("failed to serialize request body: {e}"),
})?;
let (status, data) = self
.request(
Method::Post,
"/v1/sailboxes",
&[],
Some(bytes),
DEFAULT_RETRY_POLICY,
timeout.map(|d| d.as_secs_f64()),
)
.await?;
raise_for_create_status(status, &data)?;
if status_of(&data) == SailboxStatus::Failed {
return Err(SailError::Creation {
message: format!(
"Sailbox creation failed: {}",
data.get("error_message")
.and_then(Value::as_str)
.unwrap_or("")
),
status,
body: data,
});
}
require_status(&data, SailboxStatus::Running, status).map_err(|_| SailError::Creation {
message: format!(
"Sailbox creation returned unexpected status: {}",
data.get("status").and_then(Value::as_str).unwrap_or("")
),
status,
body: data.clone(),
})?;
Ok(handle_from(&data, &req.name))
}
pub async fn get(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
let (status, data) = self
.get_request(&format!("/v1/sailboxes/{sailbox_id}"), &[])
.await?;
raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
info_from(data)
}
pub async fn list(&self, query: &ListSailboxesQuery) -> Result<SailboxPage, SailError> {
let mut params: Vec<(String, String)> = vec![
("limit".to_string(), query.limit.to_string()),
("offset".to_string(), query.offset.to_string()),
];
if let Some(app) = &query.app_id {
params.push(("app".to_string(), app.clone()));
}
if let Some(s) = &query.status {
params.push(("status".to_string(), s.as_str().to_string()));
}
if let Some(s) = &query.search {
params.push(("search".to_string(), s.clone()));
}
if let Some(v) = query.max_guest_schema_version {
params.push(("max_guest_schema_version".to_string(), v.to_string()));
}
let (status, data) = self.get_request("/v1/sailboxes", ¶ms).await?;
raise_api_error(status, &data, "")?;
let items = data
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| missing_field("data"))?
.iter()
.cloned()
.map(info_from)
.collect::<Result<Vec<_>, _>>()?;
let total = int_field(&data, "total").unwrap_or(items.len() as i64);
Ok(SailboxPage {
limit: int_field(&data, "limit").unwrap_or(query.limit),
offset: int_field(&data, "offset").unwrap_or(query.offset),
total,
has_more: data
.get("has_more")
.and_then(Value::as_bool)
.unwrap_or(false),
items,
})
}
pub async fn terminate(&self, sailbox_id: &str) -> Result<(), SailError> {
let (status, data) = self
.post(&format!("/v1/sailboxes/{sailbox_id}/terminate"), &json!({}))
.await?;
if status == 404 && is_resource_not_found(&data) {
return Ok(());
}
raise_api_error(status, &data, "")
}
pub async fn pause(&self, sailbox_id: &str) -> Result<(), SailError> {
self.stop(sailbox_id, "pause", SailboxStatus::Paused).await
}
pub async fn sleep(&self, sailbox_id: &str) -> Result<(), SailError> {
self.stop(sailbox_id, "sleep", SailboxStatus::Sleeping)
.await
}
async fn stop(
&self,
sailbox_id: &str,
action: &str,
expected: SailboxStatus,
) -> Result<(), SailError> {
let (status, data) = self
.post(&format!("/v1/sailboxes/{sailbox_id}/{action}"), &json!({}))
.await?;
raise_api_error(status, &data, "")?;
require_status(&data, expected, status)
}
pub async fn resume(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
let (status, data) = self
.post(&format!("/v1/sailboxes/{sailbox_id}/resume"), &json!({}))
.await?;
raise_api_error(status, &data, "")?;
if data.get("resume_state").and_then(Value::as_str) == Some("terminal_unavailable") {
return Err(SailError::NotFound {
message: data
.get("error_message")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("sailbox cannot be resumed")
.to_string(),
});
}
require_status(&data, SailboxStatus::Running, status)?;
Ok(handle_from(&data, ""))
}
pub async fn checkpoint(
&self,
sailbox_id: &str,
name: Option<&str>,
ttl_seconds: Option<i64>,
) -> Result<SailboxCheckpoint, SailError> {
let mut body = json!({});
if let Some(name) = name {
body["name"] = json!(name);
}
if let Some(ttl) = ttl_seconds {
if ttl <= 0 {
return Err(SailError::InvalidArgument {
message: "ttl_seconds must be greater than 0".to_string(),
});
}
body["ttl_seconds"] = json!(ttl);
}
let (status, data) = self
.post(&format!("/v1/sailboxes/{sailbox_id}/checkpoint"), &body)
.await?;
raise_api_error(status, &data, "")?;
let checkpoint_id = data
.get("checkpoint_id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let checkpoint_id = if let Some(id) = checkpoint_id {
id.to_string()
} else {
require_status(&data, SailboxStatus::Running, status)?;
return Err(SailError::Api {
status,
message: "checkpoint sailbox did not return checkpoint_id".to_string(),
body: data,
});
};
Ok(SailboxCheckpoint {
checkpoint_id,
sailbox_id: data
.get("sailbox_id")
.and_then(Value::as_str)
.unwrap_or(sailbox_id)
.to_string(),
checkpoint_generation: data
.get("checkpoint_generation")
.and_then(Value::as_i64)
.unwrap_or(0),
expires_at: data
.get("expires_at")
.and_then(Value::as_str)
.and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()),
status: status_of(&data),
})
}
pub async fn from_checkpoint(
&self,
checkpoint_id: &str,
name: Option<&str>,
timeout_seconds: Option<i64>,
) -> Result<SailboxHandle, SailError> {
if checkpoint_id.is_empty() {
return Err(SailError::InvalidArgument {
message: "checkpoint_id is required".to_string(),
});
}
let mut body = json!({ "checkpoint_id": checkpoint_id });
if let Some(name) = name {
body["name"] = json!(name);
}
if let Some(timeout) = timeout_seconds {
if timeout <= 0 {
return Err(SailError::InvalidArgument {
message: "timeout must be greater than 0".to_string(),
});
}
body["timeout_seconds"] = json!(timeout);
}
let (status, data) = self.post("/v1/sailboxes/from_checkpoint", &body).await?;
raise_api_error(status, &data, "")?;
require_status(&data, SailboxStatus::Running, status)?;
Ok(handle_from(&data, name.unwrap_or("")))
}
pub async fn upgrade(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
let (status, data) = self
.post(&format!("/v1/sailboxes/{sailbox_id}/upgrade"), &json!({}))
.await?;
raise_api_error(status, &data, "")?;
Ok(UpgradeResult {
applied: data
.get("applied")
.and_then(Value::as_bool)
.unwrap_or(false),
status: status_of(&data),
})
}
pub async fn expose(
&self,
sailbox_id: &str,
guest_port: u32,
protocol: IngressProtocol,
allowlist: &[String],
) -> Result<crate::worker::Listener, SailError> {
let allowlist = normalize_allowlist(allowlist)?;
let body = json!({
"guest_port": guest_port,
"protocol": protocol.as_str(),
"allowlist": allowlist,
});
let (status, data) = self
.post(&format!("/v1/sailboxes/{sailbox_id}/listeners"), &body)
.await?;
raise_api_error(status, &data, "")?;
serde_json::from_value::<AddListenerWire>(data)
.map(crate::worker::Listener::from)
.map_err(|e| SailError::Internal {
message: format!("failed to parse add-listener response: {e}"),
})
}
pub async fn unexpose(&self, sailbox_id: &str, guest_port: u32) -> Result<(), SailError> {
let (status, data) = self
.request(
Method::Delete,
&format!("/v1/sailboxes/{sailbox_id}/listeners/{guest_port}"),
&[],
None,
NO_RETRY,
None,
)
.await?;
raise_api_error(status, &data, "")
}
pub async fn list_listeners(&self, sailbox_id: &str) -> Result<Vec<Listener>, SailError> {
let (status, data) = self
.get_request(&format!("/v1/sailboxes/{sailbox_id}/listeners"), &[])
.await?;
raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
let rows = data.get("data").cloned().unwrap_or(Value::Null);
serde_json::from_value(rows).map_err(|e| SailError::Internal {
message: format!("failed to parse listeners: {e}"),
})
}
pub async fn get_listener(
&self,
sailbox_id: &str,
guest_port: u32,
) -> Result<Listener, SailError> {
let (status, data) = self
.get_request(
&format!("/v1/sailboxes/{sailbox_id}/listeners/{guest_port}"),
&[],
)
.await?;
raise_api_error(
status,
&data,
&format!("Listener {sailbox_id:?}:{guest_port}"),
)?;
serde_json::from_value(data).map_err(|e| SailError::Internal {
message: format!("failed to parse listener: {e}"),
})
}
pub async fn ingress_auth_headers(
&self,
sailbox_id: &str,
) -> Result<Vec<(String, String)>, SailError> {
let (status, data) = self
.get_request(&format!("/v1/sailboxes/{sailbox_id}/ingress-auth"), &[])
.await?;
raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
let headers = data
.get("headers")
.and_then(Value::as_object)
.ok_or_else(|| missing_field("headers"))?;
Ok(headers
.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
.collect())
}
pub async fn get_volume(
&self,
name: &str,
mint_if_missing: bool,
) -> Result<VolumeInfo, SailError> {
if name.trim().is_empty() {
return Err(SailError::InvalidArgument {
message: "name is required".to_string(),
});
}
let body = json!({
"name": name,
"backend": "nfs",
"mint_if_missing": mint_if_missing,
"create_if_missing": mint_if_missing,
});
let (status, data) = self.post("/v1/sailbox-volumes/get", &body).await?;
raise_api_error(status, &data, "")?;
volume_from(data)
}
pub async fn list_volumes(
&self,
max_objects: Option<i64>,
) -> Result<Vec<VolumeInfo>, SailError> {
let mut query: Vec<(String, String)> = Vec::new();
if let Some(max) = max_objects {
if max < 0 {
return Err(SailError::InvalidArgument {
message: "max_objects cannot be negative".to_string(),
});
}
query.push(("max_objects".to_string(), max.to_string()));
}
let (status, data) = self.get_request("/v1/sailbox-volumes", &query).await?;
raise_api_error(status, &data, "")?;
data.get("volumes").and_then(Value::as_array).map_or_else(
|| Ok(Vec::new()),
|rows| rows.iter().cloned().map(volume_from).collect(),
)
}
pub async fn delete_volume(
&self,
volume_id: &str,
allow_missing: bool,
) -> Result<Option<VolumeInfo>, SailError> {
if volume_id.trim().is_empty() {
return Err(SailError::InvalidArgument {
message: "volume_id is required".to_string(),
});
}
let path = format!("/v1/sailbox-volumes/{}", volume_id.trim());
let query: Vec<(String, String)> = if allow_missing {
vec![("allow_missing".to_string(), "true".to_string())]
} else {
Vec::new()
};
let policy = if allow_missing {
DEFAULT_RETRY_POLICY
} else {
NO_RETRY
};
let (status, data) = self
.request(Method::Delete, &path, &query, None, policy, None)
.await?;
if status == 204 {
return Ok(None);
}
raise_api_error(status, &data, "")?;
Ok(Some(volume_from(data)?))
}
pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
let (status, data) = self.get_request("/v1/ssh/ca", &[]).await?;
raise_api_error(status, &data, "ssh ca")?;
str_field(&data, "public_key")
}
pub async fn issue_user_cert(
&self,
public_key: &str,
timeout: Option<f64>,
) -> Result<IssuedUserCert, SailError> {
let body = json!({ "public_key": public_key });
let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
message: format!("failed to serialize request body: {e}"),
})?;
let policy = if timeout.is_some() {
NO_RETRY
} else {
DEFAULT_RETRY_POLICY
};
let (status, data) = self
.request(
Method::Post,
"/v1/ssh/certificate",
&[],
Some(bytes),
policy,
timeout,
)
.await?;
raise_api_error(status, &data, "ssh certificate")?;
Ok(IssuedUserCert {
certificate: str_field(&data, "certificate")?,
key_id: data
.get("key_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
})
}
async fn post(&self, path: &str, body: &Value) -> Result<(u16, Value), SailError> {
let bytes = serde_json::to_vec(body).map_err(|e| SailError::Internal {
message: format!("failed to serialize request body: {e}"),
})?;
self.request(
Method::Post,
path,
&[],
Some(bytes),
DEFAULT_RETRY_POLICY,
None,
)
.await
}
async fn get_request(
&self,
path: &str,
query: &[(String, String)],
) -> Result<(u16, Value), SailError> {
self.request(Method::Get, path, query, None, DEFAULT_RETRY_POLICY, None)
.await
}
async fn request(
&self,
method: Method,
path: &str,
query: &[(String, String)],
body: Option<Vec<u8>>,
policy: RetryPolicy,
timeout: Option<f64>,
) -> Result<(u16, Value), SailError> {
let spec = RequestSpec {
method,
path: path.to_string(),
query: query.to_vec(),
body,
extra_headers: Vec::new(),
timeout,
policy,
idempotency_key: IdempotencyKey::Auto,
};
self.http.request(&spec).await
}
}
fn handle_from(data: &Value, fallback_name: &str) -> SailboxHandle {
SailboxHandle {
sailbox_id: data
.get("sailbox_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or(fallback_name)
.to_string(),
status: status_of(data),
worker_address: data
.get("worker_address")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
exec_endpoint: data
.get("exec_proxy_endpoint")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
}
}
fn info_from(data: Value) -> Result<SailboxInfo, SailError> {
serde_json::from_value::<SailboxInfo>(data).map_err(|e| SailError::Internal {
message: format!("failed to parse sailbox: {e}"),
})
}
fn volume_from(data: Value) -> Result<VolumeInfo, SailError> {
serde_json::from_value::<VolumeInfo>(data).map_err(|e| SailError::Internal {
message: format!("failed to parse volume: {e}"),
})
}
fn int_field(data: &Value, key: &str) -> Result<i64, SailError> {
data.get(key)
.and_then(Value::as_i64)
.ok_or_else(|| missing_field(key))
}
fn str_field(data: &Value, key: &str) -> Result<String, SailError> {
data.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
.ok_or_else(|| missing_field(key))
}
fn missing_field(key: &str) -> SailError {
SailError::Internal {
message: format!("API response missing field {key:?}"),
}
}
fn is_resource_not_found(data: &Value) -> bool {
data.get("error")
.and_then(|e| e.get("type"))
.and_then(Value::as_str)
== Some("not_found_error")
}
fn raise_api_error(status: u16, data: &Value, context: &str) -> Result<(), SailError> {
if status < 300 {
return Ok(());
}
let mut message = crate::http::api_error_message(data, "request failed");
if !context.is_empty() {
message = format!("{context}: {message}");
}
if status == 404 && is_resource_not_found(data) {
return Err(SailError::NotFound { message });
}
if status == 401 || status == 403 {
return Err(SailError::PermissionDenied { message });
}
if status == 400 {
return Err(SailError::InvalidArgument { message });
}
Err(SailError::Api {
status,
message,
body: data.clone(),
})
}
fn raise_for_create_status(status: u16, data: &Value) -> Result<(), SailError> {
if status < 300 {
return Ok(());
}
let message = crate::http::api_error_message(data, "request failed");
if status == 401 || status == 403 {
return Err(SailError::PermissionDenied { message });
}
Err(SailError::Creation {
message,
status,
body: data.clone(),
})
}
fn status_of(data: &Value) -> SailboxStatus {
SailboxStatus::from(data.get("status").and_then(Value::as_str).unwrap_or(""))
}
fn require_status(data: &Value, expected: SailboxStatus, status: u16) -> Result<(), SailError> {
let got = status_of(data);
if got == expected {
return Ok(());
}
Err(SailError::Api {
status,
message: format!("lifecycle call returned unexpected status: {got}"),
body: data.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn port(guest_port: u32, protocol: IngressProtocol, allowlist: &[&str]) -> IngressPort {
IngressPort {
guest_port,
protocol,
allowlist: allowlist.iter().map(ToString::to_string).collect(),
}
}
fn mount(volume_id: &str, mount_path: &str) -> VolumeMount {
VolumeMount {
volume_id: volume_id.to_string(),
mount_path: mount_path.to_string(),
}
}
#[test]
fn ingress_ports_accepts_valid_and_rejects_the_rules() {
assert!(validate_ingress_ports(&[
port(8080, IngressProtocol::Http, &[]),
port(22, IngressProtocol::Tcp, &[]),
port(5432, IngressProtocol::Tcp, &["10.0.0.0/8"]),
])
.is_ok());
assert!(validate_ingress_ports(&[port(22, IngressProtocol::Http, &[])]).is_err());
assert!(validate_ingress_ports(&[port(10000, IngressProtocol::Tcp, &[])]).is_err());
assert!(validate_ingress_ports(&[port(70000, IngressProtocol::Http, &[])]).is_err());
assert!(validate_ingress_ports(&[
port(8080, IngressProtocol::Http, &[]),
port(8080, IngressProtocol::Tcp, &["0.0.0.0/0"]),
])
.is_err());
assert!(validate_ingress_ports(&[port(9000, IngressProtocol::Tcp, &["my-app"])]).is_err());
assert!(validate_ingress_ports(&[port(9000, IngressProtocol::Http, &["my-app"])]).is_ok());
assert!(validate_ingress_ports(&[port(5432, IngressProtocol::Tcp, &[])]).is_err());
assert!(
validate_ingress_ports(&[port(9000, IngressProtocol::Http, &["1.2.3.4/33"])]).is_err()
);
}
#[test]
fn volume_mounts_reject_root_reserved_and_overlaps() {
assert!(validate_volume_mounts(&[mount("vol_1", "/mnt/data")]).is_ok());
assert!(validate_volume_mounts(&[mount("", "/mnt/data")]).is_err());
assert!(validate_volume_mounts(&[mount("vol_1", "relative")]).is_err());
assert!(validate_volume_mounts(&[mount("vol_1", "/")]).is_err());
assert!(validate_volume_mounts(&[mount("vol_1", "/proc/x")]).is_err());
assert!(
validate_volume_mounts(&[mount("vol_1", "/mnt"), mount("vol_2", "/mnt/cache"),])
.is_err()
);
assert!(validate_volume_mounts(&[mount("vol_1", "/mnt/../proc")]).is_err());
}
#[test]
fn info_reads_resource_fields_and_defaults_optionals() {
let info = info_from(json!({
"sailbox_id": "sb-1", "app_id": "app-1", "app_name": "a", "name": "n",
"image_id": "img-1",
"status": "running", "memory_mib": 2048, "vcpu_count": 4,
"state_disk_size_gib": 10,
"cpu_requested_vcpu": 2, "cpu_used_vcpu": 1.5,
"memory_requested_bytes": 1024, "memory_used_bytes": 512,
"disk_requested_bytes": 4096, "disk_used_bytes": 2048,
"architecture": "amd64", "checkpoint_generation": 7,
"created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"
}))
.unwrap();
assert_eq!(info.cpu_requested_vcpu, 2);
assert_eq!(info.memory_used_bytes, 512);
assert_eq!(info.checkpoint_generation, 7);
assert_eq!(info.guest_schema_version, None);
assert_eq!(info.error_message, None);
assert_eq!(info.started_at, None);
}
#[test]
fn api_error_ladder_maps_statuses() {
let not_found = json!({"error": {"type": "not_found_error", "message": "gone"}});
assert!(matches!(
raise_api_error(404, ¬_found, ""),
Err(SailError::NotFound { .. })
));
let route_404 = json!({"error": {"message": "no route"}});
assert!(matches!(
raise_api_error(404, &route_404, ""),
Err(SailError::Api { .. })
));
let auth = json!({"error": {"message": "nope"}});
assert!(matches!(
raise_api_error(403, &auth, ""),
Err(SailError::PermissionDenied { .. })
));
assert!(matches!(
raise_api_error(400, &auth, ""),
Err(SailError::InvalidArgument { .. })
));
assert!(matches!(
raise_api_error(503, &auth, ""),
Err(SailError::Api { status: 503, .. })
));
assert!(raise_api_error(200, &json!({}), "").is_ok());
}
#[test]
fn create_ladder_maps_non_auth_to_creation() {
let body = json!({"error": {"message": "no capacity"}});
assert!(matches!(
raise_for_create_status(503, &body),
Err(SailError::Creation { .. })
));
assert!(matches!(
raise_for_create_status(401, &body),
Err(SailError::PermissionDenied { .. })
));
}
#[test]
fn image_spec_serializes_to_canonical_proto_json() {
use crate::image::{
BaseImage, ImageArchitecture, ImageBuildStep, ImageSpec, PackageInstall, RunCommand,
};
let spec = ImageSpec {
base: Some(BaseImage::Debian),
architecture: ImageArchitecture::Arm64,
python_version: "3.12".to_string(),
build_steps: vec![
ImageBuildStep::AptInstall(PackageInstall {
packages: vec!["git".to_string(), "curl".to_string()],
}),
ImageBuildStep::RunCommand(RunCommand {
command: "echo hi".to_string(),
}),
],
..Default::default()
};
let json = serde_json::to_value(&spec).unwrap();
assert_eq!(json["base"], json!("BASE_IMAGE_DEBIAN"));
assert_eq!(json["architecture"], json!("IMAGE_ARCHITECTURE_ARM64"));
assert_eq!(json["pythonVersion"], json!("3.12"));
assert_eq!(
json["buildSteps"][0]["aptInstall"]["packages"][0],
json!("git")
);
assert_eq!(
json["buildSteps"][1]["runCommand"]["command"],
json!("echo hi")
);
let devbox = ImageSpec {
base: Some(BaseImage::Devbox),
architecture: ImageArchitecture::Arm64,
..Default::default()
};
let devbox_json = serde_json::to_value(&devbox).unwrap();
assert_eq!(devbox_json["base"], json!("BASE_IMAGE_DEVBOX"));
}
}