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    /// Max concurrent guest connections.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub max_connections: Option<usize>,
754}
755
756impl Default for CloudNetworkSpec {
757    fn default() -> Self {
758        Self {
759            enabled: true,
760            policy: None,
761            secrets: None,
762            max_connections: None,
763        }
764    }
765}
766
767/// Cloud guest runtime options: a subset of [`SandboxRuntimeOptions`]. The
768/// hostname and the metrics-sampling knobs are not part of this type.
769/// `deny_unknown_fields`.
770#[derive(Debug, Clone, Default, Serialize, Deserialize)]
771#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
772#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
773#[serde(default, deny_unknown_fields)]
774pub struct CloudSandboxRuntimeOptions {
775    /// Working directory for guest commands.
776    pub workdir: Option<String>,
777
778    /// Default shell.
779    pub shell: Option<String>,
780
781    /// Named in-guest scripts.
782    pub scripts: BTreeMap<String, String>,
783
784    /// Entrypoint override.
785    pub entrypoint: Option<Vec<String>>,
786
787    /// Command override.
788    pub cmd: Option<Vec<String>>,
789
790    /// Guest user.
791    pub user: Option<String>,
792
793    /// Runtime log level.
794    pub log_level: Option<SandboxLogLevel>,
795}
796
797//--------------------------------------------------------------------------------------------------
798// Types: Response
799//--------------------------------------------------------------------------------------------------
800
801/// Wire shape of the cloud sandbox response returned by sandbox endpoints.
802#[derive(Debug, Clone, Serialize, Deserialize)]
803#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
804#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
805pub struct CloudCreateSandboxResponse {
806    /// Server-side UUID.
807    pub id: String,
808    /// Owning org's UUID.
809    pub org_id: String,
810    /// User-facing, per-org sandbox name.
811    pub name: String,
812    /// Canonical, resolved SSH username token.
813    pub slug: String,
814    /// Current lifecycle status.
815    pub status: CloudSandboxStatus,
816    /// Why the sandbox is not running yet, when known. Only present while
817    /// `status` is `starting`.
818    #[serde(default)]
819    pub status_reason: Option<CloudSandboxStatusReason>,
820    /// Curated resolved-spec projection returned by the control plane, when
821    /// available. Lifecycle and agent operations intentionally do not depend
822    /// on reconstructing the create request from this server-owned view.
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    #[cfg_attr(feature = "ts", ts(type = "unknown | null | undefined"))]
825    pub spec: Option<serde_json::Value>,
826    /// Whether the sandbox should be removed when its allocation terminates.
827    pub ephemeral: bool,
828    /// Creation timestamp.
829    #[cfg_attr(feature = "ts", ts(type = "string"))]
830    pub created_at: DateTime<Utc>,
831    /// Last start timestamp, when known.
832    #[serde(default)]
833    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
834    pub started_at: Option<DateTime<Utc>>,
835    /// Last stop timestamp, when known.
836    #[serde(default)]
837    #[cfg_attr(feature = "ts", ts(type = "string | null"))]
838    pub stopped_at: Option<DateTime<Utc>>,
839    /// Human-readable message for the most recent failure, when any.
840    #[serde(default)]
841    pub last_failure_message: Option<String>,
842}
843
844/// Sandbox lifecycle status returned by the cloud control plane.
845#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
846#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
847#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
848#[serde(rename_all = "snake_case")]
849pub enum CloudSandboxStatus {
850    /// Created in the database but not yet started.
851    Created,
852    /// Start request has been submitted.
853    Starting,
854    /// Sandbox is running.
855    Running,
856    /// Stop request has been submitted.
857    Stopping,
858    /// Sandbox is stopped.
859    Stopped,
860    /// Sandbox failed.
861    Failed,
862}
863
864/// Reason a sandbox start is still in progress. Only meaningful while
865/// `status` is `starting`.
866#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
867#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
868#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
869#[serde(rename_all = "snake_case")]
870pub enum CloudSandboxStatusReason {
871    /// The start has been accepted and is being scheduled.
872    Scheduling,
873    /// No capacity is currently available; the start proceeds when
874    /// capacity frees up.
875    InsufficientCapacity,
876}
877
878/// Wire shape of paginated list responses.
879#[derive(Debug, Clone, Serialize, Deserialize)]
880#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
881#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
882pub struct CloudPaginated<T> {
883    /// Page of response items.
884    pub data: Vec<T>,
885    /// Cursor for the next page, when one exists.
886    #[serde(default)]
887    pub next_cursor: Option<String>,
888}
889
890/// Wire shape of the message response returned by mutation endpoints.
891#[derive(Debug, Clone, Serialize, Deserialize)]
892#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
893#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
894pub struct CloudMessageResponse {
895    /// Human-readable response message.
896    pub message: String,
897}
898
899/// Wire shape of the typed error body returned by cloud APIs on 4xx/5xx responses.
900#[derive(Debug, Clone, Serialize, Deserialize)]
901#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
902#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
903pub struct CloudErrorBody {
904    /// Flat machine-readable error code, when returned in this shape.
905    #[serde(default)]
906    pub code: Option<String>,
907    /// Flat human-readable error message, when returned in this shape.
908    #[serde(default)]
909    pub message: Option<String>,
910    /// Nested error object returned by the API error responder.
911    #[serde(default)]
912    pub error: Option<CloudErrorDetails>,
913}
914
915/// Nested cloud API error details.
916#[derive(Debug, Clone, Serialize, Deserialize)]
917#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
918#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
919pub struct CloudErrorDetails {
920    /// Machine-readable error code.
921    #[serde(default)]
922    pub code: Option<String>,
923    /// Human-readable error message.
924    #[serde(default)]
925    pub message: Option<String>,
926}
927
928//--------------------------------------------------------------------------------------------------
929// Trait Implementations
930//--------------------------------------------------------------------------------------------------
931
932impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
933    type Error = TypesError;
934
935    fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
936        req.spec.try_into()
937    }
938}
939
940impl TryFrom<CloudSandboxSpec> for SandboxSpec {
941    type Error = TypesError;
942
943    fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
944        let disk_size_mib = spec.resources.disk_size_mib;
945        let image = match spec.image {
946            // The cloud wire expresses only the managed kind (a size); tmpfs and
947            // disk-image root disks are local-only until the wire grows a kind field.
948            CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
949                reference,
950                root_disk: disk_size_mib.map(RootDisk::managed),
951            }),
952            CloudRootfsSource::Bind { .. } | CloudRootfsSource::DiskImage { .. }
953                if disk_size_mib.is_some() =>
954            {
955                return Err(TypesError::invalid_config(
956                    "resources.disk_size_mib is only valid for OCI rootfs",
957                ));
958            }
959            CloudRootfsSource::Bind { path } => RootfsSource::Bind {
960                path,
961                follow_root_symlinks: false,
962            },
963            CloudRootfsSource::DiskImage {
964                path,
965                format,
966                fstype,
967            } => RootfsSource::DiskImage {
968                path,
969                format: format.into(),
970                fstype,
971            },
972        };
973
974        let resources = SandboxResources {
975            cpus: spec.resources.vcpus,
976            memory_mib: spec.resources.memory_mib,
977            // The cloud wire type has no boot-capacity fields yet; treat the
978            // effective resources as the maximum (mirrors SandboxResources
979            // deserialization for legacy configs).
980            max_cpus: spec.resources.vcpus,
981            max_memory_mib: spec.resources.memory_mib,
982            // Host runtime policy never crosses the cloud wire. A managed
983            // service applies its own placement and guest-memory defaults
984            // after resolving the tenant-controlled resource request.
985            cpu_placement: CpuPlacement::Inherit,
986            placement_profile: None,
987            thp: TransparentHugePagePolicy::Madvise,
988        };
989
990        // Fields not present on `CloudNetworkSpec` are defaulted here, listed
991        // explicitly (not `..default()`) so a new `NetworkSpec` field forces a
992        // decision here.
993        let network = NetworkSpec {
994            enabled: spec.network.enabled,
995            interface: None,
996            ports: Vec::new(),
997            policy: spec.network.policy,
998            dns: None,
999            tls: None,
1000            secrets: spec.network.secrets.map(Into::into),
1001            max_connections: spec.network.max_connections,
1002            rate_limiter: None,
1003            trust_host_cas: false,
1004        };
1005        let runtime = SandboxRuntimeOptions {
1006            workdir: spec.runtime.workdir,
1007            shell: spec.runtime.shell,
1008            scripts: spec.runtime.scripts,
1009            entrypoint: spec.runtime.entrypoint,
1010            cmd: spec.runtime.cmd,
1011            hostname: None,
1012            user: spec.runtime.user,
1013            log_level: spec.runtime.log_level,
1014            metrics_sample_interval_ms: None,
1015            disable_metrics_sample: false,
1016        };
1017
1018        Ok(Self {
1019            name: spec.name,
1020            image,
1021            resources,
1022            runtime,
1023            env: spec.env,
1024            labels: spec.labels,
1025            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1026            mounts: spec.mounts.into_iter().map(Into::into).collect(),
1027            patches: spec.patches.into_iter().map(Into::into).collect(),
1028            network,
1029            vsock: VsockSpec::default(),
1030            init: spec.init,
1031            pull_policy: spec.pull_policy.into(),
1032            security_profile: spec.security_profile,
1033            deployment_profile: DeploymentProfile::default(),
1034            lifecycle: spec.lifecycle,
1035        })
1036    }
1037}
1038
1039impl From<SandboxSpec> for CloudCreateSandboxRequest {
1040    fn from(spec: SandboxSpec) -> Self {
1041        Self { spec: spec.into() }
1042    }
1043}
1044
1045impl From<SandboxSpec> for CloudSandboxSpec {
1046    fn from(spec: SandboxSpec) -> Self {
1047        let (image, disk_size_mib) = match spec.image {
1048            // Only the managed size is representable on the cloud wire today; tmpfs and
1049            // disk-image root disks are local-only and map to no disk_size_mib.
1050            RootfsSource::Oci(oci) => (
1051                CloudRootfsSource::Oci {
1052                    reference: oci.reference,
1053                },
1054                match &oci.root_disk {
1055                    Some(RootDisk::Managed { size_mib }) => *size_mib,
1056                    _ => None,
1057                },
1058            ),
1059            RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1060            RootfsSource::DiskImage {
1061                path,
1062                format,
1063                fstype,
1064            } => (
1065                CloudRootfsSource::DiskImage {
1066                    path,
1067                    format: format.into(),
1068                    fstype,
1069                },
1070                None,
1071            ),
1072        };
1073
1074        Self {
1075            name: spec.name,
1076            image,
1077            resources: CloudSandboxResources {
1078                vcpus: spec.resources.cpus,
1079                memory_mib: spec.resources.memory_mib,
1080                disk_size_mib,
1081            },
1082            runtime: CloudSandboxRuntimeOptions {
1083                workdir: spec.runtime.workdir,
1084                shell: spec.runtime.shell,
1085                scripts: spec.runtime.scripts,
1086                entrypoint: spec.runtime.entrypoint,
1087                cmd: spec.runtime.cmd,
1088                user: spec.runtime.user,
1089                log_level: spec.runtime.log_level,
1090            },
1091            env: spec.env,
1092            labels: spec.labels,
1093            rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1094            mounts: spec.mounts.into_iter().map(Into::into).collect(),
1095            patches: spec.patches.into_iter().map(Into::into).collect(),
1096            network: CloudNetworkSpec {
1097                enabled: spec.network.enabled,
1098                policy: spec.network.policy,
1099                secrets: spec.network.secrets.map(Into::into),
1100                max_connections: spec.network.max_connections,
1101            },
1102            init: spec.init,
1103            pull_policy: spec.pull_policy.into(),
1104            security_profile: spec.security_profile,
1105            lifecycle: spec.lifecycle,
1106        }
1107    }
1108}
1109
1110impl Default for CloudSandboxResources {
1111    fn default() -> Self {
1112        let resources = SandboxResources::default();
1113        Self {
1114            vcpus: resources.cpus,
1115            memory_mib: resources.memory_mib,
1116            disk_size_mib: None,
1117        }
1118    }
1119}
1120
1121impl CloudRootfsSource {
1122    /// Create an OCI rootfs source from an image reference.
1123    pub fn oci(reference: impl Into<String>) -> Self {
1124        Self::Oci {
1125            reference: reference.into(),
1126        }
1127    }
1128
1129    /// Return the OCI image reference if this is an OCI rootfs.
1130    pub fn oci_reference(&self) -> Option<&str> {
1131        match self {
1132            Self::Oci { reference } => Some(reference),
1133            _ => None,
1134        }
1135    }
1136}
1137
1138impl Default for CloudRootfsSource {
1139    fn default() -> Self {
1140        Self::oci(String::new())
1141    }
1142}
1143
1144//--------------------------------------------------------------------------------------------------
1145// Types: Secrets
1146//--------------------------------------------------------------------------------------------------
1147
1148/// Secret-injection config for the cloud API. Twin of domain [`SecretsConfig`].
1149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1150#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1151#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1152pub struct CloudSecretsConfig {
1153    /// Secrets to inject.
1154    #[serde(default)]
1155    pub entries: Vec<CloudSecretEntry>,
1156    /// Default action when a placeholder leaks to a disallowed host.
1157    #[serde(default)]
1158    pub on_violation: CloudViolationAction,
1159}
1160
1161/// A single cloud secret entry. Twin of domain [`SecretEntry`].
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1163#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1164#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1165pub struct CloudSecretEntry {
1166    /// Environment variable name exposed to the sandbox.
1167    pub env_var: String,
1168    /// The secret value (empty when `source` carries a reference instead).
1169    #[serde(default)]
1170    pub value: String,
1171    /// Host-side source resolved into `value` at spawn time.
1172    #[serde(default, skip_serializing_if = "Option::is_none")]
1173    pub source: Option<CloudSecretSource>,
1174    /// Placeholder the sandbox sees instead of the real value.
1175    pub placeholder: String,
1176    /// Hosts allowed to receive this secret.
1177    #[serde(default)]
1178    pub allowed_hosts: Vec<CloudHostPattern>,
1179    /// Where the secret may be injected.
1180    #[serde(default)]
1181    pub injection: SecretInjection,
1182    /// Per-secret violation action overriding the config default.
1183    #[serde(default, skip_serializing_if = "Option::is_none")]
1184    pub on_violation: Option<CloudViolationAction>,
1185    /// Require verified TLS identity before substituting (default: true).
1186    #[serde(default = "cloud_default_true")]
1187    pub require_tls_identity: bool,
1188}
1189
1190/// Host-side source for a cloud secret. Twin of [`SecretSource`].
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1192#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1194#[serde(tag = "type", rename_all = "snake_case")]
1195pub enum CloudSecretSource {
1196    /// Read from a host environment variable at apply time.
1197    Env {
1198        /// Host environment variable name.
1199        var: String,
1200    },
1201    /// Read from a host-side secret store reference.
1202    Store {
1203        /// Store-specific secret reference.
1204        reference: String,
1205    },
1206}
1207
1208/// Host allowlist pattern for cloud secrets. Twin of [`HostPattern`], with the
1209/// domain's scalar variants normalized to `{ value }` for a uniform union.
1210#[derive(Debug, Clone, Serialize, Deserialize)]
1211#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1212#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1213#[serde(tag = "type", rename_all = "snake_case")]
1214pub enum CloudHostPattern {
1215    /// Exact hostname match.
1216    Exact {
1217        /// Hostname to match exactly.
1218        value: String,
1219    },
1220    /// Wildcard match (e.g. `*.openai.com`).
1221    Wildcard {
1222        /// Wildcard pattern.
1223        value: String,
1224    },
1225    /// Any host (dangerous — the secret can be exfiltrated).
1226    Any,
1227}
1228
1229/// Action on a cloud secret violation. Twin of [`ViolationAction`], with
1230/// `Passthrough`'s host list normalized to a `hosts` field.
1231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1232#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1233#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1234#[serde(tag = "type", rename_all = "snake_case")]
1235pub enum CloudViolationAction {
1236    /// Block the request silently.
1237    Block,
1238    /// Block and log (default).
1239    #[default]
1240    BlockAndLog,
1241    /// Block and terminate the sandbox.
1242    BlockAndTerminate,
1243    /// Forward the request with the placeholder unchanged for matching hosts.
1244    Passthrough {
1245        /// Hosts for which the placeholder passes through unchanged.
1246        hosts: Vec<CloudHostPattern>,
1247    },
1248}
1249
1250fn cloud_default_true() -> bool {
1251    true
1252}
1253
1254//--------------------------------------------------------------------------------------------------
1255// Conversions: Secrets
1256//--------------------------------------------------------------------------------------------------
1257
1258impl From<HostPattern> for CloudHostPattern {
1259    fn from(pattern: HostPattern) -> Self {
1260        match pattern {
1261            HostPattern::Exact(value) => Self::Exact { value },
1262            HostPattern::Wildcard(value) => Self::Wildcard { value },
1263            HostPattern::Any => Self::Any,
1264        }
1265    }
1266}
1267
1268impl From<CloudHostPattern> for HostPattern {
1269    fn from(pattern: CloudHostPattern) -> Self {
1270        match pattern {
1271            CloudHostPattern::Exact { value } => Self::Exact(value),
1272            CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1273            CloudHostPattern::Any => Self::Any,
1274        }
1275    }
1276}
1277
1278impl From<ViolationAction> for CloudViolationAction {
1279    fn from(action: ViolationAction) -> Self {
1280        match action {
1281            ViolationAction::Block => Self::Block,
1282            ViolationAction::BlockAndLog => Self::BlockAndLog,
1283            ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1284            ViolationAction::Passthrough(hosts) => Self::Passthrough {
1285                hosts: hosts.into_iter().map(Into::into).collect(),
1286            },
1287        }
1288    }
1289}
1290
1291impl From<CloudViolationAction> for ViolationAction {
1292    fn from(action: CloudViolationAction) -> Self {
1293        match action {
1294            CloudViolationAction::Block => Self::Block,
1295            CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1296            CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1297            CloudViolationAction::Passthrough { hosts } => {
1298                Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1299            }
1300        }
1301    }
1302}
1303
1304impl From<SecretSource> for CloudSecretSource {
1305    fn from(source: SecretSource) -> Self {
1306        match source {
1307            SecretSource::Env { var } => Self::Env { var },
1308            SecretSource::Store { reference } => Self::Store { reference },
1309        }
1310    }
1311}
1312
1313impl From<CloudSecretSource> for SecretSource {
1314    fn from(source: CloudSecretSource) -> Self {
1315        match source {
1316            CloudSecretSource::Env { var } => Self::Env { var },
1317            CloudSecretSource::Store { reference } => Self::Store { reference },
1318        }
1319    }
1320}
1321
1322impl From<SecretEntry> for CloudSecretEntry {
1323    fn from(entry: SecretEntry) -> Self {
1324        Self {
1325            env_var: entry.env_var,
1326            value: entry.value.to_string(),
1327            source: entry.source.map(Into::into),
1328            placeholder: entry.placeholder,
1329            allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1330            injection: entry.injection,
1331            on_violation: entry.on_violation.map(Into::into),
1332            require_tls_identity: entry.require_tls_identity,
1333        }
1334    }
1335}
1336
1337impl From<CloudSecretEntry> for SecretEntry {
1338    fn from(entry: CloudSecretEntry) -> Self {
1339        Self {
1340            env_var: entry.env_var,
1341            value: Zeroizing::new(entry.value),
1342            source: entry.source.map(Into::into),
1343            placeholder: entry.placeholder,
1344            allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1345            injection: entry.injection,
1346            on_violation: entry.on_violation.map(Into::into),
1347            require_tls_identity: entry.require_tls_identity,
1348        }
1349    }
1350}
1351
1352impl From<SecretsConfig> for CloudSecretsConfig {
1353    fn from(config: SecretsConfig) -> Self {
1354        Self {
1355            entries: config.secrets.into_iter().map(Into::into).collect(),
1356            on_violation: config.on_violation.into(),
1357        }
1358    }
1359}
1360
1361impl From<CloudSecretsConfig> for SecretsConfig {
1362    fn from(config: CloudSecretsConfig) -> Self {
1363        Self {
1364            secrets: config.entries.into_iter().map(Into::into).collect(),
1365            on_violation: config.on_violation.into(),
1366        }
1367    }
1368}
1369
1370//--------------------------------------------------------------------------------------------------
1371// Tests
1372//--------------------------------------------------------------------------------------------------
1373
1374#[cfg(test)]
1375mod tests {
1376    use super::*;
1377    use crate::domain::{
1378        DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1379    };
1380
1381    fn spec(name: &str) -> CloudSandboxSpec {
1382        CloudSandboxSpec {
1383            name: name.into(),
1384            image: CloudRootfsSource::Oci {
1385                reference: "python:3.12".into(),
1386            },
1387            ..Default::default()
1388        }
1389    }
1390
1391    #[test]
1392    fn create_request_flattens_spec() {
1393        let req = CloudCreateSandboxRequest {
1394            spec: spec("agent-1"),
1395        };
1396        let json = serde_json::to_value(&req).unwrap();
1397        // Spec fields are flattened onto the top level (SDK parity).
1398        assert_eq!(json["name"], "agent-1");
1399        assert!(json.get("image").is_some());
1400        assert!(json.get("deployment_profile").is_none());
1401
1402        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1403        assert_eq!(back.spec.name, "agent-1");
1404    }
1405
1406    #[test]
1407    fn cloud_network_rejects_rate_limit_configuration() {
1408        let error = serde_json::from_value::<CloudNetworkSpec>(serde_json::json!({
1409            "rate_limiter": {
1410                "egress": {
1411                    "bandwidth": {"size": 1024, "refill_time_ms": 1000}
1412                }
1413            }
1414        }))
1415        .unwrap_err();
1416
1417        assert!(error.to_string().contains("unknown field `rate_limiter`"));
1418    }
1419
1420    #[test]
1421    fn cloud_rootfs_source_uses_internal_tagging() {
1422        let json = serde_json::to_value(CloudRootfsSource::Oci {
1423            reference: "python:3.12".into(),
1424        })
1425        .unwrap();
1426        assert_eq!(
1427            json,
1428            serde_json::json!({"type": "oci", "reference": "python:3.12"})
1429        );
1430
1431        let bind = serde_json::to_value(CloudRootfsSource::Bind {
1432            path: "/host".into(),
1433        })
1434        .unwrap();
1435        assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1436
1437        let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1438        assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1439    }
1440
1441    #[test]
1442    fn cloud_secret_twins_use_internal_tagging() {
1443        // Scalar domain variants normalize to a uniform `{ "type", value }` union.
1444        assert_eq!(
1445            serde_json::to_value(CloudHostPattern::Exact {
1446                value: "api.example.com".into(),
1447            })
1448            .unwrap(),
1449            serde_json::json!({"type": "exact", "value": "api.example.com"})
1450        );
1451        assert_eq!(
1452            serde_json::to_value(CloudSecretSource::Env {
1453                var: "OPENAI".into()
1454            })
1455            .unwrap(),
1456            serde_json::json!({"type": "env", "var": "OPENAI"})
1457        );
1458        assert_eq!(
1459            serde_json::to_value(CloudViolationAction::Passthrough {
1460                hosts: vec![CloudHostPattern::Any],
1461            })
1462            .unwrap(),
1463            serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1464        );
1465    }
1466
1467    #[test]
1468    fn cloud_secrets_config_round_trips_through_domain() {
1469        let cloud = CloudSecretsConfig {
1470            entries: vec![CloudSecretEntry {
1471                env_var: "OPENAI_API_KEY".into(),
1472                value: "sk-x".into(),
1473                source: Some(CloudSecretSource::Env {
1474                    var: "OPENAI".into(),
1475                }),
1476                placeholder: "$MSB_OPENAI".into(),
1477                allowed_hosts: vec![CloudHostPattern::Exact {
1478                    value: "api.openai.com".into(),
1479                }],
1480                injection: SecretInjection::default(),
1481                on_violation: Some(CloudViolationAction::BlockAndTerminate),
1482                require_tls_identity: true,
1483            }],
1484            on_violation: CloudViolationAction::BlockAndLog,
1485        };
1486
1487        let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1488        assert_eq!(back.entries.len(), 1);
1489        assert_eq!(back.entries[0].value, "sk-x");
1490        assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1491        assert!(matches!(
1492            back.entries[0].on_violation,
1493            Some(CloudViolationAction::BlockAndTerminate)
1494        ));
1495    }
1496
1497    #[test]
1498    fn create_request_converts_disk_size_to_oci_rootfs() {
1499        let mut req = CloudCreateSandboxRequest {
1500            spec: spec("agent-1"),
1501        };
1502        req.spec.resources.disk_size_mib = Some(8192);
1503
1504        let domain = SandboxSpec::try_from(req).unwrap();
1505
1506        assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1507        assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1508        assert_eq!(domain.resources.thp, TransparentHugePagePolicy::Madvise);
1509        match domain.image {
1510            RootfsSource::Oci(oci) => {
1511                assert_eq!(oci.reference, "python:3.12");
1512                assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1513            }
1514            other => panic!("expected OCI rootfs, got {other:?}"),
1515        }
1516    }
1517
1518    #[test]
1519    fn cloud_resources_do_not_carry_host_runtime_policy() {
1520        let mut domain = SandboxSpec::try_from(CloudCreateSandboxRequest {
1521            spec: spec("agent-1"),
1522        })
1523        .unwrap();
1524        domain.resources.cpu_placement = CpuPlacement::Spread;
1525        domain.resources.placement_profile = Some("locality".into());
1526        domain.resources.thp = TransparentHugePagePolicy::Always;
1527
1528        let cloud = CloudSandboxSpec::from(domain);
1529        let wire = serde_json::to_value(&cloud.resources).unwrap();
1530        assert!(wire.get("cpu_placement").is_none());
1531        assert!(wire.get("placement_profile").is_none());
1532        assert!(wire.get("thp").is_none());
1533
1534        let round_trip = SandboxSpec::try_from(cloud).unwrap();
1535        assert_eq!(round_trip.resources.cpu_placement, CpuPlacement::Inherit);
1536        assert!(round_trip.resources.placement_profile.is_none());
1537        assert_eq!(round_trip.resources.thp, TransparentHugePagePolicy::Madvise);
1538    }
1539
1540    #[test]
1541    fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1542        let mut req = CloudCreateSandboxRequest {
1543            spec: spec("agent-1"),
1544        };
1545        req.spec.image = CloudRootfsSource::Bind {
1546            path: "/tmp/rootfs".into(),
1547        };
1548        req.spec.resources.disk_size_mib = Some(8192);
1549
1550        let err = SandboxSpec::try_from(req).unwrap_err();
1551
1552        assert!(err.to_string().contains("disk_size_mib"));
1553    }
1554
1555    #[test]
1556    fn domain_spec_converts_oci_size_to_cloud_resources() {
1557        let domain = SandboxSpec {
1558            name: "agent-1".into(),
1559            image: RootfsSource::Oci(OciRootfsSource {
1560                reference: "python:3.12".into(),
1561                root_disk: Some(RootDisk::managed(8192)),
1562            }),
1563            deployment_profile: DeploymentProfile::MultiTenant,
1564            ..Default::default()
1565        };
1566
1567        let req = CloudCreateSandboxRequest::from(domain);
1568
1569        // The hosting platform, not a tenant create request, selects the
1570        // effective deployment profile.
1571        assert!(
1572            serde_json::to_value(&req)
1573                .unwrap()
1574                .get("deployment_profile")
1575                .is_none()
1576        );
1577
1578        assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1579        match req.spec.image {
1580            CloudRootfsSource::Oci { reference } => {
1581                assert_eq!(reference, "python:3.12");
1582            }
1583            other => panic!("expected OCI rootfs, got {other:?}"),
1584        }
1585    }
1586
1587    #[test]
1588    fn create_request_minimal_defaults() {
1589        // Only the spec's name + image are set; everything else defaults.
1590        let req = CloudCreateSandboxRequest {
1591            spec: spec("agent-1"),
1592        };
1593        let json = serde_json::to_value(&req).unwrap();
1594        let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1595        assert_eq!(back.spec.name, "agent-1");
1596    }
1597
1598    #[test]
1599    fn sandbox_response_accepts_curated_optional_spec() {
1600        let sb = CloudCreateSandboxResponse {
1601            id: "00000000-0000-0000-0000-000000000002".into(),
1602            org_id: "00000000-0000-0000-0000-000000000001".into(),
1603            name: "agent-1".into(),
1604            slug: "brave-otter".into(),
1605            status: CloudSandboxStatus::Created,
1606            status_reason: None,
1607            spec: Some(serde_json::json!({
1608                "image": "python:3.12",
1609                "resources": { "vcpus": 2, "memory_mib": 1024 },
1610            })),
1611            ephemeral: true,
1612            created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1613            started_at: None,
1614            stopped_at: None,
1615            last_failure_message: None,
1616        };
1617        let json = serde_json::to_value(&sb).unwrap();
1618        assert_eq!(json["slug"], "brave-otter");
1619        assert_eq!(json["name"], "agent-1");
1620
1621        let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1622        assert_eq!(back.slug, "brave-otter");
1623        assert_eq!(back.status, CloudSandboxStatus::Created);
1624        assert_eq!(back.spec.as_ref().unwrap()["image"], "python:3.12");
1625        assert!(back.started_at.is_none());
1626    }
1627
1628    #[test]
1629    fn sandbox_response_accepts_omitted_spec() {
1630        let json = serde_json::json!({
1631            "id": "00000000-0000-0000-0000-000000000002",
1632            "org_id": "00000000-0000-0000-0000-000000000001",
1633            "name": "agent-1",
1634            "slug": "brave-otter",
1635            "status": "running",
1636            "ephemeral": false,
1637            "created_at": "2026-05-17T12:00:00Z",
1638            "started_at": "2026-05-17T12:00:01Z",
1639            "stopped_at": null,
1640            "last_failure_message": null
1641        });
1642
1643        let response: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1644
1645        assert!(response.spec.is_none());
1646        assert_eq!(response.status, CloudSandboxStatus::Running);
1647    }
1648}