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 std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13
14use crate::error::SailError;
15
16/// Lifecycle status of a Sailbox. An unrecognized server value is preserved in
17/// `Other` so a status a newer backend introduces never fails parsing. The
18/// default is `Other("")`: the status of a handle no server response has
19/// filled in yet.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum SailboxStatus {
22    /// Running and serving.
23    Running,
24    /// Paused in memory.
25    Paused,
26    /// Sleeping (checkpointed to disk).
27    Sleeping,
28    /// In a failed state.
29    Failed,
30    /// Terminated.
31    Terminated,
32    /// A status this SDK version does not recognize, kept verbatim.
33    Other(String),
34}
35
36impl Default for SailboxStatus {
37    fn default() -> Self {
38        SailboxStatus::Other(String::new())
39    }
40}
41
42impl SailboxStatus {
43    /// The wire string for this status.
44    pub fn as_str(&self) -> &str {
45        match self {
46            SailboxStatus::Running => "running",
47            SailboxStatus::Paused => "paused",
48            SailboxStatus::Sleeping => "sleeping",
49            SailboxStatus::Failed => "failed",
50            SailboxStatus::Terminated => "terminated",
51            SailboxStatus::Other(s) => s,
52        }
53    }
54}
55
56impl From<&str> for SailboxStatus {
57    fn from(s: &str) -> SailboxStatus {
58        match s {
59            "running" => SailboxStatus::Running,
60            "paused" => SailboxStatus::Paused,
61            "sleeping" => SailboxStatus::Sleeping,
62            "failed" => SailboxStatus::Failed,
63            "terminated" => SailboxStatus::Terminated,
64            other => SailboxStatus::Other(other.to_string()),
65        }
66    }
67}
68
69impl std::fmt::Display for SailboxStatus {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(self.as_str())
72    }
73}
74
75impl Serialize for SailboxStatus {
76    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
77        serializer.serialize_str(self.as_str())
78    }
79}
80
81impl<'de> Deserialize<'de> for SailboxStatus {
82    fn deserialize<D: serde::Deserializer<'de>>(
83        deserializer: D,
84    ) -> Result<SailboxStatus, D::Error> {
85        Ok(SailboxStatus::from(
86            String::deserialize(deserializer)?.as_str(),
87        ))
88    }
89}
90
91/// A lifecycle status a client can filter by in [`ListSailboxesQuery`]. A closed set: a
92/// client only filters by statuses the SDK knows. Server responses use the open
93/// [`SailboxStatus`], which tolerates a status a newer backend introduces.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SailboxStatusFilter {
96    /// Running and serving.
97    Running,
98    /// Paused in memory.
99    Paused,
100    /// Sleeping (checkpointed to disk).
101    Sleeping,
102    /// In a failed state.
103    Failed,
104    /// Terminated.
105    Terminated,
106}
107
108/// Server-side ordering for list results.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum SailboxListOrder {
111    /// Most recently active first: active/restorable Sailboxes, then updated_at.
112    #[default]
113    NewestActive,
114    /// Newest-created Sailboxes first.
115    NewestCreated,
116}
117
118impl SailboxListOrder {
119    /// The order's name, which is also the value the list API expects.
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            SailboxListOrder::NewestActive => "newest_active",
123            SailboxListOrder::NewestCreated => "newest_created",
124        }
125    }
126
127    /// Parse a canonical name.
128    pub fn parse(raw: &str) -> Option<SailboxListOrder> {
129        match raw.trim() {
130            "newest_active" => Some(SailboxListOrder::NewestActive),
131            "newest_created" => Some(SailboxListOrder::NewestCreated),
132            _ => None,
133        }
134    }
135}
136
137impl std::str::FromStr for SailboxListOrder {
138    type Err = SailError;
139
140    fn from_str(s: &str) -> Result<Self, Self::Err> {
141        SailboxListOrder::parse(s).ok_or_else(|| SailError::InvalidArgument {
142            message: format!(
143                "unknown list order {s:?}; use \"newest_active\" or \"newest_created\""
144            ),
145        })
146    }
147}
148
149impl std::fmt::Display for SailboxListOrder {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.write_str(self.as_str())
152    }
153}
154
155impl SailboxStatusFilter {
156    /// The wire string for this status.
157    pub fn as_str(&self) -> &'static str {
158        match self {
159            SailboxStatusFilter::Running => "running",
160            SailboxStatusFilter::Paused => "paused",
161            SailboxStatusFilter::Sleeping => "sleeping",
162            SailboxStatusFilter::Failed => "failed",
163            SailboxStatusFilter::Terminated => "terminated",
164        }
165    }
166
167    /// Parse a wire string, returning `None` for a status the SDK does not know.
168    pub fn parse(s: &str) -> Option<SailboxStatusFilter> {
169        match s {
170            "running" => Some(SailboxStatusFilter::Running),
171            "paused" => Some(SailboxStatusFilter::Paused),
172            "sleeping" => Some(SailboxStatusFilter::Sleeping),
173            "failed" => Some(SailboxStatusFilter::Failed),
174            "terminated" => Some(SailboxStatusFilter::Terminated),
175            _ => None,
176        }
177    }
178}
179
180impl std::str::FromStr for SailboxStatusFilter {
181    type Err = SailError;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        SailboxStatusFilter::parse(s).ok_or_else(|| SailError::InvalidArgument {
185            message: format!(
186                "unknown status filter {s:?}; use \"running\", \"paused\", \"sleeping\", \
187                 \"failed\", or \"terminated\""
188            ),
189        })
190    }
191}
192
193impl std::fmt::Display for SailboxStatusFilter {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.write_str(self.as_str())
196    }
197}
198
199/// Wire protocol for an ingress port or listener. An unrecognized value is
200/// preserved in `Other` so a protocol a newer backend introduces never fails.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ListenerProtocol {
203    /// Raw TCP.
204    Tcp,
205    /// HTTP.
206    Http,
207    /// A protocol this SDK version does not recognize, kept verbatim.
208    Other(String),
209}
210
211impl ListenerProtocol {
212    /// The wire string for this protocol.
213    pub fn as_str(&self) -> &str {
214        match self {
215            ListenerProtocol::Tcp => "tcp",
216            ListenerProtocol::Http => "http",
217            ListenerProtocol::Other(s) => s,
218        }
219    }
220}
221
222impl From<&str> for ListenerProtocol {
223    fn from(s: &str) -> ListenerProtocol {
224        match s {
225            "tcp" => ListenerProtocol::Tcp,
226            "http" => ListenerProtocol::Http,
227            other => ListenerProtocol::Other(other.to_string()),
228        }
229    }
230}
231
232impl std::fmt::Display for ListenerProtocol {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.write_str(self.as_str())
235    }
236}
237
238impl Serialize for ListenerProtocol {
239    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
240        serializer.serialize_str(self.as_str())
241    }
242}
243
244impl<'de> Deserialize<'de> for ListenerProtocol {
245    fn deserialize<D: serde::Deserializer<'de>>(
246        deserializer: D,
247    ) -> Result<ListenerProtocol, D::Error> {
248        Ok(ListenerProtocol::from(
249            String::deserialize(deserializer)?.as_str(),
250        ))
251    }
252}
253
254/// Status of a listener's ingress route. An unrecognized value is preserved in
255/// `Other` so a status a newer backend introduces never fails to parse.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum ListenerRouteStatus {
258    /// The route state is not yet known.
259    Unknown,
260    /// The route is being set up.
261    Pending,
262    /// The route is active and ready to carry traffic.
263    Active,
264    /// The route is being restored after a checkpoint or migration.
265    Restoring,
266    /// The route is temporarily unavailable.
267    Unavailable,
268    /// A status this SDK version does not recognize, kept verbatim.
269    Other(String),
270}
271
272impl ListenerRouteStatus {
273    /// The user-facing string for this status. The backend's proto spelling
274    /// (`LISTENER_ROUTE_STATUS_*`) is normalized here, once, so no wrapper
275    /// ever sees it.
276    pub fn as_str(&self) -> &str {
277        match self {
278            ListenerRouteStatus::Unknown => "unknown",
279            ListenerRouteStatus::Pending => "pending",
280            ListenerRouteStatus::Active => "active",
281            ListenerRouteStatus::Restoring => "restoring",
282            ListenerRouteStatus::Unavailable => "unavailable",
283            ListenerRouteStatus::Other(s) => s,
284        }
285    }
286}
287
288impl From<&str> for ListenerRouteStatus {
289    /// Parse the backend's proto spelling (`LISTENER_ROUTE_STATUS_*`) or the
290    /// friendly form that [`as_str`](Self::as_str) emits, so a serialized
291    /// [`Listener`](crate::Listener) round-trips through serde. An unrecognized
292    /// value is kept in `Other` with the `LISTENER_ROUTE_STATUS_` prefix
293    /// stripped and lowercased.
294    fn from(s: &str) -> ListenerRouteStatus {
295        match s {
296            "LISTENER_ROUTE_STATUS_UNSPECIFIED" | "unknown" => ListenerRouteStatus::Unknown,
297            "LISTENER_ROUTE_STATUS_PENDING" | "pending" => ListenerRouteStatus::Pending,
298            "LISTENER_ROUTE_STATUS_ACTIVE" | "active" => ListenerRouteStatus::Active,
299            "LISTENER_ROUTE_STATUS_RESTORING" | "restoring" => ListenerRouteStatus::Restoring,
300            "LISTENER_ROUTE_STATUS_UNAVAILABLE" | "unavailable" => ListenerRouteStatus::Unavailable,
301            other => ListenerRouteStatus::Other(
302                other
303                    .strip_prefix("LISTENER_ROUTE_STATUS_")
304                    .map_or_else(|| other.to_string(), str::to_ascii_lowercase),
305            ),
306        }
307    }
308}
309
310impl std::fmt::Display for ListenerRouteStatus {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        f.write_str(self.as_str())
313    }
314}
315
316impl Serialize for ListenerRouteStatus {
317    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
318        serializer.serialize_str(self.as_str())
319    }
320}
321
322impl<'de> Deserialize<'de> for ListenerRouteStatus {
323    fn deserialize<D: serde::Deserializer<'de>>(
324        deserializer: D,
325    ) -> Result<ListenerRouteStatus, D::Error> {
326        Ok(ListenerRouteStatus::from(
327            String::deserialize(deserializer)?.as_str(),
328        ))
329    }
330}
331
332/// Transport protocol a client requests when exposing an ingress port. A closed
333/// set: a client can only request a protocol the SDK supports. Server responses
334/// use the open [`ListenerProtocol`], which tolerates a protocol a newer backend adds.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum IngressProtocol {
337    /// Raw TCP.
338    Tcp,
339    /// HTTP.
340    Http,
341}
342
343impl IngressProtocol {
344    /// The wire string for this protocol.
345    pub fn as_str(&self) -> &'static str {
346        match self {
347            IngressProtocol::Tcp => "tcp",
348            IngressProtocol::Http => "http",
349        }
350    }
351
352    /// Parse a wire string, returning `None` for a protocol the SDK does not
353    /// support.
354    pub fn parse(s: &str) -> Option<IngressProtocol> {
355        match s {
356            "tcp" => Some(IngressProtocol::Tcp),
357            "http" => Some(IngressProtocol::Http),
358            _ => None,
359        }
360    }
361}
362
363impl std::str::FromStr for IngressProtocol {
364    type Err = SailError;
365
366    fn from_str(s: &str) -> Result<Self, Self::Err> {
367        IngressProtocol::parse(s).ok_or_else(|| SailError::InvalidArgument {
368            message: format!("unknown ingress protocol {s:?}; use \"tcp\" or \"http\""),
369        })
370    }
371}
372
373impl std::fmt::Display for IngressProtocol {
374    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375        f.write_str(self.as_str())
376    }
377}
378
379impl Serialize for IngressProtocol {
380    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
381        serializer.serialize_str(self.as_str())
382    }
383}
384
385/// Options for [`Sailbox::checkpoint`](crate::Sailbox::checkpoint).
386#[derive(Debug, Clone, Default)]
387pub struct CheckpointOptions {
388    /// Human-readable label for the checkpoint.
389    pub name: Option<String>,
390    /// Retention override; must be positive when given.
391    pub ttl: Option<std::time::Duration>,
392}
393
394/// Options for
395/// [`Sailbox::wait_for_listener`](crate::Sailbox::wait_for_listener).
396/// `Default` gives up after a minute.
397#[derive(Debug, Clone)]
398pub struct WaitForListenerOptions {
399    /// Fail with a timeout error after this long. `Duration::MAX` waits
400    /// indefinitely.
401    pub timeout: std::time::Duration,
402}
403
404impl Default for WaitForListenerOptions {
405    fn default() -> WaitForListenerOptions {
406        WaitForListenerOptions {
407            timeout: std::time::Duration::from_mins(1),
408        }
409    }
410}
411
412/// Options for [`Sailbox::fork`](crate::Sailbox::fork).
413#[derive(Debug, Clone, Default)]
414pub struct ForkOptions {
415    /// Display name for the child Sailbox; `None` lets the server derive one.
416    pub name: Option<String>,
417    /// Accepted and ignored. The call blocks until the fork finishes, so apply
418    /// your own deadline if you need one. Must be positive when given.
419    pub timeout: Option<std::time::Duration>,
420}
421
422/// Named Sailbox resource size. Sailboxes are offered in a small discrete CPU
423/// menu rather than free-form vCPU/memory/disk. Each size sets the vCPU count
424/// plus a default memory and disk ceiling. Ongoing billing is by observed
425/// usage, so a larger size raises the ceiling without reserving resources;
426/// each size also has its documented one-time creation charge.
427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428pub enum SailboxSize {
429    /// Small (1 vCPU): fastest cold starts, forks, and resumes, with
430    /// lower ceilings that cap what a runaway workload can consume.
431    Small,
432    /// Medium (4 vCPU, default).
433    Medium,
434    /// Large (8 vCPU).
435    Large,
436}
437
438impl SailboxSize {
439    /// The wire string for this size.
440    pub fn as_str(&self) -> &'static str {
441        match self {
442            SailboxSize::Small => "s",
443            SailboxSize::Medium => "m",
444            SailboxSize::Large => "l",
445        }
446    }
447
448    /// Parse a wire string, returning `None` for an unsupported size.
449    pub fn parse(s: &str) -> Option<SailboxSize> {
450        match s {
451            "s" => Some(SailboxSize::Small),
452            "m" => Some(SailboxSize::Medium),
453            "l" => Some(SailboxSize::Large),
454            _ => None,
455        }
456    }
457
458    /// The allowed size labels in ascending order, for error messages and help.
459    pub fn allowed() -> &'static [&'static str] {
460        &["s", "m", "l"]
461    }
462}
463
464impl std::str::FromStr for SailboxSize {
465    type Err = SailError;
466
467    fn from_str(s: &str) -> Result<Self, Self::Err> {
468        SailboxSize::parse(s).ok_or_else(|| SailError::InvalidArgument {
469            message: format!("unknown size {s:?}; use one of s, m, l"),
470        })
471    }
472}
473
474impl std::fmt::Display for SailboxSize {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        f.write_str(self.as_str())
477    }
478}
479
480impl Serialize for SailboxSize {
481    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
482        serializer.serialize_str(self.as_str())
483    }
484}
485
486/// A handle to a running Sailbox: the fields needed to exec against it, copy
487/// files, or reach a listener. Returned by create/resume/from_checkpoint.
488#[derive(Debug, Clone, Default, Serialize)]
489#[non_exhaustive]
490pub struct SailboxHandle {
491    /// The Sailbox's stable identifier.
492    pub sailbox_id: String,
493    /// The Sailbox's name: the one given at create, or the one Sail derived
494    /// from the source when a fork or a restore left it out.
495    pub name: String,
496    /// The Sailbox's lifecycle status (for example `running`).
497    pub status: SailboxStatus,
498    /// Internal routing address for the host serving the Sailbox.
499    #[doc(hidden)]
500    pub worker_address: String,
501    /// Internal endpoint for opening exec/file/listener streams to the Sailbox.
502    #[doc(hidden)]
503    pub exec_endpoint: String,
504}
505
506/// Actionable notice that a Sailbox's runtime should be upgraded.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508#[non_exhaustive]
509pub struct SailboxDeprecation {
510    /// Date after which the Sailbox may be upgraded automatically on its
511    /// next wake, stopping running processes and clearing in-memory state.
512    pub deadline: String,
513    /// Upgrade guidance from the server.
514    pub message: String,
515}
516
517/// Read-only snapshot from get/list. Deliberately omits worker_address /
518/// exec_endpoint (the list/get endpoints do not return them).
519#[derive(Debug, Clone, Serialize, Deserialize)]
520#[non_exhaustive]
521pub struct SailboxInfo {
522    /// The Sailbox's stable identifier.
523    pub sailbox_id: String,
524    /// Identifier of the owning app.
525    pub app_id: String,
526    /// Name of the owning app.
527    pub app_name: String,
528    /// Identifier of the image the Sailbox was created from.
529    pub image_id: String,
530    /// The Sailbox's display name: the one given at create, restore, or fork
531    /// time, or one derived from the source when that call omitted it.
532    pub name: String,
533    /// The Sailbox's lifecycle status (for example `running`).
534    pub status: SailboxStatus,
535    /// Configured memory size, in mebibytes.
536    pub memory_mib: i64,
537    /// Configured number of virtual CPUs.
538    pub vcpu_count: i64,
539    /// Configured state-disk size, in gibibytes.
540    pub state_disk_size_gib: i64,
541    /// Requested CPU allocation, in vCPUs.
542    pub cpu_requested_vcpu: i64,
543    /// Current CPU usage, in vCPUs.
544    pub cpu_used_vcpu: f64,
545    /// Requested memory allocation, in bytes.
546    pub memory_requested_bytes: i64,
547    /// Current memory usage, in bytes.
548    pub memory_used_bytes: i64,
549    /// Requested disk allocation, in bytes.
550    pub disk_requested_bytes: i64,
551    /// Current disk usage, in bytes.
552    pub disk_used_bytes: i64,
553    /// CPU architecture of the Sailbox, in the create-side vocabulary
554    /// (`amd64` or `arm64`).
555    #[serde(default)]
556    pub architecture: String,
557    /// Guest schema version the Sailbox is running, when reported by the backend.
558    #[serde(default)]
559    pub guest_schema_version: Option<i64>,
560    /// Actionable runtime deprecation notice, when an upgrade is needed.
561    #[serde(default)]
562    pub deprecation: Option<SailboxDeprecation>,
563    /// Human-readable error detail, present when the Sailbox is in an error state.
564    #[serde(default)]
565    pub error_message: Option<String>,
566    /// Monotonic checkpoint generation counter for the Sailbox.
567    pub checkpoint_generation: i64,
568    /// When the Sailbox first started running, if it has. A resume does not
569    /// rewrite it.
570    #[serde(with = "crate::rfc3339_micros::option", default)]
571    pub started_at: Option<OffsetDateTime>,
572    /// When the most recent checkpoint was taken, if any.
573    #[serde(with = "crate::rfc3339_micros::option", default)]
574    pub last_checkpointed_at: Option<OffsetDateTime>,
575    /// When the Sailbox was created.
576    #[serde(with = "crate::rfc3339_micros")]
577    pub created_at: OffsetDateTime,
578    /// When the Sailbox was last updated.
579    #[serde(with = "crate::rfc3339_micros")]
580    pub updated_at: OffsetDateTime,
581    /// The user whose credential created this Sailbox (for a fork or restore, the
582    /// user who ran it). `None` for service-key creates.
583    #[serde(default)]
584    pub created_by_user_id: Option<String>,
585    /// Access visibility: `Some("private")` restricts exec/files/SSH and
586    /// lifecycle to the creator; `None`/`Some("org")` is org-wide access.
587    #[serde(default)]
588    pub visibility: Option<String>,
589}
590
591/// The identity behind the presented API key.
592#[doc(hidden)]
593#[derive(Debug, Clone, Deserialize)]
594#[non_exhaustive]
595pub struct WhoAmI {
596    /// The organization the key belongs to.
597    pub org_id: String,
598    /// The user the key is scoped to, or `None` for a service key.
599    #[serde(default)]
600    pub user_id: Option<String>,
601}
602
603/// One page of list results plus the pagination envelope.
604#[derive(Debug, Clone, Serialize)]
605#[non_exhaustive]
606pub struct SailboxPage {
607    /// The Sailboxes in this page.
608    pub items: Vec<SailboxInfo>,
609    /// Maximum number of items requested for this page.
610    pub limit: i64,
611    /// Zero-based offset of the first item in this page.
612    pub offset: i64,
613    /// Total number of Sailboxes matching the query across all pages.
614    pub total: i64,
615    /// True when further pages exist beyond this one.
616    pub has_more: bool,
617}
618
619/// Query parameters for [`Client::sailbox_spend`](crate::Client::sailbox_spend).
620#[derive(Debug, Clone, Default)]
621pub struct SailboxSpendQuery {
622    /// Restrict spend to Sailboxes owned by this app id.
623    pub app_id: Option<String>,
624    /// Restrict spend to one Sailbox id.
625    pub sailbox_id: Option<String>,
626    /// Inclusive lower bound for the spend window. The API defaults to the
627    /// current UTC month when omitted.
628    pub from: Option<OffsetDateTime>,
629    /// Exclusive upper bound for the spend window. The API defaults to now when
630    /// omitted.
631    pub to: Option<OffsetDateTime>,
632}
633
634/// Estimated Sailbox spend over a requested time window.
635#[derive(Debug, Clone, Serialize, Deserialize)]
636#[non_exhaustive]
637pub struct SailboxSpendResponse {
638    /// Start of the spend window.
639    #[serde(with = "crate::rfc3339_micros")]
640    pub start_at: OffsetDateTime,
641    /// End of the spend window.
642    #[serde(with = "crate::rfc3339_micros")]
643    pub end_at: OffsetDateTime,
644    /// Cost from closed billing segments, in USD nanos.
645    pub finalized_cost_usd_nanos: i64,
646    /// Estimated cost from still-open billing segments, in USD nanos.
647    pub estimated_active_cost_usd_nanos: i64,
648    /// Total estimated cost, in USD nanos.
649    pub estimated_total_cost_usd_nanos: i64,
650    /// Total observed duration, in seconds.
651    pub duration_seconds: i64,
652    /// Total vCPU-seconds observed.
653    pub vcpu_seconds: f64,
654    /// Total GiB-seconds of memory observed.
655    pub memory_gib_seconds: f64,
656    /// Total GiB-seconds of state disk observed.
657    pub state_disk_gib_seconds: f64,
658    /// Whether non-zero pricing rates were configured by the backend.
659    pub pricing_configured: bool,
660    /// Pricing rates used for the local estimate.
661    pub rates: SailboxSpendRates,
662    /// Per-Sailbox spend summaries.
663    pub sailboxes: Vec<SailboxSpendItem>,
664}
665
666/// Rate metadata returned with a Sailbox spend estimate.
667#[derive(Debug, Clone, Default, Serialize, Deserialize)]
668#[non_exhaustive]
669pub struct SailboxSpendRates {
670    /// Nanodollars per vCPU-second.
671    pub vcpu_second_usd_nanos: i64,
672    /// Nanodollars per memory GiB-second.
673    pub memory_gib_second_usd_nanos: i64,
674    /// Nanodollars per arm64 state-disk GiB-second.
675    pub state_disk_gib_second_usd_nanos: i64,
676    /// Nanodollars per Sailbox creation.
677    pub creation_usd_nanos: i64,
678    /// Nanodollars per amd64 state-disk GiB-second.
679    #[serde(default)]
680    pub amd64_state_disk_gib_second_usd_nanos: i64,
681}
682
683/// Estimated spend for one Sailbox over a requested time window.
684#[derive(Debug, Clone, Serialize, Deserialize)]
685#[non_exhaustive]
686pub struct SailboxSpendItem {
687    /// Sailbox id.
688    pub sailbox_id: String,
689    /// Owning app id.
690    pub app_id: String,
691    /// Cost from closed billing segments, in USD nanos.
692    pub finalized_cost_usd_nanos: i64,
693    /// Estimated cost from still-open billing segments, in USD nanos.
694    pub estimated_active_cost_usd_nanos: i64,
695    /// Total estimated cost, in USD nanos.
696    pub estimated_total_cost_usd_nanos: i64,
697    /// Observed duration, in seconds.
698    pub duration_seconds: i64,
699    /// Observed vCPU-seconds.
700    pub vcpu_seconds: f64,
701    /// Observed GiB-seconds of memory.
702    pub memory_gib_seconds: f64,
703    /// Observed GiB-seconds of state disk.
704    pub state_disk_gib_seconds: f64,
705    /// Whether this row includes an active billing segment.
706    pub active: bool,
707}
708
709/// Query parameters for [`Client::sailbox_metrics`](crate::Client::sailbox_metrics).
710#[derive(Debug, Clone)]
711pub struct SailboxMetricsQuery {
712    /// Window name accepted by the API, for example `1h`, `6h`, `24h`, or `7d`.
713    pub range: String,
714}
715
716impl Default for SailboxMetricsQuery {
717    fn default() -> SailboxMetricsQuery {
718        SailboxMetricsQuery {
719            range: "24h".to_string(),
720        }
721    }
722}
723
724/// Resource-usage time series for one Sailbox.
725#[derive(Debug, Clone, Serialize, Deserialize)]
726#[non_exhaustive]
727pub struct SailboxMetricsResponse {
728    /// Echoed range selected by the API.
729    pub range: String,
730    /// Time-ordered metric points.
731    pub data: Vec<SailboxMetricPoint>,
732}
733
734/// One bucket in a Sailbox resource-usage time series.
735#[derive(Debug, Clone, Serialize, Deserialize)]
736#[non_exhaustive]
737pub struct SailboxMetricPoint {
738    /// Bucket timestamp.
739    #[serde(with = "crate::rfc3339_micros")]
740    pub timestamp: OffsetDateTime,
741    /// Current CPU usage, in vCPUs.
742    pub cpu_used_vcpu: f64,
743    /// Requested CPU allocation, in vCPUs.
744    pub cpu_requested_vcpu: i64,
745    /// Current memory usage, in bytes.
746    pub memory_used_bytes: i64,
747    /// Requested memory allocation, in bytes.
748    pub memory_requested_bytes: i64,
749    /// Current disk usage, in bytes.
750    pub disk_used_bytes: i64,
751    /// Requested disk allocation, in bytes.
752    pub disk_requested_bytes: i64,
753}
754
755/// A durable checkpoint handle. `status` echoes the source Sailbox's lifecycle
756/// status after checkpointing (a running Sailbox is snapshotted; a paused/sleeping
757/// one returns its existing checkpoint), so the binding can sync its handle.
758#[derive(Debug, Clone, Serialize)]
759#[non_exhaustive]
760pub struct SailboxCheckpoint {
761    /// Stable identifier of the checkpoint.
762    pub checkpoint_id: String,
763    /// Identifier of the Sailbox the checkpoint was taken from.
764    pub sailbox_id: String,
765    /// Checkpoint generation counter captured by this checkpoint.
766    pub checkpoint_generation: i64,
767    /// When the checkpoint expires: seven days out unless `ttl` asked for a
768    /// different window. Starting a Sailbox from it after that fails.
769    #[serde(with = "crate::rfc3339_micros::option")]
770    pub expires_at: Option<OffsetDateTime>,
771    /// Source Sailbox's lifecycle status after checkpointing.
772    pub status: SailboxStatus,
773}
774
775/// Filters for list/list_page.
776#[derive(Debug, Clone)]
777pub struct ListSailboxesQuery {
778    /// Restrict results to Sailboxes owned by the app with this id.
779    pub app_id: Option<String>,
780    /// Restrict results to Sailboxes in this lifecycle status.
781    pub status: Option<SailboxStatusFilter>,
782    /// Free-text search filter applied by the backend.
783    pub search: Option<String>,
784    /// Restrict results to Sailboxes with the credential injection policy
785    /// with this id attached.
786    #[doc(hidden)]
787    pub credential_policy_id: Option<String>,
788    /// Ordering to apply before pagination.
789    pub order: SailboxListOrder,
790    /// Maximum number of items to return.
791    pub limit: i64,
792    /// Zero-based offset of the first item to return.
793    pub offset: i64,
794}
795
796/// Default page size for listing, matching the Sailbox API's own default. The
797/// API rejects `limit=0`, so the derived all-zero default cannot be used.
798#[doc(hidden)]
799pub const DEFAULT_LIST_LIMIT: i64 = 50;
800
801/// Largest page size the listing endpoints accept; larger limits are rejected,
802/// not clamped. Auto-paginating listers request full pages of this size.
803#[doc(hidden)]
804pub const MAX_LIST_LIMIT: i64 = 100;
805
806impl Default for ListSailboxesQuery {
807    fn default() -> ListSailboxesQuery {
808        ListSailboxesQuery {
809            app_id: None,
810            status: None,
811            search: None,
812            credential_policy_id: None,
813            order: SailboxListOrder::NewestActive,
814            limit: DEFAULT_LIST_LIMIT,
815            offset: 0,
816        }
817    }
818}
819
820/// One guest ingress port to reserve at create time.
821#[derive(Debug, Clone, Serialize)]
822pub struct IngressPort {
823    /// Port inside the guest to expose for ingress.
824    pub guest_port: u32,
825    /// Transport protocol for the port (for example `tcp`).
826    pub protocol: IngressProtocol,
827    /// Sources allowed to reach the port: an address or a range, or a Sail app
828    /// name on an `http` listener. An app name cannot read as an address or a
829    /// range, and cannot contain a `/`. An address must not carry an IPv6 zone,
830    /// such as `fe80::1%eth0`, which names an interface on one machine rather
831    /// than a source. Empty means all sources are allowed.
832    pub allowlist: Vec<String>,
833}
834
835/// One NFS volume mount.
836#[derive(Debug, Clone, Serialize)]
837pub struct VolumeMount {
838    /// Identifier of the NFS volume to mount.
839    pub volume_id: String,
840    /// Absolute path inside the guest where the volume is mounted.
841    pub mount_path: String,
842}
843
844/// A custom domain attached to a Sailbox HTTP listener.
845#[doc(hidden)]
846#[derive(Debug, Clone, Serialize, Deserialize)]
847#[non_exhaustive]
848pub struct CustomDomainInfo {
849    /// Normalized hostname.
850    pub domain: String,
851    /// Sailbox receiving requests for the hostname.
852    pub sailbox_id: String,
853    /// Guest HTTP port receiving requests for the hostname.
854    pub guest_port: u32,
855    /// HTTPS URL served by the hostname.
856    pub url: String,
857    /// Organization-specific hostname the domain's CNAME record must target.
858    pub cname_target: String,
859    /// When the domain was attached.
860    #[serde(with = "crate::rfc3339_micros")]
861    pub created_at: OffsetDateTime,
862}
863
864/// A managed NFS volume as returned by the volume API.
865#[derive(Debug, Clone, Serialize, Deserialize)]
866#[non_exhaustive]
867pub struct VolumeInfo {
868    /// Stable identifier of the volume.
869    pub volume_id: String,
870    /// Caller-supplied volume name.
871    pub name: String,
872    /// Storage backend serving the volume.
873    pub backend: String,
874    /// Lifecycle status of the volume.
875    pub status: String,
876    /// When the volume was created, if reported.
877    #[serde(with = "crate::rfc3339_micros::option", default)]
878    pub created_at: Option<OffsetDateTime>,
879    /// When the volume was last updated, if reported.
880    #[serde(with = "crate::rfc3339_micros::option", default)]
881    pub updated_at: Option<OffsetDateTime>,
882}
883
884/// The wire shape of the add-listener response: the scheduler resolves the
885/// public endpoint in the response (so a caller renders it without a second
886/// lookup) but reports nothing about routing, so it converts to a [`Listener`]
887/// with an unknown route status.
888#[derive(Debug, Clone, Deserialize)]
889pub(crate) struct AddListenerWire {
890    pub(crate) guest_port: u32,
891    pub(crate) protocol: ListenerProtocol,
892    #[serde(default)]
893    pub(crate) public_url: String,
894    #[serde(default)]
895    pub(crate) public_host: String,
896    #[serde(default)]
897    pub(crate) public_port: u32,
898}
899
900impl From<AddListenerWire> for crate::worker::Listener {
901    fn from(wire: AddListenerWire) -> crate::worker::Listener {
902        crate::worker::Listener {
903            guest_port: wire.guest_port,
904            protocol: wire.protocol,
905            // The expose response says nothing about reachability; confirm
906            // with wait_for_listener or re-fetch via get/list.
907            route_status: ListenerRouteStatus::Unknown,
908            public_url: wire.public_url,
909            public_host: wire.public_host,
910            public_port: wire.public_port,
911        }
912    }
913}
914
915/// A CA-signed SSH user certificate plus the key id that identifies the signing
916/// org (`org=<id>;fp=...;iat=...`).
917#[derive(Debug, Clone)]
918#[non_exhaustive]
919pub struct IssuedUserCert {
920    /// The OpenSSH certificate (`<type>-cert-v01@openssh.com AAAA...`).
921    pub certificate: String,
922    /// The certificate key id.
923    pub key_id: String,
924}
925
926/// How to reach an exposed listener: a routable HTTPS URL for `http`
927/// listeners, or a host/port any TCP client can dial for `tcp` listeners.
928#[derive(Debug, Clone, PartialEq, Eq)]
929pub enum ListenerEndpoint {
930    /// The HTTPS URL to reach the guest service.
931    Http {
932        /// Routable URL.
933        url: String,
934    },
935    /// The address to dial for a raw-TCP listener.
936    Tcp {
937        /// Hostname to dial.
938        host: String,
939        /// Port to dial.
940        port: u32,
941    },
942}
943
944impl std::fmt::Display for ListenerEndpoint {
945    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
946        match self {
947            ListenerEndpoint::Http { url } => f.write_str(url),
948            ListenerEndpoint::Tcp { host, port } => write!(f, "{host}:{port}"),
949        }
950    }
951}
952
953/// Inputs to create a Sailbox. The image is a typed [`ImageSpec`](crate::image::ImageSpec);
954/// describe a custom build by filling an
955/// [`ImageDefinition`](crate::imagebuild::ImageDefinition). `Default` selects
956/// the Debian base image and the platform defaults for everything else;
957/// `app_id` (and usually `name`) must be set before the request is valid.
958#[derive(Debug, Clone)]
959pub struct CreateSailboxRequest {
960    /// Identifier of the app that will own the Sailbox.
961    pub app_id: String,
962    /// Caller-supplied name for the Sailbox.
963    pub name: String,
964    /// Guest ingress ports to reserve at create time.
965    pub ingress_ports: Vec<IngressPort>,
966    /// NFS volumes to mount into the guest.
967    pub volume_mounts: Vec<VolumeMount>,
968    /// The image to create the Sailbox from. Serialized to canonical proto-JSON
969    /// and sent as the `image` field.
970    pub image: crate::image::ImageSpec,
971    /// Requested resource size; defaults server-side when absent.
972    pub size: Option<SailboxSize>,
973    /// Optional memory limit in whole GiB, within the size's range; the
974    /// size's default when absent.
975    pub memory_gib: Option<u32>,
976    /// Optional disk size in whole GiB, within the size's range; the size's
977    /// default when absent.
978    pub disk_gib: Option<u32>,
979    /// Enable SSH on the new Sailbox after create: trust the org SSH CA, start
980    /// `sshd`, and expose guest port 22 as TCP once the CA-only daemon owns it.
981    /// An explicit port-22 ingress entry contributes just its allowlist.
982    pub ssh: bool,
983    /// By default a Sailbox is org-wide: any credential in the org can exec,
984    /// copy files, SSH, or run lifecycle operations on it. `true` restricts all
985    /// of that to the creating user. An org admin can override that with a
986    /// recorded reason for exec, files, setting a wake time, and the pause,
987    /// sleep, resume, terminate, and upgrade operations. SSH, exposing or
988    /// removing listeners, and fork/checkpoint/restore stay creator-only.
989    /// Requires a user-scoped API key.
990    pub private: bool,
991    /// Budget for rebuilding the image if Sail needs to rebuild it before
992    /// the Sailbox is created; the default build budget applies when absent.
993    pub image_build_timeout: Option<Duration>,
994}
995
996impl Default for CreateSailboxRequest {
997    /// An empty request for a plain Debian-base Sailbox: fill in `app_id` and
998    /// `name`, override the rest as needed.
999    fn default() -> CreateSailboxRequest {
1000        CreateSailboxRequest {
1001            app_id: String::new(),
1002            name: String::new(),
1003            ingress_ports: Vec::new(),
1004            volume_mounts: Vec::new(),
1005            image: crate::image::ImageSpec {
1006                base: Some(crate::image::BaseImage::Debian),
1007                ..crate::image::ImageSpec::default()
1008            },
1009            size: None,
1010            memory_gib: None,
1011            disk_gib: None,
1012            ssh: false,
1013            private: false,
1014            image_build_timeout: None,
1015        }
1016    }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    #[test]
1024    fn closed_enums_round_trip_through_fromstr_and_display() {
1025        for (text, value) in [
1026            ("newest_active", SailboxListOrder::NewestActive),
1027            ("newest_created", SailboxListOrder::NewestCreated),
1028        ] {
1029            assert_eq!(text.parse::<SailboxListOrder>().unwrap(), value);
1030            assert_eq!(value.to_string(), text);
1031        }
1032        for (text, value) in [
1033            ("running", SailboxStatusFilter::Running),
1034            ("terminated", SailboxStatusFilter::Terminated),
1035        ] {
1036            assert_eq!(text.parse::<SailboxStatusFilter>().unwrap(), value);
1037            assert_eq!(value.to_string(), text);
1038        }
1039        for (text, value) in [
1040            ("tcp", IngressProtocol::Tcp),
1041            ("http", IngressProtocol::Http),
1042        ] {
1043            assert_eq!(text.parse::<IngressProtocol>().unwrap(), value);
1044            assert_eq!(value.to_string(), text);
1045        }
1046        for (text, value) in [
1047            ("s", SailboxSize::Small),
1048            ("m", SailboxSize::Medium),
1049            ("l", SailboxSize::Large),
1050        ] {
1051            assert_eq!(text.parse::<SailboxSize>().unwrap(), value);
1052            assert_eq!(value.to_string(), text);
1053        }
1054        let err = "udp".parse::<IngressProtocol>().unwrap_err();
1055        assert!(matches!(err, SailError::InvalidArgument { .. }));
1056        assert!(err.to_string().contains("udp"));
1057    }
1058
1059    #[test]
1060    fn list_query_default_uses_a_valid_nonzero_limit() {
1061        // The sailbox API rejects limit=0, so the default must be the API's own
1062        // page size, not the derived zero.
1063        let query = ListSailboxesQuery::default();
1064        assert_eq!(query.limit, DEFAULT_LIST_LIMIT);
1065        assert!(query.limit > 0);
1066        assert_eq!(query.offset, 0);
1067    }
1068
1069    #[test]
1070    fn route_status_round_trips_through_serde() {
1071        // Serialize emits the friendly form (as_str), so a persisted Listener
1072        // read back must parse it rather than fall through to Other. Guards the
1073        // Serialize/Deserialize pair against drifting apart.
1074        for status in [
1075            ListenerRouteStatus::Unknown,
1076            ListenerRouteStatus::Pending,
1077            ListenerRouteStatus::Active,
1078            ListenerRouteStatus::Restoring,
1079            ListenerRouteStatus::Unavailable,
1080            ListenerRouteStatus::Other("degraded".to_string()),
1081        ] {
1082            let json = serde_json::to_string(&status).unwrap();
1083            let back: ListenerRouteStatus = serde_json::from_str(&json).unwrap();
1084            assert_eq!(
1085                status, back,
1086                "round-trip failed for {status:?} (json {json})"
1087            );
1088        }
1089        // The backend's proto spelling still parses to the same value.
1090        assert_eq!(
1091            ListenerRouteStatus::from("LISTENER_ROUTE_STATUS_ACTIVE"),
1092            ListenerRouteStatus::Active,
1093        );
1094    }
1095
1096    #[test]
1097    fn info_deserializes_a_deprecation_notice() {
1098        let value = serde_json::json!({
1099            "sailbox_id": "sb-1", "app_id": "app-1", "app_name": "a", "name": "n",
1100            "image_id": "img-1",
1101            "status": "running", "memory_mib": 2048, "vcpu_count": 4,
1102            "state_disk_size_gib": 10,
1103            "cpu_requested_vcpu": 2, "cpu_used_vcpu": 1.5,
1104            "memory_requested_bytes": 1024, "memory_used_bytes": 512,
1105            "disk_requested_bytes": 4096, "disk_used_bytes": 2048,
1106            "architecture": "amd64", "checkpoint_generation": 7,
1107            "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z",
1108            "deprecation": {
1109                "deadline": "2026-08-01",
1110                "message": "Upgrade this sailbox before 2026-08-01."
1111            }
1112        });
1113        let info: SailboxInfo = serde_json::from_value(value).unwrap();
1114        let notice = info.deprecation.unwrap();
1115        assert_eq!(notice.deadline, "2026-08-01");
1116        assert!(notice.message.starts_with("Upgrade this sailbox"));
1117    }
1118
1119    #[test]
1120    fn add_listener_reads_the_tcp_dial_target() {
1121        for body in [
1122            r#"{"guest_port":2222,"protocol":"tcp","public_host":"tcp.example.com","public_port":22003}"#,
1123            // A response that repeats the pair under an older name reads the
1124            // same, because a field this type does not name is ignored.
1125            r#"{"guest_port":2222,"protocol":"tcp","public_host":"tcp.example.com","public_port":22003,"tcp_public_host":"old.example.com","tcp_public_port":1}"#,
1126        ] {
1127            let wire: AddListenerWire = serde_json::from_str(body).unwrap();
1128            let listener = crate::worker::Listener::from(wire);
1129            assert_eq!(listener.public_host, "tcp.example.com");
1130            assert_eq!(listener.public_port, 22003);
1131        }
1132    }
1133}