Skip to main content

sail/
imagebuild.rs

1//! The custom-image build pipeline, shared by every SDK: resolve an
2//! [`ImageDefinition`] (walk local directories, hash files, upload content)
3//! into a content-addressed [`ImageSpec`], then build it to ready.
4//!
5//! The fluent builder DSL lives in each language wrapper; this module owns
6//! everything below it (gitignore matching, bounds, hashing, presigned
7//! uploads, the typed proto conversion, and the build poll loop) so the
8//! wrappers stay thin and cannot drift.
9
10use 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
27/// S3's single-PUT ceiling; the backend enforces the same cap.
28pub(crate) const MAX_LOCAL_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
29/// Per-directory fail-fast bound, matching the backend.
30pub(crate) const MAX_LOCAL_DIR_FILES: usize = 50_000;
31/// Longest relative path allowed inside an uploaded directory, in bytes.
32pub(crate) const MAX_LOCAL_DIR_RELATIVE_PATH_BYTES: usize = 1024;
33/// Concurrent content uploads during a resolve.
34const UPLOAD_CONCURRENCY: usize = 16;
35/// Delay between build status polls.
36const BUILD_POLL_INTERVAL: Duration = Duration::from_secs(1);
37/// Floor for one presigned PUT, plus [`MIN_UPLOAD_BYTES_PER_SEC`] of body
38/// budget: a stalled upload fails instead of hanging the resolve forever,
39/// while a slow-but-progressing link keeps a generous allowance.
40const UPLOAD_BASE_TIMEOUT: Duration = Duration::from_mins(5);
41/// Throughput floor used to scale the upload budget with content size.
42const MIN_UPLOAD_BYTES_PER_SEC: u64 = 1 << 20;
43/// Transport-retry budget per imagebuilder RPC when no deadline bounds the
44/// build (a deadline caps the budget at the time remaining instead).
45const UNBOUNDED_BUILD_RPC_BUDGET: Duration = Duration::from_mins(1);
46
47fn invalid(message: String) -> SailError {
48    SailError::InvalidArgument { message }
49}
50
51/// The status of a custom image build.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ImageBuildStatus {
54    /// The server reported a status this SDK version does not recognize.
55    Unknown,
56    /// Queued behind other builds.
57    Queued,
58    /// Building now.
59    Building,
60    /// Built and servable.
61    Ready,
62    /// The build failed; see the error message.
63    Failed,
64}
65
66impl ImageBuildStatus {
67    /// The wire string for this status.
68    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/// The state of a custom image build.
90#[derive(Debug, Clone)]
91#[non_exhaustive]
92pub struct ImageBuild {
93    /// The content-addressed image id.
94    pub image_id: String,
95    /// Build status.
96    pub status: ImageBuildStatus,
97    /// Human-readable failure detail when the status is failed, else empty.
98    pub error_message: String,
99}
100
101/// The server's plan for uploading one content-addressed local file.
102#[derive(Debug, Clone)]
103pub(crate) enum LocalFileUploadPlan {
104    /// The content is already stored; nothing to upload.
105    AlreadyExists,
106    /// Upload the bytes with one presigned PUT.
107    SinglePart {
108        /// The presigned URL to PUT to.
109        upload_url: String,
110        /// Headers the PUT must send.
111        headers: HashMap<String, String>,
112    },
113}
114
115/// One step of an [`ImageDefinition`]: a build operation, possibly referencing
116/// local files that resolve uploads before the build.
117#[derive(Debug, Clone)]
118pub enum ImageDefinitionStep {
119    /// Install system packages with apt.
120    AptInstall(Vec<String>),
121    /// Install Python packages with pip.
122    PipInstall(Vec<String>),
123    /// Run a shell command during the build.
124    RunCommand(String),
125    /// Bake one local file into the image.
126    AddLocalFile {
127        /// Path on this machine.
128        local_path: PathBuf,
129        /// Absolute POSIX path inside the image; a trailing `/` appends the
130        /// source basename.
131        remote_path: String,
132        /// Permission bits (low 9); `None` uses the builder default (0644).
133        mode: Option<u32>,
134    },
135    /// Bake a local directory tree into the image. Symlinks are skipped and
136    /// file modes are preserved.
137    AddLocalDir {
138        /// Path on this machine.
139        local_path: PathBuf,
140        /// Absolute POSIX path of the directory root inside the image.
141        remote_path: String,
142        /// Gitignore-style patterns to skip.
143        ignore: Vec<String>,
144        /// A gitignore-style file whose patterns to skip (e.g. `.gitignore`).
145        ignore_file: Option<PathBuf>,
146    },
147}
148
149/// A custom image definition: a base image plus ordered build steps, where
150/// local-file steps still reference paths on this machine. Resolve it with
151/// [`Client::resolve_image`] (hash + upload) or hand it to
152/// [`Client::build_image_definition`] to also build it to ready.
153#[derive(Debug, Clone, Default)]
154pub struct ImageDefinition {
155    /// Base image to build on.
156    pub base: Option<BaseImage>,
157    /// Target CPU architecture; unspecified lets the backend choose.
158    pub architecture: ImageArchitecture,
159    /// Environment variables baked into the image.
160    pub env: HashMap<String, String>,
161    /// Exact Python version to install as `python3`; empty uses the builder
162    /// default.
163    pub python_version: String,
164    /// Ordered build steps.
165    pub steps: Vec<ImageDefinitionStep>,
166}
167
168/// Whether a spec is a bare builtin base the backend ships prebuilt (no build
169/// needed): only build steps, env, or a pinned python version force a build.
170#[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
178/// Validate an in-image destination path: absolute POSIX, no `..`, no control
179/// or shell-hostile characters, no trailing slash.
180fn 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
215/// Hash a local file with SHA-256, returning `(hex digest, size)`.
216async 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
250/// Walk a local directory depth-first in sorted order, applying gitignore-style
251/// matching, skipping symlinks, and enforcing the per-directory bounds.
252fn 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(/* from */ 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
374// --- Typed proto conversion, shared by both bindings. ---
375
376fn 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
423/// Convert a typed [`ImageSpec`] to its wire proto.
424pub(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    }
434}
435
436impl Client {
437    /// The server's plan for uploading a content-addressed local file.
438    pub(crate) async fn prepare_local_file_upload(
439        &self,
440        content_sha256: &str,
441        content_length: u64,
442    ) -> Result<LocalFileUploadPlan, SailError> {
443        let request = pbimg::PrepareLocalFileUploadRequest {
444            content_sha256: content_sha256.to_string(),
445            content_length,
446        };
447        let response = self
448            .imagebuilder()
449            .prepare_local_file_upload(request)
450            .await?;
451        use pbimg::prepare_local_file_upload_response::Outcome;
452        match response.outcome {
453            Some(Outcome::AlreadyExists(_)) => Ok(LocalFileUploadPlan::AlreadyExists),
454            Some(Outcome::SinglePart(plan)) => Ok(LocalFileUploadPlan::SinglePart {
455                upload_url: plan.upload_url,
456                headers: plan.required_headers,
457            }),
458            None => Err(SailError::Internal {
459                message: "prepare_local_file_upload returned no outcome".to_string(),
460            }),
461        }
462    }
463
464    /// Submit or resume a custom image build. Poll
465    /// [`Client::get_image_build_status`] until the status is ready or failed,
466    /// or use [`Client::build_image_definition`] for the whole pipeline.
467    pub async fn build_image(
468        &self,
469        spec: &ImageSpec,
470        retry_timeout_secs: f64,
471    ) -> Result<ImageBuild, SailError> {
472        let request = pbimg::BuildImageRequest {
473            image: Some(image_spec_to_pb(spec)),
474        };
475        let response = self
476            .imagebuilder()
477            .build_image(request, retry_timeout_secs)
478            .await?;
479        Ok(ImageBuild {
480            image_id: response.image_id,
481            status: ImageBuildStatus::from_pb(response.status),
482            error_message: response.error_message,
483        })
484    }
485
486    /// Poll one custom image build's status.
487    pub async fn get_image_build_status(
488        &self,
489        image_id: &str,
490        retry_timeout_secs: f64,
491    ) -> Result<ImageBuild, SailError> {
492        let request = pbimg::GetImageBuildStatusRequest {
493            image_id: image_id.to_string(),
494        };
495        let response = self
496            .imagebuilder()
497            .get_image_build_status(request, retry_timeout_secs)
498            .await?;
499        Ok(ImageBuild {
500            image_id: response.image_id,
501            status: ImageBuildStatus::from_pb(response.status),
502            error_message: response.error_message,
503        })
504    }
505
506    /// Resolve one local file into a content-addressed `addLocalFile` step,
507    /// uploading its bytes if the server does not already have them.
508    #[doc(hidden)]
509    pub async fn resolve_local_file_step(
510        &self,
511        local_path: &Path,
512        remote_path: &str,
513        mode: Option<u32>,
514    ) -> Result<crate::image::AddLocalFile, SailError> {
515        let metadata = std::fs::metadata(local_path).map_err(|_| {
516            invalid(format!(
517                "addLocalFile: {} does not exist or is not a file",
518                local_path.display()
519            ))
520        })?;
521        if !metadata.is_file() {
522            return Err(invalid(format!(
523                "addLocalFile: {} is not a file",
524                local_path.display()
525            )));
526        }
527        if metadata.len() > MAX_LOCAL_FILE_BYTES {
528            return Err(invalid(format!(
529                "addLocalFile: {} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
530                local_path.display(),
531                metadata.len()
532            )));
533        }
534        let mode = validate_mode(mode)?;
535        let mut target = remote_path.to_string();
536        if target.ends_with('/') {
537            let basename = local_path
538                .file_name()
539                .map(|name| name.to_string_lossy().into_owned())
540                .unwrap_or_default();
541            target = format!("{target}{basename}");
542        }
543        validate_remote_path(&target)?;
544        let (digest, size) = hash_file(local_path).await?;
545        // A file still being written can grow past the stat-time check before
546        // hashing finishes; the hash-time size is what actually uploads.
547        if size > MAX_LOCAL_FILE_BYTES {
548            return Err(invalid(format!(
549                "addLocalFile: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte limit",
550                local_path.display()
551            )));
552        }
553        let http = reqwest::Client::new();
554        self.upload_local_content(&http, &digest, local_path, size)
555            .await?;
556        Ok(crate::image::AddLocalFile {
557            content_sha256: digest,
558            remote_path: target,
559            mode,
560        })
561    }
562
563    /// Resolve one local directory into a content-addressed `addLocalDir`
564    /// step: walk it with gitignore-style matching, hash every file, and
565    /// upload content the server does not already have.
566    #[doc(hidden)]
567    pub async fn resolve_local_dir_step(
568        &self,
569        local_path: &Path,
570        remote_path: &str,
571        ignore: &[String],
572        ignore_file: Option<&Path>,
573    ) -> Result<crate::image::AddLocalDir, SailError> {
574        let target = remote_path.trim_end_matches('/').to_string();
575        if target.is_empty() {
576            return Err(invalid(
577                "addLocalDir: remotePath must not be '/'".to_string(),
578            ));
579        }
580        validate_remote_path(&target)?;
581        // The stat/walk phase is synchronous filesystem work that a large or
582        // slow tree can stretch out; run it off the async runtime (like
583        // hash_file) so the pipeline timeout can preempt it and other core
584        // tasks keep running.
585        let walk_root = local_path.to_path_buf();
586        let ignore_owned = ignore.to_vec();
587        let ignore_file_owned = ignore_file.map(Path::to_path_buf);
588        let has_ignore = !ignore.is_empty() || ignore_file.is_some();
589        let walked = tokio::task::spawn_blocking(move || {
590            let metadata = std::fs::metadata(&walk_root).map_err(|_| {
591                invalid(format!(
592                    "addLocalDir: {} does not exist or is not a directory",
593                    walk_root.display()
594                ))
595            })?;
596            if !metadata.is_dir() {
597                return Err(invalid(format!(
598                    "addLocalDir: {} is not a directory",
599                    walk_root.display()
600                )));
601            }
602            let matcher = ignore_matcher(&walk_root, &ignore_owned, ignore_file_owned.as_deref())?;
603            let walked = walk_dir(&walk_root, &matcher)?;
604            if walked.is_empty() {
605                let qualifier = if has_ignore {
606                    " after applying ignore patterns"
607                } else {
608                    ""
609                };
610                return Err(invalid(format!(
611                    "addLocalDir: {} contains no files{qualifier}",
612                    walk_root.display()
613                )));
614            }
615            Ok(walked)
616        })
617        .await
618        .map_err(|err| SailError::Internal {
619            message: format!("directory walk task failed: {err}"),
620        })??;
621        // digest -> (source path, size); deduped so shared content uploads once.
622        let mut uploads: HashMap<String, (PathBuf, u64)> = HashMap::new();
623        let mut files = Vec::with_capacity(walked.len());
624        for file in walked {
625            let (digest, size) = hash_file(&file.abs_path).await?;
626            if size > MAX_LOCAL_FILE_BYTES {
627                return Err(invalid(format!(
628                    "addLocalDir: {} ({size} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte \
629                     per-file limit",
630                    file.abs_path.display()
631                )));
632            }
633            uploads
634                .entry(digest.clone())
635                .or_insert_with(|| (file.abs_path.clone(), size));
636            files.push(AddLocalDirFile {
637                relative_path: file.relative_path,
638                content_sha256: digest,
639                mode: file.mode,
640            });
641        }
642        files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
643        let http = reqwest::Client::new();
644        stream::iter(uploads.into_iter().map(Ok::<_, SailError>))
645            .try_for_each_concurrent(UPLOAD_CONCURRENCY, |(digest, (source, size))| {
646                let http = http.clone();
647                async move {
648                    self.upload_local_content(&http, &digest, &source, size)
649                        .await
650                }
651            })
652            .await?;
653        Ok(crate::image::AddLocalDir {
654            remote_path: target,
655            files,
656        })
657    }
658
659    /// Resolve an [`ImageDefinition`] into a content-addressed [`ImageSpec`]:
660    /// walk local directories, hash every file, and upload content the server
661    /// does not already have.
662    pub async fn resolve_image(&self, def: &ImageDefinition) -> Result<ImageSpec, SailError> {
663        let mut steps = Vec::with_capacity(def.steps.len());
664        for step in &def.steps {
665            steps.push(match step {
666                ImageDefinitionStep::AptInstall(packages) => {
667                    ImageBuildStep::AptInstall(PackageInstall {
668                        packages: packages.clone(),
669                    })
670                }
671                ImageDefinitionStep::PipInstall(packages) => {
672                    ImageBuildStep::PipInstall(PackageInstall {
673                        packages: packages.clone(),
674                    })
675                }
676                ImageDefinitionStep::RunCommand(command) => {
677                    ImageBuildStep::RunCommand(RunCommand {
678                        command: command.clone(),
679                    })
680                }
681                ImageDefinitionStep::AddLocalFile {
682                    local_path,
683                    remote_path,
684                    mode,
685                } => ImageBuildStep::AddLocalFile(
686                    self.resolve_local_file_step(local_path, remote_path, *mode)
687                        .await?,
688                ),
689                ImageDefinitionStep::AddLocalDir {
690                    local_path,
691                    remote_path,
692                    ignore,
693                    ignore_file,
694                } => ImageBuildStep::AddLocalDir(
695                    self.resolve_local_dir_step(
696                        local_path,
697                        remote_path,
698                        ignore,
699                        ignore_file.as_deref(),
700                    )
701                    .await?,
702                ),
703            });
704        }
705        Ok(ImageSpec {
706            base: def.base,
707            build_steps: steps,
708            env: def.env.clone(),
709            architecture: def.architecture,
710            python_version: def.python_version.clone(),
711        })
712    }
713
714    /// Upload one content-addressed local file if the server does not already
715    /// have it.
716    async fn upload_local_content(
717        &self,
718        http: &reqwest::Client,
719        digest: &str,
720        source: &Path,
721        size: u64,
722    ) -> Result<(), SailError> {
723        let plan = self.prepare_local_file_upload(digest, size).await?;
724        let LocalFileUploadPlan::SinglePart {
725            upload_url,
726            headers,
727        } = plan
728        else {
729            return Ok(());
730        };
731        let file = tokio::fs::File::open(source)
732            .await
733            .map_err(|err| invalid(format!("cannot read {}: {err}", source.display())))?;
734        let (request, streamed_digest) = sized_put_request(http, &upload_url, file, size, &headers);
735        let response = tokio::time::timeout(upload_timeout(size), request.send())
736            .await
737            .map_err(|_| SailError::Transport {
738                kind: TransportKind::Timeout,
739                message: format!("local file upload stalled ({size} bytes not delivered in time)"),
740                source: None,
741            })?
742            .map_err(|err| SailError::Transport {
743                kind: TransportKind::Connection,
744                message: format!("local file upload failed: {err}"),
745                source: None,
746            })?;
747        if !response.status().is_success() {
748            return Err(SailError::Api {
749                message: format!(
750                    "local file upload failed: HTTP {} {}",
751                    response.status().as_u16(),
752                    response.status().canonical_reason().unwrap_or("")
753                ),
754                status: response.status().as_u16(),
755                body: serde_json::Value::Null,
756            });
757        }
758        // The file was hashed before this second open; a rewrite in between
759        // (same size, different bytes) would poison the content-addressed
760        // store under the old digest. The body hashed what it actually
761        // streamed, so fail the build instead of using a mismatched object.
762        let streamed = streamed_digest.lock().unwrap().take();
763        if streamed.as_deref() != Some(digest) {
764            return Err(invalid(format!(
765                "{} changed while it was being uploaded; retry the build",
766                source.display()
767            )));
768        }
769        Ok(())
770    }
771
772    /// Build an already-resolved spec to ready, bounded by `timeout` (an
773    /// unrepresentably large value waits indefinitely). The envelope both
774    /// bridges and [`Client::build_image_definition`] share. Readiness is
775    /// memoized per client (see [`crate::imagecache`]): concurrent callers
776    /// share one build, a completed build serves later callers until the
777    /// refresh window lapses, and failures always retry.
778    #[doc(hidden)]
779    pub async fn build_spec_with_timeout(
780        &self,
781        spec: &ImageSpec,
782        timeout: Duration,
783    ) -> Result<ImageBuild, SailError> {
784        match Instant::now().checked_add(timeout) {
785            None => {
786                self.build_spec_ready_cached(spec, timeout, /* recovery */ false)
787                    .await
788            }
789            Some(_) => tokio::time::timeout(
790                timeout,
791                self.build_spec_ready_cached(spec, timeout, /* recovery */ false),
792            )
793            .await
794            .unwrap_or_else(|_| {
795                Err(SailError::Transport {
796                    kind: TransportKind::Timeout,
797                    message: "timed out building the image".to_string(),
798                    source: None,
799                })
800            }),
801        }
802    }
803
804    /// Build a spec to ready through the client's readiness cache. Callers
805    /// share one build per (spec, timeout) key; keying by the caller's
806    /// timeout means a caller only ever joins a build started with its own
807    /// bound. A caller that joined an earlier build and saw it hit that
808    /// build's deadline retries with a fresh entry, so joining never
809    /// shortens the caller's own budget (the caller's outer envelope still
810    /// bounds the total wait).
811    pub(crate) async fn build_spec_ready_cached(
812        &self,
813        spec: &ImageSpec,
814        timeout: Duration,
815        recovery: bool,
816    ) -> Result<ImageBuild, SailError> {
817        let key: crate::imagecache::CacheKey = (timeout, canonical_spec_key(spec)?);
818        loop {
819            let joined = self.image_ready_cache().join_or_lead(&key, recovery, |id| {
820                let client = self.clone();
821                let spec = spec.clone();
822                let key = key.clone();
823                let deadline = Instant::now().checked_add(timeout);
824                futures::FutureExt::shared(futures::FutureExt::boxed(async move {
825                    let result = client.build_spec_to_ready(&spec, deadline).await;
826                    match &result {
827                        Ok(build) => {
828                            client
829                                .image_ready_cache()
830                                .settle_success(&key, id, build.clone());
831                        }
832                        Err(_) => client.image_ready_cache().settle_failure(&key, id),
833                    }
834                    result.map_err(Arc::new)
835                }))
836            });
837            let (shared, led) = match joined {
838                crate::imagecache::Joined::Ready(build) => return Ok(build),
839                crate::imagecache::Joined::Pending { build, led } => (build, led),
840            };
841            match shared.await {
842                Ok(build) => return Ok(build),
843                Err(err) => {
844                    let timed_out = matches!(
845                        err.as_ref(),
846                        SailError::Transport {
847                            kind: TransportKind::Timeout,
848                            ..
849                        }
850                    );
851                    if led || !timed_out {
852                        // A sole caller (the common case) unwraps the original
853                        // error; concurrent failure waiters each get a copy
854                        // whose source chains to the shared original.
855                        return Err(
856                            Arc::try_unwrap(err).unwrap_or_else(|arc| SailError::fan_out(&arc))
857                        );
858                    }
859                }
860            }
861        }
862    }
863
864    /// Resolve an [`ImageDefinition`] and build it to ready, returning the
865    /// content-addressed [`ImageSpec`] to create Sailboxes from. A bare
866    /// builtin base skips the build. `timeout` bounds the whole pipeline
867    /// (hashing, uploads, and the build); 30 minutes is a good default, and
868    /// [`Duration::MAX`] waits indefinitely. Local files are re-hashed on
869    /// every call, so edits always reach the build, and rebuilding an
870    /// unchanged, already-built image returns quickly.
871    pub async fn build_image_definition(
872        &self,
873        def: &ImageDefinition,
874        timeout: Duration,
875    ) -> Result<ImageSpec, SailError> {
876        let work = async {
877            let spec = self.resolve_image(def).await?;
878            if is_builtin_base_spec(&spec) {
879                return Ok(spec);
880            }
881            self.build_spec_ready_cached(&spec, timeout, /* recovery */ false)
882                .await?;
883            Ok(spec)
884        };
885        match Instant::now().checked_add(timeout) {
886            None => work.await,
887            Some(_) => tokio::time::timeout(timeout, work)
888                .await
889                .unwrap_or_else(|_| {
890                    Err(SailError::Transport {
891                        kind: TransportKind::Timeout,
892                        message: "timed out building the image".to_string(),
893                        source: None,
894                    })
895                }),
896        }
897    }
898
899    /// Build an already-resolved spec to ready (submit + poll).
900    #[doc(hidden)]
901    pub async fn build_spec_to_ready(
902        &self,
903        spec: &ImageSpec,
904        deadline: Option<Instant>,
905    ) -> Result<ImageBuild, SailError> {
906        // Per-RPC transport-retry budget: the time left until the deadline,
907        // or a fixed bound when the caller waits indefinitely.
908        let rpc_budget = || {
909            deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
910                deadline
911                    .saturating_duration_since(Instant::now())
912                    .as_secs_f64()
913            })
914        };
915        let mut build = self.build_image(spec, rpc_budget()).await?;
916        loop {
917            match build.status {
918                ImageBuildStatus::Ready => return Ok(build),
919                ImageBuildStatus::Failed => {
920                    let message = if build.error_message.is_empty() {
921                        "image build failed".to_string()
922                    } else {
923                        build.error_message.clone()
924                    };
925                    return Err(SailError::ImageBuild { message });
926                }
927                _ => {}
928            }
929            let nap = match deadline {
930                None => BUILD_POLL_INTERVAL,
931                Some(deadline) => {
932                    let left = deadline.saturating_duration_since(Instant::now());
933                    if left.is_zero() {
934                        return Err(SailError::Transport {
935                            kind: TransportKind::Timeout,
936                            message: format!(
937                                "timed out waiting for image build {}",
938                                build.image_id
939                            ),
940                            source: None,
941                        });
942                    }
943                    left.min(BUILD_POLL_INTERVAL)
944                }
945            };
946            tokio::time::sleep(nap).await;
947            build = self
948                .get_image_build_status(&build.image_id, rpc_budget())
949                .await?;
950        }
951    }
952}
953
954/// The readiness-cache identity of a spec: the sha256 of its canonical
955/// (key-sorted) JSON, the same serialization the create request sends.
956/// Hashing bounds key memory for specs carrying many content digests.
957pub(crate) fn canonical_spec_key(spec: &ImageSpec) -> Result<String, SailError> {
958    let value = serde_json::to_value(spec).map_err(|err| SailError::Internal {
959        message: format!("serialize image spec: {err}"),
960    })?;
961    let mut hasher = Sha256::new();
962    hasher.update(value.to_string().as_bytes());
963    Ok(format!("{:x}", hasher.finalize()))
964}
965
966/// Build the presigned PUT for one content-addressed upload. Presigned PUT
967/// endpoints reject chunked transfer encoding, so the body must advertise its
968/// exact size; hyper then frames the request with Content-Length while the
969/// file still streams from disk.
970fn sized_put_request(
971    http: &reqwest::Client,
972    upload_url: &str,
973    file: tokio::fs::File,
974    size: u64,
975    headers: &HashMap<String, String>,
976) -> (
977    reqwest::RequestBuilder,
978    Arc<std::sync::Mutex<Option<String>>>,
979) {
980    let (body, streamed_digest) = SizedFileBody::new(file, size);
981    let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
982    for (name, value) in headers {
983        request = request.header(name, value);
984    }
985    (request, streamed_digest)
986}
987
988/// The whole-request budget for one presigned PUT: a base allowance plus the
989/// body at a conservative throughput floor.
990fn upload_timeout(size: u64) -> Duration {
991    UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
992}
993
994/// A streaming request body over a file with an exact size hint. Presigned
995/// PUT endpoints reject chunked transfer encoding, so the body must report
996/// its length up front; the file itself still streams from disk in 64 KiB
997/// frames rather than being buffered whole.
998struct SizedFileBody {
999    reader: tokio_util::io::ReaderStream<tokio::fs::File>,
1000    remaining: u64,
1001    hasher: Option<sha2::Sha256>,
1002    streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
1003}
1004
1005impl SizedFileBody {
1006    fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
1007        let streamed_digest = Arc::new(std::sync::Mutex::new(None));
1008        let mut hasher = Some(sha2::Sha256::new());
1009        if size == 0 {
1010            // An empty body may never be polled; its digest is already known.
1011            *streamed_digest.lock().unwrap() =
1012                Some(format!("{:x}", hasher.take().unwrap().finalize()));
1013        }
1014        (
1015            SizedFileBody {
1016                reader: tokio_util::io::ReaderStream::new(file),
1017                remaining: size,
1018                hasher,
1019                streamed_digest: Arc::clone(&streamed_digest),
1020            },
1021            streamed_digest,
1022        )
1023    }
1024}
1025
1026impl http_body::Body for SizedFileBody {
1027    type Data = bytes::Bytes;
1028    type Error = std::io::Error;
1029
1030    fn poll_frame(
1031        mut self: std::pin::Pin<&mut Self>,
1032        cx: &mut std::task::Context<'_>,
1033    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
1034        use futures::Stream;
1035        match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
1036            std::task::Poll::Ready(Some(Ok(chunk))) => {
1037                self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
1038                if let Some(hasher) = self.hasher.as_mut() {
1039                    hasher.update(&chunk);
1040                }
1041                // Exact Content-Length framing means the final end-of-stream
1042                // poll may never come; finalize as soon as the advertised
1043                // bytes have been streamed.
1044                if self.remaining == 0 {
1045                    if let Some(hasher) = self.hasher.take() {
1046                        *self.streamed_digest.lock().unwrap() =
1047                            Some(format!("{:x}", hasher.finalize()));
1048                    }
1049                }
1050                std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
1051            }
1052            std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
1053            std::task::Poll::Ready(None) => {
1054                if let Some(hasher) = self.hasher.take() {
1055                    *self.streamed_digest.lock().unwrap() =
1056                        Some(format!("{:x}", hasher.finalize()));
1057                }
1058                std::task::Poll::Ready(None)
1059            }
1060            std::task::Poll::Pending => std::task::Poll::Pending,
1061        }
1062    }
1063
1064    fn is_end_stream(&self) -> bool {
1065        self.remaining == 0
1066    }
1067
1068    fn size_hint(&self) -> http_body::SizeHint {
1069        http_body::SizeHint::with_exact(self.remaining)
1070    }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    #[test]
1076    fn upload_budget_scales_with_content_size() {
1077        assert_eq!(upload_timeout(0), Duration::from_mins(5));
1078        // 1 GiB at the 1 MiB/s floor adds 1024s to the base allowance.
1079        assert_eq!(
1080            upload_timeout(1 << 30),
1081            Duration::from_mins(5) + Duration::from_secs(1024)
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn upload_body_advertises_its_exact_size() {
1087        // The presigned plan's endpoint rejects chunked transfer encoding.
1088        // Framing is decided from the body's own size hint (a manual
1089        // Content-Length header is not sufficient on every protocol), so the
1090        // body must report the exact size before any bytes are read.
1091        let dir = tempfile::tempdir().expect("tempdir");
1092        let path = dir.path().join("payload.bin");
1093        std::fs::write(&path, b"0123456789").expect("write");
1094        let file = tokio::fs::File::open(&path).await.expect("open");
1095        let (body, _digest) = SizedFileBody::new(file, 10);
1096        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1097        assert!(!http_body::Body::is_end_stream(&body));
1098    }
1099
1100    #[tokio::test]
1101    async fn presigned_put_uses_content_length_framing() {
1102        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1103
1104        let dir = tempfile::tempdir().expect("tempdir");
1105        let path = dir.path().join("payload.bin");
1106        std::fs::write(&path, b"0123456789").expect("write");
1107
1108        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1109            .await
1110            .expect("bind");
1111        let addr = listener.local_addr().expect("addr");
1112        let server = tokio::spawn(async move {
1113            let (mut sock, _) = listener.accept().await.expect("accept");
1114            let mut raw = Vec::new();
1115            let mut buf = [0u8; 4096];
1116            loop {
1117                let n = sock.read(&mut buf).await.expect("read");
1118                raw.extend_from_slice(&buf[..n]);
1119                if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1120                    let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1121                    let body_len = raw.len() - (head_end + 4);
1122                    if body_len >= 10 {
1123                        sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1124                            .await
1125                            .expect("respond");
1126                        return head;
1127                    }
1128                }
1129            }
1130        });
1131
1132        let file = tokio::fs::File::open(&path).await.expect("open");
1133        let headers = HashMap::from([(
1134            "Content-Type".to_string(),
1135            "application/octet-stream".to_string(),
1136        )]);
1137        let (request, streamed_digest) = sized_put_request(
1138            &reqwest::Client::new(),
1139            &format!("http://{addr}/upload"),
1140            file,
1141            10,
1142            &headers,
1143        );
1144        let response = request.send().await.expect("send");
1145        assert!(response.status().is_success());
1146        // The body hashed exactly what it streamed.
1147        assert_eq!(
1148            streamed_digest.lock().unwrap().as_deref(),
1149            Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1150        );
1151
1152        let head = server.await.expect("server");
1153        // Presigned endpoints reject chunked transfer encoding; the request
1154        // must carry the exact Content-Length instead.
1155        assert!(
1156            head.contains("content-length: 10"),
1157            "missing sized framing in request head: {head}"
1158        );
1159        assert!(
1160            !head.contains("transfer-encoding"),
1161            "request must not be chunked: {head}"
1162        );
1163    }
1164
1165    use super::*;
1166
1167    #[test]
1168    fn remote_path_rules_match_the_wrappers() {
1169        assert!(validate_remote_path("/app/config.json").is_ok());
1170        assert!(validate_remote_path("relative").is_err());
1171        assert!(validate_remote_path("/app/").is_err());
1172        assert!(validate_remote_path("/app/../etc").is_err());
1173        assert!(validate_remote_path("/app/with space").is_err());
1174        assert!(validate_remote_path("/app/$HOME").is_err());
1175        assert!(validate_mode(Some(0o600)).is_ok());
1176        assert!(validate_mode(Some(0o1777)).is_err());
1177    }
1178
1179    #[tokio::test]
1180    async fn resolve_walks_hashes_and_respects_gitignore() {
1181        let dir = tempfile::tempdir().expect("tempdir");
1182        std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1183        std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1184        std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1185        std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1186        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1187
1188        let matcher = ignore_matcher(
1189            dir.path(),
1190            &["*.pyc".to_string(), "src/generated/".to_string()],
1191            /* ignore_file */ None,
1192        )
1193        .expect("matcher");
1194        let walked = walk_dir(dir.path(), &matcher).expect("walk");
1195        let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1196        paths.sort();
1197        assert_eq!(paths, ["src/keep.py", "top.txt"]);
1198
1199        let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1200        assert_eq!(size, 3);
1201        assert_eq!(
1202            digest,
1203            "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1204        );
1205    }
1206}