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}