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, TypeScript, CLI).
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, UpgradeResult};
39use crate::sailbox::object::Sailbox;
40use crate::sailbox::types::{
41    CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
42    SailboxPage, VolumeInfo,
43};
44use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
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.
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    ingress_url: Option<String>,
74}
75
76impl ClientBuilder {
77    /// A builder with the given API key; endpoints default to prod unless a
78    /// `mode` or explicit URL is set.
79    pub fn new(api_key: impl Into<String>) -> ClientBuilder {
80        ClientBuilder {
81            api_key: Some(api_key.into()),
82            ..ClientBuilder::default()
83        }
84    }
85
86    /// Select the named environment (`prod`/`dev`/`staging`/`local`), which
87    /// picks the endpoint defaults.
88    pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
89        self.mode = Some(mode.into());
90        self
91    }
92
93    /// Override the central public-API URL.
94    pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
95        self.api_url = Some(api_url.into());
96        self
97    }
98
99    /// Override the sailbox-API URL.
100    pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
101        self.sailbox_api_url = Some(url.into());
102        self
103    }
104
105    /// Override the imagebuilder dispatcher URL.
106    pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
107        self.imagebuilder_url = Some(url.into());
108        self
109    }
110
111    /// Override the listener ingress base URL (what `SAILBOX_INGRESS_URL`
112    /// sets from the environment), for custom or local sailbox stacks.
113    pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
114        self.ingress_url = Some(url.into());
115        self
116    }
117
118    /// Build the client, resolving any unset endpoint from the mode defaults.
119    pub fn build(self) -> Result<Client, SailError> {
120        let api_key = self.api_key.unwrap_or_default();
121        let config = Config::resolve(
122            self.mode.as_deref(),
123            api_key,
124            self.api_url,
125            self.sailbox_api_url,
126            self.imagebuilder_url,
127            self.ingress_url,
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. `timeout` bounds each attempt of the synchronous
197    /// create (which the scheduler can block on for minutes); the call retries
198    /// with one idempotency key so the backend can dedupe rather than
199    /// duplicate, and gives up after roughly `max_attempts * timeout`.
200    /// An interrupted or re-invoked create is a new request and may leave a
201    /// prior box behind under the same name. 10 minutes is a good default;
202    /// `None` leaves each attempt unbounded. If the budget is exhausted the
203    /// box may still be coming up server-side; find or terminate it by
204    /// `name`.
205    pub async fn create_sailbox(
206        &self,
207        req: &CreateSailboxRequest,
208        timeout: Option<Duration>,
209    ) -> Result<Sailbox, SailError> {
210        let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
211        if !req.ssh {
212            return self.sailbox_api().create(req, timeout).await.map(bind);
213        }
214        // Validate the full request now, port-22 entries included: they are
215        // stripped below (their allowlist applies at the enable_ssh expose),
216        // so create's own validation never sees them, and an invalid entry
217        // must fail here rather than after the VM exists.
218        crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
219        // SSH setup is org-scoped: preflight the org CA (created on first use)
220        // so a CA outage fails before the VM exists.
221        self.org_ssh_ca_public_key().await?;
222        // Port 22 belongs to enable_ssh, which exposes it only after verifying
223        // the CA-only sshd owns it — never the create request — so a failed
224        // setup can't leave port 22 exposed. An explicit port-22 entry
225        // contributes just its allowlist, applied at that expose.
226        let mut req = req.clone();
227        let ssh_allowlist = req
228            .ingress_ports
229            .iter()
230            .find(|port| port.guest_port == 22)
231            .map(|port| port.allowlist.clone())
232            .unwrap_or_default();
233        req.ingress_ports.retain(|port| port.guest_port != 22);
234        let handle = self.sailbox_api().create(&req, timeout).await?;
235        let handle_id = handle.sailbox_id.clone();
236        // The VM is already up, so skip the readiness probe (wait: false).
237        if let Err(err) = self
238            .enable_ssh(&handle_id, &ssh_allowlist, false, Duration::ZERO)
239            .await
240        {
241            // The sailbox exists; surface its id so the caller can fetch it to
242            // retry enable_ssh or terminate it.
243            return Err(SailError::Creation {
244                message: format!(
245                    "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
246                     id to retry enable_ssh or terminate it."
247                ),
248                status: 0,
249                body: serde_json::Value::Null,
250            });
251        }
252        Ok(bind(handle))
253    }
254
255    /// Fetch a single sailbox.
256    #[doc(hidden)]
257    pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
258        self.sailbox_api().get(sailbox_id).await
259    }
260
261    /// List sailboxes in the current org.
262    pub async fn list_sailboxes(
263        &self,
264        query: &ListSailboxesQuery,
265    ) -> Result<SailboxPage, SailError> {
266        self.sailbox_api().list(query).await
267    }
268
269    /// Terminate a sailbox (idempotent).
270    #[doc(hidden)]
271    pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
272        self.sailbox_api().terminate(sailbox_id).await
273    }
274
275    /// Pause a sailbox.
276    #[doc(hidden)]
277    pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
278        self.sailbox_api().pause(sailbox_id).await
279    }
280
281    /// Sleep a sailbox.
282    #[doc(hidden)]
283    pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
284        self.sailbox_api().sleep(sailbox_id).await
285    }
286
287    /// Resume a paused/sleeping sailbox.
288    #[doc(hidden)]
289    pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
290        self.sailbox_api().resume(sailbox_id).await
291    }
292
293    /// Checkpoint a running sailbox.
294    #[doc(hidden)]
295    pub async fn checkpoint_sailbox(
296        &self,
297        sailbox_id: &str,
298        name: Option<&str>,
299        ttl_seconds: Option<i64>,
300    ) -> Result<SailboxCheckpoint, SailError> {
301        self.sailbox_api()
302            .checkpoint(sailbox_id, name, ttl_seconds)
303            .await
304    }
305
306    /// Create a new sailbox from a checkpoint.
307    pub async fn create_from_checkpoint(
308        &self,
309        checkpoint_id: &str,
310        name: Option<&str>,
311        timeout: Option<Duration>,
312    ) -> Result<Sailbox, SailError> {
313        self.sailbox_api()
314            .from_checkpoint(checkpoint_id, name, timeout.map(|t| t.as_secs() as i64))
315            .await
316            .map(|handle| Sailbox::bind(self.clone(), handle))
317    }
318
319    /// Upgrade the in-guest agent (applies now if running, else at next wake).
320    #[doc(hidden)]
321    pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
322        self.sailbox_api().upgrade(sailbox_id).await
323    }
324
325    /// Expose a guest port at runtime; returns the add-listener response.
326    #[doc(hidden)]
327    pub async fn expose_listener(
328        &self,
329        sailbox_id: &str,
330        guest_port: u32,
331        protocol: crate::sailbox::types::IngressProtocol,
332        allowlist: &[String],
333    ) -> Result<Listener, SailError> {
334        let mut response = self
335            .sailbox_api()
336            .expose(sailbox_id, guest_port, protocol, allowlist)
337            .await?;
338        self.fill_listener_url(sailbox_id, &mut response);
339        Ok(response)
340    }
341
342    /// Remove a runtime ingress port.
343    #[doc(hidden)]
344    pub async fn unexpose_listener(
345        &self,
346        sailbox_id: &str,
347        guest_port: u32,
348    ) -> Result<(), SailError> {
349        self.sailbox_api().unexpose(sailbox_id, guest_port).await
350    }
351
352    /// List a sailbox's ingress listeners without resuming (waking) the box.
353    #[doc(hidden)]
354    pub async fn list_listeners(
355        &self,
356        sailbox_id: &str,
357    ) -> Result<Vec<crate::worker::Listener>, SailError> {
358        let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
359        for listener in &mut listeners {
360            self.fill_listener_url(sailbox_id, listener);
361        }
362        Ok(listeners)
363    }
364
365    /// Fetch one ingress listener by guest port without resuming (waking) the
366    /// box; a missing port is a [`SailError::NotFound`].
367    #[doc(hidden)]
368    pub async fn get_listener(
369        &self,
370        sailbox_id: &str,
371        guest_port: u32,
372    ) -> Result<crate::worker::Listener, SailError> {
373        let mut listener = self
374            .sailbox_api()
375            .get_listener(sailbox_id, guest_port)
376            .await?;
377        self.fill_listener_url(sailbox_id, &mut listener);
378        Ok(listener)
379    }
380
381    /// Fill an empty `public_url` on a non-TCP listener with the URL
382    /// synthesized from this client's ingress config (the server leaves
383    /// listener URLs empty in local/path mode).
384    fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
385        if listener.public_url.is_empty()
386            && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
387        {
388            listener.public_url = crate::sailbox::listeners::synthesized_public_url(
389                self.config(),
390                sailbox_id,
391                listener.guest_port,
392            );
393        }
394    }
395
396    /// Ingress-identity headers for this sailbox.
397    #[doc(hidden)]
398    pub async fn ingress_auth_headers(
399        &self,
400        sailbox_id: &str,
401    ) -> Result<Vec<(String, String)>, SailError> {
402        self.sailbox_api().ingress_auth_headers(sailbox_id).await
403    }
404
405    /// The caller org's SSH CA public key (created on first use).
406    pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
407        self.sailbox_api().org_ssh_ca_public_key().await
408    }
409
410    /// Sign `public_key` into a short-lived org-CA certificate (principal
411    /// `root`). `timeout` (seconds) bounds a single no-retry attempt; `None`
412    /// retries.
413    pub async fn issue_user_cert(
414        &self,
415        public_key: &str,
416        timeout: Option<Duration>,
417    ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
418        self.sailbox_api()
419            .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
420            .await
421    }
422
423    // --- NFS volumes ---
424
425    /// Look up (optionally minting) an NFS volume by name.
426    pub async fn get_volume(
427        &self,
428        name: &str,
429        mint_if_missing: bool,
430    ) -> Result<VolumeInfo, SailError> {
431        self.sailbox_api().get_volume(name, mint_if_missing).await
432    }
433
434    /// List NFS volumes in the current org.
435    pub async fn list_volumes(
436        &self,
437        max_objects: Option<i64>,
438    ) -> Result<Vec<VolumeInfo>, SailError> {
439        self.sailbox_api().list_volumes(max_objects).await
440    }
441
442    /// Delete a volume by id.
443    pub async fn delete_volume(
444        &self,
445        volume_id: &str,
446        allow_missing: bool,
447    ) -> Result<Option<VolumeInfo>, SailError> {
448        self.sailbox_api()
449            .delete_volume(volume_id, allow_missing)
450            .await
451    }
452
453    // --- apps (central API) ---
454
455    /// Find an app by name on the central API, optionally minting it.
456    pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
457        app::find_app(&self.inner.api_http, name, mint_if_missing).await
458    }
459
460    /// Every app the current org owns, newest first, from the central app index.
461    pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
462        app::list_apps(&self.inner.api_http).await
463    }
464
465    // --- exec and files (per-sailbox worker proxy) ---
466
467    /// Resolve a sailbox's current worker-proxy endpoint.
468    ///
469    /// `resume` wakes a paused/sleeping sailbox and returns its *current*
470    /// endpoint, which is the host worker's address and changes when the sailbox
471    /// migrates (e.g. after preemption). The GET sailbox API omits this routing
472    /// field, so resuming is the only way to learn it, and resolving it fresh per
473    /// call avoids ever dialing a stale worker.
474    #[doc(hidden)]
475    pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
476        let handle = self.resume_sailbox(sailbox_id).await?;
477        if handle.exec_endpoint.is_empty() {
478            return Err(SailError::Internal {
479                message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
480            });
481        }
482        Ok(handle.exec_endpoint)
483    }
484
485    /// Id-form of [`Sailbox::exec`](crate::Sailbox::exec), which documents the
486    /// contract. Spawns the output pump on the calling task's tokio runtime.
487    #[doc(hidden)]
488    pub async fn exec(
489        &self,
490        sailbox_id: &str,
491        argv: Vec<String>,
492        options: ExecOptions,
493    ) -> Result<ExecProcess, SailError> {
494        if argv.is_empty() {
495            return Err(SailError::InvalidArgument {
496                message: "command must be non-empty".to_string(),
497            });
498        }
499        if options.cwd.is_some() || options.background {
500            return Err(SailError::InvalidArgument {
501                message: "cwd and background require a shell command; use exec_shell".to_string(),
502            });
503        }
504        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
505        let params = ExecParams {
506            sailbox_id: sailbox_id.to_string(),
507            exec_endpoint,
508            argv,
509            // The wire is whole seconds where 0 means "no limit", so a set
510            // sub-second timeout rounds up to 1s rather than collapsing to 0.
511            timeout_seconds: options
512                .timeout
513                .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
514            idempotency_key: options.idempotency_key,
515            // A pty always feeds keystrokes to the command, so it implies an
516            // open stdin regardless of the flag.
517            open_stdin: options.open_stdin || options.pty,
518            pty: options.pty,
519            term: options.term,
520            cols: options.cols,
521            rows: options.rows,
522            retry_timeout: options.retry_timeout.as_secs_f64(),
523            extra_metadata: Vec::new(),
524        };
525        ExecProcess::start(self.worker(), params).await
526    }
527
528    /// Run a shell command in a sailbox via `/bin/sh -lc`, honoring the
529    /// `cwd`/`background` conveniences in [`ExecOptions`]. See [`Client::exec`]
530    /// for the argv form and the runtime notes.
531    #[doc(hidden)]
532    pub async fn exec_shell(
533        &self,
534        sailbox_id: &str,
535        command: &str,
536        mut options: ExecOptions,
537    ) -> Result<ExecProcess, SailError> {
538        let argv = crate::exec::shell_argv(command, &options)?;
539        // The conveniences are baked into the argv now; clear them so the argv
540        // path's guard does not re-reject them.
541        options.cwd = None;
542        options.background = false;
543        self.exec(sailbox_id, argv, options).await
544    }
545
546    /// Open a streaming read of a guest file. Resumes (wakes) the sailbox to
547    /// reach it; the returned [`FileReader`] yields chunks until end of file.
548    ///
549    /// # Runtime
550    ///
551    /// Spawns the read pump on the calling task's tokio runtime (see
552    /// [`crate::worker::WorkerProxy::read_file`]).
553    #[doc(hidden)]
554    pub async fn read_stream(
555        &self,
556        sailbox_id: &str,
557        remote_path: &str,
558    ) -> Result<FileReader, SailError> {
559        let endpoint = self.exec_endpoint(sailbox_id).await?;
560        Ok(self
561            .inner
562            .worker
563            .read_file(&endpoint, sailbox_id, remote_path))
564    }
565
566    /// Read a guest file into memory in one call (convenience over
567    /// [`Client::read_stream`], which streams a large file without
568    /// buffering it whole).
569    #[doc(hidden)]
570    pub async fn read_file(
571        &self,
572        sailbox_id: &str,
573        remote_path: &str,
574    ) -> Result<Vec<u8>, SailError> {
575        let reader = self.read_stream(sailbox_id, remote_path).await?;
576        let mut contents = Vec::new();
577        while let Some(chunk) = reader.next().await {
578            contents.extend_from_slice(&chunk?);
579        }
580        Ok(contents)
581    }
582
583    /// Open a streaming write to a guest file. Resumes (wakes) the sailbox to
584    /// reach it; feed the returned [`FileWriter`] with `write_chunk` and end with
585    /// `finish`, so a large source is never buffered whole.
586    ///
587    /// # Runtime
588    ///
589    /// Spawns the write RPC on the calling task's tokio runtime (see
590    /// [`crate::worker::WorkerProxy::write_file`]).
591    #[doc(hidden)]
592    pub async fn write_stream(
593        &self,
594        sailbox_id: &str,
595        remote_path: &str,
596        options: WriteOptions,
597    ) -> Result<FileWriter, SailError> {
598        let endpoint = self.exec_endpoint(sailbox_id).await?;
599        Ok(self.inner.worker.write_file(
600            &endpoint,
601            sailbox_id,
602            remote_path,
603            options.create_parents,
604            options.mode,
605        ))
606    }
607
608    /// Write `data` to a guest file in one call (convenience over
609    /// [`Client::write_stream`], which streams a large source without
610    /// buffering it whole).
611    #[doc(hidden)]
612    pub async fn write_file(
613        &self,
614        sailbox_id: &str,
615        remote_path: &str,
616        data: &[u8],
617        options: WriteOptions,
618    ) -> Result<(), SailError> {
619        let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
620        writer.write(data).await?;
621        writer.finish().await
622    }
623}