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 image_ready: crate::imagecache::ImageReadyCache,
67}
68
69#[derive(Debug, Default, Clone)]
72pub struct ClientBuilder {
73 mode: Option<String>,
74 api_key: Option<String>,
75 api_url: Option<String>,
76 sailbox_api_url: Option<String>,
77 imagebuilder_url: Option<String>,
78 ingress_url: Option<String>,
79}
80
81impl ClientBuilder {
82 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
85 ClientBuilder {
86 api_key: Some(api_key.into()),
87 ..ClientBuilder::default()
88 }
89 }
90
91 #[doc(hidden)]
94 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
95 self.mode = Some(mode.into());
96 self
97 }
98
99 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
101 self.api_url = Some(api_url.into());
102 self
103 }
104
105 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
107 self.sailbox_api_url = Some(url.into());
108 self
109 }
110
111 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
113 self.imagebuilder_url = Some(url.into());
114 self
115 }
116
117 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
120 self.ingress_url = Some(url.into());
121 self
122 }
123
124 pub fn build(self) -> Result<Client, SailError> {
126 let api_key = self.api_key.unwrap_or_default();
127 let config = Config::resolve(
128 self.mode.as_deref(),
129 api_key,
130 self.api_url,
131 self.sailbox_api_url,
132 self.imagebuilder_url,
133 self.ingress_url,
134 )?;
135 Client::from_config(config)
136 }
137}
138
139const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
143
144fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
148 matches!(
149 result,
150 Err(SailError::Creation {
151 status: 409,
152 message,
153 ..
154 }) if message.starts_with("resolve image:")
155 )
156}
157
158impl Client {
159 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
161 ClientBuilder::new(api_key)
162 }
163
164 pub fn from_env() -> Result<Client, SailError> {
166 Client::from_config(Config::from_env()?)
167 }
168
169 pub fn from_config(config: Config) -> Result<Client, SailError> {
171 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?;
172 let api_http = HttpCore::new(&config.api_url, &config.api_key)?;
173 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
174 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
175 Ok(Client {
176 inner: Arc::new(Inner {
177 config,
178 sailbox_http,
179 api_http,
180 worker,
181 imagebuilder,
182 image_ready: crate::imagecache::ImageReadyCache::new(),
183 }),
184 })
185 }
186
187 pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
188 &self.inner.image_ready
189 }
190
191 #[cfg(any(test, feature = "test-fakes"))]
195 pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
196 self.inner.image_ready.set_refresh_window(window);
197 }
198
199 pub fn config(&self) -> &Config {
201 &self.inner.config
202 }
203
204 #[doc(hidden)]
206 pub fn worker(&self) -> Arc<WorkerProxy> {
207 Arc::clone(&self.inner.worker)
208 }
209
210 #[doc(hidden)]
212 pub fn imagebuilder(&self) -> &ImageBuilder {
213 &self.inner.imagebuilder
214 }
215
216 #[doc(hidden)]
218 pub fn sailbox_http(&self) -> &HttpCore {
219 &self.inner.sailbox_http
220 }
221
222 #[doc(hidden)]
224 pub fn api_http(&self) -> &HttpCore {
225 &self.inner.api_http
226 }
227
228 fn sailbox_api(&self) -> SailboxApi<'_> {
229 SailboxApi::new(&self.inner.sailbox_http)
230 }
231
232 async fn create_with_image_revalidation(
240 &self,
241 req: &CreateSailboxRequest,
242 timeout: Option<Duration>,
243 ) -> Result<SailboxHandle, SailError> {
244 let create_started = std::time::Instant::now();
245 let result = self.sailbox_api().create(req, timeout).await;
246 let custom_image = req.image != crate::image::ImageSpec::default()
247 && !crate::imagebuild::is_builtin_base_spec(&req.image);
248 if !custom_image || !image_not_ready_conflict(&result) {
249 return result;
250 }
251 if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
252 self.image_ready_cache()
257 .invalidate_spec_started_before(&spec_hash, create_started);
258 }
259 let rebuild_timeout = req
263 .image_build_timeout
264 .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
265 let rebuild =
266 self.build_spec_ready_cached(&req.image, rebuild_timeout, true);
267 tokio::time::timeout(rebuild_timeout, rebuild)
268 .await
269 .unwrap_or_else(|_| {
270 Err(SailError::Transport {
271 kind: crate::error::TransportKind::Timeout,
272 message: "timed out building the image".to_string(),
273 source: None,
274 })
275 })?;
276 self.sailbox_api().create(req, timeout).await
277 }
278
279 pub async fn create_sailbox(
291 &self,
292 req: &CreateSailboxRequest,
293 timeout: Option<Duration>,
294 ) -> Result<Sailbox, SailError> {
295 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
296 if !req.ssh {
297 return self
298 .create_with_image_revalidation(req, timeout)
299 .await
300 .map(bind);
301 }
302 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
307 self.org_ssh_ca_public_key().await?;
310 let mut req = req.clone();
315 let ssh_allowlist = req
316 .ingress_ports
317 .iter()
318 .find(|port| port.guest_port == 22)
319 .map(|port| port.allowlist.clone())
320 .unwrap_or_default();
321 req.ingress_ports.retain(|port| port.guest_port != 22);
322 let handle = self.create_with_image_revalidation(&req, timeout).await?;
323 let handle_id = handle.sailbox_id.clone();
324 if let Err(err) = self
326 .enable_ssh(
327 &handle_id,
328 &ssh_allowlist,
329 false,
330 Duration::ZERO,
331 )
332 .await
333 {
334 return Err(SailError::Creation {
337 message: format!(
338 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
339 id to retry enable_ssh or terminate it."
340 ),
341 status: 0,
342 body: serde_json::Value::Null,
343 });
344 }
345 Ok(bind(handle))
346 }
347
348 #[doc(hidden)]
350 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
351 self.sailbox_api().get(sailbox_id).await
352 }
353
354 pub async fn list_sailboxes(
356 &self,
357 query: &ListSailboxesQuery,
358 ) -> Result<SailboxPage, SailError> {
359 self.sailbox_api().list(query).await
360 }
361
362 pub async fn sailbox_spend(
364 &self,
365 query: &SailboxSpendQuery,
366 ) -> Result<SailboxSpendResponse, SailError> {
367 self.sailbox_api().spend(query).await
368 }
369
370 pub async fn sailbox_metrics(
372 &self,
373 sailbox_id: &str,
374 query: &SailboxMetricsQuery,
375 ) -> Result<SailboxMetricsResponse, SailError> {
376 self.sailbox_api().metrics(sailbox_id, query).await
377 }
378
379 #[doc(hidden)]
381 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
382 self.sailbox_api().terminate(sailbox_id).await
383 }
384
385 #[doc(hidden)]
387 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
388 self.sailbox_api().pause(sailbox_id).await
389 }
390
391 #[doc(hidden)]
393 pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
394 self.sailbox_api().sleep(sailbox_id).await
395 }
396
397 #[doc(hidden)]
399 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
400 self.sailbox_api().resume(sailbox_id).await
401 }
402
403 #[doc(hidden)]
405 pub async fn checkpoint_sailbox(
406 &self,
407 sailbox_id: &str,
408 name: Option<&str>,
409 ttl_seconds: Option<i64>,
410 ) -> Result<SailboxCheckpoint, SailError> {
411 self.sailbox_api()
412 .checkpoint(sailbox_id, name, ttl_seconds)
413 .await
414 }
415
416 pub async fn create_from_checkpoint(
418 &self,
419 checkpoint_id: &str,
420 name: Option<&str>,
421 timeout: Option<Duration>,
422 ) -> Result<Sailbox, SailError> {
423 self.sailbox_api()
424 .from_checkpoint(checkpoint_id, name, timeout.map(|t| t.as_secs() as i64))
425 .await
426 .map(|handle| Sailbox::bind(self.clone(), handle))
427 }
428
429 #[doc(hidden)]
431 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
432 self.sailbox_api().upgrade(sailbox_id).await
433 }
434
435 #[doc(hidden)]
437 pub async fn expose_listener(
438 &self,
439 sailbox_id: &str,
440 guest_port: u32,
441 protocol: crate::sailbox::types::IngressProtocol,
442 allowlist: &[String],
443 ) -> Result<Listener, SailError> {
444 let mut response = self
445 .sailbox_api()
446 .expose(sailbox_id, guest_port, protocol, allowlist)
447 .await?;
448 self.fill_listener_url(sailbox_id, &mut response);
449 Ok(response)
450 }
451
452 #[doc(hidden)]
454 pub async fn unexpose_listener(
455 &self,
456 sailbox_id: &str,
457 guest_port: u32,
458 ) -> Result<(), SailError> {
459 self.sailbox_api().unexpose(sailbox_id, guest_port).await
460 }
461
462 #[doc(hidden)]
464 pub async fn list_listeners(
465 &self,
466 sailbox_id: &str,
467 ) -> Result<Vec<crate::worker::Listener>, SailError> {
468 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
469 for listener in &mut listeners {
470 self.fill_listener_url(sailbox_id, listener);
471 }
472 Ok(listeners)
473 }
474
475 #[doc(hidden)]
478 pub async fn get_listener(
479 &self,
480 sailbox_id: &str,
481 guest_port: u32,
482 ) -> Result<crate::worker::Listener, SailError> {
483 let mut listener = self
484 .sailbox_api()
485 .get_listener(sailbox_id, guest_port)
486 .await?;
487 self.fill_listener_url(sailbox_id, &mut listener);
488 Ok(listener)
489 }
490
491 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
495 if listener.public_url.is_empty()
496 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
497 {
498 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
499 self.config(),
500 sailbox_id,
501 listener.guest_port,
502 );
503 }
504 }
505
506 #[doc(hidden)]
508 pub async fn ingress_auth_headers(
509 &self,
510 sailbox_id: &str,
511 ) -> Result<Vec<(String, String)>, SailError> {
512 self.sailbox_api().ingress_auth_headers(sailbox_id).await
513 }
514
515 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
517 self.sailbox_api().org_ssh_ca_public_key().await
518 }
519
520 pub async fn issue_user_cert(
524 &self,
525 public_key: &str,
526 timeout: Option<Duration>,
527 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
528 self.sailbox_api()
529 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
530 .await
531 }
532
533 pub async fn get_volume(
537 &self,
538 name: &str,
539 mint_if_missing: bool,
540 ) -> Result<VolumeInfo, SailError> {
541 self.sailbox_api().get_volume(name, mint_if_missing).await
542 }
543
544 pub async fn list_volumes(
546 &self,
547 max_objects: Option<i64>,
548 ) -> Result<Vec<VolumeInfo>, SailError> {
549 self.sailbox_api().list_volumes(max_objects).await
550 }
551
552 pub async fn delete_volume(
554 &self,
555 volume_id: &str,
556 allow_missing: bool,
557 ) -> Result<Option<VolumeInfo>, SailError> {
558 self.sailbox_api()
559 .delete_volume(volume_id, allow_missing)
560 .await
561 }
562
563 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
567 app::find_app(&self.inner.api_http, name, mint_if_missing).await
568 }
569
570 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
572 app::list_apps(&self.inner.api_http).await
573 }
574
575 #[doc(hidden)]
585 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
586 let handle = self.resume_sailbox(sailbox_id).await?;
587 if handle.exec_endpoint.is_empty() {
588 return Err(SailError::Internal {
589 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
590 });
591 }
592 Ok(handle.exec_endpoint)
593 }
594
595 #[doc(hidden)]
598 pub async fn exec(
599 &self,
600 sailbox_id: &str,
601 argv: Vec<String>,
602 options: ExecOptions,
603 ) -> Result<ExecProcess, SailError> {
604 if argv.is_empty() {
605 return Err(SailError::InvalidArgument {
606 message: "command must be non-empty".to_string(),
607 });
608 }
609 if options.cwd.is_some() || options.background {
610 return Err(SailError::InvalidArgument {
611 message: "cwd and background require a shell command; use exec_shell".to_string(),
612 });
613 }
614 let env = crate::exec::encode_env(&options.env)?;
617 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
618 let params = ExecParams {
619 sailbox_id: sailbox_id.to_string(),
620 exec_endpoint,
621 argv,
622 timeout_seconds: options
625 .timeout
626 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
627 idempotency_key: options.idempotency_key,
628 open_stdin: options.open_stdin || options.pty,
631 pty: options.pty,
632 term: options.term,
633 cols: options.cols,
634 rows: options.rows,
635 env,
636 retry_timeout: options.retry_timeout.as_secs_f64(),
637 forward_ports: options.forward_ports,
638 forward_browser: options.forward_browser,
639 extra_metadata: Vec::new(),
640 };
641 ExecProcess::start(self.worker(), params).await
642 }
643
644 #[doc(hidden)]
648 pub async fn exec_shell(
649 &self,
650 sailbox_id: &str,
651 command: &str,
652 mut options: ExecOptions,
653 ) -> Result<ExecProcess, SailError> {
654 let argv = crate::exec::shell_argv(command, &options)?;
655 options.cwd = None;
658 options.background = false;
659 self.exec(sailbox_id, argv, options).await
660 }
661
662 #[doc(hidden)]
670 pub async fn read_stream(
671 &self,
672 sailbox_id: &str,
673 remote_path: &str,
674 ) -> Result<FileReader, SailError> {
675 let endpoint = self.exec_endpoint(sailbox_id).await?;
676 Ok(self
677 .inner
678 .worker
679 .read_file(&endpoint, sailbox_id, remote_path))
680 }
681
682 #[doc(hidden)]
686 pub async fn read_file(
687 &self,
688 sailbox_id: &str,
689 remote_path: &str,
690 ) -> Result<Vec<u8>, SailError> {
691 let reader = self.read_stream(sailbox_id, remote_path).await?;
692 let mut contents = Vec::new();
693 while let Some(chunk) = reader.next().await {
694 contents.extend_from_slice(&chunk?);
695 }
696 Ok(contents)
697 }
698
699 #[doc(hidden)]
708 pub async fn write_stream(
709 &self,
710 sailbox_id: &str,
711 remote_path: &str,
712 options: WriteOptions,
713 ) -> Result<FileWriter, SailError> {
714 let endpoint = self.exec_endpoint(sailbox_id).await?;
715 Ok(self.inner.worker.write_file(
716 &endpoint,
717 sailbox_id,
718 remote_path,
719 options.create_parents,
720 options.mode,
721 ))
722 }
723
724 #[doc(hidden)]
728 pub async fn write_file(
729 &self,
730 sailbox_id: &str,
731 remote_path: &str,
732 data: &[u8],
733 options: WriteOptions,
734 ) -> Result<(), SailError> {
735 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
736 writer.write(data).await?;
737 writer.finish().await
738 }
739
740 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
749 self.exec(sailbox_id, argv, ExecOptions::default())
750 .await?
751 .wait()
752 .await
753 }
754
755 #[doc(hidden)]
758 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
759 crate::sailbox::fs::require_path(path)?;
760 let result = self
761 .run_argv(
762 sailbox_id,
763 vec![
764 "mkdir".to_string(),
765 "-p".to_string(),
766 "--".to_string(),
767 path.to_string(),
768 ],
769 )
770 .await?;
771 fs_command_ok(&result, &format!("create directory {path}"))
772 }
773
774 #[doc(hidden)]
777 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
778 crate::sailbox::fs::require_path(path)?;
779 let result = self
780 .run_argv(
781 sailbox_id,
782 vec![
783 "rm".to_string(),
784 "-rf".to_string(),
785 "--".to_string(),
786 path.to_string(),
787 ],
788 )
789 .await?;
790 fs_command_ok(&result, &format!("remove {path}"))
791 }
792
793 #[doc(hidden)]
796 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
797 crate::sailbox::fs::require_path(path)?;
798 let result = self
799 .run_argv(
800 sailbox_id,
801 vec!["test".to_string(), "-e".to_string(), path.to_string()],
802 )
803 .await?;
804 match result.exit_code {
808 0 => Ok(true),
809 1 => Ok(false),
810 _ => Err(fs_command_error(
811 &result,
812 &format!("check whether {path} exists"),
813 )),
814 }
815 }
816
817 #[doc(hidden)]
821 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
822 crate::sailbox::fs::require_path(path)?;
823 let process = self
824 .exec(
825 sailbox_id,
826 crate::sailbox::fs::list_dir_argv(path),
827 ExecOptions::default(),
828 )
829 .await?;
830 let result = process.wait().await?;
831 fs_command_ok(&result, &format!("list directory {path}"))?;
832 if result.stdout_truncated {
835 return Err(SailError::Execution {
836 code: GrpcCode::FailedPrecondition,
837 detail: format!(
838 "directory listing for {path} was truncated because it has \
839 too many entries; list a smaller subtree"
840 ),
841 });
842 }
843 if !result.stdout_complete {
849 return Err(SailError::Execution {
850 code: GrpcCode::FailedPrecondition,
851 detail: format!(
852 "directory listing for {path} was interrupted before it \
853 finished streaming; retry the call"
854 ),
855 });
856 }
857 let mut entries =
858 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
859 .map_err(|detail| SailError::Execution {
860 code: GrpcCode::FailedPrecondition,
861 detail: format!("directory listing for {path} could not be used: {detail}"),
862 })?;
863 if entries.is_empty() {
866 return Err(SailError::Execution {
867 code: GrpcCode::FailedPrecondition,
868 detail: format!(
869 "directory listing for {path} produced no records; \
870 listing requires GNU find in the guest"
871 ),
872 });
873 }
874 let start = entries.remove(0);
875 if start.entry_type != EntryType::Directory {
876 return Err(SailError::Execution {
877 code: GrpcCode::FailedPrecondition,
878 detail: format!(
879 "{path} is not a directory (it is a {})",
880 start.entry_type.as_str()
881 ),
882 });
883 }
884 Ok(entries)
885 }
886}
887
888fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
890 if result.exit_code != 0 {
891 return Err(fs_command_error(result, action));
892 }
893 Ok(())
894}
895
896fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
899 let stderr = result.stderr.trim();
900 let suffix = if stderr.is_empty() {
901 String::new()
902 } else {
903 format!(": {stderr}")
904 };
905 SailError::Execution {
906 code: GrpcCode::FailedPrecondition,
907 detail: format!(
908 "failed to {action} (exit code {}){suffix}",
909 result.exit_code
910 ),
911 }
912}