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