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::imagebuild::BuildMode;
44use crate::imagebuilder::ImageBuilder;
45use crate::sailbox::api::{SailboxApi, UpgradeResult};
46use crate::sailbox::fs::{DirEntry, EntryType};
47use crate::sailbox::object::Sailbox;
48use crate::sailbox::types::{
49 CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
50 SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
51 SailboxSpendResponse, VolumeInfo, WhoAmI,
52};
53use crate::worker::{
54 is_transient_transport_message, FileReader, FileWriter, Listener, WorkerProxy, WriteOptions,
55};
56
57#[derive(Clone)]
59pub struct Client {
60 inner: Arc<Inner>,
61}
62
63impl std::fmt::Debug for Client {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("Client")
66 .field("config", &self.inner.config)
67 .finish_non_exhaustive()
68 }
69}
70
71struct Inner {
72 config: Config,
73 sailbox_http: HttpCore,
75 api_http: HttpCore,
77 worker: Arc<WorkerProxy>,
80 imagebuilder: ImageBuilder,
81 image_ready: crate::imagecache::ImageReadyCache,
84}
85
86const HINTED_EXEC_ENDPOINT_PROBE_TIMEOUT: Duration = Duration::from_secs(1);
89
90#[derive(Default, Clone)]
96pub struct ClientBuilder {
97 mode: Option<String>,
98 api_key: Option<String>,
99 api_url: Option<String>,
100 sailbox_api_url: Option<String>,
101 imagebuilder_url: Option<String>,
102 ingress_url: Option<String>,
103 client_label: Option<String>,
104}
105
106impl std::fmt::Debug for ClientBuilder {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("ClientBuilder")
109 .field(
110 "api_key",
111 &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
112 )
113 .field("mode", &self.mode)
114 .field("api_url", &self.api_url)
115 .field("sailbox_api_url", &self.sailbox_api_url)
116 .field("imagebuilder_url", &self.imagebuilder_url)
117 .field("ingress_url", &self.ingress_url)
118 .field("client_label", &self.client_label)
119 .finish()
120 }
121}
122
123impl ClientBuilder {
124 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
127 ClientBuilder {
128 api_key: Some(api_key.into()),
129 ..ClientBuilder::default()
130 }
131 }
132
133 #[doc(hidden)]
136 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
137 self.mode = Some(mode.into());
138 self
139 }
140
141 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
143 self.api_url = Some(api_url.into());
144 self
145 }
146
147 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
149 self.sailbox_api_url = Some(url.into());
150 self
151 }
152
153 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
155 self.imagebuilder_url = Some(url.into());
156 self
157 }
158
159 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
162 self.ingress_url = Some(url.into());
163 self
164 }
165
166 #[doc(hidden)]
168 pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
169 self.client_label = Some(label.into());
170 self
171 }
172
173 pub fn build(self) -> Result<Client, SailError> {
175 let api_key = self.api_key.unwrap_or_default();
176 let config = Config::resolve(
177 self.mode.as_deref(),
178 api_key,
179 self.api_url,
180 self.sailbox_api_url,
181 self.imagebuilder_url,
182 self.ingress_url,
183 )?;
184 Client::from_config_with_label(
185 config,
186 self.client_label
187 .as_deref()
188 .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
189 )
190 }
191}
192
193const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
197
198fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
202 matches!(
203 result,
204 Err(SailError::Creation {
205 status: 409,
206 message,
207 ..
208 }) if message.starts_with("resolve image:")
209 )
210}
211
212impl Client {
213 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
215 ClientBuilder::new(api_key)
216 }
217
218 pub fn from_env() -> Result<Client, SailError> {
220 Client::from_config(Config::from_env()?)
221 }
222
223 #[doc(hidden)]
225 pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
226 Client::from_config_with_label(Config::from_env()?, label)
227 }
228
229 pub fn from_config(config: Config) -> Result<Client, SailError> {
231 Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
232 }
233
234 fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
235 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
236 .with_client_label(client_label);
237 let api_http =
238 HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
239 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
240 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
241 Ok(Client {
242 inner: Arc::new(Inner {
243 config,
244 sailbox_http,
245 api_http,
246 worker,
247 imagebuilder,
248 image_ready: crate::imagecache::ImageReadyCache::new(),
249 }),
250 })
251 }
252
253 pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
254 &self.inner.image_ready
255 }
256
257 #[cfg(any(test, feature = "test-fakes"))]
262 pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
263 self.inner.image_ready.set_refresh_window(window);
264 }
265
266 pub fn config(&self) -> &Config {
268 &self.inner.config
269 }
270
271 #[doc(hidden)]
273 pub fn worker(&self) -> Arc<WorkerProxy> {
274 Arc::clone(&self.inner.worker)
275 }
276
277 #[doc(hidden)]
279 pub fn imagebuilder(&self) -> &ImageBuilder {
280 &self.inner.imagebuilder
281 }
282
283 #[doc(hidden)]
285 pub fn sailbox_http(&self) -> &HttpCore {
286 &self.inner.sailbox_http
287 }
288
289 #[doc(hidden)]
291 pub fn api_http(&self) -> &HttpCore {
292 &self.inner.api_http
293 }
294
295 fn sailbox_api(&self) -> SailboxApi<'_> {
296 SailboxApi::new(&self.inner.sailbox_http)
297 }
298
299 async fn create_with_image_revalidation(
307 &self,
308 req: &CreateSailboxRequest,
309 timeout: Option<Duration>,
310 ) -> Result<SailboxHandle, SailError> {
311 let create_started = std::time::Instant::now();
312 let result = self.sailbox_api().create(req, timeout).await;
313 let custom_image = req.image != crate::image::ImageSpec::default()
314 && !crate::imagebuild::is_builtin_base_spec(&req.image);
315 if !custom_image || !image_not_ready_conflict(&result) {
316 return result;
317 }
318 if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
319 self.image_ready_cache()
324 .invalidate_spec_started_before(&spec_hash, create_started);
325 }
326 let rebuild_timeout = req
330 .image_build_timeout
331 .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
332 let rebuild = self.build_spec_ready_cached(
333 &req.image,
334 rebuild_timeout,
335 true,
336 BuildMode::ReuseExisting,
337 );
338 let build = tokio::time::timeout(rebuild_timeout, rebuild)
339 .await
340 .unwrap_or_else(|_| {
341 Err(SailError::Transport {
342 kind: crate::error::TransportKind::Timeout,
343 message: "timed out building the image".to_string(),
344 source: None,
345 })
346 })?;
347 let mut retry = req.clone();
352 crate::imagebuild::pin_resolved_oci_ref(&mut retry.image, &build.resolved_oci_ref);
353 self.sailbox_api().create(&retry, timeout).await
354 }
355
356 pub async fn create_sailbox(
368 &self,
369 req: &CreateSailboxRequest,
370 timeout: Option<Duration>,
371 ) -> Result<Sailbox, SailError> {
372 crate::imagebuild::validate_image_spec_source(&req.image)?;
377 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
378 if !req.ssh {
379 return self
380 .create_with_image_revalidation(req, timeout)
381 .await
382 .map(bind);
383 }
384 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
389 self.org_ssh_ca_public_key().await?;
392 let mut req = req.clone();
397 let ssh_allowlist = req
398 .ingress_ports
399 .iter()
400 .find(|port| port.guest_port == 22)
401 .map(|port| port.allowlist.clone())
402 .unwrap_or_default();
403 req.ingress_ports.retain(|port| port.guest_port != 22);
404 let handle = self.create_with_image_revalidation(&req, timeout).await?;
405 let handle_id = handle.sailbox_id.clone();
406 if let Err(err) = self
408 .enable_ssh(
409 &handle_id,
410 &ssh_allowlist,
411 false,
412 Duration::ZERO,
413 )
414 .await
415 {
416 return Err(SailError::Creation {
419 message: format!(
420 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
421 id to retry enable_ssh or terminate it."
422 ),
423 status: 0,
424 body: serde_json::Value::Null,
425 });
426 }
427 Ok(bind(handle))
428 }
429
430 #[doc(hidden)]
432 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
433 self.sailbox_api().get(sailbox_id).await
434 }
435
436 #[doc(hidden)]
438 pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
439 self.sailbox_api().whoami().await
440 }
441
442 pub async fn list_sailboxes(
444 &self,
445 query: &ListSailboxesQuery,
446 ) -> Result<SailboxPage, SailError> {
447 self.sailbox_api().list(query).await
448 }
449
450 pub async fn sailbox_spend(
452 &self,
453 query: &SailboxSpendQuery,
454 ) -> Result<SailboxSpendResponse, SailError> {
455 self.sailbox_api().spend(query).await
456 }
457
458 pub async fn sailbox_metrics(
460 &self,
461 sailbox_id: &str,
462 query: &SailboxMetricsQuery,
463 ) -> Result<SailboxMetricsResponse, SailError> {
464 self.sailbox_api().metrics(sailbox_id, query).await
465 }
466
467 #[doc(hidden)]
469 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
470 self.sailbox_api().terminate(sailbox_id).await
471 }
472
473 #[doc(hidden)]
475 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
476 self.sailbox_api().pause(sailbox_id).await
477 }
478
479 #[doc(hidden)]
481 pub async fn sleep_sailbox(
482 &self,
483 sailbox_id: &str,
484 wake_at: Option<OffsetDateTime>,
485 ) -> Result<Option<OffsetDateTime>, SailError> {
486 self.sailbox_api().sleep(sailbox_id, wake_at).await
487 }
488
489 #[doc(hidden)]
491 pub async fn set_sailbox_auto_sleep(
492 &self,
493 sailbox_id: &str,
494 auto_sleep: crate::AutoSleep,
495 ) -> Result<(), SailError> {
496 self.sailbox_api()
497 .set_auto_sleep(sailbox_id, auto_sleep)
498 .await
499 }
500
501 #[doc(hidden)]
503 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
504 self.sailbox_api().resume(sailbox_id).await
505 }
506
507 #[doc(hidden)]
509 pub async fn checkpoint_sailbox(
510 &self,
511 sailbox_id: &str,
512 name: Option<&str>,
513 ttl_seconds: Option<i64>,
514 ) -> Result<SailboxCheckpoint, SailError> {
515 self.sailbox_api()
516 .checkpoint(sailbox_id, name, ttl_seconds)
517 .await
518 }
519
520 pub async fn create_from_checkpoint(
537 &self,
538 checkpoint_id: &str,
539 name: Option<&str>,
540 timeout: Option<Duration>,
541 ) -> Result<Sailbox, SailError> {
542 self.sailbox_api()
543 .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
544 .await
545 .map(|handle| Sailbox::bind(self.clone(), handle))
546 }
547
548 #[doc(hidden)]
550 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
551 self.sailbox_api().upgrade(sailbox_id).await
552 }
553
554 #[doc(hidden)]
559 pub async fn expose_listener(
560 &self,
561 sailbox_id: &str,
562 guest_port: u32,
563 protocol: crate::sailbox::types::IngressProtocol,
564 allowlist: &[String],
565 ) -> Result<Listener, SailError> {
566 let mut response = self
567 .sailbox_api()
568 .expose(sailbox_id, guest_port, protocol, allowlist)
569 .await?;
570 self.fill_listener_url(sailbox_id, &mut response);
571 Ok(response)
572 }
573
574 #[doc(hidden)]
576 pub async fn unexpose_listener(
577 &self,
578 sailbox_id: &str,
579 guest_port: u32,
580 ) -> Result<(), SailError> {
581 self.sailbox_api().unexpose(sailbox_id, guest_port).await
582 }
583
584 #[doc(hidden)]
586 pub async fn list_listeners(
587 &self,
588 sailbox_id: &str,
589 ) -> Result<Vec<crate::worker::Listener>, SailError> {
590 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
591 for listener in &mut listeners {
592 self.fill_listener_url(sailbox_id, listener);
593 }
594 Ok(listeners)
595 }
596
597 #[doc(hidden)]
600 pub async fn get_listener(
601 &self,
602 sailbox_id: &str,
603 guest_port: u32,
604 ) -> Result<crate::worker::Listener, SailError> {
605 let mut listener = self
606 .sailbox_api()
607 .get_listener(sailbox_id, guest_port)
608 .await?;
609 self.fill_listener_url(sailbox_id, &mut listener);
610 Ok(listener)
611 }
612
613 #[doc(hidden)]
615 pub async fn custom_domain_dns_targets(&self) -> Result<(String, Option<String>), SailError> {
616 self.sailbox_api().custom_domain_dns_targets().await
617 }
618
619 #[doc(hidden)]
621 pub async fn attach_custom_domain(
622 &self,
623 sailbox_id: &str,
624 domain: &str,
625 guest_port: u32,
626 ) -> Result<crate::sailbox::types::CustomDomainInfo, SailError> {
627 self.sailbox_api()
628 .attach_custom_domain(sailbox_id, domain, guest_port)
629 .await
630 }
631
632 #[doc(hidden)]
634 pub async fn list_custom_domains(
635 &self,
636 sailbox_id: &str,
637 ) -> Result<Vec<crate::sailbox::types::CustomDomainInfo>, SailError> {
638 self.sailbox_api().list_custom_domains(sailbox_id).await
639 }
640
641 #[doc(hidden)]
643 pub async fn detach_custom_domain(
644 &self,
645 sailbox_id: &str,
646 domain: &str,
647 ) -> Result<(), SailError> {
648 self.sailbox_api()
649 .detach_custom_domain(sailbox_id, domain)
650 .await
651 }
652
653 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
657 if listener.public_url.is_empty()
658 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
659 {
660 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
661 self.config(),
662 sailbox_id,
663 listener.guest_port,
664 );
665 }
666 }
667
668 #[doc(hidden)]
670 pub async fn ingress_auth_headers(
671 &self,
672 sailbox_id: &str,
673 ) -> Result<Vec<(String, String)>, SailError> {
674 self.sailbox_api().ingress_auth_headers(sailbox_id).await
675 }
676
677 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
679 self.sailbox_api().org_ssh_ca_public_key().await
680 }
681
682 pub async fn issue_user_cert(
686 &self,
687 public_key: &str,
688 timeout: Option<Duration>,
689 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
690 self.sailbox_api()
691 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
692 .await
693 }
694
695 pub async fn get_volume(
699 &self,
700 name: &str,
701 mint_if_missing: bool,
702 ) -> Result<VolumeInfo, SailError> {
703 self.sailbox_api().get_volume(name, mint_if_missing).await
704 }
705
706 pub async fn list_volumes(
708 &self,
709 max_objects: Option<i64>,
710 ) -> Result<Vec<VolumeInfo>, SailError> {
711 self.sailbox_api().list_volumes(max_objects).await
712 }
713
714 pub async fn delete_volume(
716 &self,
717 volume_id: &str,
718 allow_missing: bool,
719 ) -> Result<Option<VolumeInfo>, SailError> {
720 self.sailbox_api()
721 .delete_volume(volume_id, allow_missing)
722 .await
723 }
724
725 fn credential_api(&self) -> CredentialApi<'_> {
732 CredentialApi::new(&self.inner.sailbox_http)
733 }
734
735 #[doc(hidden)]
737 pub async fn set_secret(&self, name: &str, value: &str) -> Result<SecretInfo, SailError> {
738 self.credential_api().set_secret(name, value).await
739 }
740
741 #[doc(hidden)]
743 pub async fn get_secret(&self, name: &str) -> Result<SecretInfo, SailError> {
744 self.credential_api().get_secret(name).await
745 }
746
747 #[doc(hidden)]
749 pub async fn list_secrets(&self) -> Result<Vec<SecretInfo>, SailError> {
750 self.credential_api().list_secrets().await
751 }
752
753 #[doc(hidden)]
755 pub async fn delete_secret(&self, name: &str) -> Result<(), SailError> {
756 self.credential_api().delete_secret(name).await
757 }
758
759 #[doc(hidden)]
761 pub async fn create_credential_policy(
762 &self,
763 name: &str,
764 rules: &[InjectionRule],
765 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
766 self.credential_api().create_policy(name, rules).await
767 }
768
769 #[doc(hidden)]
771 pub async fn get_credential_policy(
772 &self,
773 policy_id: &str,
774 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
775 self.credential_api().get_policy(policy_id).await
776 }
777
778 #[doc(hidden)]
780 pub async fn list_credential_policies(
781 &self,
782 query: &ListCredentialInjectionPoliciesQuery,
783 ) -> Result<CredentialInjectionPolicyPage, SailError> {
784 self.credential_api().list_policies(query).await
785 }
786
787 #[doc(hidden)]
789 pub async fn rename_credential_policy(
790 &self,
791 policy_id: &str,
792 name: &str,
793 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
794 self.credential_api().rename_policy(policy_id, name).await
795 }
796
797 #[doc(hidden)]
799 pub async fn delete_credential_policy(&self, policy_id: &str) -> Result<(), SailError> {
800 self.credential_api().delete_policy(policy_id).await
801 }
802
803 #[doc(hidden)]
805 pub async fn sailbox_credential_policy(
806 &self,
807 sailbox_id: &str,
808 ) -> Result<Option<CredentialInjectionPolicyInfo>, SailError> {
809 self.credential_api().sailbox_policy(sailbox_id).await
810 }
811
812 #[doc(hidden)]
814 pub async fn set_sailbox_credential_policy(
815 &self,
816 sailbox_id: &str,
817 policy_id: &str,
818 ) -> Result<(), SailError> {
819 self.credential_api()
820 .attach_sailbox_policy(sailbox_id, policy_id)
821 .await
822 }
823
824 #[doc(hidden)]
826 pub async fn clear_sailbox_credential_policy(&self, sailbox_id: &str) -> Result<(), SailError> {
827 self.credential_api()
828 .detach_sailbox_policy(sailbox_id)
829 .await
830 }
831
832 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
836 app::find_app(&self.inner.api_http, name, mint_if_missing).await
837 }
838
839 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
841 app::list_apps(&self.inner.api_http).await
842 }
843
844 #[doc(hidden)]
854 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
855 let handle = self.resume_sailbox(sailbox_id).await?;
856 if handle.exec_endpoint.is_empty() {
857 return Err(SailError::Internal {
858 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
859 });
860 }
861 Ok(handle.exec_endpoint)
862 }
863
864 #[doc(hidden)]
867 pub async fn exec(
868 &self,
869 sailbox_id: &str,
870 argv: Vec<String>,
871 options: ExecOptions,
872 ) -> Result<ExecProcess, SailError> {
873 self.exec_at_endpoint(sailbox_id, None, argv, options).await
874 }
875
876 #[doc(hidden)]
880 pub async fn exec_at_endpoint(
881 &self,
882 sailbox_id: &str,
883 exec_endpoint: Option<&str>,
884 argv: Vec<String>,
885 options: ExecOptions,
886 ) -> Result<ExecProcess, SailError> {
887 if argv.is_empty() {
888 return Err(SailError::InvalidArgument {
889 message: "command must be non-empty".to_string(),
890 });
891 }
892 if options.cwd.is_some() || options.background {
893 return Err(SailError::InvalidArgument {
894 message: "cwd and background require a shell command; use exec_shell or run_shell"
895 .to_string(),
896 });
897 }
898 let env = crate::exec::encode_env(&options.env)?;
901 let hinted_endpoint = exec_endpoint.filter(|endpoint| !endpoint.is_empty());
902 let exec_endpoint = match hinted_endpoint {
903 Some(endpoint) => endpoint.to_string(),
904 None => self.exec_endpoint(sailbox_id).await?,
905 };
906 let params = ExecParams {
907 sailbox_id: sailbox_id.to_string(),
908 exec_endpoint,
909 argv,
910 timeout_seconds: options
913 .timeout
914 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
915 idempotency_key: options.idempotency_key,
916 open_stdin: options.open_stdin || options.pty,
919 pty: options.pty,
920 term: options.term,
921 cols: options.cols,
922 rows: options.rows,
923 env,
924 retry_timeout: options.retry_timeout.as_secs_f64(),
925 forward_ports: options.forward_ports,
926 forward_browser: options.forward_browser,
927 extra_metadata: Vec::new(),
928 forward_clipboard: options.forward_clipboard && options.pty,
931 };
932 self.start_exec_params_at_endpoint(params, hinted_endpoint.is_some())
933 .await
934 }
935
936 #[doc(hidden)]
940 pub async fn start_exec_params_at_endpoint(
941 &self,
942 mut params: ExecParams,
943 endpoint_was_hint: bool,
944 ) -> Result<ExecProcess, SailError> {
945 if !endpoint_was_hint {
946 return ExecProcess::start(self.worker(), params).await;
947 }
948
949 params.ensure_idempotency_key();
956 let hinted_start =
957 ExecProcess::start_with_initial_retry_timeout(self.worker(), params.clone(), Some(0.0));
958 match tokio::time::timeout(HINTED_EXEC_ENDPOINT_PROBE_TIMEOUT, hinted_start).await {
959 Ok(Ok(process)) => return Ok(process),
960 Ok(Err(err)) if !should_reresolve_hinted_exec_endpoint(&err) => return Err(err),
961 Ok(Err(_)) | Err(_) => {}
962 }
963
964 self.worker().channels().invalidate(¶ms.exec_endpoint);
970 let endpoint = self.exec_endpoint(¶ms.sailbox_id).await?;
971 params.exec_endpoint = endpoint;
972 ExecProcess::start(self.worker(), params).await
973 }
974
975 #[doc(hidden)]
979 pub async fn exec_shell(
980 &self,
981 sailbox_id: &str,
982 command: &str,
983 options: ExecOptions,
984 ) -> Result<ExecProcess, SailError> {
985 self.exec_shell_at_endpoint(sailbox_id, None, command, options)
986 .await
987 }
988
989 #[doc(hidden)]
991 pub async fn exec_shell_at_endpoint(
992 &self,
993 sailbox_id: &str,
994 exec_endpoint: Option<&str>,
995 command: &str,
996 mut options: ExecOptions,
997 ) -> Result<ExecProcess, SailError> {
998 let argv = crate::exec::shell_argv(command, &options)?;
999 options.cwd = None;
1002 options.background = false;
1003 self.exec_at_endpoint(sailbox_id, exec_endpoint, argv, options)
1004 .await
1005 }
1006
1007 #[doc(hidden)]
1015 pub async fn read_stream(
1016 &self,
1017 sailbox_id: &str,
1018 remote_path: &str,
1019 ) -> Result<FileReader, SailError> {
1020 let endpoint = self.exec_endpoint(sailbox_id).await?;
1021 Ok(self
1022 .inner
1023 .worker
1024 .read_file(&endpoint, sailbox_id, remote_path))
1025 }
1026
1027 #[doc(hidden)]
1031 pub async fn read_file(
1032 &self,
1033 sailbox_id: &str,
1034 remote_path: &str,
1035 ) -> Result<Vec<u8>, SailError> {
1036 let reader = self.read_stream(sailbox_id, remote_path).await?;
1037 let mut contents = Vec::new();
1038 while let Some(chunk) = reader.next().await {
1039 contents.extend_from_slice(&chunk?);
1040 }
1041 Ok(contents)
1042 }
1043
1044 #[doc(hidden)]
1053 pub async fn write_stream(
1054 &self,
1055 sailbox_id: &str,
1056 remote_path: &str,
1057 options: WriteOptions,
1058 ) -> Result<FileWriter, SailError> {
1059 let endpoint = self.exec_endpoint(sailbox_id).await?;
1060 Ok(self.inner.worker.write_file(
1061 &endpoint,
1062 sailbox_id,
1063 remote_path,
1064 options.create_parents,
1065 options.mode,
1066 ))
1067 }
1068
1069 #[doc(hidden)]
1073 pub async fn write_file(
1074 &self,
1075 sailbox_id: &str,
1076 remote_path: &str,
1077 data: &[u8],
1078 options: WriteOptions,
1079 ) -> Result<(), SailError> {
1080 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
1081 writer.write(data).await?;
1082 writer.finish().await
1083 }
1084
1085 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
1094 self.exec(sailbox_id, argv, ExecOptions::default())
1095 .await?
1096 .wait()
1097 .await
1098 }
1099
1100 #[doc(hidden)]
1103 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
1104 crate::sailbox::fs::require_path(path)?;
1105 let result = self
1106 .run_argv(
1107 sailbox_id,
1108 vec![
1109 "mkdir".to_string(),
1110 "-p".to_string(),
1111 "--".to_string(),
1112 path.to_string(),
1113 ],
1114 )
1115 .await?;
1116 fs_command_ok(&result, &format!("create directory {path}"))
1117 }
1118
1119 #[doc(hidden)]
1122 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
1123 crate::sailbox::fs::require_path(path)?;
1124 let result = self
1125 .run_argv(
1126 sailbox_id,
1127 vec![
1128 "rm".to_string(),
1129 "-rf".to_string(),
1130 "--".to_string(),
1131 path.to_string(),
1132 ],
1133 )
1134 .await?;
1135 fs_command_ok(&result, &format!("remove {path}"))
1136 }
1137
1138 #[doc(hidden)]
1141 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
1142 crate::sailbox::fs::require_path(path)?;
1143 let result = self
1144 .run_argv(
1145 sailbox_id,
1146 vec!["test".to_string(), "-e".to_string(), path.to_string()],
1147 )
1148 .await?;
1149 match result.exit_code {
1153 0 => Ok(true),
1154 1 => Ok(false),
1155 _ => Err(fs_command_error(
1156 &result,
1157 &format!("check whether {path} exists"),
1158 )),
1159 }
1160 }
1161
1162 #[doc(hidden)]
1166 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
1167 crate::sailbox::fs::require_path(path)?;
1168 let process = self
1169 .exec(
1170 sailbox_id,
1171 crate::sailbox::fs::list_dir_argv(path),
1172 ExecOptions::default(),
1173 )
1174 .await?;
1175 let result = process.wait().await?;
1176 fs_command_ok(&result, &format!("list directory {path}"))?;
1177 if result.stdout_truncated {
1180 return Err(SailError::Execution {
1181 code: RpcStatus::FailedPrecondition,
1182 detail: format!(
1183 "directory listing for {path} was truncated because it has \
1184 too many entries; list a smaller subtree"
1185 ),
1186 });
1187 }
1188 if !result.stdout_complete {
1194 return Err(SailError::Execution {
1195 code: RpcStatus::FailedPrecondition,
1196 detail: format!(
1197 "directory listing for {path} was interrupted before it \
1198 finished streaming; retry the call"
1199 ),
1200 });
1201 }
1202 let mut entries =
1203 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
1204 .map_err(|detail| SailError::Execution {
1205 code: RpcStatus::FailedPrecondition,
1206 detail: format!("directory listing for {path} could not be used: {detail}"),
1207 })?;
1208 if entries.is_empty() {
1211 return Err(SailError::Execution {
1212 code: RpcStatus::FailedPrecondition,
1213 detail: format!(
1214 "directory listing for {path} produced no records; \
1215 listing requires GNU find in the guest"
1216 ),
1217 });
1218 }
1219 let start = entries.remove(0);
1220 if start.entry_type != EntryType::Directory {
1221 return Err(SailError::Execution {
1222 code: RpcStatus::FailedPrecondition,
1223 detail: format!(
1224 "{path} is not a directory (it is a {})",
1225 start.entry_type.as_str()
1226 ),
1227 });
1228 }
1229 Ok(entries)
1230 }
1231}
1232
1233pub(crate) fn duration_to_whole_seconds(duration: Duration) -> i64 {
1237 duration.as_secs_f64().ceil() as i64
1238}
1239
1240fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
1242 if result.exit_code != 0 {
1243 return Err(fs_command_error(result, action));
1244 }
1245 Ok(())
1246}
1247
1248fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
1251 let stderr = result.stderr.trim();
1252 let suffix = if stderr.is_empty() {
1253 String::new()
1254 } else {
1255 format!(": {stderr}")
1256 };
1257 SailError::Execution {
1258 code: RpcStatus::FailedPrecondition,
1259 detail: format!(
1260 "failed to {action} (exit code {}){suffix}",
1261 result.exit_code
1262 ),
1263 }
1264}
1265
1266fn should_reresolve_hinted_exec_endpoint(err: &SailError) -> bool {
1270 err.retryable()
1271 || matches!(
1272 err,
1273 SailError::Terminated { .. } | SailError::HostLost { .. }
1274 )
1275 || matches!(
1276 err,
1277 SailError::Execution {
1278 code: RpcStatus::Unknown | RpcStatus::Internal,
1279 detail,
1280 } if is_transient_transport_message(detail)
1281 )
1282}
1283
1284#[cfg(test)]
1285mod timeout_tests {
1286 use super::*;
1287
1288 #[test]
1289 fn durations_round_up_to_whole_seconds() {
1290 assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1291 assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1292 assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1293 assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1294 }
1295
1296 #[test]
1297 fn hinted_exec_reresolves_source_less_transport_statuses() {
1298 let relayed_transport = SailError::Execution {
1299 code: RpcStatus::Unknown,
1300 detail: "error reading server preface: EOF".to_string(),
1301 };
1302 assert!(should_reresolve_hinted_exec_endpoint(&relayed_transport));
1303
1304 let server_verdict = SailError::Execution {
1305 code: RpcStatus::Unknown,
1306 detail: "application rejected exec".to_string(),
1307 };
1308 assert!(!should_reresolve_hinted_exec_endpoint(&server_verdict));
1309 }
1310}