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 };
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 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 pub fn oci(reference: impl Into<String>) -> Self {
1124 Self::Oci {
1125 reference: reference.into(),
1126 }
1127 }
1128
1129 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#[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 #[serde(default)]
1155 pub entries: Vec<CloudSecretEntry>,
1156 #[serde(default)]
1158 pub on_violation: CloudViolationAction,
1159}
1160
1161#[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 pub env_var: String,
1168 #[serde(default)]
1170 pub value: String,
1171 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub source: Option<CloudSecretSource>,
1174 pub placeholder: String,
1176 #[serde(default)]
1178 pub allowed_hosts: Vec<CloudHostPattern>,
1179 #[serde(default)]
1181 pub injection: SecretInjection,
1182 #[serde(default, skip_serializing_if = "Option::is_none")]
1184 pub on_violation: Option<CloudViolationAction>,
1185 #[serde(default = "cloud_default_true")]
1187 pub require_tls_identity: bool,
1188}
1189
1190#[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 Env {
1198 var: String,
1200 },
1201 Store {
1203 reference: String,
1205 },
1206}
1207
1208#[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 {
1217 value: String,
1219 },
1220 Wildcard {
1222 value: String,
1224 },
1225 Any,
1227}
1228
1229#[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,
1238 #[default]
1240 BlockAndLog,
1241 BlockAndTerminate,
1243 Passthrough {
1245 hosts: Vec<CloudHostPattern>,
1247 },
1248}
1249
1250fn cloud_default_true() -> bool {
1251 true
1252}
1253
1254impl 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#[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 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 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 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 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}