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 boxes, then updated_at.
112    #[default]
113    NewestActive,
114    /// Newest-created boxes 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    /// Bound on the synchronous fork call; must be positive when given.
418    /// `None` uses the server default.
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. Billing is by observed usage, so a
425/// larger size is a higher ceiling, not a higher bill.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum SailboxSize {
428    /// Small (1 vCPU): fastest cold starts, forks, and resumes, with
429    /// lower ceilings that cap what a runaway workload can consume.
430    Small,
431    /// Medium (4 vCPU, default).
432    Medium,
433}
434
435impl SailboxSize {
436    /// The wire string for this size.
437    pub fn as_str(&self) -> &'static str {
438        match self {
439            SailboxSize::Small => "s",
440            SailboxSize::Medium => "m",
441        }
442    }
443
444    /// Parse a wire string, returning `None` for an unsupported size.
445    pub fn parse(s: &str) -> Option<SailboxSize> {
446        match s {
447            "s" => Some(SailboxSize::Small),
448            "m" => Some(SailboxSize::Medium),
449            _ => None,
450        }
451    }
452
453    /// The allowed size labels in ascending order, for error messages and help.
454    pub fn allowed() -> &'static [&'static str] {
455        &["s", "m"]
456    }
457}
458
459impl std::str::FromStr for SailboxSize {
460    type Err = SailError;
461
462    fn from_str(s: &str) -> Result<Self, Self::Err> {
463        SailboxSize::parse(s).ok_or_else(|| SailError::InvalidArgument {
464            message: format!("unknown size {s:?}; use \"s\" or \"m\""),
465        })
466    }
467}
468
469impl std::fmt::Display for SailboxSize {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        f.write_str(self.as_str())
472    }
473}
474
475impl Serialize for SailboxSize {
476    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
477        serializer.serialize_str(self.as_str())
478    }
479}
480
481/// A running-sailbox handle: the fields needed to exec/file/listener against a
482/// live box. Returned by create/resume/from_checkpoint.
483#[derive(Debug, Clone, Default, Serialize)]
484#[non_exhaustive]
485pub struct SailboxHandle {
486    /// The Sailbox's stable identifier.
487    pub sailbox_id: String,
488    /// The caller-supplied Sailbox name.
489    pub name: String,
490    /// The Sailbox's lifecycle status (for example `running`).
491    pub status: SailboxStatus,
492    /// Internal routing address for the host serving the Sailbox.
493    #[doc(hidden)]
494    pub worker_address: String,
495    /// Internal endpoint for opening exec/file/listener streams to the box.
496    #[doc(hidden)]
497    pub exec_endpoint: String,
498}
499
500/// Actionable notice that a Sailbox's runtime should be upgraded.
501#[derive(Debug, Clone, Serialize, Deserialize)]
502#[non_exhaustive]
503pub struct SailboxDeprecation {
504    /// Date after which the Sailbox may be upgraded automatically on its
505    /// next wake, stopping running processes and clearing in-memory state.
506    pub deadline: String,
507    /// Upgrade guidance from the server.
508    pub message: String,
509}
510
511/// Read-only snapshot from get/list. Deliberately omits worker_address /
512/// exec_endpoint (the list/get endpoints do not return them).
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[non_exhaustive]
515pub struct SailboxInfo {
516    /// The Sailbox's stable identifier.
517    pub sailbox_id: String,
518    /// Identifier of the owning app.
519    pub app_id: String,
520    /// Name of the owning app.
521    pub app_name: String,
522    /// Identifier of the image the Sailbox was created from.
523    pub image_id: String,
524    /// The caller-supplied Sailbox name.
525    pub name: String,
526    /// The Sailbox's lifecycle status (for example `running`).
527    pub status: SailboxStatus,
528    /// Configured memory size, in mebibytes.
529    pub memory_mib: i64,
530    /// Configured number of virtual CPUs.
531    pub vcpu_count: i64,
532    /// Configured state-disk size, in gibibytes.
533    pub state_disk_size_gib: i64,
534    /// Requested CPU allocation, in vCPUs.
535    pub cpu_requested_vcpu: i64,
536    /// Current CPU usage, in vCPUs.
537    pub cpu_used_vcpu: f64,
538    /// Requested memory allocation, in bytes.
539    pub memory_requested_bytes: i64,
540    /// Current memory usage, in bytes.
541    pub memory_used_bytes: i64,
542    /// Requested disk allocation, in bytes.
543    pub disk_requested_bytes: i64,
544    /// Current disk usage, in bytes.
545    pub disk_used_bytes: i64,
546    /// CPU architecture of the Sailbox, in the create-side vocabulary
547    /// (`amd64` or `arm64`).
548    #[serde(default)]
549    pub architecture: String,
550    /// Guest schema version the box is running, when reported by the backend.
551    #[serde(default)]
552    pub guest_schema_version: Option<i64>,
553    /// Actionable runtime deprecation notice, when an upgrade is needed.
554    #[serde(default)]
555    pub deprecation: Option<SailboxDeprecation>,
556    /// Human-readable error detail, present when the box is in an error state.
557    #[serde(default)]
558    pub error_message: Option<String>,
559    /// Monotonic checkpoint generation counter for the Sailbox.
560    pub checkpoint_generation: i64,
561    /// When the box last started, if it has started.
562    #[serde(with = "time::serde::rfc3339::option", default)]
563    pub started_at: Option<OffsetDateTime>,
564    /// When the most recent checkpoint was taken, if any.
565    #[serde(with = "time::serde::rfc3339::option", default)]
566    pub last_checkpointed_at: Option<OffsetDateTime>,
567    /// When the Sailbox was created.
568    #[serde(with = "time::serde::rfc3339")]
569    pub created_at: OffsetDateTime,
570    /// When the Sailbox was last updated.
571    #[serde(with = "time::serde::rfc3339")]
572    pub updated_at: OffsetDateTime,
573    /// The user whose credential created this box (for a fork or restore, the
574    /// user who ran it). `None` for service-key creates.
575    #[serde(default)]
576    pub created_by_user_id: Option<String>,
577    /// Access visibility: `Some("private")` restricts exec/files/SSH and
578    /// lifecycle to the creator; `None`/`Some("org")` is org-wide access.
579    #[serde(default)]
580    pub visibility: Option<String>,
581}
582
583/// The identity behind the presented API key.
584#[doc(hidden)]
585#[derive(Debug, Clone, Deserialize)]
586#[non_exhaustive]
587pub struct WhoAmI {
588    /// The organization the key belongs to.
589    pub org_id: String,
590    /// The user the key is scoped to, or `None` for a service key.
591    #[serde(default)]
592    pub user_id: Option<String>,
593}
594
595/// One page of list results plus the pagination envelope.
596#[derive(Debug, Clone, Serialize)]
597#[non_exhaustive]
598pub struct SailboxPage {
599    /// The Sailboxes in this page.
600    pub items: Vec<SailboxInfo>,
601    /// Maximum number of items requested for this page.
602    pub limit: i64,
603    /// Zero-based offset of the first item in this page.
604    pub offset: i64,
605    /// Total number of Sailboxes matching the query across all pages.
606    pub total: i64,
607    /// True when further pages exist beyond this one.
608    pub has_more: bool,
609}
610
611/// Query parameters for [`Client::sailbox_spend`](crate::Client::sailbox_spend).
612#[derive(Debug, Clone, Default)]
613pub struct SailboxSpendQuery {
614    /// Restrict spend to Sailboxes owned by this app id.
615    pub app_id: Option<String>,
616    /// Restrict spend to one Sailbox id.
617    pub sailbox_id: Option<String>,
618    /// Inclusive lower bound for the spend window. The API defaults to the
619    /// current UTC month when omitted.
620    pub from: Option<OffsetDateTime>,
621    /// Exclusive upper bound for the spend window. The API defaults to now when
622    /// omitted.
623    pub to: Option<OffsetDateTime>,
624}
625
626/// Estimated Sailbox spend over a requested time window.
627#[derive(Debug, Clone, Serialize, Deserialize)]
628#[non_exhaustive]
629pub struct SailboxSpendResponse {
630    /// Start of the spend window.
631    #[serde(with = "time::serde::rfc3339")]
632    pub start_at: OffsetDateTime,
633    /// End of the spend window.
634    #[serde(with = "time::serde::rfc3339")]
635    pub end_at: OffsetDateTime,
636    /// Cost from closed billing segments, in USD nanos.
637    pub finalized_cost_usd_nanos: i64,
638    /// Estimated cost from still-open billing segments, in USD nanos.
639    pub estimated_active_cost_usd_nanos: i64,
640    /// Total estimated cost, in USD nanos.
641    pub estimated_total_cost_usd_nanos: i64,
642    /// Total observed duration, in seconds.
643    pub duration_seconds: i64,
644    /// Total vCPU-seconds observed.
645    pub vcpu_seconds: f64,
646    /// Total GiB-seconds of memory observed.
647    pub memory_gib_seconds: f64,
648    /// Total GiB-seconds of state disk observed.
649    pub state_disk_gib_seconds: f64,
650    /// Whether non-zero pricing rates were configured by the backend.
651    pub pricing_configured: bool,
652    /// Pricing rates used for the local estimate.
653    pub rates: SailboxSpendRates,
654    /// Per-sailbox spend summaries.
655    pub sailboxes: Vec<SailboxSpendItem>,
656}
657
658/// Rate metadata returned with a Sailbox spend estimate.
659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
660#[non_exhaustive]
661pub struct SailboxSpendRates {
662    /// Nanodollars per vCPU-second.
663    pub vcpu_second_usd_nanos: i64,
664    /// Nanodollars per memory GiB-second.
665    pub memory_gib_second_usd_nanos: i64,
666    /// Nanodollars per arm64 state-disk GiB-second.
667    pub state_disk_gib_second_usd_nanos: i64,
668    /// Nanodollars per Sailbox creation.
669    pub creation_usd_nanos: i64,
670    /// Nanodollars per amd64 state-disk GiB-second.
671    #[serde(default)]
672    pub amd64_state_disk_gib_second_usd_nanos: i64,
673}
674
675/// Estimated spend for one Sailbox over a requested time window.
676#[derive(Debug, Clone, Serialize, Deserialize)]
677#[non_exhaustive]
678pub struct SailboxSpendItem {
679    /// Sailbox id.
680    pub sailbox_id: String,
681    /// Owning app id.
682    pub app_id: String,
683    /// Cost from closed billing segments, in USD nanos.
684    pub finalized_cost_usd_nanos: i64,
685    /// Estimated cost from still-open billing segments, in USD nanos.
686    pub estimated_active_cost_usd_nanos: i64,
687    /// Total estimated cost, in USD nanos.
688    pub estimated_total_cost_usd_nanos: i64,
689    /// Observed duration, in seconds.
690    pub duration_seconds: i64,
691    /// Observed vCPU-seconds.
692    pub vcpu_seconds: f64,
693    /// Observed GiB-seconds of memory.
694    pub memory_gib_seconds: f64,
695    /// Observed GiB-seconds of state disk.
696    pub state_disk_gib_seconds: f64,
697    /// Whether this row includes an active billing segment.
698    pub active: bool,
699}
700
701/// Query parameters for [`Client::sailbox_metrics`](crate::Client::sailbox_metrics).
702#[derive(Debug, Clone)]
703pub struct SailboxMetricsQuery {
704    /// Window name accepted by the API, for example `1h`, `6h`, `24h`, or `7d`.
705    pub range: String,
706}
707
708impl Default for SailboxMetricsQuery {
709    fn default() -> SailboxMetricsQuery {
710        SailboxMetricsQuery {
711            range: "24h".to_string(),
712        }
713    }
714}
715
716/// Resource-usage time series for one Sailbox.
717#[derive(Debug, Clone, Serialize, Deserialize)]
718#[non_exhaustive]
719pub struct SailboxMetricsResponse {
720    /// Echoed range selected by the API.
721    pub range: String,
722    /// Time-ordered metric points.
723    pub data: Vec<SailboxMetricPoint>,
724}
725
726/// One bucket in a Sailbox resource-usage time series.
727#[derive(Debug, Clone, Serialize, Deserialize)]
728#[non_exhaustive]
729pub struct SailboxMetricPoint {
730    /// Bucket timestamp.
731    #[serde(with = "time::serde::rfc3339")]
732    pub timestamp: OffsetDateTime,
733    /// Current CPU usage, in vCPUs.
734    pub cpu_used_vcpu: f64,
735    /// Requested CPU allocation, in vCPUs.
736    pub cpu_requested_vcpu: i64,
737    /// Current memory usage, in bytes.
738    pub memory_used_bytes: i64,
739    /// Requested memory allocation, in bytes.
740    pub memory_requested_bytes: i64,
741    /// Current disk usage, in bytes.
742    pub disk_used_bytes: i64,
743    /// Requested disk allocation, in bytes.
744    pub disk_requested_bytes: i64,
745}
746
747/// A durable checkpoint handle. `status` echoes the source Sailbox's lifecycle
748/// status after checkpointing (a running box is snapshotted; a paused/sleeping
749/// one returns its existing checkpoint), so the binding can sync its handle.
750#[derive(Debug, Clone, Serialize)]
751#[non_exhaustive]
752pub struct SailboxCheckpoint {
753    /// Stable identifier of the checkpoint.
754    pub checkpoint_id: String,
755    /// Identifier of the Sailbox the checkpoint was taken from.
756    pub sailbox_id: String,
757    /// Checkpoint generation counter captured by this checkpoint.
758    pub checkpoint_generation: i64,
759    /// When the handle expires and becomes eligible for garbage collection;
760    /// `None` for a handle that carries no fresh retention bound (a paused or
761    /// sleeping source).
762    #[serde(with = "time::serde::rfc3339::option")]
763    pub expires_at: Option<OffsetDateTime>,
764    /// Source Sailbox's lifecycle status after checkpointing.
765    pub status: SailboxStatus,
766}
767
768/// Filters for list/list_page.
769#[derive(Debug, Clone)]
770pub struct ListSailboxesQuery {
771    /// Restrict results to Sailboxes owned by the app with this id.
772    pub app_id: Option<String>,
773    /// Restrict results to Sailboxes in this lifecycle status.
774    pub status: Option<SailboxStatusFilter>,
775    /// Free-text search filter applied by the backend.
776    pub search: Option<String>,
777    /// Ordering to apply before pagination.
778    pub order: SailboxListOrder,
779    /// Maximum number of items to return.
780    pub limit: i64,
781    /// Zero-based offset of the first item to return.
782    pub offset: i64,
783}
784
785/// Default page size for listing, matching the Sailbox API's own default. The
786/// API rejects `limit=0`, so the derived all-zero default cannot be used.
787#[doc(hidden)]
788pub const DEFAULT_LIST_LIMIT: i64 = 50;
789
790impl Default for ListSailboxesQuery {
791    fn default() -> ListSailboxesQuery {
792        ListSailboxesQuery {
793            app_id: None,
794            status: None,
795            search: None,
796            order: SailboxListOrder::NewestActive,
797            limit: DEFAULT_LIST_LIMIT,
798            offset: 0,
799        }
800    }
801}
802
803/// One guest ingress port to reserve at create time.
804#[derive(Debug, Clone, Serialize)]
805pub struct IngressPort {
806    /// Port inside the guest to expose for ingress.
807    pub guest_port: u32,
808    /// Transport protocol for the port (for example `tcp`).
809    pub protocol: IngressProtocol,
810    /// Source addresses allowed to reach the port; empty means all sources are
811    /// allowed. Always sent (even when empty) so the request states it
812    /// explicitly rather than relying on an omitted field.
813    pub allowlist: Vec<String>,
814}
815
816/// One NFS volume mount.
817#[derive(Debug, Clone, Serialize)]
818pub struct VolumeMount {
819    /// Identifier of the NFS volume to mount.
820    pub volume_id: String,
821    /// Absolute path inside the guest where the volume is mounted.
822    pub mount_path: String,
823}
824
825/// A managed NFS volume as returned by the sailbox-volume API. (The local
826/// mount path is a binding-side concern and not part of this wire type.)
827#[derive(Debug, Clone, Serialize, Deserialize)]
828#[non_exhaustive]
829pub struct VolumeInfo {
830    /// Stable identifier of the volume.
831    pub volume_id: String,
832    /// Caller-supplied volume name.
833    pub name: String,
834    /// Storage backend serving the volume.
835    pub backend: String,
836    /// Lifecycle status of the volume.
837    pub status: String,
838    /// When the volume was created, if reported.
839    #[serde(with = "time::serde::rfc3339::option", default)]
840    pub created_at: Option<OffsetDateTime>,
841    /// When the volume was last updated, if reported.
842    #[serde(with = "time::serde::rfc3339::option", default)]
843    pub updated_at: Option<OffsetDateTime>,
844}
845
846/// The wire shape of the add-listener response: the scheduler resolves the
847/// public endpoint in the response (so a caller renders it without a second
848/// lookup) but reports nothing about routing, so it converts to a [`Listener`]
849/// with an unknown route status.
850#[derive(Debug, Clone, Deserialize)]
851pub(crate) struct AddListenerWire {
852    pub(crate) guest_port: u32,
853    pub(crate) protocol: ListenerProtocol,
854    #[serde(default)]
855    pub(crate) public_url: String,
856    #[serde(default, rename = "tcp_public_host")]
857    pub(crate) public_host: String,
858    #[serde(default, rename = "tcp_public_port")]
859    pub(crate) public_port: u32,
860}
861
862impl From<AddListenerWire> for crate::worker::Listener {
863    fn from(wire: AddListenerWire) -> crate::worker::Listener {
864        crate::worker::Listener {
865            guest_port: wire.guest_port,
866            protocol: wire.protocol,
867            // The expose response says nothing about reachability; confirm
868            // with wait_for_listener or re-fetch via get/list.
869            route_status: ListenerRouteStatus::Unknown,
870            public_url: wire.public_url,
871            public_host: wire.public_host,
872            public_port: wire.public_port,
873        }
874    }
875}
876
877/// A CA-signed SSH user certificate plus the key id that identifies the signing
878/// org (`org=<id>;fp=...;iat=...`).
879#[derive(Debug, Clone)]
880#[non_exhaustive]
881pub struct IssuedUserCert {
882    /// The OpenSSH certificate (`<type>-cert-v01@openssh.com AAAA...`).
883    pub certificate: String,
884    /// The certificate key id.
885    pub key_id: String,
886}
887
888/// How to reach an exposed listener: a routable HTTPS URL for `http`
889/// listeners, or a host/port any TCP client can dial for `tcp` listeners.
890#[derive(Debug, Clone, PartialEq, Eq)]
891pub enum ListenerEndpoint {
892    /// The HTTPS URL to reach the guest service.
893    Http {
894        /// Routable URL.
895        url: String,
896    },
897    /// The address to dial for a raw-TCP listener.
898    Tcp {
899        /// Hostname to dial.
900        host: String,
901        /// Port to dial.
902        port: u32,
903    },
904}
905
906impl std::fmt::Display for ListenerEndpoint {
907    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908        match self {
909            ListenerEndpoint::Http { url } => f.write_str(url),
910            ListenerEndpoint::Tcp { host, port } => write!(f, "{host}:{port}"),
911        }
912    }
913}
914
915/// Inputs to create a Sailbox. The image is a typed [`ImageSpec`](crate::image::ImageSpec);
916/// describe a custom build by filling an
917/// [`ImageDefinition`](crate::imagebuild::ImageDefinition). `Default` selects
918/// the Debian base image and the platform defaults for everything else;
919/// `app_id` (and usually `name`) must be set before the request is valid.
920#[derive(Debug, Clone)]
921pub struct CreateSailboxRequest {
922    /// Identifier of the app that will own the Sailbox.
923    pub app_id: String,
924    /// Caller-supplied name for the Sailbox.
925    pub name: String,
926    /// Guest ingress ports to reserve at create time.
927    pub ingress_ports: Vec<IngressPort>,
928    /// NFS volumes to mount into the guest.
929    pub volume_mounts: Vec<VolumeMount>,
930    /// The image to create the Sailbox from. Serialized to canonical proto-JSON
931    /// and sent as the `image` field.
932    pub image: crate::image::ImageSpec,
933    /// Requested resource size; defaults server-side when absent.
934    pub size: Option<SailboxSize>,
935    /// Optional memory limit in whole GiB, within the size's range; the
936    /// size's default when absent.
937    pub memory_gib: Option<u32>,
938    /// Optional disk size in whole GiB, within the size's range; the size's
939    /// default when absent.
940    pub disk_gib: Option<u32>,
941    /// Enable SSH on the new Sailbox after create: trust the org SSH CA, start
942    /// `sshd`, and expose guest port 22 as TCP once the CA-only daemon owns it.
943    /// An explicit port-22 ingress entry contributes just its allowlist.
944    pub ssh: bool,
945    /// By default a Sailbox is org-wide: any credential in the org can exec,
946    /// copy files, SSH, or run lifecycle operations on it. `true` restricts all
947    /// of that to the creating user. An org admin can override that with a
948    /// recorded reason for exec, files, and pause/resume/terminate/upgrade. SSH,
949    /// exposing or removing listeners, and fork/checkpoint/restore stay
950    /// creator-only. Requires a user-scoped API key.
951    pub private: bool,
952    /// Budget for rebuilding the image if Sail needs to rebuild it before
953    /// the Sailbox is created; the default build budget applies when absent.
954    pub image_build_timeout: Option<Duration>,
955}
956
957impl Default for CreateSailboxRequest {
958    /// An empty request for a plain Debian-base Sailbox: fill in `app_id` and
959    /// `name`, override the rest as needed.
960    fn default() -> CreateSailboxRequest {
961        CreateSailboxRequest {
962            app_id: String::new(),
963            name: String::new(),
964            ingress_ports: Vec::new(),
965            volume_mounts: Vec::new(),
966            image: crate::image::ImageSpec {
967                base: Some(crate::image::BaseImage::Debian),
968                ..crate::image::ImageSpec::default()
969            },
970            size: None,
971            memory_gib: None,
972            disk_gib: None,
973            ssh: false,
974            private: false,
975            image_build_timeout: None,
976        }
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983
984    #[test]
985    fn closed_enums_round_trip_through_fromstr_and_display() {
986        for (text, value) in [
987            ("newest_active", SailboxListOrder::NewestActive),
988            ("newest_created", SailboxListOrder::NewestCreated),
989        ] {
990            assert_eq!(text.parse::<SailboxListOrder>().unwrap(), value);
991            assert_eq!(value.to_string(), text);
992        }
993        for (text, value) in [
994            ("running", SailboxStatusFilter::Running),
995            ("terminated", SailboxStatusFilter::Terminated),
996        ] {
997            assert_eq!(text.parse::<SailboxStatusFilter>().unwrap(), value);
998            assert_eq!(value.to_string(), text);
999        }
1000        for (text, value) in [
1001            ("tcp", IngressProtocol::Tcp),
1002            ("http", IngressProtocol::Http),
1003        ] {
1004            assert_eq!(text.parse::<IngressProtocol>().unwrap(), value);
1005            assert_eq!(value.to_string(), text);
1006        }
1007        for (text, value) in [("s", SailboxSize::Small), ("m", SailboxSize::Medium)] {
1008            assert_eq!(text.parse::<SailboxSize>().unwrap(), value);
1009            assert_eq!(value.to_string(), text);
1010        }
1011        let err = "udp".parse::<IngressProtocol>().unwrap_err();
1012        assert!(matches!(err, SailError::InvalidArgument { .. }));
1013        assert!(err.to_string().contains("udp"));
1014    }
1015
1016    #[test]
1017    fn list_query_default_uses_a_valid_nonzero_limit() {
1018        // The sailbox API rejects limit=0, so the default must be the API's own
1019        // page size, not the derived zero.
1020        let query = ListSailboxesQuery::default();
1021        assert_eq!(query.limit, DEFAULT_LIST_LIMIT);
1022        assert!(query.limit > 0);
1023        assert_eq!(query.offset, 0);
1024    }
1025
1026    #[test]
1027    fn route_status_round_trips_through_serde() {
1028        // Serialize emits the friendly form (as_str), so a persisted Listener
1029        // read back must parse it rather than fall through to Other. Guards the
1030        // Serialize/Deserialize pair against drifting apart.
1031        for status in [
1032            ListenerRouteStatus::Unknown,
1033            ListenerRouteStatus::Pending,
1034            ListenerRouteStatus::Active,
1035            ListenerRouteStatus::Restoring,
1036            ListenerRouteStatus::Unavailable,
1037            ListenerRouteStatus::Other("degraded".to_string()),
1038        ] {
1039            let json = serde_json::to_string(&status).unwrap();
1040            let back: ListenerRouteStatus = serde_json::from_str(&json).unwrap();
1041            assert_eq!(
1042                status, back,
1043                "round-trip failed for {status:?} (json {json})"
1044            );
1045        }
1046        // The backend's proto spelling still parses to the same value.
1047        assert_eq!(
1048            ListenerRouteStatus::from("LISTENER_ROUTE_STATUS_ACTIVE"),
1049            ListenerRouteStatus::Active,
1050        );
1051    }
1052
1053    #[test]
1054    fn info_deserializes_a_deprecation_notice() {
1055        let value = serde_json::json!({
1056            "sailbox_id": "sb-1", "app_id": "app-1", "app_name": "a", "name": "n",
1057            "image_id": "img-1",
1058            "status": "running", "memory_mib": 2048, "vcpu_count": 4,
1059            "state_disk_size_gib": 10,
1060            "cpu_requested_vcpu": 2, "cpu_used_vcpu": 1.5,
1061            "memory_requested_bytes": 1024, "memory_used_bytes": 512,
1062            "disk_requested_bytes": 4096, "disk_used_bytes": 2048,
1063            "architecture": "amd64", "checkpoint_generation": 7,
1064            "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-02T00:00:00Z",
1065            "deprecation": {
1066                "deadline": "2026-08-01",
1067                "message": "Upgrade this sailbox before 2026-08-01."
1068            }
1069        });
1070        let info: SailboxInfo = serde_json::from_value(value).unwrap();
1071        let notice = info.deprecation.unwrap();
1072        assert_eq!(notice.deadline, "2026-08-01");
1073        assert!(notice.message.starts_with("Upgrade this sailbox"));
1074    }
1075}