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}