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(|ttl| ttl.as_secs() as i64),
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 live state while the parent keeps running. The copy is
183    /// transient: there is no separate artifact to keep or reuse. To branch
184    /// from a saved point in time instead, take a durable [`Sailbox::checkpoint`]
185    /// and start children from it with [`Client::create_from_checkpoint`], which
186    /// works even after the parent is gone.
187    ///
188    /// The child is a new independent Sailbox: commands still running in the
189    /// parent do not continue in the child (their on-disk effects up to the
190    /// fork are preserved); start fresh execs on the child.
191    pub async fn fork(&self, options: ForkOptions) -> Result<Sailbox, SailError> {
192        self.client
193            .fork_sailbox(self.sailbox_id(), options.name.as_deref(), options.timeout)
194            .await
195    }
196
197    // --- exec ---
198
199    /// Run a command from an argv vector (no shell interpretation) and return
200    /// a handle to the live process. Resumes (wakes) the Sailbox to reach it.
201    /// The returned [`ExecProcess`] streams output, accepts stdin, and
202    /// resolves the exit status; dropping it detaches without killing the
203    /// command. The output pump spawns on the calling task's tokio runtime.
204    pub async fn exec(
205        &self,
206        argv: impl IntoIterator<Item = impl Into<String>>,
207        options: ExecOptions,
208    ) -> Result<ExecProcess, SailError> {
209        let exec_endpoint = self.exec_endpoint_hint();
210        self.client
211            .exec_at_endpoint(
212                self.sailbox_id(),
213                Some(&exec_endpoint),
214                collect_argv(argv),
215                options,
216            )
217            .await
218    }
219
220    /// Run a shell command via `/bin/sh -lc` (pipes, globs, and `$VAR`
221    /// expansion work), honoring the `cwd`/`background` options. Use
222    /// [`Sailbox::exec`] with an argv vector when arguments must reach the
223    /// command verbatim. Otherwise behaves like [`Sailbox::exec`].
224    pub async fn exec_shell(
225        &self,
226        command: &str,
227        options: ExecOptions,
228    ) -> Result<ExecProcess, SailError> {
229        let exec_endpoint = self.exec_endpoint_hint();
230        self.client
231            .exec_shell_at_endpoint(self.sailbox_id(), Some(&exec_endpoint), command, options)
232            .await
233    }
234
235    /// Run an argv command to completion and return its buffered
236    /// [`ExecResult`]: a one-shot convenience over [`Sailbox::exec`] followed
237    /// by [`ExecProcess::wait`]. A nonzero exit code reports through
238    /// [`ExecResult::exit_code`], not an error, and an exceeded
239    /// `options.timeout` reports through [`ExecResult::timed_out`]. Use
240    /// [`Sailbox::exec`] to stream output or feed stdin.
241    ///
242    /// ```no_run
243    /// # async fn demo() -> Result<(), sail::SailError> {
244    /// # let client = sail::Client::from_env()?;
245    /// let sb = client.sailbox("sb_abc123");
246    /// let result = sb.run(["echo", "hello"], Default::default()).await?;
247    /// assert_eq!(result.exit_code, 0);
248    /// println!("{}", result.stdout);
249    /// # Ok(())
250    /// # }
251    /// ```
252    pub async fn run(
253        &self,
254        argv: impl IntoIterator<Item = impl Into<String>>,
255        options: RunOptions,
256    ) -> Result<ExecResult, SailError> {
257        self.exec(argv, options.into_exec_options())
258            .await?
259            .wait()
260            .await
261    }
262
263    /// Run a shell command (`/bin/sh -lc`) to completion and return its
264    /// buffered [`ExecResult`], honoring `options.cwd`. Otherwise behaves
265    /// like [`Sailbox::run`].
266    pub async fn run_shell(
267        &self,
268        command: &str,
269        options: RunOptions,
270    ) -> Result<ExecResult, SailError> {
271        self.exec_shell(command, options.into_exec_options())
272            .await?
273            .wait()
274            .await
275    }
276
277    // --- files ---
278
279    /// Filesystem operations on this Sailbox's guest: read and write files
280    /// (buffered or streaming), and directory helpers.
281    pub fn fs(&self) -> SailboxFs<'_> {
282        SailboxFs { sailbox: self }
283    }
284
285    // --- listeners ---
286
287    /// Expose a guest port at runtime. The returned [`Listener`] carries the
288    /// resolved endpoint but an unknown route status: the expose response
289    /// does not report reachability. Confirm with
290    /// [`Sailbox::wait_for_listener`].
291    pub async fn expose(
292        &self,
293        guest_port: u32,
294        protocol: IngressProtocol,
295        allowlist: &[String],
296    ) -> Result<Listener, SailError> {
297        self.client
298            .expose_listener(self.sailbox_id(), guest_port, protocol, allowlist)
299            .await
300    }
301
302    /// Remove a runtime ingress port.
303    pub async fn unexpose(&self, guest_port: u32) -> Result<(), SailError> {
304        self.client
305            .unexpose_listener(self.sailbox_id(), guest_port)
306            .await
307    }
308
309    /// List this Sailbox's listeners without waking it.
310    pub async fn listeners(&self) -> Result<Vec<Listener>, SailError> {
311        self.client.list_listeners(self.sailbox_id()).await
312    }
313
314    /// Fetch one listener by guest port without waking the box.
315    pub async fn listener(&self, guest_port: u32) -> Result<Listener, SailError> {
316        self.client
317            .get_listener(self.sailbox_id(), guest_port)
318            .await
319    }
320
321    /// Block until the listener on `guest_port` is reachable end to end
322    /// (route active and its endpoint accepting) and return it. An HTTP
323    /// listener is probed by URL, so success means the guest server answered;
324    /// a TCP listener is ready once the guest sends bytes or holds the
325    /// connection open. This is a connectivity check, not an application
326    /// health check. Re-checks every second and fails with a timeout error
327    /// after `options.timeout`.
328    pub async fn wait_for_listener(
329        &self,
330        guest_port: u32,
331        options: WaitForListenerOptions,
332    ) -> Result<Listener, SailError> {
333        self.client
334            .wait_for_listener(self.sailbox_id(), guest_port, options.timeout)
335            .await
336    }
337
338    /// Ingress-identity headers for this Sailbox, as name/value pairs.
339    pub async fn ingress_auth_headers(&self) -> Result<Vec<(String, String)>, SailError> {
340        self.client.ingress_auth_headers(self.sailbox_id()).await
341    }
342
343    // --- ssh ---
344
345    /// Make the Sailbox reachable over SSH, returning the endpoint when
346    /// `options.wait` is set (else `None`). Installs the org SSH CA as
347    /// trusted, (re)starts `sshd`, confirms the CA-only daemon owns guest
348    /// port 22, and only then exposes the port as TCP ingress, so a failed
349    /// enable never leaves a non-CA daemon reachable. Idempotent. A non-empty
350    /// `options.allowlist` restricts port 22 to those source CIDRs; when
351    /// empty, a first enable is open to any source and a re-enable keeps an
352    /// existing restriction.
353    pub async fn enable_ssh(
354        &self,
355        options: EnableSshOptions,
356    ) -> Result<Option<SshEndpoint>, SailError> {
357        self.client
358            .enable_ssh(
359                self.sailbox_id(),
360                &options.allowlist,
361                options.wait,
362                options.timeout,
363            )
364            .await
365    }
366}
367
368/// Filesystem operations on a Sailbox's guest, reached via [`Sailbox::fs`]:
369/// buffered and streaming reads and writes, plus directory helpers with
370/// coreutils semantics (`mkdir -p`, `rm -rf`, `test -e`), documented per
371/// method.
372pub struct SailboxFs<'a> {
373    sailbox: &'a Sailbox,
374}
375
376impl std::fmt::Debug for SailboxFs<'_> {
377    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378        f.debug_struct("SailboxFs")
379            .field("sailbox_id", &self.sailbox.sailbox_id())
380            .finish()
381    }
382}
383
384impl SailboxFs<'_> {
385    /// Read a guest file into memory in one call.
386    pub async fn read(&self, path: &str) -> Result<Vec<u8>, SailError> {
387        self.sailbox
388            .client
389            .read_file(self.sailbox.sailbox_id(), path)
390            .await
391    }
392
393    /// Write `data` to a guest file in one call.
394    pub async fn write(
395        &self,
396        path: &str,
397        data: &[u8],
398        options: WriteOptions,
399    ) -> Result<(), SailError> {
400        self.sailbox
401            .client
402            .write_file(self.sailbox.sailbox_id(), path, data, options)
403            .await
404    }
405
406    /// Open a streaming read of a guest file.
407    pub async fn read_stream(&self, path: &str) -> Result<FileReader, SailError> {
408        self.sailbox
409            .client
410            .read_stream(self.sailbox.sailbox_id(), path)
411            .await
412    }
413
414    /// Open a streaming write to a guest file.
415    pub async fn write_stream(
416        &self,
417        path: &str,
418        options: WriteOptions,
419    ) -> Result<FileWriter, SailError> {
420        self.sailbox
421            .client
422            .write_stream(self.sailbox.sailbox_id(), path, options)
423            .await
424    }
425
426    /// Create a directory and any missing parents (like `mkdir -p`); a no-op if
427    /// it already exists.
428    pub async fn mkdir(&self, path: &str) -> Result<(), SailError> {
429        self.sailbox
430            .client
431            .make_dir(self.sailbox.sailbox_id(), path)
432            .await
433    }
434
435    /// Remove a file or directory tree (like `rm -rf`); a no-op if it is already
436    /// absent.
437    pub async fn remove(&self, path: &str) -> Result<(), SailError> {
438        self.sailbox
439            .client
440            .remove_path(self.sailbox.sailbox_id(), path)
441            .await
442    }
443
444    /// Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
445    /// a dangling symlink reports `false` even though [`ls`](Self::ls) lists it.
446    pub async fn exists(&self, path: &str) -> Result<bool, SailError> {
447        self.sailbox
448            .client
449            .path_exists(self.sailbox.sailbox_id(), path)
450            .await
451    }
452
453    /// List a directory's immediate entries as [`DirEntry`] records (no
454    /// recursion). Runs GNU `find` in the guest, which the default Debian image
455    /// ships. A missing path errors, as does a path that is not a directory and
456    /// a listing too large for the exec output cap. An entry whose name is not
457    /// valid UTF-8 fails the listing, since the path API cannot address it.
458    pub async fn ls(&self, path: &str) -> Result<Vec<DirEntry>, SailError> {
459        self.sailbox
460            .client
461            .list_dir(self.sailbox.sailbox_id(), path)
462            .await
463    }
464}
465
466impl Client {
467    /// Bind an existing Sailbox id to this client without a network call,
468    /// giving the method-style surface over it.
469    pub fn sailbox(&self, sailbox_id: impl Into<String>) -> Sailbox {
470        Sailbox::bind(
471            self.clone(),
472            SailboxHandle {
473                sailbox_id: sailbox_id.into(),
474                ..Default::default()
475            },
476        )
477    }
478}