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