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