1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
38pub struct CloudCreateSandboxRequest {
39 #[serde(flatten)]
41 pub spec: CloudSandboxSpec,
42}
43
44#[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 pub name: String,
52
53 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
55 pub image: CloudRootfsSource,
56
57 pub resources: CloudSandboxResources,
59
60 pub runtime: CloudSandboxRuntimeOptions,
62
63 pub env: Vec<EnvVar>,
65
66 pub labels: BTreeMap<String, String>,
68
69 pub rlimits: Vec<CloudRlimit>,
71
72 pub mounts: Vec<CloudVolumeMount>,
74
75 pub patches: Vec<CloudPatch>,
77
78 pub network: CloudNetworkSpec,
80
81 pub init: Option<HandoffInit>,
83
84 pub pull_policy: CloudPullPolicy,
86
87 pub security_profile: SecurityProfile,
89
90 pub lifecycle: SandboxPolicy,
92}
93
94#[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 pub vcpus: u8,
102
103 pub memory_mib: u32,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub disk_size_mib: Option<u32>,
109}
110
111#[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 #[default]
126 IfMissing,
127 Always,
129 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#[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 Qcow2,
162 Raw,
164 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#[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 Cpu,
197 Fsize,
199 Data,
201 Stack,
203 Core,
205 Rss,
207 Nproc,
209 Nofile,
211 Memlock,
213 As,
215 Locks,
217 Sigpending,
219 Msgqueue,
221 Nice,
223 Rtprio,
225 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#[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 pub resource: CloudRlimitResource,
282 pub soft: u64,
284 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#[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 Text {
317 path: String,
319 content: String,
321 mode: Option<u32>,
323 replace: bool,
325 },
326 File {
328 path: String,
330 content: Vec<u8>,
332 mode: Option<u32>,
334 replace: bool,
336 },
337 CopyFile {
339 #[cfg_attr(feature = "ts", ts(type = "string"))]
341 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
342 src: PathBuf,
343 dst: String,
345 mode: Option<u32>,
347 replace: bool,
349 },
350 CopyDir {
352 #[cfg_attr(feature = "ts", ts(type = "string"))]
354 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
355 src: PathBuf,
356 dst: String,
358 replace: bool,
360 },
361 Symlink {
363 target: String,
365 link: String,
367 replace: bool,
369 },
370 Mkdir {
372 path: String,
374 mode: Option<u32>,
376 },
377 Remove {
379 path: String,
381 },
382 Append {
384 path: String,
386 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#[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 Bind {
509 #[cfg_attr(feature = "ts", ts(type = "string"))]
511 path: PathBuf,
512 },
513
514 Oci {
516 reference: String,
518 },
519
520 DiskImage {
522 #[cfg_attr(feature = "ts", ts(type = "string"))]
524 path: PathBuf,
525 format: CloudDiskImageFormat,
527 fstype: Option<String>,
529 },
530}
531
532#[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 {
541 #[cfg_attr(feature = "ts", ts(type = "string"))]
543 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
544 host: PathBuf,
545 guest: String,
547 #[serde(default)]
549 options: MountOptions,
550 #[serde(default = "default_strict")]
552 stat_virtualization: StatVirtualization,
553 #[serde(default = "default_private")]
555 host_permissions: HostPermissions,
556 #[serde(default)]
558 quota_mib: Option<u32>,
559 },
560
561 Named {
563 name: String,
565 guest: String,
567 #[serde(default)]
569 options: MountOptions,
570 #[serde(default = "default_strict")]
572 stat_virtualization: StatVirtualization,
573 #[serde(default = "default_private")]
575 host_permissions: HostPermissions,
576 },
577
578 Tmpfs {
580 guest: String,
582 #[serde(default)]
584 size_mib: Option<u32>,
585 #[serde(default)]
587 options: MountOptions,
588 },
589
590 DiskImage {
592 #[cfg_attr(feature = "ts", ts(type = "string"))]
594 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
595 host: PathBuf,
596 guest: String,
598 format: CloudDiskImageFormat,
600 #[serde(default)]
602 fstype: Option<String>,
603 #[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 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#[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 pub enabled: bool,
742
743 #[serde(skip_serializing_if = "Option::is_none")]
745 pub policy: Option<NetworkPolicy>,
746
747 #[serde(skip_serializing_if = "Option::is_none")]
749 pub secrets: Option<CloudSecretsConfig>,
750
751 #[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#[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 pub workdir: Option<String>,
777
778 pub shell: Option<String>,
780
781 pub scripts: BTreeMap<String, String>,
783
784 pub entrypoint: Option<Vec<String>>,
786
787 pub cmd: Option<Vec<String>>,
789
790 pub user: Option<String>,
792
793 pub log_level: Option<SandboxLogLevel>,
795}
796
797#[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 pub id: String,
808 pub org_id: String,
810 pub name: String,
812 pub slug: String,
814 pub status: CloudSandboxStatus,
816 #[serde(default)]
819 pub status_reason: Option<CloudSandboxStatusReason>,
820 #[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 pub ephemeral: bool,
828 #[cfg_attr(feature = "ts", ts(type = "string"))]
830 pub created_at: DateTime<Utc>,
831 #[serde(default)]
833 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
834 pub started_at: Option<DateTime<Utc>>,
835 #[serde(default)]
837 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
838 pub stopped_at: Option<DateTime<Utc>>,
839 #[serde(default)]
841 pub last_failure_message: Option<String>,
842}
843
844#[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,
852 Starting,
854 Running,
856 Stopping,
858 Stopped,
860 Failed,
862}
863
864#[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 Scheduling,
873 InsufficientCapacity,
876}
877
878#[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 pub data: Vec<T>,
885 #[serde(default)]
887 pub next_cursor: Option<String>,
888}
889
890#[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 pub message: String,
897}
898
899#[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 #[serde(default)]
906 pub code: Option<String>,
907 #[serde(default)]
909 pub message: Option<String>,
910 #[serde(default)]
912 pub error: Option<CloudErrorDetails>,
913}
914
915#[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 #[serde(default)]
922 pub code: Option<String>,
923 #[serde(default)]
925 pub message: Option<String>,
926}
927
928impl 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 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 max_cpus: spec.resources.vcpus,
981 max_memory_mib: spec.resources.memory_mib,
982 cpu_placement: CpuPlacement::Inherit,
986 placement_profile: None,
987 thp: TransparentHugePagePolicy::Madvise,
988 };
989
990 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 outbound_proxy: None,
1005 };
1006 let runtime = SandboxRuntimeOptions {
1007 workdir: spec.runtime.workdir,
1008 shell: spec.runtime.shell,
1009 scripts: spec.runtime.scripts,
1010 entrypoint: spec.runtime.entrypoint,
1011 cmd: spec.runtime.cmd,
1012 hostname: None,
1013 user: spec.runtime.user,
1014 log_level: spec.runtime.log_level,
1015 metrics_sample_interval_ms: None,
1016 disable_metrics_sample: false,
1017 };
1018
1019 Ok(Self {
1020 name: spec.name,
1021 image,
1022 resources,
1023 runtime,
1024 env: spec.env,
1025 labels: spec.labels,
1026 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1027 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1028 patches: spec.patches.into_iter().map(Into::into).collect(),
1029 network,
1030 vsock: VsockSpec::default(),
1031 init: spec.init,
1032 pull_policy: spec.pull_policy.into(),
1033 security_profile: spec.security_profile,
1034 deployment_profile: DeploymentProfile::default(),
1035 lifecycle: spec.lifecycle,
1036 })
1037 }
1038}
1039
1040impl From<SandboxSpec> for CloudCreateSandboxRequest {
1041 fn from(spec: SandboxSpec) -> Self {
1042 Self { spec: spec.into() }
1043 }
1044}
1045
1046impl From<SandboxSpec> for CloudSandboxSpec {
1047 fn from(spec: SandboxSpec) -> Self {
1048 let (image, disk_size_mib) = match spec.image {
1049 RootfsSource::Oci(oci) => (
1052 CloudRootfsSource::Oci {
1053 reference: oci.reference,
1054 },
1055 match &oci.root_disk {
1056 Some(RootDisk::Managed { size_mib }) => *size_mib,
1057 _ => None,
1058 },
1059 ),
1060 RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1061 RootfsSource::DiskImage {
1062 path,
1063 format,
1064 fstype,
1065 } => (
1066 CloudRootfsSource::DiskImage {
1067 path,
1068 format: format.into(),
1069 fstype,
1070 },
1071 None,
1072 ),
1073 };
1074
1075 Self {
1076 name: spec.name,
1077 image,
1078 resources: CloudSandboxResources {
1079 vcpus: spec.resources.cpus,
1080 memory_mib: spec.resources.memory_mib,
1081 disk_size_mib,
1082 },
1083 runtime: CloudSandboxRuntimeOptions {
1084 workdir: spec.runtime.workdir,
1085 shell: spec.runtime.shell,
1086 scripts: spec.runtime.scripts,
1087 entrypoint: spec.runtime.entrypoint,
1088 cmd: spec.runtime.cmd,
1089 user: spec.runtime.user,
1090 log_level: spec.runtime.log_level,
1091 },
1092 env: spec.env,
1093 labels: spec.labels,
1094 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1095 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1096 patches: spec.patches.into_iter().map(Into::into).collect(),
1097 network: CloudNetworkSpec {
1098 enabled: spec.network.enabled,
1099 policy: spec.network.policy,
1100 secrets: spec.network.secrets.map(Into::into),
1101 max_connections: spec.network.max_connections,
1102 },
1103 init: spec.init,
1104 pull_policy: spec.pull_policy.into(),
1105 security_profile: spec.security_profile,
1106 lifecycle: spec.lifecycle,
1107 }
1108 }
1109}
1110
1111impl Default for CloudSandboxResources {
1112 fn default() -> Self {
1113 let resources = SandboxResources::default();
1114 Self {
1115 vcpus: resources.cpus,
1116 memory_mib: resources.memory_mib,
1117 disk_size_mib: None,
1118 }
1119 }
1120}
1121
1122impl CloudRootfsSource {
1123 pub fn oci(reference: impl Into<String>) -> Self {
1125 Self::Oci {
1126 reference: reference.into(),
1127 }
1128 }
1129
1130 pub fn oci_reference(&self) -> Option<&str> {
1132 match self {
1133 Self::Oci { reference } => Some(reference),
1134 _ => None,
1135 }
1136 }
1137}
1138
1139impl Default for CloudRootfsSource {
1140 fn default() -> Self {
1141 Self::oci(String::new())
1142 }
1143}
1144
1145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1151#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1152#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1153pub struct CloudSecretsConfig {
1154 #[serde(default)]
1156 pub entries: Vec<CloudSecretEntry>,
1157 #[serde(default)]
1159 pub on_violation: CloudViolationAction,
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1164#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1165#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1166pub struct CloudSecretEntry {
1167 pub env_var: String,
1169 #[serde(default)]
1171 pub value: String,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub source: Option<CloudSecretSource>,
1175 pub placeholder: String,
1177 #[serde(default)]
1179 pub allowed_hosts: Vec<CloudHostPattern>,
1180 #[serde(default)]
1182 pub injection: SecretInjection,
1183 #[serde(default, skip_serializing_if = "Option::is_none")]
1185 pub on_violation: Option<CloudViolationAction>,
1186 #[serde(default = "cloud_default_true")]
1188 pub require_tls_identity: bool,
1189}
1190
1191#[derive(Debug, Clone, Serialize, Deserialize)]
1193#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1194#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1195#[serde(tag = "type", rename_all = "snake_case")]
1196pub enum CloudSecretSource {
1197 Env {
1199 var: String,
1201 },
1202 Store {
1204 reference: String,
1206 },
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1212#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1213#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1214#[serde(tag = "type", rename_all = "snake_case")]
1215pub enum CloudHostPattern {
1216 Exact {
1218 value: String,
1220 },
1221 Wildcard {
1223 value: String,
1225 },
1226 Any,
1228}
1229
1230#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1233#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1234#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1235#[serde(tag = "type", rename_all = "snake_case")]
1236pub enum CloudViolationAction {
1237 Block,
1239 #[default]
1241 BlockAndLog,
1242 BlockAndTerminate,
1244 Passthrough {
1246 hosts: Vec<CloudHostPattern>,
1248 },
1249}
1250
1251fn cloud_default_true() -> bool {
1252 true
1253}
1254
1255impl From<HostPattern> for CloudHostPattern {
1260 fn from(pattern: HostPattern) -> Self {
1261 match pattern {
1262 HostPattern::Exact(value) => Self::Exact { value },
1263 HostPattern::Wildcard(value) => Self::Wildcard { value },
1264 HostPattern::Any => Self::Any,
1265 }
1266 }
1267}
1268
1269impl From<CloudHostPattern> for HostPattern {
1270 fn from(pattern: CloudHostPattern) -> Self {
1271 match pattern {
1272 CloudHostPattern::Exact { value } => Self::Exact(value),
1273 CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1274 CloudHostPattern::Any => Self::Any,
1275 }
1276 }
1277}
1278
1279impl From<ViolationAction> for CloudViolationAction {
1280 fn from(action: ViolationAction) -> Self {
1281 match action {
1282 ViolationAction::Block => Self::Block,
1283 ViolationAction::BlockAndLog => Self::BlockAndLog,
1284 ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1285 ViolationAction::Passthrough(hosts) => Self::Passthrough {
1286 hosts: hosts.into_iter().map(Into::into).collect(),
1287 },
1288 }
1289 }
1290}
1291
1292impl From<CloudViolationAction> for ViolationAction {
1293 fn from(action: CloudViolationAction) -> Self {
1294 match action {
1295 CloudViolationAction::Block => Self::Block,
1296 CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1297 CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1298 CloudViolationAction::Passthrough { hosts } => {
1299 Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1300 }
1301 }
1302 }
1303}
1304
1305impl From<SecretSource> for CloudSecretSource {
1306 fn from(source: SecretSource) -> Self {
1307 match source {
1308 SecretSource::Env { var } => Self::Env { var },
1309 SecretSource::Store { reference } => Self::Store { reference },
1310 }
1311 }
1312}
1313
1314impl From<CloudSecretSource> for SecretSource {
1315 fn from(source: CloudSecretSource) -> Self {
1316 match source {
1317 CloudSecretSource::Env { var } => Self::Env { var },
1318 CloudSecretSource::Store { reference } => Self::Store { reference },
1319 }
1320 }
1321}
1322
1323impl From<SecretEntry> for CloudSecretEntry {
1324 fn from(entry: SecretEntry) -> Self {
1325 Self {
1326 env_var: entry.env_var,
1327 value: entry.value.to_string(),
1328 source: entry.source.map(Into::into),
1329 placeholder: entry.placeholder,
1330 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1331 injection: entry.injection,
1332 on_violation: entry.on_violation.map(Into::into),
1333 require_tls_identity: entry.require_tls_identity,
1334 }
1335 }
1336}
1337
1338impl From<CloudSecretEntry> for SecretEntry {
1339 fn from(entry: CloudSecretEntry) -> Self {
1340 Self {
1341 env_var: entry.env_var,
1342 value: Zeroizing::new(entry.value),
1343 source: entry.source.map(Into::into),
1344 placeholder: entry.placeholder,
1345 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1346 injection: entry.injection,
1347 on_violation: entry.on_violation.map(Into::into),
1348 require_tls_identity: entry.require_tls_identity,
1349 }
1350 }
1351}
1352
1353impl From<SecretsConfig> for CloudSecretsConfig {
1354 fn from(config: SecretsConfig) -> Self {
1355 Self {
1356 entries: config.secrets.into_iter().map(Into::into).collect(),
1357 on_violation: config.on_violation.into(),
1358 }
1359 }
1360}
1361
1362impl From<CloudSecretsConfig> for SecretsConfig {
1363 fn from(config: CloudSecretsConfig) -> Self {
1364 Self {
1365 secrets: config.entries.into_iter().map(Into::into).collect(),
1366 on_violation: config.on_violation.into(),
1367 }
1368 }
1369}
1370
1371#[cfg(test)]
1376mod tests {
1377 use super::*;
1378 use crate::domain::{
1379 DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1380 };
1381
1382 fn spec(name: &str) -> CloudSandboxSpec {
1383 CloudSandboxSpec {
1384 name: name.into(),
1385 image: CloudRootfsSource::Oci {
1386 reference: "python:3.12".into(),
1387 },
1388 ..Default::default()
1389 }
1390 }
1391
1392 #[test]
1393 fn create_request_flattens_spec() {
1394 let req = CloudCreateSandboxRequest {
1395 spec: spec("agent-1"),
1396 };
1397 let json = serde_json::to_value(&req).unwrap();
1398 assert_eq!(json["name"], "agent-1");
1400 assert!(json.get("image").is_some());
1401 assert!(json.get("deployment_profile").is_none());
1402
1403 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1404 assert_eq!(back.spec.name, "agent-1");
1405 }
1406
1407 #[test]
1408 fn cloud_network_rejects_rate_limit_configuration() {
1409 let error = serde_json::from_value::<CloudNetworkSpec>(serde_json::json!({
1410 "rate_limiter": {
1411 "egress": {
1412 "bandwidth": {"size": 1024, "refill_time_ms": 1000}
1413 }
1414 }
1415 }))
1416 .unwrap_err();
1417
1418 assert!(error.to_string().contains("unknown field `rate_limiter`"));
1419 }
1420
1421 #[test]
1422 fn cloud_rootfs_source_uses_internal_tagging() {
1423 let json = serde_json::to_value(CloudRootfsSource::Oci {
1424 reference: "python:3.12".into(),
1425 })
1426 .unwrap();
1427 assert_eq!(
1428 json,
1429 serde_json::json!({"type": "oci", "reference": "python:3.12"})
1430 );
1431
1432 let bind = serde_json::to_value(CloudRootfsSource::Bind {
1433 path: "/host".into(),
1434 })
1435 .unwrap();
1436 assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1437
1438 let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1439 assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1440 }
1441
1442 #[test]
1443 fn cloud_secret_twins_use_internal_tagging() {
1444 assert_eq!(
1446 serde_json::to_value(CloudHostPattern::Exact {
1447 value: "api.example.com".into(),
1448 })
1449 .unwrap(),
1450 serde_json::json!({"type": "exact", "value": "api.example.com"})
1451 );
1452 assert_eq!(
1453 serde_json::to_value(CloudSecretSource::Env {
1454 var: "OPENAI".into()
1455 })
1456 .unwrap(),
1457 serde_json::json!({"type": "env", "var": "OPENAI"})
1458 );
1459 assert_eq!(
1460 serde_json::to_value(CloudViolationAction::Passthrough {
1461 hosts: vec![CloudHostPattern::Any],
1462 })
1463 .unwrap(),
1464 serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1465 );
1466 }
1467
1468 #[test]
1469 fn cloud_secrets_config_round_trips_through_domain() {
1470 let cloud = CloudSecretsConfig {
1471 entries: vec![CloudSecretEntry {
1472 env_var: "OPENAI_API_KEY".into(),
1473 value: "sk-x".into(),
1474 source: Some(CloudSecretSource::Env {
1475 var: "OPENAI".into(),
1476 }),
1477 placeholder: "$MSB_OPENAI".into(),
1478 allowed_hosts: vec![CloudHostPattern::Exact {
1479 value: "api.openai.com".into(),
1480 }],
1481 injection: SecretInjection::default(),
1482 on_violation: Some(CloudViolationAction::BlockAndTerminate),
1483 require_tls_identity: true,
1484 }],
1485 on_violation: CloudViolationAction::BlockAndLog,
1486 };
1487
1488 let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1489 assert_eq!(back.entries.len(), 1);
1490 assert_eq!(back.entries[0].value, "sk-x");
1491 assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1492 assert!(matches!(
1493 back.entries[0].on_violation,
1494 Some(CloudViolationAction::BlockAndTerminate)
1495 ));
1496 }
1497
1498 #[test]
1499 fn create_request_converts_disk_size_to_oci_rootfs() {
1500 let mut req = CloudCreateSandboxRequest {
1501 spec: spec("agent-1"),
1502 };
1503 req.spec.resources.disk_size_mib = Some(8192);
1504
1505 let domain = SandboxSpec::try_from(req).unwrap();
1506
1507 assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1508 assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1509 assert_eq!(domain.resources.thp, TransparentHugePagePolicy::Madvise);
1510 match domain.image {
1511 RootfsSource::Oci(oci) => {
1512 assert_eq!(oci.reference, "python:3.12");
1513 assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1514 }
1515 other => panic!("expected OCI rootfs, got {other:?}"),
1516 }
1517 }
1518
1519 #[test]
1520 fn cloud_resources_do_not_carry_host_runtime_policy() {
1521 let mut domain = SandboxSpec::try_from(CloudCreateSandboxRequest {
1522 spec: spec("agent-1"),
1523 })
1524 .unwrap();
1525 domain.resources.cpu_placement = CpuPlacement::Spread;
1526 domain.resources.placement_profile = Some("locality".into());
1527 domain.resources.thp = TransparentHugePagePolicy::Always;
1528
1529 let cloud = CloudSandboxSpec::from(domain);
1530 let wire = serde_json::to_value(&cloud.resources).unwrap();
1531 assert!(wire.get("cpu_placement").is_none());
1532 assert!(wire.get("placement_profile").is_none());
1533 assert!(wire.get("thp").is_none());
1534
1535 let round_trip = SandboxSpec::try_from(cloud).unwrap();
1536 assert_eq!(round_trip.resources.cpu_placement, CpuPlacement::Inherit);
1537 assert!(round_trip.resources.placement_profile.is_none());
1538 assert_eq!(round_trip.resources.thp, TransparentHugePagePolicy::Madvise);
1539 }
1540
1541 #[test]
1542 fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1543 let mut req = CloudCreateSandboxRequest {
1544 spec: spec("agent-1"),
1545 };
1546 req.spec.image = CloudRootfsSource::Bind {
1547 path: "/tmp/rootfs".into(),
1548 };
1549 req.spec.resources.disk_size_mib = Some(8192);
1550
1551 let err = SandboxSpec::try_from(req).unwrap_err();
1552
1553 assert!(err.to_string().contains("disk_size_mib"));
1554 }
1555
1556 #[test]
1557 fn domain_spec_converts_oci_size_to_cloud_resources() {
1558 let domain = SandboxSpec {
1559 name: "agent-1".into(),
1560 image: RootfsSource::Oci(OciRootfsSource {
1561 reference: "python:3.12".into(),
1562 root_disk: Some(RootDisk::managed(8192)),
1563 }),
1564 deployment_profile: DeploymentProfile::MultiTenant,
1565 ..Default::default()
1566 };
1567
1568 let req = CloudCreateSandboxRequest::from(domain);
1569
1570 assert!(
1573 serde_json::to_value(&req)
1574 .unwrap()
1575 .get("deployment_profile")
1576 .is_none()
1577 );
1578
1579 assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1580 match req.spec.image {
1581 CloudRootfsSource::Oci { reference } => {
1582 assert_eq!(reference, "python:3.12");
1583 }
1584 other => panic!("expected OCI rootfs, got {other:?}"),
1585 }
1586 }
1587
1588 #[test]
1589 fn create_request_minimal_defaults() {
1590 let req = CloudCreateSandboxRequest {
1592 spec: spec("agent-1"),
1593 };
1594 let json = serde_json::to_value(&req).unwrap();
1595 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1596 assert_eq!(back.spec.name, "agent-1");
1597 }
1598
1599 #[test]
1600 fn sandbox_response_accepts_curated_optional_spec() {
1601 let sb = CloudCreateSandboxResponse {
1602 id: "00000000-0000-0000-0000-000000000002".into(),
1603 org_id: "00000000-0000-0000-0000-000000000001".into(),
1604 name: "agent-1".into(),
1605 slug: "brave-otter".into(),
1606 status: CloudSandboxStatus::Created,
1607 status_reason: None,
1608 spec: Some(serde_json::json!({
1609 "image": "python:3.12",
1610 "resources": { "vcpus": 2, "memory_mib": 1024 },
1611 })),
1612 ephemeral: true,
1613 created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1614 started_at: None,
1615 stopped_at: None,
1616 last_failure_message: None,
1617 };
1618 let json = serde_json::to_value(&sb).unwrap();
1619 assert_eq!(json["slug"], "brave-otter");
1620 assert_eq!(json["name"], "agent-1");
1621
1622 let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1623 assert_eq!(back.slug, "brave-otter");
1624 assert_eq!(back.status, CloudSandboxStatus::Created);
1625 assert_eq!(back.spec.as_ref().unwrap()["image"], "python:3.12");
1626 assert!(back.started_at.is_none());
1627 }
1628
1629 #[test]
1630 fn sandbox_response_accepts_omitted_spec() {
1631 let json = serde_json::json!({
1632 "id": "00000000-0000-0000-0000-000000000002",
1633 "org_id": "00000000-0000-0000-0000-000000000001",
1634 "name": "agent-1",
1635 "slug": "brave-otter",
1636 "status": "running",
1637 "ephemeral": false,
1638 "created_at": "2026-05-17T12:00:00Z",
1639 "started_at": "2026-05-17T12:00:01Z",
1640 "stopped_at": null,
1641 "last_failure_message": null
1642 });
1643
1644 let response: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1645
1646 assert!(response.spec.is_none());
1647 assert_eq!(response.status, CloudSandboxStatus::Running);
1648 }
1649}