1use std::sync::Arc;
30use std::time::Duration;
31
32use crate::app::{self, App};
33use crate::config::Config;
34use crate::error::{RpcStatus, 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, WhoAmI,
45};
46use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
47
48#[derive(Clone)]
50pub struct Client {
51 inner: Arc<Inner>,
52}
53
54impl std::fmt::Debug for Client {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("Client")
57 .field("config", &self.inner.config)
58 .finish_non_exhaustive()
59 }
60}
61
62struct Inner {
63 config: Config,
64 sailbox_http: HttpCore,
66 api_http: HttpCore,
68 worker: Arc<WorkerProxy>,
71 imagebuilder: ImageBuilder,
72 image_ready: crate::imagecache::ImageReadyCache,
75}
76
77#[derive(Default, Clone)]
83pub struct ClientBuilder {
84 mode: Option<String>,
85 api_key: Option<String>,
86 api_url: Option<String>,
87 sailbox_api_url: Option<String>,
88 imagebuilder_url: Option<String>,
89 ingress_url: Option<String>,
90 client_label: Option<String>,
91}
92
93impl std::fmt::Debug for ClientBuilder {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("ClientBuilder")
96 .field(
97 "api_key",
98 &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
99 )
100 .field("mode", &self.mode)
101 .field("api_url", &self.api_url)
102 .field("sailbox_api_url", &self.sailbox_api_url)
103 .field("imagebuilder_url", &self.imagebuilder_url)
104 .field("ingress_url", &self.ingress_url)
105 .field("client_label", &self.client_label)
106 .finish()
107 }
108}
109
110impl ClientBuilder {
111 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
114 ClientBuilder {
115 api_key: Some(api_key.into()),
116 ..ClientBuilder::default()
117 }
118 }
119
120 #[doc(hidden)]
123 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
124 self.mode = Some(mode.into());
125 self
126 }
127
128 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
130 self.api_url = Some(api_url.into());
131 self
132 }
133
134 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
136 self.sailbox_api_url = Some(url.into());
137 self
138 }
139
140 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
142 self.imagebuilder_url = Some(url.into());
143 self
144 }
145
146 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
149 self.ingress_url = Some(url.into());
150 self
151 }
152
153 #[doc(hidden)]
155 pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
156 self.client_label = Some(label.into());
157 self
158 }
159
160 pub fn build(self) -> Result<Client, SailError> {
162 let api_key = self.api_key.unwrap_or_default();
163 let config = Config::resolve(
164 self.mode.as_deref(),
165 api_key,
166 self.api_url,
167 self.sailbox_api_url,
168 self.imagebuilder_url,
169 self.ingress_url,
170 )?;
171 Client::from_config_with_label(
172 config,
173 self.client_label
174 .as_deref()
175 .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
176 )
177 }
178}
179
180const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
184
185fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
189 matches!(
190 result,
191 Err(SailError::Creation {
192 status: 409,
193 message,
194 ..
195 }) if message.starts_with("resolve image:")
196 )
197}
198
199impl Client {
200 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
202 ClientBuilder::new(api_key)
203 }
204
205 pub fn from_env() -> Result<Client, SailError> {
207 Client::from_config(Config::from_env()?)
208 }
209
210 #[doc(hidden)]
212 pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
213 Client::from_config_with_label(Config::from_env()?, label)
214 }
215
216 pub fn from_config(config: Config) -> Result<Client, SailError> {
218 Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
219 }
220
221 fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
222 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
223 .with_client_label(client_label);
224 let api_http =
225 HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
226 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
227 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
228 Ok(Client {
229 inner: Arc::new(Inner {
230 config,
231 sailbox_http,
232 api_http,
233 worker,
234 imagebuilder,
235 image_ready: crate::imagecache::ImageReadyCache::new(),
236 }),
237 })
238 }
239
240 pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
241 &self.inner.image_ready
242 }
243
244 #[cfg(any(test, feature = "test-fakes"))]
249 pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
250 self.inner.image_ready.set_refresh_window(window);
251 }
252
253 pub fn config(&self) -> &Config {
255 &self.inner.config
256 }
257
258 #[doc(hidden)]
260 pub fn worker(&self) -> Arc<WorkerProxy> {
261 Arc::clone(&self.inner.worker)
262 }
263
264 #[doc(hidden)]
266 pub fn imagebuilder(&self) -> &ImageBuilder {
267 &self.inner.imagebuilder
268 }
269
270 #[doc(hidden)]
272 pub fn sailbox_http(&self) -> &HttpCore {
273 &self.inner.sailbox_http
274 }
275
276 #[doc(hidden)]
278 pub fn api_http(&self) -> &HttpCore {
279 &self.inner.api_http
280 }
281
282 fn sailbox_api(&self) -> SailboxApi<'_> {
283 SailboxApi::new(&self.inner.sailbox_http)
284 }
285
286 async fn create_with_image_revalidation(
294 &self,
295 req: &CreateSailboxRequest,
296 timeout: Option<Duration>,
297 ) -> Result<SailboxHandle, SailError> {
298 let create_started = std::time::Instant::now();
299 let result = self.sailbox_api().create(req, timeout).await;
300 let custom_image = req.image != crate::image::ImageSpec::default()
301 && !crate::imagebuild::is_builtin_base_spec(&req.image);
302 if !custom_image || !image_not_ready_conflict(&result) {
303 return result;
304 }
305 if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
306 self.image_ready_cache()
311 .invalidate_spec_started_before(&spec_hash, create_started);
312 }
313 let rebuild_timeout = req
317 .image_build_timeout
318 .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
319 let rebuild =
320 self.build_spec_ready_cached(&req.image, rebuild_timeout, true);
321 tokio::time::timeout(rebuild_timeout, rebuild)
322 .await
323 .unwrap_or_else(|_| {
324 Err(SailError::Transport {
325 kind: crate::error::TransportKind::Timeout,
326 message: "timed out building the image".to_string(),
327 source: None,
328 })
329 })?;
330 self.sailbox_api().create(req, timeout).await
331 }
332
333 pub async fn create_sailbox(
345 &self,
346 req: &CreateSailboxRequest,
347 timeout: Option<Duration>,
348 ) -> Result<Sailbox, SailError> {
349 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
350 if !req.ssh {
351 return self
352 .create_with_image_revalidation(req, timeout)
353 .await
354 .map(bind);
355 }
356 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
361 self.org_ssh_ca_public_key().await?;
364 let mut req = req.clone();
369 let ssh_allowlist = req
370 .ingress_ports
371 .iter()
372 .find(|port| port.guest_port == 22)
373 .map(|port| port.allowlist.clone())
374 .unwrap_or_default();
375 req.ingress_ports.retain(|port| port.guest_port != 22);
376 let handle = self.create_with_image_revalidation(&req, timeout).await?;
377 let handle_id = handle.sailbox_id.clone();
378 if let Err(err) = self
380 .enable_ssh(
381 &handle_id,
382 &ssh_allowlist,
383 false,
384 Duration::ZERO,
385 )
386 .await
387 {
388 return Err(SailError::Creation {
391 message: format!(
392 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
393 id to retry enable_ssh or terminate it."
394 ),
395 status: 0,
396 body: serde_json::Value::Null,
397 });
398 }
399 Ok(bind(handle))
400 }
401
402 #[doc(hidden)]
404 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
405 self.sailbox_api().get(sailbox_id).await
406 }
407
408 #[doc(hidden)]
410 pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
411 self.sailbox_api().whoami().await
412 }
413
414 pub async fn list_sailboxes(
416 &self,
417 query: &ListSailboxesQuery,
418 ) -> Result<SailboxPage, SailError> {
419 self.sailbox_api().list(query).await
420 }
421
422 pub async fn sailbox_spend(
424 &self,
425 query: &SailboxSpendQuery,
426 ) -> Result<SailboxSpendResponse, SailError> {
427 self.sailbox_api().spend(query).await
428 }
429
430 pub async fn sailbox_metrics(
432 &self,
433 sailbox_id: &str,
434 query: &SailboxMetricsQuery,
435 ) -> Result<SailboxMetricsResponse, SailError> {
436 self.sailbox_api().metrics(sailbox_id, query).await
437 }
438
439 #[doc(hidden)]
441 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
442 self.sailbox_api().terminate(sailbox_id).await
443 }
444
445 #[doc(hidden)]
447 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
448 self.sailbox_api().pause(sailbox_id).await
449 }
450
451 #[doc(hidden)]
453 pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
454 self.sailbox_api().sleep(sailbox_id).await
455 }
456
457 #[doc(hidden)]
459 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
460 self.sailbox_api().resume(sailbox_id).await
461 }
462
463 #[doc(hidden)]
465 pub async fn checkpoint_sailbox(
466 &self,
467 sailbox_id: &str,
468 name: Option<&str>,
469 ttl_seconds: Option<i64>,
470 ) -> Result<SailboxCheckpoint, SailError> {
471 self.sailbox_api()
472 .checkpoint(sailbox_id, name, ttl_seconds)
473 .await
474 }
475
476 #[doc(hidden)]
479 pub async fn fork_sailbox(
480 &self,
481 sailbox_id: &str,
482 name: Option<&str>,
483 timeout: Option<Duration>,
484 ) -> Result<Sailbox, SailError> {
485 self.sailbox_api()
486 .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
487 .await
488 .map(|handle| Sailbox::bind(self.clone(), handle))
489 }
490
491 pub async fn create_from_checkpoint(
493 &self,
494 checkpoint_id: &str,
495 name: Option<&str>,
496 timeout: Option<Duration>,
497 ) -> Result<Sailbox, SailError> {
498 self.sailbox_api()
499 .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
500 .await
501 .map(|handle| Sailbox::bind(self.clone(), handle))
502 }
503
504 #[doc(hidden)]
506 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
507 self.sailbox_api().upgrade(sailbox_id).await
508 }
509
510 #[doc(hidden)]
512 pub async fn expose_listener(
513 &self,
514 sailbox_id: &str,
515 guest_port: u32,
516 protocol: crate::sailbox::types::IngressProtocol,
517 allowlist: &[String],
518 ) -> Result<Listener, SailError> {
519 let mut response = self
520 .sailbox_api()
521 .expose(sailbox_id, guest_port, protocol, allowlist)
522 .await?;
523 self.fill_listener_url(sailbox_id, &mut response);
524 Ok(response)
525 }
526
527 #[doc(hidden)]
529 pub async fn unexpose_listener(
530 &self,
531 sailbox_id: &str,
532 guest_port: u32,
533 ) -> Result<(), SailError> {
534 self.sailbox_api().unexpose(sailbox_id, guest_port).await
535 }
536
537 #[doc(hidden)]
539 pub async fn list_listeners(
540 &self,
541 sailbox_id: &str,
542 ) -> Result<Vec<crate::worker::Listener>, SailError> {
543 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
544 for listener in &mut listeners {
545 self.fill_listener_url(sailbox_id, listener);
546 }
547 Ok(listeners)
548 }
549
550 #[doc(hidden)]
553 pub async fn get_listener(
554 &self,
555 sailbox_id: &str,
556 guest_port: u32,
557 ) -> Result<crate::worker::Listener, SailError> {
558 let mut listener = self
559 .sailbox_api()
560 .get_listener(sailbox_id, guest_port)
561 .await?;
562 self.fill_listener_url(sailbox_id, &mut listener);
563 Ok(listener)
564 }
565
566 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
570 if listener.public_url.is_empty()
571 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
572 {
573 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
574 self.config(),
575 sailbox_id,
576 listener.guest_port,
577 );
578 }
579 }
580
581 #[doc(hidden)]
583 pub async fn ingress_auth_headers(
584 &self,
585 sailbox_id: &str,
586 ) -> Result<Vec<(String, String)>, SailError> {
587 self.sailbox_api().ingress_auth_headers(sailbox_id).await
588 }
589
590 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
592 self.sailbox_api().org_ssh_ca_public_key().await
593 }
594
595 pub async fn issue_user_cert(
599 &self,
600 public_key: &str,
601 timeout: Option<Duration>,
602 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
603 self.sailbox_api()
604 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
605 .await
606 }
607
608 pub async fn get_volume(
612 &self,
613 name: &str,
614 mint_if_missing: bool,
615 ) -> Result<VolumeInfo, SailError> {
616 self.sailbox_api().get_volume(name, mint_if_missing).await
617 }
618
619 pub async fn list_volumes(
621 &self,
622 max_objects: Option<i64>,
623 ) -> Result<Vec<VolumeInfo>, SailError> {
624 self.sailbox_api().list_volumes(max_objects).await
625 }
626
627 pub async fn delete_volume(
629 &self,
630 volume_id: &str,
631 allow_missing: bool,
632 ) -> Result<Option<VolumeInfo>, SailError> {
633 self.sailbox_api()
634 .delete_volume(volume_id, allow_missing)
635 .await
636 }
637
638 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
642 app::find_app(&self.inner.api_http, name, mint_if_missing).await
643 }
644
645 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
647 app::list_apps(&self.inner.api_http).await
648 }
649
650 #[doc(hidden)]
660 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
661 let handle = self.resume_sailbox(sailbox_id).await?;
662 if handle.exec_endpoint.is_empty() {
663 return Err(SailError::Internal {
664 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
665 });
666 }
667 Ok(handle.exec_endpoint)
668 }
669
670 #[doc(hidden)]
673 pub async fn exec(
674 &self,
675 sailbox_id: &str,
676 argv: Vec<String>,
677 options: ExecOptions,
678 ) -> Result<ExecProcess, SailError> {
679 if argv.is_empty() {
680 return Err(SailError::InvalidArgument {
681 message: "command must be non-empty".to_string(),
682 });
683 }
684 if options.cwd.is_some() || options.background {
685 return Err(SailError::InvalidArgument {
686 message: "cwd and background require a shell command; use exec_shell or run_shell"
687 .to_string(),
688 });
689 }
690 let env = crate::exec::encode_env(&options.env)?;
693 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
694 let params = ExecParams {
695 sailbox_id: sailbox_id.to_string(),
696 exec_endpoint,
697 argv,
698 timeout_seconds: options
701 .timeout
702 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
703 idempotency_key: options.idempotency_key,
704 open_stdin: options.open_stdin || options.pty,
707 pty: options.pty,
708 term: options.term,
709 cols: options.cols,
710 rows: options.rows,
711 env,
712 retry_timeout: options.retry_timeout.as_secs_f64(),
713 forward_ports: options.forward_ports,
714 forward_browser: options.forward_browser,
715 extra_metadata: Vec::new(),
716 };
717 ExecProcess::start(self.worker(), params).await
718 }
719
720 #[doc(hidden)]
724 pub async fn exec_shell(
725 &self,
726 sailbox_id: &str,
727 command: &str,
728 mut options: ExecOptions,
729 ) -> Result<ExecProcess, SailError> {
730 let argv = crate::exec::shell_argv(command, &options)?;
731 options.cwd = None;
734 options.background = false;
735 self.exec(sailbox_id, argv, options).await
736 }
737
738 #[doc(hidden)]
746 pub async fn read_stream(
747 &self,
748 sailbox_id: &str,
749 remote_path: &str,
750 ) -> Result<FileReader, SailError> {
751 let endpoint = self.exec_endpoint(sailbox_id).await?;
752 Ok(self
753 .inner
754 .worker
755 .read_file(&endpoint, sailbox_id, remote_path))
756 }
757
758 #[doc(hidden)]
762 pub async fn read_file(
763 &self,
764 sailbox_id: &str,
765 remote_path: &str,
766 ) -> Result<Vec<u8>, SailError> {
767 let reader = self.read_stream(sailbox_id, remote_path).await?;
768 let mut contents = Vec::new();
769 while let Some(chunk) = reader.next().await {
770 contents.extend_from_slice(&chunk?);
771 }
772 Ok(contents)
773 }
774
775 #[doc(hidden)]
784 pub async fn write_stream(
785 &self,
786 sailbox_id: &str,
787 remote_path: &str,
788 options: WriteOptions,
789 ) -> Result<FileWriter, SailError> {
790 let endpoint = self.exec_endpoint(sailbox_id).await?;
791 Ok(self.inner.worker.write_file(
792 &endpoint,
793 sailbox_id,
794 remote_path,
795 options.create_parents,
796 options.mode,
797 ))
798 }
799
800 #[doc(hidden)]
804 pub async fn write_file(
805 &self,
806 sailbox_id: &str,
807 remote_path: &str,
808 data: &[u8],
809 options: WriteOptions,
810 ) -> Result<(), SailError> {
811 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
812 writer.write(data).await?;
813 writer.finish().await
814 }
815
816 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
825 self.exec(sailbox_id, argv, ExecOptions::default())
826 .await?
827 .wait()
828 .await
829 }
830
831 #[doc(hidden)]
834 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
835 crate::sailbox::fs::require_path(path)?;
836 let result = self
837 .run_argv(
838 sailbox_id,
839 vec![
840 "mkdir".to_string(),
841 "-p".to_string(),
842 "--".to_string(),
843 path.to_string(),
844 ],
845 )
846 .await?;
847 fs_command_ok(&result, &format!("create directory {path}"))
848 }
849
850 #[doc(hidden)]
853 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
854 crate::sailbox::fs::require_path(path)?;
855 let result = self
856 .run_argv(
857 sailbox_id,
858 vec![
859 "rm".to_string(),
860 "-rf".to_string(),
861 "--".to_string(),
862 path.to_string(),
863 ],
864 )
865 .await?;
866 fs_command_ok(&result, &format!("remove {path}"))
867 }
868
869 #[doc(hidden)]
872 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
873 crate::sailbox::fs::require_path(path)?;
874 let result = self
875 .run_argv(
876 sailbox_id,
877 vec!["test".to_string(), "-e".to_string(), path.to_string()],
878 )
879 .await?;
880 match result.exit_code {
884 0 => Ok(true),
885 1 => Ok(false),
886 _ => Err(fs_command_error(
887 &result,
888 &format!("check whether {path} exists"),
889 )),
890 }
891 }
892
893 #[doc(hidden)]
897 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
898 crate::sailbox::fs::require_path(path)?;
899 let process = self
900 .exec(
901 sailbox_id,
902 crate::sailbox::fs::list_dir_argv(path),
903 ExecOptions::default(),
904 )
905 .await?;
906 let result = process.wait().await?;
907 fs_command_ok(&result, &format!("list directory {path}"))?;
908 if result.stdout_truncated {
911 return Err(SailError::Execution {
912 code: RpcStatus::FailedPrecondition,
913 detail: format!(
914 "directory listing for {path} was truncated because it has \
915 too many entries; list a smaller subtree"
916 ),
917 });
918 }
919 if !result.stdout_complete {
925 return Err(SailError::Execution {
926 code: RpcStatus::FailedPrecondition,
927 detail: format!(
928 "directory listing for {path} was interrupted before it \
929 finished streaming; retry the call"
930 ),
931 });
932 }
933 let mut entries =
934 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
935 .map_err(|detail| SailError::Execution {
936 code: RpcStatus::FailedPrecondition,
937 detail: format!("directory listing for {path} could not be used: {detail}"),
938 })?;
939 if entries.is_empty() {
942 return Err(SailError::Execution {
943 code: RpcStatus::FailedPrecondition,
944 detail: format!(
945 "directory listing for {path} produced no records; \
946 listing requires GNU find in the guest"
947 ),
948 });
949 }
950 let start = entries.remove(0);
951 if start.entry_type != EntryType::Directory {
952 return Err(SailError::Execution {
953 code: RpcStatus::FailedPrecondition,
954 detail: format!(
955 "{path} is not a directory (it is a {})",
956 start.entry_type.as_str()
957 ),
958 });
959 }
960 Ok(entries)
961 }
962}
963
964fn duration_to_whole_seconds(timeout: Duration) -> i64 {
968 if timeout.is_zero() {
969 0
970 } else {
971 timeout.as_secs_f64().ceil() as i64
972 }
973}
974
975fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
977 if result.exit_code != 0 {
978 return Err(fs_command_error(result, action));
979 }
980 Ok(())
981}
982
983fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
986 let stderr = result.stderr.trim();
987 let suffix = if stderr.is_empty() {
988 String::new()
989 } else {
990 format!(": {stderr}")
991 };
992 SailError::Execution {
993 code: RpcStatus::FailedPrecondition,
994 detail: format!(
995 "failed to {action} (exit code {}){suffix}",
996 result.exit_code
997 ),
998 }
999}
1000
1001#[cfg(test)]
1002mod timeout_tests {
1003 use super::*;
1004
1005 #[test]
1006 fn durations_round_up_to_whole_seconds() {
1007 assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1008 assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1009 assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1010 assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1011 }
1012}