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