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 pub strict: bool,
753
754 #[serde(skip_serializing_if = "Option::is_none")]
756 pub max_connections: Option<usize>,
757}
758
759impl Default for CloudNetworkSpec {
760 fn default() -> Self {
761 Self {
762 enabled: true,
763 policy: None,
764 secrets: None,
765 strict: false,
766 max_connections: None,
767 }
768 }
769}
770
771#[derive(Debug, Clone, Default, Serialize, Deserialize)]
775#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
776#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
777#[serde(default, deny_unknown_fields)]
778pub struct CloudSandboxRuntimeOptions {
779 pub workdir: Option<String>,
781
782 pub shell: Option<String>,
784
785 pub scripts: BTreeMap<String, String>,
787
788 pub entrypoint: Option<Vec<String>>,
790
791 pub cmd: Option<Vec<String>>,
793
794 pub user: Option<String>,
796
797 pub log_level: Option<SandboxLogLevel>,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize)]
807#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
808#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
809pub struct CloudCreateSandboxResponse {
810 pub id: String,
812 pub org_id: String,
814 pub name: String,
816 pub slug: String,
818 pub status: CloudSandboxStatus,
820 #[serde(default)]
823 pub status_reason: Option<CloudSandboxStatusReason>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
828 #[cfg_attr(feature = "ts", ts(type = "unknown | null | undefined"))]
829 pub spec: Option<serde_json::Value>,
830 pub ephemeral: bool,
832 #[cfg_attr(feature = "ts", ts(type = "string"))]
834 pub created_at: DateTime<Utc>,
835 #[serde(default)]
837 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
838 pub started_at: Option<DateTime<Utc>>,
839 #[serde(default)]
841 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
842 pub stopped_at: Option<DateTime<Utc>>,
843 #[serde(default)]
845 pub last_failure_message: Option<String>,
846}
847
848#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
850#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
851#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
852#[serde(rename_all = "snake_case")]
853pub enum CloudSandboxStatus {
854 Created,
856 Starting,
858 Running,
860 Stopping,
862 Stopped,
864 Failed,
866}
867
868#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
871#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
872#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
873#[serde(rename_all = "snake_case")]
874pub enum CloudSandboxStatusReason {
875 Scheduling,
877 InsufficientCapacity,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
884#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
885#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
886pub struct CloudPaginated<T> {
887 pub data: Vec<T>,
889 #[serde(default)]
891 pub next_cursor: Option<String>,
892}
893
894#[derive(Debug, Clone, Serialize, Deserialize)]
896#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
897#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
898pub struct CloudMessageResponse {
899 pub message: String,
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize)]
905#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
906#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
907pub struct CloudErrorBody {
908 #[serde(default)]
910 pub code: Option<String>,
911 #[serde(default)]
913 pub message: Option<String>,
914 #[serde(default)]
916 pub error: Option<CloudErrorDetails>,
917}
918
919#[derive(Debug, Clone, Serialize, Deserialize)]
921#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
922#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
923pub struct CloudErrorDetails {
924 #[serde(default)]
926 pub code: Option<String>,
927 #[serde(default)]
929 pub message: Option<String>,
930}
931
932impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
937 type Error = TypesError;
938
939 fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
940 req.spec.try_into()
941 }
942}
943
944impl TryFrom<CloudSandboxSpec> for SandboxSpec {
945 type Error = TypesError;
946
947 fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
948 let disk_size_mib = spec.resources.disk_size_mib;
949 let image = match spec.image {
950 CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
953 reference,
954 root_disk: disk_size_mib.map(RootDisk::managed),
955 }),
956 CloudRootfsSource::Bind { .. } | CloudRootfsSource::DiskImage { .. }
957 if disk_size_mib.is_some() =>
958 {
959 return Err(TypesError::invalid_config(
960 "resources.disk_size_mib is only valid for OCI rootfs",
961 ));
962 }
963 CloudRootfsSource::Bind { path } => RootfsSource::Bind {
964 path,
965 follow_root_symlinks: false,
966 },
967 CloudRootfsSource::DiskImage {
968 path,
969 format,
970 fstype,
971 } => RootfsSource::DiskImage {
972 path,
973 format: format.into(),
974 fstype,
975 },
976 };
977
978 let resources = SandboxResources {
979 cpus: spec.resources.vcpus,
980 memory_mib: spec.resources.memory_mib,
981 max_cpus: spec.resources.vcpus,
985 max_memory_mib: spec.resources.memory_mib,
986 cpu_placement: CpuPlacement::Inherit,
990 placement_profile: None,
991 thp: TransparentHugePagePolicy::Madvise,
992 };
993
994 let network = NetworkSpec {
998 enabled: spec.network.enabled,
999 interface: None,
1000 ports: Vec::new(),
1001 policy: spec.network.policy,
1002 dns: None,
1003 tls: None,
1004 strict: spec.network.strict,
1005 secrets: spec.network.secrets.map(Into::into),
1006 max_connections: spec.network.max_connections,
1007 rate_limiter: None,
1008 trust_host_cas: false,
1009 outbound_proxy: None,
1010 };
1011 let runtime = SandboxRuntimeOptions {
1012 workdir: spec.runtime.workdir,
1013 shell: spec.runtime.shell,
1014 scripts: spec.runtime.scripts,
1015 entrypoint: spec.runtime.entrypoint,
1016 cmd: spec.runtime.cmd,
1017 hostname: None,
1018 user: spec.runtime.user,
1019 log_level: spec.runtime.log_level,
1020 metrics_sample_interval_ms: None,
1021 disable_metrics_sample: false,
1022 };
1023
1024 Ok(Self {
1025 name: spec.name,
1026 image,
1027 resources,
1028 runtime,
1029 env: spec.env,
1030 labels: spec.labels,
1031 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1032 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1033 patches: spec.patches.into_iter().map(Into::into).collect(),
1034 network,
1035 vsock: VsockSpec::default(),
1036 init: spec.init,
1037 pull_policy: spec.pull_policy.into(),
1038 security_profile: spec.security_profile,
1039 deployment_profile: DeploymentProfile::default(),
1040 lifecycle: spec.lifecycle,
1041 })
1042 }
1043}
1044
1045impl From<SandboxSpec> for CloudCreateSandboxRequest {
1046 fn from(spec: SandboxSpec) -> Self {
1047 Self { spec: spec.into() }
1048 }
1049}
1050
1051impl From<SandboxSpec> for CloudSandboxSpec {
1052 fn from(spec: SandboxSpec) -> Self {
1053 let (image, disk_size_mib) = match spec.image {
1054 RootfsSource::Oci(oci) => (
1057 CloudRootfsSource::Oci {
1058 reference: oci.reference,
1059 },
1060 match &oci.root_disk {
1061 Some(RootDisk::Managed { size_mib }) => *size_mib,
1062 _ => None,
1063 },
1064 ),
1065 RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1066 RootfsSource::DiskImage {
1067 path,
1068 format,
1069 fstype,
1070 } => (
1071 CloudRootfsSource::DiskImage {
1072 path,
1073 format: format.into(),
1074 fstype,
1075 },
1076 None,
1077 ),
1078 };
1079
1080 Self {
1081 name: spec.name,
1082 image,
1083 resources: CloudSandboxResources {
1084 vcpus: spec.resources.cpus,
1085 memory_mib: spec.resources.memory_mib,
1086 disk_size_mib,
1087 },
1088 runtime: CloudSandboxRuntimeOptions {
1089 workdir: spec.runtime.workdir,
1090 shell: spec.runtime.shell,
1091 scripts: spec.runtime.scripts,
1092 entrypoint: spec.runtime.entrypoint,
1093 cmd: spec.runtime.cmd,
1094 user: spec.runtime.user,
1095 log_level: spec.runtime.log_level,
1096 },
1097 env: spec.env,
1098 labels: spec.labels,
1099 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1100 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1101 patches: spec.patches.into_iter().map(Into::into).collect(),
1102 network: CloudNetworkSpec {
1103 enabled: spec.network.enabled,
1104 policy: spec.network.policy,
1105 secrets: spec.network.secrets.map(Into::into),
1106 strict: spec.network.strict,
1107 max_connections: spec.network.max_connections,
1108 },
1109 init: spec.init,
1110 pull_policy: spec.pull_policy.into(),
1111 security_profile: spec.security_profile,
1112 lifecycle: spec.lifecycle,
1113 }
1114 }
1115}
1116
1117impl Default for CloudSandboxResources {
1118 fn default() -> Self {
1119 let resources = SandboxResources::default();
1120 Self {
1121 vcpus: resources.cpus,
1122 memory_mib: resources.memory_mib,
1123 disk_size_mib: None,
1124 }
1125 }
1126}
1127
1128impl CloudRootfsSource {
1129 pub fn oci(reference: impl Into<String>) -> Self {
1131 Self::Oci {
1132 reference: reference.into(),
1133 }
1134 }
1135
1136 pub fn oci_reference(&self) -> Option<&str> {
1138 match self {
1139 Self::Oci { reference } => Some(reference),
1140 _ => None,
1141 }
1142 }
1143}
1144
1145impl Default for CloudRootfsSource {
1146 fn default() -> Self {
1147 Self::oci(String::new())
1148 }
1149}
1150
1151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1157#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1158#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1159pub struct CloudSecretsConfig {
1160 #[serde(default)]
1162 pub entries: Vec<CloudSecretEntry>,
1163 #[serde(default)]
1165 pub on_violation: CloudViolationAction,
1166}
1167
1168#[derive(Debug, Clone, Serialize, Deserialize)]
1170#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1171#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1172pub struct CloudSecretEntry {
1173 pub env_var: String,
1175 #[serde(default)]
1177 pub value: String,
1178 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub source: Option<CloudSecretSource>,
1181 pub placeholder: String,
1183 #[serde(default)]
1185 pub allowed_hosts: Vec<CloudHostPattern>,
1186 #[serde(default)]
1188 pub injection: SecretInjection,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1191 pub on_violation: Option<CloudViolationAction>,
1192 #[serde(default = "cloud_default_true")]
1194 pub require_tls_identity: bool,
1195}
1196
1197#[derive(Debug, Clone, Serialize, Deserialize)]
1199#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1200#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1201#[serde(tag = "type", rename_all = "snake_case")]
1202pub enum CloudSecretSource {
1203 Env {
1205 var: String,
1207 },
1208 Store {
1210 reference: String,
1212 },
1213}
1214
1215#[derive(Debug, Clone, Serialize, Deserialize)]
1218#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1220#[serde(tag = "type", rename_all = "snake_case")]
1221pub enum CloudHostPattern {
1222 Exact {
1224 value: String,
1226 },
1227 Wildcard {
1229 value: String,
1231 },
1232 Any,
1234}
1235
1236#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1239#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1240#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1241#[serde(tag = "type", rename_all = "snake_case")]
1242pub enum CloudViolationAction {
1243 Block,
1245 #[default]
1247 BlockAndLog,
1248 BlockAndTerminate,
1250 Passthrough {
1252 hosts: Vec<CloudHostPattern>,
1254 },
1255}
1256
1257fn cloud_default_true() -> bool {
1258 true
1259}
1260
1261impl From<HostPattern> for CloudHostPattern {
1266 fn from(pattern: HostPattern) -> Self {
1267 match pattern {
1268 HostPattern::Exact(value) => Self::Exact { value },
1269 HostPattern::Wildcard(value) => Self::Wildcard { value },
1270 HostPattern::Any => Self::Any,
1271 }
1272 }
1273}
1274
1275impl From<CloudHostPattern> for HostPattern {
1276 fn from(pattern: CloudHostPattern) -> Self {
1277 match pattern {
1278 CloudHostPattern::Exact { value } => Self::Exact(value),
1279 CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1280 CloudHostPattern::Any => Self::Any,
1281 }
1282 }
1283}
1284
1285impl From<ViolationAction> for CloudViolationAction {
1286 fn from(action: ViolationAction) -> Self {
1287 match action {
1288 ViolationAction::Block => Self::Block,
1289 ViolationAction::BlockAndLog => Self::BlockAndLog,
1290 ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1291 ViolationAction::Passthrough(hosts) => Self::Passthrough {
1292 hosts: hosts.into_iter().map(Into::into).collect(),
1293 },
1294 }
1295 }
1296}
1297
1298impl From<CloudViolationAction> for ViolationAction {
1299 fn from(action: CloudViolationAction) -> Self {
1300 match action {
1301 CloudViolationAction::Block => Self::Block,
1302 CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1303 CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1304 CloudViolationAction::Passthrough { hosts } => {
1305 Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1306 }
1307 }
1308 }
1309}
1310
1311impl From<SecretSource> for CloudSecretSource {
1312 fn from(source: SecretSource) -> Self {
1313 match source {
1314 SecretSource::Env { var } => Self::Env { var },
1315 SecretSource::Store { reference } => Self::Store { reference },
1316 }
1317 }
1318}
1319
1320impl From<CloudSecretSource> for SecretSource {
1321 fn from(source: CloudSecretSource) -> Self {
1322 match source {
1323 CloudSecretSource::Env { var } => Self::Env { var },
1324 CloudSecretSource::Store { reference } => Self::Store { reference },
1325 }
1326 }
1327}
1328
1329impl From<SecretEntry> for CloudSecretEntry {
1330 fn from(entry: SecretEntry) -> Self {
1331 Self {
1332 env_var: entry.env_var,
1333 value: entry.value.to_string(),
1334 source: entry.source.map(Into::into),
1335 placeholder: entry.placeholder,
1336 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1337 injection: entry.injection,
1338 on_violation: entry.on_violation.map(Into::into),
1339 require_tls_identity: entry.require_tls_identity,
1340 }
1341 }
1342}
1343
1344impl From<CloudSecretEntry> for SecretEntry {
1345 fn from(entry: CloudSecretEntry) -> Self {
1346 Self {
1347 env_var: entry.env_var,
1348 value: Zeroizing::new(entry.value),
1349 source: entry.source.map(Into::into),
1350 placeholder: entry.placeholder,
1351 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1352 injection: entry.injection,
1353 on_violation: entry.on_violation.map(Into::into),
1354 require_tls_identity: entry.require_tls_identity,
1355 }
1356 }
1357}
1358
1359impl From<SecretsConfig> for CloudSecretsConfig {
1360 fn from(config: SecretsConfig) -> Self {
1361 Self {
1362 entries: config.secrets.into_iter().map(Into::into).collect(),
1363 on_violation: config.on_violation.into(),
1364 }
1365 }
1366}
1367
1368impl From<CloudSecretsConfig> for SecretsConfig {
1369 fn from(config: CloudSecretsConfig) -> Self {
1370 Self {
1371 secrets: config.entries.into_iter().map(Into::into).collect(),
1372 on_violation: config.on_violation.into(),
1373 }
1374 }
1375}
1376
1377#[cfg(test)]
1382mod tests {
1383 use super::*;
1384 use crate::domain::{
1385 DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1386 };
1387
1388 fn spec(name: &str) -> CloudSandboxSpec {
1389 CloudSandboxSpec {
1390 name: name.into(),
1391 image: CloudRootfsSource::Oci {
1392 reference: "python:3.12".into(),
1393 },
1394 ..Default::default()
1395 }
1396 }
1397
1398 #[test]
1399 fn create_request_flattens_spec() {
1400 let req = CloudCreateSandboxRequest {
1401 spec: spec("agent-1"),
1402 };
1403 let json = serde_json::to_value(&req).unwrap();
1404 assert_eq!(json["name"], "agent-1");
1406 assert!(json.get("image").is_some());
1407 assert!(json.get("deployment_profile").is_none());
1408
1409 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1410 assert_eq!(back.spec.name, "agent-1");
1411 }
1412
1413 #[test]
1414 fn cloud_network_rejects_rate_limit_configuration() {
1415 let error = serde_json::from_value::<CloudNetworkSpec>(serde_json::json!({
1416 "rate_limiter": {
1417 "egress": {
1418 "bandwidth": {"size": 1024, "refill_time_ms": 1000}
1419 }
1420 }
1421 }))
1422 .unwrap_err();
1423
1424 assert!(error.to_string().contains("unknown field `rate_limiter`"));
1425 }
1426
1427 #[test]
1428 fn cloud_rootfs_source_uses_internal_tagging() {
1429 let json = serde_json::to_value(CloudRootfsSource::Oci {
1430 reference: "python:3.12".into(),
1431 })
1432 .unwrap();
1433 assert_eq!(
1434 json,
1435 serde_json::json!({"type": "oci", "reference": "python:3.12"})
1436 );
1437
1438 let bind = serde_json::to_value(CloudRootfsSource::Bind {
1439 path: "/host".into(),
1440 })
1441 .unwrap();
1442 assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1443
1444 let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1445 assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1446 }
1447
1448 #[test]
1449 fn cloud_secret_twins_use_internal_tagging() {
1450 assert_eq!(
1452 serde_json::to_value(CloudHostPattern::Exact {
1453 value: "api.example.com".into(),
1454 })
1455 .unwrap(),
1456 serde_json::json!({"type": "exact", "value": "api.example.com"})
1457 );
1458 assert_eq!(
1459 serde_json::to_value(CloudSecretSource::Env {
1460 var: "OPENAI".into()
1461 })
1462 .unwrap(),
1463 serde_json::json!({"type": "env", "var": "OPENAI"})
1464 );
1465 assert_eq!(
1466 serde_json::to_value(CloudViolationAction::Passthrough {
1467 hosts: vec![CloudHostPattern::Any],
1468 })
1469 .unwrap(),
1470 serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1471 );
1472 }
1473
1474 #[test]
1475 fn cloud_secrets_config_round_trips_through_domain() {
1476 let cloud = CloudSecretsConfig {
1477 entries: vec![CloudSecretEntry {
1478 env_var: "OPENAI_API_KEY".into(),
1479 value: "sk-x".into(),
1480 source: Some(CloudSecretSource::Env {
1481 var: "OPENAI".into(),
1482 }),
1483 placeholder: "$MSB_OPENAI".into(),
1484 allowed_hosts: vec![CloudHostPattern::Exact {
1485 value: "api.openai.com".into(),
1486 }],
1487 injection: SecretInjection::default(),
1488 on_violation: Some(CloudViolationAction::BlockAndTerminate),
1489 require_tls_identity: true,
1490 }],
1491 on_violation: CloudViolationAction::BlockAndLog,
1492 };
1493
1494 let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1495 assert_eq!(back.entries.len(), 1);
1496 assert_eq!(back.entries[0].value, "sk-x");
1497 assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1498 assert!(matches!(
1499 back.entries[0].on_violation,
1500 Some(CloudViolationAction::BlockAndTerminate)
1501 ));
1502 }
1503
1504 #[test]
1505 fn create_request_converts_disk_size_to_oci_rootfs() {
1506 let mut req = CloudCreateSandboxRequest {
1507 spec: spec("agent-1"),
1508 };
1509 req.spec.resources.disk_size_mib = Some(8192);
1510
1511 let domain = SandboxSpec::try_from(req).unwrap();
1512
1513 assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1514 assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1515 assert_eq!(domain.resources.thp, TransparentHugePagePolicy::Madvise);
1516 match domain.image {
1517 RootfsSource::Oci(oci) => {
1518 assert_eq!(oci.reference, "python:3.12");
1519 assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1520 }
1521 other => panic!("expected OCI rootfs, got {other:?}"),
1522 }
1523 }
1524
1525 #[test]
1526 fn cloud_resources_do_not_carry_host_runtime_policy() {
1527 let mut domain = SandboxSpec::try_from(CloudCreateSandboxRequest {
1528 spec: spec("agent-1"),
1529 })
1530 .unwrap();
1531 domain.resources.cpu_placement = CpuPlacement::Spread;
1532 domain.resources.placement_profile = Some("locality".into());
1533 domain.resources.thp = TransparentHugePagePolicy::Always;
1534
1535 let cloud = CloudSandboxSpec::from(domain);
1536 let wire = serde_json::to_value(&cloud.resources).unwrap();
1537 assert!(wire.get("cpu_placement").is_none());
1538 assert!(wire.get("placement_profile").is_none());
1539 assert!(wire.get("thp").is_none());
1540
1541 let round_trip = SandboxSpec::try_from(cloud).unwrap();
1542 assert_eq!(round_trip.resources.cpu_placement, CpuPlacement::Inherit);
1543 assert!(round_trip.resources.placement_profile.is_none());
1544 assert_eq!(round_trip.resources.thp, TransparentHugePagePolicy::Madvise);
1545 }
1546
1547 #[test]
1548 fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1549 let mut req = CloudCreateSandboxRequest {
1550 spec: spec("agent-1"),
1551 };
1552 req.spec.image = CloudRootfsSource::Bind {
1553 path: "/tmp/rootfs".into(),
1554 };
1555 req.spec.resources.disk_size_mib = Some(8192);
1556
1557 let err = SandboxSpec::try_from(req).unwrap_err();
1558
1559 assert!(err.to_string().contains("disk_size_mib"));
1560 }
1561
1562 #[test]
1563 fn domain_spec_converts_oci_size_to_cloud_resources() {
1564 let domain = SandboxSpec {
1565 name: "agent-1".into(),
1566 image: RootfsSource::Oci(OciRootfsSource {
1567 reference: "python:3.12".into(),
1568 root_disk: Some(RootDisk::managed(8192)),
1569 }),
1570 deployment_profile: DeploymentProfile::MultiTenant,
1571 ..Default::default()
1572 };
1573
1574 let req = CloudCreateSandboxRequest::from(domain);
1575
1576 assert!(
1579 serde_json::to_value(&req)
1580 .unwrap()
1581 .get("deployment_profile")
1582 .is_none()
1583 );
1584
1585 assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1586 match req.spec.image {
1587 CloudRootfsSource::Oci { reference } => {
1588 assert_eq!(reference, "python:3.12");
1589 }
1590 other => panic!("expected OCI rootfs, got {other:?}"),
1591 }
1592 }
1593
1594 #[test]
1595 fn create_request_minimal_defaults() {
1596 let req = CloudCreateSandboxRequest {
1598 spec: spec("agent-1"),
1599 };
1600 let json = serde_json::to_value(&req).unwrap();
1601 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1602 assert_eq!(back.spec.name, "agent-1");
1603 }
1604
1605 #[test]
1606 fn sandbox_response_accepts_curated_optional_spec() {
1607 let sb = CloudCreateSandboxResponse {
1608 id: "00000000-0000-0000-0000-000000000002".into(),
1609 org_id: "00000000-0000-0000-0000-000000000001".into(),
1610 name: "agent-1".into(),
1611 slug: "brave-otter".into(),
1612 status: CloudSandboxStatus::Created,
1613 status_reason: None,
1614 spec: Some(serde_json::json!({
1615 "image": "python:3.12",
1616 "resources": { "vcpus": 2, "memory_mib": 1024 },
1617 })),
1618 ephemeral: true,
1619 created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1620 started_at: None,
1621 stopped_at: None,
1622 last_failure_message: None,
1623 };
1624 let json = serde_json::to_value(&sb).unwrap();
1625 assert_eq!(json["slug"], "brave-otter");
1626 assert_eq!(json["name"], "agent-1");
1627
1628 let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1629 assert_eq!(back.slug, "brave-otter");
1630 assert_eq!(back.status, CloudSandboxStatus::Created);
1631 assert_eq!(back.spec.as_ref().unwrap()["image"], "python:3.12");
1632 assert!(back.started_at.is_none());
1633 }
1634
1635 #[test]
1636 fn sandbox_response_accepts_omitted_spec() {
1637 let json = serde_json::json!({
1638 "id": "00000000-0000-0000-0000-000000000002",
1639 "org_id": "00000000-0000-0000-0000-000000000001",
1640 "name": "agent-1",
1641 "slug": "brave-otter",
1642 "status": "running",
1643 "ephemeral": false,
1644 "created_at": "2026-05-17T12:00:00Z",
1645 "started_at": "2026-05-17T12:00:01Z",
1646 "stopped_at": null,
1647 "last_failure_message": null
1648 });
1649
1650 let response: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1651
1652 assert!(response.spec.is_none());
1653 assert_eq!(response.status, CloudSandboxStatus::Running);
1654 }
1655}