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::{RpcStatus, SailError};
35use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
36use crate::http::HttpCore;
37use crate::imagebuilder::ImageBuilder;
38use crate::sailbox::api::{SailboxApi, UpgradeResult};
39use crate::sailbox::fs::{DirEntry, EntryType};
40use crate::sailbox::object::Sailbox;
41use crate::sailbox::types::{
42    CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
43    SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
44    SailboxSpendResponse, VolumeInfo, WhoAmI,
45};
46use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
47
48/// A configured Sail client. Cheap to clone; shares transport across clones.
49#[derive(Clone)]
50pub struct Client {
51    inner: Arc<Inner>,
52}
53
54impl std::fmt::Debug for Client {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("Client")
57            .field("config", &self.inner.config)
58            .finish_non_exhaustive()
59    }
60}
61
62struct Inner {
63    config: Config,
64    /// Sailbox-API host: lifecycle, list/get, listeners, volume.
65    sailbox_http: HttpCore,
66    /// Central public-API host: app find, inference, voyages.
67    api_http: HttpCore,
68    /// Per-sailbox worker proxy: exec, files, listener reads. Its own `Arc` so
69    /// the streaming file/exec methods (which take `&Arc<Self>`) can share it.
70    worker: Arc<WorkerProxy>,
71    imagebuilder: ImageBuilder,
72    /// Successful image-readiness builds, shared by every clone of this
73    /// client (see [`crate::imagecache`]).
74    image_ready: crate::imagecache::ImageReadyCache,
75}
76
77/// Builds a [`Client`] from explicit values, falling back to the default
78/// endpoints. Prefer [`Client::from_env`] for the common env-driven case.
79///
80/// `Debug` redacts the API key, so a logged builder never leaks the
81/// credential.
82#[derive(Default, Clone)]
83pub struct ClientBuilder {
84    mode: Option<String>,
85    api_key: Option<String>,
86    api_url: Option<String>,
87    sailbox_api_url: Option<String>,
88    imagebuilder_url: Option<String>,
89    ingress_url: Option<String>,
90    client_label: Option<String>,
91}
92
93impl std::fmt::Debug for ClientBuilder {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("ClientBuilder")
96            .field(
97                "api_key",
98                &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
99            )
100            .field("mode", &self.mode)
101            .field("api_url", &self.api_url)
102            .field("sailbox_api_url", &self.sailbox_api_url)
103            .field("imagebuilder_url", &self.imagebuilder_url)
104            .field("ingress_url", &self.ingress_url)
105            .field("client_label", &self.client_label)
106            .finish()
107    }
108}
109
110impl ClientBuilder {
111    /// A builder with the given API key; unset endpoints use the Sail
112    /// defaults.
113    pub fn new(api_key: impl Into<String>) -> ClientBuilder {
114        ClientBuilder {
115            api_key: Some(api_key.into()),
116            ..ClientBuilder::default()
117        }
118    }
119
120    /// Select the named environment (`prod`/`dev`/`staging`/`local`), which
121    /// picks the endpoint defaults. Unset means prod.
122    #[doc(hidden)]
123    pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
124        self.mode = Some(mode.into());
125        self
126    }
127
128    /// Override the Sail API URL.
129    pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
130        self.api_url = Some(api_url.into());
131        self
132    }
133
134    /// Override the sailbox-API URL.
135    pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
136        self.sailbox_api_url = Some(url.into());
137        self
138    }
139
140    /// Override the image-build endpoint (`host:port`).
141    pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
142        self.imagebuilder_url = Some(url.into());
143        self
144    }
145
146    /// Override the listener ingress base URL (what `SAILBOX_INGRESS_URL`
147    /// sets from the environment), for custom or self-hosted Sailbox stacks.
148    pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
149        self.ingress_url = Some(url.into());
150        self
151    }
152
153    /// Identify the first-party binding using the shared transport.
154    #[doc(hidden)]
155    pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
156        self.client_label = Some(label.into());
157        self
158    }
159
160    /// Build the client, resolving any unset endpoint from the defaults.
161    pub fn build(self) -> Result<Client, SailError> {
162        let api_key = self.api_key.unwrap_or_default();
163        let config = Config::resolve(
164            self.mode.as_deref(),
165            api_key,
166            self.api_url,
167            self.sailbox_api_url,
168            self.imagebuilder_url,
169            self.ingress_url,
170        )?;
171        Client::from_config_with_label(
172            config,
173            self.client_label
174                .as_deref()
175                .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
176        )
177    }
178}
179
180/// Bound on the transparent image rebuild inside a create retry when the
181/// request carries no image-build timeout; matches the default build budget
182/// the SDK wrappers document.
183const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
184
185/// The scheduler's create rejection for an image it cannot resolve as ready.
186/// CreateSailbox in backend/internal/sailbox/scheduler stamps the
187/// "resolve image:" prefix on that arm; keep them in sync.
188fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
189    matches!(
190        result,
191        Err(SailError::Creation {
192            status: 409,
193            message,
194            ..
195        }) if message.starts_with("resolve image:")
196    )
197}
198
199impl Client {
200    /// Start a [`ClientBuilder`].
201    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
202        ClientBuilder::new(api_key)
203    }
204
205    /// Build a client from the environment (`SAIL_API_KEY`, …).
206    pub fn from_env() -> Result<Client, SailError> {
207        Client::from_config(Config::from_env()?)
208    }
209
210    /// Build a client from the environment and identify a first-party binding.
211    #[doc(hidden)]
212    pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
213        Client::from_config_with_label(Config::from_env()?, label)
214    }
215
216    /// Build a client from an already-resolved [`Config`].
217    pub fn from_config(config: Config) -> Result<Client, SailError> {
218        Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
219    }
220
221    fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
222        let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
223            .with_client_label(client_label);
224        let api_http =
225            HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
226        let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
227        let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
228        Ok(Client {
229            inner: Arc::new(Inner {
230                config,
231                sailbox_http,
232                api_http,
233                worker,
234                imagebuilder,
235                image_ready: crate::imagecache::ImageReadyCache::new(),
236            }),
237        })
238    }
239
240    pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
241        &self.inner.image_ready
242    }
243
244    /// Test hook: shrink the window after which a cached successful image
245    /// build is re-verified with the server. Compiled only for tests (this
246    /// crate's own and, under `test-fakes`, the integration crate), so it
247    /// never widens the published API.
248    #[cfg(any(test, feature = "test-fakes"))]
249    pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
250        self.inner.image_ready.set_refresh_window(window);
251    }
252
253    /// The resolved configuration.
254    pub fn config(&self) -> &Config {
255        &self.inner.config
256    }
257
258    /// The worker proxy for exec, file copy, and listener reads.
259    #[doc(hidden)]
260    pub fn worker(&self) -> Arc<WorkerProxy> {
261        Arc::clone(&self.inner.worker)
262    }
263
264    /// The imagebuilder dispatcher client.
265    #[doc(hidden)]
266    pub fn imagebuilder(&self) -> &ImageBuilder {
267        &self.inner.imagebuilder
268    }
269
270    /// The sailbox-API HTTP host (for binding-built requests).
271    #[doc(hidden)]
272    pub fn sailbox_http(&self) -> &HttpCore {
273        &self.inner.sailbox_http
274    }
275
276    /// The central public-API HTTP host (for binding-built requests).
277    #[doc(hidden)]
278    pub fn api_http(&self) -> &HttpCore {
279        &self.inner.api_http
280    }
281
282    fn sailbox_api(&self) -> SailboxApi<'_> {
283        SailboxApi::new(&self.inner.sailbox_http)
284    }
285
286    /// Send a create; when the scheduler rejects it because the image is not
287    /// ready even though readiness was cached, rebuild once and retry. A
288    /// backend deploy can change the canonical image identity behind the same
289    /// spec, so a cached "ready" can be stale until the refresh window. The
290    /// scheduler resolves the image before it creates any row, so nothing
291    /// exists server-side and the retried create is safe. Unrelated create
292    /// conflicts (name, idempotency) pass through untouched.
293    async fn create_with_image_revalidation(
294        &self,
295        req: &CreateSailboxRequest,
296        timeout: Option<Duration>,
297    ) -> Result<SailboxHandle, SailError> {
298        let create_started = std::time::Instant::now();
299        let result = self.sailbox_api().create(req, timeout).await;
300        let custom_image = req.image != crate::image::ImageSpec::default()
301            && !crate::imagebuild::is_builtin_base_spec(&req.image);
302        if !custom_image || !image_not_ready_conflict(&result) {
303            return result;
304        }
305        if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
306            // Drop every entry whose build started before this create began:
307            // those may carry the identity the server just rejected. A build
308            // started after conflict discovery is another stale caller's
309            // recovery, joined below rather than clobbered.
310            self.image_ready_cache()
311                .invalidate_spec_started_before(&spec_hash, create_started);
312        }
313        // The hard envelope means joining another caller's in-flight rebuild
314        // cannot outlive this caller's budget; the recovery marking keeps the
315        // rebuild joinable through later stale creates' invalidations.
316        let rebuild_timeout = req
317            .image_build_timeout
318            .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
319        let rebuild =
320            self.build_spec_ready_cached(&req.image, rebuild_timeout, /* recovery */ true);
321        tokio::time::timeout(rebuild_timeout, rebuild)
322            .await
323            .unwrap_or_else(|_| {
324                Err(SailError::Transport {
325                    kind: crate::error::TransportKind::Timeout,
326                    message: "timed out building the image".to_string(),
327                    source: None,
328                })
329            })?;
330        self.sailbox_api().create(req, timeout).await
331    }
332
333    // --- sailbox lifecycle ---
334
335    /// Create a Sailbox. `timeout` bounds each attempt of the synchronous
336    /// create (which can take minutes server-side); the call retries
337    /// with one idempotency key so the backend can dedupe rather than
338    /// duplicate, and gives up after roughly `max_attempts * timeout`.
339    /// An interrupted or re-invoked create is a new request and may leave a
340    /// prior box behind under the same name. 10 minutes is a good default;
341    /// `None` leaves each attempt unbounded. If the budget is exhausted the
342    /// box may still be coming up server-side; find or terminate it by
343    /// `name`.
344    pub async fn create_sailbox(
345        &self,
346        req: &CreateSailboxRequest,
347        timeout: Option<Duration>,
348    ) -> Result<Sailbox, SailError> {
349        let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
350        if !req.ssh {
351            return self
352                .create_with_image_revalidation(req, timeout)
353                .await
354                .map(bind);
355        }
356        // Validate the full request now, port-22 entries included: they are
357        // stripped below (their allowlist applies at the enable_ssh expose),
358        // so create's own validation never sees them, and an invalid entry
359        // must fail here rather than after the VM exists.
360        crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
361        // SSH setup is org-scoped: preflight the org CA (created on first use)
362        // so a CA outage fails before the VM exists.
363        self.org_ssh_ca_public_key().await?;
364        // Port 22 belongs to enable_ssh, which exposes it only after verifying
365        // the CA-only sshd owns it (never the create request), so a failed
366        // setup can't leave port 22 exposed. An explicit port-22 entry
367        // contributes just its allowlist, applied at that expose.
368        let mut req = req.clone();
369        let ssh_allowlist = req
370            .ingress_ports
371            .iter()
372            .find(|port| port.guest_port == 22)
373            .map(|port| port.allowlist.clone())
374            .unwrap_or_default();
375        req.ingress_ports.retain(|port| port.guest_port != 22);
376        let handle = self.create_with_image_revalidation(&req, timeout).await?;
377        let handle_id = handle.sailbox_id.clone();
378        // The VM is already up, so skip the readiness probe (wait: false).
379        if let Err(err) = self
380            .enable_ssh(
381                &handle_id,
382                &ssh_allowlist,
383                /* wait */ false,
384                Duration::ZERO,
385            )
386            .await
387        {
388            // The sailbox exists; surface its id so the caller can fetch it to
389            // retry enable_ssh or terminate it.
390            return Err(SailError::Creation {
391                message: format!(
392                    "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
393                     id to retry enable_ssh or terminate it."
394                ),
395                status: 0,
396                body: serde_json::Value::Null,
397            });
398        }
399        Ok(bind(handle))
400    }
401
402    /// Fetch a single Sailbox.
403    #[doc(hidden)]
404    pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
405        self.sailbox_api().get(sailbox_id).await
406    }
407
408    /// Fetch the identity (org, and user when user-scoped) behind the API key.
409    #[doc(hidden)]
410    pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
411        self.sailbox_api().whoami().await
412    }
413
414    /// List Sailboxes in the current org.
415    pub async fn list_sailboxes(
416        &self,
417        query: &ListSailboxesQuery,
418    ) -> Result<SailboxPage, SailError> {
419        self.sailbox_api().list(query).await
420    }
421
422    /// Estimate Sailbox spend for the current organization over a time window.
423    pub async fn sailbox_spend(
424        &self,
425        query: &SailboxSpendQuery,
426    ) -> Result<SailboxSpendResponse, SailError> {
427        self.sailbox_api().spend(query).await
428    }
429
430    /// Fetch a Sailbox's resource-usage time series.
431    pub async fn sailbox_metrics(
432        &self,
433        sailbox_id: &str,
434        query: &SailboxMetricsQuery,
435    ) -> Result<SailboxMetricsResponse, SailError> {
436        self.sailbox_api().metrics(sailbox_id, query).await
437    }
438
439    /// Terminate a Sailbox (idempotent).
440    #[doc(hidden)]
441    pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
442        self.sailbox_api().terminate(sailbox_id).await
443    }
444
445    /// Pause a Sailbox.
446    #[doc(hidden)]
447    pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
448        self.sailbox_api().pause(sailbox_id).await
449    }
450
451    /// Sleep a Sailbox.
452    #[doc(hidden)]
453    pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
454        self.sailbox_api().sleep(sailbox_id).await
455    }
456
457    /// Resume a paused/sleeping Sailbox.
458    #[doc(hidden)]
459    pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
460        self.sailbox_api().resume(sailbox_id).await
461    }
462
463    /// Checkpoint a running Sailbox.
464    #[doc(hidden)]
465    pub async fn checkpoint_sailbox(
466        &self,
467        sailbox_id: &str,
468        name: Option<&str>,
469        ttl_seconds: Option<i64>,
470    ) -> Result<SailboxCheckpoint, SailError> {
471        self.sailbox_api()
472            .checkpoint(sailbox_id, name, ttl_seconds)
473            .await
474    }
475
476    /// Fork a Sailbox into a new child in one call. Id-form of
477    /// [`Sailbox::fork`](crate::Sailbox::fork), which documents the contract.
478    #[doc(hidden)]
479    pub async fn fork_sailbox(
480        &self,
481        sailbox_id: &str,
482        name: Option<&str>,
483        timeout: Option<Duration>,
484    ) -> Result<Sailbox, SailError> {
485        self.sailbox_api()
486            .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
487            .await
488            .map(|handle| Sailbox::bind(self.clone(), handle))
489    }
490
491    /// Create a new Sailbox from a checkpoint.
492    pub async fn create_from_checkpoint(
493        &self,
494        checkpoint_id: &str,
495        name: Option<&str>,
496        timeout: Option<Duration>,
497    ) -> Result<Sailbox, SailError> {
498        self.sailbox_api()
499            .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
500            .await
501            .map(|handle| Sailbox::bind(self.clone(), handle))
502    }
503
504    /// Upgrade the Sailbox runtime (applies now if running, else at next wake).
505    #[doc(hidden)]
506    pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
507        self.sailbox_api().upgrade(sailbox_id).await
508    }
509
510    /// Expose a guest port at runtime; returns the add-listener response.
511    #[doc(hidden)]
512    pub async fn expose_listener(
513        &self,
514        sailbox_id: &str,
515        guest_port: u32,
516        protocol: crate::sailbox::types::IngressProtocol,
517        allowlist: &[String],
518    ) -> Result<Listener, SailError> {
519        let mut response = self
520            .sailbox_api()
521            .expose(sailbox_id, guest_port, protocol, allowlist)
522            .await?;
523        self.fill_listener_url(sailbox_id, &mut response);
524        Ok(response)
525    }
526
527    /// Remove a runtime ingress port.
528    #[doc(hidden)]
529    pub async fn unexpose_listener(
530        &self,
531        sailbox_id: &str,
532        guest_port: u32,
533    ) -> Result<(), SailError> {
534        self.sailbox_api().unexpose(sailbox_id, guest_port).await
535    }
536
537    /// List a Sailbox's ingress listeners without resuming (waking) the box.
538    #[doc(hidden)]
539    pub async fn list_listeners(
540        &self,
541        sailbox_id: &str,
542    ) -> Result<Vec<crate::worker::Listener>, SailError> {
543        let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
544        for listener in &mut listeners {
545            self.fill_listener_url(sailbox_id, listener);
546        }
547        Ok(listeners)
548    }
549
550    /// Fetch one ingress listener by guest port without resuming (waking) the
551    /// box; a missing port is a [`SailError::NotFound`].
552    #[doc(hidden)]
553    pub async fn get_listener(
554        &self,
555        sailbox_id: &str,
556        guest_port: u32,
557    ) -> Result<crate::worker::Listener, SailError> {
558        let mut listener = self
559            .sailbox_api()
560            .get_listener(sailbox_id, guest_port)
561            .await?;
562        self.fill_listener_url(sailbox_id, &mut listener);
563        Ok(listener)
564    }
565
566    /// Fill an empty `public_url` on a non-TCP listener with the URL
567    /// synthesized from this client's ingress config (the server leaves
568    /// listener URLs empty in local/path mode).
569    fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
570        if listener.public_url.is_empty()
571            && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
572        {
573            listener.public_url = crate::sailbox::listeners::synthesized_public_url(
574                self.config(),
575                sailbox_id,
576                listener.guest_port,
577            );
578        }
579    }
580
581    /// Ingress-identity headers for this Sailbox.
582    #[doc(hidden)]
583    pub async fn ingress_auth_headers(
584        &self,
585        sailbox_id: &str,
586    ) -> Result<Vec<(String, String)>, SailError> {
587        self.sailbox_api().ingress_auth_headers(sailbox_id).await
588    }
589
590    /// The caller org's SSH CA public key (created on first use).
591    pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
592        self.sailbox_api().org_ssh_ca_public_key().await
593    }
594
595    /// Sign `public_key` into a short-lived org-CA certificate (principal
596    /// `root`). `timeout` (seconds) bounds a single no-retry attempt; `None`
597    /// retries.
598    pub async fn issue_user_cert(
599        &self,
600        public_key: &str,
601        timeout: Option<Duration>,
602    ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
603        self.sailbox_api()
604            .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
605            .await
606    }
607
608    // --- NFS volumes ---
609
610    /// Look up (optionally minting) an NFS volume by name.
611    pub async fn get_volume(
612        &self,
613        name: &str,
614        mint_if_missing: bool,
615    ) -> Result<VolumeInfo, SailError> {
616        self.sailbox_api().get_volume(name, mint_if_missing).await
617    }
618
619    /// List NFS volumes in the current org.
620    pub async fn list_volumes(
621        &self,
622        max_objects: Option<i64>,
623    ) -> Result<Vec<VolumeInfo>, SailError> {
624        self.sailbox_api().list_volumes(max_objects).await
625    }
626
627    /// Delete a volume by id.
628    pub async fn delete_volume(
629        &self,
630        volume_id: &str,
631        allow_missing: bool,
632    ) -> Result<Option<VolumeInfo>, SailError> {
633        self.sailbox_api()
634            .delete_volume(volume_id, allow_missing)
635            .await
636    }
637
638    // --- apps (central API) ---
639
640    /// Find an app by name, optionally minting it.
641    pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
642        app::find_app(&self.inner.api_http, name, mint_if_missing).await
643    }
644
645    /// Every app the current org owns, newest first.
646    pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
647        app::list_apps(&self.inner.api_http).await
648    }
649
650    // --- exec and files (per-sailbox worker proxy) ---
651
652    /// Resolve a Sailbox's current worker-proxy endpoint.
653    ///
654    /// `resume` wakes a paused/sleeping Sailbox and returns its *current*
655    /// endpoint, which is the host worker's address and changes when the Sailbox
656    /// migrates (e.g. after preemption). The GET Sailbox API omits this routing
657    /// field, so resuming is the only way to learn it, and resolving it fresh per
658    /// call avoids ever dialing a stale worker.
659    #[doc(hidden)]
660    pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
661        let handle = self.resume_sailbox(sailbox_id).await?;
662        if handle.exec_endpoint.is_empty() {
663            return Err(SailError::Internal {
664                message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
665            });
666        }
667        Ok(handle.exec_endpoint)
668    }
669
670    /// Id-form of [`Sailbox::exec`](crate::Sailbox::exec), which documents the
671    /// contract. Spawns the output pump on the calling task's tokio runtime.
672    #[doc(hidden)]
673    pub async fn exec(
674        &self,
675        sailbox_id: &str,
676        argv: Vec<String>,
677        options: ExecOptions,
678    ) -> Result<ExecProcess, SailError> {
679        if argv.is_empty() {
680            return Err(SailError::InvalidArgument {
681                message: "command must be non-empty".to_string(),
682            });
683        }
684        if options.cwd.is_some() || options.background {
685            return Err(SailError::InvalidArgument {
686                message: "cwd and background require a shell command; use exec_shell or run_shell"
687                    .to_string(),
688            });
689        }
690        // Validate and encode the env before resolving the endpoint: it is
691        // purely local, so a malformed key must not first wake a paused sailbox.
692        let env = crate::exec::encode_env(&options.env)?;
693        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
694        let params = ExecParams {
695            sailbox_id: sailbox_id.to_string(),
696            exec_endpoint,
697            argv,
698            // The wire is whole seconds where 0 means "no limit", so a set
699            // sub-second timeout rounds up to 1s rather than collapsing to 0.
700            timeout_seconds: options
701                .timeout
702                .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
703            idempotency_key: options.idempotency_key,
704            // A pty always feeds keystrokes to the command, so it implies an
705            // open stdin regardless of the flag.
706            open_stdin: options.open_stdin || options.pty,
707            pty: options.pty,
708            term: options.term,
709            cols: options.cols,
710            rows: options.rows,
711            env,
712            retry_timeout: options.retry_timeout.as_secs_f64(),
713            forward_ports: options.forward_ports,
714            forward_browser: options.forward_browser,
715            extra_metadata: Vec::new(),
716        };
717        ExecProcess::start(self.worker(), params).await
718    }
719
720    /// Run a shell command in a Sailbox via `/bin/sh -lc`, honoring the
721    /// `cwd`/`background` conveniences in [`ExecOptions`]. See [`Client::exec`]
722    /// for the argv form and the runtime notes.
723    #[doc(hidden)]
724    pub async fn exec_shell(
725        &self,
726        sailbox_id: &str,
727        command: &str,
728        mut options: ExecOptions,
729    ) -> Result<ExecProcess, SailError> {
730        let argv = crate::exec::shell_argv(command, &options)?;
731        // The conveniences are baked into the argv now; clear them so the argv
732        // path's guard does not re-reject them.
733        options.cwd = None;
734        options.background = false;
735        self.exec(sailbox_id, argv, options).await
736    }
737
738    /// Open a streaming read of a guest file. Resumes (wakes) the Sailbox to
739    /// reach it; the returned [`FileReader`] yields chunks until end of file.
740    ///
741    /// # Runtime
742    ///
743    /// Spawns the read pump on the calling task's tokio runtime (see
744    /// [`crate::worker::WorkerProxy::read_file`]).
745    #[doc(hidden)]
746    pub async fn read_stream(
747        &self,
748        sailbox_id: &str,
749        remote_path: &str,
750    ) -> Result<FileReader, SailError> {
751        let endpoint = self.exec_endpoint(sailbox_id).await?;
752        Ok(self
753            .inner
754            .worker
755            .read_file(&endpoint, sailbox_id, remote_path))
756    }
757
758    /// Read a guest file into memory in one call (convenience over
759    /// [`Client::read_stream`], which streams a large file without
760    /// buffering it whole).
761    #[doc(hidden)]
762    pub async fn read_file(
763        &self,
764        sailbox_id: &str,
765        remote_path: &str,
766    ) -> Result<Vec<u8>, SailError> {
767        let reader = self.read_stream(sailbox_id, remote_path).await?;
768        let mut contents = Vec::new();
769        while let Some(chunk) = reader.next().await {
770            contents.extend_from_slice(&chunk?);
771        }
772        Ok(contents)
773    }
774
775    /// Open a streaming write to a guest file. Resumes (wakes) the Sailbox to
776    /// reach it; feed the returned [`FileWriter`] with `write_chunk` and end with
777    /// `finish`, so a large source is never buffered whole.
778    ///
779    /// # Runtime
780    ///
781    /// Spawns the write RPC on the calling task's tokio runtime (see
782    /// [`crate::worker::WorkerProxy::write_file`]).
783    #[doc(hidden)]
784    pub async fn write_stream(
785        &self,
786        sailbox_id: &str,
787        remote_path: &str,
788        options: WriteOptions,
789    ) -> Result<FileWriter, SailError> {
790        let endpoint = self.exec_endpoint(sailbox_id).await?;
791        Ok(self.inner.worker.write_file(
792            &endpoint,
793            sailbox_id,
794            remote_path,
795            options.create_parents,
796            options.mode,
797        ))
798    }
799
800    /// Write `data` to a guest file in one call (convenience over
801    /// [`Client::write_stream`], which streams a large source without
802    /// buffering it whole).
803    #[doc(hidden)]
804    pub async fn write_file(
805        &self,
806        sailbox_id: &str,
807        remote_path: &str,
808        data: &[u8],
809        options: WriteOptions,
810    ) -> Result<(), SailError> {
811        let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
812        writer.write(data).await?;
813        writer.finish().await
814    }
815
816    // --- filesystem helpers ---
817    //
818    // These build a coreutils command, run it to completion, and inspect the
819    // result. They live in the core so the command construction and the `find`
820    // output parse are defined once, and every language binding consumes the
821    // structured results rather than re-parsing `find`'s output.
822
823    /// Run a command to completion and return its buffered result.
824    async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
825        self.exec(sailbox_id, argv, ExecOptions::default())
826            .await?
827            .wait()
828            .await
829    }
830
831    /// Create a directory and any missing parents (like `mkdir -p`); a no-op if
832    /// it already exists.
833    #[doc(hidden)]
834    pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
835        crate::sailbox::fs::require_path(path)?;
836        let result = self
837            .run_argv(
838                sailbox_id,
839                vec![
840                    "mkdir".to_string(),
841                    "-p".to_string(),
842                    "--".to_string(),
843                    path.to_string(),
844                ],
845            )
846            .await?;
847        fs_command_ok(&result, &format!("create directory {path}"))
848    }
849
850    /// Remove a file or directory tree (like `rm -rf`); a no-op if it is already
851    /// absent.
852    #[doc(hidden)]
853    pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
854        crate::sailbox::fs::require_path(path)?;
855        let result = self
856            .run_argv(
857                sailbox_id,
858                vec![
859                    "rm".to_string(),
860                    "-rf".to_string(),
861                    "--".to_string(),
862                    path.to_string(),
863                ],
864            )
865            .await?;
866        fs_command_ok(&result, &format!("remove {path}"))
867    }
868
869    /// Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
870    /// a dangling symlink reports `false`.
871    #[doc(hidden)]
872    pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
873        crate::sailbox::fs::require_path(path)?;
874        let result = self
875            .run_argv(
876                sailbox_id,
877                vec!["test".to_string(), "-e".to_string(), path.to_string()],
878            )
879            .await?;
880        // `test -e` answers with its exit code: 0 exists, 1 does not. Any other
881        // code (for example a signal-killed process) is a failed check, not an
882        // answer, so surface it rather than reading it as absent.
883        match result.exit_code {
884            0 => Ok(true),
885            1 => Ok(false),
886            _ => Err(fs_command_error(
887                &result,
888                &format!("check whether {path} exists"),
889            )),
890        }
891    }
892
893    /// List a directory's immediate entries (files and subdirectories, no
894    /// recursion). Requires GNU `find`, which the default Debian image ships. A
895    /// missing path errors, as does a path that exists but is not a directory.
896    #[doc(hidden)]
897    pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
898        crate::sailbox::fs::require_path(path)?;
899        let process = self
900            .exec(
901                sailbox_id,
902                crate::sailbox::fs::list_dir_argv(path),
903                ExecOptions::default(),
904            )
905            .await?;
906        let result = process.wait().await?;
907        fs_command_ok(&result, &format!("list directory {path}"))?;
908        // Buffered stdout is a capped, drop-oldest tail, so parsing a truncated
909        // listing would silently drop entries.
910        if result.stdout_truncated {
911            return Err(SailError::Execution {
912                code: RpcStatus::FailedPrecondition,
913                detail: format!(
914                    "directory listing for {path} was truncated because it has \
915                     too many entries; list a smaller subtree"
916                ),
917            });
918        }
919        // The records are NUL-terminated, and only the raw buffered bytes keep
920        // NUL: the string-typed `ExecResult` replaces it, as does the persisted
921        // tail that `wait` falls back to when the live stream loses its ending.
922        // So parse the local raw bytes, and require that the stream delivered
923        // them all; when it did not, the local buffer may be missing entries.
924        if !result.stdout_complete {
925            return Err(SailError::Execution {
926                code: RpcStatus::FailedPrecondition,
927                detail: format!(
928                    "directory listing for {path} was interrupted before it \
929                     finished streaming; retry the call"
930                ),
931            });
932        }
933        let mut entries =
934            crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
935                .map_err(|detail| SailError::Execution {
936                code: RpcStatus::FailedPrecondition,
937                detail: format!("directory listing for {path} could not be used: {detail}"),
938            })?;
939        // `find` emits the start point itself as the first record, carrying the
940        // path's own type.
941        if entries.is_empty() {
942            return Err(SailError::Execution {
943                code: RpcStatus::FailedPrecondition,
944                detail: format!(
945                    "directory listing for {path} produced no records; \
946                     listing requires GNU find in the guest"
947                ),
948            });
949        }
950        let start = entries.remove(0);
951        if start.entry_type != EntryType::Directory {
952            return Err(SailError::Execution {
953                code: RpcStatus::FailedPrecondition,
954                detail: format!(
955                    "{path} is not a directory (it is a {})",
956                    start.entry_type.as_str()
957                ),
958            });
959        }
960        Ok(entries)
961    }
962}
963
964/// Whole seconds for the wire, rounding a positive duration up (like the
965/// exec timeout) so the server never enforces a shorter bound than the
966/// caller asked for; an explicit zero stays zero for the API to reject.
967fn duration_to_whole_seconds(timeout: Duration) -> i64 {
968    if timeout.is_zero() {
969        0
970    } else {
971        timeout.as_secs_f64().ceil() as i64
972    }
973}
974
975/// Fail on a non-zero exit from a filesystem helper command.
976fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
977    if result.exit_code != 0 {
978        return Err(fs_command_error(result, action));
979    }
980    Ok(())
981}
982
983/// The error for a failed filesystem helper command, folding the guest's stderr
984/// into the message.
985fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
986    let stderr = result.stderr.trim();
987    let suffix = if stderr.is_empty() {
988        String::new()
989    } else {
990        format!(": {stderr}")
991    };
992    SailError::Execution {
993        code: RpcStatus::FailedPrecondition,
994        detail: format!(
995            "failed to {action} (exit code {}){suffix}",
996            result.exit_code
997        ),
998    }
999}
1000
1001#[cfg(test)]
1002mod timeout_tests {
1003    use super::*;
1004
1005    #[test]
1006    fn durations_round_up_to_whole_seconds() {
1007        assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1008        assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1009        assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1010        assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1011    }
1012}