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    Unspecified,
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::Unspecified => "unspecified",
71            ImageBuildStatus::Queued => "queued",
72            ImageBuildStatus::Building => "building",
73            ImageBuildStatus::Ready => "ready",
74            ImageBuildStatus::Failed => "failed",
75        }
76    }
77
78    fn from_pb(status: i32) -> ImageBuildStatus {
79        match pbimage::ImageBuildStatus::try_from(status) {
80            Ok(pbimage::ImageBuildStatus::Queued) => ImageBuildStatus::Queued,
81            Ok(pbimage::ImageBuildStatus::Building) => ImageBuildStatus::Building,
82            Ok(pbimage::ImageBuildStatus::Ready) => ImageBuildStatus::Ready,
83            Ok(pbimage::ImageBuildStatus::Failed) => ImageBuildStatus::Failed,
84            _ => ImageBuildStatus::Unspecified,
85        }
86    }
87}
88
89/// The state of a custom image build.
90#[derive(Debug, Clone)]
91pub struct ImageBuild {
92    /// The content-addressed image id.
93    pub image_id: String,
94    /// Build status.
95    pub status: ImageBuildStatus,
96    /// Human-readable failure detail when the status is failed, else empty.
97    pub error_message: String,
98}
99
100/// The server's plan for uploading one content-addressed local file.
101#[derive(Debug, Clone)]
102pub(crate) enum LocalFileUploadPlan {
103    /// The content is already stored; nothing to upload.
104    AlreadyExists,
105    /// Upload the bytes with one presigned PUT.
106    SinglePart {
107        /// The presigned URL to PUT to.
108        upload_url: String,
109        /// Headers the PUT must send.
110        headers: HashMap<String, String>,
111    },
112}
113
114/// One step of an [`ImageDefinition`]: a build operation, possibly referencing
115/// local files that resolve uploads before the build.
116#[derive(Debug, Clone)]
117pub enum ImageDefinitionStep {
118    /// Install system packages with apt.
119    AptInstall(Vec<String>),
120    /// Install Python packages with pip.
121    PipInstall(Vec<String>),
122    /// Run a shell command during the build.
123    RunCommand(String),
124    /// Bake one local file into the image.
125    AddLocalFile {
126        /// Path on this machine.
127        local_path: PathBuf,
128        /// Absolute POSIX path inside the image; a trailing `/` appends the
129        /// source basename.
130        remote_path: String,
131        /// Permission bits (low 9); `None` uses the builder default (0644).
132        mode: Option<u32>,
133    },
134    /// Bake a local directory tree into the image. Symlinks are skipped and
135    /// file modes are preserved.
136    AddLocalDir {
137        /// Path on this machine.
138        local_path: PathBuf,
139        /// Absolute POSIX path of the directory root inside the image.
140        remote_path: String,
141        /// Gitignore-style patterns to skip.
142        ignore: Vec<String>,
143        /// A gitignore-style file whose patterns to skip (e.g. `.gitignore`).
144        ignore_file: Option<PathBuf>,
145    },
146}
147
148/// A custom image definition: a base image plus ordered build steps, where
149/// local-file steps still reference paths on this machine. Resolve it with
150/// [`Client::resolve_image`] (hash + upload) or hand it to
151/// [`Client::build_image_definition`] to also build it to ready.
152#[derive(Debug, Clone, Default)]
153pub struct ImageDefinition {
154    /// Base image to build on.
155    pub base: Option<BaseImage>,
156    /// Target CPU architecture; unspecified lets the backend choose.
157    pub architecture: ImageArchitecture,
158    /// Environment variables baked into the image.
159    pub env: HashMap<String, String>,
160    /// Exact Python version to install as `python3`; empty uses the builder
161    /// default.
162    pub python_version: String,
163    /// Ordered build steps.
164    pub steps: Vec<ImageDefinitionStep>,
165}
166
167/// Whether a spec is a bare builtin base the backend ships prebuilt (no build
168/// needed): only build steps, env, or a pinned python version force a build.
169#[doc(hidden)]
170pub fn is_builtin_base_spec(spec: &ImageSpec) -> bool {
171    matches!(spec.base, Some(BaseImage::Debian | BaseImage::Devbox))
172        && spec.build_steps.is_empty()
173        && spec.env.is_empty()
174        && spec.python_version.is_empty()
175}
176
177/// Validate an in-image destination path: absolute POSIX, no `..`, no control
178/// or shell-hostile characters, no trailing slash.
179fn validate_remote_path(target: &str) -> Result<(), SailError> {
180    if !target.starts_with('/') {
181        return Err(invalid(format!("remotePath {target:?} must be absolute")));
182    }
183    if target.len() > 1 && target.ends_with('/') {
184        return Err(invalid(format!(
185            "remotePath {target:?} must not end with '/'"
186        )));
187    }
188    for ch in target.chars() {
189        let code = ch as u32;
190        if code < 0x20 || code == 0x7f || matches!(ch, '"' | '\\' | '$' | ' ') {
191            return Err(invalid(format!(
192                "remotePath {target:?} contains an unsupported character"
193            )));
194        }
195    }
196    if target.split('/').any(|segment| segment == "..") {
197        return Err(invalid(format!(
198            "remotePath {target:?} must not contain '..'"
199        )));
200    }
201    Ok(())
202}
203
204fn validate_mode(mode: Option<u32>) -> Result<u32, SailError> {
205    match mode {
206        None | Some(0) => Ok(0),
207        Some(mode) if mode <= 0o777 => Ok(mode),
208        Some(mode) => Err(invalid(format!(
209            "mode 0o{mode:o} must fit in the low 9 bits"
210        ))),
211    }
212}
213
214/// Hash a local file with SHA-256, returning `(hex digest, size)`.
215async fn hash_file(path: &Path) -> Result<(String, u64), SailError> {
216    let path = path.to_path_buf();
217    tokio::task::spawn_blocking(move || {
218        use std::io::Read;
219        let file = std::fs::File::open(&path)
220            .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
221        let mut reader = std::io::BufReader::new(file);
222        let mut hasher = Sha256::new();
223        let mut buf = vec![0u8; 64 * 1024];
224        let mut size: u64 = 0;
225        loop {
226            let n = reader
227                .read(&mut buf)
228                .map_err(|err| invalid(format!("cannot read {}: {err}", path.display())))?;
229            if n == 0 {
230                break;
231            }
232            hasher.update(&buf[..n]);
233            size += n as u64;
234        }
235        Ok((format!("{:x}", hasher.finalize()), size))
236    })
237    .await
238    .map_err(|err| SailError::Internal {
239        message: format!("hashing task failed: {err}"),
240    })?
241}
242
243struct WalkedFile {
244    abs_path: PathBuf,
245    relative_path: String,
246    mode: u32,
247}
248
249/// Walk a local directory depth-first in sorted order, applying gitignore-style
250/// matching, skipping symlinks, and enforcing the per-directory bounds.
251fn walk_dir(
252    root: &Path,
253    matcher: &ignore::gitignore::Gitignore,
254) -> Result<Vec<WalkedFile>, SailError> {
255    fn recurse(
256        root: &Path,
257        dir: &Path,
258        rel: &str,
259        matcher: &ignore::gitignore::Gitignore,
260        out: &mut Vec<WalkedFile>,
261    ) -> Result<(), SailError> {
262        let mut entries: Vec<_> = std::fs::read_dir(dir)
263            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?
264            .collect::<Result<_, _>>()
265            .map_err(|err| invalid(format!("cannot read {}: {err}", dir.display())))?;
266        entries.sort_by_key(std::fs::DirEntry::file_name);
267        for entry in entries {
268            let name = entry
269                .file_name()
270                .to_str()
271                .ok_or_else(|| {
272                    invalid(format!(
273                        "addLocalDir: {} has a non-UTF-8 file name",
274                        entry.path().display()
275                    ))
276                })?
277                .to_string();
278            let rel_path = if rel.is_empty() {
279                name.clone()
280            } else {
281                format!("{rel}/{name}")
282            };
283            let file_type = entry
284                .file_type()
285                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
286            if file_type.is_symlink() {
287                continue;
288            }
289            let is_dir = file_type.is_dir();
290            if matcher
291                .matched_path_or_any_parents(&rel_path, is_dir)
292                .is_ignore()
293            {
294                continue;
295            }
296            if is_dir {
297                recurse(root, &entry.path(), &rel_path, matcher, out)?;
298                continue;
299            }
300            if !file_type.is_file() {
301                continue;
302            }
303            if rel_path.len() > MAX_LOCAL_DIR_RELATIVE_PATH_BYTES {
304                return Err(invalid(format!(
305                    "relative path {rel_path} exceeds {MAX_LOCAL_DIR_RELATIVE_PATH_BYTES} bytes"
306                )));
307            }
308            let metadata = entry
309                .metadata()
310                .map_err(|err| invalid(format!("cannot stat {}: {err}", entry.path().display())))?;
311            if metadata.len() > MAX_LOCAL_FILE_BYTES {
312                return Err(invalid(format!(
313                    "{} ({} bytes) exceeds the {MAX_LOCAL_FILE_BYTES}-byte per-file limit",
314                    entry.path().display(),
315                    metadata.len()
316                )));
317            }
318            out.push(WalkedFile {
319                abs_path: entry.path(),
320                relative_path: rel_path,
321                mode: unix_mode(&metadata),
322            });
323            if out.len() > MAX_LOCAL_DIR_FILES {
324                return Err(invalid(format!(
325                    "{} has more than {MAX_LOCAL_DIR_FILES} files (max {MAX_LOCAL_DIR_FILES})",
326                    root.display()
327                )));
328            }
329        }
330        Ok(())
331    }
332
333    let mut out = Vec::new();
334    recurse(root, root, "", matcher, &mut out)?;
335    Ok(out)
336}
337
338#[cfg(unix)]
339fn unix_mode(metadata: &std::fs::Metadata) -> u32 {
340    use std::os::unix::fs::PermissionsExt;
341    metadata.permissions().mode() & 0o777
342}
343
344#[cfg(not(unix))]
345fn unix_mode(_metadata: &std::fs::Metadata) -> u32 {
346    0o644
347}
348
349fn ignore_matcher(
350    root: &Path,
351    patterns: &[String],
352    ignore_file: Option<&Path>,
353) -> Result<ignore::gitignore::Gitignore, SailError> {
354    let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
355    if let Some(file) = ignore_file {
356        if let Some(err) = builder.add(file) {
357            return Err(invalid(format!(
358                "cannot read ignore file {}: {err}",
359                file.display()
360            )));
361        }
362    }
363    for pattern in patterns {
364        builder
365            .add_line(/* from */ None, pattern)
366            .map_err(|err| invalid(format!("invalid ignore pattern {pattern:?}: {err}")))?;
367    }
368    builder
369        .build()
370        .map_err(|err| invalid(format!("invalid ignore patterns: {err}")))
371}
372
373// --- Typed proto conversion (the single copy; previously duplicated in the
374// napi and PyO3 bridges). ---
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        BaseImage::Unspecified => pbimage::BaseImage::Unspecified,
381    }
382}
383
384fn architecture_to_pb(arch: ImageArchitecture) -> pbimage::ImageArchitecture {
385    match arch {
386        ImageArchitecture::Amd64 => pbimage::ImageArchitecture::Amd64,
387        ImageArchitecture::Arm64 => pbimage::ImageArchitecture::Arm64,
388        ImageArchitecture::Unspecified => pbimage::ImageArchitecture::Unspecified,
389    }
390}
391
392fn build_step_to_pb(step: &ImageBuildStep) -> pbimage::ImageBuildStep {
393    use pbimage::image_build_step::Step;
394    let packages = |p: &PackageInstall| pbimage::PackageInstall {
395        packages: p.packages.clone(),
396    };
397    let inner = match step {
398        ImageBuildStep::AptInstall(p) => Step::AptInstall(packages(p)),
399        ImageBuildStep::PipInstall(p) => Step::PipInstall(packages(p)),
400        ImageBuildStep::RunCommand(c) => Step::RunCommand(pbimage::RunCommand {
401            command: c.command.clone(),
402        }),
403        ImageBuildStep::AddLocalFile(f) => Step::AddLocalFile(pbimage::AddLocalFile {
404            content_sha256: f.content_sha256.clone(),
405            remote_path: f.remote_path.clone(),
406            mode: f.mode,
407        }),
408        ImageBuildStep::AddLocalDir(d) => Step::AddLocalDir(pbimage::AddLocalDir {
409            remote_path: d.remote_path.clone(),
410            files: d
411                .files
412                .iter()
413                .map(|file| pbimage::AddLocalDirFile {
414                    relative_path: file.relative_path.clone(),
415                    content_sha256: file.content_sha256.clone(),
416                    mode: file.mode,
417                })
418                .collect(),
419        }),
420    };
421    pbimage::ImageBuildStep { step: Some(inner) }
422}
423
424/// Convert a typed [`ImageSpec`] to its wire proto.
425pub(crate) fn image_spec_to_pb(spec: &ImageSpec) -> pbimage::ImageSpec {
426    pbimage::ImageSpec {
427        source: spec
428            .base
429            .map(|base| pbimage::image_spec::Source::Base(base_image_to_pb(base) as i32)),
430        build_steps: spec.build_steps.iter().map(build_step_to_pb).collect(),
431        env: spec.env.clone(),
432        architecture: architecture_to_pb(spec.architecture) as i32,
433        python_version: spec.python_version.clone(),
434    }
435}
436
437impl Client {
438    /// The server's plan for uploading a content-addressed local file.
439    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    /// Submit or resume a custom image build. Poll
466    /// [`Client::get_image_build_status`] until the status is ready or failed,
467    /// or use [`Client::build_image_definition`] for the whole pipeline.
468    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    /// Poll one custom image build's status.
488    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    /// Resolve one local file into a content-addressed `addLocalFile` step,
508    /// uploading its bytes if the server does not already have them.
509    #[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        // A file still being written can grow past the stat-time check before
547        // hashing finishes; the hash-time size is what actually uploads.
548        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    /// Resolve one local directory into a content-addressed `addLocalDir`
565    /// step: walk it with gitignore-style matching, hash every file, and
566    /// upload content the server does not already have.
567    #[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        // The stat/walk phase is synchronous filesystem work that a large or
583        // slow tree can stretch out; run it off the async runtime (like
584        // hash_file) so the pipeline timeout can preempt it and other core
585        // tasks keep running.
586        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        // digest -> (source path, size); deduped so shared content uploads once.
623        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    /// Resolve an [`ImageDefinition`] into a content-addressed [`ImageSpec`]:
661    /// walk local directories, hash every file, and upload content the server
662    /// does not already have.
663    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    /// Upload one content-addressed local file if the server does not already
716    /// have it.
717    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        // The file was hashed before this second open; a rewrite in between
760        // (same size, different bytes) would poison the content-addressed
761        // store under the old digest. The body hashed what it actually
762        // streamed, so fail the build instead of using a mismatched object.
763        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    /// Build an already-resolved spec to ready, bounded by `timeout` (an
774    /// unrepresentably large value waits indefinitely). The envelope both
775    /// bridges and [`Client::build_image_definition`] share.
776    #[doc(hidden)]
777    pub async fn build_spec_with_timeout(
778        &self,
779        spec: &ImageSpec,
780        timeout: Duration,
781    ) -> Result<ImageBuild, SailError> {
782        let deadline = Instant::now().checked_add(timeout);
783        match deadline {
784            None => self.build_spec_to_ready(spec, /* deadline */ None).await,
785            Some(_) => tokio::time::timeout(timeout, self.build_spec_to_ready(spec, deadline))
786                .await
787                .unwrap_or_else(|_| {
788                    Err(SailError::Transport {
789                        kind: TransportKind::Timeout,
790                        message: "timed out building the image".to_string(),
791                        source: None,
792                    })
793                }),
794        }
795    }
796
797    /// Resolve an [`ImageDefinition`] and build it to ready, returning the
798    /// content-addressed [`ImageSpec`] to create sailboxes from. A bare
799    /// builtin base skips the build. `timeout` bounds the whole pipeline
800    /// (hashing, uploads, and the build); 30 minutes is a good default, and
801    /// [`Duration::MAX`] waits indefinitely.
802    pub async fn build_image_definition(
803        &self,
804        def: &ImageDefinition,
805        timeout: Duration,
806    ) -> Result<ImageSpec, SailError> {
807        let deadline = Instant::now().checked_add(timeout);
808        let work = async {
809            let spec = self.resolve_image(def).await?;
810            if is_builtin_base_spec(&spec) {
811                return Ok(spec);
812            }
813            self.build_spec_to_ready(&spec, deadline).await?;
814            Ok(spec)
815        };
816        match deadline {
817            None => work.await,
818            Some(_) => tokio::time::timeout(timeout, work)
819                .await
820                .unwrap_or_else(|_| {
821                    Err(SailError::Transport {
822                        kind: TransportKind::Timeout,
823                        message: "timed out building the image".to_string(),
824                        source: None,
825                    })
826                }),
827        }
828    }
829
830    /// Build an already-resolved spec to ready (submit + poll).
831    #[doc(hidden)]
832    pub async fn build_spec_to_ready(
833        &self,
834        spec: &ImageSpec,
835        deadline: Option<Instant>,
836    ) -> Result<ImageBuild, SailError> {
837        // Per-RPC transport-retry budget: the time left until the deadline,
838        // or a fixed bound when the caller waits indefinitely.
839        let rpc_budget = || {
840            deadline.map_or(UNBOUNDED_BUILD_RPC_BUDGET.as_secs_f64(), |deadline| {
841                deadline
842                    .saturating_duration_since(Instant::now())
843                    .as_secs_f64()
844            })
845        };
846        let mut build = self.build_image(spec, rpc_budget()).await?;
847        loop {
848            match build.status {
849                ImageBuildStatus::Ready => return Ok(build),
850                ImageBuildStatus::Failed => {
851                    let message = if build.error_message.is_empty() {
852                        "image build failed".to_string()
853                    } else {
854                        build.error_message.clone()
855                    };
856                    return Err(SailError::ImageBuild { message });
857                }
858                _ => {}
859            }
860            let nap = match deadline {
861                None => BUILD_POLL_INTERVAL,
862                Some(deadline) => {
863                    let left = deadline.saturating_duration_since(Instant::now());
864                    if left.is_zero() {
865                        return Err(SailError::Transport {
866                            kind: TransportKind::Timeout,
867                            message: format!(
868                                "timed out waiting for image build {}",
869                                build.image_id
870                            ),
871                            source: None,
872                        });
873                    }
874                    left.min(BUILD_POLL_INTERVAL)
875                }
876            };
877            tokio::time::sleep(nap).await;
878            build = self
879                .get_image_build_status(&build.image_id, rpc_budget())
880                .await?;
881        }
882    }
883}
884
885/// Build the presigned PUT for one content-addressed upload. Presigned PUT
886/// endpoints reject chunked transfer encoding, so the body must advertise its
887/// exact size; hyper then frames the request with Content-Length while the
888/// file still streams from disk.
889fn sized_put_request(
890    http: &reqwest::Client,
891    upload_url: &str,
892    file: tokio::fs::File,
893    size: u64,
894    headers: &HashMap<String, String>,
895) -> (
896    reqwest::RequestBuilder,
897    Arc<std::sync::Mutex<Option<String>>>,
898) {
899    let (body, streamed_digest) = SizedFileBody::new(file, size);
900    let mut request = http.put(upload_url).body(reqwest::Body::wrap(body));
901    for (name, value) in headers {
902        request = request.header(name, value);
903    }
904    (request, streamed_digest)
905}
906
907/// The whole-request budget for one presigned PUT: a base allowance plus the
908/// body at a conservative throughput floor.
909fn upload_timeout(size: u64) -> Duration {
910    UPLOAD_BASE_TIMEOUT + Duration::from_secs(size / MIN_UPLOAD_BYTES_PER_SEC)
911}
912
913/// A streaming request body over a file with an exact size hint. Presigned
914/// PUT endpoints reject chunked transfer encoding, so the body must report
915/// its length up front; the file itself still streams from disk in 64 KiB
916/// frames rather than being buffered whole.
917struct SizedFileBody {
918    reader: tokio_util::io::ReaderStream<tokio::fs::File>,
919    remaining: u64,
920    hasher: Option<sha2::Sha256>,
921    streamed_digest: Arc<std::sync::Mutex<Option<String>>>,
922}
923
924impl SizedFileBody {
925    fn new(file: tokio::fs::File, size: u64) -> (Self, Arc<std::sync::Mutex<Option<String>>>) {
926        let streamed_digest = Arc::new(std::sync::Mutex::new(None));
927        let mut hasher = Some(sha2::Sha256::new());
928        if size == 0 {
929            // An empty body may never be polled; its digest is already known.
930            *streamed_digest.lock().unwrap() =
931                Some(format!("{:x}", hasher.take().unwrap().finalize()));
932        }
933        (
934            SizedFileBody {
935                reader: tokio_util::io::ReaderStream::new(file),
936                remaining: size,
937                hasher,
938                streamed_digest: Arc::clone(&streamed_digest),
939            },
940            streamed_digest,
941        )
942    }
943}
944
945impl http_body::Body for SizedFileBody {
946    type Data = bytes::Bytes;
947    type Error = std::io::Error;
948
949    fn poll_frame(
950        mut self: std::pin::Pin<&mut Self>,
951        cx: &mut std::task::Context<'_>,
952    ) -> std::task::Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
953        use futures::Stream;
954        match std::pin::Pin::new(&mut self.reader).poll_next(cx) {
955            std::task::Poll::Ready(Some(Ok(chunk))) => {
956                self.remaining = self.remaining.saturating_sub(chunk.len() as u64);
957                if let Some(hasher) = self.hasher.as_mut() {
958                    hasher.update(&chunk);
959                }
960                // Exact Content-Length framing means the final end-of-stream
961                // poll may never come; finalize as soon as the advertised
962                // bytes have been streamed.
963                if self.remaining == 0 {
964                    if let Some(hasher) = self.hasher.take() {
965                        *self.streamed_digest.lock().unwrap() =
966                            Some(format!("{:x}", hasher.finalize()));
967                    }
968                }
969                std::task::Poll::Ready(Some(Ok(http_body::Frame::data(chunk))))
970            }
971            std::task::Poll::Ready(Some(Err(err))) => std::task::Poll::Ready(Some(Err(err))),
972            std::task::Poll::Ready(None) => {
973                if let Some(hasher) = self.hasher.take() {
974                    *self.streamed_digest.lock().unwrap() =
975                        Some(format!("{:x}", hasher.finalize()));
976                }
977                std::task::Poll::Ready(None)
978            }
979            std::task::Poll::Pending => std::task::Poll::Pending,
980        }
981    }
982
983    fn is_end_stream(&self) -> bool {
984        self.remaining == 0
985    }
986
987    fn size_hint(&self) -> http_body::SizeHint {
988        http_body::SizeHint::with_exact(self.remaining)
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    #[test]
995    fn upload_budget_scales_with_content_size() {
996        assert_eq!(upload_timeout(0), Duration::from_mins(5));
997        // 1 GiB at the 1 MiB/s floor adds 1024s to the base allowance.
998        assert_eq!(
999            upload_timeout(1 << 30),
1000            Duration::from_mins(5) + Duration::from_secs(1024)
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn upload_body_advertises_its_exact_size() {
1006        // The presigned plan's endpoint rejects chunked transfer encoding.
1007        // Framing is decided from the body's own size hint (a manual
1008        // Content-Length header is not sufficient on every protocol), so the
1009        // body must report the exact size before any bytes are read.
1010        let dir = tempfile::tempdir().expect("tempdir");
1011        let path = dir.path().join("payload.bin");
1012        std::fs::write(&path, b"0123456789").expect("write");
1013        let file = tokio::fs::File::open(&path).await.expect("open");
1014        let (body, _digest) = SizedFileBody::new(file, 10);
1015        assert_eq!(http_body::Body::size_hint(&body).exact(), Some(10));
1016        assert!(!http_body::Body::is_end_stream(&body));
1017    }
1018
1019    #[tokio::test]
1020    async fn presigned_put_uses_content_length_framing() {
1021        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1022
1023        let dir = tempfile::tempdir().expect("tempdir");
1024        let path = dir.path().join("payload.bin");
1025        std::fs::write(&path, b"0123456789").expect("write");
1026
1027        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1028            .await
1029            .expect("bind");
1030        let addr = listener.local_addr().expect("addr");
1031        let server = tokio::spawn(async move {
1032            let (mut sock, _) = listener.accept().await.expect("accept");
1033            let mut raw = Vec::new();
1034            let mut buf = [0u8; 4096];
1035            loop {
1036                let n = sock.read(&mut buf).await.expect("read");
1037                raw.extend_from_slice(&buf[..n]);
1038                if let Some(head_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") {
1039                    let head = String::from_utf8_lossy(&raw[..head_end]).to_lowercase();
1040                    let body_len = raw.len() - (head_end + 4);
1041                    if body_len >= 10 {
1042                        sock.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
1043                            .await
1044                            .expect("respond");
1045                        return head;
1046                    }
1047                }
1048            }
1049        });
1050
1051        let file = tokio::fs::File::open(&path).await.expect("open");
1052        let headers = HashMap::from([(
1053            "Content-Type".to_string(),
1054            "application/octet-stream".to_string(),
1055        )]);
1056        let (request, streamed_digest) = sized_put_request(
1057            &reqwest::Client::new(),
1058            &format!("http://{addr}/upload"),
1059            file,
1060            10,
1061            &headers,
1062        );
1063        let response = request.send().await.expect("send");
1064        assert!(response.status().is_success());
1065        // The body hashed exactly what it streamed.
1066        assert_eq!(
1067            streamed_digest.lock().unwrap().as_deref(),
1068            Some("84d89877f0d4041efb6bf91a16f0248f2fd573e6af05c19f96bedb9f882f7882")
1069        );
1070
1071        let head = server.await.expect("server");
1072        // Presigned endpoints reject chunked transfer encoding; the request
1073        // must carry the exact Content-Length instead.
1074        assert!(
1075            head.contains("content-length: 10"),
1076            "missing sized framing in request head: {head}"
1077        );
1078        assert!(
1079            !head.contains("transfer-encoding"),
1080            "request must not be chunked: {head}"
1081        );
1082    }
1083
1084    use super::*;
1085
1086    #[test]
1087    fn remote_path_rules_match_the_wrappers() {
1088        assert!(validate_remote_path("/app/config.json").is_ok());
1089        assert!(validate_remote_path("relative").is_err());
1090        assert!(validate_remote_path("/app/").is_err());
1091        assert!(validate_remote_path("/app/../etc").is_err());
1092        assert!(validate_remote_path("/app/with space").is_err());
1093        assert!(validate_remote_path("/app/$HOME").is_err());
1094        assert!(validate_mode(Some(0o600)).is_ok());
1095        assert!(validate_mode(Some(0o1777)).is_err());
1096    }
1097
1098    #[tokio::test]
1099    async fn resolve_walks_hashes_and_respects_gitignore() {
1100        let dir = tempfile::tempdir().expect("tempdir");
1101        std::fs::create_dir_all(dir.path().join("src/generated")).unwrap();
1102        std::fs::write(dir.path().join("src/keep.py"), b"keep").unwrap();
1103        std::fs::write(dir.path().join("src/skip.pyc"), b"skip").unwrap();
1104        std::fs::write(dir.path().join("src/generated/gen.py"), b"gen").unwrap();
1105        std::fs::write(dir.path().join("top.txt"), b"top").unwrap();
1106
1107        let matcher = ignore_matcher(
1108            dir.path(),
1109            &["*.pyc".to_string(), "src/generated/".to_string()],
1110            /* ignore_file */ None,
1111        )
1112        .expect("matcher");
1113        let walked = walk_dir(dir.path(), &matcher).expect("walk");
1114        let mut paths: Vec<_> = walked.iter().map(|f| f.relative_path.clone()).collect();
1115        paths.sort();
1116        assert_eq!(paths, ["src/keep.py", "top.txt"]);
1117
1118        let (digest, size) = hash_file(&dir.path().join("top.txt")).await.expect("hash");
1119        assert_eq!(size, 3);
1120        assert_eq!(
1121            digest,
1122            "28720365c5e7476a011e4f43ac003ee5f16247a263b9d623aa85ed311d73bf39"
1123        );
1124    }
1125}