use std::collections::BTreeMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::domain::{
DiskImageFormat, EnvVar, HandoffInit, HostPattern, HostPermissions, MountOptions,
NetworkPolicy, NetworkSpec, OciRootfsSource, Patch, PullPolicy, Rlimit, RlimitResource,
RootDisk, RootfsSource, SandboxLogLevel, SandboxPolicy, SandboxResources,
SandboxRuntimeOptions, SandboxSpec, SecretEntry, SecretInjection, SecretsConfig,
SecurityProfile, StatVirtualization, ViolationAction, VolumeMount, default_private,
default_strict,
};
use crate::modify::SecretSource;
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<CloudRlimit>,
pub mounts: Vec<CloudVolumeMount>,
pub patches: Vec<CloudPatch>,
pub network: CloudNetworkSpec,
pub init: Option<HandoffInit>,
pub pull_policy: CloudPullPolicy,
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, Copy, Default, 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 CloudPullPolicy {
#[default]
IfMissing,
Always,
Never,
}
impl From<PullPolicy> for CloudPullPolicy {
fn from(policy: PullPolicy) -> Self {
match policy {
PullPolicy::IfMissing => Self::IfMissing,
PullPolicy::Always => Self::Always,
PullPolicy::Never => Self::Never,
}
}
}
impl From<CloudPullPolicy> for PullPolicy {
fn from(policy: CloudPullPolicy) -> Self {
match policy {
CloudPullPolicy::IfMissing => Self::IfMissing,
CloudPullPolicy::Always => Self::Always,
CloudPullPolicy::Never => Self::Never,
}
}
}
#[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 CloudDiskImageFormat {
Qcow2,
Raw,
Vmdk,
}
impl From<DiskImageFormat> for CloudDiskImageFormat {
fn from(format: DiskImageFormat) -> Self {
match format {
DiskImageFormat::Qcow2 => Self::Qcow2,
DiskImageFormat::Raw => Self::Raw,
DiskImageFormat::Vmdk => Self::Vmdk,
}
}
}
impl From<CloudDiskImageFormat> for DiskImageFormat {
fn from(format: CloudDiskImageFormat) -> Self {
match format {
CloudDiskImageFormat::Qcow2 => Self::Qcow2,
CloudDiskImageFormat::Raw => Self::Raw,
CloudDiskImageFormat::Vmdk => Self::Vmdk,
}
}
}
#[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 CloudRlimitResource {
Cpu,
Fsize,
Data,
Stack,
Core,
Rss,
Nproc,
Nofile,
Memlock,
As,
Locks,
Sigpending,
Msgqueue,
Nice,
Rtprio,
Rttime,
}
impl From<RlimitResource> for CloudRlimitResource {
fn from(resource: RlimitResource) -> Self {
match resource {
RlimitResource::Cpu => Self::Cpu,
RlimitResource::Fsize => Self::Fsize,
RlimitResource::Data => Self::Data,
RlimitResource::Stack => Self::Stack,
RlimitResource::Core => Self::Core,
RlimitResource::Rss => Self::Rss,
RlimitResource::Nproc => Self::Nproc,
RlimitResource::Nofile => Self::Nofile,
RlimitResource::Memlock => Self::Memlock,
RlimitResource::As => Self::As,
RlimitResource::Locks => Self::Locks,
RlimitResource::Sigpending => Self::Sigpending,
RlimitResource::Msgqueue => Self::Msgqueue,
RlimitResource::Nice => Self::Nice,
RlimitResource::Rtprio => Self::Rtprio,
RlimitResource::Rttime => Self::Rttime,
}
}
}
impl From<CloudRlimitResource> for RlimitResource {
fn from(resource: CloudRlimitResource) -> Self {
match resource {
CloudRlimitResource::Cpu => Self::Cpu,
CloudRlimitResource::Fsize => Self::Fsize,
CloudRlimitResource::Data => Self::Data,
CloudRlimitResource::Stack => Self::Stack,
CloudRlimitResource::Core => Self::Core,
CloudRlimitResource::Rss => Self::Rss,
CloudRlimitResource::Nproc => Self::Nproc,
CloudRlimitResource::Nofile => Self::Nofile,
CloudRlimitResource::Memlock => Self::Memlock,
CloudRlimitResource::As => Self::As,
CloudRlimitResource::Locks => Self::Locks,
CloudRlimitResource::Sigpending => Self::Sigpending,
CloudRlimitResource::Msgqueue => Self::Msgqueue,
CloudRlimitResource::Nice => Self::Nice,
CloudRlimitResource::Rtprio => Self::Rtprio,
CloudRlimitResource::Rttime => Self::Rttime,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudRlimit {
pub resource: CloudRlimitResource,
pub soft: u64,
pub hard: u64,
}
impl From<Rlimit> for CloudRlimit {
fn from(rlimit: Rlimit) -> Self {
Self {
resource: rlimit.resource.into(),
soft: rlimit.soft,
hard: rlimit.hard,
}
}
}
impl From<CloudRlimit> for Rlimit {
fn from(rlimit: CloudRlimit) -> Self {
Self {
resource: rlimit.resource.into(),
soft: rlimit.soft,
hard: rlimit.hard,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CloudPatch {
Text {
path: String,
content: String,
mode: Option<u32>,
replace: bool,
},
File {
path: String,
content: Vec<u8>,
mode: Option<u32>,
replace: bool,
},
CopyFile {
#[cfg_attr(feature = "ts", ts(type = "string"))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
src: PathBuf,
dst: String,
mode: Option<u32>,
replace: bool,
},
CopyDir {
#[cfg_attr(feature = "ts", ts(type = "string"))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
src: PathBuf,
dst: String,
replace: bool,
},
Symlink {
target: String,
link: String,
replace: bool,
},
Mkdir {
path: String,
mode: Option<u32>,
},
Remove {
path: String,
},
Append {
path: String,
content: String,
},
}
impl From<Patch> for CloudPatch {
fn from(patch: Patch) -> Self {
match patch {
Patch::Text {
path,
content,
mode,
replace,
} => Self::Text {
path,
content,
mode,
replace,
},
Patch::File {
path,
content,
mode,
replace,
} => Self::File {
path,
content,
mode,
replace,
},
Patch::CopyFile {
src,
dst,
mode,
replace,
} => Self::CopyFile {
src,
dst,
mode,
replace,
},
Patch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
Patch::Symlink {
target,
link,
replace,
} => Self::Symlink {
target,
link,
replace,
},
Patch::Mkdir { path, mode } => Self::Mkdir { path, mode },
Patch::Remove { path } => Self::Remove { path },
Patch::Append { path, content } => Self::Append { path, content },
}
}
}
impl From<CloudPatch> for Patch {
fn from(patch: CloudPatch) -> Self {
match patch {
CloudPatch::Text {
path,
content,
mode,
replace,
} => Self::Text {
path,
content,
mode,
replace,
},
CloudPatch::File {
path,
content,
mode,
replace,
} => Self::File {
path,
content,
mode,
replace,
},
CloudPatch::CopyFile {
src,
dst,
mode,
replace,
} => Self::CopyFile {
src,
dst,
mode,
replace,
},
CloudPatch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
CloudPatch::Symlink {
target,
link,
replace,
} => Self::Symlink {
target,
link,
replace,
},
CloudPatch::Mkdir { path, mode } => Self::Mkdir { path, mode },
CloudPatch::Remove { path } => Self::Remove { path },
CloudPatch::Append { path, content } => Self::Append { path, content },
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CloudRootfsSource {
Bind {
#[cfg_attr(feature = "ts", ts(type = "string"))]
path: PathBuf,
},
Oci {
reference: String,
},
DiskImage {
#[cfg_attr(feature = "ts", ts(type = "string"))]
path: PathBuf,
format: CloudDiskImageFormat,
fstype: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CloudVolumeMount {
Bind {
#[cfg_attr(feature = "ts", ts(type = "string"))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
host: PathBuf,
guest: String,
#[serde(default)]
options: MountOptions,
#[serde(default = "default_strict")]
stat_virtualization: StatVirtualization,
#[serde(default = "default_private")]
host_permissions: HostPermissions,
#[serde(default)]
quota_mib: Option<u32>,
},
Named {
name: String,
guest: String,
#[serde(default)]
options: MountOptions,
#[serde(default = "default_strict")]
stat_virtualization: StatVirtualization,
#[serde(default = "default_private")]
host_permissions: HostPermissions,
},
Tmpfs {
guest: String,
#[serde(default)]
size_mib: Option<u32>,
#[serde(default)]
options: MountOptions,
},
DiskImage {
#[cfg_attr(feature = "ts", ts(type = "string"))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
host: PathBuf,
guest: String,
format: CloudDiskImageFormat,
#[serde(default)]
fstype: Option<String>,
#[serde(default)]
options: MountOptions,
},
}
impl From<CloudVolumeMount> for VolumeMount {
fn from(m: CloudVolumeMount) -> Self {
match m {
CloudVolumeMount::Bind {
host,
guest,
options,
stat_virtualization,
host_permissions,
quota_mib,
} => VolumeMount::Bind {
host,
guest,
options,
stat_virtualization,
host_permissions,
follow_root_symlinks: false,
quota_mib,
},
CloudVolumeMount::Named {
name,
guest,
options,
stat_virtualization,
host_permissions,
} => VolumeMount::Named {
name,
guest,
create: None,
options,
stat_virtualization,
host_permissions,
follow_root_symlinks: false,
},
CloudVolumeMount::Tmpfs {
guest,
size_mib,
options,
} => VolumeMount::Tmpfs {
guest,
size_mib,
options,
},
CloudVolumeMount::DiskImage {
host,
guest,
format,
fstype,
options,
} => VolumeMount::DiskImage {
host,
guest,
format: format.into(),
fstype,
options,
},
}
}
}
impl From<VolumeMount> for CloudVolumeMount {
fn from(m: VolumeMount) -> Self {
match m {
VolumeMount::Bind {
host,
guest,
options,
stat_virtualization,
host_permissions,
follow_root_symlinks: _,
quota_mib,
} => CloudVolumeMount::Bind {
host,
guest,
options,
stat_virtualization,
host_permissions,
quota_mib,
},
VolumeMount::Named {
name,
guest,
create: _,
options,
stat_virtualization,
host_permissions,
follow_root_symlinks: _,
} => CloudVolumeMount::Named {
name,
guest,
options,
stat_virtualization,
host_permissions,
},
VolumeMount::Tmpfs {
guest,
size_mib,
options,
} => CloudVolumeMount::Tmpfs {
guest,
size_mib,
options,
},
VolumeMount::DiskImage {
host,
guest,
format,
fstype,
options,
} => CloudVolumeMount::DiskImage {
host,
guest,
format: format.into(),
fstype,
options,
},
}
}
}
#[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<CloudSecretsConfig>,
#[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,
#[serde(default)]
pub status_reason: Option<CloudSandboxStatusReason>,
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_failure_message: 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, 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 CloudSandboxStatusReason {
Scheduling,
InsufficientCapacity,
}
#[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,
root_disk: disk_size_mib.map(RootDisk::managed),
}),
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,
follow_root_symlinks: false,
},
CloudRootfsSource::DiskImage {
path,
format,
fstype,
} => RootfsSource::DiskImage {
path,
format: format.into(),
fstype,
},
};
let resources = SandboxResources {
cpus: spec.resources.vcpus,
memory_mib: spec.resources.memory_mib,
max_cpus: spec.resources.vcpus,
max_memory_mib: spec.resources.memory_mib,
};
let network = NetworkSpec {
enabled: spec.network.enabled,
interface: None,
ports: Vec::new(),
policy: spec.network.policy,
dns: None,
tls: None,
secrets: spec.network.secrets.map(Into::into),
max_connections: spec.network.max_connections,
trust_host_cas: false,
};
let runtime = SandboxRuntimeOptions {
workdir: spec.runtime.workdir,
shell: spec.runtime.shell,
scripts: spec.runtime.scripts,
entrypoint: spec.runtime.entrypoint,
cmd: spec.runtime.cmd,
hostname: None,
user: spec.runtime.user,
log_level: spec.runtime.log_level,
metrics_sample_interval_ms: None,
disable_metrics_sample: false,
};
Ok(Self {
name: spec.name,
image,
resources,
runtime,
env: spec.env,
labels: spec.labels,
rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
mounts: spec.mounts.into_iter().map(Into::into).collect(),
patches: spec.patches.into_iter().map(Into::into).collect(),
network,
init: spec.init,
pull_policy: spec.pull_policy.into(),
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,
},
match &oci.root_disk {
Some(RootDisk::Managed { size_mib }) => *size_mib,
_ => None,
},
),
RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
RootfsSource::DiskImage {
path,
format,
fstype,
} => (
CloudRootfsSource::DiskImage {
path,
format: format.into(),
fstype,
},
None,
),
};
Self {
name: spec.name,
image,
resources: CloudSandboxResources {
vcpus: spec.resources.cpus,
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.into_iter().map(Into::into).collect(),
mounts: spec.mounts.into_iter().map(Into::into).collect(),
patches: spec.patches.into_iter().map(Into::into).collect(),
network: CloudNetworkSpec {
enabled: spec.network.enabled,
policy: spec.network.policy,
secrets: spec.network.secrets.map(Into::into),
max_connections: spec.network.max_connections,
},
init: spec.init,
pull_policy: spec.pull_policy.into(),
security_profile: spec.security_profile,
lifecycle: spec.lifecycle,
}
}
}
impl Default for CloudSandboxResources {
fn default() -> Self {
let resources = SandboxResources::default();
Self {
vcpus: resources.cpus,
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())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudSecretsConfig {
#[serde(default)]
pub entries: Vec<CloudSecretEntry>,
#[serde(default)]
pub on_violation: CloudViolationAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CloudSecretEntry {
pub env_var: String,
#[serde(default)]
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<CloudSecretSource>,
pub placeholder: String,
#[serde(default)]
pub allowed_hosts: Vec<CloudHostPattern>,
#[serde(default)]
pub injection: SecretInjection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_violation: Option<CloudViolationAction>,
#[serde(default = "cloud_default_true")]
pub require_tls_identity: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CloudSecretSource {
Env {
var: String,
},
Store {
reference: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CloudHostPattern {
Exact {
value: String,
},
Wildcard {
value: String,
},
Any,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CloudViolationAction {
Block,
#[default]
BlockAndLog,
BlockAndTerminate,
Passthrough {
hosts: Vec<CloudHostPattern>,
},
}
fn cloud_default_true() -> bool {
true
}
impl From<HostPattern> for CloudHostPattern {
fn from(pattern: HostPattern) -> Self {
match pattern {
HostPattern::Exact(value) => Self::Exact { value },
HostPattern::Wildcard(value) => Self::Wildcard { value },
HostPattern::Any => Self::Any,
}
}
}
impl From<CloudHostPattern> for HostPattern {
fn from(pattern: CloudHostPattern) -> Self {
match pattern {
CloudHostPattern::Exact { value } => Self::Exact(value),
CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
CloudHostPattern::Any => Self::Any,
}
}
}
impl From<ViolationAction> for CloudViolationAction {
fn from(action: ViolationAction) -> Self {
match action {
ViolationAction::Block => Self::Block,
ViolationAction::BlockAndLog => Self::BlockAndLog,
ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
ViolationAction::Passthrough(hosts) => Self::Passthrough {
hosts: hosts.into_iter().map(Into::into).collect(),
},
}
}
}
impl From<CloudViolationAction> for ViolationAction {
fn from(action: CloudViolationAction) -> Self {
match action {
CloudViolationAction::Block => Self::Block,
CloudViolationAction::BlockAndLog => Self::BlockAndLog,
CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
CloudViolationAction::Passthrough { hosts } => {
Self::Passthrough(hosts.into_iter().map(Into::into).collect())
}
}
}
}
impl From<SecretSource> for CloudSecretSource {
fn from(source: SecretSource) -> Self {
match source {
SecretSource::Env { var } => Self::Env { var },
SecretSource::Store { reference } => Self::Store { reference },
}
}
}
impl From<CloudSecretSource> for SecretSource {
fn from(source: CloudSecretSource) -> Self {
match source {
CloudSecretSource::Env { var } => Self::Env { var },
CloudSecretSource::Store { reference } => Self::Store { reference },
}
}
}
impl From<SecretEntry> for CloudSecretEntry {
fn from(entry: SecretEntry) -> Self {
Self {
env_var: entry.env_var,
value: entry.value.to_string(),
source: entry.source.map(Into::into),
placeholder: entry.placeholder,
allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
injection: entry.injection,
on_violation: entry.on_violation.map(Into::into),
require_tls_identity: entry.require_tls_identity,
}
}
}
impl From<CloudSecretEntry> for SecretEntry {
fn from(entry: CloudSecretEntry) -> Self {
Self {
env_var: entry.env_var,
value: Zeroizing::new(entry.value),
source: entry.source.map(Into::into),
placeholder: entry.placeholder,
allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
injection: entry.injection,
on_violation: entry.on_violation.map(Into::into),
require_tls_identity: entry.require_tls_identity,
}
}
}
impl From<SecretsConfig> for CloudSecretsConfig {
fn from(config: SecretsConfig) -> Self {
Self {
entries: config.secrets.into_iter().map(Into::into).collect(),
on_violation: config.on_violation.into(),
}
}
}
impl From<CloudSecretsConfig> for SecretsConfig {
fn from(config: CloudSecretsConfig) -> Self {
Self {
secrets: config.entries.into_iter().map(Into::into).collect(),
on_violation: config.on_violation.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, 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 cloud_rootfs_source_uses_internal_tagging() {
let json = serde_json::to_value(CloudRootfsSource::Oci {
reference: "python:3.12".into(),
})
.unwrap();
assert_eq!(
json,
serde_json::json!({"type": "oci", "reference": "python:3.12"})
);
let bind = serde_json::to_value(CloudRootfsSource::Bind {
path: "/host".into(),
})
.unwrap();
assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
}
#[test]
fn cloud_secret_twins_use_internal_tagging() {
assert_eq!(
serde_json::to_value(CloudHostPattern::Exact {
value: "api.example.com".into(),
})
.unwrap(),
serde_json::json!({"type": "exact", "value": "api.example.com"})
);
assert_eq!(
serde_json::to_value(CloudSecretSource::Env {
var: "OPENAI".into()
})
.unwrap(),
serde_json::json!({"type": "env", "var": "OPENAI"})
);
assert_eq!(
serde_json::to_value(CloudViolationAction::Passthrough {
hosts: vec![CloudHostPattern::Any],
})
.unwrap(),
serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
);
}
#[test]
fn cloud_secrets_config_round_trips_through_domain() {
let cloud = CloudSecretsConfig {
entries: vec![CloudSecretEntry {
env_var: "OPENAI_API_KEY".into(),
value: "sk-x".into(),
source: Some(CloudSecretSource::Env {
var: "OPENAI".into(),
}),
placeholder: "$MSB_OPENAI".into(),
allowed_hosts: vec![CloudHostPattern::Exact {
value: "api.openai.com".into(),
}],
injection: SecretInjection::default(),
on_violation: Some(CloudViolationAction::BlockAndTerminate),
require_tls_identity: true,
}],
on_violation: CloudViolationAction::BlockAndLog,
};
let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
assert_eq!(back.entries.len(), 1);
assert_eq!(back.entries[0].value, "sk-x");
assert_eq!(back.entries[0].allowed_hosts.len(), 1);
assert!(matches!(
back.entries[0].on_violation,
Some(CloudViolationAction::BlockAndTerminate)
));
}
#[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.cpus, DEFAULT_SANDBOX_CPUS);
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.root_disk, Some(RootDisk::managed(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 {
path: "/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(),
root_disk: Some(RootDisk::managed(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_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,
status_reason: None,
spec: spec("agent-1"),
ephemeral: true,
created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
started_at: None,
stopped_at: None,
last_failure_message: 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());
}
}