1use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::domain::{
15 DiskImageFormat, EnvVar, HandoffInit, NetworkPolicy, NetworkSpec, OciRootfsSource, Patch,
16 PullPolicy, Rlimit, RootfsSource, SandboxLogLevel, SandboxPolicy, SandboxResources,
17 SandboxRuntimeOptions, SandboxSpec, SecretsConfig, SecurityProfile, VolumeMount,
18};
19use crate::{TypesError, TypesResult};
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
33pub struct CloudCreateSandboxRequest {
34 #[serde(flatten)]
36 pub spec: CloudSandboxSpec,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
43#[serde(default)]
44pub struct CloudSandboxSpec {
45 pub name: String,
47
48 #[cfg_attr(feature = "utoipa", schema(value_type = Object))]
50 pub image: CloudRootfsSource,
51
52 pub resources: CloudSandboxResources,
54
55 pub runtime: CloudSandboxRuntimeOptions,
57
58 pub env: Vec<EnvVar>,
60
61 pub labels: BTreeMap<String, String>,
63
64 pub rlimits: Vec<Rlimit>,
66
67 pub mounts: Vec<VolumeMount>,
69
70 pub patches: Vec<Patch>,
72
73 pub network: CloudNetworkSpec,
75
76 pub init: Option<HandoffInit>,
78
79 pub pull_policy: PullPolicy,
81
82 pub security_profile: SecurityProfile,
84
85 pub lifecycle: SandboxPolicy,
87}
88
89#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
91#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
92#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
93#[serde(default)]
94pub struct CloudSandboxResources {
95 pub vcpus: u8,
97
98 pub memory_mib: u32,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub disk_size_mib: Option<u32>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
113#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
114#[serde(rename_all = "snake_case")]
115pub enum CloudRootfsSource {
116 #[serde(alias = "Bind")]
118 Bind(
119 #[cfg_attr(feature = "ts", ts(type = "string"))]
121 PathBuf,
122 ),
123
124 #[serde(alias = "Oci")]
126 Oci {
127 reference: String,
129 },
130
131 #[serde(alias = "DiskImage")]
133 DiskImage {
134 #[cfg_attr(feature = "ts", ts(type = "string"))]
136 path: PathBuf,
137 format: DiskImageFormat,
139 fstype: Option<String>,
141 },
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
150#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
151#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
152#[serde(default, deny_unknown_fields)]
153pub struct CloudNetworkSpec {
154 pub enabled: bool,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
160 pub policy: Option<NetworkPolicy>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub secrets: Option<SecretsConfig>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub max_connections: Option<usize>,
169}
170
171impl Default for CloudNetworkSpec {
172 fn default() -> Self {
173 Self {
174 enabled: true,
175 policy: None,
176 secrets: None,
177 max_connections: None,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
187#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
188#[serde(default, deny_unknown_fields)]
189pub struct CloudSandboxRuntimeOptions {
190 pub workdir: Option<String>,
192
193 pub shell: Option<String>,
195
196 pub scripts: BTreeMap<String, String>,
198
199 pub entrypoint: Option<Vec<String>>,
201
202 pub cmd: Option<Vec<String>>,
204
205 pub user: Option<String>,
207
208 pub log_level: Option<SandboxLogLevel>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
218#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
220pub struct CloudCreateSandboxResponse {
221 pub id: String,
223 pub org_id: String,
225 pub name: String,
227 pub slug: String,
229 pub status: CloudSandboxStatus,
231 pub spec: CloudSandboxSpec,
233 pub ephemeral: bool,
235 #[cfg_attr(feature = "ts", ts(type = "string"))]
237 pub created_at: DateTime<Utc>,
238 #[serde(default)]
240 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
241 pub started_at: Option<DateTime<Utc>>,
242 #[serde(default)]
244 #[cfg_attr(feature = "ts", ts(type = "string | null"))]
245 pub stopped_at: Option<DateTime<Utc>>,
246 #[serde(default)]
248 pub last_error: Option<String>,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
254#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
255#[serde(rename_all = "snake_case")]
256pub enum CloudSandboxStatus {
257 Created,
259 Starting,
261 Running,
263 Stopping,
265 Stopped,
267 Failed,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
274#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
275pub struct CloudPaginated<T> {
276 pub data: Vec<T>,
278 #[serde(default)]
280 pub next_cursor: Option<String>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
286#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
287pub struct CloudMessageResponse {
288 pub message: String,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
295#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
296pub struct CloudErrorBody {
297 #[serde(default)]
299 pub code: Option<String>,
300 #[serde(default)]
302 pub message: Option<String>,
303 #[serde(default)]
305 pub error: Option<CloudErrorDetails>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
311#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
312pub struct CloudErrorDetails {
313 #[serde(default)]
315 pub code: Option<String>,
316 #[serde(default)]
318 pub message: Option<String>,
319}
320
321impl TryFrom<CloudCreateSandboxRequest> for SandboxSpec {
326 type Error = TypesError;
327
328 fn try_from(req: CloudCreateSandboxRequest) -> TypesResult<Self> {
329 req.spec.try_into()
330 }
331}
332
333impl TryFrom<CloudSandboxSpec> for SandboxSpec {
334 type Error = TypesError;
335
336 fn try_from(spec: CloudSandboxSpec) -> TypesResult<Self> {
337 let disk_size_mib = spec.resources.disk_size_mib;
338 let image = match spec.image {
339 CloudRootfsSource::Oci { reference } => RootfsSource::Oci(OciRootfsSource {
340 reference,
341 upper_size_mib: disk_size_mib,
342 }),
343 CloudRootfsSource::Bind(_) | CloudRootfsSource::DiskImage { .. }
344 if disk_size_mib.is_some() =>
345 {
346 return Err(TypesError::invalid_config(
347 "resources.disk_size_mib is only valid for OCI rootfs",
348 ));
349 }
350 CloudRootfsSource::Bind(path) => RootfsSource::Bind(path),
351 CloudRootfsSource::DiskImage {
352 path,
353 format,
354 fstype,
355 } => RootfsSource::DiskImage {
356 path,
357 format,
358 fstype,
359 },
360 };
361
362 let resources = SandboxResources {
363 vcpus: spec.resources.vcpus,
364 memory_mib: spec.resources.memory_mib,
365 max_vcpus: spec.resources.vcpus,
369 max_memory_mib: spec.resources.memory_mib,
370 };
371
372 let network = NetworkSpec {
375 enabled: spec.network.enabled,
376 policy: spec.network.policy,
377 secrets: spec.network.secrets,
378 max_connections: spec.network.max_connections,
379 ..NetworkSpec::default()
380 };
381 let runtime = SandboxRuntimeOptions {
382 workdir: spec.runtime.workdir,
383 shell: spec.runtime.shell,
384 scripts: spec.runtime.scripts,
385 entrypoint: spec.runtime.entrypoint,
386 cmd: spec.runtime.cmd,
387 user: spec.runtime.user,
388 log_level: spec.runtime.log_level,
389 ..SandboxRuntimeOptions::default()
390 };
391
392 Ok(Self {
393 name: spec.name,
394 image,
395 resources,
396 runtime,
397 env: spec.env,
398 labels: spec.labels,
399 rlimits: spec.rlimits,
400 mounts: spec.mounts,
401 patches: spec.patches,
402 network,
403 init: spec.init,
404 pull_policy: spec.pull_policy,
405 security_profile: spec.security_profile,
406 lifecycle: spec.lifecycle,
407 })
408 }
409}
410
411impl From<SandboxSpec> for CloudCreateSandboxRequest {
412 fn from(spec: SandboxSpec) -> Self {
413 Self { spec: spec.into() }
414 }
415}
416
417impl From<SandboxSpec> for CloudSandboxSpec {
418 fn from(spec: SandboxSpec) -> Self {
419 let (image, disk_size_mib) = match spec.image {
420 RootfsSource::Oci(oci) => (
421 CloudRootfsSource::Oci {
422 reference: oci.reference,
423 },
424 oci.upper_size_mib,
425 ),
426 RootfsSource::Bind(path) => (CloudRootfsSource::Bind(path), None),
427 RootfsSource::DiskImage {
428 path,
429 format,
430 fstype,
431 } => (
432 CloudRootfsSource::DiskImage {
433 path,
434 format,
435 fstype,
436 },
437 None,
438 ),
439 };
440
441 Self {
442 name: spec.name,
443 image,
444 resources: CloudSandboxResources {
445 vcpus: spec.resources.vcpus,
446 memory_mib: spec.resources.memory_mib,
447 disk_size_mib,
448 },
449 runtime: CloudSandboxRuntimeOptions {
450 workdir: spec.runtime.workdir,
451 shell: spec.runtime.shell,
452 scripts: spec.runtime.scripts,
453 entrypoint: spec.runtime.entrypoint,
454 cmd: spec.runtime.cmd,
455 user: spec.runtime.user,
456 log_level: spec.runtime.log_level,
457 },
458 env: spec.env,
459 labels: spec.labels,
460 rlimits: spec.rlimits,
461 mounts: spec.mounts,
462 patches: spec.patches,
463 network: CloudNetworkSpec {
464 enabled: spec.network.enabled,
465 policy: spec.network.policy,
466 secrets: spec.network.secrets,
467 max_connections: spec.network.max_connections,
468 },
469 init: spec.init,
470 pull_policy: spec.pull_policy,
471 security_profile: spec.security_profile,
472 lifecycle: spec.lifecycle,
473 }
474 }
475}
476
477impl Default for CloudSandboxResources {
478 fn default() -> Self {
479 let resources = SandboxResources::default();
480 Self {
481 vcpus: resources.vcpus,
482 memory_mib: resources.memory_mib,
483 disk_size_mib: None,
484 }
485 }
486}
487
488impl CloudRootfsSource {
489 pub fn oci(reference: impl Into<String>) -> Self {
491 Self::Oci {
492 reference: reference.into(),
493 }
494 }
495
496 pub fn oci_reference(&self) -> Option<&str> {
498 match self {
499 Self::Oci { reference } => Some(reference),
500 _ => None,
501 }
502 }
503}
504
505impl Default for CloudRootfsSource {
506 fn default() -> Self {
507 Self::oci(String::new())
508 }
509}
510
511#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::domain::{
519 DEFAULT_SANDBOX_MEMORY_MIB, DEFAULT_SANDBOX_VCPUS, OciRootfsSource, RootfsSource,
520 };
521
522 fn spec(name: &str) -> CloudSandboxSpec {
523 CloudSandboxSpec {
524 name: name.into(),
525 image: CloudRootfsSource::Oci {
526 reference: "python:3.12".into(),
527 },
528 ..Default::default()
529 }
530 }
531
532 #[test]
533 fn create_request_flattens_spec() {
534 let req = CloudCreateSandboxRequest {
535 spec: spec("agent-1"),
536 };
537 let json = serde_json::to_value(&req).unwrap();
538 assert_eq!(json["name"], "agent-1");
540 assert!(json.get("image").is_some());
541
542 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
543 assert_eq!(back.spec.name, "agent-1");
544 }
545
546 #[test]
547 fn create_request_converts_disk_size_to_oci_rootfs() {
548 let mut req = CloudCreateSandboxRequest {
549 spec: spec("agent-1"),
550 };
551 req.spec.resources.disk_size_mib = Some(8192);
552
553 let domain = SandboxSpec::try_from(req).unwrap();
554
555 assert_eq!(domain.resources.vcpus, DEFAULT_SANDBOX_VCPUS);
556 assert_eq!(domain.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
557 match domain.image {
558 RootfsSource::Oci(oci) => {
559 assert_eq!(oci.reference, "python:3.12");
560 assert_eq!(oci.upper_size_mib, Some(8192));
561 }
562 other => panic!("expected OCI rootfs, got {other:?}"),
563 }
564 }
565
566 #[test]
567 fn create_request_rejects_disk_size_for_non_oci_rootfs() {
568 let mut req = CloudCreateSandboxRequest {
569 spec: spec("agent-1"),
570 };
571 req.spec.image = CloudRootfsSource::Bind("/tmp/rootfs".into());
572 req.spec.resources.disk_size_mib = Some(8192);
573
574 let err = SandboxSpec::try_from(req).unwrap_err();
575
576 assert!(err.to_string().contains("disk_size_mib"));
577 }
578
579 #[test]
580 fn domain_spec_converts_oci_size_to_cloud_resources() {
581 let domain = SandboxSpec {
582 name: "agent-1".into(),
583 image: RootfsSource::Oci(OciRootfsSource {
584 reference: "python:3.12".into(),
585 upper_size_mib: Some(8192),
586 }),
587 ..Default::default()
588 };
589
590 let req = CloudCreateSandboxRequest::from(domain);
591
592 assert_eq!(req.spec.resources.disk_size_mib, Some(8192));
593 match req.spec.image {
594 CloudRootfsSource::Oci { reference } => {
595 assert_eq!(reference, "python:3.12");
596 }
597 other => panic!("expected OCI rootfs, got {other:?}"),
598 }
599 }
600
601 #[test]
602 fn create_request_minimal_defaults() {
603 let req = CloudCreateSandboxRequest {
605 spec: spec("agent-1"),
606 };
607 let json = serde_json::to_value(&req).unwrap();
608 let back: CloudCreateSandboxRequest = serde_json::from_value(json).unwrap();
609 assert_eq!(back.spec.name, "agent-1");
610 }
611
612 #[test]
613 fn sandbox_status_round_trips_snake_case() {
614 for status in [
615 CloudSandboxStatus::Created,
616 CloudSandboxStatus::Starting,
617 CloudSandboxStatus::Running,
618 CloudSandboxStatus::Stopping,
619 CloudSandboxStatus::Stopped,
620 CloudSandboxStatus::Failed,
621 ] {
622 let s = serde_json::to_string(&status).unwrap();
623 assert_eq!(
624 serde_json::from_str::<CloudSandboxStatus>(&s).unwrap(),
625 status
626 );
627 }
628 assert_eq!(
629 serde_json::to_string(&CloudSandboxStatus::Starting).unwrap(),
630 "\"starting\""
631 );
632 }
633
634 #[test]
635 fn sandbox_response_round_trips() {
636 let sb = CloudCreateSandboxResponse {
637 id: "00000000-0000-0000-0000-000000000002".into(),
638 org_id: "00000000-0000-0000-0000-000000000001".into(),
639 name: "agent-1".into(),
640 slug: "brave-otter".into(),
641 status: CloudSandboxStatus::Created,
642 spec: spec("agent-1"),
643 ephemeral: true,
644 created_at: "2026-05-17T12:00:00Z".parse().unwrap(),
645 started_at: None,
646 stopped_at: None,
647 last_error: None,
648 };
649 let json = serde_json::to_value(&sb).unwrap();
650 assert_eq!(json["slug"], "brave-otter");
651 assert_eq!(json["name"], "agent-1");
652
653 let back: CloudCreateSandboxResponse = serde_json::from_value(json).unwrap();
654 assert_eq!(back.slug, "brave-otter");
655 assert_eq!(back.status, CloudSandboxStatus::Created);
656 assert_eq!(back.spec.name, "agent-1");
657 assert!(back.started_at.is_none());
658 }
659}