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 DiskImageFormat, EnvVar, HandoffInit, HostPattern, HostPermissions, MountOptions,
18 NetworkPolicy, NetworkSpec, OciRootfsSource, Patch, PullPolicy, Rlimit, RlimitResource,
19 RootDisk, RootfsSource, SandboxLogLevel, SandboxPolicy, SandboxResources,
20 SandboxRuntimeOptions, SandboxSpec, SecretEntry, SecretInjection, SecretsConfig,
21 SecurityProfile, StatVirtualization, ViolationAction, VolumeMount, default_private,
22 default_strict,
23};
24use crate::modify::SecretSource;
25use crate::{TypesError, TypesResult};
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
39pub struct CloudCreateSandboxRequest {
40 #[serde(flatten)]
42 pub spec: CloudSandboxSpec,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
48#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
49#[serde(default)]
50pub struct CloudSandboxSpec {
51 pub name: String,
53
54 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
56 pub image: CloudRootfsSource,
57
58 pub resources: CloudSandboxResources,
60
61 pub runtime: CloudSandboxRuntimeOptions,
63
64 pub env: Vec<EnvVar>,
66
67 pub labels: BTreeMap<String, String>,
69
70 pub rlimits: Vec<CloudRlimit>,
72
73 pub mounts: Vec<CloudVolumeMount>,
75
76 pub patches: Vec<CloudPatch>,
78
79 pub network: CloudNetworkSpec,
81
82 pub init: Option<HandoffInit>,
84
85 pub pull_policy: CloudPullPolicy,
87
88 pub security_profile: SecurityProfile,
90
91 pub lifecycle: SandboxPolicy,
93}
94
95#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
97#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
98#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
99#[serde(default)]
100pub struct CloudSandboxResources {
101 pub vcpus: u8,
103
104 pub memory_mib: u32,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub disk_size_mib: Option<u32>,
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
121#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
122#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
123#[serde(rename_all = "snake_case")]
124pub enum CloudPullPolicy {
125 #[default]
127 IfMissing,
128 Always,
130 Never,
132}
133
134impl From<PullPolicy> for CloudPullPolicy {
135 fn from(policy: PullPolicy) -> Self {
136 match policy {
137 PullPolicy::IfMissing => Self::IfMissing,
138 PullPolicy::Always => Self::Always,
139 PullPolicy::Never => Self::Never,
140 }
141 }
142}
143
144impl From<CloudPullPolicy> for PullPolicy {
145 fn from(policy: CloudPullPolicy) -> Self {
146 match policy {
147 CloudPullPolicy::IfMissing => Self::IfMissing,
148 CloudPullPolicy::Always => Self::Always,
149 CloudPullPolicy::Never => Self::Never,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
158#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
159#[serde(rename_all = "snake_case")]
160pub enum CloudDiskImageFormat {
161 Qcow2,
163 Raw,
165 Vmdk,
167}
168
169impl From<DiskImageFormat> for CloudDiskImageFormat {
170 fn from(format: DiskImageFormat) -> Self {
171 match format {
172 DiskImageFormat::Qcow2 => Self::Qcow2,
173 DiskImageFormat::Raw => Self::Raw,
174 DiskImageFormat::Vmdk => Self::Vmdk,
175 }
176 }
177}
178
179impl From<CloudDiskImageFormat> for DiskImageFormat {
180 fn from(format: CloudDiskImageFormat) -> Self {
181 match format {
182 CloudDiskImageFormat::Qcow2 => Self::Qcow2,
183 CloudDiskImageFormat::Raw => Self::Raw,
184 CloudDiskImageFormat::Vmdk => Self::Vmdk,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
194#[serde(rename_all = "snake_case")]
195pub enum CloudRlimitResource {
196 Cpu,
198 Fsize,
200 Data,
202 Stack,
204 Core,
206 Rss,
208 Nproc,
210 Nofile,
212 Memlock,
214 As,
216 Locks,
218 Sigpending,
220 Msgqueue,
222 Nice,
224 Rtprio,
226 Rttime,
228}
229
230impl From<RlimitResource> for CloudRlimitResource {
231 fn from(resource: RlimitResource) -> Self {
232 match resource {
233 RlimitResource::Cpu => Self::Cpu,
234 RlimitResource::Fsize => Self::Fsize,
235 RlimitResource::Data => Self::Data,
236 RlimitResource::Stack => Self::Stack,
237 RlimitResource::Core => Self::Core,
238 RlimitResource::Rss => Self::Rss,
239 RlimitResource::Nproc => Self::Nproc,
240 RlimitResource::Nofile => Self::Nofile,
241 RlimitResource::Memlock => Self::Memlock,
242 RlimitResource::As => Self::As,
243 RlimitResource::Locks => Self::Locks,
244 RlimitResource::Sigpending => Self::Sigpending,
245 RlimitResource::Msgqueue => Self::Msgqueue,
246 RlimitResource::Nice => Self::Nice,
247 RlimitResource::Rtprio => Self::Rtprio,
248 RlimitResource::Rttime => Self::Rttime,
249 }
250 }
251}
252
253impl From<CloudRlimitResource> for RlimitResource {
254 fn from(resource: CloudRlimitResource) -> Self {
255 match resource {
256 CloudRlimitResource::Cpu => Self::Cpu,
257 CloudRlimitResource::Fsize => Self::Fsize,
258 CloudRlimitResource::Data => Self::Data,
259 CloudRlimitResource::Stack => Self::Stack,
260 CloudRlimitResource::Core => Self::Core,
261 CloudRlimitResource::Rss => Self::Rss,
262 CloudRlimitResource::Nproc => Self::Nproc,
263 CloudRlimitResource::Nofile => Self::Nofile,
264 CloudRlimitResource::Memlock => Self::Memlock,
265 CloudRlimitResource::As => Self::As,
266 CloudRlimitResource::Locks => Self::Locks,
267 CloudRlimitResource::Sigpending => Self::Sigpending,
268 CloudRlimitResource::Msgqueue => Self::Msgqueue,
269 CloudRlimitResource::Nice => Self::Nice,
270 CloudRlimitResource::Rtprio => Self::Rtprio,
271 CloudRlimitResource::Rttime => Self::Rttime,
272 }
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
279#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
280pub struct CloudRlimit {
281 pub resource: CloudRlimitResource,
283 pub soft: u64,
285 pub hard: u64,
287}
288
289impl From<Rlimit> for CloudRlimit {
290 fn from(rlimit: Rlimit) -> Self {
291 Self {
292 resource: rlimit.resource.into(),
293 soft: rlimit.soft,
294 hard: rlimit.hard,
295 }
296 }
297}
298
299impl From<CloudRlimit> for Rlimit {
300 fn from(rlimit: CloudRlimit) -> Self {
301 Self {
302 resource: rlimit.resource.into(),
303 soft: rlimit.soft,
304 hard: rlimit.hard,
305 }
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
313#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
314#[serde(tag = "type", rename_all = "snake_case")]
315pub enum CloudPatch {
316 Text {
318 path: String,
320 content: String,
322 mode: Option<u32>,
324 replace: bool,
326 },
327 File {
329 path: String,
331 content: Vec<u8>,
333 mode: Option<u32>,
335 replace: bool,
337 },
338 CopyFile {
340 #[cfg_attr(feature = "ts", ts(type = "string"))]
342 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
343 src: PathBuf,
344 dst: String,
346 mode: Option<u32>,
348 replace: bool,
350 },
351 CopyDir {
353 #[cfg_attr(feature = "ts", ts(type = "string"))]
355 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
356 src: PathBuf,
357 dst: String,
359 replace: bool,
361 },
362 Symlink {
364 target: String,
366 link: String,
368 replace: bool,
370 },
371 Mkdir {
373 path: String,
375 mode: Option<u32>,
377 },
378 Remove {
380 path: String,
382 },
383 Append {
385 path: String,
387 content: String,
389 },
390}
391
392impl From<Patch> for CloudPatch {
393 fn from(patch: Patch) -> Self {
394 match patch {
395 Patch::Text {
396 path,
397 content,
398 mode,
399 replace,
400 } => Self::Text {
401 path,
402 content,
403 mode,
404 replace,
405 },
406 Patch::File {
407 path,
408 content,
409 mode,
410 replace,
411 } => Self::File {
412 path,
413 content,
414 mode,
415 replace,
416 },
417 Patch::CopyFile {
418 src,
419 dst,
420 mode,
421 replace,
422 } => Self::CopyFile {
423 src,
424 dst,
425 mode,
426 replace,
427 },
428 Patch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
429 Patch::Symlink {
430 target,
431 link,
432 replace,
433 } => Self::Symlink {
434 target,
435 link,
436 replace,
437 },
438 Patch::Mkdir { path, mode } => Self::Mkdir { path, mode },
439 Patch::Remove { path } => Self::Remove { path },
440 Patch::Append { path, content } => Self::Append { path, content },
441 }
442 }
443}
444
445impl From<CloudPatch> for Patch {
446 fn from(patch: CloudPatch) -> Self {
447 match patch {
448 CloudPatch::Text {
449 path,
450 content,
451 mode,
452 replace,
453 } => Self::Text {
454 path,
455 content,
456 mode,
457 replace,
458 },
459 CloudPatch::File {
460 path,
461 content,
462 mode,
463 replace,
464 } => Self::File {
465 path,
466 content,
467 mode,
468 replace,
469 },
470 CloudPatch::CopyFile {
471 src,
472 dst,
473 mode,
474 replace,
475 } => Self::CopyFile {
476 src,
477 dst,
478 mode,
479 replace,
480 },
481 CloudPatch::CopyDir { src, dst, replace } => Self::CopyDir { src, dst, replace },
482 CloudPatch::Symlink {
483 target,
484 link,
485 replace,
486 } => Self::Symlink {
487 target,
488 link,
489 replace,
490 },
491 CloudPatch::Mkdir { path, mode } => Self::Mkdir { path, mode },
492 CloudPatch::Remove { path } => Self::Remove { path },
493 CloudPatch::Append { path, content } => Self::Append { path, content },
494 }
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
506#[serde(tag = "type", rename_all = "snake_case")]
507pub enum CloudRootfsSource {
508 Bind {
510 #[cfg_attr(feature = "ts", ts(type = "string"))]
512 path: PathBuf,
513 },
514
515 Oci {
517 reference: String,
519 },
520
521 DiskImage {
523 #[cfg_attr(feature = "ts", ts(type = "string"))]
525 path: PathBuf,
526 format: CloudDiskImageFormat,
528 fstype: Option<String>,
530 },
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
536#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
537#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
538#[serde(tag = "type", rename_all = "snake_case")]
539pub enum CloudVolumeMount {
540 Bind {
542 #[cfg_attr(feature = "ts", ts(type = "string"))]
544 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
545 host: PathBuf,
546 guest: String,
548 #[serde(default)]
550 options: MountOptions,
551 #[serde(default = "default_strict")]
553 stat_virtualization: StatVirtualization,
554 #[serde(default = "default_private")]
556 host_permissions: HostPermissions,
557 #[serde(default)]
559 quota_mib: Option<u32>,
560 },
561
562 Named {
564 name: String,
566 guest: String,
568 #[serde(default)]
570 options: MountOptions,
571 #[serde(default = "default_strict")]
573 stat_virtualization: StatVirtualization,
574 #[serde(default = "default_private")]
576 host_permissions: HostPermissions,
577 },
578
579 Tmpfs {
581 guest: String,
583 #[serde(default)]
585 size_mib: Option<u32>,
586 #[serde(default)]
588 options: MountOptions,
589 },
590
591 DiskImage {
593 #[cfg_attr(feature = "ts", ts(type = "string"))]
595 #[cfg_attr(feature = "utoipa", schema(value_type = String))]
596 host: PathBuf,
597 guest: String,
599 format: CloudDiskImageFormat,
601 #[serde(default)]
603 fstype: Option<String>,
604 #[serde(default)]
606 options: MountOptions,
607 },
608}
609
610impl From<CloudVolumeMount> for VolumeMount {
611 fn from(m: CloudVolumeMount) -> Self {
612 match m {
613 CloudVolumeMount::Bind {
614 host,
615 guest,
616 options,
617 stat_virtualization,
618 host_permissions,
619 quota_mib,
620 } => VolumeMount::Bind {
621 host,
622 guest,
623 options,
624 stat_virtualization,
625 host_permissions,
626 follow_root_symlinks: false,
629 quota_mib,
630 },
631 CloudVolumeMount::Named {
632 name,
633 guest,
634 options,
635 stat_virtualization,
636 host_permissions,
637 } => VolumeMount::Named {
638 name,
639 guest,
640 create: None,
641 options,
642 stat_virtualization,
643 host_permissions,
644 follow_root_symlinks: false,
645 },
646 CloudVolumeMount::Tmpfs {
647 guest,
648 size_mib,
649 options,
650 } => VolumeMount::Tmpfs {
651 guest,
652 size_mib,
653 options,
654 },
655 CloudVolumeMount::DiskImage {
656 host,
657 guest,
658 format,
659 fstype,
660 options,
661 } => VolumeMount::DiskImage {
662 host,
663 guest,
664 format: format.into(),
665 fstype,
666 options,
667 },
668 }
669 }
670}
671
672impl From<VolumeMount> for CloudVolumeMount {
673 fn from(m: VolumeMount) -> Self {
674 match m {
675 VolumeMount::Bind {
676 host,
677 guest,
678 options,
679 stat_virtualization,
680 host_permissions,
681 follow_root_symlinks: _,
682 quota_mib,
683 } => CloudVolumeMount::Bind {
684 host,
685 guest,
686 options,
687 stat_virtualization,
688 host_permissions,
689 quota_mib,
690 },
691 VolumeMount::Named {
692 name,
693 guest,
694 create: _,
695 options,
696 stat_virtualization,
697 host_permissions,
698 follow_root_symlinks: _,
699 } => CloudVolumeMount::Named {
700 name,
701 guest,
702 options,
703 stat_virtualization,
704 host_permissions,
705 },
706 VolumeMount::Tmpfs {
707 guest,
708 size_mib,
709 options,
710 } => CloudVolumeMount::Tmpfs {
711 guest,
712 size_mib,
713 options,
714 },
715 VolumeMount::DiskImage {
716 host,
717 guest,
718 format,
719 fstype,
720 options,
721 } => CloudVolumeMount::DiskImage {
722 host,
723 guest,
724 format: format.into(),
725 fstype,
726 options,
727 },
728 }
729 }
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize)]
737#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
738#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
739#[serde(default, deny_unknown_fields)]
740pub struct CloudNetworkSpec {
741 pub enabled: bool,
743
744 #[serde(skip_serializing_if = "Option::is_none")]
746 pub policy: Option<NetworkPolicy>,
747
748 #[serde(skip_serializing_if = "Option::is_none")]
750 pub secrets: Option<CloudSecretsConfig>,
751
752 #[serde(skip_serializing_if = "Option::is_none")]
754 pub max_connections: Option<usize>,
755}
756
757impl Default for CloudNetworkSpec {
758 fn default() -> Self {
759 Self {
760 enabled: true,
761 policy: None,
762 secrets: None,
763 max_connections: None,
764 }
765 }
766}
767
768#[derive(Debug, Clone, Default, Serialize, Deserialize)]
772#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
773#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
774#[serde(default, deny_unknown_fields)]
775pub struct CloudSandboxRuntimeOptions {
776 pub workdir: Option<String>,
778
779 pub shell: Option<String>,
781
782 pub scripts: BTreeMap<String, String>,
784
785 pub entrypoint: Option<Vec<String>>,
787
788 pub cmd: Option<Vec<String>>,
790
791 pub user: Option<String>,
793
794 pub log_level: Option<SandboxLogLevel>,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize)]
804#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
805#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
806pub struct CloudCreateSandboxResponse {
807 pub id: String,
809 pub org_id: String,
811 pub name: String,
813 pub slug: String,
815 pub status: CloudSandboxStatus,
817 #[serde(default)]
820 pub status_reason: Option<CloudSandboxStatusReason>,
821 pub spec: CloudSandboxSpec,
823 pub ephemeral: bool,
825 #[cfg_attr(feature = "ts", ts(type = "string"))]
827 pub created_at: DateTime<Utc>,
828 #[serde(default)]
830 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
831 pub started_at: Option<DateTime<Utc>>,
832 #[serde(default)]
834 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
835 pub stopped_at: Option<DateTime<Utc>>,
836 #[serde(default)]
838 pub last_failure_message: Option<String>,
839}
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
843#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
844#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
845#[serde(rename_all = "snake_case")]
846pub enum CloudSandboxStatus {
847 Created,
849 Starting,
851 Running,
853 Stopping,
855 Stopped,
857 Failed,
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
865#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
866#[serde(rename_all = "snake_case")]
867pub enum CloudSandboxStatusReason {
868 Scheduling,
870 InsufficientCapacity,
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize)]
877#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
878#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
879pub struct CloudPaginated<T> {
880 pub data: Vec<T>,
882 #[serde(default)]
884 pub next_cursor: Option<String>,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
890#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
891pub struct CloudMessageResponse {
892 pub message: String,
894}
895
896#[derive(Debug, Clone, Serialize, Deserialize)]
898#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
899#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
900pub struct CloudErrorBody {
901 #[serde(default)]
903 pub code: Option<String>,
904 #[serde(default)]
906 pub message: Option<String>,
907 #[serde(default)]
909 pub error: Option<CloudErrorDetails>,
910}
911
912#[derive(Debug, Clone, Serialize, Deserialize)]
914#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
915#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
916pub struct CloudErrorDetails {
917 #[serde(default)]
919 pub code: Option<String>,
920 #[serde(default)]
922 pub message: Option<String>,
923}
924
925impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
930 type Error = TypesError;
931
932 fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
933 req.spec.try_into()
934 }
935}
936
937impl TryFrom<CloudSandboxSpec> for SandboxSpec {
938 type Error = TypesError;
939
940 fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
941 let disk_size_mib = spec.resources.disk_size_mib;
942 let image = match spec.image {
943 CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
946 reference,
947 root_disk: disk_size_mib.map(RootDisk::managed),
948 }),
949 CloudRootfsSource::Bind { .. } | CloudRootfsSource::DiskImage { .. }
950 if disk_size_mib.is_some() =>
951 {
952 return Err(TypesError::invalid_config(
953 "resources.disk_size_mib is only valid for OCI rootfs",
954 ));
955 }
956 CloudRootfsSource::Bind { path } => RootfsSource::Bind {
957 path,
958 follow_root_symlinks: false,
959 },
960 CloudRootfsSource::DiskImage {
961 path,
962 format,
963 fstype,
964 } => RootfsSource::DiskImage {
965 path,
966 format: format.into(),
967 fstype,
968 },
969 };
970
971 let resources = SandboxResources {
972 cpus: spec.resources.vcpus,
973 memory_mib: spec.resources.memory_mib,
974 max_cpus: spec.resources.vcpus,
978 max_memory_mib: spec.resources.memory_mib,
979 };
980
981 let network = NetworkSpec {
985 enabled: spec.network.enabled,
986 interface: None,
987 ports: Vec::new(),
988 policy: spec.network.policy,
989 dns: None,
990 tls: None,
991 secrets: spec.network.secrets.map(Into::into),
992 max_connections: spec.network.max_connections,
993 trust_host_cas: false,
994 };
995 let runtime = SandboxRuntimeOptions {
996 workdir: spec.runtime.workdir,
997 shell: spec.runtime.shell,
998 scripts: spec.runtime.scripts,
999 entrypoint: spec.runtime.entrypoint,
1000 cmd: spec.runtime.cmd,
1001 hostname: None,
1002 user: spec.runtime.user,
1003 log_level: spec.runtime.log_level,
1004 metrics_sample_interval_ms: None,
1005 disable_metrics_sample: false,
1006 };
1007
1008 Ok(Self {
1009 name: spec.name,
1010 image,
1011 resources,
1012 runtime,
1013 env: spec.env,
1014 labels: spec.labels,
1015 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1016 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1017 patches: spec.patches.into_iter().map(Into::into).collect(),
1018 network,
1019 init: spec.init,
1020 pull_policy: spec.pull_policy.into(),
1021 security_profile: spec.security_profile,
1022 lifecycle: spec.lifecycle,
1023 })
1024 }
1025}
1026
1027impl From<SandboxSpec> for CloudCreateSandboxRequest {
1028 fn from(spec: SandboxSpec) -> Self {
1029 Self { spec: spec.into() }
1030 }
1031}
1032
1033impl From<SandboxSpec> for CloudSandboxSpec {
1034 fn from(spec: SandboxSpec) -> Self {
1035 let (image, disk_size_mib) = match spec.image {
1036 RootfsSource::Oci(oci) => (
1039 CloudRootfsSource::Oci {
1040 reference: oci.reference,
1041 },
1042 match &oci.root_disk {
1043 Some(RootDisk::Managed { size_mib }) => *size_mib,
1044 _ => None,
1045 },
1046 ),
1047 RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1048 RootfsSource::DiskImage {
1049 path,
1050 format,
1051 fstype,
1052 } => (
1053 CloudRootfsSource::DiskImage {
1054 path,
1055 format: format.into(),
1056 fstype,
1057 },
1058 None,
1059 ),
1060 };
1061
1062 Self {
1063 name: spec.name,
1064 image,
1065 resources: CloudSandboxResources {
1066 vcpus: spec.resources.cpus,
1067 memory_mib: spec.resources.memory_mib,
1068 disk_size_mib,
1069 },
1070 runtime: CloudSandboxRuntimeOptions {
1071 workdir: spec.runtime.workdir,
1072 shell: spec.runtime.shell,
1073 scripts: spec.runtime.scripts,
1074 entrypoint: spec.runtime.entrypoint,
1075 cmd: spec.runtime.cmd,
1076 user: spec.runtime.user,
1077 log_level: spec.runtime.log_level,
1078 },
1079 env: spec.env,
1080 labels: spec.labels,
1081 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1082 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1083 patches: spec.patches.into_iter().map(Into::into).collect(),
1084 network: CloudNetworkSpec {
1085 enabled: spec.network.enabled,
1086 policy: spec.network.policy,
1087 secrets: spec.network.secrets.map(Into::into),
1088 max_connections: spec.network.max_connections,
1089 },
1090 init: spec.init,
1091 pull_policy: spec.pull_policy.into(),
1092 security_profile: spec.security_profile,
1093 lifecycle: spec.lifecycle,
1094 }
1095 }
1096}
1097
1098impl Default for CloudSandboxResources {
1099 fn default() -> Self {
1100 let resources = SandboxResources::default();
1101 Self {
1102 vcpus: resources.cpus,
1103 memory_mib: resources.memory_mib,
1104 disk_size_mib: None,
1105 }
1106 }
1107}
1108
1109impl CloudRootfsSource {
1110 pub fn oci(reference: impl Into<String>) -> Self {
1112 Self::Oci {
1113 reference: reference.into(),
1114 }
1115 }
1116
1117 pub fn oci_reference(&self) -> Option<&str> {
1119 match self {
1120 Self::Oci { reference } => Some(reference),
1121 _ => None,
1122 }
1123 }
1124}
1125
1126impl Default for CloudRootfsSource {
1127 fn default() -> Self {
1128 Self::oci(String::new())
1129 }
1130}
1131
1132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1138#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1139#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1140pub struct CloudSecretsConfig {
1141 #[serde(default)]
1143 pub entries: Vec<CloudSecretEntry>,
1144 #[serde(default)]
1146 pub on_violation: CloudViolationAction,
1147}
1148
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1151#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1152#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1153pub struct CloudSecretEntry {
1154 pub env_var: String,
1156 #[serde(default)]
1158 pub value: String,
1159 #[serde(default, skip_serializing_if = "Option::is_none")]
1161 pub source: Option<CloudSecretSource>,
1162 pub placeholder: String,
1164 #[serde(default)]
1166 pub allowed_hosts: Vec<CloudHostPattern>,
1167 #[serde(default)]
1169 pub injection: SecretInjection,
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1172 pub on_violation: Option<CloudViolationAction>,
1173 #[serde(default = "cloud_default_true")]
1175 pub require_tls_identity: bool,
1176}
1177
1178#[derive(Debug, Clone, Serialize, Deserialize)]
1180#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1181#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1182#[serde(tag = "type", rename_all = "snake_case")]
1183pub enum CloudSecretSource {
1184 Env {
1186 var: String,
1188 },
1189 Store {
1191 reference: String,
1193 },
1194}
1195
1196#[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 CloudHostPattern {
1203 Exact {
1205 value: String,
1207 },
1208 Wildcard {
1210 value: String,
1212 },
1213 Any,
1215}
1216
1217#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1220#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1221#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1222#[serde(tag = "type", rename_all = "snake_case")]
1223pub enum CloudViolationAction {
1224 Block,
1226 #[default]
1228 BlockAndLog,
1229 BlockAndTerminate,
1231 Passthrough {
1233 hosts: Vec<CloudHostPattern>,
1235 },
1236}
1237
1238fn cloud_default_true() -> bool {
1239 true
1240}
1241
1242impl From<HostPattern> for CloudHostPattern {
1247 fn from(pattern: HostPattern) -> Self {
1248 match pattern {
1249 HostPattern::Exact(value) => Self::Exact { value },
1250 HostPattern::Wildcard(value) => Self::Wildcard { value },
1251 HostPattern::Any => Self::Any,
1252 }
1253 }
1254}
1255
1256impl From<CloudHostPattern> for HostPattern {
1257 fn from(pattern: CloudHostPattern) -> Self {
1258 match pattern {
1259 CloudHostPattern::Exact { value } => Self::Exact(value),
1260 CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1261 CloudHostPattern::Any => Self::Any,
1262 }
1263 }
1264}
1265
1266impl From<ViolationAction> for CloudViolationAction {
1267 fn from(action: ViolationAction) -> Self {
1268 match action {
1269 ViolationAction::Block => Self::Block,
1270 ViolationAction::BlockAndLog => Self::BlockAndLog,
1271 ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1272 ViolationAction::Passthrough(hosts) => Self::Passthrough {
1273 hosts: hosts.into_iter().map(Into::into).collect(),
1274 },
1275 }
1276 }
1277}
1278
1279impl From<CloudViolationAction> for ViolationAction {
1280 fn from(action: CloudViolationAction) -> Self {
1281 match action {
1282 CloudViolationAction::Block => Self::Block,
1283 CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1284 CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1285 CloudViolationAction::Passthrough { hosts } => {
1286 Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1287 }
1288 }
1289 }
1290}
1291
1292impl From<SecretSource> for CloudSecretSource {
1293 fn from(source: SecretSource) -> Self {
1294 match source {
1295 SecretSource::Env { var } => Self::Env { var },
1296 SecretSource::Store { reference } => Self::Store { reference },
1297 }
1298 }
1299}
1300
1301impl From<CloudSecretSource> for SecretSource {
1302 fn from(source: CloudSecretSource) -> Self {
1303 match source {
1304 CloudSecretSource::Env { var } => Self::Env { var },
1305 CloudSecretSource::Store { reference } => Self::Store { reference },
1306 }
1307 }
1308}
1309
1310impl From<SecretEntry> for CloudSecretEntry {
1311 fn from(entry: SecretEntry) -> Self {
1312 Self {
1313 env_var: entry.env_var,
1314 value: entry.value.to_string(),
1315 source: entry.source.map(Into::into),
1316 placeholder: entry.placeholder,
1317 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1318 injection: entry.injection,
1319 on_violation: entry.on_violation.map(Into::into),
1320 require_tls_identity: entry.require_tls_identity,
1321 }
1322 }
1323}
1324
1325impl From<CloudSecretEntry> for SecretEntry {
1326 fn from(entry: CloudSecretEntry) -> Self {
1327 Self {
1328 env_var: entry.env_var,
1329 value: Zeroizing::new(entry.value),
1330 source: entry.source.map(Into::into),
1331 placeholder: entry.placeholder,
1332 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1333 injection: entry.injection,
1334 on_violation: entry.on_violation.map(Into::into),
1335 require_tls_identity: entry.require_tls_identity,
1336 }
1337 }
1338}
1339
1340impl From<SecretsConfig> for CloudSecretsConfig {
1341 fn from(config: SecretsConfig) -> Self {
1342 Self {
1343 entries: config.secrets.into_iter().map(Into::into).collect(),
1344 on_violation: config.on_violation.into(),
1345 }
1346 }
1347}
1348
1349impl From<CloudSecretsConfig> for SecretsConfig {
1350 fn from(config: CloudSecretsConfig) -> Self {
1351 Self {
1352 secrets: config.entries.into_iter().map(Into::into).collect(),
1353 on_violation: config.on_violation.into(),
1354 }
1355 }
1356}
1357
1358#[cfg(test)]
1363mod tests {
1364 use super::*;
1365 use crate::domain::{
1366 DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1367 };
1368
1369 fn spec(name: &str) -> CloudSandboxSpec {
1370 CloudSandboxSpec {
1371 name: name.into(),
1372 image: CloudRootfsSource::Oci {
1373 reference: "python:3.12".into(),
1374 },
1375 ..Default::default()
1376 }
1377 }
1378
1379 #[test]
1380 fn create_request_flattens_spec() {
1381 let req = CloudCreateSandboxRequest {
1382 spec: spec("agent-1"),
1383 };
1384 let json = serde_json::to_value(&req).unwrap();
1385 assert_eq!(json["name"], "agent-1");
1387 assert!(json.get("image").is_some());
1388
1389 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1390 assert_eq!(back.spec.name, "agent-1");
1391 }
1392
1393 #[test]
1394 fn cloud_rootfs_source_uses_internal_tagging() {
1395 let json = serde_json::to_value(CloudRootfsSource::Oci {
1396 reference: "python:3.12".into(),
1397 })
1398 .unwrap();
1399 assert_eq!(
1400 json,
1401 serde_json::json!({"type": "oci", "reference": "python:3.12"})
1402 );
1403
1404 let bind = serde_json::to_value(CloudRootfsSource::Bind {
1405 path: "/host".into(),
1406 })
1407 .unwrap();
1408 assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1409
1410 let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1411 assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1412 }
1413
1414 #[test]
1415 fn cloud_secret_twins_use_internal_tagging() {
1416 assert_eq!(
1418 serde_json::to_value(CloudHostPattern::Exact {
1419 value: "api.example.com".into(),
1420 })
1421 .unwrap(),
1422 serde_json::json!({"type": "exact", "value": "api.example.com"})
1423 );
1424 assert_eq!(
1425 serde_json::to_value(CloudSecretSource::Env {
1426 var: "OPENAI".into()
1427 })
1428 .unwrap(),
1429 serde_json::json!({"type": "env", "var": "OPENAI"})
1430 );
1431 assert_eq!(
1432 serde_json::to_value(CloudViolationAction::Passthrough {
1433 hosts: vec![CloudHostPattern::Any],
1434 })
1435 .unwrap(),
1436 serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1437 );
1438 }
1439
1440 #[test]
1441 fn cloud_secrets_config_round_trips_through_domain() {
1442 let cloud = CloudSecretsConfig {
1443 entries: vec![CloudSecretEntry {
1444 env_var: "OPENAI_API_KEY".into(),
1445 value: "sk-x".into(),
1446 source: Some(CloudSecretSource::Env {
1447 var: "OPENAI".into(),
1448 }),
1449 placeholder: "$MSB_OPENAI".into(),
1450 allowed_hosts: vec![CloudHostPattern::Exact {
1451 value: "api.openai.com".into(),
1452 }],
1453 injection: SecretInjection::default(),
1454 on_violation: Some(CloudViolationAction::BlockAndTerminate),
1455 require_tls_identity: true,
1456 }],
1457 on_violation: CloudViolationAction::BlockAndLog,
1458 };
1459
1460 let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1461 assert_eq!(back.entries.len(), 1);
1462 assert_eq!(back.entries[0].value, "sk-x");
1463 assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1464 assert!(matches!(
1465 back.entries[0].on_violation,
1466 Some(CloudViolationAction::BlockAndTerminate)
1467 ));
1468 }
1469
1470 #[test]
1471 fn create_request_converts_disk_size_to_oci_rootfs() {
1472 let mut req = CloudCreateSandboxRequest {
1473 spec: spec("agent-1"),
1474 };
1475 req.spec.resources.disk_size_mib = Some(8192);
1476
1477 let domain = SandboxSpec::try_from(req).unwrap();
1478
1479 assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1480 assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1481 match domain.image {
1482 RootfsSource::Oci(oci) => {
1483 assert_eq!(oci.reference, "python:3.12");
1484 assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1485 }
1486 other => panic!("expected OCI rootfs, got {other:?}"),
1487 }
1488 }
1489
1490 #[test]
1491 fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1492 let mut req = CloudCreateSandboxRequest {
1493 spec: spec("agent-1"),
1494 };
1495 req.spec.image = CloudRootfsSource::Bind {
1496 path: "/tmp/rootfs".into(),
1497 };
1498 req.spec.resources.disk_size_mib = Some(8192);
1499
1500 let err = SandboxSpec::try_from(req).unwrap_err();
1501
1502 assert!(err.to_string().contains("disk_size_mib"));
1503 }
1504
1505 #[test]
1506 fn domain_spec_converts_oci_size_to_cloud_resources() {
1507 let domain = SandboxSpec {
1508 name: "agent-1".into(),
1509 image: RootfsSource::Oci(OciRootfsSource {
1510 reference: "python:3.12".into(),
1511 root_disk: Some(RootDisk::managed(8192)),
1512 }),
1513 ..Default::default()
1514 };
1515
1516 let req = CloudCreateSandboxRequest::from(domain);
1517
1518 assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1519 match req.spec.image {
1520 CloudRootfsSource::Oci { reference } => {
1521 assert_eq!(reference, "python:3.12");
1522 }
1523 other => panic!("expected OCI rootfs, got {other:?}"),
1524 }
1525 }
1526
1527 #[test]
1528 fn create_request_minimal_defaults() {
1529 let req = CloudCreateSandboxRequest {
1531 spec: spec("agent-1"),
1532 };
1533 let json = serde_json::to_value(&req).unwrap();
1534 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1535 assert_eq!(back.spec.name, "agent-1");
1536 }
1537
1538 #[test]
1539 fn sandbox_response_round_trips() {
1540 let sb = CloudCreateSandboxResponse {
1541 id: "00000000-0000-0000-0000-000000000002".into(),
1542 org_id: "00000000-0000-0000-0000-000000000001".into(),
1543 name: "agent-1".into(),
1544 slug: "brave-otter".into(),
1545 status: CloudSandboxStatus::Created,
1546 status_reason: None,
1547 spec: spec("agent-1"),
1548 ephemeral: true,
1549 created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1550 started_at: None,
1551 stopped_at: None,
1552 last_failure_message: None,
1553 };
1554 let json = serde_json::to_value(&sb).unwrap();
1555 assert_eq!(json["slug"], "brave-otter");
1556 assert_eq!(json["name"], "agent-1");
1557
1558 let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1559 assert_eq!(back.slug, "brave-otter");
1560 assert_eq!(back.status, CloudSandboxStatus::Created);
1561 assert_eq!(back.spec.name, "agent-1");
1562 assert!(back.started_at.is_none());
1563 }
1564}