use std::collections::BTreeMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::{
DiskImageFormat, EnvVar, HandoffInit, NetworkPolicy, NetworkSpec, OciRootfsSource, Patch,
PullPolicy, Rlimit, RootfsSource, SandboxLogLevel, SandboxPolicy, SandboxResources,
SandboxRuntimeOptions, SandboxSpec, SecretsConfig, SecurityProfile, VolumeMount,
};
use crate::{TypesError, TypesResult};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct CloudCreateSandboxRequest {
#[serde(flatten)]
pub spec: CloudSandboxSpec,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct CloudSandboxSpec {
pub name: String,
#[cfg_attr(feature = "utoipa", schema(value_type = Object))]
pub image: CloudRootfsSource,
pub resources: CloudSandboxResources,
pub runtime: CloudSandboxRuntimeOptions,
pub env: Vec<EnvVar>,
pub labels: BTreeMap<String, String>,
pub rlimits: Vec<Rlimit>,
pub mounts: Vec<VolumeMount>,
pub patches: Vec<Patch>,
pub network: CloudNetworkSpec,
pub init: Option<HandoffInit>,
pub pull_policy: PullPolicy,
pub security_profile: SecurityProfile,
pub lifecycle: SandboxPolicy,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct CloudSandboxResources {
pub vcpus: u8,
pub memory_mib: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_size_mib: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CloudRootfsSource {
#[serde(alias = "Bind")]
Bind(
#[cfg_attr(feature = "ts", ts(type = "string"))]
PathBuf,
),
#[serde(alias = "Oci")]
Oci {
reference: String,
},
#[serde(alias = "DiskImage")]
DiskImage {
#[cfg_attr(feature = "ts", ts(type = "string"))]
path: PathBuf,
format: DiskImageFormat,
fstype: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default, deny_unknown_fields)]
pub struct CloudNetworkSpec {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<NetworkPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secrets: Option<SecretsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_connections: Option<usize>,
}
impl Default for CloudNetworkSpec {
fn default() -> Self {
Self {
enabled: true,
policy: None,
secrets: None,
max_connections: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default, deny_unknown_fields)]
pub struct CloudSandboxRuntimeOptions {
pub workdir: Option<String>,
pub shell: Option<String>,
pub scripts: BTreeMap<String, String>,
pub entrypoint: Option<Vec<String>>,
pub cmd: Option<Vec<String>>,
pub user: Option<String>,
pub log_level: Option<SandboxLogLevel>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudCreateSandboxResponse {
pub id: String,
pub org_id: String,
pub name: String,
pub slug: String,
pub status: CloudSandboxStatus,
pub spec: CloudSandboxSpec,
pub ephemeral: bool,
#[cfg_attr(feature = "ts", ts(type = "string"))]
pub created_at: DateTime<Utc>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub started_at: Option<DateTime<Utc>>,
#[serde(default)]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub stopped_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CloudSandboxStatus {
Created,
Starting,
Running,
Stopping,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudPaginated<T> {
pub data: Vec<T>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudMessageResponse {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudErrorBody {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub error: Option<CloudErrorDetails>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudErrorDetails {
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
type Error = TypesError;
fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
req.spec.try_into()
}
}
impl TryFrom<CloudSandboxSpec> for SandboxSpec {
type Error = TypesError;
fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
let disk_size_mib = spec.resources.disk_size_mib;
let image = match spec.image {
CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
reference,
upper_size_mib: disk_size_mib,
}),
CloudRootfsSource::Bind(_) | CloudRootfsSource::DiskImage { .. }
if disk_size_mib.is_some() =>
{
return Err(TypesError::invalid_config(
"resources.disk_size_mib is only valid for OCI rootfs",
));
}
CloudRootfsSource::Bind(path) => RootfsSource::Bind(path),
CloudRootfsSource::DiskImage {
path,
format,
fstype,
} => RootfsSource::DiskImage {
path,
format,
fstype,
},
};
let resources = SandboxResources {
vcpus: spec.resources.vcpus,
memory_mib: spec.resources.memory_mib,
max_vcpus: spec.resources.vcpus,
max_memory_mib: spec.resources.memory_mib,
};
let network = NetworkSpec {
enabled: spec.network.enabled,
policy: spec.network.policy,
secrets: spec.network.secrets,
max_connections: spec.network.max_connections,
..NetworkSpec::default()
};
let runtime = SandboxRuntimeOptions {
workdir: spec.runtime.workdir,
shell: spec.runtime.shell,
scripts: spec.runtime.scripts,
entrypoint: spec.runtime.entrypoint,
cmd: spec.runtime.cmd,
user: spec.runtime.user,
log_level: spec.runtime.log_level,
..SandboxRuntimeOptions::default()
};
Ok(Self {
name: spec.name,
image,
resources,
runtime,
env: spec.env,
labels: spec.labels,
rlimits: spec.rlimits,
mounts: spec.mounts,
patches: spec.patches,
network,
init: spec.init,
pull_policy: spec.pull_policy,
security_profile: spec.security_profile,
lifecycle: spec.lifecycle,
})
}
}
impl From<SandboxSpec> for CloudCreateSandboxRequest {
fn from(spec: SandboxSpec) -> Self {
Self { spec: spec.into() }
}
}
impl From<SandboxSpec> for CloudSandboxSpec {
fn from(spec: SandboxSpec) -> Self {
let (image, disk_size_mib) = match spec.image {
RootfsSource::Oci(oci) => (
CloudRootfsSource::Oci {
reference: oci.reference,
},
oci.upper_size_mib,
),
RootfsSource::Bind(path) => (CloudRootfsSource::Bind(path), None),
RootfsSource::DiskImage {
path,
format,
fstype,
} => (
CloudRootfsSource::DiskImage {
path,
format,
fstype,
},
None,
),
};
Self {
name: spec.name,
image,
resources: CloudSandboxResources {
vcpus: spec.resources.vcpus,
memory_mib: spec.resources.memory_mib,
disk_size_mib,
},
runtime: CloudSandboxRuntimeOptions {
workdir: spec.runtime.workdir,
shell: spec.runtime.shell,
scripts: spec.runtime.scripts,
entrypoint: spec.runtime.entrypoint,
cmd: spec.runtime.cmd,
user: spec.runtime.user,
log_level: spec.runtime.log_level,
},
env: spec.env,
labels: spec.labels,
rlimits: spec.rlimits,
mounts: spec.mounts,
patches: spec.patches,
network: CloudNetworkSpec {
enabled: spec.network.enabled,
policy: spec.network.policy,
secrets: spec.network.secrets,
max_connections: spec.network.max_connections,
},
init: spec.init,
pull_policy: spec.pull_policy,
security_profile: spec.security_profile,
lifecycle: spec.lifecycle,
}
}
}
impl Default for CloudSandboxResources {
fn default() -> Self {
let resources = SandboxResources::default();
Self {
vcpus: resources.vcpus,
memory_mib: resources.memory_mib,
disk_size_mib: None,
}
}
}
impl CloudRootfsSource {
pub fn oci(reference: impl Into<String>) -> Self {
Self::Oci {
reference: reference.into(),
}
}
pub fn oci_reference(&self) -> Option<&str> {
match self {
Self::Oci { reference } => Some(reference),
_ => None,
}
}
}
impl Default for CloudRootfsSource {
fn default() -> Self {
Self::oci(String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
DEFAULT_SANDBOX_MEMORY_MIB, DEFAULT_SANDBOX_VCPUS, OciRootfsSource, RootfsSource,
};
fn spec(name: &str) -> CloudSandboxSpec {
CloudSandboxSpec {
name: name.into(),
image: CloudRootfsSource::Oci {
reference: "python:3.12".into(),
},
..Default::default()
}
}
#[test]
fn create_request_flattens_spec() {
let req = CloudCreateSandboxRequest {
spec: spec("agent-1"),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["name"], "agent-1");
assert!(json.get("image").is_some());
let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
assert_eq!(back.spec.name, "agent-1");
}
#[test]
fn create_request_converts_disk_size_to_oci_rootfs() {
let mut req = CloudCreateSandboxRequest {
spec: spec("agent-1"),
};
req.spec.resources.disk_size_mib = Some(8192);
let domain = SandboxSpec::try_from(req).unwrap();
assert_eq!(domain.resources.vcpus, DEFAULT_SANDBOX_VCPUS);
assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
match domain.image {
RootfsSource::Oci(oci) => {
assert_eq!(oci.reference, "python:3.12");
assert_eq!(oci.upper_size_mib, Some(8192));
}
other => panic!("expected OCI rootfs, got {other:?}"),
}
}
#[test]
fn create_request_rejects_disk_size_for_non_oci_rootfs() {
let mut req = CloudCreateSandboxRequest {
spec: spec("agent-1"),
};
req.spec.image = CloudRootfsSource::Bind("/tmp/rootfs".into());
req.spec.resources.disk_size_mib = Some(8192);
let err = SandboxSpec::try_from(req).unwrap_err();
assert!(err.to_string().contains("disk_size_mib"));
}
#[test]
fn domain_spec_converts_oci_size_to_cloud_resources() {
let domain = SandboxSpec {
name: "agent-1".into(),
image: RootfsSource::Oci(OciRootfsSource {
reference: "python:3.12".into(),
upper_size_mib: Some(8192),
}),
..Default::default()
};
let req = CloudCreateSandboxRequest::from(domain);
assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
match req.spec.image {
CloudRootfsSource::Oci { reference } => {
assert_eq!(reference, "python:3.12");
}
other => panic!("expected OCI rootfs, got {other:?}"),
}
}
#[test]
fn create_request_minimal_defaults() {
let req = CloudCreateSandboxRequest {
spec: spec("agent-1"),
};
let json = serde_json::to_value(&req).unwrap();
let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
assert_eq!(back.spec.name, "agent-1");
}
#[test]
fn sandbox_status_round_trips_snake_case() {
for status in [
CloudSandboxStatus::Created,
CloudSandboxStatus::Starting,
CloudSandboxStatus::Running,
CloudSandboxStatus::Stopping,
CloudSandboxStatus::Stopped,
CloudSandboxStatus::Failed,
] {
let s = serde_json::to_string(&status).unwrap();
assert_eq!(
serde_json::from_str::<CloudSandboxStatus>(&s).unwrap(),
status
);
}
assert_eq!(
serde_json::to_string(&CloudSandboxStatus::Starting).unwrap(),
"\"starting\""
);
}
#[test]
fn sandbox_response_round_trips() {
let sb = CloudCreateSandboxResponse {
id: "00000000-0000-0000-0000-000000000002".into(),
org_id: "00000000-0000-0000-0000-000000000001".into(),
name: "agent-1".into(),
slug: "brave-otter".into(),
status: CloudSandboxStatus::Created,
spec: spec("agent-1"),
ephemeral: true,
created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
started_at: None,
stopped_at: None,
last_error: None,
};
let json = serde_json::to_value(&sb).unwrap();
assert_eq!(json["slug"], "brave-otter");
assert_eq!(json["name"], "agent-1");
let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
assert_eq!(back.slug, "brave-otter");
assert_eq!(back.status, CloudSandboxStatus::Created);
assert_eq!(back.spec.name, "agent-1");
assert!(back.started_at.is_none());
}
}