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 extra_metadata: Vec::new(),
553 };
554 ExecProcess::start(self.worker(), params).await
555 }
556
557 #[doc(hidden)]
561 pub async fn exec_shell(
562 &self,
563 sailbox_id: &str,
564 command: &str,
565 mut options: ExecOptions,
566 ) -> Result<ExecProcess, SailError> {
567 let argv = crate::exec::shell_argv(command, &options)?;
568 options.cwd = None;
571 options.background = false;
572 self.exec(sailbox_id, argv, options).await
573 }
574
575 #[doc(hidden)]
583 pub async fn read_stream(
584 &self,
585 sailbox_id: &str,
586 remote_path: &str,
587 ) -> Result<FileReader, SailError> {
588 let endpoint = self.exec_endpoint(sailbox_id).await?;
589 Ok(self
590 .inner
591 .worker
592 .read_file(&endpoint, sailbox_id, remote_path))
593 }
594
595 #[doc(hidden)]
599 pub async fn read_file(
600 &self,
601 sailbox_id: &str,
602 remote_path: &str,
603 ) -> Result<Vec<u8>, SailError> {
604 let reader = self.read_stream(sailbox_id, remote_path).await?;
605 let mut contents = Vec::new();
606 while let Some(chunk) = reader.next().await {
607 contents.extend_from_slice(&chunk?);
608 }
609 Ok(contents)
610 }
611
612 #[doc(hidden)]
621 pub async fn write_stream(
622 &self,
623 sailbox_id: &str,
624 remote_path: &str,
625 options: WriteOptions,
626 ) -> Result<FileWriter, SailError> {
627 let endpoint = self.exec_endpoint(sailbox_id).await?;
628 Ok(self.inner.worker.write_file(
629 &endpoint,
630 sailbox_id,
631 remote_path,
632 options.create_parents,
633 options.mode,
634 ))
635 }
636
637 #[doc(hidden)]
641 pub async fn write_file(
642 &self,
643 sailbox_id: &str,
644 remote_path: &str,
645 data: &[u8],
646 options: WriteOptions,
647 ) -> Result<(), SailError> {
648 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
649 writer.write(data).await?;
650 writer.finish().await
651 }
652
653 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
662 self.exec(sailbox_id, argv, ExecOptions::default())
663 .await?
664 .wait()
665 .await
666 }
667
668 #[doc(hidden)]
671 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
672 crate::sailbox::fs::require_path(path)?;
673 let result = self
674 .run_argv(
675 sailbox_id,
676 vec![
677 "mkdir".to_string(),
678 "-p".to_string(),
679 "--".to_string(),
680 path.to_string(),
681 ],
682 )
683 .await?;
684 fs_command_ok(&result, &format!("create directory {path}"))
685 }
686
687 #[doc(hidden)]
690 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
691 crate::sailbox::fs::require_path(path)?;
692 let result = self
693 .run_argv(
694 sailbox_id,
695 vec![
696 "rm".to_string(),
697 "-rf".to_string(),
698 "--".to_string(),
699 path.to_string(),
700 ],
701 )
702 .await?;
703 fs_command_ok(&result, &format!("remove {path}"))
704 }
705
706 #[doc(hidden)]
709 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
710 crate::sailbox::fs::require_path(path)?;
711 let result = self
712 .run_argv(
713 sailbox_id,
714 vec!["test".to_string(), "-e".to_string(), path.to_string()],
715 )
716 .await?;
717 match result.exit_code {
721 0 => Ok(true),
722 1 => Ok(false),
723 _ => Err(fs_command_error(
724 &result,
725 &format!("check whether {path} exists"),
726 )),
727 }
728 }
729
730 #[doc(hidden)]
734 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
735 crate::sailbox::fs::require_path(path)?;
736 let process = self
737 .exec(
738 sailbox_id,
739 crate::sailbox::fs::list_dir_argv(path),
740 ExecOptions::default(),
741 )
742 .await?;
743 let result = process.wait().await?;
744 fs_command_ok(&result, &format!("list directory {path}"))?;
745 if result.stdout_truncated {
748 return Err(SailError::Execution {
749 code: GrpcCode::FailedPrecondition,
750 detail: format!(
751 "directory listing for {path} was truncated because it has \
752 too many entries; list a smaller subtree"
753 ),
754 });
755 }
756 if !result.stdout_complete {
762 return Err(SailError::Execution {
763 code: GrpcCode::FailedPrecondition,
764 detail: format!(
765 "directory listing for {path} was interrupted before it \
766 finished streaming; retry the call"
767 ),
768 });
769 }
770 let mut entries =
771 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
772 .map_err(|detail| SailError::Execution {
773 code: GrpcCode::FailedPrecondition,
774 detail: format!("directory listing for {path} could not be used: {detail}"),
775 })?;
776 if entries.is_empty() {
779 return Err(SailError::Execution {
780 code: GrpcCode::FailedPrecondition,
781 detail: format!(
782 "directory listing for {path} produced no records; \
783 listing requires GNU find in the guest"
784 ),
785 });
786 }
787 let start = entries.remove(0);
788 if start.entry_type != EntryType::Directory {
789 return Err(SailError::Execution {
790 code: GrpcCode::FailedPrecondition,
791 detail: format!(
792 "{path} is not a directory (it is a {})",
793 start.entry_type.as_str()
794 ),
795 });
796 }
797 Ok(entries)
798 }
799}
800
801fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
803 if result.exit_code != 0 {
804 return Err(fs_command_error(result, action));
805 }
806 Ok(())
807}
808
809fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
812 let stderr = result.stderr.trim();
813 let suffix = if stderr.is_empty() {
814 String::new()
815 } else {
816 format!(": {stderr}")
817 };
818 SailError::Execution {
819 code: GrpcCode::FailedPrecondition,
820 detail: format!(
821 "failed to {action} (exit code {}){suffix}",
822 result.exit_code
823 ),
824 }
825}