1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, Instant};
13
14use futures::stream::{self, TryStreamExt};
15use sha2::{Digest, Sha256};
16use std::sync::Arc;
17
18use crate::error::{SailError, TransportKind};
19use crate::image::{
20 AddLocalDirFile, BaseImage, ImageArchitecture, ImageBuildStep, ImageSpec, PackageInstall,
21 RunCommand,
22};
23use crate::pb::image::v1 as pbimage;
24use crate::pb::imagebuilder::v1 as pbimg;
25use crate::Client;
26
27pub(crate) const MAX_LOCAL_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
29pub(crate) const MAX_LOCAL_DIR_FILES: usize = 50_000;
31pub(crate) const MAX_LOCAL_DIR_RELATIVE_PATH_BYTES: usize = 1024;
33const UPLOAD_CONCURRENCY: usize = 16;
35const BUILD_POLL_INTERVAL: Duration = Duration::from_secs(1);
37const UPLOAD_BASE_TIMEOUT: Duration = Duration::from_mins(5);
41const MIN_UPLOAD_BYTES_PER_SEC: u64 = 1 << 20;
43const UNBOUNDED_BUILD_RPC_BUDGET: Duration = Duration::from_mins(1);
46
47fn invalid(message: String) -> SailError {
48 SailError::InvalidArgument { message }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ImageBuildStatus {
54 Unspecified,
56 Queued,
58 Building,
60 Ready,
62 Failed,
64}
65
66impl ImageBuildStatus {
67 pub fn as_str(self) -> &'static str {
69 match self {
70 ImageBuildStatus::Unspecified => "unspecified",
71 ImageBuildStatus::Queued => "queued",
72 ImageBuildStatus::Building => "building",
73 ImageBuildStatus::Ready => "ready",
74 ImageBuildStatus::Failed => "failed",
75 }
76 }
77
78 fn from_pb(status: i32) -> ImageBuildStatus {
79 match pbimage::ImageBuildStatus::try_from(status) {
80 Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
81 Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
82 Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
83 Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
84 _ => ImageBuildStatus::Unspecified,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct ImageBuild {
92 pub image_id: String,
94 pub status: ImageBuildStatus,
96 pub error_message: String,
98}
99
100#[derive(Debug, Clone)]
102pub(crate) enum LocalFileUploadPlan {
103 AlreadyExists,
105 SinglePart {
107 upload_url: String,
109 headers: HashMap<String, String>,
111 },
112}
113
114#[derive(Debug, Clone)]
117pub enum ImageDefinitionStep {
118 AptInstall(Vec<String>),
120 PipInstall(Vec<String>),
122 RunCommand(String),
124 AddLocalFile {
126 local_path: PathBuf,
128 remote_path: String,
131 mode: Option<u32>,
133 },
134 AddLocalDir {
137 local_path: PathBuf,
139 remote_path: String,
141 ignore: Vec<String>,
143 ignore_file: Option<PathBuf>,
145 },
146}
147
148#[derive(Debug, Clone, Default)]
153pub struct ImageDefinition {
154 pub base: Option<BaseImage>,
156 pub architecture: ImageArchitecture,
158 pub env: HashMap<String, String>,
160 pub python_version: String,
163 pub steps: Vec<ImageDefinitionStep>,
165}
166
167#[doc(hidden)]
170pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
171 matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
172 && spec.build_steps.is_empty()
173 && spec.env.is_empty()
174 && spec.python_version.is_empty()
175}
176
177fn validate_remote_path(target: &str) -> Result<(), SailError> {
180 if !target.starts_with('/') {
181 return Err(invalid(format!("remotePath {target:?} must be absolute")));
182 }
183 if target.len() > 1 && target.ends_with('/') {
184 return Err(invalid(format!(
185 "remotePath {target:?} must not end with '/'"
186 )));
187 }
188 for ch in target.chars() {
189 let code = ch as u32;
190 if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
191 return Err(invalid(format!(
192 "remotePath {target:?} contains an unsupported character"
193 )));
194 }
195 }
196 if target.split('/').any(|segment| segment == "..") {
197 return Err(invalid(format!(
198 "remotePath {target:?} must not contain '..'"
199 )));
200 }
201 Ok(())
202}
203
204fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
205 match mode {
206 None | Some(0) => Ok(0),
207 Some(mode) if mode <= 0o777 => Ok(mode),
208 Some(mode) => Err(invalid(format!(
209 "mode 0o{mode:o} must fit in the low 9 bits"
210 ))),
211 }
212}
213
214async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
216 let path = path.to_path_buf();
217 tokio::task::spawn_blocking(move || {
218 use std::io::Read;
219 let file = std::fs::File::open(&path)
220 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
221 let mut reader = std::io::BufReader::new(file);
222 let mut hasher = Sha256::new();
223 let mut buf = vec![0u8; 64 * 1024];
224 let mut size: u64 = 0;
225 loop {
226 let n = reader
227 .read(&mut buf)
228 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
229 if n == 0 {
230 break;
231 }
232 hasher.update(&buf[..n]);
233 size += n as u64;
234 }
235 Ok((format!("{:x}", hasher.finalize()), size))
236 })
237 .await
238 .map_err(|err| SailError::Internal {
239 message: format!("hashing task failed: {err}"),
240 })?
241}
242
243struct WalkedFile {
244 abs_path: PathBuf,
245 relative_path: String,
246 mode: u32,
247}
248
249fn walk_dir(
252 root: &Path,
253 matcher: &ignore::gitignore::Gitignore,
254) -> Result<Vec<WalkedFile>, SailError> {
255 fn recurse(
256 root: &Path,
257 dir: &Path,
258 rel: &str,
259 matcher: &ignore::gitignore::Gitignore,
260 out: &mut Vec<WalkedFile>,
261 ) -> Result<(), SailError> {
262 let mut entries: Vec<_> = std::fs::read_dir(dir)
263 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
264 .collect::<Result<_, _>>()
265 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
266 entries.sort_by_key(std::fs::DirEntry::file_name);
267 for entry in entries {
268 let name = entry
269 .file_name()
270 .to_str()
271 .ok_or_else(|| {
272 invalid(format!(
273 "addLocalDir: {} has a non-UTF-8 file name",
274 entry.path().display()
275 ))
276 })?
277 .to_string();
278 let rel_path = if rel.is_empty() {
279 name.clone()
280 } else {
281 format!("{rel}/{name}")
282 };
283 let file_type = entry
284 .file_type()
285 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
286 if file_type.is_symlink() {
287 continue;
288 }
289 let is_dir = file_type.is_dir();
290 if matcher
291 .matched_path_or_any_parents(&rel_path, is_dir)
292 .is_ignore()
293 {
294 continue;
295 }
296 if is_dir {
297 recurse(root, &entry.path(), &rel_path, matcher, out)?;
298 continue;
299 }
300 if !file_type.is_file() {
301 continue;
302 }
303 if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
304 return Err(invalid(format!(
305 "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
306 )));
307 }
308 let metadata = entry
309 .metadata()
310 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
311 if metadata.len() > MAX_LOCAL_FILE_BYTES {
312 return Err(invalid(format!(
313 "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
314 entry.path().display(),
315 metadata.len()
316 )));
317 }
318 out.push(WalkedFile {
319 abs_path: entry.path(),
320 relative_path: rel_path,
321 mode: unix_mode(&metadata),
322 });
323 if out.len() > MAX_LOCAL_DIR_FILES {
324 return Err(invalid(format!(
325 "{} has more than {MAX_LOCAL_DIR_FILES} files (max {MAX_LOCAL_DIR_FILES})",
326 root.display()
327 )));
328 }
329 }
330 Ok(())
331 }
332
333 let mut out = Vec::new();
334 recurse(root, root, "", matcher, &mut out)?;
335 Ok(out)
336}
337
338#[cfg(unix)]
339fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
340 use std::os::unix::fs::PermissionsExt;
341 metadata.permissions().mode() & 0o777
342}
343
344#[cfg(not(unix))]
345fn unix_mode(_metadata: &std::fs::Metadata) -> u32 {
346 0o644
347}
348
349fn ignore_matcher(
350 root: &Path,
351 patterns: &[String],
352 ignore_file: Option<&Path>,
353) -> Result<ignore::gitignore::Gitignore, SailError> {
354 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
355 if let Some(file) = ignore_file {
356 if let Some(err) = builder.add(file) {
357 return Err(invalid(format!(
358 "cannot read ignore file {}: {err}",
359 file.display()
360 )));
361 }
362 }
363 for pattern in patterns {
364 builder
365 .add_line(None, pattern)
366 .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
367 }
368 builder
369 .build()
370 .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
371}
372
373fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
377 match base {
378 BaseImage::Debian => pbimage::BaseImage::Debian,
379 BaseImage::Devbox => pbimage::BaseImage::Devbox,
380 BaseImage::Unspecified => pbimage::BaseImage::Unspecified,
381 }
382}
383
384fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
385 match arch {
386 ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
387 ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
388 ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
389 }
390}
391
392fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
393 use pbimage::image_build_step::Step;
394 let packages = |p: &PackageInstall| pbimage::PackageInstall {
395 packages: p.packages.clone(),
396 };
397 let inner = match step {
398 ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
399 ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
400 ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
401 command: c.command.clone(),
402 }),
403 ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
404 content_sha256: f.content_sha256.clone(),
405 remote_path: f.remote_path.clone(),
406 mode: f.mode,
407 }),
408 ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
409 remote_path: d.remote_path.clone(),
410 files: d
411 .files
412 .iter()
413 .map(|file| pbimage::AddLocalDirFile {
414 relative_path: file.relative_path.clone(),
415 content_sha256: file.content_sha256.clone(),
416 mode: file.mode,
417 })
418 .collect(),
419 }),
420 };
421 pbimage::ImageBuildStep { step: Some(inner) }
422}
423
424pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
426 pbimage::ImageSpec {
427 source: spec
428 .base
429 .map(|base| pbimage::image_spec::Source::Base(base_image_to_pb(base) as i32)),
430 build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
431 env: spec.env.clone(),
432 architecture: architecture_to_pb(spec.architecture) as i32,
433 python_version: spec.python_version.clone(),
434 }
435}
436
437impl Client {
438 pub(crate) async fn prepare_local_file_upload(
440 &self,
441 content_sha256: &str,
442 content_length: u64,
443 ) -> Result<LocalFileUploadPlan, SailError> {
444 let request = pbimg::PrepareLocalFileUploadRequest {
445 content_sha256: content_sha256.to_string(),
446 content_length,
447 };
448 let response = self
449 .imagebuilder()
450 .prepare_local_file_upload(request)
451 .await?;
452 use pbimg::prepare_local_file_upload_response::Outcome;
453 match response.outcome {
454 Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
455 Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
456 upload_url: plan.upload_url,
457 headers: plan.required_headers,
458 }),
459 None => Err(SailError::Internal {
460 message: "prepare_local_file_upload returned no outcome".to_string(),
461 }),
462 }
463 }
464
465 pub async fn build_image(
469 &self,
470 spec: &ImageSpec,
471 retry_timeout_secs: f64,
472 ) -> Result<ImageBuild, SailError> {
473 let request = pbimg::BuildImageRequest {
474 image: Some(image_spec_to_pb(spec)),
475 };
476 let response = self
477 .imagebuilder()
478 .build_image(request, retry_timeout_secs)
479 .await?;
480 Ok(ImageBuild {
481 image_id: response.image_id,
482 status: ImageBuildStatus::from_pb(response.status),
483 error_message: response.error_message,
484 })
485 }
486
487 pub async fn get_image_build_status(
489 &self,
490 image_id: &str,
491 retry_timeout_secs: f64,
492 ) -> Result<ImageBuild, SailError> {
493 let request = pbimg::GetImageBuildStatusRequest {
494 image_id: image_id.to_string(),
495 };
496 let response = self
497 .imagebuilder()
498 .get_image_build_status(request, retry_timeout_secs)
499 .await?;
500 Ok(ImageBuild {
501 image_id: response.image_id,
502 status: ImageBuildStatus::from_pb(response.status),
503 error_message: response.error_message,
504 })
505 }
506
507 #[doc(hidden)]
510 pub async fn resolve_local_file_step(
511 &self,
512 local_path: &Path,
513 remote_path: &str,
514 mode: Option<u32>,
515 ) -> Result<crate::image::AddLocalFile, SailError> {
516 let metadata = std::fs::metadata(local_path).map_err(|_| {
517 invalid(format!(
518 "addLocalFile: {} does not exist or is not a file",
519 local_path.display()
520 ))
521 })?;
522 if !metadata.is_file() {
523 return Err(invalid(format!(
524 "addLocalFile: {} is not a file",
525 local_path.display()
526 )));
527 }
528 if metadata.len() > MAX_LOCAL_FILE_BYTES {
529 return Err(invalid(format!(
530 "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
531 local_path.display(),
532 metadata.len()
533 )));
534 }
535 let mode = validate_mode(mode)?;
536 let mut target = remote_path.to_string();
537 if target.ends_with('/') {
538 let basename = local_path
539 .file_name()
540 .map(|name| name.to_string_lossy().into_owned())
541 .unwrap_or_default();
542 target = format!("{target}{basename}");
543 }
544 validate_remote_path(&target)?;
545 let (digest, size) = hash_file(local_path).await?;
546 if size > MAX_LOCAL_FILE_BYTES {
549 return Err(invalid(format!(
550 "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
551 local_path.display()
552 )));
553 }
554 let http = reqwest::Client::new();
555 self.upload_local_content(&http, &digest, local_path, size)
556 .await?;
557 Ok(crate::image::AddLocalFile {
558 content_sha256: digest,
559 remote_path: target,
560 mode,
561 })
562 }
563
564 #[doc(hidden)]
568 pub async fn resolve_local_dir_step(
569 &self,
570 local_path: &Path,
571 remote_path: &str,
572 ignore: &[String],
573 ignore_file: Option<&Path>,
574 ) -> Result<crate::image::AddLocalDir, SailError> {
575 let target = remote_path.trim_end_matches('/').to_string();
576 if target.is_empty() {
577 return Err(invalid(
578 "addLocalDir: remotePath must not be '/'".to_string(),
579 ));
580 }
581 validate_remote_path(&target)?;
582 let walk_root = local_path.to_path_buf();
587 let ignore_owned = ignore.to_vec();
588 let ignore_file_owned = ignore_file.map(Path::to_path_buf);
589 let has_ignore = !ignore.is_empty() || ignore_file.is_some();
590 let walked = tokio::task::spawn_blocking(move || {
591 let metadata = std::fs::metadata(&walk_root).map_err(|_| {
592 invalid(format!(
593 "addLocalDir: {} does not exist or is not a directory",
594 walk_root.display()
595 ))
596 })?;
597 if !metadata.is_dir() {
598 return Err(invalid(format!(
599 "addLocalDir: {} is not a directory",
600 walk_root.display()
601 )));
602 }
603 let matcher = ignore_matcher(&walk_root, &ignore_owned, ignore_file_owned.as_deref())?;
604 let walked = walk_dir(&walk_root, &matcher)?;
605 if walked.is_empty() {
606 let qualifier = if has_ignore {
607 " after applying ignore patterns"
608 } else {
609 ""
610 };
611 return Err(invalid(format!(
612 "addLocalDir: {} contains no files{qualifier}",
613 walk_root.display()
614 )));
615 }
616 Ok(walked)
617 })
618 .await
619 .map_err(|err| SailError::Internal {
620 message: format!("directory walk task failed: {err}"),
621 })??;
622 let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
624 let mut files = Vec::with_capacity(walked.len());
625 for file in walked {
626 let (digest, size) = hash_file(&file.abs_path).await?;
627 if size > MAX_LOCAL_FILE_BYTES {
628 return Err(invalid(format!(
629 "addLocalDir: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
630 per-file limit",
631 file.abs_path.display()
632 )));
633 }
634 uploads
635 .entry(digest.clone())
636 .or_insert_with(|| (file.abs_path.clone(), size));
637 files.push(AddLocalDirFile {
638 relative_path: file.relative_path,
639 content_sha256: digest,
640 mode: file.mode,
641 });
642 }
643 files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
644 let http = reqwest::Client::new();
645 stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
646 .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
647 let http = http.clone();
648 async move {
649 self.upload_local_content(&http, &digest, &source, size)
650 .await
651 }
652 })
653 .await?;
654 Ok(crate::image::AddLocalDir {
655 remote_path: target,
656 files,
657 })
658 }
659
660 pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
664 let mut steps = Vec::with_capacity(def.steps.len());
665 for step in &def.steps {
666 steps.push(match step {
667 ImageDefinitionStep::AptInstall(packages) => {
668 ImageBuildStep::AptInstall(PackageInstall {
669 packages: packages.clone(),
670 })
671 }
672 ImageDefinitionStep::PipInstall(packages) => {
673 ImageBuildStep::PipInstall(PackageInstall {
674 packages: packages.clone(),
675 })
676 }
677 ImageDefinitionStep::RunCommand(command) => {
678 ImageBuildStep::RunCommand(RunCommand {
679 command: command.clone(),
680 })
681 }
682 ImageDefinitionStep::AddLocalFile {
683 local_path,
684 remote_path,
685 mode,
686 } => ImageBuildStep::AddLocalFile(
687 self.resolve_local_file_step(local_path, remote_path, *mode)
688 .await?,
689 ),
690 ImageDefinitionStep::AddLocalDir {
691 local_path,
692 remote_path,
693 ignore,
694 ignore_file,
695 } => ImageBuildStep::AddLocalDir(
696 self.resolve_local_dir_step(
697 local_path,
698 remote_path,
699 ignore,
700 ignore_file.as_deref(),
701 )
702 .await?,
703 ),
704 });
705 }
706 Ok(ImageSpec {
707 base: def.base,
708 build_steps: steps,
709 env: def.env.clone(),
710 architecture: def.architecture,
711 python_version: def.python_version.clone(),
712 })
713 }
714
715 async fn upload_local_content(
718 &self,
719 http: &reqwest::Client,
720 digest: &str,
721 source: &Path,
722 size: u64,
723 ) -> Result<(), SailError> {
724 let plan = self.prepare_local_file_upload(digest, size).await?;
725 let LocalFileUploadPlan::SinglePart {
726 upload_url,
727 headers,
728 } = plan
729 else {
730 return Ok(());
731 };
732 let file = tokio::fs::File::open(source)
733 .await
734 .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
735 let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
736 let response = tokio::time::timeout(upload_timeout(size), request.send())
737 .await
738 .map_err(|_| SailError::Transport {
739 kind: TransportKind::Timeout,
740 message: format!("local file upload stalled ({size} bytes not delivered in time)"),
741 source: None,
742 })?
743 .map_err(|err| SailError::Transport {
744 kind: TransportKind::Connection,
745 message: format!("local file upload failed: {err}"),
746 source: None,
747 })?;
748 if !response.status().is_success() {
749 return Err(SailError::Api {
750 message: format!(
751 "local file upload failed: HTTP {} {}",
752 response.status().as_u16(),
753 response.status().canonical_reason().unwrap_or("")
754 ),
755 status: response.status().as_u16(),
756 body: serde_json::Value::Null,
757 });
758 }
759 let streamed = streamed_digest.lock().unwrap().take();
764 if streamed.as_deref() != Some(digest) {
765 return Err(invalid(format!(
766 "{} changed while it was being uploaded; retry the build",
767 source.display()
768 )));
769 }
770 Ok(())
771 }
772
773 #[doc(hidden)]
780 pub async fn build_spec_with_timeout(
781 &self,
782 spec: &ImageSpec,
783 timeout: Duration,
784 ) -> Result<ImageBuild, SailError> {
785 match Instant::now().checked_add(timeout) {
786 None => {
787 self.build_spec_ready_cached(spec, timeout, false)
788 .await
789 }
790 Some(_) => tokio::time::timeout(
791 timeout,
792 self.build_spec_ready_cached(spec, timeout, false),
793 )
794 .await
795 .unwrap_or_else(|_| {
796 Err(SailError::Transport {
797 kind: TransportKind::Timeout,
798 message: "timed out building the image".to_string(),
799 source: None,
800 })
801 }),
802 }
803 }
804
805 pub(crate) async fn build_spec_ready_cached(
813 &self,
814 spec: &ImageSpec,
815 timeout: Duration,
816 recovery: bool,
817 ) -> Result<ImageBuild, SailError> {
818 let key: crate::imagecache::CacheKey = (timeout, canonical_spec_key(spec)?);
819 loop {
820 let joined = self.image_ready_cache().join_or_lead(&key, recovery, |id| {
821 let client = self.clone();
822 let spec = spec.clone();
823 let key = key.clone();
824 let deadline = Instant::now().checked_add(timeout);
825 futures::FutureExt::shared(futures::FutureExt::boxed(async move {
826 let result = client.build_spec_to_ready(&spec, deadline).await;
827 match &result {
828 Ok(build) => {
829 client
830 .image_ready_cache()
831 .settle_success(&key, id, build.clone());
832 }
833 Err(_) => client.image_ready_cache().settle_failure(&key, id),
834 }
835 result.map_err(Arc::new)
836 }))
837 });
838 let (shared, led) = match joined {
839 crate::imagecache::Joined::Ready(build) => return Ok(build),
840 crate::imagecache::Joined::Pending { build, led } => (build, led),
841 };
842 match shared.await {
843 Ok(build) => return Ok(build),
844 Err(err) => {
845 let timed_out = matches!(
846 err.as_ref(),
847 SailError::Transport {
848 kind: TransportKind::Timeout,
849 ..
850 }
851 );
852 if led || !timed_out {
853 return Err(
857 Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
858 );
859 }
860 }
861 }
862 }
863 }
864
865 pub async fn build_image_definition(
873 &self,
874 def: &ImageDefinition,
875 timeout: Duration,
876 ) -> Result<ImageSpec, SailError> {
877 let work = async {
878 let spec = self.resolve_image(def).await?;
879 if is_builtin_base_spec(&spec) {
880 return Ok(spec);
881 }
882 self.build_spec_ready_cached(&spec, timeout, false)
883 .await?;
884 Ok(spec)
885 };
886 match Instant::now().checked_add(timeout) {
887 None => work.await,
888 Some(_) => tokio::time::timeout(timeout, work)
889 .await
890 .unwrap_or_else(|_| {
891 Err(SailError::Transport {
892 kind: TransportKind::Timeout,
893 message: "timed out building the image".to_string(),
894 source: None,
895 })
896 }),
897 }
898 }
899
900 #[doc(hidden)]
902 pub async fn build_spec_to_ready(
903 &self,
904 spec: &ImageSpec,
905 deadline: Option<Instant>,
906 ) -> Result<ImageBuild, SailError> {
907 let rpc_budget = || {
910 deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
911 deadline
912 .saturating_duration_since(Instant::now())
913 .as_secs_f64()
914 })
915 };
916 let mut build = self.build_image(spec, rpc_budget()).await?;
917 loop {
918 match build.status {
919 ImageBuildStatus::Ready => return Ok(build),
920 ImageBuildStatus::Failed => {
921 let message = if build.error_message.is_empty() {
922 "image build failed".to_string()
923 } else {
924 build.error_message.clone()
925 };
926 return Err(SailError::ImageBuild { message });
927 }
928 _ => {}
929 }
930 let nap = match deadline {
931 None => BUILD_POLL_INTERVAL,
932 Some(deadline) => {
933 let left = deadline.saturating_duration_since(Instant::now());
934 if left.is_zero() {
935 return Err(SailError::Transport {
936 kind: TransportKind::Timeout,
937 message: format!(
938 "timed out waiting for image build {}",
939 build.image_id
940 ),
941 source: None,
942 });
943 }
944 left.min(BUILD_POLL_INTERVAL)
945 }
946 };
947 tokio::time::sleep(nap).await;
948 build = self
949 .get_image_build_status(&build.image_id, rpc_budget())
950 .await?;
951 }
952 }
953}
954
955pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
959 let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
960 message: format!("serialize image spec: {err}"),
961 })?;
962 let mut hasher = Sha256::new();
963 hasher.update(value.to_string().as_bytes());
964 Ok(format!("{:x}", hasher.finalize()))
965}
966
967fn sized_put_request(
972 http: &reqwest::Client,
973 upload_url: &str,
974 file: tokio::fs::File,
975 size: u64,
976 headers: &HashMap<String, String>,
977) -> (
978 reqwest::RequestBuilder,
979 Arc<std::sync::Mutex<Option<String>>>,
980) {
981 let (body, streamed_digest) = SizedFileBody::new(file, size);
982 let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
983 for (name, value) in headers {
984 request = request.header(name, value);
985 }
986 (request, streamed_digest)
987}
988
989fn upload_timeout(size: u64) -> Duration {
992 UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
993}
994
995struct SizedFileBody {
1000 reader: tokio_util::io::ReaderStream<tokio::fs::File>,
1001 remaining: u64,
1002 hasher: Option<sha2::Sha256>,
1003 streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
1004}
1005
1006impl SizedFileBody {
1007 fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
1008 let streamed_digest = Arc::new(std::sync::Mutex::new(None));
1009 let mut hasher = Some(sha2::Sha256::new());
1010 if size == 0 {
1011 *streamed_digest.lock().unwrap() =
1013 Some(format!("{:x}", hasher.take().unwrap().finalize()));
1014 }
1015 (
1016 SizedFileBody {
1017 reader: tokio_util::io::ReaderStream::new(file),
1018 remaining: size,
1019 hasher,
1020 streamed_digest: Arc::clone(&streamed_digest),
1021 },
1022 streamed_digest,
1023 )
1024 }
1025}
1026
1027impl http_body::Body for SizedFileBody {
1028 type Data = bytes::Bytes;
1029 type Error = std::io::Error;
1030
1031 fn poll_frame(
1032 mut self: std::pin::Pin<&mut Self>,
1033 cx: &mut std::task::Context<'_>,
1034 ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
1035 use futures::Stream;
1036 match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
1037 std::task::Poll::Ready(Some(Ok(chunk))) => {
1038 self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
1039 if let Some(hasher) = self.hasher.as_mut() {
1040 hasher.update(&chunk);
1041 }
1042 if self.remaining == 0 {
1046 if let Some(hasher) = self.hasher.take() {
1047 *self.streamed_digest.lock().unwrap() =
1048 Some(format!("{:x}", hasher.finalize()));
1049 }
1050 }
1051 std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
1052 }
1053 std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
1054 std::task::Poll::Ready(None) => {
1055 if let Some(hasher) = self.hasher.take() {
1056 *self.streamed_digest.lock().unwrap() =
1057 Some(format!("{:x}", hasher.finalize()));
1058 }
1059 std::task::Poll::Ready(None)
1060 }
1061 std::task::Poll::Pending => std::task::Poll::Pending,
1062 }
1063 }
1064
1065 fn is_end_stream(&self) -> bool {
1066 self.remaining == 0
1067 }
1068
1069 fn size_hint(&self) -> http_body::SizeHint {
1070 http_body::SizeHint::with_exact(self.remaining)
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 #[test]
1077 fn upload_budget_scales_with_content_size() {
1078 assert_eq!(upload_timeout(0), Duration::from_mins(5));
1079 assert_eq!(
1081 upload_timeout(1 << 30),
1082 Duration::from_mins(5) + Duration::from_secs(1024)
1083 );
1084 }
1085
1086 #[tokio::test]
1087 async fn upload_body_advertises_its_exact_size() {
1088 let dir = tempfile::tempdir().expect("tempdir");
1093 let path = dir.path().join("payload.bin");
1094 std::fs::write(&path, b"0123456789").expect("write");
1095 let file = tokio::fs::File::open(&path).await.expect("open");
1096 let (body, _digest) = SizedFileBody::new(file, 10);
1097 assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1098 assert!(!http_body::Body::is_end_stream(&body));
1099 }
1100
1101 #[tokio::test]
1102 async fn presigned_put_uses_content_length_framing() {
1103 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1104
1105 let dir = tempfile::tempdir().expect("tempdir");
1106 let path = dir.path().join("payload.bin");
1107 std::fs::write(&path, b"0123456789").expect("write");
1108
1109 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1110 .await
1111 .expect("bind");
1112 let addr = listener.local_addr().expect("addr");
1113 let server = tokio::spawn(async move {
1114 let (mut sock, _) = listener.accept().await.expect("accept");
1115 let mut raw = Vec::new();
1116 let mut buf = [0u8; 4096];
1117 loop {
1118 let n = sock.read(&mut buf).await.expect("read");
1119 raw.extend_from_slice(&buf[..n]);
1120 if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1121 let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1122 let body_len = raw.len() - (head_end + 4);
1123 if body_len >= 10 {
1124 sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1125 .await
1126 .expect("respond");
1127 return head;
1128 }
1129 }
1130 }
1131 });
1132
1133 let file = tokio::fs::File::open(&path).await.expect("open");
1134 let headers = HashMap::from([(
1135 "Content-Type".to_string(),
1136 "application/octet-stream".to_string(),
1137 )]);
1138 let (request, streamed_digest) = sized_put_request(
1139 &reqwest::Client::new(),
1140 &format!("http://{addr}/upload"),
1141 file,
1142 10,
1143 &headers,
1144 );
1145 let response = request.send().await.expect("send");
1146 assert!(response.status().is_success());
1147 assert_eq!(
1149 streamed_digest.lock().unwrap().as_deref(),
1150 Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1151 );
1152
1153 let head = server.await.expect("server");
1154 assert!(
1157 head.contains("content-length: 10"),
1158 "missing sized framing in request head: {head}"
1159 );
1160 assert!(
1161 !head.contains("transfer-encoding"),
1162 "request must not be chunked: {head}"
1163 );
1164 }
1165
1166 use super::*;
1167
1168 #[test]
1169 fn remote_path_rules_match_the_wrappers() {
1170 assert!(validate_remote_path("/app/config.json").is_ok());
1171 assert!(validate_remote_path("relative").is_err());
1172 assert!(validate_remote_path("/app/").is_err());
1173 assert!(validate_remote_path("/app/../etc").is_err());
1174 assert!(validate_remote_path("/app/with space").is_err());
1175 assert!(validate_remote_path("/app/$HOME").is_err());
1176 assert!(validate_mode(Some(0o600)).is_ok());
1177 assert!(validate_mode(Some(0o1777)).is_err());
1178 }
1179
1180 #[tokio::test]
1181 async fn resolve_walks_hashes_and_respects_gitignore() {
1182 let dir = tempfile::tempdir().expect("tempdir");
1183 std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1184 std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1185 std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1186 std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1187 std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1188
1189 let matcher = ignore_matcher(
1190 dir.path(),
1191 &["*.pyc".to_string(), "src/generated/".to_string()],
1192 None,
1193 )
1194 .expect("matcher");
1195 let walked = walk_dir(dir.path(), &matcher).expect("walk");
1196 let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1197 paths.sort();
1198 assert_eq!(paths, ["src/keep.py", "top.txt"]);
1199
1200 let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1201 assert_eq!(size, 3);
1202 assert_eq!(
1203 digest,
1204 "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1205 );
1206 }
1207}