Skip to main content

sail/sailbox/
api.rs

1//! Typed client over the sailbox HTTP API (lifecycle, listeners, and volumes).
2//!
3//! Each method builds the request body, runs the HTTP call (with retry +
4//! Idempotency-Key from [`HttpCore`]), maps a non-2xx response onto the
5//! canonical [`SailError`] taxonomy, validates the echoed status, and
6//! deserializes the body into a typed struct.
7
8use 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
25// Platform-reserved guest ports, per protocol. 22 = ssh; 10000/10001 = the guest
26// agent's control/streaming listeners; 15001/15002 = the in-guest tcp-sidecar.
27// HTTP ingress reserves ssh plus the infra ports; TCP intentionally allows 22
28// (exposing sshd is the canonical use) but still reserves the rest.
29const RESERVED_HTTP_INGRESS_PORTS: &[u32] = &[22, 10000, 10001, 15001, 15002];
30const RESERVED_TCP_INGRESS_PORTS: &[u32] = &[10000, 10001, 15001, 15002];
31// Well-known unauthenticated service ports (databases/caches) that must not be
32// exposed as raw public TCP without a source allowlist.
33const UNAUTHENTICATED_TCP_SERVICE_PORTS: &[u32] = &[5432, 3306, 6379, 27017, 9200, 11211];
34// Guest runtime paths a volume mount must not cover.
35const VOLUME_RESERVED_MOUNT_PATHS: &[&str] =
36    &["/dev", "/proc", "/sys", "/run/sail", "/var/run/sail"];
37
38/// Whether an allowlist entry is an IP address or CIDR prefix (as opposed to an
39/// app-name entry, which is HTTP-only).
40fn is_cidr_or_ip(value: &str) -> bool {
41    value.parse::<ipnet::IpNet>().is_ok() || value.parse::<std::net::IpAddr>().is_ok()
42}
43
44/// Per-size [floor, max] memory/disk ceilings in whole GiB. The scheduler is
45/// the authoritative ladder and re-validates at create; this fails fast
46/// before an image build. Locked to the scheduler by the backend's
47/// TestSDKSizesMatchScheduler.
48const SAILBOX_SIZE_RANGES: &[(&str, u32, u32, u32, u32)] =
49    &[("s", 2, 64, 8, 128), ("m", 8, 128, 32, 512)];
50
51/// The size whose range applies when a create names no size.
52const DEFAULT_SIZE_LABEL: &str = "m";
53
54/// Validate optional create-time memory/disk ceilings (whole GiB) against the
55/// named size's range (the default size's range when unsized).
56pub 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        // An unknown size is rejected by the size parser before this runs.
68        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
91/// Validate the autosleep timeout: when set, whole minutes in 1..=1440. The
92/// scheduler enforces the same range; `None` disables autosleep.
93pub 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
104/// Normalize an allowlist for the wire: trim entries, canonicalize CIDRs
105/// (clearing host bits, expanding a bare IP to its /32 or /128), reject a
106/// slash-containing entry that is not a valid CIDR (app names never contain a
107/// slash, so it can only be a typo), and dedupe. App-name entries pass through
108/// verbatim.
109pub(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
138/// Validate ingress ports against the platform's per-protocol rules (reserved
139/// ports, 1..65535 range, unique guest port, allowlist shape). The scheduler
140/// enforces the same rules; this is the shared client-side source of truth so a
141/// wrapper fails fast before an image build. An empty list is accepted.
142pub 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            // A '/' can only be intended as a CIDR (app names never contain one).
159            if value.contains('/') && !is_cidr {
160                return invalid(format!("allowlist entry {value:?} is not a valid CIDR"));
161            }
162            // CR-soon nbaruah: allow app-name entries on tcp once the
163            // tcp-spooler short-circuit gives raw-TCP connections a source app
164            // identity (see workerproxy/tcp_ingress.go). Until then the public
165            // TCP relay carries no app identity, so an app-name entry on a tcp
166            // listener can never match and would mint a listener that denies
167            // every real connection.
168            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
221/// Normalize a POSIX path like Python's `posixpath.normpath`: collapse repeated
222/// slashes, resolve `.`/`..`, and drop a trailing slash, preserving a leading
223/// `/`. Used to compare volume mount paths for overlap.
224fn 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
250/// Whether path `a` equals, contains, or is contained by path `b`.
251fn mount_paths_overlap(a: &str, b: &str) -> bool {
252    a == b || a.starts_with(&format!("{b}/")) || b.starts_with(&format!("{a}/"))
253}
254
255/// Validate volume mounts: each path must be absolute, not the filesystem root,
256/// not cover a reserved guest runtime path, and not overlap another mount; each
257/// volume id must be non-empty. Shared client-side source of truth.
258pub 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/// Result of an in-guest agent upgrade.
287#[derive(Debug, Clone, serde::Serialize)]
288pub struct UpgradeResult {
289    /// True when applied immediately (running box); false when deferred to the
290    /// next wake.
291    pub applied: bool,
292    /// Lifecycle status of the sailbox after the upgrade call.
293    pub status: SailboxStatus,
294}
295
296/// Typed client over the sailbox HTTP API host.
297pub struct SailboxApi<'a> {
298    http: &'a HttpCore,
299}
300
301impl<'a> SailboxApi<'a> {
302    /// Build a client bound to a sailbox-API `HttpCore`.
303    pub fn new(http: &'a HttpCore) -> SailboxApi<'a> {
304        SailboxApi { http }
305    }
306
307    /// Create a sailbox (`POST /v1/sailboxes`) and wait for it to come up.
308    ///
309    /// The resource size is included only when set.
310    /// A non-2xx response or an echoed `failed` status maps to
311    /// [`SailError::Creation`]; the returned handle requires a `running` status.
312    ///
313    /// `timeout` bounds each attempt: this is a synchronous long-poll the
314    /// scheduler can block on for many minutes (capacity queue + VM boot), so
315    /// without a bound the call can hang. Retries within one call reuse the
316    /// request's idempotency key so the backend can dedupe them; the call
317    /// returns as soon as the box is ready and gives up after the policy's
318    /// attempts, roughly `max_attempts * timeout`. That dedupe is
319    /// best-effort — a re-invoked create is a new request, and an attempt the
320    /// scheduler failed for capacity may not be resumable — so an interrupted
321    /// create can leave a failed box behind under the same name. `None` leaves each attempt unbounded. If
322    /// the budget is exhausted the box may still be coming up server-side; the
323    /// caller never saw its id, so find or terminate it by `name`.
324    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        // Serialize the typed ImageSpec to canonical proto-JSON (camelCase field
338        // names and enum value names, via its serde attributes): an ordinary JSON
339        // object the backend decodes with protojson, no shape-matching by hand.
340        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        // create's documented failure type is Creation; only auth stays
376        // PermissionDenied.
377        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    /// Fetch the current state of one sailbox (`GET /v1/sailboxes/{id}`).
402    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    /// List sailboxes (`GET /v1/sailboxes`) with paging and optional filters.
411    ///
412    /// App, status, search, and max-guest-schema-version filters are sent only
413    /// when present; returns one page plus the paging cursor fields.
414    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", &params).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        // Tolerate a missing pagination counter: echo the request's limit/offset
445        // and fall back to the page size for total, so one absent field does not
446        // sink the whole list.
447        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    /// Estimate sailbox spend over a time window (`GET /v1/sailboxes/spend`).
461    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", &params).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    /// Fetch a sailbox's resource-usage time series (`GET /v1/sailboxes/{id}/metrics`).
496    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"), &params)
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    /// Idempotent: a resource-miss 404 is the same outcome as a fresh terminate.
512    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    /// Pause a sailbox (`POST /v1/sailboxes/{id}/pause`), keeping it in memory.
523    ///
524    /// Requires the echoed status to be `paused`.
525    pub async fn pause(&self, sailbox_id: &str) -> Result<(), SailError> {
526        self.stop(sailbox_id, "pause", SailboxStatus::Paused).await
527    }
528
529    /// Sleep a sailbox (`POST /v1/sailboxes/{id}/sleep`), persisting it to disk.
530    ///
531    /// Requires the echoed status to be `sleeping`.
532    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    /// Resume a paused or sleeping sailbox (`POST /v1/sailboxes/{id}/resume`).
551    ///
552    /// A `terminal_unavailable` resume state maps to [`SailError::NotFound`]
553    /// since a terminated sailbox cannot be brought back; otherwise requires a
554    /// `running` status and returns a fresh handle.
555    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            // A terminated sailbox is gone for good; NotFound maps to the
562            // binding's lookup error.
563            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    /// Checkpoint a running sailbox (`POST /v1/sailboxes/{id}/checkpoint`).
577    ///
578    /// `name` sets the handle's display name; `ttl_seconds`, when given, must be
579    /// positive and overrides the server's default retention. Returns the new
580    /// checkpoint's id, generation, and expiry; a response missing
581    /// `checkpoint_id` is treated as an [`SailError::Api`] failure.
582    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    /// Create a sailbox from an existing checkpoint
638    /// (`POST /v1/sailboxes/from_checkpoint`).
639    ///
640    /// `checkpoint_id` must be non-empty and `timeout_seconds`, when given, must
641    /// be positive; both are validated as [`SailError::InvalidArgument`] before
642    /// the call. Requires a `running` status and returns the new sailbox handle.
643    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    /// Upgrade a sailbox's in-guest agent (`POST /v1/sailboxes/{id}/upgrade`).
673    ///
674    /// The returned [`UpgradeResult`] reports whether the upgrade applied
675    /// immediately (running box) or was deferred to the next wake.
676    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    /// Expose a runtime ingress port; returns the [`Listener`](crate::worker::Listener)
691    /// with the endpoint the scheduler resolved and an unspecified route status
692    /// (the response does not report reachability).
693    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        // Always send allowlist (even empty) so re-exposing a port can clear it
701        // and the request is explicit rather than relying on absence semantics.
702        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    /// Remove a runtime ingress listener
720    /// (`DELETE /v1/sailboxes/{id}/listeners/{guest_port}`).
721    ///
722    /// Runs without retry so a lost response on a committed delete does not
723    /// re-issue the call and surface an already-unexposed port as an error.
724    pub async fn unexpose(&self, sailbox_id: &str, guest_port: u32) -> Result<(), SailError> {
725        // No retry: a committed DELETE whose response is lost would, on retry,
726        // hit the scheduler as an already-unexposed port (NotFound) and raise
727        // even though the port was removed.
728        let (status, data) = self
729            .request(
730                Method::Delete,
731                &format!("/v1/sailboxes/{sailbox_id}/listeners/{guest_port}"),
732                &[],
733                /* body */ None,
734                NO_RETRY,
735                /* timeout */ None,
736            )
737            .await?;
738        raise_api_error(status, &data, "")
739    }
740
741    /// List a sailbox's ingress listeners
742    /// (`GET /v1/sailboxes/{id}/listeners`). Served from the control plane, so
743    /// it does not resume (wake) a paused or sleeping box.
744    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    /// Fetch one ingress listener by guest port
756    /// (`GET /v1/sailboxes/{id}/listeners/{guest_port}`); a missing port is a
757    /// [`SailError::NotFound`]. Served without resuming the box.
758    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    /// Ingress-identity headers for this sailbox.
780    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    // --- NFS volumes (sailbox-volume API on the same host) ---
799
800    /// Look up an NFS volume by name (`POST /v1/sailbox-volumes/get`),
801    /// optionally creating it when missing.
802    ///
803    /// `name` must be non-empty. When `mint_if_missing` is set the volume is
804    /// created if it does not yet exist.
805    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    /// List the org's NFS volumes (`GET /v1/sailbox-volumes`).
827    ///
828    /// `max_objects`, when given, caps the number returned and must be
829    /// non-negative.
830    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    /// Delete a volume by id; `Ok(None)` on a 204 (no body), the deleted handle
852    /// otherwise.
853    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                /* body */ None,
880                policy,
881                /* timeout */ 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    /// Fetch the caller org's SSH certificate-authority public key, creating the
892    /// CA on first use. A box trusts this key, so installing it is what lets the
893    /// org's signed user certificates authenticate.
894    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    /// Exchange a local SSH public key for a short-lived certificate the org CA
901    /// signs (principal `root`). Returns the OpenSSH certificate and its key id
902    /// (`org=<id>;fp=...;iat=...`), which identifies the signing org.
903    ///
904    /// `timeout` bounds a single attempt with no retry (used by the auto-refresh
905    /// hook, which runs inside the ssh connect path and must fail open quickly);
906    /// `None` uses the default retrying transport.
907    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    // --- transport helpers ---
943
944    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            /* timeout */ 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            /* body */ None,
969            DEFAULT_RETRY_POLICY,
970            /* timeout */ 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
1056/// A 404 body the API tags as a real missing resource (vs a route-level 404).
1057fn 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
1064/// Map a non-2xx lifecycle/monitoring response onto the canonical taxonomy:
1065/// resource-miss 404 → NotFound, 401/403 → PermissionDenied, 400 →
1066/// InvalidArgument, any other → Api (a transient/unexpected API failure).
1067fn 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
1091/// create's ladder: every non-2xx except auth is a Creation failure.
1092fn 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
1107/// The echoed lifecycle `status`, parsed into the typed enum.
1108fn status_of(data: &Value) -> SailboxStatus {
1109    SailboxStatus::from(data.get("status").and_then(Value::as_str).unwrap_or(""))
1110}
1111
1112/// Raise an Api error when the echoed status is not the expected one.
1113fn 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        // http/22 reserved for ssh.
1153        assert!(validate_ingress_ports(&[port(22, IngressProtocol::Http, &[])]).is_err());
1154        // agent infra port.
1155        assert!(validate_ingress_ports(&[port(10000, IngressProtocol::Tcp, &[])]).is_err());
1156        // out of range.
1157        assert!(validate_ingress_ports(&[port(70000, IngressProtocol::Http, &[])]).is_err());
1158        // duplicate guest port across protocols.
1159        assert!(validate_ingress_ports(&[
1160            port(8080, IngressProtocol::Http, &[]),
1161            port(8080, IngressProtocol::Tcp, &["0.0.0.0/0"]),
1162        ])
1163        .is_err());
1164        // app-name allowlist on a tcp listener.
1165        assert!(validate_ingress_ports(&[port(9000, IngressProtocol::Tcp, &["my-app"])]).is_err());
1166        // an app-name allowlist on http is fine.
1167        assert!(validate_ingress_ports(&[port(9000, IngressProtocol::Http, &["my-app"])]).is_ok());
1168        // unauthenticated service port needs an allowlist.
1169        assert!(validate_ingress_ports(&[port(5432, IngressProtocol::Tcp, &[])]).is_err());
1170        // invalid CIDR.
1171        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        // normpath resolves .. before the overlap/reserved checks.
1188        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        // Resource fields are read straight from the wire (the backend always sends them).
1206        assert_eq!(info.cpu_requested_vcpu, 2);
1207        assert_eq!(info.memory_used_bytes, 512);
1208        assert_eq!(info.checkpoint_generation, 7);
1209        // Genuinely-optional fields default to None when absent.
1210        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, &not_found, ""),
1220            Err(SailError::NotFound { .. })
1221        ));
1222        // A route-level 404 (no not_found_error type) is transient, not a miss.
1223        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    // The Rust half of the cross-language image contract: ImageSpec's serde
1258    // attributes must produce canonical proto-JSON (camelCase fields, enum value
1259    // names, flattened oneofs). The Go side proves protojson decodes this exact
1260    // shape (TestCreateSailboxHandlerAcceptsCanonicalProtoJSON). Keep the two in sync.
1261    #[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}