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