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