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)]
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 };
983
984 let network = NetworkSpec {
988 enabled: spec.network.enabled,
989 interface: None,
990 ports: Vec::new(),
991 policy: spec.network.policy,
992 dns: None,
993 tls: None,
994 secrets: spec.network.secrets.map(Into::into),
995 max_connections: spec.network.max_connections,
996 trust_host_cas: false,
997 };
998 let runtime = SandboxRuntimeOptions {
999 workdir: spec.runtime.workdir,
1000 shell: spec.runtime.shell,
1001 scripts: spec.runtime.scripts,
1002 entrypoint: spec.runtime.entrypoint,
1003 cmd: spec.runtime.cmd,
1004 hostname: None,
1005 user: spec.runtime.user,
1006 log_level: spec.runtime.log_level,
1007 metrics_sample_interval_ms: None,
1008 disable_metrics_sample: false,
1009 };
1010
1011 Ok(Self {
1012 name: spec.name,
1013 image,
1014 resources,
1015 runtime,
1016 env: spec.env,
1017 labels: spec.labels,
1018 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1019 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1020 patches: spec.patches.into_iter().map(Into::into).collect(),
1021 network,
1022 init: spec.init,
1023 pull_policy: spec.pull_policy.into(),
1024 security_profile: spec.security_profile,
1025 lifecycle: spec.lifecycle,
1026 })
1027 }
1028}
1029
1030impl From<SandboxSpec> for CloudCreateSandboxRequest {
1031 fn from(spec: SandboxSpec) -> Self {
1032 Self { spec: spec.into() }
1033 }
1034}
1035
1036impl From<SandboxSpec> for CloudSandboxSpec {
1037 fn from(spec: SandboxSpec) -> Self {
1038 let (image, disk_size_mib) = match spec.image {
1039 RootfsSource::Oci(oci) => (
1042 CloudRootfsSource::Oci {
1043 reference: oci.reference,
1044 },
1045 match &oci.root_disk {
1046 Some(RootDisk::Managed { size_mib }) => *size_mib,
1047 _ => None,
1048 },
1049 ),
1050 RootfsSource::Bind { path, .. } => (CloudRootfsSource::Bind { path }, None),
1051 RootfsSource::DiskImage {
1052 path,
1053 format,
1054 fstype,
1055 } => (
1056 CloudRootfsSource::DiskImage {
1057 path,
1058 format: format.into(),
1059 fstype,
1060 },
1061 None,
1062 ),
1063 };
1064
1065 Self {
1066 name: spec.name,
1067 image,
1068 resources: CloudSandboxResources {
1069 vcpus: spec.resources.cpus,
1070 memory_mib: spec.resources.memory_mib,
1071 disk_size_mib,
1072 },
1073 runtime: CloudSandboxRuntimeOptions {
1074 workdir: spec.runtime.workdir,
1075 shell: spec.runtime.shell,
1076 scripts: spec.runtime.scripts,
1077 entrypoint: spec.runtime.entrypoint,
1078 cmd: spec.runtime.cmd,
1079 user: spec.runtime.user,
1080 log_level: spec.runtime.log_level,
1081 },
1082 env: spec.env,
1083 labels: spec.labels,
1084 rlimits: spec.rlimits.into_iter().map(Into::into).collect(),
1085 mounts: spec.mounts.into_iter().map(Into::into).collect(),
1086 patches: spec.patches.into_iter().map(Into::into).collect(),
1087 network: CloudNetworkSpec {
1088 enabled: spec.network.enabled,
1089 policy: spec.network.policy,
1090 secrets: spec.network.secrets.map(Into::into),
1091 max_connections: spec.network.max_connections,
1092 },
1093 init: spec.init,
1094 pull_policy: spec.pull_policy.into(),
1095 security_profile: spec.security_profile,
1096 lifecycle: spec.lifecycle,
1097 }
1098 }
1099}
1100
1101impl Default for CloudSandboxResources {
1102 fn default() -> Self {
1103 let resources = SandboxResources::default();
1104 Self {
1105 vcpus: resources.cpus,
1106 memory_mib: resources.memory_mib,
1107 disk_size_mib: None,
1108 }
1109 }
1110}
1111
1112impl CloudRootfsSource {
1113 pub fn oci(reference: impl Into<String>) -> Self {
1115 Self::Oci {
1116 reference: reference.into(),
1117 }
1118 }
1119
1120 pub fn oci_reference(&self) -> Option<&str> {
1122 match self {
1123 Self::Oci { reference } => Some(reference),
1124 _ => None,
1125 }
1126 }
1127}
1128
1129impl Default for CloudRootfsSource {
1130 fn default() -> Self {
1131 Self::oci(String::new())
1132 }
1133}
1134
1135#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1141#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1142#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1143pub struct CloudSecretsConfig {
1144 #[serde(default)]
1146 pub entries: Vec<CloudSecretEntry>,
1147 #[serde(default)]
1149 pub on_violation: CloudViolationAction,
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize)]
1154#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1155#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1156pub struct CloudSecretEntry {
1157 pub env_var: String,
1159 #[serde(default)]
1161 pub value: String,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1164 pub source: Option<CloudSecretSource>,
1165 pub placeholder: String,
1167 #[serde(default)]
1169 pub allowed_hosts: Vec<CloudHostPattern>,
1170 #[serde(default)]
1172 pub injection: SecretInjection,
1173 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub on_violation: Option<CloudViolationAction>,
1176 #[serde(default = "cloud_default_true")]
1178 pub require_tls_identity: bool,
1179}
1180
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1183#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1185#[serde(tag = "type", rename_all = "snake_case")]
1186pub enum CloudSecretSource {
1187 Env {
1189 var: String,
1191 },
1192 Store {
1194 reference: String,
1196 },
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize)]
1202#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1203#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1204#[serde(tag = "type", rename_all = "snake_case")]
1205pub enum CloudHostPattern {
1206 Exact {
1208 value: String,
1210 },
1211 Wildcard {
1213 value: String,
1215 },
1216 Any,
1218}
1219
1220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1223#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
1225#[serde(tag = "type", rename_all = "snake_case")]
1226pub enum CloudViolationAction {
1227 Block,
1229 #[default]
1231 BlockAndLog,
1232 BlockAndTerminate,
1234 Passthrough {
1236 hosts: Vec<CloudHostPattern>,
1238 },
1239}
1240
1241fn cloud_default_true() -> bool {
1242 true
1243}
1244
1245impl From<HostPattern> for CloudHostPattern {
1250 fn from(pattern: HostPattern) -> Self {
1251 match pattern {
1252 HostPattern::Exact(value) => Self::Exact { value },
1253 HostPattern::Wildcard(value) => Self::Wildcard { value },
1254 HostPattern::Any => Self::Any,
1255 }
1256 }
1257}
1258
1259impl From<CloudHostPattern> for HostPattern {
1260 fn from(pattern: CloudHostPattern) -> Self {
1261 match pattern {
1262 CloudHostPattern::Exact { value } => Self::Exact(value),
1263 CloudHostPattern::Wildcard { value } => Self::Wildcard(value),
1264 CloudHostPattern::Any => Self::Any,
1265 }
1266 }
1267}
1268
1269impl From<ViolationAction> for CloudViolationAction {
1270 fn from(action: ViolationAction) -> Self {
1271 match action {
1272 ViolationAction::Block => Self::Block,
1273 ViolationAction::BlockAndLog => Self::BlockAndLog,
1274 ViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1275 ViolationAction::Passthrough(hosts) => Self::Passthrough {
1276 hosts: hosts.into_iter().map(Into::into).collect(),
1277 },
1278 }
1279 }
1280}
1281
1282impl From<CloudViolationAction> for ViolationAction {
1283 fn from(action: CloudViolationAction) -> Self {
1284 match action {
1285 CloudViolationAction::Block => Self::Block,
1286 CloudViolationAction::BlockAndLog => Self::BlockAndLog,
1287 CloudViolationAction::BlockAndTerminate => Self::BlockAndTerminate,
1288 CloudViolationAction::Passthrough { hosts } => {
1289 Self::Passthrough(hosts.into_iter().map(Into::into).collect())
1290 }
1291 }
1292 }
1293}
1294
1295impl From<SecretSource> for CloudSecretSource {
1296 fn from(source: SecretSource) -> Self {
1297 match source {
1298 SecretSource::Env { var } => Self::Env { var },
1299 SecretSource::Store { reference } => Self::Store { reference },
1300 }
1301 }
1302}
1303
1304impl From<CloudSecretSource> for SecretSource {
1305 fn from(source: CloudSecretSource) -> Self {
1306 match source {
1307 CloudSecretSource::Env { var } => Self::Env { var },
1308 CloudSecretSource::Store { reference } => Self::Store { reference },
1309 }
1310 }
1311}
1312
1313impl From<SecretEntry> for CloudSecretEntry {
1314 fn from(entry: SecretEntry) -> Self {
1315 Self {
1316 env_var: entry.env_var,
1317 value: entry.value.to_string(),
1318 source: entry.source.map(Into::into),
1319 placeholder: entry.placeholder,
1320 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1321 injection: entry.injection,
1322 on_violation: entry.on_violation.map(Into::into),
1323 require_tls_identity: entry.require_tls_identity,
1324 }
1325 }
1326}
1327
1328impl From<CloudSecretEntry> for SecretEntry {
1329 fn from(entry: CloudSecretEntry) -> Self {
1330 Self {
1331 env_var: entry.env_var,
1332 value: Zeroizing::new(entry.value),
1333 source: entry.source.map(Into::into),
1334 placeholder: entry.placeholder,
1335 allowed_hosts: entry.allowed_hosts.into_iter().map(Into::into).collect(),
1336 injection: entry.injection,
1337 on_violation: entry.on_violation.map(Into::into),
1338 require_tls_identity: entry.require_tls_identity,
1339 }
1340 }
1341}
1342
1343impl From<SecretsConfig> for CloudSecretsConfig {
1344 fn from(config: SecretsConfig) -> Self {
1345 Self {
1346 entries: config.secrets.into_iter().map(Into::into).collect(),
1347 on_violation: config.on_violation.into(),
1348 }
1349 }
1350}
1351
1352impl From<CloudSecretsConfig> for SecretsConfig {
1353 fn from(config: CloudSecretsConfig) -> Self {
1354 Self {
1355 secrets: config.entries.into_iter().map(Into::into).collect(),
1356 on_violation: config.on_violation.into(),
1357 }
1358 }
1359}
1360
1361#[cfg(test)]
1366mod tests {
1367 use super::*;
1368 use crate::domain::{
1369 DEFAULT_SANDBOX_CPUS, DEFAULT_SANDBOX_MEMORY_MIB, OciRootfsSource, RootDisk, RootfsSource,
1370 };
1371
1372 fn spec(name: &str) -> CloudSandboxSpec {
1373 CloudSandboxSpec {
1374 name: name.into(),
1375 image: CloudRootfsSource::Oci {
1376 reference: "python:3.12".into(),
1377 },
1378 ..Default::default()
1379 }
1380 }
1381
1382 #[test]
1383 fn create_request_flattens_spec() {
1384 let req = CloudCreateSandboxRequest {
1385 spec: spec("agent-1"),
1386 };
1387 let json = serde_json::to_value(&req).unwrap();
1388 assert_eq!(json["name"], "agent-1");
1390 assert!(json.get("image").is_some());
1391
1392 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1393 assert_eq!(back.spec.name, "agent-1");
1394 }
1395
1396 #[test]
1397 fn cloud_rootfs_source_uses_internal_tagging() {
1398 let json = serde_json::to_value(CloudRootfsSource::Oci {
1399 reference: "python:3.12".into(),
1400 })
1401 .unwrap();
1402 assert_eq!(
1403 json,
1404 serde_json::json!({"type": "oci", "reference": "python:3.12"})
1405 );
1406
1407 let bind = serde_json::to_value(CloudRootfsSource::Bind {
1408 path: "/host".into(),
1409 })
1410 .unwrap();
1411 assert_eq!(bind, serde_json::json!({"type": "bind", "path": "/host"}));
1412
1413 let back: CloudRootfsSource = serde_json::from_value(json).unwrap();
1414 assert!(matches!(back, CloudRootfsSource::Oci { reference } if reference == "python:3.12"));
1415 }
1416
1417 #[test]
1418 fn cloud_secret_twins_use_internal_tagging() {
1419 assert_eq!(
1421 serde_json::to_value(CloudHostPattern::Exact {
1422 value: "api.example.com".into(),
1423 })
1424 .unwrap(),
1425 serde_json::json!({"type": "exact", "value": "api.example.com"})
1426 );
1427 assert_eq!(
1428 serde_json::to_value(CloudSecretSource::Env {
1429 var: "OPENAI".into()
1430 })
1431 .unwrap(),
1432 serde_json::json!({"type": "env", "var": "OPENAI"})
1433 );
1434 assert_eq!(
1435 serde_json::to_value(CloudViolationAction::Passthrough {
1436 hosts: vec![CloudHostPattern::Any],
1437 })
1438 .unwrap(),
1439 serde_json::json!({"type": "passthrough", "hosts": [{"type": "any"}]})
1440 );
1441 }
1442
1443 #[test]
1444 fn cloud_secrets_config_round_trips_through_domain() {
1445 let cloud = CloudSecretsConfig {
1446 entries: vec![CloudSecretEntry {
1447 env_var: "OPENAI_API_KEY".into(),
1448 value: "sk-x".into(),
1449 source: Some(CloudSecretSource::Env {
1450 var: "OPENAI".into(),
1451 }),
1452 placeholder: "$MSB_OPENAI".into(),
1453 allowed_hosts: vec![CloudHostPattern::Exact {
1454 value: "api.openai.com".into(),
1455 }],
1456 injection: SecretInjection::default(),
1457 on_violation: Some(CloudViolationAction::BlockAndTerminate),
1458 require_tls_identity: true,
1459 }],
1460 on_violation: CloudViolationAction::BlockAndLog,
1461 };
1462
1463 let back: CloudSecretsConfig = SecretsConfig::from(cloud.clone()).into();
1464 assert_eq!(back.entries.len(), 1);
1465 assert_eq!(back.entries[0].value, "sk-x");
1466 assert_eq!(back.entries[0].allowed_hosts.len(), 1);
1467 assert!(matches!(
1468 back.entries[0].on_violation,
1469 Some(CloudViolationAction::BlockAndTerminate)
1470 ));
1471 }
1472
1473 #[test]
1474 fn create_request_converts_disk_size_to_oci_rootfs() {
1475 let mut req = CloudCreateSandboxRequest {
1476 spec: spec("agent-1"),
1477 };
1478 req.spec.resources.disk_size_mib = Some(8192);
1479
1480 let domain = SandboxSpec::try_from(req).unwrap();
1481
1482 assert_eq!(domain.resources.cpus, DEFAULT_SANDBOX_CPUS);
1483 assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
1484 match domain.image {
1485 RootfsSource::Oci(oci) => {
1486 assert_eq!(oci.reference, "python:3.12");
1487 assert_eq!(oci.root_disk, Some(RootDisk::managed(8192)));
1488 }
1489 other => panic!("expected OCI rootfs, got {other:?}"),
1490 }
1491 }
1492
1493 #[test]
1494 fn create_request_rejects_disk_size_for_non_oci_rootfs() {
1495 let mut req = CloudCreateSandboxRequest {
1496 spec: spec("agent-1"),
1497 };
1498 req.spec.image = CloudRootfsSource::Bind {
1499 path: "/tmp/rootfs".into(),
1500 };
1501 req.spec.resources.disk_size_mib = Some(8192);
1502
1503 let err = SandboxSpec::try_from(req).unwrap_err();
1504
1505 assert!(err.to_string().contains("disk_size_mib"));
1506 }
1507
1508 #[test]
1509 fn domain_spec_converts_oci_size_to_cloud_resources() {
1510 let domain = SandboxSpec {
1511 name: "agent-1".into(),
1512 image: RootfsSource::Oci(OciRootfsSource {
1513 reference: "python:3.12".into(),
1514 root_disk: Some(RootDisk::managed(8192)),
1515 }),
1516 ..Default::default()
1517 };
1518
1519 let req = CloudCreateSandboxRequest::from(domain);
1520
1521 assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
1522 match req.spec.image {
1523 CloudRootfsSource::Oci { reference } => {
1524 assert_eq!(reference, "python:3.12");
1525 }
1526 other => panic!("expected OCI rootfs, got {other:?}"),
1527 }
1528 }
1529
1530 #[test]
1531 fn create_request_minimal_defaults() {
1532 let req = CloudCreateSandboxRequest {
1534 spec: spec("agent-1"),
1535 };
1536 let json = serde_json::to_value(&req).unwrap();
1537 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
1538 assert_eq!(back.spec.name, "agent-1");
1539 }
1540
1541 #[test]
1542 fn sandbox_response_accepts_curated_optional_spec() {
1543 let sb = CloudCreateSandboxResponse {
1544 id: "00000000-0000-0000-0000-000000000002".into(),
1545 org_id: "00000000-0000-0000-0000-000000000001".into(),
1546 name: "agent-1".into(),
1547 slug: "brave-otter".into(),
1548 status: CloudSandboxStatus::Created,
1549 status_reason: None,
1550 spec: Some(serde_json::json!({
1551 "image": "python:3.12",
1552 "resources": { "vcpus": 2, "memory_mib": 1024 },
1553 })),
1554 ephemeral: true,
1555 created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
1556 started_at: None,
1557 stopped_at: None,
1558 last_failure_message: None,
1559 };
1560 let json = serde_json::to_value(&sb).unwrap();
1561 assert_eq!(json["slug"], "brave-otter");
1562 assert_eq!(json["name"], "agent-1");
1563
1564 let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1565 assert_eq!(back.slug, "brave-otter");
1566 assert_eq!(back.status, CloudSandboxStatus::Created);
1567 assert_eq!(back.spec.as_ref().unwrap()["image"], "python:3.12");
1568 assert!(back.started_at.is_none());
1569 }
1570
1571 #[test]
1572 fn sandbox_response_accepts_omitted_spec() {
1573 let json = serde_json::json!({
1574 "id": "00000000-0000-0000-0000-000000000002",
1575 "org_id": "00000000-0000-0000-0000-000000000001",
1576 "name": "agent-1",
1577 "slug": "brave-otter",
1578 "status": "running",
1579 "ephemeral": false,
1580 "created_at": "2026-05-17T12:00:00Z",
1581 "started_at": "2026-05-17T12:00:01Z",
1582 "stopped_at": null,
1583 "last_failure_message": null
1584 });
1585
1586 let response: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
1587
1588 assert!(response.spec.is_none());
1589 assert_eq!(response.status, CloudSandboxStatus::Running);
1590 }
1591}