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 Unknown,
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::Unknown => "unknown",
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::Unknown,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91#[non_exhaustive]
92pub struct ImageBuild {
93 pub image_id: String,
95 pub status: ImageBuildStatus,
97 pub error_message: String,
99}
100
101#[derive(Debug, Clone)]
103pub(crate) enum LocalFileUploadPlan {
104 AlreadyExists,
106 SinglePart {
108 upload_url: String,
110 headers: HashMap<String, String>,
112 },
113}
114
115#[derive(Debug, Clone)]
118pub enum ImageDefinitionStep {
119 AptInstall(Vec<String>),
121 PipInstall(Vec<String>),
123 RunCommand(String),
125 AddLocalFile {
127 local_path: PathBuf,
129 remote_path: String,
132 mode: Option<u32>,
134 },
135 AddLocalDir {
138 local_path: PathBuf,
140 remote_path: String,
142 ignore: Vec<String>,
144 ignore_file: Option<PathBuf>,
146 },
147}
148
149#[derive(Debug, Clone, Default)]
154pub struct ImageDefinition {
155 pub base: Option<BaseImage>,
157 pub architecture: ImageArchitecture,
159 pub env: HashMap<String, String>,
161 pub python_version: String,
164 pub steps: Vec<ImageDefinitionStep>,
166}
167
168#[doc(hidden)]
171pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
172 matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
173 && spec.build_steps.is_empty()
174 && spec.env.is_empty()
175 && spec.python_version.is_empty()
176}
177
178fn validate_remote_path(target: &str) -> Result<(), SailError> {
181 if !target.starts_with('/') {
182 return Err(invalid(format!("remotePath {target:?} must be absolute")));
183 }
184 if target.len() > 1 && target.ends_with('/') {
185 return Err(invalid(format!(
186 "remotePath {target:?} must not end with '/'"
187 )));
188 }
189 for ch in target.chars() {
190 let code = ch as u32;
191 if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
192 return Err(invalid(format!(
193 "remotePath {target:?} contains an unsupported character"
194 )));
195 }
196 }
197 if target.split('/').any(|segment| segment == "..") {
198 return Err(invalid(format!(
199 "remotePath {target:?} must not contain '..'"
200 )));
201 }
202 Ok(())
203}
204
205fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
206 match mode {
207 None | Some(0) => Ok(0),
208 Some(mode) if mode <= 0o777 => Ok(mode),
209 Some(mode) => Err(invalid(format!(
210 "mode 0o{mode:o} must fit in the low 9 bits"
211 ))),
212 }
213}
214
215async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
217 let path = path.to_path_buf();
218 tokio::task::spawn_blocking(move || {
219 use std::io::Read;
220 let file = std::fs::File::open(&path)
221 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
222 let mut reader = std::io::BufReader::new(file);
223 let mut hasher = Sha256::new();
224 let mut buf = vec![0u8; 64 * 1024];
225 let mut size: u64 = 0;
226 loop {
227 let n = reader
228 .read(&mut buf)
229 .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
230 if n == 0 {
231 break;
232 }
233 hasher.update(&buf[..n]);
234 size += n as u64;
235 }
236 Ok((format!("{:x}", hasher.finalize()), size))
237 })
238 .await
239 .map_err(|err| SailError::Internal {
240 message: format!("hashing task failed: {err}"),
241 })?
242}
243
244struct WalkedFile {
245 abs_path: PathBuf,
246 relative_path: String,
247 mode: u32,
248}
249
250fn walk_dir(
253 root: &Path,
254 matcher: &ignore::gitignore::Gitignore,
255) -> Result<Vec<WalkedFile>, SailError> {
256 fn recurse(
257 root: &Path,
258 dir: &Path,
259 rel: &str,
260 matcher: &ignore::gitignore::Gitignore,
261 out: &mut Vec<WalkedFile>,
262 ) -> Result<(), SailError> {
263 let mut entries: Vec<_> = std::fs::read_dir(dir)
264 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
265 .collect::<Result<_, _>>()
266 .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
267 entries.sort_by_key(std::fs::DirEntry::file_name);
268 for entry in entries {
269 let name = entry
270 .file_name()
271 .to_str()
272 .ok_or_else(|| {
273 invalid(format!(
274 "addLocalDir: {} has a non-UTF-8 file name",
275 entry.path().display()
276 ))
277 })?
278 .to_string();
279 let rel_path = if rel.is_empty() {
280 name.clone()
281 } else {
282 format!("{rel}/{name}")
283 };
284 let file_type = entry
285 .file_type()
286 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
287 if file_type.is_symlink() {
288 continue;
289 }
290 let is_dir = file_type.is_dir();
291 if matcher
292 .matched_path_or_any_parents(&rel_path, is_dir)
293 .is_ignore()
294 {
295 continue;
296 }
297 if is_dir {
298 recurse(root, &entry.path(), &rel_path, matcher, out)?;
299 continue;
300 }
301 if !file_type.is_file() {
302 continue;
303 }
304 if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
305 return Err(invalid(format!(
306 "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
307 )));
308 }
309 let metadata = entry
310 .metadata()
311 .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
312 if metadata.len() > MAX_LOCAL_FILE_BYTES {
313 return Err(invalid(format!(
314 "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
315 entry.path().display(),
316 metadata.len()
317 )));
318 }
319 out.push(WalkedFile {
320 abs_path: entry.path(),
321 relative_path: rel_path,
322 mode: unix_mode(&metadata),
323 });
324 if out.len() > MAX_LOCAL_DIR_FILES {
325 return Err(invalid(format!(
326 "{} has more than {MAX_LOCAL_DIR_FILES} files (max {MAX_LOCAL_DIR_FILES})",
327 root.display()
328 )));
329 }
330 }
331 Ok(())
332 }
333
334 let mut out = Vec::new();
335 recurse(root, root, "", matcher, &mut out)?;
336 Ok(out)
337}
338
339#[cfg(unix)]
340fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
341 use std::os::unix::fs::PermissionsExt;
342 metadata.permissions().mode() & 0o777
343}
344
345#[cfg(not(unix))]
346fn unix_mode(_metadata: &std::fs::Metadata) -> u32 {
347 0o644
348}
349
350fn ignore_matcher(
351 root: &Path,
352 patterns: &[String],
353 ignore_file: Option<&Path>,
354) -> Result<ignore::gitignore::Gitignore, SailError> {
355 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
356 if let Some(file) = ignore_file {
357 if let Some(err) = builder.add(file) {
358 return Err(invalid(format!(
359 "cannot read ignore file {}: {err}",
360 file.display()
361 )));
362 }
363 }
364 for pattern in patterns {
365 builder
366 .add_line(None, pattern)
367 .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
368 }
369 builder
370 .build()
371 .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
372}
373
374fn base_image_to_pb(base: BaseImage) -> pbimage::BaseImage {
377 match base {
378 BaseImage::Debian => pbimage::BaseImage::Debian,
379 BaseImage::Devbox => pbimage::BaseImage::Devbox,
380 }
381}
382
383fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
384 match arch {
385 ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
386 ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
387 ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
388 }
389}
390
391fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
392 use pbimage::image_build_step::Step;
393 let packages = |p: &PackageInstall| pbimage::PackageInstall {
394 packages: p.packages.clone(),
395 };
396 let inner = match step {
397 ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
398 ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
399 ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
400 command: c.command.clone(),
401 }),
402 ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
403 content_sha256: f.content_sha256.clone(),
404 remote_path: f.remote_path.clone(),
405 mode: f.mode,
406 }),
407 ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
408 remote_path: d.remote_path.clone(),
409 files: d
410 .files
411 .iter()
412 .map(|file| pbimage::AddLocalDirFile {
413 relative_path: file.relative_path.clone(),
414 content_sha256: file.content_sha256.clone(),
415 mode: file.mode,
416 })
417 .collect(),
418 }),
419 };
420 pbimage::ImageBuildStep { step: Some(inner) }
421}
422
423pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
425 pbimage::ImageSpec {
426 source: spec
427 .base
428 .map(|base| pbimage::image_spec::Source::Base(base_image_to_pb(base) as i32)),
429 build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
430 env: spec.env.clone(),
431 architecture: architecture_to_pb(spec.architecture) as i32,
432 python_version: spec.python_version.clone(),
433 filesystem: pbimage::ImageFilesystem::Unspecified as i32,
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(
885 &self,
886 def: &ImageDefinition,
887 timeout: Duration,
888 ) -> Result<ImageSpec, SailError> {
889 let work = async {
890 let spec = self.resolve_image(def).await?;
891 if is_builtin_base_spec(&spec) {
892 return Ok(spec);
893 }
894 self.build_spec_ready_cached(&spec, timeout, false)
895 .await?;
896 Ok(spec)
897 };
898 match Instant::now().checked_add(timeout) {
899 None => work.await,
900 Some(_) => tokio::time::timeout(timeout, work)
901 .await
902 .unwrap_or_else(|_| {
903 Err(SailError::Transport {
904 kind: TransportKind::Timeout,
905 message: "timed out building the image".to_string(),
906 source: None,
907 })
908 }),
909 }
910 }
911
912 #[doc(hidden)]
914 pub async fn build_spec_to_ready(
915 &self,
916 spec: &ImageSpec,
917 deadline: Option<Instant>,
918 ) -> Result<ImageBuild, SailError> {
919 let rpc_budget = || {
922 deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
923 deadline
924 .saturating_duration_since(Instant::now())
925 .as_secs_f64()
926 })
927 };
928 let mut build = self.build_image(spec, rpc_budget()).await?;
929 loop {
930 match build.status {
931 ImageBuildStatus::Ready => return Ok(build),
932 ImageBuildStatus::Failed => {
933 let message = if build.error_message.is_empty() {
934 "image build failed".to_string()
935 } else {
936 build.error_message.clone()
937 };
938 return Err(SailError::ImageBuild { message });
939 }
940 _ => {}
941 }
942 let nap = match deadline {
943 None => BUILD_POLL_INTERVAL,
944 Some(deadline) => {
945 let left = deadline.saturating_duration_since(Instant::now());
946 if left.is_zero() {
947 return Err(SailError::Transport {
948 kind: TransportKind::Timeout,
949 message: format!(
950 "timed out waiting for image build {}",
951 build.image_id
952 ),
953 source: None,
954 });
955 }
956 left.min(BUILD_POLL_INTERVAL)
957 }
958 };
959 tokio::time::sleep(nap).await;
960 build = self
961 .get_image_build_status(&build.image_id, rpc_budget())
962 .await?;
963 }
964 }
965}
966
967pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
971 let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
972 message: format!("serialize image spec: {err}"),
973 })?;
974 let mut hasher = Sha256::new();
975 hasher.update(value.to_string().as_bytes());
976 Ok(format!("{:x}", hasher.finalize()))
977}
978
979fn sized_put_request(
984 http: &reqwest::Client,
985 upload_url: &str,
986 file: tokio::fs::File,
987 size: u64,
988 headers: &HashMap<String, String>,
989) -> (
990 reqwest::RequestBuilder,
991 Arc<std::sync::Mutex<Option<String>>>,
992) {
993 let (body, streamed_digest) = SizedFileBody::new(file, size);
994 let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
995 for (name, value) in headers {
996 request = request.header(name, value);
997 }
998 (request, streamed_digest)
999}
1000
1001fn upload_timeout(size: u64) -> Duration {
1004 UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
1005}
1006
1007struct SizedFileBody {
1012 reader: tokio_util::io::ReaderStream<tokio::fs::File>,
1013 remaining: u64,
1014 hasher: Option<sha2::Sha256>,
1015 streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
1016}
1017
1018impl SizedFileBody {
1019 fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
1020 let streamed_digest = Arc::new(std::sync::Mutex::new(None));
1021 let mut hasher = Some(sha2::Sha256::new());
1022 if size == 0 {
1023 *streamed_digest.lock().unwrap() =
1025 Some(format!("{:x}", hasher.take().unwrap().finalize()));
1026 }
1027 (
1028 SizedFileBody {
1029 reader: tokio_util::io::ReaderStream::new(file),
1030 remaining: size,
1031 hasher,
1032 streamed_digest: Arc::clone(&streamed_digest),
1033 },
1034 streamed_digest,
1035 )
1036 }
1037}
1038
1039impl http_body::Body for SizedFileBody {
1040 type Data = bytes::Bytes;
1041 type Error = std::io::Error;
1042
1043 fn poll_frame(
1044 mut self: std::pin::Pin<&mut Self>,
1045 cx: &mut std::task::Context<'_>,
1046 ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
1047 use futures::Stream;
1048 match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
1049 std::task::Poll::Ready(Some(Ok(chunk))) => {
1050 self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
1051 if let Some(hasher) = self.hasher.as_mut() {
1052 hasher.update(&chunk);
1053 }
1054 if self.remaining == 0 {
1058 if let Some(hasher) = self.hasher.take() {
1059 *self.streamed_digest.lock().unwrap() =
1060 Some(format!("{:x}", hasher.finalize()));
1061 }
1062 }
1063 std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
1064 }
1065 std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
1066 std::task::Poll::Ready(None) => {
1067 if let Some(hasher) = self.hasher.take() {
1068 *self.streamed_digest.lock().unwrap() =
1069 Some(format!("{:x}", hasher.finalize()));
1070 }
1071 std::task::Poll::Ready(None)
1072 }
1073 std::task::Poll::Pending => std::task::Poll::Pending,
1074 }
1075 }
1076
1077 fn is_end_stream(&self) -> bool {
1078 self.remaining == 0
1079 }
1080
1081 fn size_hint(&self) -> http_body::SizeHint {
1082 http_body::SizeHint::with_exact(self.remaining)
1083 }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 #[test]
1089 fn upload_budget_scales_with_content_size() {
1090 assert_eq!(upload_timeout(0), Duration::from_mins(5));
1091 assert_eq!(
1093 upload_timeout(1 << 30),
1094 Duration::from_mins(5) + Duration::from_secs(1024)
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn upload_body_advertises_its_exact_size() {
1100 let dir = tempfile::tempdir().expect("tempdir");
1105 let path = dir.path().join("payload.bin");
1106 std::fs::write(&path, b"0123456789").expect("write");
1107 let file = tokio::fs::File::open(&path).await.expect("open");
1108 let (body, _digest) = SizedFileBody::new(file, 10);
1109 assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1110 assert!(!http_body::Body::is_end_stream(&body));
1111 }
1112
1113 #[tokio::test]
1114 async fn presigned_put_uses_content_length_framing() {
1115 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1116
1117 let dir = tempfile::tempdir().expect("tempdir");
1118 let path = dir.path().join("payload.bin");
1119 std::fs::write(&path, b"0123456789").expect("write");
1120
1121 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1122 .await
1123 .expect("bind");
1124 let addr = listener.local_addr().expect("addr");
1125 let server = tokio::spawn(async move {
1126 let (mut sock, _) = listener.accept().await.expect("accept");
1127 let mut raw = Vec::new();
1128 let mut buf = [0u8; 4096];
1129 loop {
1130 let n = sock.read(&mut buf).await.expect("read");
1131 raw.extend_from_slice(&buf[..n]);
1132 if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1133 let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1134 let body_len = raw.len() - (head_end + 4);
1135 if body_len >= 10 {
1136 sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1137 .await
1138 .expect("respond");
1139 return head;
1140 }
1141 }
1142 }
1143 });
1144
1145 let file = tokio::fs::File::open(&path).await.expect("open");
1146 let headers = HashMap::from([(
1147 "Content-Type".to_string(),
1148 "application/octet-stream".to_string(),
1149 )]);
1150 let (request, streamed_digest) = sized_put_request(
1151 &reqwest::Client::new(),
1152 &format!("http://{addr}/upload"),
1153 file,
1154 10,
1155 &headers,
1156 );
1157 let response = request.send().await.expect("send");
1158 assert!(response.status().is_success());
1159 assert_eq!(
1161 streamed_digest.lock().unwrap().as_deref(),
1162 Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1163 );
1164
1165 let head = server.await.expect("server");
1166 assert!(
1169 head.contains("content-length: 10"),
1170 "missing sized framing in request head: {head}"
1171 );
1172 assert!(
1173 !head.contains("transfer-encoding"),
1174 "request must not be chunked: {head}"
1175 );
1176 }
1177
1178 use super::*;
1179
1180 #[test]
1181 fn remote_path_rules_match_the_wrappers() {
1182 assert!(validate_remote_path("/app/config.json").is_ok());
1183 assert!(validate_remote_path("relative").is_err());
1184 assert!(validate_remote_path("/app/").is_err());
1185 assert!(validate_remote_path("/app/../etc").is_err());
1186 assert!(validate_remote_path("/app/with space").is_err());
1187 assert!(validate_remote_path("/app/$HOME").is_err());
1188 assert!(validate_mode(Some(0o600)).is_ok());
1189 assert!(validate_mode(Some(0o1777)).is_err());
1190 }
1191
1192 #[tokio::test]
1193 async fn resolve_walks_hashes_and_respects_gitignore() {
1194 let dir = tempfile::tempdir().expect("tempdir");
1195 std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1196 std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1197 std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1198 std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1199 std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1200
1201 let matcher = ignore_matcher(
1202 dir.path(),
1203 &["*.pyc".to_string(), "src/generated/".to_string()],
1204 None,
1205 )
1206 .expect("matcher");
1207 let walked = walk_dir(dir.path(), &matcher).expect("walk");
1208 let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1209 paths.sort();
1210 assert_eq!(paths, ["src/keep.py", "top.txt"]);
1211
1212 let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1213 assert_eq!(size, 3);
1214 assert_eq!(
1215 digest,
1216 "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1217 );
1218 }
1219}