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