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