Skip to main content

sail/
client.rs

1//! The Sail client: the canonical async surface that owns configuration and
2//! transport, shared by every binding (Python, CLI, future hosts).
3//!
4//! [`Client`] is a cheap-to-clone handle (`Arc` inside, like `reqwest::Client`):
5//! clone it freely to share the connection pools and config. Construct it with
6//! [`Client::from_env`] or [`Client::builder`].
7//!
8//! Every method is `async`. Synchronous callers (the PyO3 bridge with the GIL
9//! released, the CLI) drive these futures with
10//! [`crate::block_on`]; an async host awaits them directly.
11//!
12//! ```no_run
13//! # async fn run() -> Result<(), sail::error::SailError> {
14//! use sail::Client;
15//!
16//! // From the environment (SAIL_API_KEY, SAIL_MODE, …):
17//! let client = Client::from_env()?;
18//! let page = client.list_sailboxes(&Default::default()).await?;
19//! println!("{} sailboxes", page.items.len());
20//!
21//! // Or build one explicitly for the dev environment:
22//! let client = Client::builder("sk_...").mode("dev").build()?;
23//! let app = client.find_app("my-app", true).await?;
24//! # let _ = (client, app);
25//! # Ok(())
26//! # }
27//! ```
28
29use std::sync::Arc;
30
31use serde_json::Value;
32
33use crate::app::{self, App, AppOverview};
34use crate::config::Config;
35use crate::error::SailError;
36use crate::exec::{ExecOptions, ExecParams, ExecProcess};
37use crate::http::HttpCore;
38use crate::imagebuilder::ImageBuilder;
39use crate::sailbox::api::{SailboxApi, UpgradeOutcome};
40use crate::sailbox::types::{
41    CreateSailboxRequest, ListQuery, NfsVolume, SailboxCheckpoint, SailboxHandle, SailboxInfo,
42    SailboxPage,
43};
44use crate::worker::{FileReader, FileWriter, UploadOptions, WorkerProxy};
45
46/// A configured Sail client. Cheap to clone; shares transport across clones.
47#[derive(Clone)]
48pub struct Client {
49    inner: Arc<Inner>,
50}
51
52struct Inner {
53    config: Config,
54    /// Sailbox-API host: lifecycle, list/get, listeners, volume, overview.
55    sailbox_http: HttpCore,
56    /// Central public-API host: app find, inference, voyages.
57    api_http: HttpCore,
58    /// Per-sailbox worker proxy: exec, files, listener reads. Its own `Arc` so
59    /// the streaming file/exec methods (which take `&Arc<Self>`) can share it.
60    worker: Arc<WorkerProxy>,
61    imagebuilder: ImageBuilder,
62}
63
64/// Builds a [`Client`] from explicit values, falling back to a mode's endpoint
65/// defaults. Prefer [`Client::from_env`] for the common env-driven case.
66#[derive(Debug, Default, Clone)]
67pub struct ClientBuilder {
68    mode: Option<String>,
69    api_key: Option<String>,
70    api_url: Option<String>,
71    sailbox_api_url: Option<String>,
72    imagebuilder_url: Option<String>,
73}
74
75impl ClientBuilder {
76    /// A builder with the given API key; endpoints default to prod unless a
77    /// `mode` or explicit URL is set.
78    pub fn new(api_key: impl Into<String>) -> ClientBuilder {
79        ClientBuilder {
80            api_key: Some(api_key.into()),
81            ..ClientBuilder::default()
82        }
83    }
84
85    /// Select the named environment (`prod`/`dev`/`staging`/`local`), which
86    /// picks the endpoint defaults.
87    pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
88        self.mode = Some(mode.into());
89        self
90    }
91
92    /// Set the API key.
93    pub fn api_key(mut self, api_key: impl Into<String>) -> ClientBuilder {
94        self.api_key = Some(api_key.into());
95        self
96    }
97
98    /// Override the central public-API URL.
99    pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
100        self.api_url = Some(api_url.into());
101        self
102    }
103
104    /// Override the sailbox-API URL.
105    pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
106        self.sailbox_api_url = Some(url.into());
107        self
108    }
109
110    /// Override the imagebuilder dispatcher URL.
111    pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
112        self.imagebuilder_url = Some(url.into());
113        self
114    }
115
116    /// Build the client, resolving any unset endpoint from the mode defaults.
117    pub fn build(self) -> Result<Client, SailError> {
118        let api_key = self.api_key.unwrap_or_default();
119        let config = Config::resolve(
120            self.mode.as_deref(),
121            api_key,
122            self.api_url,
123            self.sailbox_api_url,
124            self.imagebuilder_url,
125            // The builder targets a mode or explicit endpoints; listener ingress
126            // follows the mode default.
127            None,
128        )?;
129        Client::from_config(config)
130    }
131}
132
133impl Client {
134    /// Start a [`ClientBuilder`].
135    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
136        ClientBuilder::new(api_key)
137    }
138
139    /// Build a client from the environment (`SAIL_MODE`, `SAIL_API_KEY`, …).
140    pub fn from_env() -> Result<Client, SailError> {
141        Client::from_config(Config::from_env()?)
142    }
143
144    /// Build a client from an already-resolved [`Config`].
145    pub fn from_config(config: Config) -> Result<Client, SailError> {
146        let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?;
147        let api_http = HttpCore::new(&config.api_url, &config.api_key)?;
148        let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
149        let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
150        Ok(Client {
151            inner: Arc::new(Inner {
152                config,
153                sailbox_http,
154                api_http,
155                worker,
156                imagebuilder,
157            }),
158        })
159    }
160
161    /// The resolved configuration.
162    pub fn config(&self) -> &Config {
163        &self.inner.config
164    }
165
166    /// The worker proxy for exec, file copy, and listener reads.
167    #[doc(hidden)]
168    pub fn worker(&self) -> Arc<WorkerProxy> {
169        Arc::clone(&self.inner.worker)
170    }
171
172    /// The imagebuilder dispatcher client.
173    #[doc(hidden)]
174    pub fn imagebuilder(&self) -> &ImageBuilder {
175        &self.inner.imagebuilder
176    }
177
178    /// The sailbox-API HTTP host (for binding-built requests).
179    #[doc(hidden)]
180    pub fn sailbox_http(&self) -> &HttpCore {
181        &self.inner.sailbox_http
182    }
183
184    /// The central public-API HTTP host (for binding-built requests).
185    #[doc(hidden)]
186    pub fn api_http(&self) -> &HttpCore {
187        &self.inner.api_http
188    }
189
190    fn sailbox_api(&self) -> SailboxApi<'_> {
191        SailboxApi::new(&self.inner.sailbox_http)
192    }
193
194    // --- sailbox lifecycle ---
195
196    /// Create a sailbox.
197    pub async fn create_sailbox(
198        &self,
199        req: &CreateSailboxRequest,
200    ) -> Result<SailboxHandle, SailError> {
201        self.sailbox_api().create(req).await
202    }
203
204    /// Fetch a single sailbox.
205    pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
206        self.sailbox_api().get(sailbox_id).await
207    }
208
209    /// List sailboxes in the current org.
210    pub async fn list_sailboxes(&self, query: &ListQuery) -> Result<SailboxPage, SailError> {
211        self.sailbox_api().list(query).await
212    }
213
214    /// Terminate a sailbox (idempotent).
215    pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
216        self.sailbox_api().terminate(sailbox_id).await
217    }
218
219    /// Pause a sailbox.
220    pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
221        self.sailbox_api().pause(sailbox_id).await
222    }
223
224    /// Sleep a sailbox.
225    pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
226        self.sailbox_api().sleep(sailbox_id).await
227    }
228
229    /// Resume a paused/sleeping sailbox.
230    pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
231        self.sailbox_api().resume(sailbox_id).await
232    }
233
234    /// Checkpoint a running sailbox.
235    pub async fn checkpoint_sailbox(
236        &self,
237        sailbox_id: &str,
238    ) -> Result<SailboxCheckpoint, SailError> {
239        self.sailbox_api().checkpoint(sailbox_id).await
240    }
241
242    /// Create a new sailbox from a checkpoint.
243    pub async fn create_from_checkpoint(
244        &self,
245        checkpoint_id: &str,
246        name: Option<&str>,
247        timeout_seconds: Option<i64>,
248    ) -> Result<SailboxHandle, SailError> {
249        self.sailbox_api()
250            .from_checkpoint(checkpoint_id, name, timeout_seconds)
251            .await
252    }
253
254    /// Upgrade the in-guest agent (applies now if running, else at next wake).
255    pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeOutcome, SailError> {
256        self.sailbox_api().upgrade(sailbox_id).await
257    }
258
259    /// Expose a guest port at runtime; returns the add-listener response.
260    pub async fn expose_listener(
261        &self,
262        sailbox_id: &str,
263        guest_port: u32,
264        protocol: &str,
265        allowlist: &[String],
266    ) -> Result<Value, SailError> {
267        self.sailbox_api()
268            .expose(sailbox_id, guest_port, protocol, allowlist)
269            .await
270    }
271
272    /// Remove a runtime ingress port.
273    pub async fn unexpose_listener(
274        &self,
275        sailbox_id: &str,
276        guest_port: u32,
277    ) -> Result<(), SailError> {
278        self.sailbox_api().unexpose(sailbox_id, guest_port).await
279    }
280
281    /// List a sailbox's ingress listeners without resuming (waking) the box.
282    pub async fn list_listeners(
283        &self,
284        sailbox_id: &str,
285    ) -> Result<Vec<crate::worker::Listener>, SailError> {
286        self.sailbox_api().list_listeners(sailbox_id).await
287    }
288
289    /// Fetch one ingress listener by guest port without resuming (waking) the
290    /// box; a missing port is a [`SailError::NotFound`].
291    pub async fn get_listener(
292        &self,
293        sailbox_id: &str,
294        guest_port: u32,
295    ) -> Result<crate::worker::Listener, SailError> {
296        self.sailbox_api()
297            .get_listener(sailbox_id, guest_port)
298            .await
299    }
300
301    /// Ingress-identity headers for this sailbox.
302    pub async fn ingress_auth_headers(
303        &self,
304        sailbox_id: &str,
305    ) -> Result<Vec<(String, String)>, SailError> {
306        self.sailbox_api().ingress_auth_headers(sailbox_id).await
307    }
308
309    // --- NFS volumes ---
310
311    /// Look up (optionally minting) an NFS volume by name.
312    pub async fn get_volume(
313        &self,
314        name: &str,
315        mint_if_missing: bool,
316    ) -> Result<NfsVolume, SailError> {
317        self.sailbox_api().get_volume(name, mint_if_missing).await
318    }
319
320    /// List NFS volumes in the current org.
321    pub async fn list_volumes(
322        &self,
323        max_objects: Option<i64>,
324    ) -> Result<Vec<NfsVolume>, SailError> {
325        self.sailbox_api().list_volumes(max_objects).await
326    }
327
328    /// Delete a volume by id.
329    pub async fn delete_volume(
330        &self,
331        volume_id: &str,
332        allow_missing: bool,
333    ) -> Result<Option<NfsVolume>, SailError> {
334        self.sailbox_api()
335            .delete_volume(volume_id, allow_missing)
336            .await
337    }
338
339    // --- apps (central API) ---
340
341    /// Find an app by name on the central API, optionally minting it.
342    pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
343        app::find_app(&self.inner.api_http, name, mint_if_missing).await
344    }
345
346    /// List apps in the current org with rollup usage.
347    pub async fn list_apps(&self) -> Result<Vec<AppOverview>, SailError> {
348        app::list_apps(&self.inner.sailbox_http).await
349    }
350
351    // --- exec and files (per-sailbox worker proxy) ---
352
353    /// Resolve a sailbox's current worker-proxy endpoint.
354    ///
355    /// `resume` wakes a paused/sleeping sailbox and returns its *current*
356    /// endpoint, which is the host worker's address and changes when the sailbox
357    /// migrates (e.g. after preemption). The GET sailbox API omits this routing
358    /// field, so resuming is the only way to learn it, and resolving it fresh per
359    /// call avoids ever dialing a stale worker.
360    pub(crate) async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
361        let handle = self.resume_sailbox(sailbox_id).await?;
362        if handle.exec_endpoint.is_empty() {
363            return Err(SailError::Internal {
364                message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
365            });
366        }
367        Ok(handle.exec_endpoint)
368    }
369
370    /// Run a command in a sailbox and return a handle to the live process.
371    ///
372    /// Resumes (wakes) the sailbox to reach it. The returned [`ExecProcess`]
373    /// streams output, accepts stdin, and resolves the exit status; dropping it
374    /// detaches without killing the command.
375    ///
376    /// # Runtime
377    ///
378    /// Spawns the output pump on the calling task's tokio runtime (see
379    /// [`ExecProcess::start`]).
380    pub async fn exec(
381        &self,
382        sailbox_id: &str,
383        argv: Vec<String>,
384        options: ExecOptions,
385    ) -> Result<ExecProcess, SailError> {
386        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
387        let params = ExecParams {
388            sailbox_id: sailbox_id.to_string(),
389            exec_endpoint,
390            argv,
391            timeout_seconds: options.timeout_seconds,
392            idempotency_key: options.idempotency_key,
393            open_stdin: options.open_stdin,
394            pty: options.pty,
395            term: options.term,
396            cols: options.cols,
397            rows: options.rows,
398            retry_timeout: options.retry_timeout,
399            extra_metadata: Vec::new(),
400        };
401        ExecProcess::start(self.worker(), params).await
402    }
403
404    /// Open a streaming read of a guest file. Resumes (wakes) the sailbox to
405    /// reach it; the returned [`FileReader`] yields chunks until end of file.
406    ///
407    /// # Runtime
408    ///
409    /// Spawns the read pump on the calling task's tokio runtime (see
410    /// [`crate::worker::WorkerProxy::read_file`]).
411    pub async fn download_file(
412        &self,
413        sailbox_id: &str,
414        remote_path: &str,
415    ) -> Result<FileReader, SailError> {
416        let endpoint = self.exec_endpoint(sailbox_id).await?;
417        Ok(self
418            .inner
419            .worker
420            .read_file(&endpoint, sailbox_id, remote_path))
421    }
422
423    /// Open a streaming write to a guest file. Resumes (wakes) the sailbox to
424    /// reach it; feed the returned [`FileWriter`] with `write_chunk` and end with
425    /// `finish`, so a large source is never buffered whole.
426    ///
427    /// # Runtime
428    ///
429    /// Spawns the write RPC on the calling task's tokio runtime (see
430    /// [`crate::worker::WorkerProxy::write_file`]).
431    pub async fn upload_file(
432        &self,
433        sailbox_id: &str,
434        remote_path: &str,
435        options: UploadOptions,
436    ) -> Result<FileWriter, SailError> {
437        let endpoint = self.exec_endpoint(sailbox_id).await?;
438        Ok(self.inner.worker.write_file(
439            &endpoint,
440            sailbox_id,
441            remote_path,
442            options.create_parents,
443            options.mode,
444        ))
445    }
446}