1use std::sync::Arc;
30use std::time::Duration;
31use time::OffsetDateTime;
32
33use crate::app::{self, App};
34use crate::config::Config;
35use crate::error::{RpcStatus, SailError};
36use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
37use crate::http::HttpCore;
38use crate::imagebuilder::ImageBuilder;
39use crate::sailbox::api::{SailboxApi, UpgradeResult};
40use crate::sailbox::fs::{DirEntry, EntryType};
41use crate::sailbox::object::Sailbox;
42use crate::sailbox::types::{
43 CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
44 SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
45 SailboxSpendResponse, VolumeInfo, WhoAmI,
46};
47use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
48
49#[derive(Clone)]
51pub struct Client {
52 inner: Arc<Inner>,
53}
54
55impl std::fmt::Debug for Client {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("Client")
58 .field("config", &self.inner.config)
59 .finish_non_exhaustive()
60 }
61}
62
63struct Inner {
64 config: Config,
65 sailbox_http: HttpCore,
67 api_http: HttpCore,
69 worker: Arc<WorkerProxy>,
72 imagebuilder: ImageBuilder,
73 image_ready: crate::imagecache::ImageReadyCache,
76}
77
78#[derive(Default, Clone)]
84pub struct ClientBuilder {
85 mode: Option<String>,
86 api_key: Option<String>,
87 api_url: Option<String>,
88 sailbox_api_url: Option<String>,
89 imagebuilder_url: Option<String>,
90 ingress_url: Option<String>,
91 client_label: Option<String>,
92}
93
94impl std::fmt::Debug for ClientBuilder {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 f.debug_struct("ClientBuilder")
97 .field(
98 "api_key",
99 &crate::config::redact_key(self.api_key.as_deref().unwrap_or("")),
100 )
101 .field("mode", &self.mode)
102 .field("api_url", &self.api_url)
103 .field("sailbox_api_url", &self.sailbox_api_url)
104 .field("imagebuilder_url", &self.imagebuilder_url)
105 .field("ingress_url", &self.ingress_url)
106 .field("client_label", &self.client_label)
107 .finish()
108 }
109}
110
111impl ClientBuilder {
112 pub fn new(api_key: impl Into<String>) -> ClientBuilder {
115 ClientBuilder {
116 api_key: Some(api_key.into()),
117 ..ClientBuilder::default()
118 }
119 }
120
121 #[doc(hidden)]
124 pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
125 self.mode = Some(mode.into());
126 self
127 }
128
129 pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
131 self.api_url = Some(api_url.into());
132 self
133 }
134
135 pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
137 self.sailbox_api_url = Some(url.into());
138 self
139 }
140
141 pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
143 self.imagebuilder_url = Some(url.into());
144 self
145 }
146
147 pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
150 self.ingress_url = Some(url.into());
151 self
152 }
153
154 #[doc(hidden)]
156 pub fn client_label(mut self, label: impl Into<String>) -> ClientBuilder {
157 self.client_label = Some(label.into());
158 self
159 }
160
161 pub fn build(self) -> Result<Client, SailError> {
163 let api_key = self.api_key.unwrap_or_default();
164 let config = Config::resolve(
165 self.mode.as_deref(),
166 api_key,
167 self.api_url,
168 self.sailbox_api_url,
169 self.imagebuilder_url,
170 self.ingress_url,
171 )?;
172 Client::from_config_with_label(
173 config,
174 self.client_label
175 .as_deref()
176 .unwrap_or(crate::http::DEFAULT_CLIENT_LABEL),
177 )
178 }
179}
180
181const STALE_IMAGE_REBUILD_TIMEOUT: Duration = Duration::from_mins(30);
185
186fn image_not_ready_conflict(result: &Result<SailboxHandle, SailError>) -> bool {
190 matches!(
191 result,
192 Err(SailError::Creation {
193 status: 409,
194 message,
195 ..
196 }) if message.starts_with("resolve image:")
197 )
198}
199
200impl Client {
201 pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
203 ClientBuilder::new(api_key)
204 }
205
206 pub fn from_env() -> Result<Client, SailError> {
208 Client::from_config(Config::from_env()?)
209 }
210
211 #[doc(hidden)]
213 pub fn from_env_with_label(label: &str) -> Result<Client, SailError> {
214 Client::from_config_with_label(Config::from_env()?, label)
215 }
216
217 pub fn from_config(config: Config) -> Result<Client, SailError> {
219 Client::from_config_with_label(config, crate::http::DEFAULT_CLIENT_LABEL)
220 }
221
222 fn from_config_with_label(config: Config, client_label: &str) -> Result<Client, SailError> {
223 let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?
224 .with_client_label(client_label);
225 let api_http =
226 HttpCore::new(&config.api_url, &config.api_key)?.with_client_label(client_label);
227 let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
228 let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
229 Ok(Client {
230 inner: Arc::new(Inner {
231 config,
232 sailbox_http,
233 api_http,
234 worker,
235 imagebuilder,
236 image_ready: crate::imagecache::ImageReadyCache::new(),
237 }),
238 })
239 }
240
241 pub(crate) fn image_ready_cache(&self) -> &crate::imagecache::ImageReadyCache {
242 &self.inner.image_ready
243 }
244
245 #[cfg(any(test, feature = "test-fakes"))]
250 pub fn set_image_ready_refresh_window(&self, window: std::time::Duration) {
251 self.inner.image_ready.set_refresh_window(window);
252 }
253
254 pub fn config(&self) -> &Config {
256 &self.inner.config
257 }
258
259 #[doc(hidden)]
261 pub fn worker(&self) -> Arc<WorkerProxy> {
262 Arc::clone(&self.inner.worker)
263 }
264
265 #[doc(hidden)]
267 pub fn imagebuilder(&self) -> &ImageBuilder {
268 &self.inner.imagebuilder
269 }
270
271 #[doc(hidden)]
273 pub fn sailbox_http(&self) -> &HttpCore {
274 &self.inner.sailbox_http
275 }
276
277 #[doc(hidden)]
279 pub fn api_http(&self) -> &HttpCore {
280 &self.inner.api_http
281 }
282
283 fn sailbox_api(&self) -> SailboxApi<'_> {
284 SailboxApi::new(&self.inner.sailbox_http)
285 }
286
287 async fn create_with_image_revalidation(
295 &self,
296 req: &CreateSailboxRequest,
297 timeout: Option<Duration>,
298 ) -> Result<SailboxHandle, SailError> {
299 let create_started = std::time::Instant::now();
300 let result = self.sailbox_api().create(req, timeout).await;
301 let custom_image = req.image != crate::image::ImageSpec::default()
302 && !crate::imagebuild::is_builtin_base_spec(&req.image);
303 if !custom_image || !image_not_ready_conflict(&result) {
304 return result;
305 }
306 if let Ok(spec_hash) = crate::imagebuild::canonical_spec_key(&req.image) {
307 self.image_ready_cache()
312 .invalidate_spec_started_before(&spec_hash, create_started);
313 }
314 let rebuild_timeout = req
318 .image_build_timeout
319 .unwrap_or(STALE_IMAGE_REBUILD_TIMEOUT);
320 let rebuild =
321 self.build_spec_ready_cached(&req.image, rebuild_timeout, true);
322 tokio::time::timeout(rebuild_timeout, rebuild)
323 .await
324 .unwrap_or_else(|_| {
325 Err(SailError::Transport {
326 kind: crate::error::TransportKind::Timeout,
327 message: "timed out building the image".to_string(),
328 source: None,
329 })
330 })?;
331 self.sailbox_api().create(req, timeout).await
332 }
333
334 pub async fn create_sailbox(
346 &self,
347 req: &CreateSailboxRequest,
348 timeout: Option<Duration>,
349 ) -> Result<Sailbox, SailError> {
350 let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
351 if !req.ssh {
352 return self
353 .create_with_image_revalidation(req, timeout)
354 .await
355 .map(bind);
356 }
357 crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
362 self.org_ssh_ca_public_key().await?;
365 let mut req = req.clone();
370 let ssh_allowlist = req
371 .ingress_ports
372 .iter()
373 .find(|port| port.guest_port == 22)
374 .map(|port| port.allowlist.clone())
375 .unwrap_or_default();
376 req.ingress_ports.retain(|port| port.guest_port != 22);
377 let handle = self.create_with_image_revalidation(&req, timeout).await?;
378 let handle_id = handle.sailbox_id.clone();
379 if let Err(err) = self
381 .enable_ssh(
382 &handle_id,
383 &ssh_allowlist,
384 false,
385 Duration::ZERO,
386 )
387 .await
388 {
389 return Err(SailError::Creation {
392 message: format!(
393 "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
394 id to retry enable_ssh or terminate it."
395 ),
396 status: 0,
397 body: serde_json::Value::Null,
398 });
399 }
400 Ok(bind(handle))
401 }
402
403 #[doc(hidden)]
405 pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
406 self.sailbox_api().get(sailbox_id).await
407 }
408
409 #[doc(hidden)]
411 pub async fn whoami(&self) -> Result<WhoAmI, SailError> {
412 self.sailbox_api().whoami().await
413 }
414
415 pub async fn list_sailboxes(
417 &self,
418 query: &ListSailboxesQuery,
419 ) -> Result<SailboxPage, SailError> {
420 self.sailbox_api().list(query).await
421 }
422
423 pub async fn sailbox_spend(
425 &self,
426 query: &SailboxSpendQuery,
427 ) -> Result<SailboxSpendResponse, SailError> {
428 self.sailbox_api().spend(query).await
429 }
430
431 pub async fn sailbox_metrics(
433 &self,
434 sailbox_id: &str,
435 query: &SailboxMetricsQuery,
436 ) -> Result<SailboxMetricsResponse, SailError> {
437 self.sailbox_api().metrics(sailbox_id, query).await
438 }
439
440 #[doc(hidden)]
442 pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
443 self.sailbox_api().terminate(sailbox_id).await
444 }
445
446 #[doc(hidden)]
448 pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
449 self.sailbox_api().pause(sailbox_id).await
450 }
451
452 #[doc(hidden)]
454 pub async fn sleep_sailbox(
455 &self,
456 sailbox_id: &str,
457 wake_at: Option<OffsetDateTime>,
458 ) -> Result<Option<OffsetDateTime>, SailError> {
459 self.sailbox_api().sleep(sailbox_id, wake_at).await
460 }
461
462 #[doc(hidden)]
464 pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
465 self.sailbox_api().resume(sailbox_id).await
466 }
467
468 #[doc(hidden)]
470 pub async fn checkpoint_sailbox(
471 &self,
472 sailbox_id: &str,
473 name: Option<&str>,
474 ttl_seconds: Option<i64>,
475 ) -> Result<SailboxCheckpoint, SailError> {
476 self.sailbox_api()
477 .checkpoint(sailbox_id, name, ttl_seconds)
478 .await
479 }
480
481 #[doc(hidden)]
484 pub async fn fork_sailbox(
485 &self,
486 sailbox_id: &str,
487 name: Option<&str>,
488 timeout: Option<Duration>,
489 ) -> Result<Sailbox, SailError> {
490 self.sailbox_api()
491 .fork(sailbox_id, name, timeout.map(duration_to_whole_seconds))
492 .await
493 .map(|handle| Sailbox::bind(self.clone(), handle))
494 }
495
496 pub async fn create_from_checkpoint(
498 &self,
499 checkpoint_id: &str,
500 name: Option<&str>,
501 timeout: Option<Duration>,
502 ) -> Result<Sailbox, SailError> {
503 self.sailbox_api()
504 .from_checkpoint(checkpoint_id, name, timeout.map(duration_to_whole_seconds))
505 .await
506 .map(|handle| Sailbox::bind(self.clone(), handle))
507 }
508
509 #[doc(hidden)]
511 pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
512 self.sailbox_api().upgrade(sailbox_id).await
513 }
514
515 #[doc(hidden)]
517 pub async fn expose_listener(
518 &self,
519 sailbox_id: &str,
520 guest_port: u32,
521 protocol: crate::sailbox::types::IngressProtocol,
522 allowlist: &[String],
523 ) -> Result<Listener, SailError> {
524 let mut response = self
525 .sailbox_api()
526 .expose(sailbox_id, guest_port, protocol, allowlist)
527 .await?;
528 self.fill_listener_url(sailbox_id, &mut response);
529 Ok(response)
530 }
531
532 #[doc(hidden)]
534 pub async fn unexpose_listener(
535 &self,
536 sailbox_id: &str,
537 guest_port: u32,
538 ) -> Result<(), SailError> {
539 self.sailbox_api().unexpose(sailbox_id, guest_port).await
540 }
541
542 #[doc(hidden)]
544 pub async fn list_listeners(
545 &self,
546 sailbox_id: &str,
547 ) -> Result<Vec<crate::worker::Listener>, SailError> {
548 let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
549 for listener in &mut listeners {
550 self.fill_listener_url(sailbox_id, listener);
551 }
552 Ok(listeners)
553 }
554
555 #[doc(hidden)]
558 pub async fn get_listener(
559 &self,
560 sailbox_id: &str,
561 guest_port: u32,
562 ) -> Result<crate::worker::Listener, SailError> {
563 let mut listener = self
564 .sailbox_api()
565 .get_listener(sailbox_id, guest_port)
566 .await?;
567 self.fill_listener_url(sailbox_id, &mut listener);
568 Ok(listener)
569 }
570
571 fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
575 if listener.public_url.is_empty()
576 && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
577 {
578 listener.public_url = crate::sailbox::listeners::synthesized_public_url(
579 self.config(),
580 sailbox_id,
581 listener.guest_port,
582 );
583 }
584 }
585
586 #[doc(hidden)]
588 pub async fn ingress_auth_headers(
589 &self,
590 sailbox_id: &str,
591 ) -> Result<Vec<(String, String)>, SailError> {
592 self.sailbox_api().ingress_auth_headers(sailbox_id).await
593 }
594
595 pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
597 self.sailbox_api().org_ssh_ca_public_key().await
598 }
599
600 pub async fn issue_user_cert(
604 &self,
605 public_key: &str,
606 timeout: Option<Duration>,
607 ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
608 self.sailbox_api()
609 .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
610 .await
611 }
612
613 pub async fn get_volume(
617 &self,
618 name: &str,
619 mint_if_missing: bool,
620 ) -> Result<VolumeInfo, SailError> {
621 self.sailbox_api().get_volume(name, mint_if_missing).await
622 }
623
624 pub async fn list_volumes(
626 &self,
627 max_objects: Option<i64>,
628 ) -> Result<Vec<VolumeInfo>, SailError> {
629 self.sailbox_api().list_volumes(max_objects).await
630 }
631
632 pub async fn delete_volume(
634 &self,
635 volume_id: &str,
636 allow_missing: bool,
637 ) -> Result<Option<VolumeInfo>, SailError> {
638 self.sailbox_api()
639 .delete_volume(volume_id, allow_missing)
640 .await
641 }
642
643 pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
647 app::find_app(&self.inner.api_http, name, mint_if_missing).await
648 }
649
650 pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
652 app::list_apps(&self.inner.api_http).await
653 }
654
655 #[doc(hidden)]
665 pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
666 let handle = self.resume_sailbox(sailbox_id).await?;
667 if handle.exec_endpoint.is_empty() {
668 return Err(SailError::Internal {
669 message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
670 });
671 }
672 Ok(handle.exec_endpoint)
673 }
674
675 #[doc(hidden)]
678 pub async fn exec(
679 &self,
680 sailbox_id: &str,
681 argv: Vec<String>,
682 options: ExecOptions,
683 ) -> Result<ExecProcess, SailError> {
684 if argv.is_empty() {
685 return Err(SailError::InvalidArgument {
686 message: "command must be non-empty".to_string(),
687 });
688 }
689 if options.cwd.is_some() || options.background {
690 return Err(SailError::InvalidArgument {
691 message: "cwd and background require a shell command; use exec_shell or run_shell"
692 .to_string(),
693 });
694 }
695 let env = crate::exec::encode_env(&options.env)?;
698 let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
699 let params = ExecParams {
700 sailbox_id: sailbox_id.to_string(),
701 exec_endpoint,
702 argv,
703 timeout_seconds: options
706 .timeout
707 .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
708 idempotency_key: options.idempotency_key,
709 open_stdin: options.open_stdin || options.pty,
712 pty: options.pty,
713 term: options.term,
714 cols: options.cols,
715 rows: options.rows,
716 env,
717 retry_timeout: options.retry_timeout.as_secs_f64(),
718 forward_ports: options.forward_ports,
719 forward_browser: options.forward_browser,
720 extra_metadata: Vec::new(),
721 forward_clipboard: options.forward_clipboard && options.pty,
724 };
725 ExecProcess::start(self.worker(), params).await
726 }
727
728 #[doc(hidden)]
732 pub async fn exec_shell(
733 &self,
734 sailbox_id: &str,
735 command: &str,
736 mut options: ExecOptions,
737 ) -> Result<ExecProcess, SailError> {
738 let argv = crate::exec::shell_argv(command, &options)?;
739 options.cwd = None;
742 options.background = false;
743 self.exec(sailbox_id, argv, options).await
744 }
745
746 #[doc(hidden)]
754 pub async fn read_stream(
755 &self,
756 sailbox_id: &str,
757 remote_path: &str,
758 ) -> Result<FileReader, SailError> {
759 let endpoint = self.exec_endpoint(sailbox_id).await?;
760 Ok(self
761 .inner
762 .worker
763 .read_file(&endpoint, sailbox_id, remote_path))
764 }
765
766 #[doc(hidden)]
770 pub async fn read_file(
771 &self,
772 sailbox_id: &str,
773 remote_path: &str,
774 ) -> Result<Vec<u8>, SailError> {
775 let reader = self.read_stream(sailbox_id, remote_path).await?;
776 let mut contents = Vec::new();
777 while let Some(chunk) = reader.next().await {
778 contents.extend_from_slice(&chunk?);
779 }
780 Ok(contents)
781 }
782
783 #[doc(hidden)]
792 pub async fn write_stream(
793 &self,
794 sailbox_id: &str,
795 remote_path: &str,
796 options: WriteOptions,
797 ) -> Result<FileWriter, SailError> {
798 let endpoint = self.exec_endpoint(sailbox_id).await?;
799 Ok(self.inner.worker.write_file(
800 &endpoint,
801 sailbox_id,
802 remote_path,
803 options.create_parents,
804 options.mode,
805 ))
806 }
807
808 #[doc(hidden)]
812 pub async fn write_file(
813 &self,
814 sailbox_id: &str,
815 remote_path: &str,
816 data: &[u8],
817 options: WriteOptions,
818 ) -> Result<(), SailError> {
819 let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
820 writer.write(data).await?;
821 writer.finish().await
822 }
823
824 async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
833 self.exec(sailbox_id, argv, ExecOptions::default())
834 .await?
835 .wait()
836 .await
837 }
838
839 #[doc(hidden)]
842 pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
843 crate::sailbox::fs::require_path(path)?;
844 let result = self
845 .run_argv(
846 sailbox_id,
847 vec![
848 "mkdir".to_string(),
849 "-p".to_string(),
850 "--".to_string(),
851 path.to_string(),
852 ],
853 )
854 .await?;
855 fs_command_ok(&result, &format!("create directory {path}"))
856 }
857
858 #[doc(hidden)]
861 pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
862 crate::sailbox::fs::require_path(path)?;
863 let result = self
864 .run_argv(
865 sailbox_id,
866 vec![
867 "rm".to_string(),
868 "-rf".to_string(),
869 "--".to_string(),
870 path.to_string(),
871 ],
872 )
873 .await?;
874 fs_command_ok(&result, &format!("remove {path}"))
875 }
876
877 #[doc(hidden)]
880 pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
881 crate::sailbox::fs::require_path(path)?;
882 let result = self
883 .run_argv(
884 sailbox_id,
885 vec!["test".to_string(), "-e".to_string(), path.to_string()],
886 )
887 .await?;
888 match result.exit_code {
892 0 => Ok(true),
893 1 => Ok(false),
894 _ => Err(fs_command_error(
895 &result,
896 &format!("check whether {path} exists"),
897 )),
898 }
899 }
900
901 #[doc(hidden)]
905 pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
906 crate::sailbox::fs::require_path(path)?;
907 let process = self
908 .exec(
909 sailbox_id,
910 crate::sailbox::fs::list_dir_argv(path),
911 ExecOptions::default(),
912 )
913 .await?;
914 let result = process.wait().await?;
915 fs_command_ok(&result, &format!("list directory {path}"))?;
916 if result.stdout_truncated {
919 return Err(SailError::Execution {
920 code: RpcStatus::FailedPrecondition,
921 detail: format!(
922 "directory listing for {path} was truncated because it has \
923 too many entries; list a smaller subtree"
924 ),
925 });
926 }
927 if !result.stdout_complete {
933 return Err(SailError::Execution {
934 code: RpcStatus::FailedPrecondition,
935 detail: format!(
936 "directory listing for {path} was interrupted before it \
937 finished streaming; retry the call"
938 ),
939 });
940 }
941 let mut entries =
942 crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
943 .map_err(|detail| SailError::Execution {
944 code: RpcStatus::FailedPrecondition,
945 detail: format!("directory listing for {path} could not be used: {detail}"),
946 })?;
947 if entries.is_empty() {
950 return Err(SailError::Execution {
951 code: RpcStatus::FailedPrecondition,
952 detail: format!(
953 "directory listing for {path} produced no records; \
954 listing requires GNU find in the guest"
955 ),
956 });
957 }
958 let start = entries.remove(0);
959 if start.entry_type != EntryType::Directory {
960 return Err(SailError::Execution {
961 code: RpcStatus::FailedPrecondition,
962 detail: format!(
963 "{path} is not a directory (it is a {})",
964 start.entry_type.as_str()
965 ),
966 });
967 }
968 Ok(entries)
969 }
970}
971
972fn duration_to_whole_seconds(timeout: Duration) -> i64 {
976 if timeout.is_zero() {
977 0
978 } else {
979 timeout.as_secs_f64().ceil() as i64
980 }
981}
982
983fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
985 if result.exit_code != 0 {
986 return Err(fs_command_error(result, action));
987 }
988 Ok(())
989}
990
991fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
994 let stderr = result.stderr.trim();
995 let suffix = if stderr.is_empty() {
996 String::new()
997 } else {
998 format!(": {stderr}")
999 };
1000 SailError::Execution {
1001 code: RpcStatus::FailedPrecondition,
1002 detail: format!(
1003 "failed to {action} (exit code {}){suffix}",
1004 result.exit_code
1005 ),
1006 }
1007}
1008
1009#[cfg(test)]
1010mod timeout_tests {
1011 use super::*;
1012
1013 #[test]
1014 fn durations_round_up_to_whole_seconds() {
1015 assert_eq!(duration_to_whole_seconds(Duration::ZERO), 0);
1016 assert_eq!(duration_to_whole_seconds(Duration::from_millis(500)), 1);
1017 assert_eq!(duration_to_whole_seconds(Duration::from_millis(1500)), 2);
1018 assert_eq!(duration_to_whole_seconds(Duration::from_mins(1)), 60);
1019 }
1020}