Skip to main content

microsandbox_types/cloud/
mod.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//!
8//! Cloud request objects ignore unknown fields so SDK and server releases can
9//! evolve independently. Missing fields keep their documented defaults; known
10//! fields and enum variants are still validated. Acceptance of a request does
11//! not imply support for settings unknown to the receiving server.
12
13use std::collections::BTreeMap;
14use std::path::PathBuf;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use crate::domain::{
20    CpuPlacement, DeploymentProfile, EnvVar, HandoffInit, NetworkSpec, OciRootfsSource, RootDisk,
21    RootfsSource, SandboxPolicy, SandboxResources, SandboxRuntimeOptions, SandboxSpec,
22    SecurityProfile, TransparentHugePagePolicy, VsockSpec,
23};
24use crate::{TypesError, TypesResult};
25
26mod compat;
27mod secrets;
28mod snapshots;
29mod specs;
30
31pub use secrets::{
32    CloudHostPattern, CloudSecretEntry, CloudSecretSource, CloudSecretsConfig, CloudViolationAction,
33};
34pub use snapshots::{
35    CloudCreateSnapshotRequest, CloudSnapshot, CloudSnapshotDetails, CloudSnapshotKind,
36    CloudSnapshotLocation, CloudSnapshotOperation, CloudSnapshotOperationStatus, CloudSnapshotSpec,
37};
38pub use specs::{
39    CloudDiskImageFormat, CloudNetworkSpec, CloudPatch, CloudPullPolicy, CloudRlimit,
40    CloudRlimitResource, CloudRootfsSource, CloudSandboxRuntimeOptions, CloudVolumeMount,
41};
42
43//--------------------------------------------------------------------------------------------------
44// Types: Request
45//--------------------------------------------------------------------------------------------------
46
47/// Wire shape of a cloud sandbox create request body.
48///
49/// Each root filesystem origin is a distinct source variant. The common
50/// sandbox settings remain flat beside the source-specific fields. Legacy
51/// requests carrying an `image` object are accepted during migration.
52#[derive(Debug, Clone, Serialize)]
53#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
54#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
55#[serde(tag = "source", rename_all = "snake_case")]
56pub enum CloudCreateSandboxRequest {
57    /// Create a sandbox from an OCI image.
58    Oci {
59        /// Settings shared by every sandbox source.
60        #[serde(flatten)]
61        sandbox: CloudSandboxSpec,
62        /// OCI image reference.
63        reference: String,
64        /// CPU, memory, and writable-disk resources.
65        #[serde(default)]
66        resources: CloudSandboxResources,
67        /// Rootfs patches applied before VM start.
68        #[serde(default)]
69        patches: Vec<CloudPatch>,
70        /// OCI image pull policy.
71        #[serde(default)]
72        pull_policy: CloudPullPolicy,
73    },
74    /// Create a sandbox from a host directory.
75    Bind {
76        /// Settings shared by every sandbox source.
77        #[serde(flatten)]
78        sandbox: CloudSandboxSpec,
79        /// Host directory used as the root filesystem.
80        #[cfg_attr(feature = "ts", ts(type = "string"))]
81        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
82        path: PathBuf,
83        /// CPU and memory resources.
84        #[serde(default)]
85        resources: CloudSandboxComputeResources,
86        /// Rootfs patches applied before VM start.
87        #[serde(default)]
88        patches: Vec<CloudPatch>,
89    },
90    /// Create a sandbox from a disk image file.
91    DiskImage {
92        /// Settings shared by every sandbox source.
93        #[serde(flatten)]
94        sandbox: CloudSandboxSpec,
95        /// Host path to the disk image.
96        #[cfg_attr(feature = "ts", ts(type = "string"))]
97        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
98        path: PathBuf,
99        /// Disk image format.
100        format: CloudDiskImageFormat,
101        /// Inner filesystem type, when it cannot be detected automatically.
102        fstype: Option<String>,
103        /// CPU and memory resources.
104        #[serde(default)]
105        resources: CloudSandboxComputeResources,
106        /// Rootfs patches applied before VM start.
107        #[serde(default)]
108        patches: Vec<CloudPatch>,
109    },
110    /// Create a fresh-booted sandbox from a disk snapshot.
111    DiskSnapshot {
112        /// Settings shared by every sandbox source.
113        #[serde(flatten)]
114        sandbox: CloudSandboxSpec,
115        /// Disk snapshot to restore.
116        disk_snapshot_ref: CloudSnapshotLocation,
117        /// CPU and memory resources.
118        #[serde(default)]
119        resources: CloudSandboxComputeResources,
120        /// Pull policy used if the snapshot's pinned base image must be fetched.
121        #[serde(default)]
122        pull_policy: CloudPullPolicy,
123    },
124}
125
126/// Settings shared by every cloud sandbox creation source.
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
129#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
130#[serde(default)]
131pub struct CloudSandboxSpec {
132    /// Unique sandbox name.
133    #[cfg_attr(feature = "utoipa", schema(required = true))]
134    pub name: String,
135
136    /// Guest runtime options.
137    pub runtime: CloudSandboxRuntimeOptions,
138
139    /// Environment variables visible to commands in the sandbox.
140    pub env: Vec<EnvVar>,
141
142    /// User-defined labels attached to the sandbox.
143    pub labels: BTreeMap<String, String>,
144
145    /// Sandbox-wide resource limits inherited by guest processes.
146    pub rlimits: Vec<CloudRlimit>,
147
148    /// Volume mounts.
149    pub mounts: Vec<CloudVolumeMount>,
150
151    /// Network specification.
152    pub network: CloudNetworkSpec,
153
154    /// Hand off PID 1 to a guest init binary after agentd setup.
155    pub init: Option<HandoffInit>,
156
157    /// In-guest security profile.
158    pub security_profile: SecurityProfile,
159
160    /// Sandbox lifecycle policy.
161    pub lifecycle: SandboxPolicy,
162}
163
164/// CPU and memory request shared by sources without a managed writable disk.
165#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
166#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
167#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
168#[serde(default)]
169pub struct CloudSandboxComputeResources {
170    /// Number of virtual CPUs.
171    pub vcpus: u8,
172
173    /// Guest memory in MiB.
174    pub memory_mib: u32,
175}
176
177/// Cloud resource request.
178#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
179#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
180#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
181#[serde(default)]
182pub struct CloudSandboxResources {
183    /// Number of virtual CPUs.
184    pub vcpus: u8,
185
186    /// Guest memory in MiB.
187    pub memory_mib: u32,
188
189    /// Writable disk size in MiB. Applies only to OCI root filesystems.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub disk_size_mib: Option<u32>,
192}
193
194//--------------------------------------------------------------------------------------------------
195// Types: Response
196//--------------------------------------------------------------------------------------------------
197
198/// Wire shape of the cloud sandbox response returned by sandbox endpoints.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
201#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
202pub struct CloudCreateSandboxResponse {
203    /// Server-side UUID.
204    pub id: String,
205    /// Owning org's UUID.
206    pub org_id: String,
207    /// User-facing, per-org sandbox name.
208    pub name: String,
209    /// Canonical, resolved SSH username token.
210    pub slug: String,
211    /// Current lifecycle status.
212    pub status: CloudSandboxStatus,
213    /// Why the sandbox is not running yet, when known. Only present while
214    /// `status` is `starting`.
215    #[serde(default)]
216    pub status_reason: Option<CloudSandboxStatusReason>,
217    /// Curated resolved-spec projection returned by the control plane, when
218    /// available. Lifecycle and agent operations intentionally do not depend
219    /// on reconstructing the create request from this server-owned view.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    #[cfg_attr(feature = "ts", ts(type = "unknown | null | undefined"))]
222    pub spec: Option<serde_json::Value>,
223    /// Whether the sandbox should be removed when its allocation terminates.
224    pub ephemeral: bool,
225    /// Creation timestamp.
226    #[cfg_attr(feature = "ts", ts(type = "string"))]
227    pub created_at: DateTime<Utc>,
228    /// Last start timestamp, when known.
229    #[serde(default)]
230    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
231    pub started_at: Option<DateTime<Utc>>,
232    /// Last stop timestamp, when known.
233    #[serde(default)]
234    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
235    pub stopped_at: Option<DateTime<Utc>>,
236    /// Human-readable message for the most recent failure, when any.
237    #[serde(default)]
238    pub last_failure_message: Option<String>,
239}
240
241/// Sandbox lifecycle status returned by the cloud control plane.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
244#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
245#[serde(rename_all = "snake_case")]
246pub enum CloudSandboxStatus {
247    /// Created in the database but not yet started.
248    Created,
249    /// Start request has been submitted.
250    Starting,
251    /// Sandbox is running.
252    Running,
253    /// Stop request has been submitted.
254    Stopping,
255    /// Sandbox is stopped.
256    Stopped,
257    /// Sandbox failed.
258    Failed,
259}
260
261/// Reason a sandbox start is still in progress. Only meaningful while
262/// `status` is `starting`.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
265#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
266#[serde(rename_all = "snake_case")]
267pub enum CloudSandboxStatusReason {
268    /// The start has been accepted and is being scheduled.
269    Scheduling,
270    /// No capacity is currently available; the start proceeds when
271    /// capacity frees up.
272    InsufficientCapacity,
273}
274
275/// Wire shape of paginated list responses.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
278#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
279pub struct CloudPaginated<T> {
280    /// Page of response items.
281    pub data: Vec<T>,
282    /// Cursor for the next page, when one exists.
283    #[serde(default)]
284    pub next_cursor: Option<String>,
285}
286
287/// Wire shape of the message response returned by mutation endpoints.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
290#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
291pub struct CloudMessageResponse {
292    /// Human-readable response message.
293    pub message: String,
294}
295
296/// Wire shape of the typed error body returned by cloud APIs on 4xx/5xx responses.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
299#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
300pub struct CloudErrorBody {
301    /// Flat machine-readable error code, when returned in this shape.
302    #[serde(default)]
303    pub code: Option<String>,
304    /// Flat human-readable error message, when returned in this shape.
305    #[serde(default)]
306    pub message: Option<String>,
307    /// Nested error object returned by the API error responder.
308    #[serde(default)]
309    pub error: Option<CloudErrorDetails>,
310}
311
312/// Nested cloud API error details.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
315#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
316pub struct CloudErrorDetails {
317    /// Machine-readable error code.
318    #[serde(default)]
319    pub code: Option<String>,
320    /// Human-readable error message.
321    #[serde(default)]
322    pub message: Option<String>,
323}
324
325//--------------------------------------------------------------------------------------------------
326// Trait Implementations
327//--------------------------------------------------------------------------------------------------
328
329impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
330    type Error = TypesError;
331
332    fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
333        match req {
334            CloudCreateSandboxRequest::Oci {
335                sandbox,
336                reference,
337                resources,
338                patches,
339                pull_policy,
340            } => sandbox.into_domain_spec(
341                RootfsSource::Oci(OciRootfsSource {
342                    reference,
343                    root_disk: resources.disk_size_mib.map(RootDisk::managed),
344                }),
345                resources.into(),
346                patches,
347                pull_policy,
348            ),
349            CloudCreateSandboxRequest::Bind {
350                sandbox,
351                path,
352                resources,
353                patches,
354            } => sandbox.into_domain_spec(
355                RootfsSource::Bind {
356                    path,
357                    follow_root_symlinks: false,
358                },
359                resources,
360                patches,
361                CloudPullPolicy::default(),
362            ),
363            CloudCreateSandboxRequest::DiskImage {
364                sandbox,
365                path,
366                format,
367                fstype,
368                resources,
369                patches,
370            } => sandbox.into_domain_spec(
371                RootfsSource::DiskImage {
372                    path,
373                    format: format.into(),
374                    fstype,
375                },
376                resources,
377                patches,
378                CloudPullPolicy::default(),
379            ),
380            CloudCreateSandboxRequest::DiskSnapshot { .. } => Err(TypesError::invalid_config(
381                "disk_snapshot_ref is not supported here: resolve the snapshot reference \
382                 to a concrete image before converting to a sandbox spec",
383            )),
384        }
385    }
386}
387
388impl CloudCreateSandboxRequest {
389    /// Return settings shared by every sandbox source.
390    pub const fn sandbox_spec(&self) -> &CloudSandboxSpec {
391        match self {
392            Self::Oci { sandbox, .. }
393            | Self::Bind { sandbox, .. }
394            | Self::DiskImage { sandbox, .. }
395            | Self::DiskSnapshot { sandbox, .. } => sandbox,
396        }
397    }
398
399    /// Return mutable settings shared by every sandbox source.
400    pub const fn sandbox_spec_mut(&mut self) -> &mut CloudSandboxSpec {
401        match self {
402            Self::Oci { sandbox, .. }
403            | Self::Bind { sandbox, .. }
404            | Self::DiskImage { sandbox, .. }
405            | Self::DiskSnapshot { sandbox, .. } => sandbox,
406        }
407    }
408
409    /// Return the disk snapshot reference, when restoring one.
410    pub const fn disk_snapshot_ref(&self) -> Option<&CloudSnapshotLocation> {
411        match self {
412            Self::DiskSnapshot {
413                disk_snapshot_ref, ..
414            } => Some(disk_snapshot_ref),
415            _ => None,
416        }
417    }
418
419    /// Return the OCI image reference, when creating from OCI.
420    pub fn oci_reference(&self) -> Option<&str> {
421        match self {
422            Self::Oci { reference, .. } => Some(reference),
423            _ => None,
424        }
425    }
426
427    /// Return the requested CPU and memory resources.
428    pub const fn compute_resources(&self) -> CloudSandboxComputeResources {
429        match self {
430            Self::Oci { resources, .. } => CloudSandboxComputeResources {
431                vcpus: resources.vcpus,
432                memory_mib: resources.memory_mib,
433            },
434            Self::Bind { resources, .. }
435            | Self::DiskImage { resources, .. }
436            | Self::DiskSnapshot { resources, .. } => *resources,
437        }
438    }
439
440    /// Return the requested OCI writable-disk size, if this is an OCI source.
441    pub const fn oci_disk_size_mib(&self) -> Option<Option<u32>> {
442        match self {
443            Self::Oci { resources, .. } => Some(resources.disk_size_mib),
444            _ => None,
445        }
446    }
447
448    /// Set the OCI writable-disk size, returning whether this is an OCI source.
449    pub fn set_oci_disk_size_mib(&mut self, disk_size_mib: u32) -> bool {
450        let Self::Oci { resources, .. } = self else {
451            return false;
452        };
453        resources.disk_size_mib = Some(disk_size_mib);
454        true
455    }
456}
457
458impl CloudSandboxSpec {
459    fn into_domain_spec(
460        self,
461        image: RootfsSource,
462        resources: CloudSandboxComputeResources,
463        patches: Vec<CloudPatch>,
464        pull_policy: CloudPullPolicy,
465    ) -> TypesResult<SandboxSpec> {
466        let resources = SandboxResources {
467            cpus: resources.vcpus,
468            memory_mib: resources.memory_mib,
469            // The cloud wire type has no boot-capacity fields yet; treat the
470            // effective resources as the maximum (mirrors SandboxResources
471            // deserialization for legacy configs).
472            max_cpus: resources.vcpus,
473            max_memory_mib: resources.memory_mib,
474            // Host runtime policy never crosses the cloud wire. A managed
475            // service applies its own placement and guest-memory defaults
476            // after resolving the tenant-controlled resource request.
477            cpu_placement: CpuPlacement::Inherit,
478            placement_profile: None,
479            thp: TransparentHugePagePolicy::Madvise,
480        };
481
482        // Fields not present on `CloudNetworkSpec` are defaulted here, listed
483        // explicitly (not `..default()`) so a new `NetworkSpec` field forces a
484        // decision here.
485        let network = NetworkSpec {
486            enabled: self.network.enabled,
487            interface: None,
488            ports: Vec::new(),
489            policy: self.network.policy,
490            dns: None,
491            tls: None,
492            strict: self.network.strict,
493            secrets: self.network.secrets.map(Into::into),
494            max_tcp_connections: self.network.max_tcp_connections,
495            max_udp_connections: self.network.max_udp_connections,
496            rate_limiter: None,
497            trust_host_cas: false,
498            outbound_proxy: None,
499        };
500        let runtime = SandboxRuntimeOptions {
501            workdir: self.runtime.workdir,
502            shell: self.runtime.shell,
503            scripts: self.runtime.scripts,
504            entrypoint: self.runtime.entrypoint,
505            cmd: self.runtime.cmd,
506            hostname: None,
507            user: self.runtime.user,
508            log_level: self.runtime.log_level,
509            metrics_sample_interval_ms: None,
510            disable_metrics_sample: false,
511        };
512
513        Ok(SandboxSpec {
514            name: self.name,
515            image,
516            resources,
517            runtime,
518            env: self.env,
519            labels: self.labels,
520            rlimits: self.rlimits.into_iter().map(Into::into).collect(),
521            mounts: self.mounts.into_iter().map(Into::into).collect(),
522            patches: patches.into_iter().map(Into::into).collect(),
523            network,
524            vsock: VsockSpec::default(),
525            init: self.init,
526            pull_policy: pull_policy.into(),
527            security_profile: self.security_profile,
528            deployment_profile: DeploymentProfile::default(),
529            lifecycle: self.lifecycle,
530        })
531    }
532}
533
534impl From<SandboxSpec> for CloudCreateSandboxRequest {
535    fn from(spec: SandboxSpec) -> Self {
536        let resources = CloudSandboxComputeResources {
537            vcpus: spec.resources.cpus,
538            memory_mib: spec.resources.memory_mib,
539        };
540        let patches = spec.patches.into_iter().map(Into::into).collect();
541        let pull_policy = spec.pull_policy.into();
542        let sandbox = CloudSandboxSpec {
543            name: spec.name,
544            runtime: CloudSandboxRuntimeOptions {
545                workdir: spec.runtime.workdir,
546                shell: spec.runtime.shell,
547                scripts: spec.runtime.scripts,
548                entrypoint: spec.runtime.entrypoint,
549                cmd: spec.runtime.cmd,
550                user: spec.runtime.user,
551                log_level: spec.runtime.log_level,
552            },
553            env: spec.env,
554            labels: spec.labels,
555            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
556            mounts: spec.mounts.into_iter().map(Into::into).collect(),
557            network: CloudNetworkSpec {
558                enabled: spec.network.enabled,
559                policy: spec.network.policy,
560                secrets: spec.network.secrets.map(Into::into),
561                strict: spec.network.strict,
562                max_tcp_connections: spec.network.max_tcp_connections,
563                max_udp_connections: spec.network.max_udp_connections,
564            },
565            init: spec.init,
566            security_profile: spec.security_profile,
567            lifecycle: spec.lifecycle,
568        };
569
570        match spec.image {
571            RootfsSource::Oci(oci) => Self::Oci {
572                sandbox,
573                reference: oci.reference,
574                resources: CloudSandboxResources {
575                    vcpus: resources.vcpus,
576                    memory_mib: resources.memory_mib,
577                    disk_size_mib: match oci.root_disk {
578                        Some(RootDisk::Managed { size_mib }) => size_mib,
579                        _ => None,
580                    },
581                },
582                patches,
583                pull_policy,
584            },
585            RootfsSource::Bind { path, .. } => Self::Bind {
586                sandbox,
587                path,
588                resources,
589                patches,
590            },
591            RootfsSource::DiskImage {
592                path,
593                format,
594                fstype,
595            } => Self::DiskImage {
596                sandbox,
597                path,
598                format: format.into(),
599                fstype,
600                resources,
601                patches,
602            },
603        }
604    }
605}
606
607impl Default for CloudSandboxResources {
608    fn default() -> Self {
609        let resources = SandboxResources::default();
610        Self {
611            vcpus: resources.cpus,
612            memory_mib: resources.memory_mib,
613            disk_size_mib: None,
614        }
615    }
616}
617
618impl Default for CloudSandboxComputeResources {
619    fn default() -> Self {
620        let resources = SandboxResources::default();
621        Self {
622            vcpus: resources.cpus,
623            memory_mib: resources.memory_mib,
624        }
625    }
626}
627
628impl From<CloudSandboxResources> for CloudSandboxComputeResources {
629    fn from(resources: CloudSandboxResources) -> Self {
630        Self {
631            vcpus: resources.vcpus,
632            memory_mib: resources.memory_mib,
633        }
634    }
635}
636
637impl Default for CloudCreateSandboxRequest {
638    fn default() -> Self {
639        Self::Oci {
640            sandbox: CloudSandboxSpec::default(),
641            reference: String::new(),
642            resources: CloudSandboxResources::default(),
643            patches: Vec::new(),
644            pull_policy: CloudPullPolicy::default(),
645        }
646    }
647}
648
649impl CloudRootfsSource {
650    /// Create an OCI rootfs source from an image reference.
651    pub fn oci(reference: impl Into<String>) -> Self {
652        Self::Oci {
653            reference: reference.into(),
654        }
655    }
656
657    /// Return the OCI image reference if this is an OCI rootfs.
658    pub fn oci_reference(&self) -> Option<&str> {
659        match self {
660            Self::Oci { reference } => Some(reference),
661            _ => None,
662        }
663    }
664}
665
666impl Default for CloudRootfsSource {
667    fn default() -> Self {
668        Self::oci(String::new())
669    }
670}
671
672#[cfg(test)]
673mod tests;