1use std::time::Duration;
9
10use serde_json::{json, Value};
11use time::format_description::well_known::Rfc3339;
12use time::OffsetDateTime;
13
14use crate::error::SailError;
15use crate::http::{HttpCore, IdempotencyKey, Method, RequestSpec};
16use crate::retry::{RetryPolicy, DEFAULT_RETRY_POLICY, NO_RETRY};
17use crate::sailbox::types::{
18 AddListenerWire, CreateSailboxRequest, IngressPort, IngressProtocol, IssuedUserCert,
19 ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo, SailboxListOrder,
20 SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
21 SailboxSpendResponse, SailboxStatus, VolumeInfo, VolumeMount,
22};
23use crate::worker::Listener;
24
25const RESERVED_HTTP_INGRESS_PORTS: &[u32] = &[22, 10000, 10001, 15001, 15002];
30const RESERVED_TCP_INGRESS_PORTS: &[u32] = &[10000, 10001, 15001, 15002];
31const UNAUTHENTICATED_TCP_SERVICE_PORTS: &[u32] = &[5432, 3306, 6379, 27017, 9200, 11211];
34const VOLUME_RESERVED_MOUNT_PATHS: &[&str] =
36 &["/dev", "/proc", "/sys", "/run/sail", "/var/run/sail"];
37
38fn is_cidr_or_ip(value: &str) -> bool {
41 value.parse::<ipnet::IpNet>().is_ok() || value.parse::<std::net::IpAddr>().is_ok()
42}
43
44const SAILBOX_SIZE_RANGES: &[(&str, u32, u32, u32, u32)] =
49 &[("s", 2, 64, 8, 128), ("m", 8, 128, 32, 512)];
50
51const DEFAULT_SIZE_LABEL: &str = "m";
53
54pub fn validate_size_limits(
57 size: Option<&str>,
58 memory_gib: Option<u32>,
59 disk_gib: Option<u32>,
60) -> Result<(), SailError> {
61 let label = size.unwrap_or(DEFAULT_SIZE_LABEL);
62 let Some((_, mem_min, mem_max, disk_min, disk_max)) = SAILBOX_SIZE_RANGES
63 .iter()
64 .find(|(name, ..)| *name == label)
65 .copied()
66 else {
67 return Ok(());
69 };
70 if let Some(memory_gib) = memory_gib {
71 if !(mem_min..=mem_max).contains(&memory_gib) {
72 return Err(SailError::InvalidArgument {
73 message: format!(
74 "memory_gib for size {label} must be between {mem_min} and {mem_max}"
75 ),
76 });
77 }
78 }
79 if let Some(disk_gib) = disk_gib {
80 if !(disk_min..=disk_max).contains(&disk_gib) {
81 return Err(SailError::InvalidArgument {
82 message: format!(
83 "disk_gib for size {label} must be between {disk_min} and {disk_max}"
84 ),
85 });
86 }
87 }
88 Ok(())
89}
90
91pub fn validate_autosleep_timeout(minutes: Option<i64>) -> Result<(), SailError> {
94 if let Some(minutes) = minutes {
95 if !(1..=1440).contains(&minutes) {
96 return Err(SailError::InvalidArgument {
97 message: "autosleep_timeout_minutes must be between 1 and 1440".to_string(),
98 });
99 }
100 }
101 Ok(())
102}
103
104pub(crate) fn normalize_allowlist(entries: &[String]) -> Result<Vec<String>, SailError> {
110 let mut out = Vec::new();
111 let mut seen = std::collections::HashSet::new();
112 for entry in entries {
113 let value = entry.trim();
114 if value.is_empty() {
115 continue;
116 }
117 let normalized = if let Ok(net) = value.parse::<ipnet::IpNet>() {
118 net.trunc().to_string()
119 } else if let Ok(addr) = value.parse::<std::net::IpAddr>() {
120 match addr {
121 std::net::IpAddr::V4(v4) => format!("{v4}/32"),
122 std::net::IpAddr::V6(v6) => format!("{v6}/128"),
123 }
124 } else if value.contains('/') {
125 return Err(SailError::InvalidArgument {
126 message: format!("allowlist entry {value:?} is not a valid CIDR"),
127 });
128 } else {
129 value.to_string()
130 };
131 if seen.insert(normalized.clone()) {
132 out.push(normalized);
133 }
134 }
135 Ok(out)
136}
137
138pub fn validate_ingress_ports(ports: &[IngressPort]) -> Result<(), SailError> {
143 let invalid = |message: String| Err(SailError::InvalidArgument { message });
144 let mut seen = std::collections::HashSet::new();
145 for port in ports {
146 if port.guest_port < 1 || port.guest_port > 65535 {
147 return invalid("ingress_ports must be between 1 and 65535".to_string());
148 }
149 let is_tcp = matches!(port.protocol, IngressProtocol::Tcp);
150 let mut has_allowlist = false;
151 for entry in &port.allowlist {
152 let value = entry.trim();
153 if value.is_empty() {
154 continue;
155 }
156 has_allowlist = true;
157 let is_cidr = is_cidr_or_ip(value);
158 if value.contains('/') && !is_cidr {
160 return invalid(format!("allowlist entry {value:?} is not a valid CIDR"));
161 }
162 if is_tcp && !is_cidr {
169 return invalid(format!(
170 "allowlist entry {value:?}: app-name entries are not supported for tcp \
171 listeners; tcp allowlists must be CIDR prefixes"
172 ));
173 }
174 }
175 let reserved = if is_tcp {
176 RESERVED_TCP_INGRESS_PORTS
177 } else {
178 RESERVED_HTTP_INGRESS_PORTS
179 };
180 if reserved.contains(&port.guest_port) {
181 if port.guest_port == 22 {
182 return invalid(
183 "guest port 22 is reserved for ssh and cannot be exposed as an http port; \
184 expose it as raw TCP instead"
185 .to_string(),
186 );
187 }
188 if port.guest_port == 10000 || port.guest_port == 10001 {
189 return invalid(format!(
190 "guest port {} is reserved for the in-guest sail agent and cannot be exposed \
191 as an ingress port",
192 port.guest_port
193 ));
194 }
195 let proto = if is_tcp { "tcp" } else { "http" };
196 return invalid(format!(
197 "ingress_ports contains reserved {proto} port {}",
198 port.guest_port
199 ));
200 }
201 if !seen.insert(port.guest_port) {
202 return invalid(format!(
203 "ingress_ports guest port {} must be unique",
204 port.guest_port
205 ));
206 }
207 if is_tcp && UNAUTHENTICATED_TCP_SERVICE_PORTS.contains(&port.guest_port) && !has_allowlist
208 {
209 return invalid(format!(
210 "guest port {} is a well-known unauthenticated service port; exposing it as raw \
211 public TCP with no source restriction is a common breach vector. Pass an \
212 allowlist of CIDR prefixes to restrict source IPs (use [\"0.0.0.0/0\", \"::/0\"] \
213 to allow every source)",
214 port.guest_port
215 ));
216 }
217 }
218 Ok(())
219}
220
221fn normalize_posix_path(path: &str) -> String {
225 let is_absolute = path.starts_with('/');
226 let mut parts: Vec<&str> = Vec::new();
227 for segment in path.split('/') {
228 match segment {
229 "" | "." => {}
230 ".." => {
231 if parts.last().is_some_and(|&last| last != "..") {
232 parts.pop();
233 } else if !is_absolute {
234 parts.push("..");
235 }
236 }
237 other => parts.push(other),
238 }
239 }
240 let joined = parts.join("/");
241 if is_absolute {
242 format!("/{joined}")
243 } else if joined.is_empty() {
244 ".".to_string()
245 } else {
246 joined
247 }
248}
249
250fn mount_paths_overlap(a: &str, b: &str) -> bool {
252 a == b || a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/"))
253}
254
255pub fn validate_volume_mounts(mounts: &[VolumeMount]) -> Result<(), SailError> {
259 let invalid = |message: String| Err(SailError::InvalidArgument { message });
260 let mut seen: Vec<String> = Vec::new();
261 for mount in mounts {
262 if mount.volume_id.trim().is_empty() {
263 return invalid("volume mount volume_id must not be empty".to_string());
264 }
265 let path = normalize_posix_path(mount.mount_path.trim());
266 if !path.starts_with('/') {
267 return invalid("volume mount paths must be absolute".to_string());
268 }
269 if path == "/" {
270 return invalid("volume mount path must not be filesystem root".to_string());
271 }
272 if VOLUME_RESERVED_MOUNT_PATHS
273 .iter()
274 .any(|reserved| mount_paths_overlap(&path, reserved))
275 {
276 return invalid(format!("volume mount path {path:?} is reserved"));
277 }
278 if seen.iter().any(|other| mount_paths_overlap(&path, other)) {
279 return invalid("volume mount paths must not overlap".to_string());
280 }
281 seen.push(path);
282 }
283 Ok(())
284}
285
286#[derive(Debug, Clone, serde::Serialize)]
288pub struct UpgradeResult {
289 pub applied: bool,
292 pub status: SailboxStatus,
294}
295
296pub struct SailboxApi<'a> {
298 http: &'a HttpCore,
299}
300
301impl<'a> SailboxApi<'a> {
302 pub fn new(http: &'a HttpCore) -> SailboxApi<'a> {
304 SailboxApi { http }
305 }
306
307 pub async fn create(
325 &self,
326 req: &CreateSailboxRequest,
327 timeout: Option<Duration>,
328 ) -> Result<SailboxHandle, SailError> {
329 validate_size_limits(req.size.map(|s| s.as_str()), req.memory_gib, req.disk_gib)?;
330 validate_ingress_ports(&req.ingress_ports)?;
331 validate_autosleep_timeout(req.autosleep_timeout_minutes)?;
332 validate_volume_mounts(&req.volume_mounts)?;
333 let mut ingress_ports = req.ingress_ports.clone();
334 for port in &mut ingress_ports {
335 port.allowlist = normalize_allowlist(&port.allowlist)?;
336 }
337 let image = serde_json::to_value(&req.image).map_err(|e| SailError::Internal {
341 message: format!("failed to serialize image spec: {e}"),
342 })?;
343 let mut body = json!({
344 "app_id": req.app_id,
345 "name": req.name,
346 "ingress_ports": ingress_ports,
347 "volume_mounts": req.volume_mounts,
348 "image": image,
349 });
350 if let Some(size) = req.size {
351 body["size"] = json!(size.as_str());
352 }
353 if let Some(memory_gib) = req.memory_gib {
354 body["memory_limit_gib"] = json!(memory_gib);
355 }
356 if let Some(disk_gib) = req.disk_gib {
357 body["state_disk_limit_gib"] = json!(disk_gib);
358 }
359 if let Some(timeout) = req.autosleep_timeout_minutes {
360 body["autosleep_timeout_min"] = json!(timeout);
361 }
362 let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
363 message: format!("failed to serialize request body: {e}"),
364 })?;
365 let (status, data) = self
366 .request(
367 Method::Post,
368 "/v1/sailboxes",
369 &[],
370 Some(bytes),
371 DEFAULT_RETRY_POLICY,
372 timeout.map(|d| d.as_secs_f64()),
373 )
374 .await?;
375 raise_for_create_status(status, &data)?;
378 if status_of(&data) == SailboxStatus::Failed {
379 return Err(SailError::Creation {
380 message: format!(
381 "Sailbox creation failed: {}",
382 data.get("error_message")
383 .and_then(Value::as_str)
384 .unwrap_or("")
385 ),
386 status,
387 body: data,
388 });
389 }
390 require_status(&data, SailboxStatus::Running, status).map_err(|_| SailError::Creation {
391 message: format!(
392 "Sailbox creation returned unexpected status: {}",
393 data.get("status").and_then(Value::as_str).unwrap_or("")
394 ),
395 status,
396 body: data.clone(),
397 })?;
398 Ok(handle_from(&data, &req.name))
399 }
400
401 pub async fn get(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
403 let (status, data) = self
404 .get_request(&format!("/v1/sailboxes/{sailbox_id}"), &[])
405 .await?;
406 raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
407 info_from(data)
408 }
409
410 pub async fn list(&self, query: &ListSailboxesQuery) -> Result<SailboxPage, SailError> {
415 let mut params: Vec<(String, String)> = vec![
416 ("limit".to_string(), query.limit.to_string()),
417 ("offset".to_string(), query.offset.to_string()),
418 ];
419 if query.order != SailboxListOrder::Activity {
420 params.push(("order".to_string(), query.order.as_str().to_string()));
421 }
422 if let Some(app) = &query.app_id {
423 params.push(("app".to_string(), app.clone()));
424 }
425 if let Some(s) = &query.status {
426 params.push(("status".to_string(), s.as_str().to_string()));
427 }
428 if let Some(s) = &query.search {
429 params.push(("search".to_string(), s.clone()));
430 }
431 if let Some(v) = query.max_guest_schema_version {
432 params.push(("max_guest_schema_version".to_string(), v.to_string()));
433 }
434 let (status, data) = self.get_request("/v1/sailboxes", ¶ms).await?;
435 raise_api_error(status, &data, "")?;
436 let items = data
437 .get("data")
438 .and_then(Value::as_array)
439 .ok_or_else(|| missing_field("data"))?
440 .iter()
441 .cloned()
442 .map(info_from)
443 .collect::<Result<Vec<_>, _>>()?;
444 let total = int_field(&data, "total").unwrap_or(items.len() as i64);
448 Ok(SailboxPage {
449 limit: int_field(&data, "limit").unwrap_or(query.limit),
450 offset: int_field(&data, "offset").unwrap_or(query.offset),
451 total,
452 has_more: data
453 .get("has_more")
454 .and_then(Value::as_bool)
455 .unwrap_or(false),
456 items,
457 })
458 }
459
460 pub async fn spend(
462 &self,
463 query: &SailboxSpendQuery,
464 ) -> Result<SailboxSpendResponse, SailError> {
465 let mut params: Vec<(String, String)> = Vec::new();
466 if let Some(app_id) = &query.app_id {
467 params.push(("app_id".to_string(), app_id.clone()));
468 }
469 if let Some(sailbox_id) = &query.sailbox_id {
470 params.push(("sailbox_id".to_string(), sailbox_id.clone()));
471 }
472 if let Some(from) = query.from {
473 params.push((
474 "from".to_string(),
475 from.format(&Rfc3339).map_err(|err| SailError::Internal {
476 message: format!("failed to format spend start timestamp: {err}"),
477 })?,
478 ));
479 }
480 if let Some(to) = query.to {
481 params.push((
482 "to".to_string(),
483 to.format(&Rfc3339).map_err(|err| SailError::Internal {
484 message: format!("failed to format spend end timestamp: {err}"),
485 })?,
486 ));
487 }
488 let (status, data) = self.get_request("/v1/sailboxes/spend", ¶ms).await?;
489 raise_api_error(status, &data, "Sailbox spend")?;
490 serde_json::from_value(data).map_err(|err| SailError::Internal {
491 message: format!("failed to parse sailbox spend response: {err}"),
492 })
493 }
494
495 pub async fn metrics(
497 &self,
498 sailbox_id: &str,
499 query: &SailboxMetricsQuery,
500 ) -> Result<SailboxMetricsResponse, SailError> {
501 let params = vec![("range".to_string(), query.range.clone())];
502 let (status, data) = self
503 .get_request(&format!("/v1/sailboxes/{sailbox_id}/metrics"), ¶ms)
504 .await?;
505 raise_api_error(status, &data, "Sailbox metrics")?;
506 serde_json::from_value(data).map_err(|err| SailError::Internal {
507 message: format!("failed to parse sailbox metrics response: {err}"),
508 })
509 }
510
511 pub async fn terminate(&self, sailbox_id: &str) -> Result<(), SailError> {
513 let (status, data) = self
514 .post(&format!("/v1/sailboxes/{sailbox_id}/terminate"), &json!({}))
515 .await?;
516 if status == 404 && is_resource_not_found(&data) {
517 return Ok(());
518 }
519 raise_api_error(status, &data, "")
520 }
521
522 pub async fn pause(&self, sailbox_id: &str) -> Result<(), SailError> {
526 self.stop(sailbox_id, "pause", SailboxStatus::Paused).await
527 }
528
529 pub async fn sleep(&self, sailbox_id: &str) -> Result<(), SailError> {
533 self.stop(sailbox_id, "sleep", SailboxStatus::Sleeping)
534 .await
535 }
536
537 async fn stop(
538 &self,
539 sailbox_id: &str,
540 action: &str,
541 expected: SailboxStatus,
542 ) -> Result<(), SailError> {
543 let (status, data) = self
544 .post(&format!("/v1/sailboxes/{sailbox_id}/{action}"), &json!({}))
545 .await?;
546 raise_api_error(status, &data, "")?;
547 require_status(&data, expected, status)
548 }
549
550 pub async fn resume(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
556 let (status, data) = self
557 .post(&format!("/v1/sailboxes/{sailbox_id}/resume"), &json!({}))
558 .await?;
559 raise_api_error(status, &data, "")?;
560 if data.get("resume_state").and_then(Value::as_str) == Some("terminal_unavailable") {
561 return Err(SailError::NotFound {
564 message: data
565 .get("error_message")
566 .and_then(Value::as_str)
567 .filter(|s| !s.is_empty())
568 .unwrap_or("sailbox cannot be resumed")
569 .to_string(),
570 });
571 }
572 require_status(&data, SailboxStatus::Running, status)?;
573 Ok(handle_from(&data, ""))
574 }
575
576 pub async fn checkpoint(
583 &self,
584 sailbox_id: &str,
585 name: Option<&str>,
586 ttl_seconds: Option<i64>,
587 ) -> Result<SailboxCheckpoint, SailError> {
588 let mut body = json!({});
589 if let Some(name) = name {
590 body["name"] = json!(name);
591 }
592 if let Some(ttl) = ttl_seconds {
593 if ttl <= 0 {
594 return Err(SailError::InvalidArgument {
595 message: "ttl_seconds must be greater than 0".to_string(),
596 });
597 }
598 body["ttl_seconds"] = json!(ttl);
599 }
600 let (status, data) = self
601 .post(&format!("/v1/sailboxes/{sailbox_id}/checkpoint"), &body)
602 .await?;
603 raise_api_error(status, &data, "")?;
604 let checkpoint_id = data
605 .get("checkpoint_id")
606 .and_then(Value::as_str)
607 .filter(|s| !s.is_empty());
608 let checkpoint_id = if let Some(id) = checkpoint_id {
609 id.to_string()
610 } else {
611 require_status(&data, SailboxStatus::Running, status)?;
612 return Err(SailError::Api {
613 status,
614 message: "checkpoint sailbox did not return checkpoint_id".to_string(),
615 body: data,
616 });
617 };
618 Ok(SailboxCheckpoint {
619 checkpoint_id,
620 sailbox_id: data
621 .get("sailbox_id")
622 .and_then(Value::as_str)
623 .unwrap_or(sailbox_id)
624 .to_string(),
625 checkpoint_generation: data
626 .get("checkpoint_generation")
627 .and_then(Value::as_i64)
628 .unwrap_or(0),
629 expires_at: data
630 .get("expires_at")
631 .and_then(Value::as_str)
632 .and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()),
633 status: status_of(&data),
634 })
635 }
636
637 pub async fn from_checkpoint(
644 &self,
645 checkpoint_id: &str,
646 name: Option<&str>,
647 timeout_seconds: Option<i64>,
648 ) -> Result<SailboxHandle, SailError> {
649 if checkpoint_id.is_empty() {
650 return Err(SailError::InvalidArgument {
651 message: "checkpoint_id is required".to_string(),
652 });
653 }
654 let mut body = json!({ "checkpoint_id": checkpoint_id });
655 if let Some(name) = name {
656 body["name"] = json!(name);
657 }
658 if let Some(timeout) = timeout_seconds {
659 if timeout <= 0 {
660 return Err(SailError::InvalidArgument {
661 message: "timeout must be greater than 0".to_string(),
662 });
663 }
664 body["timeout_seconds"] = json!(timeout);
665 }
666 let (status, data) = self.post("/v1/sailboxes/from_checkpoint", &body).await?;
667 raise_api_error(status, &data, "")?;
668 require_status(&data, SailboxStatus::Running, status)?;
669 Ok(handle_from(&data, name.unwrap_or("")))
670 }
671
672 pub async fn upgrade(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
677 let (status, data) = self
678 .post(&format!("/v1/sailboxes/{sailbox_id}/upgrade"), &json!({}))
679 .await?;
680 raise_api_error(status, &data, "")?;
681 Ok(UpgradeResult {
682 applied: data
683 .get("applied")
684 .and_then(Value::as_bool)
685 .unwrap_or(false),
686 status: status_of(&data),
687 })
688 }
689
690 pub async fn expose(
694 &self,
695 sailbox_id: &str,
696 guest_port: u32,
697 protocol: IngressProtocol,
698 allowlist: &[String],
699 ) -> Result<crate::worker::Listener, SailError> {
700 let allowlist = normalize_allowlist(allowlist)?;
703 let body = json!({
704 "guest_port": guest_port,
705 "protocol": protocol.as_str(),
706 "allowlist": allowlist,
707 });
708 let (status, data) = self
709 .post(&format!("/v1/sailboxes/{sailbox_id}/listeners"), &body)
710 .await?;
711 raise_api_error(status, &data, "")?;
712 serde_json::from_value::<AddListenerWire>(data)
713 .map(crate::worker::Listener::from)
714 .map_err(|e| SailError::Internal {
715 message: format!("failed to parse add-listener response: {e}"),
716 })
717 }
718
719 pub async fn unexpose(&self, sailbox_id: &str, guest_port: u32) -> Result<(), SailError> {
725 let (status, data) = self
729 .request(
730 Method::Delete,
731 &format!("/v1/sailboxes/{sailbox_id}/listeners/{guest_port}"),
732 &[],
733 None,
734 NO_RETRY,
735 None,
736 )
737 .await?;
738 raise_api_error(status, &data, "")
739 }
740
741 pub async fn list_listeners(&self, sailbox_id: &str) -> Result<Vec<Listener>, SailError> {
745 let (status, data) = self
746 .get_request(&format!("/v1/sailboxes/{sailbox_id}/listeners"), &[])
747 .await?;
748 raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
749 let rows = data.get("data").cloned().unwrap_or(Value::Null);
750 serde_json::from_value(rows).map_err(|e| SailError::Internal {
751 message: format!("failed to parse listeners: {e}"),
752 })
753 }
754
755 pub async fn get_listener(
759 &self,
760 sailbox_id: &str,
761 guest_port: u32,
762 ) -> Result<Listener, SailError> {
763 let (status, data) = self
764 .get_request(
765 &format!("/v1/sailboxes/{sailbox_id}/listeners/{guest_port}"),
766 &[],
767 )
768 .await?;
769 raise_api_error(
770 status,
771 &data,
772 &format!("Listener {sailbox_id:?}:{guest_port}"),
773 )?;
774 serde_json::from_value(data).map_err(|e| SailError::Internal {
775 message: format!("failed to parse listener: {e}"),
776 })
777 }
778
779 pub async fn ingress_auth_headers(
781 &self,
782 sailbox_id: &str,
783 ) -> Result<Vec<(String, String)>, SailError> {
784 let (status, data) = self
785 .get_request(&format!("/v1/sailboxes/{sailbox_id}/ingress-auth"), &[])
786 .await?;
787 raise_api_error(status, &data, &format!("Sailbox {sailbox_id:?}"))?;
788 let headers = data
789 .get("headers")
790 .and_then(Value::as_object)
791 .ok_or_else(|| missing_field("headers"))?;
792 Ok(headers
793 .iter()
794 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
795 .collect())
796 }
797
798 pub async fn get_volume(
806 &self,
807 name: &str,
808 mint_if_missing: bool,
809 ) -> Result<VolumeInfo, SailError> {
810 if name.trim().is_empty() {
811 return Err(SailError::InvalidArgument {
812 message: "name is required".to_string(),
813 });
814 }
815 let body = json!({
816 "name": name,
817 "backend": "nfs",
818 "mint_if_missing": mint_if_missing,
819 "create_if_missing": mint_if_missing,
820 });
821 let (status, data) = self.post("/v1/sailbox-volumes/get", &body).await?;
822 raise_api_error(status, &data, "")?;
823 volume_from(data)
824 }
825
826 pub async fn list_volumes(
831 &self,
832 max_objects: Option<i64>,
833 ) -> Result<Vec<VolumeInfo>, SailError> {
834 let mut query: Vec<(String, String)> = Vec::new();
835 if let Some(max) = max_objects {
836 if max < 0 {
837 return Err(SailError::InvalidArgument {
838 message: "max_objects cannot be negative".to_string(),
839 });
840 }
841 query.push(("max_objects".to_string(), max.to_string()));
842 }
843 let (status, data) = self.get_request("/v1/sailbox-volumes", &query).await?;
844 raise_api_error(status, &data, "")?;
845 data.get("volumes").and_then(Value::as_array).map_or_else(
846 || Ok(Vec::new()),
847 |rows| rows.iter().cloned().map(volume_from).collect(),
848 )
849 }
850
851 pub async fn delete_volume(
854 &self,
855 volume_id: &str,
856 allow_missing: bool,
857 ) -> Result<Option<VolumeInfo>, SailError> {
858 if volume_id.trim().is_empty() {
859 return Err(SailError::InvalidArgument {
860 message: "volume_id is required".to_string(),
861 });
862 }
863 let path = format!("/v1/sailbox-volumes/{}", volume_id.trim());
864 let query: Vec<(String, String)> = if allow_missing {
865 vec![("allow_missing".to_string(), "true".to_string())]
866 } else {
867 Vec::new()
868 };
869 let policy = if allow_missing {
870 DEFAULT_RETRY_POLICY
871 } else {
872 NO_RETRY
873 };
874 let (status, data) = self
875 .request(
876 Method::Delete,
877 &path,
878 &query,
879 None,
880 policy,
881 None,
882 )
883 .await?;
884 if status == 204 {
885 return Ok(None);
886 }
887 raise_api_error(status, &data, "")?;
888 Ok(Some(volume_from(data)?))
889 }
890
891 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
895 let (status, data) = self.get_request("/v1/ssh/ca", &[]).await?;
896 raise_api_error(status, &data, "ssh ca")?;
897 str_field(&data, "public_key")
898 }
899
900 pub async fn issue_user_cert(
908 &self,
909 public_key: &str,
910 timeout: Option<f64>,
911 ) -> Result<IssuedUserCert, SailError> {
912 let body = json!({ "public_key": public_key });
913 let bytes = serde_json::to_vec(&body).map_err(|e| SailError::Internal {
914 message: format!("failed to serialize request body: {e}"),
915 })?;
916 let policy = if timeout.is_some() {
917 NO_RETRY
918 } else {
919 DEFAULT_RETRY_POLICY
920 };
921 let (status, data) = self
922 .request(
923 Method::Post,
924 "/v1/ssh/certificate",
925 &[],
926 Some(bytes),
927 policy,
928 timeout,
929 )
930 .await?;
931 raise_api_error(status, &data, "ssh certificate")?;
932 Ok(IssuedUserCert {
933 certificate: str_field(&data, "certificate")?,
934 key_id: data
935 .get("key_id")
936 .and_then(Value::as_str)
937 .unwrap_or_default()
938 .to_string(),
939 })
940 }
941
942 async fn post(&self, path: &str, body: &Value) -> Result<(u16, Value), SailError> {
945 let bytes = serde_json::to_vec(body).map_err(|e| SailError::Internal {
946 message: format!("failed to serialize request body: {e}"),
947 })?;
948 self.request(
949 Method::Post,
950 path,
951 &[],
952 Some(bytes),
953 DEFAULT_RETRY_POLICY,
954 None,
955 )
956 .await
957 }
958
959 async fn get_request(
960 &self,
961 path: &str,
962 query: &[(String, String)],
963 ) -> Result<(u16, Value), SailError> {
964 self.request(
965 Method::Get,
966 path,
967 query,
968 None,
969 DEFAULT_RETRY_POLICY,
970 None,
971 )
972 .await
973 }
974
975 async fn request(
976 &self,
977 method: Method,
978 path: &str,
979 query: &[(String, String)],
980 body: Option<Vec<u8>>,
981 policy: RetryPolicy,
982 timeout: Option<f64>,
983 ) -> Result<(u16, Value), SailError> {
984 let spec = RequestSpec {
985 method,
986 path: path.to_string(),
987 query: query.to_vec(),
988 body,
989 extra_headers: Vec::new(),
990 timeout,
991 policy,
992 idempotency_key: IdempotencyKey::Auto,
993 };
994 self.http.request(&spec).await
995 }
996}
997
998fn handle_from(data: &Value, fallback_name: &str) -> SailboxHandle {
999 SailboxHandle {
1000 sailbox_id: data
1001 .get("sailbox_id")
1002 .and_then(Value::as_str)
1003 .unwrap_or("")
1004 .to_string(),
1005 name: data
1006 .get("name")
1007 .and_then(Value::as_str)
1008 .unwrap_or(fallback_name)
1009 .to_string(),
1010 status: status_of(data),
1011 worker_address: data
1012 .get("worker_address")
1013 .and_then(Value::as_str)
1014 .unwrap_or("")
1015 .to_string(),
1016 exec_endpoint: data
1017 .get("exec_proxy_endpoint")
1018 .and_then(Value::as_str)
1019 .unwrap_or("")
1020 .to_string(),
1021 }
1022}
1023
1024fn info_from(data: Value) -> Result<SailboxInfo, SailError> {
1025 serde_json::from_value::<SailboxInfo>(data).map_err(|e| SailError::Internal {
1026 message: format!("failed to parse sailbox: {e}"),
1027 })
1028}
1029
1030fn volume_from(data: Value) -> Result<VolumeInfo, SailError> {
1031 serde_json::from_value::<VolumeInfo>(data).map_err(|e| SailError::Internal {
1032 message: format!("failed to parse volume: {e}"),
1033 })
1034}
1035
1036fn int_field(data: &Value, key: &str) -> Result<i64, SailError> {
1037 data.get(key)
1038 .and_then(Value::as_i64)
1039 .ok_or_else(|| missing_field(key))
1040}
1041
1042fn str_field(data: &Value, key: &str) -> Result<String, SailError> {
1043 data.get(key)
1044 .and_then(Value::as_str)
1045 .filter(|s| !s.is_empty())
1046 .map(str::to_string)
1047 .ok_or_else(|| missing_field(key))
1048}
1049
1050fn missing_field(key: &str) -> SailError {
1051 SailError::Internal {
1052 message: format!("API response missing field {key:?}"),
1053 }
1054}
1055
1056fn is_resource_not_found(data: &Value) -> bool {
1058 data.get("error")
1059 .and_then(|e| e.get("type"))
1060 .and_then(Value::as_str)
1061 == Some("not_found_error")
1062}
1063
1064fn raise_api_error(status: u16, data: &Value, context: &str) -> Result<(), SailError> {
1068 if status < 300 {
1069 return Ok(());
1070 }
1071 let mut message = crate::http::api_error_message(data, "request failed");
1072 if !context.is_empty() {
1073 message = format!("{context}: {message}");
1074 }
1075 if status == 404 && is_resource_not_found(data) {
1076 return Err(SailError::NotFound { message });
1077 }
1078 if status == 401 || status == 403 {
1079 return Err(SailError::PermissionDenied { message });
1080 }
1081 if status == 400 {
1082 return Err(SailError::InvalidArgument { message });
1083 }
1084 Err(SailError::Api {
1085 status,
1086 message,
1087 body: data.clone(),
1088 })
1089}
1090
1091fn raise_for_create_status(status: u16, data: &Value) -> Result<(), SailError> {
1093 if status < 300 {
1094 return Ok(());
1095 }
1096 let message = crate::http::api_error_message(data, "request failed");
1097 if status == 401 || status == 403 {
1098 return Err(SailError::PermissionDenied { message });
1099 }
1100 Err(SailError::Creation {
1101 message,
1102 status,
1103 body: data.clone(),
1104 })
1105}
1106
1107fn status_of(data: &Value) -> SailboxStatus {
1109 SailboxStatus::from(data.get("status").and_then(Value::as_str).unwrap_or(""))
1110}
1111
1112fn require_status(data: &Value, expected: SailboxStatus, status: u16) -> Result<(), SailError> {
1114 let got = status_of(data);
1115 if got == expected {
1116 return Ok(());
1117 }
1118 Err(SailError::Api {
1119 status,
1120 message: format!("lifecycle call returned unexpected status: {got}"),
1121 body: data.clone(),
1122 })
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128
1129 fn port(guest_port: u32, protocol: IngressProtocol, allowlist: &[&str]) -> IngressPort {
1130 IngressPort {
1131 guest_port,
1132 protocol,
1133 allowlist: allowlist.iter().map(ToString::to_string).collect(),
1134 }
1135 }
1136
1137 fn mount(volume_id: &str, mount_path: &str) -> VolumeMount {
1138 VolumeMount {
1139 volume_id: volume_id.to_string(),
1140 mount_path: mount_path.to_string(),
1141 }
1142 }
1143
1144 #[test]
1145 fn ingress_ports_accepts_valid_and_rejects_the_rules() {
1146 assert!(validate_ingress_ports(&[
1147 port(8080, IngressProtocol::Http, &[]),
1148 port(22, IngressProtocol::Tcp, &[]),
1149 port(5432, IngressProtocol::Tcp, &["10.0.0.0/8"]),
1150 ])
1151 .is_ok());
1152 assert!(validate_ingress_ports(&[port(22, IngressProtocol::Http, &[])]).is_err());
1154 assert!(validate_ingress_ports(&[port(10000, IngressProtocol::Tcp, &[])]).is_err());
1156 assert!(validate_ingress_ports(&[port(70000, IngressProtocol::Http, &[])]).is_err());
1158 assert!(validate_ingress_ports(&[
1160 port(8080, IngressProtocol::Http, &[]),
1161 port(8080, IngressProtocol::Tcp, &["0.0.0.0/0"]),
1162 ])
1163 .is_err());
1164 assert!(validate_ingress_ports(&[port(9000, IngressProtocol::Tcp, &["my-app"])]).is_err());
1166 assert!(validate_ingress_ports(&[port(9000, IngressProtocol::Http, &["my-app"])]).is_ok());
1168 assert!(validate_ingress_ports(&[port(5432, IngressProtocol::Tcp, &[])]).is_err());
1170 assert!(
1172 validate_ingress_ports(&[port(9000, IngressProtocol::Http, &["1.2.3.4/33"])]).is_err()
1173 );
1174 }
1175
1176 #[test]
1177 fn volume_mounts_reject_root_reserved_and_overlaps() {
1178 assert!(validate_volume_mounts(&[mount("vol_1", "/mnt/data")]).is_ok());
1179 assert!(validate_volume_mounts(&[mount("", "/mnt/data")]).is_err());
1180 assert!(validate_volume_mounts(&[mount("vol_1", "relative")]).is_err());
1181 assert!(validate_volume_mounts(&[mount("vol_1", "/")]).is_err());
1182 assert!(validate_volume_mounts(&[mount("vol_1", "/proc/x")]).is_err());
1183 assert!(
1184 validate_volume_mounts(&[mount("vol_1", "/mnt"), mount("vol_2", "/mnt/cache"),])
1185 .is_err()
1186 );
1187 assert!(validate_volume_mounts(&[mount("vol_1", "/mnt/../proc")]).is_err());
1189 }
1190
1191 #[test]
1192 fn info_reads_resource_fields_and_defaults_optionals() {
1193 let info = info_from(json!({
1194 "sailbox_id": "sb-1", "app_id": "app-1", "app_name": "a", "name": "n",
1195 "image_id": "img-1",
1196 "status": "running", "memory_mib": 2048, "vcpu_count": 4,
1197 "state_disk_size_gib": 10,
1198 "cpu_requested_vcpu": 2, "cpu_used_vcpu": 1.5,
1199 "memory_requested_bytes": 1024, "memory_used_bytes": 512,
1200 "disk_requested_bytes": 4096, "disk_used_bytes": 2048,
1201 "architecture": "amd64", "checkpoint_generation": 7,
1202 "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z"
1203 }))
1204 .unwrap();
1205 assert_eq!(info.cpu_requested_vcpu, 2);
1207 assert_eq!(info.memory_used_bytes, 512);
1208 assert_eq!(info.checkpoint_generation, 7);
1209 assert_eq!(info.guest_schema_version, None);
1211 assert_eq!(info.error_message, None);
1212 assert_eq!(info.started_at, None);
1213 }
1214
1215 #[test]
1216 fn api_error_ladder_maps_statuses() {
1217 let not_found = json!({"error": {"type": "not_found_error", "message": "gone"}});
1218 assert!(matches!(
1219 raise_api_error(404, ¬_found, ""),
1220 Err(SailError::NotFound { .. })
1221 ));
1222 let route_404 = json!({"error": {"message": "no route"}});
1224 assert!(matches!(
1225 raise_api_error(404, &route_404, ""),
1226 Err(SailError::Api { .. })
1227 ));
1228 let auth = json!({"error": {"message": "nope"}});
1229 assert!(matches!(
1230 raise_api_error(403, &auth, ""),
1231 Err(SailError::PermissionDenied { .. })
1232 ));
1233 assert!(matches!(
1234 raise_api_error(400, &auth, ""),
1235 Err(SailError::InvalidArgument { .. })
1236 ));
1237 assert!(matches!(
1238 raise_api_error(503, &auth, ""),
1239 Err(SailError::Api { status: 503, .. })
1240 ));
1241 assert!(raise_api_error(200, &json!({}), "").is_ok());
1242 }
1243
1244 #[test]
1245 fn create_ladder_maps_non_auth_to_creation() {
1246 let body = json!({"error": {"message": "no capacity"}});
1247 assert!(matches!(
1248 raise_for_create_status(503, &body),
1249 Err(SailError::Creation { .. })
1250 ));
1251 assert!(matches!(
1252 raise_for_create_status(401, &body),
1253 Err(SailError::PermissionDenied { .. })
1254 ));
1255 }
1256
1257 #[test]
1262 fn image_spec_serializes_to_canonical_proto_json() {
1263 use crate::image::{
1264 BaseImage, ImageArchitecture, ImageBuildStep, ImageSpec, PackageInstall, RunCommand,
1265 };
1266 let spec = ImageSpec {
1267 base: Some(BaseImage::Debian),
1268 architecture: ImageArchitecture::Arm64,
1269 python_version: "3.12".to_string(),
1270 build_steps: vec![
1271 ImageBuildStep::AptInstall(PackageInstall {
1272 packages: vec!["git".to_string(), "curl".to_string()],
1273 }),
1274 ImageBuildStep::RunCommand(RunCommand {
1275 command: "echo hi".to_string(),
1276 }),
1277 ],
1278 ..Default::default()
1279 };
1280 let json = serde_json::to_value(&spec).unwrap();
1281 assert_eq!(json["base"], json!("BASE_IMAGE_DEBIAN"));
1282 assert_eq!(json["architecture"], json!("IMAGE_ARCHITECTURE_ARM64"));
1283 assert_eq!(json["pythonVersion"], json!("3.12"));
1284 assert_eq!(
1285 json["buildSteps"][0]["aptInstall"]["packages"][0],
1286 json!("git")
1287 );
1288 assert_eq!(
1289 json["buildSteps"][1]["runCommand"]["command"],
1290 json!("echo hi")
1291 );
1292
1293 let devbox = ImageSpec {
1294 base: Some(BaseImage::Devbox),
1295 architecture: ImageArchitecture::Arm64,
1296 ..Default::default()
1297 };
1298 let devbox_json = serde_json::to_value(&devbox).unwrap();
1299 assert_eq!(devbox_json["base"], json!("BASE_IMAGE_DEVBOX"));
1300 }
1301}