1use std::sync::Arc;
30use std::time::Duration;
31
32use crate::app::{self, App};
33use crate::config::Config;
34use crate::error::{GrpcCode, SailError};
35use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
36use crate::http::HttpCore;
37use crate::imagebuilder::ImageBuilder;
38use crate::sailbox::api::{SailboxApi, UpgradeResult};
39use crate::sailbox::fs::{DirEntry, EntryType};
40use crate::sailbox::object::Sailbox;
41use crate::sailbox::types::{
42 CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
43 SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
44 SailboxSpendResponse, VolumeInfo,
45};
46use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
47
48#[derive(Clone)]
50pub struct Client {
51 inner: Arc<Inner>,
52}
53
54struct Inner {
55 config: Config,
56 sailbox_http: HttpCore,
58 api_http: HttpCore,
60 worker: Arc<WorkerProxy>,
63 imagebuilder: ImageBuilder,
64}
65
66#[derive(Debug, Default, Clone)]
69pub struct ClientBuilder {
70 mode: Option<String>,
71 api_key: Option<String>,
72 api_url: Option<String>,
73 sailbox_api_url: Option<String>,
74 imagebuilder_url: Option<String>,
75 ingress_url: Option<String>,
76}
77
78impl ClientBuilder {
79 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
82 ClientBuilder {
83 api_key: Some(api_key.into()),
84 ..ClientBuilder::default()
85 }
86 }
87
88 #[doc(hidden)]
91 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
92 self.mode = Some(mode.into());
93 self
94 }
95
96 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
98 self.api_url = Some(api_url.into());
99 self
100 }
101
102 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
104 self.sailbox_api_url = Some(url.into());
105 self
106 }
107
108 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
110 self.imagebuilder_url = Some(url.into());
111 self
112 }
113
114 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
117 self.ingress_url = Some(url.into());
118 self
119 }
120
121 pub fn build(self) -> Result<Client, SailError> {
123 let api_key = self.api_key.unwrap_or_default();
124 let config = Config::resolve(
125 self.mode.as_deref(),
126 api_key,
127 self.api_url,
128 self.sailbox_api_url,
129 self.imagebuilder_url,
130 self.ingress_url,
131 )?;
132 Client::from_config(config)
133 }
134}
135
136impl Client {
137 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
139 ClientBuilder::new(api_key)
140 }
141
142 pub fn from_env() -> Result<Client, SailError> {
144 Client::from_config(Config::from_env()?)
145 }
146
147 pub fn from_config(config: Config) -> Result<Client, SailError> {
149 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?;
150 let api_http = HttpCore::new(&config.api_url, &config.api_key)?;
151 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
152 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
153 Ok(Client {
154 inner: Arc::new(Inner {
155 config,
156 sailbox_http,
157 api_http,
158 worker,
159 imagebuilder,
160 }),
161 })
162 }
163
164 pub fn config(&self) -> &Config {
166 &self.inner.config
167 }
168
169 #[doc(hidden)]
171 pub fn worker(&self) -> Arc<WorkerProxy> {
172 Arc::clone(&self.inner.worker)
173 }
174
175 #[doc(hidden)]
177 pub fn imagebuilder(&self) -> &ImageBuilder {
178 &self.inner.imagebuilder
179 }
180
181 #[doc(hidden)]
183 pub fn sailbox_http(&self) -> &HttpCore {
184 &self.inner.sailbox_http
185 }
186
187 #[doc(hidden)]
189 pub fn api_http(&self) -> &HttpCore {
190 &self.inner.api_http
191 }
192
193 fn sailbox_api(&self) -> SailboxApi<'_> {
194 SailboxApi::new(&self.inner.sailbox_http)
195 }
196
197 pub async fn create_sailbox(
209 &self,
210 req: &CreateSailboxRequest,
211 timeout: Option<Duration>,
212 ) -> Result<Sailbox, SailError> {
213 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
214 if !req.ssh {
215 return self.sailbox_api().create(req, timeout).await.map(bind);
216 }
217 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
222 self.org_ssh_ca_public_key().await?;
225 let mut req = req.clone();
230 let ssh_allowlist = req
231 .ingress_ports
232 .iter()
233 .find(|port| port.guest_port == 22)
234 .map(|port| port.allowlist.clone())
235 .unwrap_or_default();
236 req.ingress_ports.retain(|port| port.guest_port != 22);
237 let handle = self.sailbox_api().create(&req, timeout).await?;
238 let handle_id = handle.sailbox_id.clone();
239 if let Err(err) = self
241 .enable_ssh(
242 &handle_id,
243 &ssh_allowlist,
244 false,
245 Duration::ZERO,
246 )
247 .await
248 {
249 return Err(SailError::Creation {
252 message: format!(
253 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
254 id to retry enable_ssh or terminate it."
255 ),
256 status: 0,
257 body: serde_json::Value::Null,
258 });
259 }
260 Ok(bind(handle))
261 }
262
263 #[doc(hidden)]
265 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
266 self.sailbox_api().get(sailbox_id).await
267 }
268
269 pub async fn list_sailboxes(
271 &self,
272 query: &ListSailboxesQuery,
273 ) -> Result<SailboxPage, SailError> {
274 self.sailbox_api().list(query).await
275 }
276
277 pub async fn sailbox_spend(
279 &self,
280 query: &SailboxSpendQuery,
281 ) -> Result<SailboxSpendResponse, SailError> {
282 self.sailbox_api().spend(query).await
283 }
284
285 pub async fn sailbox_metrics(
287 &self,
288 sailbox_id: &str,
289 query: &SailboxMetricsQuery,
290 ) -> Result<SailboxMetricsResponse, SailError> {
291 self.sailbox_api().metrics(sailbox_id, query).await
292 }
293
294 #[doc(hidden)]
296 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
297 self.sailbox_api().terminate(sailbox_id).await
298 }
299
300 #[doc(hidden)]
302 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
303 self.sailbox_api().pause(sailbox_id).await
304 }
305
306 #[doc(hidden)]
308 pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
309 self.sailbox_api().sleep(sailbox_id).await
310 }
311
312 #[doc(hidden)]
314 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
315 self.sailbox_api().resume(sailbox_id).await
316 }
317
318 #[doc(hidden)]
320 pub async fn checkpoint_sailbox(
321 &self,
322 sailbox_id: &str,
323 name: Option<&str>,
324 ttl_seconds: Option<i64>,
325 ) -> Result<SailboxCheckpoint, SailError> {
326 self.sailbox_api()
327 .checkpoint(sailbox_id, name, ttl_seconds)
328 .await
329 }
330
331 pub async fn create_from_checkpoint(
333 &self,
334 checkpoint_id: &str,
335 name: Option<&str>,
336 timeout: Option<Duration>,
337 ) -> Result<Sailbox, SailError> {
338 self.sailbox_api()
339 .from_checkpoint(checkpoint_id, name, timeout.map(|t| t.as_secs() as i64))
340 .await
341 .map(|handle| Sailbox::bind(self.clone(), handle))
342 }
343
344 #[doc(hidden)]
346 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
347 self.sailbox_api().upgrade(sailbox_id).await
348 }
349
350 #[doc(hidden)]
352 pub async fn expose_listener(
353 &self,
354 sailbox_id: &str,
355 guest_port: u32,
356 protocol: crate::sailbox::types::IngressProtocol,
357 allowlist: &[String],
358 ) -> Result<Listener, SailError> {
359 let mut response = self
360 .sailbox_api()
361 .expose(sailbox_id, guest_port, protocol, allowlist)
362 .await?;
363 self.fill_listener_url(sailbox_id, &mut response);
364 Ok(response)
365 }
366
367 #[doc(hidden)]
369 pub async fn unexpose_listener(
370 &self,
371 sailbox_id: &str,
372 guest_port: u32,
373 ) -> Result<(), SailError> {
374 self.sailbox_api().unexpose(sailbox_id, guest_port).await
375 }
376
377 #[doc(hidden)]
379 pub async fn list_listeners(
380 &self,
381 sailbox_id: &str,
382 ) -> Result<Vec<crate::worker::Listener>, SailError> {
383 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
384 for listener in &mut listeners {
385 self.fill_listener_url(sailbox_id, listener);
386 }
387 Ok(listeners)
388 }
389
390 #[doc(hidden)]
393 pub async fn get_listener(
394 &self,
395 sailbox_id: &str,
396 guest_port: u32,
397 ) -> Result<crate::worker::Listener, SailError> {
398 let mut listener = self
399 .sailbox_api()
400 .get_listener(sailbox_id, guest_port)
401 .await?;
402 self.fill_listener_url(sailbox_id, &mut listener);
403 Ok(listener)
404 }
405
406 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
410 if listener.public_url.is_empty()
411 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
412 {
413 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
414 self.config(),
415 sailbox_id,
416 listener.guest_port,
417 );
418 }
419 }
420
421 #[doc(hidden)]
423 pub async fn ingress_auth_headers(
424 &self,
425 sailbox_id: &str,
426 ) -> Result<Vec<(String, String)>, SailError> {
427 self.sailbox_api().ingress_auth_headers(sailbox_id).await
428 }
429
430 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
432 self.sailbox_api().org_ssh_ca_public_key().await
433 }
434
435 pub async fn issue_user_cert(
439 &self,
440 public_key: &str,
441 timeout: Option<Duration>,
442 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
443 self.sailbox_api()
444 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
445 .await
446 }
447
448 pub async fn get_volume(
452 &self,
453 name: &str,
454 mint_if_missing: bool,
455 ) -> Result<VolumeInfo, SailError> {
456 self.sailbox_api().get_volume(name, mint_if_missing).await
457 }
458
459 pub async fn list_volumes(
461 &self,
462 max_objects: Option<i64>,
463 ) -> Result<Vec<VolumeInfo>, SailError> {
464 self.sailbox_api().list_volumes(max_objects).await
465 }
466
467 pub async fn delete_volume(
469 &self,
470 volume_id: &str,
471 allow_missing: bool,
472 ) -> Result<Option<VolumeInfo>, SailError> {
473 self.sailbox_api()
474 .delete_volume(volume_id, allow_missing)
475 .await
476 }
477
478 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
482 app::find_app(&self.inner.api_http, name, mint_if_missing).await
483 }
484
485 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
487 app::list_apps(&self.inner.api_http).await
488 }
489
490 #[doc(hidden)]
500 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
501 let handle = self.resume_sailbox(sailbox_id).await?;
502 if handle.exec_endpoint.is_empty() {
503 return Err(SailError::Internal {
504 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
505 });
506 }
507 Ok(handle.exec_endpoint)
508 }
509
510 #[doc(hidden)]
513 pub async fn exec(
514 &self,
515 sailbox_id: &str,
516 argv: Vec<String>,
517 options: ExecOptions,
518 ) -> Result<ExecProcess, SailError> {
519 if argv.is_empty() {
520 return Err(SailError::InvalidArgument {
521 message: "command must be non-empty".to_string(),
522 });
523 }
524 if options.cwd.is_some() || options.background {
525 return Err(SailError::InvalidArgument {
526 message: "cwd and background require a shell command; use exec_shell".to_string(),
527 });
528 }
529 let env = crate::exec::encode_env(&options.env)?;
532 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
533 let params = ExecParams {
534 sailbox_id: sailbox_id.to_string(),
535 exec_endpoint,
536 argv,
537 timeout_seconds: options
540 .timeout
541 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
542 idempotency_key: options.idempotency_key,
543 open_stdin: options.open_stdin || options.pty,
546 pty: options.pty,
547 term: options.term,
548 cols: options.cols,
549 rows: options.rows,
550 env,
551 retry_timeout: options.retry_timeout.as_secs_f64(),
552 forward_ports: options.forward_ports,
553 forward_browser: options.forward_browser,
554 extra_metadata: Vec::new(),
555 };
556 ExecProcess::start(self.worker(), params).await
557 }
558
559 #[doc(hidden)]
563 pub async fn exec_shell(
564 &self,
565 sailbox_id: &str,
566 command: &str,
567 mut options: ExecOptions,
568 ) -> Result<ExecProcess, SailError> {
569 let argv = crate::exec::shell_argv(command, &options)?;
570 options.cwd = None;
573 options.background = false;
574 self.exec(sailbox_id, argv, options).await
575 }
576
577 #[doc(hidden)]
585 pub async fn read_stream(
586 &self,
587 sailbox_id: &str,
588 remote_path: &str,
589 ) -> Result<FileReader, SailError> {
590 let endpoint = self.exec_endpoint(sailbox_id).await?;
591 Ok(self
592 .inner
593 .worker
594 .read_file(&endpoint, sailbox_id, remote_path))
595 }
596
597 #[doc(hidden)]
601 pub async fn read_file(
602 &self,
603 sailbox_id: &str,
604 remote_path: &str,
605 ) -> Result<Vec<u8>, SailError> {
606 let reader = self.read_stream(sailbox_id, remote_path).await?;
607 let mut contents = Vec::new();
608 while let Some(chunk) = reader.next().await {
609 contents.extend_from_slice(&chunk?);
610 }
611 Ok(contents)
612 }
613
614 #[doc(hidden)]
623 pub async fn write_stream(
624 &self,
625 sailbox_id: &str,
626 remote_path: &str,
627 options: WriteOptions,
628 ) -> Result<FileWriter, SailError> {
629 let endpoint = self.exec_endpoint(sailbox_id).await?;
630 Ok(self.inner.worker.write_file(
631 &endpoint,
632 sailbox_id,
633 remote_path,
634 options.create_parents,
635 options.mode,
636 ))
637 }
638
639 #[doc(hidden)]
643 pub async fn write_file(
644 &self,
645 sailbox_id: &str,
646 remote_path: &str,
647 data: &[u8],
648 options: WriteOptions,
649 ) -> Result<(), SailError> {
650 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
651 writer.write(data).await?;
652 writer.finish().await
653 }
654
655 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
664 self.exec(sailbox_id, argv, ExecOptions::default())
665 .await?
666 .wait()
667 .await
668 }
669
670 #[doc(hidden)]
673 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
674 crate::sailbox::fs::require_path(path)?;
675 let result = self
676 .run_argv(
677 sailbox_id,
678 vec![
679 "mkdir".to_string(),
680 "-p".to_string(),
681 "--".to_string(),
682 path.to_string(),
683 ],
684 )
685 .await?;
686 fs_command_ok(&result, &format!("create directory {path}"))
687 }
688
689 #[doc(hidden)]
692 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
693 crate::sailbox::fs::require_path(path)?;
694 let result = self
695 .run_argv(
696 sailbox_id,
697 vec![
698 "rm".to_string(),
699 "-rf".to_string(),
700 "--".to_string(),
701 path.to_string(),
702 ],
703 )
704 .await?;
705 fs_command_ok(&result, &format!("remove {path}"))
706 }
707
708 #[doc(hidden)]
711 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
712 crate::sailbox::fs::require_path(path)?;
713 let result = self
714 .run_argv(
715 sailbox_id,
716 vec!["test".to_string(), "-e".to_string(), path.to_string()],
717 )
718 .await?;
719 match result.exit_code {
723 0 => Ok(true),
724 1 => Ok(false),
725 _ => Err(fs_command_error(
726 &result,
727 &format!("check whether {path} exists"),
728 )),
729 }
730 }
731
732 #[doc(hidden)]
736 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
737 crate::sailbox::fs::require_path(path)?;
738 let process = self
739 .exec(
740 sailbox_id,
741 crate::sailbox::fs::list_dir_argv(path),
742 ExecOptions::default(),
743 )
744 .await?;
745 let result = process.wait().await?;
746 fs_command_ok(&result, &format!("list directory {path}"))?;
747 if result.stdout_truncated {
750 return Err(SailError::Execution {
751 code: GrpcCode::FailedPrecondition,
752 detail: format!(
753 "directory listing for {path} was truncated because it has \
754 too many entries; list a smaller subtree"
755 ),
756 });
757 }
758 if !result.stdout_complete {
764 return Err(SailError::Execution {
765 code: GrpcCode::FailedPrecondition,
766 detail: format!(
767 "directory listing for {path} was interrupted before it \
768 finished streaming; retry the call"
769 ),
770 });
771 }
772 let mut entries =
773 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
774 .map_err(|detail| SailError::Execution {
775 code: GrpcCode::FailedPrecondition,
776 detail: format!("directory listing for {path} could not be used: {detail}"),
777 })?;
778 if entries.is_empty() {
781 return Err(SailError::Execution {
782 code: GrpcCode::FailedPrecondition,
783 detail: format!(
784 "directory listing for {path} produced no records; \
785 listing requires GNU find in the guest"
786 ),
787 });
788 }
789 let start = entries.remove(0);
790 if start.entry_type != EntryType::Directory {
791 return Err(SailError::Execution {
792 code: GrpcCode::FailedPrecondition,
793 detail: format!(
794 "{path} is not a directory (it is a {})",
795 start.entry_type.as_str()
796 ),
797 });
798 }
799 Ok(entries)
800 }
801}
802
803fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
805 if result.exit_code != 0 {
806 return Err(fs_command_error(result, action));
807 }
808 Ok(())
809}
810
811fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
814 let stderr = result.stderr.trim();
815 let suffix = if stderr.is_empty() {
816 String::new()
817 } else {
818 format!(": {stderr}")
819 };
820 SailError::Execution {
821 code: GrpcCode::FailedPrecondition,
822 detail: format!(
823 "failed to {action} (exit code {}){suffix}",
824 result.exit_code
825 ),
826 }
827}