Skip to main content

sail/sailbox/
object.rs

1//! The bound sailbox object: a sailbox id paired with the [`Client`] that
2//! reaches it, so every operation is a method instead of an id-threading call.
3//!
4//! Each method is a one-line delegate to the corresponding [`Client`] method,
5//! which remains the single implementation (and the surface the language
6//! bridges call with plain ids). The delegation means the two surfaces cannot
7//! drift: a signature change on either side fails to compile.
8
9use std::time::Duration;
10
11use crate::client::Client;
12use crate::error::SailError;
13use crate::exec::{ExecOptions, ExecProcess};
14use crate::sailbox::api::UpgradeResult;
15use crate::sailbox::fs::DirEntry;
16use crate::sailbox::ssh::{EnableSshOptions, SshEndpoint};
17use crate::sailbox::types::{
18    CheckpointOptions, IngressProtocol, SailboxCheckpoint, SailboxHandle, SailboxInfo,
19};
20use crate::worker::{FileReader, FileWriter, Listener, WriteOptions};
21
22/// A sailbox bound to the client that reaches it. Obtained from
23/// [`Client::create_sailbox`], [`Client::create_from_checkpoint`], or
24/// [`Client::sailbox`] (which binds an existing id without a network call).
25///
26/// Cheap to clone; clones share the underlying client transport.
27///
28/// ```no_run
29/// # async fn demo() -> Result<(), sail::error::SailError> {
30/// # let client = sail::Client::from_env()?;
31/// let sb = client.sailbox("sb_abc123");
32/// let result = sb.exec_shell("echo hello", Default::default()).await?.wait().await?;
33/// sb.fs().write("/workspace/input.txt", b"hello\n", Default::default()).await?;
34/// sb.terminate().await?;
35/// # Ok(())
36/// # }
37/// ```
38#[derive(Clone)]
39pub struct Sailbox {
40    client: Client,
41    handle: SailboxHandle,
42}
43
44impl std::fmt::Debug for Sailbox {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("Sailbox")
47            .field("sailbox_id", &self.handle.sailbox_id)
48            .finish_non_exhaustive()
49    }
50}
51
52impl Sailbox {
53    pub(crate) fn bind(client: Client, handle: SailboxHandle) -> Sailbox {
54        Sailbox { client, handle }
55    }
56
57    pub(crate) fn client(&self) -> &Client {
58        &self.client
59    }
60
61    /// The sailbox's stable identifier.
62    pub fn sailbox_id(&self) -> &str {
63        &self.handle.sailbox_id
64    }
65
66    /// The data snapshot from the call that produced this object (create,
67    /// from-checkpoint). A sailbox bound by id via [`Client::sailbox`] carries
68    /// only the id; use [`Sailbox::info`] for fresh state either way.
69    pub fn handle(&self) -> &SailboxHandle {
70        &self.handle
71    }
72
73    /// Consume the object, keeping just the data snapshot.
74    pub fn into_handle(self) -> SailboxHandle {
75        self.handle
76    }
77
78    /// Fetch this sailbox's current state.
79    pub async fn info(&self) -> Result<SailboxInfo, SailError> {
80        self.client.get_sailbox(self.sailbox_id()).await
81    }
82
83    // --- lifecycle ---
84
85    /// Terminate the sailbox (idempotent).
86    pub async fn terminate(&self) -> Result<(), SailError> {
87        self.client.terminate_sailbox(self.sailbox_id()).await
88    }
89
90    /// Pause the sailbox in memory.
91    pub async fn pause(&self) -> Result<(), SailError> {
92        self.client.pause_sailbox(self.sailbox_id()).await
93    }
94
95    /// Sleep the sailbox to disk (it wakes on traffic).
96    pub async fn sleep(&self) -> Result<(), SailError> {
97        self.client.sleep_sailbox(self.sailbox_id()).await
98    }
99
100    /// Resume a paused or sleeping sailbox.
101    pub async fn resume(&self) -> Result<(), SailError> {
102        self.client
103            .resume_sailbox(self.sailbox_id())
104            .await
105            .map(|_| ())
106    }
107
108    /// Checkpoint the sailbox. `options.name` labels the handle;
109    /// `options.ttl`, when given, must be positive and overrides the server's
110    /// default retention.
111    pub async fn checkpoint(
112        &self,
113        options: CheckpointOptions,
114    ) -> Result<SailboxCheckpoint, SailError> {
115        self.client
116            .checkpoint_sailbox(
117                self.sailbox_id(),
118                options.name.as_deref(),
119                options.ttl.map(|ttl| ttl.as_secs() as i64),
120            )
121            .await
122    }
123
124    /// Upgrade the in-guest agent (now if running, else at next wake).
125    pub async fn upgrade(&self) -> Result<UpgradeResult, SailError> {
126        self.client.upgrade_sailbox(self.sailbox_id()).await
127    }
128
129    // --- exec ---
130
131    /// Run a command from an argv vector (no shell interpretation) and return
132    /// a handle to the live process. Resumes (wakes) the sailbox to reach it.
133    /// The returned [`ExecProcess`] streams output, accepts stdin, and
134    /// resolves the exit status; dropping it detaches without killing the
135    /// command. The output pump spawns on the calling task's tokio runtime.
136    pub async fn exec(
137        &self,
138        argv: Vec<String>,
139        options: ExecOptions,
140    ) -> Result<ExecProcess, SailError> {
141        self.client.exec(self.sailbox_id(), argv, options).await
142    }
143
144    /// Run a shell command via `/bin/sh -lc` (pipes, globs, and `$VAR`
145    /// expansion work), honoring the `cwd`/`background` options. Use
146    /// [`Sailbox::exec`] with an argv vector when arguments must reach the
147    /// command verbatim. Otherwise behaves like [`Sailbox::exec`].
148    pub async fn exec_shell(
149        &self,
150        command: &str,
151        options: ExecOptions,
152    ) -> Result<ExecProcess, SailError> {
153        self.client
154            .exec_shell(self.sailbox_id(), command, options)
155            .await
156    }
157
158    // --- files ---
159
160    /// Filesystem operations on this sailbox's guest: read and write files
161    /// (buffered or streaming), and directory helpers.
162    pub fn fs(&self) -> SailboxFs<'_> {
163        SailboxFs { sailbox: self }
164    }
165
166    // --- listeners ---
167
168    /// Expose a guest port at runtime. The returned [`Listener`] carries the
169    /// resolved endpoint but an unspecified route status: the expose response
170    /// does not report reachability. Confirm with
171    /// [`Sailbox::wait_for_listener`].
172    pub async fn expose(
173        &self,
174        guest_port: u32,
175        protocol: IngressProtocol,
176        allowlist: &[String],
177    ) -> Result<Listener, SailError> {
178        self.client
179            .expose_listener(self.sailbox_id(), guest_port, protocol, allowlist)
180            .await
181    }
182
183    /// Remove a runtime ingress port.
184    pub async fn unexpose(&self, guest_port: u32) -> Result<(), SailError> {
185        self.client
186            .unexpose_listener(self.sailbox_id(), guest_port)
187            .await
188    }
189
190    /// List this sailbox's listeners without waking it.
191    pub async fn listeners(&self) -> Result<Vec<Listener>, SailError> {
192        self.client.list_listeners(self.sailbox_id()).await
193    }
194
195    /// Fetch one listener by guest port without waking the box.
196    pub async fn listener(&self, guest_port: u32) -> Result<Listener, SailError> {
197        self.client
198            .get_listener(self.sailbox_id(), guest_port)
199            .await
200    }
201
202    /// Block until the listener on `guest_port` is reachable end to end
203    /// (route active and its endpoint accepting) and return it. An HTTP
204    /// listener is probed by URL, so success means the guest server answered;
205    /// a TCP listener is ready once the guest sends bytes or holds the
206    /// connection open. This is a connectivity check, not an application
207    /// health check. Polls every `poll_interval` (1s is a good default) and
208    /// fails with a Timeout error after `timeout` (60s is a good default; an
209    /// unrepresentably large value waits indefinitely).
210    pub async fn wait_for_listener(
211        &self,
212        guest_port: u32,
213        timeout: Duration,
214        poll_interval: Duration,
215    ) -> Result<Listener, SailError> {
216        self.client
217            .wait_for_listener(self.sailbox_id(), guest_port, timeout, poll_interval)
218            .await
219    }
220
221    /// Ingress-identity headers for this sailbox, as name/value pairs.
222    pub async fn ingress_auth_headers(&self) -> Result<Vec<(String, String)>, SailError> {
223        self.client.ingress_auth_headers(self.sailbox_id()).await
224    }
225
226    // --- ssh ---
227
228    /// Make the sailbox reachable over SSH, returning the endpoint when
229    /// `options.wait` is set (else `None`). Installs the org SSH CA as
230    /// trusted, (re)starts `sshd`, confirms the CA-only daemon owns guest
231    /// port 22, and only then exposes the port as TCP ingress, so a failed
232    /// enable never leaves a non-CA daemon reachable. Idempotent. A non-empty
233    /// `options.allowlist` restricts port 22 to those source CIDRs; when
234    /// empty, a first enable is open to any source and a re-enable keeps an
235    /// existing restriction.
236    pub async fn enable_ssh(
237        &self,
238        options: EnableSshOptions,
239    ) -> Result<Option<SshEndpoint>, SailError> {
240        self.client
241            .enable_ssh(
242                self.sailbox_id(),
243                &options.allowlist,
244                options.wait,
245                options.timeout,
246            )
247            .await
248    }
249}
250
251/// Filesystem operations on a sailbox's guest, reached via [`Sailbox::fs`].
252/// Reads and writes stream bytes to/from the guest over the file service; the
253/// directory helpers run standard coreutils in the guest.
254pub struct SailboxFs<'a> {
255    sailbox: &'a Sailbox,
256}
257
258impl SailboxFs<'_> {
259    /// Read a guest file into memory in one call.
260    pub async fn read(&self, path: &str) -> Result<Vec<u8>, SailError> {
261        self.sailbox
262            .client
263            .read_file(self.sailbox.sailbox_id(), path)
264            .await
265    }
266
267    /// Write `data` to a guest file in one call.
268    pub async fn write(
269        &self,
270        path: &str,
271        data: &[u8],
272        options: WriteOptions,
273    ) -> Result<(), SailError> {
274        self.sailbox
275            .client
276            .write_file(self.sailbox.sailbox_id(), path, data, options)
277            .await
278    }
279
280    /// Open a streaming read of a guest file.
281    pub async fn read_stream(&self, path: &str) -> Result<FileReader, SailError> {
282        self.sailbox
283            .client
284            .read_stream(self.sailbox.sailbox_id(), path)
285            .await
286    }
287
288    /// Open a streaming write to a guest file.
289    pub async fn write_stream(
290        &self,
291        path: &str,
292        options: WriteOptions,
293    ) -> Result<FileWriter, SailError> {
294        self.sailbox
295            .client
296            .write_stream(self.sailbox.sailbox_id(), path, options)
297            .await
298    }
299
300    /// Create a directory and any missing parents (like `mkdir -p`); a no-op if
301    /// it already exists.
302    pub async fn mkdir(&self, path: &str) -> Result<(), SailError> {
303        self.sailbox
304            .client
305            .make_dir(self.sailbox.sailbox_id(), path)
306            .await
307    }
308
309    /// Remove a file or directory tree (like `rm -rf`); a no-op if it is already
310    /// absent.
311    pub async fn remove(&self, path: &str) -> Result<(), SailError> {
312        self.sailbox
313            .client
314            .remove_path(self.sailbox.sailbox_id(), path)
315            .await
316    }
317
318    /// Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
319    /// a dangling symlink reports `false` even though [`ls`](Self::ls) lists it.
320    pub async fn exists(&self, path: &str) -> Result<bool, SailError> {
321        self.sailbox
322            .client
323            .path_exists(self.sailbox.sailbox_id(), path)
324            .await
325    }
326
327    /// List a directory's immediate entries as [`DirEntry`] records (no
328    /// recursion). Runs GNU `find` in the guest, which the default Debian image
329    /// ships. A missing path errors, as does a path that is not a directory and
330    /// a listing too large for the exec output cap. An entry whose name is not
331    /// valid UTF-8 fails the listing, since the path API cannot address it.
332    pub async fn ls(&self, path: &str) -> Result<Vec<DirEntry>, SailError> {
333        self.sailbox
334            .client
335            .list_dir(self.sailbox.sailbox_id(), path)
336            .await
337    }
338}
339
340impl Client {
341    /// Bind an existing sailbox id to this client without a network call,
342    /// giving the method-style surface over it.
343    pub fn sailbox(&self, sailbox_id: impl Into<String>) -> Sailbox {
344        Sailbox {
345            client: self.clone(),
346            handle: SailboxHandle {
347                sailbox_id: sailbox_id.into(),
348                ..Default::default()
349            },
350        }
351    }
352}