Skip to main content

airbender_build/build/
docker.rs

1//! Docker-based reproducible build support.
2//!
3//! Runs `cargo build` and `cargo objcopy` inside a pinned container so the
4//! same source always produces bit-for-bit identical artifacts regardless of
5//! the host toolchain or OS environment.
6//!
7//! # Build strategy
8//!
9//! ```text
10//! docker run -v workspace:/src:ro  →  docker cp <artifacts out>  →  docker rm
11//! ```
12//!
13//! Source is bind-mounted read-only (no host writes). Artifacts are copied out
14//! with `docker cp`, which writes files as the host user — no root-owned files
15//! ever land on the host filesystem. The container is always removed on return,
16//! whether by success, error, or panic.
17//!
18//! # Volume strategy
19//!
20//! | Volume | Scope | Lifetime |
21//! |---|---|---|
22//! | `airbender-cargo-registry` | shared across all projects | persistent (crate download cache) |
23//!
24//! The `/cargo-target` and `/dist` directories live in the container's writable
25//! layer and are discarded when the container is removed at end of build.
26//!
27//! # Image tag
28//!
29//! The image tag is `airbender-build:<toolchain>` where `<toolchain>` is
30//! `DEFAULT_GUEST_TOOLCHAIN`. To update the toolchain or rotate the base image
31//! digest, change `DEFAULT_GUEST_TOOLCHAIN` in `constants.rs`; the new tag
32//! forces a fresh `docker build`.
33//!
34//! # Cleanup
35//!
36//! Use [`clean_reproducible_volumes`] (exposed as `cargo airbender clean`) to remove
37//! the shared registry cache and any stopped `airbender-build` containers left by
38//! interrupted builds.
39
40use crate::build::DistApp;
41use crate::constants::DEFAULT_GUEST_TOOLCHAIN;
42use crate::errors::{BuildError, Result};
43use crate::resolver::ResolvedBuildParams;
44use crate::utils::run_command;
45use airbender_core::host::manifest::Profile;
46use std::io::Write;
47use std::io::{self, Read};
48use std::path::Path;
49use std::process::{Command, Stdio};
50
51/// Returns the Dockerfile for the reproducible build image.
52///
53/// Base image digest sourced from `zksync-airbender/tools/reproduce/Dockerfile`.
54/// To rotate the base image digest, update the sha256 hash below and bump
55/// `DEFAULT_GUEST_TOOLCHAIN` in `constants.rs` to force a fresh `docker build`.
56fn dockerfile_contents() -> String {
57    format!(
58        r#"FROM debian:bullseye-slim@sha256:f527627d07c18abf87313c341ee8ef1b36f106baa8b6b6dc33f4c872d988b651
59
60RUN apt-get update && \
61    apt-get install -y --no-install-recommends \
62        curl \
63        build-essential \
64        clang \
65        git \
66        libssl-dev \
67        pkg-config \
68        ca-certificates && \
69    rm -rf /var/lib/apt/lists/*
70
71ENV RUSTUP_HOME=/usr/local/rustup \
72    CARGO_HOME=/usr/local/cargo \
73    PATH=/usr/local/cargo/bin:$PATH
74
75RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
76    sh -s -- -y --no-modify-path --default-toolchain {DEFAULT_GUEST_TOOLCHAIN}
77
78RUN rustup component add llvm-tools-preview rust-src && \
79    cargo install cargo-binutils --locked
80
81WORKDIR /build
82"#
83    )
84}
85
86fn docker_image_tag() -> String {
87    format!("airbender-build:{DEFAULT_GUEST_TOOLCHAIN}")
88}
89
90/// Checks that Docker is installed and the daemon is reachable.
91fn ensure_docker_available() -> Result<()> {
92    let result = Command::new("docker")
93        .args(["info", "--format", "{{.ServerVersion}}"])
94        .stdout(Stdio::null())
95        .stderr(Stdio::null())
96        .status();
97    match result {
98        Ok(s) if s.success() => Ok(()),
99        Ok(_) => Err(BuildError::DockerNotRunning),
100        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(BuildError::DockerNotFound),
101        Err(e) => Err(BuildError::Io(e)),
102    }
103}
104
105/// Builds the Docker image if it does not already exist for the current toolchain tag.
106fn ensure_image_built() -> Result<()> {
107    let tag = docker_image_tag();
108
109    let exists = Command::new("docker")
110        .args(["image", "inspect", &tag])
111        .stdout(Stdio::null())
112        .stderr(Stdio::null())
113        .status()?
114        .success();
115    if exists {
116        return Ok(());
117    }
118
119    // Pass `-` as the build context so Docker reads the Dockerfile from stdin —
120    // no temp files or directories needed.
121    let mut child = Command::new("docker")
122        .args(["build", "--platform", "linux/amd64", "-t", &tag, "-"])
123        .stdin(Stdio::piped())
124        .spawn()?;
125
126    if let Some(mut stdin) = child.stdin.take() {
127        stdin.write_all(dockerfile_contents().as_bytes())?;
128    }
129
130    let status = child.wait()?;
131    if !status.success() {
132        return Err(BuildError::ProcessFailed {
133            cmd: "docker build".to_string(),
134            status,
135        });
136    }
137    Ok(())
138}
139
140/// RAII guard that force-removes a named Docker container when dropped.
141struct TempContainer(String);
142
143impl Drop for TempContainer {
144    fn drop(&mut self) {
145        let _ = Command::new("docker")
146            .args(["rm", "-f", &self.0])
147            .stdout(Stdio::null())
148            .stderr(Stdio::null())
149            .status();
150    }
151}
152
153/// Generates a unique container name for the current build run.
154///
155/// XORs nanosecond timestamp with the process ID so that two concurrent builds
156/// launched at the same instant (same `nanos`) from different processes still
157/// get distinct names, and two builds in the same process at different times
158/// (same `pid`) also get distinct names.
159fn container_name() -> String {
160    let nanos = std::time::SystemTime::now()
161        .duration_since(std::time::UNIX_EPOCH)
162        .unwrap_or_default()
163        .as_nanos();
164    let id = (nanos ^ (std::process::id() as u128)) as u64;
165    format!("airbender-build-{id:016x}")
166}
167
168/// Builds the `sh -c` command string: `cargo build` then three `cargo objcopy` invocations.
169fn build_container_cmd(
170    bin_name: &str,
171    target: &str,
172    dist_app: &DistApp,
173    profile: Profile,
174    cargo_args: &[String],
175    extra_config: Option<&str>,
176) -> String {
177    let profile_flag = if profile == Profile::Release {
178        "--release"
179    } else {
180        ""
181    };
182
183    let mut cargo_flags: Vec<&str> = Vec::new();
184    if !profile_flag.is_empty() {
185        cargo_flags.push(profile_flag);
186    }
187    cargo_flags.extend(["--bin", bin_name, "--target", target, "--locked"]);
188    // Single-quote the config value so shell special characters ([, ", ,) are preserved.
189    let extra_config_flag = extra_config.map(|v| format!("--config '{v}'"));
190    let user_flags = cargo_args.join(" ");
191    let cargo_flags = {
192        let base = cargo_flags.join(" ");
193        let with_extra = match extra_config_flag.as_deref() {
194            Some(f) => format!("{base} {f}"),
195            None => base,
196        };
197        if user_flags.is_empty() {
198            with_extra
199        } else {
200            format!("{with_extra} {user_flags}")
201        }
202    };
203
204    fn file_name(p: &Path) -> &str {
205        p.file_name()
206            .expect("must be valid")
207            .to_str()
208            .expect("must be valid")
209    }
210
211    let build = format!("cargo build {cargo_flags}");
212    let obj_bin = format!(
213        "cargo objcopy {cargo_flags} -- -O binary /dist/{}",
214        file_name(dist_app.bin())
215    );
216    let obj_elf = format!(
217        "cargo objcopy {cargo_flags} -- -R .text /dist/{}",
218        file_name(dist_app.elf())
219    );
220    let obj_text = format!(
221        "cargo objcopy {cargo_flags} -- -O binary --only-section=.text /dist/{}",
222        file_name(dist_app.text())
223    );
224
225    format!("mkdir -p /dist && {build} && {obj_bin} && {obj_elf} && {obj_text}")
226}
227
228/// Executes a guest build inside a pinned Docker container for reproducible output.
229#[derive(Debug)]
230pub(crate) struct ReproducibleBuild<'a> {
231    params: &'a ResolvedBuildParams,
232}
233
234impl<'a> ReproducibleBuild<'a> {
235    /// Validates pre-conditions and returns a ready-to-run build.
236    pub(crate) fn new(params: &'a ResolvedBuildParams) -> Result<Self> {
237        if !params.project_dir.join("Cargo.lock").exists() {
238            return Err(BuildError::LockfileNotReady {
239                project: params.project_dir.display().to_string(),
240                toolchain: DEFAULT_GUEST_TOOLCHAIN,
241            });
242        }
243
244        ensure_docker_available()?;
245        ensure_image_built()?;
246
247        Ok(Self { params })
248    }
249
250    /// Runs the build container and copies `app.bin`, `app.elf`, `app.text` into `dist_dir`.
251    pub(crate) fn run(
252        &self,
253        profile: Profile,
254        cargo_args: &[String],
255        extra_config: Option<&str>,
256    ) -> Result<()> {
257        // Guard registered before any Docker call — no orphan window.
258        let name = container_name();
259        let _guard = TempContainer(name.clone());
260        self.run_container(profile, cargo_args, extra_config, &name)?;
261        self.cp_artifacts(&name)
262    }
263
264    /// Starts the container and waits for it to exit, capturing stderr for error remapping.
265    fn run_container(
266        &self,
267        profile: Profile,
268        cargo_args: &[String],
269        extra_config: Option<&str>,
270        name: &str,
271    ) -> Result<()> {
272        let tag = docker_image_tag();
273        let project_abs = self
274            .params
275            .project_dir
276            .canonicalize()
277            .unwrap_or_else(|_| self.params.project_dir.to_path_buf());
278        let mount_root_abs = self
279            .params
280            .mount_root
281            .canonicalize()
282            .unwrap_or_else(|_| self.params.mount_root.to_path_buf());
283        let project_rel = project_abs
284            .strip_prefix(&mount_root_abs)
285            .unwrap_or(Path::new(""));
286        let workdir = format!("/src/{}", project_rel.display());
287        let build_cmd = build_container_cmd(
288            &self.params.bin_name,
289            &self.params.target,
290            &self.params.dist_app,
291            profile,
292            cargo_args,
293            extra_config,
294        );
295
296        let mut cmd = Command::new("docker");
297        cmd.args([
298            "run",
299            "--name",
300            name,
301            "--platform",
302            "linux/amd64",
303            "--workdir",
304            &workdir,
305            "-e",
306            "CARGO_TARGET_DIR=/cargo-target",
307            "-v",
308            &format!("{}:/src:ro", self.params.mount_root.display()),
309            "-v",
310            "airbender-cargo-registry:/usr/local/cargo/registry",
311            &tag,
312            "sh",
313            "-c",
314            &build_cmd,
315        ]);
316        cmd.stdout(Stdio::inherit());
317        cmd.stderr(Stdio::piped());
318
319        let mut child = cmd.spawn()?;
320        let mut build_stderr = String::new();
321        if let Some(mut stderr) = child.stderr.take() {
322            stderr.read_to_string(&mut build_stderr)?;
323        }
324        let status = child.wait()?;
325
326        eprint!("{build_stderr}");
327
328        if !status.success() {
329            if build_stderr.contains("cannot update the lock file") {
330                return Err(BuildError::LockfileNotReady {
331                    project: self.params.project_dir.display().to_string(),
332                    toolchain: DEFAULT_GUEST_TOOLCHAIN,
333                });
334            }
335            return Err(BuildError::ProcessFailed {
336                cmd: "docker run".to_string(),
337                status,
338            });
339        }
340        Ok(())
341    }
342
343    /// Copies all artifacts from `/dist` inside the container to `dist_app` in one call.
344    fn cp_artifacts(&self, name: &str) -> Result<()> {
345        std::fs::create_dir_all(self.params.dist_app.dir())?;
346        let src = format!("{name}:/dist/.");
347        let mut cmd = Command::new("docker");
348        cmd.args(["cp", &src, self.params.dist_app.dir().to_str().unwrap()]);
349        run_command(cmd, "docker cp")
350    }
351}
352
353/// Removes the shared `airbender-cargo-registry` volume and any stopped
354/// `airbender-build` containers left by interrupted builds.
355///
356/// Returns the number of resources removed.
357pub fn clean_reproducible_volumes() -> Result<usize> {
358    let vol_output = Command::new("docker")
359        .args(["volume", "ls", "-q", "--filter", "name=airbender"])
360        .output()?;
361    let vol_stdout = String::from_utf8_lossy(&vol_output.stdout);
362    let volumes: Vec<&str> = vol_stdout.lines().filter(|l| !l.is_empty()).collect();
363    let vol_count = volumes.len();
364    if vol_count > 0 {
365        let mut cmd = Command::new("docker");
366        cmd.args(["volume", "rm"]);
367        cmd.args(&volumes);
368        run_command(cmd, "docker volume rm")?;
369    }
370
371    let ctr_output = Command::new("docker")
372        .args([
373            "container",
374            "ls",
375            "-a",
376            "-q",
377            "--filter",
378            "ancestor=airbender-build",
379        ])
380        .output()?;
381    let ctr_stdout = String::from_utf8_lossy(&ctr_output.stdout);
382    let containers: Vec<&str> = ctr_stdout.lines().filter(|l| !l.is_empty()).collect();
383    let ctr_count = containers.len();
384    if ctr_count > 0 {
385        let mut cmd = Command::new("docker");
386        cmd.args(["rm", "-f"]);
387        cmd.args(&containers);
388        run_command(cmd, "docker rm")?;
389    }
390
391    Ok(vol_count + ctr_count)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::constants::DEFAULT_GUEST_TARGET;
398    use crate::errors::BuildError;
399
400    #[test]
401    fn dockerfile_contents_contains_toolchain_date() {
402        let contents = dockerfile_contents();
403        assert!(contents.contains(DEFAULT_GUEST_TOOLCHAIN));
404    }
405
406    #[test]
407    fn docker_image_tag_contains_toolchain() {
408        let tag = docker_image_tag();
409        assert!(tag.starts_with("airbender-build:"));
410        assert!(tag.contains(DEFAULT_GUEST_TOOLCHAIN));
411    }
412
413    #[test]
414    fn docker_image_tag_is_deterministic() {
415        assert_eq!(docker_image_tag(), docker_image_tag());
416    }
417
418    #[test]
419    fn reproducible_build_errors_when_lockfile_missing() {
420        let tmp = std::env::temp_dir().join("airbender_test_lockfile_missing");
421        std::fs::create_dir_all(&tmp).unwrap();
422        std::fs::write(tmp.join("Cargo.toml"), "[package]\nname = \"guest\"\n").unwrap();
423
424        let params = crate::resolver::ResolvedBuildParams {
425            project_dir: tmp.clone(),
426            package_name: "guest".to_string(),
427            bin_name: "guest".to_string(),
428            manifest_bin_name: None,
429            target: DEFAULT_GUEST_TARGET.to_string(),
430            dist_app: crate::build::DistApp::new(tmp.join("dist")),
431            mount_root: tmp.clone(),
432            panic_immediate_abort: false,
433            git: crate::resolver::GitMetadata::default(),
434        };
435        let result = ReproducibleBuild::new(&params);
436
437        std::fs::remove_dir_all(&tmp).ok();
438
439        let err = result.unwrap_err();
440        assert!(
441            matches!(err, BuildError::LockfileNotReady { .. }),
442            "expected LockfileNotReady, got: {err:?}"
443        );
444        assert!(err.to_string().contains(DEFAULT_GUEST_TOOLCHAIN));
445    }
446}