Skip to main content

microsandbox_types/
cloud.rs

1//! Wire types for the cloud backend's HTTP calls.
2//!
3//! HTTP route versions choose this concrete request shape. The request shape is
4//! user-facing intent, so disk sizing sits beside CPU and memory; conversion
5//! into the domain spec moves that value onto the OCI rootfs where the runtime
6//! realizes it.
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::domain::{
15    DiskImageFormat, EnvVar, HandoffInit, NetworkPolicy, NetworkSpec, OciRootfsSource, Patch,
16    PullPolicy, Rlimit, RootfsSource, SandboxLogLevel, SandboxPolicy, SandboxResources,
17    SandboxRuntimeOptions, SandboxSpec, SecretsConfig, SecurityProfile, VolumeMount,
18};
19use crate::{TypesError, TypesResult};
20
21//--------------------------------------------------------------------------------------------------
22// Types: Request
23//--------------------------------------------------------------------------------------------------
24
25/// Wire shape of a cloud sandbox create request body.
26///
27/// Flattens [`CloudSandboxSpec`] onto the request body, so on the wire this is
28/// byte-identical to `CloudSandboxSpec`. typeshare cannot model `#[serde(flatten)]`,
29/// so the generated bindings surface the flattened shape as `CloudSandboxSpec`
30/// directly (see [`CloudCreateSandboxResponse::spec`], typed `CloudSandboxSpec`).
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
33pub struct CloudCreateSandboxRequest {
34    /// The cloud sandbox specification, flattened onto the request body.
35    #[serde(flatten)]
36    pub spec: CloudSandboxSpec,
37}
38
39/// Cloud sandbox specification carried on create routes.
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
43#[serde(default)]
44pub struct CloudSandboxSpec {
45    /// Unique sandbox name.
46    pub name: String,
47
48    /// Root filesystem source.
49    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
50    pub image: CloudRootfsSource,
51
52    /// CPU, memory, and user-facing disk resources.
53    pub resources: CloudSandboxResources,
54
55    /// Guest runtime options (curated; platform-controlled fields omitted).
56    pub runtime: CloudSandboxRuntimeOptions,
57
58    /// Environment variables visible to commands in the sandbox.
59    pub env: Vec<EnvVar>,
60
61    /// User-defined labels attached to the sandbox.
62    pub labels: BTreeMap<String, String>,
63
64    /// Sandbox-wide resource limits inherited by guest processes.
65    pub rlimits: Vec<Rlimit>,
66
67    /// Volume mounts.
68    pub mounts: Vec<VolumeMount>,
69
70    /// Rootfs patches applied before VM start.
71    pub patches: Vec<Patch>,
72
73    /// Network specification (curated; platform-controlled fields omitted).
74    pub network: CloudNetworkSpec,
75
76    /// Hand off PID 1 to a guest init binary after agentd setup.
77    pub init: Option<HandoffInit>,
78
79    /// Pull policy for OCI images.
80    pub pull_policy: PullPolicy,
81
82    /// In-guest security profile.
83    pub security_profile: SecurityProfile,
84
85    /// Sandbox lifecycle policy.
86    pub lifecycle: SandboxPolicy,
87}
88
89/// Cloud resource request.
90#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
91#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
92#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
93#[serde(default)]
94pub struct CloudSandboxResources {
95    /// Number of virtual CPUs.
96    pub vcpus: u8,
97
98    /// Guest memory in MiB.
99    pub memory_mib: u32,
100
101    /// Writable disk size in MiB. Applies only to OCI root filesystems.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub disk_size_mib: Option<u32>,
104}
105
106/// Cloud root filesystem source.
107///
108/// Mirrors the domain [`RootfsSource`] JSON shape, but keeps writable-disk
109/// sizing out of the image payload. Cloud callers express that intent through
110/// [`CloudSandboxResources::disk_size_mib`]; conversion to the domain spec
111/// attaches it to OCI rootfs.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
114#[serde(rename_all = "snake_case")]
115pub enum CloudRootfsSource {
116    /// Use a host directory directly as the root filesystem.
117    #[serde(alias = "Bind")]
118    Bind(
119        /// Host path to bind mount.
120        #[cfg_attr(feature = "ts", ts(type = "string"))]
121        PathBuf,
122    ),
123
124    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
125    #[serde(alias = "Oci")]
126    Oci {
127        /// OCI image reference (e.g. `python`).
128        reference: String,
129    },
130
131    /// Use a disk image file as the root filesystem via virtio-blk.
132    #[serde(alias = "DiskImage")]
133    DiskImage {
134        /// Path to the disk image file on the host.
135        #[cfg_attr(feature = "ts", ts(type = "string"))]
136        path: PathBuf,
137        /// Disk image format.
138        format: DiskImageFormat,
139        /// Inner filesystem type (optional; auto-detected if absent).
140        fstype: Option<String>,
141    },
142}
143
144/// Cloud network specification. Mirrors the domain [`NetworkSpec`] but omits
145/// the platform-controlled fields: interface overrides, host port mapping,
146/// DNS, TLS interception, and host-CA trust are set by the cloud, not the
147/// caller. `deny_unknown_fields` — posting an omitted field is an error, not a
148/// silent drop.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
151#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
152#[serde(default, deny_unknown_fields)]
153pub struct CloudNetworkSpec {
154    /// Whether networking is enabled for this sandbox.
155    pub enabled: bool,
156
157    /// Egress/ingress policy. The cloud floors it (hard-denies the internal
158    /// network) before boot; public egress stays the caller's to govern.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub policy: Option<NetworkPolicy>,
161
162    /// Secret-injection config.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub secrets: Option<SecretsConfig>,
165
166    /// Max concurrent guest connections (the cloud clamps it to a ceiling).
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub max_connections: Option<usize>,
169}
170
171impl Default for CloudNetworkSpec {
172    fn default() -> Self {
173        Self {
174            enabled: true,
175            policy: None,
176            secrets: None,
177            max_connections: None,
178        }
179    }
180}
181
182/// Cloud guest runtime options. Mirrors [`SandboxRuntimeOptions`] but omits the
183/// platform-controlled fields: the hostname (pinned to the sandbox id) and the
184/// metrics-sampling knobs (metering integrity). `deny_unknown_fields`.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
187#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
188#[serde(default, deny_unknown_fields)]
189pub struct CloudSandboxRuntimeOptions {
190    /// Working directory for guest commands.
191    pub workdir: Option<String>,
192
193    /// Default shell.
194    pub shell: Option<String>,
195
196    /// Named in-guest scripts.
197    pub scripts: BTreeMap<String, String>,
198
199    /// Entrypoint override.
200    pub entrypoint: Option<Vec<String>>,
201
202    /// Command override.
203    pub cmd: Option<Vec<String>>,
204
205    /// Guest user.
206    pub user: Option<String>,
207
208    /// Runtime log level.
209    pub log_level: Option<SandboxLogLevel>,
210}
211
212//--------------------------------------------------------------------------------------------------
213// Types: Response
214//--------------------------------------------------------------------------------------------------
215
216/// Wire shape of the cloud sandbox response returned by sandbox endpoints.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
220pub struct CloudCreateSandboxResponse {
221    /// Server-side UUID.
222    pub id: String,
223    /// Owning org's UUID.
224    pub org_id: String,
225    /// User-facing, per-org sandbox name.
226    pub name: String,
227    /// Canonical, resolved SSH username token.
228    pub slug: String,
229    /// Current lifecycle status.
230    pub status: CloudSandboxStatus,
231    /// The sandbox spec the cloud control plane stored for this sandbox.
232    pub spec: CloudSandboxSpec,
233    /// Whether the sandbox should be removed when its allocation terminates.
234    pub ephemeral: bool,
235    /// Creation timestamp.
236    #[cfg_attr(feature = "ts", ts(type = "string"))]
237    pub created_at: DateTime<Utc>,
238    /// Last start timestamp, when known.
239    #[serde(default)]
240    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
241    pub started_at: Option<DateTime<Utc>>,
242    /// Last stop timestamp, when known.
243    #[serde(default)]
244    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
245    pub stopped_at: Option<DateTime<Utc>>,
246    /// Last failure reason, when any.
247    #[serde(default)]
248    pub last_error: Option<String>,
249}
250
251/// Sandbox lifecycle status returned by the cloud control plane.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
254#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
255#[serde(rename_all = "snake_case")]
256pub enum CloudSandboxStatus {
257    /// Created in the database but not yet started.
258    Created,
259    /// Start request has been submitted.
260    Starting,
261    /// Sandbox is running.
262    Running,
263    /// Stop request has been submitted.
264    Stopping,
265    /// Sandbox is stopped.
266    Stopped,
267    /// Sandbox failed.
268    Failed,
269}
270
271/// Wire shape of paginated list responses.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
274#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
275pub struct CloudPaginated<T> {
276    /// Page of response items.
277    pub data: Vec<T>,
278    /// Cursor for the next page, when one exists.
279    #[serde(default)]
280    pub next_cursor: Option<String>,
281}
282
283/// Wire shape of the message response returned by mutation endpoints.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
286#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
287pub struct CloudMessageResponse {
288    /// Human-readable response message.
289    pub message: String,
290}
291
292/// Wire shape of the typed error body returned by cloud APIs on 4xx/5xx responses.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
295#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
296pub struct CloudErrorBody {
297    /// Flat machine-readable error code, when returned in this shape.
298    #[serde(default)]
299    pub code: Option<String>,
300    /// Flat human-readable error message, when returned in this shape.
301    #[serde(default)]
302    pub message: Option<String>,
303    /// Nested error object returned by the API error responder.
304    #[serde(default)]
305    pub error: Option<CloudErrorDetails>,
306}
307
308/// Nested cloud API error details.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
311#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
312pub struct CloudErrorDetails {
313    /// Machine-readable error code.
314    #[serde(default)]
315    pub code: Option<String>,
316    /// Human-readable error message.
317    #[serde(default)]
318    pub message: Option<String>,
319}
320
321//--------------------------------------------------------------------------------------------------
322// Trait Implementations
323//--------------------------------------------------------------------------------------------------
324
325impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
326    type Error = TypesError;
327
328    fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
329        req.spec.try_into()
330    }
331}
332
333impl TryFrom<CloudSandboxSpec> for SandboxSpec {
334    type Error = TypesError;
335
336    fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
337        let disk_size_mib = spec.resources.disk_size_mib;
338        let image = match spec.image {
339            CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
340                reference,
341                upper_size_mib: disk_size_mib,
342            }),
343            CloudRootfsSource::Bind(_) | CloudRootfsSource::DiskImage { .. }
344                if disk_size_mib.is_some() =>
345            {
346                return Err(TypesError::invalid_config(
347                    "resources.disk_size_mib is only valid for OCI rootfs",
348                ));
349            }
350            CloudRootfsSource::Bind(path) => RootfsSource::Bind(path),
351            CloudRootfsSource::DiskImage {
352                path,
353                format,
354                fstype,
355            } => RootfsSource::DiskImage {
356                path,
357                format,
358                fstype,
359            },
360        };
361
362        let resources = SandboxResources {
363            vcpus: spec.resources.vcpus,
364            memory_mib: spec.resources.memory_mib,
365            // The cloud wire type has no boot-capacity fields yet; treat the
366            // effective resources as the maximum (mirrors SandboxResources
367            // deserialization for legacy configs).
368            max_vcpus: spec.resources.vcpus,
369            max_memory_mib: spec.resources.memory_mib,
370        };
371
372        // Fill the platform-controlled fields the cloud twins omit with safe
373        // defaults; the resolver + driver floor set/override them.
374        let network = NetworkSpec {
375            enabled: spec.network.enabled,
376            policy: spec.network.policy,
377            secrets: spec.network.secrets,
378            max_connections: spec.network.max_connections,
379            ..NetworkSpec::default()
380        };
381        let runtime = SandboxRuntimeOptions {
382            workdir: spec.runtime.workdir,
383            shell: spec.runtime.shell,
384            scripts: spec.runtime.scripts,
385            entrypoint: spec.runtime.entrypoint,
386            cmd: spec.runtime.cmd,
387            user: spec.runtime.user,
388            log_level: spec.runtime.log_level,
389            ..SandboxRuntimeOptions::default()
390        };
391
392        Ok(Self {
393            name: spec.name,
394            image,
395            resources,
396            runtime,
397            env: spec.env,
398            labels: spec.labels,
399            rlimits: spec.rlimits,
400            mounts: spec.mounts,
401            patches: spec.patches,
402            network,
403            init: spec.init,
404            pull_policy: spec.pull_policy,
405            security_profile: spec.security_profile,
406            lifecycle: spec.lifecycle,
407        })
408    }
409}
410
411impl From<SandboxSpec> for CloudCreateSandboxRequest {
412    fn from(spec: SandboxSpec) -> Self {
413        Self { spec: spec.into() }
414    }
415}
416
417impl From<SandboxSpec> for CloudSandboxSpec {
418    fn from(spec: SandboxSpec) -> Self {
419        let (image, disk_size_mib) = match spec.image {
420            RootfsSource::Oci(oci) => (
421                CloudRootfsSource::Oci {
422                    reference: oci.reference,
423                },
424                oci.upper_size_mib,
425            ),
426            RootfsSource::Bind(path) => (CloudRootfsSource::Bind(path), None),
427            RootfsSource::DiskImage {
428                path,
429                format,
430                fstype,
431            } => (
432                CloudRootfsSource::DiskImage {
433                    path,
434                    format,
435                    fstype,
436                },
437                None,
438            ),
439        };
440
441        Self {
442            name: spec.name,
443            image,
444            resources: CloudSandboxResources {
445                vcpus: spec.resources.vcpus,
446                memory_mib: spec.resources.memory_mib,
447                disk_size_mib,
448            },
449            runtime: CloudSandboxRuntimeOptions {
450                workdir: spec.runtime.workdir,
451                shell: spec.runtime.shell,
452                scripts: spec.runtime.scripts,
453                entrypoint: spec.runtime.entrypoint,
454                cmd: spec.runtime.cmd,
455                user: spec.runtime.user,
456                log_level: spec.runtime.log_level,
457            },
458            env: spec.env,
459            labels: spec.labels,
460            rlimits: spec.rlimits,
461            mounts: spec.mounts,
462            patches: spec.patches,
463            network: CloudNetworkSpec {
464                enabled: spec.network.enabled,
465                policy: spec.network.policy,
466                secrets: spec.network.secrets,
467                max_connections: spec.network.max_connections,
468            },
469            init: spec.init,
470            pull_policy: spec.pull_policy,
471            security_profile: spec.security_profile,
472            lifecycle: spec.lifecycle,
473        }
474    }
475}
476
477impl Default for CloudSandboxResources {
478    fn default() -> Self {
479        let resources = SandboxResources::default();
480        Self {
481            vcpus: resources.vcpus,
482            memory_mib: resources.memory_mib,
483            disk_size_mib: None,
484        }
485    }
486}
487
488impl CloudRootfsSource {
489    /// Create an OCI rootfs source from an image reference.
490    pub fn oci(reference: impl Into<String>) -> Self {
491        Self::Oci {
492            reference: reference.into(),
493        }
494    }
495
496    /// Return the OCI image reference if this is an OCI rootfs.
497    pub fn oci_reference(&self) -> Option<&str> {
498        match self {
499            Self::Oci { reference } => Some(reference),
500            _ => None,
501        }
502    }
503}
504
505impl Default for CloudRootfsSource {
506    fn default() -> Self {
507        Self::oci(String::new())
508    }
509}
510
511//--------------------------------------------------------------------------------------------------
512// Tests
513//--------------------------------------------------------------------------------------------------
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::domain::{
519        DEFAULT_SANDBOX_MEMORY_MIB, DEFAULT_SANDBOX_VCPUS, OciRootfsSource, RootfsSource,
520    };
521
522    fn spec(name: &str) -> CloudSandboxSpec {
523        CloudSandboxSpec {
524            name: name.into(),
525            image: CloudRootfsSource::Oci {
526                reference: "python:3.12".into(),
527            },
528            ..Default::default()
529        }
530    }
531
532    #[test]
533    fn create_request_flattens_spec() {
534        let req = CloudCreateSandboxRequest {
535            spec: spec("agent-1"),
536        };
537        let json = serde_json::to_value(&req).unwrap();
538        // Spec fields are flattened onto the top level (SDK parity).
539        assert_eq!(json["name"], "agent-1");
540        assert!(json.get("image").is_some());
541
542        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
543        assert_eq!(back.spec.name, "agent-1");
544    }
545
546    #[test]
547    fn create_request_converts_disk_size_to_oci_rootfs() {
548        let mut req = CloudCreateSandboxRequest {
549            spec: spec("agent-1"),
550        };
551        req.spec.resources.disk_size_mib = Some(8192);
552
553        let domain = SandboxSpec::try_from(req).unwrap();
554
555        assert_eq!(domain.resources.vcpus, DEFAULT_SANDBOX_VCPUS);
556        assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
557        match domain.image {
558            RootfsSource::Oci(oci) => {
559                assert_eq!(oci.reference, "python:3.12");
560                assert_eq!(oci.upper_size_mib, Some(8192));
561            }
562            other => panic!("expected OCI rootfs, got {other:?}"),
563        }
564    }
565
566    #[test]
567    fn create_request_rejects_disk_size_for_non_oci_rootfs() {
568        let mut req = CloudCreateSandboxRequest {
569            spec: spec("agent-1"),
570        };
571        req.spec.image = CloudRootfsSource::Bind("/tmp/rootfs".into());
572        req.spec.resources.disk_size_mib = Some(8192);
573
574        let err = SandboxSpec::try_from(req).unwrap_err();
575
576        assert!(err.to_string().contains("disk_size_mib"));
577    }
578
579    #[test]
580    fn domain_spec_converts_oci_size_to_cloud_resources() {
581        let domain = SandboxSpec {
582            name: "agent-1".into(),
583            image: RootfsSource::Oci(OciRootfsSource {
584                reference: "python:3.12".into(),
585                upper_size_mib: Some(8192),
586            }),
587            ..Default::default()
588        };
589
590        let req = CloudCreateSandboxRequest::from(domain);
591
592        assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
593        match req.spec.image {
594            CloudRootfsSource::Oci { reference } => {
595                assert_eq!(reference, "python:3.12");
596            }
597            other => panic!("expected OCI rootfs, got {other:?}"),
598        }
599    }
600
601    #[test]
602    fn create_request_minimal_defaults() {
603        // Only the spec's name + image are set; everything else defaults.
604        let req = CloudCreateSandboxRequest {
605            spec: spec("agent-1"),
606        };
607        let json = serde_json::to_value(&req).unwrap();
608        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
609        assert_eq!(back.spec.name, "agent-1");
610    }
611
612    #[test]
613    fn sandbox_status_round_trips_snake_case() {
614        for status in [
615            CloudSandboxStatus::Created,
616            CloudSandboxStatus::Starting,
617            CloudSandboxStatus::Running,
618            CloudSandboxStatus::Stopping,
619            CloudSandboxStatus::Stopped,
620            CloudSandboxStatus::Failed,
621        ] {
622            let s = serde_json::to_string(&status).unwrap();
623            assert_eq!(
624                serde_json::from_str::<CloudSandboxStatus>(&s).unwrap(),
625                status
626            );
627        }
628        assert_eq!(
629            serde_json::to_string(&CloudSandboxStatus::Starting).unwrap(),
630            "\"starting\""
631        );
632    }
633
634    #[test]
635    fn sandbox_response_round_trips() {
636        let sb = CloudCreateSandboxResponse {
637            id: "00000000-0000-0000-0000-000000000002".into(),
638            org_id: "00000000-0000-0000-0000-000000000001".into(),
639            name: "agent-1".into(),
640            slug: "brave-otter".into(),
641            status: CloudSandboxStatus::Created,
642            spec: spec("agent-1"),
643            ephemeral: true,
644            created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
645            started_at: None,
646            stopped_at: None,
647            last_error: None,
648        };
649        let json = serde_json::to_value(&sb).unwrap();
650        assert_eq!(json["slug"], "brave-otter");
651        assert_eq!(json["name"], "agent-1");
652
653        let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
654        assert_eq!(back.slug, "brave-otter");
655        assert_eq!(back.status, CloudSandboxStatus::Created);
656        assert_eq!(back.spec.name, "agent-1");
657        assert!(back.started_at.is_none());
658    }
659}