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::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
53
54#[derive(Clone)]
56pub struct Client {
57 inner: Arc<Inner>,
58}
59
60impl std::fmt::Debug for Client {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("Client")
63 .field("config", &self.inner.config)
64 .finish_non_exhaustive()
65 }
66}
67
68struct Inner {
69 config: Config,
70 sailbox_http: HttpCore,
72 api_http: HttpCore,
74 worker: Arc<WorkerProxy>,
77 imagebuilder: ImageBuilder,
78 image_ready: crate::imagecache::ImageReadyCache,
81}
82
83#[derive(Default, Clone)]
89pub struct ClientBuilder {
90 mode: Option<String>,
91 api_key: Option<String>,
92 api_url: Option<String>,
93 sailbox_api_url: Option<String>,
94 imagebuilder_url: Option<String>,
95 ingress_url: Option<String>,
96 client_label: Option<String>,
97}
98
99impl std::fmt::Debug for ClientBuilder {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct("ClientBuilder")
102 .field(
103 "api_key",
104 &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
105 )
106 .field("mode", &self.mode)
107 .field("api_url", &self.api_url)
108 .field("sailbox_api_url", &self.sailbox_api_url)
109 .field("imagebuilder_url", &self.imagebuilder_url)
110 .field("ingress_url", &self.ingress_url)
111 .field("client_label", &self.client_label)
112 .finish()
113 }
114}
115
116impl ClientBuilder {
117 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
120 ClientBuilder {
121 api_key: Some(api_key.into()),
122 ..ClientBuilder::default()
123 }
124 }
125
126 #[doc(hidden)]
129 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
130 self.mode = Some(mode.into());
131 self
132 }
133
134 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
136 self.api_url = Some(api_url.into());
137 self
138 }
139
140 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
142 self.sailbox_api_url = Some(url.into());
143 self
144 }
145
146 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
148 self.imagebuilder_url = Some(url.into());
149 self
150 }
151
152 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
155 self.ingress_url = Some(url.into());
156 self
157 }
158
159 #[doc(hidden)]
161 pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
162 self.client_label = Some(label.into());
163 self
164 }
165
166 pub fn build(self) -> Result<Client, SailError> {
168 let api_key = self.api_key.unwrap_or_default();
169 let config = Config::resolve(
170 self.mode.as_deref(),
171 api_key,
172 self.api_url,
173 self.sailbox_api_url,
174 self.imagebuilder_url,
175 self.ingress_url,
176 )?;
177 Client::from_config_with_label(
178 config,
179 self.client_label
180 .as_deref()
181 .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
182 )
183 }
184}
185
186const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
190
191fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
195 matches!(
196 result,
197 Err(SailError::Creation {
198 status: 409,
199 message,
200 ..
201 }) if message.starts_with("resolve image:")
202 )
203}
204
205impl Client {
206 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
208 ClientBuilder::new(api_key)
209 }
210
211 pub fn from_env() -> Result<Client, SailError> {
213 Client::from_config(Config::from_env()?)
214 }
215
216 #[doc(hidden)]
218 pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
219 Client::from_config_with_label(Config::from_env()?, label)
220 }
221
222 pub fn from_config(config: Config) -> Result<Client, SailError> {
224 Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
225 }
226
227 fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
228 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
229 .with_client_label(client_label);
230 let api_http =
231 HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
232 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
233 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
234 Ok(Client {
235 inner: Arc::new(Inner {
236 config,
237 sailbox_http,
238 api_http,
239 worker,
240 imagebuilder,
241 image_ready: crate::imagecache::ImageReadyCache::new(),
242 }),
243 })
244 }
245
246 pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
247 &self.inner.image_ready
248 }
249
250 #[cfg(any(test, feature = "test-fakes"))]
255 pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
256 self.inner.image_ready.set_refresh_window(window);
257 }
258
259 pub fn config(&self) -> &Config {
261 &self.inner.config
262 }
263
264 #[doc(hidden)]
266 pub fn worker(&self) -> Arc<WorkerProxy> {
267 Arc::clone(&self.inner.worker)
268 }
269
270 #[doc(hidden)]
272 pub fn imagebuilder(&self) -> &ImageBuilder {
273 &self.inner.imagebuilder
274 }
275
276 #[doc(hidden)]
278 pub fn sailbox_http(&self) -> &HttpCore {
279 &self.inner.sailbox_http
280 }
281
282 #[doc(hidden)]
284 pub fn api_http(&self) -> &HttpCore {
285 &self.inner.api_http
286 }
287
288 fn sailbox_api(&self) -> SailboxApi<'_> {
289 SailboxApi::new(&self.inner.sailbox_http)
290 }
291
292 async fn create_with_image_revalidation(
300 &self,
301 req: &CreateSailboxRequest,
302 timeout: Option<Duration>,
303 ) -> Result<SailboxHandle, SailError> {
304 let create_started = std::time::Instant::now();
305 let result = self.sailbox_api().create(req, timeout).await;
306 let custom_image = req.image != crate::image::ImageSpec::default()
307 && !crate::imagebuild::is_builtin_base_spec(&req.image);
308 if !custom_image || !image_not_ready_conflict(&result) {
309 return result;
310 }
311 if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
312 self.image_ready_cache()
317 .invalidate_spec_started_before(&spec_hash, create_started);
318 }
319 let rebuild_timeout = req
323 .image_build_timeout
324 .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
325 let rebuild =
326 self.build_spec_ready_cached(&req.image, rebuild_timeout, true);
327 tokio::time::timeout(rebuild_timeout, rebuild)
328 .await
329 .unwrap_or_else(|_| {
330 Err(SailError::Transport {
331 kind: crate::error::TransportKind::Timeout,
332 message: "timed out building the image".to_string(),
333 source: None,
334 })
335 })?;
336 self.sailbox_api().create(req, timeout).await
337 }
338
339 pub async fn create_sailbox(
351 &self,
352 req: &CreateSailboxRequest,
353 timeout: Option<Duration>,
354 ) -> Result<Sailbox, SailError> {
355 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
356 if !req.ssh {
357 return self
358 .create_with_image_revalidation(req, timeout)
359 .await
360 .map(bind);
361 }
362 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
367 self.org_ssh_ca_public_key().await?;
370 let mut req = req.clone();
375 let ssh_allowlist = req
376 .ingress_ports
377 .iter()
378 .find(|port| port.guest_port == 22)
379 .map(|port| port.allowlist.clone())
380 .unwrap_or_default();
381 req.ingress_ports.retain(|port| port.guest_port != 22);
382 let handle = self.create_with_image_revalidation(&req, timeout).await?;
383 let handle_id = handle.sailbox_id.clone();
384 if let Err(err) = self
386 .enable_ssh(
387 &handle_id,
388 &ssh_allowlist,
389 false,
390 Duration::ZERO,
391 )
392 .await
393 {
394 return Err(SailError::Creation {
397 message: format!(
398 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
399 id to retry enable_ssh or terminate it."
400 ),
401 status: 0,
402 body: serde_json::Value::Null,
403 });
404 }
405 Ok(bind(handle))
406 }
407
408 #[doc(hidden)]
410 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
411 self.sailbox_api().get(sailbox_id).await
412 }
413
414 #[doc(hidden)]
416 pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
417 self.sailbox_api().whoami().await
418 }
419
420 pub async fn list_sailboxes(
422 &self,
423 query: &ListSailboxesQuery,
424 ) -> Result<SailboxPage, SailError> {
425 self.sailbox_api().list(query).await
426 }
427
428 pub async fn sailbox_spend(
430 &self,
431 query: &SailboxSpendQuery,
432 ) -> Result<SailboxSpendResponse, SailError> {
433 self.sailbox_api().spend(query).await
434 }
435
436 pub async fn sailbox_metrics(
438 &self,
439 sailbox_id: &str,
440 query: &SailboxMetricsQuery,
441 ) -> Result<SailboxMetricsResponse, SailError> {
442 self.sailbox_api().metrics(sailbox_id, query).await
443 }
444
445 #[doc(hidden)]
447 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
448 self.sailbox_api().terminate(sailbox_id).await
449 }
450
451 #[doc(hidden)]
453 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
454 self.sailbox_api().pause(sailbox_id).await
455 }
456
457 #[doc(hidden)]
459 pub async fn sleep_sailbox(
460 &self,
461 sailbox_id: &str,
462 wake_at: Option<OffsetDateTime>,
463 ) -> Result<Option<OffsetDateTime>, SailError> {
464 self.sailbox_api().sleep(sailbox_id, wake_at).await
465 }
466
467 #[doc(hidden)]
469 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
470 self.sailbox_api().resume(sailbox_id).await
471 }
472
473 #[doc(hidden)]
475 pub async fn checkpoint_sailbox(
476 &self,
477 sailbox_id: &str,
478 name: Option<&str>,
479 ttl_seconds: Option<i64>,
480 ) -> Result<SailboxCheckpoint, SailError> {
481 self.sailbox_api()
482 .checkpoint(sailbox_id, name, ttl_seconds)
483 .await
484 }
485
486 #[doc(hidden)]
489 pub async fn fork_sailbox(
490 &self,
491 sailbox_id: &str,
492 name: Option<&str>,
493 timeout: Option<Duration>,
494 ) -> Result<Sailbox, SailError> {
495 self.sailbox_api()
496 .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
497 .await
498 .map(|handle| Sailbox::bind(self.clone(), handle))
499 }
500
501 pub async fn create_from_checkpoint(
503 &self,
504 checkpoint_id: &str,
505 name: Option<&str>,
506 timeout: Option<Duration>,
507 ) -> Result<Sailbox, SailError> {
508 self.sailbox_api()
509 .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
510 .await
511 .map(|handle| Sailbox::bind(self.clone(), handle))
512 }
513
514 #[doc(hidden)]
516 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
517 self.sailbox_api().upgrade(sailbox_id).await
518 }
519
520 #[doc(hidden)]
522 pub async fn expose_listener(
523 &self,
524 sailbox_id: &str,
525 guest_port: u32,
526 protocol: crate::sailbox::types::IngressProtocol,
527 allowlist: &[String],
528 ) -> Result<Listener, SailError> {
529 let mut response = self
530 .sailbox_api()
531 .expose(sailbox_id, guest_port, protocol, allowlist)
532 .await?;
533 self.fill_listener_url(sailbox_id, &mut response);
534 Ok(response)
535 }
536
537 #[doc(hidden)]
539 pub async fn unexpose_listener(
540 &self,
541 sailbox_id: &str,
542 guest_port: u32,
543 ) -> Result<(), SailError> {
544 self.sailbox_api().unexpose(sailbox_id, guest_port).await
545 }
546
547 #[doc(hidden)]
549 pub async fn list_listeners(
550 &self,
551 sailbox_id: &str,
552 ) -> Result<Vec<crate::worker::Listener>, SailError> {
553 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
554 for listener in &mut listeners {
555 self.fill_listener_url(sailbox_id, listener);
556 }
557 Ok(listeners)
558 }
559
560 #[doc(hidden)]
563 pub async fn get_listener(
564 &self,
565 sailbox_id: &str,
566 guest_port: u32,
567 ) -> Result<crate::worker::Listener, SailError> {
568 let mut listener = self
569 .sailbox_api()
570 .get_listener(sailbox_id, guest_port)
571 .await?;
572 self.fill_listener_url(sailbox_id, &mut listener);
573 Ok(listener)
574 }
575
576 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
580 if listener.public_url.is_empty()
581 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
582 {
583 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
584 self.config(),
585 sailbox_id,
586 listener.guest_port,
587 );
588 }
589 }
590
591 #[doc(hidden)]
593 pub async fn ingress_auth_headers(
594 &self,
595 sailbox_id: &str,
596 ) -> Result<Vec<(String, String)>, SailError> {
597 self.sailbox_api().ingress_auth_headers(sailbox_id).await
598 }
599
600 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
602 self.sailbox_api().org_ssh_ca_public_key().await
603 }
604
605 pub async fn issue_user_cert(
609 &self,
610 public_key: &str,
611 timeout: Option<Duration>,
612 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
613 self.sailbox_api()
614 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
615 .await
616 }
617
618 pub async fn get_volume(
622 &self,
623 name: &str,
624 mint_if_missing: bool,
625 ) -> Result<VolumeInfo, SailError> {
626 self.sailbox_api().get_volume(name, mint_if_missing).await
627 }
628
629 pub async fn list_volumes(
631 &self,
632 max_objects: Option<i64>,
633 ) -> Result<Vec<VolumeInfo>, SailError> {
634 self.sailbox_api().list_volumes(max_objects).await
635 }
636
637 pub async fn delete_volume(
639 &self,
640 volume_id: &str,
641 allow_missing: bool,
642 ) -> Result<Option<VolumeInfo>, SailError> {
643 self.sailbox_api()
644 .delete_volume(volume_id, allow_missing)
645 .await
646 }
647
648 fn credential_api(&self) -> CredentialApi<'_> {
655 CredentialApi::new(&self.inner.sailbox_http)
656 }
657
658 #[doc(hidden)]
660 pub async fn set_secret(&self, name: &str, value: &str) -> Result<SecretInfo, SailError> {
661 self.credential_api().set_secret(name, value).await
662 }
663
664 #[doc(hidden)]
666 pub async fn get_secret(&self, name: &str) -> Result<SecretInfo, SailError> {
667 self.credential_api().get_secret(name).await
668 }
669
670 #[doc(hidden)]
672 pub async fn list_secrets(&self) -> Result<Vec<SecretInfo>, SailError> {
673 self.credential_api().list_secrets().await
674 }
675
676 #[doc(hidden)]
678 pub async fn delete_secret(&self, name: &str) -> Result<(), SailError> {
679 self.credential_api().delete_secret(name).await
680 }
681
682 #[doc(hidden)]
684 pub async fn create_credential_policy(
685 &self,
686 name: &str,
687 rules: &[InjectionRule],
688 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
689 self.credential_api().create_policy(name, rules).await
690 }
691
692 #[doc(hidden)]
694 pub async fn get_credential_policy(
695 &self,
696 policy_id: &str,
697 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
698 self.credential_api().get_policy(policy_id).await
699 }
700
701 #[doc(hidden)]
703 pub async fn list_credential_policies(
704 &self,
705 query: &ListCredentialInjectionPoliciesQuery,
706 ) -> Result<CredentialInjectionPolicyPage, SailError> {
707 self.credential_api().list_policies(query).await
708 }
709
710 #[doc(hidden)]
712 pub async fn rename_credential_policy(
713 &self,
714 policy_id: &str,
715 name: &str,
716 ) -> Result<CredentialInjectionPolicyInfo, SailError> {
717 self.credential_api().rename_policy(policy_id, name).await
718 }
719
720 #[doc(hidden)]
722 pub async fn delete_credential_policy(&self, policy_id: &str) -> Result<(), SailError> {
723 self.credential_api().delete_policy(policy_id).await
724 }
725
726 #[doc(hidden)]
728 pub async fn sailbox_credential_policy(
729 &self,
730 sailbox_id: &str,
731 ) -> Result<Option<CredentialInjectionPolicyInfo>, SailError> {
732 self.credential_api().sailbox_policy(sailbox_id).await
733 }
734
735 #[doc(hidden)]
737 pub async fn set_sailbox_credential_policy(
738 &self,
739 sailbox_id: &str,
740 policy_id: &str,
741 ) -> Result<(), SailError> {
742 self.credential_api()
743 .attach_sailbox_policy(sailbox_id, policy_id)
744 .await
745 }
746
747 #[doc(hidden)]
749 pub async fn clear_sailbox_credential_policy(&self, sailbox_id: &str) -> Result<(), SailError> {
750 self.credential_api()
751 .detach_sailbox_policy(sailbox_id)
752 .await
753 }
754
755 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
759 app::find_app(&self.inner.api_http, name, mint_if_missing).await
760 }
761
762 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
764 app::list_apps(&self.inner.api_http).await
765 }
766
767 #[doc(hidden)]
777 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
778 let handle = self.resume_sailbox(sailbox_id).await?;
779 if handle.exec_endpoint.is_empty() {
780 return Err(SailError::Internal {
781 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
782 });
783 }
784 Ok(handle.exec_endpoint)
785 }
786
787 #[doc(hidden)]
790 pub async fn exec(
791 &self,
792 sailbox_id: &str,
793 argv: Vec<String>,
794 options: ExecOptions,
795 ) -> Result<ExecProcess, SailError> {
796 if argv.is_empty() {
797 return Err(SailError::InvalidArgument {
798 message: "command must be non-empty".to_string(),
799 });
800 }
801 if options.cwd.is_some() || options.background {
802 return Err(SailError::InvalidArgument {
803 message: "cwd and background require a shell command; use exec_shell or run_shell"
804 .to_string(),
805 });
806 }
807 let env = crate::exec::encode_env(&options.env)?;
810 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
811 let params = ExecParams {
812 sailbox_id: sailbox_id.to_string(),
813 exec_endpoint,
814 argv,
815 timeout_seconds: options
818 .timeout
819 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
820 idempotency_key: options.idempotency_key,
821 open_stdin: options.open_stdin || options.pty,
824 pty: options.pty,
825 term: options.term,
826 cols: options.cols,
827 rows: options.rows,
828 env,
829 retry_timeout: options.retry_timeout.as_secs_f64(),
830 forward_ports: options.forward_ports,
831 forward_browser: options.forward_browser,
832 extra_metadata: Vec::new(),
833 forward_clipboard: options.forward_clipboard && options.pty,
836 };
837 ExecProcess::start(self.worker(), params).await
838 }
839
840 #[doc(hidden)]
844 pub async fn exec_shell(
845 &self,
846 sailbox_id: &str,
847 command: &str,
848 mut options: ExecOptions,
849 ) -> Result<ExecProcess, SailError> {
850 let argv = crate::exec::shell_argv(command, &options)?;
851 options.cwd = None;
854 options.background = false;
855 self.exec(sailbox_id, argv, options).await
856 }
857
858 #[doc(hidden)]
866 pub async fn read_stream(
867 &self,
868 sailbox_id: &str,
869 remote_path: &str,
870 ) -> Result<FileReader, SailError> {
871 let endpoint = self.exec_endpoint(sailbox_id).await?;
872 Ok(self
873 .inner
874 .worker
875 .read_file(&endpoint, sailbox_id, remote_path))
876 }
877
878 #[doc(hidden)]
882 pub async fn read_file(
883 &self,
884 sailbox_id: &str,
885 remote_path: &str,
886 ) -> Result<Vec<u8>, SailError> {
887 let reader = self.read_stream(sailbox_id, remote_path).await?;
888 let mut contents = Vec::new();
889 while let Some(chunk) = reader.next().await {
890 contents.extend_from_slice(&chunk?);
891 }
892 Ok(contents)
893 }
894
895 #[doc(hidden)]
904 pub async fn write_stream(
905 &self,
906 sailbox_id: &str,
907 remote_path: &str,
908 options: WriteOptions,
909 ) -> Result<FileWriter, SailError> {
910 let endpoint = self.exec_endpoint(sailbox_id).await?;
911 Ok(self.inner.worker.write_file(
912 &endpoint,
913 sailbox_id,
914 remote_path,
915 options.create_parents,
916 options.mode,
917 ))
918 }
919
920 #[doc(hidden)]
924 pub async fn write_file(
925 &self,
926 sailbox_id: &str,
927 remote_path: &str,
928 data: &[u8],
929 options: WriteOptions,
930 ) -> Result<(), SailError> {
931 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
932 writer.write(data).await?;
933 writer.finish().await
934 }
935
936 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
945 self.exec(sailbox_id, argv, ExecOptions::default())
946 .await?
947 .wait()
948 .await
949 }
950
951 #[doc(hidden)]
954 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
955 crate::sailbox::fs::require_path(path)?;
956 let result = self
957 .run_argv(
958 sailbox_id,
959 vec![
960 "mkdir".to_string(),
961 "-p".to_string(),
962 "--".to_string(),
963 path.to_string(),
964 ],
965 )
966 .await?;
967 fs_command_ok(&result, &format!("create directory {path}"))
968 }
969
970 #[doc(hidden)]
973 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
974 crate::sailbox::fs::require_path(path)?;
975 let result = self
976 .run_argv(
977 sailbox_id,
978 vec![
979 "rm".to_string(),
980 "-rf".to_string(),
981 "--".to_string(),
982 path.to_string(),
983 ],
984 )
985 .await?;
986 fs_command_ok(&result, &format!("remove {path}"))
987 }
988
989 #[doc(hidden)]
992 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
993 crate::sailbox::fs::require_path(path)?;
994 let result = self
995 .run_argv(
996 sailbox_id,
997 vec!["test".to_string(), "-e".to_string(), path.to_string()],
998 )
999 .await?;
1000 match result.exit_code {
1004 0 => Ok(true),
1005 1 => Ok(false),
1006 _ => Err(fs_command_error(
1007 &result,
1008 &format!("check whether {path} exists"),
1009 )),
1010 }
1011 }
1012
1013 #[doc(hidden)]
1017 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
1018 crate::sailbox::fs::require_path(path)?;
1019 let process = self
1020 .exec(
1021 sailbox_id,
1022 crate::sailbox::fs::list_dir_argv(path),
1023 ExecOptions::default(),
1024 )
1025 .await?;
1026 let result = process.wait().await?;
1027 fs_command_ok(&result, &format!("list directory {path}"))?;
1028 if result.stdout_truncated {
1031 return Err(SailError::Execution {
1032 code: RpcStatus::FailedPrecondition,
1033 detail: format!(
1034 "directory listing for {path} was truncated because it has \
1035 too many entries; list a smaller subtree"
1036 ),
1037 });
1038 }
1039 if !result.stdout_complete {
1045 return Err(SailError::Execution {
1046 code: RpcStatus::FailedPrecondition,
1047 detail: format!(
1048 "directory listing for {path} was interrupted before it \
1049 finished streaming; retry the call"
1050 ),
1051 });
1052 }
1053 let mut entries =
1054 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
1055 .map_err(|detail| SailError::Execution {
1056 code: RpcStatus::FailedPrecondition,
1057 detail: format!("directory listing for {path} could not be used: {detail}"),
1058 })?;
1059 if entries.is_empty() {
1062 return Err(SailError::Execution {
1063 code: RpcStatus::FailedPrecondition,
1064 detail: format!(
1065 "directory listing for {path} produced no records; \
1066 listing requires GNU find in the guest"
1067 ),
1068 });
1069 }
1070 let start = entries.remove(0);
1071 if start.entry_type != EntryType::Directory {
1072 return Err(SailError::Execution {
1073 code: RpcStatus::FailedPrecondition,
1074 detail: format!(
1075 "{path} is not a directory (it is a {})",
1076 start.entry_type.as_str()
1077 ),
1078 });
1079 }
1080 Ok(entries)
1081 }
1082}
1083
1084fn duration_to_whole_seconds(timeout: Duration) -> i64 {
1088 if timeout.is_zero() {
1089 0
1090 } else {
1091 timeout.as_secs_f64().ceil() as i64
1092 }
1093}
1094
1095fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
1097 if result.exit_code != 0 {
1098 return Err(fs_command_error(result, action));
1099 }
1100 Ok(())
1101}
1102
1103fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
1106 let stderr = result.stderr.trim();
1107 let suffix = if stderr.is_empty() {
1108 String::new()
1109 } else {
1110 format!(": {stderr}")
1111 };
1112 SailError::Execution {
1113 code: RpcStatus::FailedPrecondition,
1114 detail: format!(
1115 "failed to {action} (exit code {}){suffix}",
1116 result.exit_code
1117 ),
1118 }
1119}
1120
1121#[cfg(test)]
1122mod timeout_tests {
1123 use super::*;
1124
1125 #[test]
1126 fn durations_round_up_to_whole_seconds() {
1127 assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1128 assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1129 assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1130 assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1131 }
1132}