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 std::time::Duration;
10
11use crate::client::Client;
12use crate::error::SailError;
13use crate::exec::{ExecOptions, ExecProcess};
14use crate::sailbox::api::UpgradeResult;
15use crate::sailbox::ssh::{EnableSshOptions, SshEndpoint};
16use crate::sailbox::types::{
17 CheckpointOptions, IngressProtocol, SailboxCheckpoint, SailboxHandle, SailboxInfo,
18};
19use crate::worker::{FileReader, FileWriter, Listener, WriteOptions};
20
21/// A sailbox bound to the client that reaches it. Obtained from
22/// [`Client::create_sailbox`], [`Client::create_from_checkpoint`], or
23/// [`Client::sailbox`] (which binds an existing id without a network call).
24///
25/// Cheap to clone; clones share the underlying client transport.
26///
27/// ```no_run
28/// # async fn demo() -> Result<(), sail::error::SailError> {
29/// # let client = sail::Client::from_env()?;
30/// let sb = client.sailbox("sb_abc123");
31/// let result = sb.exec_shell("echo hello", Default::default()).await?.wait().await?;
32/// sb.write("/workspace/input.txt", b"hello\n", Default::default()).await?;
33/// sb.terminate().await?;
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Clone)]
38pub struct Sailbox {
39 client: Client,
40 handle: SailboxHandle,
41}
42
43impl std::fmt::Debug for Sailbox {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.debug_struct("Sailbox")
46 .field("sailbox_id", &self.handle.sailbox_id)
47 .finish_non_exhaustive()
48 }
49}
50
51impl Sailbox {
52 pub(crate) fn bind(client: Client, handle: SailboxHandle) -> Sailbox {
53 Sailbox { client, handle }
54 }
55
56 pub(crate) fn client(&self) -> &Client {
57 &self.client
58 }
59
60 /// The sailbox's stable identifier.
61 pub fn sailbox_id(&self) -> &str {
62 &self.handle.sailbox_id
63 }
64
65 /// The data snapshot from the call that produced this object (create,
66 /// from-checkpoint). A sailbox bound by id via [`Client::sailbox`] carries
67 /// only the id; use [`Sailbox::info`] for fresh state either way.
68 pub fn handle(&self) -> &SailboxHandle {
69 &self.handle
70 }
71
72 /// Consume the object, keeping just the data snapshot.
73 pub fn into_handle(self) -> SailboxHandle {
74 self.handle
75 }
76
77 /// Fetch this sailbox's current state.
78 pub async fn info(&self) -> Result<SailboxInfo, SailError> {
79 self.client.get_sailbox(self.sailbox_id()).await
80 }
81
82 // --- lifecycle ---
83
84 /// Terminate the sailbox (idempotent).
85 pub async fn terminate(&self) -> Result<(), SailError> {
86 self.client.terminate_sailbox(self.sailbox_id()).await
87 }
88
89 /// Pause the sailbox in memory.
90 pub async fn pause(&self) -> Result<(), SailError> {
91 self.client.pause_sailbox(self.sailbox_id()).await
92 }
93
94 /// Sleep the sailbox to disk (it wakes on traffic).
95 pub async fn sleep(&self) -> Result<(), SailError> {
96 self.client.sleep_sailbox(self.sailbox_id()).await
97 }
98
99 /// Resume a paused or sleeping sailbox.
100 pub async fn resume(&self) -> Result<(), SailError> {
101 self.client
102 .resume_sailbox(self.sailbox_id())
103 .await
104 .map(|_| ())
105 }
106
107 /// Checkpoint the sailbox. `options.name` labels the handle;
108 /// `options.ttl`, when given, must be positive and overrides the server's
109 /// default retention.
110 pub async fn checkpoint(
111 &self,
112 options: CheckpointOptions,
113 ) -> Result<SailboxCheckpoint, SailError> {
114 self.client
115 .checkpoint_sailbox(
116 self.sailbox_id(),
117 options.name.as_deref(),
118 options.ttl.map(|ttl| ttl.as_secs() as i64),
119 )
120 .await
121 }
122
123 /// Upgrade the in-guest agent (now if running, else at next wake).
124 pub async fn upgrade(&self) -> Result<UpgradeResult, SailError> {
125 self.client.upgrade_sailbox(self.sailbox_id()).await
126 }
127
128 // --- exec ---
129
130 /// Run a command from an argv vector (no shell interpretation) and return
131 /// a handle to the live process. Resumes (wakes) the sailbox to reach it.
132 /// The returned [`ExecProcess`] streams output, accepts stdin, and
133 /// resolves the exit status; dropping it detaches without killing the
134 /// command. The output pump spawns on the calling task's tokio runtime.
135 pub async fn exec(
136 &self,
137 argv: Vec<String>,
138 options: ExecOptions,
139 ) -> Result<ExecProcess, SailError> {
140 self.client.exec(self.sailbox_id(), argv, options).await
141 }
142
143 /// Run a shell command via `/bin/sh -lc` (pipes, globs, and `$VAR`
144 /// expansion work), honoring the `cwd`/`background` options. Use
145 /// [`Sailbox::exec`] with an argv vector when arguments must reach the
146 /// command verbatim. Otherwise behaves like [`Sailbox::exec`].
147 pub async fn exec_shell(
148 &self,
149 command: &str,
150 options: ExecOptions,
151 ) -> Result<ExecProcess, SailError> {
152 self.client
153 .exec_shell(self.sailbox_id(), command, options)
154 .await
155 }
156
157 // --- files ---
158
159 /// Read a guest file into memory in one call.
160 pub async fn read(&self, path: &str) -> Result<Vec<u8>, SailError> {
161 self.client.read_file(self.sailbox_id(), path).await
162 }
163
164 /// Write `data` to a guest file in one call.
165 pub async fn write(
166 &self,
167 path: &str,
168 data: &[u8],
169 options: WriteOptions,
170 ) -> Result<(), SailError> {
171 self.client
172 .write_file(self.sailbox_id(), path, data, options)
173 .await
174 }
175
176 /// Open a streaming read of a guest file.
177 pub async fn read_stream(&self, path: &str) -> Result<FileReader, SailError> {
178 self.client.read_stream(self.sailbox_id(), path).await
179 }
180
181 /// Open a streaming write to a guest file.
182 pub async fn write_stream(
183 &self,
184 path: &str,
185 options: WriteOptions,
186 ) -> Result<FileWriter, SailError> {
187 self.client
188 .write_stream(self.sailbox_id(), path, options)
189 .await
190 }
191
192 // --- listeners ---
193
194 /// Expose a guest port at runtime. The returned [`Listener`] carries the
195 /// resolved endpoint but an unspecified route status: the expose response
196 /// does not report reachability. Confirm with
197 /// [`Sailbox::wait_for_listener`].
198 pub async fn expose(
199 &self,
200 guest_port: u32,
201 protocol: IngressProtocol,
202 allowlist: &[String],
203 ) -> Result<Listener, SailError> {
204 self.client
205 .expose_listener(self.sailbox_id(), guest_port, protocol, allowlist)
206 .await
207 }
208
209 /// Remove a runtime ingress port.
210 pub async fn unexpose(&self, guest_port: u32) -> Result<(), SailError> {
211 self.client
212 .unexpose_listener(self.sailbox_id(), guest_port)
213 .await
214 }
215
216 /// List this sailbox's listeners without waking it.
217 pub async fn listeners(&self) -> Result<Vec<Listener>, SailError> {
218 self.client.list_listeners(self.sailbox_id()).await
219 }
220
221 /// Fetch one listener by guest port without waking the box.
222 pub async fn listener(&self, guest_port: u32) -> Result<Listener, SailError> {
223 self.client
224 .get_listener(self.sailbox_id(), guest_port)
225 .await
226 }
227
228 /// Block until the listener on `guest_port` is reachable end to end
229 /// (route active and its endpoint accepting) and return it. An HTTP
230 /// listener is probed by URL, so success means the guest server answered;
231 /// a TCP listener is ready once the guest sends bytes or holds the
232 /// connection open. This is a connectivity check, not an application
233 /// health check. Polls every `poll_interval` (1s is a good default) and
234 /// fails with a Timeout error after `timeout` (60s is a good default; an
235 /// unrepresentably large value waits indefinitely).
236 pub async fn wait_for_listener(
237 &self,
238 guest_port: u32,
239 timeout: Duration,
240 poll_interval: Duration,
241 ) -> Result<Listener, SailError> {
242 self.client
243 .wait_for_listener(self.sailbox_id(), guest_port, timeout, poll_interval)
244 .await
245 }
246
247 /// Ingress-identity headers for this sailbox, as name/value pairs.
248 pub async fn ingress_auth_headers(&self) -> Result<Vec<(String, String)>, SailError> {
249 self.client.ingress_auth_headers(self.sailbox_id()).await
250 }
251
252 // --- ssh ---
253
254 /// Make the sailbox reachable over SSH, returning the endpoint when
255 /// `options.wait` is set (else `None`). Installs the org SSH CA as
256 /// trusted, (re)starts `sshd`, confirms the CA-only daemon owns guest
257 /// port 22, and only then exposes the port as TCP ingress, so a failed
258 /// enable never leaves a non-CA daemon reachable. Idempotent. A non-empty
259 /// `options.allowlist` restricts port 22 to those source CIDRs; when
260 /// empty, a first enable is open to any source and a re-enable keeps an
261 /// existing restriction.
262 pub async fn enable_ssh(
263 &self,
264 options: EnableSshOptions,
265 ) -> Result<Option<SshEndpoint>, SailError> {
266 self.client
267 .enable_ssh(
268 self.sailbox_id(),
269 &options.allowlist,
270 options.wait,
271 options.timeout,
272 )
273 .await
274 }
275}
276
277impl Client {
278 /// Bind an existing sailbox id to this client without a network call,
279 /// giving the method-style surface over it.
280 pub fn sailbox(&self, sailbox_id: impl Into<String>) -> Sailbox {
281 Sailbox {
282 client: self.clone(),
283 handle: SailboxHandle {
284 sailbox_id: sailbox_id.into(),
285 ..Default::default()
286 },
287 }
288 }
289}