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 zeroize::Zeroizing;
15
16use crate::domain::{
17    CpuPlacement, DeploymentProfile, DiskImageFormat, EnvVar, HandoffInit, HostPattern,
18    HostPermissions, MountOptions, NetworkPolicy, NetworkSpec, OciRootfsSource, Patch, PullPolicy,
19    Rlimit, RlimitResource, RootDisk, RootfsSource, SandboxLogLevel, SandboxPolicy,
20    SandboxResources, SandboxRuntimeOptions, SandboxSpec, SecretEntry, SecretInjection,
21    SecretsConfig, SecurityProfile, StatVirtualization, TransparentHugePagePolicy, ViolationAction,
22    VolumeMount, VsockSpec, default_private, default_strict,
23};
24use crate::modify::SecretSource;
25use crate::{TypesError, TypesResult};
26
27//--------------------------------------------------------------------------------------------------
28// Types: Request
29//--------------------------------------------------------------------------------------------------
30
31/// Wire shape of a cloud sandbox create request body.
32///
33/// Flattens [`CloudSandboxSpec`] onto the request body, so on the wire this is
34/// byte-identical to `CloudSandboxSpec`. The generated bindings surface the
35/// flattened shape as `CloudSandboxSpec` directly.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
38pub struct CloudCreateSandboxRequest {
39    /// The cloud sandbox specification, flattened onto the request body.
40    #[serde(flatten)]
41    pub spec: CloudSandboxSpec,
42}
43
44/// Cloud sandbox specification carried on create routes.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
47#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
48#[serde(default)]
49pub struct CloudSandboxSpec {
50    /// Unique sandbox name.
51    pub name: String,
52
53    /// Root filesystem source.
54    #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
55    pub image: CloudRootfsSource,
56
57    /// CPU, memory, and user-facing disk resources.
58    pub resources: CloudSandboxResources,
59
60    /// Guest runtime options.
61    pub runtime: CloudSandboxRuntimeOptions,
62
63    /// Environment variables visible to commands in the sandbox.
64    pub env: Vec<EnvVar>,
65
66    /// User-defined labels attached to the sandbox.
67    pub labels: BTreeMap<String, String>,
68
69    /// Sandbox-wide resource limits inherited by guest processes.
70    pub rlimits: Vec<CloudRlimit>,
71
72    /// Volume mounts.
73    pub mounts: Vec<CloudVolumeMount>,
74
75    /// Rootfs patches applied before VM start.
76    pub patches: Vec<CloudPatch>,
77
78    /// Network specification.
79    pub network: CloudNetworkSpec,
80
81    /// Hand off PID 1 to a guest init binary after agentd setup.
82    pub init: Option<HandoffInit>,
83
84    /// Pull policy for OCI images.
85    pub pull_policy: CloudPullPolicy,
86
87    /// In-guest security profile.
88    pub security_profile: SecurityProfile,
89
90    /// Sandbox lifecycle policy.
91    pub lifecycle: SandboxPolicy,
92}
93
94/// Cloud resource request.
95#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
96#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
97#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
98#[serde(default)]
99pub struct CloudSandboxResources {
100    /// Number of virtual CPUs.
101    pub vcpus: u8,
102
103    /// Guest memory in MiB.
104    pub memory_mib: u32,
105
106    /// Writable disk size in MiB. Applies only to OCI root filesystems.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub disk_size_mib: Option<u32>,
109}
110
111//--------------------------------------------------------------------------------------------------
112// Types: Spec sub-twins
113//
114// Snake_case wire twins for domain enums that serialize PascalCase, so the whole
115// cloud contract stays snake_case without changing the domain (runtime/SDK) wire.
116//--------------------------------------------------------------------------------------------------
117
118/// Cloud pull policy. Twin of domain [`PullPolicy`] with a snake_case wire.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
121#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
122#[serde(rename_all = "snake_case")]
123pub enum CloudPullPolicy {
124    /// Use cached layers if complete, pull otherwise.
125    #[default]
126    IfMissing,
127    /// Always fetch the manifest, reusing cached layers whose digests match.
128    Always,
129    /// Never contact the registry; error if the image is not fully cached.
130    Never,
131}
132
133impl From<PullPolicy> for CloudPullPolicy {
134    fn from(policy: PullPolicy) -> Self {
135        match policy {
136            PullPolicy::IfMissing => Self::IfMissing,
137            PullPolicy::Always => Self::Always,
138            PullPolicy::Never => Self::Never,
139        }
140    }
141}
142
143impl From<CloudPullPolicy> for PullPolicy {
144    fn from(policy: CloudPullPolicy) -> Self {
145        match policy {
146            CloudPullPolicy::IfMissing => Self::IfMissing,
147            CloudPullPolicy::Always => Self::Always,
148            CloudPullPolicy::Never => Self::Never,
149        }
150    }
151}
152
153/// Disk image format for cloud disk-image sources. Twin of [`DiskImageFormat`]
154/// with a snake_case wire.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
157#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
158#[serde(rename_all = "snake_case")]
159pub enum CloudDiskImageFormat {
160    /// QEMU Copy-on-Write v2.
161    Qcow2,
162    /// Raw disk image.
163    Raw,
164    /// VMware Disk (FLAT/ZERO only, no delta links).
165    Vmdk,
166}
167
168impl From<DiskImageFormat> for CloudDiskImageFormat {
169    fn from(format: DiskImageFormat) -> Self {
170        match format {
171            DiskImageFormat::Qcow2 => Self::Qcow2,
172            DiskImageFormat::Raw => Self::Raw,
173            DiskImageFormat::Vmdk => Self::Vmdk,
174        }
175    }
176}
177
178impl From<CloudDiskImageFormat> for DiskImageFormat {
179    fn from(format: CloudDiskImageFormat) -> Self {
180        match format {
181            CloudDiskImageFormat::Qcow2 => Self::Qcow2,
182            CloudDiskImageFormat::Raw => Self::Raw,
183            CloudDiskImageFormat::Vmdk => Self::Vmdk,
184        }
185    }
186}
187
188/// POSIX resource-limit identifiers. Twin of [`RlimitResource`] with a
189/// snake_case wire.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
192#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
193#[serde(rename_all = "snake_case")]
194pub enum CloudRlimitResource {
195    /// Max CPU time in seconds (`RLIMIT_CPU`).
196    Cpu,
197    /// Max file size in bytes (`RLIMIT_FSIZE`).
198    Fsize,
199    /// Max data segment size (`RLIMIT_DATA`).
200    Data,
201    /// Max stack size (`RLIMIT_STACK`).
202    Stack,
203    /// Max core file size (`RLIMIT_CORE`).
204    Core,
205    /// Max resident set size (`RLIMIT_RSS`).
206    Rss,
207    /// Max number of processes (`RLIMIT_NPROC`).
208    Nproc,
209    /// Max open file descriptors (`RLIMIT_NOFILE`).
210    Nofile,
211    /// Max locked memory (`RLIMIT_MEMLOCK`).
212    Memlock,
213    /// Max address space size (`RLIMIT_AS`).
214    As,
215    /// Max file locks (`RLIMIT_LOCKS`).
216    Locks,
217    /// Max pending signals (`RLIMIT_SIGPENDING`).
218    Sigpending,
219    /// Max bytes in POSIX message queues (`RLIMIT_MSGQUEUE`).
220    Msgqueue,
221    /// Max nice priority (`RLIMIT_NICE`).
222    Nice,
223    /// Max real-time priority (`RLIMIT_RTPRIO`).
224    Rtprio,
225    /// Max real-time timeout (`RLIMIT_RTTIME`).
226    Rttime,
227}
228
229impl From<RlimitResource> for CloudRlimitResource {
230    fn from(resource: RlimitResource) -> Self {
231        match resource {
232            RlimitResource::Cpu => Self::Cpu,
233            RlimitResource::Fsize => Self::Fsize,
234            RlimitResource::Data => Self::Data,
235            RlimitResource::Stack => Self::Stack,
236            RlimitResource::Core => Self::Core,
237            RlimitResource::Rss => Self::Rss,
238            RlimitResource::Nproc => Self::Nproc,
239            RlimitResource::Nofile => Self::Nofile,
240            RlimitResource::Memlock => Self::Memlock,
241            RlimitResource::As => Self::As,
242            RlimitResource::Locks => Self::Locks,
243            RlimitResource::Sigpending => Self::Sigpending,
244            RlimitResource::Msgqueue => Self::Msgqueue,
245            RlimitResource::Nice => Self::Nice,
246            RlimitResource::Rtprio => Self::Rtprio,
247            RlimitResource::Rttime => Self::Rttime,
248        }
249    }
250}
251
252impl From<CloudRlimitResource> for RlimitResource {
253    fn from(resource: CloudRlimitResource) -> Self {
254        match resource {
255            CloudRlimitResource::Cpu => Self::Cpu,
256            CloudRlimitResource::Fsize => Self::Fsize,
257            CloudRlimitResource::Data => Self::Data,
258            CloudRlimitResource::Stack => Self::Stack,
259            CloudRlimitResource::Core => Self::Core,
260            CloudRlimitResource::Rss => Self::Rss,
261            CloudRlimitResource::Nproc => Self::Nproc,
262            CloudRlimitResource::Nofile => Self::Nofile,
263            CloudRlimitResource::Memlock => Self::Memlock,
264            CloudRlimitResource::As => Self::As,
265            CloudRlimitResource::Locks => Self::Locks,
266            CloudRlimitResource::Sigpending => Self::Sigpending,
267            CloudRlimitResource::Msgqueue => Self::Msgqueue,
268            CloudRlimitResource::Nice => Self::Nice,
269            CloudRlimitResource::Rtprio => Self::Rtprio,
270            CloudRlimitResource::Rttime => Self::Rttime,
271        }
272    }
273}
274
275/// A POSIX resource limit. Twin of [`Rlimit`] using [`CloudRlimitResource`].
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 CloudRlimit {
280    /// Resource type.
281    pub resource: CloudRlimitResource,
282    /// Soft limit (can be raised up to the hard limit by the process).
283    pub soft: u64,
284    /// Hard limit (ceiling, requires privileges to raise).
285    pub hard: u64,
286}
287
288impl From<Rlimit> for CloudRlimit {
289    fn from(rlimit: Rlimit) -> Self {
290        Self {
291            resource: rlimit.resource.into(),
292            soft: rlimit.soft,
293            hard: rlimit.hard,
294        }
295    }
296}
297
298impl From<CloudRlimit> for Rlimit {
299    fn from(rlimit: CloudRlimit) -> Self {
300        Self {
301            resource: rlimit.resource.into(),
302            soft: rlimit.soft,
303            hard: rlimit.hard,
304        }
305    }
306}
307
308/// Rootfs patch applied before VM start. Twin of [`Patch`], internally tagged
309/// with a snake_case `type` instead of the domain's external PascalCase tag.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
312#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
313#[serde(tag = "type", rename_all = "snake_case")]
314pub enum CloudPatch {
315    /// Write text content to a file.
316    Text {
317        /// Absolute guest path, such as `/etc/app.conf`.
318        path: String,
319        /// Text content to write.
320        content: String,
321        /// File permissions, such as `0o644`. `None` uses the default.
322        mode: Option<u32>,
323        /// Allow replacing a file that already exists in the rootfs.
324        replace: bool,
325    },
326    /// Write raw bytes to a file.
327    File {
328        /// Absolute guest path.
329        path: String,
330        /// Raw byte content to write.
331        content: Vec<u8>,
332        /// File permissions, such as `0o644`. `None` uses the default.
333        mode: Option<u32>,
334        /// Allow replacing a file that already exists in the rootfs.
335        replace: bool,
336    },
337    /// Copy a file from the host into the rootfs.
338    CopyFile {
339        /// Host path to copy from.
340        #[cfg_attr(feature = "ts", ts(type = "string"))]
341        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
342        src: PathBuf,
343        /// Absolute guest destination path.
344        dst: String,
345        /// File permissions. `None` preserves source permissions.
346        mode: Option<u32>,
347        /// Allow replacing a file that already exists in the rootfs.
348        replace: bool,
349    },
350    /// Copy a directory from the host into the rootfs.
351    CopyDir {
352        /// Host directory to copy from.
353        #[cfg_attr(feature = "ts", ts(type = "string"))]
354        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
355        src: PathBuf,
356        /// Absolute guest destination path.
357        dst: String,
358        /// Allow replacing files that already exist in the rootfs.
359        replace: bool,
360    },
361    /// Create a symlink.
362    Symlink {
363        /// Symlink target path.
364        target: String,
365        /// Absolute guest path where the symlink is created.
366        link: String,
367        /// Allow replacing a path that already exists in the rootfs.
368        replace: bool,
369    },
370    /// Create a directory.
371    Mkdir {
372        /// Absolute guest path.
373        path: String,
374        /// Directory permissions, such as `0o755`. `None` uses the default.
375        mode: Option<u32>,
376    },
377    /// Remove a file or directory.
378    Remove {
379        /// Absolute guest path to remove.
380        path: String,
381    },
382    /// Append content to an existing file.
383    Append {
384        /// Absolute guest path of the file to append to.
385        path: String,
386        /// Content to append.
387        content: String,
388    },
389}
390
391impl From<Patch> for CloudPatch {
392    fn from(patch: Patch) -> Self {
393        match patch {
394            Patch::Text {
395                path,
396                content,
397                mode,
398                replace,
399            } => Self::Text {
400                path,
401                content,
402                mode,
403                replace,
404            },
405            Patch::File {
406                path,
407                content,
408                mode,
409                replace,
410            } => Self::File {
411                path,
412                content,
413                mode,
414                replace,
415            },
416            Patch::CopyFile {
417                src,
418                dst,
419                mode,
420                replace,
421            } => Self::CopyFile {
422                src,
423                dst,
424                mode,
425                replace,
426            },
427            Patch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
428            Patch::Symlink {
429                target,
430                link,
431                replace,
432            } => Self::Symlink {
433                target,
434                link,
435                replace,
436            },
437            Patch::Mkdir { path, mode } => Self::Mkdir { path, mode },
438            Patch::Remove { path } => Self::Remove { path },
439            Patch::Append { path, content } => Self::Append { path, content },
440        }
441    }
442}
443
444impl From<CloudPatch> for Patch {
445    fn from(patch: CloudPatch) -> Self {
446        match patch {
447            CloudPatch::Text {
448                path,
449                content,
450                mode,
451                replace,
452            } => Self::Text {
453                path,
454                content,
455                mode,
456                replace,
457            },
458            CloudPatch::File {
459                path,
460                content,
461                mode,
462                replace,
463            } => Self::File {
464                path,
465                content,
466                mode,
467                replace,
468            },
469            CloudPatch::CopyFile {
470                src,
471                dst,
472                mode,
473                replace,
474            } => Self::CopyFile {
475                src,
476                dst,
477                mode,
478                replace,
479            },
480            CloudPatch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
481            CloudPatch::Symlink {
482                target,
483                link,
484                replace,
485            } => Self::Symlink {
486                target,
487                link,
488                replace,
489            },
490            CloudPatch::Mkdir { path, mode } => Self::Mkdir { path, mode },
491            CloudPatch::Remove { path } => Self::Remove { path },
492            CloudPatch::Append { path, content } => Self::Append { path, content },
493        }
494    }
495}
496
497/// Cloud root filesystem source.
498///
499/// Mirrors the domain [`RootfsSource`] JSON shape, but keeps writable-disk
500/// sizing out of the image payload. Cloud callers express that intent through
501/// [`CloudSandboxResources::disk_size_mib`]; conversion to the domain spec
502/// attaches it to OCI rootfs.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
505#[serde(tag = "type", rename_all = "snake_case")]
506pub enum CloudRootfsSource {
507    /// Use a host directory directly as the root filesystem.
508    Bind {
509        /// Host path to bind mount.
510        #[cfg_attr(feature = "ts", ts(type = "string"))]
511        path: PathBuf,
512    },
513
514    /// Use an OCI image reference with an EROFS lower and ext4 overlay upper.
515    Oci {
516        /// OCI image reference (e.g. `python`).
517        reference: String,
518    },
519
520    /// Use a disk image file as the root filesystem via virtio-blk.
521    DiskImage {
522        /// Path to the disk image file on the host.
523        #[cfg_attr(feature = "ts", ts(type = "string"))]
524        path: PathBuf,
525        /// Disk image format.
526        format: CloudDiskImageFormat,
527        /// Inner filesystem type (optional; auto-detected if absent).
528        fstype: Option<String>,
529    },
530}
531
532/// Cloud volume mount. Internal-tagged mirror of the domain [`VolumeMount`];
533/// the transient `create` field is not carried on the wire.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537#[serde(tag = "type", rename_all = "snake_case")]
538pub enum CloudVolumeMount {
539    /// Bind mount a host directory into the guest.
540    Bind {
541        /// Host directory to bind into the guest.
542        #[cfg_attr(feature = "ts", ts(type = "string"))]
543        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
544        host: PathBuf,
545        /// Guest path to mount at.
546        guest: String,
547        /// Mount options (read-only, no-exec, …).
548        #[serde(default)]
549        options: MountOptions,
550        /// How guest `stat()` results are virtualized.
551        #[serde(default = "default_strict")]
552        stat_virtualization: StatVirtualization,
553        /// Host permission policy applied to the mount.
554        #[serde(default = "default_private")]
555        host_permissions: HostPermissions,
556        /// Optional guest-write quota in MiB.
557        #[serde(default)]
558        quota_mib: Option<u32>,
559    },
560
561    /// Mount a named volume into the guest.
562    Named {
563        /// Named volume to mount.
564        name: String,
565        /// Guest path to mount at.
566        guest: String,
567        /// Mount options (read-only, no-exec, …).
568        #[serde(default)]
569        options: MountOptions,
570        /// How guest `stat()` results are virtualized.
571        #[serde(default = "default_strict")]
572        stat_virtualization: StatVirtualization,
573        /// Host permission policy applied to the mount.
574        #[serde(default = "default_private")]
575        host_permissions: HostPermissions,
576    },
577
578    /// Temporary filesystem backed by guest memory.
579    Tmpfs {
580        /// Guest path to mount at.
581        guest: String,
582        /// Optional size cap in MiB.
583        #[serde(default)]
584        size_mib: Option<u32>,
585        /// Mount options (read-only, no-exec, …).
586        #[serde(default)]
587        options: MountOptions,
588    },
589
590    /// Mount a disk image file as a virtio-blk device at a guest path.
591    DiskImage {
592        /// Host path to the disk image file.
593        #[cfg_attr(feature = "ts", ts(type = "string"))]
594        #[cfg_attr(feature = "utoipa", schema(value_type = String))]
595        host: PathBuf,
596        /// Guest path to mount at.
597        guest: String,
598        /// Disk image format.
599        format: CloudDiskImageFormat,
600        /// Inner filesystem type (auto-detected if absent).
601        #[serde(default)]
602        fstype: Option<String>,
603        /// Mount options (read-only, no-exec, …).
604        #[serde(default)]
605        options: MountOptions,
606    },
607}
608
609impl From<CloudVolumeMount> for VolumeMount {
610    fn from(m: CloudVolumeMount) -> Self {
611        match m {
612            CloudVolumeMount::Bind {
613                host,
614                guest,
615                options,
616                stat_virtualization,
617                host_permissions,
618                quota_mib,
619            } => VolumeMount::Bind {
620                host,
621                guest,
622                options,
623                stat_virtualization,
624                host_permissions,
625                // The cloud wire type does not carry the opt-out yet; default to
626                // the protective no-follow behavior.
627                follow_root_symlinks: false,
628                quota_mib,
629            },
630            CloudVolumeMount::Named {
631                name,
632                guest,
633                options,
634                stat_virtualization,
635                host_permissions,
636            } => VolumeMount::Named {
637                name,
638                guest,
639                create: None,
640                options,
641                stat_virtualization,
642                host_permissions,
643                follow_root_symlinks: false,
644            },
645            CloudVolumeMount::Tmpfs {
646                guest,
647                size_mib,
648                options,
649            } => VolumeMount::Tmpfs {
650                guest,
651                size_mib,
652                options,
653            },
654            CloudVolumeMount::DiskImage {
655                host,
656                guest,
657                format,
658                fstype,
659                options,
660            } => VolumeMount::DiskImage {
661                host,
662                guest,
663                format: format.into(),
664                fstype,
665                options,
666            },
667        }
668    }
669}
670
671impl From<VolumeMount> for CloudVolumeMount {
672    fn from(m: VolumeMount) -> Self {
673        match m {
674            VolumeMount::Bind {
675                host,
676                guest,
677                options,
678                stat_virtualization,
679                host_permissions,
680                follow_root_symlinks: _,
681                quota_mib,
682            } => CloudVolumeMount::Bind {
683                host,
684                guest,
685                options,
686                stat_virtualization,
687                host_permissions,
688                quota_mib,
689            },
690            VolumeMount::Named {
691                name,
692                guest,
693                create: _,
694                options,
695                stat_virtualization,
696                host_permissions,
697                follow_root_symlinks: _,
698            } => CloudVolumeMount::Named {
699                name,
700                guest,
701                options,
702                stat_virtualization,
703                host_permissions,
704            },
705            VolumeMount::Tmpfs {
706                guest,
707                size_mib,
708                options,
709            } => CloudVolumeMount::Tmpfs {
710                guest,
711                size_mib,
712                options,
713            },
714            VolumeMount::DiskImage {
715                host,
716                guest,
717                format,
718                fstype,
719                options,
720            } => CloudVolumeMount::DiskImage {
721                host,
722                guest,
723                format: format.into(),
724                fstype,
725                options,
726            },
727        }
728    }
729}
730
731/// Cloud network specification: a subset of the domain [`NetworkSpec`].
732/// Interface overrides, host port mapping, DNS, TLS interception, rate limits,
733/// and host-CA trust are not part of this type. `deny_unknown_fields` — posting
734/// an omitted field is an error, not a silent drop.
735#[derive(Debug, Clone, Serialize, Deserialize)]
736#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
737#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
738#[serde(default, deny_unknown_fields)]
739pub struct CloudNetworkSpec {
740    /// Whether networking is enabled for this sandbox.
741    pub enabled: bool,
742
743    /// Egress/ingress policy.
744    #[serde(skip_serializing_if = "Option::is_none")]
745    pub policy: Option<NetworkPolicy>,
746
747    /// Secret-injection config.
748    #[serde(skip_serializing_if = "Option::is_none")]
749    pub secrets: Option<CloudSecretsConfig>,
750
751    /// Require hostname-based policy allows to use inspectable application authority.
752    pub strict: bool,
753
754    /// Max concurrent guest connections.
755    #[serde(skip_serializing_if = "Option::is_none")]
756    pub max_connections: Option<usize>,
757}
758
759impl Default for CloudNetworkSpec {
760    fn default() -> Self {
761        Self {
762            enabled: true,
763            policy: None,
764            secrets: None,
765            strict: false,
766            max_connections: None,
767        }
768    }
769}
770
771/// Cloud guest runtime options: a subset of [`SandboxRuntimeOptions`]. The
772/// hostname and the metrics-sampling knobs are not part of this type.
773/// `deny_unknown_fields`.
774#[derive(Debug, Clone, Default, Serialize, Deserialize)]
775#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
776#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
777#[serde(default, deny_unknown_fields)]
778pub struct CloudSandboxRuntimeOptions {
779    /// Working directory for guest commands.
780    pub workdir: Option<String>,
781
782    /// Default shell.
783    pub shell: Option<String>,
784
785    /// Named in-guest scripts.
786    pub scripts: BTreeMap<String, String>,
787
788    /// Entrypoint override.
789    pub entrypoint: Option<Vec<String>>,
790
791    /// Command override.
792    pub cmd: Option<Vec<String>>,
793
794    /// Guest user.
795    pub user: Option<String>,
796
797    /// Runtime log level.
798    pub log_level: Option<SandboxLogLevel>,
799}
800
801//--------------------------------------------------------------------------------------------------
802// Types: Response
803//--------------------------------------------------------------------------------------------------
804
805/// Wire shape of the cloud sandbox response returned by sandbox endpoints.
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
808#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
809pub struct CloudCreateSandboxResponse {
810    /// Server-side UUID.
811    pub id: String,
812    /// Owning org's UUID.
813    pub org_id: String,
814    /// User-facing, per-org sandbox name.
815    pub name: String,
816    /// Canonical, resolved SSH username token.
817    pub slug: String,
818    /// Current lifecycle status.
819    pub status: CloudSandboxStatus,
820    /// Why the sandbox is not running yet, when known. Only present while
821    /// `status` is `starting`.
822    #[serde(default)]
823    pub status_reason: Option<CloudSandboxStatusReason>,
824    /// Curated resolved-spec projection returned by the control plane, when
825    /// available. Lifecycle and agent operations intentionally do not depend
826    /// on reconstructing the create request from this server-owned view.
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    #[cfg_attr(feature = "ts", ts(type = "unknown | null | undefined"))]
829    pub spec: Option<serde_json::Value>,
830    /// Whether the sandbox should be removed when its allocation terminates.
831    pub ephemeral: bool,
832    /// Creation timestamp.
833    #[cfg_attr(feature = "ts", ts(type = "string"))]
834    pub created_at: DateTime<Utc>,
835    /// Last start timestamp, when known.
836    #[serde(default)]
837    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
838    pub started_at: Option<DateTime<Utc>>,
839    /// Last stop timestamp, when known.
840    #[serde(default)]
841    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
842    pub stopped_at: Option<DateTime<Utc>>,
843    /// Human-readable message for the most recent failure, when any.
844    #[serde(default)]
845    pub last_failure_message: Option<String>,
846}
847
848/// Sandbox lifecycle status returned by the cloud control plane.
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
850#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
851#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
852#[serde(rename_all = "snake_case")]
853pub enum CloudSandboxStatus {
854    /// Created in the database but not yet started.
855    Created,
856    /// Start request has been submitted.
857    Starting,
858    /// Sandbox is running.
859    Running,
860    /// Stop request has been submitted.
861    Stopping,
862    /// Sandbox is stopped.
863    Stopped,
864    /// Sandbox failed.
865    Failed,
866}
867
868/// Reason a sandbox start is still in progress. Only meaningful while
869/// `status` is `starting`.
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
871#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
872#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
873#[serde(rename_all = "snake_case")]
874pub enum CloudSandboxStatusReason {
875    /// The start has been accepted and is being scheduled.
876    Scheduling,
877    /// No capacity is currently available; the start proceeds when
878    /// capacity frees up.
879    InsufficientCapacity,
880}
881
882/// Wire shape of paginated list responses.
883#[derive(Debug, Clone, Serialize, Deserialize)]
884#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
885#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
886pub struct CloudPaginated<T> {
887    /// Page of response items.
888    pub data: Vec<T>,
889    /// Cursor for the next page, when one exists.
890    #[serde(default)]
891    pub next_cursor: Option<String>,
892}
893
894/// Wire shape of the message response returned by mutation endpoints.
895#[derive(Debug, Clone, Serialize, Deserialize)]
896#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
897#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
898pub struct CloudMessageResponse {
899    /// Human-readable response message.
900    pub message: String,
901}
902
903/// Wire shape of the typed error body returned by cloud APIs on 4xx/5xx responses.
904#[derive(Debug, Clone, Serialize, Deserialize)]
905#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
906#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
907pub struct CloudErrorBody {
908    /// Flat machine-readable error code, when returned in this shape.
909    #[serde(default)]
910    pub code: Option<String>,
911    /// Flat human-readable error message, when returned in this shape.
912    #[serde(default)]
913    pub message: Option<String>,
914    /// Nested error object returned by the API error responder.
915    #[serde(default)]
916    pub error: Option<CloudErrorDetails>,
917}
918
919/// Nested cloud API error details.
920#[derive(Debug, Clone, Serialize, Deserialize)]
921#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
922#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
923pub struct CloudErrorDetails {
924    /// Machine-readable error code.
925    #[serde(default)]
926    pub code: Option<String>,
927    /// Human-readable error message.
928    #[serde(default)]
929    pub message: Option<String>,
930}
931
932//--------------------------------------------------------------------------------------------------
933// Trait Implementations
934//--------------------------------------------------------------------------------------------------
935
936impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
937    type Error = TypesError;
938
939    fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
940        req.spec.try_into()
941    }
942}
943
944impl TryFrom<CloudSandboxSpec> for SandboxSpec {
945    type Error = TypesError;
946
947    fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
948        let disk_size_mib = spec.resources.disk_size_mib;
949        let image = match spec.image {
950            // The cloud wire expresses only the managed kind (a size); tmpfs and
951            // disk-image root disks are local-only until the wire grows a kind field.
952            CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
953                reference,
954                root_disk: disk_size_mib.map(RootDisk::managed),
955            }),
956            CloudRootfsSource::Bind { .. } | CloudRootfsSource::DiskImage { .. }
957                if disk_size_mib.is_some() =>
958            {
959                return Err(TypesError::invalid_config(
960                    "resources.disk_size_mib is only valid for OCI rootfs",
961                ));
962            }
963            CloudRootfsSource::Bind { path } => RootfsSource::Bind {
964                path,
965                follow_root_symlinks: false,
966            },
967            CloudRootfsSource::DiskImage {
968                path,
969                format,
970                fstype,
971            } => RootfsSource::DiskImage {
972                path,
973                format: format.into(),
974                fstype,
975            },
976        };
977
978        let resources = SandboxResources {
979            cpus: spec.resources.vcpus,
980            memory_mib: spec.resources.memory_mib,
981            // The cloud wire type has no boot-capacity fields yet; treat the
982            // effective resources as the maximum (mirrors SandboxResources
983            // deserialization for legacy configs).
984            max_cpus: spec.resources.vcpus,
985            max_memory_mib: spec.resources.memory_mib,
986            // Host runtime policy never crosses the cloud wire. A managed
987            // service applies its own placement and guest-memory defaults
988            // after resolving the tenant-controlled resource request.
989            cpu_placement: CpuPlacement::Inherit,
990            placement_profile: None,
991            thp: TransparentHugePagePolicy::Madvise,
992        };
993
994        // Fields not present on `CloudNetworkSpec` are defaulted here, listed
995        // explicitly (not `..default()`) so a new `NetworkSpec` field forces a
996        // decision here.
997        let network = NetworkSpec {
998            enabled: spec.network.enabled,
999            interface: None,
1000            ports: Vec::new(),
1001            policy: spec.network.policy,
1002            dns: None,
1003            tls: None,
1004            strict: spec.network.strict,
1005            secrets: spec.network.secrets.map(Into::into),
1006            max_connections: spec.network.max_connections,
1007            rate_limiter: None,
1008            trust_host_cas: false,
1009            outbound_proxy: None,
1010        };
1011        let runtime = SandboxRuntimeOptions {
1012            workdir: spec.runtime.workdir,
1013            shell: spec.runtime.shell,
1014            scripts: spec.runtime.scripts,
1015            entrypoint: spec.runtime.entrypoint,
1016            cmd: spec.runtime.cmd,
1017            hostname: None,
1018            user: spec.runtime.user,
1019            log_level: spec.runtime.log_level,
1020            metrics_sample_interval_ms: None,
1021            disable_metrics_sample: false,
1022        };
1023
1024        Ok(Self {
1025            name: spec.name,
1026            image,
1027            resources,
1028            runtime,
1029            env: spec.env,
1030            labels: spec.labels,
1031            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1032            mounts: spec.mounts.into_iter().map(Into::into).collect(),
1033            patches: spec.patches.into_iter().map(Into::into).collect(),
1034            network,
1035            vsock: VsockSpec::default(),
1036            init: spec.init,
1037            pull_policy: spec.pull_policy.into(),
1038            security_profile: spec.security_profile,
1039            deployment_profile: DeploymentProfile::default(),
1040            lifecycle: spec.lifecycle,
1041        })
1042    }
1043}
1044
1045impl From<SandboxSpec> for CloudCreateSandboxRequest {
1046    fn from(spec: SandboxSpec) -> Self {
1047        Self { spec: spec.into() }
1048    }
1049}
1050
1051impl From<SandboxSpec> for CloudSandboxSpec {
1052    fn from(spec: SandboxSpec) -> Self {
1053        let (image, disk_size_mib) = match spec.image {
1054            // Only the managed size is representable on the cloud wire today; tmpfs and
1055            // disk-image root disks are local-only and map to no disk_size_mib.
1056            RootfsSource::Oci(oci) => (
1057                CloudRootfsSource::Oci {
1058                    reference: oci.reference,
1059                },
1060                match &oci.root_disk {
1061                    Some(RootDisk::Managed { size_mib }) => *size_mib,
1062                    _ => None,
1063                },
1064            ),
1065            RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1066            RootfsSource::DiskImage {
1067                path,
1068                format,
1069                fstype,
1070            } => (
1071                CloudRootfsSource::DiskImage {
1072                    path,
1073                    format: format.into(),
1074                    fstype,
1075                },
1076                None,
1077            ),
1078        };
1079
1080        Self {
1081            name: spec.name,
1082            image,
1083            resources: CloudSandboxResources {
1084                vcpus: spec.resources.cpus,
1085                memory_mib: spec.resources.memory_mib,
1086                disk_size_mib,
1087            },
1088            runtime: CloudSandboxRuntimeOptions {
1089                workdir: spec.runtime.workdir,
1090                shell: spec.runtime.shell,
1091                scripts: spec.runtime.scripts,
1092                entrypoint: spec.runtime.entrypoint,
1093                cmd: spec.runtime.cmd,
1094                user: spec.runtime.user,
1095                log_level: spec.runtime.log_level,
1096            },
1097            env: spec.env,
1098            labels: spec.labels,
1099            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1100            mounts: spec.mounts.into_iter().map(Into::into).collect(),
1101            patches: spec.patches.into_iter().map(Into::into).collect(),
1102            network: CloudNetworkSpec {
1103                enabled: spec.network.enabled,
1104                policy: spec.network.policy,
1105                secrets: spec.network.secrets.map(Into::into),
1106                strict: spec.network.strict,
1107                max_connections: spec.network.max_connections,
1108            },
1109            init: spec.init,
1110            pull_policy: spec.pull_policy.into(),
1111            security_profile: spec.security_profile,
1112            lifecycle: spec.lifecycle,
1113        }
1114    }
1115}
1116
1117impl Default for CloudSandboxResources {
1118    fn default() -> Self {
1119        let resources = SandboxResources::default();
1120        Self {
1121            vcpus: resources.cpus,
1122            memory_mib: resources.memory_mib,
1123            disk_size_mib: None,
1124        }
1125    }
1126}
1127
1128impl CloudRootfsSource {
1129    /// Create an OCI rootfs source from an image reference.
1130    pub fn oci(reference: impl Into<String>) -> Self {
1131        Self::Oci {
1132            reference: reference.into(),
1133        }
1134    }
1135
1136    /// Return the OCI image reference if this is an OCI rootfs.
1137    pub fn oci_reference(&self) -> Option<&str> {
1138        match self {
1139            Self::Oci { reference } => Some(reference),
1140            _ => None,
1141        }
1142    }
1143}
1144
1145impl Default for CloudRootfsSource {
1146    fn default() -> Self {
1147        Self::oci(String::new())
1148    }
1149}
1150
1151//--------------------------------------------------------------------------------------------------
1152// Types: Secrets
1153//--------------------------------------------------------------------------------------------------
1154
1155/// Secret-injection config for the cloud API. Twin of domain [`SecretsConfig`].
1156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1157#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1158#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1159pub struct CloudSecretsConfig {
1160    /// Secrets to inject.
1161    #[serde(default)]
1162    pub entries: Vec<CloudSecretEntry>,
1163    /// Default action when a placeholder leaks to a disallowed host.
1164    #[serde(default)]
1165    pub on_violation: CloudViolationAction,
1166}
1167
1168/// A single cloud secret entry. Twin of domain [`SecretEntry`].
1169#[derive(Debug, Clone, Serialize, Deserialize)]
1170#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1171#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1172pub struct CloudSecretEntry {
1173    /// Environment variable name exposed to the sandbox.
1174    pub env_var: String,
1175    /// The secret value (empty when `source` carries a reference instead).
1176    #[serde(default)]
1177    pub value: String,
1178    /// Host-side source resolved into `value` at spawn time.
1179    #[serde(default, skip_serializing_if = "Option::is_none")]
1180    pub source: Option<CloudSecretSource>,
1181    /// Placeholder the sandbox sees instead of the real value.
1182    pub placeholder: String,
1183    /// Hosts allowed to receive this secret.
1184    #[serde(default)]
1185    pub allowed_hosts: Vec<CloudHostPattern>,
1186    /// Where the secret may be injected.
1187    #[serde(default)]
1188    pub injection: SecretInjection,
1189    /// Per-secret violation action overriding the config default.
1190    #[serde(default, skip_serializing_if = "Option::is_none")]
1191    pub on_violation: Option<CloudViolationAction>,
1192    /// Require verified TLS identity before substituting (default: true).
1193    #[serde(default = "cloud_default_true")]
1194    pub require_tls_identity: bool,
1195}
1196
1197/// Host-side source for a cloud secret. Twin of [`SecretSource`].
1198#[derive(Debug, Clone, Serialize, Deserialize)]
1199#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1200#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1201#[serde(tag = "type", rename_all = "snake_case")]
1202pub enum CloudSecretSource {
1203    /// Read from a host environment variable at apply time.
1204    Env {
1205        /// Host environment variable name.
1206        var: String,
1207    },
1208    /// Read from a host-side secret store reference.
1209    Store {
1210        /// Store-specific secret reference.
1211        reference: String,
1212    },
1213}
1214
1215/// Host allowlist pattern for cloud secrets. Twin of [`HostPattern`], with the
1216/// domain's scalar variants normalized to `{ value }` for a uniform union.
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1218#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1220#[serde(tag = "type", rename_all = "snake_case")]
1221pub enum CloudHostPattern {
1222    /// Exact hostname match.
1223    Exact {
1224        /// Hostname to match exactly.
1225        value: String,
1226    },
1227    /// Wildcard match (e.g. `*.openai.com`).
1228    Wildcard {
1229        /// Wildcard pattern.
1230        value: String,
1231    },
1232    /// Any host (dangerous — the secret can be exfiltrated).
1233    Any,
1234}
1235
1236/// Action on a cloud secret violation. Twin of [`ViolationAction`], with
1237/// `Passthrough`'s host list normalized to a `hosts` field.
1238#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1239#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1240#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1241#[serde(tag = "type", rename_all = "snake_case")]
1242pub enum CloudViolationAction {
1243    /// Block the request silently.
1244    Block,
1245    /// Block and log (default).
1246    #[default]
1247    BlockAndLog,
1248    /// Block and terminate the sandbox.
1249    BlockAndTerminate,
1250    /// Forward the request with the placeholder unchanged for matching hosts.
1251    Passthrough {
1252        /// Hosts for which the placeholder passes through unchanged.
1253        hosts: Vec<CloudHostPattern>,
1254    },
1255}
1256
1257fn cloud_default_true() -> bool {
1258    true
1259}
1260
1261//--------------------------------------------------------------------------------------------------
1262// Conversions: Secrets
1263//--------------------------------------------------------------------------------------------------
1264
1265impl From<HostPattern> for CloudHostPattern {
1266    fn from(pattern: HostPattern) -> Self {
1267        match pattern {
1268            HostPattern::Exact(value) => Self::Exact { value },
1269            HostPattern::Wildcard(value) => Self::Wildcard { value },
1270            HostPattern::Any => Self::Any,
1271        }
1272    }
1273}
1274
1275impl From<CloudHostPattern> for HostPattern {
1276    fn from(pattern: CloudHostPattern) -> Self {
1277        match pattern {
1278            CloudHostPattern::Exact { value } => Self::Exact(value),
1279            CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1280            CloudHostPattern::Any => Self::Any,
1281        }
1282    }
1283}
1284
1285impl From<ViolationAction> for CloudViolationAction {
1286    fn from(action: ViolationAction) -> Self {
1287        match action {
1288            ViolationAction::Block => Self::Block,
1289            ViolationAction::BlockAndLog => Self::BlockAndLog,
1290            ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1291            ViolationAction::Passthrough(hosts) => Self::Passthrough {
1292                hosts: hosts.into_iter().map(Into::into).collect(),
1293            },
1294        }
1295    }
1296}
1297
1298impl From<CloudViolationAction> for ViolationAction {
1299    fn from(action: CloudViolationAction) -> Self {
1300        match action {
1301            CloudViolationAction::Block => Self::Block,
1302            CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1303            CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1304            CloudViolationAction::Passthrough { hosts } => {
1305                Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1306            }
1307        }
1308    }
1309}
1310
1311impl From<SecretSource> for CloudSecretSource {
1312    fn from(source: SecretSource) -> Self {
1313        match source {
1314            SecretSource::Env { var } => Self::Env { var },
1315            SecretSource::Store { reference } => Self::Store { reference },
1316        }
1317    }
1318}
1319
1320impl From<CloudSecretSource> for SecretSource {
1321    fn from(source: CloudSecretSource) -> Self {
1322        match source {
1323            CloudSecretSource::Env { var } => Self::Env { var },
1324            CloudSecretSource::Store { reference } => Self::Store { reference },
1325        }
1326    }
1327}
1328
1329impl From<SecretEntry> for CloudSecretEntry {
1330    fn from(entry: SecretEntry) -> Self {
1331        Self {
1332            env_var: entry.env_var,
1333            value: entry.value.to_string(),
1334            source: entry.source.map(Into::into),
1335            placeholder: entry.placeholder,
1336            allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1337            injection: entry.injection,
1338            on_violation: entry.on_violation.map(Into::into),
1339            require_tls_identity: entry.require_tls_identity,
1340        }
1341    }
1342}
1343
1344impl From<CloudSecretEntry> for SecretEntry {
1345    fn from(entry: CloudSecretEntry) -> Self {
1346        Self {
1347            env_var: entry.env_var,
1348            value: Zeroizing::new(entry.value),
1349            source: entry.source.map(Into::into),
1350            placeholder: entry.placeholder,
1351            allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1352            injection: entry.injection,
1353            on_violation: entry.on_violation.map(Into::into),
1354            require_tls_identity: entry.require_tls_identity,
1355        }
1356    }
1357}
1358
1359impl From<SecretsConfig> for CloudSecretsConfig {
1360    fn from(config: SecretsConfig) -> Self {
1361        Self {
1362            entries: config.secrets.into_iter().map(Into::into).collect(),
1363            on_violation: config.on_violation.into(),
1364        }
1365    }
1366}
1367
1368impl From<CloudSecretsConfig> for SecretsConfig {
1369    fn from(config: CloudSecretsConfig) -> Self {
1370        Self {
1371            secrets: config.entries.into_iter().map(Into::into).collect(),
1372            on_violation: config.on_violation.into(),
1373        }
1374    }
1375}
1376
1377//--------------------------------------------------------------------------------------------------
1378// Tests
1379//--------------------------------------------------------------------------------------------------
1380
1381#[cfg(test)]
1382mod tests {
1383    use super::*;
1384    use crate::domain::{
1385        DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1386    };
1387
1388    fn spec(name: &str) -> CloudSandboxSpec {
1389        CloudSandboxSpec {
1390            name: name.into(),
1391            image: CloudRootfsSource::Oci {
1392                reference: "python:3.12".into(),
1393            },
1394            ..Default::default()
1395        }
1396    }
1397
1398    #[test]
1399    fn create_request_flattens_spec() {
1400        let req = CloudCreateSandboxRequest {
1401            spec: spec("agent-1"),
1402        };
1403        let json = serde_json::to_value(&req).unwrap();
1404        // Spec fields are flattened onto the top level (SDK parity).
1405        assert_eq!(json["name"], "agent-1");
1406        assert!(json.get("image").is_some());
1407        assert!(json.get("deployment_profile").is_none());
1408
1409        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1410        assert_eq!(back.spec.name, "agent-1");
1411    }
1412
1413    #[test]
1414    fn cloud_network_rejects_rate_limit_configuration() {
1415        let error = serde_json::from_value::<CloudNetworkSpec>(serde_json::json!({
1416            "rate_limiter": {
1417                "egress": {
1418                    "bandwidth": {"size": 1024, "refill_time_ms": 1000}
1419                }
1420            }
1421        }))
1422        .unwrap_err();
1423
1424        assert!(error.to_string().contains("unknown field `rate_limiter`"));
1425    }
1426
1427    #[test]
1428    fn cloud_rootfs_source_uses_internal_tagging() {
1429        let json = serde_json::to_value(CloudRootfsSource::Oci {
1430            reference: "python:3.12".into(),
1431        })
1432        .unwrap();
1433        assert_eq!(
1434            json,
1435            serde_json::json!({"type": "oci", "reference": "python:3.12"})
1436        );
1437
1438        let bind = serde_json::to_value(CloudRootfsSource::Bind {
1439            path: "/host".into(),
1440        })
1441        .unwrap();
1442        assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1443
1444        let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1445        assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1446    }
1447
1448    #[test]
1449    fn cloud_secret_twins_use_internal_tagging() {
1450        // Scalar domain variants normalize to a uniform `{ "type", value }` union.
1451        assert_eq!(
1452            serde_json::to_value(CloudHostPattern::Exact {
1453                value: "api.example.com".into(),
1454            })
1455            .unwrap(),
1456            serde_json::json!({"type": "exact", "value": "api.example.com"})
1457        );
1458        assert_eq!(
1459            serde_json::to_value(CloudSecretSource::Env {
1460                var: "OPENAI".into()
1461            })
1462            .unwrap(),
1463            serde_json::json!({"type": "env", "var": "OPENAI"})
1464        );
1465        assert_eq!(
1466            serde_json::to_value(CloudViolationAction::Passthrough {
1467                hosts: vec![CloudHostPattern::Any],
1468            })
1469            .unwrap(),
1470            serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1471        );
1472    }
1473
1474    #[test]
1475    fn cloud_secrets_config_round_trips_through_domain() {
1476        let cloud = CloudSecretsConfig {
1477            entries: vec![CloudSecretEntry {
1478                env_var: "OPENAI_API_KEY".into(),
1479                value: "sk-x".into(),
1480                source: Some(CloudSecretSource::Env {
1481                    var: "OPENAI".into(),
1482                }),
1483                placeholder: "$MSB_OPENAI".into(),
1484                allowed_hosts: vec![CloudHostPattern::Exact {
1485                    value: "api.openai.com".into(),
1486                }],
1487                injection: SecretInjection::default(),
1488                on_violation: Some(CloudViolationAction::BlockAndTerminate),
1489                require_tls_identity: true,
1490            }],
1491            on_violation: CloudViolationAction::BlockAndLog,
1492        };
1493
1494        let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1495        assert_eq!(back.entries.len(), 1);
1496        assert_eq!(back.entries[0].value, "sk-x");
1497        assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1498        assert!(matches!(
1499            back.entries[0].on_violation,
1500            Some(CloudViolationAction::BlockAndTerminate)
1501        ));
1502    }
1503
1504    #[test]
1505    fn create_request_converts_disk_size_to_oci_rootfs() {
1506        let mut req = CloudCreateSandboxRequest {
1507            spec: spec("agent-1"),
1508        };
1509        req.spec.resources.disk_size_mib = Some(8192);
1510
1511        let domain = SandboxSpec::try_from(req).unwrap();
1512
1513        assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1514        assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1515        assert_eq!(domain.resources.thp, TransparentHugePagePolicy::Madvise);
1516        match domain.image {
1517            RootfsSource::Oci(oci) => {
1518                assert_eq!(oci.reference, "python:3.12");
1519                assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1520            }
1521            other => panic!("expected OCI rootfs, got {other:?}"),
1522        }
1523    }
1524
1525    #[test]
1526    fn cloud_resources_do_not_carry_host_runtime_policy() {
1527        let mut domain = SandboxSpec::try_from(CloudCreateSandboxRequest {
1528            spec: spec("agent-1"),
1529        })
1530        .unwrap();
1531        domain.resources.cpu_placement = CpuPlacement::Spread;
1532        domain.resources.placement_profile = Some("locality".into());
1533        domain.resources.thp = TransparentHugePagePolicy::Always;
1534
1535        let cloud = CloudSandboxSpec::from(domain);
1536        let wire = serde_json::to_value(&cloud.resources).unwrap();
1537        assert!(wire.get("cpu_placement").is_none());
1538        assert!(wire.get("placement_profile").is_none());
1539        assert!(wire.get("thp").is_none());
1540
1541        let round_trip = SandboxSpec::try_from(cloud).unwrap();
1542        assert_eq!(round_trip.resources.cpu_placement, CpuPlacement::Inherit);
1543        assert!(round_trip.resources.placement_profile.is_none());
1544        assert_eq!(round_trip.resources.thp, TransparentHugePagePolicy::Madvise);
1545    }
1546
1547    #[test]
1548    fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1549        let mut req = CloudCreateSandboxRequest {
1550            spec: spec("agent-1"),
1551        };
1552        req.spec.image = CloudRootfsSource::Bind {
1553            path: "/tmp/rootfs".into(),
1554        };
1555        req.spec.resources.disk_size_mib = Some(8192);
1556
1557        let err = SandboxSpec::try_from(req).unwrap_err();
1558
1559        assert!(err.to_string().contains("disk_size_mib"));
1560    }
1561
1562    #[test]
1563    fn domain_spec_converts_oci_size_to_cloud_resources() {
1564        let domain = SandboxSpec {
1565            name: "agent-1".into(),
1566            image: RootfsSource::Oci(OciRootfsSource {
1567                reference: "python:3.12".into(),
1568                root_disk: Some(RootDisk::managed(8192)),
1569            }),
1570            deployment_profile: DeploymentProfile::MultiTenant,
1571            ..Default::default()
1572        };
1573
1574        let req = CloudCreateSandboxRequest::from(domain);
1575
1576        // The hosting platform, not a tenant create request, selects the
1577        // effective deployment profile.
1578        assert!(
1579            serde_json::to_value(&req)
1580                .unwrap()
1581                .get("deployment_profile")
1582                .is_none()
1583        );
1584
1585        assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1586        match req.spec.image {
1587            CloudRootfsSource::Oci { reference } => {
1588                assert_eq!(reference, "python:3.12");
1589            }
1590            other => panic!("expected OCI rootfs, got {other:?}"),
1591        }
1592    }
1593
1594    #[test]
1595    fn create_request_minimal_defaults() {
1596        // Only the spec's name + image are set; everything else defaults.
1597        let req = CloudCreateSandboxRequest {
1598            spec: spec("agent-1"),
1599        };
1600        let json = serde_json::to_value(&req).unwrap();
1601        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1602        assert_eq!(back.spec.name, "agent-1");
1603    }
1604
1605    #[test]
1606    fn sandbox_response_accepts_curated_optional_spec() {
1607        let sb = CloudCreateSandboxResponse {
1608            id: "00000000-0000-0000-0000-000000000002".into(),
1609            org_id: "00000000-0000-0000-0000-000000000001".into(),
1610            name: "agent-1".into(),
1611            slug: "brave-otter".into(),
1612            status: CloudSandboxStatus::Created,
1613            status_reason: None,
1614            spec: Some(serde_json::json!({
1615                "image": "python:3.12",
1616                "resources": { "vcpus": 2, "memory_mib": 1024 },
1617            })),
1618            ephemeral: true,
1619            created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1620            started_at: None,
1621            stopped_at: None,
1622            last_failure_message: None,
1623        };
1624        let json = serde_json::to_value(&sb).unwrap();
1625        assert_eq!(json["slug"], "brave-otter");
1626        assert_eq!(json["name"], "agent-1");
1627
1628        let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1629        assert_eq!(back.slug, "brave-otter");
1630        assert_eq!(back.status, CloudSandboxStatus::Created);
1631        assert_eq!(back.spec.as_ref().unwrap()["image"], "python:3.12");
1632        assert!(back.started_at.is_none());
1633    }
1634
1635    #[test]
1636    fn sandbox_response_accepts_omitted_spec() {
1637        let json = serde_json::json!({
1638            "id": "00000000-0000-0000-0000-000000000002",
1639            "org_id": "00000000-0000-0000-0000-000000000001",
1640            "name": "agent-1",
1641            "slug": "brave-otter",
1642            "status": "running",
1643            "ephemeral": false,
1644            "created_at": "2026-05-17T12:00:00Z",
1645            "started_at": "2026-05-17T12:00:01Z",
1646            "stopped_at": null,
1647            "last_failure_message": null
1648        });
1649
1650        let response: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1651
1652        assert!(response.spec.is_none());
1653        assert_eq!(response.status, CloudSandboxStatus::Running);
1654    }
1655}