1use std::sync::Arc;
30use std::time::Duration;
31use time::OffsetDateTime;
32
33use crate::app::{self, App};
34use crate::config::Config;
35use crate::credential::api::CredentialApi;
36use crate::credential::types::{
37 CredentialInjectionPolicyInfo, CredentialInjectionPolicyPage, InjectionRule,
38 ListCredentialInjectionPoliciesQuery, SecretInfo,
39};
40use crate::error::{RpcStatus, SailError};
41use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
42use crate::http::HttpCore;
43use crate::imagebuilder::ImageBuilder;
44use crate::sailbox::api::{SailboxApi, UpgradeResult};
45use crate::sailbox::fs::{DirEntry, EntryType};
46use crate::sailbox::object::Sailbox;
47use crate::sailbox::types::{
48 CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
49 SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
50 SailboxSpendResponse, VolumeInfo, WhoAmI,
51};
52use crate::worker::{
53 is_transient_transport_message, FileReader, FileWriter, Listener, WorkerProxy, WriteOptions,
54};
55
56#[derive(Clone)]
58pub struct Client {
59 inner: Arc<Inner>,
60}
61
62impl std::fmt::Debug for Client {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("Client")
65 .field("config", &self.inner.config)
66 .finish_non_exhaustive()
67 }
68}
69
70struct Inner {
71 config: Config,
72 sailbox_http: HttpCore,
74 api_http: HttpCore,
76 worker: Arc<WorkerProxy>,
79 imagebuilder: ImageBuilder,
80 image_ready: crate::imagecache::ImageReadyCache,
83}
84
85const HINTED_EXEC_ENDPOINT_PROBE_TIMEOUT: Duration = Duration::from_secs(1);
88
89#[derive(Default, Clone)]
95pub struct ClientBuilder {
96 mode: Option<String>,
97 api_key: Option<String>,
98 api_url: Option<String>,
99 sailbox_api_url: Option<String>,
100 imagebuilder_url: Option<String>,
101 ingress_url: Option<String>,
102 client_label: Option<String>,
103}
104
105impl std::fmt::Debug for ClientBuilder {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 f.debug_struct("ClientBuilder")
108 .field(
109 "api_key",
110 &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
111 )
112 .field("mode", &self.mode)
113 .field("api_url", &self.api_url)
114 .field("sailbox_api_url", &self.sailbox_api_url)
115 .field("imagebuilder_url", &self.imagebuilder_url)
116 .field("ingress_url", &self.ingress_url)
117 .field("client_label", &self.client_label)
118 .finish()
119 }
120}
121
122impl ClientBuilder {
123 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
126 ClientBuilder {
127 api_key: Some(api_key.into()),
128 ..ClientBuilder::default()
129 }
130 }
131
132 #[doc(hidden)]
135 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
136 self.mode = Some(mode.into());
137 self
138 }
139
140 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
142 self.api_url = Some(api_url.into());
143 self
144 }
145
146 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
148 self.sailbox_api_url = Some(url.into());
149 self
150 }
151
152 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
154 self.imagebuilder_url = Some(url.into());
155 self
156 }
157
158 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
161 self.ingress_url = Some(url.into());
162 self
163 }
164
165 #[doc(hidden)]
167 pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
168 self.client_label = Some(label.into());
169 self
170 }
171
172 pub fn build(self) -> Result<Client, SailError> {
174 let api_key = self.api_key.unwrap_or_default();
175 let config = Config::resolve(
176 self.mode.as_deref(),
177 api_key,
178 self.api_url,
179 self.sailbox_api_url,
180 self.imagebuilder_url,
181 self.ingress_url,
182 )?;
183 Client::from_config_with_label(
184 config,
185 self.client_label
186 .as_deref()
187 .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
188 )
189 }
190}
191
192const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
196
197fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
201 matches!(
202 result,
203 Err(SailError::Creation {
204 status: 409,
205 message,
206 ..
207 }) if message.starts_with("resolve image:")
208 )
209}
210
211impl Client {
212 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
214 ClientBuilder::new(api_key)
215 }
216
217 pub fn from_env() -> Result<Client, SailError> {
219 Client::from_config(Config::from_env()?)
220 }
221
222 #[doc(hidden)]
224 pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
225 Client::from_config_with_label(Config::from_env()?, label)
226 }
227
228 pub fn from_config(config: Config) -> Result<Client, SailError> {
230 Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
231 }
232
233 fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
234 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
235 .with_client_label(client_label);
236 let api_http =
237 HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
238 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
239 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
240 Ok(Client {
241 inner: Arc::new(Inner {
242 config,
243 sailbox_http,
244 api_http,
245 worker,
246 imagebuilder,
247 image_ready: crate::imagecache::ImageReadyCache::new(),
248 }),
249 })
250 }
251
252 pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
253 &self.inner.image_ready
254 }
255
256 #[cfg(any(test, feature = "test-fakes"))]
261 pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
262 self.inner.image_ready.set_refresh_window(window);
263 }
264
265 pub fn config(&self) -> &Config {
267 &self.inner.config
268 }
269
270 #[doc(hidden)]
272 pub fn worker(&self) -> Arc<WorkerProxy> {
273 Arc::clone(&self.inner.worker)
274 }
275
276 #[doc(hidden)]
278 pub fn imagebuilder(&self) -> &ImageBuilder {
279 &self.inner.imagebuilder
280 }
281
282 #[doc(hidden)]
284 pub fn sailbox_http(&self) -> &HttpCore {
285 &self.inner.sailbox_http
286 }
287
288 #[doc(hidden)]
290 pub fn api_http(&self) -> &HttpCore {
291 &self.inner.api_http
292 }
293
294 fn sailbox_api(&self) -> SailboxApi<'_> {
295 SailboxApi::new(&self.inner.sailbox_http)
296 }
297
298 async fn create_with_image_revalidation(
306 &self,
307 req: &CreateSailboxRequest,
308 timeout: Option<Duration>,
309 ) -> Result<SailboxHandle, SailError> {
310 let create_started = std::time::Instant::now();
311 let result = self.sailbox_api().create(req, timeout).await;
312 let custom_image = req.image != crate::image::ImageSpec::default()
313 && !crate::imagebuild::is_builtin_base_spec(&req.image);
314 if !custom_image || !image_not_ready_conflict(&result) {
315 return result;
316 }
317 if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
318 self.image_ready_cache()
323 .invalidate_spec_started_before(&spec_hash, create_started);
324 }
325 let rebuild_timeout = req
329 .image_build_timeout
330 .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
331 let rebuild =
332 self.build_spec_ready_cached(&req.image, rebuild_timeout, true);
333 tokio::time::timeout(rebuild_timeout, rebuild)
334 .await
335 .unwrap_or_else(|_| {
336 Err(SailError::Transport {
337 kind: crate::error::TransportKind::Timeout,
338 message: "timed out building the image".to_string(),
339 source: None,
340 })
341 })?;
342 self.sailbox_api().create(req, timeout).await
343 }
344
345 pub async fn create_sailbox(
357 &self,
358 req: &CreateSailboxRequest,
359 timeout: Option<Duration>,
360 ) -> Result<Sailbox, SailError> {
361 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
362 if !req.ssh {
363 return self
364 .create_with_image_revalidation(req, timeout)
365 .await
366 .map(bind);
367 }
368 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
373 self.org_ssh_ca_public_key().await?;
376 let mut req = req.clone();
381 let ssh_allowlist = req
382 .ingress_ports
383 .iter()
384 .find(|port| port.guest_port == 22)
385 .map(|port| port.allowlist.clone())
386 .unwrap_or_default();
387 req.ingress_ports.retain(|port| port.guest_port != 22);
388 let handle = self.create_with_image_revalidation(&req, timeout).await?;
389 let handle_id = handle.sailbox_id.clone();
390 if let Err(err) = self
392 .enable_ssh(
393 &handle_id,
394 &ssh_allowlist,
395 false,
396 Duration::ZERO,
397 )
398 .await
399 {
400 return Err(SailError::Creation {
403 message: format!(
404 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
405 id to retry enable_ssh or terminate it."
406 ),
407 status: 0,
408 body: serde_json::Value::Null,
409 });
410 }
411 Ok(bind(handle))
412 }
413
414 #[doc(hidden)]
416 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
417 self.sailbox_api().get(sailbox_id).await
418 }
419
420 #[doc(hidden)]
422 pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
423 self.sailbox_api().whoami().await
424 }
425
426 pub async fn list_sailboxes(
428 &self,
429 query: &ListSailboxesQuery,
430 ) -> Result<SailboxPage, SailError> {
431 self.sailbox_api().list(query).await
432 }
433
434 pub async fn sailbox_spend(
436 &self,
437 query: &SailboxSpendQuery,
438 ) -> Result<SailboxSpendResponse, SailError> {
439 self.sailbox_api().spend(query).await
440 }
441
442 pub async fn sailbox_metrics(
444 &self,
445 sailbox_id: &str,
446 query: &SailboxMetricsQuery,
447 ) -> Result<SailboxMetricsResponse, SailError> {
448 self.sailbox_api().metrics(sailbox_id, query).await
449 }
450
451 #[doc(hidden)]
453 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
454 self.sailbox_api().terminate(sailbox_id).await
455 }
456
457 #[doc(hidden)]
459 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
460 self.sailbox_api().pause(sailbox_id).await
461 }
462
463 #[doc(hidden)]
465 pub async fn sleep_sailbox(
466 &self,
467 sailbox_id: &str,
468 wake_at: Option<OffsetDateTime>,
469 ) -> Result<Option<OffsetDateTime>, SailError> {
470 self.sailbox_api().sleep(sailbox_id, wake_at).await
471 }
472
473 #[doc(hidden)]
475 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
476 self.sailbox_api().resume(sailbox_id).await
477 }
478
479 #[doc(hidden)]
481 pub async fn checkpoint_sailbox(
482 &self,
483 sailbox_id: &str,
484 name: Option<&str>,
485 ttl_seconds: Option<i64>,
486 ) -> Result<SailboxCheckpoint, SailError> {
487 self.sailbox_api()
488 .checkpoint(sailbox_id, name, ttl_seconds)
489 .await
490 }
491
492 #[doc(hidden)]
495 pub async fn fork_sailbox(
496 &self,
497 sailbox_id: &str,
498 name: Option<&str>,
499 timeout: Option<Duration>,
500 ) -> Result<Sailbox, SailError> {
501 self.sailbox_api()
502 .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
503 .await
504 .map(|handle| Sailbox::bind(self.clone(), handle))
505 }
506
507 pub async fn create_from_checkpoint(
509 &self,
510 checkpoint_id: &str,
511 name: Option<&str>,
512 timeout: Option<Duration>,
513 ) -> Result<Sailbox, SailError> {
514 self.sailbox_api()
515 .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
516 .await
517 .map(|handle| Sailbox::bind(self.clone(), handle))
518 }
519
520 #[doc(hidden)]
522 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
523 self.sailbox_api().upgrade(sailbox_id).await
524 }
525
526 #[doc(hidden)]
528 pub async fn expose_listener(
529 &self,
530 sailbox_id: &str,
531 guest_port: u32,
532 protocol: crate::sailbox::types::IngressProtocol,
533 allowlist: &[String],
534 ) -> Result<Listener, SailError> {
535 let mut response = self
536 .sailbox_api()
537 .expose(sailbox_id, guest_port, protocol, allowlist)
538 .await?;
539 self.fill_listener_url(sailbox_id, &mut response);
540 Ok(response)
541 }
542
543 #[doc(hidden)]
545 pub async fn unexpose_listener(
546 &self,
547 sailbox_id: &str,
548 guest_port: u32,
549 ) -> Result<(), SailError> {
550 self.sailbox_api().unexpose(sailbox_id, guest_port).await
551 }
552
553 #[doc(hidden)]
555 pub async fn list_listeners(
556 &self,
557 sailbox_id: &str,
558 ) -> Result<Vec<crate::worker::Listener>, SailError> {
559 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
560 for listener in &mut listeners {
561 self.fill_listener_url(sailbox_id, listener);
562 }
563 Ok(listeners)
564 }
565
566 #[doc(hidden)]
569 pub async fn get_listener(
570 &self,
571 sailbox_id: &str,
572 guest_port: u32,
573 ) -> Result<crate::worker::Listener, SailError> {
574 let mut listener = self
575 .sailbox_api()
576 .get_listener(sailbox_id, guest_port)
577 .await?;
578 self.fill_listener_url(sailbox_id, &mut listener);
579 Ok(listener)
580 }
581
582 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
586 if listener.public_url.is_empty()
587 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
588 {
589 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
590 self.config(),
591 sailbox_id,
592 listener.guest_port,
593 );
594 }
595 }
596
597 #[doc(hidden)]
599 pub async fn ingress_auth_headers(
600 &self,
601 sailbox_id: &str,
602 ) -> Result<Vec<(String, String)>, SailError> {
603 self.sailbox_api().ingress_auth_headers(sailbox_id).await
604 }
605
606 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
608 self.sailbox_api().org_ssh_ca_public_key().await
609 }
610
611 pub async fn issue_user_cert(
615 &self,
616 public_key: &str,
617 timeout: Option<Duration>,
618 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
619 self.sailbox_api()
620 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
621 .await
622 }
623
624 pub async fn get_volume(
628 &self,
629 name: &str,
630 mint_if_missing: bool,
631 ) -> Result<VolumeInfo, SailError> {
632 self.sailbox_api().get_volume(name, mint_if_missing).await
633 }
634
635 pub async fn list_volumes(
637 &self,
638 max_objects: Option<i64>,
639 ) -> Result<Vec<VolumeInfo>, SailError> {
640 self.sailbox_api().list_volumes(max_objects).await
641 }
642
643 pub async fn delete_volume(
645 &self,
646 volume_id: &str,
647 allow_missing: bool,
648 ) -> Result<Option<VolumeInfo>, SailError> {
649 self.sailbox_api()
650 .delete_volume(volume_id, allow_missing)
651 .await
652 }
653
654 fn credential_api(&self) -> CredentialApi<'_> {
661 CredentialApi::new(&self.inner.sailbox_http)
662 }
663
664 #[doc(hidden)]
666 pub async fn set_secret(&self, name: &str, value: &str) -> Result<SecretInfo, SailError> {
667 self.credential_api().set_secret(name, value).await
668 }
669
670 #[doc(hidden)]
672 pub async fn get_secret(&self, name: &str) -> Result<SecretInfo, SailError> {
673 self.credential_api().get_secret(name).await
674 }
675
676 #[doc(hidden)]
678 pub async fn list_secrets(&self) -> Result<Vec<SecretInfo>, SailError> {
679 self.credential_api().list_secrets().await
680 }
681
682 #[doc(hidden)]
684 pub async fn delete_secret(&self, name: &str) -> Result<(), SailError> {
685 self.credential_api().delete_secret(name).await
686 }
687
688 #[doc(hidden)]
690 pub async fn create_credential_policy(
691 &self,
692 name: &str,
693 rules: &[InjectionRule],
694 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
695 self.credential_api().create_policy(name, rules).await
696 }
697
698 #[doc(hidden)]
700 pub async fn get_credential_policy(
701 &self,
702 policy_id: &str,
703 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
704 self.credential_api().get_policy(policy_id).await
705 }
706
707 #[doc(hidden)]
709 pub async fn list_credential_policies(
710 &self,
711 query: &ListCredentialInjectionPoliciesQuery,
712 ) -> Result<CredentialInjectionPolicyPage, SailError> {
713 self.credential_api().list_policies(query).await
714 }
715
716 #[doc(hidden)]
718 pub async fn rename_credential_policy(
719 &self,
720 policy_id: &str,
721 name: &str,
722 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
723 self.credential_api().rename_policy(policy_id, name).await
724 }
725
726 #[doc(hidden)]
728 pub async fn delete_credential_policy(&self, policy_id: &str) -> Result<(), SailError> {
729 self.credential_api().delete_policy(policy_id).await
730 }
731
732 #[doc(hidden)]
734 pub async fn sailbox_credential_policy(
735 &self,
736 sailbox_id: &str,
737 ) -> Result<Option<CredentialInjectionPolicyInfo>, SailError> {
738 self.credential_api().sailbox_policy(sailbox_id).await
739 }
740
741 #[doc(hidden)]
743 pub async fn set_sailbox_credential_policy(
744 &self,
745 sailbox_id: &str,
746 policy_id: &str,
747 ) -> Result<(), SailError> {
748 self.credential_api()
749 .attach_sailbox_policy(sailbox_id, policy_id)
750 .await
751 }
752
753 #[doc(hidden)]
755 pub async fn clear_sailbox_credential_policy(&self, sailbox_id: &str) -> Result<(), SailError> {
756 self.credential_api()
757 .detach_sailbox_policy(sailbox_id)
758 .await
759 }
760
761 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
765 app::find_app(&self.inner.api_http, name, mint_if_missing).await
766 }
767
768 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
770 app::list_apps(&self.inner.api_http).await
771 }
772
773 #[doc(hidden)]
783 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
784 let handle = self.resume_sailbox(sailbox_id).await?;
785 if handle.exec_endpoint.is_empty() {
786 return Err(SailError::Internal {
787 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
788 });
789 }
790 Ok(handle.exec_endpoint)
791 }
792
793 #[doc(hidden)]
796 pub async fn exec(
797 &self,
798 sailbox_id: &str,
799 argv: Vec<String>,
800 options: ExecOptions,
801 ) -> Result<ExecProcess, SailError> {
802 self.exec_at_endpoint(sailbox_id, None, argv, options).await
803 }
804
805 #[doc(hidden)]
809 pub async fn exec_at_endpoint(
810 &self,
811 sailbox_id: &str,
812 exec_endpoint: Option<&str>,
813 argv: Vec<String>,
814 options: ExecOptions,
815 ) -> Result<ExecProcess, SailError> {
816 if argv.is_empty() {
817 return Err(SailError::InvalidArgument {
818 message: "command must be non-empty".to_string(),
819 });
820 }
821 if options.cwd.is_some() || options.background {
822 return Err(SailError::InvalidArgument {
823 message: "cwd and background require a shell command; use exec_shell or run_shell"
824 .to_string(),
825 });
826 }
827 let env = crate::exec::encode_env(&options.env)?;
830 let hinted_endpoint = exec_endpoint.filter(|endpoint| !endpoint.is_empty());
831 let exec_endpoint = match hinted_endpoint {
832 Some(endpoint) => endpoint.to_string(),
833 None => self.exec_endpoint(sailbox_id).await?,
834 };
835 let params = ExecParams {
836 sailbox_id: sailbox_id.to_string(),
837 exec_endpoint,
838 argv,
839 timeout_seconds: options
842 .timeout
843 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
844 idempotency_key: options.idempotency_key,
845 open_stdin: options.open_stdin || options.pty,
848 pty: options.pty,
849 term: options.term,
850 cols: options.cols,
851 rows: options.rows,
852 env,
853 retry_timeout: options.retry_timeout.as_secs_f64(),
854 forward_ports: options.forward_ports,
855 forward_browser: options.forward_browser,
856 extra_metadata: Vec::new(),
857 forward_clipboard: options.forward_clipboard && options.pty,
860 };
861 self.start_exec_params_at_endpoint(params, hinted_endpoint.is_some())
862 .await
863 }
864
865 #[doc(hidden)]
869 pub async fn start_exec_params_at_endpoint(
870 &self,
871 mut params: ExecParams,
872 endpoint_was_hint: bool,
873 ) -> Result<ExecProcess, SailError> {
874 if !endpoint_was_hint {
875 return ExecProcess::start(self.worker(), params).await;
876 }
877
878 params.ensure_idempotency_key();
885 let hinted_start =
886 ExecProcess::start_with_initial_retry_timeout(self.worker(), params.clone(), Some(0.0));
887 match tokio::time::timeout(HINTED_EXEC_ENDPOINT_PROBE_TIMEOUT, hinted_start).await {
888 Ok(Ok(process)) => return Ok(process),
889 Ok(Err(err)) if !should_reresolve_hinted_exec_endpoint(&err) => return Err(err),
890 Ok(Err(_)) | Err(_) => {}
891 }
892
893 let endpoint = self.exec_endpoint(¶ms.sailbox_id).await?;
894 params.exec_endpoint = endpoint;
895 ExecProcess::start(self.worker(), params).await
896 }
897
898 #[doc(hidden)]
902 pub async fn exec_shell(
903 &self,
904 sailbox_id: &str,
905 command: &str,
906 options: ExecOptions,
907 ) -> Result<ExecProcess, SailError> {
908 self.exec_shell_at_endpoint(sailbox_id, None, command, options)
909 .await
910 }
911
912 #[doc(hidden)]
914 pub async fn exec_shell_at_endpoint(
915 &self,
916 sailbox_id: &str,
917 exec_endpoint: Option<&str>,
918 command: &str,
919 mut options: ExecOptions,
920 ) -> Result<ExecProcess, SailError> {
921 let argv = crate::exec::shell_argv(command, &options)?;
922 options.cwd = None;
925 options.background = false;
926 self.exec_at_endpoint(sailbox_id, exec_endpoint, argv, options)
927 .await
928 }
929
930 #[doc(hidden)]
938 pub async fn read_stream(
939 &self,
940 sailbox_id: &str,
941 remote_path: &str,
942 ) -> Result<FileReader, SailError> {
943 let endpoint = self.exec_endpoint(sailbox_id).await?;
944 Ok(self
945 .inner
946 .worker
947 .read_file(&endpoint, sailbox_id, remote_path))
948 }
949
950 #[doc(hidden)]
954 pub async fn read_file(
955 &self,
956 sailbox_id: &str,
957 remote_path: &str,
958 ) -> Result<Vec<u8>, SailError> {
959 let reader = self.read_stream(sailbox_id, remote_path).await?;
960 let mut contents = Vec::new();
961 while let Some(chunk) = reader.next().await {
962 contents.extend_from_slice(&chunk?);
963 }
964 Ok(contents)
965 }
966
967 #[doc(hidden)]
976 pub async fn write_stream(
977 &self,
978 sailbox_id: &str,
979 remote_path: &str,
980 options: WriteOptions,
981 ) -> Result<FileWriter, SailError> {
982 let endpoint = self.exec_endpoint(sailbox_id).await?;
983 Ok(self.inner.worker.write_file(
984 &endpoint,
985 sailbox_id,
986 remote_path,
987 options.create_parents,
988 options.mode,
989 ))
990 }
991
992 #[doc(hidden)]
996 pub async fn write_file(
997 &self,
998 sailbox_id: &str,
999 remote_path: &str,
1000 data: &[u8],
1001 options: WriteOptions,
1002 ) -> Result<(), SailError> {
1003 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
1004 writer.write(data).await?;
1005 writer.finish().await
1006 }
1007
1008 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
1017 self.exec(sailbox_id, argv, ExecOptions::default())
1018 .await?
1019 .wait()
1020 .await
1021 }
1022
1023 #[doc(hidden)]
1026 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
1027 crate::sailbox::fs::require_path(path)?;
1028 let result = self
1029 .run_argv(
1030 sailbox_id,
1031 vec![
1032 "mkdir".to_string(),
1033 "-p".to_string(),
1034 "--".to_string(),
1035 path.to_string(),
1036 ],
1037 )
1038 .await?;
1039 fs_command_ok(&result, &format!("create directory {path}"))
1040 }
1041
1042 #[doc(hidden)]
1045 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
1046 crate::sailbox::fs::require_path(path)?;
1047 let result = self
1048 .run_argv(
1049 sailbox_id,
1050 vec![
1051 "rm".to_string(),
1052 "-rf".to_string(),
1053 "--".to_string(),
1054 path.to_string(),
1055 ],
1056 )
1057 .await?;
1058 fs_command_ok(&result, &format!("remove {path}"))
1059 }
1060
1061 #[doc(hidden)]
1064 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
1065 crate::sailbox::fs::require_path(path)?;
1066 let result = self
1067 .run_argv(
1068 sailbox_id,
1069 vec!["test".to_string(), "-e".to_string(), path.to_string()],
1070 )
1071 .await?;
1072 match result.exit_code {
1076 0 => Ok(true),
1077 1 => Ok(false),
1078 _ => Err(fs_command_error(
1079 &result,
1080 &format!("check whether {path} exists"),
1081 )),
1082 }
1083 }
1084
1085 #[doc(hidden)]
1089 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
1090 crate::sailbox::fs::require_path(path)?;
1091 let process = self
1092 .exec(
1093 sailbox_id,
1094 crate::sailbox::fs::list_dir_argv(path),
1095 ExecOptions::default(),
1096 )
1097 .await?;
1098 let result = process.wait().await?;
1099 fs_command_ok(&result, &format!("list directory {path}"))?;
1100 if result.stdout_truncated {
1103 return Err(SailError::Execution {
1104 code: RpcStatus::FailedPrecondition,
1105 detail: format!(
1106 "directory listing for {path} was truncated because it has \
1107 too many entries; list a smaller subtree"
1108 ),
1109 });
1110 }
1111 if !result.stdout_complete {
1117 return Err(SailError::Execution {
1118 code: RpcStatus::FailedPrecondition,
1119 detail: format!(
1120 "directory listing for {path} was interrupted before it \
1121 finished streaming; retry the call"
1122 ),
1123 });
1124 }
1125 let mut entries =
1126 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
1127 .map_err(|detail| SailError::Execution {
1128 code: RpcStatus::FailedPrecondition,
1129 detail: format!("directory listing for {path} could not be used: {detail}"),
1130 })?;
1131 if entries.is_empty() {
1134 return Err(SailError::Execution {
1135 code: RpcStatus::FailedPrecondition,
1136 detail: format!(
1137 "directory listing for {path} produced no records; \
1138 listing requires GNU find in the guest"
1139 ),
1140 });
1141 }
1142 let start = entries.remove(0);
1143 if start.entry_type != EntryType::Directory {
1144 return Err(SailError::Execution {
1145 code: RpcStatus::FailedPrecondition,
1146 detail: format!(
1147 "{path} is not a directory (it is a {})",
1148 start.entry_type.as_str()
1149 ),
1150 });
1151 }
1152 Ok(entries)
1153 }
1154}
1155
1156fn duration_to_whole_seconds(timeout: Duration) -> i64 {
1160 if timeout.is_zero() {
1161 0
1162 } else {
1163 timeout.as_secs_f64().ceil() as i64
1164 }
1165}
1166
1167fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
1169 if result.exit_code != 0 {
1170 return Err(fs_command_error(result, action));
1171 }
1172 Ok(())
1173}
1174
1175fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
1178 let stderr = result.stderr.trim();
1179 let suffix = if stderr.is_empty() {
1180 String::new()
1181 } else {
1182 format!(": {stderr}")
1183 };
1184 SailError::Execution {
1185 code: RpcStatus::FailedPrecondition,
1186 detail: format!(
1187 "failed to {action} (exit code {}){suffix}",
1188 result.exit_code
1189 ),
1190 }
1191}
1192
1193fn should_reresolve_hinted_exec_endpoint(err: &SailError) -> bool {
1197 err.retryable()
1198 || matches!(
1199 err,
1200 SailError::Terminated { .. } | SailError::HostLost { .. }
1201 )
1202 || matches!(
1203 err,
1204 SailError::Execution {
1205 code: RpcStatus::Unknown | RpcStatus::Internal,
1206 detail,
1207 } if is_transient_transport_message(detail)
1208 )
1209}
1210
1211#[cfg(test)]
1212mod timeout_tests {
1213 use super::*;
1214
1215 #[test]
1216 fn durations_round_up_to_whole_seconds() {
1217 assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1218 assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1219 assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1220 assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1221 }
1222
1223 #[test]
1224 fn hinted_exec_reresolves_source_less_transport_statuses() {
1225 let relayed_transport = SailError::Execution {
1226 code: RpcStatus::Unknown,
1227 detail: "error reading server preface: EOF".to_string(),
1228 };
1229 assert!(should_reresolve_hinted_exec_endpoint(&relayed_transport));
1230
1231 let server_verdict = SailError::Execution {
1232 code: RpcStatus::Unknown,
1233 detail: "application rejected exec".to_string(),
1234 };
1235 assert!(!should_reresolve_hinted_exec_endpoint(&server_verdict));
1236 }
1237}