1use 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
51fn 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
90fn 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
105fn 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 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
140struct 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
153fn 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
168fn 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 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#[derive(Debug)]
230pub(crate) struct ReproducibleBuild<'a> {
231 params: &'a ResolvedBuildParams,
232}
233
234impl<'a> ReproducibleBuild<'a> {
235 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 pub(crate) fn run(
252 &self,
253 profile: Profile,
254 cargo_args: &[String],
255 extra_config: Option<&str>,
256 ) -> Result<()> {
257 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 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 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
353pub 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(¶ms);
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}