Skip to main content

sail/sailbox/
types.rs

1//! Typed domain models for the sailbox API: lifecycle, listing, ingress
2//! listeners, and volumes.
3//!
4//! The core owns the wire schema: it deserializes API responses into these
5//! structs (applying the wire-schema field defaults) and serializes them back
6//! out for bindings. A binding maps a struct onto its own public type by field
7//! name without re-parsing the wire shape.
8
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11
12/// Lifecycle status of a sailbox. An unrecognized server value is preserved in
13/// `Other` so a status a newer backend introduces never fails parsing. The
14/// default is `Other("")`: the status of a handle no server response has
15/// filled in yet.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum SailboxStatus {
18    /// Running and serving.
19    Running,
20    /// Paused in memory.
21    Paused,
22    /// Sleeping (checkpointed to disk).
23    Sleeping,
24    /// In a failed state.
25    Failed,
26    /// Terminated.
27    Terminated,
28    /// A status this SDK version does not recognize, kept verbatim.
29    Other(String),
30}
31
32impl Default for SailboxStatus {
33    fn default() -> Self {
34        SailboxStatus::Other(String::new())
35    }
36}
37
38impl SailboxStatus {
39    /// The wire string for this status.
40    pub fn as_str(&self) -> &str {
41        match self {
42            SailboxStatus::Running => "running",
43            SailboxStatus::Paused => "paused",
44            SailboxStatus::Sleeping => "sleeping",
45            SailboxStatus::Failed => "failed",
46            SailboxStatus::Terminated => "terminated",
47            SailboxStatus::Other(s) => s,
48        }
49    }
50}
51
52impl From<&str> for SailboxStatus {
53    fn from(s: &str) -> SailboxStatus {
54        match s {
55            "running" => SailboxStatus::Running,
56            "paused" => SailboxStatus::Paused,
57            "sleeping" => SailboxStatus::Sleeping,
58            "failed" => SailboxStatus::Failed,
59            "terminated" => SailboxStatus::Terminated,
60            other => SailboxStatus::Other(other.to_string()),
61        }
62    }
63}
64
65impl std::fmt::Display for SailboxStatus {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71impl Serialize for SailboxStatus {
72    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73        serializer.serialize_str(self.as_str())
74    }
75}
76
77impl<'de> Deserialize<'de> for SailboxStatus {
78    fn deserialize<D: serde::Deserializer<'de>>(
79        deserializer: D,
80    ) -> Result<SailboxStatus, D::Error> {
81        Ok(SailboxStatus::from(
82            String::deserialize(deserializer)?.as_str(),
83        ))
84    }
85}
86
87/// A lifecycle status a client can filter by in [`ListSailboxesQuery`]. A closed set: a
88/// client only filters by statuses the SDK knows. Server responses use the open
89/// [`SailboxStatus`], which tolerates a status a newer backend introduces.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SailboxStatusFilter {
92    /// Running and serving.
93    Running,
94    /// Paused in memory.
95    Paused,
96    /// Sleeping (checkpointed to disk).
97    Sleeping,
98    /// In a failed state.
99    Failed,
100    /// Terminated.
101    Terminated,
102}
103
104/// Server-side ordering for list results.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub enum SailboxListOrder {
107    /// Activity recency: active/restorable boxes first, then updated_at.
108    #[default]
109    Activity,
110    /// Newest-created boxes first.
111    CreatedAtDesc,
112}
113
114impl SailboxListOrder {
115    /// The wire string for this order.
116    pub fn as_str(&self) -> &'static str {
117        match self {
118            SailboxListOrder::Activity => "activity",
119            SailboxListOrder::CreatedAtDesc => "created_at_desc",
120        }
121    }
122
123    /// Parse a wire string.
124    pub fn parse(raw: &str) -> Option<SailboxListOrder> {
125        match raw.trim() {
126            "activity" => Some(SailboxListOrder::Activity),
127            "created_at_desc" => Some(SailboxListOrder::CreatedAtDesc),
128            _ => None,
129        }
130    }
131}
132
133impl SailboxStatusFilter {
134    /// The wire string for this status.
135    pub fn as_str(&self) -> &'static str {
136        match self {
137            SailboxStatusFilter::Running => "running",
138            SailboxStatusFilter::Paused => "paused",
139            SailboxStatusFilter::Sleeping => "sleeping",
140            SailboxStatusFilter::Failed => "failed",
141            SailboxStatusFilter::Terminated => "terminated",
142        }
143    }
144
145    /// Parse a wire string, returning `None` for a status the SDK does not know.
146    pub fn parse(s: &str) -> Option<SailboxStatusFilter> {
147        match s {
148            "running" => Some(SailboxStatusFilter::Running),
149            "paused" => Some(SailboxStatusFilter::Paused),
150            "sleeping" => Some(SailboxStatusFilter::Sleeping),
151            "failed" => Some(SailboxStatusFilter::Failed),
152            "terminated" => Some(SailboxStatusFilter::Terminated),
153            _ => None,
154        }
155    }
156}
157
158/// Wire protocol for an ingress port or listener. An unrecognized value is
159/// preserved in `Other` so a protocol a newer backend introduces never fails.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum ListenerProtocol {
162    /// Raw TCP.
163    Tcp,
164    /// HTTP.
165    Http,
166    /// A protocol this SDK version does not recognize, kept verbatim.
167    Other(String),
168}
169
170impl ListenerProtocol {
171    /// The wire string for this protocol.
172    pub fn as_str(&self) -> &str {
173        match self {
174            ListenerProtocol::Tcp => "tcp",
175            ListenerProtocol::Http => "http",
176            ListenerProtocol::Other(s) => s,
177        }
178    }
179}
180
181impl From<&str> for ListenerProtocol {
182    fn from(s: &str) -> ListenerProtocol {
183        match s {
184            "tcp" => ListenerProtocol::Tcp,
185            "http" => ListenerProtocol::Http,
186            other => ListenerProtocol::Other(other.to_string()),
187        }
188    }
189}
190
191impl std::fmt::Display for ListenerProtocol {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.write_str(self.as_str())
194    }
195}
196
197impl Serialize for ListenerProtocol {
198    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
199        serializer.serialize_str(self.as_str())
200    }
201}
202
203impl<'de> Deserialize<'de> for ListenerProtocol {
204    fn deserialize<D: serde::Deserializer<'de>>(
205        deserializer: D,
206    ) -> Result<ListenerProtocol, D::Error> {
207        Ok(ListenerProtocol::from(
208            String::deserialize(deserializer)?.as_str(),
209        ))
210    }
211}
212
213/// Status of a listener's ingress route. An unrecognized value is preserved in
214/// `Other` so a status a newer backend introduces never fails to parse.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum ListenerRouteStatus {
217    /// The route state is not yet known.
218    Unspecified,
219    /// The route is being set up.
220    Pending,
221    /// The route is active and ready to carry traffic.
222    Active,
223    /// The route is being restored after a checkpoint or migration.
224    Restoring,
225    /// The route is temporarily unavailable.
226    Unavailable,
227    /// A status this SDK version does not recognize, kept verbatim.
228    Other(String),
229}
230
231impl ListenerRouteStatus {
232    /// The user-facing string for this status. The backend's proto spelling
233    /// (`LISTENER_ROUTE_STATUS_*`) is normalized here, once, so no wrapper
234    /// ever sees it.
235    pub fn as_str(&self) -> &str {
236        match self {
237            ListenerRouteStatus::Unspecified => "unspecified",
238            ListenerRouteStatus::Pending => "pending",
239            ListenerRouteStatus::Active => "active",
240            ListenerRouteStatus::Restoring => "restoring",
241            ListenerRouteStatus::Unavailable => "unavailable",
242            ListenerRouteStatus::Other(s) => s,
243        }
244    }
245}
246
247impl From<&str> for ListenerRouteStatus {
248    /// Parse either the backend's proto spelling or the friendly form (so a
249    /// serialized [`Listener`](crate::worker::Listener) round-trips).
250    fn from(s: &str) -> ListenerRouteStatus {
251        match s {
252            "LISTENER_ROUTE_STATUS_UNSPECIFIED" | "unspecified" => ListenerRouteStatus::Unspecified,
253            "LISTENER_ROUTE_STATUS_PENDING" | "pending" => ListenerRouteStatus::Pending,
254            "LISTENER_ROUTE_STATUS_ACTIVE" | "active" => ListenerRouteStatus::Active,
255            "LISTENER_ROUTE_STATUS_RESTORING" | "restoring" => ListenerRouteStatus::Restoring,
256            "LISTENER_ROUTE_STATUS_UNAVAILABLE" | "unavailable" => ListenerRouteStatus::Unavailable,
257            other => ListenerRouteStatus::Other(other.to_string()),
258        }
259    }
260}
261
262impl std::fmt::Display for ListenerRouteStatus {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.write_str(self.as_str())
265    }
266}
267
268impl Serialize for ListenerRouteStatus {
269    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
270        serializer.serialize_str(self.as_str())
271    }
272}
273
274impl<'de> Deserialize<'de> for ListenerRouteStatus {
275    fn deserialize<D: serde::Deserializer<'de>>(
276        deserializer: D,
277    ) -> Result<ListenerRouteStatus, D::Error> {
278        Ok(ListenerRouteStatus::from(
279            String::deserialize(deserializer)?.as_str(),
280        ))
281    }
282}
283
284/// Transport protocol a client requests when exposing an ingress port. A closed
285/// set: a client can only request a protocol the SDK supports. Server responses
286/// use the open [`ListenerProtocol`], which tolerates a protocol a newer backend adds.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum IngressProtocol {
289    /// Raw TCP.
290    Tcp,
291    /// HTTP.
292    Http,
293}
294
295impl IngressProtocol {
296    /// The wire string for this protocol.
297    pub fn as_str(&self) -> &'static str {
298        match self {
299            IngressProtocol::Tcp => "tcp",
300            IngressProtocol::Http => "http",
301        }
302    }
303
304    /// Parse a wire string, returning `None` for a protocol the SDK does not
305    /// support.
306    pub fn parse(s: &str) -> Option<IngressProtocol> {
307        match s {
308            "tcp" => Some(IngressProtocol::Tcp),
309            "http" => Some(IngressProtocol::Http),
310            _ => None,
311        }
312    }
313}
314
315impl std::fmt::Display for IngressProtocol {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        f.write_str(self.as_str())
318    }
319}
320
321impl Serialize for IngressProtocol {
322    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
323        serializer.serialize_str(self.as_str())
324    }
325}
326
327/// Options for [`Sailbox::checkpoint`](crate::Sailbox::checkpoint).
328#[derive(Debug, Clone, Default)]
329pub struct CheckpointOptions {
330    /// Human-readable label for the checkpoint.
331    pub name: Option<String>,
332    /// Retention override; must be positive when given.
333    pub ttl: Option<std::time::Duration>,
334}
335
336/// Backends report kernel spellings (`x86_64`, `aarch64`); the SDK speaks the
337/// create-side vocabulary (`amd64`, `arm64`).
338fn normalized_architecture<'de, D: serde::Deserializer<'de>>(
339    deserializer: D,
340) -> Result<String, D::Error> {
341    let raw = String::deserialize(deserializer)?;
342    Ok(match raw.as_str() {
343        "x86_64" | "amd64" => "amd64".to_string(),
344        "aarch64" | "arm64" => "arm64".to_string(),
345        _ => raw,
346    })
347}
348
349/// Named sailbox resource size. Sailboxes are offered in a small discrete CPU
350/// menu rather than free-form vCPU/memory/disk. Each size sets the vCPU count
351/// plus default memory and disk; the scheduler owns the exact numbers. Billing
352/// is by observed usage, so a larger size is a higher ceiling, not a higher
353/// bill.
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum SailboxSize {
356    /// Small (1 vCPU): fastest cold starts, forks, and resumes, with
357    /// lower ceilings that cap what a runaway workload can consume.
358    Small,
359    /// Medium (4 vCPU, default).
360    Medium,
361}
362
363impl SailboxSize {
364    /// The wire string for this size.
365    pub fn as_str(&self) -> &'static str {
366        match self {
367            SailboxSize::Small => "s",
368            SailboxSize::Medium => "m",
369        }
370    }
371
372    /// Parse a wire string, returning `None` for an unsupported size.
373    pub fn parse(s: &str) -> Option<SailboxSize> {
374        match s {
375            "s" => Some(SailboxSize::Small),
376            "m" => Some(SailboxSize::Medium),
377            _ => None,
378        }
379    }
380
381    /// The allowed size labels in ascending order, for error messages and help.
382    pub fn allowed() -> &'static [&'static str] {
383        &["s", "m"]
384    }
385}
386
387impl std::fmt::Display for SailboxSize {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.write_str(self.as_str())
390    }
391}
392
393impl Serialize for SailboxSize {
394    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
395        serializer.serialize_str(self.as_str())
396    }
397}
398
399/// A running-sailbox handle: the fields needed to exec/file/listener against a
400/// live box. Returned by create/resume/from_checkpoint.
401#[derive(Debug, Clone, Default, Serialize)]
402pub struct SailboxHandle {
403    /// The sailbox's stable identifier.
404    pub sailbox_id: String,
405    /// The caller-supplied sailbox name.
406    pub name: String,
407    /// The sailbox's lifecycle status (for example `running`).
408    pub status: SailboxStatus,
409    /// Network address of the worker hosting the sailbox.
410    pub worker_address: String,
411    /// Endpoint used to open exec/file/listener streams to the box.
412    pub exec_endpoint: String,
413}
414
415/// Read-only snapshot from get/list. Deliberately omits worker_address /
416/// exec_endpoint (the list/get endpoints do not return them).
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SailboxInfo {
419    /// The sailbox's stable identifier.
420    pub sailbox_id: String,
421    /// Identifier of the owning app.
422    pub app_id: String,
423    /// Name of the owning app.
424    pub app_name: String,
425    /// Identifier of the image the sailbox was created from.
426    pub image_id: String,
427    /// The caller-supplied sailbox name.
428    pub name: String,
429    /// The sailbox's lifecycle status (for example `running`).
430    pub status: SailboxStatus,
431    /// Configured memory size, in mebibytes.
432    pub memory_mib: i64,
433    /// Configured number of virtual CPUs.
434    pub vcpu_count: i64,
435    /// Configured state-disk size, in gibibytes.
436    pub state_disk_size_gib: i64,
437    /// Requested CPU allocation, in vCPUs.
438    pub cpu_requested_vcpu: i64,
439    /// Current CPU usage, in vCPUs.
440    pub cpu_used_vcpu: f64,
441    /// Requested memory allocation, in bytes.
442    pub memory_requested_bytes: i64,
443    /// Current memory usage, in bytes.
444    pub memory_used_bytes: i64,
445    /// Requested disk allocation, in bytes.
446    pub disk_requested_bytes: i64,
447    /// Current disk usage, in bytes.
448    pub disk_used_bytes: i64,
449    /// CPU architecture of the sailbox, in the create-side vocabulary
450    /// (`amd64` or `arm64`).
451    #[serde(deserialize_with = "normalized_architecture", default)]
452    pub architecture: String,
453    /// Guest schema version the box is running, when reported by the backend.
454    #[serde(default)]
455    pub guest_schema_version: Option<i64>,
456    /// Human-readable error detail, present when the box is in an error state.
457    #[serde(default)]
458    pub error_message: Option<String>,
459    /// Monotonic checkpoint generation counter for the sailbox.
460    pub checkpoint_generation: i64,
461    /// When the box last started, if it has started.
462    #[serde(with = "time::serde::rfc3339::option", default)]
463    pub started_at: Option<OffsetDateTime>,
464    /// When the most recent checkpoint was taken, if any.
465    #[serde(with = "time::serde::rfc3339::option", default)]
466    pub last_checkpointed_at: Option<OffsetDateTime>,
467    /// When the sailbox was created.
468    #[serde(with = "time::serde::rfc3339")]
469    pub created_at: OffsetDateTime,
470    /// When the sailbox was last updated.
471    #[serde(with = "time::serde::rfc3339")]
472    pub updated_at: OffsetDateTime,
473}
474
475/// One page of list results plus the pagination envelope.
476#[derive(Debug, Clone, Serialize)]
477pub struct SailboxPage {
478    /// The sailboxes in this page.
479    pub items: Vec<SailboxInfo>,
480    /// Maximum number of items requested for this page.
481    pub limit: i64,
482    /// Zero-based offset of the first item in this page.
483    pub offset: i64,
484    /// Total number of sailboxes matching the query across all pages.
485    pub total: i64,
486    /// True when further pages exist beyond this one.
487    pub has_more: bool,
488}
489
490/// A durable checkpoint handle. `status` echoes the source sailbox's lifecycle
491/// status after checkpointing (a running box is snapshotted; a paused/sleeping
492/// one returns its existing checkpoint), so the binding can sync its handle.
493#[derive(Debug, Clone, Serialize)]
494pub struct SailboxCheckpoint {
495    /// Stable identifier of the checkpoint.
496    pub checkpoint_id: String,
497    /// Identifier of the sailbox the checkpoint was taken from.
498    pub sailbox_id: String,
499    /// Checkpoint generation counter captured by this checkpoint.
500    pub checkpoint_generation: i64,
501    /// When the handle expires and becomes eligible for garbage collection;
502    /// `None` for a handle that carries no fresh retention bound (a paused or
503    /// sleeping source).
504    #[serde(with = "time::serde::rfc3339::option")]
505    pub expires_at: Option<OffsetDateTime>,
506    /// Source sailbox's lifecycle status after checkpointing.
507    pub status: SailboxStatus,
508}
509
510/// Filters for list/list_page.
511#[derive(Debug, Clone)]
512pub struct ListSailboxesQuery {
513    /// Restrict results to sailboxes owned by the app with this id.
514    pub app_id: Option<String>,
515    /// Restrict results to sailboxes in this lifecycle status.
516    pub status: Option<SailboxStatusFilter>,
517    /// Free-text search filter applied by the backend.
518    pub search: Option<String>,
519    /// Exclude sailboxes whose guest schema version exceeds this value.
520    pub max_guest_schema_version: Option<i64>,
521    /// Ordering to apply before pagination.
522    pub order: SailboxListOrder,
523    /// Maximum number of items to return.
524    pub limit: i64,
525    /// Zero-based offset of the first item to return.
526    pub offset: i64,
527}
528
529/// Default page size for listing, matching the sailbox API's own default. The
530/// API rejects `limit=0`, so the derived all-zero default cannot be used.
531pub const DEFAULT_LIST_LIMIT: i64 = 50;
532
533impl Default for ListSailboxesQuery {
534    fn default() -> ListSailboxesQuery {
535        ListSailboxesQuery {
536            app_id: None,
537            status: None,
538            search: None,
539            max_guest_schema_version: None,
540            order: SailboxListOrder::Activity,
541            limit: DEFAULT_LIST_LIMIT,
542            offset: 0,
543        }
544    }
545}
546
547/// One guest ingress port to reserve at create time.
548#[derive(Debug, Clone, Serialize)]
549pub struct IngressPort {
550    /// Port inside the guest to expose for ingress.
551    pub guest_port: u32,
552    /// Transport protocol for the port (for example `tcp`).
553    pub protocol: IngressProtocol,
554    /// Source addresses allowed to reach the port; empty means all sources are
555    /// allowed. Always sent (even when empty) so the request states it
556    /// explicitly rather than relying on an omitted field.
557    pub allowlist: Vec<String>,
558}
559
560/// One NFS volume mount.
561#[derive(Debug, Clone, Serialize)]
562pub struct VolumeMount {
563    /// Identifier of the NFS volume to mount.
564    pub volume_id: String,
565    /// Absolute path inside the guest where the volume is mounted.
566    pub mount_path: String,
567}
568
569/// A managed NFS volume as returned by the sailbox-volume API. (The local
570/// mount path is a binding-side concern and not part of this wire type.)
571#[derive(Debug, Clone, Serialize, Deserialize)]
572pub struct VolumeInfo {
573    /// Stable identifier of the volume.
574    pub volume_id: String,
575    /// Caller-supplied volume name.
576    pub name: String,
577    /// Storage backend serving the volume.
578    pub backend: String,
579    /// Lifecycle status of the volume.
580    pub status: String,
581    /// When the volume was created, if reported.
582    #[serde(with = "time::serde::rfc3339::option", default)]
583    pub created_at: Option<OffsetDateTime>,
584    /// When the volume was last updated, if reported.
585    #[serde(with = "time::serde::rfc3339::option", default)]
586    pub updated_at: Option<OffsetDateTime>,
587}
588
589/// The wire shape of the add-listener response: the scheduler resolves the
590/// public endpoint in the response (so a caller renders it without a second
591/// lookup) but reports nothing about routing, so it converts to a [`Listener`]
592/// with an unspecified route status.
593#[derive(Debug, Clone, Deserialize)]
594pub(crate) struct AddListenerWire {
595    pub(crate) guest_port: u32,
596    pub(crate) protocol: ListenerProtocol,
597    #[serde(default)]
598    pub(crate) public_url: String,
599    #[serde(default, rename = "tcp_public_host")]
600    pub(crate) public_host: String,
601    #[serde(default, rename = "tcp_public_port")]
602    pub(crate) public_port: u32,
603}
604
605impl From<AddListenerWire> for crate::worker::Listener {
606    fn from(wire: AddListenerWire) -> crate::worker::Listener {
607        crate::worker::Listener {
608            guest_port: wire.guest_port,
609            protocol: wire.protocol,
610            // The expose response says nothing about reachability; confirm
611            // with wait_for_listener or re-fetch via get/list.
612            route_status: ListenerRouteStatus::Unspecified,
613            public_url: wire.public_url,
614            public_host: wire.public_host,
615            public_port: wire.public_port,
616        }
617    }
618}
619
620/// A CA-signed SSH user certificate plus the key id that identifies the signing
621/// org (`org=<id>;fp=...;iat=...`).
622#[derive(Debug, Clone)]
623pub struct IssuedUserCert {
624    /// The OpenSSH certificate (`<type>-cert-v01@openssh.com AAAA...`).
625    pub certificate: String,
626    /// The certificate key id.
627    pub key_id: String,
628}
629
630/// How to reach an exposed listener: a routable HTTPS URL for `http`
631/// listeners, or a host/port any TCP client can dial for `tcp` listeners.
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub enum ListenerEndpoint {
634    /// The HTTPS URL to reach the guest service.
635    Http {
636        /// Routable URL.
637        url: String,
638    },
639    /// The address to dial for a raw-TCP listener.
640    Tcp {
641        /// Hostname to dial.
642        host: String,
643        /// Port to dial.
644        port: u32,
645    },
646}
647
648impl std::fmt::Display for ListenerEndpoint {
649    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650        match self {
651            ListenerEndpoint::Http { url } => f.write_str(url),
652            ListenerEndpoint::Tcp { host, port } => write!(f, "{host}:{port}"),
653        }
654    }
655}
656
657/// Inputs to create a sailbox. The image is a typed [`ImageSpec`](crate::image::ImageSpec)
658/// (the higher-level image builder lives in the language wrapper); the lifecycle
659/// envelope is owned by the core.
660#[derive(Debug, Clone)]
661pub struct CreateSailboxRequest {
662    /// Identifier of the app that will own the sailbox.
663    pub app_id: String,
664    /// Caller-supplied name for the sailbox.
665    pub name: String,
666    /// Guest ingress ports to reserve at create time.
667    pub ingress_ports: Vec<IngressPort>,
668    /// NFS volumes to mount into the guest.
669    pub volume_mounts: Vec<VolumeMount>,
670    /// The image to create the sailbox from. Serialized to canonical proto-JSON
671    /// and sent as the `image` field.
672    pub image: crate::image::ImageSpec,
673    /// Requested resource size; defaults server-side when absent.
674    pub size: Option<SailboxSize>,
675    /// Optional memory limit in whole GiB, within the size's range; the
676    /// size's default when absent.
677    pub memory_gib: Option<u32>,
678    /// Optional disk size in whole GiB, within the size's range; the size's
679    /// default when absent.
680    pub disk_gib: Option<u32>,
681    /// Optional idle timeout in minutes after which the platform may autosleep.
682    pub autosleep_timeout_minutes: Option<i64>,
683    /// Enable SSH on the new sailbox after create: trust the org SSH CA, start
684    /// `sshd`, and expose guest port 22 as TCP once the CA-only daemon owns it.
685    /// An explicit port-22 ingress entry contributes just its allowlist.
686    pub ssh: bool,
687}
688
689impl Default for CreateSailboxRequest {
690    /// An empty request for a plain Debian-base sailbox: fill in `app_id` and
691    /// `name`, override the rest as needed.
692    fn default() -> CreateSailboxRequest {
693        CreateSailboxRequest {
694            app_id: String::new(),
695            name: String::new(),
696            ingress_ports: Vec::new(),
697            volume_mounts: Vec::new(),
698            image: crate::image::ImageSpec {
699                base: Some(crate::image::BaseImage::Debian),
700                ..crate::image::ImageSpec::default()
701            },
702            size: None,
703            memory_gib: None,
704            disk_gib: None,
705            autosleep_timeout_minutes: None,
706            ssh: false,
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn list_query_default_uses_a_valid_nonzero_limit() {
717        // The sailbox API rejects limit=0, so the default must be the API's own
718        // page size, not the derived zero.
719        let query = ListSailboxesQuery::default();
720        assert_eq!(query.limit, DEFAULT_LIST_LIMIT);
721        assert!(query.limit > 0);
722        assert_eq!(query.offset, 0);
723    }
724}