Skip to main content

sail/
client.rs

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