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.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SailboxStatus {
16    /// Running and serving.
17    Running,
18    /// Paused in memory.
19    Paused,
20    /// Sleeping (checkpointed to disk).
21    Sleeping,
22    /// In a failed state.
23    Failed,
24    /// Terminated.
25    Terminated,
26    /// A status this SDK version does not recognize, kept verbatim.
27    Other(String),
28}
29
30impl SailboxStatus {
31    /// The wire string for this status.
32    pub fn as_str(&self) -> &str {
33        match self {
34            SailboxStatus::Running => "running",
35            SailboxStatus::Paused => "paused",
36            SailboxStatus::Sleeping => "sleeping",
37            SailboxStatus::Failed => "failed",
38            SailboxStatus::Terminated => "terminated",
39            SailboxStatus::Other(s) => s,
40        }
41    }
42}
43
44impl From<&str> for SailboxStatus {
45    fn from(s: &str) -> SailboxStatus {
46        match s {
47            "running" => SailboxStatus::Running,
48            "paused" => SailboxStatus::Paused,
49            "sleeping" => SailboxStatus::Sleeping,
50            "failed" => SailboxStatus::Failed,
51            "terminated" => SailboxStatus::Terminated,
52            other => SailboxStatus::Other(other.to_string()),
53        }
54    }
55}
56
57impl std::fmt::Display for SailboxStatus {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(self.as_str())
60    }
61}
62
63impl Serialize for SailboxStatus {
64    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
65        serializer.serialize_str(self.as_str())
66    }
67}
68
69impl<'de> Deserialize<'de> for SailboxStatus {
70    fn deserialize<D: serde::Deserializer<'de>>(
71        deserializer: D,
72    ) -> Result<SailboxStatus, D::Error> {
73        Ok(SailboxStatus::from(
74            String::deserialize(deserializer)?.as_str(),
75        ))
76    }
77}
78
79/// A lifecycle status a client can filter by in [`ListQuery`]. A closed set: a
80/// client only filters by statuses the SDK knows. Server responses use the open
81/// [`SailboxStatus`], which tolerates a status a newer backend introduces.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum SailboxStatusFilter {
84    /// Running and serving.
85    Running,
86    /// Paused in memory.
87    Paused,
88    /// Sleeping (checkpointed to disk).
89    Sleeping,
90    /// In a failed state.
91    Failed,
92    /// Terminated.
93    Terminated,
94}
95
96impl SailboxStatusFilter {
97    /// The wire string for this status.
98    pub fn as_str(&self) -> &'static str {
99        match self {
100            SailboxStatusFilter::Running => "running",
101            SailboxStatusFilter::Paused => "paused",
102            SailboxStatusFilter::Sleeping => "sleeping",
103            SailboxStatusFilter::Failed => "failed",
104            SailboxStatusFilter::Terminated => "terminated",
105        }
106    }
107
108    /// Parse a wire string, returning `None` for a status the SDK does not know.
109    pub fn parse(s: &str) -> Option<SailboxStatusFilter> {
110        match s {
111            "running" => Some(SailboxStatusFilter::Running),
112            "paused" => Some(SailboxStatusFilter::Paused),
113            "sleeping" => Some(SailboxStatusFilter::Sleeping),
114            "failed" => Some(SailboxStatusFilter::Failed),
115            "terminated" => Some(SailboxStatusFilter::Terminated),
116            _ => None,
117        }
118    }
119}
120
121/// Wire protocol for an ingress port or listener. An unrecognized value is
122/// preserved in `Other` so a protocol a newer backend introduces never fails.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum Protocol {
125    /// Raw TCP.
126    Tcp,
127    /// HTTP.
128    Http,
129    /// A protocol this SDK version does not recognize, kept verbatim.
130    Other(String),
131}
132
133impl Protocol {
134    /// The wire string for this protocol.
135    pub fn as_str(&self) -> &str {
136        match self {
137            Protocol::Tcp => "tcp",
138            Protocol::Http => "http",
139            Protocol::Other(s) => s,
140        }
141    }
142}
143
144impl From<&str> for Protocol {
145    fn from(s: &str) -> Protocol {
146        match s {
147            "tcp" => Protocol::Tcp,
148            "http" => Protocol::Http,
149            other => Protocol::Other(other.to_string()),
150        }
151    }
152}
153
154impl std::fmt::Display for Protocol {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.write_str(self.as_str())
157    }
158}
159
160impl Serialize for Protocol {
161    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
162        serializer.serialize_str(self.as_str())
163    }
164}
165
166impl<'de> Deserialize<'de> for Protocol {
167    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Protocol, D::Error> {
168        Ok(Protocol::from(String::deserialize(deserializer)?.as_str()))
169    }
170}
171
172/// Status of a listener's ingress route. An unrecognized value is preserved in
173/// `Other` so a status a newer backend introduces never fails to parse.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum ListenerRouteStatus {
176    /// The route state is not yet known.
177    Unspecified,
178    /// The route is being set up.
179    Pending,
180    /// The route is active and ready to carry traffic.
181    Active,
182    /// The route is being restored after a checkpoint or migration.
183    Restoring,
184    /// The route is temporarily unavailable.
185    Unavailable,
186    /// A status this SDK version does not recognize, kept verbatim.
187    Other(String),
188}
189
190impl ListenerRouteStatus {
191    /// The wire string for this status.
192    pub fn as_str(&self) -> &str {
193        match self {
194            ListenerRouteStatus::Unspecified => "LISTENER_ROUTE_STATUS_UNSPECIFIED",
195            ListenerRouteStatus::Pending => "LISTENER_ROUTE_STATUS_PENDING",
196            ListenerRouteStatus::Active => "LISTENER_ROUTE_STATUS_ACTIVE",
197            ListenerRouteStatus::Restoring => "LISTENER_ROUTE_STATUS_RESTORING",
198            ListenerRouteStatus::Unavailable => "LISTENER_ROUTE_STATUS_UNAVAILABLE",
199            ListenerRouteStatus::Other(s) => s,
200        }
201    }
202}
203
204impl From<&str> for ListenerRouteStatus {
205    fn from(s: &str) -> ListenerRouteStatus {
206        match s {
207            "LISTENER_ROUTE_STATUS_UNSPECIFIED" => ListenerRouteStatus::Unspecified,
208            "LISTENER_ROUTE_STATUS_PENDING" => ListenerRouteStatus::Pending,
209            "LISTENER_ROUTE_STATUS_ACTIVE" => ListenerRouteStatus::Active,
210            "LISTENER_ROUTE_STATUS_RESTORING" => ListenerRouteStatus::Restoring,
211            "LISTENER_ROUTE_STATUS_UNAVAILABLE" => ListenerRouteStatus::Unavailable,
212            other => ListenerRouteStatus::Other(other.to_string()),
213        }
214    }
215}
216
217impl std::fmt::Display for ListenerRouteStatus {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.write_str(self.as_str())
220    }
221}
222
223impl Serialize for ListenerRouteStatus {
224    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
225        serializer.serialize_str(self.as_str())
226    }
227}
228
229impl<'de> Deserialize<'de> for ListenerRouteStatus {
230    fn deserialize<D: serde::Deserializer<'de>>(
231        deserializer: D,
232    ) -> Result<ListenerRouteStatus, D::Error> {
233        Ok(ListenerRouteStatus::from(
234            String::deserialize(deserializer)?.as_str(),
235        ))
236    }
237}
238
239/// Transport protocol a client requests when exposing an ingress port. A closed
240/// set: a client can only request a protocol the SDK supports. Server responses
241/// use the open [`Protocol`], which tolerates a protocol a newer backend adds.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum IngressProtocol {
244    /// Raw TCP.
245    Tcp,
246    /// HTTP.
247    Http,
248}
249
250impl IngressProtocol {
251    /// The wire string for this protocol.
252    pub fn as_str(&self) -> &'static str {
253        match self {
254            IngressProtocol::Tcp => "tcp",
255            IngressProtocol::Http => "http",
256        }
257    }
258
259    /// Parse a wire string, returning `None` for a protocol the SDK does not
260    /// support.
261    pub fn parse(s: &str) -> Option<IngressProtocol> {
262        match s {
263            "tcp" => Some(IngressProtocol::Tcp),
264            "http" => Some(IngressProtocol::Http),
265            _ => None,
266        }
267    }
268}
269
270impl std::fmt::Display for IngressProtocol {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.write_str(self.as_str())
273    }
274}
275
276impl Serialize for IngressProtocol {
277    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
278        serializer.serialize_str(self.as_str())
279    }
280}
281
282/// A running-sailbox handle: the fields needed to exec/file/listener against a
283/// live box. Returned by create/resume/from_checkpoint.
284#[derive(Debug, Clone, Serialize)]
285pub struct SailboxHandle {
286    /// The sailbox's stable identifier.
287    pub sailbox_id: String,
288    /// The caller-supplied sailbox name.
289    pub name: String,
290    /// The sailbox's lifecycle status (for example `running`).
291    pub status: SailboxStatus,
292    /// Network address of the worker hosting the sailbox.
293    pub worker_address: String,
294    /// Endpoint used to open exec/file/listener streams to the box.
295    pub exec_endpoint: String,
296}
297
298/// Read-only snapshot from get/list. Deliberately omits worker_address /
299/// exec_endpoint (the list/get endpoints do not return them).
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct SailboxInfo {
302    /// The sailbox's stable identifier.
303    pub sailbox_id: String,
304    /// Identifier of the owning app.
305    pub app_id: String,
306    /// Name of the owning app.
307    pub app_name: String,
308    /// Identifier of the image the sailbox was created from.
309    pub image_id: String,
310    /// The caller-supplied sailbox name.
311    pub name: String,
312    /// The sailbox's lifecycle status (for example `running`).
313    pub status: SailboxStatus,
314    /// Configured memory size, in mebibytes.
315    pub memory_mib: i64,
316    /// Configured number of virtual CPUs.
317    pub vcpu_count: i64,
318    /// Configured state-disk size, in gibibytes.
319    pub state_disk_size_gib: i64,
320    /// Requested CPU allocation, in vCPUs.
321    pub cpu_requested_vcpu: i64,
322    /// Current CPU usage, in vCPUs.
323    pub cpu_used_vcpu: f64,
324    /// Requested memory allocation, in bytes.
325    pub memory_requested_bytes: i64,
326    /// Current memory usage, in bytes.
327    pub memory_used_bytes: i64,
328    /// Requested disk allocation, in bytes.
329    pub disk_requested_bytes: i64,
330    /// Current disk usage, in bytes.
331    pub disk_used_bytes: i64,
332    /// CPU architecture of the sailbox (for example `x86_64` or `arm64`).
333    pub architecture: String,
334    /// Guest schema version the box is running, when reported by the backend.
335    #[serde(default)]
336    pub guest_schema_version: Option<i64>,
337    /// Human-readable error detail, present when the box is in an error state.
338    #[serde(default)]
339    pub error_message: Option<String>,
340    /// Monotonic checkpoint generation counter for the sailbox.
341    pub checkpoint_generation: i64,
342    /// When the box last started, if it has started.
343    #[serde(with = "time::serde::rfc3339::option", default)]
344    pub started_at: Option<OffsetDateTime>,
345    /// When the most recent checkpoint was taken, if any.
346    #[serde(with = "time::serde::rfc3339::option", default)]
347    pub last_checkpointed_at: Option<OffsetDateTime>,
348    /// When the sailbox was created.
349    #[serde(with = "time::serde::rfc3339")]
350    pub created_at: OffsetDateTime,
351    /// When the sailbox was last updated.
352    #[serde(with = "time::serde::rfc3339")]
353    pub updated_at: OffsetDateTime,
354}
355
356/// One page of list results plus the pagination envelope.
357#[derive(Debug, Clone, Serialize)]
358pub struct SailboxPage {
359    /// The sailboxes in this page.
360    pub items: Vec<SailboxInfo>,
361    /// Maximum number of items requested for this page.
362    pub limit: i64,
363    /// Zero-based offset of the first item in this page.
364    pub offset: i64,
365    /// Total number of sailboxes matching the query across all pages.
366    pub total: i64,
367    /// True when further pages exist beyond this one.
368    pub has_more: bool,
369}
370
371/// A durable checkpoint handle. `status` echoes the source sailbox's lifecycle
372/// status after checkpointing (a running box is snapshotted; a paused/sleeping
373/// one returns its existing checkpoint), so the binding can sync its handle.
374#[derive(Debug, Clone, Serialize)]
375pub struct SailboxCheckpoint {
376    /// Stable identifier of the checkpoint.
377    pub checkpoint_id: String,
378    /// Identifier of the sailbox the checkpoint was taken from.
379    pub sailbox_id: String,
380    /// Checkpoint generation counter captured by this checkpoint.
381    pub checkpoint_generation: i64,
382    /// Source sailbox's lifecycle status after checkpointing.
383    pub status: SailboxStatus,
384}
385
386/// Filters for list/list_page.
387#[derive(Debug, Clone)]
388pub struct ListQuery {
389    /// Restrict results to sailboxes owned by this app (id or name).
390    pub app: Option<String>,
391    /// Restrict results to sailboxes in this lifecycle status.
392    pub status: Option<SailboxStatusFilter>,
393    /// Free-text search filter applied by the backend.
394    pub search: Option<String>,
395    /// Exclude sailboxes whose guest schema version exceeds this value.
396    pub max_guest_schema_version: Option<i64>,
397    /// Maximum number of items to return.
398    pub limit: i64,
399    /// Zero-based offset of the first item to return.
400    pub offset: i64,
401}
402
403/// Default page size for listing, matching the sailbox API's own default. The
404/// API rejects `limit=0`, so the derived all-zero default cannot be used.
405pub const DEFAULT_LIST_LIMIT: i64 = 50;
406
407/// Resource-limit bounds enforced at create time. These mirror the scheduler's
408/// authoritative limits so every binding (CLI, Python, future SDKs) fails fast
409/// with the same error instead of round-tripping to a backend rejection.
410pub const MAX_SAILBOX_VCPUS: i64 = 4;
411/// Minimum requested memory in MiB (0 is allowed and means "use the default").
412pub const MIN_SAILBOX_MEMORY_MIB: i64 = 1024;
413/// Maximum requested memory in MiB.
414pub const MAX_SAILBOX_MEMORY_MIB: i64 = 8192;
415/// Maximum requested state-disk size in GiB.
416pub const MAX_SAILBOX_DISK_GIB: i64 = 32;
417
418impl Default for ListQuery {
419    fn default() -> ListQuery {
420        ListQuery {
421            app: None,
422            status: None,
423            search: None,
424            max_guest_schema_version: None,
425            limit: DEFAULT_LIST_LIMIT,
426            offset: 0,
427        }
428    }
429}
430
431/// One guest ingress port to reserve at create time.
432#[derive(Debug, Clone, Serialize)]
433pub struct IngressPort {
434    /// Port inside the guest to expose for ingress.
435    pub guest_port: u32,
436    /// Transport protocol for the port (for example `tcp`).
437    pub protocol: IngressProtocol,
438    /// Source addresses allowed to reach the port; empty means all sources are
439    /// allowed. Always sent (even when empty) so the request states it
440    /// explicitly rather than relying on an omitted field.
441    pub allowlist: Vec<String>,
442}
443
444/// One NFS volume mount.
445#[derive(Debug, Clone, Serialize)]
446pub struct VolumeMount {
447    /// Identifier of the NFS volume to mount.
448    pub volume_id: String,
449    /// Absolute path inside the guest where the volume is mounted.
450    pub mount_path: String,
451}
452
453/// A managed NFS volume as returned by the sailbox-volume API. (The local
454/// mount path is a binding-side concern and not part of this wire type.)
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct NfsVolume {
457    /// Stable identifier of the volume.
458    pub volume_id: String,
459    /// Caller-supplied volume name.
460    pub name: String,
461    /// Storage backend serving the volume.
462    pub backend: String,
463    /// Lifecycle status of the volume.
464    pub status: String,
465    /// When the volume was created, if reported.
466    #[serde(with = "time::serde::rfc3339::option", default)]
467    pub created_at: Option<OffsetDateTime>,
468    /// When the volume was last updated, if reported.
469    #[serde(with = "time::serde::rfc3339::option", default)]
470    pub updated_at: Option<OffsetDateTime>,
471}
472
473/// Response from exposing a runtime ingress port (`expose`). The scheduler
474/// resolves the public endpoint in this response, so a caller renders it
475/// without a second lookup.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct AddListenerResponse {
478    /// The in-guest port now exposed.
479    pub guest_port: u32,
480    /// Wire protocol exposed, e.g. `tcp` or `http`.
481    pub protocol: Protocol,
482    /// Publicly reachable URL, when the listener has one (typically `http`).
483    #[serde(default)]
484    pub public_url: String,
485    /// Public hostname for a raw-TCP listener.
486    #[serde(default)]
487    pub tcp_public_host: String,
488    /// Public port for a raw-TCP listener.
489    #[serde(default)]
490    pub tcp_public_port: u32,
491}
492
493/// A CA-signed SSH user certificate plus the key id that identifies the signing
494/// org (`org=<id>;fp=...;iat=...`).
495#[derive(Debug, Clone)]
496pub struct IssuedUserCert {
497    /// The OpenSSH certificate (`<type>-cert-v01@openssh.com AAAA...`).
498    pub certificate: String,
499    /// The certificate key id.
500    pub key_id: String,
501}
502
503impl AddListenerResponse {
504    /// The reachable endpoint: `public_url` if present, else `host:port` for a
505    /// raw-TCP listener, else `None` (e.g. a local stack returns neither).
506    pub fn endpoint(&self) -> Option<String> {
507        if !self.public_url.is_empty() {
508            return Some(self.public_url.clone());
509        }
510        if !self.tcp_public_host.is_empty() && self.tcp_public_port != 0 {
511            return Some(format!("{}:{}", self.tcp_public_host, self.tcp_public_port));
512        }
513        None
514    }
515}
516
517/// Inputs to create a sailbox. The image is a typed [`ImageSpec`](crate::image::ImageSpec)
518/// (the higher-level image builder lives in the language wrapper); the lifecycle
519/// envelope is owned by the core.
520#[derive(Debug, Clone)]
521pub struct CreateSailboxRequest {
522    /// Identifier of the app that will own the sailbox.
523    pub app_id: String,
524    /// Caller-supplied name for the sailbox.
525    pub name: String,
526    /// Guest ingress ports to reserve at create time.
527    pub ingress_ports: Vec<IngressPort>,
528    /// NFS volumes to mount into the guest.
529    pub volume_mounts: Vec<VolumeMount>,
530    /// The image to create the sailbox from. Serialized to canonical proto-JSON
531    /// and sent as the `image` field.
532    pub image: crate::image::ImageSpec,
533    /// Requested CPU allocation in vCPUs; defaults server-side when absent.
534    pub cpu: Option<i64>,
535    /// Requested memory size in mebibytes; defaults server-side when absent.
536    pub memory_mib: Option<i64>,
537    /// Requested state-disk size in gibibytes; defaults server-side when absent.
538    pub state_disk_size_gib: Option<i64>,
539    /// Optional idle timeout in minutes after which the platform may autosleep.
540    pub autosleep_timeout_min: Option<i64>,
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn list_query_default_uses_a_valid_nonzero_limit() {
549        // The sailbox API rejects limit=0, so the default must be the API's own
550        // page size, not the derived zero.
551        let query = ListQuery::default();
552        assert_eq!(query.limit, DEFAULT_LIST_LIMIT);
553        assert!(query.limit > 0);
554        assert_eq!(query.offset, 0);
555    }
556}