Skip to main content

microsandbox_types/cloud/
specs.rs

1//! Cloud sandbox spec wire twins and domain conversions.
2
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use super::CloudSecretsConfig;
9use crate::domain::{
10    DiskImageFormat, HostPermissions, MountOptions, NetworkPolicy, OwnedVolumeStorage, Patch,
11    PullPolicy, Rlimit, RlimitResource, SandboxLogLevel, StatVirtualization, VolumeMount,
12    default_private, default_strict,
13};
14
15//--------------------------------------------------------------------------------------------------
16// Types: Spec sub-twins
17//
18// Snake_case wire twins for domain enums that serialize PascalCase, so the whole
19// cloud contract stays snake_case without changing the domain (runtime/SDK) wire.
20//--------------------------------------------------------------------------------------------------
21
22/// Cloud pull policy. Twin of domain [`PullPolicy`] with a snake_case wire.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
25#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
26#[serde(rename_all = "snake_case")]
27pub enum CloudPullPolicy {
28    /// Use cached layers if complete, pull otherwise.
29    #[default]
30    IfMissing,
31    /// Always fetch the manifest, reusing cached layers whose digests match.
32    Always,
33    /// Never contact the registry; error if the image is not fully cached.
34    Never,
35}
36
37impl From<PullPolicy> for CloudPullPolicy {
38    fn from(policy: PullPolicy) -> Self {
39        match policy {
40            PullPolicy::IfMissing => Self::IfMissing,
41            PullPolicy::Always => Self::Always,
42            PullPolicy::Never => Self::Never,
43        }
44    }
45}
46
47impl From<CloudPullPolicy> for PullPolicy {
48    fn from(policy: CloudPullPolicy) -> Self {
49        match policy {
50            CloudPullPolicy::IfMissing => Self::IfMissing,
51            CloudPullPolicy::Always => Self::Always,
52            CloudPullPolicy::Never => Self::Never,
53        }
54    }
55}
56
57/// Disk image format for cloud disk-image sources. Twin of [`DiskImageFormat`]
58/// with a snake_case wire.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
61#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
62#[serde(rename_all = "snake_case")]
63pub enum CloudDiskImageFormat {
64    /// QEMU Copy-on-Write v2.
65    Qcow2,
66    /// Raw disk image.
67    Raw,
68    /// VMware Disk (FLAT/ZERO only, no delta links).
69    Vmdk,
70}
71
72impl From<DiskImageFormat> for CloudDiskImageFormat {
73    fn from(format: DiskImageFormat) -> Self {
74        match format {
75            DiskImageFormat::Qcow2 => Self::Qcow2,
76            DiskImageFormat::Raw => Self::Raw,
77            DiskImageFormat::Vmdk => Self::Vmdk,
78        }
79    }
80}
81
82impl From<CloudDiskImageFormat> for DiskImageFormat {
83    fn from(format: CloudDiskImageFormat) -> Self {
84        match format {
85            CloudDiskImageFormat::Qcow2 => Self::Qcow2,
86            CloudDiskImageFormat::Raw => Self::Raw,
87            CloudDiskImageFormat::Vmdk => Self::Vmdk,
88        }
89    }
90}
91
92/// POSIX resource-limit identifiers. Twin of [`RlimitResource`] with a
93/// snake_case wire.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
96#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
97#[serde(rename_all = "snake_case")]
98pub enum CloudRlimitResource {
99    /// Max CPU time in seconds (`RLIMIT_CPU`).
100    Cpu,
101    /// Max file size in bytes (`RLIMIT_FSIZE`).
102    Fsize,
103    /// Max data segment size (`RLIMIT_DATA`).
104    Data,
105    /// Max stack size (`RLIMIT_STACK`).
106    Stack,
107    /// Max core file size (`RLIMIT_CORE`).
108    Core,
109    /// Max resident set size (`RLIMIT_RSS`).
110    Rss,
111    /// Max number of processes (`RLIMIT_NPROC`).
112    Nproc,
113    /// Max open file descriptors (`RLIMIT_NOFILE`).
114    Nofile,
115    /// Max locked memory (`RLIMIT_MEMLOCK`).
116    Memlock,
117    /// Max address space size (`RLIMIT_AS`).
118    As,
119    /// Max file locks (`RLIMIT_LOCKS`).
120    Locks,
121    /// Max pending signals (`RLIMIT_SIGPENDING`).
122    Sigpending,
123    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
124    Msgqueue,
125    /// Max nice priority (`RLIMIT_NICE`).
126    Nice,
127    /// Max real-time priority (`RLIMIT_RTPRIO`).
128    Rtprio,
129    /// Max real-time timeout (`RLIMIT_RTTIME`).
130    Rttime,
131}
132
133impl From<RlimitResource> for CloudRlimitResource {
134    fn from(resource: RlimitResource) -> Self {
135        match resource {
136            RlimitResource::Cpu => Self::Cpu,
137            RlimitResource::Fsize => Self::Fsize,
138            RlimitResource::Data => Self::Data,
139            RlimitResource::Stack => Self::Stack,
140            RlimitResource::Core => Self::Core,
141            RlimitResource::Rss => Self::Rss,
142            RlimitResource::Nproc => Self::Nproc,
143            RlimitResource::Nofile => Self::Nofile,
144            RlimitResource::Memlock => Self::Memlock,
145            RlimitResource::As => Self::As,
146            RlimitResource::Locks => Self::Locks,
147            RlimitResource::Sigpending => Self::Sigpending,
148            RlimitResource::Msgqueue => Self::Msgqueue,
149            RlimitResource::Nice => Self::Nice,
150            RlimitResource::Rtprio => Self::Rtprio,
151            RlimitResource::Rttime => Self::Rttime,
152        }
153    }
154}
155
156impl From<CloudRlimitResource> for RlimitResource {
157    fn from(resource: CloudRlimitResource) -> Self {
158        match resource {
159            CloudRlimitResource::Cpu => Self::Cpu,
160            CloudRlimitResource::Fsize => Self::Fsize,
161            CloudRlimitResource::Data => Self::Data,
162            CloudRlimitResource::Stack => Self::Stack,
163            CloudRlimitResource::Core => Self::Core,
164            CloudRlimitResource::Rss => Self::Rss,
165            CloudRlimitResource::Nproc => Self::Nproc,
166            CloudRlimitResource::Nofile => Self::Nofile,
167            CloudRlimitResource::Memlock => Self::Memlock,
168            CloudRlimitResource::As => Self::As,
169            CloudRlimitResource::Locks => Self::Locks,
170            CloudRlimitResource::Sigpending => Self::Sigpending,
171            CloudRlimitResource::Msgqueue => Self::Msgqueue,
172            CloudRlimitResource::Nice => Self::Nice,
173            CloudRlimitResource::Rtprio => Self::Rtprio,
174            CloudRlimitResource::Rttime => Self::Rttime,
175        }
176    }
177}
178
179/// A POSIX resource limit. Twin of [`Rlimit`] using [`CloudRlimitResource`].
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
182#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
183pub struct CloudRlimit {
184    /// Resource type.
185    pub resource: CloudRlimitResource,
186    /// Soft limit (can be raised up to the hard limit by the process).
187    pub soft: u64,
188    /// Hard limit (ceiling, requires privileges to raise).
189    pub hard: u64,
190}
191
192impl From<Rlimit> for CloudRlimit {
193    fn from(rlimit: Rlimit) -> Self {
194        Self {
195            resource: rlimit.resource.into(),
196            soft: rlimit.soft,
197            hard: rlimit.hard,
198        }
199    }
200}
201
202impl From<CloudRlimit> for Rlimit {
203    fn from(rlimit: CloudRlimit) -> Self {
204        Self {
205            resource: rlimit.resource.into(),
206            soft: rlimit.soft,
207            hard: rlimit.hard,
208        }
209    }
210}
211
212/// Rootfs patch applied before VM start. Twin of [`Patch`], internally tagged
213/// with a snake_case `type` instead of the domain's external PascalCase tag.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
216#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
217#[serde(tag = "type", rename_all = "snake_case")]
218pub enum CloudPatch {
219    /// Write text content to a file.
220    Text {
221        /// Absolute guest path, such as `/etc/app.conf`.
222        path: String,
223        /// Text content to write.
224        content: String,
225        /// File permissions, such as `0o644`. `None` uses the default.
226        mode: Option<u32>,
227        /// Allow replacing a file that already exists in the rootfs.
228        replace: bool,
229    },
230    /// Write raw bytes to a file.
231    File {
232        /// Absolute guest path.
233        path: String,
234        /// Raw byte content to write.
235        content: Vec<u8>,
236        /// File permissions, such as `0o644`. `None` uses the default.
237        mode: Option<u32>,
238        /// Allow replacing a file that already exists in the rootfs.
239        replace: bool,
240    },
241    /// Copy a file from the host into the rootfs.
242    CopyFile {
243        /// Host path to copy from.
244        #[cfg_attr(feature = "ts", ts(type = "string"))]
245        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
246        src: PathBuf,
247        /// Absolute guest destination path.
248        dst: String,
249        /// File permissions. `None` preserves source permissions.
250        mode: Option<u32>,
251        /// Allow replacing a file that already exists in the rootfs.
252        replace: bool,
253    },
254    /// Copy a directory from the host into the rootfs.
255    CopyDir {
256        /// Host directory to copy from.
257        #[cfg_attr(feature = "ts", ts(type = "string"))]
258        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
259        src: PathBuf,
260        /// Absolute guest destination path.
261        dst: String,
262        /// Allow replacing files that already exist in the rootfs.
263        replace: bool,
264    },
265    /// Create a symlink.
266    Symlink {
267        /// Symlink target path.
268        target: String,
269        /// Absolute guest path where the symlink is created.
270        link: String,
271        /// Allow replacing a path that already exists in the rootfs.
272        replace: bool,
273    },
274    /// Create a directory.
275    Mkdir {
276        /// Absolute guest path.
277        path: String,
278        /// Directory permissions, such as `0o755`. `None` uses the default.
279        mode: Option<u32>,
280    },
281    /// Remove a file or directory.
282    Remove {
283        /// Absolute guest path to remove.
284        path: String,
285    },
286    /// Append content to an existing file.
287    Append {
288        /// Absolute guest path of the file to append to.
289        path: String,
290        /// Content to append.
291        content: String,
292    },
293}
294
295impl From<Patch> for CloudPatch {
296    fn from(patch: Patch) -> Self {
297        match patch {
298            Patch::Text {
299                path,
300                content,
301                mode,
302                replace,
303            } => Self::Text {
304                path,
305                content,
306                mode,
307                replace,
308            },
309            Patch::File {
310                path,
311                content,
312                mode,
313                replace,
314            } => Self::File {
315                path,
316                content,
317                mode,
318                replace,
319            },
320            Patch::CopyFile {
321                src,
322                dst,
323                mode,
324                replace,
325            } => Self::CopyFile {
326                src,
327                dst,
328                mode,
329                replace,
330            },
331            Patch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
332            Patch::Symlink {
333                target,
334                link,
335                replace,
336            } => Self::Symlink {
337                target,
338                link,
339                replace,
340            },
341            Patch::Mkdir { path, mode } => Self::Mkdir { path, mode },
342            Patch::Remove { path } => Self::Remove { path },
343            Patch::Append { path, content } => Self::Append { path, content },
344        }
345    }
346}
347
348impl From<CloudPatch> for Patch {
349    fn from(patch: CloudPatch) -> Self {
350        match patch {
351            CloudPatch::Text {
352                path,
353                content,
354                mode,
355                replace,
356            } => Self::Text {
357                path,
358                content,
359                mode,
360                replace,
361            },
362            CloudPatch::File {
363                path,
364                content,
365                mode,
366                replace,
367            } => Self::File {
368                path,
369                content,
370                mode,
371                replace,
372            },
373            CloudPatch::CopyFile {
374                src,
375                dst,
376                mode,
377                replace,
378            } => Self::CopyFile {
379                src,
380                dst,
381                mode,
382                replace,
383            },
384            CloudPatch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
385            CloudPatch::Symlink {
386                target,
387                link,
388                replace,
389            } => Self::Symlink {
390                target,
391                link,
392                replace,
393            },
394            CloudPatch::Mkdir { path, mode } => Self::Mkdir { path, mode },
395            CloudPatch::Remove { path } => Self::Remove { path },
396            CloudPatch::Append { path, content } => Self::Append { path, content },
397        }
398    }
399}
400
401/// Cloud root filesystem source.
402///
403/// Mirrors the domain [`crate::domain::RootfsSource`] JSON shape, but keeps writable-disk
404/// sizing out of the image payload. Cloud callers express that intent through
405/// [`super::CloudSandboxResources::disk_size_mib`]; conversion to the domain spec
406/// attaches it to OCI rootfs.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
409#[serde(tag = "type", rename_all = "snake_case")]
410pub enum CloudRootfsSource {
411    /// Use a host directory directly as the root filesystem.
412    Bind {
413        /// Host path to bind mount.
414        #[cfg_attr(feature = "ts", ts(type = "string"))]
415        path: PathBuf,
416    },
417
418    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
419    Oci {
420        /// OCI image reference (e.g. `python`).
421        reference: String,
422    },
423
424    /// Use a disk image file as the root filesystem via virtio-blk.
425    DiskImage {
426        /// Path to the disk image file on the host.
427        #[cfg_attr(feature = "ts", ts(type = "string"))]
428        path: PathBuf,
429        /// Disk image format.
430        format: CloudDiskImageFormat,
431        /// Inner filesystem type (optional; auto-detected if absent).
432        fstype: Option<String>,
433    },
434}
435
436/// Cloud volume mount. Internal-tagged mirror of the domain [`VolumeMount`];
437/// the transient `create` field is not carried on the wire.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
440#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
441#[serde(tag = "type", rename_all = "snake_case")]
442pub enum CloudVolumeMount {
443    /// Sandbox-owned storage; currently rejected by cloud sandbox creation.
444    Owned {
445        /// Absolute guest mount path.
446        guest: String,
447        /// Private directory or ext4 storage.
448        storage: OwnedVolumeStorage,
449        /// Guest mount options.
450        options: MountOptions,
451        /// Directory stat policy.
452        stat_virtualization: StatVirtualization,
453        /// Directory host permission policy.
454        host_permissions: HostPermissions,
455    },
456    /// Bind mount a host directory into the guest.
457    Bind {
458        /// Host directory to bind into the guest.
459        #[cfg_attr(feature = "ts", ts(type = "string"))]
460        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
461        host: PathBuf,
462        /// Guest path to mount at.
463        guest: String,
464        /// Mount options (read-only, no-exec, …).
465        #[serde(default)]
466        options: MountOptions,
467        /// How guest `stat()` results are virtualized.
468        #[serde(default = "default_strict")]
469        stat_virtualization: StatVirtualization,
470        /// Host permission policy applied to the mount.
471        #[serde(default = "default_private")]
472        host_permissions: HostPermissions,
473        /// Optional guest-write quota in MiB.
474        #[serde(default)]
475        quota_mib: Option<u32>,
476    },
477
478    /// Mount a named volume into the guest.
479    Named {
480        /// Named volume to mount.
481        name: String,
482        /// Guest path to mount at.
483        guest: String,
484        /// Mount options (read-only, no-exec, …).
485        #[serde(default)]
486        options: MountOptions,
487        /// How guest `stat()` results are virtualized.
488        #[serde(default = "default_strict")]
489        stat_virtualization: StatVirtualization,
490        /// Host permission policy applied to the mount.
491        #[serde(default = "default_private")]
492        host_permissions: HostPermissions,
493    },
494
495    /// Temporary filesystem backed by guest memory.
496    Tmpfs {
497        /// Guest path to mount at.
498        guest: String,
499        /// Optional size cap in MiB.
500        #[serde(default)]
501        size_mib: Option<u32>,
502        /// Mount options (read-only, no-exec, …).
503        #[serde(default)]
504        options: MountOptions,
505    },
506
507    /// Mount a disk image file as a virtio-blk device at a guest path.
508    DiskImage {
509        /// Host path to the disk image file.
510        #[cfg_attr(feature = "ts", ts(type = "string"))]
511        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
512        host: PathBuf,
513        /// Guest path to mount at.
514        guest: String,
515        /// Disk image format.
516        format: CloudDiskImageFormat,
517        /// Inner filesystem type (auto-detected if absent).
518        #[serde(default)]
519        fstype: Option<String>,
520        /// Mount options (read-only, no-exec, …).
521        #[serde(default)]
522        options: MountOptions,
523    },
524}
525
526impl From<CloudVolumeMount> for VolumeMount {
527    fn from(m: CloudVolumeMount) -> Self {
528        match m {
529            CloudVolumeMount::Owned {
530                guest,
531                storage,
532                options,
533                stat_virtualization,
534                host_permissions,
535            } => VolumeMount::Owned {
536                guest,
537                storage,
538                options,
539                stat_virtualization,
540                host_permissions,
541            },
542            CloudVolumeMount::Bind {
543                host,
544                guest,
545                options,
546                stat_virtualization,
547                host_permissions,
548                quota_mib,
549            } => VolumeMount::Bind {
550                host,
551                guest,
552                options,
553                stat_virtualization,
554                host_permissions,
555                // The cloud wire type does not carry the opt-out yet; default to
556                // the protective no-follow behavior.
557                follow_root_symlinks: false,
558                quota_mib,
559            },
560            CloudVolumeMount::Named {
561                name,
562                guest,
563                options,
564                stat_virtualization,
565                host_permissions,
566            } => VolumeMount::Named {
567                name,
568                guest,
569                create: None,
570                options,
571                stat_virtualization,
572                host_permissions,
573                follow_root_symlinks: false,
574            },
575            CloudVolumeMount::Tmpfs {
576                guest,
577                size_mib,
578                options,
579            } => VolumeMount::Tmpfs {
580                guest,
581                size_mib,
582                options,
583            },
584            CloudVolumeMount::DiskImage {
585                host,
586                guest,
587                format,
588                fstype,
589                options,
590            } => VolumeMount::DiskImage {
591                host,
592                guest,
593                format: format.into(),
594                fstype,
595                options,
596            },
597        }
598    }
599}
600
601impl From<VolumeMount> for CloudVolumeMount {
602    fn from(m: VolumeMount) -> Self {
603        match m {
604            VolumeMount::Owned {
605                guest,
606                storage,
607                options,
608                stat_virtualization,
609                host_permissions,
610            } => CloudVolumeMount::Owned {
611                guest,
612                storage,
613                options,
614                stat_virtualization,
615                host_permissions,
616            },
617            VolumeMount::Bind {
618                host,
619                guest,
620                options,
621                stat_virtualization,
622                host_permissions,
623                follow_root_symlinks: _,
624                quota_mib,
625            } => CloudVolumeMount::Bind {
626                host,
627                guest,
628                options,
629                stat_virtualization,
630                host_permissions,
631                quota_mib,
632            },
633            VolumeMount::Named {
634                name,
635                guest,
636                create: _,
637                options,
638                stat_virtualization,
639                host_permissions,
640                follow_root_symlinks: _,
641            } => CloudVolumeMount::Named {
642                name,
643                guest,
644                options,
645                stat_virtualization,
646                host_permissions,
647            },
648            VolumeMount::Tmpfs {
649                guest,
650                size_mib,
651                options,
652            } => CloudVolumeMount::Tmpfs {
653                guest,
654                size_mib,
655                options,
656            },
657            VolumeMount::DiskImage {
658                host,
659                guest,
660                format,
661                fstype,
662                options,
663            } => CloudVolumeMount::DiskImage {
664                host,
665                guest,
666                format: format.into(),
667                fstype,
668                options,
669            },
670        }
671    }
672}
673
674/// Cloud network specification: a subset of the domain [`crate::domain::NetworkSpec`].
675/// Interface overrides, host port mapping, DNS, TLS interception, rate limits,
676/// and host-CA trust are not part of this type. Unknown fields are ignored for
677/// compatibility with newer clients; accepting them does not enable their behavior.
678#[derive(Debug, Clone, Serialize, Deserialize)]
679#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
680#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
681#[serde(default)]
682pub struct CloudNetworkSpec {
683    /// Whether networking is enabled for this sandbox.
684    pub enabled: bool,
685
686    /// Egress/ingress policy.
687    #[serde(skip_serializing_if = "Option::is_none")]
688    pub policy: Option<NetworkPolicy>,
689
690    /// Secret-substitution config.
691    #[serde(skip_serializing_if = "Option::is_none")]
692    pub secrets: Option<CloudSecretsConfig>,
693
694    /// Require hostname-based policy allows to use inspectable application authority.
695    pub strict: bool,
696
697    /// Max concurrent TCP connections.
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    // Keep outbound requests compatible with existing cloud servers.
700    #[serde(rename = "max_connections", alias = "max_tcp_connections")]
701    pub max_tcp_connections: Option<usize>,
702
703    /// Max concurrent UDP relay sessions. Omitted is unlimited for single-tenant and 1024 for multi-tenant; zero means unlimited.
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub max_udp_connections: Option<usize>,
706}
707
708impl Default for CloudNetworkSpec {
709    fn default() -> Self {
710        Self {
711            enabled: true,
712            policy: None,
713            secrets: None,
714            strict: false,
715            max_tcp_connections: None,
716            max_udp_connections: None,
717        }
718    }
719}
720
721/// Cloud guest runtime options: a subset of [`crate::domain::SandboxRuntimeOptions`]. The
722/// hostname and the metrics-sampling knobs are not part of this type.
723/// Unknown fields are ignored for compatibility with newer clients.
724#[derive(Debug, Clone, Default, Serialize, Deserialize)]
725#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
726#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
727#[serde(default)]
728pub struct CloudSandboxRuntimeOptions {
729    /// Working directory for guest commands.
730    pub workdir: Option<String>,
731
732    /// Default shell.
733    pub shell: Option<String>,
734
735    /// Named in-guest scripts.
736    pub scripts: BTreeMap<String, String>,
737
738    /// Entrypoint override.
739    pub entrypoint: Option<Vec<String>>,
740
741    /// Command override.
742    pub cmd: Option<Vec<String>>,
743
744    /// Guest user.
745    pub user: Option<String>,
746
747    /// Runtime log level.
748    pub log_level: Option<SandboxLogLevel>,
749}