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 forward_clipboard: options.forward_clipboard && options.pty,
719 };
720 ExecProcess::start(self.worker(), params).await
721 }
722
723 #[doc(hidden)]
727 pub async fn exec_shell(
728 &self,
729 sailbox_id: &str,
730 command: &str,
731 mut options: ExecOptions,
732 ) -> Result<ExecProcess, SailError> {
733 let argv = crate::exec::shell_argv(command, &options)?;
734 options.cwd = None;
737 options.background = false;
738 self.exec(sailbox_id, argv, options).await
739 }
740
741 #[doc(hidden)]
749 pub async fn read_stream(
750 &self,
751 sailbox_id: &str,
752 remote_path: &str,
753 ) -> Result<FileReader, SailError> {
754 let endpoint = self.exec_endpoint(sailbox_id).await?;
755 Ok(self
756 .inner
757 .worker
758 .read_file(&endpoint, sailbox_id, remote_path))
759 }
760
761 #[doc(hidden)]
765 pub async fn read_file(
766 &self,
767 sailbox_id: &str,
768 remote_path: &str,
769 ) -> Result<Vec<u8>, SailError> {
770 let reader = self.read_stream(sailbox_id, remote_path).await?;
771 let mut contents = Vec::new();
772 while let Some(chunk) = reader.next().await {
773 contents.extend_from_slice(&chunk?);
774 }
775 Ok(contents)
776 }
777
778 #[doc(hidden)]
787 pub async fn write_stream(
788 &self,
789 sailbox_id: &str,
790 remote_path: &str,
791 options: WriteOptions,
792 ) -> Result<FileWriter, SailError> {
793 let endpoint = self.exec_endpoint(sailbox_id).await?;
794 Ok(self.inner.worker.write_file(
795 &endpoint,
796 sailbox_id,
797 remote_path,
798 options.create_parents,
799 options.mode,
800 ))
801 }
802
803 #[doc(hidden)]
807 pub async fn write_file(
808 &self,
809 sailbox_id: &str,
810 remote_path: &str,
811 data: &[u8],
812 options: WriteOptions,
813 ) -> Result<(), SailError> {
814 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
815 writer.write(data).await?;
816 writer.finish().await
817 }
818
819 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
828 self.exec(sailbox_id, argv, ExecOptions::default())
829 .await?
830 .wait()
831 .await
832 }
833
834 #[doc(hidden)]
837 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
838 crate::sailbox::fs::require_path(path)?;
839 let result = self
840 .run_argv(
841 sailbox_id,
842 vec![
843 "mkdir".to_string(),
844 "-p".to_string(),
845 "--".to_string(),
846 path.to_string(),
847 ],
848 )
849 .await?;
850 fs_command_ok(&result, &format!("create directory {path}"))
851 }
852
853 #[doc(hidden)]
856 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
857 crate::sailbox::fs::require_path(path)?;
858 let result = self
859 .run_argv(
860 sailbox_id,
861 vec![
862 "rm".to_string(),
863 "-rf".to_string(),
864 "--".to_string(),
865 path.to_string(),
866 ],
867 )
868 .await?;
869 fs_command_ok(&result, &format!("remove {path}"))
870 }
871
872 #[doc(hidden)]
875 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
876 crate::sailbox::fs::require_path(path)?;
877 let result = self
878 .run_argv(
879 sailbox_id,
880 vec!["test".to_string(), "-e".to_string(), path.to_string()],
881 )
882 .await?;
883 match result.exit_code {
887 0 => Ok(true),
888 1 => Ok(false),
889 _ => Err(fs_command_error(
890 &result,
891 &format!("check whether {path} exists"),
892 )),
893 }
894 }
895
896 #[doc(hidden)]
900 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
901 crate::sailbox::fs::require_path(path)?;
902 let process = self
903 .exec(
904 sailbox_id,
905 crate::sailbox::fs::list_dir_argv(path),
906 ExecOptions::default(),
907 )
908 .await?;
909 let result = process.wait().await?;
910 fs_command_ok(&result, &format!("list directory {path}"))?;
911 if result.stdout_truncated {
914 return Err(SailError::Execution {
915 code: RpcStatus::FailedPrecondition,
916 detail: format!(
917 "directory listing for {path} was truncated because it has \
918 too many entries; list a smaller subtree"
919 ),
920 });
921 }
922 if !result.stdout_complete {
928 return Err(SailError::Execution {
929 code: RpcStatus::FailedPrecondition,
930 detail: format!(
931 "directory listing for {path} was interrupted before it \
932 finished streaming; retry the call"
933 ),
934 });
935 }
936 let mut entries =
937 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
938 .map_err(|detail| SailError::Execution {
939 code: RpcStatus::FailedPrecondition,
940 detail: format!("directory listing for {path} could not be used: {detail}"),
941 })?;
942 if entries.is_empty() {
945 return Err(SailError::Execution {
946 code: RpcStatus::FailedPrecondition,
947 detail: format!(
948 "directory listing for {path} produced no records; \
949 listing requires GNU find in the guest"
950 ),
951 });
952 }
953 let start = entries.remove(0);
954 if start.entry_type != EntryType::Directory {
955 return Err(SailError::Execution {
956 code: RpcStatus::FailedPrecondition,
957 detail: format!(
958 "{path} is not a directory (it is a {})",
959 start.entry_type.as_str()
960 ),
961 });
962 }
963 Ok(entries)
964 }
965}
966
967fn duration_to_whole_seconds(timeout: Duration) -> i64 {
971 if timeout.is_zero() {
972 0
973 } else {
974 timeout.as_secs_f64().ceil() as i64
975 }
976}
977
978fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
980 if result.exit_code != 0 {
981 return Err(fs_command_error(result, action));
982 }
983 Ok(())
984}
985
986fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
989 let stderr = result.stderr.trim();
990 let suffix = if stderr.is_empty() {
991 String::new()
992 } else {
993 format!(": {stderr}")
994 };
995 SailError::Execution {
996 code: RpcStatus::FailedPrecondition,
997 detail: format!(
998 "failed to {action} (exit code {}){suffix}",
999 result.exit_code
1000 ),
1001 }
1002}
1003
1004#[cfg(test)]
1005mod timeout_tests {
1006 use super::*;
1007
1008 #[test]
1009 fn durations_round_up_to_whole_seconds() {
1010 assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1011 assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1012 assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1013 assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1014 }
1015}